Code

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