Code

oops, missed a +
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.88 2003-02-17 01:04:31 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.
83     Special form variables:
84      Note that in various places throughout this code, special form
85      variables of the form :<name> are used. The colon (":") part may
86      actually be one of either ":" or "@".
87     '''
89     #
90     # special form variables
91     #
92     FV_TEMPLATE = re.compile(r'[@:]template')
93     FV_OK_MESSAGE = re.compile(r'[@:]ok_message')
94     FV_ERROR_MESSAGE = re.compile(r'[@:]error_message')
96     # specials for parsePropsFromForm
97     FV_REQUIRED = re.compile(r'[@:]required')
98     FV_ADD = re.compile(r'([@:])add\1')
99     FV_REMOVE = re.compile(r'([@:])remove\1')
100     FV_CONFIRM = re.compile(r'.+[@:]confirm')
101     FV_LINK = re.compile(r'([@:])link\1(.+)')
103     # deprecated
104     FV_NOTE = re.compile(r'[@:]note')
105     FV_FILE = re.compile(r'[@:]file')
107     # Note: index page stuff doesn't appear here:
108     # columns, sort, sortdir, filter, group, groupdir, search_text,
109     # pagesize, startwith
111     def __init__(self, instance, request, env, form=None):
112         hyperdb.traceMark()
113         self.instance = instance
114         self.request = request
115         self.env = env
117         # save off the path
118         self.path = env['PATH_INFO']
120         # this is the base URL for this tracker
121         self.base = self.instance.config.TRACKER_WEB
123         # this is the "cookie path" for this tracker (ie. the path part of
124         # the "base" url)
125         self.cookie_path = urlparse.urlparse(self.base)[2]
126         self.cookie_name = 'roundup_session_' + re.sub('[^a-zA-Z]', '',
127             self.instance.config.TRACKER_NAME)
129         # see if we need to re-parse the environment for the form (eg Zope)
130         if form is None:
131             self.form = cgi.FieldStorage(environ=env)
132         else:
133             self.form = form
135         # turn debugging on/off
136         try:
137             self.debug = int(env.get("ROUNDUP_DEBUG", 0))
138         except ValueError:
139             # someone gave us a non-int debug level, turn it off
140             self.debug = 0
142         # flag to indicate that the HTTP headers have been sent
143         self.headers_done = 0
145         # additional headers to send with the request - must be registered
146         # before the first write
147         self.additional_headers = {}
148         self.response_code = 200
151     def main(self):
152         ''' Wrap the real main in a try/finally so we always close off the db.
153         '''
154         try:
155             self.inner_main()
156         finally:
157             if hasattr(self, 'db'):
158                 self.db.close()
160     def inner_main(self):
161         ''' Process a request.
163             The most common requests are handled like so:
164             1. figure out who we are, defaulting to the "anonymous" user
165                see determine_user
166             2. figure out what the request is for - the context
167                see determine_context
168             3. handle any requested action (item edit, search, ...)
169                see handle_action
170             4. render a template, resulting in HTML output
172             In some situations, exceptions occur:
173             - HTTP Redirect  (generally raised by an action)
174             - SendFile       (generally raised by determine_context)
175               serve up a FileClass "content" property
176             - SendStaticFile (generally raised by determine_context)
177               serve up a file from the tracker "html" directory
178             - Unauthorised   (generally raised by an action)
179               the action is cancelled, the request is rendered and an error
180               message is displayed indicating that permission was not
181               granted for the action to take place
182             - NotFound       (raised wherever it needs to be)
183               percolates up to the CGI interface that called the client
184         '''
185         self.ok_message = []
186         self.error_message = []
187         try:
188             # make sure we're identified (even anonymously)
189             self.determine_user()
190             # figure out the context and desired content template
191             self.determine_context()
192             # possibly handle a form submit action (may change self.classname
193             # and self.template, and may also append error/ok_messages)
194             self.handle_action()
195             # now render the page
197             # we don't want clients caching our dynamic pages
198             self.additional_headers['Cache-Control'] = 'no-cache'
199             self.additional_headers['Pragma'] = 'no-cache'
200             self.additional_headers['Expires'] = 'Thu, 1 Jan 1970 00:00:00 GMT'
202             # render the content
203             self.write(self.renderContext())
204         except Redirect, url:
205             # let's redirect - if the url isn't None, then we need to do
206             # the headers, otherwise the headers have been set before the
207             # exception was raised
208             if url:
209                 self.additional_headers['Location'] = url
210                 self.response_code = 302
211             self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
212         except SendFile, designator:
213             self.serve_file(designator)
214         except SendStaticFile, file:
215             self.serve_static_file(str(file))
216         except Unauthorised, message:
217             self.classname=None
218             self.template=''
219             self.error_message.append(message)
220             self.write(self.renderContext())
221         except NotFound:
222             # pass through
223             raise
224         except:
225             # everything else
226             self.write(cgitb.html())
228     def clean_sessions(self):
229         '''age sessions, remove when they haven't been used for a week.
230         Do it only once an hour'''
231         sessions = self.db.sessions
232         last_clean = sessions.get('last_clean', 'last_use') or 0
234         week = 60*60*24*7
235         hour = 60*60
236         now = time.time()
237         if now - last_clean > hour:
238             # remove age sessions
239             for sessid in sessions.list():
240                 interval = now - sessions.get(sessid, 'last_use')
241                 if interval > week:
242                     sessions.destroy(sessid)
243             sessions.set('last_clean', last_use=time.time())
245     def determine_user(self):
246         ''' Determine who the user is
247         '''
248         # determine the uid to use
249         self.opendb('admin')
250         # clean age sessions
251         self.clean_sessions()
252         # make sure we have the session Class
253         sessions = self.db.sessions
255         # look up the user session cookie
256         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
257         user = 'anonymous'
259         # bump the "revision" of the cookie since the format changed
260         if (cookie.has_key(self.cookie_name) and
261                 cookie[self.cookie_name].value != 'deleted'):
263             # get the session key from the cookie
264             self.session = cookie[self.cookie_name].value
265             # get the user from the session
266             try:
267                 # update the lifetime datestamp
268                 sessions.set(self.session, last_use=time.time())
269                 sessions.commit()
270                 user = sessions.get(self.session, 'user')
271             except KeyError:
272                 user = 'anonymous'
274         # sanity check on the user still being valid, getting the userid
275         # at the same time
276         try:
277             self.userid = self.db.user.lookup(user)
278         except (KeyError, TypeError):
279             user = 'anonymous'
281         # make sure the anonymous user is valid if we're using it
282         if user == 'anonymous':
283             self.make_user_anonymous()
284         else:
285             self.user = user
287         # reopen the database as the correct user
288         self.opendb(self.user)
290     def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
291         ''' Determine the context of this page from the URL:
293             The URL path after the instance identifier is examined. The path
294             is generally only one entry long.
296             - if there is no path, then we are in the "home" context.
297             * if the path is "_file", then the additional path entry
298               specifies the filename of a static file we're to serve up
299               from the instance "html" directory. Raises a SendStaticFile
300               exception.
301             - if there is something in the path (eg "issue"), it identifies
302               the tracker class we're to display.
303             - if the path is an item designator (eg "issue123"), then we're
304               to display a specific item.
305             * if the path starts with an item designator and is longer than
306               one entry, then we're assumed to be handling an item of a
307               FileClass, and the extra path information gives the filename
308               that the client is going to label the download with (ie
309               "file123/image.png" is nicer to download than "file123"). This
310               raises a SendFile exception.
312             Both of the "*" types of contexts stop before we bother to
313             determine the template we're going to use. That's because they
314             don't actually use templates.
316             The template used is specified by the :template CGI variable,
317             which defaults to:
319              only classname suplied:          "index"
320              full item designator supplied:   "item"
322             We set:
323              self.classname  - the class to display, can be None
324              self.template   - the template to render the current context with
325              self.nodeid     - the nodeid of the class we're displaying
326         '''
327         # default the optional variables
328         self.classname = None
329         self.nodeid = None
331         # see if a template or messages are specified
332         template_override = ok_message = error_message = None
333         for key in self.form.keys():
334             if self.FV_TEMPLATE.match(key):
335                 template_override = self.form[key].value
336             elif self.FV_OK_MESSAGE.match(key):
337                 ok_message = self.form[key].value
338             elif self.FV_ERROR_MESSAGE.match(key):
339                 error_message = self.form[key].value
341         # determine the classname and possibly nodeid
342         path = self.path.split('/')
343         if not path or path[0] in ('', 'home', 'index'):
344             if template_override is not None:
345                 self.template = template_override
346             else:
347                 self.template = ''
348             return
349         elif path[0] == '_file':
350             raise SendStaticFile, os.path.join(*path[1:])
351         else:
352             self.classname = path[0]
353             if len(path) > 1:
354                 # send the file identified by the designator in path[0]
355                 raise SendFile, path[0]
357         # see if we got a designator
358         m = dre.match(self.classname)
359         if m:
360             self.classname = m.group(1)
361             self.nodeid = m.group(2)
362             if not self.db.getclass(self.classname).hasnode(self.nodeid):
363                 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
364             # with a designator, we default to item view
365             self.template = 'item'
366         else:
367             # with only a class, we default to index view
368             self.template = 'index'
370         # make sure the classname is valid
371         try:
372             self.db.getclass(self.classname)
373         except KeyError:
374             raise NotFound, self.classname
376         # see if we have a template override
377         if template_override is not None:
378             self.template = template_override
380         # see if we were passed in a message
381         if ok_message:
382             self.ok_message.append(ok_message)
383         if error_message:
384             self.error_message.append(error_message)
386     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
387         ''' Serve the file from the content property of the designated item.
388         '''
389         m = dre.match(str(designator))
390         if not m:
391             raise NotFound, str(designator)
392         classname, nodeid = m.group(1), m.group(2)
393         if classname != 'file':
394             raise NotFound, designator
396         # we just want to serve up the file named
397         file = self.db.file
398         self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
399         self.write(file.get(nodeid, 'content'))
401     def serve_static_file(self, file):
402         # we just want to serve up the file named
403         mt = mimetypes.guess_type(str(file))[0]
404         self.additional_headers['Content-Type'] = mt
405         self.write(open(os.path.join(self.instance.config.TEMPLATES,
406             file)).read())
408     def renderContext(self):
409         ''' Return a PageTemplate for the named page
410         '''
411         name = self.classname
412         extension = self.template
413         pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
415         # catch errors so we can handle PT rendering errors more nicely
416         args = {
417             'ok_message': self.ok_message,
418             'error_message': self.error_message
419         }
420         try:
421             # let the template render figure stuff out
422             return pt.render(self, None, None, **args)
423         except NoTemplate, message:
424             return '<strong>%s</strong>'%message
425         except:
426             # everything else
427             return cgitb.pt_html()
429     # these are the actions that are available
430     actions = (
431         ('edit',     'editItemAction'),
432         ('editCSV',  'editCSVAction'),
433         ('new',      'newItemAction'),
434         ('register', 'registerAction'),
435         ('login',    'loginAction'),
436         ('logout',   'logout_action'),
437         ('search',   'searchAction'),
438         ('retire',   'retireAction'),
439         ('show',     'showAction'),
440     )
441     def handle_action(self):
442         ''' Determine whether there should be an _action called.
444             The action is defined by the form variable :action which
445             identifies the method on this object to call. The four basic
446             actions are defined in the "actions" sequence on this class:
447              "edit"      -> self.editItemAction
448              "new"       -> self.newItemAction
449              "register"  -> self.registerAction
450              "login"     -> self.loginAction
451              "logout"    -> self.logout_action
452              "search"    -> self.searchAction
453              "retire"    -> self.retireAction
454         '''
455         if not self.form.has_key(':action'):
456             return None
457         try:
458             # get the action, validate it
459             action = self.form[':action'].value
460             for name, method in self.actions:
461                 if name == action:
462                     break
463             else:
464                 raise ValueError, 'No such action "%s"'%action
466             # call the mapped action
467             getattr(self, method)()
468         except Redirect:
469             raise
470         except Unauthorised:
471             raise
472         except:
473             self.db.rollback()
474             s = StringIO.StringIO()
475             traceback.print_exc(None, s)
476             self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
478     def write(self, content):
479         if not self.headers_done:
480             self.header()
481         self.request.wfile.write(content)
483     def header(self, headers=None, response=None):
484         '''Put up the appropriate header.
485         '''
486         if headers is None:
487             headers = {'Content-Type':'text/html'}
488         if response is None:
489             response = self.response_code
491         # update with additional info
492         headers.update(self.additional_headers)
494         if not headers.has_key('Content-Type'):
495             headers['Content-Type'] = 'text/html'
496         self.request.send_response(response)
497         for entry in headers.items():
498             self.request.send_header(*entry)
499         self.request.end_headers()
500         self.headers_done = 1
501         if self.debug:
502             self.headers_sent = headers
504     def set_cookie(self, user):
505         ''' Set up a session cookie for the user and store away the user's
506             login info against the session.
507         '''
508         # TODO generate a much, much stronger session key ;)
509         self.session = binascii.b2a_base64(repr(random.random())).strip()
511         # clean up the base64
512         if self.session[-1] == '=':
513             if self.session[-2] == '=':
514                 self.session = self.session[:-2]
515             else:
516                 self.session = self.session[:-1]
518         # insert the session in the sessiondb
519         self.db.sessions.set(self.session, user=user, last_use=time.time())
521         # and commit immediately
522         self.db.sessions.commit()
524         # expire us in a long, long time
525         expire = Cookie._getdate(86400*365)
527         # generate the cookie path - make sure it has a trailing '/'
528         self.additional_headers['Set-Cookie'] = \
529           '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
530             expire, self.cookie_path)
532     def make_user_anonymous(self):
533         ''' Make us anonymous
535             This method used to handle non-existence of the 'anonymous'
536             user, but that user is mandatory now.
537         '''
538         self.userid = self.db.user.lookup('anonymous')
539         self.user = 'anonymous'
541     def opendb(self, user):
542         ''' Open the database.
543         '''
544         # open the db if the user has changed
545         if not hasattr(self, 'db') or user != self.db.journaltag:
546             if hasattr(self, 'db'):
547                 self.db.close()
548             self.db = self.instance.open(user)
550     #
551     # Actions
552     #
553     def loginAction(self):
554         ''' Attempt to log a user in.
556             Sets up a session for the user which contains the login
557             credentials.
558         '''
559         # we need the username at a minimum
560         if not self.form.has_key('__login_name'):
561             self.error_message.append(_('Username required'))
562             return
564         # get the login info
565         self.user = self.form['__login_name'].value
566         if self.form.has_key('__login_password'):
567             password = self.form['__login_password'].value
568         else:
569             password = ''
571         # make sure the user exists
572         try:
573             self.userid = self.db.user.lookup(self.user)
574         except KeyError:
575             name = self.user
576             self.error_message.append(_('No such user "%(name)s"')%locals())
577             self.make_user_anonymous()
578             return
580         # verify the password
581         if not self.verifyPassword(self.userid, password):
582             self.make_user_anonymous()
583             self.error_message.append(_('Incorrect password'))
584             return
586         # make sure we're allowed to be here
587         if not self.loginPermission():
588             self.make_user_anonymous()
589             self.error_message.append(_("You do not have permission to login"))
590             return
592         # now we're OK, re-open the database for real, using the user
593         self.opendb(self.user)
595         # set the session cookie
596         self.set_cookie(self.user)
598     def verifyPassword(self, userid, password):
599         ''' Verify the password that the user has supplied
600         '''
601         stored = self.db.user.get(self.userid, 'password')
602         if password == stored:
603             return 1
604         if not password and not stored:
605             return 1
606         return 0
608     def loginPermission(self):
609         ''' Determine whether the user has permission to log in.
611             Base behaviour is to check the user has "Web Access".
612         ''' 
613         if not self.db.security.hasPermission('Web Access', self.userid):
614             return 0
615         return 1
617     def logout_action(self):
618         ''' Make us really anonymous - nuke the cookie too
619         '''
620         # log us out
621         self.make_user_anonymous()
623         # construct the logout cookie
624         now = Cookie._getdate()
625         self.additional_headers['Set-Cookie'] = \
626            '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.cookie_name,
627             now, self.cookie_path)
629         # Let the user know what's going on
630         self.ok_message.append(_('You are logged out'))
632     def registerAction(self):
633         '''Attempt to create a new user based on the contents of the form
634         and then set the cookie.
636         return 1 on successful login
637         '''
638         # create the new user
639         cl = self.db.user
641         # parse the props from the form
642         try:
643             props = self.parsePropsFromForm()
644         except (ValueError, KeyError), message:
645             self.error_message.append(_('Error: ') + str(message))
646             return
648         # make sure we're allowed to register
649         if not self.registerPermission(props):
650             raise Unauthorised, _("You do not have permission to register")
652         # re-open the database as "admin"
653         if self.user != 'admin':
654             self.opendb('admin')
655             
656         # create the new user
657         cl = self.db.user
658         try:
659             props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
660             self.userid = cl.create(**props['user'])
661             self.db.commit()
662         except (ValueError, KeyError), message:
663             self.error_message.append(message)
664             return
666         # log the new user in
667         self.user = cl.get(self.userid, 'username')
668         # re-open the database for real, using the user
669         self.opendb(self.user)
671         # if we have a session, update it
672         if hasattr(self, 'session'):
673             self.db.sessions.set(self.session, user=self.user,
674                 last_use=time.time())
675         else:
676             # new session cookie
677             self.set_cookie(self.user)
679         # nice message
680         message = _('You are now registered, welcome!')
682         # redirect to the item's edit page
683         raise Redirect, '%s%s%s?+ok_message=%s'%(
684             self.base, self.classname, self.userid,  urllib.quote(message))
686     def registerPermission(self, props):
687         ''' Determine whether the user has permission to register
689             Base behaviour is to check the user has "Web Registration".
690         '''
691         # registration isn't allowed to supply roles
692         if props.has_key('roles'):
693             return 0
694         if self.db.security.hasPermission('Web Registration', self.userid):
695             return 1
696         return 0
698     def editItemAction(self):
699         ''' Perform an edit of an item in the database.
701            See parsePropsFromForm and _editnodes for special variables
702         '''
703         # parse the props from the form
704         if 1:
705 #        try:
706             props, links = self.parsePropsFromForm()
707 #        except (ValueError, KeyError), message:
708 #            self.error_message.append(_('Error: ') + str(message))
709 #            return
711         # handle the props
712         if 1:
713 #        try:
714             message = self._editnodes(props, links)
715 #        except (ValueError, KeyError, IndexError), message:
716 #            self.error_message.append(_('Error: ') + str(message))
717 #            return
719         # commit now that all the tricky stuff is done
720         self.db.commit()
722         # redirect to the item's edit page
723         raise Redirect, '%s%s%s?@ok_message=%s'%(self.base, self.classname,
724             self.nodeid,  urllib.quote(message))
726     def editItemPermission(self, props):
727         ''' Determine whether the user has permission to edit this item.
729             Base behaviour is to check the user can edit this class. If we're
730             editing the "user" class, users are allowed to edit their own
731             details. Unless it's the "roles" property, which requires the
732             special Permission "Web Roles".
733         '''
734         # if this is a user node and the user is editing their own node, then
735         # we're OK
736         has = self.db.security.hasPermission
737         if self.classname == 'user':
738             # reject if someone's trying to edit "roles" and doesn't have the
739             # right permission.
740             if props.has_key('roles') and not has('Web Roles', self.userid,
741                     'user'):
742                 return 0
743             # if the item being edited is the current user, we're ok
744             if self.nodeid == self.userid:
745                 return 1
746         if self.db.security.hasPermission('Edit', self.userid, self.classname):
747             return 1
748         return 0
750     def newItemAction(self):
751         ''' Add a new item to the database.
753             This follows the same form as the editItemAction, with the same
754             special form values.
755         '''
756         # parse the props from the form
757 # XXX reinstate exception handling
758 #        try:
759         if 1:
760             props, links = self.parsePropsFromForm()
761 #        except (ValueError, KeyError), message:
762 #            self.error_message.append(_('Error: ') + str(message))
763 #            return
765         # handle the props - edit or create
766 # XXX reinstate exception handling
767 #        try:
768         if 1:
769             # create the context here
770             cn = self.classname
771             nid = self._createnode(cn, props[(cn, None)])
772             del props[(cn, None)]
774             extra = self._editnodes(props, links, {(cn, None): nid})
775             if extra:
776                 extra = '<br>' + extra
778             # now do the rest
779             messages = '%s %s created'%(cn, nid) + extra
780 #        except (ValueError, KeyError, IndexError), message:
781 #            # these errors might just be indicative of user dumbness
782 #            self.error_message.append(_('Error: ') + str(message))
783 #            return
785         # commit now that all the tricky stuff is done
786         self.db.commit()
788         # redirect to the new item's page
789         raise Redirect, '%s%s%s?@ok_message=%s'%(self.base, self.classname,
790             nid, urllib.quote(messages))
792     def newItemPermission(self, props):
793         ''' Determine whether the user has permission to create (edit) this
794             item.
796             Base behaviour is to check the user can edit this class. No
797             additional property checks are made. Additionally, new user items
798             may be created if the user has the "Web Registration" Permission.
799         '''
800         has = self.db.security.hasPermission
801         if self.classname == 'user' and has('Web Registration', self.userid,
802                 'user'):
803             return 1
804         if has('Edit', self.userid, self.classname):
805             return 1
806         return 0
808     def editCSVAction(self):
809         ''' Performs an edit of all of a class' items in one go.
811             The "rows" CGI var defines the CSV-formatted entries for the
812             class. New nodes are identified by the ID 'X' (or any other
813             non-existent ID) and removed lines are retired.
814         '''
815         # this is per-class only
816         if not self.editCSVPermission():
817             self.error_message.append(
818                 _('You do not have permission to edit %s' %self.classname))
820         # get the CSV module
821         try:
822             import csv
823         except ImportError:
824             self.error_message.append(_(
825                 'Sorry, you need the csv module to use this function.<br>\n'
826                 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
827             return
829         cl = self.db.classes[self.classname]
830         idlessprops = cl.getprops(protected=0).keys()
831         idlessprops.sort()
832         props = ['id'] + idlessprops
834         # do the edit
835         rows = self.form['rows'].value.splitlines()
836         p = csv.parser()
837         found = {}
838         line = 0
839         for row in rows[1:]:
840             line += 1
841             values = p.parse(row)
842             # not a complete row, keep going
843             if not values: continue
845             # skip property names header
846             if values == props:
847                 continue
849             # extract the nodeid
850             nodeid, values = values[0], values[1:]
851             found[nodeid] = 1
853             # confirm correct weight
854             if len(idlessprops) != len(values):
855                 self.error_message.append(
856                     _('Not enough values on line %(line)s')%{'line':line})
857                 return
859             # extract the new values
860             d = {}
861             for name, value in zip(idlessprops, values):
862                 value = value.strip()
863                 # only add the property if it has a value
864                 if value:
865                     # if it's a multilink, split it
866                     if isinstance(cl.properties[name], hyperdb.Multilink):
867                         value = value.split(':')
868                     d[name] = value
870             # perform the edit
871             if cl.hasnode(nodeid):
872                 # edit existing
873                 cl.set(nodeid, **d)
874             else:
875                 # new node
876                 found[cl.create(**d)] = 1
878         # retire the removed entries
879         for nodeid in cl.list():
880             if not found.has_key(nodeid):
881                 cl.retire(nodeid)
883         # all OK
884         self.db.commit()
886         self.ok_message.append(_('Items edited OK'))
888     def editCSVPermission(self):
889         ''' Determine whether the user has permission to edit this class.
891             Base behaviour is to check the user can edit this class.
892         ''' 
893         if not self.db.security.hasPermission('Edit', self.userid,
894                 self.classname):
895             return 0
896         return 1
898     def searchAction(self):
899         ''' Mangle some of the form variables.
901             Set the form ":filter" variable based on the values of the
902             filter variables - if they're set to anything other than
903             "dontcare" then add them to :filter.
905             Also handle the ":queryname" variable and save off the query to
906             the user's query list.
907         '''
908         # generic edit is per-class only
909         if not self.searchPermission():
910             self.error_message.append(
911                 _('You do not have permission to search %s' %self.classname))
913         # add a faked :filter form variable for each filtering prop
914 # XXX migrate to new : @ + 
915         props = self.db.classes[self.classname].getprops()
916         for key in self.form.keys():
917             if not props.has_key(key): continue
918             if isinstance(self.form[key], type([])):
919                 # search for at least one entry which is not empty
920                 for minifield in self.form[key]:
921                     if minifield.value:
922                         break
923                 else:
924                     continue
925             else:
926                 if not self.form[key].value: continue
927             self.form.value.append(cgi.MiniFieldStorage(':filter', key))
929         # handle saving the query params
930         if self.form.has_key(':queryname'):
931             queryname = self.form[':queryname'].value.strip()
932             if queryname:
933                 # parse the environment and figure what the query _is_
934                 req = HTMLRequest(self)
935                 url = req.indexargs_href('', {})
937                 # handle editing an existing query
938                 try:
939                     qid = self.db.query.lookup(queryname)
940                     self.db.query.set(qid, klass=self.classname, url=url)
941                 except KeyError:
942                     # create a query
943                     qid = self.db.query.create(name=queryname,
944                         klass=self.classname, url=url)
946                     # and add it to the user's query multilink
947                     queries = self.db.user.get(self.userid, 'queries')
948                     queries.append(qid)
949                     self.db.user.set(self.userid, queries=queries)
951                 # commit the query change to the database
952                 self.db.commit()
954     def searchPermission(self):
955         ''' Determine whether the user has permission to search this class.
957             Base behaviour is to check the user can view this class.
958         ''' 
959         if not self.db.security.hasPermission('View', self.userid,
960                 self.classname):
961             return 0
962         return 1
965     def retireAction(self):
966         ''' Retire the context item.
967         '''
968         # if we want to view the index template now, then unset the nodeid
969         # context info (a special-case for retire actions on the index page)
970         nodeid = self.nodeid
971         if self.template == 'index':
972             self.nodeid = None
974         # generic edit is per-class only
975         if not self.retirePermission():
976             self.error_message.append(
977                 _('You do not have permission to retire %s' %self.classname))
978             return
980         # make sure we don't try to retire admin or anonymous
981         if self.classname == 'user' and \
982                 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
983             self.error_message.append(
984                 _('You may not retire the admin or anonymous user'))
985             return
987         # do the retire
988         self.db.getclass(self.classname).retire(nodeid)
989         self.db.commit()
991         self.ok_message.append(
992             _('%(classname)s %(itemid)s has been retired')%{
993                 'classname': self.classname.capitalize(), 'itemid': nodeid})
995     def retirePermission(self):
996         ''' Determine whether the user has permission to retire this class.
998             Base behaviour is to check the user can edit this class.
999         ''' 
1000         if not self.db.security.hasPermission('Edit', self.userid,
1001                 self.classname):
1002             return 0
1003         return 1
1006     def showAction(self):
1007         ''' Show a node
1008         '''
1009 # XXX allow : @ +
1010         t = self.form[':type'].value
1011         n = self.form[':number'].value
1012         url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1013         raise Redirect, url
1016     #
1017     #  Utility methods for editing
1018     #
1019     def _editnodes(self, all_props, all_links, newids=None):
1020         ''' Use the props in all_props to perform edit and creation, then
1021             use the link specs in all_links to do linking.
1022         '''
1023         m = []
1024         if newids is None:
1025             newids = {}
1026         for (cn, nodeid), props in all_props.items():
1027             if int(nodeid) > 0:
1028                 # make changes to the node
1029                 props = self._changenode(cn, nodeid, props)
1031                 # and some nice feedback for the user
1032                 if props:
1033                     info = ', '.join(props.keys())
1034                     m.append('%s %s %s edited ok'%(cn, nodeid, info))
1035                 else:
1036                     m.append('%s %s - nothing changed'%(cn, nodeid))
1037             elif props:
1038                 # make a new node
1039                 newid = self._createnode(cn, props)
1040                 newids[(cn, nodeid)] = newid
1041                 nodeid = newid
1043                 # and some nice feedback for the user
1044                 m.append('%s %s created'%(cn, newid))
1046         # handle linked nodes
1047         keys = self.form.keys()
1048         for cn, nodeid, propname, value in all_links:
1049             cl = self.db.classes[cn]
1050             property = cl.getprops()[propname]
1051             if nodeid is None or nodeid.startswith('-'):
1052                 if not newids.has_key((cn, nodeid)):
1053                     continue
1054                 nodeid = newids[(cn, nodeid)]
1056             # map the desired classnames to their actual created ids
1057             for link in value:
1058                 if not newids.has_key(link):
1059                     continue
1060                 linkid = newids[link]
1061                 if isinstance(property, hyperdb.Multilink):
1062                     # take a dupe of the list so we're not changing the cache
1063                     existing = cl.get(nodeid, propname)[:]
1064                     existing.append(linkid)
1065                     cl.set(nodeid, **{propname: existing})
1066                 elif isinstance(property, hyperdb.Link):
1067                     # make the Link set
1068                     cl.set(nodeid, **{propname: linkid})
1069                 else:
1070                     raise ValueError, '%s %s is not a link or multilink '\
1071                         'property'%(cn, propname)
1072                 m.append('%s %s linked to <a href="%s%s">%s %s</a>'%(
1073                     link[0], linkid, cn, nodeid, cn, nodeid))
1075         return '<br>'.join(m)
1077     def _changenode(self, cn, nodeid, props):
1078         ''' change the node based on the contents of the form
1079         '''
1080         # check for permission
1081         if not self.editItemPermission(props):
1082             raise PermissionError, 'You do not have permission to edit %s'%cn
1084         # make the changes
1085         cl = self.db.classes[cn]
1086         return cl.set(nodeid, **props)
1088     def _createnode(self, cn, props):
1089         ''' create a node based on the contents of the form
1090         '''
1091         # check for permission
1092         if not self.newItemPermission(props):
1093             raise PermissionError, 'You do not have permission to create %s'%cn
1095         # create the node and return its id
1096         cl = self.db.classes[cn]
1097         return cl.create(**props)
1099     def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1100         ''' Pull properties for the given class out of the form.
1102             In the following, <bracketed> values are variable, ":" may be
1103             one of ":" or "@", and other text "required" is fixed.
1105             Properties are specified as form variables
1106              <designator>:<propname>
1108             where the propery belongs to the context class or item if the
1109             designator is not specified. The designator may specify a
1110             negative item id value (ie. "issue-1") and a new item of the
1111             specified class will be created for each negative id found.
1113             If a "<designator>:required" parameter is supplied,
1114             then the named property values must be supplied or a
1115             ValueError will be raised.
1117             Other special form values:
1118              [classname|designator]:remove:<propname>=id(s)
1119               The ids will be removed from the multilink property.
1120              [classname|designator]:add:<propname>=id(s)
1121               The ids will be added to the multilink property.
1123              [classname|designator]:link:<propname>=<classname>
1124               Used to add a link to new items created during edit.
1125               These are collected up and returned in all_links. This will
1126               result in an additional linking operation (either Link set or
1127               Multilink append) after the edit/create is done using
1128               all_props in _editnodes. The <propname> on
1129               [classname|designator] will be set/appended the id of the
1130               newly created item of class <classname>.
1132             Note: the colon may be either ":" or "@".
1134             Any of the form variables may be prefixed with a classname or
1135             designator.
1137             The return from this method is a dict of 
1138                 (classname, id): properties
1139             ... this dict _always_ has an entry for the current context,
1140             even if it's empty (ie. a submission for an existing issue that
1141             doesn't result in any changes would return {('issue','123'): {}})
1142             The id may be None, which indicates that an item should be
1143             created.
1145             If a String property's form value is a file upload, then we
1146             try to set additional properties "filename" and "type" (if
1147             they are valid for the class).
1149             Two special form values are supported for backwards
1150             compatibility:
1151              :note - create a message (with content, author and date), link
1152                      to the context item
1153              :file - create a file, attach to the current item and any
1154                      message created by :note
1155         '''
1156         # some very useful variables
1157         db = self.db
1158         form = self.form
1160         if not hasattr(self, 'FV_ITEMSPEC'):
1161             # generate the regexp for detecting
1162             # <classname|designator>[@:+]property
1163             classes = '|'.join(db.classes.keys())
1164             self.FV_ITEMSPEC = re.compile(r'(%s)([-\d]+)[@:](.+)$'%classes)
1165             self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1167         # these indicate the default class / item
1168         default_cn = self.classname
1169         default_cl = self.db.classes[default_cn]
1170         default_nodeid = self.nodeid
1172         # we'll store info about the individual class/item edit in these
1173         all_required = {}       # one entry per class/item
1174         all_props = {}          # one entry per class/item
1175         all_propdef = {}        # note - only one entry per class
1176         all_links = []          # as many as are required
1178         # we should always return something, even empty, for the context
1179         all_props[(default_cn, default_nodeid)] = {}
1181         keys = form.keys()
1182         timezone = db.getUserTimezone()
1184         for key in keys:
1185             # see if this value modifies a different item to the default
1186             m = self.FV_ITEMSPEC.match(key)
1187             if m:
1188                 # we got a designator
1189                 cn = m.group(1)
1190                 cl = self.db.classes[cn]
1191                 nodeid = m.group(2)
1192                 propname = m.group(3)
1193             elif key == ':note':
1194                 # backwards compatibility: the special note field
1195                 cn = 'msg'
1196                 cl = self.db.classes[cn]
1197                 nodeid = '-1'
1198                 propname = 'content'
1199                 all_links.append((default_cn, default_nodeid, 'messages',
1200                     [('msg', '-1')]))
1201             elif key == ':file':
1202                 # backwards compatibility: the special file field
1203                 cn = 'file'
1204                 cl = self.db.classes[cn]
1205                 nodeid = '-1'
1206                 propname = 'content'
1207                 all_links.append((default_cn, default_nodeid, 'files',
1208                     [('file', '-1')]))
1209                 if self.form.has_key(':note'):
1210                     all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1211             else:
1212                 # default
1213                 cn = default_cn
1214                 cl = default_cl
1215                 nodeid = default_nodeid
1216                 propname = key
1218             # the thing this value relates to is...
1219             this = (cn, nodeid)
1221             # is this a link command?
1222             if self.FV_LINK.match(propname):
1223                 value = []
1224                 for entry in extractFormList(form[key]):
1225                     m = self.FV_DESIGNATOR.match(entry)
1226                     if not m:
1227                         raise ValueError, \
1228                             'link "%s" value "%s" not a designator'%(key, entry)
1229                     value.append((m.groups(1), m.groups(2)))
1230                 all_links.append((cn, nodeid, propname[6:], value))
1232             # get more info about the class, and the current set of
1233             # form props for it
1234             if not all_propdef.has_key(cn):
1235                 all_propdef[cn] = cl.getprops()
1236             propdef = all_propdef[cn]
1237             if not all_props.has_key(this):
1238                 all_props[this] = {}
1239             props = all_props[this]
1241             # detect the special ":required" variable
1242             if self.FV_REQUIRED.match(key):
1243                 all_required[this] = extractFormList(form[key])
1244                 continue
1246             # get the required values list
1247             if not all_required.has_key(this):
1248                 all_required[this] = []
1249             required = all_required[this]
1251             # see if we're performing a special multilink action
1252             mlaction = 'set'
1253             if self.FV_REMOVE.match(propname):
1254                 propname = propname[8:]
1255                 mlaction = 'remove'
1256             elif self.FV_ADD.match(propname):
1257                 propname = propname[5:]
1258                 mlaction = 'add'
1260             # does the property exist?
1261             if not propdef.has_key(propname):
1262                 if mlaction != 'set':
1263                     raise ValueError, 'You have submitted a %s action for'\
1264                         ' the property "%s" which doesn\'t exist'%(mlaction,
1265                         propname)
1266                 continue
1267             proptype = propdef[propname]
1269             # Get the form value. This value may be a MiniFieldStorage or a list
1270             # of MiniFieldStorages.
1271             value = form[key]
1273             # handle unpacking of the MiniFieldStorage / list form value
1274             if isinstance(proptype, hyperdb.Multilink):
1275                 value = extractFormList(value)
1276             else:
1277                 # multiple values are not OK
1278                 if isinstance(value, type([])):
1279                     raise ValueError, 'You have submitted more than one value'\
1280                         ' for the %s property'%propname
1281                 # value might be a file upload...
1282                 if not hasattr(value, 'filename') or value.filename is None:
1283                     # nope, pull out the value and strip it
1284                     value = value.value.strip()
1286             # now that we have the props field, we need a teensy little
1287             # extra bit of help for the old :note field...
1288             if key == ':note' and value:
1289                 props['author'] = self.db.getuid()
1290                 props['date'] = date.Date()
1292             # handle by type now
1293             if isinstance(proptype, hyperdb.Password):
1294                 if not value:
1295                     # ignore empty password values
1296                     continue
1297                 for key in keys:
1298                     if self.FV_CONFIRM.match(key):
1299                         confirm = form[key]
1300                         break
1301                 else:
1302                     raise ValueError, 'Password and confirmation text do '\
1303                         'not match'
1304                 if isinstance(confirm, type([])):
1305                     raise ValueError, 'You have submitted more than one value'\
1306                         ' for the %s property'%propname
1307                 if value != confirm.value:
1308                     raise ValueError, 'Password and confirmation text do '\
1309                         'not match'
1310                 value = password.Password(value)
1312             elif isinstance(proptype, hyperdb.Link):
1313                 # see if it's the "no selection" choice
1314                 if value == '-1' or not value:
1315                     # if we're creating, just don't include this property
1316                     if not nodeid or nodeid.startswith('-'):
1317                         continue
1318                     value = None
1319                 else:
1320                     # handle key values
1321                     link = proptype.classname
1322                     if not num_re.match(value):
1323                         try:
1324                             value = db.classes[link].lookup(value)
1325                         except KeyError:
1326                             raise ValueError, _('property "%(propname)s": '
1327                                 '%(value)s not a %(classname)s')%{
1328                                 'propname': propname, 'value': value,
1329                                 'classname': link}
1330                         except TypeError, message:
1331                             raise ValueError, _('you may only enter ID values '
1332                                 'for property "%(propname)s": %(message)s')%{
1333                                 'propname': propname, 'message': message}
1334             elif isinstance(proptype, hyperdb.Multilink):
1335                 # perform link class key value lookup if necessary
1336                 link = proptype.classname
1337                 link_cl = db.classes[link]
1338                 l = []
1339                 for entry in value:
1340                     if not entry: continue
1341                     if not num_re.match(entry):
1342                         try:
1343                             entry = link_cl.lookup(entry)
1344                         except KeyError:
1345                             raise ValueError, _('property "%(propname)s": '
1346                                 '"%(value)s" not an entry of %(classname)s')%{
1347                                 'propname': propname, 'value': entry,
1348                                 'classname': link}
1349                         except TypeError, message:
1350                             raise ValueError, _('you may only enter ID values '
1351                                 'for property "%(propname)s": %(message)s')%{
1352                                 'propname': propname, 'message': message}
1353                     l.append(entry)
1354                 l.sort()
1356                 # now use that list of ids to modify the multilink
1357                 if mlaction == 'set':
1358                     value = l
1359                 else:
1360                     # we're modifying the list - get the current list of ids
1361                     if props.has_key(propname):
1362                         existing = props[propname]
1363                     elif nodeid and not nodeid.startswith('-'):
1364                         existing = cl.get(nodeid, propname, [])
1365                     else:
1366                         existing = []
1368                     # now either remove or add
1369                     if mlaction == 'remove':
1370                         # remove - handle situation where the id isn't in
1371                         # the list
1372                         for entry in l:
1373                             try:
1374                                 existing.remove(entry)
1375                             except ValueError:
1376                                 raise ValueError, _('property "%(propname)s": '
1377                                     '"%(value)s" not currently in list')%{
1378                                     'propname': propname, 'value': entry}
1379                     else:
1380                         # add - easy, just don't dupe
1381                         for entry in l:
1382                             if entry not in existing:
1383                                 existing.append(entry)
1384                     value = existing
1385                     value.sort()
1387             elif value == '':
1388                 # if we're creating, just don't include this property
1389                 if not nodeid or nodeid.startswith('-'):
1390                     continue
1391                 # other types should be None'd if there's no value
1392                 value = None
1393             else:
1394                 if isinstance(proptype, hyperdb.String):
1395                     if (hasattr(value, 'filename') and
1396                             value.filename is not None):
1397                         # skip if the upload is empty
1398                         if not value.filename:
1399                             continue
1400                         # this String is actually a _file_
1401                         # try to determine the file content-type
1402                         filename = value.filename.split('\\')[-1]
1403                         if propdef.has_key('name'):
1404                             props['name'] = filename
1405                         # use this info as the type/filename properties
1406                         if propdef.has_key('type'):
1407                             props['type'] = mimetypes.guess_type(filename)[0]
1408                             if not props['type']:
1409                                 props['type'] = "application/octet-stream"
1410                         # finally, read the content
1411                         value = value.value
1412                     else:
1413                         # normal String fix the CRLF/CR -> LF stuff
1414                         value = fixNewlines(value)
1416                 elif isinstance(proptype, hyperdb.Date):
1417                     value = date.Date(value, offset=timezone)
1418                 elif isinstance(proptype, hyperdb.Interval):
1419                     value = date.Interval(value)
1420                 elif isinstance(proptype, hyperdb.Boolean):
1421                     value = value.lower() in ('yes', 'true', 'on', '1')
1422                 elif isinstance(proptype, hyperdb.Number):
1423                     value = float(value)
1425             # get the old value
1426             if nodeid and not nodeid.startswith('-'):
1427                 try:
1428                     existing = cl.get(nodeid, propname)
1429                 except KeyError:
1430                     # this might be a new property for which there is
1431                     # no existing value
1432                     if not propdef.has_key(propname):
1433                         raise
1435                 # make sure the existing multilink is sorted
1436                 if isinstance(proptype, hyperdb.Multilink):
1437                     existing.sort()
1439                 # "missing" existing values may not be None
1440                 if not existing:
1441                     if isinstance(proptype, hyperdb.String) and not existing:
1442                         # some backends store "missing" Strings as empty strings
1443                         existing = None
1444                     elif isinstance(proptype, hyperdb.Number) and not existing:
1445                         # some backends store "missing" Numbers as 0 :(
1446                         existing = 0
1447                     elif isinstance(proptype, hyperdb.Boolean) and not existing:
1448                         # likewise Booleans
1449                         existing = 0
1451                 # if changed, set it
1452                 if value != existing:
1453                     props[propname] = value
1454             else:
1455                 # don't bother setting empty/unset values
1456                 if value is None:
1457                     continue
1458                 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1459                     continue
1460                 elif isinstance(proptype, hyperdb.String) and value == '':
1461                     continue
1463                 props[propname] = value
1465             # register this as received if required?
1466             if propname in required and value is not None:
1467                 required.remove(propname)
1469         # see if all the required properties have been supplied
1470         s = []
1471         for thing, required in all_required.items():
1472             if not required:
1473                 continue
1474             if len(required) > 1:
1475                 p = 'properties'
1476             else:
1477                 p = 'property'
1478             s.append('Required %s %s %s not supplied'%(thing[0], p,
1479                 ', '.join(required)))
1480         if s:
1481             raise ValueError, '\n'.join(s)
1483         return all_props, all_links
1485 def fixNewlines(text):
1486     ''' Homogenise line endings.
1488         Different web clients send different line ending values, but
1489         other systems (eg. email) don't necessarily handle those line
1490         endings. Our solution is to convert all line endings to LF.
1491     '''
1492     text = text.replace('\r\n', '\n')
1493     return text.replace('\r', '\n')
1495 def extractFormList(value):
1496     ''' Extract a list of values from the form value.
1498         It may be one of:
1499          [MiniFieldStorage, MiniFieldStorage, ...]
1500          MiniFieldStorage('value,value,...')
1501          MiniFieldStorage('value')
1502     '''
1503     # multiple values are OK
1504     if isinstance(value, type([])):
1505         # it's a list of MiniFieldStorages
1506         value = [i.value.strip() for i in value]
1507     else:
1508         # it's a MiniFieldStorage, but may be a comma-separated list
1509         # of values
1510         value = [i.strip() for i in value.value.split(',')]
1512     # filter out the empty bits
1513     return filter(None, value)