Code

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