Code

Extracted duplicated mail-sending code from mailgw, roundupdb and client.py to
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.136 2003-09-08 09:28:28 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         subject = 'Complete your registration to %s'%tracker_name
770         body = '''
771 To complete your registration of the user "%(name)s" with %(tracker)s,
772 please visit the following URL:
774    %(url)s?@action=confrego&otk=%(otk)s
775 '''%{'name': props['username'], 'tracker': tracker_name, 'url': self.base,
776                 'otk': otk}
777         if not self.standard_message(props['address'], subject, body):
778             return
780         # commit changes to the database
781         self.db.commit()
783         # redirect to the "you're almost there" page
784         raise Redirect, '%suser?@template=rego_progress'%self.base
786     def standard_message(self, to, subject, body):
787         try:
788             self.mailer.standard_message(to, subject, body)
789             return 1
790         except MessageSendException, e:
791             self.error_message.append(str(e))
792             
794     def registerPermission(self, props):
795         ''' Determine whether the user has permission to register
797             Base behaviour is to check the user has "Web Registration".
798         '''
799         # registration isn't allowed to supply roles
800         if props.has_key('roles'):
801             return 0
802         if self.db.security.hasPermission('Web Registration', self.userid):
803             return 1
804         return 0
806     def confRegoAction(self):
807         ''' Grab the OTK, use it to load up the new user details
808         '''
809         try:
810             # pull the rego information out of the otk database
811             self.userid = self.db.confirm_registration(self.form['otk'].value)
812         except (ValueError, KeyError), message:
813             # XXX: we need to make the "default" page be able to display errors!
814             self.error_message.append(str(message))
815             return
816         
817         # log the new user in
818         self.user = self.db.user.get(self.userid, 'username')
819         # re-open the database for real, using the user
820         self.opendb(self.user)
822         # if we have a session, update it
823         if hasattr(self, 'session'):
824             self.db.sessions.set(self.session, user=self.user,
825                 last_use=time.time())
826         else:
827             # new session cookie
828             self.set_cookie(self.user)
830         # nice message
831         message = _('You are now registered, welcome!')
833         # redirect to the user's page
834         raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
835             self.userid, urllib.quote(message))
837     def passResetAction(self):
838         ''' Handle password reset requests.
840             Presence of either "name" or "address" generate email.
841             Presense of "otk" performs the reset.
842         '''
843         if self.form.has_key('otk'):
844             # pull the rego information out of the otk database
845             otk = self.form['otk'].value
846             uid = self.db.otks.get(otk, 'uid')
847             if uid is None:
848                 self.error_message.append('Invalid One Time Key!')
849                 return
851             # re-open the database as "admin"
852             if self.user != 'admin':
853                 self.opendb('admin')
855             # change the password
856             newpw = password.generatePassword()
858             cl = self.db.user
859 # XXX we need to make the "default" page be able to display errors!
860             try:
861                 # set the password
862                 cl.set(uid, password=password.Password(newpw))
863                 # clear the props from the otk database
864                 self.db.otks.destroy(otk)
865                 self.db.commit()
866             except (ValueError, KeyError), message:
867                 self.error_message.append(str(message))
868                 return
870             # user info
871             address = self.db.user.get(uid, 'address')
872             name = self.db.user.get(uid, 'username')
874             # send the email
875             tracker_name = self.db.config.TRACKER_NAME
876             subject = 'Password reset for %s'%tracker_name
877             body = '''
878 The password has been reset for username "%(name)s".
880 Your password is now: %(password)s
881 '''%{'name': name, 'password': newpw}
882             if not self.standard_message(address, subject, body):
883                 return
885             self.ok_message.append('Password reset and email sent to %s'%address)
886             return
888         # no OTK, so now figure the user
889         if self.form.has_key('username'):
890             name = self.form['username'].value
891             try:
892                 uid = self.db.user.lookup(name)
893             except KeyError:
894                 self.error_message.append('Unknown username')
895                 return
896             address = self.db.user.get(uid, 'address')
897         elif self.form.has_key('address'):
898             address = self.form['address'].value
899             uid = uidFromAddress(self.db, ('', address), create=0)
900             if not uid:
901                 self.error_message.append('Unknown email address')
902                 return
903             name = self.db.user.get(uid, 'username')
904         else:
905             self.error_message.append('You need to specify a username '
906                 'or address')
907             return
909         # generate the one-time-key and store the props for later
910         otk = ''.join([random.choice(chars) for x in range(32)])
911         self.db.otks.set(otk, uid=uid, __time=time.time())
913         # send the email
914         tracker_name = self.db.config.TRACKER_NAME
915         subject = 'Confirm reset of password for %s'%tracker_name
916         body = '''
917 Someone, perhaps you, has requested that the password be changed for your
918 username, "%(name)s". If you wish to proceed with the change, please follow
919 the link below:
921   %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
923 You should then receive another email with the new password.
924 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
925         if not self.standard_message(address, subject, body):
926             return
928         self.ok_message.append('Email sent to %s'%address)
930     def editItemAction(self):
931         ''' Perform an edit of an item in the database.
933            See parsePropsFromForm and _editnodes for special variables
934         '''
935         # parse the props from the form
936         try:
937             props, links = self.parsePropsFromForm()
938         except (ValueError, KeyError), message:
939             self.error_message.append(_('Parse Error: ') + str(message))
940             return
942         # handle the props
943         try:
944             message = self._editnodes(props, links)
945         except (ValueError, KeyError, IndexError), message:
946             self.error_message.append(_('Apply Error: ') + str(message))
947             return
949         # commit now that all the tricky stuff is done
950         self.db.commit()
952         # redirect to the item's edit page
953         raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
954             self.classname, self.nodeid, urllib.quote(message),
955             urllib.quote(self.template))
957     def editItemPermission(self, props):
958         ''' Determine whether the user has permission to edit this item.
960             Base behaviour is to check the user can edit this class. If we're
961             editing the "user" class, users are allowed to edit their own
962             details. Unless it's the "roles" property, which requires the
963             special Permission "Web Roles".
964         '''
965         # if this is a user node and the user is editing their own node, then
966         # we're OK
967         has = self.db.security.hasPermission
968         if self.classname == 'user':
969             # reject if someone's trying to edit "roles" and doesn't have the
970             # right permission.
971             if props.has_key('roles') and not has('Web Roles', self.userid,
972                     'user'):
973                 return 0
974             # if the item being edited is the current user, we're ok
975             if self.nodeid == self.userid:
976                 return 1
977         if self.db.security.hasPermission('Edit', self.userid, self.classname):
978             return 1
979         return 0
981     def newItemAction(self):
982         ''' Add a new item to the database.
984             This follows the same form as the editItemAction, with the same
985             special form values.
986         '''
987         # parse the props from the form
988         try:
989             props, links = self.parsePropsFromForm()
990         except (ValueError, KeyError), message:
991             self.error_message.append(_('Error: ') + str(message))
992             return
994         # handle the props - edit or create
995         try:
996             # when it hits the None element, it'll set self.nodeid
997             messages = self._editnodes(props, links)
999         except (ValueError, KeyError, IndexError), message:
1000             # these errors might just be indicative of user dumbness
1001             self.error_message.append(_('Error: ') + str(message))
1002             return
1004         # commit now that all the tricky stuff is done
1005         self.db.commit()
1007         # redirect to the new item's page
1008         raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
1009             self.classname, self.nodeid, urllib.quote(messages),
1010             urllib.quote(self.template))
1012     def newItemPermission(self, props):
1013         ''' Determine whether the user has permission to create (edit) this
1014             item.
1016             Base behaviour is to check the user can edit this class. No
1017             additional property checks are made. Additionally, new user items
1018             may be created if the user has the "Web Registration" Permission.
1019         '''
1020         has = self.db.security.hasPermission
1021         if self.classname == 'user' and has('Web Registration', self.userid,
1022                 'user'):
1023             return 1
1024         if has('Edit', self.userid, self.classname):
1025             return 1
1026         return 0
1029     #
1030     #  Utility methods for editing
1031     #
1032     def _editnodes(self, all_props, all_links, newids=None):
1033         ''' Use the props in all_props to perform edit and creation, then
1034             use the link specs in all_links to do linking.
1035         '''
1036         # figure dependencies and re-work links
1037         deps = {}
1038         links = {}
1039         for cn, nodeid, propname, vlist in all_links:
1040             if not all_props.has_key((cn, nodeid)):
1041                 # link item to link to doesn't (and won't) exist
1042                 continue
1043             for value in vlist:
1044                 if not all_props.has_key(value):
1045                     # link item to link to doesn't (and won't) exist
1046                     continue
1047                 deps.setdefault((cn, nodeid), []).append(value)
1048                 links.setdefault(value, []).append((cn, nodeid, propname))
1050         # figure chained dependencies ordering
1051         order = []
1052         done = {}
1053         # loop detection
1054         change = 0
1055         while len(all_props) != len(done):
1056             for needed in all_props.keys():
1057                 if done.has_key(needed):
1058                     continue
1059                 tlist = deps.get(needed, [])
1060                 for target in tlist:
1061                     if not done.has_key(target):
1062                         break
1063                 else:
1064                     done[needed] = 1
1065                     order.append(needed)
1066                     change = 1
1067             if not change:
1068                 raise ValueError, 'linking must not loop!'
1070         # now, edit / create
1071         m = []
1072         for needed in order:
1073             props = all_props[needed]
1074             if not props:
1075                 # nothing to do
1076                 continue
1077             cn, nodeid = needed
1079             if nodeid is not None and int(nodeid) > 0:
1080                 # make changes to the node
1081                 props = self._changenode(cn, nodeid, props)
1083                 # and some nice feedback for the user
1084                 if props:
1085                     info = ', '.join(props.keys())
1086                     m.append('%s %s %s edited ok'%(cn, nodeid, info))
1087                 else:
1088                     m.append('%s %s - nothing changed'%(cn, nodeid))
1089             else:
1090                 assert props
1092                 # make a new node
1093                 newid = self._createnode(cn, props)
1094                 if nodeid is None:
1095                     self.nodeid = newid
1096                 nodeid = newid
1098                 # and some nice feedback for the user
1099                 m.append('%s %s created'%(cn, newid))
1101             # fill in new ids in links
1102             if links.has_key(needed):
1103                 for linkcn, linkid, linkprop in links[needed]:
1104                     props = all_props[(linkcn, linkid)]
1105                     cl = self.db.classes[linkcn]
1106                     propdef = cl.getprops()[linkprop]
1107                     if not props.has_key(linkprop):
1108                         if linkid is None or linkid.startswith('-'):
1109                             # linking to a new item
1110                             if isinstance(propdef, hyperdb.Multilink):
1111                                 props[linkprop] = [newid]
1112                             else:
1113                                 props[linkprop] = newid
1114                         else:
1115                             # linking to an existing item
1116                             if isinstance(propdef, hyperdb.Multilink):
1117                                 existing = cl.get(linkid, linkprop)[:]
1118                                 existing.append(nodeid)
1119                                 props[linkprop] = existing
1120                             else:
1121                                 props[linkprop] = newid
1123         return '<br>'.join(m)
1125     def _changenode(self, cn, nodeid, props):
1126         ''' change the node based on the contents of the form
1127         '''
1128         # check for permission
1129         if not self.editItemPermission(props):
1130             raise Unauthorised, 'You do not have permission to edit %s'%cn
1132         # make the changes
1133         cl = self.db.classes[cn]
1134         return cl.set(nodeid, **props)
1136     def _createnode(self, cn, props):
1137         ''' create a node based on the contents of the form
1138         '''
1139         # check for permission
1140         if not self.newItemPermission(props):
1141             raise Unauthorised, 'You do not have permission to create %s'%cn
1143         # create the node and return its id
1144         cl = self.db.classes[cn]
1145         return cl.create(**props)
1147     # 
1148     # More actions
1149     #
1150     def editCSVAction(self):
1151         ''' Performs an edit of all of a class' items in one go.
1153             The "rows" CGI var defines the CSV-formatted entries for the
1154             class. New nodes are identified by the ID 'X' (or any other
1155             non-existent ID) and removed lines are retired.
1156         '''
1157         # this is per-class only
1158         if not self.editCSVPermission():
1159             self.error_message.append(
1160                 _('You do not have permission to edit %s' %self.classname))
1162         # get the CSV module
1163         if rcsv.error:
1164             self.error_message.append(_(rcsv.error))
1165             return
1167         cl = self.db.classes[self.classname]
1168         idlessprops = cl.getprops(protected=0).keys()
1169         idlessprops.sort()
1170         props = ['id'] + idlessprops
1172         # do the edit
1173         rows = StringIO.StringIO(self.form['rows'].value)
1174         reader = rcsv.reader(rows, rcsv.comma_separated)
1175         found = {}
1176         line = 0
1177         for values in reader:
1178             line += 1
1179             if line == 1: continue
1180             # skip property names header
1181             if values == props:
1182                 continue
1184             # extract the nodeid
1185             nodeid, values = values[0], values[1:]
1186             found[nodeid] = 1
1188             # see if the node exists
1189             if nodeid in ('x', 'X') or not cl.hasnode(nodeid):
1190                 exists = 0
1191             else:
1192                 exists = 1
1194             # confirm correct weight
1195             if len(idlessprops) != len(values):
1196                 self.error_message.append(
1197                     _('Not enough values on line %(line)s')%{'line':line})
1198                 return
1200             # extract the new values
1201             d = {}
1202             for name, value in zip(idlessprops, values):
1203                 prop = cl.properties[name]
1204                 value = value.strip()
1205                 # only add the property if it has a value
1206                 if value:
1207                     # if it's a multilink, split it
1208                     if isinstance(prop, hyperdb.Multilink):
1209                         value = value.split(':')
1210                     elif isinstance(prop, hyperdb.Password):
1211                         value = password.Password(value)
1212                     elif isinstance(prop, hyperdb.Interval):
1213                         value = date.Interval(value)
1214                     elif isinstance(prop, hyperdb.Date):
1215                         value = date.Date(value)
1216                     elif isinstance(prop, hyperdb.Boolean):
1217                         value = value.lower() in ('yes', 'true', 'on', '1')
1218                     elif isinstance(prop, hyperdb.Number):
1219                         value = float(value)
1220                     d[name] = value
1221                 elif exists:
1222                     # nuke the existing value
1223                     if isinstance(prop, hyperdb.Multilink):
1224                         d[name] = []
1225                     else:
1226                         d[name] = None
1228             # perform the edit
1229             if exists:
1230                 # edit existing
1231                 cl.set(nodeid, **d)
1232             else:
1233                 # new node
1234                 found[cl.create(**d)] = 1
1236         # retire the removed entries
1237         for nodeid in cl.list():
1238             if not found.has_key(nodeid):
1239                 cl.retire(nodeid)
1241         # all OK
1242         self.db.commit()
1244         self.ok_message.append(_('Items edited OK'))
1246     def editCSVPermission(self):
1247         ''' Determine whether the user has permission to edit this class.
1249             Base behaviour is to check the user can edit this class.
1250         ''' 
1251         if not self.db.security.hasPermission('Edit', self.userid,
1252                 self.classname):
1253             return 0
1254         return 1
1256     def searchAction(self, wcre=re.compile(r'[\s,]+')):
1257         ''' Mangle some of the form variables.
1259             Set the form ":filter" variable based on the values of the
1260             filter variables - if they're set to anything other than
1261             "dontcare" then add them to :filter.
1263             Handle the ":queryname" variable and save off the query to
1264             the user's query list.
1266             Split any String query values on whitespace and comma.
1267         '''
1268         # generic edit is per-class only
1269         if not self.searchPermission():
1270             self.error_message.append(
1271                 _('You do not have permission to search %s' %self.classname))
1273         # add a faked :filter form variable for each filtering prop
1274         props = self.db.classes[self.classname].getprops()
1275         queryname = ''
1276         for key in self.form.keys():
1277             # special vars
1278             if self.FV_QUERYNAME.match(key):
1279                 queryname = self.form[key].value.strip()
1280                 continue
1282             if not props.has_key(key):
1283                 continue
1284             if isinstance(self.form[key], type([])):
1285                 # search for at least one entry which is not empty
1286                 for minifield in self.form[key]:
1287                     if minifield.value:
1288                         break
1289                 else:
1290                     continue
1291             else:
1292                 if not self.form[key].value:
1293                     continue
1294                 if isinstance(props[key], hyperdb.String):
1295                     v = self.form[key].value
1296                     l = token.token_split(v)
1297                     if len(l) > 1 or l[0] != v:
1298                         self.form.value.remove(self.form[key])
1299                         # replace the single value with the split list
1300                         for v in l:
1301                             self.form.value.append(cgi.MiniFieldStorage(key, v))
1303             self.form.value.append(cgi.MiniFieldStorage('@filter', key))
1305         # handle saving the query params
1306         if queryname:
1307             # parse the environment and figure what the query _is_
1308             req = HTMLRequest(self)
1310             # The [1:] strips off the '?' character, it isn't part of the
1311             # query string.
1312             url = req.indexargs_href('', {})[1:]
1314             # handle editing an existing query
1315             try:
1316                 qid = self.db.query.lookup(queryname)
1317                 self.db.query.set(qid, klass=self.classname, url=url)
1318             except KeyError:
1319                 # create a query
1320                 qid = self.db.query.create(name=queryname,
1321                     klass=self.classname, url=url)
1323                 # and add it to the user's query multilink
1324                 queries = self.db.user.get(self.userid, 'queries')
1325                 queries.append(qid)
1326                 self.db.user.set(self.userid, queries=queries)
1328             # commit the query change to the database
1329             self.db.commit()
1331     def searchPermission(self):
1332         ''' Determine whether the user has permission to search this class.
1334             Base behaviour is to check the user can view this class.
1335         ''' 
1336         if not self.db.security.hasPermission('View', self.userid,
1337                 self.classname):
1338             return 0
1339         return 1
1342     def retireAction(self):
1343         ''' Retire the context item.
1344         '''
1345         # if we want to view the index template now, then unset the nodeid
1346         # context info (a special-case for retire actions on the index page)
1347         nodeid = self.nodeid
1348         if self.template == 'index':
1349             self.nodeid = None
1351         # generic edit is per-class only
1352         if not self.retirePermission():
1353             self.error_message.append(
1354                 _('You do not have permission to retire %s' %self.classname))
1355             return
1357         # make sure we don't try to retire admin or anonymous
1358         if self.classname == 'user' and \
1359                 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1360             self.error_message.append(
1361                 _('You may not retire the admin or anonymous user'))
1362             return
1364         # do the retire
1365         self.db.getclass(self.classname).retire(nodeid)
1366         self.db.commit()
1368         self.ok_message.append(
1369             _('%(classname)s %(itemid)s has been retired')%{
1370                 'classname': self.classname.capitalize(), 'itemid': nodeid})
1372     def retirePermission(self):
1373         ''' Determine whether the user has permission to retire this class.
1375             Base behaviour is to check the user can edit this class.
1376         ''' 
1377         if not self.db.security.hasPermission('Edit', self.userid,
1378                 self.classname):
1379             return 0
1380         return 1
1383     def showAction(self, typere=re.compile('[@:]type'),
1384             numre=re.compile('[@:]number')):
1385         ''' Show a node of a particular class/id
1386         '''
1387         t = n = ''
1388         for key in self.form.keys():
1389             if typere.match(key):
1390                 t = self.form[key].value.strip()
1391             elif numre.match(key):
1392                 n = self.form[key].value.strip()
1393         if not t:
1394             raise ValueError, 'Invalid %s number'%t
1395         url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1396         raise Redirect, url
1398     def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1399         ''' Item properties and their values are edited with html FORM
1400             variables and their values. You can:
1402             - Change the value of some property of the current item.
1403             - Create a new item of any class, and edit the new item's
1404               properties,
1405             - Attach newly created items to a multilink property of the
1406               current item.
1407             - Remove items from a multilink property of the current item.
1408             - Specify that some properties are required for the edit
1409               operation to be successful.
1411             In the following, <bracketed> values are variable, "@" may be
1412             either ":" or "@", and other text "required" is fixed.
1414             Most properties are specified as form variables:
1416              <propname>
1417               - property on the current context item
1419              <designator>"@"<propname>
1420               - property on the indicated item (for editing related
1421                 information)
1423             Designators name a specific item of a class.
1425             <classname><N>
1427                 Name an existing item of class <classname>.
1429             <classname>"-"<N>
1431                 Name the <N>th new item of class <classname>. If the form
1432                 submission is successful, a new item of <classname> is
1433                 created. Within the submitted form, a particular
1434                 designator of this form always refers to the same new
1435                 item.
1437             Once we have determined the "propname", we look at it to see
1438             if it's special:
1440             @required
1441                 The associated form value is a comma-separated list of
1442                 property names that must be specified when the form is
1443                 submitted for the edit operation to succeed.  
1445                 When the <designator> is missing, the properties are
1446                 for the current context item.  When <designator> is
1447                 present, they are for the item specified by
1448                 <designator>.
1450                 The "@required" specifier must come before any of the
1451                 properties it refers to are assigned in the form.
1453             @remove@<propname>=id(s) or @add@<propname>=id(s)
1454                 The "@add@" and "@remove@" edit actions apply only to
1455                 Multilink properties.  The form value must be a
1456                 comma-separate list of keys for the class specified by
1457                 the simple form variable.  The listed items are added
1458                 to (respectively, removed from) the specified
1459                 property.
1461             @link@<propname>=<designator>
1462                 If the edit action is "@link@", the simple form
1463                 variable must specify a Link or Multilink property.
1464                 The form value is a comma-separated list of
1465                 designators.  The item corresponding to each
1466                 designator is linked to the property given by simple
1467                 form variable.  These are collected up and returned in
1468                 all_links.
1470             None of the above (ie. just a simple form value)
1471                 The value of the form variable is converted
1472                 appropriately, depending on the type of the property.
1474                 For a Link('klass') property, the form value is a
1475                 single key for 'klass', where the key field is
1476                 specified in dbinit.py.  
1478                 For a Multilink('klass') property, the form value is a
1479                 comma-separated list of keys for 'klass', where the
1480                 key field is specified in dbinit.py.  
1482                 Note that for simple-form-variables specifiying Link
1483                 and Multilink properties, the linked-to class must
1484                 have a key field.
1486                 For a String() property specifying a filename, the
1487                 file named by the form value is uploaded. This means we
1488                 try to set additional properties "filename" and "type" (if
1489                 they are valid for the class).  Otherwise, the property
1490                 is set to the form value.
1492                 For Date(), Interval(), Boolean(), and Number()
1493                 properties, the form value is converted to the
1494                 appropriate
1496             Any of the form variables may be prefixed with a classname or
1497             designator.
1499             Two special form values are supported for backwards
1500             compatibility:
1502             @note
1503                 This is equivalent to::
1505                     @link@messages=msg-1
1506                     @msg-1@content=value
1508                 except that in addition, the "author" and "date"
1509                 properties of "msg-1" are set to the userid of the
1510                 submitter, and the current time, respectively.
1512             @file
1513                 This is equivalent to::
1515                     @link@files=file-1
1516                     @file-1@content=value
1518                 The String content value is handled as described above for
1519                 file uploads.
1521             If both the "@note" and "@file" form variables are
1522             specified, the action::
1524                     @link@msg-1@files=file-1
1526             is also performed.
1528             We also check that FileClass items have a "content" property with
1529             actual content, otherwise we remove them from all_props before
1530             returning.
1532             The return from this method is a dict of 
1533                 (classname, id): properties
1534             ... this dict _always_ has an entry for the current context,
1535             even if it's empty (ie. a submission for an existing issue that
1536             doesn't result in any changes would return {('issue','123'): {}})
1537             The id may be None, which indicates that an item should be
1538             created.
1539         '''
1540         # some very useful variables
1541         db = self.db
1542         form = self.form
1544         if not hasattr(self, 'FV_SPECIAL'):
1545             # generate the regexp for handling special form values
1546             classes = '|'.join(db.classes.keys())
1547             # specials for parsePropsFromForm
1548             # handle the various forms (see unit tests)
1549             self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1550             self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1552         # these indicate the default class / item
1553         default_cn = self.classname
1554         default_cl = self.db.classes[default_cn]
1555         default_nodeid = self.nodeid
1557         # we'll store info about the individual class/item edit in these
1558         all_required = {}       # required props per class/item
1559         all_props = {}          # props to set per class/item
1560         got_props = {}          # props received per class/item
1561         all_propdef = {}        # note - only one entry per class
1562         all_links = []          # as many as are required
1564         # we should always return something, even empty, for the context
1565         all_props[(default_cn, default_nodeid)] = {}
1567         keys = form.keys()
1568         timezone = db.getUserTimezone()
1570         # sentinels for the :note and :file props
1571         have_note = have_file = 0
1573         # extract the usable form labels from the form
1574         matches = []
1575         for key in keys:
1576             m = self.FV_SPECIAL.match(key)
1577             if m:
1578                 matches.append((key, m.groupdict()))
1580         # now handle the matches
1581         for key, d in matches:
1582             if d['classname']:
1583                 # we got a designator
1584                 cn = d['classname']
1585                 cl = self.db.classes[cn]
1586                 nodeid = d['id']
1587                 propname = d['propname']
1588             elif d['note']:
1589                 # the special note field
1590                 cn = 'msg'
1591                 cl = self.db.classes[cn]
1592                 nodeid = '-1'
1593                 propname = 'content'
1594                 all_links.append((default_cn, default_nodeid, 'messages',
1595                     [('msg', '-1')]))
1596                 have_note = 1
1597             elif d['file']:
1598                 # the special file field
1599                 cn = 'file'
1600                 cl = self.db.classes[cn]
1601                 nodeid = '-1'
1602                 propname = 'content'
1603                 all_links.append((default_cn, default_nodeid, 'files',
1604                     [('file', '-1')]))
1605                 have_file = 1
1606             else:
1607                 # default
1608                 cn = default_cn
1609                 cl = default_cl
1610                 nodeid = default_nodeid
1611                 propname = d['propname']
1613             # the thing this value relates to is...
1614             this = (cn, nodeid)
1616             # get more info about the class, and the current set of
1617             # form props for it
1618             if not all_propdef.has_key(cn):
1619                 all_propdef[cn] = cl.getprops()
1620             propdef = all_propdef[cn]
1621             if not all_props.has_key(this):
1622                 all_props[this] = {}
1623             props = all_props[this]
1624             if not got_props.has_key(this):
1625                 got_props[this] = {}
1627             # is this a link command?
1628             if d['link']:
1629                 value = []
1630                 for entry in extractFormList(form[key]):
1631                     m = self.FV_DESIGNATOR.match(entry)
1632                     if not m:
1633                         raise ValueError, \
1634                             'link "%s" value "%s" not a designator'%(key, entry)
1635                     value.append((m.group(1), m.group(2)))
1637                 # make sure the link property is valid
1638                 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1639                         not isinstance(propdef[propname], hyperdb.Link)):
1640                     raise ValueError, '%s %s is not a link or '\
1641                         'multilink property'%(cn, propname)
1643                 all_links.append((cn, nodeid, propname, value))
1644                 continue
1646             # detect the special ":required" variable
1647             if d['required']:
1648                 all_required[this] = extractFormList(form[key])
1649                 continue
1651             # see if we're performing a special multilink action
1652             mlaction = 'set'
1653             if d['remove']:
1654                 mlaction = 'remove'
1655             elif d['add']:
1656                 mlaction = 'add'
1658             # does the property exist?
1659             if not propdef.has_key(propname):
1660                 if mlaction != 'set':
1661                     raise ValueError, 'You have submitted a %s action for'\
1662                         ' the property "%s" which doesn\'t exist'%(mlaction,
1663                         propname)
1664                 # the form element is probably just something we don't care
1665                 # about - ignore it
1666                 continue
1667             proptype = propdef[propname]
1669             # Get the form value. This value may be a MiniFieldStorage or a list
1670             # of MiniFieldStorages.
1671             value = form[key]
1673             # handle unpacking of the MiniFieldStorage / list form value
1674             if isinstance(proptype, hyperdb.Multilink):
1675                 value = extractFormList(value)
1676             else:
1677                 # multiple values are not OK
1678                 if isinstance(value, type([])):
1679                     raise ValueError, 'You have submitted more than one value'\
1680                         ' for the %s property'%propname
1681                 # value might be a file upload...
1682                 if not hasattr(value, 'filename') or value.filename is None:
1683                     # nope, pull out the value and strip it
1684                     value = value.value.strip()
1686             # now that we have the props field, we need a teensy little
1687             # extra bit of help for the old :note field...
1688             if d['note'] and value:
1689                 props['author'] = self.db.getuid()
1690                 props['date'] = date.Date()
1692             # handle by type now
1693             if isinstance(proptype, hyperdb.Password):
1694                 if not value:
1695                     # ignore empty password values
1696                     continue
1697                 for key, d in matches:
1698                     if d['confirm'] and d['propname'] == propname:
1699                         confirm = form[key]
1700                         break
1701                 else:
1702                     raise ValueError, 'Password and confirmation text do '\
1703                         'not match'
1704                 if isinstance(confirm, type([])):
1705                     raise ValueError, 'You have submitted more than one value'\
1706                         ' for the %s property'%propname
1707                 if value != confirm.value:
1708                     raise ValueError, 'Password and confirmation text do '\
1709                         'not match'
1710                 value = password.Password(value)
1712             elif isinstance(proptype, hyperdb.Link):
1713                 # see if it's the "no selection" choice
1714                 if value == '-1' or not value:
1715                     # if we're creating, just don't include this property
1716                     if not nodeid or nodeid.startswith('-'):
1717                         continue
1718                     value = None
1719                 else:
1720                     # handle key values
1721                     link = proptype.classname
1722                     if not num_re.match(value):
1723                         try:
1724                             value = db.classes[link].lookup(value)
1725                         except KeyError:
1726                             raise ValueError, _('property "%(propname)s": '
1727                                 '%(value)s not a %(classname)s')%{
1728                                 'propname': propname, 'value': value,
1729                                 'classname': link}
1730                         except TypeError, message:
1731                             raise ValueError, _('you may only enter ID values '
1732                                 'for property "%(propname)s": %(message)s')%{
1733                                 'propname': propname, 'message': message}
1734             elif isinstance(proptype, hyperdb.Multilink):
1735                 # perform link class key value lookup if necessary
1736                 link = proptype.classname
1737                 link_cl = db.classes[link]
1738                 l = []
1739                 for entry in value:
1740                     if not entry: continue
1741                     if not num_re.match(entry):
1742                         try:
1743                             entry = link_cl.lookup(entry)
1744                         except KeyError:
1745                             raise ValueError, _('property "%(propname)s": '
1746                                 '"%(value)s" not an entry of %(classname)s')%{
1747                                 'propname': propname, 'value': entry,
1748                                 'classname': link}
1749                         except TypeError, message:
1750                             raise ValueError, _('you may only enter ID values '
1751                                 'for property "%(propname)s": %(message)s')%{
1752                                 'propname': propname, 'message': message}
1753                     l.append(entry)
1754                 l.sort()
1756                 # now use that list of ids to modify the multilink
1757                 if mlaction == 'set':
1758                     value = l
1759                 else:
1760                     # we're modifying the list - get the current list of ids
1761                     if props.has_key(propname):
1762                         existing = props[propname]
1763                     elif nodeid and not nodeid.startswith('-'):
1764                         existing = cl.get(nodeid, propname, [])
1765                     else:
1766                         existing = []
1768                     # now either remove or add
1769                     if mlaction == 'remove':
1770                         # remove - handle situation where the id isn't in
1771                         # the list
1772                         for entry in l:
1773                             try:
1774                                 existing.remove(entry)
1775                             except ValueError:
1776                                 raise ValueError, _('property "%(propname)s": '
1777                                     '"%(value)s" not currently in list')%{
1778                                     'propname': propname, 'value': entry}
1779                     else:
1780                         # add - easy, just don't dupe
1781                         for entry in l:
1782                             if entry not in existing:
1783                                 existing.append(entry)
1784                     value = existing
1785                     value.sort()
1787             elif value == '':
1788                 # if we're creating, just don't include this property
1789                 if not nodeid or nodeid.startswith('-'):
1790                     continue
1791                 # other types should be None'd if there's no value
1792                 value = None
1793             else:
1794                 # handle ValueErrors for all these in a similar fashion
1795                 try:
1796                     if isinstance(proptype, hyperdb.String):
1797                         if (hasattr(value, 'filename') and
1798                                 value.filename is not None):
1799                             # skip if the upload is empty
1800                             if not value.filename:
1801                                 continue
1802                             # this String is actually a _file_
1803                             # try to determine the file content-type
1804                             fn = value.filename.split('\\')[-1]
1805                             if propdef.has_key('name'):
1806                                 props['name'] = fn
1807                             # use this info as the type/filename properties
1808                             if propdef.has_key('type'):
1809                                 props['type'] = mimetypes.guess_type(fn)[0]
1810                                 if not props['type']:
1811                                     props['type'] = "application/octet-stream"
1812                             # finally, read the content
1813                             value = value.value
1814                         else:
1815                             # normal String fix the CRLF/CR -> LF stuff
1816                             value = fixNewlines(value)
1818                     elif isinstance(proptype, hyperdb.Date):
1819                         value = date.Date(value, offset=timezone)
1820                     elif isinstance(proptype, hyperdb.Interval):
1821                         value = date.Interval(value)
1822                     elif isinstance(proptype, hyperdb.Boolean):
1823                         value = value.lower() in ('yes', 'true', 'on', '1')
1824                     elif isinstance(proptype, hyperdb.Number):
1825                         value = float(value)
1826                 except ValueError, msg:
1827                     raise ValueError, _('Error with %s property: %s')%(
1828                         propname, msg)
1830             # register that we got this property
1831             if value:
1832                 got_props[this][propname] = 1
1834             # get the old value
1835             if nodeid and not nodeid.startswith('-'):
1836                 try:
1837                     existing = cl.get(nodeid, propname)
1838                 except KeyError:
1839                     # this might be a new property for which there is
1840                     # no existing value
1841                     if not propdef.has_key(propname):
1842                         raise
1844                 # make sure the existing multilink is sorted
1845                 if isinstance(proptype, hyperdb.Multilink):
1846                     existing.sort()
1848                 # "missing" existing values may not be None
1849                 if not existing:
1850                     if isinstance(proptype, hyperdb.String) and not existing:
1851                         # some backends store "missing" Strings as empty strings
1852                         existing = None
1853                     elif isinstance(proptype, hyperdb.Number) and not existing:
1854                         # some backends store "missing" Numbers as 0 :(
1855                         existing = 0
1856                     elif isinstance(proptype, hyperdb.Boolean) and not existing:
1857                         # likewise Booleans
1858                         existing = 0
1860                 # if changed, set it
1861                 if value != existing:
1862                     props[propname] = value
1863             else:
1864                 # don't bother setting empty/unset values
1865                 if value is None:
1866                     continue
1867                 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1868                     continue
1869                 elif isinstance(proptype, hyperdb.String) and value == '':
1870                     continue
1872                 props[propname] = value
1874         # check to see if we need to specially link a file to the note
1875         if have_note and have_file:
1876             all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1878         # see if all the required properties have been supplied
1879         s = []
1880         for thing, required in all_required.items():
1881             # register the values we got
1882             got = got_props.get(thing, {})
1883             for entry in required[:]:
1884                 if got.has_key(entry):
1885                     required.remove(entry)
1887             # any required values not present?
1888             if not required:
1889                 continue
1891             # tell the user to entry the values required
1892             if len(required) > 1:
1893                 p = 'properties'
1894             else:
1895                 p = 'property'
1896             s.append('Required %s %s %s not supplied'%(thing[0], p,
1897                 ', '.join(required)))
1898         if s:
1899             raise ValueError, '\n'.join(s)
1901         # When creating a FileClass node, it should have a non-empty content
1902         # property to be created. When editing a FileClass node, it should
1903         # either have a non-empty content property or no property at all. In
1904         # the latter case, nothing will change.
1905         for (cn, id), props in all_props.items():
1906             if isinstance(self.db.classes[cn], hyperdb.FileClass):
1907                 if id == '-1':
1908                       if not props.get('content', ''):
1909                             del all_props[(cn, id)]
1910                 elif props.has_key('content') and not props['content']:
1911                       raise ValueError, _('File is empty')
1912         return all_props, all_links
1914 def fixNewlines(text):
1915     ''' Homogenise line endings.
1917         Different web clients send different line ending values, but
1918         other systems (eg. email) don't necessarily handle those line
1919         endings. Our solution is to convert all line endings to LF.
1920     '''
1921     text = text.replace('\r\n', '\n')
1922     return text.replace('\r', '\n')
1924 def extractFormList(value):
1925     ''' Extract a list of values from the form value.
1927         It may be one of:
1928          [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
1929          MiniFieldStorage('value,value,...')
1930          MiniFieldStorage('value')
1931     '''
1932     # multiple values are OK
1933     if isinstance(value, type([])):
1934         # it's a list of MiniFieldStorages - join then into
1935         values = ','.join([i.value.strip() for i in value])
1936     else:
1937         # it's a MiniFieldStorage, but may be a comma-separated list
1938         # of values
1939         values = value.value
1941     value = [i.strip() for i in values.split(',')]
1943     # filter out the empty bits
1944     return filter(None, value)