Code

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