Code

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