Code

fix re-enabling queries (sf bug 861940)
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.151 2004-01-17 01:59:33 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.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 stuff.
295         """
296         sessions = self.db.sessions
297         last_clean = sessions.get('last_clean', 'last_use') or 0
299         week = 60*60*24*7
300         hour = 60*60
301         now = time.time()
302         if now - last_clean > hour:
303             # remove aged sessions
304             for sessid in sessions.list():
305                 interval = now - sessions.get(sessid, 'last_use')
306                 if interval > week:
307                     sessions.destroy(sessid)
308             # remove aged otks
309             otks = self.db.otks
310             for sessid in otks.list():
311                 interval = now - otks.get(sessid, '__time')
312                 if interval > week:
313                     otks.destroy(sessid)
314             sessions.set('last_clean', last_use=time.time())
316     def determine_user(self):
317         '''Determine who the user is.
318         '''
319         # open the database as admin
320         self.opendb('admin')
322         # clean age sessions
323         self.clean_sessions()
325         # make sure we have the session Class
326         sessions = self.db.sessions
328         # look up the user session cookie
329         cookie = Cookie.SimpleCookie(self.env.get('HTTP_COOKIE', ''))
330         user = 'anonymous'
332         # bump the "revision" of the cookie since the format changed
333         if (cookie.has_key(self.cookie_name) and
334                 cookie[self.cookie_name].value != 'deleted'):
336             # get the session key from the cookie
337             self.session = cookie[self.cookie_name].value
338             # get the user from the session
339             try:
340                 # update the lifetime datestamp
341                 sessions.set(self.session, last_use=time.time())
342                 sessions.commit()
343                 user = sessions.get(self.session, 'user')
344             except KeyError:
345                 user = 'anonymous'
347         # sanity check on the user still being valid, getting the userid
348         # at the same time
349         try:
350             self.userid = self.db.user.lookup(user)
351         except (KeyError, TypeError):
352             user = 'anonymous'
354         # make sure the anonymous user is valid if we're using it
355         if user == 'anonymous':
356             self.make_user_anonymous()
357         else:
358             self.user = user
360         # reopen the database as the correct user
361         self.opendb(self.user)
363     def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
364         """ Determine the context of this page from the URL:
366             The URL path after the instance identifier is examined. The path
367             is generally only one entry long.
369             - if there is no path, then we are in the "home" context.
370             * if the path is "_file", then the additional path entry
371               specifies the filename of a static file we're to serve up
372               from the instance "html" directory. Raises a SendStaticFile
373               exception.
374             - if there is something in the path (eg "issue"), it identifies
375               the tracker class we're to display.
376             - if the path is an item designator (eg "issue123"), then we're
377               to display a specific item.
378             * if the path starts with an item designator and is longer than
379               one entry, then we're assumed to be handling an item of a
380               FileClass, and the extra path information gives the filename
381               that the client is going to label the download with (ie
382               "file123/image.png" is nicer to download than "file123"). This
383               raises a SendFile exception.
385             Both of the "*" types of contexts stop before we bother to
386             determine the template we're going to use. That's because they
387             don't actually use templates.
389             The template used is specified by the :template CGI variable,
390             which defaults to:
392              only classname suplied:          "index"
393              full item designator supplied:   "item"
395             We set:
396              self.classname  - the class to display, can be None
397              self.template   - the template to render the current context with
398              self.nodeid     - the nodeid of the class we're displaying
399         """
400         # default the optional variables
401         self.classname = None
402         self.nodeid = None
404         # see if a template or messages are specified
405         template_override = ok_message = error_message = None
406         for key in self.form.keys():
407             if self.FV_TEMPLATE.match(key):
408                 template_override = self.form[key].value
409             elif self.FV_OK_MESSAGE.match(key):
410                 ok_message = self.form[key].value
411                 ok_message = clean_message(ok_message)
412             elif self.FV_ERROR_MESSAGE.match(key):
413                 error_message = self.form[key].value
414                 error_message = clean_message(error_message)
416         # determine the classname and possibly nodeid
417         path = self.path.split('/')
418         if not path or path[0] in ('', 'home', 'index'):
419             if template_override is not None:
420                 self.template = template_override
421             else:
422                 self.template = ''
423             return
424         elif path[0] in ('_file', '@@file'):
425             raise SendStaticFile, os.path.join(*path[1:])
426         else:
427             self.classname = path[0]
428             if len(path) > 1:
429                 # send the file identified by the designator in path[0]
430                 raise SendFile, path[0]
432         # we need the db for further context stuff - open it as admin
433         self.opendb('admin')
435         # see if we got a designator
436         m = dre.match(self.classname)
437         if m:
438             self.classname = m.group(1)
439             self.nodeid = m.group(2)
440             if not self.db.getclass(self.classname).hasnode(self.nodeid):
441                 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
442             # with a designator, we default to item view
443             self.template = 'item'
444         else:
445             # with only a class, we default to index view
446             self.template = 'index'
448         # make sure the classname is valid
449         try:
450             self.db.getclass(self.classname)
451         except KeyError:
452             raise NotFound, self.classname
454         # see if we have a template override
455         if template_override is not None:
456             self.template = template_override
458         # see if we were passed in a message
459         if ok_message:
460             self.ok_message.append(ok_message)
461         if error_message:
462             self.error_message.append(error_message)
464     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
465         ''' Serve the file from the content property of the designated item.
466         '''
467         m = dre.match(str(designator))
468         if not m:
469             raise NotFound, str(designator)
470         classname, nodeid = m.group(1), m.group(2)
472         self.opendb('admin')
473         klass = self.db.getclass(classname)
475         # make sure we have the appropriate properties
476         props = klass.getprops()
477         if not pops.has_key('type'):
478             raise NotFound, designator
479         if not pops.has_key('content'):
480             raise NotFound, designator
482         mime_type = klass.get(nodeid, 'type')
483         content = klass.get(nodeid, 'content')
484         lmt = klass.get(nodeid, 'activity').timestamp()
486         self._serve_file(lmt, mime_type, content)
488     def serve_static_file(self, file):
489         ''' Serve up the file named from the templates dir
490         '''
491         filename = os.path.join(self.instance.config.TEMPLATES, file)
493         # last-modified time
494         lmt = os.stat(filename)[stat.ST_MTIME]
496         # detemine meta-type
497         file = str(file)
498         mime_type = mimetypes.guess_type(file)[0]
499         if not mime_type:
500             if file.endswith('.css'):
501                 mime_type = 'text/css'
502             else:
503                 mime_type = 'text/plain'
505         # snarf the content
506         f = open(filename, 'rb')
507         try:
508             content = f.read()
509         finally:
510             f.close()
512         self._serve_file(lmt, mime_type, content)
514     def _serve_file(self, last_modified, mime_type, content):
515         ''' guts of serve_file() and serve_static_file()
516         '''
517         ims = None
518         # see if there's an if-modified-since...
519         if hasattr(self.request, 'headers'):
520             ims = self.request.headers.getheader('if-modified-since')
521         elif self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
522             # cgi will put the header in the env var
523             ims = self.env['HTTP_IF_MODIFIED_SINCE']
524         if ims:
525             ims = rfc822.parsedate(ims)[:6]
526             lmtt = time.gmtime(lmt)[:6]
527             if lmtt <= ims:
528                 raise NotModified
530         # spit out headers
531         self.additional_headers['Content-Type'] = mime_type
532         self.additional_headers['Content-Length'] = len(content)
533         lmt = rfc822.formatdate(last_modified)
534         self.additional_headers['Last-Modifed'] = lmt
535         self.write(content)
537     def renderContext(self):
538         ''' Return a PageTemplate for the named page
539         '''
540         name = self.classname
541         extension = self.template
542         pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
544         # catch errors so we can handle PT rendering errors more nicely
545         args = {
546             'ok_message': self.ok_message,
547             'error_message': self.error_message
548         }
549         try:
550             # let the template render figure stuff out
551             result = pt.render(self, None, None, **args)
552             self.additional_headers['Content-Type'] = pt.content_type
553             return result
554         except NoTemplate, message:
555             return '<strong>%s</strong>'%message
556         except:
557             # everything else
558             return cgitb.pt_html()
560     # these are the actions that are available
561     actions = (
562         ('edit',     'editItemAction'),
563         ('editcsv',  'editCSVAction'),
564         ('new',      'newItemAction'),
565         ('register', 'registerAction'),
566         ('confrego', 'confRegoAction'),
567         ('passrst',  'passResetAction'),
568         ('login',    'loginAction'),
569         ('logout',   'logout_action'),
570         ('search',   'searchAction'),
571         ('retire',   'retireAction'),
572         ('show',     'showAction'),
573     )
574     def handle_action(self):
575         ''' Determine whether there should be an Action called.
577             The action is defined by the form variable :action which
578             identifies the method on this object to call. The actions
579             are defined in the "actions" sequence on this class.
580         '''
581         if self.form.has_key(':action'):
582             action = self.form[':action'].value.lower()
583         elif self.form.has_key('@action'):
584             action = self.form['@action'].value.lower()
585         else:
586             return None
587         try:
588             # get the action, validate it
589             for name, method in self.actions:
590                 if name == action:
591                     break
592             else:
593                 raise ValueError, 'No such action "%s"'%action
594             # call the mapped action
595             getattr(self, method)()
596         except Redirect:
597             raise
598         except Unauthorised:
599             raise
601     def write(self, content):
602         if not self.headers_done:
603             self.header()
604         self.request.wfile.write(content)
606     def header(self, headers=None, response=None):
607         '''Put up the appropriate header.
608         '''
609         if headers is None:
610             headers = {'Content-Type':'text/html'}
611         if response is None:
612             response = self.response_code
614         # update with additional info
615         headers.update(self.additional_headers)
617         if not headers.has_key('Content-Type'):
618             headers['Content-Type'] = 'text/html'
619         self.request.send_response(response)
620         for entry in headers.items():
621             self.request.send_header(*entry)
622         self.request.end_headers()
623         self.headers_done = 1
624         if self.debug:
625             self.headers_sent = headers
627     def set_cookie(self, user):
628         """Set up a session cookie for the user.
630         Also store away the user's login info against the session.
631         """
632         # TODO generate a much, much stronger session key ;)
633         self.session = binascii.b2a_base64(repr(random.random())).strip()
635         # clean up the base64
636         if self.session[-1] == '=':
637             if self.session[-2] == '=':
638                 self.session = self.session[:-2]
639             else:
640                 self.session = self.session[:-1]
642         # insert the session in the sessiondb
643         self.db.sessions.set(self.session, user=user, last_use=time.time())
645         # and commit immediately
646         self.db.sessions.commit()
648         # expire us in a long, long time
649         expire = Cookie._getdate(86400*365)
651         # generate the cookie path - make sure it has a trailing '/'
652         self.additional_headers['Set-Cookie'] = \
653           '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
654             expire, self.cookie_path)
656     def make_user_anonymous(self):
657         ''' Make us anonymous
659             This method used to handle non-existence of the 'anonymous'
660             user, but that user is mandatory now.
661         '''
662         self.userid = self.db.user.lookup('anonymous')
663         self.user = 'anonymous'
665     def opendb(self, user):
666         ''' Open the database.
667         '''
668         # open the db if the user has changed
669         if not hasattr(self, 'db') or user != self.db.journaltag:
670             if hasattr(self, 'db'):
671                 self.db.close()
672             self.db = self.instance.open(user)
674     #
675     # Actions
676     #
677     def loginAction(self):
678         ''' Attempt to log a user in.
680             Sets up a session for the user which contains the login
681             credentials.
682         '''
683         # we need the username at a minimum
684         if not self.form.has_key('__login_name'):
685             self.error_message.append(_('Username required'))
686             return
688         # get the login info
689         self.user = self.form['__login_name'].value
690         if self.form.has_key('__login_password'):
691             password = self.form['__login_password'].value
692         else:
693             password = ''
695         # make sure the user exists
696         try:
697             self.userid = self.db.user.lookup(self.user)
698         except KeyError:
699             name = self.user
700             self.error_message.append(_('No such user "%(name)s"')%locals())
701             self.make_user_anonymous()
702             return
704         # verify the password
705         if not self.verifyPassword(self.userid, password):
706             self.make_user_anonymous()
707             self.error_message.append(_('Incorrect password'))
708             return
710         # make sure we're allowed to be here
711         if not self.loginPermission():
712             self.make_user_anonymous()
713             self.error_message.append(_("You do not have permission to login"))
714             return
716         # now we're OK, re-open the database for real, using the user
717         self.opendb(self.user)
719         # set the session cookie
720         self.set_cookie(self.user)
722     def verifyPassword(self, userid, password):
723         ''' Verify the password that the user has supplied
724         '''
725         stored = self.db.user.get(self.userid, 'password')
726         if password == stored:
727             return 1
728         if not password and not stored:
729             return 1
730         return 0
732     def loginPermission(self):
733         ''' Determine whether the user has permission to log in.
735             Base behaviour is to check the user has "Web Access".
736         ''' 
737         if not self.db.security.hasPermission('Web Access', self.userid):
738             return 0
739         return 1
741     def logout_action(self):
742         ''' Make us really anonymous - nuke the cookie too
743         '''
744         # log us out
745         self.make_user_anonymous()
747         # construct the logout cookie
748         now = Cookie._getdate()
749         self.additional_headers['Set-Cookie'] = \
750            '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.cookie_name,
751             now, self.cookie_path)
753         # Let the user know what's going on
754         self.ok_message.append(_('You are logged out'))
756     def registerAction(self):
757         '''Attempt to create a new user based on the contents of the form
758         and then set the cookie.
760         return 1 on successful login
761         '''
762         props = self.parsePropsFromForm()[0][('user', None)]
764         # make sure we're allowed to register
765         if not self.registerPermission(props):
766             raise Unauthorised, _("You do not have permission to register")
768         try:
769             self.db.user.lookup(props['username'])
770             self.error_message.append('Error: A user with the username "%s" '
771                 'already exists'%props['username'])
772             return
773         except KeyError:
774             pass
776         # generate the one-time-key and store the props for later
777         otk = ''.join([random.choice(chars) for x in range(32)])
778         for propname, proptype in self.db.user.getprops().items():
779             value = props.get(propname, None)
780             if value is None:
781                 pass
782             elif isinstance(proptype, hyperdb.Date):
783                 props[propname] = str(value)
784             elif isinstance(proptype, hyperdb.Interval):
785                 props[propname] = str(value)
786             elif isinstance(proptype, hyperdb.Password):
787                 props[propname] = str(value)
788         props['__time'] = time.time()
789         self.db.otks.set(otk, **props)
791         # send the email
792         tracker_name = self.db.config.TRACKER_NAME
793         tracker_email = self.db.config.TRACKER_EMAIL
794         subject = 'Complete your registration to %s -- key %s' % (tracker_name,
795                                                                   otk)
796         body = """To complete your registration of the user "%(name)s" with
797 %(tracker)s, please do one of the following:
799 - send a reply to %(tracker_email)s and maintain the subject line as is (the
800 reply's additional "Re:" is ok),
802 - or visit the following URL:
804    %(url)s?@action=confrego&otk=%(otk)s
805 """ % {'name': props['username'], 'tracker': tracker_name, 'url': self.base,
806        'otk': otk, 'tracker_email': tracker_email}
807         if not self.standard_message([props['address']], subject, body,
808                                      tracker_email):
809             return
811         # commit changes to the database
812         self.db.commit()
814         # redirect to the "you're almost there" page
815         raise Redirect, '%suser?@template=rego_progress'%self.base
817     def standard_message(self, to, subject, body, author=None):
818         try:
819             self.mailer.standard_message(to, subject, body, author)
820             return 1
821         except MessageSendError, e:
822             self.error_message.append(str(e))
823             
824     def registerPermission(self, props):
825         ''' Determine whether the user has permission to register
827             Base behaviour is to check the user has "Web Registration".
828         '''
829         # registration isn't allowed to supply roles
830         if props.has_key('roles'):
831             return 0
832         if self.db.security.hasPermission('Web Registration', self.userid):
833             return 1
834         return 0
836     def confRegoAction(self):
837         ''' Grab the OTK, use it to load up the new user details
838         '''
839         try:
840             # pull the rego information out of the otk database
841             self.userid = self.db.confirm_registration(self.form['otk'].value)
842         except (ValueError, KeyError), message:
843             # XXX: we need to make the "default" page be able to display errors!
844             self.error_message.append(str(message))
845             return
846         
847         # log the new user in
848         self.user = self.db.user.get(self.userid, 'username')
849         # re-open the database for real, using the user
850         self.opendb(self.user)
852         # if we have a session, update it
853         if hasattr(self, 'session'):
854             self.db.sessions.set(self.session, user=self.user,
855                 last_use=time.time())
856         else:
857             # new session cookie
858             self.set_cookie(self.user)
860         # nice message
861         message = _('You are now registered, welcome!')
863         # redirect to the user's page
864         raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
865             self.userid, urllib.quote(message))
867     def passResetAction(self):
868         ''' Handle password reset requests.
870             Presence of either "name" or "address" generate email.
871             Presense of "otk" performs the reset.
872         '''
873         if self.form.has_key('otk'):
874             # pull the rego information out of the otk database
875             otk = self.form['otk'].value
876             uid = self.db.otks.get(otk, 'uid')
877             if uid is None:
878                 self.error_message.append("""Invalid One Time Key!
879 (a Mozilla bug may cause this message to show up erroneously,
880  please check your email)""")
881                 return
883             # re-open the database as "admin"
884             if self.user != 'admin':
885                 self.opendb('admin')
887             # change the password
888             newpw = password.generatePassword()
890             cl = self.db.user
891 # XXX we need to make the "default" page be able to display errors!
892             try:
893                 # set the password
894                 cl.set(uid, password=password.Password(newpw))
895                 # clear the props from the otk database
896                 self.db.otks.destroy(otk)
897                 self.db.commit()
898             except (ValueError, KeyError), message:
899                 self.error_message.append(str(message))
900                 return
902             # user info
903             address = self.db.user.get(uid, 'address')
904             name = self.db.user.get(uid, 'username')
906             # send the email
907             tracker_name = self.db.config.TRACKER_NAME
908             subject = 'Password reset for %s'%tracker_name
909             body = '''
910 The password has been reset for username "%(name)s".
912 Your password is now: %(password)s
913 '''%{'name': name, 'password': newpw}
914             if not self.standard_message([address], subject, body):
915                 return
917             self.ok_message.append('Password reset and email sent to %s' %
918                                    address)
919             return
921         # no OTK, so now figure the user
922         if self.form.has_key('username'):
923             name = self.form['username'].value
924             try:
925                 uid = self.db.user.lookup(name)
926             except KeyError:
927                 self.error_message.append('Unknown username')
928                 return
929             address = self.db.user.get(uid, 'address')
930         elif self.form.has_key('address'):
931             address = self.form['address'].value
932             uid = uidFromAddress(self.db, ('', address), create=0)
933             if not uid:
934                 self.error_message.append('Unknown email address')
935                 return
936             name = self.db.user.get(uid, 'username')
937         else:
938             self.error_message.append('You need to specify a username '
939                 'or address')
940             return
942         # generate the one-time-key and store the props for later
943         otk = ''.join([random.choice(chars) for x in range(32)])
944         self.db.otks.set(otk, uid=uid, __time=time.time())
946         # send the email
947         tracker_name = self.db.config.TRACKER_NAME
948         subject = 'Confirm reset of password for %s'%tracker_name
949         body = '''
950 Someone, perhaps you, has requested that the password be changed for your
951 username, "%(name)s". If you wish to proceed with the change, please follow
952 the link below:
954   %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
956 You should then receive another email with the new password.
957 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
958         if not self.standard_message([address], subject, body):
959             return
961         self.ok_message.append('Email sent to %s'%address)
963     def editItemAction(self):
964         ''' Perform an edit of an item in the database.
966            See parsePropsFromForm and _editnodes for special variables
967         '''
968         props, links = self.parsePropsFromForm()
970         # handle the props
971         try:
972             message = self._editnodes(props, links)
973         except (ValueError, KeyError, IndexError), message:
974             self.error_message.append(_('Apply Error: ') + str(message))
975             return
977         # commit now that all the tricky stuff is done
978         self.db.commit()
980         # redirect to the item's edit page
981         raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
982             self.classname, self.nodeid, urllib.quote(message),
983             urllib.quote(self.template))
985     newItemAction = editItemAction
987     def editItemPermission(self, props):
988         """Determine whether the user has permission to edit this item.
990         Base behaviour is to check the user can edit this class. If we're
991         editing the"user" class, users are allowed to edit their own details.
992         Unless it's the "roles" property, which requires the special Permission
993         "Web Roles".
994         """
995         # if this is a user node and the user is editing their own node, then
996         # we're OK
997         has = self.db.security.hasPermission
998         if self.classname == 'user':
999             # reject if someone's trying to edit "roles" and doesn't have the
1000             # right permission.
1001             if props.has_key('roles') and not has('Web Roles', self.userid,
1002                     'user'):
1003                 return 0
1004             # if the item being edited is the current user, we're ok
1005             if (self.nodeid == self.userid
1006                 and self.db.user.get(self.nodeid, 'username') != 'anonymous'):
1007                 return 1
1008         if self.db.security.hasPermission('Edit', self.userid, self.classname):
1009             return 1
1010         return 0
1012     def newItemPermission(self, props):
1013         ''' Determine whether the user has permission to create (edit) this
1014             item.
1016             Base behaviour is to check the user can edit this class. No
1017             additional property checks are made. Additionally, new user items
1018             may be created if the user has the "Web Registration" Permission.
1019         '''
1020         has = self.db.security.hasPermission
1021         if self.classname == 'user' and has('Web Registration', self.userid,
1022                 'user'):
1023             return 1
1024         if has('Edit', self.userid, self.classname):
1025             return 1
1026         return 0
1029     #
1030     #  Utility methods for editing
1031     #
1032     def _editnodes(self, all_props, all_links, newids=None):
1033         ''' Use the props in all_props to perform edit and creation, then
1034             use the link specs in all_links to do linking.
1035         '''
1036         # figure dependencies and re-work links
1037         deps = {}
1038         links = {}
1039         for cn, nodeid, propname, vlist in all_links:
1040             if not all_props.has_key((cn, nodeid)):
1041                 # link item to link to doesn't (and won't) exist
1042                 continue
1043             for value in vlist:
1044                 if not all_props.has_key(value):
1045                     # link item to link to doesn't (and won't) exist
1046                     continue
1047                 deps.setdefault((cn, nodeid), []).append(value)
1048                 links.setdefault(value, []).append((cn, nodeid, propname))
1050         # figure chained dependencies ordering
1051         order = []
1052         done = {}
1053         # loop detection
1054         change = 0
1055         while len(all_props) != len(done):
1056             for needed in all_props.keys():
1057                 if done.has_key(needed):
1058                     continue
1059                 tlist = deps.get(needed, [])
1060                 for target in tlist:
1061                     if not done.has_key(target):
1062                         break
1063                 else:
1064                     done[needed] = 1
1065                     order.append(needed)
1066                     change = 1
1067             if not change:
1068                 raise ValueError, 'linking must not loop!'
1070         # now, edit / create
1071         m = []
1072         for needed in order:
1073             props = all_props[needed]
1074             if not props:
1075                 # nothing to do
1076                 continue
1077             cn, nodeid = needed
1079             if nodeid is not None and int(nodeid) > 0:
1080                 # make changes to the node
1081                 props = self._changenode(cn, nodeid, props)
1083                 # and some nice feedback for the user
1084                 if props:
1085                     info = ', '.join(props.keys())
1086                     m.append('%s %s %s edited ok'%(cn, nodeid, info))
1087                 else:
1088                     m.append('%s %s - nothing changed'%(cn, nodeid))
1089             else:
1090                 assert props
1092                 # make a new node
1093                 newid = self._createnode(cn, props)
1094                 if nodeid is None:
1095                     self.nodeid = newid
1096                 nodeid = newid
1098                 # and some nice feedback for the user
1099                 m.append('%s %s created'%(cn, newid))
1101             # fill in new ids in links
1102             if links.has_key(needed):
1103                 for linkcn, linkid, linkprop in links[needed]:
1104                     props = all_props[(linkcn, linkid)]
1105                     cl = self.db.classes[linkcn]
1106                     propdef = cl.getprops()[linkprop]
1107                     if not props.has_key(linkprop):
1108                         if linkid is None or linkid.startswith('-'):
1109                             # linking to a new item
1110                             if isinstance(propdef, hyperdb.Multilink):
1111                                 props[linkprop] = [newid]
1112                             else:
1113                                 props[linkprop] = newid
1114                         else:
1115                             # linking to an existing item
1116                             if isinstance(propdef, hyperdb.Multilink):
1117                                 existing = cl.get(linkid, linkprop)[:]
1118                                 existing.append(nodeid)
1119                                 props[linkprop] = existing
1120                             else:
1121                                 props[linkprop] = newid
1123         return '<br>'.join(m)
1125     def _changenode(self, cn, nodeid, props):
1126         ''' change the node based on the contents of the form
1127         '''
1128         # check for permission
1129         if not self.editItemPermission(props):
1130             raise Unauthorised, 'You do not have permission to edit %s'%cn
1132         # make the changes
1133         cl = self.db.classes[cn]
1134         return cl.set(nodeid, **props)
1136     def _createnode(self, cn, props):
1137         ''' create a node based on the contents of the form
1138         '''
1139         # check for permission
1140         if not self.newItemPermission(props):
1141             raise Unauthorised, 'You do not have permission to create %s'%cn
1143         # create the node and return its id
1144         cl = self.db.classes[cn]
1145         return cl.create(**props)
1147     # 
1148     # More actions
1149     #
1150     def editCSVAction(self):
1151         """ Performs an edit of all of a class' items in one go.
1153             The "rows" CGI var defines the CSV-formatted entries for the
1154             class. New nodes are identified by the ID 'X' (or any other
1155             non-existent ID) and removed lines are retired.
1156         """
1157         # this is per-class only
1158         if not self.editCSVPermission():
1159             self.error_message.append(
1160                  _('You do not have permission to edit %s' %self.classname))
1161             return
1163         # get the CSV module
1164         if rcsv.error:
1165             self.error_message.append(_(rcsv.error))
1166             return
1168         cl = self.db.classes[self.classname]
1169         idlessprops = cl.getprops(protected=0).keys()
1170         idlessprops.sort()
1171         props = ['id'] + idlessprops
1173         # do the edit
1174         rows = StringIO.StringIO(self.form['rows'].value)
1175         reader = rcsv.reader(rows, rcsv.comma_separated)
1176         found = {}
1177         line = 0
1178         for values in reader:
1179             line += 1
1180             if line == 1: continue
1181             # skip property names header
1182             if values == props:
1183                 continue
1185             # extract the nodeid
1186             nodeid, values = values[0], values[1:]
1187             found[nodeid] = 1
1189             # see if the node exists
1190             if nodeid in ('x', 'X') or not cl.hasnode(nodeid):
1191                 exists = 0
1192             else:
1193                 exists = 1
1195             # confirm correct weight
1196             if len(idlessprops) != len(values):
1197                 self.error_message.append(
1198                     _('Not enough values on line %(line)s')%{'line':line})
1199                 return
1201             # extract the new values
1202             d = {}
1203             for name, value in zip(idlessprops, values):
1204                 prop = cl.properties[name]
1205                 value = value.strip()
1206                 # only add the property if it has a value
1207                 if value:
1208                     # if it's a multilink, split it
1209                     if isinstance(prop, hyperdb.Multilink):
1210                         value = value.split(':')
1211                     elif isinstance(prop, hyperdb.Password):
1212                         value = password.Password(value)
1213                     elif isinstance(prop, hyperdb.Interval):
1214                         value = date.Interval(value)
1215                     elif isinstance(prop, hyperdb.Date):
1216                         value = date.Date(value)
1217                     elif isinstance(prop, hyperdb.Boolean):
1218                         value = value.lower() in ('yes', 'true', 'on', '1')
1219                     elif isinstance(prop, hyperdb.Number):
1220                         value = float(value)
1221                     d[name] = value
1222                 elif exists:
1223                     # nuke the existing value
1224                     if isinstance(prop, hyperdb.Multilink):
1225                         d[name] = []
1226                     else:
1227                         d[name] = None
1229             # perform the edit
1230             if exists:
1231                 # edit existing
1232                 cl.set(nodeid, **d)
1233             else:
1234                 # new node
1235                 found[cl.create(**d)] = 1
1237         # retire the removed entries
1238         for nodeid in cl.list():
1239             if not found.has_key(nodeid):
1240                 cl.retire(nodeid)
1242         # all OK
1243         self.db.commit()
1245         self.ok_message.append(_('Items edited OK'))
1247     def editCSVPermission(self):
1248         ''' Determine whether the user has permission to edit this class.
1250             Base behaviour is to check the user can edit this class.
1251         ''' 
1252         if not self.db.security.hasPermission('Edit', self.userid,
1253                 self.classname):
1254             return 0
1255         return 1
1257     def searchAction(self, wcre=re.compile(r'[\s,]+')):
1258         ''' Mangle some of the form variables.
1260             Set the form ":filter" variable based on the values of the
1261             filter variables - if they're set to anything other than
1262             "dontcare" then add them to :filter.
1264             Handle the ":queryname" variable and save off the query to
1265             the user's query list.
1267             Split any String query values on whitespace and comma.
1268         '''
1269         # generic edit is per-class only
1270         if not self.searchPermission():
1271             self.error_message.append(
1272                 _('You do not have permission to search %s' %self.classname))
1273             return
1275         # add a faked :filter form variable for each filtering prop
1276         props = self.db.classes[self.classname].getprops()
1277         queryname = ''
1278         for key in self.form.keys():
1279             # special vars
1280             if self.FV_QUERYNAME.match(key):
1281                 queryname = self.form[key].value.strip()
1282                 continue
1284             if not props.has_key(key):
1285                 continue
1286             if isinstance(self.form[key], type([])):
1287                 # search for at least one entry which is not empty
1288                 for minifield in self.form[key]:
1289                     if minifield.value:
1290                         break
1291                 else:
1292                     continue
1293             else:
1294                 if not self.form[key].value:
1295                     continue
1296                 if isinstance(props[key], hyperdb.String):
1297                     v = self.form[key].value
1298                     l = token.token_split(v)
1299                     if len(l) > 1 or l[0] != v:
1300                         self.form.value.remove(self.form[key])
1301                         # replace the single value with the split list
1302                         for v in l:
1303                             self.form.value.append(cgi.MiniFieldStorage(key, v))
1305             self.form.value.append(cgi.MiniFieldStorage('@filter', key))
1307         # handle saving the query params
1308         if queryname:
1309             # parse the environment and figure what the query _is_
1310             req = HTMLRequest(self)
1312             # The [1:] strips off the '?' character, it isn't part of the
1313             # query string.
1314             url = req.indexargs_href('', {})[1:]
1316             # handle editing an existing query
1317             try:
1318                 qid = self.db.query.lookup(queryname)
1319                 self.db.query.set(qid, klass=self.classname, url=url)
1320             except KeyError:
1321                 # create a query
1322                 qid = self.db.query.create(name=queryname,
1323                     klass=self.classname, url=url)
1325             # and add it to the user's query multilink
1326             queries = self.db.user.get(self.userid, 'queries')
1327             queries.append(qid)
1328             self.db.user.set(self.userid, queries=queries)
1330             # commit the query change to the database
1331             self.db.commit()
1333     def searchPermission(self):
1334         ''' Determine whether the user has permission to search this class.
1336             Base behaviour is to check the user can view this class.
1337         ''' 
1338         if not self.db.security.hasPermission('View', self.userid,
1339                 self.classname):
1340             return 0
1341         return 1
1344     def retireAction(self):
1345         ''' Retire the context item.
1346         '''
1347         # if we want to view the index template now, then unset the nodeid
1348         # context info (a special-case for retire actions on the index page)
1349         nodeid = self.nodeid
1350         if self.template == 'index':
1351             self.nodeid = None
1353         # generic edit is per-class only
1354         if not self.retirePermission():
1355             self.error_message.append(
1356                 _('You do not have permission to retire %s' %self.classname))
1357             return
1359         # make sure we don't try to retire admin or anonymous
1360         if self.classname == 'user' and \
1361                 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1362             self.error_message.append(
1363                 _('You may not retire the admin or anonymous user'))
1364             return
1366         # do the retire
1367         self.db.getclass(self.classname).retire(nodeid)
1368         self.db.commit()
1370         self.ok_message.append(
1371             _('%(classname)s %(itemid)s has been retired')%{
1372                 'classname': self.classname.capitalize(), 'itemid': nodeid})
1374     def retirePermission(self):
1375         ''' Determine whether the user has permission to retire this class.
1377             Base behaviour is to check the user can edit this class.
1378         ''' 
1379         if not self.db.security.hasPermission('Edit', self.userid,
1380                 self.classname):
1381             return 0
1382         return 1
1385     def showAction(self, typere=re.compile('[@:]type'),
1386             numre=re.compile('[@:]number')):
1387         ''' Show a node of a particular class/id
1388         '''
1389         t = n = ''
1390         for key in self.form.keys():
1391             if typere.match(key):
1392                 t = self.form[key].value.strip()
1393             elif numre.match(key):
1394                 n = self.form[key].value.strip()
1395         if not t:
1396             raise ValueError, 'Invalid %s number'%t
1397         url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1398         raise Redirect, url
1400     def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1401         """ Item properties and their values are edited with html FORM
1402             variables and their values. You can:
1404             - Change the value of some property of the current item.
1405             - Create a new item of any class, and edit the new item's
1406               properties,
1407             - Attach newly created items to a multilink property of the
1408               current item.
1409             - Remove items from a multilink property of the current item.
1410             - Specify that some properties are required for the edit
1411               operation to be successful.
1413             In the following, <bracketed> values are variable, "@" may be
1414             either ":" or "@", and other text "required" is fixed.
1416             Most properties are specified as form variables:
1418              <propname>
1419               - property on the current context item
1421              <designator>"@"<propname>
1422               - property on the indicated item (for editing related
1423                 information)
1425             Designators name a specific item of a class.
1427             <classname><N>
1429                 Name an existing item of class <classname>.
1431             <classname>"-"<N>
1433                 Name the <N>th new item of class <classname>. If the form
1434                 submission is successful, a new item of <classname> is
1435                 created. Within the submitted form, a particular
1436                 designator of this form always refers to the same new
1437                 item.
1439             Once we have determined the "propname", we look at it to see
1440             if it's special:
1442             @required
1443                 The associated form value is a comma-separated list of
1444                 property names that must be specified when the form is
1445                 submitted for the edit operation to succeed.  
1447                 When the <designator> is missing, the properties are
1448                 for the current context item.  When <designator> is
1449                 present, they are for the item specified by
1450                 <designator>.
1452                 The "@required" specifier must come before any of the
1453                 properties it refers to are assigned in the form.
1455             @remove@<propname>=id(s) or @add@<propname>=id(s)
1456                 The "@add@" and "@remove@" edit actions apply only to
1457                 Multilink properties.  The form value must be a
1458                 comma-separate list of keys for the class specified by
1459                 the simple form variable.  The listed items are added
1460                 to (respectively, removed from) the specified
1461                 property.
1463             @link@<propname>=<designator>
1464                 If the edit action is "@link@", the simple form
1465                 variable must specify a Link or Multilink property.
1466                 The form value is a comma-separated list of
1467                 designators.  The item corresponding to each
1468                 designator is linked to the property given by simple
1469                 form variable.  These are collected up and returned in
1470                 all_links.
1472             None of the above (ie. just a simple form value)
1473                 The value of the form variable is converted
1474                 appropriately, depending on the type of the property.
1476                 For a Link('klass') property, the form value is a
1477                 single key for 'klass', where the key field is
1478                 specified in dbinit.py.  
1480                 For a Multilink('klass') property, the form value is a
1481                 comma-separated list of keys for 'klass', where the
1482                 key field is specified in dbinit.py.  
1484                 Note that for simple-form-variables specifiying Link
1485                 and Multilink properties, the linked-to class must
1486                 have a key field.
1488                 For a String() property specifying a filename, the
1489                 file named by the form value is uploaded. This means we
1490                 try to set additional properties "filename" and "type" (if
1491                 they are valid for the class).  Otherwise, the property
1492                 is set to the form value.
1494                 For Date(), Interval(), Boolean(), and Number()
1495                 properties, the form value is converted to the
1496                 appropriate
1498             Any of the form variables may be prefixed with a classname or
1499             designator.
1501             Two special form values are supported for backwards
1502             compatibility:
1504             @note
1505                 This is equivalent to::
1507                     @link@messages=msg-1
1508                     msg-1@content=value
1510                 except that in addition, the "author" and "date"
1511                 properties of "msg-1" are set to the userid of the
1512                 submitter, and the current time, respectively.
1514             @file
1515                 This is equivalent to::
1517                     @link@files=file-1
1518                     file-1@content=value
1520                 The String content value is handled as described above for
1521                 file uploads.
1523             If both the "@note" and "@file" form variables are
1524             specified, the action::
1526                     @link@msg-1@files=file-1
1528             is also performed.
1530             We also check that FileClass items have a "content" property with
1531             actual content, otherwise we remove them from all_props before
1532             returning.
1534             The return from this method is a dict of 
1535                 (classname, id): properties
1536             ... this dict _always_ has an entry for the current context,
1537             even if it's empty (ie. a submission for an existing issue that
1538             doesn't result in any changes would return {('issue','123'): {}})
1539             The id may be None, which indicates that an item should be
1540             created.
1541         """
1542         # some very useful variables
1543         db = self.db
1544         form = self.form
1546         if not hasattr(self, 'FV_SPECIAL'):
1547             # generate the regexp for handling special form values
1548             classes = '|'.join(db.classes.keys())
1549             # specials for parsePropsFromForm
1550             # handle the various forms (see unit tests)
1551             self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1552             self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1554         # these indicate the default class / item
1555         default_cn = self.classname
1556         default_cl = self.db.classes[default_cn]
1557         default_nodeid = self.nodeid
1559         # we'll store info about the individual class/item edit in these
1560         all_required = {}       # required props per class/item
1561         all_props = {}          # props to set per class/item
1562         got_props = {}          # props received per class/item
1563         all_propdef = {}        # note - only one entry per class
1564         all_links = []          # as many as are required
1566         # we should always return something, even empty, for the context
1567         all_props[(default_cn, default_nodeid)] = {}
1569         keys = form.keys()
1570         timezone = db.getUserTimezone()
1572         # sentinels for the :note and :file props
1573         have_note = have_file = 0
1575         # extract the usable form labels from the form
1576         matches = []
1577         for key in keys:
1578             m = self.FV_SPECIAL.match(key)
1579             if m:
1580                 matches.append((key, m.groupdict()))
1582         # now handle the matches
1583         for key, d in matches:
1584             if d['classname']:
1585                 # we got a designator
1586                 cn = d['classname']
1587                 cl = self.db.classes[cn]
1588                 nodeid = d['id']
1589                 propname = d['propname']
1590             elif d['note']:
1591                 # the special note field
1592                 cn = 'msg'
1593                 cl = self.db.classes[cn]
1594                 nodeid = '-1'
1595                 propname = 'content'
1596                 all_links.append((default_cn, default_nodeid, 'messages',
1597                     [('msg', '-1')]))
1598                 have_note = 1
1599             elif d['file']:
1600                 # the special file field
1601                 cn = 'file'
1602                 cl = self.db.classes[cn]
1603                 nodeid = '-1'
1604                 propname = 'content'
1605                 all_links.append((default_cn, default_nodeid, 'files',
1606                     [('file', '-1')]))
1607                 have_file = 1
1608             else:
1609                 # default
1610                 cn = default_cn
1611                 cl = default_cl
1612                 nodeid = default_nodeid
1613                 propname = d['propname']
1615             # the thing this value relates to is...
1616             this = (cn, nodeid)
1618             # get more info about the class, and the current set of
1619             # form props for it
1620             if not all_propdef.has_key(cn):
1621                 all_propdef[cn] = cl.getprops()
1622             propdef = all_propdef[cn]
1623             if not all_props.has_key(this):
1624                 all_props[this] = {}
1625             props = all_props[this]
1626             if not got_props.has_key(this):
1627                 got_props[this] = {}
1629             # is this a link command?
1630             if d['link']:
1631                 value = []
1632                 for entry in extractFormList(form[key]):
1633                     m = self.FV_DESIGNATOR.match(entry)
1634                     if not m:
1635                         raise FormError, \
1636                             'link "%s" value "%s" not a designator'%(key, entry)
1637                     value.append((m.group(1), m.group(2)))
1639                 # make sure the link property is valid
1640                 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1641                         not isinstance(propdef[propname], hyperdb.Link)):
1642                     raise FormError, '%s %s is not a link or '\
1643                         'multilink property'%(cn, propname)
1645                 all_links.append((cn, nodeid, propname, value))
1646                 continue
1648             # detect the special ":required" variable
1649             if d['required']:
1650                 all_required[this] = extractFormList(form[key])
1651                 continue
1653             # see if we're performing a special multilink action
1654             mlaction = 'set'
1655             if d['remove']:
1656                 mlaction = 'remove'
1657             elif d['add']:
1658                 mlaction = 'add'
1660             # does the property exist?
1661             if not propdef.has_key(propname):
1662                 if mlaction != 'set':
1663                     raise FormError, 'You have submitted a %s action for'\
1664                         ' the property "%s" which doesn\'t exist'%(mlaction,
1665                         propname)
1666                 # the form element is probably just something we don't care
1667                 # about - ignore it
1668                 continue
1669             proptype = propdef[propname]
1671             # Get the form value. This value may be a MiniFieldStorage or a list
1672             # of MiniFieldStorages.
1673             value = form[key]
1675             # handle unpacking of the MiniFieldStorage / list form value
1676             if isinstance(proptype, hyperdb.Multilink):
1677                 value = extractFormList(value)
1678             else:
1679                 # multiple values are not OK
1680                 if isinstance(value, type([])):
1681                     raise FormError, 'You have submitted more than one value'\
1682                         ' for the %s property'%propname
1683                 # value might be a file upload...
1684                 if not hasattr(value, 'filename') or value.filename is None:
1685                     # nope, pull out the value and strip it
1686                     value = value.value.strip()
1688             # now that we have the props field, we need a teensy little
1689             # extra bit of help for the old :note field...
1690             if d['note'] and value:
1691                 props['author'] = self.db.getuid()
1692                 props['date'] = date.Date()
1694             # handle by type now
1695             if isinstance(proptype, hyperdb.Password):
1696                 if not value:
1697                     # ignore empty password values
1698                     continue
1699                 for key, d in matches:
1700                     if d['confirm'] and d['propname'] == propname:
1701                         confirm = form[key]
1702                         break
1703                 else:
1704                     raise FormError, 'Password and confirmation text do '\
1705                         'not match'
1706                 if isinstance(confirm, type([])):
1707                     raise FormError, 'You have submitted more than one value'\
1708                         ' for the %s property'%propname
1709                 if value != confirm.value:
1710                     raise FormError, 'Password and confirmation text do '\
1711                         'not match'
1712                 try:
1713                     value = password.Password(value)
1714                 except hyperdb.HyperdbValueError, msg:
1715                     raise FormError, msg
1717             elif isinstance(proptype, hyperdb.Multilink):
1718                 # convert input to list of ids
1719                 try:
1720                     l = hyperdb.rawToHyperdb(self.db, cl, nodeid,
1721                         propname, value)
1722                 except hyperdb.HyperdbValueError, msg:
1723                     raise FormError, msg
1725                 # now use that list of ids to modify the multilink
1726                 if mlaction == 'set':
1727                     value = l
1728                 else:
1729                     # we're modifying the list - get the current list of ids
1730                     if props.has_key(propname):
1731                         existing = props[propname]
1732                     elif nodeid and not nodeid.startswith('-'):
1733                         existing = cl.get(nodeid, propname, [])
1734                     else:
1735                         existing = []
1737                     # now either remove or add
1738                     if mlaction == 'remove':
1739                         # remove - handle situation where the id isn't in
1740                         # the list
1741                         for entry in l:
1742                             try:
1743                                 existing.remove(entry)
1744                             except ValueError:
1745                                 raise FormError, _('property "%(propname)s": '
1746                                     '"%(value)s" not currently in list')%{
1747                                     'propname': propname, 'value': entry}
1748                     else:
1749                         # add - easy, just don't dupe
1750                         for entry in l:
1751                             if entry not in existing:
1752                                 existing.append(entry)
1753                     value = existing
1754                     value.sort()
1756             elif value == '':
1757                 # other types should be None'd if there's no value
1758                 value = None
1759             else:
1760                 # handle all other types
1761                 try:
1762                     if isinstance(proptype, hyperdb.String):
1763                         if (hasattr(value, 'filename') and
1764                                 value.filename is not None):
1765                             # skip if the upload is empty
1766                             if not value.filename:
1767                                 continue
1768                             # this String is actually a _file_
1769                             # try to determine the file content-type
1770                             fn = value.filename.split('\\')[-1]
1771                             if propdef.has_key('name'):
1772                                 props['name'] = fn
1773                             # use this info as the type/filename properties
1774                             if propdef.has_key('type'):
1775                                 props['type'] = mimetypes.guess_type(fn)[0]
1776                                 if not props['type']:
1777                                     props['type'] = "application/octet-stream"
1778                             # finally, read the content RAW
1779                             value = value.value
1780                         else:
1781                             value = hyperdb.rawToHyperdb(self.db, cl,
1782                                 nodeid, propname, value)
1784                     else:
1785                         value = hyperdb.rawToHyperdb(self.db, cl, nodeid,
1786                             propname, value)
1787                 except hyperdb.HyperdbValueError, msg:
1788                     raise FormError, msg
1790             # register that we got this property
1791             if value:
1792                 got_props[this][propname] = 1
1794             # get the old value
1795             if nodeid and not nodeid.startswith('-'):
1796                 try:
1797                     existing = cl.get(nodeid, propname)
1798                 except KeyError:
1799                     # this might be a new property for which there is
1800                     # no existing value
1801                     if not propdef.has_key(propname):
1802                         raise
1803                 except IndexError, message:
1804                     raise FormError(str(message))
1806                 # make sure the existing multilink is sorted
1807                 if isinstance(proptype, hyperdb.Multilink):
1808                     existing.sort()
1810                 # "missing" existing values may not be None
1811                 if not existing:
1812                     if isinstance(proptype, hyperdb.String) and not existing:
1813                         # some backends store "missing" Strings as empty strings
1814                         existing = None
1815                     elif isinstance(proptype, hyperdb.Number) and not existing:
1816                         # some backends store "missing" Numbers as 0 :(
1817                         existing = 0
1818                     elif isinstance(proptype, hyperdb.Boolean) and not existing:
1819                         # likewise Booleans
1820                         existing = 0
1822                 # if changed, set it
1823                 if value != existing:
1824                     props[propname] = value
1825             else:
1826                 # don't bother setting empty/unset values
1827                 if value is None:
1828                     continue
1829                 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1830                     continue
1831                 elif isinstance(proptype, hyperdb.String) and value == '':
1832                     continue
1834                 props[propname] = value
1836         # check to see if we need to specially link a file to the note
1837         if have_note and have_file:
1838             all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1840         # see if all the required properties have been supplied
1841         s = []
1842         for thing, required in all_required.items():
1843             # register the values we got
1844             got = got_props.get(thing, {})
1845             for entry in required[:]:
1846                 if got.has_key(entry):
1847                     required.remove(entry)
1849             # any required values not present?
1850             if not required:
1851                 continue
1853             # tell the user to entry the values required
1854             if len(required) > 1:
1855                 p = 'properties'
1856             else:
1857                 p = 'property'
1858             s.append('Required %s %s %s not supplied'%(thing[0], p,
1859                 ', '.join(required)))
1860         if s:
1861             raise FormError, '\n'.join(s)
1863         # When creating a FileClass node, it should have a non-empty content
1864         # property to be created. When editing a FileClass node, it should
1865         # either have a non-empty content property or no property at all. In
1866         # the latter case, nothing will change.
1867         for (cn, id), props in all_props.items():
1868             if isinstance(self.db.classes[cn], hyperdb.FileClass):
1869                 if id == '-1':
1870                     if not props.get('content', ''):
1871                         del all_props[(cn, id)]
1872                 elif props.has_key('content') and not props['content']:
1873                     raise FormError, _('File is empty')
1874         return all_props, all_links
1876 def extractFormList(value):
1877     ''' Extract a list of values from the form value.
1879         It may be one of:
1880          [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
1881          MiniFieldStorage('value,value,...')
1882          MiniFieldStorage('value')
1883     '''
1884     # multiple values are OK
1885     if isinstance(value, type([])):
1886         # it's a list of MiniFieldStorages - join then into
1887         values = ','.join([i.value.strip() for i in value])
1888     else:
1889         # it's a MiniFieldStorage, but may be a comma-separated list
1890         # of values
1891         values = value.value
1893     value = [i.strip() for i in values.split(',')]
1895     # filter out the empty bits
1896     return filter(None, value)