Code

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