Code

Fix misnamed exception clause.
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.138 2003-09-08 21:08:18 jlgijsbers 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, MimeWriter, smtplib, socket, quopri
9 import stat, rfc822, string
11 from roundup import roundupdb, date, hyperdb, password, token, rcsv
12 from roundup.i18n import _
13 from roundup.cgi.templating import Templates, HTMLRequest, NoTemplate
14 from roundup.cgi import cgitb
15 from roundup.cgi.PageTemplates import PageTemplate
16 from roundup.rfc2822 import encode_header
17 from roundup.mailgw import uidFromAddress
18 from roundup.mailer import Mailer, MessageSendError
20 class HTTPException(Exception):
21       pass
22 class  Unauthorised(HTTPException):
23        pass
24 class  NotFound(HTTPException):
25        pass
26 class  Redirect(HTTPException):
27        pass
28 class  NotModified(HTTPException):
29        pass
31 # used by a couple of routines
32 if hasattr(string, 'ascii_letters'):
33     chars = string.ascii_letters+string.digits
34 else:
35     # python2.1 doesn't have ascii_letters
36     chars = string.letters+string.digits
38 # XXX actually _use_ FormError
39 class FormError(ValueError):
40     ''' An "expected" exception occurred during form parsing.
41         - ie. something we know can go wrong, and don't want to alarm the
42           user with
44         We trap this at the user interface level and feed back a nice error
45         to the user.
46     '''
47     pass
49 class SendFile(Exception):
50     ''' Send a file from the database '''
52 class SendStaticFile(Exception):
53     ''' Send a static file from the instance html directory '''
55 def initialiseSecurity(security):
56     ''' Create some Permissions and Roles on the security object
58         This function is directly invoked by security.Security.__init__()
59         as a part of the Security object instantiation.
60     '''
61     security.addPermission(name="Web Registration",
62         description="User may register through the web")
63     p = security.addPermission(name="Web Access",
64         description="User may access the web interface")
65     security.addPermissionToRole('Admin', p)
67     # doing Role stuff through the web - make sure Admin can
68     p = security.addPermission(name="Web Roles",
69         description="User may manipulate user Roles through the web")
70     security.addPermissionToRole('Admin', p)
72 # used to clean messages passed through CGI variables - HTML-escape any tag
73 # that isn't <a href="">, <i>, <b> and <br> (including XHTML variants) so
74 # that people can't pass through nasties like <script>, <iframe>, ...
75 CLEAN_MESSAGE_RE = r'(<(/?(.*?)(\s*href="[^"]")?\s*/?)>)'
76 def clean_message(message, mc=re.compile(CLEAN_MESSAGE_RE, re.I)):
77     return mc.sub(clean_message_callback, message)
78 def clean_message_callback(match, ok={'a':1,'i':1,'b':1,'br':1}):
79     ''' Strip all non <a>,<i>,<b> and <br> tags from a string
80     '''
81     if ok.has_key(match.group(3).lower()):
82         return match.group(1)
83     return '&lt;%s&gt;'%match.group(2)
85 class Client:
86     ''' Instantiate to handle one CGI request.
88     See inner_main for request processing.
90     Client attributes at instantiation:
91         "path" is the PATH_INFO inside the instance (with no leading '/')
92         "base" is the base URL for the instance
93         "form" is the cgi form, an instance of FieldStorage from the standard
94                cgi module
95         "additional_headers" is a dictionary of additional HTTP headers that
96                should be sent to the client
97         "response_code" is the HTTP response code to send to the client
99     During the processing of a request, the following attributes are used:
100         "error_message" holds a list of error messages
101         "ok_message" holds a list of OK messages
102         "session" is the current user session id
103         "user" is the current user's name
104         "userid" is the current user's id
105         "template" is the current :template context
106         "classname" is the current class context name
107         "nodeid" is the current context item id
109     User Identification:
110      If the user has no login cookie, then they are anonymous and are logged
111      in as that user. This typically gives them all Permissions assigned to the
112      Anonymous Role.
114      Once a user logs in, they are assigned a session. The Client instance
115      keeps the nodeid of the session as the "session" attribute.
118     Special form variables:
119      Note that in various places throughout this code, special form
120      variables of the form :<name> are used. The colon (":") part may
121      actually be one of either ":" or "@".
122     '''
124     #
125     # special form variables
126     #
127     FV_TEMPLATE = re.compile(r'[@:]template')
128     FV_OK_MESSAGE = re.compile(r'[@:]ok_message')
129     FV_ERROR_MESSAGE = re.compile(r'[@:]error_message')
131     FV_QUERYNAME = re.compile(r'[@:]queryname')
133     # edit form variable handling (see unit tests)
134     FV_LABELS = r'''
135        ^(
136          (?P<note>[@:]note)|
137          (?P<file>[@:]file)|
138          (
139           ((?P<classname>%s)(?P<id>[-\d]+))?  # optional leading designator
140           ((?P<required>[@:]required$)|       # :required
141            (
142             (
143              (?P<add>[@:]add[@:])|            # :add:<prop>
144              (?P<remove>[@:]remove[@:])|      # :remove:<prop>
145              (?P<confirm>[@:]confirm[@:])|    # :confirm:<prop>
146              (?P<link>[@:]link[@:])|          # :link:<prop>
147              ([@:])                           # just a separator
148             )?
149             (?P<propname>[^@:]+)             # <prop>
150            )
151           )
152          )
153         )$'''
155     # Note: index page stuff doesn't appear here:
156     # columns, sort, sortdir, filter, group, groupdir, search_text,
157     # pagesize, startwith
159     def __init__(self, instance, request, env, form=None):
160         hyperdb.traceMark()
161         self.instance = instance
162         self.request = request
163         self.env = env
164         self.mailer = Mailer(instance.config)
166         # save off the path
167         self.path = env['PATH_INFO']
169         # this is the base URL for this tracker
170         self.base = self.instance.config.TRACKER_WEB
172         # this is the "cookie path" for this tracker (ie. the path part of
173         # the "base" url)
174         self.cookie_path = urlparse.urlparse(self.base)[2]
175         self.cookie_name = 'roundup_session_' + re.sub('[^a-zA-Z]', '',
176             self.instance.config.TRACKER_NAME)
178         # see if we need to re-parse the environment for the form (eg Zope)
179         if form is None:
180             self.form = cgi.FieldStorage(environ=env)
181         else:
182             self.form = form
184         # turn debugging on/off
185         try:
186             self.debug = int(env.get("ROUNDUP_DEBUG", 0))
187         except ValueError:
188             # someone gave us a non-int debug level, turn it off
189             self.debug = 0
191         # flag to indicate that the HTTP headers have been sent
192         self.headers_done = 0
194         # additional headers to send with the request - must be registered
195         # before the first write
196         self.additional_headers = {}
197         self.response_code = 200
200     def main(self):
201         ''' Wrap the real main in a try/finally so we always close off the db.
202         '''
203         try:
204             self.inner_main()
205         finally:
206             if hasattr(self, 'db'):
207                 self.db.close()
209     def inner_main(self):
210         ''' Process a request.
212             The most common requests are handled like so:
213             1. figure out who we are, defaulting to the "anonymous" user
214                see determine_user
215             2. figure out what the request is for - the context
216                see determine_context
217             3. handle any requested action (item edit, search, ...)
218                see handle_action
219             4. render a template, resulting in HTML output
221             In some situations, exceptions occur:
222             - HTTP Redirect  (generally raised by an action)
223             - SendFile       (generally raised by determine_context)
224               serve up a FileClass "content" property
225             - SendStaticFile (generally raised by determine_context)
226               serve up a file from the tracker "html" directory
227             - Unauthorised   (generally raised by an action)
228               the action is cancelled, the request is rendered and an error
229               message is displayed indicating that permission was not
230               granted for the action to take place
231             - NotFound       (raised wherever it needs to be)
232               percolates up to the CGI interface that called the client
233         '''
234         self.ok_message = []
235         self.error_message = []
236         try:
237             # figure out the context and desired content template
238             # do this first so we don't authenticate for static files
239             # Note: this method opens the database as "admin" in order to
240             # perform context checks
241             self.determine_context()
243             # make sure we're identified (even anonymously)
244             self.determine_user()
246             # possibly handle a form submit action (may change self.classname
247             # and self.template, and may also append error/ok_messages)
248             self.handle_action()
250             # now render the page
251             # we don't want clients caching our dynamic pages
252             self.additional_headers['Cache-Control'] = 'no-cache'
253 # Pragma: no-cache makes Mozilla and its ilk double-load all pages!!
254 #            self.additional_headers['Pragma'] = 'no-cache'
256             # expire this page 5 seconds from now
257             date = rfc822.formatdate(time.time() + 5)
258             self.additional_headers['Expires'] = date
260             # render the content
261             self.write(self.renderContext())
262         except Redirect, url:
263             # let's redirect - if the url isn't None, then we need to do
264             # the headers, otherwise the headers have been set before the
265             # exception was raised
266             if url:
267                 self.additional_headers['Location'] = url
268                 self.response_code = 302
269             self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
270         except SendFile, designator:
271             self.serve_file(designator)
272         except SendStaticFile, file:
273             try:
274                 self.serve_static_file(str(file))
275             except NotModified:
276                 # send the 304 response
277                 self.request.send_response(304)
278                 self.request.end_headers()
279         except Unauthorised, message:
280             self.classname = None
281             self.template = ''
282             self.error_message.append(message)
283             self.write(self.renderContext())
284         except NotFound:
285             # pass through
286             raise
287         except:
288             # everything else
289             self.write(cgitb.html())
291     def clean_sessions(self):
292         ''' Age sessions, remove when they haven't been used for a week.
293         
294             Do it only once an hour.
296             Note: also cleans One Time Keys, and other "session" based
297             stuff.
298         '''
299         sessions = self.db.sessions
300         last_clean = sessions.get('last_clean', 'last_use') or 0
302         week = 60*60*24*7
303         hour = 60*60
304         now = time.time()
305         if now - last_clean > hour:
306             # remove aged sessions
307             for sessid in sessions.list():
308                 interval = now - sessions.get(sessid, 'last_use')
309                 if interval > week:
310                     sessions.destroy(sessid)
311             # remove aged otks
312             otks = self.db.otks
313             for sessid in otks.list():
314                 interval = now - otks.get(sessid, '__time')
315                 if interval > week:
316                     otks.destroy(sessid)
317             sessions.set('last_clean', last_use=time.time())
319     def determine_user(self):
320         ''' Determine who the user is
321         '''
322         # open the database as admin
323         self.opendb('admin')
325         # clean age sessions
326         self.clean_sessions()
328         # make sure we have the session Class
329         sessions = self.db.sessions
331         # look up the user session cookie
332         cookie = Cookie.SimpleCookie(self.env.get('HTTP_COOKIE', ''))
333         user = 'anonymous'
335         # bump the "revision" of the cookie since the format changed
336         if (cookie.has_key(self.cookie_name) and
337                 cookie[self.cookie_name].value != 'deleted'):
339             # get the session key from the cookie
340             self.session = cookie[self.cookie_name].value
341             # get the user from the session
342             try:
343                 # update the lifetime datestamp
344                 sessions.set(self.session, last_use=time.time())
345                 sessions.commit()
346                 user = sessions.get(self.session, 'user')
347             except KeyError:
348                 user = 'anonymous'
350         # sanity check on the user still being valid, getting the userid
351         # at the same time
352         try:
353             self.userid = self.db.user.lookup(user)
354         except (KeyError, TypeError):
355             user = 'anonymous'
357         # make sure the anonymous user is valid if we're using it
358         if user == 'anonymous':
359             self.make_user_anonymous()
360         else:
361             self.user = user
363         # reopen the database as the correct user
364         self.opendb(self.user)
366     def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
367         ''' Determine the context of this page from the URL:
369             The URL path after the instance identifier is examined. The path
370             is generally only one entry long.
372             - if there is no path, then we are in the "home" context.
373             * if the path is "_file", then the additional path entry
374               specifies the filename of a static file we're to serve up
375               from the instance "html" directory. Raises a SendStaticFile
376               exception.
377             - if there is something in the path (eg "issue"), it identifies
378               the tracker class we're to display.
379             - if the path is an item designator (eg "issue123"), then we're
380               to display a specific item.
381             * if the path starts with an item designator and is longer than
382               one entry, then we're assumed to be handling an item of a
383               FileClass, and the extra path information gives the filename
384               that the client is going to label the download with (ie
385               "file123/image.png" is nicer to download than "file123"). This
386               raises a SendFile exception.
388             Both of the "*" types of contexts stop before we bother to
389             determine the template we're going to use. That's because they
390             don't actually use templates.
392             The template used is specified by the :template CGI variable,
393             which defaults to:
395              only classname suplied:          "index"
396              full item designator supplied:   "item"
398             We set:
399              self.classname  - the class to display, can be None
400              self.template   - the template to render the current context with
401              self.nodeid     - the nodeid of the class we're displaying
402         '''
403         # default the optional variables
404         self.classname = None
405         self.nodeid = None
407         # see if a template or messages are specified
408         template_override = ok_message = error_message = None
409         for key in self.form.keys():
410             if self.FV_TEMPLATE.match(key):
411                 template_override = self.form[key].value
412             elif self.FV_OK_MESSAGE.match(key):
413                 ok_message = self.form[key].value
414                 ok_message = clean_message(ok_message)
415             elif self.FV_ERROR_MESSAGE.match(key):
416                 error_message = self.form[key].value
417                 error_message = clean_message(error_message)
419         # determine the classname and possibly nodeid
420         path = self.path.split('/')
421         if not path or path[0] in ('', 'home', 'index'):
422             if template_override is not None:
423                 self.template = template_override
424             else:
425                 self.template = ''
426             return
427         elif path[0] == '_file':
428             raise SendStaticFile, os.path.join(*path[1:])
429         else:
430             self.classname = path[0]
431             if len(path) > 1:
432                 # send the file identified by the designator in path[0]
433                 raise SendFile, path[0]
435         # we need the db for further context stuff - open it as admin
436         self.opendb('admin')
438         # see if we got a designator
439         m = dre.match(self.classname)
440         if m:
441             self.classname = m.group(1)
442             self.nodeid = m.group(2)
443             if not self.db.getclass(self.classname).hasnode(self.nodeid):
444                 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
445             # with a designator, we default to item view
446             self.template = 'item'
447         else:
448             # with only a class, we default to index view
449             self.template = 'index'
451         # make sure the classname is valid
452         try:
453             self.db.getclass(self.classname)
454         except KeyError:
455             raise NotFound, self.classname
457         # see if we have a template override
458         if template_override is not None:
459             self.template = template_override
461         # see if we were passed in a message
462         if ok_message:
463             self.ok_message.append(ok_message)
464         if error_message:
465             self.error_message.append(error_message)
467     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
468         ''' Serve the file from the content property of the designated item.
469         '''
470         m = dre.match(str(designator))
471         if not m:
472             raise NotFound, str(designator)
473         classname, nodeid = m.group(1), m.group(2)
474         if classname != 'file':
475             raise NotFound, designator
477         # we just want to serve up the file named
478         self.opendb('admin')
479         file = self.db.file
480         self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
481         self.write(file.get(nodeid, 'content'))
483     def serve_static_file(self, file):
484         ims = None
485         # see if there's an if-modified-since...
486         if hasattr(self.request, 'headers'):
487             ims = self.request.headers.getheader('if-modified-since')
488         elif self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
489             # cgi will put the header in the env var
490             ims = self.env['HTTP_IF_MODIFIED_SINCE']
491         filename = os.path.join(self.instance.config.TEMPLATES, file)
492         lmt = os.stat(filename)[stat.ST_MTIME]
493         if ims:
494             ims = rfc822.parsedate(ims)[:6]
495             lmtt = time.gmtime(lmt)[:6]
496             if lmtt <= ims:
497                 raise NotModified
499         # we just want to serve up the file named
500         file = str(file)
501         mt = mimetypes.guess_type(file)[0]
502         if not mt:
503             if file.endswith('.css'):
504                 mt = 'text/css'
505             else:
506                 mt = 'text/plain'
507         self.additional_headers['Content-Type'] = mt
508         self.additional_headers['Last-Modifed'] = rfc822.formatdate(lmt)
509         self.write(open(filename, 'rb').read())
511     def renderContext(self):
512         ''' Return a PageTemplate for the named page
513         '''
514         name = self.classname
515         extension = self.template
516         pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
518         # catch errors so we can handle PT rendering errors more nicely
519         args = {
520             'ok_message': self.ok_message,
521             'error_message': self.error_message
522         }
523         try:
524             # let the template render figure stuff out
525             return pt.render(self, None, None, **args)
526         except NoTemplate, message:
527             return '<strong>%s</strong>'%message
528         except:
529             # everything else
530             return cgitb.pt_html()
532     # these are the actions that are available
533     actions = (
534         ('edit',     'editItemAction'),
535         ('editcsv',  'editCSVAction'),
536         ('new',      'newItemAction'),
537         ('register', 'registerAction'),
538         ('confrego', 'confRegoAction'),
539         ('passrst',  'passResetAction'),
540         ('login',    'loginAction'),
541         ('logout',   'logout_action'),
542         ('search',   'searchAction'),
543         ('retire',   'retireAction'),
544         ('show',     'showAction'),
545     )
546     def handle_action(self):
547         ''' Determine whether there should be an Action called.
549             The action is defined by the form variable :action which
550             identifies the method on this object to call. The actions
551             are defined in the "actions" sequence on this class.
552         '''
553         if self.form.has_key(':action'):
554             action = self.form[':action'].value.lower()
555         elif self.form.has_key('@action'):
556             action = self.form['@action'].value.lower()
557         else:
558             return None
559         try:
560             # get the action, validate it
561             for name, method in self.actions:
562                 if name == action:
563                     break
564             else:
565                 raise ValueError, 'No such action "%s"'%action
566             # call the mapped action
567             getattr(self, method)()
568         except Redirect:
569             raise
570         except Unauthorised:
571             raise
573     def write(self, content):
574         if not self.headers_done:
575             self.header()
576         self.request.wfile.write(content)
578     def header(self, headers=None, response=None):
579         '''Put up the appropriate header.
580         '''
581         if headers is None:
582             headers = {'Content-Type':'text/html'}
583         if response is None:
584             response = self.response_code
586         # update with additional info
587         headers.update(self.additional_headers)
589         if not headers.has_key('Content-Type'):
590             headers['Content-Type'] = 'text/html'
591         self.request.send_response(response)
592         for entry in headers.items():
593             self.request.send_header(*entry)
594         self.request.end_headers()
595         self.headers_done = 1
596         if self.debug:
597             self.headers_sent = headers
599     def set_cookie(self, user):
600         ''' Set up a session cookie for the user and store away the user's
601             login info against the session.
602         '''
603         # TODO generate a much, much stronger session key ;)
604         self.session = binascii.b2a_base64(repr(random.random())).strip()
606         # clean up the base64
607         if self.session[-1] == '=':
608             if self.session[-2] == '=':
609                 self.session = self.session[:-2]
610             else:
611                 self.session = self.session[:-1]
613         # insert the session in the sessiondb
614         self.db.sessions.set(self.session, user=user, last_use=time.time())
616         # and commit immediately
617         self.db.sessions.commit()
619         # expire us in a long, long time
620         expire = Cookie._getdate(86400*365)
622         # generate the cookie path - make sure it has a trailing '/'
623         self.additional_headers['Set-Cookie'] = \
624           '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
625             expire, self.cookie_path)
627     def make_user_anonymous(self):
628         ''' Make us anonymous
630             This method used to handle non-existence of the 'anonymous'
631             user, but that user is mandatory now.
632         '''
633         self.userid = self.db.user.lookup('anonymous')
634         self.user = 'anonymous'
636     def opendb(self, user):
637         ''' Open the database.
638         '''
639         # open the db if the user has changed
640         if not hasattr(self, 'db') or user != self.db.journaltag:
641             if hasattr(self, 'db'):
642                 self.db.close()
643             self.db = self.instance.open(user)
645     #
646     # Actions
647     #
648     def loginAction(self):
649         ''' Attempt to log a user in.
651             Sets up a session for the user which contains the login
652             credentials.
653         '''
654         # we need the username at a minimum
655         if not self.form.has_key('__login_name'):
656             self.error_message.append(_('Username required'))
657             return
659         # get the login info
660         self.user = self.form['__login_name'].value
661         if self.form.has_key('__login_password'):
662             password = self.form['__login_password'].value
663         else:
664             password = ''
666         # make sure the user exists
667         try:
668             self.userid = self.db.user.lookup(self.user)
669         except KeyError:
670             name = self.user
671             self.error_message.append(_('No such user "%(name)s"')%locals())
672             self.make_user_anonymous()
673             return
675         # verify the password
676         if not self.verifyPassword(self.userid, password):
677             self.make_user_anonymous()
678             self.error_message.append(_('Incorrect password'))
679             return
681         # make sure we're allowed to be here
682         if not self.loginPermission():
683             self.make_user_anonymous()
684             self.error_message.append(_("You do not have permission to login"))
685             return
687         # now we're OK, re-open the database for real, using the user
688         self.opendb(self.user)
690         # set the session cookie
691         self.set_cookie(self.user)
693     def verifyPassword(self, userid, password):
694         ''' Verify the password that the user has supplied
695         '''
696         stored = self.db.user.get(self.userid, 'password')
697         if password == stored:
698             return 1
699         if not password and not stored:
700             return 1
701         return 0
703     def loginPermission(self):
704         ''' Determine whether the user has permission to log in.
706             Base behaviour is to check the user has "Web Access".
707         ''' 
708         if not self.db.security.hasPermission('Web Access', self.userid):
709             return 0
710         return 1
712     def logout_action(self):
713         ''' Make us really anonymous - nuke the cookie too
714         '''
715         # log us out
716         self.make_user_anonymous()
718         # construct the logout cookie
719         now = Cookie._getdate()
720         self.additional_headers['Set-Cookie'] = \
721            '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.cookie_name,
722             now, self.cookie_path)
724         # Let the user know what's going on
725         self.ok_message.append(_('You are logged out'))
727     def registerAction(self):
728         '''Attempt to create a new user based on the contents of the form
729         and then set the cookie.
731         return 1 on successful login
732         '''
733         # parse the props from the form
734         try:
735             props = self.parsePropsFromForm()[0][('user', None)]
736         except (ValueError, KeyError), message:
737             self.error_message.append(_('Error: ') + str(message))
738             return
740         # make sure we're allowed to register
741         if not self.registerPermission(props):
742             raise Unauthorised, _("You do not have permission to register")
744         try:
745             self.db.user.lookup(props['username'])
746             self.error_message.append('Error: A user with the username "%s" '
747                 'already exists'%props['username'])
748             return
749         except KeyError:
750             pass
752         # generate the one-time-key and store the props for later
753         otk = ''.join([random.choice(chars) for x in range(32)])
754         for propname, proptype in self.db.user.getprops().items():
755             value = props.get(propname, None)
756             if value is None:
757                 pass
758             elif isinstance(proptype, hyperdb.Date):
759                 props[propname] = str(value)
760             elif isinstance(proptype, hyperdb.Interval):
761                 props[propname] = str(value)
762             elif isinstance(proptype, hyperdb.Password):
763                 props[propname] = str(value)
764         props['__time'] = time.time()
765         self.db.otks.set(otk, **props)
767         # send the email
768         tracker_name = self.db.config.TRACKER_NAME
769         tracker_email = self.db.config.TRACKER_EMAIL
770         subject = 'Complete your registration to %s -- key %s' % (tracker_name,
771                                                                   otk)
772         body = """To complete your registration of the user "%(name)s" with
773 %(tracker)s, please do one of the following:
775 - send a reply to %(tracker_email)s and maintain the subject line as is (the
776 reply's additional "Re:" is ok),
778 - or visit the following URL:
780    %(url)s?@action=confrego&otk=%(otk)s
781 """ % {'name': props['username'], 'tracker': tracker_name, 'url': self.base,
782        'otk': otk, 'tracker_email': tracker_email}
783         if not self.standard_message(props['address'], subject, body,
784                                      tracker_email):
785             return
787         # commit changes to the database
788         self.db.commit()
790         # redirect to the "you're almost there" page
791         raise Redirect, '%suser?@template=rego_progress'%self.base
793     def standard_message(self, to, subject, body, author=None):
794         try:
795             self.mailer.standard_message(to, subject, body, author)
796             return 1
797         except MessageSendError, e:
798             self.error_message.append(str(e))
799             
800     def registerPermission(self, props):
801         ''' Determine whether the user has permission to register
803             Base behaviour is to check the user has "Web Registration".
804         '''
805         # registration isn't allowed to supply roles
806         if props.has_key('roles'):
807             return 0
808         if self.db.security.hasPermission('Web Registration', self.userid):
809             return 1
810         return 0
812     def confRegoAction(self):
813         ''' Grab the OTK, use it to load up the new user details
814         '''
815         try:
816             # pull the rego information out of the otk database
817             self.userid = self.db.confirm_registration(self.form['otk'].value)
818         except (ValueError, KeyError), message:
819             # XXX: we need to make the "default" page be able to display errors!
820             self.error_message.append(str(message))
821             return
822         
823         # log the new user in
824         self.user = self.db.user.get(self.userid, 'username')
825         # re-open the database for real, using the user
826         self.opendb(self.user)
828         # if we have a session, update it
829         if hasattr(self, 'session'):
830             self.db.sessions.set(self.session, user=self.user,
831                 last_use=time.time())
832         else:
833             # new session cookie
834             self.set_cookie(self.user)
836         # nice message
837         message = _('You are now registered, welcome!')
839         # redirect to the user's page
840         raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
841             self.userid, urllib.quote(message))
843     def passResetAction(self):
844         ''' Handle password reset requests.
846             Presence of either "name" or "address" generate email.
847             Presense of "otk" performs the reset.
848         '''
849         if self.form.has_key('otk'):
850             # pull the rego information out of the otk database
851             otk = self.form['otk'].value
852             uid = self.db.otks.get(otk, 'uid')
853             if uid is None:
854                 self.error_message.append('Invalid One Time Key!')
855                 return
857             # re-open the database as "admin"
858             if self.user != 'admin':
859                 self.opendb('admin')
861             # change the password
862             newpw = password.generatePassword()
864             cl = self.db.user
865 # XXX we need to make the "default" page be able to display errors!
866             try:
867                 # set the password
868                 cl.set(uid, password=password.Password(newpw))
869                 # clear the props from the otk database
870                 self.db.otks.destroy(otk)
871                 self.db.commit()
872             except (ValueError, KeyError), message:
873                 self.error_message.append(str(message))
874                 return
876             # user info
877             address = self.db.user.get(uid, 'address')
878             name = self.db.user.get(uid, 'username')
880             # send the email
881             tracker_name = self.db.config.TRACKER_NAME
882             subject = 'Password reset for %s'%tracker_name
883             body = '''
884 The password has been reset for username "%(name)s".
886 Your password is now: %(password)s
887 '''%{'name': name, 'password': newpw}
888             if not self.standard_message(address, subject, body):
889                 return
891             self.ok_message.append('Password reset and email sent to %s'%address)
892             return
894         # no OTK, so now figure the user
895         if self.form.has_key('username'):
896             name = self.form['username'].value
897             try:
898                 uid = self.db.user.lookup(name)
899             except KeyError:
900                 self.error_message.append('Unknown username')
901                 return
902             address = self.db.user.get(uid, 'address')
903         elif self.form.has_key('address'):
904             address = self.form['address'].value
905             uid = uidFromAddress(self.db, ('', address), create=0)
906             if not uid:
907                 self.error_message.append('Unknown email address')
908                 return
909             name = self.db.user.get(uid, 'username')
910         else:
911             self.error_message.append('You need to specify a username '
912                 'or address')
913             return
915         # generate the one-time-key and store the props for later
916         otk = ''.join([random.choice(chars) for x in range(32)])
917         self.db.otks.set(otk, uid=uid, __time=time.time())
919         # send the email
920         tracker_name = self.db.config.TRACKER_NAME
921         subject = 'Confirm reset of password for %s'%tracker_name
922         body = '''
923 Someone, perhaps you, has requested that the password be changed for your
924 username, "%(name)s". If you wish to proceed with the change, please follow
925 the link below:
927   %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
929 You should then receive another email with the new password.
930 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
931         if not self.standard_message(address, subject, body):
932             return
934         self.ok_message.append('Email sent to %s'%address)
936     def editItemAction(self):
937         ''' Perform an edit of an item in the database.
939            See parsePropsFromForm and _editnodes for special variables
940         '''
941         # parse the props from the form
942         try:
943             props, links = self.parsePropsFromForm()
944         except (ValueError, KeyError), message:
945             self.error_message.append(_('Parse Error: ') + str(message))
946             return
948         # handle the props
949         try:
950             message = self._editnodes(props, links)
951         except (ValueError, KeyError, IndexError), message:
952             self.error_message.append(_('Apply Error: ') + str(message))
953             return
955         # commit now that all the tricky stuff is done
956         self.db.commit()
958         # redirect to the item's edit page
959         raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
960             self.classname, self.nodeid, urllib.quote(message),
961             urllib.quote(self.template))
963     def editItemPermission(self, props):
964         ''' Determine whether the user has permission to edit this item.
966             Base behaviour is to check the user can edit this class. If we're
967             editing the "user" class, users are allowed to edit their own
968             details. Unless it's the "roles" property, which requires the
969             special Permission "Web Roles".
970         '''
971         # if this is a user node and the user is editing their own node, then
972         # we're OK
973         has = self.db.security.hasPermission
974         if self.classname == 'user':
975             # reject if someone's trying to edit "roles" and doesn't have the
976             # right permission.
977             if props.has_key('roles') and not has('Web Roles', self.userid,
978                     'user'):
979                 return 0
980             # if the item being edited is the current user, we're ok
981             if self.nodeid == self.userid:
982                 return 1
983         if self.db.security.hasPermission('Edit', self.userid, self.classname):
984             return 1
985         return 0
987     def newItemAction(self):
988         ''' Add a new item to the database.
990             This follows the same form as the editItemAction, with the same
991             special form values.
992         '''
993         # parse the props from the form
994         try:
995             props, links = self.parsePropsFromForm()
996         except (ValueError, KeyError), message:
997             self.error_message.append(_('Error: ') + str(message))
998             return
1000         # handle the props - edit or create
1001         try:
1002             # when it hits the None element, it'll set self.nodeid
1003             messages = self._editnodes(props, links)
1005         except (ValueError, KeyError, IndexError), message:
1006             # these errors might just be indicative of user dumbness
1007             self.error_message.append(_('Error: ') + str(message))
1008             return
1010         # commit now that all the tricky stuff is done
1011         self.db.commit()
1013         # redirect to the new item's page
1014         raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
1015             self.classname, self.nodeid, urllib.quote(messages),
1016             urllib.quote(self.template))
1018     def newItemPermission(self, props):
1019         ''' Determine whether the user has permission to create (edit) this
1020             item.
1022             Base behaviour is to check the user can edit this class. No
1023             additional property checks are made. Additionally, new user items
1024             may be created if the user has the "Web Registration" Permission.
1025         '''
1026         has = self.db.security.hasPermission
1027         if self.classname == 'user' and has('Web Registration', self.userid,
1028                 'user'):
1029             return 1
1030         if has('Edit', self.userid, self.classname):
1031             return 1
1032         return 0
1035     #
1036     #  Utility methods for editing
1037     #
1038     def _editnodes(self, all_props, all_links, newids=None):
1039         ''' Use the props in all_props to perform edit and creation, then
1040             use the link specs in all_links to do linking.
1041         '''
1042         # figure dependencies and re-work links
1043         deps = {}
1044         links = {}
1045         for cn, nodeid, propname, vlist in all_links:
1046             if not all_props.has_key((cn, nodeid)):
1047                 # link item to link to doesn't (and won't) exist
1048                 continue
1049             for value in vlist:
1050                 if not all_props.has_key(value):
1051                     # link item to link to doesn't (and won't) exist
1052                     continue
1053                 deps.setdefault((cn, nodeid), []).append(value)
1054                 links.setdefault(value, []).append((cn, nodeid, propname))
1056         # figure chained dependencies ordering
1057         order = []
1058         done = {}
1059         # loop detection
1060         change = 0
1061         while len(all_props) != len(done):
1062             for needed in all_props.keys():
1063                 if done.has_key(needed):
1064                     continue
1065                 tlist = deps.get(needed, [])
1066                 for target in tlist:
1067                     if not done.has_key(target):
1068                         break
1069                 else:
1070                     done[needed] = 1
1071                     order.append(needed)
1072                     change = 1
1073             if not change:
1074                 raise ValueError, 'linking must not loop!'
1076         # now, edit / create
1077         m = []
1078         for needed in order:
1079             props = all_props[needed]
1080             if not props:
1081                 # nothing to do
1082                 continue
1083             cn, nodeid = needed
1085             if nodeid is not None and int(nodeid) > 0:
1086                 # make changes to the node
1087                 props = self._changenode(cn, nodeid, props)
1089                 # and some nice feedback for the user
1090                 if props:
1091                     info = ', '.join(props.keys())
1092                     m.append('%s %s %s edited ok'%(cn, nodeid, info))
1093                 else:
1094                     m.append('%s %s - nothing changed'%(cn, nodeid))
1095             else:
1096                 assert props
1098                 # make a new node
1099                 newid = self._createnode(cn, props)
1100                 if nodeid is None:
1101                     self.nodeid = newid
1102                 nodeid = newid
1104                 # and some nice feedback for the user
1105                 m.append('%s %s created'%(cn, newid))
1107             # fill in new ids in links
1108             if links.has_key(needed):
1109                 for linkcn, linkid, linkprop in links[needed]:
1110                     props = all_props[(linkcn, linkid)]
1111                     cl = self.db.classes[linkcn]
1112                     propdef = cl.getprops()[linkprop]
1113                     if not props.has_key(linkprop):
1114                         if linkid is None or linkid.startswith('-'):
1115                             # linking to a new item
1116                             if isinstance(propdef, hyperdb.Multilink):
1117                                 props[linkprop] = [newid]
1118                             else:
1119                                 props[linkprop] = newid
1120                         else:
1121                             # linking to an existing item
1122                             if isinstance(propdef, hyperdb.Multilink):
1123                                 existing = cl.get(linkid, linkprop)[:]
1124                                 existing.append(nodeid)
1125                                 props[linkprop] = existing
1126                             else:
1127                                 props[linkprop] = newid
1129         return '<br>'.join(m)
1131     def _changenode(self, cn, nodeid, props):
1132         ''' change the node based on the contents of the form
1133         '''
1134         # check for permission
1135         if not self.editItemPermission(props):
1136             raise Unauthorised, 'You do not have permission to edit %s'%cn
1138         # make the changes
1139         cl = self.db.classes[cn]
1140         return cl.set(nodeid, **props)
1142     def _createnode(self, cn, props):
1143         ''' create a node based on the contents of the form
1144         '''
1145         # check for permission
1146         if not self.newItemPermission(props):
1147             raise Unauthorised, 'You do not have permission to create %s'%cn
1149         # create the node and return its id
1150         cl = self.db.classes[cn]
1151         return cl.create(**props)
1153     # 
1154     # More actions
1155     #
1156     def editCSVAction(self):
1157         ''' Performs an edit of all of a class' items in one go.
1159             The "rows" CGI var defines the CSV-formatted entries for the
1160             class. New nodes are identified by the ID 'X' (or any other
1161             non-existent ID) and removed lines are retired.
1162         '''
1163         # this is per-class only
1164         if not self.editCSVPermission():
1165             self.error_message.append(
1166                 _('You do not have permission to edit %s' %self.classname))
1168         # get the CSV module
1169         if rcsv.error:
1170             self.error_message.append(_(rcsv.error))
1171             return
1173         cl = self.db.classes[self.classname]
1174         idlessprops = cl.getprops(protected=0).keys()
1175         idlessprops.sort()
1176         props = ['id'] + idlessprops
1178         # do the edit
1179         rows = StringIO.StringIO(self.form['rows'].value)
1180         reader = rcsv.reader(rows, rcsv.comma_separated)
1181         found = {}
1182         line = 0
1183         for values in reader:
1184             line += 1
1185             if line == 1: continue
1186             # skip property names header
1187             if values == props:
1188                 continue
1190             # extract the nodeid
1191             nodeid, values = values[0], values[1:]
1192             found[nodeid] = 1
1194             # see if the node exists
1195             if nodeid in ('x', 'X') or not cl.hasnode(nodeid):
1196                 exists = 0
1197             else:
1198                 exists = 1
1200             # confirm correct weight
1201             if len(idlessprops) != len(values):
1202                 self.error_message.append(
1203                     _('Not enough values on line %(line)s')%{'line':line})
1204                 return
1206             # extract the new values
1207             d = {}
1208             for name, value in zip(idlessprops, values):
1209                 prop = cl.properties[name]
1210                 value = value.strip()
1211                 # only add the property if it has a value
1212                 if value:
1213                     # if it's a multilink, split it
1214                     if isinstance(prop, hyperdb.Multilink):
1215                         value = value.split(':')
1216                     elif isinstance(prop, hyperdb.Password):
1217                         value = password.Password(value)
1218                     elif isinstance(prop, hyperdb.Interval):
1219                         value = date.Interval(value)
1220                     elif isinstance(prop, hyperdb.Date):
1221                         value = date.Date(value)
1222                     elif isinstance(prop, hyperdb.Boolean):
1223                         value = value.lower() in ('yes', 'true', 'on', '1')
1224                     elif isinstance(prop, hyperdb.Number):
1225                         value = float(value)
1226                     d[name] = value
1227                 elif exists:
1228                     # nuke the existing value
1229                     if isinstance(prop, hyperdb.Multilink):
1230                         d[name] = []
1231                     else:
1232                         d[name] = None
1234             # perform the edit
1235             if exists:
1236                 # edit existing
1237                 cl.set(nodeid, **d)
1238             else:
1239                 # new node
1240                 found[cl.create(**d)] = 1
1242         # retire the removed entries
1243         for nodeid in cl.list():
1244             if not found.has_key(nodeid):
1245                 cl.retire(nodeid)
1247         # all OK
1248         self.db.commit()
1250         self.ok_message.append(_('Items edited OK'))
1252     def editCSVPermission(self):
1253         ''' Determine whether the user has permission to edit this class.
1255             Base behaviour is to check the user can edit this class.
1256         ''' 
1257         if not self.db.security.hasPermission('Edit', self.userid,
1258                 self.classname):
1259             return 0
1260         return 1
1262     def searchAction(self, wcre=re.compile(r'[\s,]+')):
1263         ''' Mangle some of the form variables.
1265             Set the form ":filter" variable based on the values of the
1266             filter variables - if they're set to anything other than
1267             "dontcare" then add them to :filter.
1269             Handle the ":queryname" variable and save off the query to
1270             the user's query list.
1272             Split any String query values on whitespace and comma.
1273         '''
1274         # generic edit is per-class only
1275         if not self.searchPermission():
1276             self.error_message.append(
1277                 _('You do not have permission to search %s' %self.classname))
1279         # add a faked :filter form variable for each filtering prop
1280         props = self.db.classes[self.classname].getprops()
1281         queryname = ''
1282         for key in self.form.keys():
1283             # special vars
1284             if self.FV_QUERYNAME.match(key):
1285                 queryname = self.form[key].value.strip()
1286                 continue
1288             if not props.has_key(key):
1289                 continue
1290             if isinstance(self.form[key], type([])):
1291                 # search for at least one entry which is not empty
1292                 for minifield in self.form[key]:
1293                     if minifield.value:
1294                         break
1295                 else:
1296                     continue
1297             else:
1298                 if not self.form[key].value:
1299                     continue
1300                 if isinstance(props[key], hyperdb.String):
1301                     v = self.form[key].value
1302                     l = token.token_split(v)
1303                     if len(l) > 1 or l[0] != v:
1304                         self.form.value.remove(self.form[key])
1305                         # replace the single value with the split list
1306                         for v in l:
1307                             self.form.value.append(cgi.MiniFieldStorage(key, v))
1309             self.form.value.append(cgi.MiniFieldStorage('@filter', key))
1311         # handle saving the query params
1312         if queryname:
1313             # parse the environment and figure what the query _is_
1314             req = HTMLRequest(self)
1316             # The [1:] strips off the '?' character, it isn't part of the
1317             # query string.
1318             url = req.indexargs_href('', {})[1:]
1320             # handle editing an existing query
1321             try:
1322                 qid = self.db.query.lookup(queryname)
1323                 self.db.query.set(qid, klass=self.classname, url=url)
1324             except KeyError:
1325                 # create a query
1326                 qid = self.db.query.create(name=queryname,
1327                     klass=self.classname, url=url)
1329                 # and add it to the user's query multilink
1330                 queries = self.db.user.get(self.userid, 'queries')
1331                 queries.append(qid)
1332                 self.db.user.set(self.userid, queries=queries)
1334             # commit the query change to the database
1335             self.db.commit()
1337     def searchPermission(self):
1338         ''' Determine whether the user has permission to search this class.
1340             Base behaviour is to check the user can view this class.
1341         ''' 
1342         if not self.db.security.hasPermission('View', self.userid,
1343                 self.classname):
1344             return 0
1345         return 1
1348     def retireAction(self):
1349         ''' Retire the context item.
1350         '''
1351         # if we want to view the index template now, then unset the nodeid
1352         # context info (a special-case for retire actions on the index page)
1353         nodeid = self.nodeid
1354         if self.template == 'index':
1355             self.nodeid = None
1357         # generic edit is per-class only
1358         if not self.retirePermission():
1359             self.error_message.append(
1360                 _('You do not have permission to retire %s' %self.classname))
1361             return
1363         # make sure we don't try to retire admin or anonymous
1364         if self.classname == 'user' and \
1365                 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1366             self.error_message.append(
1367                 _('You may not retire the admin or anonymous user'))
1368             return
1370         # do the retire
1371         self.db.getclass(self.classname).retire(nodeid)
1372         self.db.commit()
1374         self.ok_message.append(
1375             _('%(classname)s %(itemid)s has been retired')%{
1376                 'classname': self.classname.capitalize(), 'itemid': nodeid})
1378     def retirePermission(self):
1379         ''' Determine whether the user has permission to retire this class.
1381             Base behaviour is to check the user can edit this class.
1382         ''' 
1383         if not self.db.security.hasPermission('Edit', self.userid,
1384                 self.classname):
1385             return 0
1386         return 1
1389     def showAction(self, typere=re.compile('[@:]type'),
1390             numre=re.compile('[@:]number')):
1391         ''' Show a node of a particular class/id
1392         '''
1393         t = n = ''
1394         for key in self.form.keys():
1395             if typere.match(key):
1396                 t = self.form[key].value.strip()
1397             elif numre.match(key):
1398                 n = self.form[key].value.strip()
1399         if not t:
1400             raise ValueError, 'Invalid %s number'%t
1401         url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1402         raise Redirect, url
1404     def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1405         ''' Item properties and their values are edited with html FORM
1406             variables and their values. You can:
1408             - Change the value of some property of the current item.
1409             - Create a new item of any class, and edit the new item's
1410               properties,
1411             - Attach newly created items to a multilink property of the
1412               current item.
1413             - Remove items from a multilink property of the current item.
1414             - Specify that some properties are required for the edit
1415               operation to be successful.
1417             In the following, <bracketed> values are variable, "@" may be
1418             either ":" or "@", and other text "required" is fixed.
1420             Most properties are specified as form variables:
1422              <propname>
1423               - property on the current context item
1425              <designator>"@"<propname>
1426               - property on the indicated item (for editing related
1427                 information)
1429             Designators name a specific item of a class.
1431             <classname><N>
1433                 Name an existing item of class <classname>.
1435             <classname>"-"<N>
1437                 Name the <N>th new item of class <classname>. If the form
1438                 submission is successful, a new item of <classname> is
1439                 created. Within the submitted form, a particular
1440                 designator of this form always refers to the same new
1441                 item.
1443             Once we have determined the "propname", we look at it to see
1444             if it's special:
1446             @required
1447                 The associated form value is a comma-separated list of
1448                 property names that must be specified when the form is
1449                 submitted for the edit operation to succeed.  
1451                 When the <designator> is missing, the properties are
1452                 for the current context item.  When <designator> is
1453                 present, they are for the item specified by
1454                 <designator>.
1456                 The "@required" specifier must come before any of the
1457                 properties it refers to are assigned in the form.
1459             @remove@<propname>=id(s) or @add@<propname>=id(s)
1460                 The "@add@" and "@remove@" edit actions apply only to
1461                 Multilink properties.  The form value must be a
1462                 comma-separate list of keys for the class specified by
1463                 the simple form variable.  The listed items are added
1464                 to (respectively, removed from) the specified
1465                 property.
1467             @link@<propname>=<designator>
1468                 If the edit action is "@link@", the simple form
1469                 variable must specify a Link or Multilink property.
1470                 The form value is a comma-separated list of
1471                 designators.  The item corresponding to each
1472                 designator is linked to the property given by simple
1473                 form variable.  These are collected up and returned in
1474                 all_links.
1476             None of the above (ie. just a simple form value)
1477                 The value of the form variable is converted
1478                 appropriately, depending on the type of the property.
1480                 For a Link('klass') property, the form value is a
1481                 single key for 'klass', where the key field is
1482                 specified in dbinit.py.  
1484                 For a Multilink('klass') property, the form value is a
1485                 comma-separated list of keys for 'klass', where the
1486                 key field is specified in dbinit.py.  
1488                 Note that for simple-form-variables specifiying Link
1489                 and Multilink properties, the linked-to class must
1490                 have a key field.
1492                 For a String() property specifying a filename, the
1493                 file named by the form value is uploaded. This means we
1494                 try to set additional properties "filename" and "type" (if
1495                 they are valid for the class).  Otherwise, the property
1496                 is set to the form value.
1498                 For Date(), Interval(), Boolean(), and Number()
1499                 properties, the form value is converted to the
1500                 appropriate
1502             Any of the form variables may be prefixed with a classname or
1503             designator.
1505             Two special form values are supported for backwards
1506             compatibility:
1508             @note
1509                 This is equivalent to::
1511                     @link@messages=msg-1
1512                     @msg-1@content=value
1514                 except that in addition, the "author" and "date"
1515                 properties of "msg-1" are set to the userid of the
1516                 submitter, and the current time, respectively.
1518             @file
1519                 This is equivalent to::
1521                     @link@files=file-1
1522                     @file-1@content=value
1524                 The String content value is handled as described above for
1525                 file uploads.
1527             If both the "@note" and "@file" form variables are
1528             specified, the action::
1530                     @link@msg-1@files=file-1
1532             is also performed.
1534             We also check that FileClass items have a "content" property with
1535             actual content, otherwise we remove them from all_props before
1536             returning.
1538             The return from this method is a dict of 
1539                 (classname, id): properties
1540             ... this dict _always_ has an entry for the current context,
1541             even if it's empty (ie. a submission for an existing issue that
1542             doesn't result in any changes would return {('issue','123'): {}})
1543             The id may be None, which indicates that an item should be
1544             created.
1545         '''
1546         # some very useful variables
1547         db = self.db
1548         form = self.form
1550         if not hasattr(self, 'FV_SPECIAL'):
1551             # generate the regexp for handling special form values
1552             classes = '|'.join(db.classes.keys())
1553             # specials for parsePropsFromForm
1554             # handle the various forms (see unit tests)
1555             self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1556             self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1558         # these indicate the default class / item
1559         default_cn = self.classname
1560         default_cl = self.db.classes[default_cn]
1561         default_nodeid = self.nodeid
1563         # we'll store info about the individual class/item edit in these
1564         all_required = {}       # required props per class/item
1565         all_props = {}          # props to set per class/item
1566         got_props = {}          # props received per class/item
1567         all_propdef = {}        # note - only one entry per class
1568         all_links = []          # as many as are required
1570         # we should always return something, even empty, for the context
1571         all_props[(default_cn, default_nodeid)] = {}
1573         keys = form.keys()
1574         timezone = db.getUserTimezone()
1576         # sentinels for the :note and :file props
1577         have_note = have_file = 0
1579         # extract the usable form labels from the form
1580         matches = []
1581         for key in keys:
1582             m = self.FV_SPECIAL.match(key)
1583             if m:
1584                 matches.append((key, m.groupdict()))
1586         # now handle the matches
1587         for key, d in matches:
1588             if d['classname']:
1589                 # we got a designator
1590                 cn = d['classname']
1591                 cl = self.db.classes[cn]
1592                 nodeid = d['id']
1593                 propname = d['propname']
1594             elif d['note']:
1595                 # the special note field
1596                 cn = 'msg'
1597                 cl = self.db.classes[cn]
1598                 nodeid = '-1'
1599                 propname = 'content'
1600                 all_links.append((default_cn, default_nodeid, 'messages',
1601                     [('msg', '-1')]))
1602                 have_note = 1
1603             elif d['file']:
1604                 # the special file field
1605                 cn = 'file'
1606                 cl = self.db.classes[cn]
1607                 nodeid = '-1'
1608                 propname = 'content'
1609                 all_links.append((default_cn, default_nodeid, 'files',
1610                     [('file', '-1')]))
1611                 have_file = 1
1612             else:
1613                 # default
1614                 cn = default_cn
1615                 cl = default_cl
1616                 nodeid = default_nodeid
1617                 propname = d['propname']
1619             # the thing this value relates to is...
1620             this = (cn, nodeid)
1622             # get more info about the class, and the current set of
1623             # form props for it
1624             if not all_propdef.has_key(cn):
1625                 all_propdef[cn] = cl.getprops()
1626             propdef = all_propdef[cn]
1627             if not all_props.has_key(this):
1628                 all_props[this] = {}
1629             props = all_props[this]
1630             if not got_props.has_key(this):
1631                 got_props[this] = {}
1633             # is this a link command?
1634             if d['link']:
1635                 value = []
1636                 for entry in extractFormList(form[key]):
1637                     m = self.FV_DESIGNATOR.match(entry)
1638                     if not m:
1639                         raise ValueError, \
1640                             'link "%s" value "%s" not a designator'%(key, entry)
1641                     value.append((m.group(1), m.group(2)))
1643                 # make sure the link property is valid
1644                 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1645                         not isinstance(propdef[propname], hyperdb.Link)):
1646                     raise ValueError, '%s %s is not a link or '\
1647                         'multilink property'%(cn, propname)
1649                 all_links.append((cn, nodeid, propname, value))
1650                 continue
1652             # detect the special ":required" variable
1653             if d['required']:
1654                 all_required[this] = extractFormList(form[key])
1655                 continue
1657             # see if we're performing a special multilink action
1658             mlaction = 'set'
1659             if d['remove']:
1660                 mlaction = 'remove'
1661             elif d['add']:
1662                 mlaction = 'add'
1664             # does the property exist?
1665             if not propdef.has_key(propname):
1666                 if mlaction != 'set':
1667                     raise ValueError, 'You have submitted a %s action for'\
1668                         ' the property "%s" which doesn\'t exist'%(mlaction,
1669                         propname)
1670                 # the form element is probably just something we don't care
1671                 # about - ignore it
1672                 continue
1673             proptype = propdef[propname]
1675             # Get the form value. This value may be a MiniFieldStorage or a list
1676             # of MiniFieldStorages.
1677             value = form[key]
1679             # handle unpacking of the MiniFieldStorage / list form value
1680             if isinstance(proptype, hyperdb.Multilink):
1681                 value = extractFormList(value)
1682             else:
1683                 # multiple values are not OK
1684                 if isinstance(value, type([])):
1685                     raise ValueError, 'You have submitted more than one value'\
1686                         ' for the %s property'%propname
1687                 # value might be a file upload...
1688                 if not hasattr(value, 'filename') or value.filename is None:
1689                     # nope, pull out the value and strip it
1690                     value = value.value.strip()
1692             # now that we have the props field, we need a teensy little
1693             # extra bit of help for the old :note field...
1694             if d['note'] and value:
1695                 props['author'] = self.db.getuid()
1696                 props['date'] = date.Date()
1698             # handle by type now
1699             if isinstance(proptype, hyperdb.Password):
1700                 if not value:
1701                     # ignore empty password values
1702                     continue
1703                 for key, d in matches:
1704                     if d['confirm'] and d['propname'] == propname:
1705                         confirm = form[key]
1706                         break
1707                 else:
1708                     raise ValueError, 'Password and confirmation text do '\
1709                         'not match'
1710                 if isinstance(confirm, type([])):
1711                     raise ValueError, 'You have submitted more than one value'\
1712                         ' for the %s property'%propname
1713                 if value != confirm.value:
1714                     raise ValueError, 'Password and confirmation text do '\
1715                         'not match'
1716                 value = password.Password(value)
1718             elif isinstance(proptype, hyperdb.Link):
1719                 # see if it's the "no selection" choice
1720                 if value == '-1' or not value:
1721                     # if we're creating, just don't include this property
1722                     if not nodeid or nodeid.startswith('-'):
1723                         continue
1724                     value = None
1725                 else:
1726                     # handle key values
1727                     link = proptype.classname
1728                     if not num_re.match(value):
1729                         try:
1730                             value = db.classes[link].lookup(value)
1731                         except KeyError:
1732                             raise ValueError, _('property "%(propname)s": '
1733                                 '%(value)s not a %(classname)s')%{
1734                                 'propname': propname, 'value': value,
1735                                 'classname': link}
1736                         except TypeError, message:
1737                             raise ValueError, _('you may only enter ID values '
1738                                 'for property "%(propname)s": %(message)s')%{
1739                                 'propname': propname, 'message': message}
1740             elif isinstance(proptype, hyperdb.Multilink):
1741                 # perform link class key value lookup if necessary
1742                 link = proptype.classname
1743                 link_cl = db.classes[link]
1744                 l = []
1745                 for entry in value:
1746                     if not entry: continue
1747                     if not num_re.match(entry):
1748                         try:
1749                             entry = link_cl.lookup(entry)
1750                         except KeyError:
1751                             raise ValueError, _('property "%(propname)s": '
1752                                 '"%(value)s" not an entry of %(classname)s')%{
1753                                 'propname': propname, 'value': entry,
1754                                 'classname': link}
1755                         except TypeError, message:
1756                             raise ValueError, _('you may only enter ID values '
1757                                 'for property "%(propname)s": %(message)s')%{
1758                                 'propname': propname, 'message': message}
1759                     l.append(entry)
1760                 l.sort()
1762                 # now use that list of ids to modify the multilink
1763                 if mlaction == 'set':
1764                     value = l
1765                 else:
1766                     # we're modifying the list - get the current list of ids
1767                     if props.has_key(propname):
1768                         existing = props[propname]
1769                     elif nodeid and not nodeid.startswith('-'):
1770                         existing = cl.get(nodeid, propname, [])
1771                     else:
1772                         existing = []
1774                     # now either remove or add
1775                     if mlaction == 'remove':
1776                         # remove - handle situation where the id isn't in
1777                         # the list
1778                         for entry in l:
1779                             try:
1780                                 existing.remove(entry)
1781                             except ValueError:
1782                                 raise ValueError, _('property "%(propname)s": '
1783                                     '"%(value)s" not currently in list')%{
1784                                     'propname': propname, 'value': entry}
1785                     else:
1786                         # add - easy, just don't dupe
1787                         for entry in l:
1788                             if entry not in existing:
1789                                 existing.append(entry)
1790                     value = existing
1791                     value.sort()
1793             elif value == '':
1794                 # if we're creating, just don't include this property
1795                 if not nodeid or nodeid.startswith('-'):
1796                     continue
1797                 # other types should be None'd if there's no value
1798                 value = None
1799             else:
1800                 # handle ValueErrors for all these in a similar fashion
1801                 try:
1802                     if isinstance(proptype, hyperdb.String):
1803                         if (hasattr(value, 'filename') and
1804                                 value.filename is not None):
1805                             # skip if the upload is empty
1806                             if not value.filename:
1807                                 continue
1808                             # this String is actually a _file_
1809                             # try to determine the file content-type
1810                             fn = value.filename.split('\\')[-1]
1811                             if propdef.has_key('name'):
1812                                 props['name'] = fn
1813                             # use this info as the type/filename properties
1814                             if propdef.has_key('type'):
1815                                 props['type'] = mimetypes.guess_type(fn)[0]
1816                                 if not props['type']:
1817                                     props['type'] = "application/octet-stream"
1818                             # finally, read the content
1819                             value = value.value
1820                         else:
1821                             # normal String fix the CRLF/CR -> LF stuff
1822                             value = fixNewlines(value)
1824                     elif isinstance(proptype, hyperdb.Date):
1825                         value = date.Date(value, offset=timezone)
1826                     elif isinstance(proptype, hyperdb.Interval):
1827                         value = date.Interval(value)
1828                     elif isinstance(proptype, hyperdb.Boolean):
1829                         value = value.lower() in ('yes', 'true', 'on', '1')
1830                     elif isinstance(proptype, hyperdb.Number):
1831                         value = float(value)
1832                 except ValueError, msg:
1833                     raise ValueError, _('Error with %s property: %s')%(
1834                         propname, msg)
1836             # register that we got this property
1837             if value:
1838                 got_props[this][propname] = 1
1840             # get the old value
1841             if nodeid and not nodeid.startswith('-'):
1842                 try:
1843                     existing = cl.get(nodeid, propname)
1844                 except KeyError:
1845                     # this might be a new property for which there is
1846                     # no existing value
1847                     if not propdef.has_key(propname):
1848                         raise
1850                 # make sure the existing multilink is sorted
1851                 if isinstance(proptype, hyperdb.Multilink):
1852                     existing.sort()
1854                 # "missing" existing values may not be None
1855                 if not existing:
1856                     if isinstance(proptype, hyperdb.String) and not existing:
1857                         # some backends store "missing" Strings as empty strings
1858                         existing = None
1859                     elif isinstance(proptype, hyperdb.Number) and not existing:
1860                         # some backends store "missing" Numbers as 0 :(
1861                         existing = 0
1862                     elif isinstance(proptype, hyperdb.Boolean) and not existing:
1863                         # likewise Booleans
1864                         existing = 0
1866                 # if changed, set it
1867                 if value != existing:
1868                     props[propname] = value
1869             else:
1870                 # don't bother setting empty/unset values
1871                 if value is None:
1872                     continue
1873                 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1874                     continue
1875                 elif isinstance(proptype, hyperdb.String) and value == '':
1876                     continue
1878                 props[propname] = value
1880         # check to see if we need to specially link a file to the note
1881         if have_note and have_file:
1882             all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1884         # see if all the required properties have been supplied
1885         s = []
1886         for thing, required in all_required.items():
1887             # register the values we got
1888             got = got_props.get(thing, {})
1889             for entry in required[:]:
1890                 if got.has_key(entry):
1891                     required.remove(entry)
1893             # any required values not present?
1894             if not required:
1895                 continue
1897             # tell the user to entry the values required
1898             if len(required) > 1:
1899                 p = 'properties'
1900             else:
1901                 p = 'property'
1902             s.append('Required %s %s %s not supplied'%(thing[0], p,
1903                 ', '.join(required)))
1904         if s:
1905             raise ValueError, '\n'.join(s)
1907         # When creating a FileClass node, it should have a non-empty content
1908         # property to be created. When editing a FileClass node, it should
1909         # either have a non-empty content property or no property at all. In
1910         # the latter case, nothing will change.
1911         for (cn, id), props in all_props.items():
1912             if isinstance(self.db.classes[cn], hyperdb.FileClass):
1913                 if id == '-1':
1914                       if not props.get('content', ''):
1915                             del all_props[(cn, id)]
1916                 elif props.has_key('content') and not props['content']:
1917                       raise ValueError, _('File is empty')
1918         return all_props, all_links
1920 def fixNewlines(text):
1921     ''' Homogenise line endings.
1923         Different web clients send different line ending values, but
1924         other systems (eg. email) don't necessarily handle those line
1925         endings. Our solution is to convert all line endings to LF.
1926     '''
1927     text = text.replace('\r\n', '\n')
1928     return text.replace('\r', '\n')
1930 def extractFormList(value):
1931     ''' Extract a list of values from the form value.
1933         It may be one of:
1934          [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
1935          MiniFieldStorage('value,value,...')
1936          MiniFieldStorage('value')
1937     '''
1938     # multiple values are OK
1939     if isinstance(value, type([])):
1940         # it's a list of MiniFieldStorages - join then into
1941         values = ','.join([i.value.strip() for i in value])
1942     else:
1943         # it's a MiniFieldStorage, but may be a comma-separated list
1944         # of values
1945         values = value.value
1947     value = [i.strip() for i in values.split(',')]
1949     # filter out the empty bits
1950     return filter(None, value)