Code

new form handling complete
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.82 2003-02-13 07:38:34 richard Exp $
3 __doc__ = """
4 WWW request handler (also used in the stand-alone server).
5 """
7 import os, os.path, cgi, StringIO, urlparse, re, traceback, mimetypes, urllib
8 import binascii, Cookie, time, random
10 from roundup import roundupdb, date, hyperdb, password
11 from roundup.i18n import _
13 from roundup.cgi.templating import Templates, HTMLRequest, NoTemplate
14 from roundup.cgi import cgitb
16 from roundup.cgi.PageTemplates import PageTemplate
18 class Unauthorised(ValueError):
19     pass
21 class NotFound(ValueError):
22     pass
24 class Redirect(Exception):
25     pass
27 class SendFile(Exception):
28     ' Sent a file from the database '
30 class SendStaticFile(Exception):
31     ' Send a static file from the instance html directory '
33 def initialiseSecurity(security):
34     ''' Create some Permissions and Roles on the security object
36         This function is directly invoked by security.Security.__init__()
37         as a part of the Security object instantiation.
38     '''
39     security.addPermission(name="Web Registration",
40         description="User may register through the web")
41     p = security.addPermission(name="Web Access",
42         description="User may access the web interface")
43     security.addPermissionToRole('Admin', p)
45     # doing Role stuff through the web - make sure Admin can
46     p = security.addPermission(name="Web Roles",
47         description="User may manipulate user Roles through the web")
48     security.addPermissionToRole('Admin', p)
50 class Client:
51     ''' Instantiate to handle one CGI request.
53     See inner_main for request processing.
55     Client attributes at instantiation:
56         "path" is the PATH_INFO inside the instance (with no leading '/')
57         "base" is the base URL for the instance
58         "form" is the cgi form, an instance of FieldStorage from the standard
59                cgi module
60         "additional_headers" is a dictionary of additional HTTP headers that
61                should be sent to the client
62         "response_code" is the HTTP response code to send to the client
64     During the processing of a request, the following attributes are used:
65         "error_message" holds a list of error messages
66         "ok_message" holds a list of OK messages
67         "session" is the current user session id
68         "user" is the current user's name
69         "userid" is the current user's id
70         "template" is the current :template context
71         "classname" is the current class context name
72         "nodeid" is the current context item id
74     User Identification:
75      If the user has no login cookie, then they are anonymous and are logged
76      in as that user. This typically gives them all Permissions assigned to the
77      Anonymous Role.
79      Once a user logs in, they are assigned a session. The Client instance
80      keeps the nodeid of the session as the "session" attribute.
83     Special form variables:
84      Note that in various places throughout this code, special form
85      variables of the form :<name> are used. The colon (":") part may
86      actually be one of several characters from the set:
88        : @ + 
90     '''
92     #
93     # special form variables
94     #
95     FV_TEMPLATE = re.compile(r'[@+:]template')
96     FV_OK_MESSAGE = re.compile(r'[@+:]ok_message')
97     FV_ERROR_MESSAGE = re.compile(r'[@+:]error_message')
99     # specials for parsePropsFromForm
100     FV_REQUIRED = re.compile(r'[@+:]required')
101     FV_ADD = re.compile(r'([@+:])add\1')
102     FV_REMOVE = re.compile(r'([@+:])remove\1')
103     FV_CONFIRM = re.compile(r'.+[@+:]confirm')
104     FV_LINK = re.compile(r'([@+:])link\1(.+)')
106     # deprecated
107     FV_NOTE = re.compile(r'[@+:]note')
108     FV_FILE = re.compile(r'[@+:]file')
110     # Note: index page stuff doesn't appear here:
111     # columns, sort, sortdir, filter, group, groupdir, search_text,
112     # pagesize, startwith
114     def __init__(self, instance, request, env, form=None):
115         hyperdb.traceMark()
116         self.instance = instance
117         self.request = request
118         self.env = env
120         # save off the path
121         self.path = env['PATH_INFO']
123         # this is the base URL for this tracker
124         self.base = self.instance.config.TRACKER_WEB
126         # this is the "cookie path" for this tracker (ie. the path part of
127         # the "base" url)
128         self.cookie_path = urlparse.urlparse(self.base)[2]
129         self.cookie_name = 'roundup_session_' + re.sub('[^a-zA-Z]', '',
130             self.instance.config.TRACKER_NAME)
132         # see if we need to re-parse the environment for the form (eg Zope)
133         if form is None:
134             self.form = cgi.FieldStorage(environ=env)
135         else:
136             self.form = form
138         # turn debugging on/off
139         try:
140             self.debug = int(env.get("ROUNDUP_DEBUG", 0))
141         except ValueError:
142             # someone gave us a non-int debug level, turn it off
143             self.debug = 0
145         # flag to indicate that the HTTP headers have been sent
146         self.headers_done = 0
148         # additional headers to send with the request - must be registered
149         # before the first write
150         self.additional_headers = {}
151         self.response_code = 200
154     def main(self):
155         ''' Wrap the real main in a try/finally so we always close off the db.
156         '''
157         try:
158             self.inner_main()
159         finally:
160             if hasattr(self, 'db'):
161                 self.db.close()
163     def inner_main(self):
164         ''' Process a request.
166             The most common requests are handled like so:
167             1. figure out who we are, defaulting to the "anonymous" user
168                see determine_user
169             2. figure out what the request is for - the context
170                see determine_context
171             3. handle any requested action (item edit, search, ...)
172                see handle_action
173             4. render a template, resulting in HTML output
175             In some situations, exceptions occur:
176             - HTTP Redirect  (generally raised by an action)
177             - SendFile       (generally raised by determine_context)
178               serve up a FileClass "content" property
179             - SendStaticFile (generally raised by determine_context)
180               serve up a file from the tracker "html" directory
181             - Unauthorised   (generally raised by an action)
182               the action is cancelled, the request is rendered and an error
183               message is displayed indicating that permission was not
184               granted for the action to take place
185             - NotFound       (raised wherever it needs to be)
186               percolates up to the CGI interface that called the client
187         '''
188         self.ok_message = []
189         self.error_message = []
190         try:
191             # make sure we're identified (even anonymously)
192             self.determine_user()
193             # figure out the context and desired content template
194             self.determine_context()
195             # possibly handle a form submit action (may change self.classname
196             # and self.template, and may also append error/ok_messages)
197             self.handle_action()
198             # now render the page
200             # we don't want clients caching our dynamic pages
201             self.additional_headers['Cache-Control'] = 'no-cache'
202             self.additional_headers['Pragma'] = 'no-cache'
203             self.additional_headers['Expires'] = 'Thu, 1 Jan 1970 00:00:00 GMT'
205             # render the content
206             self.write(self.renderContext())
207         except Redirect, url:
208             # let's redirect - if the url isn't None, then we need to do
209             # the headers, otherwise the headers have been set before the
210             # exception was raised
211             if url:
212                 self.additional_headers['Location'] = url
213                 self.response_code = 302
214             self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
215         except SendFile, designator:
216             self.serve_file(designator)
217         except SendStaticFile, file:
218             self.serve_static_file(str(file))
219         except Unauthorised, message:
220             self.classname=None
221             self.template=''
222             self.error_message.append(message)
223             self.write(self.renderContext())
224         except NotFound:
225             # pass through
226             raise
227         except:
228             # everything else
229             self.write(cgitb.html())
231     def clean_sessions(self):
232         '''age sessions, remove when they haven't been used for a week.
233         Do it only once an hour'''
234         sessions = self.db.sessions
235         last_clean = sessions.get('last_clean', 'last_use') or 0
237         week = 60*60*24*7
238         hour = 60*60
239         now = time.time()
240         if now - last_clean > hour:
241             # remove age sessions
242             for sessid in sessions.list():
243                 interval = now - sessions.get(sessid, 'last_use')
244                 if interval > week:
245                     sessions.destroy(sessid)
246             sessions.set('last_clean', last_use=time.time())
248     def determine_user(self):
249         ''' Determine who the user is
250         '''
251         # determine the uid to use
252         self.opendb('admin')
253         # clean age sessions
254         self.clean_sessions()
255         # make sure we have the session Class
256         sessions = self.db.sessions
258         # look up the user session cookie
259         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
260         user = 'anonymous'
262         # bump the "revision" of the cookie since the format changed
263         if (cookie.has_key(self.cookie_name) and
264                 cookie[self.cookie_name].value != 'deleted'):
266             # get the session key from the cookie
267             self.session = cookie[self.cookie_name].value
268             # get the user from the session
269             try:
270                 # update the lifetime datestamp
271                 sessions.set(self.session, last_use=time.time())
272                 sessions.commit()
273                 user = sessions.get(self.session, 'user')
274             except KeyError:
275                 user = 'anonymous'
277         # sanity check on the user still being valid, getting the userid
278         # at the same time
279         try:
280             self.userid = self.db.user.lookup(user)
281         except (KeyError, TypeError):
282             user = 'anonymous'
284         # make sure the anonymous user is valid if we're using it
285         if user == 'anonymous':
286             self.make_user_anonymous()
287         else:
288             self.user = user
290         # reopen the database as the correct user
291         self.opendb(self.user)
293     def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
294         ''' Determine the context of this page from the URL:
296             The URL path after the instance identifier is examined. The path
297             is generally only one entry long.
299             - if there is no path, then we are in the "home" context.
300             * if the path is "_file", then the additional path entry
301               specifies the filename of a static file we're to serve up
302               from the instance "html" directory. Raises a SendStaticFile
303               exception.
304             - if there is something in the path (eg "issue"), it identifies
305               the tracker class we're to display.
306             - if the path is an item designator (eg "issue123"), then we're
307               to display a specific item.
308             * if the path starts with an item designator and is longer than
309               one entry, then we're assumed to be handling an item of a
310               FileClass, and the extra path information gives the filename
311               that the client is going to label the download with (ie
312               "file123/image.png" is nicer to download than "file123"). This
313               raises a SendFile exception.
315             Both of the "*" types of contexts stop before we bother to
316             determine the template we're going to use. That's because they
317             don't actually use templates.
319             The template used is specified by the :template CGI variable,
320             which defaults to:
322              only classname suplied:          "index"
323              full item designator supplied:   "item"
325             We set:
326              self.classname  - the class to display, can be None
327              self.template   - the template to render the current context with
328              self.nodeid     - the nodeid of the class we're displaying
329         '''
330         # default the optional variables
331         self.classname = None
332         self.nodeid = None
334         # see if a template or messages are specified
335         template_override = ok_message = error_message = None
336         for key in self.form.keys():
337             if self.FV_TEMPLATE.match(key):
338                 template_override = self.form[key].value
339             elif self.FV_OK_MESSAGE.match(key):
340                 ok_message = self.form[key].value
341             elif self.FV_ERROR_MESSAGE.match(key):
342                 error_message = self.form[key].value
344         # determine the classname and possibly nodeid
345         path = self.path.split('/')
346         if not path or path[0] in ('', 'home', 'index'):
347             if template_override is not None:
348                 self.template = template_override
349             else:
350                 self.template = ''
351             return
352         elif path[0] == '_file':
353             raise SendStaticFile, path[1]
354         else:
355             self.classname = path[0]
356             if len(path) > 1:
357                 # send the file identified by the designator in path[0]
358                 raise SendFile, path[0]
360         # see if we got a designator
361         m = dre.match(self.classname)
362         if m:
363             self.classname = m.group(1)
364             self.nodeid = m.group(2)
365             if not self.db.getclass(self.classname).hasnode(self.nodeid):
366                 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
367             # with a designator, we default to item view
368             self.template = 'item'
369         else:
370             # with only a class, we default to index view
371             self.template = 'index'
373         # make sure the classname is valid
374         try:
375             self.db.getclass(self.classname)
376         except KeyError:
377             raise NotFound, self.classname
379         # see if we have a template override
380         if template_override is not None:
381             self.template = template_override
383         # see if we were passed in a message
384         if ok_message:
385             self.ok_message.append(ok_message)
386         if error_message:
387             self.error_message.append(error_message)
389     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
390         ''' Serve the file from the content property of the designated item.
391         '''
392         m = dre.match(str(designator))
393         if not m:
394             raise NotFound, str(designator)
395         classname, nodeid = m.group(1), m.group(2)
396         if classname != 'file':
397             raise NotFound, designator
399         # we just want to serve up the file named
400         file = self.db.file
401         self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
402         self.write(file.get(nodeid, 'content'))
404     def serve_static_file(self, file):
405         # we just want to serve up the file named
406         mt = mimetypes.guess_type(str(file))[0]
407         self.additional_headers['Content-Type'] = mt
408         self.write(open(os.path.join(self.instance.config.TEMPLATES,
409             file)).read())
411     def renderContext(self):
412         ''' Return a PageTemplate for the named page
413         '''
414         name = self.classname
415         extension = self.template
416         pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
418         # catch errors so we can handle PT rendering errors more nicely
419         args = {
420             'ok_message': self.ok_message,
421             'error_message': self.error_message
422         }
423         try:
424             # let the template render figure stuff out
425             return pt.render(self, None, None, **args)
426         except NoTemplate, message:
427             return '<strong>%s</strong>'%message
428         except:
429             # everything else
430             return cgitb.pt_html()
432     # these are the actions that are available
433     actions = (
434         ('edit',     'editItemAction'),
435         ('editCSV',  'editCSVAction'),
436         ('new',      'newItemAction'),
437         ('register', 'registerAction'),
438         ('login',    'loginAction'),
439         ('logout',   'logout_action'),
440         ('search',   'searchAction'),
441         ('retire',   'retireAction'),
442         ('show',     'showAction'),
443     )
444     def handle_action(self):
445         ''' Determine whether there should be an _action called.
447             The action is defined by the form variable :action which
448             identifies the method on this object to call. The four basic
449             actions are defined in the "actions" sequence on this class:
450              "edit"      -> self.editItemAction
451              "new"       -> self.newItemAction
452              "register"  -> self.registerAction
453              "login"     -> self.loginAction
454              "logout"    -> self.logout_action
455              "search"    -> self.searchAction
456              "retire"    -> self.retireAction
457         '''
458         if not self.form.has_key(':action'):
459             return None
460         try:
461             # get the action, validate it
462             action = self.form[':action'].value
463             for name, method in self.actions:
464                 if name == action:
465                     break
466             else:
467                 raise ValueError, 'No such action "%s"'%action
469             # call the mapped action
470             getattr(self, method)()
471         except Redirect:
472             raise
473         except Unauthorised:
474             raise
475         except:
476             self.db.rollback()
477             s = StringIO.StringIO()
478             traceback.print_exc(None, s)
479             self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
481     def write(self, content):
482         if not self.headers_done:
483             self.header()
484         self.request.wfile.write(content)
486     def header(self, headers=None, response=None):
487         '''Put up the appropriate header.
488         '''
489         if headers is None:
490             headers = {'Content-Type':'text/html'}
491         if response is None:
492             response = self.response_code
494         # update with additional info
495         headers.update(self.additional_headers)
497         if not headers.has_key('Content-Type'):
498             headers['Content-Type'] = 'text/html'
499         self.request.send_response(response)
500         for entry in headers.items():
501             self.request.send_header(*entry)
502         self.request.end_headers()
503         self.headers_done = 1
504         if self.debug:
505             self.headers_sent = headers
507     def set_cookie(self, user):
508         ''' Set up a session cookie for the user and store away the user's
509             login info against the session.
510         '''
511         # TODO generate a much, much stronger session key ;)
512         self.session = binascii.b2a_base64(repr(random.random())).strip()
514         # clean up the base64
515         if self.session[-1] == '=':
516             if self.session[-2] == '=':
517                 self.session = self.session[:-2]
518             else:
519                 self.session = self.session[:-1]
521         # insert the session in the sessiondb
522         self.db.sessions.set(self.session, user=user, last_use=time.time())
524         # and commit immediately
525         self.db.sessions.commit()
527         # expire us in a long, long time
528         expire = Cookie._getdate(86400*365)
530         # generate the cookie path - make sure it has a trailing '/'
531         self.additional_headers['Set-Cookie'] = \
532           '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
533             expire, self.cookie_path)
535     def make_user_anonymous(self):
536         ''' Make us anonymous
538             This method used to handle non-existence of the 'anonymous'
539             user, but that user is mandatory now.
540         '''
541         self.userid = self.db.user.lookup('anonymous')
542         self.user = 'anonymous'
544     def opendb(self, user):
545         ''' Open the database.
546         '''
547         # open the db if the user has changed
548         if not hasattr(self, 'db') or user != self.db.journaltag:
549             if hasattr(self, 'db'):
550                 self.db.close()
551             self.db = self.instance.open(user)
553     #
554     # Actions
555     #
556     def loginAction(self):
557         ''' Attempt to log a user in.
559             Sets up a session for the user which contains the login
560             credentials.
561         '''
562         # we need the username at a minimum
563         if not self.form.has_key('__login_name'):
564             self.error_message.append(_('Username required'))
565             return
567         # get the login info
568         self.user = self.form['__login_name'].value
569         if self.form.has_key('__login_password'):
570             password = self.form['__login_password'].value
571         else:
572             password = ''
574         # make sure the user exists
575         try:
576             self.userid = self.db.user.lookup(self.user)
577         except KeyError:
578             name = self.user
579             self.error_message.append(_('No such user "%(name)s"')%locals())
580             self.make_user_anonymous()
581             return
583         # verify the password
584         if not self.verifyPassword(self.userid, password):
585             self.make_user_anonymous()
586             self.error_message.append(_('Incorrect password'))
587             return
589         # make sure we're allowed to be here
590         if not self.loginPermission():
591             self.make_user_anonymous()
592             self.error_message.append(_("You do not have permission to login"))
593             return
595         # now we're OK, re-open the database for real, using the user
596         self.opendb(self.user)
598         # set the session cookie
599         self.set_cookie(self.user)
601     def verifyPassword(self, userid, password):
602         ''' Verify the password that the user has supplied
603         '''
604         stored = self.db.user.get(self.userid, 'password')
605         if password == stored:
606             return 1
607         if not password and not stored:
608             return 1
609         return 0
611     def loginPermission(self):
612         ''' Determine whether the user has permission to log in.
614             Base behaviour is to check the user has "Web Access".
615         ''' 
616         if not self.db.security.hasPermission('Web Access', self.userid):
617             return 0
618         return 1
620     def logout_action(self):
621         ''' Make us really anonymous - nuke the cookie too
622         '''
623         # log us out
624         self.make_user_anonymous()
626         # construct the logout cookie
627         now = Cookie._getdate()
628         self.additional_headers['Set-Cookie'] = \
629            '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.cookie_name,
630             now, self.cookie_path)
632         # Let the user know what's going on
633         self.ok_message.append(_('You are logged out'))
635     def registerAction(self):
636         '''Attempt to create a new user based on the contents of the form
637         and then set the cookie.
639         return 1 on successful login
640         '''
641         # create the new user
642         cl = self.db.user
644         # parse the props from the form
645         try:
646             props = self.parsePropsFromForm()
647         except (ValueError, KeyError), message:
648             self.error_message.append(_('Error: ') + str(message))
649             return
651         # make sure we're allowed to register
652         if not self.registerPermission(props):
653             raise Unauthorised, _("You do not have permission to register")
655         # re-open the database as "admin"
656         if self.user != 'admin':
657             self.opendb('admin')
658             
659         # create the new user
660         cl = self.db.user
661         try:
662             props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
663             self.userid = cl.create(**props['user'])
664             self.db.commit()
665         except (ValueError, KeyError), message:
666             self.error_message.append(message)
667             return
669         # log the new user in
670         self.user = cl.get(self.userid, 'username')
671         # re-open the database for real, using the user
672         self.opendb(self.user)
674         # if we have a session, update it
675         if hasattr(self, 'session'):
676             self.db.sessions.set(self.session, user=self.user,
677                 last_use=time.time())
678         else:
679             # new session cookie
680             self.set_cookie(self.user)
682         # nice message
683         message = _('You are now registered, welcome!')
685         # redirect to the item's edit page
686         raise Redirect, '%s%s%s?+ok_message=%s'%(
687             self.base, self.classname, self.userid,  urllib.quote(message))
689     def registerPermission(self, props):
690         ''' Determine whether the user has permission to register
692             Base behaviour is to check the user has "Web Registration".
693         '''
694         # registration isn't allowed to supply roles
695         if props.has_key('roles'):
696             return 0
697         if self.db.security.hasPermission('Web Registration', self.userid):
698             return 1
699         return 0
701     def editItemAction(self):
702         ''' Perform an edit of an item in the database.
704            See parsePropsFromForm and _editnodes for special variables
705         '''
706         # parse the props from the form
707         try:
708             props, links = self.parsePropsFromForm()
709         except (ValueError, KeyError), message:
710             self.error_message.append(_('Error: ') + str(message))
711             return
713         # handle the props
714         try:
715             message = self._editnodes(props, links)
716         except (ValueError, KeyError, IndexError), message:
717             self.error_message.append(_('Error: ') + str(message))
718             return
720         # commit now that all the tricky stuff is done
721         self.db.commit()
723         # redirect to the item's edit page
724         raise Redirect, '%s%s%s?+ok_message=%s'%(self.base, self.classname,
725             self.nodeid,  urllib.quote(message))
727     def editItemPermission(self, props):
728         ''' Determine whether the user has permission to edit this item.
730             Base behaviour is to check the user can edit this class. If we're
731             editing the "user" class, users are allowed to edit their own
732             details. Unless it's the "roles" property, which requires the
733             special Permission "Web Roles".
734         '''
735         # if this is a user node and the user is editing their own node, then
736         # we're OK
737         has = self.db.security.hasPermission
738         if self.classname == 'user':
739             # reject if someone's trying to edit "roles" and doesn't have the
740             # right permission.
741             if props.has_key('roles') and not has('Web Roles', self.userid,
742                     'user'):
743                 return 0
744             # if the item being edited is the current user, we're ok
745             if self.nodeid == self.userid:
746                 return 1
747         if self.db.security.hasPermission('Edit', self.userid, self.classname):
748             return 1
749         return 0
751     def newItemAction(self):
752         ''' Add a new item to the database.
754             This follows the same form as the editItemAction, with the same
755             special form values.
756         '''
757         # parse the props from the form
758 #        try:
759         if 1:
760             props, links = self.parsePropsFromForm()
761 #        except (ValueError, KeyError), message:
762 #            self.error_message.append(_('Error: ') + str(message))
763 #            return
765         # handle the props - edit or create
766 #        try:
767         if 1:
768             # create the context here
769             cn = self.classname
770             nid = self._createnode(cn, props[(cn, None)])
771             del props[(cn, None)]
773             extra = self._editnodes(props, links, {(cn, None): nid})
774             if extra:
775                 extra = '<br>' + extra
777             # now do the rest
778             messages = '%s %s created'%(cn, nid) + extra
779 #        except (ValueError, KeyError, IndexError), message:
780 #            # these errors might just be indicative of user dumbness
781 #            self.error_message.append(_('Error: ') + str(message))
782 #            return
784         # commit now that all the tricky stuff is done
785         self.db.commit()
787         # redirect to the new item's page
788         raise Redirect, '%s%s%s?:ok_message=%s'%(self.base, self.classname,
789             nid, urllib.quote(messages))
791     def newItemPermission(self, props):
792         ''' Determine whether the user has permission to create (edit) this
793             item.
795             Base behaviour is to check the user can edit this class. No
796             additional property checks are made. Additionally, new user items
797             may be created if the user has the "Web Registration" Permission.
798         '''
799         has = self.db.security.hasPermission
800         if self.classname == 'user' and has('Web Registration', self.userid,
801                 'user'):
802             return 1
803         if has('Edit', self.userid, self.classname):
804             return 1
805         return 0
807     def editCSVAction(self):
808         ''' Performs an edit of all of a class' items in one go.
810             The "rows" CGI var defines the CSV-formatted entries for the
811             class. New nodes are identified by the ID 'X' (or any other
812             non-existent ID) and removed lines are retired.
813         '''
814         # this is per-class only
815         if not self.editCSVPermission():
816             self.error_message.append(
817                 _('You do not have permission to edit %s' %self.classname))
819         # get the CSV module
820         try:
821             import csv
822         except ImportError:
823             self.error_message.append(_(
824                 'Sorry, you need the csv module to use this function.<br>\n'
825                 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
826             return
828         cl = self.db.classes[self.classname]
829         idlessprops = cl.getprops(protected=0).keys()
830         idlessprops.sort()
831         props = ['id'] + idlessprops
833         # do the edit
834         rows = self.form['rows'].value.splitlines()
835         p = csv.parser()
836         found = {}
837         line = 0
838         for row in rows[1:]:
839             line += 1
840             values = p.parse(row)
841             # not a complete row, keep going
842             if not values: continue
844             # skip property names header
845             if values == props:
846                 continue
848             # extract the nodeid
849             nodeid, values = values[0], values[1:]
850             found[nodeid] = 1
852             # confirm correct weight
853             if len(idlessprops) != len(values):
854                 self.error_message.append(
855                     _('Not enough values on line %(line)s')%{'line':line})
856                 return
858             # extract the new values
859             d = {}
860             for name, value in zip(idlessprops, values):
861                 value = value.strip()
862                 # only add the property if it has a value
863                 if value:
864                     # if it's a multilink, split it
865                     if isinstance(cl.properties[name], hyperdb.Multilink):
866                         value = value.split(':')
867                     d[name] = value
869             # perform the edit
870             if cl.hasnode(nodeid):
871                 # edit existing
872                 cl.set(nodeid, **d)
873             else:
874                 # new node
875                 found[cl.create(**d)] = 1
877         # retire the removed entries
878         for nodeid in cl.list():
879             if not found.has_key(nodeid):
880                 cl.retire(nodeid)
882         # all OK
883         self.db.commit()
885         self.ok_message.append(_('Items edited OK'))
887     def editCSVPermission(self):
888         ''' Determine whether the user has permission to edit this class.
890             Base behaviour is to check the user can edit this class.
891         ''' 
892         if not self.db.security.hasPermission('Edit', self.userid,
893                 self.classname):
894             return 0
895         return 1
897     def searchAction(self):
898         ''' Mangle some of the form variables.
900             Set the form ":filter" variable based on the values of the
901             filter variables - if they're set to anything other than
902             "dontcare" then add them to :filter.
904             Also handle the ":queryname" variable and save off the query to
905             the user's query list.
906         '''
907         # generic edit is per-class only
908         if not self.searchPermission():
909             self.error_message.append(
910                 _('You do not have permission to search %s' %self.classname))
912         # add a faked :filter form variable for each filtering prop
913         props = self.db.classes[self.classname].getprops()
914         for key in self.form.keys():
915             if not props.has_key(key): continue
916             if isinstance(self.form[key], type([])):
917                 # search for at least one entry which is not empty
918                 for minifield in self.form[key]:
919                     if minifield.value:
920                         break
921                 else:
922                     continue
923             else:
924                 if not self.form[key].value: continue
925             self.form.value.append(cgi.MiniFieldStorage(':filter', key))
927         # handle saving the query params
928         if self.form.has_key(':queryname'):
929             queryname = self.form[':queryname'].value.strip()
930             if queryname:
931                 # parse the environment and figure what the query _is_
932                 req = HTMLRequest(self)
933                 url = req.indexargs_href('', {})
935                 # handle editing an existing query
936                 try:
937                     qid = self.db.query.lookup(queryname)
938                     self.db.query.set(qid, klass=self.classname, url=url)
939                 except KeyError:
940                     # create a query
941                     qid = self.db.query.create(name=queryname,
942                         klass=self.classname, url=url)
944                     # and add it to the user's query multilink
945                     queries = self.db.user.get(self.userid, 'queries')
946                     queries.append(qid)
947                     self.db.user.set(self.userid, queries=queries)
949                 # commit the query change to the database
950                 self.db.commit()
952     def searchPermission(self):
953         ''' Determine whether the user has permission to search this class.
955             Base behaviour is to check the user can view this class.
956         ''' 
957         if not self.db.security.hasPermission('View', self.userid,
958                 self.classname):
959             return 0
960         return 1
963     def retireAction(self):
964         ''' Retire the context item.
965         '''
966         # if we want to view the index template now, then unset the nodeid
967         # context info (a special-case for retire actions on the index page)
968         nodeid = self.nodeid
969         if self.template == 'index':
970             self.nodeid = None
972         # generic edit is per-class only
973         if not self.retirePermission():
974             self.error_message.append(
975                 _('You do not have permission to retire %s' %self.classname))
976             return
978         # make sure we don't try to retire admin or anonymous
979         if self.classname == 'user' and \
980                 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
981             self.error_message.append(
982                 _('You may not retire the admin or anonymous user'))
983             return
985         # do the retire
986         self.db.getclass(self.classname).retire(nodeid)
987         self.db.commit()
989         self.ok_message.append(
990             _('%(classname)s %(itemid)s has been retired')%{
991                 'classname': self.classname.capitalize(), 'itemid': nodeid})
993     def retirePermission(self):
994         ''' Determine whether the user has permission to retire this class.
996             Base behaviour is to check the user can edit this class.
997         ''' 
998         if not self.db.security.hasPermission('Edit', self.userid,
999                 self.classname):
1000             return 0
1001         return 1
1004     def showAction(self):
1005         ''' Show a node
1006         '''
1007         t = self.form[':type'].value
1008         n = self.form[':number'].value
1009         url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1010         raise Redirect, url
1013     #
1014     #  Utility methods for editing
1015     #
1016     def _editnodes(self, all_props, all_links, newids=None):
1017         ''' Use the props in all_props to perform edit and creation, then
1018             use the link specs in all_links to do linking.
1019         '''
1020         m = []
1021         if newids is None:
1022             newids = {}
1023         for (cn, nodeid), props in all_props.items():
1024             if int(nodeid) > 0:
1025                 # make changes to the node
1026                 props = self._changenode(cn, nodeid, props)
1028                 # and some nice feedback for the user
1029                 if props:
1030                     info = ', '.join(props.keys())
1031                     m.append('%s %s %s edited ok'%(cn, nodeid, info))
1032                 else:
1033                     m.append('%s %s - nothing changed'%(cn, nodeid))
1034             elif props:
1035                 # make a new node
1036                 newid = self._createnode(cn, props)
1037                 newids[(cn, nodeid)] = newid
1038                 nodeid = newid
1040                 # and some nice feedback for the user
1041                 m.append('%s %s created'%(cn, newid))
1043         # handle linked nodes
1044         keys = self.form.keys()
1045         for cn, nodeid, propname, value in all_links:
1046             cl = self.db.classes[cn]
1047             property = cl.getprops()[propname]
1048             if nodeid is None or nodeid.startswith('-'):
1049                 if not newids.has_key((cn, nodeid)):
1050                     continue
1051                 nodeid = newids[(cn, nodeid)]
1053             # map the desired classnames to their actual created ids
1054             for link in value:
1055                 if not newids.has_key(link):
1056                     continue
1057                 linkid = newids[link]
1058                 if isinstance(property, hyperdb.Multilink):
1059                     # take a dupe of the list so we're not changing the cache
1060                     existing = cl.get(nodeid, propname)[:]
1061                     existing.append(linkid)
1062                     cl.set(nodeid, **{propname: existing})
1063                 elif isinstance(property, hyperdb.Link):
1064                     # make the Link set
1065                     cl.set(nodeid, **{propname: linkid})
1066                 else:
1067                     raise ValueError, '%s %s is not a link or multilink '\
1068                         'property'%(cn, propname)
1069                 m.append('%s %s linked to <a href="%s%s">%s %s</a>'%(
1070                     link[0], linkid, cn, nodeid, cn, nodeid))
1072         return '<br>'.join(m)
1074     def _changenode(self, cn, nodeid, props):
1075         ''' change the node based on the contents of the form
1076         '''
1077         # check for permission
1078         if not self.editItemPermission(props):
1079             raise PermissionError, 'You do not have permission to edit %s'%cn
1081         # make the changes
1082         cl = self.db.classes[cn]
1083         return cl.set(nodeid, **props)
1085     def _createnode(self, cn, props):
1086         ''' create a node based on the contents of the form
1087         '''
1088         # check for permission
1089         if not self.newItemPermission(props):
1090             raise PermissionError, 'You do not have permission to create %s'%cn
1092         # create the node and return its id
1093         cl = self.db.classes[cn]
1094         return cl.create(**props)
1096     def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1097         ''' Pull properties for the given class out of the form.
1099             In the following, <bracketed> values are variable, ":" may be
1100             any of : @ + and other text "required" is fixed.
1102             Properties are specified as form variables
1103              <designator>:<propname>
1105             where the propery belongs to the context class or item if the
1106             designator is not specified. The designator may specify a
1107             negative item id value (ie. "issue-1") and a new item of the
1108             specified class will be created for each negative id found.
1110             If a "<designator>:required" parameter is supplied,
1111             then the named property values must be supplied or a
1112             ValueError will be raised.
1114             Other special form values:
1115              [classname|designator]:remove:<propname>=id(s)
1116               The ids will be removed from the multilink property.
1117              [classname|designator]:add:<propname>=id(s)
1118               The ids will be added to the multilink property.
1120              [classname|designator]:link:<propname>=<classname>
1121               Used to add a link to new items created during edit.
1122               These are collected up and returned in all_links. This will
1123               result in an additional linking operation (either Link set or
1124               Multilink append) after the edit/create is done using
1125               all_props in _editnodes. The <propname> on
1126               [classname|designator] will be set/appended the id of the
1127               newly created item of class <classname>.
1129             Note: the colon may be one of:  : @ +
1131             Any of the form variables may be prefixed with a classname or
1132             designator.
1134             The return from this method is a dict of 
1135                 (classname, id): properties
1136             ... this dict _always_ has an entry for the current context,
1137             even if it's empty (ie. a submission for an existing issue that
1138             doesn't result in any changes would return {('issue','123'): {}})
1139             The id may be None, which indicates that an item should be
1140             created.
1142             If a String property's form value is a file upload, then we
1143             try to set additional properties "filename" and "type" (if
1144             they are valid for the class).
1145         '''
1146         # some very useful variables
1147         db = self.db
1148         form = self.form
1150         if not hasattr(self, 'FV_ITEMSPEC'):
1151             # generate the regexp for detecting
1152             # <classname|designator>[@:+]property
1153             classes = '|'.join(db.classes.keys())
1154             self.FV_ITEMSPEC = re.compile(r'(%s)([-\d]+)[@+:](.+)$'%classes)
1155             self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1157         # these indicate the default class / item
1158         default_cn = self.classname
1159         default_cl = self.db.classes[default_cn]
1160         default_nodeid = self.nodeid
1162         # we'll store info about the individual class/item edit in these
1163         all_required = {}       # one entry per class/item
1164         all_props = {}          # one entry per class/item
1165         all_propdef = {}        # note - only one entry per class
1166         all_links = []          # as many as are required
1168         # we should always return something, even empty, for the context
1169         all_props[(default_cn, default_nodeid)] = {}
1171         keys = form.keys()
1172         timezone = db.getUserTimezone()
1174         for key in keys:
1175             # see if this value modifies a different item to the default
1176             m = self.FV_ITEMSPEC.match(key)
1177             if m:
1178                 # we got a designator
1179                 cn = m.group(1)
1180                 cl = self.db.classes[cn]
1181                 nodeid = m.group(2)
1182                 propname = m.group(3)
1183             elif key == ':note':
1184                 # backwards compatibility: the special note field
1185                 cn = 'msg'
1186                 cl = self.db.classes[cn]
1187                 nodeid = '-1'
1188                 propname = 'content'
1189                 all_links.append((default_cn, default_nodeid, 'messages',
1190                     [('msg', '-1')]))
1191             elif key == ':file':
1192                 # backwards compatibility: the special file field
1193                 cn = 'file'
1194                 cl = self.db.classes[cn]
1195                 nodeid = '-1'
1196                 propname = 'content'
1197                 all_links.append((default_cn, default_nodeid, 'files',
1198                     [('file', '-1')]))
1199                 if self.form.has_key(':note'):
1200                     all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1201             else:
1202                 # default
1203                 cn = default_cn
1204                 cl = default_cl
1205                 nodeid = default_nodeid
1206                 propname = key
1208             # the thing this value relates to is...
1209             this = (cn, nodeid)
1211             # is this a link command?
1212             if self.FV_LINK.match(propname):
1213                 value = []
1214                 for entry in extractFormList(form[key]):
1215                     m = self.FV_DESIGNATOR.match(entry)
1216                     if not m:
1217                         raise ValueError, \
1218                             'link "%s" value "%s" not a designator'%(key, entry)
1219                     value.append((m.groups(1), m.groups(2)))
1220                 all_links.append((cn, nodeid, propname[6:], value))
1222             # get more info about the class, and the current set of
1223             # form props for it
1224             if not all_propdef.has_key(cn):
1225                 all_propdef[cn] = cl.getprops()
1226             propdef = all_propdef[cn]
1227             if not all_props.has_key(this):
1228                 all_props[this] = {}
1229             props = all_props[this]
1231             # detect the special ":required" variable
1232             if self.FV_REQUIRED.match(key):
1233                 all_required[this] = extractFormList(form[key])
1234                 continue
1236             # get the required values list
1237             if not all_required.has_key(this):
1238                 all_required[this] = []
1239             required = all_required[this]
1241             # see if we're performing a special multilink action
1242             mlaction = 'set'
1243             if self.FV_REMOVE.match(propname):
1244                 propname = propname[8:]
1245                 mlaction = 'remove'
1246             elif self.FV_ADD.match(propname):
1247                 propname = propname[5:]
1248                 mlaction = 'add'
1250             # does the property exist?
1251             if not propdef.has_key(propname):
1252                 if mlaction != 'set':
1253                     raise ValueError, 'You have submitted a %s action for'\
1254                         ' the property "%s" which doesn\'t exist'%(mlaction,
1255                         propname)
1256                 continue
1257             proptype = propdef[propname]
1259             # Get the form value. This value may be a MiniFieldStorage or a list
1260             # of MiniFieldStorages.
1261             value = form[key]
1263             # handle unpacking of the MiniFieldStorage / list form value
1264             if isinstance(proptype, hyperdb.Multilink):
1265                 value = extractFormList(value)
1266             else:
1267                 # multiple values are not OK
1268                 if isinstance(value, type([])):
1269                     raise ValueError, 'You have submitted more than one value'\
1270                         ' for the %s property'%propname
1271                 # value might be a file upload...
1272                 if not hasattr(value, 'filename') or value.filename is None:
1273                     # nope, pull out the value and strip it
1274                     value = value.value.strip()
1276             # handle by type now
1277             if isinstance(proptype, hyperdb.Password):
1278                 if not value:
1279                     # ignore empty password values
1280                     continue
1281                 for key in keys:
1282                     if self.FV_CONFIRM.match(key):
1283                         confirm = form[key]
1284                         break
1285                 else:
1286                     raise ValueError, 'Password and confirmation text do '\
1287                         'not match'
1288                 if isinstance(confirm, type([])):
1289                     raise ValueError, 'You have submitted more than one value'\
1290                         ' for the %s property'%propname
1291                 if value != confirm.value:
1292                     raise ValueError, 'Password and confirmation text do '\
1293                         'not match'
1294                 value = password.Password(value)
1296             elif isinstance(proptype, hyperdb.Link):
1297                 # see if it's the "no selection" choice
1298                 if value == '-1' or not value:
1299                     # if we're creating, just don't include this property
1300                     if not nodeid or nodeid.startswith('-'):
1301                         continue
1302                     value = None
1303                 else:
1304                     # handle key values
1305                     link = proptype.classname
1306                     if not num_re.match(value):
1307                         try:
1308                             value = db.classes[link].lookup(value)
1309                         except KeyError:
1310                             raise ValueError, _('property "%(propname)s": '
1311                                 '%(value)s not a %(classname)s')%{
1312                                 'propname': propname, 'value': value,
1313                                 'classname': link}
1314                         except TypeError, message:
1315                             raise ValueError, _('you may only enter ID values '
1316                                 'for property "%(propname)s": %(message)s')%{
1317                                 'propname': propname, 'message': message}
1318             elif isinstance(proptype, hyperdb.Multilink):
1319                 # perform link class key value lookup if necessary
1320                 link = proptype.classname
1321                 link_cl = db.classes[link]
1322                 l = []
1323                 for entry in value:
1324                     if not entry: continue
1325                     if not num_re.match(entry):
1326                         try:
1327                             entry = link_cl.lookup(entry)
1328                         except KeyError:
1329                             raise ValueError, _('property "%(propname)s": '
1330                                 '"%(value)s" not an entry of %(classname)s')%{
1331                                 'propname': propname, 'value': entry,
1332                                 'classname': link}
1333                         except TypeError, message:
1334                             raise ValueError, _('you may only enter ID values '
1335                                 'for property "%(propname)s": %(message)s')%{
1336                                 'propname': propname, 'message': message}
1337                     l.append(entry)
1338                 l.sort()
1340                 # now use that list of ids to modify the multilink
1341                 if mlaction == 'set':
1342                     value = l
1343                 else:
1344                     # we're modifying the list - get the current list of ids
1345                     if props.has_key(propname):
1346                         existing = props[propname]
1347                     elif nodeid and not nodeid.startswith('-'):
1348                         existing = cl.get(nodeid, propname, [])
1349                     else:
1350                         existing = []
1352                     # now either remove or add
1353                     if mlaction == 'remove':
1354                         # remove - handle situation where the id isn't in
1355                         # the list
1356                         for entry in l:
1357                             try:
1358                                 existing.remove(entry)
1359                             except ValueError:
1360                                 raise ValueError, _('property "%(propname)s": '
1361                                     '"%(value)s" not currently in list')%{
1362                                     'propname': propname, 'value': entry}
1363                     else:
1364                         # add - easy, just don't dupe
1365                         for entry in l:
1366                             if entry not in existing:
1367                                 existing.append(entry)
1368                     value = existing
1369                     value.sort()
1371             elif value == '':
1372                 # if we're creating, just don't include this property
1373                 if not nodeid or nodeid.startswith('-'):
1374                     continue
1375                 # other types should be None'd if there's no value
1376                 value = None
1377             else:
1378                 if isinstance(proptype, hyperdb.String):
1379                     if (hasattr(value, 'filename') and
1380                             value.filename is not None):
1381                         # this String is actually a _file_
1382                         # try to determine the file content-type
1383                         filename = value.filename.split('\\')[-1]
1384                         if propdef.has_key('name'):
1385                             props['name'] = filename
1386                         # use this info as the type/filename properties
1387                         if propdef.has_key('type'):
1388                             props['type'] = mimetypes.guess_type(filename)[0]
1389                             if not props['type']:
1390                                 props['type'] = "application/octet-stream"
1391                         # finally, read the content
1392                         value = value.value
1393                     else:
1394                         # normal String fix the CRLF/CR -> LF stuff
1395                         value = fixNewlines(value)
1397                 elif isinstance(proptype, hyperdb.Date):
1398                     value = date.Date(value, offset=timezone)
1399                 elif isinstance(proptype, hyperdb.Interval):
1400                     value = date.Interval(value)
1401                 elif isinstance(proptype, hyperdb.Boolean):
1402                     value = value.lower() in ('yes', 'true', 'on', '1')
1403                 elif isinstance(proptype, hyperdb.Number):
1404                     value = float(value)
1406             # get the old value
1407             if nodeid and not nodeid.startswith('-'):
1408                 try:
1409                     existing = cl.get(nodeid, propname)
1410                 except KeyError:
1411                     # this might be a new property for which there is
1412                     # no existing value
1413                     if not propdef.has_key(propname):
1414                         raise
1416                 # make sure the existing multilink is sorted
1417                 if isinstance(proptype, hyperdb.Multilink):
1418                     existing.sort()
1420                 # "missing" existing values may not be None
1421                 if not existing:
1422                     if isinstance(proptype, hyperdb.String) and not existing:
1423                         # some backends store "missing" Strings as empty strings
1424                         existing = None
1425                     elif isinstance(proptype, hyperdb.Number) and not existing:
1426                         # some backends store "missing" Numbers as 0 :(
1427                         existing = 0
1428                     elif isinstance(proptype, hyperdb.Boolean) and not existing:
1429                         # likewise Booleans
1430                         existing = 0
1432                 # if changed, set it
1433                 if value != existing:
1434                     props[propname] = value
1435             else:
1436                 # don't bother setting empty/unset values
1437                 if value is None:
1438                     continue
1439                 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1440                     continue
1441                 elif isinstance(proptype, hyperdb.String) and value == '':
1442                     continue
1444                 props[propname] = value
1446             # register this as received if required?
1447             if propname in required and value is not None:
1448                 required.remove(propname)
1450         # see if all the required properties have been supplied
1451         s = []
1452         for thing, required in all_required.items():
1453             if not required:
1454                 continue
1455             if len(required) > 1:
1456                 p = 'properties'
1457             else:
1458                 p = 'property'
1459             s.append('Required %s %s %s not supplied'%(thing[0], p,
1460                 ', '.join(required)))
1461         if s:
1462             raise ValueError, '\n'.join(s)
1464         return all_props, all_links
1466 def fixNewlines(text):
1467     ''' Homogenise line endings.
1469         Different web clients send different line ending values, but
1470         other systems (eg. email) don't necessarily handle those line
1471         endings. Our solution is to convert all line endings to LF.
1472     '''
1473     text = text.replace('\r\n', '\n')
1474     return text.replace('\r', '\n')
1476 def extractFormList(value):
1477     ''' Extract a list of values from the form value.
1479         It may be one of:
1480          [MiniFieldStorage, MiniFieldStorage, ...]
1481          MiniFieldStorage('value,value,...')
1482          MiniFieldStorage('value')
1483     '''
1484     # multiple values are OK
1485     if isinstance(value, type([])):
1486         # it's a list of MiniFieldStorages
1487         value = [i.value.strip() for i in value]
1488     else:
1489         # it's a MiniFieldStorage, but may be a comma-separated list
1490         # of values
1491         value = [i.strip() for i in value.value.split(',')]
1493     # filter out the empty bits
1494     return filter(None, value)