Code

more docco... and we need to check for web access Permission!
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.17 2002-09-06 03:21:30 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':    'login_action',
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.login_action
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:
384             self.db.rollback()
385             s = StringIO.StringIO()
386             traceback.print_exc(None, s)
387             self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
389     def write(self, content):
390         if not self.headers_done:
391             self.header()
392         self.request.wfile.write(content)
394     def header(self, headers=None, response=200):
395         '''Put up the appropriate header.
396         '''
397         if headers is None:
398             headers = {'Content-Type':'text/html'}
399         if not headers.has_key('Content-Type'):
400             headers['Content-Type'] = 'text/html'
401         self.request.send_response(response)
402         for entry in headers.items():
403             self.request.send_header(*entry)
404         self.request.end_headers()
405         self.headers_done = 1
406         if self.debug:
407             self.headers_sent = headers
409     def set_cookie(self, user, password):
410         # TODO generate a much, much stronger session key ;)
411         self.session = binascii.b2a_base64(repr(time.time())).strip()
413         # clean up the base64
414         if self.session[-1] == '=':
415             if self.session[-2] == '=':
416                 self.session = self.session[:-2]
417             else:
418                 self.session = self.session[:-1]
420         # insert the session in the sessiondb
421         self.db.sessions.set(self.session, user=user, last_use=time.time())
423         # and commit immediately
424         self.db.sessions.commit()
426         # expire us in a long, long time
427         expire = Cookie._getdate(86400*365)
429         # generate the cookie path - make sure it has a trailing '/'
430         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
431             ''))
432         self.header({'Set-Cookie': 'roundup_user_2=%s; expires=%s; Path=%s;'%(
433             self.session, expire, path)})
435     def make_user_anonymous(self):
436         ''' Make us anonymous
438             This method used to handle non-existence of the 'anonymous'
439             user, but that user is mandatory now.
440         '''
441         self.userid = self.db.user.lookup('anonymous')
442         self.user = 'anonymous'
444     def logout(self):
445         ''' Make us really anonymous - nuke the cookie too
446         '''
447         self.make_user_anonymous()
449         # construct the logout cookie
450         now = Cookie._getdate()
451         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
452             ''))
453         self.header({'Set-Cookie':
454             'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
455             path)})
456         self.login()
458     def opendb(self, user):
459         ''' Open the database.
460         '''
461         # open the db if the user has changed
462         if not hasattr(self, 'db') or user != self.db.journaltag:
463             self.db = self.instance.open(user)
465     #
466     # Actions
467     #
468     def login_action(self):
469         ''' Attempt to log a user in and set the cookie
470         '''
471         # we need the username at a minimum
472         if not self.form.has_key('__login_name'):
473             self.error_message.append(_('Username required'))
474             return
476         self.user = self.form['__login_name'].value
477         # re-open the database for real, using the user
478         self.opendb(self.user)
479         if self.form.has_key('__login_password'):
480             password = self.form['__login_password'].value
481         else:
482             password = ''
483         # make sure the user exists
484         try:
485             self.userid = self.db.user.lookup(self.user)
486         except KeyError:
487             name = self.user
488             self.make_user_anonymous()
489             self.error_message.append(_('No such user "%(name)s"')%locals())
490             return
492         # and that the password is correct
493         pw = self.db.user.get(self.userid, 'password')
494         if password != pw:
495             self.make_user_anonymous()
496             self.error_message.append(_('Incorrect password'))
497             return
499         # XXX check for web access permission!!!!
501         # set the session cookie
502         self.set_cookie(self.user, password)
504     def logout_action(self):
505         ''' Make us really anonymous - nuke the cookie too
506         '''
507         # log us out
508         self.make_user_anonymous()
510         # construct the logout cookie
511         now = Cookie._getdate()
512         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
513             ''))
514         self.header(headers={'Set-Cookie':
515           'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)})
517         # Let the user know what's going on
518         self.ok_message.append(_('You are logged out'))
520     def registerAction(self):
521         '''Attempt to create a new user based on the contents of the form
522         and then set the cookie.
524         return 1 on successful login
525         '''
526         # create the new user
527         cl = self.db.user
529         # parse the props from the form
530         try:
531             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
532         except (ValueError, KeyError), message:
533             self.error_message.append(_('Error: ') + str(message))
534             return
536         # make sure we're allowed to register
537         if not self.registerPermission(props):
538             raise Unauthorised, _("You do not have permission to register")
540         # re-open the database as "admin"
541         if self.user != 'admin':
542             self.opendb('admin')
543             
544         # create the new user
545         cl = self.db.user
546         try:
547             props = parsePropsFromForm(self.db, cl, self.form)
548             props['roles'] = self.instance.NEW_WEB_USER_ROLES
549             self.userid = cl.create(**props)
550             self.db.commit()
551         except ValueError, message:
552             self.error_message.append(message)
554         # log the new user in
555         self.user = cl.get(self.userid, 'username')
556         # re-open the database for real, using the user
557         self.opendb(self.user)
558         password = self.db.user.get(self.userid, 'password')
559         self.set_cookie(self.user, password)
561         # nice message
562         self.ok_message.append(_('You are now registered, welcome!'))
564     def registerPermission(self, props):
565         ''' Determine whether the user has permission to register
567             Base behaviour is to check the user has "Web Registration".
568         '''
569         # registration isn't allowed to supply roles
570         if props.has_key('roles'):
571             return 0
572         if self.db.security.hasPermission('Web Registration', self.userid):
573             return 1
574         return 0
576     def editItemAction(self):
577         ''' Perform an edit of an item in the database.
579             Some special form elements:
581             :link=designator:property
582             :multilink=designator:property
583              The value specifies a node designator and the property on that
584              node to add _this_ node to as a link or multilink.
585             __note
586              Create a message and attach it to the current node's
587              "messages" property.
588             __file
589              Create a file and attach it to the current node's
590              "files" property. Attach the file to the message created from
591              the __note if it's supplied.
593             :required=property,property,...
594              The named properties are required to be filled in the form.
596         '''
597         cl = self.db.classes[self.classname]
599         # parse the props from the form
600         try:
601             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
602         except (ValueError, KeyError), message:
603             self.error_message.append(_('Error: ') + str(message))
604             return
606         # check permission
607         if not self.editItemPermission(props):
608             self.error_message.append(
609                 _('You do not have permission to edit %(classname)s'%
610                 self.__dict__))
611             return
613         # perform the edit
614         try:
615             # make changes to the node
616             props = self._changenode(props)
617             # handle linked nodes 
618             self._post_editnode(self.nodeid)
619         except (ValueError, KeyError), message:
620             self.error_message.append(_('Error: ') + str(message))
621             return
623         # commit now that all the tricky stuff is done
624         self.db.commit()
626         # and some nice feedback for the user
627         if props:
628             message = _('%(changes)s edited ok')%{'changes':
629                 ', '.join(props.keys())}
630         elif self.form.has_key('__note') and self.form['__note'].value:
631             message = _('note added')
632         elif (self.form.has_key('__file') and self.form['__file'].filename):
633             message = _('file added')
634         else:
635             message = _('nothing changed')
637         # redirect to the item's edit page
638         raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
639             self.nodeid,  urllib.quote(message))
641     def editItemPermission(self, props):
642         ''' Determine whether the user has permission to edit this item.
644             Base behaviour is to check the user can edit this class. If we're
645             editing the "user" class, users are allowed to edit their own
646             details. Unless it's the "roles" property, which requires the
647             special Permission "Web Roles".
648         '''
649         # if this is a user node and the user is editing their own node, then
650         # we're OK
651         has = self.db.security.hasPermission
652         if self.classname == 'user':
653             # reject if someone's trying to edit "roles" and doesn't have the
654             # right permission.
655             if props.has_key('roles') and not has('Web Roles', self.userid,
656                     'user'):
657                 return 0
658             # if the item being edited is the current user, we're ok
659             if self.nodeid == self.userid:
660                 return 1
661         if self.db.security.hasPermission('Edit', self.userid, self.classname):
662             return 1
663         return 0
665     def newItemAction(self):
666         ''' Add a new item to the database.
668             This follows the same form as the editItemAction, with the same
669             special form values.
670         '''
671         cl = self.db.classes[self.classname]
673         # parse the props from the form
674         try:
675             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
676         except (ValueError, KeyError), message:
677             self.error_message.append(_('Error: ') + str(message))
678             return
680         if not self.newItemPermission(props):
681             self.error_message.append(
682                 _('You do not have permission to create %s' %self.classname))
684         # create a little extra message for anticipated :link / :multilink
685         if self.form.has_key(':multilink'):
686             link = self.form[':multilink'].value
687         elif self.form.has_key(':link'):
688             link = self.form[':multilink'].value
689         else:
690             link = None
691             xtra = ''
692         if link:
693             designator, linkprop = link.split(':')
694             xtra = ' for <a href="%s">%s</a>'%(designator, designator)
696         try:
697             # do the create
698             nid = self._createnode(props)
700             # handle linked nodes 
701             self._post_editnode(nid)
703             # commit now that all the tricky stuff is done
704             self.db.commit()
706             # render the newly created item
707             self.nodeid = nid
709             # and some nice feedback for the user
710             message = _('%(classname)s created ok')%self.__dict__ + xtra
711         except (ValueError, KeyError), message:
712             self.error_message.append(_('Error: ') + str(message))
713             return
714         except:
715             # oops
716             self.db.rollback()
717             s = StringIO.StringIO()
718             traceback.print_exc(None, s)
719             self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
720             return
722         # redirect to the new item's page
723         raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
724             nid,  urllib.quote(message))
726     def newItemPermission(self, props):
727         ''' Determine whether the user has permission to create (edit) this
728             item.
730             Base behaviour is to check the user can edit this class. No
731             additional property checks are made. Additionally, new user items
732             may be created if the user has the "Web Registration" Permission.
733         '''
734         has = self.db.security.hasPermission
735         if self.classname == 'user' and has('Web Registration', self.userid,
736                 'user'):
737             return 1
738         if has('Edit', self.userid, self.classname):
739             return 1
740         return 0
742     def editCSVAction(self):
743         ''' Performs an edit of all of a class' items in one go.
745             The "rows" CGI var defines the CSV-formatted entries for the
746             class. New nodes are identified by the ID 'X' (or any other
747             non-existent ID) and removed lines are retired.
748         '''
749         # this is per-class only
750         if not self.editCSVPermission():
751             self.error_message.append(
752                 _('You do not have permission to edit %s' %self.classname))
754         # get the CSV module
755         try:
756             import csv
757         except ImportError:
758             self.error_message.append(_(
759                 'Sorry, you need the csv module to use this function.<br>\n'
760                 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
761             return
763         cl = self.db.classes[self.classname]
764         idlessprops = cl.getprops(protected=0).keys()
765         idlessprops.sort()
766         props = ['id'] + idlessprops
768         # do the edit
769         rows = self.form['rows'].value.splitlines()
770         p = csv.parser()
771         found = {}
772         line = 0
773         for row in rows[1:]:
774             line += 1
775             values = p.parse(row)
776             # not a complete row, keep going
777             if not values: continue
779             # skip property names header
780             if values == props:
781                 continue
783             # extract the nodeid
784             nodeid, values = values[0], values[1:]
785             found[nodeid] = 1
787             # confirm correct weight
788             if len(idlessprops) != len(values):
789                 self.error_message.append(
790                     _('Not enough values on line %(line)s')%{'line':line})
791                 return
793             # extract the new values
794             d = {}
795             for name, value in zip(idlessprops, values):
796                 value = value.strip()
797                 # only add the property if it has a value
798                 if value:
799                     # if it's a multilink, split it
800                     if isinstance(cl.properties[name], hyperdb.Multilink):
801                         value = value.split(':')
802                     d[name] = value
804             # perform the edit
805             if cl.hasnode(nodeid):
806                 # edit existing
807                 cl.set(nodeid, **d)
808             else:
809                 # new node
810                 found[cl.create(**d)] = 1
812         # retire the removed entries
813         for nodeid in cl.list():
814             if not found.has_key(nodeid):
815                 cl.retire(nodeid)
817         # all OK
818         self.db.commit()
820         self.ok_message.append(_('Items edited OK'))
822     def editCSVPermission(self):
823         ''' Determine whether the user has permission to edit this class.
825             Base behaviour is to check the user can edit this class.
826         ''' 
827         if not self.db.security.hasPermission('Edit', self.userid,
828                 self.classname):
829             return 0
830         return 1
832     def searchAction(self):
833         ''' Mangle some of the form variables.
835             Set the form ":filter" variable based on the values of the
836             filter variables - if they're set to anything other than
837             "dontcare" then add them to :filter.
839             Also handle the ":queryname" variable and save off the query to
840             the user's query list.
841         '''
842         # generic edit is per-class only
843         if not self.searchPermission():
844             self.error_message.append(
845                 _('You do not have permission to search %s' %self.classname))
847         # add a faked :filter form variable for each filtering prop
848         props = self.db.classes[self.classname].getprops()
849         for key in self.form.keys():
850             if not props.has_key(key): continue
851             if not self.form[key].value: continue
852             self.form.value.append(cgi.MiniFieldStorage(':filter', key))
854         # handle saving the query params
855         if self.form.has_key(':queryname'):
856             queryname = self.form[':queryname'].value.strip()
857             if queryname:
858                 # parse the environment and figure what the query _is_
859                 req = HTMLRequest(self)
860                 url = req.indexargs_href('', {})
862                 # handle editing an existing query
863                 try:
864                     qid = self.db.query.lookup(queryname)
865                     self.db.query.set(qid, klass=self.classname, url=url)
866                 except KeyError:
867                     # create a query
868                     qid = self.db.query.create(name=queryname,
869                         klass=self.classname, url=url)
871                     # and add it to the user's query multilink
872                     queries = self.db.user.get(self.userid, 'queries')
873                     queries.append(qid)
874                     self.db.user.set(self.userid, queries=queries)
876                 # commit the query change to the database
877                 self.db.commit()
880     def searchPermission(self):
881         ''' Determine whether the user has permission to search this class.
883             Base behaviour is to check the user can view this class.
884         ''' 
885         if not self.db.security.hasPermission('View', self.userid,
886                 self.classname):
887             return 0
888         return 1
890     def XXXremove_action(self,  dre=re.compile(r'([^\d]+)(\d+)')):
891         # XXX I believe this could be handled by a regular edit action that
892         # just sets the multilink...
893         # XXX handle this !
894         target = self.index_arg(':target')[0]
895         m = dre.match(target)
896         if m:
897             classname = m.group(1)
898             nodeid = m.group(2)
899             cl = self.db.getclass(classname)
900             cl.retire(nodeid)
901             # now take care of the reference
902             parentref =  self.index_arg(':multilink')[0]
903             parent, prop = parentref.split(':')
904             m = dre.match(parent)
905             if m:
906                 self.classname = m.group(1)
907                 self.nodeid = m.group(2)
908                 cl = self.db.getclass(self.classname)
909                 value = cl.get(self.nodeid, prop)
910                 value.remove(nodeid)
911                 cl.set(self.nodeid, **{prop:value})
912                 func = getattr(self, 'show%s'%self.classname)
913                 return func()
914             else:
915                 raise NotFound, parent
916         else:
917             raise NotFound, target
919     #
920     #  Utility methods for editing
921     #
922     def _changenode(self, props):
923         ''' change the node based on the contents of the form
924         '''
925         cl = self.db.classes[self.classname]
927         # create the message
928         message, files = self._handle_message()
929         if message:
930             props['messages'] = cl.get(self.nodeid, 'messages') + [message]
931         if files:
932             props['files'] = cl.get(self.nodeid, 'files') + files
934         # make the changes
935         return cl.set(self.nodeid, **props)
937     def _createnode(self, props):
938         ''' create a node based on the contents of the form
939         '''
940         cl = self.db.classes[self.classname]
942         # check for messages and files
943         message, files = self._handle_message()
944         if message:
945             props['messages'] = [message]
946         if files:
947             props['files'] = files
948         # create the node and return it's id
949         return cl.create(**props)
951     def _handle_message(self):
952         ''' generate an edit message
953         '''
954         # handle file attachments 
955         files = []
956         if self.form.has_key('__file'):
957             file = self.form['__file']
958             if file.filename:
959                 filename = file.filename.split('\\')[-1]
960                 mime_type = mimetypes.guess_type(filename)[0]
961                 if not mime_type:
962                     mime_type = "application/octet-stream"
963                 # create the new file entry
964                 files.append(self.db.file.create(type=mime_type,
965                     name=filename, content=file.file.read()))
967         # we don't want to do a message if none of the following is true...
968         cn = self.classname
969         cl = self.db.classes[self.classname]
970         props = cl.getprops()
971         note = None
972         # in a nutshell, don't do anything if there's no note or there's no
973         # NOSY
974         if self.form.has_key('__note'):
975             note = self.form['__note'].value.strip()
976         if not note:
977             return None, files
978         if not props.has_key('messages'):
979             return None, files
980         if not isinstance(props['messages'], hyperdb.Multilink):
981             return None, files
982         if not props['messages'].classname == 'msg':
983             return None, files
984         if not (self.form.has_key('nosy') or note):
985             return None, files
987         # handle the note
988         if '\n' in note:
989             summary = re.split(r'\n\r?', note)[0]
990         else:
991             summary = note
992         m = ['%s\n'%note]
994         # handle the messageid
995         # TODO: handle inreplyto
996         messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
997             self.classname, self.instance.MAIL_DOMAIN)
999         # now create the message, attaching the files
1000         content = '\n'.join(m)
1001         message_id = self.db.msg.create(author=self.userid,
1002             recipients=[], date=date.Date('.'), summary=summary,
1003             content=content, files=files, messageid=messageid)
1005         # update the messages property
1006         return message_id, files
1008     def _post_editnode(self, nid):
1009         '''Do the linking part of the node creation.
1011            If a form element has :link or :multilink appended to it, its
1012            value specifies a node designator and the property on that node
1013            to add _this_ node to as a link or multilink.
1015            This is typically used on, eg. the file upload page to indicated
1016            which issue to link the file to.
1018            TODO: I suspect that this and newfile will go away now that
1019            there's the ability to upload a file using the issue __file form
1020            element!
1021         '''
1022         cn = self.classname
1023         cl = self.db.classes[cn]
1024         # link if necessary
1025         keys = self.form.keys()
1026         for key in keys:
1027             if key == ':multilink':
1028                 value = self.form[key].value
1029                 if type(value) != type([]): value = [value]
1030                 for value in value:
1031                     designator, property = value.split(':')
1032                     link, nodeid = hyperdb.splitDesignator(designator)
1033                     link = self.db.classes[link]
1034                     # take a dupe of the list so we're not changing the cache
1035                     value = link.get(nodeid, property)[:]
1036                     value.append(nid)
1037                     link.set(nodeid, **{property: value})
1038             elif key == ':link':
1039                 value = self.form[key].value
1040                 if type(value) != type([]): value = [value]
1041                 for value in value:
1042                     designator, property = value.split(':')
1043                     link, nodeid = hyperdb.splitDesignator(designator)
1044                     link = self.db.classes[link]
1045                     link.set(nodeid, **{property: nid})
1048 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
1049     ''' Pull properties for the given class out of the form.
1051         If a ":required" parameter is supplied, then the names property values
1052         must be supplied or a ValueError will be raised.
1053     '''
1054     required = []
1055     if form.has_key(':required'):
1056         value = form[':required']
1057         print 'required', value
1058         if isinstance(value, type([])):
1059             required = [i.value.strip() for i in value]
1060         else:
1061             required = [i.strip() for i in value.value.split(',')]
1063     props = {}
1064     keys = form.keys()
1065     for key in keys:
1066         if not cl.properties.has_key(key):
1067             continue
1068         proptype = cl.properties[key]
1070         # Get the form value. This value may be a MiniFieldStorage or a list
1071         # of MiniFieldStorages.
1072         value = form[key]
1074         # make sure non-multilinks only get one value
1075         if not isinstance(proptype, hyperdb.Multilink):
1076             if isinstance(value, type([])):
1077                 raise ValueError, 'You have submitted more than one value'\
1078                     ' for the %s property'%key
1079             # we've got a MiniFieldStorage, so pull out the value and strip
1080             # surrounding whitespace
1081             value = value.value.strip()
1083         if isinstance(proptype, hyperdb.String):
1084             if not value:
1085                 continue
1086         elif isinstance(proptype, hyperdb.Password):
1087             if not value:
1088                 # ignore empty password values
1089                 continue
1090             value = password.Password(value)
1091         elif isinstance(proptype, hyperdb.Date):
1092             if value:
1093                 value = date.Date(form[key].value.strip())
1094             else:
1095                 value = None
1096         elif isinstance(proptype, hyperdb.Interval):
1097             if value:
1098                 value = date.Interval(form[key].value.strip())
1099             else:
1100                 value = None
1101         elif isinstance(proptype, hyperdb.Link):
1102             # see if it's the "no selection" choice
1103             if value == '-1':
1104                 value = None
1105             else:
1106                 # handle key values
1107                 link = cl.properties[key].classname
1108                 if not num_re.match(value):
1109                     try:
1110                         value = db.classes[link].lookup(value)
1111                     except KeyError:
1112                         raise ValueError, _('property "%(propname)s": '
1113                             '%(value)s not a %(classname)s')%{'propname':key, 
1114                             'value': value, 'classname': link}
1115         elif isinstance(proptype, hyperdb.Multilink):
1116             if isinstance(value, type([])):
1117                 # it's a list of MiniFieldStorages
1118                 value = [i.value.strip() for i in value]
1119             else:
1120                 # it's a MiniFieldStorage, but may be a comma-separated list
1121                 # of values
1122                 value = [i.strip() for i in value.value.split(',')]
1123             link = cl.properties[key].classname
1124             l = []
1125             for entry in map(str, value):
1126                 if entry == '': continue
1127                 if not num_re.match(entry):
1128                     try:
1129                         entry = db.classes[link].lookup(entry)
1130                     except KeyError:
1131                         raise ValueError, _('property "%(propname)s": '
1132                             '"%(value)s" not an entry of %(classname)s')%{
1133                             'propname':key, 'value': entry, 'classname': link}
1134                 l.append(entry)
1135             l.sort()
1136             value = l
1137         elif isinstance(proptype, hyperdb.Boolean):
1138             props[key] = value = value.lower() in ('yes', 'true', 'on', '1')
1139         elif isinstance(proptype, hyperdb.Number):
1140             props[key] = value = int(value)
1142         # get the old value
1143         if nodeid:
1144             try:
1145                 existing = cl.get(nodeid, key)
1146             except KeyError:
1147                 # this might be a new property for which there is no existing
1148                 # value
1149                 if not cl.properties.has_key(key): raise
1151             # if changed, set it
1152             if value != existing:
1153                 props[key] = value
1154         else:
1155             props[key] = value
1157     # see if all the required properties have been supplied
1158     l = []
1159     for property in required:
1160         if not props.has_key(property):
1161             l.append(property)
1162     if l:
1163         raise ValueError, 'Required properties %s not supplied'%(', '.join(l))
1165     return props