Code

consistency ... __file/__note -> :file/:note
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.24 2002-09-09 23:38:41 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, NoTemplate
14 from roundup.cgi import cgitb
16 from roundup.cgi.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         ''' Process a request.
105             The most common requests are handled like so:
106             1. figure out who we are, defaulting to the "anonymous" user
107                see determine_user
108             2. figure out what the request is for - the context
109                see determine_context
110             3. handle any requested action (item edit, search, ...)
111                see handle_action
112             4. render a template, resulting in HTML output
114             In some situations, exceptions occur:
115             - HTTP Redirect  (generally raised by an action)
116             - SendFile       (generally raised by determine_context)
117               serve up a FileClass "content" property
118             - SendStaticFile (generally raised by determine_context)
119               serve up a file from the tracker "html" directory
120             - Unauthorised   (generally raised by an action)
121               the action is cancelled, the request is rendered and an error
122               message is displayed indicating that permission was not
123               granted for the action to take place
124             - NotFound       (raised wherever it needs to be)
125               percolates up to the CGI interface that called the client
126         '''
127         self.content_action = None
128         self.ok_message = []
129         self.error_message = []
130         try:
131             # make sure we're identified (even anonymously)
132             self.determine_user()
133             # figure out the context and desired content template
134             self.determine_context()
135             # possibly handle a form submit action (may change self.classname
136             # and self.template, and may also append error/ok_messages)
137             self.handle_action()
138             # now render the page
139             if self.form.has_key(':contentonly'):
140                 # just the content
141                 self.write(self.content())
142             else:
143                 # render the content inside the page template
144                 self.write(self.renderTemplate('page', '',
145                     ok_message=self.ok_message,
146                     error_message=self.error_message))
147         except Redirect, url:
148             # let's redirect - if the url isn't None, then we need to do
149             # the headers, otherwise the headers have been set before the
150             # exception was raised
151             if url:
152                 self.header({'Location': url}, response=302)
153         except SendFile, designator:
154             self.serve_file(designator)
155         except SendStaticFile, file:
156             self.serve_static_file(str(file))
157         except Unauthorised, message:
158             self.write(self.renderTemplate('page', '', error_message=message))
159         except:
160             # everything else
161             self.write(cgitb.html())
163     def determine_user(self):
164         ''' Determine who the user is
165         '''
166         # determine the uid to use
167         self.opendb('admin')
169         # make sure we have the session Class
170         sessions = self.db.sessions
172         # age sessions, remove when they haven't been used for a week
173         # TODO: this shouldn't be done every access
174         week = 60*60*24*7
175         now = time.time()
176         for sessid in sessions.list():
177             interval = now - sessions.get(sessid, 'last_use')
178             if interval > week:
179                 sessions.destroy(sessid)
181         # look up the user session cookie
182         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
183         user = 'anonymous'
185         # bump the "revision" of the cookie since the format changed
186         if (cookie.has_key('roundup_user_2') and
187                 cookie['roundup_user_2'].value != 'deleted'):
189             # get the session key from the cookie
190             self.session = cookie['roundup_user_2'].value
191             # get the user from the session
192             try:
193                 # update the lifetime datestamp
194                 sessions.set(self.session, last_use=time.time())
195                 sessions.commit()
196                 user = sessions.get(self.session, 'user')
197             except KeyError:
198                 user = 'anonymous'
200         # sanity check on the user still being valid, getting the userid
201         # at the same time
202         try:
203             self.userid = self.db.user.lookup(user)
204         except (KeyError, TypeError):
205             user = 'anonymous'
207         # make sure the anonymous user is valid if we're using it
208         if user == 'anonymous':
209             self.make_user_anonymous()
210         else:
211             self.user = user
213         # reopen the database as the correct user
214         self.opendb(self.user)
216     def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
217         ''' Determine the context of this page from the URL:
219             The URL path after the instance identifier is examined. The path
220             is generally only one entry long.
222             - if there is no path, then we are in the "home" context.
223             * if the path is "_file", then the additional path entry
224               specifies the filename of a static file we're to serve up
225               from the instance "html" directory. Raises a SendStaticFile
226               exception.
227             - if there is something in the path (eg "issue"), it identifies
228               the tracker class we're to display.
229             - if the path is an item designator (eg "issue123"), then we're
230               to display a specific item.
231             * if the path starts with an item designator and is longer than
232               one entry, then we're assumed to be handling an item of a
233               FileClass, and the extra path information gives the filename
234               that the client is going to label the download with (ie
235               "file123/image.png" is nicer to download than "file123"). This
236               raises a SendFile exception.
238             Both of the "*" types of contexts stop before we bother to
239             determine the template we're going to use. That's because they
240             don't actually use templates.
242             The template used is specified by the :template CGI variable,
243             which defaults to:
245              only classname suplied:          "index"
246              full item designator supplied:   "item"
248             We set:
249              self.classname  - the class to display, can be None
250              self.template   - the template to render the current context with
251              self.nodeid     - the nodeid of the class we're displaying
252         '''
253         # default the optional variables
254         self.classname = None
255         self.nodeid = None
257         # determine the classname and possibly nodeid
258         path = self.split_path
259         if not path or path[0] in ('', 'home', 'index'):
260             if self.form.has_key(':template'):
261                 self.template = self.form[':template'].value
262             else:
263                 self.template = ''
264             return
265         elif path[0] == '_file':
266             raise SendStaticFile, path[1]
267         else:
268             self.classname = path[0]
269             if len(path) > 1:
270                 # send the file identified by the designator in path[0]
271                 raise SendFile, path[0]
273         # see if we got a designator
274         m = dre.match(self.classname)
275         if m:
276             self.classname = m.group(1)
277             self.nodeid = m.group(2)
278             # with a designator, we default to item view
279             self.template = 'item'
280         else:
281             # with only a class, we default to index view
282             self.template = 'index'
284         # see if we have a template override
285         if self.form.has_key(':template'):
286             self.template = self.form[':template'].value
288         # see if we were passed in a message
289         if self.form.has_key(':ok_message'):
290             self.ok_message.append(self.form[':ok_message'].value)
291         if self.form.has_key(':error_message'):
292             self.error_message.append(self.form[':error_message'].value)
294     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
295         ''' Serve the file from the content property of the designated item.
296         '''
297         m = dre.match(str(designator))
298         if not m:
299             raise NotFound, str(designator)
300         classname, nodeid = m.group(1), m.group(2)
301         if classname != 'file':
302             raise NotFound, designator
304         # we just want to serve up the file named
305         file = self.db.file
306         self.header({'Content-Type': file.get(nodeid, 'type')})
307         self.write(file.get(nodeid, 'content'))
309     def serve_static_file(self, file):
310         # we just want to serve up the file named
311         mt = mimetypes.guess_type(str(file))[0]
312         self.header({'Content-Type': mt})
313         self.write(open(os.path.join(self.instance.TEMPLATES, file)).read())
315     def renderTemplate(self, name, extension, **kwargs):
316         ''' Return a PageTemplate for the named page
317         '''
318         pt = getTemplate(self.instance.TEMPLATES, name, extension)
319         # XXX handle PT rendering errors here more nicely
320         try:
321             # let the template render figure stuff out
322             return pt.render(self, None, None, **kwargs)
323         except PageTemplate.PTRuntimeError, message:
324             return '<strong>%s</strong><ol>%s</ol>'%(message,
325                 '<li>'.join(pt._v_errors))
326         except NoTemplate, message:
327             return '<strong>%s</strong>'%message
328         except:
329             # everything else
330             return cgitb.pt_html()
332     def content(self):
333         ''' Callback used by the page template to render the content of 
334             the page.
336             If we don't have a specific class to display, that is none was
337             determined in determine_context(), then we display a "home"
338             template.
339         '''
340         # now render the page content using the template we determined in
341         # determine_context
342         if self.classname is None:
343             name = 'home'
344         else:
345             name = self.classname
346         return self.renderTemplate(self.classname, self.template)
348     # these are the actions that are available
349     actions = {
350         'edit':     'editItemAction',
351         'editCSV':  'editCSVAction',
352         'new':      'newItemAction',
353         'register': 'registerAction',
354         'login':    'loginAction',
355         'logout':   'logout_action',
356         'search':   'searchAction',
357     }
358     def handle_action(self):
359         ''' Determine whether there should be an _action called.
361             The action is defined by the form variable :action which
362             identifies the method on this object to call. The four basic
363             actions are defined in the "actions" dictionary on this class:
364              "edit"      -> self.editItemAction
365              "new"       -> self.newItemAction
366              "register"  -> self.registerAction
367              "login"     -> self.loginAction
368              "logout"    -> self.logout_action
369              "search"    -> self.searchAction
371         '''
372         if not self.form.has_key(':action'):
373             return None
374         try:
375             # get the action, validate it
376             action = self.form[':action'].value
377             if not self.actions.has_key(action):
378                 raise ValueError, 'No such action "%s"'%action
380             # call the mapped action
381             getattr(self, self.actions[action])()
382         except Redirect:
383             raise
384         except Unauthorised:
385             raise
386         except:
387             self.db.rollback()
388             s = StringIO.StringIO()
389             traceback.print_exc(None, s)
390             self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
392     def write(self, content):
393         if not self.headers_done:
394             self.header()
395         self.request.wfile.write(content)
397     def header(self, headers=None, response=200):
398         '''Put up the appropriate header.
399         '''
400         if headers is None:
401             headers = {'Content-Type':'text/html'}
402         if not headers.has_key('Content-Type'):
403             headers['Content-Type'] = 'text/html'
404         self.request.send_response(response)
405         for entry in headers.items():
406             self.request.send_header(*entry)
407         self.request.end_headers()
408         self.headers_done = 1
409         if self.debug:
410             self.headers_sent = headers
412     def set_cookie(self, user, password):
413         # TODO generate a much, much stronger session key ;)
414         self.session = binascii.b2a_base64(repr(random.random())).strip()
416         # clean up the base64
417         if self.session[-1] == '=':
418             if self.session[-2] == '=':
419                 self.session = self.session[:-2]
420             else:
421                 self.session = self.session[:-1]
423         # insert the session in the sessiondb
424         self.db.sessions.set(self.session, user=user, last_use=time.time())
426         # and commit immediately
427         self.db.sessions.commit()
429         # expire us in a long, long time
430         expire = Cookie._getdate(86400*365)
432         # generate the cookie path - make sure it has a trailing '/'
433         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
434             ''))
435         self.header({'Set-Cookie': 'roundup_user_2=%s; expires=%s; Path=%s;'%(
436             self.session, expire, path)})
438     def make_user_anonymous(self):
439         ''' Make us anonymous
441             This method used to handle non-existence of the 'anonymous'
442             user, but that user is mandatory now.
443         '''
444         self.userid = self.db.user.lookup('anonymous')
445         self.user = 'anonymous'
447     def logout(self):
448         ''' Make us really anonymous - nuke the cookie too
449         '''
450         self.make_user_anonymous()
452         # construct the logout cookie
453         now = Cookie._getdate()
454         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
455             ''))
456         self.header({'Set-Cookie':
457             'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
458             path)})
459         self.login()
461     def opendb(self, user):
462         ''' Open the database.
463         '''
464         # open the db if the user has changed
465         if not hasattr(self, 'db') or user != self.db.journaltag:
466             self.db = self.instance.open(user)
468     #
469     # Actions
470     #
471     def loginAction(self):
472         ''' Attempt to log a user in.
474             Sets up a session for the user which contains the login
475             credentials.
476         '''
477         # we need the username at a minimum
478         if not self.form.has_key('__login_name'):
479             self.error_message.append(_('Username required'))
480             return
482         self.user = self.form['__login_name'].value
483         # re-open the database for real, using the user
484         self.opendb(self.user)
485         if self.form.has_key('__login_password'):
486             password = self.form['__login_password'].value
487         else:
488             password = ''
489         # make sure the user exists
490         try:
491             self.userid = self.db.user.lookup(self.user)
492         except KeyError:
493             name = self.user
494             self.make_user_anonymous()
495             self.error_message.append(_('No such user "%(name)s"')%locals())
496             return
498         # and that the password is correct
499         pw = self.db.user.get(self.userid, 'password')
500         if password != pw:
501             self.make_user_anonymous()
502             self.error_message.append(_('Incorrect password'))
503             return
505         # make sure we're allowed to be here
506         if not self.loginPermission():
507             self.make_user_anonymous()
508             raise Unauthorised, _("You do not have permission to login")
510         # set the session cookie
511         self.set_cookie(self.user, password)
513     def loginPermission(self):
514         ''' Determine whether the user has permission to log in.
516             Base behaviour is to check the user has "Web Access".
517         ''' 
518         if not self.db.security.hasPermission('Web Access', self.userid):
519             return 0
520         return 1
522     def logout_action(self):
523         ''' Make us really anonymous - nuke the cookie too
524         '''
525         # log us out
526         self.make_user_anonymous()
528         # construct the logout cookie
529         now = Cookie._getdate()
530         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
531             ''))
532         self.header(headers={'Set-Cookie':
533           'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)})
535         # Let the user know what's going on
536         self.ok_message.append(_('You are logged out'))
538     def registerAction(self):
539         '''Attempt to create a new user based on the contents of the form
540         and then set the cookie.
542         return 1 on successful login
543         '''
544         # create the new user
545         cl = self.db.user
547         # parse the props from the form
548         try:
549             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
550         except (ValueError, KeyError), message:
551             self.error_message.append(_('Error: ') + str(message))
552             return
554         # make sure we're allowed to register
555         if not self.registerPermission(props):
556             raise Unauthorised, _("You do not have permission to register")
558         # re-open the database as "admin"
559         if self.user != 'admin':
560             self.opendb('admin')
561             
562         # create the new user
563         cl = self.db.user
564         try:
565             props = parsePropsFromForm(self.db, cl, self.form)
566             props['roles'] = self.instance.NEW_WEB_USER_ROLES
567             self.userid = cl.create(**props)
568             self.db.commit()
569         except ValueError, message:
570             self.error_message.append(message)
572         # log the new user in
573         self.user = cl.get(self.userid, 'username')
574         # re-open the database for real, using the user
575         self.opendb(self.user)
576         password = self.db.user.get(self.userid, 'password')
577         self.set_cookie(self.user, password)
579         # nice message
580         self.ok_message.append(_('You are now registered, welcome!'))
582     def registerPermission(self, props):
583         ''' Determine whether the user has permission to register
585             Base behaviour is to check the user has "Web Registration".
586         '''
587         # registration isn't allowed to supply roles
588         if props.has_key('roles'):
589             return 0
590         if self.db.security.hasPermission('Web Registration', self.userid):
591             return 1
592         return 0
594     def editItemAction(self):
595         ''' Perform an edit of an item in the database.
597             Some special form elements:
599             :link=designator:property
600             :multilink=designator:property
601              The value specifies a node designator and the property on that
602              node to add _this_ node to as a link or multilink.
603             :note
604              Create a message and attach it to the current node's
605              "messages" property.
606             :file
607              Create a file and attach it to the current node's
608              "files" property. Attach the file to the message created from
609              the :note if it's supplied.
611             :required=property,property,...
612              The named properties are required to be filled in the form.
614         '''
615         cl = self.db.classes[self.classname]
617         # parse the props from the form
618         try:
619             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
620         except (ValueError, KeyError), message:
621             self.error_message.append(_('Error: ') + str(message))
622             return
624         # check permission
625         if not self.editItemPermission(props):
626             self.error_message.append(
627                 _('You do not have permission to edit %(classname)s'%
628                 self.__dict__))
629             return
631         # perform the edit
632         try:
633             # make changes to the node
634             props = self._changenode(props)
635             # handle linked nodes 
636             self._post_editnode(self.nodeid)
637         except (ValueError, KeyError), message:
638             self.error_message.append(_('Error: ') + str(message))
639             return
641         # commit now that all the tricky stuff is done
642         self.db.commit()
644         # and some nice feedback for the user
645         if props:
646             message = _('%(changes)s edited ok')%{'changes':
647                 ', '.join(props.keys())}
648         elif self.form.has_key(':note') and self.form[':note'].value:
649             message = _('note added')
650         elif (self.form.has_key(':file') and self.form[':file'].filename):
651             message = _('file added')
652         else:
653             message = _('nothing changed')
655         # redirect to the item's edit page
656         raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
657             self.nodeid,  urllib.quote(message))
659     def editItemPermission(self, props):
660         ''' Determine whether the user has permission to edit this item.
662             Base behaviour is to check the user can edit this class. If we're
663             editing the "user" class, users are allowed to edit their own
664             details. Unless it's the "roles" property, which requires the
665             special Permission "Web Roles".
666         '''
667         # if this is a user node and the user is editing their own node, then
668         # we're OK
669         has = self.db.security.hasPermission
670         if self.classname == 'user':
671             # reject if someone's trying to edit "roles" and doesn't have the
672             # right permission.
673             if props.has_key('roles') and not has('Web Roles', self.userid,
674                     'user'):
675                 return 0
676             # if the item being edited is the current user, we're ok
677             if self.nodeid == self.userid:
678                 return 1
679         if self.db.security.hasPermission('Edit', self.userid, self.classname):
680             return 1
681         return 0
683     def newItemAction(self):
684         ''' Add a new item to the database.
686             This follows the same form as the editItemAction, with the same
687             special form values.
688         '''
689         cl = self.db.classes[self.classname]
691         # parse the props from the form
692         try:
693             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
694         except (ValueError, KeyError), message:
695             self.error_message.append(_('Error: ') + str(message))
696             return
698         if not self.newItemPermission(props):
699             self.error_message.append(
700                 _('You do not have permission to create %s' %self.classname))
702         # create a little extra message for anticipated :link / :multilink
703         if self.form.has_key(':multilink'):
704             link = self.form[':multilink'].value
705         elif self.form.has_key(':link'):
706             link = self.form[':multilink'].value
707         else:
708             link = None
709             xtra = ''
710         if link:
711             designator, linkprop = link.split(':')
712             xtra = ' for <a href="%s">%s</a>'%(designator, designator)
714         try:
715             # do the create
716             nid = self._createnode(props)
718             # handle linked nodes 
719             self._post_editnode(nid)
721             # commit now that all the tricky stuff is done
722             self.db.commit()
724             # render the newly created item
725             self.nodeid = nid
727             # and some nice feedback for the user
728             message = _('%(classname)s created ok')%self.__dict__ + xtra
729         except (ValueError, KeyError), message:
730             self.error_message.append(_('Error: ') + str(message))
731             return
732         except:
733             # oops
734             self.db.rollback()
735             s = StringIO.StringIO()
736             traceback.print_exc(None, s)
737             self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
738             return
740         # redirect to the new item's page
741         raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
742             nid,  urllib.quote(message))
744     def newItemPermission(self, props):
745         ''' Determine whether the user has permission to create (edit) this
746             item.
748             Base behaviour is to check the user can edit this class. No
749             additional property checks are made. Additionally, new user items
750             may be created if the user has the "Web Registration" Permission.
751         '''
752         has = self.db.security.hasPermission
753         if self.classname == 'user' and has('Web Registration', self.userid,
754                 'user'):
755             return 1
756         if has('Edit', self.userid, self.classname):
757             return 1
758         return 0
760     def editCSVAction(self):
761         ''' Performs an edit of all of a class' items in one go.
763             The "rows" CGI var defines the CSV-formatted entries for the
764             class. New nodes are identified by the ID 'X' (or any other
765             non-existent ID) and removed lines are retired.
766         '''
767         # this is per-class only
768         if not self.editCSVPermission():
769             self.error_message.append(
770                 _('You do not have permission to edit %s' %self.classname))
772         # get the CSV module
773         try:
774             import csv
775         except ImportError:
776             self.error_message.append(_(
777                 'Sorry, you need the csv module to use this function.<br>\n'
778                 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
779             return
781         cl = self.db.classes[self.classname]
782         idlessprops = cl.getprops(protected=0).keys()
783         idlessprops.sort()
784         props = ['id'] + idlessprops
786         # do the edit
787         rows = self.form['rows'].value.splitlines()
788         p = csv.parser()
789         found = {}
790         line = 0
791         for row in rows[1:]:
792             line += 1
793             values = p.parse(row)
794             # not a complete row, keep going
795             if not values: continue
797             # skip property names header
798             if values == props:
799                 continue
801             # extract the nodeid
802             nodeid, values = values[0], values[1:]
803             found[nodeid] = 1
805             # confirm correct weight
806             if len(idlessprops) != len(values):
807                 self.error_message.append(
808                     _('Not enough values on line %(line)s')%{'line':line})
809                 return
811             # extract the new values
812             d = {}
813             for name, value in zip(idlessprops, values):
814                 value = value.strip()
815                 # only add the property if it has a value
816                 if value:
817                     # if it's a multilink, split it
818                     if isinstance(cl.properties[name], hyperdb.Multilink):
819                         value = value.split(':')
820                     d[name] = value
822             # perform the edit
823             if cl.hasnode(nodeid):
824                 # edit existing
825                 cl.set(nodeid, **d)
826             else:
827                 # new node
828                 found[cl.create(**d)] = 1
830         # retire the removed entries
831         for nodeid in cl.list():
832             if not found.has_key(nodeid):
833                 cl.retire(nodeid)
835         # all OK
836         self.db.commit()
838         self.ok_message.append(_('Items edited OK'))
840     def editCSVPermission(self):
841         ''' Determine whether the user has permission to edit this class.
843             Base behaviour is to check the user can edit this class.
844         ''' 
845         if not self.db.security.hasPermission('Edit', self.userid,
846                 self.classname):
847             return 0
848         return 1
850     def searchAction(self):
851         ''' Mangle some of the form variables.
853             Set the form ":filter" variable based on the values of the
854             filter variables - if they're set to anything other than
855             "dontcare" then add them to :filter.
857             Also handle the ":queryname" variable and save off the query to
858             the user's query list.
859         '''
860         # generic edit is per-class only
861         if not self.searchPermission():
862             self.error_message.append(
863                 _('You do not have permission to search %s' %self.classname))
865         # add a faked :filter form variable for each filtering prop
866         props = self.db.classes[self.classname].getprops()
867         for key in self.form.keys():
868             if not props.has_key(key): continue
869             if not self.form[key].value: continue
870             self.form.value.append(cgi.MiniFieldStorage(':filter', key))
872         # handle saving the query params
873         if self.form.has_key(':queryname'):
874             queryname = self.form[':queryname'].value.strip()
875             if queryname:
876                 # parse the environment and figure what the query _is_
877                 req = HTMLRequest(self)
878                 url = req.indexargs_href('', {})
880                 # handle editing an existing query
881                 try:
882                     qid = self.db.query.lookup(queryname)
883                     self.db.query.set(qid, klass=self.classname, url=url)
884                 except KeyError:
885                     # create a query
886                     qid = self.db.query.create(name=queryname,
887                         klass=self.classname, url=url)
889                     # and add it to the user's query multilink
890                     queries = self.db.user.get(self.userid, 'queries')
891                     queries.append(qid)
892                     self.db.user.set(self.userid, queries=queries)
894                 # commit the query change to the database
895                 self.db.commit()
897     def searchPermission(self):
898         ''' Determine whether the user has permission to search this class.
900             Base behaviour is to check the user can view this class.
901         ''' 
902         if not self.db.security.hasPermission('View', self.userid,
903                 self.classname):
904             return 0
905         return 1
907     def remove_action(self,  dre=re.compile(r'([^\d]+)(\d+)')):
908         # XXX I believe this could be handled by a regular edit action that
909         # just sets the multilink...
910         target = self.index_arg(':target')[0]
911         m = dre.match(target)
912         if m:
913             classname = m.group(1)
914             nodeid = m.group(2)
915             cl = self.db.getclass(classname)
916             cl.retire(nodeid)
917             # now take care of the reference
918             parentref =  self.index_arg(':multilink')[0]
919             parent, prop = parentref.split(':')
920             m = dre.match(parent)
921             if m:
922                 self.classname = m.group(1)
923                 self.nodeid = m.group(2)
924                 cl = self.db.getclass(self.classname)
925                 value = cl.get(self.nodeid, prop)
926                 value.remove(nodeid)
927                 cl.set(self.nodeid, **{prop:value})
928                 func = getattr(self, 'show%s'%self.classname)
929                 return func()
930             else:
931                 raise NotFound, parent
932         else:
933             raise NotFound, target
935     #
936     #  Utility methods for editing
937     #
938     def _changenode(self, props):
939         ''' change the node based on the contents of the form
940         '''
941         cl = self.db.classes[self.classname]
943         # create the message
944         message, files = self._handle_message()
945         if message:
946             props['messages'] = cl.get(self.nodeid, 'messages') + [message]
947         if files:
948             props['files'] = cl.get(self.nodeid, 'files') + files
950         # make the changes
951         return cl.set(self.nodeid, **props)
953     def _createnode(self, props):
954         ''' create a node based on the contents of the form
955         '''
956         cl = self.db.classes[self.classname]
958         # check for messages and files
959         message, files = self._handle_message()
960         if message:
961             props['messages'] = [message]
962         if files:
963             props['files'] = files
964         # create the node and return it's id
965         return cl.create(**props)
967     def _handle_message(self):
968         ''' generate an edit message
969         '''
970         # handle file attachments 
971         files = []
972         if self.form.has_key(':file'):
973             file = self.form[':file']
974             if file.filename:
975                 filename = file.filename.split('\\')[-1]
976                 mime_type = mimetypes.guess_type(filename)[0]
977                 if not mime_type:
978                     mime_type = "application/octet-stream"
979                 # create the new file entry
980                 files.append(self.db.file.create(type=mime_type,
981                     name=filename, content=file.file.read()))
983         # we don't want to do a message if none of the following is true...
984         cn = self.classname
985         cl = self.db.classes[self.classname]
986         props = cl.getprops()
987         note = None
988         # in a nutshell, don't do anything if there's no note or there's no
989         # NOSY
990         if self.form.has_key(':note'):
991             note = self.form[':note'].value.strip()
992         if not note:
993             return None, files
994         if not props.has_key('messages'):
995             return None, files
996         if not isinstance(props['messages'], hyperdb.Multilink):
997             return None, files
998         if not props['messages'].classname == 'msg':
999             return None, files
1000         if not (self.form.has_key('nosy') or note):
1001             return None, files
1003         # handle the note
1004         if '\n' in note:
1005             summary = re.split(r'\n\r?', note)[0]
1006         else:
1007             summary = note
1008         m = ['%s\n'%note]
1010         # handle the messageid
1011         # TODO: handle inreplyto
1012         messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
1013             self.classname, self.instance.MAIL_DOMAIN)
1015         # now create the message, attaching the files
1016         content = '\n'.join(m)
1017         message_id = self.db.msg.create(author=self.userid,
1018             recipients=[], date=date.Date('.'), summary=summary,
1019             content=content, files=files, messageid=messageid)
1021         # update the messages property
1022         return message_id, files
1024     def _post_editnode(self, nid):
1025         '''Do the linking part of the node creation.
1027            If a form element has :link or :multilink appended to it, its
1028            value specifies a node designator and the property on that node
1029            to add _this_ node to as a link or multilink.
1031            This is typically used on, eg. the file upload page to indicated
1032            which issue to link the file to.
1034            TODO: I suspect that this and newfile will go away now that
1035            there's the ability to upload a file using the issue :file form
1036            element!
1037         '''
1038         cn = self.classname
1039         cl = self.db.classes[cn]
1040         # link if necessary
1041         keys = self.form.keys()
1042         for key in keys:
1043             if key == ':multilink':
1044                 value = self.form[key].value
1045                 if type(value) != type([]): value = [value]
1046                 for value in value:
1047                     designator, property = value.split(':')
1048                     link, nodeid = hyperdb.splitDesignator(designator)
1049                     link = self.db.classes[link]
1050                     # take a dupe of the list so we're not changing the cache
1051                     value = link.get(nodeid, property)[:]
1052                     value.append(nid)
1053                     link.set(nodeid, **{property: value})
1054             elif key == ':link':
1055                 value = self.form[key].value
1056                 if type(value) != type([]): value = [value]
1057                 for value in value:
1058                     designator, property = value.split(':')
1059                     link, nodeid = hyperdb.splitDesignator(designator)
1060                     link = self.db.classes[link]
1061                     link.set(nodeid, **{property: nid})
1064 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
1065     ''' Pull properties for the given class out of the form.
1067         If a ":required" parameter is supplied, then the names property values
1068         must be supplied or a ValueError will be raised.
1069     '''
1070     required = []
1071     if form.has_key(':required'):
1072         value = form[':required']
1073         if isinstance(value, type([])):
1074             required = [i.value.strip() for i in value]
1075         else:
1076             required = [i.strip() for i in value.value.split(',')]
1078     props = {}
1079     keys = form.keys()
1080     for key in keys:
1081         if not cl.properties.has_key(key):
1082             continue
1083         proptype = cl.properties[key]
1085         # Get the form value. This value may be a MiniFieldStorage or a list
1086         # of MiniFieldStorages.
1087         value = form[key]
1089         # make sure non-multilinks only get one value
1090         if not isinstance(proptype, hyperdb.Multilink):
1091             if isinstance(value, type([])):
1092                 raise ValueError, 'You have submitted more than one value'\
1093                     ' for the %s property'%key
1094             # we've got a MiniFieldStorage, so pull out the value and strip
1095             # surrounding whitespace
1096             value = value.value.strip()
1098         if isinstance(proptype, hyperdb.String):
1099             if not value:
1100                 continue
1101         elif isinstance(proptype, hyperdb.Password):
1102             if not value:
1103                 # ignore empty password values
1104                 continue
1105             value = password.Password(value)
1106         elif isinstance(proptype, hyperdb.Date):
1107             if value:
1108                 value = date.Date(form[key].value.strip())
1109             else:
1110                 value = None
1111         elif isinstance(proptype, hyperdb.Interval):
1112             if value:
1113                 value = date.Interval(form[key].value.strip())
1114             else:
1115                 value = None
1116         elif isinstance(proptype, hyperdb.Link):
1117             # see if it's the "no selection" choice
1118             if value == '-1':
1119                 value = None
1120             else:
1121                 # handle key values
1122                 link = cl.properties[key].classname
1123                 if not num_re.match(value):
1124                     try:
1125                         value = db.classes[link].lookup(value)
1126                     except KeyError:
1127                         raise ValueError, _('property "%(propname)s": '
1128                             '%(value)s not a %(classname)s')%{'propname':key, 
1129                             'value': value, 'classname': link}
1130         elif isinstance(proptype, hyperdb.Multilink):
1131             if isinstance(value, type([])):
1132                 # it's a list of MiniFieldStorages
1133                 value = [i.value.strip() for i in value]
1134             else:
1135                 # it's a MiniFieldStorage, but may be a comma-separated list
1136                 # of values
1137                 value = [i.strip() for i in value.value.split(',')]
1138             link = cl.properties[key].classname
1139             l = []
1140             for entry in map(str, value):
1141                 if entry == '': continue
1142                 if not num_re.match(entry):
1143                     try:
1144                         entry = db.classes[link].lookup(entry)
1145                     except KeyError:
1146                         raise ValueError, _('property "%(propname)s": '
1147                             '"%(value)s" not an entry of %(classname)s')%{
1148                             'propname':key, 'value': entry, 'classname': link}
1149                 l.append(entry)
1150             l.sort()
1151             value = l
1152         elif isinstance(proptype, hyperdb.Boolean):
1153             props[key] = value = value.lower() in ('yes', 'true', 'on', '1')
1154         elif isinstance(proptype, hyperdb.Number):
1155             props[key] = value = int(value)
1157         # register this as received if required
1158         if key in required:
1159             required.remove(key)
1161         # get the old value
1162         if nodeid:
1163             try:
1164                 existing = cl.get(nodeid, key)
1165             except KeyError:
1166                 # this might be a new property for which there is no existing
1167                 # value
1168                 if not cl.properties.has_key(key): raise
1170             # if changed, set it
1171             if value != existing:
1172                 props[key] = value
1173         else:
1174             props[key] = value
1176     # see if all the required properties have been supplied
1177     if required:
1178         if len(required) > 1:
1179             p = 'properties'
1180         else:
1181             p = 'property'
1182         raise ValueError, 'Required %s %s not supplied'%(p, ', '.join(required))
1184     return props