Code

reinstated registration, cleaned up PT compile error reporting
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.5 2002-09-01 23:57:53 richard Exp $
3 __doc__ = """
4 WWW request handler (also used in the stand-alone server).
5 """
7 import os, 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 RoundupPageTemplate
14 from roundup.cgi import cgitb
15 from PageTemplates import PageTemplate
17 class Unauthorised(ValueError):
18     pass
20 class NotFound(ValueError):
21     pass
23 class Redirect(Exception):
24     pass
26 class SendFile(Exception):
27     ' Sent a file from the database '
29 class SendStaticFile(Exception):
30     ' Send a static file from the instance html directory '
32 def initialiseSecurity(security):
33     ''' Create some Permissions and Roles on the security object
35         This function is directly invoked by security.Security.__init__()
36         as a part of the Security object instantiation.
37     '''
38     security.addPermission(name="Web Registration",
39         description="User may register through the web")
40     p = security.addPermission(name="Web Access",
41         description="User may access the web interface")
42     security.addPermissionToRole('Admin', p)
44     # doing Role stuff through the web - make sure Admin can
45     p = security.addPermission(name="Web Roles",
46         description="User may manipulate user Roles through the web")
47     security.addPermissionToRole('Admin', p)
49 class Client:
50     '''
51     A note about login
52     ------------------
54     If the user has no login cookie, then they are anonymous. There
55     are two levels of anonymous use. If there is no 'anonymous' user, there
56     is no login at all and the database is opened in read-only mode. If the
57     'anonymous' user exists, the user is logged in using that user (though
58     there is no cookie). This allows them to modify the database, and all
59     modifications are attributed to the 'anonymous' user.
61     Once a user logs in, they are assigned a session. The Client instance
62     keeps the nodeid of the session as the "session" attribute.
64     Client attributes:
65         "url" is the current url path
66         "path" is the PATH_INFO inside the instance
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         self.path = env['PATH_INFO']
77         self.split_path = self.path.split('/')
78         self.instance_path_name = env['INSTANCE_NAME']
80         # this is the base URL for this instance
81         url = self.env['SCRIPT_NAME'] + '/' + self.instance_path_name
82         self.base = urlparse.urlunparse(('http', env['HTTP_HOST'], url,
83             None, None, None))
85         # request.path is the full request path
86         x, x, path, x, x, x = urlparse.urlparse(request.path)
87         self.url = urlparse.urlunparse(('http', env['HTTP_HOST'], path,
88             None, None, None))
90         if form is None:
91             self.form = cgi.FieldStorage(environ=env)
92         else:
93             self.form = form
94         self.headers_done = 0
95         try:
96             self.debug = int(env.get("ROUNDUP_DEBUG", 0))
97         except ValueError:
98             # someone gave us a non-int debug level, turn it off
99             self.debug = 0
101     def main(self):
102         ''' Wrap the request and handle unauthorised requests
103         '''
104         self.content_action = None
105         self.ok_message = []
106         self.error_message = []
107         try:
108             # make sure we're identified (even anonymously)
109             self.determine_user()
110             # figure out the context and desired content template
111             self.determine_context()
112             # possibly handle a form submit action (may change self.message
113             # and self.template_name)
114             self.handle_action()
115             # now render the page
116             self.write(self.template('page', ok_message=self.ok_message,
117                 error_message=self.error_message))
118         except Redirect, url:
119             # let's redirect - if the url isn't None, then we need to do
120             # the headers, otherwise the headers have been set before the
121             # exception was raised
122             if url:
123                 self.header({'Location': url}, response=302)
124         except SendFile, designator:
125             self.serve_file(designator)
126         except SendStaticFile, file:
127             self.serve_static_file(file)
128         except Unauthorised, message:
129             self.write(self.template('page.unauthorised',
130                 error_message=message))
131         except:
132             # everything else
133             self.write(cgitb.html())
135     def determine_user(self):
136         ''' Determine who the user is
137         '''
138         # determine the uid to use
139         self.opendb('admin')
141         # make sure we have the session Class
142         sessions = self.db.sessions
144         # age sessions, remove when they haven't been used for a week
145         # TODO: this shouldn't be done every access
146         week = 60*60*24*7
147         now = time.time()
148         for sessid in sessions.list():
149             interval = now - sessions.get(sessid, 'last_use')
150             if interval > week:
151                 sessions.destroy(sessid)
153         # look up the user session cookie
154         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
155         user = 'anonymous'
157         if (cookie.has_key('roundup_user') and
158                 cookie['roundup_user'].value != 'deleted'):
160             # get the session key from the cookie
161             self.session = cookie['roundup_user'].value
162             # get the user from the session
163             try:
164                 # update the lifetime datestamp
165                 sessions.set(self.session, last_use=time.time())
166                 sessions.commit()
167                 user = sessions.get(self.session, 'user')
168             except KeyError:
169                 user = 'anonymous'
171         # sanity check on the user still being valid, getting the userid
172         # at the same time
173         try:
174             self.userid = self.db.user.lookup(user)
175         except (KeyError, TypeError):
176             user = 'anonymous'
178         # make sure the anonymous user is valid if we're using it
179         if user == 'anonymous':
180             self.make_user_anonymous()
181         else:
182             self.user = user
184         # reopen the database as the correct user
185         self.opendb(self.user)
187     def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
188         ''' Determine the context of this page:
190              home              (default if no url is given)
191              classname
192              designator        (classname and nodeid)
194             The desired template to be rendered is also determined There
195             are two exceptional contexts:
197              _file            - serve up a static file
198              path len > 1     - serve up a FileClass content
199                                 (the additional path gives the browser a
200                                  nicer filename to save as)
202             The template used is specified by the :template CGI variable,
203             which defaults to:
204              only classname suplied:          "index"
205              full item designator supplied:   "item"
207             We set:
208              self.classname
209              self.nodeid
210              self.template_name
211         '''
212         # default the optional variables
213         self.classname = None
214         self.nodeid = None
216         # determine the classname and possibly nodeid
217         path = self.split_path
218         if not path or path[0] in ('', 'home', 'index'):
219             if self.form.has_key(':template'):
220                 self.template_type = self.form[':template'].value
221                 self.template_name = 'home' + '.' + self.template_type
222             else:
223                 self.template_type = ''
224                 self.template_name = 'home'
225             return
226         elif path[0] == '_file':
227             raise SendStaticFile, path[1]
228         else:
229             self.classname = path[0]
230             if len(path) > 1:
231                 # send the file identified by the designator in path[0]
232                 raise SendFile, path[0]
234         # see if we got a designator
235         m = dre.match(self.classname)
236         if m:
237             self.classname = m.group(1)
238             self.nodeid = m.group(2)
239             # with a designator, we default to item view
240             self.template_type = 'item'
241         else:
242             # with only a class, we default to index view
243             self.template_type = 'index'
245         # see if we have a template override
246         if self.form.has_key(':template'):
247             self.template_type = self.form[':template'].value
250         # see if we were passed in a message
251         if self.form.has_key(':ok_message'):
252             self.ok_message.append(self.form[':ok_message'].value)
253         if self.form.has_key(':error_message'):
254             self.error_message.append(self.form[':error_message'].value)
256         # we have the template name now
257         self.template_name = self.classname + '.' + self.template_type
259     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
260         ''' Serve the file from the content property of the designated item.
261         '''
262         m = dre.match(str(designator))
263         if not m:
264             raise NotFound, str(designator)
265         classname, nodeid = m.group(1), m.group(2)
266         if classname != 'file':
267             raise NotFound, designator
269         # we just want to serve up the file named
270         file = self.db.file
271         self.header({'Content-Type': file.get(nodeid, 'type')})
272         self.write(file.get(nodeid, 'content'))
274     def serve_static_file(self, file):
275         # we just want to serve up the file named
276         mt = mimetypes.guess_type(str(file))[0]
277         self.header({'Content-Type': mt})
278         self.write(open('/tmp/test/html/%s'%file).read())
280     def template(self, name, **kwargs):
281         ''' Return a PageTemplate for the named page
282         '''
283         pt = RoundupPageTemplate(self)
284         # make errors nicer
285         pt.id = name
286         pt.write(open('/tmp/test/html/%s'%name).read())
287         # XXX handle PT rendering errors here nicely
288         try:
289             return pt.render(**kwargs)
290         except PageTemplate.PTRuntimeError, message:
291             return '<strong>%s</strong><ol>%s</ol>'%(message,
292                 '<li>'.join(pt._v_errors))
293         except:
294             # everything else
295             return cgitb.html()
297     def content(self):
298         ''' Callback used by the page template to render the content of 
299             the page.
300         '''
301         # now render the page content using the template we determined in
302         # determine_context
303         return self.template(self.template_name)
305     # these are the actions that are available
306     actions = {
307         'edit':     'editItemAction',
308         'new':      'newItemAction',
309         'register': 'registerAction',
310         'login':    'login_action',
311         'logout':   'logout_action',
312         'search':   'searchAction',
313     }
314     def handle_action(self):
315         ''' Determine whether there should be an _action called.
317             The action is defined by the form variable :action which
318             identifies the method on this object to call. The four basic
319             actions are defined in the "actions" dictionary on this class:
320              "edit"      -> self.editItemAction
321              "new"       -> self.newItemAction
322              "register"  -> self.registerAction
323              "login"     -> self.login_action
324              "logout"    -> self.logout_action
325              "search"    -> self.searchAction
327         '''
328         if not self.form.has_key(':action'):
329             return None
330         try:
331             # get the action, validate it
332             action = self.form[':action'].value
333             if not self.actions.has_key(action):
334                 raise ValueError, 'No such action "%s"'%action
336             # call the mapped action
337             getattr(self, self.actions[action])()
338         except Redirect:
339             raise
340         except:
341             self.db.rollback()
342             s = StringIO.StringIO()
343             traceback.print_exc(None, s)
344             self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
346     def write(self, content):
347         if not self.headers_done:
348             self.header()
349         self.request.wfile.write(content)
351     def header(self, headers=None, response=200):
352         '''Put up the appropriate header.
353         '''
354         if headers is None:
355             headers = {'Content-Type':'text/html'}
356         if not headers.has_key('Content-Type'):
357             headers['Content-Type'] = 'text/html'
358         self.request.send_response(response)
359         for entry in headers.items():
360             self.request.send_header(*entry)
361         self.request.end_headers()
362         self.headers_done = 1
363         if self.debug:
364             self.headers_sent = headers
366     def set_cookie(self, user, password):
367         # TODO generate a much, much stronger session key ;)
368         self.session = binascii.b2a_base64(repr(time.time())).strip()
370         # clean up the base64
371         if self.session[-1] == '=':
372             if self.session[-2] == '=':
373                 self.session = self.session[:-2]
374             else:
375                 self.session = self.session[:-1]
377         # insert the session in the sessiondb
378         self.db.sessions.set(self.session, user=user, last_use=time.time())
380         # and commit immediately
381         self.db.sessions.commit()
383         # expire us in a long, long time
384         expire = Cookie._getdate(86400*365)
386         # generate the cookie path - make sure it has a trailing '/'
387         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
388             ''))
389         self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;'%(
390             self.session, expire, path)})
392     def make_user_anonymous(self):
393         ''' Make us anonymous
395             This method used to handle non-existence of the 'anonymous'
396             user, but that user is mandatory now.
397         '''
398         self.userid = self.db.user.lookup('anonymous')
399         self.user = 'anonymous'
401     def logout(self):
402         ''' Make us really anonymous - nuke the cookie too
403         '''
404         self.make_user_anonymous()
406         # construct the logout cookie
407         now = Cookie._getdate()
408         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
409             ''))
410         self.header({'Set-Cookie':
411             'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
412             path)})
413         self.login()
415     def opendb(self, user):
416         ''' Open the database.
417         '''
418         # open the db if the user has changed
419         if not hasattr(self, 'db') or user != self.db.journaltag:
420             self.db = self.instance.open(user)
422     #
423     # Actions
424     #
425     def login_action(self):
426         ''' Attempt to log a user in and set the cookie
427         '''
428         # we need the username at a minimum
429         if not self.form.has_key('__login_name'):
430             self.error_message.append(_('Username required'))
431             return
433         self.user = self.form['__login_name'].value
434         # re-open the database for real, using the user
435         self.opendb(self.user)
436         if self.form.has_key('__login_password'):
437             password = self.form['__login_password'].value
438         else:
439             password = ''
440         # make sure the user exists
441         try:
442             self.userid = self.db.user.lookup(self.user)
443         except KeyError:
444             name = self.user
445             self.make_user_anonymous()
446             self.error_message.append(_('No such user "%(name)s"')%locals())
447             return
449         # and that the password is correct
450         pw = self.db.user.get(self.userid, 'password')
451         if password != pw:
452             self.make_user_anonymous()
453             self.error_message.append(_('Incorrect password'))
454             return
456         # set the session cookie
457         self.set_cookie(self.user, password)
459     def logout_action(self):
460         ''' Make us really anonymous - nuke the cookie too
461         '''
462         # log us out
463         self.make_user_anonymous()
465         # construct the logout cookie
466         now = Cookie._getdate()
467         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
468             ''))
469         self.header(headers={'Set-Cookie':
470           'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)})
472         # Let the user know what's going on
473         self.ok_message.append(_('You are logged out'))
475     def registerAction(self):
476         '''Attempt to create a new user based on the contents of the form
477         and then set the cookie.
479         return 1 on successful login
480         '''
481         # create the new user
482         cl = self.db.user
484         # parse the props from the form
485         try:
486             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
487         except (ValueError, KeyError), message:
488             self.error_message.append(_('Error: ') + str(message))
489             return
491         # make sure we're allowed to register
492         if not self.registerPermission(props):
493             raise Unauthorised, _("You do not have permission to register")
495         # re-open the database as "admin"
496         if self.user != 'admin':
497             self.opendb('admin')
498             
499         # create the new user
500         cl = self.db.user
501         try:
502             props = parsePropsFromForm(self.db, cl, self.form)
503             props['roles'] = self.instance.NEW_WEB_USER_ROLES
504             self.userid = cl.create(**props)
505             self.db.commit()
506         except ValueError, message:
507             self.error_message.append(message)
509         # log the new user in
510         self.user = cl.get(self.userid, 'username')
511         # re-open the database for real, using the user
512         self.opendb(self.user)
513         password = self.db.user.get(self.userid, 'password')
514         self.set_cookie(self.user, password)
516         # nice message
517         self.ok_message.append(_('You are now registered, welcome!'))
519     def registerPermission(self, props):
520         ''' Determine whether the user has permission to register
522             Base behaviour is to check the user has "Web Registration".
523         '''
524         # registration isn't allowed to supply roles
525         if props.has_key('roles'):
526             return 0
527         if self.db.security.hasPermission('Web Registration', self.userid):
528             return 1
529         return 0
531     def editItemAction(self):
532         ''' Perform an edit of an item in the database.
534             Some special form elements:
536             :link=designator:property
537             :multilink=designator:property
538              The value specifies a node designator and the property on that
539              node to add _this_ node to as a link or multilink.
540             __note
541              Create a message and attach it to the current node's
542              "messages" property.
543             __file
544              Create a file and attach it to the current node's
545              "files" property. Attach the file to the message created from
546              the __note if it's supplied.
547         '''
548         cl = self.db.classes[self.classname]
550         # parse the props from the form
551         try:
552             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
553         except (ValueError, KeyError), message:
554             self.error_message.append(_('Error: ') + str(message))
555             return
557         # check permission
558         if not self.editItemPermission(props):
559             self.error_message.append(
560                 _('You do not have permission to edit %(classname)s'%
561                 self.__dict__))
562             return
564         # perform the edit
565         try:
566             # make changes to the node
567             props = self._changenode(props)
568             # handle linked nodes 
569             self._post_editnode(self.nodeid)
570         except (ValueError, KeyError), message:
571             self.error_message.append(_('Error: ') + str(message))
572             return
574         # commit now that all the tricky stuff is done
575         self.db.commit()
577         # and some nice feedback for the user
578         if props:
579             message = _('%(changes)s edited ok')%{'changes':
580                 ', '.join(props.keys())}
581         elif self.form.has_key('__note') and self.form['__note'].value:
582             message = _('note added')
583         elif (self.form.has_key('__file') and self.form['__file'].filename):
584             message = _('file added')
585         else:
586             message = _('nothing changed')
588         # redirect to the item's edit page
589         raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
590             self.nodeid,  urllib.quote(message))
592     def editItemPermission(self, props):
593         ''' Determine whether the user has permission to edit this item.
595             Base behaviour is to check the user can edit this class. If we're
596             editing the "user" class, users are allowed to edit their own
597             details. Unless it's the "roles" property, which requires the
598             special Permission "Web Roles".
599         '''
600         # if this is a user node and the user is editing their own node, then
601         # we're OK
602         has = self.db.security.hasPermission
603         if self.classname == 'user':
604             # reject if someone's trying to edit "roles" and doesn't have the
605             # right permission.
606             if props.has_key('roles') and not has('Web Roles', self.userid,
607                     'user'):
608                 return 0
609             # if the item being edited is the current user, we're ok
610             if self.nodeid == self.userid:
611                 return 1
612         if self.db.security.hasPermission('Edit', self.userid, self.classname):
613             return 1
614         return 0
616     def newItemAction(self):
617         ''' Add a new item to the database.
619             This follows the same form as the editItemAction
620         '''
621         cl = self.db.classes[self.classname]
623         # parse the props from the form
624         try:
625             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
626         except (ValueError, KeyError), message:
627             self.error_message.append(_('Error: ') + str(message))
628             return
630         if not self.newItemPermission(props):
631             self.error_message.append(
632                 _('You do not have permission to create %s' %self.classname))
634         # XXX
635 #        cl = self.db.classes[cn]
636 #        if self.form.has_key(':multilink'):
637 #            link = self.form[':multilink'].value
638 #            designator, linkprop = link.split(':')
639 #            xtra = ' for <a href="%s">%s</a>' % (designator, designator)
640 #        else:
641 #            xtra = ''
643         try:
644             # do the create
645             nid = self._createnode(props)
647             # handle linked nodes 
648             self._post_editnode(nid)
650             # commit now that all the tricky stuff is done
651             self.db.commit()
653             # render the newly created item
654             self.nodeid = nid
656             # and some nice feedback for the user
657             message = _('%(classname)s created ok')%self.__dict__
658         except (ValueError, KeyError), message:
659             self.error_message.append(_('Error: ') + str(message))
660             return
661         except:
662             # oops
663             self.db.rollback()
664             s = StringIO.StringIO()
665             traceback.print_exc(None, s)
666             self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
667             return
669         # redirect to the new item's page
670         raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
671             nid,  urllib.quote(message))
673     def newItemPermission(self, props):
674         ''' Determine whether the user has permission to create (edit) this
675             item.
677             Base behaviour is to check the user can edit this class. No
678             additional property checks are made. Additionally, new user items
679             may be created if the user has the "Web Registration" Permission.
680         '''
681         has = self.db.security.hasPermission
682         if self.classname == 'user' and has('Web Registration', self.userid,
683                 'user'):
684             return 1
685         if has('Edit', self.userid, self.classname):
686             return 1
687         return 0
689     def genericEditAction(self):
690         ''' Performs an edit of all of a class' items in one go.
692             The "rows" CGI var defines the CSV-formatted entries for the
693             class. New nodes are identified by the ID 'X' (or any other
694             non-existent ID) and removed lines are retired.
695         '''
696         # generic edit is per-class only
697         if not self.genericEditPermission():
698             self.error_message.append(
699                 _('You do not have permission to edit %s' %self.classname))
701         # get the CSV module
702         try:
703             import csv
704         except ImportError:
705             self.error_message.append(_(
706                 'Sorry, you need the csv module to use this function.<br>\n'
707                 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
708             return
710         cl = self.db.classes[self.classname]
711         idlessprops = cl.getprops(protected=0).keys()
712         props = ['id'] + idlessprops
714         # do the edit
715         rows = self.form['rows'].value.splitlines()
716         p = csv.parser()
717         found = {}
718         line = 0
719         for row in rows:
720             line += 1
721             values = p.parse(row)
722             # not a complete row, keep going
723             if not values: continue
725             # extract the nodeid
726             nodeid, values = values[0], values[1:]
727             found[nodeid] = 1
729             # confirm correct weight
730             if len(idlessprops) != len(values):
731                 message=(_('Not enough values on line %(line)s'%{'line':line}))
732                 return
734             # extract the new values
735             d = {}
736             for name, value in zip(idlessprops, values):
737                 value = value.strip()
738                 # only add the property if it has a value
739                 if value:
740                     # if it's a multilink, split it
741                     if isinstance(cl.properties[name], hyperdb.Multilink):
742                         value = value.split(':')
743                     d[name] = value
745             # perform the edit
746             if cl.hasnode(nodeid):
747                 # edit existing
748                 cl.set(nodeid, **d)
749             else:
750                 # new node
751                 found[cl.create(**d)] = 1
753         # retire the removed entries
754         for nodeid in cl.list():
755             if not found.has_key(nodeid):
756                 cl.retire(nodeid)
758         message = _('items edited OK')
760         # redirect to the class' edit page
761         raise Redirect, '%s/%s?:ok_message=%s'%(self.base, self.classname, 
762             urllib.quote(message))
764     def genericEditPermission(self):
765         ''' Determine whether the user has permission to edit this class.
767             Base behaviour is to check the user can edit this class.
768         ''' 
769         if not self.db.security.hasPermission('Edit', self.userid,
770                 self.classname):
771             return 0
772         return 1
774     def searchAction(self):
775         ''' Mangle some of the form variables.
777             Set the form ":filter" variable based on the values of the
778             filter variables - if they're set to anything other than
779             "dontcare" then add them to :filter.
780         '''
781         # generic edit is per-class only
782         if not self.searchPermission():
783             self.error_message.append(
784                 _('You do not have permission to search %s' %self.classname))
786         # add a faked :filter form variable for each filtering prop
787         props = self.db.classes[self.classname].getprops()
788         for key in self.form.keys():
789             if not props.has_key(key): continue
790             if not self.form[key].value: continue
791             self.form.value.append(cgi.MiniFieldStorage(':filter', key))
793     def searchPermission(self):
794         ''' Determine whether the user has permission to search this class.
796             Base behaviour is to check the user can view this class.
797         ''' 
798         if not self.db.security.hasPermission('View', self.userid,
799                 self.classname):
800             return 0
801         return 1
803     def XXXremove_action(self,  dre=re.compile(r'([^\d]+)(\d+)')):
804         # XXX I believe this could be handled by a regular edit action that
805         # just sets the multilink...
806         # XXX handle this !
807         target = self.index_arg(':target')[0]
808         m = dre.match(target)
809         if m:
810             classname = m.group(1)
811             nodeid = m.group(2)
812             cl = self.db.getclass(classname)
813             cl.retire(nodeid)
814             # now take care of the reference
815             parentref =  self.index_arg(':multilink')[0]
816             parent, prop = parentref.split(':')
817             m = dre.match(parent)
818             if m:
819                 self.classname = m.group(1)
820                 self.nodeid = m.group(2)
821                 cl = self.db.getclass(self.classname)
822                 value = cl.get(self.nodeid, prop)
823                 value.remove(nodeid)
824                 cl.set(self.nodeid, **{prop:value})
825                 func = getattr(self, 'show%s'%self.classname)
826                 return func()
827             else:
828                 raise NotFound, parent
829         else:
830             raise NotFound, target
832     #
833     #  Utility methods for editing
834     #
835     def _changenode(self, props):
836         ''' change the node based on the contents of the form
837         '''
838         cl = self.db.classes[self.classname]
840         # create the message
841         message, files = self._handle_message()
842         if message:
843             props['messages'] = cl.get(self.nodeid, 'messages') + [message]
844         if files:
845             props['files'] = cl.get(self.nodeid, 'files') + files
847         # make the changes
848         return cl.set(self.nodeid, **props)
850     def _createnode(self, props):
851         ''' create a node based on the contents of the form
852         '''
853         cl = self.db.classes[self.classname]
855         # check for messages and files
856         message, files = self._handle_message()
857         if message:
858             props['messages'] = [message]
859         if files:
860             props['files'] = files
861         # create the node and return it's id
862         return cl.create(**props)
864     def _handle_message(self):
865         ''' generate an edit message
866         '''
867         # handle file attachments 
868         files = []
869         if self.form.has_key('__file'):
870             file = self.form['__file']
871             if file.filename:
872                 filename = file.filename.split('\\')[-1]
873                 mime_type = mimetypes.guess_type(filename)[0]
874                 if not mime_type:
875                     mime_type = "application/octet-stream"
876                 # create the new file entry
877                 files.append(self.db.file.create(type=mime_type,
878                     name=filename, content=file.file.read()))
880         # we don't want to do a message if none of the following is true...
881         cn = self.classname
882         cl = self.db.classes[self.classname]
883         props = cl.getprops()
884         note = None
885         # in a nutshell, don't do anything if there's no note or there's no
886         # NOSY
887         if self.form.has_key('__note'):
888             note = self.form['__note'].value.strip()
889         if not note:
890             return None, files
891         if not props.has_key('messages'):
892             return None, files
893         if not isinstance(props['messages'], hyperdb.Multilink):
894             return None, files
895         if not props['messages'].classname == 'msg':
896             return None, files
897         if not (self.form.has_key('nosy') or note):
898             return None, files
900         # handle the note
901         if '\n' in note:
902             summary = re.split(r'\n\r?', note)[0]
903         else:
904             summary = note
905         m = ['%s\n'%note]
907         # handle the messageid
908         # TODO: handle inreplyto
909         messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
910             self.classname, self.instance.MAIL_DOMAIN)
912         # now create the message, attaching the files
913         content = '\n'.join(m)
914         message_id = self.db.msg.create(author=self.userid,
915             recipients=[], date=date.Date('.'), summary=summary,
916             content=content, files=files, messageid=messageid)
918         # update the messages property
919         return message_id, files
921     def _post_editnode(self, nid):
922         '''Do the linking part of the node creation.
924            If a form element has :link or :multilink appended to it, its
925            value specifies a node designator and the property on that node
926            to add _this_ node to as a link or multilink.
928            This is typically used on, eg. the file upload page to indicated
929            which issue to link the file to.
931            TODO: I suspect that this and newfile will go away now that
932            there's the ability to upload a file using the issue __file form
933            element!
934         '''
935         cn = self.classname
936         cl = self.db.classes[cn]
937         # link if necessary
938         keys = self.form.keys()
939         for key in keys:
940             if key == ':multilink':
941                 value = self.form[key].value
942                 if type(value) != type([]): value = [value]
943                 for value in value:
944                     designator, property = value.split(':')
945                     link, nodeid = hyperdb.splitDesignator(designator)
946                     link = self.db.classes[link]
947                     # take a dupe of the list so we're not changing the cache
948                     value = link.get(nodeid, property)[:]
949                     value.append(nid)
950                     link.set(nodeid, **{property: value})
951             elif key == ':link':
952                 value = self.form[key].value
953                 if type(value) != type([]): value = [value]
954                 for value in value:
955                     designator, property = value.split(':')
956                     link, nodeid = hyperdb.splitDesignator(designator)
957                     link = self.db.classes[link]
958                     link.set(nodeid, **{property: nid})
961 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
962     '''Pull properties for the given class out of the form.
963     '''
964     props = {}
965     keys = form.keys()
966     for key in keys:
967         if not cl.properties.has_key(key):
968             continue
969         proptype = cl.properties[key]
970         if isinstance(proptype, hyperdb.String):
971             value = form[key].value.strip()
972         elif isinstance(proptype, hyperdb.Password):
973             value = form[key].value.strip()
974             if not value:
975                 # ignore empty password values
976                 continue
977             value = password.Password(value)
978         elif isinstance(proptype, hyperdb.Date):
979             value = form[key].value.strip()
980             if value:
981                 value = date.Date(form[key].value.strip())
982             else:
983                 value = None
984         elif isinstance(proptype, hyperdb.Interval):
985             value = form[key].value.strip()
986             if value:
987                 value = date.Interval(form[key].value.strip())
988             else:
989                 value = None
990         elif isinstance(proptype, hyperdb.Link):
991             value = form[key].value.strip()
992             # see if it's the "no selection" choice
993             if value == '-1':
994                 value = None
995             else:
996                 # handle key values
997                 link = cl.properties[key].classname
998                 if not num_re.match(value):
999                     try:
1000                         value = db.classes[link].lookup(value)
1001                     except KeyError:
1002                         raise ValueError, _('property "%(propname)s": '
1003                             '%(value)s not a %(classname)s')%{'propname':key, 
1004                             'value': value, 'classname': link}
1005         elif isinstance(proptype, hyperdb.Multilink):
1006             value = form[key]
1007             if hasattr(value, 'value'):
1008                 # Quite likely to be a FormItem instance
1009                 value = value.value
1010             if not isinstance(value, type([])):
1011                 value = [i.strip() for i in value.split(',')]
1012             else:
1013                 value = [i.strip() for i in value]
1014             link = cl.properties[key].classname
1015             l = []
1016             for entry in map(str, value):
1017                 if entry == '': continue
1018                 if not num_re.match(entry):
1019                     try:
1020                         entry = db.classes[link].lookup(entry)
1021                     except KeyError:
1022                         raise ValueError, _('property "%(propname)s": '
1023                             '"%(value)s" not an entry of %(classname)s')%{
1024                             'propname':key, 'value': entry, 'classname': link}
1025                 l.append(entry)
1026             l.sort()
1027             value = l
1028         elif isinstance(proptype, hyperdb.Boolean):
1029             value = form[key].value.strip()
1030             props[key] = value = value.lower() in ('yes', 'true', 'on', '1')
1031         elif isinstance(proptype, hyperdb.Number):
1032             value = form[key].value.strip()
1033             props[key] = value = int(value)
1035         # get the old value
1036         if nodeid:
1037             try:
1038                 existing = cl.get(nodeid, key)
1039             except KeyError:
1040                 # this might be a new property for which there is no existing
1041                 # value
1042                 if not cl.properties.has_key(key): raise
1044             # if changed, set it
1045             if value != existing:
1046                 props[key] = value
1047         else:
1048             props[key] = value
1049     return props