Code

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