Code

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