Code

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