Code

handle :add: better in cgi form parsing (sf bug 663235)
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.64 2003-01-08 04:33:56 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 Templates, 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     ''' Instantiate to handle one CGI request.
53     See inner_main for request processing.
55     Client attributes at instantiation:
56         "path" is the PATH_INFO inside the instance (with no leading '/')
57         "base" is the base URL for the instance
58         "form" is the cgi form, an instance of FieldStorage from the standard
59                cgi module
60         "additional_headers" is a dictionary of additional HTTP headers that
61                should be sent to the client
62         "response_code" is the HTTP response code to send to the client
64     During the processing of a request, the following attributes are used:
65         "error_message" holds a list of error messages
66         "ok_message" holds a list of OK messages
67         "session" is the current user session id
68         "user" is the current user's name
69         "userid" is the current user's id
70         "template" is the current :template context
71         "classname" is the current class context name
72         "nodeid" is the current context item id
74     User Identification:
75      If the user has no login cookie, then they are anonymous and are logged
76      in as that user. This typically gives them all Permissions assigned to the
77      Anonymous Role.
79      Once a user logs in, they are assigned a session. The Client instance
80      keeps the nodeid of the session as the "session" attribute.
81     '''
83     def __init__(self, instance, request, env, form=None):
84         hyperdb.traceMark()
85         self.instance = instance
86         self.request = request
87         self.env = env
89         # save off the path
90         self.path = env['PATH_INFO']
92         # this is the base URL for this instance
93         self.base = self.instance.config.TRACKER_WEB
95         # see if we need to re-parse the environment for the form (eg Zope)
96         if form is None:
97             self.form = cgi.FieldStorage(environ=env)
98         else:
99             self.form = form
101         # turn debugging on/off
102         try:
103             self.debug = int(env.get("ROUNDUP_DEBUG", 0))
104         except ValueError:
105             # someone gave us a non-int debug level, turn it off
106             self.debug = 0
108         # flag to indicate that the HTTP headers have been sent
109         self.headers_done = 0
111         # additional headers to send with the request - must be registered
112         # before the first write
113         self.additional_headers = {}
114         self.response_code = 200
116     def main(self):
117         ''' Wrap the real main in a try/finally so we always close off the db.
118         '''
119         try:
120             self.inner_main()
121         finally:
122             if hasattr(self, 'db'):
123                 self.db.close()
125     def inner_main(self):
126         ''' Process a request.
128             The most common requests are handled like so:
129             1. figure out who we are, defaulting to the "anonymous" user
130                see determine_user
131             2. figure out what the request is for - the context
132                see determine_context
133             3. handle any requested action (item edit, search, ...)
134                see handle_action
135             4. render a template, resulting in HTML output
137             In some situations, exceptions occur:
138             - HTTP Redirect  (generally raised by an action)
139             - SendFile       (generally raised by determine_context)
140               serve up a FileClass "content" property
141             - SendStaticFile (generally raised by determine_context)
142               serve up a file from the tracker "html" directory
143             - Unauthorised   (generally raised by an action)
144               the action is cancelled, the request is rendered and an error
145               message is displayed indicating that permission was not
146               granted for the action to take place
147             - NotFound       (raised wherever it needs to be)
148               percolates up to the CGI interface that called the client
149         '''
150         self.ok_message = []
151         self.error_message = []
152         try:
153             # make sure we're identified (even anonymously)
154             self.determine_user()
155             # figure out the context and desired content template
156             self.determine_context()
157             # possibly handle a form submit action (may change self.classname
158             # and self.template, and may also append error/ok_messages)
159             self.handle_action()
160             # now render the page
162             # we don't want clients caching our dynamic pages
163             self.additional_headers['Cache-Control'] = 'no-cache'
164             self.additional_headers['Pragma'] = 'no-cache'
165             self.additional_headers['Expires'] = 'Thu, 1 Jan 1970 00:00:00 GMT'
167             # render the content
168             self.write(self.renderContext())
169         except Redirect, url:
170             # let's redirect - if the url isn't None, then we need to do
171             # the headers, otherwise the headers have been set before the
172             # exception was raised
173             if url:
174                 self.additional_headers['Location'] = url
175                 self.response_code = 302
176             self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
177         except SendFile, designator:
178             self.serve_file(designator)
179         except SendStaticFile, file:
180             self.serve_static_file(str(file))
181         except Unauthorised, message:
182             self.classname=None
183             self.template=''
184             self.error_message.append(message)
185             self.write(self.renderContext())
186         except NotFound:
187             # pass through
188             raise
189         except:
190             # everything else
191             self.write(cgitb.html())
193     def determine_user(self):
194         ''' Determine who the user is
195         '''
196         # determine the uid to use
197         self.opendb('admin')
199         # make sure we have the session Class
200         sessions = self.db.sessions
202         # age sessions, remove when they haven't been used for a week
203         # TODO: this shouldn't be done every access
204         week = 60*60*24*7
205         now = time.time()
206         for sessid in sessions.list():
207             interval = now - sessions.get(sessid, 'last_use')
208             if interval > week:
209                 sessions.destroy(sessid)
211         # look up the user session cookie
212         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
213         user = 'anonymous'
215         # bump the "revision" of the cookie since the format changed
216         if (cookie.has_key('roundup_user_2') and
217                 cookie['roundup_user_2'].value != 'deleted'):
219             # get the session key from the cookie
220             self.session = cookie['roundup_user_2'].value
221             # get the user from the session
222             try:
223                 # update the lifetime datestamp
224                 sessions.set(self.session, last_use=time.time())
225                 sessions.commit()
226                 user = sessions.get(self.session, 'user')
227             except KeyError:
228                 user = 'anonymous'
230         # sanity check on the user still being valid, getting the userid
231         # at the same time
232         try:
233             self.userid = self.db.user.lookup(user)
234         except (KeyError, TypeError):
235             user = 'anonymous'
237         # make sure the anonymous user is valid if we're using it
238         if user == 'anonymous':
239             self.make_user_anonymous()
240         else:
241             self.user = user
243         # reopen the database as the correct user
244         self.opendb(self.user)
246     def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
247         ''' Determine the context of this page from the URL:
249             The URL path after the instance identifier is examined. The path
250             is generally only one entry long.
252             - if there is no path, then we are in the "home" context.
253             * if the path is "_file", then the additional path entry
254               specifies the filename of a static file we're to serve up
255               from the instance "html" directory. Raises a SendStaticFile
256               exception.
257             - if there is something in the path (eg "issue"), it identifies
258               the tracker class we're to display.
259             - if the path is an item designator (eg "issue123"), then we're
260               to display a specific item.
261             * if the path starts with an item designator and is longer than
262               one entry, then we're assumed to be handling an item of a
263               FileClass, and the extra path information gives the filename
264               that the client is going to label the download with (ie
265               "file123/image.png" is nicer to download than "file123"). This
266               raises a SendFile exception.
268             Both of the "*" types of contexts stop before we bother to
269             determine the template we're going to use. That's because they
270             don't actually use templates.
272             The template used is specified by the :template CGI variable,
273             which defaults to:
275              only classname suplied:          "index"
276              full item designator supplied:   "item"
278             We set:
279              self.classname  - the class to display, can be None
280              self.template   - the template to render the current context with
281              self.nodeid     - the nodeid of the class we're displaying
282         '''
283         # default the optional variables
284         self.classname = None
285         self.nodeid = None
287         # determine the classname and possibly nodeid
288         path = self.path.split('/')
289         if not path or path[0] in ('', 'home', 'index'):
290             if self.form.has_key(':template'):
291                 self.template = self.form[':template'].value
292             else:
293                 self.template = ''
294             return
295         elif path[0] == '_file':
296             raise SendStaticFile, path[1]
297         else:
298             self.classname = path[0]
299             if len(path) > 1:
300                 # send the file identified by the designator in path[0]
301                 raise SendFile, path[0]
303         # see if we got a designator
304         m = dre.match(self.classname)
305         if m:
306             self.classname = m.group(1)
307             self.nodeid = m.group(2)
308             if not self.db.getclass(self.classname).hasnode(self.nodeid):
309                 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
310             # with a designator, we default to item view
311             self.template = 'item'
312         else:
313             # with only a class, we default to index view
314             self.template = 'index'
316         # make sure the classname is valid
317         try:
318             self.db.getclass(self.classname)
319         except KeyError:
320             raise NotFound, self.classname
322         # see if we have a template override
323         if self.form.has_key(':template'):
324             self.template = self.form[':template'].value
326         # see if we were passed in a message
327         if self.form.has_key(':ok_message'):
328             self.ok_message.append(self.form[':ok_message'].value)
329         if self.form.has_key(':error_message'):
330             self.error_message.append(self.form[':error_message'].value)
332     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
333         ''' Serve the file from the content property of the designated item.
334         '''
335         m = dre.match(str(designator))
336         if not m:
337             raise NotFound, str(designator)
338         classname, nodeid = m.group(1), m.group(2)
339         if classname != 'file':
340             raise NotFound, designator
342         # we just want to serve up the file named
343         file = self.db.file
344         self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
345         self.write(file.get(nodeid, 'content'))
347     def serve_static_file(self, file):
348         # we just want to serve up the file named
349         mt = mimetypes.guess_type(str(file))[0]
350         self.additional_headers['Content-Type'] = mt
351         self.write(open(os.path.join(self.instance.config.TEMPLATES,
352             file)).read())
354     def renderContext(self):
355         ''' Return a PageTemplate for the named page
356         '''
357         name = self.classname
358         extension = self.template
359         pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
361         # catch errors so we can handle PT rendering errors more nicely
362         args = {
363             'ok_message': self.ok_message,
364             'error_message': self.error_message
365         }
366         try:
367             # let the template render figure stuff out
368             return pt.render(self, None, None, **args)
369         except NoTemplate, message:
370             return '<strong>%s</strong>'%message
371         except:
372             # everything else
373             return cgitb.pt_html()
375     # these are the actions that are available
376     actions = (
377         ('edit',     'editItemAction'),
378         ('editCSV',  'editCSVAction'),
379         ('new',      'newItemAction'),
380         ('register', 'registerAction'),
381         ('login',    'loginAction'),
382         ('logout',   'logout_action'),
383         ('search',   'searchAction'),
384         ('retire',   'retireAction'),
385     )
386     def handle_action(self):
387         ''' Determine whether there should be an _action called.
389             The action is defined by the form variable :action which
390             identifies the method on this object to call. The four basic
391             actions are defined in the "actions" sequence on this class:
392              "edit"      -> self.editItemAction
393              "new"       -> self.newItemAction
394              "register"  -> self.registerAction
395              "login"     -> self.loginAction
396              "logout"    -> self.logout_action
397              "search"    -> self.searchAction
398              "retire"    -> self.retireAction
399         '''
400         if not self.form.has_key(':action'):
401             return None
402         try:
403             # get the action, validate it
404             action = self.form[':action'].value
405             for name, method in self.actions:
406                 if name == action:
407                     break
408             else:
409                 raise ValueError, 'No such action "%s"'%action
411             # call the mapped action
412             getattr(self, method)()
413         except Redirect:
414             raise
415         except Unauthorised:
416             raise
417         except:
418             self.db.rollback()
419             s = StringIO.StringIO()
420             traceback.print_exc(None, s)
421             self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
423     def write(self, content):
424         if not self.headers_done:
425             self.header()
426         self.request.wfile.write(content)
428     def header(self, headers=None, response=None):
429         '''Put up the appropriate header.
430         '''
431         if headers is None:
432             headers = {'Content-Type':'text/html'}
433         if response is None:
434             response = self.response_code
436         # update with additional info
437         headers.update(self.additional_headers)
439         if not headers.has_key('Content-Type'):
440             headers['Content-Type'] = 'text/html'
441         self.request.send_response(response)
442         for entry in headers.items():
443             self.request.send_header(*entry)
444         self.request.end_headers()
445         self.headers_done = 1
446         if self.debug:
447             self.headers_sent = headers
449     def set_cookie(self, user):
450         ''' Set up a session cookie for the user and store away the user's
451             login info against the session.
452         '''
453         # TODO generate a much, much stronger session key ;)
454         self.session = binascii.b2a_base64(repr(random.random())).strip()
456         # clean up the base64
457         if self.session[-1] == '=':
458             if self.session[-2] == '=':
459                 self.session = self.session[:-2]
460             else:
461                 self.session = self.session[:-1]
463         # insert the session in the sessiondb
464         self.db.sessions.set(self.session, user=user, last_use=time.time())
466         # and commit immediately
467         self.db.sessions.commit()
469         # expire us in a long, long time
470         expire = Cookie._getdate(86400*365)
472         # generate the cookie path - make sure it has a trailing '/'
473         path = '/'.join((self.env['SCRIPT_NAME'], self.env['TRACKER_NAME'],
474             ''))
475         self.additional_headers['Set-Cookie'] = \
476           'roundup_user_2=%s; expires=%s; Path=%s;'%(self.session, expire, path)
478     def make_user_anonymous(self):
479         ''' Make us anonymous
481             This method used to handle non-existence of the 'anonymous'
482             user, but that user is mandatory now.
483         '''
484         self.userid = self.db.user.lookup('anonymous')
485         self.user = 'anonymous'
487     def opendb(self, user):
488         ''' Open the database.
489         '''
490         # open the db if the user has changed
491         if not hasattr(self, 'db') or user != self.db.journaltag:
492             if hasattr(self, 'db'):
493                 self.db.close()
494             self.db = self.instance.open(user)
496     #
497     # Actions
498     #
499     def loginAction(self):
500         ''' Attempt to log a user in.
502             Sets up a session for the user which contains the login
503             credentials.
504         '''
505         # we need the username at a minimum
506         if not self.form.has_key('__login_name'):
507             self.error_message.append(_('Username required'))
508             return
510         # get the login info
511         self.user = self.form['__login_name'].value
512         if self.form.has_key('__login_password'):
513             password = self.form['__login_password'].value
514         else:
515             password = ''
517         # make sure the user exists
518         try:
519             self.userid = self.db.user.lookup(self.user)
520         except KeyError:
521             name = self.user
522             self.error_message.append(_('No such user "%(name)s"')%locals())
523             self.make_user_anonymous()
524             return
526         # verify the password
527         if not self.verifyPassword(self.userid, password):
528             self.make_user_anonymous()
529             self.error_message.append(_('Incorrect password'))
530             return
532         # make sure we're allowed to be here
533         if not self.loginPermission():
534             self.make_user_anonymous()
535             self.error_message.append(_("You do not have permission to login"))
536             return
538         # now we're OK, re-open the database for real, using the user
539         self.opendb(self.user)
541         # set the session cookie
542         self.set_cookie(self.user)
544     def verifyPassword(self, userid, password):
545         ''' Verify the password that the user has supplied
546         '''
547         stored = self.db.user.get(self.userid, 'password')
548         if password == stored:
549             return 1
550         if not password and not stored:
551             return 1
552         return 0
554     def loginPermission(self):
555         ''' Determine whether the user has permission to log in.
557             Base behaviour is to check the user has "Web Access".
558         ''' 
559         if not self.db.security.hasPermission('Web Access', self.userid):
560             return 0
561         return 1
563     def logout_action(self):
564         ''' Make us really anonymous - nuke the cookie too
565         '''
566         # log us out
567         self.make_user_anonymous()
569         # construct the logout cookie
570         now = Cookie._getdate()
571         path = '/'.join((self.env['SCRIPT_NAME'], self.env['TRACKER_NAME'],
572             ''))
573         self.additional_headers['Set-Cookie'] = \
574            'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)
576         # Let the user know what's going on
577         self.ok_message.append(_('You are logged out'))
579     def registerAction(self):
580         '''Attempt to create a new user based on the contents of the form
581         and then set the cookie.
583         return 1 on successful login
584         '''
585         # create the new user
586         cl = self.db.user
588         # parse the props from the form
589         try:
590             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
591         except (ValueError, KeyError), message:
592             self.error_message.append(_('Error: ') + str(message))
593             return
595         # make sure we're allowed to register
596         if not self.registerPermission(props):
597             raise Unauthorised, _("You do not have permission to register")
599         # re-open the database as "admin"
600         if self.user != 'admin':
601             self.opendb('admin')
602             
603         # create the new user
604         cl = self.db.user
605         try:
606             props = parsePropsFromForm(self.db, cl, self.form)
607             props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
608             self.userid = cl.create(**props)
609             self.db.commit()
610         except (ValueError, KeyError), message:
611             self.error_message.append(message)
612             return
614         # log the new user in
615         self.user = cl.get(self.userid, 'username')
616         # re-open the database for real, using the user
617         self.opendb(self.user)
619         # if we have a session, update it
620         if hasattr(self, 'session'):
621             self.db.sessions.set(self.session, user=self.user,
622                 last_use=time.time())
623         else:
624             # new session cookie
625             self.set_cookie(self.user)
627         # nice message
628         message = _('You are now registered, welcome!')
630         # redirect to the item's edit page
631         raise Redirect, '%s%s%s?:ok_message=%s'%(
632             self.base, self.classname, self.userid,  urllib.quote(message))
634     def registerPermission(self, props):
635         ''' Determine whether the user has permission to register
637             Base behaviour is to check the user has "Web Registration".
638         '''
639         # registration isn't allowed to supply roles
640         if props.has_key('roles'):
641             return 0
642         if self.db.security.hasPermission('Web Registration', self.userid):
643             return 1
644         return 0
646     def editItemAction(self):
647         ''' Perform an edit of an item in the database.
649             Some special form elements:
651             :link=designator:property
652             :multilink=designator:property
653              The value specifies a node designator and the property on that
654              node to add _this_ node to as a link or multilink.
655             :note
656              Create a message and attach it to the current node's
657              "messages" property.
658             :file
659              Create a file and attach it to the current node's
660              "files" property. Attach the file to the message created from
661              the :note if it's supplied.
663             :required=property,property,...
664              The named properties are required to be filled in the form.
666             :remove:<propname>=id(s)
667              The ids will be removed from the multilink property.
668             :add:<propname>=id(s)
669              The ids will be added to the multilink property.
671         '''
672         cl = self.db.classes[self.classname]
674         # parse the props from the form
675         try:
676             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
677         except (ValueError, KeyError), message:
678             self.error_message.append(_('Error: ') + str(message))
679             return
681         # check permission
682         if not self.editItemPermission(props):
683             self.error_message.append(
684                 _('You do not have permission to edit %(classname)s'%
685                 self.__dict__))
686             return
688         # perform the edit
689         try:
690             # make changes to the node
691             props = self._changenode(props)
692             # handle linked nodes 
693             self._post_editnode(self.nodeid)
694         except (ValueError, KeyError, IndexError), message:
695             self.error_message.append(_('Error: ') + str(message))
696             return
698         # commit now that all the tricky stuff is done
699         self.db.commit()
701         # and some nice feedback for the user
702         if props:
703             message = _('%(changes)s edited ok')%{'changes':
704                 ', '.join(props.keys())}
705         elif self.form.has_key(':note') and self.form[':note'].value:
706             message = _('note added')
707         elif (self.form.has_key(':file') and self.form[':file'].filename):
708             message = _('file added')
709         else:
710             message = _('nothing changed')
712         # redirect to the item's edit page
713         raise Redirect, '%s%s%s?:ok_message=%s'%(self.base, self.classname,
714             self.nodeid,  urllib.quote(message))
716     def editItemPermission(self, props):
717         ''' Determine whether the user has permission to edit this item.
719             Base behaviour is to check the user can edit this class. If we're
720             editing the "user" class, users are allowed to edit their own
721             details. Unless it's the "roles" property, which requires the
722             special Permission "Web Roles".
723         '''
724         # if this is a user node and the user is editing their own node, then
725         # we're OK
726         has = self.db.security.hasPermission
727         if self.classname == 'user':
728             # reject if someone's trying to edit "roles" and doesn't have the
729             # right permission.
730             if props.has_key('roles') and not has('Web Roles', self.userid,
731                     'user'):
732                 return 0
733             # if the item being edited is the current user, we're ok
734             if self.nodeid == self.userid:
735                 return 1
736         if self.db.security.hasPermission('Edit', self.userid, self.classname):
737             return 1
738         return 0
740     def newItemAction(self):
741         ''' Add a new item to the database.
743             This follows the same form as the editItemAction, with the same
744             special form values.
745         '''
746         cl = self.db.classes[self.classname]
748         # parse the props from the form
749         try:
750             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
751         except (ValueError, KeyError), message:
752             self.error_message.append(_('Error: ') + str(message))
753             return
755         if not self.newItemPermission(props):
756             self.error_message.append(
757                 _('You do not have permission to create %s' %self.classname))
759         # create a little extra message for anticipated :link / :multilink
760         if self.form.has_key(':multilink'):
761             link = self.form[':multilink'].value
762         elif self.form.has_key(':link'):
763             link = self.form[':multilink'].value
764         else:
765             link = None
766             xtra = ''
767         if link:
768             designator, linkprop = link.split(':')
769             xtra = ' for <a href="%s">%s</a>'%(designator, designator)
771         try:
772             # do the create
773             nid = self._createnode(props)
774         except (ValueError, KeyError, IndexError), message:
775             # these errors might just be indicative of user dumbness
776             self.error_message.append(_('Error: ') + str(message))
777             return
778         except:
779             # oops
780             self.db.rollback()
781             s = StringIO.StringIO()
782             traceback.print_exc(None, s)
783             self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
784             return
786         try:
787             # handle linked nodes 
788             self._post_editnode(nid)
790             # commit now that all the tricky stuff is done
791             self.db.commit()
793             # render the newly created item
794             self.nodeid = nid
796             # and some nice feedback for the user
797             message = _('%(classname)s created ok')%self.__dict__ + xtra
798         except:
799             # oops
800             self.db.rollback()
801             s = StringIO.StringIO()
802             traceback.print_exc(None, s)
803             self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
804             return
806         # redirect to the new item's page
807         raise Redirect, '%s%s%s?:ok_message=%s'%(self.base, self.classname,
808             nid,  urllib.quote(message))
810     def newItemPermission(self, props):
811         ''' Determine whether the user has permission to create (edit) this
812             item.
814             Base behaviour is to check the user can edit this class. No
815             additional property checks are made. Additionally, new user items
816             may be created if the user has the "Web Registration" Permission.
817         '''
818         has = self.db.security.hasPermission
819         if self.classname == 'user' and has('Web Registration', self.userid,
820                 'user'):
821             return 1
822         if has('Edit', self.userid, self.classname):
823             return 1
824         return 0
826     def editCSVAction(self):
827         ''' Performs an edit of all of a class' items in one go.
829             The "rows" CGI var defines the CSV-formatted entries for the
830             class. New nodes are identified by the ID 'X' (or any other
831             non-existent ID) and removed lines are retired.
832         '''
833         # this is per-class only
834         if not self.editCSVPermission():
835             self.error_message.append(
836                 _('You do not have permission to edit %s' %self.classname))
838         # get the CSV module
839         try:
840             import csv
841         except ImportError:
842             self.error_message.append(_(
843                 'Sorry, you need the csv module to use this function.<br>\n'
844                 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
845             return
847         cl = self.db.classes[self.classname]
848         idlessprops = cl.getprops(protected=0).keys()
849         idlessprops.sort()
850         props = ['id'] + idlessprops
852         # do the edit
853         rows = self.form['rows'].value.splitlines()
854         p = csv.parser()
855         found = {}
856         line = 0
857         for row in rows[1:]:
858             line += 1
859             values = p.parse(row)
860             # not a complete row, keep going
861             if not values: continue
863             # skip property names header
864             if values == props:
865                 continue
867             # extract the nodeid
868             nodeid, values = values[0], values[1:]
869             found[nodeid] = 1
871             # confirm correct weight
872             if len(idlessprops) != len(values):
873                 self.error_message.append(
874                     _('Not enough values on line %(line)s')%{'line':line})
875                 return
877             # extract the new values
878             d = {}
879             for name, value in zip(idlessprops, values):
880                 value = value.strip()
881                 # only add the property if it has a value
882                 if value:
883                     # if it's a multilink, split it
884                     if isinstance(cl.properties[name], hyperdb.Multilink):
885                         value = value.split(':')
886                     d[name] = value
888             # perform the edit
889             if cl.hasnode(nodeid):
890                 # edit existing
891                 cl.set(nodeid, **d)
892             else:
893                 # new node
894                 found[cl.create(**d)] = 1
896         # retire the removed entries
897         for nodeid in cl.list():
898             if not found.has_key(nodeid):
899                 cl.retire(nodeid)
901         # all OK
902         self.db.commit()
904         self.ok_message.append(_('Items edited OK'))
906     def editCSVPermission(self):
907         ''' Determine whether the user has permission to edit this class.
909             Base behaviour is to check the user can edit this class.
910         ''' 
911         if not self.db.security.hasPermission('Edit', self.userid,
912                 self.classname):
913             return 0
914         return 1
916     def searchAction(self):
917         ''' Mangle some of the form variables.
919             Set the form ":filter" variable based on the values of the
920             filter variables - if they're set to anything other than
921             "dontcare" then add them to :filter.
923             Also handle the ":queryname" variable and save off the query to
924             the user's query list.
925         '''
926         # generic edit is per-class only
927         if not self.searchPermission():
928             self.error_message.append(
929                 _('You do not have permission to search %s' %self.classname))
931         # add a faked :filter form variable for each filtering prop
932         props = self.db.classes[self.classname].getprops()
933         for key in self.form.keys():
934             if not props.has_key(key): continue
935             if not self.form[key].value: continue
936             self.form.value.append(cgi.MiniFieldStorage(':filter', key))
938         # handle saving the query params
939         if self.form.has_key(':queryname'):
940             queryname = self.form[':queryname'].value.strip()
941             if queryname:
942                 # parse the environment and figure what the query _is_
943                 req = HTMLRequest(self)
944                 url = req.indexargs_href('', {})
946                 # handle editing an existing query
947                 try:
948                     qid = self.db.query.lookup(queryname)
949                     self.db.query.set(qid, klass=self.classname, url=url)
950                 except KeyError:
951                     # create a query
952                     qid = self.db.query.create(name=queryname,
953                         klass=self.classname, url=url)
955                     # and add it to the user's query multilink
956                     queries = self.db.user.get(self.userid, 'queries')
957                     queries.append(qid)
958                     self.db.user.set(self.userid, queries=queries)
960                 # commit the query change to the database
961                 self.db.commit()
963     def searchPermission(self):
964         ''' Determine whether the user has permission to search this class.
966             Base behaviour is to check the user can view this class.
967         ''' 
968         if not self.db.security.hasPermission('View', self.userid,
969                 self.classname):
970             return 0
971         return 1
973     def retireAction(self):
974         ''' Retire the context item.
975         '''
976         # if we want to view the index template now, then unset the nodeid
977         # context info (a special-case for retire actions on the index page)
978         nodeid = self.nodeid
979         if self.template == 'index':
980             self.nodeid = None
982         # generic edit is per-class only
983         if not self.retirePermission():
984             self.error_message.append(
985                 _('You do not have permission to retire %s' %self.classname))
986             return
988         # make sure we don't try to retire admin or anonymous
989         if self.classname == 'user' and \
990                 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
991             self.error_message.append(
992                 _('You may not retire the admin or anonymous user'))
993             return
995         # do the retire
996         self.db.getclass(self.classname).retire(nodeid)
997         self.db.commit()
999         self.ok_message.append(
1000             _('%(classname)s %(itemid)s has been retired')%{
1001                 'classname': self.classname.capitalize(), 'itemid': nodeid})
1003     def retirePermission(self):
1004         ''' Determine whether the user has permission to retire this class.
1006             Base behaviour is to check the user can edit this class.
1007         ''' 
1008         if not self.db.security.hasPermission('Edit', self.userid,
1009                 self.classname):
1010             return 0
1011         return 1
1014     #
1015     #  Utility methods for editing
1016     #
1017     def _changenode(self, props):
1018         ''' change the node based on the contents of the form
1019         '''
1020         cl = self.db.classes[self.classname]
1022         # create the message
1023         message, files = self._handle_message()
1024         if message:
1025             props['messages'] = cl.get(self.nodeid, 'messages') + [message]
1026         if files:
1027             props['files'] = cl.get(self.nodeid, 'files') + files
1029         # make the changes
1030         return cl.set(self.nodeid, **props)
1032     def _createnode(self, props):
1033         ''' create a node based on the contents of the form
1034         '''
1035         cl = self.db.classes[self.classname]
1037         # check for messages and files
1038         message, files = self._handle_message()
1039         if message:
1040             props['messages'] = [message]
1041         if files:
1042             props['files'] = files
1043         # create the node and return it's id
1044         return cl.create(**props)
1046     def _handle_message(self):
1047         ''' generate an edit message
1048         '''
1049         # handle file attachments 
1050         files = []
1051         if self.form.has_key(':file'):
1052             file = self.form[':file']
1053             if file.filename:
1054                 filename = file.filename.split('\\')[-1]
1055                 mime_type = mimetypes.guess_type(filename)[0]
1056                 if not mime_type:
1057                     mime_type = "application/octet-stream"
1058                 # create the new file entry
1059                 files.append(self.db.file.create(type=mime_type,
1060                     name=filename, content=file.file.read()))
1062         # we don't want to do a message if none of the following is true...
1063         cn = self.classname
1064         cl = self.db.classes[self.classname]
1065         props = cl.getprops()
1066         note = None
1067         # in a nutshell, don't do anything if there's no note or there's no
1068         # NOSY
1069         if self.form.has_key(':note'):
1070             # fix the CRLF/CR -> LF stuff
1071             note = fixNewlines(self.form[':note'].value.strip())
1072         if not note:
1073             return None, files
1074         if not props.has_key('messages'):
1075             return None, files
1076         if not isinstance(props['messages'], hyperdb.Multilink):
1077             return None, files
1078         if not props['messages'].classname == 'msg':
1079             return None, files
1080         if not (self.form.has_key('nosy') or note):
1081             return None, files
1083         # handle the note
1084         if '\n' in note:
1085             summary = re.split(r'\n\r?', note)[0]
1086         else:
1087             summary = note
1088         m = ['%s\n'%note]
1090         # handle the messageid
1091         # TODO: handle inreplyto
1092         messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
1093             self.classname, self.instance.config.MAIL_DOMAIN)
1095         # now create the message, attaching the files
1096         content = '\n'.join(m)
1097         message_id = self.db.msg.create(author=self.userid,
1098             recipients=[], date=date.Date('.'), summary=summary,
1099             content=content, files=files, messageid=messageid)
1101         # update the messages property
1102         return message_id, files
1104     def _post_editnode(self, nid):
1105         '''Do the linking part of the node creation.
1107            If a form element has :link or :multilink appended to it, its
1108            value specifies a node designator and the property on that node
1109            to add _this_ node to as a link or multilink.
1111            This is typically used on, eg. the file upload page to indicated
1112            which issue to link the file to.
1114            TODO: I suspect that this and newfile will go away now that
1115            there's the ability to upload a file using the issue :file form
1116            element!
1117         '''
1118         cn = self.classname
1119         cl = self.db.classes[cn]
1120         # link if necessary
1121         keys = self.form.keys()
1122         for key in keys:
1123             if key == ':multilink':
1124                 value = self.form[key].value
1125                 if type(value) != type([]): value = [value]
1126                 for value in value:
1127                     designator, property = value.split(':')
1128                     link, nodeid = hyperdb.splitDesignator(designator)
1129                     link = self.db.classes[link]
1130                     # take a dupe of the list so we're not changing the cache
1131                     value = link.get(nodeid, property)[:]
1132                     value.append(nid)
1133                     link.set(nodeid, **{property: value})
1134             elif key == ':link':
1135                 value = self.form[key].value
1136                 if type(value) != type([]): value = [value]
1137                 for value in value:
1138                     designator, property = value.split(':')
1139                     link, nodeid = hyperdb.splitDesignator(designator)
1140                     link = self.db.classes[link]
1141                     link.set(nodeid, **{property: nid})
1143 def fixNewlines(text):
1144     ''' Homogenise line endings.
1146         Different web clients send different line ending values, but
1147         other systems (eg. email) don't necessarily handle those line
1148         endings. Our solution is to convert all line endings to LF.
1149     '''
1150     text = text.replace('\r\n', '\n')
1151     return text.replace('\r', '\n')
1153 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
1154     ''' Pull properties for the given class out of the form.
1156         If a ":required" parameter is supplied, then the names property values
1157         must be supplied or a ValueError will be raised.
1159         Other special form values:
1160          :remove:<propname>=id(s)
1161           The ids will be removed from the multilink property.
1162          :add:<propname>=id(s)
1163           The ids will be added to the multilink property.
1164     '''
1165     required = []
1166     if form.has_key(':required'):
1167         value = form[':required']
1168         if isinstance(value, type([])):
1169             required = [i.value.strip() for i in value]
1170         else:
1171             required = [i.strip() for i in value.value.split(',')]
1173     props = {}
1174     keys = form.keys()
1175     properties = cl.getprops()
1176     existing_cache = {}
1177     for key in keys:
1178         # see if we're performing a special multilink action
1179         mlaction = 'set'
1180         if key.startswith(':remove:'):
1181             propname = key[8:]
1182             mlaction = 'remove'
1183         elif key.startswith(':add:'):
1184             propname = key[5:]
1185             mlaction = 'add'
1186         else:
1187             propname = key
1189         # does the property exist?
1190         if not properties.has_key(propname):
1191             if mlaction == 'remove':
1192                 raise ValueError, 'You have submitted a remove action for'\
1193                     ' the property "%s" which doesn\'t exist'%propname
1194             continue
1195         proptype = properties[propname]
1197         # Get the form value. This value may be a MiniFieldStorage or a list
1198         # of MiniFieldStorages.
1199         value = form[key]
1201         # handle unpacking of the MiniFieldStorage / list form value
1202         if isinstance(proptype, hyperdb.Multilink):
1203             # multiple values are OK
1204             if isinstance(value, type([])):
1205                 # it's a list of MiniFieldStorages
1206                 value = [i.value.strip() for i in value]
1207             else:
1208                 # it's a MiniFieldStorage, but may be a comma-separated list
1209                 # of values
1210                 value = [i.strip() for i in value.value.split(',')]
1211         else:
1212             # multiple values are not OK
1213             if isinstance(value, type([])):
1214                 raise ValueError, 'You have submitted more than one value'\
1215                     ' for the %s property'%propname
1216             # we've got a MiniFieldStorage, so pull out the value and strip
1217             # surrounding whitespace
1218             value = value.value.strip()
1220         if isinstance(proptype, hyperdb.String):
1221             if not value:
1222                 continue
1223             # fix the CRLF/CR -> LF stuff
1224             value = fixNewlines(value)
1225         elif isinstance(proptype, hyperdb.Password):
1226             if not value:
1227                 # ignore empty password values
1228                 continue
1229             if not form.has_key('%s:confirm'%propname):
1230                 raise ValueError, 'Password and confirmation text do not match'
1231             confirm = form['%s:confirm'%propname]
1232             if isinstance(confirm, type([])):
1233                 raise ValueError, 'You have submitted more than one value'\
1234                     ' for the %s property'%propname
1235             if value != confirm.value:
1236                 raise ValueError, 'Password and confirmation text do not match'
1237             value = password.Password(value)
1238         elif isinstance(proptype, hyperdb.Date):
1239             if value:
1240                 value = date.Date(value)
1241             else:
1242                 value = None
1243         elif isinstance(proptype, hyperdb.Interval):
1244             if value:
1245                 value = date.Interval(value)
1246             else:
1247                 value = None
1248         elif isinstance(proptype, hyperdb.Link):
1249             # see if it's the "no selection" choice
1250             if value == '-1':
1251                 # if we're creating, just don't include this property
1252                 if not nodeid:
1253                     continue
1254                 value = None
1255             else:
1256                 # handle key values
1257                 link = proptype.classname
1258                 if not num_re.match(value):
1259                     try:
1260                         value = db.classes[link].lookup(value)
1261                     except KeyError:
1262                         raise ValueError, _('property "%(propname)s": '
1263                             '%(value)s not a %(classname)s')%{
1264                             'propname': propname, 'value': value,
1265                             'classname': link}
1266                     except TypeError, message:
1267                         raise ValueError, _('you may only enter ID values '
1268                             'for property "%(propname)s": %(message)s')%{
1269                             'propname': propname, 'message': message}
1270         elif isinstance(proptype, hyperdb.Multilink):
1271             # perform link class key value lookup if necessary
1272             link = proptype.classname
1273             l = []
1274             for entry in value:
1275                 if entry == '': continue
1276                 if not num_re.match(entry):
1277                     try:
1278                         entry = db.classes[link].lookup(entry)
1279                     except KeyError:
1280                         raise ValueError, _('property "%(propname)s": '
1281                             '"%(value)s" not an entry of %(classname)s')%{
1282                             'propname': propname, 'value': entry,
1283                             'classname': link}
1284                     except TypeError, message:
1285                         raise ValueError, _('you may only enter ID values '
1286                             'for property "%(propname)s": %(message)s')%{
1287                             'propname': propname, 'message': message}
1288                 l.append(entry)
1289             l.sort()
1291             # now use that list of ids to modify the multilink
1292             if mlaction == 'set':
1293                 value = l
1294             else:
1295                 # we're modifying the list - get the current list of ids
1296                 if props.has_key(propname):
1297                     existing = props[propname]
1298                 else:
1299                     existing = cl.get(nodeid, propname, [])
1301                 # now either remove or add
1302                 if mlaction == 'remove':
1303                     # remove - handle situation where the id isn't in the list
1304                     for entry in l:
1305                         try:
1306                             existing.remove(entry)
1307                         except ValueError:
1308                             raise ValueError, _('property "%(propname)s": '
1309                                 '"%(value)s" not currently in list')%{
1310                                 'propname': propname, 'value': entry}
1311                 else:
1312                     # add - easy, just don't dupe
1313                     for entry in l:
1314                         if entry not in existing:
1315                             existing.append(entry)
1316                 value = existing
1317                 value.sort()
1319         elif isinstance(proptype, hyperdb.Boolean):
1320             value = value.lower() in ('yes', 'true', 'on', '1')
1321         elif isinstance(proptype, hyperdb.Number):
1322             value = int(value)
1324         # register this as received if required?
1325         if propname in required and value is not None:
1326             required.remove(propname)
1328         # get the old value
1329         if nodeid:
1330             try:
1331                 existing = cl.get(nodeid, propname)
1332             except KeyError:
1333                 # this might be a new property for which there is no existing
1334                 # value
1335                 if not properties.has_key(propname):
1336                     raise
1338             # if changed, set it
1339             if value != existing:
1340                 props[propname] = value
1341         else:
1342             props[propname] = value
1344     # see if all the required properties have been supplied
1345     if required:
1346         if len(required) > 1:
1347             p = 'properties'
1348         else:
1349             p = 'property'
1350         raise ValueError, 'Required %s %s not supplied'%(p, ', '.join(required))
1352     return props