Code

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