Code

fe8831371b8590243b153b644e93d7bd4bef0411
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.149 2003-12-05 03:28: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.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)
471         if classname != 'file':
472             raise NotFound, designator
474         self.opendb('admin')
475         file = self.db.file
477         mime_type = file.get(nodeid, 'type')
478         content = file.get(nodeid, 'content')
479         lmt = file.get(nodeid, 'activity').timestamp()
481         self._serve_file(lmt, mime_type, content)
483     def serve_static_file(self, file):
484         ''' Serve up the file named from the templates dir
485         '''
486         filename = os.path.join(self.instance.config.TEMPLATES, file)
488         # last-modified time
489         lmt = os.stat(filename)[stat.ST_MTIME]
491         # detemine meta-type
492         file = str(file)
493         mime_type = mimetypes.guess_type(file)[0]
494         if not mime_type:
495             if file.endswith('.css'):
496                 mime_type = 'text/css'
497             else:
498                 mime_type = 'text/plain'
500         # snarf the content
501         f = open(filename, 'rb')
502         try:
503             content = f.read()
504         finally:
505             f.close()
507         self._serve_file(lmt, mime_type, content)
509     def _serve_file(self, last_modified, mime_type, content):
510         ''' guts of serve_file() and serve_static_file()
511         '''
512         ims = None
513         # see if there's an if-modified-since...
514         if hasattr(self.request, 'headers'):
515             ims = self.request.headers.getheader('if-modified-since')
516         elif self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
517             # cgi will put the header in the env var
518             ims = self.env['HTTP_IF_MODIFIED_SINCE']
519         if ims:
520             ims = rfc822.parsedate(ims)[:6]
521             lmtt = time.gmtime(lmt)[:6]
522             if lmtt <= ims:
523                 raise NotModified
525         # spit out headers
526         self.additional_headers['Content-Type'] = mime_type
527         self.additional_headers['Content-Length'] = len(content)
528         lmt = rfc822.formatdate(last_modified)
529         self.additional_headers['Last-Modifed'] = lmt
530         self.write(content)
532     def renderContext(self):
533         ''' Return a PageTemplate for the named page
534         '''
535         name = self.classname
536         extension = self.template
537         pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
539         # catch errors so we can handle PT rendering errors more nicely
540         args = {
541             'ok_message': self.ok_message,
542             'error_message': self.error_message
543         }
544         try:
545             # let the template render figure stuff out
546             return pt.render(self, None, None, **args)
547         except NoTemplate, message:
548             return '<strong>%s</strong>'%message
549         except:
550             # everything else
551             return cgitb.pt_html()
553     # these are the actions that are available
554     actions = (
555         ('edit',     'editItemAction'),
556         ('editcsv',  'editCSVAction'),
557         ('new',      'newItemAction'),
558         ('register', 'registerAction'),
559         ('confrego', 'confRegoAction'),
560         ('passrst',  'passResetAction'),
561         ('login',    'loginAction'),
562         ('logout',   'logout_action'),
563         ('search',   'searchAction'),
564         ('retire',   'retireAction'),
565         ('show',     'showAction'),
566     )
567     def handle_action(self):
568         ''' Determine whether there should be an Action called.
570             The action is defined by the form variable :action which
571             identifies the method on this object to call. The actions
572             are defined in the "actions" sequence on this class.
573         '''
574         if self.form.has_key(':action'):
575             action = self.form[':action'].value.lower()
576         elif self.form.has_key('@action'):
577             action = self.form['@action'].value.lower()
578         else:
579             return None
580         try:
581             # get the action, validate it
582             for name, method in self.actions:
583                 if name == action:
584                     break
585             else:
586                 raise ValueError, 'No such action "%s"'%action
587             # call the mapped action
588             getattr(self, method)()
589         except Redirect:
590             raise
591         except Unauthorised:
592             raise
594     def write(self, content):
595         if not self.headers_done:
596             self.header()
597         self.request.wfile.write(content)
599     def header(self, headers=None, response=None):
600         '''Put up the appropriate header.
601         '''
602         if headers is None:
603             headers = {'Content-Type':'text/html'}
604         if response is None:
605             response = self.response_code
607         # update with additional info
608         headers.update(self.additional_headers)
610         if not headers.has_key('Content-Type'):
611             headers['Content-Type'] = 'text/html'
612         self.request.send_response(response)
613         for entry in headers.items():
614             self.request.send_header(*entry)
615         self.request.end_headers()
616         self.headers_done = 1
617         if self.debug:
618             self.headers_sent = headers
620     def set_cookie(self, user):
621         """Set up a session cookie for the user.
623         Also store away the user's login info against the session.
624         """
625         # TODO generate a much, much stronger session key ;)
626         self.session = binascii.b2a_base64(repr(random.random())).strip()
628         # clean up the base64
629         if self.session[-1] == '=':
630             if self.session[-2] == '=':
631                 self.session = self.session[:-2]
632             else:
633                 self.session = self.session[:-1]
635         # insert the session in the sessiondb
636         self.db.sessions.set(self.session, user=user, last_use=time.time())
638         # and commit immediately
639         self.db.sessions.commit()
641         # expire us in a long, long time
642         expire = Cookie._getdate(86400*365)
644         # generate the cookie path - make sure it has a trailing '/'
645         self.additional_headers['Set-Cookie'] = \
646           '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
647             expire, self.cookie_path)
649     def make_user_anonymous(self):
650         ''' Make us anonymous
652             This method used to handle non-existence of the 'anonymous'
653             user, but that user is mandatory now.
654         '''
655         self.userid = self.db.user.lookup('anonymous')
656         self.user = 'anonymous'
658     def opendb(self, user):
659         ''' Open the database.
660         '''
661         # open the db if the user has changed
662         if not hasattr(self, 'db') or user != self.db.journaltag:
663             if hasattr(self, 'db'):
664                 self.db.close()
665             self.db = self.instance.open(user)
667     #
668     # Actions
669     #
670     def loginAction(self):
671         ''' Attempt to log a user in.
673             Sets up a session for the user which contains the login
674             credentials.
675         '''
676         # we need the username at a minimum
677         if not self.form.has_key('__login_name'):
678             self.error_message.append(_('Username required'))
679             return
681         # get the login info
682         self.user = self.form['__login_name'].value
683         if self.form.has_key('__login_password'):
684             password = self.form['__login_password'].value
685         else:
686             password = ''
688         # make sure the user exists
689         try:
690             self.userid = self.db.user.lookup(self.user)
691         except KeyError:
692             name = self.user
693             self.error_message.append(_('No such user "%(name)s"')%locals())
694             self.make_user_anonymous()
695             return
697         # verify the password
698         if not self.verifyPassword(self.userid, password):
699             self.make_user_anonymous()
700             self.error_message.append(_('Incorrect password'))
701             return
703         # make sure we're allowed to be here
704         if not self.loginPermission():
705             self.make_user_anonymous()
706             self.error_message.append(_("You do not have permission to login"))
707             return
709         # now we're OK, re-open the database for real, using the user
710         self.opendb(self.user)
712         # set the session cookie
713         self.set_cookie(self.user)
715     def verifyPassword(self, userid, password):
716         ''' Verify the password that the user has supplied
717         '''
718         stored = self.db.user.get(self.userid, 'password')
719         if password == stored:
720             return 1
721         if not password and not stored:
722             return 1
723         return 0
725     def loginPermission(self):
726         ''' Determine whether the user has permission to log in.
728             Base behaviour is to check the user has "Web Access".
729         ''' 
730         if not self.db.security.hasPermission('Web Access', self.userid):
731             return 0
732         return 1
734     def logout_action(self):
735         ''' Make us really anonymous - nuke the cookie too
736         '''
737         # log us out
738         self.make_user_anonymous()
740         # construct the logout cookie
741         now = Cookie._getdate()
742         self.additional_headers['Set-Cookie'] = \
743            '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.cookie_name,
744             now, self.cookie_path)
746         # Let the user know what's going on
747         self.ok_message.append(_('You are logged out'))
749     def registerAction(self):
750         '''Attempt to create a new user based on the contents of the form
751         and then set the cookie.
753         return 1 on successful login
754         '''
755         props = self.parsePropsFromForm()[0][('user', None)]
757         # make sure we're allowed to register
758         if not self.registerPermission(props):
759             raise Unauthorised, _("You do not have permission to register")
761         try:
762             self.db.user.lookup(props['username'])
763             self.error_message.append('Error: A user with the username "%s" '
764                 'already exists'%props['username'])
765             return
766         except KeyError:
767             pass
769         # generate the one-time-key and store the props for later
770         otk = ''.join([random.choice(chars) for x in range(32)])
771         for propname, proptype in self.db.user.getprops().items():
772             value = props.get(propname, None)
773             if value is None:
774                 pass
775             elif isinstance(proptype, hyperdb.Date):
776                 props[propname] = str(value)
777             elif isinstance(proptype, hyperdb.Interval):
778                 props[propname] = str(value)
779             elif isinstance(proptype, hyperdb.Password):
780                 props[propname] = str(value)
781         props['__time'] = time.time()
782         self.db.otks.set(otk, **props)
784         # send the email
785         tracker_name = self.db.config.TRACKER_NAME
786         tracker_email = self.db.config.TRACKER_EMAIL
787         subject = 'Complete your registration to %s -- key %s' % (tracker_name,
788                                                                   otk)
789         body = """To complete your registration of the user "%(name)s" with
790 %(tracker)s, please do one of the following:
792 - send a reply to %(tracker_email)s and maintain the subject line as is (the
793 reply's additional "Re:" is ok),
795 - or visit the following URL:
797    %(url)s?@action=confrego&otk=%(otk)s
798 """ % {'name': props['username'], 'tracker': tracker_name, 'url': self.base,
799        'otk': otk, 'tracker_email': tracker_email}
800         if not self.standard_message([props['address']], subject, body,
801                                      tracker_email):
802             return
804         # commit changes to the database
805         self.db.commit()
807         # redirect to the "you're almost there" page
808         raise Redirect, '%suser?@template=rego_progress'%self.base
810     def standard_message(self, to, subject, body, author=None):
811         try:
812             self.mailer.standard_message(to, subject, body, author)
813             return 1
814         except MessageSendError, e:
815             self.error_message.append(str(e))
816             
817     def registerPermission(self, props):
818         ''' Determine whether the user has permission to register
820             Base behaviour is to check the user has "Web Registration".
821         '''
822         # registration isn't allowed to supply roles
823         if props.has_key('roles'):
824             return 0
825         if self.db.security.hasPermission('Web Registration', self.userid):
826             return 1
827         return 0
829     def confRegoAction(self):
830         ''' Grab the OTK, use it to load up the new user details
831         '''
832         try:
833             # pull the rego information out of the otk database
834             self.userid = self.db.confirm_registration(self.form['otk'].value)
835         except (ValueError, KeyError), message:
836             # XXX: we need to make the "default" page be able to display errors!
837             self.error_message.append(str(message))
838             return
839         
840         # log the new user in
841         self.user = self.db.user.get(self.userid, 'username')
842         # re-open the database for real, using the user
843         self.opendb(self.user)
845         # if we have a session, update it
846         if hasattr(self, 'session'):
847             self.db.sessions.set(self.session, user=self.user,
848                 last_use=time.time())
849         else:
850             # new session cookie
851             self.set_cookie(self.user)
853         # nice message
854         message = _('You are now registered, welcome!')
856         # redirect to the user's page
857         raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
858             self.userid, urllib.quote(message))
860     def passResetAction(self):
861         ''' Handle password reset requests.
863             Presence of either "name" or "address" generate email.
864             Presense of "otk" performs the reset.
865         '''
866         if self.form.has_key('otk'):
867             # pull the rego information out of the otk database
868             otk = self.form['otk'].value
869             uid = self.db.otks.get(otk, 'uid')
870             if uid is None:
871                 self.error_message.append("""Invalid One Time Key!
872 (a Mozilla bug may cause this message to show up erroneously,
873  please check your email)""")
874                 return
876             # re-open the database as "admin"
877             if self.user != 'admin':
878                 self.opendb('admin')
880             # change the password
881             newpw = password.generatePassword()
883             cl = self.db.user
884 # XXX we need to make the "default" page be able to display errors!
885             try:
886                 # set the password
887                 cl.set(uid, password=password.Password(newpw))
888                 # clear the props from the otk database
889                 self.db.otks.destroy(otk)
890                 self.db.commit()
891             except (ValueError, KeyError), message:
892                 self.error_message.append(str(message))
893                 return
895             # user info
896             address = self.db.user.get(uid, 'address')
897             name = self.db.user.get(uid, 'username')
899             # send the email
900             tracker_name = self.db.config.TRACKER_NAME
901             subject = 'Password reset for %s'%tracker_name
902             body = '''
903 The password has been reset for username "%(name)s".
905 Your password is now: %(password)s
906 '''%{'name': name, 'password': newpw}
907             if not self.standard_message([address], subject, body):
908                 return
910             self.ok_message.append('Password reset and email sent to %s' %
911                                    address)
912             return
914         # no OTK, so now figure the user
915         if self.form.has_key('username'):
916             name = self.form['username'].value
917             try:
918                 uid = self.db.user.lookup(name)
919             except KeyError:
920                 self.error_message.append('Unknown username')
921                 return
922             address = self.db.user.get(uid, 'address')
923         elif self.form.has_key('address'):
924             address = self.form['address'].value
925             uid = uidFromAddress(self.db, ('', address), create=0)
926             if not uid:
927                 self.error_message.append('Unknown email address')
928                 return
929             name = self.db.user.get(uid, 'username')
930         else:
931             self.error_message.append('You need to specify a username '
932                 'or address')
933             return
935         # generate the one-time-key and store the props for later
936         otk = ''.join([random.choice(chars) for x in range(32)])
937         self.db.otks.set(otk, uid=uid, __time=time.time())
939         # send the email
940         tracker_name = self.db.config.TRACKER_NAME
941         subject = 'Confirm reset of password for %s'%tracker_name
942         body = '''
943 Someone, perhaps you, has requested that the password be changed for your
944 username, "%(name)s". If you wish to proceed with the change, please follow
945 the link below:
947   %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
949 You should then receive another email with the new password.
950 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
951         if not self.standard_message([address], subject, body):
952             return
954         self.ok_message.append('Email sent to %s'%address)
956     def editItemAction(self):
957         ''' Perform an edit of an item in the database.
959            See parsePropsFromForm and _editnodes for special variables
960         '''
961         props, links = self.parsePropsFromForm()
963         # handle the props
964         try:
965             message = self._editnodes(props, links)
966         except (ValueError, KeyError, IndexError), message:
967             self.error_message.append(_('Apply Error: ') + str(message))
968             return
970         # commit now that all the tricky stuff is done
971         self.db.commit()
973         # redirect to the item's edit page
974         raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
975             self.classname, self.nodeid, urllib.quote(message),
976             urllib.quote(self.template))
978     newItemAction = editItemAction
980     def editItemPermission(self, props):
981         """Determine whether the user has permission to edit this item.
983         Base behaviour is to check the user can edit this class. If we're
984         editing the"user" class, users are allowed to edit their own details.
985         Unless it's the "roles" property, which requires the special Permission
986         "Web Roles".
987         """
988         # if this is a user node and the user is editing their own node, then
989         # we're OK
990         has = self.db.security.hasPermission
991         if self.classname == 'user':
992             # reject if someone's trying to edit "roles" and doesn't have the
993             # right permission.
994             if props.has_key('roles') and not has('Web Roles', self.userid,
995                     'user'):
996                 return 0
997             # if the item being edited is the current user, we're ok
998             if (self.nodeid == self.userid
999                 and self.db.user.get(self.nodeid, 'username') != 'anonymous'):
1000                 return 1
1001         if self.db.security.hasPermission('Edit', self.userid, self.classname):
1002             return 1
1003         return 0
1005     def newItemPermission(self, props):
1006         ''' Determine whether the user has permission to create (edit) this
1007             item.
1009             Base behaviour is to check the user can edit this class. No
1010             additional property checks are made. Additionally, new user items
1011             may be created if the user has the "Web Registration" Permission.
1012         '''
1013         has = self.db.security.hasPermission
1014         if self.classname == 'user' and has('Web Registration', self.userid,
1015                 'user'):
1016             return 1
1017         if has('Edit', self.userid, self.classname):
1018             return 1
1019         return 0
1022     #
1023     #  Utility methods for editing
1024     #
1025     def _editnodes(self, all_props, all_links, newids=None):
1026         ''' Use the props in all_props to perform edit and creation, then
1027             use the link specs in all_links to do linking.
1028         '''
1029         # figure dependencies and re-work links
1030         deps = {}
1031         links = {}
1032         for cn, nodeid, propname, vlist in all_links:
1033             if not all_props.has_key((cn, nodeid)):
1034                 # link item to link to doesn't (and won't) exist
1035                 continue
1036             for value in vlist:
1037                 if not all_props.has_key(value):
1038                     # link item to link to doesn't (and won't) exist
1039                     continue
1040                 deps.setdefault((cn, nodeid), []).append(value)
1041                 links.setdefault(value, []).append((cn, nodeid, propname))
1043         # figure chained dependencies ordering
1044         order = []
1045         done = {}
1046         # loop detection
1047         change = 0
1048         while len(all_props) != len(done):
1049             for needed in all_props.keys():
1050                 if done.has_key(needed):
1051                     continue
1052                 tlist = deps.get(needed, [])
1053                 for target in tlist:
1054                     if not done.has_key(target):
1055                         break
1056                 else:
1057                     done[needed] = 1
1058                     order.append(needed)
1059                     change = 1
1060             if not change:
1061                 raise ValueError, 'linking must not loop!'
1063         # now, edit / create
1064         m = []
1065         for needed in order:
1066             props = all_props[needed]
1067             if not props:
1068                 # nothing to do
1069                 continue
1070             cn, nodeid = needed
1072             if nodeid is not None and int(nodeid) > 0:
1073                 # make changes to the node
1074                 props = self._changenode(cn, nodeid, props)
1076                 # and some nice feedback for the user
1077                 if props:
1078                     info = ', '.join(props.keys())
1079                     m.append('%s %s %s edited ok'%(cn, nodeid, info))
1080                 else:
1081                     m.append('%s %s - nothing changed'%(cn, nodeid))
1082             else:
1083                 assert props
1085                 # make a new node
1086                 newid = self._createnode(cn, props)
1087                 if nodeid is None:
1088                     self.nodeid = newid
1089                 nodeid = newid
1091                 # and some nice feedback for the user
1092                 m.append('%s %s created'%(cn, newid))
1094             # fill in new ids in links
1095             if links.has_key(needed):
1096                 for linkcn, linkid, linkprop in links[needed]:
1097                     props = all_props[(linkcn, linkid)]
1098                     cl = self.db.classes[linkcn]
1099                     propdef = cl.getprops()[linkprop]
1100                     if not props.has_key(linkprop):
1101                         if linkid is None or linkid.startswith('-'):
1102                             # linking to a new item
1103                             if isinstance(propdef, hyperdb.Multilink):
1104                                 props[linkprop] = [newid]
1105                             else:
1106                                 props[linkprop] = newid
1107                         else:
1108                             # linking to an existing item
1109                             if isinstance(propdef, hyperdb.Multilink):
1110                                 existing = cl.get(linkid, linkprop)[:]
1111                                 existing.append(nodeid)
1112                                 props[linkprop] = existing
1113                             else:
1114                                 props[linkprop] = newid
1116         return '<br>'.join(m)
1118     def _changenode(self, cn, nodeid, props):
1119         ''' change the node based on the contents of the form
1120         '''
1121         # check for permission
1122         if not self.editItemPermission(props):
1123             raise Unauthorised, 'You do not have permission to edit %s'%cn
1125         # make the changes
1126         cl = self.db.classes[cn]
1127         return cl.set(nodeid, **props)
1129     def _createnode(self, cn, props):
1130         ''' create a node based on the contents of the form
1131         '''
1132         # check for permission
1133         if not self.newItemPermission(props):
1134             raise Unauthorised, 'You do not have permission to create %s'%cn
1136         # create the node and return its id
1137         cl = self.db.classes[cn]
1138         return cl.create(**props)
1140     # 
1141     # More actions
1142     #
1143     def editCSVAction(self):
1144         """ Performs an edit of all of a class' items in one go.
1146             The "rows" CGI var defines the CSV-formatted entries for the
1147             class. New nodes are identified by the ID 'X' (or any other
1148             non-existent ID) and removed lines are retired.
1149         """
1150         # this is per-class only
1151         if not self.editCSVPermission():
1152             self.error_message.append(
1153                  _('You do not have permission to edit %s' %self.classname))
1154             return
1156         # get the CSV module
1157         if rcsv.error:
1158             self.error_message.append(_(rcsv.error))
1159             return
1161         cl = self.db.classes[self.classname]
1162         idlessprops = cl.getprops(protected=0).keys()
1163         idlessprops.sort()
1164         props = ['id'] + idlessprops
1166         # do the edit
1167         rows = StringIO.StringIO(self.form['rows'].value)
1168         reader = rcsv.reader(rows, rcsv.comma_separated)
1169         found = {}
1170         line = 0
1171         for values in reader:
1172             line += 1
1173             if line == 1: continue
1174             # skip property names header
1175             if values == props:
1176                 continue
1178             # extract the nodeid
1179             nodeid, values = values[0], values[1:]
1180             found[nodeid] = 1
1182             # see if the node exists
1183             if nodeid in ('x', 'X') or not cl.hasnode(nodeid):
1184                 exists = 0
1185             else:
1186                 exists = 1
1188             # confirm correct weight
1189             if len(idlessprops) != len(values):
1190                 self.error_message.append(
1191                     _('Not enough values on line %(line)s')%{'line':line})
1192                 return
1194             # extract the new values
1195             d = {}
1196             for name, value in zip(idlessprops, values):
1197                 prop = cl.properties[name]
1198                 value = value.strip()
1199                 # only add the property if it has a value
1200                 if value:
1201                     # if it's a multilink, split it
1202                     if isinstance(prop, hyperdb.Multilink):
1203                         value = value.split(':')
1204                     elif isinstance(prop, hyperdb.Password):
1205                         value = password.Password(value)
1206                     elif isinstance(prop, hyperdb.Interval):
1207                         value = date.Interval(value)
1208                     elif isinstance(prop, hyperdb.Date):
1209                         value = date.Date(value)
1210                     elif isinstance(prop, hyperdb.Boolean):
1211                         value = value.lower() in ('yes', 'true', 'on', '1')
1212                     elif isinstance(prop, hyperdb.Number):
1213                         value = float(value)
1214                     d[name] = value
1215                 elif exists:
1216                     # nuke the existing value
1217                     if isinstance(prop, hyperdb.Multilink):
1218                         d[name] = []
1219                     else:
1220                         d[name] = None
1222             # perform the edit
1223             if exists:
1224                 # edit existing
1225                 cl.set(nodeid, **d)
1226             else:
1227                 # new node
1228                 found[cl.create(**d)] = 1
1230         # retire the removed entries
1231         for nodeid in cl.list():
1232             if not found.has_key(nodeid):
1233                 cl.retire(nodeid)
1235         # all OK
1236         self.db.commit()
1238         self.ok_message.append(_('Items edited OK'))
1240     def editCSVPermission(self):
1241         ''' Determine whether the user has permission to edit this class.
1243             Base behaviour is to check the user can edit this class.
1244         ''' 
1245         if not self.db.security.hasPermission('Edit', self.userid,
1246                 self.classname):
1247             return 0
1248         return 1
1250     def searchAction(self, wcre=re.compile(r'[\s,]+')):
1251         ''' Mangle some of the form variables.
1253             Set the form ":filter" variable based on the values of the
1254             filter variables - if they're set to anything other than
1255             "dontcare" then add them to :filter.
1257             Handle the ":queryname" variable and save off the query to
1258             the user's query list.
1260             Split any String query values on whitespace and comma.
1261         '''
1262         # generic edit is per-class only
1263         if not self.searchPermission():
1264             self.error_message.append(
1265                 _('You do not have permission to search %s' %self.classname))
1266             return
1268         # add a faked :filter form variable for each filtering prop
1269         props = self.db.classes[self.classname].getprops()
1270         queryname = ''
1271         for key in self.form.keys():
1272             # special vars
1273             if self.FV_QUERYNAME.match(key):
1274                 queryname = self.form[key].value.strip()
1275                 continue
1277             if not props.has_key(key):
1278                 continue
1279             if isinstance(self.form[key], type([])):
1280                 # search for at least one entry which is not empty
1281                 for minifield in self.form[key]:
1282                     if minifield.value:
1283                         break
1284                 else:
1285                     continue
1286             else:
1287                 if not self.form[key].value:
1288                     continue
1289                 if isinstance(props[key], hyperdb.String):
1290                     v = self.form[key].value
1291                     l = token.token_split(v)
1292                     if len(l) > 1 or l[0] != v:
1293                         self.form.value.remove(self.form[key])
1294                         # replace the single value with the split list
1295                         for v in l:
1296                             self.form.value.append(cgi.MiniFieldStorage(key, v))
1298             self.form.value.append(cgi.MiniFieldStorage('@filter', key))
1300         # handle saving the query params
1301         if queryname:
1302             # parse the environment and figure what the query _is_
1303             req = HTMLRequest(self)
1305             # The [1:] strips off the '?' character, it isn't part of the
1306             # query string.
1307             url = req.indexargs_href('', {})[1:]
1309             # handle editing an existing query
1310             try:
1311                 qid = self.db.query.lookup(queryname)
1312                 self.db.query.set(qid, klass=self.classname, url=url)
1313             except KeyError:
1314                 # create a query
1315                 qid = self.db.query.create(name=queryname,
1316                     klass=self.classname, url=url)
1318                 # and add it to the user's query multilink
1319                 queries = self.db.user.get(self.userid, 'queries')
1320                 queries.append(qid)
1321                 self.db.user.set(self.userid, queries=queries)
1323             # commit the query change to the database
1324             self.db.commit()
1326     def searchPermission(self):
1327         ''' Determine whether the user has permission to search this class.
1329             Base behaviour is to check the user can view this class.
1330         ''' 
1331         if not self.db.security.hasPermission('View', self.userid,
1332                 self.classname):
1333             return 0
1334         return 1
1337     def retireAction(self):
1338         ''' Retire the context item.
1339         '''
1340         # if we want to view the index template now, then unset the nodeid
1341         # context info (a special-case for retire actions on the index page)
1342         nodeid = self.nodeid
1343         if self.template == 'index':
1344             self.nodeid = None
1346         # generic edit is per-class only
1347         if not self.retirePermission():
1348             self.error_message.append(
1349                 _('You do not have permission to retire %s' %self.classname))
1350             return
1352         # make sure we don't try to retire admin or anonymous
1353         if self.classname == 'user' and \
1354                 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1355             self.error_message.append(
1356                 _('You may not retire the admin or anonymous user'))
1357             return
1359         # do the retire
1360         self.db.getclass(self.classname).retire(nodeid)
1361         self.db.commit()
1363         self.ok_message.append(
1364             _('%(classname)s %(itemid)s has been retired')%{
1365                 'classname': self.classname.capitalize(), 'itemid': nodeid})
1367     def retirePermission(self):
1368         ''' Determine whether the user has permission to retire this class.
1370             Base behaviour is to check the user can edit this class.
1371         ''' 
1372         if not self.db.security.hasPermission('Edit', self.userid,
1373                 self.classname):
1374             return 0
1375         return 1
1378     def showAction(self, typere=re.compile('[@:]type'),
1379             numre=re.compile('[@:]number')):
1380         ''' Show a node of a particular class/id
1381         '''
1382         t = n = ''
1383         for key in self.form.keys():
1384             if typere.match(key):
1385                 t = self.form[key].value.strip()
1386             elif numre.match(key):
1387                 n = self.form[key].value.strip()
1388         if not t:
1389             raise ValueError, 'Invalid %s number'%t
1390         url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1391         raise Redirect, url
1393     def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1394         """ Item properties and their values are edited with html FORM
1395             variables and their values. You can:
1397             - Change the value of some property of the current item.
1398             - Create a new item of any class, and edit the new item's
1399               properties,
1400             - Attach newly created items to a multilink property of the
1401               current item.
1402             - Remove items from a multilink property of the current item.
1403             - Specify that some properties are required for the edit
1404               operation to be successful.
1406             In the following, <bracketed> values are variable, "@" may be
1407             either ":" or "@", and other text "required" is fixed.
1409             Most properties are specified as form variables:
1411              <propname>
1412               - property on the current context item
1414              <designator>"@"<propname>
1415               - property on the indicated item (for editing related
1416                 information)
1418             Designators name a specific item of a class.
1420             <classname><N>
1422                 Name an existing item of class <classname>.
1424             <classname>"-"<N>
1426                 Name the <N>th new item of class <classname>. If the form
1427                 submission is successful, a new item of <classname> is
1428                 created. Within the submitted form, a particular
1429                 designator of this form always refers to the same new
1430                 item.
1432             Once we have determined the "propname", we look at it to see
1433             if it's special:
1435             @required
1436                 The associated form value is a comma-separated list of
1437                 property names that must be specified when the form is
1438                 submitted for the edit operation to succeed.  
1440                 When the <designator> is missing, the properties are
1441                 for the current context item.  When <designator> is
1442                 present, they are for the item specified by
1443                 <designator>.
1445                 The "@required" specifier must come before any of the
1446                 properties it refers to are assigned in the form.
1448             @remove@<propname>=id(s) or @add@<propname>=id(s)
1449                 The "@add@" and "@remove@" edit actions apply only to
1450                 Multilink properties.  The form value must be a
1451                 comma-separate list of keys for the class specified by
1452                 the simple form variable.  The listed items are added
1453                 to (respectively, removed from) the specified
1454                 property.
1456             @link@<propname>=<designator>
1457                 If the edit action is "@link@", the simple form
1458                 variable must specify a Link or Multilink property.
1459                 The form value is a comma-separated list of
1460                 designators.  The item corresponding to each
1461                 designator is linked to the property given by simple
1462                 form variable.  These are collected up and returned in
1463                 all_links.
1465             None of the above (ie. just a simple form value)
1466                 The value of the form variable is converted
1467                 appropriately, depending on the type of the property.
1469                 For a Link('klass') property, the form value is a
1470                 single key for 'klass', where the key field is
1471                 specified in dbinit.py.  
1473                 For a Multilink('klass') property, the form value is a
1474                 comma-separated list of keys for 'klass', where the
1475                 key field is specified in dbinit.py.  
1477                 Note that for simple-form-variables specifiying Link
1478                 and Multilink properties, the linked-to class must
1479                 have a key field.
1481                 For a String() property specifying a filename, the
1482                 file named by the form value is uploaded. This means we
1483                 try to set additional properties "filename" and "type" (if
1484                 they are valid for the class).  Otherwise, the property
1485                 is set to the form value.
1487                 For Date(), Interval(), Boolean(), and Number()
1488                 properties, the form value is converted to the
1489                 appropriate
1491             Any of the form variables may be prefixed with a classname or
1492             designator.
1494             Two special form values are supported for backwards
1495             compatibility:
1497             @note
1498                 This is equivalent to::
1500                     @link@messages=msg-1
1501                     msg-1@content=value
1503                 except that in addition, the "author" and "date"
1504                 properties of "msg-1" are set to the userid of the
1505                 submitter, and the current time, respectively.
1507             @file
1508                 This is equivalent to::
1510                     @link@files=file-1
1511                     file-1@content=value
1513                 The String content value is handled as described above for
1514                 file uploads.
1516             If both the "@note" and "@file" form variables are
1517             specified, the action::
1519                     @link@msg-1@files=file-1
1521             is also performed.
1523             We also check that FileClass items have a "content" property with
1524             actual content, otherwise we remove them from all_props before
1525             returning.
1527             The return from this method is a dict of 
1528                 (classname, id): properties
1529             ... this dict _always_ has an entry for the current context,
1530             even if it's empty (ie. a submission for an existing issue that
1531             doesn't result in any changes would return {('issue','123'): {}})
1532             The id may be None, which indicates that an item should be
1533             created.
1534         """
1535         # some very useful variables
1536         db = self.db
1537         form = self.form
1539         if not hasattr(self, 'FV_SPECIAL'):
1540             # generate the regexp for handling special form values
1541             classes = '|'.join(db.classes.keys())
1542             # specials for parsePropsFromForm
1543             # handle the various forms (see unit tests)
1544             self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1545             self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1547         # these indicate the default class / item
1548         default_cn = self.classname
1549         default_cl = self.db.classes[default_cn]
1550         default_nodeid = self.nodeid
1552         # we'll store info about the individual class/item edit in these
1553         all_required = {}       # required props per class/item
1554         all_props = {}          # props to set per class/item
1555         got_props = {}          # props received per class/item
1556         all_propdef = {}        # note - only one entry per class
1557         all_links = []          # as many as are required
1559         # we should always return something, even empty, for the context
1560         all_props[(default_cn, default_nodeid)] = {}
1562         keys = form.keys()
1563         timezone = db.getUserTimezone()
1565         # sentinels for the :note and :file props
1566         have_note = have_file = 0
1568         # extract the usable form labels from the form
1569         matches = []
1570         for key in keys:
1571             m = self.FV_SPECIAL.match(key)
1572             if m:
1573                 matches.append((key, m.groupdict()))
1575         # now handle the matches
1576         for key, d in matches:
1577             if d['classname']:
1578                 # we got a designator
1579                 cn = d['classname']
1580                 cl = self.db.classes[cn]
1581                 nodeid = d['id']
1582                 propname = d['propname']
1583             elif d['note']:
1584                 # the special note field
1585                 cn = 'msg'
1586                 cl = self.db.classes[cn]
1587                 nodeid = '-1'
1588                 propname = 'content'
1589                 all_links.append((default_cn, default_nodeid, 'messages',
1590                     [('msg', '-1')]))
1591                 have_note = 1
1592             elif d['file']:
1593                 # the special file field
1594                 cn = 'file'
1595                 cl = self.db.classes[cn]
1596                 nodeid = '-1'
1597                 propname = 'content'
1598                 all_links.append((default_cn, default_nodeid, 'files',
1599                     [('file', '-1')]))
1600                 have_file = 1
1601             else:
1602                 # default
1603                 cn = default_cn
1604                 cl = default_cl
1605                 nodeid = default_nodeid
1606                 propname = d['propname']
1608             # the thing this value relates to is...
1609             this = (cn, nodeid)
1611             # get more info about the class, and the current set of
1612             # form props for it
1613             if not all_propdef.has_key(cn):
1614                 all_propdef[cn] = cl.getprops()
1615             propdef = all_propdef[cn]
1616             if not all_props.has_key(this):
1617                 all_props[this] = {}
1618             props = all_props[this]
1619             if not got_props.has_key(this):
1620                 got_props[this] = {}
1622             # is this a link command?
1623             if d['link']:
1624                 value = []
1625                 for entry in extractFormList(form[key]):
1626                     m = self.FV_DESIGNATOR.match(entry)
1627                     if not m:
1628                         raise FormError, \
1629                             'link "%s" value "%s" not a designator'%(key, entry)
1630                     value.append((m.group(1), m.group(2)))
1632                 # make sure the link property is valid
1633                 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1634                         not isinstance(propdef[propname], hyperdb.Link)):
1635                     raise FormError, '%s %s is not a link or '\
1636                         'multilink property'%(cn, propname)
1638                 all_links.append((cn, nodeid, propname, value))
1639                 continue
1641             # detect the special ":required" variable
1642             if d['required']:
1643                 all_required[this] = extractFormList(form[key])
1644                 continue
1646             # see if we're performing a special multilink action
1647             mlaction = 'set'
1648             if d['remove']:
1649                 mlaction = 'remove'
1650             elif d['add']:
1651                 mlaction = 'add'
1653             # does the property exist?
1654             if not propdef.has_key(propname):
1655                 if mlaction != 'set':
1656                     raise FormError, 'You have submitted a %s action for'\
1657                         ' the property "%s" which doesn\'t exist'%(mlaction,
1658                         propname)
1659                 # the form element is probably just something we don't care
1660                 # about - ignore it
1661                 continue
1662             proptype = propdef[propname]
1664             # Get the form value. This value may be a MiniFieldStorage or a list
1665             # of MiniFieldStorages.
1666             value = form[key]
1668             # handle unpacking of the MiniFieldStorage / list form value
1669             if isinstance(proptype, hyperdb.Multilink):
1670                 value = extractFormList(value)
1671             else:
1672                 # multiple values are not OK
1673                 if isinstance(value, type([])):
1674                     raise FormError, 'You have submitted more than one value'\
1675                         ' for the %s property'%propname
1676                 # value might be a file upload...
1677                 if not hasattr(value, 'filename') or value.filename is None:
1678                     # nope, pull out the value and strip it
1679                     value = value.value.strip()
1681             # now that we have the props field, we need a teensy little
1682             # extra bit of help for the old :note field...
1683             if d['note'] and value:
1684                 props['author'] = self.db.getuid()
1685                 props['date'] = date.Date()
1687             # handle by type now
1688             if isinstance(proptype, hyperdb.Password):
1689                 if not value:
1690                     # ignore empty password values
1691                     continue
1692                 for key, d in matches:
1693                     if d['confirm'] and d['propname'] == propname:
1694                         confirm = form[key]
1695                         break
1696                 else:
1697                     raise FormError, 'Password and confirmation text do '\
1698                         'not match'
1699                 if isinstance(confirm, type([])):
1700                     raise FormError, 'You have submitted more than one value'\
1701                         ' for the %s property'%propname
1702                 if value != confirm.value:
1703                     raise FormError, 'Password and confirmation text do '\
1704                         'not match'
1705                 try:
1706                     value = password.Password(value)
1707                 except hyperdb.HyperdbValueError, msg:
1708                     raise FormError, msg
1710             elif isinstance(proptype, hyperdb.Multilink):
1711                 # convert input to list of ids
1712                 try:
1713                     l = hyperdb.rawToHyperdb(self.db, cl, nodeid,
1714                         propname, value)
1715                 except hyperdb.HyperdbValueError, msg:
1716                     raise FormError, msg
1718                 # now use that list of ids to modify the multilink
1719                 if mlaction == 'set':
1720                     value = l
1721                 else:
1722                     # we're modifying the list - get the current list of ids
1723                     if props.has_key(propname):
1724                         existing = props[propname]
1725                     elif nodeid and not nodeid.startswith('-'):
1726                         existing = cl.get(nodeid, propname, [])
1727                     else:
1728                         existing = []
1730                     # now either remove or add
1731                     if mlaction == 'remove':
1732                         # remove - handle situation where the id isn't in
1733                         # the list
1734                         for entry in l:
1735                             try:
1736                                 existing.remove(entry)
1737                             except ValueError:
1738                                 raise FormError, _('property "%(propname)s": '
1739                                     '"%(value)s" not currently in list')%{
1740                                     'propname': propname, 'value': entry}
1741                     else:
1742                         # add - easy, just don't dupe
1743                         for entry in l:
1744                             if entry not in existing:
1745                                 existing.append(entry)
1746                     value = existing
1747                     value.sort()
1749             elif value == '':
1750                 # other types should be None'd if there's no value
1751                 value = None
1752             else:
1753                 # handle all other types
1754                 try:
1755                     if isinstance(proptype, hyperdb.String):
1756                         if (hasattr(value, 'filename') and
1757                                 value.filename is not None):
1758                             # skip if the upload is empty
1759                             if not value.filename:
1760                                 continue
1761                             # this String is actually a _file_
1762                             # try to determine the file content-type
1763                             fn = value.filename.split('\\')[-1]
1764                             if propdef.has_key('name'):
1765                                 props['name'] = fn
1766                             # use this info as the type/filename properties
1767                             if propdef.has_key('type'):
1768                                 props['type'] = mimetypes.guess_type(fn)[0]
1769                                 if not props['type']:
1770                                     props['type'] = "application/octet-stream"
1771                             # finally, read the content RAW
1772                             value = value.value
1773                         else:
1774                             value = hyperdb.rawToHyperdb(self.db, cl,
1775                                 nodeid, propname, value)
1777                     else:
1778                         value = hyperdb.rawToHyperdb(self.db, cl, nodeid,
1779                             propname, value)
1780                 except hyperdb.HyperdbValueError, msg:
1781                     raise FormError, msg
1783             # register that we got this property
1784             if value:
1785                 got_props[this][propname] = 1
1787             # get the old value
1788             if nodeid and not nodeid.startswith('-'):
1789                 try:
1790                     existing = cl.get(nodeid, propname)
1791                 except KeyError:
1792                     # this might be a new property for which there is
1793                     # no existing value
1794                     if not propdef.has_key(propname):
1795                         raise
1796                 except IndexError, message:
1797                     raise FormError(str(message))
1799                 # make sure the existing multilink is sorted
1800                 if isinstance(proptype, hyperdb.Multilink):
1801                     existing.sort()
1803                 # "missing" existing values may not be None
1804                 if not existing:
1805                     if isinstance(proptype, hyperdb.String) and not existing:
1806                         # some backends store "missing" Strings as empty strings
1807                         existing = None
1808                     elif isinstance(proptype, hyperdb.Number) and not existing:
1809                         # some backends store "missing" Numbers as 0 :(
1810                         existing = 0
1811                     elif isinstance(proptype, hyperdb.Boolean) and not existing:
1812                         # likewise Booleans
1813                         existing = 0
1815                 # if changed, set it
1816                 if value != existing:
1817                     props[propname] = value
1818             else:
1819                 # don't bother setting empty/unset values
1820                 if value is None:
1821                     continue
1822                 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1823                     continue
1824                 elif isinstance(proptype, hyperdb.String) and value == '':
1825                     continue
1827                 props[propname] = value
1829         # check to see if we need to specially link a file to the note
1830         if have_note and have_file:
1831             all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1833         # see if all the required properties have been supplied
1834         s = []
1835         for thing, required in all_required.items():
1836             # register the values we got
1837             got = got_props.get(thing, {})
1838             for entry in required[:]:
1839                 if got.has_key(entry):
1840                     required.remove(entry)
1842             # any required values not present?
1843             if not required:
1844                 continue
1846             # tell the user to entry the values required
1847             if len(required) > 1:
1848                 p = 'properties'
1849             else:
1850                 p = 'property'
1851             s.append('Required %s %s %s not supplied'%(thing[0], p,
1852                 ', '.join(required)))
1853         if s:
1854             raise FormError, '\n'.join(s)
1856         # When creating a FileClass node, it should have a non-empty content
1857         # property to be created. When editing a FileClass node, it should
1858         # either have a non-empty content property or no property at all. In
1859         # the latter case, nothing will change.
1860         for (cn, id), props in all_props.items():
1861             if isinstance(self.db.classes[cn], hyperdb.FileClass):
1862                 if id == '-1':
1863                     if not props.get('content', ''):
1864                         del all_props[(cn, id)]
1865                 elif props.has_key('content') and not props['content']:
1866                     raise FormError, _('File is empty')
1867         return all_props, all_links
1869 def extractFormList(value):
1870     ''' Extract a list of values from the form value.
1872         It may be one of:
1873          [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
1874          MiniFieldStorage('value,value,...')
1875          MiniFieldStorage('value')
1876     '''
1877     # multiple values are OK
1878     if isinstance(value, type([])):
1879         # it's a list of MiniFieldStorages - join then into
1880         values = ','.join([i.value.strip() for i in value])
1881     else:
1882         # it's a MiniFieldStorage, but may be a comma-separated list
1883         # of values
1884         values = value.value
1886     value = [i.strip() for i in values.split(',')]
1888     # filter out the empty bits
1889     return filter(None, value)