Code

831ac7a7f6860734523ce3a846000dd6e661dd36
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.37 2002-09-16 22:37:26 richard Exp $
3 __doc__ = """
4 WWW request handler (also used in the stand-alone server).
5 """
7 import os, os.path, cgi, StringIO, urlparse, re, traceback, mimetypes, urllib
8 import binascii, Cookie, time, random
10 from roundup import roundupdb, date, hyperdb, password
11 from roundup.i18n import _
13 from roundup.cgi.templating import getTemplate, HTMLRequest, NoTemplate
14 from roundup.cgi import cgitb
16 from roundup.cgi.PageTemplates import PageTemplate
18 class Unauthorised(ValueError):
19     pass
21 class NotFound(ValueError):
22     pass
24 class Redirect(Exception):
25     pass
27 class SendFile(Exception):
28     ' Sent a file from the database '
30 class SendStaticFile(Exception):
31     ' Send a static file from the instance html directory '
33 def initialiseSecurity(security):
34     ''' Create some Permissions and Roles on the security object
36         This function is directly invoked by security.Security.__init__()
37         as a part of the Security object instantiation.
38     '''
39     security.addPermission(name="Web Registration",
40         description="User may register through the web")
41     p = security.addPermission(name="Web Access",
42         description="User may access the web interface")
43     security.addPermissionToRole('Admin', p)
45     # doing Role stuff through the web - make sure Admin can
46     p = security.addPermission(name="Web Roles",
47         description="User may manipulate user Roles through the web")
48     security.addPermissionToRole('Admin', p)
50 class Client:
51     '''
52     A note about login
53     ------------------
55     If the user has no login cookie, then they are anonymous. There
56     are two levels of anonymous use. If there is no 'anonymous' user, there
57     is no login at all and the database is opened in read-only mode. If the
58     'anonymous' user exists, the user is logged in using that user (though
59     there is no cookie). This allows them to modify the database, and all
60     modifications are attributed to the 'anonymous' user.
62     Once a user logs in, they are assigned a session. The Client instance
63     keeps the nodeid of the session as the "session" attribute.
65     Client attributes:
66         "path" is the PATH_INFO inside the instance (with no leading '/')
67         "base" is the base URL for the instance
68     '''
70     def __init__(self, instance, request, env, form=None):
71         hyperdb.traceMark()
72         self.instance = instance
73         self.request = request
74         self.env = env
76         # save off the path
77         self.path = env['PATH_INFO']
79         # this is the base URL for this instance
80         self.base = self.instance.config.TRACKER_WEB
82         # see if we need to re-parse the environment for the form (eg Zope)
83         if form is None:
84             self.form = cgi.FieldStorage(environ=env)
85         else:
86             self.form = form
88         # turn debugging on/off
89         try:
90             self.debug = int(env.get("ROUNDUP_DEBUG", 0))
91         except ValueError:
92             # someone gave us a non-int debug level, turn it off
93             self.debug = 0
95         # flag to indicate that the HTTP headers have been sent
96         self.headers_done = 0
98         # additional headers to send with the request - must be registered
99         # before the first write
100         self.additional_headers = {}
101         self.response_code = 200
103     def main(self):
104         ''' Wrap the real main in a try/finally so we always close off the db.
105         '''
106         try:
107             self.inner_main()
108         finally:
109             if hasattr(self, 'db'):
110                 self.db.close()
112     def inner_main(self):
113         ''' Process a request.
115             The most common requests are handled like so:
116             1. figure out who we are, defaulting to the "anonymous" user
117                see determine_user
118             2. figure out what the request is for - the context
119                see determine_context
120             3. handle any requested action (item edit, search, ...)
121                see handle_action
122             4. render a template, resulting in HTML output
124             In some situations, exceptions occur:
125             - HTTP Redirect  (generally raised by an action)
126             - SendFile       (generally raised by determine_context)
127               serve up a FileClass "content" property
128             - SendStaticFile (generally raised by determine_context)
129               serve up a file from the tracker "html" directory
130             - Unauthorised   (generally raised by an action)
131               the action is cancelled, the request is rendered and an error
132               message is displayed indicating that permission was not
133               granted for the action to take place
134             - NotFound       (raised wherever it needs to be)
135               percolates up to the CGI interface that called the client
136         '''
137         self.content_action = None
138         self.ok_message = []
139         self.error_message = []
140         try:
141             # make sure we're identified (even anonymously)
142             self.determine_user()
143             # figure out the context and desired content template
144             self.determine_context()
145             # possibly handle a form submit action (may change self.classname
146             # and self.template, and may also append error/ok_messages)
147             self.handle_action()
148             # now render the page
150             # we don't want clients caching our dynamic pages
151             self.additional_headers['Cache-Control'] = 'no-cache'
152             self.additional_headers['Pragma'] = 'no-cache'
153             self.additional_headers['Expires'] = 'Thu, 1 Jan 1970 00:00:00 GMT'
155             if self.form.has_key(':contentonly'):
156                 # just the content
157                 self.write(self.content())
158             else:
159                 # render the content inside the page template
160                 self.write(self.renderTemplate('page', '',
161                     ok_message=self.ok_message,
162                     error_message=self.error_message))
163         except Redirect, url:
164             # let's redirect - if the url isn't None, then we need to do
165             # the headers, otherwise the headers have been set before the
166             # exception was raised
167             if url:
168                 self.additional_headers['Location'] = url
169                 self.response_code = 302
170             self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
171         except SendFile, designator:
172             self.serve_file(designator)
173         except SendStaticFile, file:
174             self.serve_static_file(str(file))
175         except Unauthorised, message:
176             self.write(self.renderTemplate('page', '', error_message=message))
177         except:
178             # everything else
179             self.write(cgitb.html())
181     def determine_user(self):
182         ''' Determine who the user is
183         '''
184         # determine the uid to use
185         self.opendb('admin')
187         # make sure we have the session Class
188         sessions = self.db.sessions
190         # age sessions, remove when they haven't been used for a week
191         # TODO: this shouldn't be done every access
192         week = 60*60*24*7
193         now = time.time()
194         for sessid in sessions.list():
195             interval = now - sessions.get(sessid, 'last_use')
196             if interval > week:
197                 sessions.destroy(sessid)
199         # look up the user session cookie
200         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
201         user = 'anonymous'
203         # bump the "revision" of the cookie since the format changed
204         if (cookie.has_key('roundup_user_2') and
205                 cookie['roundup_user_2'].value != 'deleted'):
207             # get the session key from the cookie
208             self.session = cookie['roundup_user_2'].value
209             # get the user from the session
210             try:
211                 # update the lifetime datestamp
212                 sessions.set(self.session, last_use=time.time())
213                 sessions.commit()
214                 user = sessions.get(self.session, 'user')
215             except KeyError:
216                 user = 'anonymous'
218         # sanity check on the user still being valid, getting the userid
219         # at the same time
220         try:
221             self.userid = self.db.user.lookup(user)
222         except (KeyError, TypeError):
223             user = 'anonymous'
225         # make sure the anonymous user is valid if we're using it
226         if user == 'anonymous':
227             self.make_user_anonymous()
228         else:
229             self.user = user
231         # reopen the database as the correct user
232         self.opendb(self.user)
234     def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
235         ''' Determine the context of this page from the URL:
237             The URL path after the instance identifier is examined. The path
238             is generally only one entry long.
240             - if there is no path, then we are in the "home" context.
241             * if the path is "_file", then the additional path entry
242               specifies the filename of a static file we're to serve up
243               from the instance "html" directory. Raises a SendStaticFile
244               exception.
245             - if there is something in the path (eg "issue"), it identifies
246               the tracker class we're to display.
247             - if the path is an item designator (eg "issue123"), then we're
248               to display a specific item.
249             * if the path starts with an item designator and is longer than
250               one entry, then we're assumed to be handling an item of a
251               FileClass, and the extra path information gives the filename
252               that the client is going to label the download with (ie
253               "file123/image.png" is nicer to download than "file123"). This
254               raises a SendFile exception.
256             Both of the "*" types of contexts stop before we bother to
257             determine the template we're going to use. That's because they
258             don't actually use templates.
260             The template used is specified by the :template CGI variable,
261             which defaults to:
263              only classname suplied:          "index"
264              full item designator supplied:   "item"
266             We set:
267              self.classname  - the class to display, can be None
268              self.template   - the template to render the current context with
269              self.nodeid     - the nodeid of the class we're displaying
270         '''
271         # default the optional variables
272         self.classname = None
273         self.nodeid = None
275         # determine the classname and possibly nodeid
276         path = self.path.split('/')
277         if not path or path[0] in ('', 'home', 'index'):
278             if self.form.has_key(':template'):
279                 self.template = self.form[':template'].value
280             else:
281                 self.template = ''
282             return
283         elif path[0] == '_file':
284             raise SendStaticFile, path[1]
285         else:
286             self.classname = path[0]
287             if len(path) > 1:
288                 # send the file identified by the designator in path[0]
289                 raise SendFile, path[0]
291         # see if we got a designator
292         m = dre.match(self.classname)
293         if m:
294             self.classname = m.group(1)
295             self.nodeid = m.group(2)
296             if not self.db.getclass(self.classname).hasnode(self.nodeid):
297                 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
298             # with a designator, we default to item view
299             self.template = 'item'
300         else:
301             # with only a class, we default to index view
302             self.template = 'index'
304         # see if we have a template override
305         if self.form.has_key(':template'):
306             self.template = self.form[':template'].value
308         # see if we were passed in a message
309         if self.form.has_key(':ok_message'):
310             self.ok_message.append(self.form[':ok_message'].value)
311         if self.form.has_key(':error_message'):
312             self.error_message.append(self.form[':error_message'].value)
314     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
315         ''' Serve the file from the content property of the designated item.
316         '''
317         m = dre.match(str(designator))
318         if not m:
319             raise NotFound, str(designator)
320         classname, nodeid = m.group(1), m.group(2)
321         if classname != 'file':
322             raise NotFound, designator
324         # we just want to serve up the file named
325         file = self.db.file
326         self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
327         self.write(file.get(nodeid, 'content'))
329     def serve_static_file(self, file):
330         # we just want to serve up the file named
331         mt = mimetypes.guess_type(str(file))[0]
332         self.additional_headers['Content-Type'] = mt
333         self.write(open(os.path.join(self.instance.config.TEMPLATES,
334             file)).read())
336     def renderTemplate(self, name, extension, **kwargs):
337         ''' Return a PageTemplate for the named page
338         '''
339         pt = getTemplate(self.instance.config.TEMPLATES, name, extension)
340         # catch errors so we can handle PT rendering errors more nicely
341         try:
342             # let the template render figure stuff out
343             return pt.render(self, None, None, **kwargs)
344         except PageTemplate.PTRuntimeError, message:
345             return '<strong>%s</strong><ol><li>%s</ol>'%(message,
346                 '<li>'.join([cgi.escape(x) for x in pt._v_errors]))
347         except NoTemplate, message:
348             return '<strong>%s</strong>'%message
349         except:
350             # everything else
351             return cgitb.pt_html()
353     def content(self):
354         ''' Callback used by the page template to render the content of 
355             the page.
357             If we don't have a specific class to display, that is none was
358             determined in determine_context(), then we display a "home"
359             template.
360         '''
361         # now render the page content using the template we determined in
362         # determine_context
363         if self.classname is None:
364             name = 'home'
365         else:
366             name = self.classname
367         return self.renderTemplate(self.classname, self.template)
369     # these are the actions that are available
370     actions = (
371         ('edit',     'editItemAction'),
372         ('editCSV',  'editCSVAction'),
373         ('new',      'newItemAction'),
374         ('register', 'registerAction'),
375         ('login',    'loginAction'),
376         ('logout',   'logout_action'),
377         ('search',   'searchAction'),
378     )
379     def handle_action(self):
380         ''' Determine whether there should be an _action called.
382             The action is defined by the form variable :action which
383             identifies the method on this object to call. The four basic
384             actions are defined in the "actions" sequence on this class:
385              "edit"      -> self.editItemAction
386              "new"       -> self.newItemAction
387              "register"  -> self.registerAction
388              "login"     -> self.loginAction
389              "logout"    -> self.logout_action
390              "search"    -> self.searchAction
392         '''
393         if not self.form.has_key(':action'):
394             return None
395         try:
396             # get the action, validate it
397             action = self.form[':action'].value
398             for name, method in self.actions:
399                 if name == action:
400                     break
401             else:
402                 raise ValueError, 'No such action "%s"'%action
404             # call the mapped action
405             getattr(self, method)()
406         except Redirect:
407             raise
408         except Unauthorised:
409             raise
410         except:
411             self.db.rollback()
412             s = StringIO.StringIO()
413             traceback.print_exc(None, s)
414             self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
416     def write(self, content):
417         if not self.headers_done:
418             self.header()
419         self.request.wfile.write(content)
421     def header(self, headers=None, response=None):
422         '''Put up the appropriate header.
423         '''
424         if headers is None:
425             headers = {'Content-Type':'text/html'}
426         if response is None:
427             response = self.response_code
429         # update with additional info
430         headers.update(self.additional_headers)
432         if not headers.has_key('Content-Type'):
433             headers['Content-Type'] = 'text/html'
434         self.request.send_response(response)
435         for entry in headers.items():
436             self.request.send_header(*entry)
437         self.request.end_headers()
438         self.headers_done = 1
439         if self.debug:
440             self.headers_sent = headers
442     def set_cookie(self, user, password):
443         # TODO generate a much, much stronger session key ;)
444         self.session = binascii.b2a_base64(repr(random.random())).strip()
446         # clean up the base64
447         if self.session[-1] == '=':
448             if self.session[-2] == '=':
449                 self.session = self.session[:-2]
450             else:
451                 self.session = self.session[:-1]
453         # insert the session in the sessiondb
454         self.db.sessions.set(self.session, user=user, last_use=time.time())
456         # and commit immediately
457         self.db.sessions.commit()
459         # expire us in a long, long time
460         expire = Cookie._getdate(86400*365)
462         # generate the cookie path - make sure it has a trailing '/'
463         path = '/'.join((self.env['SCRIPT_NAME'], self.env['TRACKER_NAME'],
464             ''))
465         self.additional_headers['Set-Cookie'] = \
466           'roundup_user_2=%s; expires=%s; Path=%s;'%(self.session, expire, path)
468     def make_user_anonymous(self):
469         ''' Make us anonymous
471             This method used to handle non-existence of the 'anonymous'
472             user, but that user is mandatory now.
473         '''
474         self.userid = self.db.user.lookup('anonymous')
475         self.user = 'anonymous'
477     def logout(self):
478         ''' Make us really anonymous - nuke the cookie too
479         '''
480         self.make_user_anonymous()
482         # construct the logout cookie
483         now = Cookie._getdate()
484         path = '/'.join((self.env['SCRIPT_NAME'], self.env['TRACKER_NAME'],
485             ''))
486         self.additional_headers['Set-Cookie'] = \
487            'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)
488         self.login()
490     def opendb(self, user):
491         ''' Open the database.
492         '''
493         # open the db if the user has changed
494         if not hasattr(self, 'db') or user != self.db.journaltag:
495             self.db = self.instance.open(user)
497     #
498     # Actions
499     #
500     def loginAction(self):
501         ''' Attempt to log a user in.
503             Sets up a session for the user which contains the login
504             credentials.
505         '''
506         # we need the username at a minimum
507         if not self.form.has_key('__login_name'):
508             self.error_message.append(_('Username required'))
509             return
511         self.user = self.form['__login_name'].value
512         # re-open the database for real, using the user
513         self.opendb(self.user)
514         if self.form.has_key('__login_password'):
515             password = self.form['__login_password'].value
516         else:
517             password = ''
518         # make sure the user exists
519         try:
520             self.userid = self.db.user.lookup(self.user)
521         except KeyError:
522             name = self.user
523             self.make_user_anonymous()
524             self.error_message.append(_('No such user "%(name)s"')%locals())
525             return
527         # and that the password is correct
528         pw = self.db.user.get(self.userid, 'password')
529         if password != pw:
530             self.make_user_anonymous()
531             self.error_message.append(_('Incorrect password'))
532             return
534         # make sure we're allowed to be here
535         if not self.loginPermission():
536             self.make_user_anonymous()
537             raise Unauthorised, _("You do not have permission to login")
539         # set the session cookie
540         self.set_cookie(self.user, password)
542     def loginPermission(self):
543         ''' Determine whether the user has permission to log in.
545             Base behaviour is to check the user has "Web Access".
546         ''' 
547         if not self.db.security.hasPermission('Web Access', self.userid):
548             return 0
549         return 1
551     def logout_action(self):
552         ''' Make us really anonymous - nuke the cookie too
553         '''
554         # log us out
555         self.make_user_anonymous()
557         # construct the logout cookie
558         now = Cookie._getdate()
559         path = '/'.join((self.env['SCRIPT_NAME'], self.env['TRACKER_NAME'],
560             ''))
561         self.additional_headers['Set-Cookie'] = \
562            'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)
564         # Let the user know what's going on
565         self.ok_message.append(_('You are logged out'))
567     def registerAction(self):
568         '''Attempt to create a new user based on the contents of the form
569         and then set the cookie.
571         return 1 on successful login
572         '''
573         # create the new user
574         cl = self.db.user
576         # parse the props from the form
577         try:
578             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
579         except (ValueError, KeyError), message:
580             self.error_message.append(_('Error: ') + str(message))
581             return
583         # make sure we're allowed to register
584         if not self.registerPermission(props):
585             raise Unauthorised, _("You do not have permission to register")
587         # re-open the database as "admin"
588         if self.user != 'admin':
589             self.opendb('admin')
590             
591         # create the new user
592         cl = self.db.user
593         try:
594             props = parsePropsFromForm(self.db, cl, self.form)
595             props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
596             self.userid = cl.create(**props)
597             self.db.commit()
598         except ValueError, message:
599             self.error_message.append(message)
600             return
602         # log the new user in
603         self.user = cl.get(self.userid, 'username')
604         # re-open the database for real, using the user
605         self.opendb(self.user)
606         password = self.db.user.get(self.userid, 'password')
607         self.set_cookie(self.user, password)
609         # nice message
610         message = _('You are now registered, welcome!')
612         # redirect to the item's edit page
613         raise Redirect, '%s/%s%s?:ok_message=%s'%(
614             self.base, self.classname, self.userid,  urllib.quote(message))
616     def registerPermission(self, props):
617         ''' Determine whether the user has permission to register
619             Base behaviour is to check the user has "Web Registration".
620         '''
621         # registration isn't allowed to supply roles
622         if props.has_key('roles'):
623             return 0
624         if self.db.security.hasPermission('Web Registration', self.userid):
625             return 1
626         return 0
628     def editItemAction(self):
629         ''' Perform an edit of an item in the database.
631             Some special form elements:
633             :link=designator:property
634             :multilink=designator:property
635              The value specifies a node designator and the property on that
636              node to add _this_ node to as a link or multilink.
637             :note
638              Create a message and attach it to the current node's
639              "messages" property.
640             :file
641              Create a file and attach it to the current node's
642              "files" property. Attach the file to the message created from
643              the :note if it's supplied.
645             :required=property,property,...
646              The named properties are required to be filled in the form.
648         '''
649         cl = self.db.classes[self.classname]
651         # parse the props from the form
652         try:
653             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
654         except (ValueError, KeyError), message:
655             self.error_message.append(_('Error: ') + str(message))
656             return
658         # check permission
659         if not self.editItemPermission(props):
660             self.error_message.append(
661                 _('You do not have permission to edit %(classname)s'%
662                 self.__dict__))
663             return
665         # perform the edit
666         try:
667             # make changes to the node
668             props = self._changenode(props)
669             # handle linked nodes 
670             self._post_editnode(self.nodeid)
671         except (ValueError, KeyError), message:
672             self.error_message.append(_('Error: ') + str(message))
673             return
675         # commit now that all the tricky stuff is done
676         self.db.commit()
678         # and some nice feedback for the user
679         if props:
680             message = _('%(changes)s edited ok')%{'changes':
681                 ', '.join(props.keys())}
682         elif self.form.has_key(':note') and self.form[':note'].value:
683             message = _('note added')
684         elif (self.form.has_key(':file') and self.form[':file'].filename):
685             message = _('file added')
686         else:
687             message = _('nothing changed')
689         # redirect to the item's edit page
690         raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
691             self.nodeid,  urllib.quote(message))
693     def editItemPermission(self, props):
694         ''' Determine whether the user has permission to edit this item.
696             Base behaviour is to check the user can edit this class. If we're
697             editing the "user" class, users are allowed to edit their own
698             details. Unless it's the "roles" property, which requires the
699             special Permission "Web Roles".
700         '''
701         # if this is a user node and the user is editing their own node, then
702         # we're OK
703         has = self.db.security.hasPermission
704         if self.classname == 'user':
705             # reject if someone's trying to edit "roles" and doesn't have the
706             # right permission.
707             if props.has_key('roles') and not has('Web Roles', self.userid,
708                     'user'):
709                 return 0
710             # if the item being edited is the current user, we're ok
711             if self.nodeid == self.userid:
712                 return 1
713         if self.db.security.hasPermission('Edit', self.userid, self.classname):
714             return 1
715         return 0
717     def newItemAction(self):
718         ''' Add a new item to the database.
720             This follows the same form as the editItemAction, with the same
721             special form values.
722         '''
723         cl = self.db.classes[self.classname]
725         # parse the props from the form
726         try:
727             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
728         except (ValueError, KeyError), message:
729             self.error_message.append(_('Error: ') + str(message))
730             return
732         if not self.newItemPermission(props):
733             self.error_message.append(
734                 _('You do not have permission to create %s' %self.classname))
736         # create a little extra message for anticipated :link / :multilink
737         if self.form.has_key(':multilink'):
738             link = self.form[':multilink'].value
739         elif self.form.has_key(':link'):
740             link = self.form[':multilink'].value
741         else:
742             link = None
743             xtra = ''
744         if link:
745             designator, linkprop = link.split(':')
746             xtra = ' for <a href="%s">%s</a>'%(designator, designator)
748         try:
749             # do the create
750             nid = self._createnode(props)
752             # handle linked nodes 
753             self._post_editnode(nid)
755             # commit now that all the tricky stuff is done
756             self.db.commit()
758             # render the newly created item
759             self.nodeid = nid
761             # and some nice feedback for the user
762             message = _('%(classname)s created ok')%self.__dict__ + xtra
763         except (ValueError, KeyError), message:
764             self.error_message.append(_('Error: ') + str(message))
765             return
766         except:
767             # oops
768             self.db.rollback()
769             s = StringIO.StringIO()
770             traceback.print_exc(None, s)
771             self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
772             return
774         # redirect to the new item's page
775         raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
776             nid,  urllib.quote(message))
778     def newItemPermission(self, props):
779         ''' Determine whether the user has permission to create (edit) this
780             item.
782             Base behaviour is to check the user can edit this class. No
783             additional property checks are made. Additionally, new user items
784             may be created if the user has the "Web Registration" Permission.
785         '''
786         has = self.db.security.hasPermission
787         if self.classname == 'user' and has('Web Registration', self.userid,
788                 'user'):
789             return 1
790         if has('Edit', self.userid, self.classname):
791             return 1
792         return 0
794     def editCSVAction(self):
795         ''' Performs an edit of all of a class' items in one go.
797             The "rows" CGI var defines the CSV-formatted entries for the
798             class. New nodes are identified by the ID 'X' (or any other
799             non-existent ID) and removed lines are retired.
800         '''
801         # this is per-class only
802         if not self.editCSVPermission():
803             self.error_message.append(
804                 _('You do not have permission to edit %s' %self.classname))
806         # get the CSV module
807         try:
808             import csv
809         except ImportError:
810             self.error_message.append(_(
811                 'Sorry, you need the csv module to use this function.<br>\n'
812                 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
813             return
815         cl = self.db.classes[self.classname]
816         idlessprops = cl.getprops(protected=0).keys()
817         idlessprops.sort()
818         props = ['id'] + idlessprops
820         # do the edit
821         rows = self.form['rows'].value.splitlines()
822         p = csv.parser()
823         found = {}
824         line = 0
825         for row in rows[1:]:
826             line += 1
827             values = p.parse(row)
828             # not a complete row, keep going
829             if not values: continue
831             # skip property names header
832             if values == props:
833                 continue
835             # extract the nodeid
836             nodeid, values = values[0], values[1:]
837             found[nodeid] = 1
839             # confirm correct weight
840             if len(idlessprops) != len(values):
841                 self.error_message.append(
842                     _('Not enough values on line %(line)s')%{'line':line})
843                 return
845             # extract the new values
846             d = {}
847             for name, value in zip(idlessprops, values):
848                 value = value.strip()
849                 # only add the property if it has a value
850                 if value:
851                     # if it's a multilink, split it
852                     if isinstance(cl.properties[name], hyperdb.Multilink):
853                         value = value.split(':')
854                     d[name] = value
856             # perform the edit
857             if cl.hasnode(nodeid):
858                 # edit existing
859                 cl.set(nodeid, **d)
860             else:
861                 # new node
862                 found[cl.create(**d)] = 1
864         # retire the removed entries
865         for nodeid in cl.list():
866             if not found.has_key(nodeid):
867                 cl.retire(nodeid)
869         # all OK
870         self.db.commit()
872         self.ok_message.append(_('Items edited OK'))
874     def editCSVPermission(self):
875         ''' Determine whether the user has permission to edit this class.
877             Base behaviour is to check the user can edit this class.
878         ''' 
879         if not self.db.security.hasPermission('Edit', self.userid,
880                 self.classname):
881             return 0
882         return 1
884     def searchAction(self):
885         ''' Mangle some of the form variables.
887             Set the form ":filter" variable based on the values of the
888             filter variables - if they're set to anything other than
889             "dontcare" then add them to :filter.
891             Also handle the ":queryname" variable and save off the query to
892             the user's query list.
893         '''
894         # generic edit is per-class only
895         if not self.searchPermission():
896             self.error_message.append(
897                 _('You do not have permission to search %s' %self.classname))
899         # add a faked :filter form variable for each filtering prop
900         props = self.db.classes[self.classname].getprops()
901         for key in self.form.keys():
902             if not props.has_key(key): continue
903             if not self.form[key].value: continue
904             self.form.value.append(cgi.MiniFieldStorage(':filter', key))
906         # handle saving the query params
907         if self.form.has_key(':queryname'):
908             queryname = self.form[':queryname'].value.strip()
909             if queryname:
910                 # parse the environment and figure what the query _is_
911                 req = HTMLRequest(self)
912                 url = req.indexargs_href('', {})
914                 # handle editing an existing query
915                 try:
916                     qid = self.db.query.lookup(queryname)
917                     self.db.query.set(qid, klass=self.classname, url=url)
918                 except KeyError:
919                     # create a query
920                     qid = self.db.query.create(name=queryname,
921                         klass=self.classname, url=url)
923                     # and add it to the user's query multilink
924                     queries = self.db.user.get(self.userid, 'queries')
925                     queries.append(qid)
926                     self.db.user.set(self.userid, queries=queries)
928                 # commit the query change to the database
929                 self.db.commit()
931     def searchPermission(self):
932         ''' Determine whether the user has permission to search this class.
934             Base behaviour is to check the user can view this class.
935         ''' 
936         if not self.db.security.hasPermission('View', self.userid,
937                 self.classname):
938             return 0
939         return 1
941     def remove_action(self,  dre=re.compile(r'([^\d]+)(\d+)')):
942         # XXX I believe this could be handled by a regular edit action that
943         # just sets the multilink...
944         target = self.index_arg(':target')[0]
945         m = dre.match(target)
946         if m:
947             classname = m.group(1)
948             nodeid = m.group(2)
949             cl = self.db.getclass(classname)
950             cl.retire(nodeid)
951             # now take care of the reference
952             parentref =  self.index_arg(':multilink')[0]
953             parent, prop = parentref.split(':')
954             m = dre.match(parent)
955             if m:
956                 self.classname = m.group(1)
957                 self.nodeid = m.group(2)
958                 cl = self.db.getclass(self.classname)
959                 value = cl.get(self.nodeid, prop)
960                 value.remove(nodeid)
961                 cl.set(self.nodeid, **{prop:value})
962                 func = getattr(self, 'show%s'%self.classname)
963                 return func()
964             else:
965                 raise NotFound, parent
966         else:
967             raise NotFound, target
969     #
970     #  Utility methods for editing
971     #
972     def _changenode(self, props):
973         ''' change the node based on the contents of the form
974         '''
975         cl = self.db.classes[self.classname]
977         # create the message
978         message, files = self._handle_message()
979         if message:
980             props['messages'] = cl.get(self.nodeid, 'messages') + [message]
981         if files:
982             props['files'] = cl.get(self.nodeid, 'files') + files
984         # make the changes
985         return cl.set(self.nodeid, **props)
987     def _createnode(self, props):
988         ''' create a node based on the contents of the form
989         '''
990         cl = self.db.classes[self.classname]
992         # check for messages and files
993         message, files = self._handle_message()
994         if message:
995             props['messages'] = [message]
996         if files:
997             props['files'] = files
998         # create the node and return it's id
999         return cl.create(**props)
1001     def _handle_message(self):
1002         ''' generate an edit message
1003         '''
1004         # handle file attachments 
1005         files = []
1006         if self.form.has_key(':file'):
1007             file = self.form[':file']
1008             if file.filename:
1009                 filename = file.filename.split('\\')[-1]
1010                 mime_type = mimetypes.guess_type(filename)[0]
1011                 if not mime_type:
1012                     mime_type = "application/octet-stream"
1013                 # create the new file entry
1014                 files.append(self.db.file.create(type=mime_type,
1015                     name=filename, content=file.file.read()))
1017         # we don't want to do a message if none of the following is true...
1018         cn = self.classname
1019         cl = self.db.classes[self.classname]
1020         props = cl.getprops()
1021         note = None
1022         # in a nutshell, don't do anything if there's no note or there's no
1023         # NOSY
1024         if self.form.has_key(':note'):
1025             note = self.form[':note'].value.strip()
1026         if not note:
1027             return None, files
1028         if not props.has_key('messages'):
1029             return None, files
1030         if not isinstance(props['messages'], hyperdb.Multilink):
1031             return None, files
1032         if not props['messages'].classname == 'msg':
1033             return None, files
1034         if not (self.form.has_key('nosy') or note):
1035             return None, files
1037         # handle the note
1038         if '\n' in note:
1039             summary = re.split(r'\n\r?', note)[0]
1040         else:
1041             summary = note
1042         m = ['%s\n'%note]
1044         # handle the messageid
1045         # TODO: handle inreplyto
1046         messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
1047             self.classname, self.instance.config.MAIL_DOMAIN)
1049         # now create the message, attaching the files
1050         content = '\n'.join(m)
1051         message_id = self.db.msg.create(author=self.userid,
1052             recipients=[], date=date.Date('.'), summary=summary,
1053             content=content, files=files, messageid=messageid)
1055         # update the messages property
1056         return message_id, files
1058     def _post_editnode(self, nid):
1059         '''Do the linking part of the node creation.
1061            If a form element has :link or :multilink appended to it, its
1062            value specifies a node designator and the property on that node
1063            to add _this_ node to as a link or multilink.
1065            This is typically used on, eg. the file upload page to indicated
1066            which issue to link the file to.
1068            TODO: I suspect that this and newfile will go away now that
1069            there's the ability to upload a file using the issue :file form
1070            element!
1071         '''
1072         cn = self.classname
1073         cl = self.db.classes[cn]
1074         # link if necessary
1075         keys = self.form.keys()
1076         for key in keys:
1077             if key == ':multilink':
1078                 value = self.form[key].value
1079                 if type(value) != type([]): value = [value]
1080                 for value in value:
1081                     designator, property = value.split(':')
1082                     link, nodeid = hyperdb.splitDesignator(designator)
1083                     link = self.db.classes[link]
1084                     # take a dupe of the list so we're not changing the cache
1085                     value = link.get(nodeid, property)[:]
1086                     value.append(nid)
1087                     link.set(nodeid, **{property: value})
1088             elif key == ':link':
1089                 value = self.form[key].value
1090                 if type(value) != type([]): value = [value]
1091                 for value in value:
1092                     designator, property = value.split(':')
1093                     link, nodeid = hyperdb.splitDesignator(designator)
1094                     link = self.db.classes[link]
1095                     link.set(nodeid, **{property: nid})
1098 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
1099     ''' Pull properties for the given class out of the form.
1101         If a ":required" parameter is supplied, then the names property values
1102         must be supplied or a ValueError will be raised.
1103     '''
1104     required = []
1105     if form.has_key(':required'):
1106         value = form[':required']
1107         if isinstance(value, type([])):
1108             required = [i.value.strip() for i in value]
1109         else:
1110             required = [i.strip() for i in value.value.split(',')]
1112     props = {}
1113     keys = form.keys()
1114     for key in keys:
1115         if not cl.properties.has_key(key):
1116             continue
1117         proptype = cl.properties[key]
1119         # Get the form value. This value may be a MiniFieldStorage or a list
1120         # of MiniFieldStorages.
1121         value = form[key]
1123         # make sure non-multilinks only get one value
1124         if not isinstance(proptype, hyperdb.Multilink):
1125             if isinstance(value, type([])):
1126                 raise ValueError, 'You have submitted more than one value'\
1127                     ' for the %s property'%key
1128             # we've got a MiniFieldStorage, so pull out the value and strip
1129             # surrounding whitespace
1130             value = value.value.strip()
1132         if isinstance(proptype, hyperdb.String):
1133             if not value:
1134                 continue
1135         elif isinstance(proptype, hyperdb.Password):
1136             if not value:
1137                 # ignore empty password values
1138                 continue
1139             if not form.has_key('%s:confirm'%key):
1140                 raise ValueError, 'Password and confirmation text do not match'
1141             confirm = form['%s:confirm'%key]
1142             if isinstance(confirm, type([])):
1143                 raise ValueError, 'You have submitted more than one value'\
1144                     ' for the %s property'%key
1145             if value != confirm.value:
1146                 raise ValueError, 'Password and confirmation text do not match'
1147             value = password.Password(value)
1148         elif isinstance(proptype, hyperdb.Date):
1149             if value:
1150                 value = date.Date(form[key].value.strip())
1151             else:
1152                 value = None
1153         elif isinstance(proptype, hyperdb.Interval):
1154             if value:
1155                 value = date.Interval(form[key].value.strip())
1156             else:
1157                 value = None
1158         elif isinstance(proptype, hyperdb.Link):
1159             # see if it's the "no selection" choice
1160             if value == '-1':
1161                 value = None
1162             else:
1163                 # handle key values
1164                 link = cl.properties[key].classname
1165                 if not num_re.match(value):
1166                     try:
1167                         value = db.classes[link].lookup(value)
1168                     except KeyError:
1169                         raise ValueError, _('property "%(propname)s": '
1170                             '%(value)s not a %(classname)s')%{'propname':key, 
1171                             'value': value, 'classname': link}
1172                     except TypeError, message:
1173                         raise ValueError, _('you may only enter ID values '
1174                             'for property "%(propname)s": %(message)s')%{
1175                             'propname':key, 'message': message}
1176         elif isinstance(proptype, hyperdb.Multilink):
1177             if isinstance(value, type([])):
1178                 # it's a list of MiniFieldStorages
1179                 value = [i.value.strip() for i in value]
1180             else:
1181                 # it's a MiniFieldStorage, but may be a comma-separated list
1182                 # of values
1183                 value = [i.strip() for i in value.value.split(',')]
1184             link = cl.properties[key].classname
1185             l = []
1186             for entry in map(str, value):
1187                 if entry == '': continue
1188                 if not num_re.match(entry):
1189                     try:
1190                         entry = db.classes[link].lookup(entry)
1191                     except KeyError:
1192                         raise ValueError, _('property "%(propname)s": '
1193                             '"%(value)s" not an entry of %(classname)s')%{
1194                             'propname':key, 'value': entry, 'classname': link}
1195                     except TypeError, message:
1196                         raise ValueError, _('you may only enter ID values '
1197                             'for property "%(propname)s": %(message)s')%{
1198                             'propname':key, 'message': message}
1199                 l.append(entry)
1200             l.sort()
1201             value = l
1202         elif isinstance(proptype, hyperdb.Boolean):
1203             props[key] = value = value.lower() in ('yes', 'true', 'on', '1')
1204         elif isinstance(proptype, hyperdb.Number):
1205             props[key] = value = int(value)
1207         # register this as received if required
1208         if key in required:
1209             required.remove(key)
1211         # get the old value
1212         if nodeid:
1213             try:
1214                 existing = cl.get(nodeid, key)
1215             except KeyError:
1216                 # this might be a new property for which there is no existing
1217                 # value
1218                 if not cl.properties.has_key(key): raise
1220             # if changed, set it
1221             if value != existing:
1222                 props[key] = value
1223         else:
1224             props[key] = value
1226     # see if all the required properties have been supplied
1227     if required:
1228         if len(required) > 1:
1229             p = 'properties'
1230         else:
1231             p = 'property'
1232         raise ValueError, 'Required %s %s not supplied'%(p, ', '.join(required))
1234     return props