Code

a9443765c1c6bfc7479336cd1b97021457bb20f4
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.11 2002-09-04 04:31:51 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             self.write(self.renderTemplate('page', '', ok_message=self.ok_message,
118                 error_message=self.error_message))
119         except Redirect, url:
120             # let's redirect - if the url isn't None, then we need to do
121             # the headers, otherwise the headers have been set before the
122             # exception was raised
123             if url:
124                 self.header({'Location': url}, response=302)
125         except SendFile, designator:
126             self.serve_file(designator)
127         except SendStaticFile, file:
128             self.serve_static_file(str(file))
129         except Unauthorised, message:
130             self.write(self.renderTemplate('page', '', 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         # bump the "revision" of the cookie since the format changed
158         if (cookie.has_key('roundup_user_2') and
159                 cookie['roundup_user_2'].value != 'deleted'):
161             # get the session key from the cookie
162             self.session = cookie['roundup_user_2'].value
163             # get the user from the session
164             try:
165                 # update the lifetime datestamp
166                 sessions.set(self.session, last_use=time.time())
167                 sessions.commit()
168                 user = sessions.get(self.session, 'user')
169             except KeyError:
170                 user = 'anonymous'
172         # sanity check on the user still being valid, getting the userid
173         # at the same time
174         try:
175             self.userid = self.db.user.lookup(user)
176         except (KeyError, TypeError):
177             user = 'anonymous'
179         # make sure the anonymous user is valid if we're using it
180         if user == 'anonymous':
181             self.make_user_anonymous()
182         else:
183             self.user = user
185         # reopen the database as the correct user
186         self.opendb(self.user)
188     def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
189         ''' Determine the context of this page:
191              home              (default if no url is given)
192              classname
193              designator        (classname and nodeid)
195             The desired template to be rendered is also determined There
196             are two exceptional contexts:
198              _file            - serve up a static file
199              path len > 1     - serve up a FileClass content
200                                 (the additional path gives the browser a
201                                  nicer filename to save as)
203             The template used is specified by the :template CGI variable,
204             which defaults to:
205              only classname suplied:          "index"
206              full item designator supplied:   "item"
208             We set:
209              self.classname  - the class to display, can be None
210              self.template   - the template to render the current context with
211              self.nodeid     - the nodeid of the class we're displaying
212         '''
213         # default the optional variables
214         self.classname = None
215         self.nodeid = None
217         # determine the classname and possibly nodeid
218         path = self.split_path
219         if not path or path[0] in ('', 'home', 'index'):
220             if self.form.has_key(':template'):
221                 self.template = self.form[':template'].value
222             else:
223                 self.template = ''
224             return
225         elif path[0] == '_file':
226             raise SendStaticFile, path[1]
227         else:
228             self.classname = path[0]
229             if len(path) > 1:
230                 # send the file identified by the designator in path[0]
231                 raise SendFile, path[0]
233         # see if we got a designator
234         m = dre.match(self.classname)
235         if m:
236             self.classname = m.group(1)
237             self.nodeid = m.group(2)
238             # with a designator, we default to item view
239             self.template = 'item'
240         else:
241             # with only a class, we default to index view
242             self.template = 'index'
244         # see if we have a template override
245         if self.form.has_key(':template'):
246             self.template = self.form[':template'].value
249         # see if we were passed in a message
250         if self.form.has_key(':ok_message'):
251             self.ok_message.append(self.form[':ok_message'].value)
252         if self.form.has_key(':error_message'):
253             self.error_message.append(self.form[':error_message'].value)
255     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
256         ''' Serve the file from the content property of the designated item.
257         '''
258         m = dre.match(str(designator))
259         if not m:
260             raise NotFound, str(designator)
261         classname, nodeid = m.group(1), m.group(2)
262         if classname != 'file':
263             raise NotFound, designator
265         # we just want to serve up the file named
266         file = self.db.file
267         self.header({'Content-Type': file.get(nodeid, 'type')})
268         self.write(file.get(nodeid, 'content'))
270     def serve_static_file(self, file):
271         # we just want to serve up the file named
272         mt = mimetypes.guess_type(str(file))[0]
273         self.header({'Content-Type': mt})
274         self.write(open(os.path.join(self.instance.TEMPLATES, file)).read())
276     def renderTemplate(self, name, extension, **kwargs):
277         ''' Return a PageTemplate for the named page
278         '''
279         pt = getTemplate(self.instance.TEMPLATES, name, extension)
280         # XXX handle PT rendering errors here more nicely
281         try:
282             # let the template render figure stuff out
283             return pt.render(self, None, None, **kwargs)
284         except PageTemplate.PTRuntimeError, message:
285             return '<strong>%s</strong><ol>%s</ol>'%(message,
286                 '<li>'.join(pt._v_errors))
287         except:
288             # everything else
289             return cgitb.html()
291     def content(self):
292         ''' Callback used by the page template to render the content of 
293             the page.
295             If we don't have a specific class to display, that is none was
296             determined in determine_context(), then we display a "home"
297             template.
298         '''
299         # now render the page content using the template we determined in
300         # determine_context
301         if self.classname is None:
302             name = 'home'
303         else:
304             name = self.classname
305         return self.renderTemplate(self.classname, self.template)
307     # these are the actions that are available
308     actions = {
309         'edit':     'editItemAction',
310         'editCSV':  'editCSVAction',
311         'new':      'newItemAction',
312         'register': 'registerAction',
313         'login':    'login_action',
314         'logout':   'logout_action',
315         'search':   'searchAction',
316     }
317     def handle_action(self):
318         ''' Determine whether there should be an _action called.
320             The action is defined by the form variable :action which
321             identifies the method on this object to call. The four basic
322             actions are defined in the "actions" dictionary on this class:
323              "edit"      -> self.editItemAction
324              "new"       -> self.newItemAction
325              "register"  -> self.registerAction
326              "login"     -> self.login_action
327              "logout"    -> self.logout_action
328              "search"    -> self.searchAction
330         '''
331         if not self.form.has_key(':action'):
332             return None
333         try:
334             # get the action, validate it
335             action = self.form[':action'].value
336             if not self.actions.has_key(action):
337                 raise ValueError, 'No such action "%s"'%action
339             # call the mapped action
340             getattr(self, self.actions[action])()
341         except Redirect:
342             raise
343         except:
344             self.db.rollback()
345             s = StringIO.StringIO()
346             traceback.print_exc(None, s)
347             self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
349     def write(self, content):
350         if not self.headers_done:
351             self.header()
352         self.request.wfile.write(content)
354     def header(self, headers=None, response=200):
355         '''Put up the appropriate header.
356         '''
357         if headers is None:
358             headers = {'Content-Type':'text/html'}
359         if not headers.has_key('Content-Type'):
360             headers['Content-Type'] = 'text/html'
361         self.request.send_response(response)
362         for entry in headers.items():
363             self.request.send_header(*entry)
364         self.request.end_headers()
365         self.headers_done = 1
366         if self.debug:
367             self.headers_sent = headers
369     def set_cookie(self, user, password):
370         # TODO generate a much, much stronger session key ;)
371         self.session = binascii.b2a_base64(repr(time.time())).strip()
373         # clean up the base64
374         if self.session[-1] == '=':
375             if self.session[-2] == '=':
376                 self.session = self.session[:-2]
377             else:
378                 self.session = self.session[:-1]
380         # insert the session in the sessiondb
381         self.db.sessions.set(self.session, user=user, last_use=time.time())
383         # and commit immediately
384         self.db.sessions.commit()
386         # expire us in a long, long time
387         expire = Cookie._getdate(86400*365)
389         # generate the cookie path - make sure it has a trailing '/'
390         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
391             ''))
392         self.header({'Set-Cookie': 'roundup_user_2=%s; expires=%s; Path=%s;'%(
393             self.session, expire, path)})
395     def make_user_anonymous(self):
396         ''' Make us anonymous
398             This method used to handle non-existence of the 'anonymous'
399             user, but that user is mandatory now.
400         '''
401         self.userid = self.db.user.lookup('anonymous')
402         self.user = 'anonymous'
404     def logout(self):
405         ''' Make us really anonymous - nuke the cookie too
406         '''
407         self.make_user_anonymous()
409         # construct the logout cookie
410         now = Cookie._getdate()
411         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
412             ''))
413         self.header({'Set-Cookie':
414             'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
415             path)})
416         self.login()
418     def opendb(self, user):
419         ''' Open the database.
420         '''
421         # open the db if the user has changed
422         if not hasattr(self, 'db') or user != self.db.journaltag:
423             self.db = self.instance.open(user)
425     #
426     # Actions
427     #
428     def login_action(self):
429         ''' Attempt to log a user in and set the cookie
430         '''
431         # we need the username at a minimum
432         if not self.form.has_key('__login_name'):
433             self.error_message.append(_('Username required'))
434             return
436         self.user = self.form['__login_name'].value
437         # re-open the database for real, using the user
438         self.opendb(self.user)
439         if self.form.has_key('__login_password'):
440             password = self.form['__login_password'].value
441         else:
442             password = ''
443         # make sure the user exists
444         try:
445             self.userid = self.db.user.lookup(self.user)
446         except KeyError:
447             name = self.user
448             self.make_user_anonymous()
449             self.error_message.append(_('No such user "%(name)s"')%locals())
450             return
452         # and that the password is correct
453         pw = self.db.user.get(self.userid, 'password')
454         if password != pw:
455             self.make_user_anonymous()
456             self.error_message.append(_('Incorrect password'))
457             return
459         # set the session cookie
460         self.set_cookie(self.user, password)
462     def logout_action(self):
463         ''' Make us really anonymous - nuke the cookie too
464         '''
465         # log us out
466         self.make_user_anonymous()
468         # construct the logout cookie
469         now = Cookie._getdate()
470         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
471             ''))
472         self.header(headers={'Set-Cookie':
473           'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)})
475         # Let the user know what's going on
476         self.ok_message.append(_('You are logged out'))
478     def registerAction(self):
479         '''Attempt to create a new user based on the contents of the form
480         and then set the cookie.
482         return 1 on successful login
483         '''
484         # create the new user
485         cl = self.db.user
487         # parse the props from the form
488         try:
489             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
490         except (ValueError, KeyError), message:
491             self.error_message.append(_('Error: ') + str(message))
492             return
494         # make sure we're allowed to register
495         if not self.registerPermission(props):
496             raise Unauthorised, _("You do not have permission to register")
498         # re-open the database as "admin"
499         if self.user != 'admin':
500             self.opendb('admin')
501             
502         # create the new user
503         cl = self.db.user
504         try:
505             props = parsePropsFromForm(self.db, cl, self.form)
506             props['roles'] = self.instance.NEW_WEB_USER_ROLES
507             self.userid = cl.create(**props)
508             self.db.commit()
509         except ValueError, message:
510             self.error_message.append(message)
512         # log the new user in
513         self.user = cl.get(self.userid, 'username')
514         # re-open the database for real, using the user
515         self.opendb(self.user)
516         password = self.db.user.get(self.userid, 'password')
517         self.set_cookie(self.user, password)
519         # nice message
520         self.ok_message.append(_('You are now registered, welcome!'))
522     def registerPermission(self, props):
523         ''' Determine whether the user has permission to register
525             Base behaviour is to check the user has "Web Registration".
526         '''
527         # registration isn't allowed to supply roles
528         if props.has_key('roles'):
529             return 0
530         if self.db.security.hasPermission('Web Registration', self.userid):
531             return 1
532         return 0
534     def editItemAction(self):
535         ''' Perform an edit of an item in the database.
537             Some special form elements:
539             :link=designator:property
540             :multilink=designator:property
541              The value specifies a node designator and the property on that
542              node to add _this_ node to as a link or multilink.
543             __note
544              Create a message and attach it to the current node's
545              "messages" property.
546             __file
547              Create a file and attach it to the current node's
548              "files" property. Attach the file to the message created from
549              the __note if it's supplied.
550         '''
551         cl = self.db.classes[self.classname]
553         # parse the props from the form
554         try:
555             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
556         except (ValueError, KeyError), message:
557             self.error_message.append(_('Error: ') + str(message))
558             return
560         # check permission
561         if not self.editItemPermission(props):
562             self.error_message.append(
563                 _('You do not have permission to edit %(classname)s'%
564                 self.__dict__))
565             return
567         # perform the edit
568         try:
569             # make changes to the node
570             props = self._changenode(props)
571             # handle linked nodes 
572             self._post_editnode(self.nodeid)
573         except (ValueError, KeyError), message:
574             self.error_message.append(_('Error: ') + str(message))
575             return
577         # commit now that all the tricky stuff is done
578         self.db.commit()
580         # and some nice feedback for the user
581         if props:
582             message = _('%(changes)s edited ok')%{'changes':
583                 ', '.join(props.keys())}
584         elif self.form.has_key('__note') and self.form['__note'].value:
585             message = _('note added')
586         elif (self.form.has_key('__file') and self.form['__file'].filename):
587             message = _('file added')
588         else:
589             message = _('nothing changed')
591         # redirect to the item's edit page
592         raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
593             self.nodeid,  urllib.quote(message))
595     def editItemPermission(self, props):
596         ''' Determine whether the user has permission to edit this item.
598             Base behaviour is to check the user can edit this class. If we're
599             editing the "user" class, users are allowed to edit their own
600             details. Unless it's the "roles" property, which requires the
601             special Permission "Web Roles".
602         '''
603         # if this is a user node and the user is editing their own node, then
604         # we're OK
605         has = self.db.security.hasPermission
606         if self.classname == 'user':
607             # reject if someone's trying to edit "roles" and doesn't have the
608             # right permission.
609             if props.has_key('roles') and not has('Web Roles', self.userid,
610                     'user'):
611                 return 0
612             # if the item being edited is the current user, we're ok
613             if self.nodeid == self.userid:
614                 return 1
615         if self.db.security.hasPermission('Edit', self.userid, self.classname):
616             return 1
617         return 0
619     def newItemAction(self):
620         ''' Add a new item to the database.
622             This follows the same form as the editItemAction
623         '''
624         cl = self.db.classes[self.classname]
626         # parse the props from the form
627         try:
628             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
629         except (ValueError, KeyError), message:
630             self.error_message.append(_('Error: ') + str(message))
631             return
633         if not self.newItemPermission(props):
634             self.error_message.append(
635                 _('You do not have permission to create %s' %self.classname))
637         # create a little extra message for anticipated :link / :multilink
638         if self.form.has_key(':multilink'):
639             link = self.form[':multilink'].value
640         elif self.form.has_key(':link'):
641             link = self.form[':multilink'].value
642         else:
643             link = None
644             xtra = ''
645         if link:
646             designator, linkprop = link.split(':')
647             xtra = ' for <a href="%s">%s</a>'%(designator, designator)
649         try:
650             # do the create
651             nid = self._createnode(props)
653             # handle linked nodes 
654             self._post_editnode(nid)
656             # commit now that all the tricky stuff is done
657             self.db.commit()
659             # render the newly created item
660             self.nodeid = nid
662             # and some nice feedback for the user
663             message = _('%(classname)s created ok')%self.__dict__ + xtra
664         except (ValueError, KeyError), message:
665             self.error_message.append(_('Error: ') + str(message))
666             return
667         except:
668             # oops
669             self.db.rollback()
670             s = StringIO.StringIO()
671             traceback.print_exc(None, s)
672             self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
673             return
675         # redirect to the new item's page
676         raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
677             nid,  urllib.quote(message))
679     def newItemPermission(self, props):
680         ''' Determine whether the user has permission to create (edit) this
681             item.
683             Base behaviour is to check the user can edit this class. No
684             additional property checks are made. Additionally, new user items
685             may be created if the user has the "Web Registration" Permission.
686         '''
687         has = self.db.security.hasPermission
688         if self.classname == 'user' and has('Web Registration', self.userid,
689                 'user'):
690             return 1
691         if has('Edit', self.userid, self.classname):
692             return 1
693         return 0
695     def editCSVAction(self):
696         ''' Performs an edit of all of a class' items in one go.
698             The "rows" CGI var defines the CSV-formatted entries for the
699             class. New nodes are identified by the ID 'X' (or any other
700             non-existent ID) and removed lines are retired.
701         '''
702         # this is per-class only
703         if not self.editCSVPermission():
704             self.error_message.append(
705                 _('You do not have permission to edit %s' %self.classname))
707         # get the CSV module
708         try:
709             import csv
710         except ImportError:
711             self.error_message.append(_(
712                 'Sorry, you need the csv module to use this function.<br>\n'
713                 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
714             return
716         cl = self.db.classes[self.classname]
717         idlessprops = cl.getprops(protected=0).keys()
718         idlessprops.sort()
719         props = ['id'] + idlessprops
721         # do the edit
722         rows = self.form['rows'].value.splitlines()
723         p = csv.parser()
724         found = {}
725         line = 0
726         for row in rows[1:]:
727             line += 1
728             values = p.parse(row)
729             # not a complete row, keep going
730             if not values: continue
732             # skip property names header
733             if values == props:
734                 continue
736             # extract the nodeid
737             nodeid, values = values[0], values[1:]
738             found[nodeid] = 1
740             # confirm correct weight
741             if len(idlessprops) != len(values):
742                 self.error_message.append(
743                     _('Not enough values on line %(line)s')%{'line':line})
744                 return
746             # extract the new values
747             d = {}
748             for name, value in zip(idlessprops, values):
749                 value = value.strip()
750                 # only add the property if it has a value
751                 if value:
752                     # if it's a multilink, split it
753                     if isinstance(cl.properties[name], hyperdb.Multilink):
754                         value = value.split(':')
755                     d[name] = value
757             # perform the edit
758             if cl.hasnode(nodeid):
759                 # edit existing
760                 cl.set(nodeid, **d)
761             else:
762                 # new node
763                 found[cl.create(**d)] = 1
765         # retire the removed entries
766         for nodeid in cl.list():
767             if not found.has_key(nodeid):
768                 cl.retire(nodeid)
770         # all OK
771         self.db.commit()
773         self.ok_message.append(_('Items edited OK'))
775     def editCSVPermission(self):
776         ''' Determine whether the user has permission to edit this class.
778             Base behaviour is to check the user can edit this class.
779         ''' 
780         if not self.db.security.hasPermission('Edit', self.userid,
781                 self.classname):
782             return 0
783         return 1
785     def searchAction(self):
786         ''' Mangle some of the form variables.
788             Set the form ":filter" variable based on the values of the
789             filter variables - if they're set to anything other than
790             "dontcare" then add them to :filter.
792             Also handle the ":queryname" variable and save off the query to
793             the user's query list.
794         '''
795         # generic edit is per-class only
796         if not self.searchPermission():
797             self.error_message.append(
798                 _('You do not have permission to search %s' %self.classname))
800         # add a faked :filter form variable for each filtering prop
801         props = self.db.classes[self.classname].getprops()
802         for key in self.form.keys():
803             if not props.has_key(key): continue
804             if not self.form[key].value: continue
805             self.form.value.append(cgi.MiniFieldStorage(':filter', key))
807         # handle saving the query params
808         if self.form.has_key(':queryname'):
809             queryname = self.form[':queryname'].value.strip()
810             if queryname:
811                 # parse the environment and figure what the query _is_
812                 req = HTMLRequest(self)
813                 url = req.indexargs_href('', {})
815                 # handle editing an existing query
816                 try:
817                     qid = self.db.query.lookup(queryname)
818                     self.db.query.set(qid, klass=self.classname, url=url)
819                 except KeyError:
820                     # create a query
821                     qid = self.db.query.create(name=queryname,
822                         klass=self.classname, url=url)
824                     # and add it to the user's query multilink
825                     queries = self.db.user.get(self.userid, 'queries')
826                     queries.append(qid)
827                     self.db.user.set(self.userid, queries=queries)
829                 # commit the query change to the database
830                 self.db.commit()
833     def searchPermission(self):
834         ''' Determine whether the user has permission to search this class.
836             Base behaviour is to check the user can view this class.
837         ''' 
838         if not self.db.security.hasPermission('View', self.userid,
839                 self.classname):
840             return 0
841         return 1
843     def XXXremove_action(self,  dre=re.compile(r'([^\d]+)(\d+)')):
844         # XXX I believe this could be handled by a regular edit action that
845         # just sets the multilink...
846         # XXX handle this !
847         target = self.index_arg(':target')[0]
848         m = dre.match(target)
849         if m:
850             classname = m.group(1)
851             nodeid = m.group(2)
852             cl = self.db.getclass(classname)
853             cl.retire(nodeid)
854             # now take care of the reference
855             parentref =  self.index_arg(':multilink')[0]
856             parent, prop = parentref.split(':')
857             m = dre.match(parent)
858             if m:
859                 self.classname = m.group(1)
860                 self.nodeid = m.group(2)
861                 cl = self.db.getclass(self.classname)
862                 value = cl.get(self.nodeid, prop)
863                 value.remove(nodeid)
864                 cl.set(self.nodeid, **{prop:value})
865                 func = getattr(self, 'show%s'%self.classname)
866                 return func()
867             else:
868                 raise NotFound, parent
869         else:
870             raise NotFound, target
872     #
873     #  Utility methods for editing
874     #
875     def _changenode(self, props):
876         ''' change the node based on the contents of the form
877         '''
878         cl = self.db.classes[self.classname]
880         # create the message
881         message, files = self._handle_message()
882         if message:
883             props['messages'] = cl.get(self.nodeid, 'messages') + [message]
884         if files:
885             props['files'] = cl.get(self.nodeid, 'files') + files
887         # make the changes
888         return cl.set(self.nodeid, **props)
890     def _createnode(self, props):
891         ''' create a node based on the contents of the form
892         '''
893         cl = self.db.classes[self.classname]
895         # check for messages and files
896         message, files = self._handle_message()
897         if message:
898             props['messages'] = [message]
899         if files:
900             props['files'] = files
901         # create the node and return it's id
902         return cl.create(**props)
904     def _handle_message(self):
905         ''' generate an edit message
906         '''
907         # handle file attachments 
908         files = []
909         if self.form.has_key('__file'):
910             file = self.form['__file']
911             if file.filename:
912                 filename = file.filename.split('\\')[-1]
913                 mime_type = mimetypes.guess_type(filename)[0]
914                 if not mime_type:
915                     mime_type = "application/octet-stream"
916                 # create the new file entry
917                 files.append(self.db.file.create(type=mime_type,
918                     name=filename, content=file.file.read()))
920         # we don't want to do a message if none of the following is true...
921         cn = self.classname
922         cl = self.db.classes[self.classname]
923         props = cl.getprops()
924         note = None
925         # in a nutshell, don't do anything if there's no note or there's no
926         # NOSY
927         if self.form.has_key('__note'):
928             note = self.form['__note'].value.strip()
929         if not note:
930             return None, files
931         if not props.has_key('messages'):
932             return None, files
933         if not isinstance(props['messages'], hyperdb.Multilink):
934             return None, files
935         if not props['messages'].classname == 'msg':
936             return None, files
937         if not (self.form.has_key('nosy') or note):
938             return None, files
940         # handle the note
941         if '\n' in note:
942             summary = re.split(r'\n\r?', note)[0]
943         else:
944             summary = note
945         m = ['%s\n'%note]
947         # handle the messageid
948         # TODO: handle inreplyto
949         messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
950             self.classname, self.instance.MAIL_DOMAIN)
952         # now create the message, attaching the files
953         content = '\n'.join(m)
954         message_id = self.db.msg.create(author=self.userid,
955             recipients=[], date=date.Date('.'), summary=summary,
956             content=content, files=files, messageid=messageid)
958         # update the messages property
959         return message_id, files
961     def _post_editnode(self, nid):
962         '''Do the linking part of the node creation.
964            If a form element has :link or :multilink appended to it, its
965            value specifies a node designator and the property on that node
966            to add _this_ node to as a link or multilink.
968            This is typically used on, eg. the file upload page to indicated
969            which issue to link the file to.
971            TODO: I suspect that this and newfile will go away now that
972            there's the ability to upload a file using the issue __file form
973            element!
974         '''
975         cn = self.classname
976         cl = self.db.classes[cn]
977         # link if necessary
978         keys = self.form.keys()
979         for key in keys:
980             if key == ':multilink':
981                 value = self.form[key].value
982                 if type(value) != type([]): value = [value]
983                 for value in value:
984                     designator, property = value.split(':')
985                     link, nodeid = hyperdb.splitDesignator(designator)
986                     link = self.db.classes[link]
987                     # take a dupe of the list so we're not changing the cache
988                     value = link.get(nodeid, property)[:]
989                     value.append(nid)
990                     link.set(nodeid, **{property: value})
991             elif key == ':link':
992                 value = self.form[key].value
993                 if type(value) != type([]): value = [value]
994                 for value in value:
995                     designator, property = value.split(':')
996                     link, nodeid = hyperdb.splitDesignator(designator)
997                     link = self.db.classes[link]
998                     link.set(nodeid, **{property: nid})
1001 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
1002     '''Pull properties for the given class out of the form.
1003     '''
1004     props = {}
1005     keys = form.keys()
1006     for key in keys:
1007         if not cl.properties.has_key(key):
1008             continue
1009         proptype = cl.properties[key]
1010         if isinstance(proptype, hyperdb.String):
1011             value = form[key].value.strip()
1012         elif isinstance(proptype, hyperdb.Password):
1013             value = form[key].value.strip()
1014             if not value:
1015                 # ignore empty password values
1016                 continue
1017             value = password.Password(value)
1018         elif isinstance(proptype, hyperdb.Date):
1019             value = form[key].value.strip()
1020             if value:
1021                 value = date.Date(form[key].value.strip())
1022             else:
1023                 value = None
1024         elif isinstance(proptype, hyperdb.Interval):
1025             value = form[key].value.strip()
1026             if value:
1027                 value = date.Interval(form[key].value.strip())
1028             else:
1029                 value = None
1030         elif isinstance(proptype, hyperdb.Link):
1031             value = form[key].value.strip()
1032             # see if it's the "no selection" choice
1033             if value == '-1':
1034                 value = None
1035             else:
1036                 # handle key values
1037                 link = cl.properties[key].classname
1038                 if not num_re.match(value):
1039                     try:
1040                         value = db.classes[link].lookup(value)
1041                     except KeyError:
1042                         raise ValueError, _('property "%(propname)s": '
1043                             '%(value)s not a %(classname)s')%{'propname':key, 
1044                             'value': value, 'classname': link}
1045         elif isinstance(proptype, hyperdb.Multilink):
1046             value = form[key]
1047             if not isinstance(value, type([])):
1048                 value = [i.strip() for i in value.value.split(',')]
1049             else:
1050                 value = [i.value.strip() for i in value]
1051             link = cl.properties[key].classname
1052             l = []
1053             for entry in map(str, value):
1054                 if entry == '': continue
1055                 if not num_re.match(entry):
1056                     try:
1057                         entry = db.classes[link].lookup(entry)
1058                     except KeyError:
1059                         raise ValueError, _('property "%(propname)s": '
1060                             '"%(value)s" not an entry of %(classname)s')%{
1061                             'propname':key, 'value': entry, 'classname': link}
1062                 l.append(entry)
1063             l.sort()
1064             value = l
1065         elif isinstance(proptype, hyperdb.Boolean):
1066             value = form[key].value.strip()
1067             props[key] = value = value.lower() in ('yes', 'true', 'on', '1')
1068         elif isinstance(proptype, hyperdb.Number):
1069             value = form[key].value.strip()
1070             props[key] = value = int(value)
1072         # get the old value
1073         if nodeid:
1074             try:
1075                 existing = cl.get(nodeid, key)
1076             except KeyError:
1077                 # this might be a new property for which there is no existing
1078                 # value
1079                 if not cl.properties.has_key(key): raise
1081             # if changed, set it
1082             if value != existing:
1083                 props[key] = value
1084         else:
1085             props[key] = value
1086     return props