Code

122c0bc07a936cbfe03d271bd0155e5a94ea4b3d
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.19 2002-09-06 07:21:31 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
14 from roundup.cgi import cgitb
16 from PageTemplates import PageTemplate
18 class Unauthorised(ValueError):
19     pass
21 class NotFound(ValueError):
22     pass
24 class Redirect(Exception):
25     pass
27 class SendFile(Exception):
28     ' Sent a file from the database '
30 class SendStaticFile(Exception):
31     ' Send a static file from the instance html directory '
33 def initialiseSecurity(security):
34     ''' Create some Permissions and Roles on the security object
36         This function is directly invoked by security.Security.__init__()
37         as a part of the Security object instantiation.
38     '''
39     security.addPermission(name="Web Registration",
40         description="User may register through the web")
41     p = security.addPermission(name="Web Access",
42         description="User may access the web interface")
43     security.addPermissionToRole('Admin', p)
45     # doing Role stuff through the web - make sure Admin can
46     p = security.addPermission(name="Web Roles",
47         description="User may manipulate user Roles through the web")
48     security.addPermissionToRole('Admin', p)
50 class Client:
51     '''
52     A note about login
53     ------------------
55     If the user has no login cookie, then they are anonymous. There
56     are two levels of anonymous use. If there is no 'anonymous' user, there
57     is no login at all and the database is opened in read-only mode. If the
58     'anonymous' user exists, the user is logged in using that user (though
59     there is no cookie). This allows them to modify the database, and all
60     modifications are attributed to the 'anonymous' user.
62     Once a user logs in, they are assigned a session. The Client instance
63     keeps the nodeid of the session as the "session" attribute.
65     Client attributes:
66         "url" is the current url path
67         "path" is the PATH_INFO inside the instance
68         "base" is the base URL for the instance
69     '''
71     def __init__(self, instance, request, env, form=None):
72         hyperdb.traceMark()
73         self.instance = instance
74         self.request = request
75         self.env = env
77         self.path = env['PATH_INFO']
78         self.split_path = self.path.split('/')
79         self.instance_path_name = env['INSTANCE_NAME']
81         # this is the base URL for this instance
82         url = self.env['SCRIPT_NAME'] + '/' + self.instance_path_name
83         self.base = urlparse.urlunparse(('http', env['HTTP_HOST'], url,
84             None, None, None))
86         # request.path is the full request path
87         x, x, path, x, x, x = urlparse.urlparse(request.path)
88         self.url = urlparse.urlunparse(('http', env['HTTP_HOST'], path,
89             None, None, None))
91         if form is None:
92             self.form = cgi.FieldStorage(environ=env)
93         else:
94             self.form = form
95         self.headers_done = 0
96         try:
97             self.debug = int(env.get("ROUNDUP_DEBUG", 0))
98         except ValueError:
99             # someone gave us a non-int debug level, turn it off
100             self.debug = 0
102     def main(self):
103         ''' Process a request.
105             The most common requests are handled like so:
106             1. figure out who we are, defaulting to the "anonymous" user
107                see determine_user
108             2. figure out what the request is for - the context
109                see determine_context
110             3. handle any requested action (item edit, search, ...)
111                see handle_action
112             4. render a template, resulting in HTML output
114             In some situations, exceptions occur:
115             - HTTP Redirect  (generally raised by an action)
116             - SendFile       (generally raised by determine_context)
117               serve up a FileClass "content" property
118             - SendStaticFile (generally raised by determine_context)
119               serve up a file from the tracker "html" directory
120             - Unauthorised   (generally raised by an action)
121               the action is cancelled, the request is rendered and an error
122               message is displayed indicating that permission was not
123               granted for the action to take place
124             - NotFound       (raised wherever it needs to be)
125               percolates up to the CGI interface that called the client
126         '''
127         self.content_action = None
128         self.ok_message = []
129         self.error_message = []
130         try:
131             # make sure we're identified (even anonymously)
132             self.determine_user()
133             # figure out the context and desired content template
134             self.determine_context()
135             # possibly handle a form submit action (may change self.classname
136             # and self.template, and may also append error/ok_messages)
137             self.handle_action()
138             # now render the page
139             if self.form.has_key(':contentonly'):
140                 # just the content
141                 self.write(self.content())
142             else:
143                 # render the content inside the page template
144                 self.write(self.renderTemplate('page', '',
145                     ok_message=self.ok_message,
146                     error_message=self.error_message))
147         except Redirect, url:
148             # let's redirect - if the url isn't None, then we need to do
149             # the headers, otherwise the headers have been set before the
150             # exception was raised
151             if url:
152                 self.header({'Location': url}, response=302)
153         except SendFile, designator:
154             self.serve_file(designator)
155         except SendStaticFile, file:
156             self.serve_static_file(str(file))
157         except Unauthorised, message:
158             self.write(self.renderTemplate('page', '', error_message=message))
159         except:
160             # everything else
161             self.write(cgitb.html())
163     def determine_user(self):
164         ''' Determine who the user is
165         '''
166         # determine the uid to use
167         self.opendb('admin')
169         # make sure we have the session Class
170         sessions = self.db.sessions
172         # age sessions, remove when they haven't been used for a week
173         # TODO: this shouldn't be done every access
174         week = 60*60*24*7
175         now = time.time()
176         for sessid in sessions.list():
177             interval = now - sessions.get(sessid, 'last_use')
178             if interval > week:
179                 sessions.destroy(sessid)
181         # look up the user session cookie
182         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
183         user = 'anonymous'
185         # bump the "revision" of the cookie since the format changed
186         if (cookie.has_key('roundup_user_2') and
187                 cookie['roundup_user_2'].value != 'deleted'):
189             # get the session key from the cookie
190             self.session = cookie['roundup_user_2'].value
191             # get the user from the session
192             try:
193                 # update the lifetime datestamp
194                 sessions.set(self.session, last_use=time.time())
195                 sessions.commit()
196                 user = sessions.get(self.session, 'user')
197             except KeyError:
198                 user = 'anonymous'
200         # sanity check on the user still being valid, getting the userid
201         # at the same time
202         try:
203             self.userid = self.db.user.lookup(user)
204         except (KeyError, TypeError):
205             user = 'anonymous'
207         # make sure the anonymous user is valid if we're using it
208         if user == 'anonymous':
209             self.make_user_anonymous()
210         else:
211             self.user = user
213         # reopen the database as the correct user
214         self.opendb(self.user)
216     def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
217         ''' Determine the context of this page from the URL:
219             The URL path after the instance identifier is examined. The path
220             is generally only one entry long.
222             - if there is no path, then we are in the "home" context.
223             * if the path is "_file", then the additional path entry
224               specifies the filename of a static file we're to serve up
225               from the instance "html" directory. Raises a SendStaticFile
226               exception.
227             - if there is something in the path (eg "issue"), it identifies
228               the tracker class we're to display.
229             - if the path is an item designator (eg "issue123"), then we're
230               to display a specific item.
231             * if the path starts with an item designator and is longer than
232               one entry, then we're assumed to be handling an item of a
233               FileClass, and the extra path information gives the filename
234               that the client is going to label the download with (ie
235               "file123/image.png" is nicer to download than "file123"). This
236               raises a SendFile exception.
238             Both of the "*" types of contexts stop before we bother to
239             determine the template we're going to use. That's because they
240             don't actually use templates.
242             The template used is specified by the :template CGI variable,
243             which defaults to:
245              only classname suplied:          "index"
246              full item designator supplied:   "item"
248             We set:
249              self.classname  - the class to display, can be None
250              self.template   - the template to render the current context with
251              self.nodeid     - the nodeid of the class we're displaying
252         '''
253         # default the optional variables
254         self.classname = None
255         self.nodeid = None
257         # determine the classname and possibly nodeid
258         path = self.split_path
259         if not path or path[0] in ('', 'home', 'index'):
260             if self.form.has_key(':template'):
261                 self.template = self.form[':template'].value
262             else:
263                 self.template = ''
264             return
265         elif path[0] == '_file':
266             raise SendStaticFile, path[1]
267         else:
268             self.classname = path[0]
269             if len(path) > 1:
270                 # send the file identified by the designator in path[0]
271                 raise SendFile, path[0]
273         # see if we got a designator
274         m = dre.match(self.classname)
275         if m:
276             self.classname = m.group(1)
277             self.nodeid = m.group(2)
278             # with a designator, we default to item view
279             self.template = 'item'
280         else:
281             # with only a class, we default to index view
282             self.template = 'index'
284         # see if we have a template override
285         if self.form.has_key(':template'):
286             self.template = self.form[':template'].value
289         # see if we were passed in a message
290         if self.form.has_key(':ok_message'):
291             self.ok_message.append(self.form[':ok_message'].value)
292         if self.form.has_key(':error_message'):
293             self.error_message.append(self.form[':error_message'].value)
295     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
296         ''' Serve the file from the content property of the designated item.
297         '''
298         m = dre.match(str(designator))
299         if not m:
300             raise NotFound, str(designator)
301         classname, nodeid = m.group(1), m.group(2)
302         if classname != 'file':
303             raise NotFound, designator
305         # we just want to serve up the file named
306         file = self.db.file
307         self.header({'Content-Type': file.get(nodeid, 'type')})
308         self.write(file.get(nodeid, 'content'))
310     def serve_static_file(self, file):
311         # we just want to serve up the file named
312         mt = mimetypes.guess_type(str(file))[0]
313         self.header({'Content-Type': mt})
314         self.write(open(os.path.join(self.instance.TEMPLATES, file)).read())
316     def renderTemplate(self, name, extension, **kwargs):
317         ''' Return a PageTemplate for the named page
318         '''
319         pt = getTemplate(self.instance.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:
328             # everything else
329             return cgitb.pt_html()
331     def content(self):
332         ''' Callback used by the page template to render the content of 
333             the page.
335             If we don't have a specific class to display, that is none was
336             determined in determine_context(), then we display a "home"
337             template.
338         '''
339         # now render the page content using the template we determined in
340         # determine_context
341         if self.classname is None:
342             name = 'home'
343         else:
344             name = self.classname
345         return self.renderTemplate(self.classname, self.template)
347     # these are the actions that are available
348     actions = {
349         'edit':     'editItemAction',
350         'editCSV':  'editCSVAction',
351         'new':      'newItemAction',
352         'register': 'registerAction',
353         'login':    'loginAction',
354         'logout':   'logout_action',
355         'search':   'searchAction',
356     }
357     def handle_action(self):
358         ''' Determine whether there should be an _action called.
360             The action is defined by the form variable :action which
361             identifies the method on this object to call. The four basic
362             actions are defined in the "actions" dictionary on this class:
363              "edit"      -> self.editItemAction
364              "new"       -> self.newItemAction
365              "register"  -> self.registerAction
366              "login"     -> self.loginAction
367              "logout"    -> self.logout_action
368              "search"    -> self.searchAction
370         '''
371         if not self.form.has_key(':action'):
372             return None
373         try:
374             # get the action, validate it
375             action = self.form[':action'].value
376             if not self.actions.has_key(action):
377                 raise ValueError, 'No such action "%s"'%action
379             # call the mapped action
380             getattr(self, self.actions[action])()
381         except Redirect:
382             raise
383         except Unauthorised:
384             raise
385         except:
386             self.db.rollback()
387             s = StringIO.StringIO()
388             traceback.print_exc(None, s)
389             self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
391     def write(self, content):
392         if not self.headers_done:
393             self.header()
394         self.request.wfile.write(content)
396     def header(self, headers=None, response=200):
397         '''Put up the appropriate header.
398         '''
399         if headers is None:
400             headers = {'Content-Type':'text/html'}
401         if not headers.has_key('Content-Type'):
402             headers['Content-Type'] = 'text/html'
403         self.request.send_response(response)
404         for entry in headers.items():
405             self.request.send_header(*entry)
406         self.request.end_headers()
407         self.headers_done = 1
408         if self.debug:
409             self.headers_sent = headers
411     def set_cookie(self, user, password):
412         # TODO generate a much, much stronger session key ;)
413         self.session = binascii.b2a_base64(repr(time.time())).strip()
415         # clean up the base64
416         if self.session[-1] == '=':
417             if self.session[-2] == '=':
418                 self.session = self.session[:-2]
419             else:
420                 self.session = self.session[:-1]
422         # insert the session in the sessiondb
423         self.db.sessions.set(self.session, user=user, last_use=time.time())
425         # and commit immediately
426         self.db.sessions.commit()
428         # expire us in a long, long time
429         expire = Cookie._getdate(86400*365)
431         # generate the cookie path - make sure it has a trailing '/'
432         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
433             ''))
434         self.header({'Set-Cookie': 'roundup_user_2=%s; expires=%s; Path=%s;'%(
435             self.session, expire, path)})
437     def make_user_anonymous(self):
438         ''' Make us anonymous
440             This method used to handle non-existence of the 'anonymous'
441             user, but that user is mandatory now.
442         '''
443         self.userid = self.db.user.lookup('anonymous')
444         self.user = 'anonymous'
446     def logout(self):
447         ''' Make us really anonymous - nuke the cookie too
448         '''
449         self.make_user_anonymous()
451         # construct the logout cookie
452         now = Cookie._getdate()
453         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
454             ''))
455         self.header({'Set-Cookie':
456             'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
457             path)})
458         self.login()
460     def opendb(self, user):
461         ''' Open the database.
462         '''
463         # open the db if the user has changed
464         if not hasattr(self, 'db') or user != self.db.journaltag:
465             self.db = self.instance.open(user)
467     #
468     # Actions
469     #
470     def loginAction(self):
471         ''' Attempt to log a user in.
473             Sets up a session for the user which contains the login
474             credentials.
475         '''
476         # we need the username at a minimum
477         if not self.form.has_key('__login_name'):
478             self.error_message.append(_('Username required'))
479             return
481         self.user = self.form['__login_name'].value
482         # re-open the database for real, using the user
483         self.opendb(self.user)
484         if self.form.has_key('__login_password'):
485             password = self.form['__login_password'].value
486         else:
487             password = ''
488         # make sure the user exists
489         try:
490             self.userid = self.db.user.lookup(self.user)
491         except KeyError:
492             name = self.user
493             self.make_user_anonymous()
494             self.error_message.append(_('No such user "%(name)s"')%locals())
495             return
497         # and that the password is correct
498         pw = self.db.user.get(self.userid, 'password')
499         if password != pw:
500             self.make_user_anonymous()
501             self.error_message.append(_('Incorrect password'))
502             return
504         # make sure we're allowed to be here
505         if not self.loginPermission():
506             self.make_user_anonymous()
507             raise Unauthorised, _("You do not have permission to login")
509         # set the session cookie
510         self.set_cookie(self.user, password)
512     def loginPermission(self):
513         ''' Determine whether the user has permission to log in.
515             Base behaviour is to check the user has "Web Access".
516         ''' 
517         if not self.db.security.hasPermission('Web Access', self.userid):
518             return 0
519         return 1
521     def logout_action(self):
522         ''' Make us really anonymous - nuke the cookie too
523         '''
524         # log us out
525         self.make_user_anonymous()
527         # construct the logout cookie
528         now = Cookie._getdate()
529         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
530             ''))
531         self.header(headers={'Set-Cookie':
532           'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)})
534         # Let the user know what's going on
535         self.ok_message.append(_('You are logged out'))
537     def registerAction(self):
538         '''Attempt to create a new user based on the contents of the form
539         and then set the cookie.
541         return 1 on successful login
542         '''
543         # create the new user
544         cl = self.db.user
546         # parse the props from the form
547         try:
548             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
549         except (ValueError, KeyError), message:
550             self.error_message.append(_('Error: ') + str(message))
551             return
553         # make sure we're allowed to register
554         if not self.registerPermission(props):
555             raise Unauthorised, _("You do not have permission to register")
557         # re-open the database as "admin"
558         if self.user != 'admin':
559             self.opendb('admin')
560             
561         # create the new user
562         cl = self.db.user
563         try:
564             props = parsePropsFromForm(self.db, cl, self.form)
565             props['roles'] = self.instance.NEW_WEB_USER_ROLES
566             self.userid = cl.create(**props)
567             self.db.commit()
568         except ValueError, message:
569             self.error_message.append(message)
571         # log the new user in
572         self.user = cl.get(self.userid, 'username')
573         # re-open the database for real, using the user
574         self.opendb(self.user)
575         password = self.db.user.get(self.userid, 'password')
576         self.set_cookie(self.user, password)
578         # nice message
579         self.ok_message.append(_('You are now registered, welcome!'))
581     def registerPermission(self, props):
582         ''' Determine whether the user has permission to register
584             Base behaviour is to check the user has "Web Registration".
585         '''
586         # registration isn't allowed to supply roles
587         if props.has_key('roles'):
588             return 0
589         if self.db.security.hasPermission('Web Registration', self.userid):
590             return 1
591         return 0
593     def editItemAction(self):
594         ''' Perform an edit of an item in the database.
596             Some special form elements:
598             :link=designator:property
599             :multilink=designator:property
600              The value specifies a node designator and the property on that
601              node to add _this_ node to as a link or multilink.
602             __note
603              Create a message and attach it to the current node's
604              "messages" property.
605             __file
606              Create a file and attach it to the current node's
607              "files" property. Attach the file to the message created from
608              the __note if it's supplied.
610             :required=property,property,...
611              The named properties are required to be filled in the form.
613         '''
614         cl = self.db.classes[self.classname]
616         # parse the props from the form
617         try:
618             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
619         except (ValueError, KeyError), message:
620             self.error_message.append(_('Error: ') + str(message))
621             return
623         # check permission
624         if not self.editItemPermission(props):
625             self.error_message.append(
626                 _('You do not have permission to edit %(classname)s'%
627                 self.__dict__))
628             return
630         # perform the edit
631         try:
632             # make changes to the node
633             props = self._changenode(props)
634             # handle linked nodes 
635             self._post_editnode(self.nodeid)
636         except (ValueError, KeyError), message:
637             self.error_message.append(_('Error: ') + str(message))
638             return
640         # commit now that all the tricky stuff is done
641         self.db.commit()
643         # and some nice feedback for the user
644         if props:
645             message = _('%(changes)s edited ok')%{'changes':
646                 ', '.join(props.keys())}
647         elif self.form.has_key('__note') and self.form['__note'].value:
648             message = _('note added')
649         elif (self.form.has_key('__file') and self.form['__file'].filename):
650             message = _('file added')
651         else:
652             message = _('nothing changed')
654         # redirect to the item's edit page
655         raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
656             self.nodeid,  urllib.quote(message))
658     def editItemPermission(self, props):
659         ''' Determine whether the user has permission to edit this item.
661             Base behaviour is to check the user can edit this class. If we're
662             editing the "user" class, users are allowed to edit their own
663             details. Unless it's the "roles" property, which requires the
664             special Permission "Web Roles".
665         '''
666         # if this is a user node and the user is editing their own node, then
667         # we're OK
668         has = self.db.security.hasPermission
669         if self.classname == 'user':
670             # reject if someone's trying to edit "roles" and doesn't have the
671             # right permission.
672             if props.has_key('roles') and not has('Web Roles', self.userid,
673                     'user'):
674                 return 0
675             # if the item being edited is the current user, we're ok
676             if self.nodeid == self.userid:
677                 return 1
678         if self.db.security.hasPermission('Edit', self.userid, self.classname):
679             return 1
680         return 0
682     def newItemAction(self):
683         ''' Add a new item to the database.
685             This follows the same form as the editItemAction, with the same
686             special form values.
687         '''
688         cl = self.db.classes[self.classname]
690         # parse the props from the form
691         try:
692             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
693         except (ValueError, KeyError), message:
694             self.error_message.append(_('Error: ') + str(message))
695             return
697         if not self.newItemPermission(props):
698             self.error_message.append(
699                 _('You do not have permission to create %s' %self.classname))
701         # create a little extra message for anticipated :link / :multilink
702         if self.form.has_key(':multilink'):
703             link = self.form[':multilink'].value
704         elif self.form.has_key(':link'):
705             link = self.form[':multilink'].value
706         else:
707             link = None
708             xtra = ''
709         if link:
710             designator, linkprop = link.split(':')
711             xtra = ' for <a href="%s">%s</a>'%(designator, designator)
713         try:
714             # do the create
715             nid = self._createnode(props)
717             # handle linked nodes 
718             self._post_editnode(nid)
720             # commit now that all the tricky stuff is done
721             self.db.commit()
723             # render the newly created item
724             self.nodeid = nid
726             # and some nice feedback for the user
727             message = _('%(classname)s created ok')%self.__dict__ + xtra
728         except (ValueError, KeyError), message:
729             self.error_message.append(_('Error: ') + str(message))
730             return
731         except:
732             # oops
733             self.db.rollback()
734             s = StringIO.StringIO()
735             traceback.print_exc(None, s)
736             self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
737             return
739         # redirect to the new item's page
740         raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
741             nid,  urllib.quote(message))
743     def newItemPermission(self, props):
744         ''' Determine whether the user has permission to create (edit) this
745             item.
747             Base behaviour is to check the user can edit this class. No
748             additional property checks are made. Additionally, new user items
749             may be created if the user has the "Web Registration" Permission.
750         '''
751         has = self.db.security.hasPermission
752         if self.classname == 'user' and has('Web Registration', self.userid,
753                 'user'):
754             return 1
755         if has('Edit', self.userid, self.classname):
756             return 1
757         return 0
759     def editCSVAction(self):
760         ''' Performs an edit of all of a class' items in one go.
762             The "rows" CGI var defines the CSV-formatted entries for the
763             class. New nodes are identified by the ID 'X' (or any other
764             non-existent ID) and removed lines are retired.
765         '''
766         # this is per-class only
767         if not self.editCSVPermission():
768             self.error_message.append(
769                 _('You do not have permission to edit %s' %self.classname))
771         # get the CSV module
772         try:
773             import csv
774         except ImportError:
775             self.error_message.append(_(
776                 'Sorry, you need the csv module to use this function.<br>\n'
777                 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
778             return
780         cl = self.db.classes[self.classname]
781         idlessprops = cl.getprops(protected=0).keys()
782         idlessprops.sort()
783         props = ['id'] + idlessprops
785         # do the edit
786         rows = self.form['rows'].value.splitlines()
787         p = csv.parser()
788         found = {}
789         line = 0
790         for row in rows[1:]:
791             line += 1
792             values = p.parse(row)
793             # not a complete row, keep going
794             if not values: continue
796             # skip property names header
797             if values == props:
798                 continue
800             # extract the nodeid
801             nodeid, values = values[0], values[1:]
802             found[nodeid] = 1
804             # confirm correct weight
805             if len(idlessprops) != len(values):
806                 self.error_message.append(
807                     _('Not enough values on line %(line)s')%{'line':line})
808                 return
810             # extract the new values
811             d = {}
812             for name, value in zip(idlessprops, values):
813                 value = value.strip()
814                 # only add the property if it has a value
815                 if value:
816                     # if it's a multilink, split it
817                     if isinstance(cl.properties[name], hyperdb.Multilink):
818                         value = value.split(':')
819                     d[name] = value
821             # perform the edit
822             if cl.hasnode(nodeid):
823                 # edit existing
824                 cl.set(nodeid, **d)
825             else:
826                 # new node
827                 found[cl.create(**d)] = 1
829         # retire the removed entries
830         for nodeid in cl.list():
831             if not found.has_key(nodeid):
832                 cl.retire(nodeid)
834         # all OK
835         self.db.commit()
837         self.ok_message.append(_('Items edited OK'))
839     def editCSVPermission(self):
840         ''' Determine whether the user has permission to edit this class.
842             Base behaviour is to check the user can edit this class.
843         ''' 
844         if not self.db.security.hasPermission('Edit', self.userid,
845                 self.classname):
846             return 0
847         return 1
849     def searchAction(self):
850         ''' Mangle some of the form variables.
852             Set the form ":filter" variable based on the values of the
853             filter variables - if they're set to anything other than
854             "dontcare" then add them to :filter.
856             Also handle the ":queryname" variable and save off the query to
857             the user's query list.
858         '''
859         # generic edit is per-class only
860         if not self.searchPermission():
861             self.error_message.append(
862                 _('You do not have permission to search %s' %self.classname))
864         # add a faked :filter form variable for each filtering prop
865         props = self.db.classes[self.classname].getprops()
866         for key in self.form.keys():
867             if not props.has_key(key): continue
868             if not self.form[key].value: continue
869             self.form.value.append(cgi.MiniFieldStorage(':filter', key))
871         # handle saving the query params
872         if self.form.has_key(':queryname'):
873             queryname = self.form[':queryname'].value.strip()
874             if queryname:
875                 # parse the environment and figure what the query _is_
876                 req = HTMLRequest(self)
877                 url = req.indexargs_href('', {})
879                 # handle editing an existing query
880                 try:
881                     qid = self.db.query.lookup(queryname)
882                     self.db.query.set(qid, klass=self.classname, url=url)
883                 except KeyError:
884                     # create a query
885                     qid = self.db.query.create(name=queryname,
886                         klass=self.classname, url=url)
888                     # and add it to the user's query multilink
889                     queries = self.db.user.get(self.userid, 'queries')
890                     queries.append(qid)
891                     self.db.user.set(self.userid, queries=queries)
893                 # commit the query change to the database
894                 self.db.commit()
896     def searchPermission(self):
897         ''' Determine whether the user has permission to search this class.
899             Base behaviour is to check the user can view this class.
900         ''' 
901         if not self.db.security.hasPermission('View', self.userid,
902                 self.classname):
903             return 0
904         return 1
906     def remove_action(self,  dre=re.compile(r'([^\d]+)(\d+)')):
907         # XXX I believe this could be handled by a regular edit action that
908         # just sets the multilink...
909         target = self.index_arg(':target')[0]
910         m = dre.match(target)
911         if m:
912             classname = m.group(1)
913             nodeid = m.group(2)
914             cl = self.db.getclass(classname)
915             cl.retire(nodeid)
916             # now take care of the reference
917             parentref =  self.index_arg(':multilink')[0]
918             parent, prop = parentref.split(':')
919             m = dre.match(parent)
920             if m:
921                 self.classname = m.group(1)
922                 self.nodeid = m.group(2)
923                 cl = self.db.getclass(self.classname)
924                 value = cl.get(self.nodeid, prop)
925                 value.remove(nodeid)
926                 cl.set(self.nodeid, **{prop:value})
927                 func = getattr(self, 'show%s'%self.classname)
928                 return func()
929             else:
930                 raise NotFound, parent
931         else:
932             raise NotFound, target
934     #
935     #  Utility methods for editing
936     #
937     def _changenode(self, props):
938         ''' change the node based on the contents of the form
939         '''
940         cl = self.db.classes[self.classname]
942         # create the message
943         message, files = self._handle_message()
944         if message:
945             props['messages'] = cl.get(self.nodeid, 'messages') + [message]
946         if files:
947             props['files'] = cl.get(self.nodeid, 'files') + files
949         # make the changes
950         return cl.set(self.nodeid, **props)
952     def _createnode(self, props):
953         ''' create a node based on the contents of the form
954         '''
955         cl = self.db.classes[self.classname]
957         # check for messages and files
958         message, files = self._handle_message()
959         if message:
960             props['messages'] = [message]
961         if files:
962             props['files'] = files
963         # create the node and return it's id
964         return cl.create(**props)
966     def _handle_message(self):
967         ''' generate an edit message
968         '''
969         # handle file attachments 
970         files = []
971         if self.form.has_key('__file'):
972             file = self.form['__file']
973             if file.filename:
974                 filename = file.filename.split('\\')[-1]
975                 mime_type = mimetypes.guess_type(filename)[0]
976                 if not mime_type:
977                     mime_type = "application/octet-stream"
978                 # create the new file entry
979                 files.append(self.db.file.create(type=mime_type,
980                     name=filename, content=file.file.read()))
982         # we don't want to do a message if none of the following is true...
983         cn = self.classname
984         cl = self.db.classes[self.classname]
985         props = cl.getprops()
986         note = None
987         # in a nutshell, don't do anything if there's no note or there's no
988         # NOSY
989         if self.form.has_key('__note'):
990             note = self.form['__note'].value.strip()
991         if not note:
992             return None, files
993         if not props.has_key('messages'):
994             return None, files
995         if not isinstance(props['messages'], hyperdb.Multilink):
996             return None, files
997         if not props['messages'].classname == 'msg':
998             return None, files
999         if not (self.form.has_key('nosy') or note):
1000             return None, files
1002         # handle the note
1003         if '\n' in note:
1004             summary = re.split(r'\n\r?', note)[0]
1005         else:
1006             summary = note
1007         m = ['%s\n'%note]
1009         # handle the messageid
1010         # TODO: handle inreplyto
1011         messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
1012             self.classname, self.instance.MAIL_DOMAIN)
1014         # now create the message, attaching the files
1015         content = '\n'.join(m)
1016         message_id = self.db.msg.create(author=self.userid,
1017             recipients=[], date=date.Date('.'), summary=summary,
1018             content=content, files=files, messageid=messageid)
1020         # update the messages property
1021         return message_id, files
1023     def _post_editnode(self, nid):
1024         '''Do the linking part of the node creation.
1026            If a form element has :link or :multilink appended to it, its
1027            value specifies a node designator and the property on that node
1028            to add _this_ node to as a link or multilink.
1030            This is typically used on, eg. the file upload page to indicated
1031            which issue to link the file to.
1033            TODO: I suspect that this and newfile will go away now that
1034            there's the ability to upload a file using the issue __file form
1035            element!
1036         '''
1037         cn = self.classname
1038         cl = self.db.classes[cn]
1039         # link if necessary
1040         keys = self.form.keys()
1041         for key in keys:
1042             if key == ':multilink':
1043                 value = self.form[key].value
1044                 if type(value) != type([]): value = [value]
1045                 for value in value:
1046                     designator, property = value.split(':')
1047                     link, nodeid = hyperdb.splitDesignator(designator)
1048                     link = self.db.classes[link]
1049                     # take a dupe of the list so we're not changing the cache
1050                     value = link.get(nodeid, property)[:]
1051                     value.append(nid)
1052                     link.set(nodeid, **{property: value})
1053             elif key == ':link':
1054                 value = self.form[key].value
1055                 if type(value) != type([]): value = [value]
1056                 for value in value:
1057                     designator, property = value.split(':')
1058                     link, nodeid = hyperdb.splitDesignator(designator)
1059                     link = self.db.classes[link]
1060                     link.set(nodeid, **{property: nid})
1063 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
1064     ''' Pull properties for the given class out of the form.
1066         If a ":required" parameter is supplied, then the names property values
1067         must be supplied or a ValueError will be raised.
1068     '''
1069     required = []
1070     print form.keys()
1071     if form.has_key(':required'):
1072         value = form[':required']
1073         print 'required', value
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         raise ValueError, 'Required properties %s not supplied'%(
1180             ', '.join(required))
1182     return props