Code

Keep a cache of compiled PageTemplates.
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.7 2002-09-03 02:58:11 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             # and self.template_name)
115             self.handle_action()
116             # now render the page
117             self.write(self.template('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(file)
129         except Unauthorised, message:
130             self.write(self.template('page.unauthorised',
131                 error_message=message))
132         except:
133             # everything else
134             self.write(cgitb.html())
136     def determine_user(self):
137         ''' Determine who the user is
138         '''
139         # determine the uid to use
140         self.opendb('admin')
142         # make sure we have the session Class
143         sessions = self.db.sessions
145         # age sessions, remove when they haven't been used for a week
146         # TODO: this shouldn't be done every access
147         week = 60*60*24*7
148         now = time.time()
149         for sessid in sessions.list():
150             interval = now - sessions.get(sessid, 'last_use')
151             if interval > week:
152                 sessions.destroy(sessid)
154         # look up the user session cookie
155         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
156         user = 'anonymous'
158         if (cookie.has_key('roundup_user') and
159                 cookie['roundup_user'].value != 'deleted'):
161             # get the session key from the cookie
162             self.session = cookie['roundup_user'].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
210              self.nodeid
211              self.template_name
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_type = self.form[':template'].value
222                 self.template_name = 'home' + '.' + self.template_type
223             else:
224                 self.template_type = ''
225                 self.template_name = 'home'
226             return
227         elif path[0] == '_file':
228             raise SendStaticFile, path[1]
229         else:
230             self.classname = path[0]
231             if len(path) > 1:
232                 # send the file identified by the designator in path[0]
233                 raise SendFile, path[0]
235         # see if we got a designator
236         m = dre.match(self.classname)
237         if m:
238             self.classname = m.group(1)
239             self.nodeid = m.group(2)
240             # with a designator, we default to item view
241             self.template_type = 'item'
242         else:
243             # with only a class, we default to index view
244             self.template_type = 'index'
246         # see if we have a template override
247         if self.form.has_key(':template'):
248             self.template_type = self.form[':template'].value
251         # see if we were passed in a message
252         if self.form.has_key(':ok_message'):
253             self.ok_message.append(self.form[':ok_message'].value)
254         if self.form.has_key(':error_message'):
255             self.error_message.append(self.form[':error_message'].value)
257         # we have the template name now
258         self.template_name = self.classname + '.' + self.template_type
260     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
261         ''' Serve the file from the content property of the designated item.
262         '''
263         m = dre.match(str(designator))
264         if not m:
265             raise NotFound, str(designator)
266         classname, nodeid = m.group(1), m.group(2)
267         if classname != 'file':
268             raise NotFound, designator
270         # we just want to serve up the file named
271         file = self.db.file
272         self.header({'Content-Type': file.get(nodeid, 'type')})
273         self.write(file.get(nodeid, 'content'))
275     def serve_static_file(self, file):
276         # we just want to serve up the file named
277         mt = mimetypes.guess_type(str(file))[0]
278         self.header({'Content-Type': mt})
279         self.write(open(os.path.join(self.instance.TEMPLATES, file)).read())
281     def template(self, name, **kwargs):
282         ''' Return a PageTemplate for the named page
283         '''
284         pt = getTemplate(self.instance.TEMPLATES, name)
285         # XXX handle PT rendering errors here more nicely
286         try:
287             # let the template render figure stuff out
288             return pt.render(self, None, None, **kwargs)
289         except PageTemplate.PTRuntimeError, message:
290             return '<strong>%s</strong><ol>%s</ol>'%(message,
291                 '<li>'.join(pt._v_errors))
292         except:
293             # everything else
294             return cgitb.html()
296     def content(self):
297         ''' Callback used by the page template to render the content of 
298             the page.
299         '''
300         # now render the page content using the template we determined in
301         # determine_context
302         return self.template(self.template_name)
304     # these are the actions that are available
305     actions = {
306         'edit':     'editItemAction',
307         'new':      'newItemAction',
308         'register': 'registerAction',
309         'login':    'login_action',
310         'logout':   'logout_action',
311         'search':   'searchAction',
312     }
313     def handle_action(self):
314         ''' Determine whether there should be an _action called.
316             The action is defined by the form variable :action which
317             identifies the method on this object to call. The four basic
318             actions are defined in the "actions" dictionary on this class:
319              "edit"      -> self.editItemAction
320              "new"       -> self.newItemAction
321              "register"  -> self.registerAction
322              "login"     -> self.login_action
323              "logout"    -> self.logout_action
324              "search"    -> self.searchAction
326         '''
327         if not self.form.has_key(':action'):
328             return None
329         try:
330             # get the action, validate it
331             action = self.form[':action'].value
332             if not self.actions.has_key(action):
333                 raise ValueError, 'No such action "%s"'%action
335             # call the mapped action
336             getattr(self, self.actions[action])()
337         except Redirect:
338             raise
339         except:
340             self.db.rollback()
341             s = StringIO.StringIO()
342             traceback.print_exc(None, s)
343             self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
345     def write(self, content):
346         if not self.headers_done:
347             self.header()
348         self.request.wfile.write(content)
350     def header(self, headers=None, response=200):
351         '''Put up the appropriate header.
352         '''
353         if headers is None:
354             headers = {'Content-Type':'text/html'}
355         if not headers.has_key('Content-Type'):
356             headers['Content-Type'] = 'text/html'
357         self.request.send_response(response)
358         for entry in headers.items():
359             self.request.send_header(*entry)
360         self.request.end_headers()
361         self.headers_done = 1
362         if self.debug:
363             self.headers_sent = headers
365     def set_cookie(self, user, password):
366         # TODO generate a much, much stronger session key ;)
367         self.session = binascii.b2a_base64(repr(time.time())).strip()
369         # clean up the base64
370         if self.session[-1] == '=':
371             if self.session[-2] == '=':
372                 self.session = self.session[:-2]
373             else:
374                 self.session = self.session[:-1]
376         # insert the session in the sessiondb
377         self.db.sessions.set(self.session, user=user, last_use=time.time())
379         # and commit immediately
380         self.db.sessions.commit()
382         # expire us in a long, long time
383         expire = Cookie._getdate(86400*365)
385         # generate the cookie path - make sure it has a trailing '/'
386         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
387             ''))
388         self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;'%(
389             self.session, expire, path)})
391     def make_user_anonymous(self):
392         ''' Make us anonymous
394             This method used to handle non-existence of the 'anonymous'
395             user, but that user is mandatory now.
396         '''
397         self.userid = self.db.user.lookup('anonymous')
398         self.user = 'anonymous'
400     def logout(self):
401         ''' Make us really anonymous - nuke the cookie too
402         '''
403         self.make_user_anonymous()
405         # construct the logout cookie
406         now = Cookie._getdate()
407         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
408             ''))
409         self.header({'Set-Cookie':
410             'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
411             path)})
412         self.login()
414     def opendb(self, user):
415         ''' Open the database.
416         '''
417         # open the db if the user has changed
418         if not hasattr(self, 'db') or user != self.db.journaltag:
419             self.db = self.instance.open(user)
421     #
422     # Actions
423     #
424     def login_action(self):
425         ''' Attempt to log a user in and set the cookie
426         '''
427         # we need the username at a minimum
428         if not self.form.has_key('__login_name'):
429             self.error_message.append(_('Username required'))
430             return
432         self.user = self.form['__login_name'].value
433         # re-open the database for real, using the user
434         self.opendb(self.user)
435         if self.form.has_key('__login_password'):
436             password = self.form['__login_password'].value
437         else:
438             password = ''
439         # make sure the user exists
440         try:
441             self.userid = self.db.user.lookup(self.user)
442         except KeyError:
443             name = self.user
444             self.make_user_anonymous()
445             self.error_message.append(_('No such user "%(name)s"')%locals())
446             return
448         # and that the password is correct
449         pw = self.db.user.get(self.userid, 'password')
450         if password != pw:
451             self.make_user_anonymous()
452             self.error_message.append(_('Incorrect password'))
453             return
455         # set the session cookie
456         self.set_cookie(self.user, password)
458     def logout_action(self):
459         ''' Make us really anonymous - nuke the cookie too
460         '''
461         # log us out
462         self.make_user_anonymous()
464         # construct the logout cookie
465         now = Cookie._getdate()
466         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
467             ''))
468         self.header(headers={'Set-Cookie':
469           'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)})
471         # Let the user know what's going on
472         self.ok_message.append(_('You are logged out'))
474     def registerAction(self):
475         '''Attempt to create a new user based on the contents of the form
476         and then set the cookie.
478         return 1 on successful login
479         '''
480         # create the new user
481         cl = self.db.user
483         # parse the props from the form
484         try:
485             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
486         except (ValueError, KeyError), message:
487             self.error_message.append(_('Error: ') + str(message))
488             return
490         # make sure we're allowed to register
491         if not self.registerPermission(props):
492             raise Unauthorised, _("You do not have permission to register")
494         # re-open the database as "admin"
495         if self.user != 'admin':
496             self.opendb('admin')
497             
498         # create the new user
499         cl = self.db.user
500         try:
501             props = parsePropsFromForm(self.db, cl, self.form)
502             props['roles'] = self.instance.NEW_WEB_USER_ROLES
503             self.userid = cl.create(**props)
504             self.db.commit()
505         except ValueError, message:
506             self.error_message.append(message)
508         # log the new user in
509         self.user = cl.get(self.userid, 'username')
510         # re-open the database for real, using the user
511         self.opendb(self.user)
512         password = self.db.user.get(self.userid, 'password')
513         self.set_cookie(self.user, password)
515         # nice message
516         self.ok_message.append(_('You are now registered, welcome!'))
518     def registerPermission(self, props):
519         ''' Determine whether the user has permission to register
521             Base behaviour is to check the user has "Web Registration".
522         '''
523         # registration isn't allowed to supply roles
524         if props.has_key('roles'):
525             return 0
526         if self.db.security.hasPermission('Web Registration', self.userid):
527             return 1
528         return 0
530     def editItemAction(self):
531         ''' Perform an edit of an item in the database.
533             Some special form elements:
535             :link=designator:property
536             :multilink=designator:property
537              The value specifies a node designator and the property on that
538              node to add _this_ node to as a link or multilink.
539             __note
540              Create a message and attach it to the current node's
541              "messages" property.
542             __file
543              Create a file and attach it to the current node's
544              "files" property. Attach the file to the message created from
545              the __note if it's supplied.
546         '''
547         cl = self.db.classes[self.classname]
549         # parse the props from the form
550         try:
551             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
552         except (ValueError, KeyError), message:
553             self.error_message.append(_('Error: ') + str(message))
554             return
556         # check permission
557         if not self.editItemPermission(props):
558             self.error_message.append(
559                 _('You do not have permission to edit %(classname)s'%
560                 self.__dict__))
561             return
563         # perform the edit
564         try:
565             # make changes to the node
566             props = self._changenode(props)
567             # handle linked nodes 
568             self._post_editnode(self.nodeid)
569         except (ValueError, KeyError), message:
570             self.error_message.append(_('Error: ') + str(message))
571             return
573         # commit now that all the tricky stuff is done
574         self.db.commit()
576         # and some nice feedback for the user
577         if props:
578             message = _('%(changes)s edited ok')%{'changes':
579                 ', '.join(props.keys())}
580         elif self.form.has_key('__note') and self.form['__note'].value:
581             message = _('note added')
582         elif (self.form.has_key('__file') and self.form['__file'].filename):
583             message = _('file added')
584         else:
585             message = _('nothing changed')
587         # redirect to the item's edit page
588         raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
589             self.nodeid,  urllib.quote(message))
591     def editItemPermission(self, props):
592         ''' Determine whether the user has permission to edit this item.
594             Base behaviour is to check the user can edit this class. If we're
595             editing the "user" class, users are allowed to edit their own
596             details. Unless it's the "roles" property, which requires the
597             special Permission "Web Roles".
598         '''
599         # if this is a user node and the user is editing their own node, then
600         # we're OK
601         has = self.db.security.hasPermission
602         if self.classname == 'user':
603             # reject if someone's trying to edit "roles" and doesn't have the
604             # right permission.
605             if props.has_key('roles') and not has('Web Roles', self.userid,
606                     'user'):
607                 return 0
608             # if the item being edited is the current user, we're ok
609             if self.nodeid == self.userid:
610                 return 1
611         if self.db.security.hasPermission('Edit', self.userid, self.classname):
612             return 1
613         return 0
615     def newItemAction(self):
616         ''' Add a new item to the database.
618             This follows the same form as the editItemAction
619         '''
620         cl = self.db.classes[self.classname]
622         # parse the props from the form
623         try:
624             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
625         except (ValueError, KeyError), message:
626             self.error_message.append(_('Error: ') + str(message))
627             return
629         if not self.newItemPermission(props):
630             self.error_message.append(
631                 _('You do not have permission to create %s' %self.classname))
633         # XXX
634 #        cl = self.db.classes[cn]
635 #        if self.form.has_key(':multilink'):
636 #            link = self.form[':multilink'].value
637 #            designator, linkprop = link.split(':')
638 #            xtra = ' for <a href="%s">%s</a>' % (designator, designator)
639 #        else:
640 #            xtra = ''
642         try:
643             # do the create
644             nid = self._createnode(props)
646             # handle linked nodes 
647             self._post_editnode(nid)
649             # commit now that all the tricky stuff is done
650             self.db.commit()
652             # render the newly created item
653             self.nodeid = nid
655             # and some nice feedback for the user
656             message = _('%(classname)s created ok')%self.__dict__
657         except (ValueError, KeyError), message:
658             self.error_message.append(_('Error: ') + str(message))
659             return
660         except:
661             # oops
662             self.db.rollback()
663             s = StringIO.StringIO()
664             traceback.print_exc(None, s)
665             self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
666             return
668         # redirect to the new item's page
669         raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
670             nid,  urllib.quote(message))
672     def newItemPermission(self, props):
673         ''' Determine whether the user has permission to create (edit) this
674             item.
676             Base behaviour is to check the user can edit this class. No
677             additional property checks are made. Additionally, new user items
678             may be created if the user has the "Web Registration" Permission.
679         '''
680         has = self.db.security.hasPermission
681         if self.classname == 'user' and has('Web Registration', self.userid,
682                 'user'):
683             return 1
684         if has('Edit', self.userid, self.classname):
685             return 1
686         return 0
688     def genericEditAction(self):
689         ''' Performs an edit of all of a class' items in one go.
691             The "rows" CGI var defines the CSV-formatted entries for the
692             class. New nodes are identified by the ID 'X' (or any other
693             non-existent ID) and removed lines are retired.
694         '''
695         # generic edit is per-class only
696         if not self.genericEditPermission():
697             self.error_message.append(
698                 _('You do not have permission to edit %s' %self.classname))
700         # get the CSV module
701         try:
702             import csv
703         except ImportError:
704             self.error_message.append(_(
705                 'Sorry, you need the csv module to use this function.<br>\n'
706                 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
707             return
709         cl = self.db.classes[self.classname]
710         idlessprops = cl.getprops(protected=0).keys()
711         props = ['id'] + idlessprops
713         # do the edit
714         rows = self.form['rows'].value.splitlines()
715         p = csv.parser()
716         found = {}
717         line = 0
718         for row in rows:
719             line += 1
720             values = p.parse(row)
721             # not a complete row, keep going
722             if not values: continue
724             # extract the nodeid
725             nodeid, values = values[0], values[1:]
726             found[nodeid] = 1
728             # confirm correct weight
729             if len(idlessprops) != len(values):
730                 message=(_('Not enough values on line %(line)s'%{'line':line}))
731                 return
733             # extract the new values
734             d = {}
735             for name, value in zip(idlessprops, values):
736                 value = value.strip()
737                 # only add the property if it has a value
738                 if value:
739                     # if it's a multilink, split it
740                     if isinstance(cl.properties[name], hyperdb.Multilink):
741                         value = value.split(':')
742                     d[name] = value
744             # perform the edit
745             if cl.hasnode(nodeid):
746                 # edit existing
747                 cl.set(nodeid, **d)
748             else:
749                 # new node
750                 found[cl.create(**d)] = 1
752         # retire the removed entries
753         for nodeid in cl.list():
754             if not found.has_key(nodeid):
755                 cl.retire(nodeid)
757         message = _('items edited OK')
759         # redirect to the class' edit page
760         raise Redirect, '%s/%s?:ok_message=%s'%(self.base, self.classname, 
761             urllib.quote(message))
763     def genericEditPermission(self):
764         ''' Determine whether the user has permission to edit this class.
766             Base behaviour is to check the user can edit this class.
767         ''' 
768         if not self.db.security.hasPermission('Edit', self.userid,
769                 self.classname):
770             return 0
771         return 1
773     def searchAction(self):
774         ''' Mangle some of the form variables.
776             Set the form ":filter" variable based on the values of the
777             filter variables - if they're set to anything other than
778             "dontcare" then add them to :filter.
780             Also handle the ":queryname" variable and save off the query to
781             the user's query list.
782         '''
783         # generic edit is per-class only
784         if not self.searchPermission():
785             self.error_message.append(
786                 _('You do not have permission to search %s' %self.classname))
788         # add a faked :filter form variable for each filtering prop
789         props = self.db.classes[self.classname].getprops()
790         for key in self.form.keys():
791             if not props.has_key(key): continue
792             if not self.form[key].value: continue
793             self.form.value.append(cgi.MiniFieldStorage(':filter', key))
795         # handle saving the query params
796         if self.form.has_key(':queryname'):
797             queryname = self.form[':queryname'].value.strip()
798             if queryname:
799                 # parse the environment and figure what the query _is_
800                 req = HTMLRequest(self)
801                 url = req.indexargs_href('', {})
803                 # handle editing an existing query
804                 try:
805                     qid = self.db.query.lookup(queryname)
806                     self.db.query.set(qid, klass=self.classname, url=url)
807                 except KeyError:
808                     # create a query
809                     qid = self.db.query.create(name=queryname,
810                         klass=self.classname, url=url)
812                     # and add it to the user's query multilink
813                     queries = self.db.user.get(self.userid, 'queries')
814                     queries.append(qid)
815                     self.db.user.set(self.userid, queries=queries)
817                 # commit the query change to the database
818                 self.db.commit()
821     def searchPermission(self):
822         ''' Determine whether the user has permission to search this class.
824             Base behaviour is to check the user can view this class.
825         ''' 
826         if not self.db.security.hasPermission('View', self.userid,
827                 self.classname):
828             return 0
829         return 1
831     def XXXremove_action(self,  dre=re.compile(r'([^\d]+)(\d+)')):
832         # XXX I believe this could be handled by a regular edit action that
833         # just sets the multilink...
834         # XXX handle this !
835         target = self.index_arg(':target')[0]
836         m = dre.match(target)
837         if m:
838             classname = m.group(1)
839             nodeid = m.group(2)
840             cl = self.db.getclass(classname)
841             cl.retire(nodeid)
842             # now take care of the reference
843             parentref =  self.index_arg(':multilink')[0]
844             parent, prop = parentref.split(':')
845             m = dre.match(parent)
846             if m:
847                 self.classname = m.group(1)
848                 self.nodeid = m.group(2)
849                 cl = self.db.getclass(self.classname)
850                 value = cl.get(self.nodeid, prop)
851                 value.remove(nodeid)
852                 cl.set(self.nodeid, **{prop:value})
853                 func = getattr(self, 'show%s'%self.classname)
854                 return func()
855             else:
856                 raise NotFound, parent
857         else:
858             raise NotFound, target
860     #
861     #  Utility methods for editing
862     #
863     def _changenode(self, props):
864         ''' change the node based on the contents of the form
865         '''
866         cl = self.db.classes[self.classname]
868         # create the message
869         message, files = self._handle_message()
870         if message:
871             props['messages'] = cl.get(self.nodeid, 'messages') + [message]
872         if files:
873             props['files'] = cl.get(self.nodeid, 'files') + files
875         # make the changes
876         return cl.set(self.nodeid, **props)
878     def _createnode(self, props):
879         ''' create a node based on the contents of the form
880         '''
881         cl = self.db.classes[self.classname]
883         # check for messages and files
884         message, files = self._handle_message()
885         if message:
886             props['messages'] = [message]
887         if files:
888             props['files'] = files
889         # create the node and return it's id
890         return cl.create(**props)
892     def _handle_message(self):
893         ''' generate an edit message
894         '''
895         # handle file attachments 
896         files = []
897         if self.form.has_key('__file'):
898             file = self.form['__file']
899             if file.filename:
900                 filename = file.filename.split('\\')[-1]
901                 mime_type = mimetypes.guess_type(filename)[0]
902                 if not mime_type:
903                     mime_type = "application/octet-stream"
904                 # create the new file entry
905                 files.append(self.db.file.create(type=mime_type,
906                     name=filename, content=file.file.read()))
908         # we don't want to do a message if none of the following is true...
909         cn = self.classname
910         cl = self.db.classes[self.classname]
911         props = cl.getprops()
912         note = None
913         # in a nutshell, don't do anything if there's no note or there's no
914         # NOSY
915         if self.form.has_key('__note'):
916             note = self.form['__note'].value.strip()
917         if not note:
918             return None, files
919         if not props.has_key('messages'):
920             return None, files
921         if not isinstance(props['messages'], hyperdb.Multilink):
922             return None, files
923         if not props['messages'].classname == 'msg':
924             return None, files
925         if not (self.form.has_key('nosy') or note):
926             return None, files
928         # handle the note
929         if '\n' in note:
930             summary = re.split(r'\n\r?', note)[0]
931         else:
932             summary = note
933         m = ['%s\n'%note]
935         # handle the messageid
936         # TODO: handle inreplyto
937         messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
938             self.classname, self.instance.MAIL_DOMAIN)
940         # now create the message, attaching the files
941         content = '\n'.join(m)
942         message_id = self.db.msg.create(author=self.userid,
943             recipients=[], date=date.Date('.'), summary=summary,
944             content=content, files=files, messageid=messageid)
946         # update the messages property
947         return message_id, files
949     def _post_editnode(self, nid):
950         '''Do the linking part of the node creation.
952            If a form element has :link or :multilink appended to it, its
953            value specifies a node designator and the property on that node
954            to add _this_ node to as a link or multilink.
956            This is typically used on, eg. the file upload page to indicated
957            which issue to link the file to.
959            TODO: I suspect that this and newfile will go away now that
960            there's the ability to upload a file using the issue __file form
961            element!
962         '''
963         cn = self.classname
964         cl = self.db.classes[cn]
965         # link if necessary
966         keys = self.form.keys()
967         for key in keys:
968             if key == ':multilink':
969                 value = self.form[key].value
970                 if type(value) != type([]): value = [value]
971                 for value in value:
972                     designator, property = value.split(':')
973                     link, nodeid = hyperdb.splitDesignator(designator)
974                     link = self.db.classes[link]
975                     # take a dupe of the list so we're not changing the cache
976                     value = link.get(nodeid, property)[:]
977                     value.append(nid)
978                     link.set(nodeid, **{property: value})
979             elif key == ':link':
980                 value = self.form[key].value
981                 if type(value) != type([]): value = [value]
982                 for value in value:
983                     designator, property = value.split(':')
984                     link, nodeid = hyperdb.splitDesignator(designator)
985                     link = self.db.classes[link]
986                     link.set(nodeid, **{property: nid})
989 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
990     '''Pull properties for the given class out of the form.
991     '''
992     props = {}
993     keys = form.keys()
994     for key in keys:
995         if not cl.properties.has_key(key):
996             continue
997         proptype = cl.properties[key]
998         if isinstance(proptype, hyperdb.String):
999             value = form[key].value.strip()
1000         elif isinstance(proptype, hyperdb.Password):
1001             value = form[key].value.strip()
1002             if not value:
1003                 # ignore empty password values
1004                 continue
1005             value = password.Password(value)
1006         elif isinstance(proptype, hyperdb.Date):
1007             value = form[key].value.strip()
1008             if value:
1009                 value = date.Date(form[key].value.strip())
1010             else:
1011                 value = None
1012         elif isinstance(proptype, hyperdb.Interval):
1013             value = form[key].value.strip()
1014             if value:
1015                 value = date.Interval(form[key].value.strip())
1016             else:
1017                 value = None
1018         elif isinstance(proptype, hyperdb.Link):
1019             value = form[key].value.strip()
1020             # see if it's the "no selection" choice
1021             if value == '-1':
1022                 value = None
1023             else:
1024                 # handle key values
1025                 link = cl.properties[key].classname
1026                 if not num_re.match(value):
1027                     try:
1028                         value = db.classes[link].lookup(value)
1029                     except KeyError:
1030                         raise ValueError, _('property "%(propname)s": '
1031                             '%(value)s not a %(classname)s')%{'propname':key, 
1032                             'value': value, 'classname': link}
1033         elif isinstance(proptype, hyperdb.Multilink):
1034             value = form[key]
1035             if not isinstance(value, type([])):
1036                 value = [i.strip() for i in value.split(',')]
1037             else:
1038                 value = [i.value.strip() for i in value]
1039             link = cl.properties[key].classname
1040             l = []
1041             for entry in map(str, value):
1042                 if entry == '': continue
1043                 if not num_re.match(entry):
1044                     try:
1045                         entry = db.classes[link].lookup(entry)
1046                     except KeyError:
1047                         raise ValueError, _('property "%(propname)s": '
1048                             '"%(value)s" not an entry of %(classname)s')%{
1049                             'propname':key, 'value': entry, 'classname': link}
1050                 l.append(entry)
1051             l.sort()
1052             value = l
1053         elif isinstance(proptype, hyperdb.Boolean):
1054             value = form[key].value.strip()
1055             props[key] = value = value.lower() in ('yes', 'true', 'on', '1')
1056         elif isinstance(proptype, hyperdb.Number):
1057             value = form[key].value.strip()
1058             props[key] = value = int(value)
1060         # get the old value
1061         if nodeid:
1062             try:
1063                 existing = cl.get(nodeid, key)
1064             except KeyError:
1065                 # this might be a new property for which there is no existing
1066                 # value
1067                 if not cl.properties.has_key(key): raise
1069             # if changed, set it
1070             if value != existing:
1071                 props[key] = value
1072         else:
1073             props[key] = value
1074     return props