Code

enforce login permission, fix to :required checking
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.18 2002-09-06 05:53:02 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.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 XXXremove_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         # XXX handle this !
910         target = self.index_arg(':target')[0]
911         m = dre.match(target)
912         if m:
913             classname = m.group(1)
914             nodeid = m.group(2)
915             cl = self.db.getclass(classname)
916             cl.retire(nodeid)
917             # now take care of the reference
918             parentref =  self.index_arg(':multilink')[0]
919             parent, prop = parentref.split(':')
920             m = dre.match(parent)
921             if m:
922                 self.classname = m.group(1)
923                 self.nodeid = m.group(2)
924                 cl = self.db.getclass(self.classname)
925                 value = cl.get(self.nodeid, prop)
926                 value.remove(nodeid)
927                 cl.set(self.nodeid, **{prop:value})
928                 func = getattr(self, 'show%s'%self.classname)
929                 return func()
930             else:
931                 raise NotFound, parent
932         else:
933             raise NotFound, target
935     #
936     #  Utility methods for editing
937     #
938     def _changenode(self, props):
939         ''' change the node based on the contents of the form
940         '''
941         cl = self.db.classes[self.classname]
943         # create the message
944         message, files = self._handle_message()
945         if message:
946             props['messages'] = cl.get(self.nodeid, 'messages') + [message]
947         if files:
948             props['files'] = cl.get(self.nodeid, 'files') + files
950         # make the changes
951         return cl.set(self.nodeid, **props)
953     def _createnode(self, props):
954         ''' create a node based on the contents of the form
955         '''
956         cl = self.db.classes[self.classname]
958         # check for messages and files
959         message, files = self._handle_message()
960         if message:
961             props['messages'] = [message]
962         if files:
963             props['files'] = files
964         # create the node and return it's id
965         return cl.create(**props)
967     def _handle_message(self):
968         ''' generate an edit message
969         '''
970         # handle file attachments 
971         files = []
972         if self.form.has_key('__file'):
973             file = self.form['__file']
974             if file.filename:
975                 filename = file.filename.split('\\')[-1]
976                 mime_type = mimetypes.guess_type(filename)[0]
977                 if not mime_type:
978                     mime_type = "application/octet-stream"
979                 # create the new file entry
980                 files.append(self.db.file.create(type=mime_type,
981                     name=filename, content=file.file.read()))
983         # we don't want to do a message if none of the following is true...
984         cn = self.classname
985         cl = self.db.classes[self.classname]
986         props = cl.getprops()
987         note = None
988         # in a nutshell, don't do anything if there's no note or there's no
989         # NOSY
990         if self.form.has_key('__note'):
991             note = self.form['__note'].value.strip()
992         if not note:
993             return None, files
994         if not props.has_key('messages'):
995             return None, files
996         if not isinstance(props['messages'], hyperdb.Multilink):
997             return None, files
998         if not props['messages'].classname == 'msg':
999             return None, files
1000         if not (self.form.has_key('nosy') or note):
1001             return None, files
1003         # handle the note
1004         if '\n' in note:
1005             summary = re.split(r'\n\r?', note)[0]
1006         else:
1007             summary = note
1008         m = ['%s\n'%note]
1010         # handle the messageid
1011         # TODO: handle inreplyto
1012         messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
1013             self.classname, self.instance.MAIL_DOMAIN)
1015         # now create the message, attaching the files
1016         content = '\n'.join(m)
1017         message_id = self.db.msg.create(author=self.userid,
1018             recipients=[], date=date.Date('.'), summary=summary,
1019             content=content, files=files, messageid=messageid)
1021         # update the messages property
1022         return message_id, files
1024     def _post_editnode(self, nid):
1025         '''Do the linking part of the node creation.
1027            If a form element has :link or :multilink appended to it, its
1028            value specifies a node designator and the property on that node
1029            to add _this_ node to as a link or multilink.
1031            This is typically used on, eg. the file upload page to indicated
1032            which issue to link the file to.
1034            TODO: I suspect that this and newfile will go away now that
1035            there's the ability to upload a file using the issue __file form
1036            element!
1037         '''
1038         cn = self.classname
1039         cl = self.db.classes[cn]
1040         # link if necessary
1041         keys = self.form.keys()
1042         for key in keys:
1043             if key == ':multilink':
1044                 value = self.form[key].value
1045                 if type(value) != type([]): value = [value]
1046                 for value in value:
1047                     designator, property = value.split(':')
1048                     link, nodeid = hyperdb.splitDesignator(designator)
1049                     link = self.db.classes[link]
1050                     # take a dupe of the list so we're not changing the cache
1051                     value = link.get(nodeid, property)[:]
1052                     value.append(nid)
1053                     link.set(nodeid, **{property: value})
1054             elif key == ':link':
1055                 value = self.form[key].value
1056                 if type(value) != type([]): value = [value]
1057                 for value in value:
1058                     designator, property = value.split(':')
1059                     link, nodeid = hyperdb.splitDesignator(designator)
1060                     link = self.db.classes[link]
1061                     link.set(nodeid, **{property: nid})
1064 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
1065     ''' Pull properties for the given class out of the form.
1067         If a ":required" parameter is supplied, then the names property values
1068         must be supplied or a ValueError will be raised.
1069     '''
1070     required = []
1071     print form.keys()
1072     if form.has_key(':required'):
1073         value = form[':required']
1074         print 'required', value
1075         if isinstance(value, type([])):
1076             required = [i.value.strip() for i in value]
1077         else:
1078             required = [i.strip() for i in value.value.split(',')]
1080     props = {}
1081     keys = form.keys()
1082     for key in keys:
1083         if not cl.properties.has_key(key):
1084             continue
1085         proptype = cl.properties[key]
1087         # Get the form value. This value may be a MiniFieldStorage or a list
1088         # of MiniFieldStorages.
1089         value = form[key]
1091         # make sure non-multilinks only get one value
1092         if not isinstance(proptype, hyperdb.Multilink):
1093             if isinstance(value, type([])):
1094                 raise ValueError, 'You have submitted more than one value'\
1095                     ' for the %s property'%key
1096             # we've got a MiniFieldStorage, so pull out the value and strip
1097             # surrounding whitespace
1098             value = value.value.strip()
1100         if isinstance(proptype, hyperdb.String):
1101             if not value:
1102                 continue
1103         elif isinstance(proptype, hyperdb.Password):
1104             if not value:
1105                 # ignore empty password values
1106                 continue
1107             value = password.Password(value)
1108         elif isinstance(proptype, hyperdb.Date):
1109             if value:
1110                 value = date.Date(form[key].value.strip())
1111             else:
1112                 value = None
1113         elif isinstance(proptype, hyperdb.Interval):
1114             if value:
1115                 value = date.Interval(form[key].value.strip())
1116             else:
1117                 value = None
1118         elif isinstance(proptype, hyperdb.Link):
1119             # see if it's the "no selection" choice
1120             if value == '-1':
1121                 value = None
1122             else:
1123                 # handle key values
1124                 link = cl.properties[key].classname
1125                 if not num_re.match(value):
1126                     try:
1127                         value = db.classes[link].lookup(value)
1128                     except KeyError:
1129                         raise ValueError, _('property "%(propname)s": '
1130                             '%(value)s not a %(classname)s')%{'propname':key, 
1131                             'value': value, 'classname': link}
1132         elif isinstance(proptype, hyperdb.Multilink):
1133             if isinstance(value, type([])):
1134                 # it's a list of MiniFieldStorages
1135                 value = [i.value.strip() for i in value]
1136             else:
1137                 # it's a MiniFieldStorage, but may be a comma-separated list
1138                 # of values
1139                 value = [i.strip() for i in value.value.split(',')]
1140             link = cl.properties[key].classname
1141             l = []
1142             for entry in map(str, value):
1143                 if entry == '': continue
1144                 if not num_re.match(entry):
1145                     try:
1146                         entry = db.classes[link].lookup(entry)
1147                     except KeyError:
1148                         raise ValueError, _('property "%(propname)s": '
1149                             '"%(value)s" not an entry of %(classname)s')%{
1150                             'propname':key, 'value': entry, 'classname': link}
1151                 l.append(entry)
1152             l.sort()
1153             value = l
1154         elif isinstance(proptype, hyperdb.Boolean):
1155             props[key] = value = value.lower() in ('yes', 'true', 'on', '1')
1156         elif isinstance(proptype, hyperdb.Number):
1157             props[key] = value = int(value)
1159         # register this as received if required
1160         if key in required:
1161             required.remove(key)
1163         # get the old value
1164         if nodeid:
1165             try:
1166                 existing = cl.get(nodeid, key)
1167             except KeyError:
1168                 # this might be a new property for which there is no existing
1169                 # value
1170                 if not cl.properties.has_key(key): raise
1172             # if changed, set it
1173             if value != existing:
1174                 props[key] = value
1175         else:
1176             props[key] = value
1178     # see if all the required properties have been supplied
1179     if required:
1180         raise ValueError, 'Required properties %s not supplied'%(
1181             ', '.join(required))
1183     return props