Code

more explanatory docsting
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.13 2002-09-05 04:46:36 richard Exp $
3 __doc__ = """
4 WWW request handler (also used in the stand-alone server).
5 """
7 import os, os.path, cgi, StringIO, urlparse, re, traceback, mimetypes, urllib
8 import binascii, Cookie, time, random
10 from roundup import roundupdb, date, hyperdb, password
11 from roundup.i18n import _
13 from roundup.cgi.templating import getTemplate, HTMLRequest
14 from roundup.cgi import cgitb
16 from PageTemplates import PageTemplate
18 class Unauthorised(ValueError):
19     pass
21 class NotFound(ValueError):
22     pass
24 class Redirect(Exception):
25     pass
27 class SendFile(Exception):
28     ' Sent a file from the database '
30 class SendStaticFile(Exception):
31     ' Send a static file from the instance html directory '
33 def initialiseSecurity(security):
34     ''' Create some Permissions and Roles on the security object
36         This function is directly invoked by security.Security.__init__()
37         as a part of the Security object instantiation.
38     '''
39     security.addPermission(name="Web Registration",
40         description="User may register through the web")
41     p = security.addPermission(name="Web Access",
42         description="User may access the web interface")
43     security.addPermissionToRole('Admin', p)
45     # doing Role stuff through the web - make sure Admin can
46     p = security.addPermission(name="Web Roles",
47         description="User may manipulate user Roles through the web")
48     security.addPermissionToRole('Admin', p)
50 class Client:
51     '''
52     A note about login
53     ------------------
55     If the user has no login cookie, then they are anonymous. There
56     are two levels of anonymous use. If there is no 'anonymous' user, there
57     is no login at all and the database is opened in read-only mode. If the
58     'anonymous' user exists, the user is logged in using that user (though
59     there is no cookie). This allows them to modify the database, and all
60     modifications are attributed to the 'anonymous' user.
62     Once a user logs in, they are assigned a session. The Client instance
63     keeps the nodeid of the session as the "session" attribute.
65     Client attributes:
66         "url" is the current url path
67         "path" is the PATH_INFO inside the instance
68         "base" is the base URL for the instance
69     '''
71     def __init__(self, instance, request, env, form=None):
72         hyperdb.traceMark()
73         self.instance = instance
74         self.request = request
75         self.env = env
77         self.path = env['PATH_INFO']
78         self.split_path = self.path.split('/')
79         self.instance_path_name = env['INSTANCE_NAME']
81         # this is the base URL for this instance
82         url = self.env['SCRIPT_NAME'] + '/' + self.instance_path_name
83         self.base = urlparse.urlunparse(('http', env['HTTP_HOST'], url,
84             None, None, None))
86         # request.path is the full request path
87         x, x, path, x, x, x = urlparse.urlparse(request.path)
88         self.url = urlparse.urlunparse(('http', env['HTTP_HOST'], path,
89             None, None, None))
91         if form is None:
92             self.form = cgi.FieldStorage(environ=env)
93         else:
94             self.form = form
95         self.headers_done = 0
96         try:
97             self.debug = int(env.get("ROUNDUP_DEBUG", 0))
98         except ValueError:
99             # someone gave us a non-int debug level, turn it off
100             self.debug = 0
102     def main(self):
103         ''' Wrap the request and handle unauthorised requests
104         '''
105         self.content_action = None
106         self.ok_message = []
107         self.error_message = []
108         try:
109             # make sure we're identified (even anonymously)
110             self.determine_user()
111             # figure out the context and desired content template
112             self.determine_context()
113             # possibly handle a form submit action (may change self.message,
114             # self.classname and self.template)
115             self.handle_action()
116             # now render the page
117             if self.form.has_key(':contentonly'):
118                 # just the content
119                 self.write(self.content())
120             else:
121                 # render the content inside the page template
122                 self.write(self.renderTemplate('page', '',
123                     ok_message=self.ok_message,
124                     error_message=self.error_message))
125         except Redirect, url:
126             # let's redirect - if the url isn't None, then we need to do
127             # the headers, otherwise the headers have been set before the
128             # exception was raised
129             if url:
130                 self.header({'Location': url}, response=302)
131         except SendFile, designator:
132             self.serve_file(designator)
133         except SendStaticFile, file:
134             self.serve_static_file(str(file))
135         except Unauthorised, message:
136             self.write(self.renderTemplate('page', '', error_message=message))
137         except:
138             # everything else
139             self.write(cgitb.html())
141     def determine_user(self):
142         ''' Determine who the user is
143         '''
144         # determine the uid to use
145         self.opendb('admin')
147         # make sure we have the session Class
148         sessions = self.db.sessions
150         # age sessions, remove when they haven't been used for a week
151         # TODO: this shouldn't be done every access
152         week = 60*60*24*7
153         now = time.time()
154         for sessid in sessions.list():
155             interval = now - sessions.get(sessid, 'last_use')
156             if interval > week:
157                 sessions.destroy(sessid)
159         # look up the user session cookie
160         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
161         user = 'anonymous'
163         # bump the "revision" of the cookie since the format changed
164         if (cookie.has_key('roundup_user_2') and
165                 cookie['roundup_user_2'].value != 'deleted'):
167             # get the session key from the cookie
168             self.session = cookie['roundup_user_2'].value
169             # get the user from the session
170             try:
171                 # update the lifetime datestamp
172                 sessions.set(self.session, last_use=time.time())
173                 sessions.commit()
174                 user = sessions.get(self.session, 'user')
175             except KeyError:
176                 user = 'anonymous'
178         # sanity check on the user still being valid, getting the userid
179         # at the same time
180         try:
181             self.userid = self.db.user.lookup(user)
182         except (KeyError, TypeError):
183             user = 'anonymous'
185         # make sure the anonymous user is valid if we're using it
186         if user == 'anonymous':
187             self.make_user_anonymous()
188         else:
189             self.user = user
191         # reopen the database as the correct user
192         self.opendb(self.user)
194     def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
195         ''' Determine the context of this page from the URL:
197             The URL path after the instance identifier is examined. The path
198             is generally only one entry long.
200             - if there is no path, then we are in the "home" context.
201             * if the path is "_file", then the additional path entry
202               specifies the filename of a static file we're to serve up
203               from the instance "html" directory. Raises a SendStaticFile
204               exception.
205             - if there is something in the path (eg "issue"), it identifies
206               the tracker class we're to display.
207             - if the path is an item designator (eg "issue123"), then we're
208               to display a specific item.
209             * if the path starts with an item designator and is longer than
210               one entry, then we're assumed to be handling an item of a
211               FileClass, and the extra path information gives the filename
212               that the client is going to label the download with (ie
213               "file123/image.png" is nicer to download than "file123"). This
214               raises a SendFile exception.
216             Both of the "*" types of contexts stop before we bother to
217             determine the template we're going to use. That's because they
218             don't actually use templates.
220             The template used is specified by the :template CGI variable,
221             which defaults to:
223              only classname suplied:          "index"
224              full item designator supplied:   "item"
226             We set:
227              self.classname  - the class to display, can be None
228              self.template   - the template to render the current context with
229              self.nodeid     - the nodeid of the class we're displaying
230         '''
231         # default the optional variables
232         self.classname = None
233         self.nodeid = None
235         # determine the classname and possibly nodeid
236         path = self.split_path
237         if not path or path[0] in ('', 'home', 'index'):
238             if self.form.has_key(':template'):
239                 self.template = self.form[':template'].value
240             else:
241                 self.template = ''
242             return
243         elif path[0] == '_file':
244             raise SendStaticFile, path[1]
245         else:
246             self.classname = path[0]
247             if len(path) > 1:
248                 # send the file identified by the designator in path[0]
249                 raise SendFile, path[0]
251         # see if we got a designator
252         m = dre.match(self.classname)
253         if m:
254             self.classname = m.group(1)
255             self.nodeid = m.group(2)
256             # with a designator, we default to item view
257             self.template = 'item'
258         else:
259             # with only a class, we default to index view
260             self.template = 'index'
262         # see if we have a template override
263         if self.form.has_key(':template'):
264             self.template = self.form[':template'].value
267         # see if we were passed in a message
268         if self.form.has_key(':ok_message'):
269             self.ok_message.append(self.form[':ok_message'].value)
270         if self.form.has_key(':error_message'):
271             self.error_message.append(self.form[':error_message'].value)
273     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
274         ''' Serve the file from the content property of the designated item.
275         '''
276         m = dre.match(str(designator))
277         if not m:
278             raise NotFound, str(designator)
279         classname, nodeid = m.group(1), m.group(2)
280         if classname != 'file':
281             raise NotFound, designator
283         # we just want to serve up the file named
284         file = self.db.file
285         self.header({'Content-Type': file.get(nodeid, 'type')})
286         self.write(file.get(nodeid, 'content'))
288     def serve_static_file(self, file):
289         # we just want to serve up the file named
290         mt = mimetypes.guess_type(str(file))[0]
291         self.header({'Content-Type': mt})
292         self.write(open(os.path.join(self.instance.TEMPLATES, file)).read())
294     def renderTemplate(self, name, extension, **kwargs):
295         ''' Return a PageTemplate for the named page
296         '''
297         pt = getTemplate(self.instance.TEMPLATES, name, extension)
298         # XXX handle PT rendering errors here more nicely
299         try:
300             # let the template render figure stuff out
301             return pt.render(self, None, None, **kwargs)
302         except PageTemplate.PTRuntimeError, message:
303             return '<strong>%s</strong><ol>%s</ol>'%(message,
304                 '<li>'.join(pt._v_errors))
305         except:
306             # everything else
307             return cgitb.html()
309     def content(self):
310         ''' Callback used by the page template to render the content of 
311             the page.
313             If we don't have a specific class to display, that is none was
314             determined in determine_context(), then we display a "home"
315             template.
316         '''
317         # now render the page content using the template we determined in
318         # determine_context
319         if self.classname is None:
320             name = 'home'
321         else:
322             name = self.classname
323         return self.renderTemplate(self.classname, self.template)
325     # these are the actions that are available
326     actions = {
327         'edit':     'editItemAction',
328         'editCSV':  'editCSVAction',
329         'new':      'newItemAction',
330         'register': 'registerAction',
331         'login':    'login_action',
332         'logout':   'logout_action',
333         'search':   'searchAction',
334     }
335     def handle_action(self):
336         ''' Determine whether there should be an _action called.
338             The action is defined by the form variable :action which
339             identifies the method on this object to call. The four basic
340             actions are defined in the "actions" dictionary on this class:
341              "edit"      -> self.editItemAction
342              "new"       -> self.newItemAction
343              "register"  -> self.registerAction
344              "login"     -> self.login_action
345              "logout"    -> self.logout_action
346              "search"    -> self.searchAction
348         '''
349         if not self.form.has_key(':action'):
350             return None
351         try:
352             # get the action, validate it
353             action = self.form[':action'].value
354             if not self.actions.has_key(action):
355                 raise ValueError, 'No such action "%s"'%action
357             # call the mapped action
358             getattr(self, self.actions[action])()
359         except Redirect:
360             raise
361         except:
362             self.db.rollback()
363             s = StringIO.StringIO()
364             traceback.print_exc(None, s)
365             self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
367     def write(self, content):
368         if not self.headers_done:
369             self.header()
370         self.request.wfile.write(content)
372     def header(self, headers=None, response=200):
373         '''Put up the appropriate header.
374         '''
375         if headers is None:
376             headers = {'Content-Type':'text/html'}
377         if not headers.has_key('Content-Type'):
378             headers['Content-Type'] = 'text/html'
379         self.request.send_response(response)
380         for entry in headers.items():
381             self.request.send_header(*entry)
382         self.request.end_headers()
383         self.headers_done = 1
384         if self.debug:
385             self.headers_sent = headers
387     def set_cookie(self, user, password):
388         # TODO generate a much, much stronger session key ;)
389         self.session = binascii.b2a_base64(repr(time.time())).strip()
391         # clean up the base64
392         if self.session[-1] == '=':
393             if self.session[-2] == '=':
394                 self.session = self.session[:-2]
395             else:
396                 self.session = self.session[:-1]
398         # insert the session in the sessiondb
399         self.db.sessions.set(self.session, user=user, last_use=time.time())
401         # and commit immediately
402         self.db.sessions.commit()
404         # expire us in a long, long time
405         expire = Cookie._getdate(86400*365)
407         # generate the cookie path - make sure it has a trailing '/'
408         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
409             ''))
410         self.header({'Set-Cookie': 'roundup_user_2=%s; expires=%s; Path=%s;'%(
411             self.session, expire, path)})
413     def make_user_anonymous(self):
414         ''' Make us anonymous
416             This method used to handle non-existence of the 'anonymous'
417             user, but that user is mandatory now.
418         '''
419         self.userid = self.db.user.lookup('anonymous')
420         self.user = 'anonymous'
422     def logout(self):
423         ''' Make us really anonymous - nuke the cookie too
424         '''
425         self.make_user_anonymous()
427         # construct the logout cookie
428         now = Cookie._getdate()
429         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
430             ''))
431         self.header({'Set-Cookie':
432             'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
433             path)})
434         self.login()
436     def opendb(self, user):
437         ''' Open the database.
438         '''
439         # open the db if the user has changed
440         if not hasattr(self, 'db') or user != self.db.journaltag:
441             self.db = self.instance.open(user)
443     #
444     # Actions
445     #
446     def login_action(self):
447         ''' Attempt to log a user in and set the cookie
448         '''
449         # we need the username at a minimum
450         if not self.form.has_key('__login_name'):
451             self.error_message.append(_('Username required'))
452             return
454         self.user = self.form['__login_name'].value
455         # re-open the database for real, using the user
456         self.opendb(self.user)
457         if self.form.has_key('__login_password'):
458             password = self.form['__login_password'].value
459         else:
460             password = ''
461         # make sure the user exists
462         try:
463             self.userid = self.db.user.lookup(self.user)
464         except KeyError:
465             name = self.user
466             self.make_user_anonymous()
467             self.error_message.append(_('No such user "%(name)s"')%locals())
468             return
470         # and that the password is correct
471         pw = self.db.user.get(self.userid, 'password')
472         if password != pw:
473             self.make_user_anonymous()
474             self.error_message.append(_('Incorrect password'))
475             return
477         # set the session cookie
478         self.set_cookie(self.user, password)
480     def logout_action(self):
481         ''' Make us really anonymous - nuke the cookie too
482         '''
483         # log us out
484         self.make_user_anonymous()
486         # construct the logout cookie
487         now = Cookie._getdate()
488         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
489             ''))
490         self.header(headers={'Set-Cookie':
491           'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)})
493         # Let the user know what's going on
494         self.ok_message.append(_('You are logged out'))
496     def registerAction(self):
497         '''Attempt to create a new user based on the contents of the form
498         and then set the cookie.
500         return 1 on successful login
501         '''
502         # create the new user
503         cl = self.db.user
505         # parse the props from the form
506         try:
507             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
508         except (ValueError, KeyError), message:
509             self.error_message.append(_('Error: ') + str(message))
510             return
512         # make sure we're allowed to register
513         if not self.registerPermission(props):
514             raise Unauthorised, _("You do not have permission to register")
516         # re-open the database as "admin"
517         if self.user != 'admin':
518             self.opendb('admin')
519             
520         # create the new user
521         cl = self.db.user
522         try:
523             props = parsePropsFromForm(self.db, cl, self.form)
524             props['roles'] = self.instance.NEW_WEB_USER_ROLES
525             self.userid = cl.create(**props)
526             self.db.commit()
527         except ValueError, message:
528             self.error_message.append(message)
530         # log the new user in
531         self.user = cl.get(self.userid, 'username')
532         # re-open the database for real, using the user
533         self.opendb(self.user)
534         password = self.db.user.get(self.userid, 'password')
535         self.set_cookie(self.user, password)
537         # nice message
538         self.ok_message.append(_('You are now registered, welcome!'))
540     def registerPermission(self, props):
541         ''' Determine whether the user has permission to register
543             Base behaviour is to check the user has "Web Registration".
544         '''
545         # registration isn't allowed to supply roles
546         if props.has_key('roles'):
547             return 0
548         if self.db.security.hasPermission('Web Registration', self.userid):
549             return 1
550         return 0
552     def editItemAction(self):
553         ''' Perform an edit of an item in the database.
555             Some special form elements:
557             :link=designator:property
558             :multilink=designator:property
559              The value specifies a node designator and the property on that
560              node to add _this_ node to as a link or multilink.
561             __note
562              Create a message and attach it to the current node's
563              "messages" property.
564             __file
565              Create a file and attach it to the current node's
566              "files" property. Attach the file to the message created from
567              the __note if it's supplied.
568         '''
569         cl = self.db.classes[self.classname]
571         # parse the props from the form
572         try:
573             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
574         except (ValueError, KeyError), message:
575             self.error_message.append(_('Error: ') + str(message))
576             return
578         # check permission
579         if not self.editItemPermission(props):
580             self.error_message.append(
581                 _('You do not have permission to edit %(classname)s'%
582                 self.__dict__))
583             return
585         # perform the edit
586         try:
587             # make changes to the node
588             props = self._changenode(props)
589             # handle linked nodes 
590             self._post_editnode(self.nodeid)
591         except (ValueError, KeyError), message:
592             self.error_message.append(_('Error: ') + str(message))
593             return
595         # commit now that all the tricky stuff is done
596         self.db.commit()
598         # and some nice feedback for the user
599         if props:
600             message = _('%(changes)s edited ok')%{'changes':
601                 ', '.join(props.keys())}
602         elif self.form.has_key('__note') and self.form['__note'].value:
603             message = _('note added')
604         elif (self.form.has_key('__file') and self.form['__file'].filename):
605             message = _('file added')
606         else:
607             message = _('nothing changed')
609         # redirect to the item's edit page
610         raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
611             self.nodeid,  urllib.quote(message))
613     def editItemPermission(self, props):
614         ''' Determine whether the user has permission to edit this item.
616             Base behaviour is to check the user can edit this class. If we're
617             editing the "user" class, users are allowed to edit their own
618             details. Unless it's the "roles" property, which requires the
619             special Permission "Web Roles".
620         '''
621         # if this is a user node and the user is editing their own node, then
622         # we're OK
623         has = self.db.security.hasPermission
624         if self.classname == 'user':
625             # reject if someone's trying to edit "roles" and doesn't have the
626             # right permission.
627             if props.has_key('roles') and not has('Web Roles', self.userid,
628                     'user'):
629                 return 0
630             # if the item being edited is the current user, we're ok
631             if self.nodeid == self.userid:
632                 return 1
633         if self.db.security.hasPermission('Edit', self.userid, self.classname):
634             return 1
635         return 0
637     def newItemAction(self):
638         ''' Add a new item to the database.
640             This follows the same form as the editItemAction
641         '''
642         cl = self.db.classes[self.classname]
644         # parse the props from the form
645         try:
646             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
647         except (ValueError, KeyError), message:
648             self.error_message.append(_('Error: ') + str(message))
649             return
651         if not self.newItemPermission(props):
652             self.error_message.append(
653                 _('You do not have permission to create %s' %self.classname))
655         # create a little extra message for anticipated :link / :multilink
656         if self.form.has_key(':multilink'):
657             link = self.form[':multilink'].value
658         elif self.form.has_key(':link'):
659             link = self.form[':multilink'].value
660         else:
661             link = None
662             xtra = ''
663         if link:
664             designator, linkprop = link.split(':')
665             xtra = ' for <a href="%s">%s</a>'%(designator, designator)
667         try:
668             # do the create
669             nid = self._createnode(props)
671             # handle linked nodes 
672             self._post_editnode(nid)
674             # commit now that all the tricky stuff is done
675             self.db.commit()
677             # render the newly created item
678             self.nodeid = nid
680             # and some nice feedback for the user
681             message = _('%(classname)s created ok')%self.__dict__ + xtra
682         except (ValueError, KeyError), message:
683             self.error_message.append(_('Error: ') + str(message))
684             return
685         except:
686             # oops
687             self.db.rollback()
688             s = StringIO.StringIO()
689             traceback.print_exc(None, s)
690             self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
691             return
693         # redirect to the new item's page
694         raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
695             nid,  urllib.quote(message))
697     def newItemPermission(self, props):
698         ''' Determine whether the user has permission to create (edit) this
699             item.
701             Base behaviour is to check the user can edit this class. No
702             additional property checks are made. Additionally, new user items
703             may be created if the user has the "Web Registration" Permission.
704         '''
705         has = self.db.security.hasPermission
706         if self.classname == 'user' and has('Web Registration', self.userid,
707                 'user'):
708             return 1
709         if has('Edit', self.userid, self.classname):
710             return 1
711         return 0
713     def editCSVAction(self):
714         ''' Performs an edit of all of a class' items in one go.
716             The "rows" CGI var defines the CSV-formatted entries for the
717             class. New nodes are identified by the ID 'X' (or any other
718             non-existent ID) and removed lines are retired.
719         '''
720         # this is per-class only
721         if not self.editCSVPermission():
722             self.error_message.append(
723                 _('You do not have permission to edit %s' %self.classname))
725         # get the CSV module
726         try:
727             import csv
728         except ImportError:
729             self.error_message.append(_(
730                 'Sorry, you need the csv module to use this function.<br>\n'
731                 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
732             return
734         cl = self.db.classes[self.classname]
735         idlessprops = cl.getprops(protected=0).keys()
736         idlessprops.sort()
737         props = ['id'] + idlessprops
739         # do the edit
740         rows = self.form['rows'].value.splitlines()
741         p = csv.parser()
742         found = {}
743         line = 0
744         for row in rows[1:]:
745             line += 1
746             values = p.parse(row)
747             # not a complete row, keep going
748             if not values: continue
750             # skip property names header
751             if values == props:
752                 continue
754             # extract the nodeid
755             nodeid, values = values[0], values[1:]
756             found[nodeid] = 1
758             # confirm correct weight
759             if len(idlessprops) != len(values):
760                 self.error_message.append(
761                     _('Not enough values on line %(line)s')%{'line':line})
762                 return
764             # extract the new values
765             d = {}
766             for name, value in zip(idlessprops, values):
767                 value = value.strip()
768                 # only add the property if it has a value
769                 if value:
770                     # if it's a multilink, split it
771                     if isinstance(cl.properties[name], hyperdb.Multilink):
772                         value = value.split(':')
773                     d[name] = value
775             # perform the edit
776             if cl.hasnode(nodeid):
777                 # edit existing
778                 cl.set(nodeid, **d)
779             else:
780                 # new node
781                 found[cl.create(**d)] = 1
783         # retire the removed entries
784         for nodeid in cl.list():
785             if not found.has_key(nodeid):
786                 cl.retire(nodeid)
788         # all OK
789         self.db.commit()
791         self.ok_message.append(_('Items edited OK'))
793     def editCSVPermission(self):
794         ''' Determine whether the user has permission to edit this class.
796             Base behaviour is to check the user can edit this class.
797         ''' 
798         if not self.db.security.hasPermission('Edit', self.userid,
799                 self.classname):
800             return 0
801         return 1
803     def searchAction(self):
804         ''' Mangle some of the form variables.
806             Set the form ":filter" variable based on the values of the
807             filter variables - if they're set to anything other than
808             "dontcare" then add them to :filter.
810             Also handle the ":queryname" variable and save off the query to
811             the user's query list.
812         '''
813         # generic edit is per-class only
814         if not self.searchPermission():
815             self.error_message.append(
816                 _('You do not have permission to search %s' %self.classname))
818         # add a faked :filter form variable for each filtering prop
819         props = self.db.classes[self.classname].getprops()
820         for key in self.form.keys():
821             if not props.has_key(key): continue
822             if not self.form[key].value: continue
823             self.form.value.append(cgi.MiniFieldStorage(':filter', key))
825         # handle saving the query params
826         if self.form.has_key(':queryname'):
827             queryname = self.form[':queryname'].value.strip()
828             if queryname:
829                 # parse the environment and figure what the query _is_
830                 req = HTMLRequest(self)
831                 url = req.indexargs_href('', {})
833                 # handle editing an existing query
834                 try:
835                     qid = self.db.query.lookup(queryname)
836                     self.db.query.set(qid, klass=self.classname, url=url)
837                 except KeyError:
838                     # create a query
839                     qid = self.db.query.create(name=queryname,
840                         klass=self.classname, url=url)
842                     # and add it to the user's query multilink
843                     queries = self.db.user.get(self.userid, 'queries')
844                     queries.append(qid)
845                     self.db.user.set(self.userid, queries=queries)
847                 # commit the query change to the database
848                 self.db.commit()
851     def searchPermission(self):
852         ''' Determine whether the user has permission to search this class.
854             Base behaviour is to check the user can view this class.
855         ''' 
856         if not self.db.security.hasPermission('View', self.userid,
857                 self.classname):
858             return 0
859         return 1
861     def XXXremove_action(self,  dre=re.compile(r'([^\d]+)(\d+)')):
862         # XXX I believe this could be handled by a regular edit action that
863         # just sets the multilink...
864         # XXX handle this !
865         target = self.index_arg(':target')[0]
866         m = dre.match(target)
867         if m:
868             classname = m.group(1)
869             nodeid = m.group(2)
870             cl = self.db.getclass(classname)
871             cl.retire(nodeid)
872             # now take care of the reference
873             parentref =  self.index_arg(':multilink')[0]
874             parent, prop = parentref.split(':')
875             m = dre.match(parent)
876             if m:
877                 self.classname = m.group(1)
878                 self.nodeid = m.group(2)
879                 cl = self.db.getclass(self.classname)
880                 value = cl.get(self.nodeid, prop)
881                 value.remove(nodeid)
882                 cl.set(self.nodeid, **{prop:value})
883                 func = getattr(self, 'show%s'%self.classname)
884                 return func()
885             else:
886                 raise NotFound, parent
887         else:
888             raise NotFound, target
890     #
891     #  Utility methods for editing
892     #
893     def _changenode(self, props):
894         ''' change the node based on the contents of the form
895         '''
896         cl = self.db.classes[self.classname]
898         # create the message
899         message, files = self._handle_message()
900         if message:
901             props['messages'] = cl.get(self.nodeid, 'messages') + [message]
902         if files:
903             props['files'] = cl.get(self.nodeid, 'files') + files
905         # make the changes
906         return cl.set(self.nodeid, **props)
908     def _createnode(self, props):
909         ''' create a node based on the contents of the form
910         '''
911         cl = self.db.classes[self.classname]
913         # check for messages and files
914         message, files = self._handle_message()
915         if message:
916             props['messages'] = [message]
917         if files:
918             props['files'] = files
919         # create the node and return it's id
920         return cl.create(**props)
922     def _handle_message(self):
923         ''' generate an edit message
924         '''
925         # handle file attachments 
926         files = []
927         if self.form.has_key('__file'):
928             file = self.form['__file']
929             if file.filename:
930                 filename = file.filename.split('\\')[-1]
931                 mime_type = mimetypes.guess_type(filename)[0]
932                 if not mime_type:
933                     mime_type = "application/octet-stream"
934                 # create the new file entry
935                 files.append(self.db.file.create(type=mime_type,
936                     name=filename, content=file.file.read()))
938         # we don't want to do a message if none of the following is true...
939         cn = self.classname
940         cl = self.db.classes[self.classname]
941         props = cl.getprops()
942         note = None
943         # in a nutshell, don't do anything if there's no note or there's no
944         # NOSY
945         if self.form.has_key('__note'):
946             note = self.form['__note'].value.strip()
947         if not note:
948             return None, files
949         if not props.has_key('messages'):
950             return None, files
951         if not isinstance(props['messages'], hyperdb.Multilink):
952             return None, files
953         if not props['messages'].classname == 'msg':
954             return None, files
955         if not (self.form.has_key('nosy') or note):
956             return None, files
958         # handle the note
959         if '\n' in note:
960             summary = re.split(r'\n\r?', note)[0]
961         else:
962             summary = note
963         m = ['%s\n'%note]
965         # handle the messageid
966         # TODO: handle inreplyto
967         messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
968             self.classname, self.instance.MAIL_DOMAIN)
970         # now create the message, attaching the files
971         content = '\n'.join(m)
972         message_id = self.db.msg.create(author=self.userid,
973             recipients=[], date=date.Date('.'), summary=summary,
974             content=content, files=files, messageid=messageid)
976         # update the messages property
977         return message_id, files
979     def _post_editnode(self, nid):
980         '''Do the linking part of the node creation.
982            If a form element has :link or :multilink appended to it, its
983            value specifies a node designator and the property on that node
984            to add _this_ node to as a link or multilink.
986            This is typically used on, eg. the file upload page to indicated
987            which issue to link the file to.
989            TODO: I suspect that this and newfile will go away now that
990            there's the ability to upload a file using the issue __file form
991            element!
992         '''
993         cn = self.classname
994         cl = self.db.classes[cn]
995         # link if necessary
996         keys = self.form.keys()
997         for key in keys:
998             if key == ':multilink':
999                 value = self.form[key].value
1000                 if type(value) != type([]): value = [value]
1001                 for value in value:
1002                     designator, property = value.split(':')
1003                     link, nodeid = hyperdb.splitDesignator(designator)
1004                     link = self.db.classes[link]
1005                     # take a dupe of the list so we're not changing the cache
1006                     value = link.get(nodeid, property)[:]
1007                     value.append(nid)
1008                     link.set(nodeid, **{property: value})
1009             elif key == ':link':
1010                 value = self.form[key].value
1011                 if type(value) != type([]): value = [value]
1012                 for value in value:
1013                     designator, property = value.split(':')
1014                     link, nodeid = hyperdb.splitDesignator(designator)
1015                     link = self.db.classes[link]
1016                     link.set(nodeid, **{property: nid})
1019 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
1020     '''Pull properties for the given class out of the form.
1021     '''
1022     props = {}
1023     keys = form.keys()
1024     for key in keys:
1025         if not cl.properties.has_key(key):
1026             continue
1027         proptype = cl.properties[key]
1028         if isinstance(proptype, hyperdb.String):
1029             value = form[key].value.strip()
1030         elif isinstance(proptype, hyperdb.Password):
1031             value = form[key].value.strip()
1032             if not value:
1033                 # ignore empty password values
1034                 continue
1035             value = password.Password(value)
1036         elif isinstance(proptype, hyperdb.Date):
1037             value = form[key].value.strip()
1038             if value:
1039                 value = date.Date(form[key].value.strip())
1040             else:
1041                 value = None
1042         elif isinstance(proptype, hyperdb.Interval):
1043             value = form[key].value.strip()
1044             if value:
1045                 value = date.Interval(form[key].value.strip())
1046             else:
1047                 value = None
1048         elif isinstance(proptype, hyperdb.Link):
1049             value = form[key].value.strip()
1050             # see if it's the "no selection" choice
1051             if value == '-1':
1052                 value = None
1053             else:
1054                 # handle key values
1055                 link = cl.properties[key].classname
1056                 if not num_re.match(value):
1057                     try:
1058                         value = db.classes[link].lookup(value)
1059                     except KeyError:
1060                         raise ValueError, _('property "%(propname)s": '
1061                             '%(value)s not a %(classname)s')%{'propname':key, 
1062                             'value': value, 'classname': link}
1063         elif isinstance(proptype, hyperdb.Multilink):
1064             value = form[key]
1065             if not isinstance(value, type([])):
1066                 value = [i.strip() for i in value.value.split(',')]
1067             else:
1068                 value = [i.value.strip() for i in value]
1069             link = cl.properties[key].classname
1070             l = []
1071             for entry in map(str, value):
1072                 if entry == '': continue
1073                 if not num_re.match(entry):
1074                     try:
1075                         entry = db.classes[link].lookup(entry)
1076                     except KeyError:
1077                         raise ValueError, _('property "%(propname)s": '
1078                             '"%(value)s" not an entry of %(classname)s')%{
1079                             'propname':key, 'value': entry, 'classname': link}
1080                 l.append(entry)
1081             l.sort()
1082             value = l
1083         elif isinstance(proptype, hyperdb.Boolean):
1084             value = form[key].value.strip()
1085             props[key] = value = value.lower() in ('yes', 'true', 'on', '1')
1086         elif isinstance(proptype, hyperdb.Number):
1087             value = form[key].value.strip()
1088             props[key] = value = int(value)
1090         # get the old value
1091         if nodeid:
1092             try:
1093                 existing = cl.get(nodeid, key)
1094             except KeyError:
1095                 # this might be a new property for which there is no existing
1096                 # value
1097                 if not cl.properties.has_key(key): raise
1099             # if changed, set it
1100             if value != existing:
1101                 props[key] = value
1102         else:
1103             props[key] = value
1104     return props