Code

use the upload-supplied content-type if there is one
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.153 2004-01-20 03:58:38 richard Exp $
3 __doc__ = """
4 WWW request handler (also used in the stand-alone server).
5 """
7 import os, os.path, cgi, StringIO, urlparse, re, traceback, mimetypes, urllib
8 import binascii, Cookie, time, random, 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 import templating, cgitb
14 from roundup.cgi.PageTemplates import PageTemplate
15 from roundup.rfc2822 import encode_header
16 from roundup.mailgw import uidFromAddress
17 from roundup.mailer import Mailer, MessageSendError
19 class HTTPException(Exception):
20     pass
21 class Unauthorised(HTTPException):
22     pass
23 class NotFound(HTTPException):
24     pass
25 class Redirect(HTTPException):
26     pass
27 class NotModified(HTTPException):
28     pass
30 # used by a couple of routines
31 chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
33 class FormError(ValueError):
34     """ An "expected" exception occurred during form parsing.
35         - ie. something we know can go wrong, and don't want to alarm the
36           user with
38         We trap this at the user interface level and feed back a nice error
39         to the user.
40     """
41     pass
43 class SendFile(Exception):
44     ''' Send a file from the database '''
46 class SendStaticFile(Exception):
47     ''' Send a static file from the instance html directory '''
49 def initialiseSecurity(security):
50     ''' Create some Permissions and Roles on the security object
52         This function is directly invoked by security.Security.__init__()
53         as a part of the Security object instantiation.
54     '''
55     security.addPermission(name="Web Registration",
56         description="User may register through the web")
57     p = security.addPermission(name="Web Access",
58         description="User may access the web interface")
59     security.addPermissionToRole('Admin', p)
61     # doing Role stuff through the web - make sure Admin can
62     p = security.addPermission(name="Web Roles",
63         description="User may manipulate user Roles through the web")
64     security.addPermissionToRole('Admin', p)
66 # used to clean messages passed through CGI variables - HTML-escape any tag
67 # that isn't <a href="">, <i>, <b> and <br> (including XHTML variants) so
68 # that people can't pass through nasties like <script>, <iframe>, ...
69 CLEAN_MESSAGE_RE = r'(<(/?(.*?)(\s*href="[^"]")?\s*/?)>)'
70 def clean_message(message, mc=re.compile(CLEAN_MESSAGE_RE, re.I)):
71     return mc.sub(clean_message_callback, message)
72 def clean_message_callback(match, ok={'a':1,'i':1,'b':1,'br':1}):
73     ''' Strip all non <a>,<i>,<b> and <br> tags from a string
74     '''
75     if ok.has_key(match.group(3).lower()):
76         return match.group(1)
77     return '&lt;%s&gt;'%match.group(2)
79 class Client:
80     ''' Instantiate to handle one CGI request.
82     See inner_main for request processing.
84     Client attributes at instantiation:
85         "path" is the PATH_INFO inside the instance (with no leading '/')
86         "base" is the base URL for the instance
87         "form" is the cgi form, an instance of FieldStorage from the standard
88                cgi module
89         "additional_headers" is a dictionary of additional HTTP headers that
90                should be sent to the client
91         "response_code" is the HTTP response code to send to the client
93     During the processing of a request, the following attributes are used:
94         "error_message" holds a list of error messages
95         "ok_message" holds a list of OK messages
96         "session" is the current user session id
97         "user" is the current user's name
98         "userid" is the current user's id
99         "template" is the current :template context
100         "classname" is the current class context name
101         "nodeid" is the current context item id
103     User Identification:
104      If the user has no login cookie, then they are anonymous and are logged
105      in as that user. This typically gives them all Permissions assigned to the
106      Anonymous Role.
108      Once a user logs in, they are assigned a session. The Client instance
109      keeps the nodeid of the session as the "session" attribute.
112     Special form variables:
113      Note that in various places throughout this code, special form
114      variables of the form :<name> are used. The colon (":") part may
115      actually be one of either ":" or "@".
116     '''
118     #
119     # special form variables
120     #
121     FV_TEMPLATE = re.compile(r'[@:]template')
122     FV_OK_MESSAGE = re.compile(r'[@:]ok_message')
123     FV_ERROR_MESSAGE = re.compile(r'[@:]error_message')
125     FV_QUERYNAME = re.compile(r'[@:]queryname')
127     # edit form variable handling (see unit tests)
128     FV_LABELS = r'''
129        ^(
130          (?P<note>[@:]note)|
131          (?P<file>[@:]file)|
132          (
133           ((?P<classname>%s)(?P<id>[-\d]+))?  # optional leading designator
134           ((?P<required>[@:]required$)|       # :required
135            (
136             (
137              (?P<add>[@:]add[@:])|            # :add:<prop>
138              (?P<remove>[@:]remove[@:])|      # :remove:<prop>
139              (?P<confirm>[@:]confirm[@:])|    # :confirm:<prop>
140              (?P<link>[@:]link[@:])|          # :link:<prop>
141              ([@:])                           # just a separator
142             )?
143             (?P<propname>[^@:]+)             # <prop>
144            )
145           )
146          )
147         )$'''
149     # Note: index page stuff doesn't appear here:
150     # columns, sort, sortdir, filter, group, groupdir, search_text,
151     # pagesize, startwith
153     def __init__(self, instance, request, env, form=None):
154         hyperdb.traceMark()
155         self.instance = instance
156         self.request = request
157         self.env = env
158         self.mailer = Mailer(instance.config)
160         # save off the path
161         self.path = env['PATH_INFO']
163         # this is the base URL for this tracker
164         self.base = self.instance.config.TRACKER_WEB
166         # this is the "cookie path" for this tracker (ie. the path part of
167         # the "base" url)
168         self.cookie_path = urlparse.urlparse(self.base)[2]
169         self.cookie_name = 'roundup_session_' + re.sub('[^a-zA-Z]', '',
170             self.instance.config.TRACKER_NAME)
172         # see if we need to re-parse the environment for the form (eg Zope)
173         if form is None:
174             self.form = cgi.FieldStorage(environ=env)
175         else:
176             self.form = form
178         # turn debugging on/off
179         try:
180             self.debug = int(env.get("ROUNDUP_DEBUG", 0))
181         except ValueError:
182             # someone gave us a non-int debug level, turn it off
183             self.debug = 0
185         # flag to indicate that the HTTP headers have been sent
186         self.headers_done = 0
188         # additional headers to send with the request - must be registered
189         # before the first write
190         self.additional_headers = {}
191         self.response_code = 200
194     def main(self):
195         ''' Wrap the real main in a try/finally so we always close off the db.
196         '''
197         try:
198             self.inner_main()
199         finally:
200             if hasattr(self, 'db'):
201                 self.db.close()
203     def inner_main(self):
204         ''' Process a request.
206             The most common requests are handled like so:
207             1. figure out who we are, defaulting to the "anonymous" user
208                see determine_user
209             2. figure out what the request is for - the context
210                see determine_context
211             3. handle any requested action (item edit, search, ...)
212                see handle_action
213             4. render a template, resulting in HTML output
215             In some situations, exceptions occur:
216             - HTTP Redirect  (generally raised by an action)
217             - SendFile       (generally raised by determine_context)
218               serve up a FileClass "content" property
219             - SendStaticFile (generally raised by determine_context)
220               serve up a file from the tracker "html" directory
221             - Unauthorised   (generally raised by an action)
222               the action is cancelled, the request is rendered and an error
223               message is displayed indicating that permission was not
224               granted for the action to take place
225             - templating.Unauthorised   (templating action not permitted)
226               raised by an attempted rendering of a template when the user
227               doesn't have permission
228             - NotFound       (raised wherever it needs to be)
229               percolates up to the CGI interface that called the client
230         '''
231         self.ok_message = []
232         self.error_message = []
233         try:
234             # figure out the context and desired content template
235             # do this first so we don't authenticate for static files
236             # Note: this method opens the database as "admin" in order to
237             # perform context checks
238             self.determine_context()
240             # make sure we're identified (even anonymously)
241             self.determine_user()
243             # possibly handle a form submit action (may change self.classname
244             # and self.template, and may also append error/ok_messages)
245             self.handle_action()
247             # now render the page
248             # we don't want clients caching our dynamic pages
249             self.additional_headers['Cache-Control'] = 'no-cache'
250 # Pragma: no-cache makes Mozilla and its ilk double-load all pages!!
251 #            self.additional_headers['Pragma'] = 'no-cache'
253             # expire this page 5 seconds from now
254             date = rfc822.formatdate(time.time() + 5)
255             self.additional_headers['Expires'] = date
257             # render the content
258             self.write(self.renderContext())
259         except Redirect, url:
260             # let's redirect - if the url isn't None, then we need to do
261             # the headers, otherwise the headers have been set before the
262             # exception was raised
263             if url:
264                 self.additional_headers['Location'] = url
265                 self.response_code = 302
266             self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
267         except SendFile, designator:
268             self.serve_file(designator)
269         except SendStaticFile, file:
270             try:
271                 self.serve_static_file(str(file))
272             except NotModified:
273                 # send the 304 response
274                 self.request.send_response(304)
275                 self.request.end_headers()
276         except Unauthorised, message:
277             # users may always see the front page
278             self.classname = self.nodeid = None
279             self.template = ''
280             self.error_message.append(message)
281             self.write(self.renderContext())
282         except NotFound:
283             # pass through
284             raise
285         except FormError, e:
286             self.error_message.append(_('Form Error: ') + str(e))
287             self.write(self.renderContext())
288         except:
289             # everything else
290             self.write(cgitb.html())
292     def clean_sessions(self):
293         """Age sessions, remove when they haven't been used for a week.
294         
295         Do it only once an hour.
297         Note: also cleans One Time Keys, and other "session" based 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         # see if we were passed in a message
420         if ok_message:
421             self.ok_message.append(ok_message)
422         if error_message:
423             self.error_message.append(error_message)
425         # determine the classname and possibly nodeid
426         path = self.path.split('/')
427         if not path or path[0] in ('', 'home', 'index'):
428             if template_override is not None:
429                 self.template = template_override
430             else:
431                 self.template = ''
432             return
433         elif path[0] in ('_file', '@@file'):
434             raise SendStaticFile, os.path.join(*path[1:])
435         else:
436             self.classname = path[0]
437             if len(path) > 1:
438                 # send the file identified by the designator in path[0]
439                 raise SendFile, path[0]
441         # we need the db for further context stuff - open it as admin
442         self.opendb('admin')
444         # see if we got a designator
445         m = dre.match(self.classname)
446         if m:
447             self.classname = m.group(1)
448             self.nodeid = m.group(2)
449             if not self.db.getclass(self.classname).hasnode(self.nodeid):
450                 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
451             # with a designator, we default to item view
452             self.template = 'item'
453         else:
454             # with only a class, we default to index view
455             self.template = 'index'
457         # make sure the classname is valid
458         try:
459             self.db.getclass(self.classname)
460         except KeyError:
461             raise NotFound, self.classname
463         # see if we have a template override
464         if template_override is not None:
465             self.template = template_override
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)
475         self.opendb('admin')
476         klass = self.db.getclass(classname)
478         # make sure we have the appropriate properties
479         props = klass.getprops()
480         if not props.has_key('type'):
481             raise NotFound, designator
482         if not props.has_key('content'):
483             raise NotFound, designator
485         mime_type = klass.get(nodeid, 'type')
486         content = klass.get(nodeid, 'content')
487         lmt = klass.get(nodeid, 'activity').timestamp()
489         self._serve_file(lmt, mime_type, content)
491     def serve_static_file(self, file):
492         ''' Serve up the file named from the templates dir
493         '''
494         filename = os.path.join(self.instance.config.TEMPLATES, file)
496         # last-modified time
497         lmt = os.stat(filename)[stat.ST_MTIME]
499         # detemine meta-type
500         file = str(file)
501         mime_type = mimetypes.guess_type(file)[0]
502         if not mime_type:
503             if file.endswith('.css'):
504                 mime_type = 'text/css'
505             else:
506                 mime_type = 'text/plain'
508         # snarf the content
509         f = open(filename, 'rb')
510         try:
511             content = f.read()
512         finally:
513             f.close()
515         self._serve_file(lmt, mime_type, content)
517     def _serve_file(self, last_modified, mime_type, content):
518         ''' guts of serve_file() and serve_static_file()
519         '''
520         ims = None
521         # see if there's an if-modified-since...
522         if hasattr(self.request, 'headers'):
523             ims = self.request.headers.getheader('if-modified-since')
524         elif self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
525             # cgi will put the header in the env var
526             ims = self.env['HTTP_IF_MODIFIED_SINCE']
527         if ims:
528             ims = rfc822.parsedate(ims)[:6]
529             lmtt = time.gmtime(lmt)[:6]
530             if lmtt <= ims:
531                 raise NotModified
533         # spit out headers
534         self.additional_headers['Content-Type'] = mime_type
535         self.additional_headers['Content-Length'] = len(content)
536         lmt = rfc822.formatdate(last_modified)
537         self.additional_headers['Last-Modifed'] = lmt
538         self.write(content)
540     def renderContext(self):
541         ''' Return a PageTemplate for the named page
542         '''
543         name = self.classname
544         extension = self.template
545         pt = templating.Templates(self.instance.config.TEMPLATES).get(name,
546             extension)
548         # catch errors so we can handle PT rendering errors more nicely
549         args = {
550             'ok_message': self.ok_message,
551             'error_message': self.error_message
552         }
553         try:
554             # let the template render figure stuff out
555             result = pt.render(self, None, None, **args)
556             self.additional_headers['Content-Type'] = pt.content_type
557             return result
558         except templating.NoTemplate, message:
559             return '<strong>%s</strong>'%message
560         except templating.Unauthorised, message:
561             raise Unauthorised, str(message)
562         except:
563             # everything else
564             return cgitb.pt_html()
566     # these are the actions that are available
567     actions = (
568         ('edit',     'editItemAction'),
569         ('editcsv',  'editCSVAction'),
570         ('new',      'newItemAction'),
571         ('register', 'registerAction'),
572         ('confrego', 'confRegoAction'),
573         ('passrst',  'passResetAction'),
574         ('login',    'loginAction'),
575         ('logout',   'logout_action'),
576         ('search',   'searchAction'),
577         ('retire',   'retireAction'),
578         ('show',     'showAction'),
579     )
580     def handle_action(self):
581         ''' Determine whether there should be an Action called.
583             The action is defined by the form variable :action which
584             identifies the method on this object to call. The actions
585             are defined in the "actions" sequence on this class.
586         '''
587         if self.form.has_key(':action'):
588             action = self.form[':action'].value.lower()
589         elif self.form.has_key('@action'):
590             action = self.form['@action'].value.lower()
591         else:
592             return None
593         try:
594             # get the action, validate it
595             for name, method in self.actions:
596                 if name == action:
597                     break
598             else:
599                 raise ValueError, 'No such action "%s"'%action
600             # call the mapped action
601             getattr(self, method)()
602         except Redirect:
603             raise
604         except Unauthorised:
605             raise
607     def write(self, content):
608         if not self.headers_done:
609             self.header()
610         self.request.wfile.write(content)
612     def header(self, headers=None, response=None):
613         '''Put up the appropriate header.
614         '''
615         if headers is None:
616             headers = {'Content-Type':'text/html'}
617         if response is None:
618             response = self.response_code
620         # update with additional info
621         headers.update(self.additional_headers)
623         if not headers.has_key('Content-Type'):
624             headers['Content-Type'] = 'text/html'
625         self.request.send_response(response)
626         for entry in headers.items():
627             self.request.send_header(*entry)
628         self.request.end_headers()
629         self.headers_done = 1
630         if self.debug:
631             self.headers_sent = headers
633     def set_cookie(self, user):
634         """Set up a session cookie for the user.
636         Also store away the user's login info against the session.
637         """
638         # TODO generate a much, much stronger session key ;)
639         self.session = binascii.b2a_base64(repr(random.random())).strip()
641         # clean up the base64
642         if self.session[-1] == '=':
643             if self.session[-2] == '=':
644                 self.session = self.session[:-2]
645             else:
646                 self.session = self.session[:-1]
648         # insert the session in the sessiondb
649         self.db.sessions.set(self.session, user=user, last_use=time.time())
651         # and commit immediately
652         self.db.sessions.commit()
654         # expire us in a long, long time
655         expire = Cookie._getdate(86400*365)
657         # generate the cookie path - make sure it has a trailing '/'
658         self.additional_headers['Set-Cookie'] = \
659           '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
660             expire, self.cookie_path)
662     def make_user_anonymous(self):
663         ''' Make us anonymous
665             This method used to handle non-existence of the 'anonymous'
666             user, but that user is mandatory now.
667         '''
668         self.userid = self.db.user.lookup('anonymous')
669         self.user = 'anonymous'
671     def opendb(self, user):
672         ''' Open the database.
673         '''
674         # open the db if the user has changed
675         if not hasattr(self, 'db') or user != self.db.journaltag:
676             if hasattr(self, 'db'):
677                 self.db.close()
678             self.db = self.instance.open(user)
680     #
681     # Actions
682     #
683     def loginAction(self):
684         ''' Attempt to log a user in.
686             Sets up a session for the user which contains the login
687             credentials.
688         '''
689         # we need the username at a minimum
690         if not self.form.has_key('__login_name'):
691             self.error_message.append(_('Username required'))
692             return
694         # get the login info
695         self.user = self.form['__login_name'].value
696         if self.form.has_key('__login_password'):
697             password = self.form['__login_password'].value
698         else:
699             password = ''
701         # make sure the user exists
702         try:
703             self.userid = self.db.user.lookup(self.user)
704         except KeyError:
705             name = self.user
706             self.error_message.append(_('No such user "%(name)s"')%locals())
707             self.make_user_anonymous()
708             return
710         # verify the password
711         if not self.verifyPassword(self.userid, password):
712             self.make_user_anonymous()
713             self.error_message.append(_('Incorrect password'))
714             return
716         # make sure we're allowed to be here
717         if not self.loginPermission():
718             self.make_user_anonymous()
719             self.error_message.append(_("You do not have permission to login"))
720             return
722         # now we're OK, re-open the database for real, using the user
723         self.opendb(self.user)
725         # set the session cookie
726         self.set_cookie(self.user)
728     def verifyPassword(self, userid, password):
729         ''' Verify the password that the user has supplied
730         '''
731         stored = self.db.user.get(self.userid, 'password')
732         if password == stored:
733             return 1
734         if not password and not stored:
735             return 1
736         return 0
738     def loginPermission(self):
739         ''' Determine whether the user has permission to log in.
741             Base behaviour is to check the user has "Web Access".
742         ''' 
743         if not self.db.security.hasPermission('Web Access', self.userid):
744             return 0
745         return 1
747     def logout_action(self):
748         ''' Make us really anonymous - nuke the cookie too
749         '''
750         # log us out
751         self.make_user_anonymous()
753         # construct the logout cookie
754         now = Cookie._getdate()
755         self.additional_headers['Set-Cookie'] = \
756            '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.cookie_name,
757             now, self.cookie_path)
759         # Let the user know what's going on
760         self.ok_message.append(_('You are logged out'))
762     def registerAction(self):
763         '''Attempt to create a new user based on the contents of the form
764         and then set the cookie.
766         return 1 on successful login
767         '''
768         props = self.parsePropsFromForm()[0][('user', None)]
770         # make sure we're allowed to register
771         if not self.registerPermission(props):
772             raise Unauthorised, _("You do not have permission to register")
774         try:
775             self.db.user.lookup(props['username'])
776             self.error_message.append('Error: A user with the username "%s" '
777                 'already exists'%props['username'])
778             return
779         except KeyError:
780             pass
782         # generate the one-time-key and store the props for later
783         otk = ''.join([random.choice(chars) for x in range(32)])
784         for propname, proptype in self.db.user.getprops().items():
785             value = props.get(propname, None)
786             if value is None:
787                 pass
788             elif isinstance(proptype, hyperdb.Date):
789                 props[propname] = str(value)
790             elif isinstance(proptype, hyperdb.Interval):
791                 props[propname] = str(value)
792             elif isinstance(proptype, hyperdb.Password):
793                 props[propname] = str(value)
794         props['__time'] = time.time()
795         self.db.otks.set(otk, **props)
797         # send the email
798         tracker_name = self.db.config.TRACKER_NAME
799         tracker_email = self.db.config.TRACKER_EMAIL
800         subject = 'Complete your registration to %s -- key %s' % (tracker_name,
801                                                                   otk)
802         body = """To complete your registration of the user "%(name)s" with
803 %(tracker)s, please do one of the following:
805 - send a reply to %(tracker_email)s and maintain the subject line as is (the
806 reply's additional "Re:" is ok),
808 - or visit the following URL:
810    %(url)s?@action=confrego&otk=%(otk)s
811 """ % {'name': props['username'], 'tracker': tracker_name, 'url': self.base,
812        'otk': otk, 'tracker_email': tracker_email}
813         if not self.standard_message([props['address']], subject, body,
814                                      tracker_email):
815             return
817         # commit changes to the database
818         self.db.commit()
820         # redirect to the "you're almost there" page
821         raise Redirect, '%suser?@template=rego_progress'%self.base
823     def standard_message(self, to, subject, body, author=None):
824         try:
825             self.mailer.standard_message(to, subject, body, author)
826             return 1
827         except MessageSendError, e:
828             self.error_message.append(str(e))
829             
830     def registerPermission(self, props):
831         ''' Determine whether the user has permission to register
833             Base behaviour is to check the user has "Web Registration".
834         '''
835         # registration isn't allowed to supply roles
836         if props.has_key('roles'):
837             return 0
838         if self.db.security.hasPermission('Web Registration', self.userid):
839             return 1
840         return 0
842     def confRegoAction(self):
843         ''' Grab the OTK, use it to load up the new user details
844         '''
845         try:
846             # pull the rego information out of the otk database
847             self.userid = self.db.confirm_registration(self.form['otk'].value)
848         except (ValueError, KeyError), message:
849             # XXX: we need to make the "default" page be able to display errors!
850             self.error_message.append(str(message))
851             return
852         
853         # log the new user in
854         self.user = self.db.user.get(self.userid, 'username')
855         # re-open the database for real, using the user
856         self.opendb(self.user)
858         # if we have a session, update it
859         if hasattr(self, 'session'):
860             self.db.sessions.set(self.session, user=self.user,
861                 last_use=time.time())
862         else:
863             # new session cookie
864             self.set_cookie(self.user)
866         # nice message
867         message = _('You are now registered, welcome!')
869         # redirect to the user's page
870         raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
871             self.userid, urllib.quote(message))
873     def passResetAction(self):
874         ''' Handle password reset requests.
876             Presence of either "name" or "address" generate email.
877             Presense of "otk" performs the reset.
878         '''
879         if self.form.has_key('otk'):
880             # pull the rego information out of the otk database
881             otk = self.form['otk'].value
882             uid = self.db.otks.get(otk, 'uid')
883             if uid is None:
884                 self.error_message.append("""Invalid One Time Key!
885 (a Mozilla bug may cause this message to show up erroneously,
886  please check your email)""")
887                 return
889             # re-open the database as "admin"
890             if self.user != 'admin':
891                 self.opendb('admin')
893             # change the password
894             newpw = password.generatePassword()
896             cl = self.db.user
897 # XXX we need to make the "default" page be able to display errors!
898             try:
899                 # set the password
900                 cl.set(uid, password=password.Password(newpw))
901                 # clear the props from the otk database
902                 self.db.otks.destroy(otk)
903                 self.db.commit()
904             except (ValueError, KeyError), message:
905                 self.error_message.append(str(message))
906                 return
908             # user info
909             address = self.db.user.get(uid, 'address')
910             name = self.db.user.get(uid, 'username')
912             # send the email
913             tracker_name = self.db.config.TRACKER_NAME
914             subject = 'Password reset for %s'%tracker_name
915             body = '''
916 The password has been reset for username "%(name)s".
918 Your password is now: %(password)s
919 '''%{'name': name, 'password': newpw}
920             if not self.standard_message([address], subject, body):
921                 return
923             self.ok_message.append('Password reset and email sent to %s' %
924                                    address)
925             return
927         # no OTK, so now figure the user
928         if self.form.has_key('username'):
929             name = self.form['username'].value
930             try:
931                 uid = self.db.user.lookup(name)
932             except KeyError:
933                 self.error_message.append('Unknown username')
934                 return
935             address = self.db.user.get(uid, 'address')
936         elif self.form.has_key('address'):
937             address = self.form['address'].value
938             uid = uidFromAddress(self.db, ('', address), create=0)
939             if not uid:
940                 self.error_message.append('Unknown email address')
941                 return
942             name = self.db.user.get(uid, 'username')
943         else:
944             self.error_message.append('You need to specify a username '
945                 'or address')
946             return
948         # generate the one-time-key and store the props for later
949         otk = ''.join([random.choice(chars) for x in range(32)])
950         self.db.otks.set(otk, uid=uid, __time=time.time())
952         # send the email
953         tracker_name = self.db.config.TRACKER_NAME
954         subject = 'Confirm reset of password for %s'%tracker_name
955         body = '''
956 Someone, perhaps you, has requested that the password be changed for your
957 username, "%(name)s". If you wish to proceed with the change, please follow
958 the link below:
960   %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
962 You should then receive another email with the new password.
963 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
964         if not self.standard_message([address], subject, body):
965             return
967         self.ok_message.append('Email sent to %s'%address)
969     def editItemAction(self):
970         ''' Perform an edit of an item in the database.
972            See parsePropsFromForm and _editnodes for special variables
973         '''
974         props, links = self.parsePropsFromForm()
976         # handle the props
977         try:
978             message = self._editnodes(props, links)
979         except (ValueError, KeyError, IndexError), message:
980             self.error_message.append(_('Apply Error: ') + str(message))
981             return
983         # commit now that all the tricky stuff is done
984         self.db.commit()
986         # redirect to the item's edit page
987         raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
988             self.classname, self.nodeid, urllib.quote(message),
989             urllib.quote(self.template))
991     newItemAction = editItemAction
993     def editItemPermission(self, props):
994         """Determine whether the user has permission to edit this item.
996         Base behaviour is to check the user can edit this class. If we're
997         editing the"user" class, users are allowed to edit their own details.
998         Unless it's the "roles" property, which requires the special Permission
999         "Web Roles".
1000         """
1001         # if this is a user node and the user is editing their own node, then
1002         # we're OK
1003         has = self.db.security.hasPermission
1004         if self.classname == 'user':
1005             # reject if someone's trying to edit "roles" and doesn't have the
1006             # right permission.
1007             if props.has_key('roles') and not has('Web Roles', self.userid,
1008                     'user'):
1009                 return 0
1010             # if the item being edited is the current user, we're ok
1011             if (self.nodeid == self.userid
1012                 and self.db.user.get(self.nodeid, 'username') != 'anonymous'):
1013                 return 1
1014         if self.db.security.hasPermission('Edit', self.userid, self.classname):
1015             return 1
1016         return 0
1018     def newItemPermission(self, props):
1019         ''' Determine whether the user has permission to create (edit) this
1020             item.
1022             Base behaviour is to check the user can edit this class. No
1023             additional property checks are made. Additionally, new user items
1024             may be created if the user has the "Web Registration" Permission.
1025         '''
1026         has = self.db.security.hasPermission
1027         if self.classname == 'user' and has('Web Registration', self.userid,
1028                 'user'):
1029             return 1
1030         if has('Edit', self.userid, self.classname):
1031             return 1
1032         return 0
1035     #
1036     #  Utility methods for editing
1037     #
1038     def _editnodes(self, all_props, all_links, newids=None):
1039         ''' Use the props in all_props to perform edit and creation, then
1040             use the link specs in all_links to do linking.
1041         '''
1042         # figure dependencies and re-work links
1043         deps = {}
1044         links = {}
1045         for cn, nodeid, propname, vlist in all_links:
1046             if not all_props.has_key((cn, nodeid)):
1047                 # link item to link to doesn't (and won't) exist
1048                 continue
1049             for value in vlist:
1050                 if not all_props.has_key(value):
1051                     # link item to link to doesn't (and won't) exist
1052                     continue
1053                 deps.setdefault((cn, nodeid), []).append(value)
1054                 links.setdefault(value, []).append((cn, nodeid, propname))
1056         # figure chained dependencies ordering
1057         order = []
1058         done = {}
1059         # loop detection
1060         change = 0
1061         while len(all_props) != len(done):
1062             for needed in all_props.keys():
1063                 if done.has_key(needed):
1064                     continue
1065                 tlist = deps.get(needed, [])
1066                 for target in tlist:
1067                     if not done.has_key(target):
1068                         break
1069                 else:
1070                     done[needed] = 1
1071                     order.append(needed)
1072                     change = 1
1073             if not change:
1074                 raise ValueError, 'linking must not loop!'
1076         # now, edit / create
1077         m = []
1078         for needed in order:
1079             props = all_props[needed]
1080             if not props:
1081                 # nothing to do
1082                 continue
1083             cn, nodeid = needed
1085             if nodeid is not None and int(nodeid) > 0:
1086                 # make changes to the node
1087                 props = self._changenode(cn, nodeid, props)
1089                 # and some nice feedback for the user
1090                 if props:
1091                     info = ', '.join(props.keys())
1092                     m.append('%s %s %s edited ok'%(cn, nodeid, info))
1093                 else:
1094                     m.append('%s %s - nothing changed'%(cn, nodeid))
1095             else:
1096                 assert props
1098                 # make a new node
1099                 newid = self._createnode(cn, props)
1100                 if nodeid is None:
1101                     self.nodeid = newid
1102                 nodeid = newid
1104                 # and some nice feedback for the user
1105                 m.append('%s %s created'%(cn, newid))
1107             # fill in new ids in links
1108             if links.has_key(needed):
1109                 for linkcn, linkid, linkprop in links[needed]:
1110                     props = all_props[(linkcn, linkid)]
1111                     cl = self.db.classes[linkcn]
1112                     propdef = cl.getprops()[linkprop]
1113                     if not props.has_key(linkprop):
1114                         if linkid is None or linkid.startswith('-'):
1115                             # linking to a new item
1116                             if isinstance(propdef, hyperdb.Multilink):
1117                                 props[linkprop] = [newid]
1118                             else:
1119                                 props[linkprop] = newid
1120                         else:
1121                             # linking to an existing item
1122                             if isinstance(propdef, hyperdb.Multilink):
1123                                 existing = cl.get(linkid, linkprop)[:]
1124                                 existing.append(nodeid)
1125                                 props[linkprop] = existing
1126                             else:
1127                                 props[linkprop] = newid
1129         return '<br>'.join(m)
1131     def _changenode(self, cn, nodeid, props):
1132         ''' change the node based on the contents of the form
1133         '''
1134         # check for permission
1135         if not self.editItemPermission(props):
1136             raise Unauthorised, 'You do not have permission to edit %s'%cn
1138         # make the changes
1139         cl = self.db.classes[cn]
1140         return cl.set(nodeid, **props)
1142     def _createnode(self, cn, props):
1143         ''' create a node based on the contents of the form
1144         '''
1145         # check for permission
1146         if not self.newItemPermission(props):
1147             raise Unauthorised, 'You do not have permission to create %s'%cn
1149         # create the node and return its id
1150         cl = self.db.classes[cn]
1151         return cl.create(**props)
1153     # 
1154     # More actions
1155     #
1156     def editCSVAction(self):
1157         """ Performs an edit of all of a class' items in one go.
1159             The "rows" CGI var defines the CSV-formatted entries for the
1160             class. New nodes are identified by the ID 'X' (or any other
1161             non-existent ID) and removed lines are retired.
1162         """
1163         # this is per-class only
1164         if not self.editCSVPermission():
1165             self.error_message.append(
1166                  _('You do not have permission to edit %s' %self.classname))
1167             return
1169         # get the CSV module
1170         if rcsv.error:
1171             self.error_message.append(_(rcsv.error))
1172             return
1174         cl = self.db.classes[self.classname]
1175         idlessprops = cl.getprops(protected=0).keys()
1176         idlessprops.sort()
1177         props = ['id'] + idlessprops
1179         # do the edit
1180         rows = StringIO.StringIO(self.form['rows'].value)
1181         reader = rcsv.reader(rows, rcsv.comma_separated)
1182         found = {}
1183         line = 0
1184         for values in reader:
1185             line += 1
1186             if line == 1: continue
1187             # skip property names header
1188             if values == props:
1189                 continue
1191             # extract the nodeid
1192             nodeid, values = values[0], values[1:]
1193             found[nodeid] = 1
1195             # see if the node exists
1196             if nodeid in ('x', 'X') or not cl.hasnode(nodeid):
1197                 exists = 0
1198             else:
1199                 exists = 1
1201             # confirm correct weight
1202             if len(idlessprops) != len(values):
1203                 self.error_message.append(
1204                     _('Not enough values on line %(line)s')%{'line':line})
1205                 return
1207             # extract the new values
1208             d = {}
1209             for name, value in zip(idlessprops, values):
1210                 prop = cl.properties[name]
1211                 value = value.strip()
1212                 # only add the property if it has a value
1213                 if value:
1214                     # if it's a multilink, split it
1215                     if isinstance(prop, hyperdb.Multilink):
1216                         value = value.split(':')
1217                     elif isinstance(prop, hyperdb.Password):
1218                         value = password.Password(value)
1219                     elif isinstance(prop, hyperdb.Interval):
1220                         value = date.Interval(value)
1221                     elif isinstance(prop, hyperdb.Date):
1222                         value = date.Date(value)
1223                     elif isinstance(prop, hyperdb.Boolean):
1224                         value = value.lower() in ('yes', 'true', 'on', '1')
1225                     elif isinstance(prop, hyperdb.Number):
1226                         value = float(value)
1227                     d[name] = value
1228                 elif exists:
1229                     # nuke the existing value
1230                     if isinstance(prop, hyperdb.Multilink):
1231                         d[name] = []
1232                     else:
1233                         d[name] = None
1235             # perform the edit
1236             if exists:
1237                 # edit existing
1238                 cl.set(nodeid, **d)
1239             else:
1240                 # new node
1241                 found[cl.create(**d)] = 1
1243         # retire the removed entries
1244         for nodeid in cl.list():
1245             if not found.has_key(nodeid):
1246                 cl.retire(nodeid)
1248         # all OK
1249         self.db.commit()
1251         self.ok_message.append(_('Items edited OK'))
1253     def editCSVPermission(self):
1254         ''' Determine whether the user has permission to edit this class.
1256             Base behaviour is to check the user can edit this class.
1257         ''' 
1258         if not self.db.security.hasPermission('Edit', self.userid,
1259                 self.classname):
1260             return 0
1261         return 1
1263     def searchAction(self, wcre=re.compile(r'[\s,]+')):
1264         ''' Mangle some of the form variables.
1266             Set the form ":filter" variable based on the values of the
1267             filter variables - if they're set to anything other than
1268             "dontcare" then add them to :filter.
1270             Handle the ":queryname" variable and save off the query to
1271             the user's query list.
1273             Split any String query values on whitespace and comma.
1274         '''
1275         # generic edit is per-class only
1276         if not self.searchPermission():
1277             self.error_message.append(
1278                 _('You do not have permission to search %s' %self.classname))
1279             return
1281         # add a faked :filter form variable for each filtering prop
1282         props = self.db.classes[self.classname].getprops()
1283         queryname = ''
1284         for key in self.form.keys():
1285             # special vars
1286             if self.FV_QUERYNAME.match(key):
1287                 queryname = self.form[key].value.strip()
1288                 continue
1290             if not props.has_key(key):
1291                 continue
1292             if isinstance(self.form[key], type([])):
1293                 # search for at least one entry which is not empty
1294                 for minifield in self.form[key]:
1295                     if minifield.value:
1296                         break
1297                 else:
1298                     continue
1299             else:
1300                 if not self.form[key].value:
1301                     continue
1302                 if isinstance(props[key], hyperdb.String):
1303                     v = self.form[key].value
1304                     l = token.token_split(v)
1305                     if len(l) > 1 or l[0] != v:
1306                         self.form.value.remove(self.form[key])
1307                         # replace the single value with the split list
1308                         for v in l:
1309                             self.form.value.append(cgi.MiniFieldStorage(key, v))
1311             self.form.value.append(cgi.MiniFieldStorage('@filter', key))
1313         # handle saving the query params
1314         if queryname:
1315             # parse the environment and figure what the query _is_
1316             req = templating.HTMLRequest(self)
1318             # The [1:] strips off the '?' character, it isn't part of the
1319             # query string.
1320             url = req.indexargs_href('', {})[1:]
1322             # handle editing an existing query
1323             try:
1324                 qid = self.db.query.lookup(queryname)
1325                 self.db.query.set(qid, klass=self.classname, url=url)
1326             except KeyError:
1327                 # create a query
1328                 qid = self.db.query.create(name=queryname,
1329                     klass=self.classname, url=url)
1331             # and add it to the user's query multilink
1332             queries = self.db.user.get(self.userid, 'queries')
1333             queries.append(qid)
1334             self.db.user.set(self.userid, queries=queries)
1336             # commit the query change to the database
1337             self.db.commit()
1339     def searchPermission(self):
1340         ''' Determine whether the user has permission to search this class.
1342             Base behaviour is to check the user can view this class.
1343         ''' 
1344         if not self.db.security.hasPermission('View', self.userid,
1345                 self.classname):
1346             return 0
1347         return 1
1350     def retireAction(self):
1351         ''' Retire the context item.
1352         '''
1353         # if we want to view the index template now, then unset the nodeid
1354         # context info (a special-case for retire actions on the index page)
1355         nodeid = self.nodeid
1356         if self.template == 'index':
1357             self.nodeid = None
1359         # generic edit is per-class only
1360         if not self.retirePermission():
1361             self.error_message.append(
1362                 _('You do not have permission to retire %s' %self.classname))
1363             return
1365         # make sure we don't try to retire admin or anonymous
1366         if self.classname == 'user' and \
1367                 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1368             self.error_message.append(
1369                 _('You may not retire the admin or anonymous user'))
1370             return
1372         # do the retire
1373         self.db.getclass(self.classname).retire(nodeid)
1374         self.db.commit()
1376         self.ok_message.append(
1377             _('%(classname)s %(itemid)s has been retired')%{
1378                 'classname': self.classname.capitalize(), 'itemid': nodeid})
1380     def retirePermission(self):
1381         ''' Determine whether the user has permission to retire this class.
1383             Base behaviour is to check the user can edit this class.
1384         ''' 
1385         if not self.db.security.hasPermission('Edit', self.userid,
1386                 self.classname):
1387             return 0
1388         return 1
1391     def showAction(self, typere=re.compile('[@:]type'),
1392             numre=re.compile('[@:]number')):
1393         ''' Show a node of a particular class/id
1394         '''
1395         t = n = ''
1396         for key in self.form.keys():
1397             if typere.match(key):
1398                 t = self.form[key].value.strip()
1399             elif numre.match(key):
1400                 n = self.form[key].value.strip()
1401         if not t:
1402             raise ValueError, 'Invalid %s number'%t
1403         url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1404         raise Redirect, url
1406     def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1407         """ Item properties and their values are edited with html FORM
1408             variables and their values. You can:
1410             - Change the value of some property of the current item.
1411             - Create a new item of any class, and edit the new item's
1412               properties,
1413             - Attach newly created items to a multilink property of the
1414               current item.
1415             - Remove items from a multilink property of the current item.
1416             - Specify that some properties are required for the edit
1417               operation to be successful.
1419             In the following, <bracketed> values are variable, "@" may be
1420             either ":" or "@", and other text "required" is fixed.
1422             Most properties are specified as form variables:
1424              <propname>
1425               - property on the current context item
1427              <designator>"@"<propname>
1428               - property on the indicated item (for editing related
1429                 information)
1431             Designators name a specific item of a class.
1433             <classname><N>
1435                 Name an existing item of class <classname>.
1437             <classname>"-"<N>
1439                 Name the <N>th new item of class <classname>. If the form
1440                 submission is successful, a new item of <classname> is
1441                 created. Within the submitted form, a particular
1442                 designator of this form always refers to the same new
1443                 item.
1445             Once we have determined the "propname", we look at it to see
1446             if it's special:
1448             @required
1449                 The associated form value is a comma-separated list of
1450                 property names that must be specified when the form is
1451                 submitted for the edit operation to succeed.  
1453                 When the <designator> is missing, the properties are
1454                 for the current context item.  When <designator> is
1455                 present, they are for the item specified by
1456                 <designator>.
1458                 The "@required" specifier must come before any of the
1459                 properties it refers to are assigned in the form.
1461             @remove@<propname>=id(s) or @add@<propname>=id(s)
1462                 The "@add@" and "@remove@" edit actions apply only to
1463                 Multilink properties.  The form value must be a
1464                 comma-separate list of keys for the class specified by
1465                 the simple form variable.  The listed items are added
1466                 to (respectively, removed from) the specified
1467                 property.
1469             @link@<propname>=<designator>
1470                 If the edit action is "@link@", the simple form
1471                 variable must specify a Link or Multilink property.
1472                 The form value is a comma-separated list of
1473                 designators.  The item corresponding to each
1474                 designator is linked to the property given by simple
1475                 form variable.  These are collected up and returned in
1476                 all_links.
1478             None of the above (ie. just a simple form value)
1479                 The value of the form variable is converted
1480                 appropriately, depending on the type of the property.
1482                 For a Link('klass') property, the form value is a
1483                 single key for 'klass', where the key field is
1484                 specified in dbinit.py.  
1486                 For a Multilink('klass') property, the form value is a
1487                 comma-separated list of keys for 'klass', where the
1488                 key field is specified in dbinit.py.  
1490                 Note that for simple-form-variables specifiying Link
1491                 and Multilink properties, the linked-to class must
1492                 have a key field.
1494                 For a String() property specifying a filename, the
1495                 file named by the form value is uploaded. This means we
1496                 try to set additional properties "filename" and "type" (if
1497                 they are valid for the class).  Otherwise, the property
1498                 is set to the form value.
1500                 For Date(), Interval(), Boolean(), and Number()
1501                 properties, the form value is converted to the
1502                 appropriate
1504             Any of the form variables may be prefixed with a classname or
1505             designator.
1507             Two special form values are supported for backwards
1508             compatibility:
1510             @note
1511                 This is equivalent to::
1513                     @link@messages=msg-1
1514                     msg-1@content=value
1516                 except that in addition, the "author" and "date"
1517                 properties of "msg-1" are set to the userid of the
1518                 submitter, and the current time, respectively.
1520             @file
1521                 This is equivalent to::
1523                     @link@files=file-1
1524                     file-1@content=value
1526                 The String content value is handled as described above for
1527                 file uploads.
1529             If both the "@note" and "@file" form variables are
1530             specified, the action::
1532                     @link@msg-1@files=file-1
1534             is also performed.
1536             We also check that FileClass items have a "content" property with
1537             actual content, otherwise we remove them from all_props before
1538             returning.
1540             The return from this method is a dict of 
1541                 (classname, id): properties
1542             ... this dict _always_ has an entry for the current context,
1543             even if it's empty (ie. a submission for an existing issue that
1544             doesn't result in any changes would return {('issue','123'): {}})
1545             The id may be None, which indicates that an item should be
1546             created.
1547         """
1548         # some very useful variables
1549         db = self.db
1550         form = self.form
1552         if not hasattr(self, 'FV_SPECIAL'):
1553             # generate the regexp for handling special form values
1554             classes = '|'.join(db.classes.keys())
1555             # specials for parsePropsFromForm
1556             # handle the various forms (see unit tests)
1557             self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1558             self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1560         # these indicate the default class / item
1561         default_cn = self.classname
1562         default_cl = self.db.classes[default_cn]
1563         default_nodeid = self.nodeid
1565         # we'll store info about the individual class/item edit in these
1566         all_required = {}       # required props per class/item
1567         all_props = {}          # props to set per class/item
1568         got_props = {}          # props received per class/item
1569         all_propdef = {}        # note - only one entry per class
1570         all_links = []          # as many as are required
1572         # we should always return something, even empty, for the context
1573         all_props[(default_cn, default_nodeid)] = {}
1575         keys = form.keys()
1576         timezone = db.getUserTimezone()
1578         # sentinels for the :note and :file props
1579         have_note = have_file = 0
1581         # extract the usable form labels from the form
1582         matches = []
1583         for key in keys:
1584             m = self.FV_SPECIAL.match(key)
1585             if m:
1586                 matches.append((key, m.groupdict()))
1588         # now handle the matches
1589         for key, d in matches:
1590             if d['classname']:
1591                 # we got a designator
1592                 cn = d['classname']
1593                 cl = self.db.classes[cn]
1594                 nodeid = d['id']
1595                 propname = d['propname']
1596             elif d['note']:
1597                 # the special note field
1598                 cn = 'msg'
1599                 cl = self.db.classes[cn]
1600                 nodeid = '-1'
1601                 propname = 'content'
1602                 all_links.append((default_cn, default_nodeid, 'messages',
1603                     [('msg', '-1')]))
1604                 have_note = 1
1605             elif d['file']:
1606                 # the special file field
1607                 cn = 'file'
1608                 cl = self.db.classes[cn]
1609                 nodeid = '-1'
1610                 propname = 'content'
1611                 all_links.append((default_cn, default_nodeid, 'files',
1612                     [('file', '-1')]))
1613                 have_file = 1
1614             else:
1615                 # default
1616                 cn = default_cn
1617                 cl = default_cl
1618                 nodeid = default_nodeid
1619                 propname = d['propname']
1621             # the thing this value relates to is...
1622             this = (cn, nodeid)
1624             # get more info about the class, and the current set of
1625             # form props for it
1626             if not all_propdef.has_key(cn):
1627                 all_propdef[cn] = cl.getprops()
1628             propdef = all_propdef[cn]
1629             if not all_props.has_key(this):
1630                 all_props[this] = {}
1631             props = all_props[this]
1632             if not got_props.has_key(this):
1633                 got_props[this] = {}
1635             # is this a link command?
1636             if d['link']:
1637                 value = []
1638                 for entry in extractFormList(form[key]):
1639                     m = self.FV_DESIGNATOR.match(entry)
1640                     if not m:
1641                         raise FormError, \
1642                             'link "%s" value "%s" not a designator'%(key, entry)
1643                     value.append((m.group(1), m.group(2)))
1645                 # make sure the link property is valid
1646                 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1647                         not isinstance(propdef[propname], hyperdb.Link)):
1648                     raise FormError, '%s %s is not a link or '\
1649                         'multilink property'%(cn, propname)
1651                 all_links.append((cn, nodeid, propname, value))
1652                 continue
1654             # detect the special ":required" variable
1655             if d['required']:
1656                 all_required[this] = extractFormList(form[key])
1657                 continue
1659             # see if we're performing a special multilink action
1660             mlaction = 'set'
1661             if d['remove']:
1662                 mlaction = 'remove'
1663             elif d['add']:
1664                 mlaction = 'add'
1666             # does the property exist?
1667             if not propdef.has_key(propname):
1668                 if mlaction != 'set':
1669                     raise FormError, 'You have submitted a %s action for'\
1670                         ' the property "%s" which doesn\'t exist'%(mlaction,
1671                         propname)
1672                 # the form element is probably just something we don't care
1673                 # about - ignore it
1674                 continue
1675             proptype = propdef[propname]
1677             # Get the form value. This value may be a MiniFieldStorage or a list
1678             # of MiniFieldStorages.
1679             value = form[key]
1681             # handle unpacking of the MiniFieldStorage / list form value
1682             if isinstance(proptype, hyperdb.Multilink):
1683                 value = extractFormList(value)
1684             else:
1685                 # multiple values are not OK
1686                 if isinstance(value, type([])):
1687                     raise FormError, 'You have submitted more than one value'\
1688                         ' for the %s property'%propname
1689                 # value might be a file upload...
1690                 if not hasattr(value, 'filename') or value.filename is None:
1691                     # nope, pull out the value and strip it
1692                     value = value.value.strip()
1694             # now that we have the props field, we need a teensy little
1695             # extra bit of help for the old :note field...
1696             if d['note'] and value:
1697                 props['author'] = self.db.getuid()
1698                 props['date'] = date.Date()
1700             # handle by type now
1701             if isinstance(proptype, hyperdb.Password):
1702                 if not value:
1703                     # ignore empty password values
1704                     continue
1705                 for key, d in matches:
1706                     if d['confirm'] and d['propname'] == propname:
1707                         confirm = form[key]
1708                         break
1709                 else:
1710                     raise FormError, 'Password and confirmation text do '\
1711                         'not match'
1712                 if isinstance(confirm, type([])):
1713                     raise FormError, 'You have submitted more than one value'\
1714                         ' for the %s property'%propname
1715                 if value != confirm.value:
1716                     raise FormError, 'Password and confirmation text do '\
1717                         'not match'
1718                 try:
1719                     value = password.Password(value)
1720                 except hyperdb.HyperdbValueError, msg:
1721                     raise FormError, msg
1723             elif isinstance(proptype, hyperdb.Multilink):
1724                 # convert input to list of ids
1725                 try:
1726                     l = hyperdb.rawToHyperdb(self.db, cl, nodeid,
1727                         propname, value)
1728                 except hyperdb.HyperdbValueError, msg:
1729                     raise FormError, msg
1731                 # now use that list of ids to modify the multilink
1732                 if mlaction == 'set':
1733                     value = l
1734                 else:
1735                     # we're modifying the list - get the current list of ids
1736                     if props.has_key(propname):
1737                         existing = props[propname]
1738                     elif nodeid and not nodeid.startswith('-'):
1739                         existing = cl.get(nodeid, propname, [])
1740                     else:
1741                         existing = []
1743                     # now either remove or add
1744                     if mlaction == 'remove':
1745                         # remove - handle situation where the id isn't in
1746                         # the list
1747                         for entry in l:
1748                             try:
1749                                 existing.remove(entry)
1750                             except ValueError:
1751                                 raise FormError, _('property "%(propname)s": '
1752                                     '"%(value)s" not currently in list')%{
1753                                     'propname': propname, 'value': entry}
1754                     else:
1755                         # add - easy, just don't dupe
1756                         for entry in l:
1757                             if entry not in existing:
1758                                 existing.append(entry)
1759                     value = existing
1760                     value.sort()
1762             elif value == '':
1763                 # other types should be None'd if there's no value
1764                 value = None
1765             else:
1766                 # handle all other types
1767                 try:
1768                     if isinstance(proptype, hyperdb.String):
1769                         if (hasattr(value, 'filename') and
1770                                 value.filename is not None):
1771                             # skip if the upload is empty
1772                             if not value.filename:
1773                                 continue
1774                             # this String is actually a _file_
1775                             # try to determine the file content-type
1776                             fn = value.filename.split('\\')[-1]
1777                             if propdef.has_key('name'):
1778                                 props['name'] = fn
1779                             # use this info as the type/filename properties
1780                             if propdef.has_key('type'):
1781                                 if hasattr(value, 'type'):
1782                                     props['type'] = value.type
1783                                 elif mimetypes.guess_type(fn)[0]:
1784                                     props['type'] = mimetypes.guess_type(fn)[0]
1785                                 else:
1786                                     props['type'] = "application/octet-stream"
1787                             # finally, read the content RAW
1788                             value = value.value
1789                         else:
1790                             value = hyperdb.rawToHyperdb(self.db, cl,
1791                                 nodeid, propname, value)
1793                     else:
1794                         value = hyperdb.rawToHyperdb(self.db, cl, nodeid,
1795                             propname, value)
1796                 except hyperdb.HyperdbValueError, msg:
1797                     raise FormError, msg
1799             # register that we got this property
1800             if value:
1801                 got_props[this][propname] = 1
1803             # get the old value
1804             if nodeid and not nodeid.startswith('-'):
1805                 try:
1806                     existing = cl.get(nodeid, propname)
1807                 except KeyError:
1808                     # this might be a new property for which there is
1809                     # no existing value
1810                     if not propdef.has_key(propname):
1811                         raise
1812                 except IndexError, message:
1813                     raise FormError(str(message))
1815                 # make sure the existing multilink is sorted
1816                 if isinstance(proptype, hyperdb.Multilink):
1817                     existing.sort()
1819                 # "missing" existing values may not be None
1820                 if not existing:
1821                     if isinstance(proptype, hyperdb.String) and not existing:
1822                         # some backends store "missing" Strings as empty strings
1823                         existing = None
1824                     elif isinstance(proptype, hyperdb.Number) and not existing:
1825                         # some backends store "missing" Numbers as 0 :(
1826                         existing = 0
1827                     elif isinstance(proptype, hyperdb.Boolean) and not existing:
1828                         # likewise Booleans
1829                         existing = 0
1831                 # if changed, set it
1832                 if value != existing:
1833                     props[propname] = value
1834             else:
1835                 # don't bother setting empty/unset values
1836                 if value is None:
1837                     continue
1838                 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1839                     continue
1840                 elif isinstance(proptype, hyperdb.String) and value == '':
1841                     continue
1843                 props[propname] = value
1845         # check to see if we need to specially link a file to the note
1846         if have_note and have_file:
1847             all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1849         # see if all the required properties have been supplied
1850         s = []
1851         for thing, required in all_required.items():
1852             # register the values we got
1853             got = got_props.get(thing, {})
1854             for entry in required[:]:
1855                 if got.has_key(entry):
1856                     required.remove(entry)
1858             # any required values not present?
1859             if not required:
1860                 continue
1862             # tell the user to entry the values required
1863             if len(required) > 1:
1864                 p = 'properties'
1865             else:
1866                 p = 'property'
1867             s.append('Required %s %s %s not supplied'%(thing[0], p,
1868                 ', '.join(required)))
1869         if s:
1870             raise FormError, '\n'.join(s)
1872         # When creating a FileClass node, it should have a non-empty content
1873         # property to be created. When editing a FileClass node, it should
1874         # either have a non-empty content property or no property at all. In
1875         # the latter case, nothing will change.
1876         for (cn, id), props in all_props.items():
1877             if isinstance(self.db.classes[cn], hyperdb.FileClass):
1878                 if id == '-1':
1879                     if not props.get('content', ''):
1880                         del all_props[(cn, id)]
1881                 elif props.has_key('content') and not props['content']:
1882                     raise FormError, _('File is empty')
1883         return all_props, all_links
1885 def extractFormList(value):
1886     ''' Extract a list of values from the form value.
1888         It may be one of:
1889          [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
1890          MiniFieldStorage('value,value,...')
1891          MiniFieldStorage('value')
1892     '''
1893     # multiple values are OK
1894     if isinstance(value, type([])):
1895         # it's a list of MiniFieldStorages - join then into
1896         values = ','.join([i.value.strip() for i in value])
1897     else:
1898         # it's a MiniFieldStorage, but may be a comma-separated list
1899         # of values
1900         values = value.value
1902     value = [i.strip() for i in values.split(',')]
1904     # filter out the empty bits
1905     return filter(None, value)