Code

#613310 ] traceback on onexistant items
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.43 2002-09-25 05:15:36 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, password):
428         # TODO generate a much, much stronger session key ;)
429         self.session = binascii.b2a_base64(repr(random.random())).strip()
431         # clean up the base64
432         if self.session[-1] == '=':
433             if self.session[-2] == '=':
434                 self.session = self.session[:-2]
435             else:
436                 self.session = self.session[:-1]
438         # insert the session in the sessiondb
439         self.db.sessions.set(self.session, user=user, last_use=time.time())
441         # and commit immediately
442         self.db.sessions.commit()
444         # expire us in a long, long time
445         expire = Cookie._getdate(86400*365)
447         # generate the cookie path - make sure it has a trailing '/'
448         path = '/'.join((self.env['SCRIPT_NAME'], self.env['TRACKER_NAME'],
449             ''))
450         self.additional_headers['Set-Cookie'] = \
451           'roundup_user_2=%s; expires=%s; Path=%s;'%(self.session, expire, path)
453     def make_user_anonymous(self):
454         ''' Make us anonymous
456             This method used to handle non-existence of the 'anonymous'
457             user, but that user is mandatory now.
458         '''
459         self.userid = self.db.user.lookup('anonymous')
460         self.user = 'anonymous'
462     def opendb(self, user):
463         ''' Open the database.
464         '''
465         # open the db if the user has changed
466         if not hasattr(self, 'db') or user != self.db.journaltag:
467             if hasattr(self, 'db'):
468                 self.db.close()
469             self.db = self.instance.open(user)
471     #
472     # Actions
473     #
474     def loginAction(self):
475         ''' Attempt to log a user in.
477             Sets up a session for the user which contains the login
478             credentials.
479         '''
480         # we need the username at a minimum
481         if not self.form.has_key('__login_name'):
482             self.error_message.append(_('Username required'))
483             return
485         self.user = self.form['__login_name'].value
486         # re-open the database for real, using the user
487         self.opendb(self.user)
488         if self.form.has_key('__login_password'):
489             password = self.form['__login_password'].value
490         else:
491             password = ''
492         # make sure the user exists
493         try:
494             self.userid = self.db.user.lookup(self.user)
495         except KeyError:
496             name = self.user
497             self.make_user_anonymous()
498             self.error_message.append(_('No such user "%(name)s"')%locals())
499             return
501         # and that the password is correct
502         pw = self.db.user.get(self.userid, 'password')
503         if password != pw:
504             self.make_user_anonymous()
505             self.error_message.append(_('Incorrect password'))
506             return
508         # make sure we're allowed to be here
509         if not self.loginPermission():
510             self.make_user_anonymous()
511             raise Unauthorised, _("You do not have permission to login")
513         # set the session cookie
514         self.set_cookie(self.user, password)
516     def loginPermission(self):
517         ''' Determine whether the user has permission to log in.
519             Base behaviour is to check the user has "Web Access".
520         ''' 
521         if not self.db.security.hasPermission('Web Access', self.userid):
522             return 0
523         return 1
525     def logout_action(self):
526         ''' Make us really anonymous - nuke the cookie too
527         '''
528         # log us out
529         self.make_user_anonymous()
531         # construct the logout cookie
532         now = Cookie._getdate()
533         path = '/'.join((self.env['SCRIPT_NAME'], self.env['TRACKER_NAME'],
534             ''))
535         self.additional_headers['Set-Cookie'] = \
536            'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)
538         # Let the user know what's going on
539         self.ok_message.append(_('You are logged out'))
541     def registerAction(self):
542         '''Attempt to create a new user based on the contents of the form
543         and then set the cookie.
545         return 1 on successful login
546         '''
547         # create the new user
548         cl = self.db.user
550         # parse the props from the form
551         try:
552             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
553         except (ValueError, KeyError), message:
554             self.error_message.append(_('Error: ') + str(message))
555             return
557         # make sure we're allowed to register
558         if not self.registerPermission(props):
559             raise Unauthorised, _("You do not have permission to register")
561         # re-open the database as "admin"
562         if self.user != 'admin':
563             self.opendb('admin')
564             
565         # create the new user
566         cl = self.db.user
567         try:
568             props = parsePropsFromForm(self.db, cl, self.form)
569             props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
570             self.userid = cl.create(**props)
571             self.db.commit()
572         except ValueError, message:
573             self.error_message.append(message)
574             return
576         # log the new user in
577         self.user = cl.get(self.userid, 'username')
578         # re-open the database for real, using the user
579         self.opendb(self.user)
580         password = self.db.user.get(self.userid, 'password')
581         self.set_cookie(self.user, password)
583         # nice message
584         message = _('You are now registered, welcome!')
586         # redirect to the item's edit page
587         raise Redirect, '%s%s%s?:ok_message=%s'%(
588             self.base, self.classname, self.userid,  urllib.quote(message))
590     def registerPermission(self, props):
591         ''' Determine whether the user has permission to register
593             Base behaviour is to check the user has "Web Registration".
594         '''
595         # registration isn't allowed to supply roles
596         if props.has_key('roles'):
597             return 0
598         if self.db.security.hasPermission('Web Registration', self.userid):
599             return 1
600         return 0
602     def editItemAction(self):
603         ''' Perform an edit of an item in the database.
605             Some special form elements:
607             :link=designator:property
608             :multilink=designator:property
609              The value specifies a node designator and the property on that
610              node to add _this_ node to as a link or multilink.
611             :note
612              Create a message and attach it to the current node's
613              "messages" property.
614             :file
615              Create a file and attach it to the current node's
616              "files" property. Attach the file to the message created from
617              the :note if it's supplied.
619             :required=property,property,...
620              The named properties are required to be filled in the form.
622         '''
623         cl = self.db.classes[self.classname]
625         # parse the props from the form
626         try:
627             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
628         except (ValueError, KeyError), message:
629             self.error_message.append(_('Error: ') + str(message))
630             return
632         # check permission
633         if not self.editItemPermission(props):
634             self.error_message.append(
635                 _('You do not have permission to edit %(classname)s'%
636                 self.__dict__))
637             return
639         # perform the edit
640         try:
641             # make changes to the node
642             props = self._changenode(props)
643             # handle linked nodes 
644             self._post_editnode(self.nodeid)
645         except (ValueError, KeyError), message:
646             self.error_message.append(_('Error: ') + str(message))
647             return
649         # commit now that all the tricky stuff is done
650         self.db.commit()
652         # and some nice feedback for the user
653         if props:
654             message = _('%(changes)s edited ok')%{'changes':
655                 ', '.join(props.keys())}
656         elif self.form.has_key(':note') and self.form[':note'].value:
657             message = _('note added')
658         elif (self.form.has_key(':file') and self.form[':file'].filename):
659             message = _('file added')
660         else:
661             message = _('nothing changed')
663         # redirect to the item's edit page
664         raise Redirect, '%s%s%s?:ok_message=%s'%(self.base, self.classname,
665             self.nodeid,  urllib.quote(message))
667     def editItemPermission(self, props):
668         ''' Determine whether the user has permission to edit this item.
670             Base behaviour is to check the user can edit this class. If we're
671             editing the "user" class, users are allowed to edit their own
672             details. Unless it's the "roles" property, which requires the
673             special Permission "Web Roles".
674         '''
675         # if this is a user node and the user is editing their own node, then
676         # we're OK
677         has = self.db.security.hasPermission
678         if self.classname == 'user':
679             # reject if someone's trying to edit "roles" and doesn't have the
680             # right permission.
681             if props.has_key('roles') and not has('Web Roles', self.userid,
682                     'user'):
683                 return 0
684             # if the item being edited is the current user, we're ok
685             if self.nodeid == self.userid:
686                 return 1
687         if self.db.security.hasPermission('Edit', self.userid, self.classname):
688             return 1
689         return 0
691     def newItemAction(self):
692         ''' Add a new item to the database.
694             This follows the same form as the editItemAction, with the same
695             special form values.
696         '''
697         cl = self.db.classes[self.classname]
699         # parse the props from the form
700         try:
701             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
702         except (ValueError, KeyError), message:
703             self.error_message.append(_('Error: ') + str(message))
704             return
706         if not self.newItemPermission(props):
707             self.error_message.append(
708                 _('You do not have permission to create %s' %self.classname))
710         # create a little extra message for anticipated :link / :multilink
711         if self.form.has_key(':multilink'):
712             link = self.form[':multilink'].value
713         elif self.form.has_key(':link'):
714             link = self.form[':multilink'].value
715         else:
716             link = None
717             xtra = ''
718         if link:
719             designator, linkprop = link.split(':')
720             xtra = ' for <a href="%s">%s</a>'%(designator, designator)
722         try:
723             # do the create
724             nid = self._createnode(props)
726             # handle linked nodes 
727             self._post_editnode(nid)
729             # commit now that all the tricky stuff is done
730             self.db.commit()
732             # render the newly created item
733             self.nodeid = nid
735             # and some nice feedback for the user
736             message = _('%(classname)s created ok')%self.__dict__ + xtra
737         except (ValueError, KeyError), message:
738             self.error_message.append(_('Error: ') + str(message))
739             return
740         except:
741             # oops
742             self.db.rollback()
743             s = StringIO.StringIO()
744             traceback.print_exc(None, s)
745             self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
746             return
748         # redirect to the new item's page
749         raise Redirect, '%s%s%s?:ok_message=%s'%(self.base, self.classname,
750             nid,  urllib.quote(message))
752     def newItemPermission(self, props):
753         ''' Determine whether the user has permission to create (edit) this
754             item.
756             Base behaviour is to check the user can edit this class. No
757             additional property checks are made. Additionally, new user items
758             may be created if the user has the "Web Registration" Permission.
759         '''
760         has = self.db.security.hasPermission
761         if self.classname == 'user' and has('Web Registration', self.userid,
762                 'user'):
763             return 1
764         if has('Edit', self.userid, self.classname):
765             return 1
766         return 0
768     def editCSVAction(self):
769         ''' Performs an edit of all of a class' items in one go.
771             The "rows" CGI var defines the CSV-formatted entries for the
772             class. New nodes are identified by the ID 'X' (or any other
773             non-existent ID) and removed lines are retired.
774         '''
775         # this is per-class only
776         if not self.editCSVPermission():
777             self.error_message.append(
778                 _('You do not have permission to edit %s' %self.classname))
780         # get the CSV module
781         try:
782             import csv
783         except ImportError:
784             self.error_message.append(_(
785                 'Sorry, you need the csv module to use this function.<br>\n'
786                 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
787             return
789         cl = self.db.classes[self.classname]
790         idlessprops = cl.getprops(protected=0).keys()
791         idlessprops.sort()
792         props = ['id'] + idlessprops
794         # do the edit
795         rows = self.form['rows'].value.splitlines()
796         p = csv.parser()
797         found = {}
798         line = 0
799         for row in rows[1:]:
800             line += 1
801             values = p.parse(row)
802             # not a complete row, keep going
803             if not values: continue
805             # skip property names header
806             if values == props:
807                 continue
809             # extract the nodeid
810             nodeid, values = values[0], values[1:]
811             found[nodeid] = 1
813             # confirm correct weight
814             if len(idlessprops) != len(values):
815                 self.error_message.append(
816                     _('Not enough values on line %(line)s')%{'line':line})
817                 return
819             # extract the new values
820             d = {}
821             for name, value in zip(idlessprops, values):
822                 value = value.strip()
823                 # only add the property if it has a value
824                 if value:
825                     # if it's a multilink, split it
826                     if isinstance(cl.properties[name], hyperdb.Multilink):
827                         value = value.split(':')
828                     d[name] = value
830             # perform the edit
831             if cl.hasnode(nodeid):
832                 # edit existing
833                 cl.set(nodeid, **d)
834             else:
835                 # new node
836                 found[cl.create(**d)] = 1
838         # retire the removed entries
839         for nodeid in cl.list():
840             if not found.has_key(nodeid):
841                 cl.retire(nodeid)
843         # all OK
844         self.db.commit()
846         self.ok_message.append(_('Items edited OK'))
848     def editCSVPermission(self):
849         ''' Determine whether the user has permission to edit this class.
851             Base behaviour is to check the user can edit this class.
852         ''' 
853         if not self.db.security.hasPermission('Edit', self.userid,
854                 self.classname):
855             return 0
856         return 1
858     def searchAction(self):
859         ''' Mangle some of the form variables.
861             Set the form ":filter" variable based on the values of the
862             filter variables - if they're set to anything other than
863             "dontcare" then add them to :filter.
865             Also handle the ":queryname" variable and save off the query to
866             the user's query list.
867         '''
868         # generic edit is per-class only
869         if not self.searchPermission():
870             self.error_message.append(
871                 _('You do not have permission to search %s' %self.classname))
873         # add a faked :filter form variable for each filtering prop
874         props = self.db.classes[self.classname].getprops()
875         for key in self.form.keys():
876             if not props.has_key(key): continue
877             if not self.form[key].value: continue
878             self.form.value.append(cgi.MiniFieldStorage(':filter', key))
880         # handle saving the query params
881         if self.form.has_key(':queryname'):
882             queryname = self.form[':queryname'].value.strip()
883             if queryname:
884                 # parse the environment and figure what the query _is_
885                 req = HTMLRequest(self)
886                 url = req.indexargs_href('', {})
888                 # handle editing an existing query
889                 try:
890                     qid = self.db.query.lookup(queryname)
891                     self.db.query.set(qid, klass=self.classname, url=url)
892                 except KeyError:
893                     # create a query
894                     qid = self.db.query.create(name=queryname,
895                         klass=self.classname, url=url)
897                     # and add it to the user's query multilink
898                     queries = self.db.user.get(self.userid, 'queries')
899                     queries.append(qid)
900                     self.db.user.set(self.userid, queries=queries)
902                 # commit the query change to the database
903                 self.db.commit()
905     def searchPermission(self):
906         ''' Determine whether the user has permission to search this class.
908             Base behaviour is to check the user can view this class.
909         ''' 
910         if not self.db.security.hasPermission('View', self.userid,
911                 self.classname):
912             return 0
913         return 1
915     def remove_action(self,  dre=re.compile(r'([^\d]+)(\d+)')):
916         # XXX I believe this could be handled by a regular edit action that
917         # just sets the multilink...
918         target = self.index_arg(':target')[0]
919         m = dre.match(target)
920         if m:
921             classname = m.group(1)
922             nodeid = m.group(2)
923             cl = self.db.getclass(classname)
924             cl.retire(nodeid)
925             # now take care of the reference
926             parentref =  self.index_arg(':multilink')[0]
927             parent, prop = parentref.split(':')
928             m = dre.match(parent)
929             if m:
930                 self.classname = m.group(1)
931                 self.nodeid = m.group(2)
932                 cl = self.db.getclass(self.classname)
933                 value = cl.get(self.nodeid, prop)
934                 value.remove(nodeid)
935                 cl.set(self.nodeid, **{prop:value})
936                 func = getattr(self, 'show%s'%self.classname)
937                 return func()
938             else:
939                 raise NotFound, parent
940         else:
941             raise NotFound, target
943     #
944     #  Utility methods for editing
945     #
946     def _changenode(self, props):
947         ''' change the node based on the contents of the form
948         '''
949         cl = self.db.classes[self.classname]
951         # create the message
952         message, files = self._handle_message()
953         if message:
954             props['messages'] = cl.get(self.nodeid, 'messages') + [message]
955         if files:
956             props['files'] = cl.get(self.nodeid, 'files') + files
958         # make the changes
959         return cl.set(self.nodeid, **props)
961     def _createnode(self, props):
962         ''' create a node based on the contents of the form
963         '''
964         cl = self.db.classes[self.classname]
966         # check for messages and files
967         message, files = self._handle_message()
968         if message:
969             props['messages'] = [message]
970         if files:
971             props['files'] = files
972         # create the node and return it's id
973         return cl.create(**props)
975     def _handle_message(self):
976         ''' generate an edit message
977         '''
978         # handle file attachments 
979         files = []
980         if self.form.has_key(':file'):
981             file = self.form[':file']
982             if file.filename:
983                 filename = file.filename.split('\\')[-1]
984                 mime_type = mimetypes.guess_type(filename)[0]
985                 if not mime_type:
986                     mime_type = "application/octet-stream"
987                 # create the new file entry
988                 files.append(self.db.file.create(type=mime_type,
989                     name=filename, content=file.file.read()))
991         # we don't want to do a message if none of the following is true...
992         cn = self.classname
993         cl = self.db.classes[self.classname]
994         props = cl.getprops()
995         note = None
996         # in a nutshell, don't do anything if there's no note or there's no
997         # NOSY
998         if self.form.has_key(':note'):
999             note = self.form[':note'].value.strip()
1000         if not note:
1001             return None, files
1002         if not props.has_key('messages'):
1003             return None, files
1004         if not isinstance(props['messages'], hyperdb.Multilink):
1005             return None, files
1006         if not props['messages'].classname == 'msg':
1007             return None, files
1008         if not (self.form.has_key('nosy') or note):
1009             return None, files
1011         # handle the note
1012         if '\n' in note:
1013             summary = re.split(r'\n\r?', note)[0]
1014         else:
1015             summary = note
1016         m = ['%s\n'%note]
1018         # handle the messageid
1019         # TODO: handle inreplyto
1020         messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
1021             self.classname, self.instance.config.MAIL_DOMAIN)
1023         # now create the message, attaching the files
1024         content = '\n'.join(m)
1025         message_id = self.db.msg.create(author=self.userid,
1026             recipients=[], date=date.Date('.'), summary=summary,
1027             content=content, files=files, messageid=messageid)
1029         # update the messages property
1030         return message_id, files
1032     def _post_editnode(self, nid):
1033         '''Do the linking part of the node creation.
1035            If a form element has :link or :multilink appended to it, its
1036            value specifies a node designator and the property on that node
1037            to add _this_ node to as a link or multilink.
1039            This is typically used on, eg. the file upload page to indicated
1040            which issue to link the file to.
1042            TODO: I suspect that this and newfile will go away now that
1043            there's the ability to upload a file using the issue :file form
1044            element!
1045         '''
1046         cn = self.classname
1047         cl = self.db.classes[cn]
1048         # link if necessary
1049         keys = self.form.keys()
1050         for key in keys:
1051             if key == ':multilink':
1052                 value = self.form[key].value
1053                 if type(value) != type([]): value = [value]
1054                 for value in value:
1055                     designator, property = value.split(':')
1056                     link, nodeid = hyperdb.splitDesignator(designator)
1057                     link = self.db.classes[link]
1058                     # take a dupe of the list so we're not changing the cache
1059                     value = link.get(nodeid, property)[:]
1060                     value.append(nid)
1061                     link.set(nodeid, **{property: value})
1062             elif key == ':link':
1063                 value = self.form[key].value
1064                 if type(value) != type([]): value = [value]
1065                 for value in value:
1066                     designator, property = value.split(':')
1067                     link, nodeid = hyperdb.splitDesignator(designator)
1068                     link = self.db.classes[link]
1069                     link.set(nodeid, **{property: nid})
1072 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
1073     ''' Pull properties for the given class out of the form.
1075         If a ":required" parameter is supplied, then the names property values
1076         must be supplied or a ValueError will be raised.
1077     '''
1078     required = []
1079     if form.has_key(':required'):
1080         value = form[':required']
1081         if isinstance(value, type([])):
1082             required = [i.value.strip() for i in value]
1083         else:
1084             required = [i.strip() for i in value.value.split(',')]
1086     props = {}
1087     keys = form.keys()
1088     properties = cl.getprops()
1089     for key in keys:
1090         if not properties.has_key(key):
1091             continue
1092         proptype = properties[key]
1094         # Get the form value. This value may be a MiniFieldStorage or a list
1095         # of MiniFieldStorages.
1096         value = form[key]
1098         # make sure non-multilinks only get one value
1099         if not isinstance(proptype, hyperdb.Multilink):
1100             if isinstance(value, type([])):
1101                 raise ValueError, 'You have submitted more than one value'\
1102                     ' for the %s property'%key
1103             # we've got a MiniFieldStorage, so pull out the value and strip
1104             # surrounding whitespace
1105             value = value.value.strip()
1107         if isinstance(proptype, hyperdb.String):
1108             if not value:
1109                 continue
1110         elif isinstance(proptype, hyperdb.Password):
1111             if not value:
1112                 # ignore empty password values
1113                 continue
1114             if not form.has_key('%s:confirm'%key):
1115                 raise ValueError, 'Password and confirmation text do not match'
1116             confirm = form['%s:confirm'%key]
1117             if isinstance(confirm, type([])):
1118                 raise ValueError, 'You have submitted more than one value'\
1119                     ' for the %s property'%key
1120             if value != confirm.value:
1121                 raise ValueError, 'Password and confirmation text do not match'
1122             value = password.Password(value)
1123         elif isinstance(proptype, hyperdb.Date):
1124             if value:
1125                 value = date.Date(form[key].value.strip())
1126             else:
1127                 continue
1128         elif isinstance(proptype, hyperdb.Interval):
1129             if value:
1130                 value = date.Interval(form[key].value.strip())
1131             else:
1132                 continue
1133         elif isinstance(proptype, hyperdb.Link):
1134             # see if it's the "no selection" choice
1135             if value == '-1':
1136                 continue
1137             # handle key values
1138             link = proptype.classname
1139             if not num_re.match(value):
1140                 try:
1141                     value = db.classes[link].lookup(value)
1142                 except KeyError:
1143                     raise ValueError, _('property "%(propname)s": '
1144                         '%(value)s not a %(classname)s')%{'propname':key, 
1145                         'value': value, 'classname': link}
1146                 except TypeError, message:
1147                     raise ValueError, _('you may only enter ID values '
1148                         'for property "%(propname)s": %(message)s')%{
1149                         'propname':key, 'message': message}
1150         elif isinstance(proptype, hyperdb.Multilink):
1151             if isinstance(value, type([])):
1152                 # it's a list of MiniFieldStorages
1153                 value = [i.value.strip() for i in value]
1154             else:
1155                 # it's a MiniFieldStorage, but may be a comma-separated list
1156                 # of values
1157                 value = [i.strip() for i in value.value.split(',')]
1158             link = proptype.classname
1159             l = []
1160             for entry in map(str, value):
1161                 if entry == '': continue
1162                 if not num_re.match(entry):
1163                     try:
1164                         entry = db.classes[link].lookup(entry)
1165                     except KeyError:
1166                         raise ValueError, _('property "%(propname)s": '
1167                             '"%(value)s" not an entry of %(classname)s')%{
1168                             'propname':key, 'value': entry, 'classname': link}
1169                     except TypeError, message:
1170                         raise ValueError, _('you may only enter ID values '
1171                             'for property "%(propname)s": %(message)s')%{
1172                             'propname':key, 'message': message}
1173                 l.append(entry)
1174             l.sort()
1175             value = l
1176         elif isinstance(proptype, hyperdb.Boolean):
1177             props[key] = value = value.lower() in ('yes', 'true', 'on', '1')
1178         elif isinstance(proptype, hyperdb.Number):
1179             props[key] = value = int(value)
1181         # register this as received if required
1182         if key in required:
1183             required.remove(key)
1185         # get the old value
1186         if nodeid:
1187             try:
1188                 existing = cl.get(nodeid, key)
1189             except KeyError:
1190                 # this might be a new property for which there is no existing
1191                 # value
1192                 if not properties.has_key(key): raise
1194             # if changed, set it
1195             if value != existing:
1196                 props[key] = value
1197         else:
1198             props[key] = value
1200     # see if all the required properties have been supplied
1201     if required:
1202         if len(required) > 1:
1203             p = 'properties'
1204         else:
1205             p = 'property'
1206         raise ValueError, 'Required %s %s not supplied'%(p, ', '.join(required))
1208     return props