Code

6924ad73d5e69bbe08b4463da3094b8564970915
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.4 2002-09-01 22:09:20 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                 cgi.escape('<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         'login':    'login_action',
310         'logout':   'logout_action',
311         'register': 'register_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              "login"     -> self.login_action
323              "logout"    -> self.logout_action
324              "register"  -> self.register_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 register_action(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         # make sure we're allowed to register
482         userid = self.db.user.lookup(self.user)
483         if not self.db.security.hasPermission('Web Registration', userid):
484             raise Unauthorised, _("You do not have permission to access"\
485                         " %(action)s.")%{'action': 'registration'}
487         # re-open the database as "admin"
488         if self.user != 'admin':
489             self.opendb('admin')
490             
491         # create the new user
492         cl = self.db.user
493         try:
494             props = parsePropsFromForm(self.db, cl, self.form)
495             props['roles'] = self.instance.NEW_WEB_USER_ROLES
496             uid = cl.create(**props)
497             self.db.commit()
498         except ValueError, message:
499             self.error_message.append(message)
501         # log the new user in
502         self.user = cl.get(uid, 'username')
503         # re-open the database for real, using the user
504         self.opendb(self.user)
505         password = cl.get(uid, 'password')
506         self.set_cookie(self.user, password)
508         # nice message
509         self.ok_message.append(_('You are now registered, welcome!'))
511     def editItemAction(self):
512         ''' Perform an edit of an item in the database.
514             Some special form elements:
516             :link=designator:property
517             :multilink=designator:property
518              The value specifies a node designator and the property on that
519              node to add _this_ node to as a link or multilink.
520             __note
521              Create a message and attach it to the current node's
522              "messages" property.
523             __file
524              Create a file and attach it to the current node's
525              "files" property. Attach the file to the message created from
526              the __note if it's supplied.
527         '''
528         cl = self.db.classes[self.classname]
530         # parse the props from the form
531         try:
532             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
533         except (ValueError, KeyError), message:
534             self.error_message.append(_('Error: ') + str(message))
535             return
537         # check permission
538         if not self.editItemPermission(props):
539             self.error_message.append(
540                 _('You do not have permission to edit %(classname)s'%
541                 self.__dict__))
542             return
544         # perform the edit
545         try:
546             # make changes to the node
547             props = self._changenode(props)
548             # handle linked nodes 
549             self._post_editnode(self.nodeid)
550         except (ValueError, KeyError), message:
551             self.error_message.append(_('Error: ') + str(message))
552             return
554         # commit now that all the tricky stuff is done
555         self.db.commit()
557         # and some nice feedback for the user
558         if props:
559             message = _('%(changes)s edited ok')%{'changes':
560                 ', '.join(props.keys())}
561         elif self.form.has_key('__note') and self.form['__note'].value:
562             message = _('note added')
563         elif (self.form.has_key('__file') and self.form['__file'].filename):
564             message = _('file added')
565         else:
566             message = _('nothing changed')
568         # redirect to the item's edit page
569         raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
570             self.nodeid,  urllib.quote(message))
572     def editItemPermission(self, props):
573         ''' Determine whether the user has permission to edit this item.
575             Base behaviour is to check the user can edit this class. If we're
576             editing the "user" class, users are allowed to edit their own
577             details. Unless it's the "roles" property, which requires the
578             special Permission "Web Roles".
579         '''
580         # if this is a user node and the user is editing their own node, then
581         # we're OK
582         has = self.db.security.hasPermission
583         if self.classname == 'user':
584             # reject if someone's trying to edit "roles" and doesn't have the
585             # right permission.
586             if props.has_key('roles') and not has('Web Roles', self.userid,
587                     'user'):
588                 return 0
589             # if the item being edited is the current user, we're ok
590             if self.nodeid == self.userid:
591                 return 1
592         if not self.db.security.hasPermission('Edit', self.userid,
593                 self.classname):
594             return 0
595         return 1
597     def newItemAction(self):
598         ''' Add a new item to the database.
600             This follows the same form as the editItemAction
601         '''
602         cl = self.db.classes[self.classname]
604         # parse the props from the form
605         try:
606             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
607         except (ValueError, KeyError), message:
608             self.error_message.append(_('Error: ') + str(message))
609             return
611         if not self.newItemPermission(props):
612             self.error_message.append(
613                 _('You do not have permission to create %s' %self.classname))
615         # XXX
616 #        cl = self.db.classes[cn]
617 #        if self.form.has_key(':multilink'):
618 #            link = self.form[':multilink'].value
619 #            designator, linkprop = link.split(':')
620 #            xtra = ' for <a href="%s">%s</a>' % (designator, designator)
621 #        else:
622 #            xtra = ''
624         try:
625             # do the create
626             nid = self._createnode(props)
628             # handle linked nodes 
629             self._post_editnode(nid)
631             # commit now that all the tricky stuff is done
632             self.db.commit()
634             # render the newly created item
635             self.nodeid = nid
637             # and some nice feedback for the user
638             message = _('%(classname)s created ok')%self.__dict__
639         except (ValueError, KeyError), message:
640             self.error_message.append(_('Error: ') + str(message))
641             return
642         except:
643             # oops
644             self.db.rollback()
645             s = StringIO.StringIO()
646             traceback.print_exc(None, s)
647             self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
648             return
650         # redirect to the new item's page
651         raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
652             nid,  urllib.quote(message))
654     def newItemPermission(self, props):
655         ''' Determine whether the user has permission to create (edit) this
656             item.
658             Base behaviour is to check the user can edit this class. No
659             additional property checks are made. Additionally, new user items
660             may be created if the user has the "Web Registration" Permission.
661         '''
662         has = self.db.security.hasPermission
663         if self.classname == 'user' and has('Web Registration', self.userid,
664                 'user'):
665             return 1
666         if not has('Edit', self.userid, self.classname):
667             return 0
668         return 1
670     def genericEditAction(self):
671         ''' Performs an edit of all of a class' items in one go.
673             The "rows" CGI var defines the CSV-formatted entries for the
674             class. New nodes are identified by the ID 'X' (or any other
675             non-existent ID) and removed lines are retired.
676         '''
677         # generic edit is per-class only
678         if not self.genericEditPermission():
679             self.error_message.append(
680                 _('You do not have permission to edit %s' %self.classname))
682         # get the CSV module
683         try:
684             import csv
685         except ImportError:
686             self.error_message.append(_(
687                 'Sorry, you need the csv module to use this function.<br>\n'
688                 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
689             return
691         cl = self.db.classes[self.classname]
692         idlessprops = cl.getprops(protected=0).keys()
693         props = ['id'] + idlessprops
695         # do the edit
696         rows = self.form['rows'].value.splitlines()
697         p = csv.parser()
698         found = {}
699         line = 0
700         for row in rows:
701             line += 1
702             values = p.parse(row)
703             # not a complete row, keep going
704             if not values: continue
706             # extract the nodeid
707             nodeid, values = values[0], values[1:]
708             found[nodeid] = 1
710             # confirm correct weight
711             if len(idlessprops) != len(values):
712                 message=(_('Not enough values on line %(line)s'%{'line':line}))
713                 return
715             # extract the new values
716             d = {}
717             for name, value in zip(idlessprops, values):
718                 value = value.strip()
719                 # only add the property if it has a value
720                 if value:
721                     # if it's a multilink, split it
722                     if isinstance(cl.properties[name], hyperdb.Multilink):
723                         value = value.split(':')
724                     d[name] = value
726             # perform the edit
727             if cl.hasnode(nodeid):
728                 # edit existing
729                 cl.set(nodeid, **d)
730             else:
731                 # new node
732                 found[cl.create(**d)] = 1
734         # retire the removed entries
735         for nodeid in cl.list():
736             if not found.has_key(nodeid):
737                 cl.retire(nodeid)
739         message = _('items edited OK')
741         # redirect to the class' edit page
742         raise Redirect, '%s/%s?:ok_message=%s'%(self.base, self.classname, 
743             urllib.quote(message))
745     def genericEditPermission(self):
746         ''' Determine whether the user has permission to edit this class.
748             Base behaviour is to check the user can edit this class.
749         ''' 
750         if not self.db.security.hasPermission('Edit', self.userid,
751                 self.classname):
752             return 0
753         return 1
755     def searchAction(self):
756         ''' Mangle some of the form variables.
758             Set the form ":filter" variable based on the values of the
759             filter variables - if they're set to anything other than
760             "dontcare" then add them to :filter.
761         '''
762         # generic edit is per-class only
763         if not self.searchPermission():
764             self.error_message.append(
765                 _('You do not have permission to search %s' %self.classname))
767         # add a faked :filter form variable for each filtering prop
768         props = self.db.classes[self.classname].getprops()
769         for key in self.form.keys():
770             if not props.has_key(key): continue
771             if not self.form[key].value: continue
772             self.form.value.append(cgi.MiniFieldStorage(':filter', key))
774     def searchPermission(self):
775         ''' Determine whether the user has permission to search this class.
777             Base behaviour is to check the user can view this class.
778         ''' 
779         if not self.db.security.hasPermission('View', self.userid,
780                 self.classname):
781             return 0
782         return 1
784     def XXXremove_action(self,  dre=re.compile(r'([^\d]+)(\d+)')):
785         # XXX I believe this could be handled by a regular edit action that
786         # just sets the multilink...
787         # XXX handle this !
788         target = self.index_arg(':target')[0]
789         m = dre.match(target)
790         if m:
791             classname = m.group(1)
792             nodeid = m.group(2)
793             cl = self.db.getclass(classname)
794             cl.retire(nodeid)
795             # now take care of the reference
796             parentref =  self.index_arg(':multilink')[0]
797             parent, prop = parentref.split(':')
798             m = dre.match(parent)
799             if m:
800                 self.classname = m.group(1)
801                 self.nodeid = m.group(2)
802                 cl = self.db.getclass(self.classname)
803                 value = cl.get(self.nodeid, prop)
804                 value.remove(nodeid)
805                 cl.set(self.nodeid, **{prop:value})
806                 func = getattr(self, 'show%s'%self.classname)
807                 return func()
808             else:
809                 raise NotFound, parent
810         else:
811             raise NotFound, target
813     #
814     #  Utility methods for editing
815     #
816     def _changenode(self, props):
817         ''' change the node based on the contents of the form
818         '''
819         cl = self.db.classes[self.classname]
821         # create the message
822         message, files = self._handle_message()
823         if message:
824             props['messages'] = cl.get(self.nodeid, 'messages') + [message]
825         if files:
826             props['files'] = cl.get(self.nodeid, 'files') + files
828         # make the changes
829         return cl.set(self.nodeid, **props)
831     def _createnode(self, props):
832         ''' create a node based on the contents of the form
833         '''
834         cl = self.db.classes[self.classname]
836         # check for messages and files
837         message, files = self._handle_message()
838         if message:
839             props['messages'] = [message]
840         if files:
841             props['files'] = files
842         # create the node and return it's id
843         return cl.create(**props)
845     def _handle_message(self):
846         ''' generate an edit message
847         '''
848         # handle file attachments 
849         files = []
850         if self.form.has_key('__file'):
851             file = self.form['__file']
852             if file.filename:
853                 filename = file.filename.split('\\')[-1]
854                 mime_type = mimetypes.guess_type(filename)[0]
855                 if not mime_type:
856                     mime_type = "application/octet-stream"
857                 # create the new file entry
858                 files.append(self.db.file.create(type=mime_type,
859                     name=filename, content=file.file.read()))
861         # we don't want to do a message if none of the following is true...
862         cn = self.classname
863         cl = self.db.classes[self.classname]
864         props = cl.getprops()
865         note = None
866         # in a nutshell, don't do anything if there's no note or there's no
867         # NOSY
868         if self.form.has_key('__note'):
869             note = self.form['__note'].value.strip()
870         if not note:
871             return None, files
872         if not props.has_key('messages'):
873             return None, files
874         if not isinstance(props['messages'], hyperdb.Multilink):
875             return None, files
876         if not props['messages'].classname == 'msg':
877             return None, files
878         if not (self.form.has_key('nosy') or note):
879             return None, files
881         # handle the note
882         if '\n' in note:
883             summary = re.split(r'\n\r?', note)[0]
884         else:
885             summary = note
886         m = ['%s\n'%note]
888         # handle the messageid
889         # TODO: handle inreplyto
890         messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
891             self.classname, self.instance.MAIL_DOMAIN)
893         # now create the message, attaching the files
894         content = '\n'.join(m)
895         message_id = self.db.msg.create(author=self.userid,
896             recipients=[], date=date.Date('.'), summary=summary,
897             content=content, files=files, messageid=messageid)
899         # update the messages property
900         return message_id, files
902     def _post_editnode(self, nid):
903         '''Do the linking part of the node creation.
905            If a form element has :link or :multilink appended to it, its
906            value specifies a node designator and the property on that node
907            to add _this_ node to as a link or multilink.
909            This is typically used on, eg. the file upload page to indicated
910            which issue to link the file to.
912            TODO: I suspect that this and newfile will go away now that
913            there's the ability to upload a file using the issue __file form
914            element!
915         '''
916         cn = self.classname
917         cl = self.db.classes[cn]
918         # link if necessary
919         keys = self.form.keys()
920         for key in keys:
921             if key == ':multilink':
922                 value = self.form[key].value
923                 if type(value) != type([]): value = [value]
924                 for value in value:
925                     designator, property = value.split(':')
926                     link, nodeid = hyperdb.splitDesignator(designator)
927                     link = self.db.classes[link]
928                     # take a dupe of the list so we're not changing the cache
929                     value = link.get(nodeid, property)[:]
930                     value.append(nid)
931                     link.set(nodeid, **{property: value})
932             elif key == ':link':
933                 value = self.form[key].value
934                 if type(value) != type([]): value = [value]
935                 for value in value:
936                     designator, property = value.split(':')
937                     link, nodeid = hyperdb.splitDesignator(designator)
938                     link = self.db.classes[link]
939                     link.set(nodeid, **{property: nid})
942 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
943     '''Pull properties for the given class out of the form.
944     '''
945     props = {}
946     keys = form.keys()
947     for key in keys:
948         if not cl.properties.has_key(key):
949             continue
950         proptype = cl.properties[key]
951         if isinstance(proptype, hyperdb.String):
952             value = form[key].value.strip()
953         elif isinstance(proptype, hyperdb.Password):
954             value = form[key].value.strip()
955             if not value:
956                 # ignore empty password values
957                 continue
958             value = password.Password(value)
959         elif isinstance(proptype, hyperdb.Date):
960             value = form[key].value.strip()
961             if value:
962                 value = date.Date(form[key].value.strip())
963             else:
964                 value = None
965         elif isinstance(proptype, hyperdb.Interval):
966             value = form[key].value.strip()
967             if value:
968                 value = date.Interval(form[key].value.strip())
969             else:
970                 value = None
971         elif isinstance(proptype, hyperdb.Link):
972             value = form[key].value.strip()
973             # see if it's the "no selection" choice
974             if value == '-1':
975                 value = None
976             else:
977                 # handle key values
978                 link = cl.properties[key].classname
979                 if not num_re.match(value):
980                     try:
981                         value = db.classes[link].lookup(value)
982                     except KeyError:
983                         raise ValueError, _('property "%(propname)s": '
984                             '%(value)s not a %(classname)s')%{'propname':key, 
985                             'value': value, 'classname': link}
986         elif isinstance(proptype, hyperdb.Multilink):
987             value = form[key]
988             if hasattr(value, 'value'):
989                 # Quite likely to be a FormItem instance
990                 value = value.value
991             if not isinstance(value, type([])):
992                 value = [i.strip() for i in value.split(',')]
993             else:
994                 value = [i.strip() for i in value]
995             link = cl.properties[key].classname
996             l = []
997             for entry in map(str, value):
998                 if entry == '': continue
999                 if not num_re.match(entry):
1000                     try:
1001                         entry = db.classes[link].lookup(entry)
1002                     except KeyError:
1003                         raise ValueError, _('property "%(propname)s": '
1004                             '"%(value)s" not an entry of %(classname)s')%{
1005                             'propname':key, 'value': entry, 'classname': link}
1006                 l.append(entry)
1007             l.sort()
1008             value = l
1009         elif isinstance(proptype, hyperdb.Boolean):
1010             value = form[key].value.strip()
1011             props[key] = value = value.lower() in ('yes', 'true', 'on', '1')
1012         elif isinstance(proptype, hyperdb.Number):
1013             value = form[key].value.strip()
1014             props[key] = value = int(value)
1016         # get the old value
1017         if nodeid:
1018             try:
1019                 existing = cl.get(nodeid, key)
1020             except KeyError:
1021                 # this might be a new property for which there is no existing
1022                 # value
1023                 if not cl.properties.has_key(key): raise
1025             # if changed, set it
1026             if value != existing:
1027                 props[key] = value
1028         else:
1029             props[key] = value
1030     return props