Code

fix :required ordering problem (sf bug 740214)
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.121 2003-06-24 03:51:15 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, string
11 from roundup import roundupdb, date, hyperdb, password, token
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, openSMTPConnection
19 class HTTPException(Exception):
20       pass
21 class  Unauthorised(HTTPException):
22        pass
23 class  NotFound(HTTPException):
24        pass
25 class  Redirect(HTTPException):
26        pass
27 class  NotModified(HTTPException):
28        pass
30 # set to indicate to roundup not to actually _send_ email
31 # this var must contain a file to write the mail to
32 SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '')
34 # used by a couple of routines
35 chars = string.letters+string.digits
37 # XXX actually _use_ FormError
38 class FormError(ValueError):
39     ''' An "expected" exception occurred during form parsing.
40         - ie. something we know can go wrong, and don't want to alarm the
41           user with
43         We trap this at the user interface level and feed back a nice error
44         to the user.
45     '''
46     pass
48 class SendFile(Exception):
49     ''' Send a file from the database '''
51 class SendStaticFile(Exception):
52     ''' Send a static file from the instance html directory '''
54 def initialiseSecurity(security):
55     ''' Create some Permissions and Roles on the security object
57         This function is directly invoked by security.Security.__init__()
58         as a part of the Security object instantiation.
59     '''
60     security.addPermission(name="Web Registration",
61         description="User may register through the web")
62     p = security.addPermission(name="Web Access",
63         description="User may access the web interface")
64     security.addPermissionToRole('Admin', p)
66     # doing Role stuff through the web - make sure Admin can
67     p = security.addPermission(name="Web Roles",
68         description="User may manipulate user Roles through the web")
69     security.addPermissionToRole('Admin', p)
71 # used to clean messages passed through CGI variables - HTML-escape any tag
72 # that isn't <a href="">, <i>, <b> and <br> (including XHTML variants) so
73 # that people can't pass through nasties like <script>, <iframe>, ...
74 CLEAN_MESSAGE_RE = r'(<(/?(.*?)(\s*href="[^"]")?\s*/?)>)'
75 def clean_message(message, mc=re.compile(CLEAN_MESSAGE_RE, re.I)):
76     return mc.sub(clean_message_callback, message)
77 def clean_message_callback(match, ok={'a':1,'i':1,'b':1,'br':1}):
78     ''' Strip all non <a>,<i>,<b> and <br> tags from a string
79     '''
80     if ok.has_key(match.group(3).lower()):
81         return match.group(1)
82     return '&lt;%s&gt;'%match.group(2)
84 class Client:
85     ''' Instantiate to handle one CGI request.
87     See inner_main for request processing.
89     Client attributes at instantiation:
90         "path" is the PATH_INFO inside the instance (with no leading '/')
91         "base" is the base URL for the instance
92         "form" is the cgi form, an instance of FieldStorage from the standard
93                cgi module
94         "additional_headers" is a dictionary of additional HTTP headers that
95                should be sent to the client
96         "response_code" is the HTTP response code to send to the client
98     During the processing of a request, the following attributes are used:
99         "error_message" holds a list of error messages
100         "ok_message" holds a list of OK messages
101         "session" is the current user session id
102         "user" is the current user's name
103         "userid" is the current user's id
104         "template" is the current :template context
105         "classname" is the current class context name
106         "nodeid" is the current context item id
108     User Identification:
109      If the user has no login cookie, then they are anonymous and are logged
110      in as that user. This typically gives them all Permissions assigned to the
111      Anonymous Role.
113      Once a user logs in, they are assigned a session. The Client instance
114      keeps the nodeid of the session as the "session" attribute.
117     Special form variables:
118      Note that in various places throughout this code, special form
119      variables of the form :<name> are used. The colon (":") part may
120      actually be one of either ":" or "@".
121     '''
123     #
124     # special form variables
125     #
126     FV_TEMPLATE = re.compile(r'[@:]template')
127     FV_OK_MESSAGE = re.compile(r'[@:]ok_message')
128     FV_ERROR_MESSAGE = re.compile(r'[@:]error_message')
130     FV_QUERYNAME = re.compile(r'[@:]queryname')
132     # edit form variable handling (see unit tests)
133     FV_LABELS = r'''
134        ^(
135          (?P<note>[@:]note)|
136          (?P<file>[@:]file)|
137          (
138           ((?P<classname>%s)(?P<id>[-\d]+))?  # optional leading designator
139           ((?P<required>[@:]required$)|       # :required
140            (
141             (
142              (?P<add>[@:]add[@:])|            # :add:<prop>
143              (?P<remove>[@:]remove[@:])|      # :remove:<prop>
144              (?P<confirm>[@:]confirm[@:])|    # :confirm:<prop>
145              (?P<link>[@:]link[@:])|          # :link:<prop>
146              ([@:])                           # just a separator
147             )?
148             (?P<propname>[^@:]+)             # <prop>
149            )
150           )
151          )
152         )$'''
154     # Note: index page stuff doesn't appear here:
155     # columns, sort, sortdir, filter, group, groupdir, search_text,
156     # pagesize, startwith
158     def __init__(self, instance, request, env, form=None):
159         hyperdb.traceMark()
160         self.instance = instance
161         self.request = request
162         self.env = env
164         # save off the path
165         self.path = env['PATH_INFO']
167         # this is the base URL for this tracker
168         self.base = self.instance.config.TRACKER_WEB
170         # this is the "cookie path" for this tracker (ie. the path part of
171         # the "base" url)
172         self.cookie_path = urlparse.urlparse(self.base)[2]
173         self.cookie_name = 'roundup_session_' + re.sub('[^a-zA-Z]', '',
174             self.instance.config.TRACKER_NAME)
176         # see if we need to re-parse the environment for the form (eg Zope)
177         if form is None:
178             self.form = cgi.FieldStorage(environ=env)
179         else:
180             self.form = form
182         # turn debugging on/off
183         try:
184             self.debug = int(env.get("ROUNDUP_DEBUG", 0))
185         except ValueError:
186             # someone gave us a non-int debug level, turn it off
187             self.debug = 0
189         # flag to indicate that the HTTP headers have been sent
190         self.headers_done = 0
192         # additional headers to send with the request - must be registered
193         # before the first write
194         self.additional_headers = {}
195         self.response_code = 200
198     def main(self):
199         ''' Wrap the real main in a try/finally so we always close off the db.
200         '''
201         try:
202             self.inner_main()
203         finally:
204             if hasattr(self, 'db'):
205                 self.db.close()
207     def inner_main(self):
208         ''' Process a request.
210             The most common requests are handled like so:
211             1. figure out who we are, defaulting to the "anonymous" user
212                see determine_user
213             2. figure out what the request is for - the context
214                see determine_context
215             3. handle any requested action (item edit, search, ...)
216                see handle_action
217             4. render a template, resulting in HTML output
219             In some situations, exceptions occur:
220             - HTTP Redirect  (generally raised by an action)
221             - SendFile       (generally raised by determine_context)
222               serve up a FileClass "content" property
223             - SendStaticFile (generally raised by determine_context)
224               serve up a file from the tracker "html" directory
225             - Unauthorised   (generally raised by an action)
226               the action is cancelled, the request is rendered and an error
227               message is displayed indicating that permission was not
228               granted for the action to take place
229             - NotFound       (raised wherever it needs to be)
230               percolates up to the CGI interface that called the client
231         '''
232         self.ok_message = []
233         self.error_message = []
234         try:
235             # make sure we're identified (even anonymously)
236             self.determine_user()
237             # figure out the context and desired content template
238             self.determine_context()
239             # possibly handle a form submit action (may change self.classname
240             # and self.template, and may also append error/ok_messages)
241             self.handle_action()
243             # now render the page
244             # we don't want clients caching our dynamic pages
245             self.additional_headers['Cache-Control'] = 'no-cache'
246 # Pragma: no-cache makes Mozilla and its ilk double-load all pages!!
247 #            self.additional_headers['Pragma'] = 'no-cache'
249             # expire this page 5 seconds from now
250             date = rfc822.formatdate(time.time() + 5)
251             self.additional_headers['Expires'] = date
253             # render the content
254             self.write(self.renderContext())
255         except Redirect, url:
256             # let's redirect - if the url isn't None, then we need to do
257             # the headers, otherwise the headers have been set before the
258             # exception was raised
259             if url:
260                 self.additional_headers['Location'] = url
261                 self.response_code = 302
262             self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
263         except SendFile, designator:
264             self.serve_file(designator)
265         except SendStaticFile, file:
266             try:
267                 self.serve_static_file(str(file))
268             except NotModified:
269                 # send the 304 response
270                 self.request.send_response(304)
271                 self.request.end_headers()
272         except Unauthorised, message:
273             self.classname = None
274             self.template = ''
275             self.error_message.append(message)
276             self.write(self.renderContext())
277         except NotFound:
278             # pass through
279             raise
280         except:
281             # everything else
282             self.write(cgitb.html())
284     def clean_sessions(self):
285         ''' Age sessions, remove when they haven't been used for a week.
286         
287             Do it only once an hour.
289             Note: also cleans One Time Keys, and other "session" based
290             stuff.
291         '''
292         sessions = self.db.sessions
293         last_clean = sessions.get('last_clean', 'last_use') or 0
295         week = 60*60*24*7
296         hour = 60*60
297         now = time.time()
298         if now - last_clean > hour:
299             # remove aged sessions
300             for sessid in sessions.list():
301                 interval = now - sessions.get(sessid, 'last_use')
302                 if interval > week:
303                     sessions.destroy(sessid)
304             # remove aged otks
305             otks = self.db.otks
306             for sessid in otks.list():
307                 interval = now - otks.get(sessid, '__time')
308                 if interval > week:
309                     otks.destroy(sessid)
310             sessions.set('last_clean', last_use=time.time())
312     def determine_user(self):
313         ''' Determine who the user is
314         '''
315         # determine the uid to use
316         self.opendb('admin')
317         # clean age sessions
318         self.clean_sessions()
319         # make sure we have the session Class
320         sessions = self.db.sessions
322         # look up the user session cookie
323         cookie = Cookie.SimpleCookie(self.env.get('HTTP_COOKIE', ''))
324         user = 'anonymous'
326         # bump the "revision" of the cookie since the format changed
327         if (cookie.has_key(self.cookie_name) and
328                 cookie[self.cookie_name].value != 'deleted'):
330             # get the session key from the cookie
331             self.session = cookie[self.cookie_name].value
332             # get the user from the session
333             try:
334                 # update the lifetime datestamp
335                 sessions.set(self.session, last_use=time.time())
336                 sessions.commit()
337                 user = sessions.get(self.session, 'user')
338             except KeyError:
339                 user = 'anonymous'
341         # sanity check on the user still being valid, getting the userid
342         # at the same time
343         try:
344             self.userid = self.db.user.lookup(user)
345         except (KeyError, TypeError):
346             user = 'anonymous'
348         # make sure the anonymous user is valid if we're using it
349         if user == 'anonymous':
350             self.make_user_anonymous()
351         else:
352             self.user = user
354         # reopen the database as the correct user
355         self.opendb(self.user)
357     def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
358         ''' Determine the context of this page from the URL:
360             The URL path after the instance identifier is examined. The path
361             is generally only one entry long.
363             - if there is no path, then we are in the "home" context.
364             * if the path is "_file", then the additional path entry
365               specifies the filename of a static file we're to serve up
366               from the instance "html" directory. Raises a SendStaticFile
367               exception.
368             - if there is something in the path (eg "issue"), it identifies
369               the tracker class we're to display.
370             - if the path is an item designator (eg "issue123"), then we're
371               to display a specific item.
372             * if the path starts with an item designator and is longer than
373               one entry, then we're assumed to be handling an item of a
374               FileClass, and the extra path information gives the filename
375               that the client is going to label the download with (ie
376               "file123/image.png" is nicer to download than "file123"). This
377               raises a SendFile exception.
379             Both of the "*" types of contexts stop before we bother to
380             determine the template we're going to use. That's because they
381             don't actually use templates.
383             The template used is specified by the :template CGI variable,
384             which defaults to:
386              only classname suplied:          "index"
387              full item designator supplied:   "item"
389             We set:
390              self.classname  - the class to display, can be None
391              self.template   - the template to render the current context with
392              self.nodeid     - the nodeid of the class we're displaying
393         '''
394         # default the optional variables
395         self.classname = None
396         self.nodeid = None
398         # see if a template or messages are specified
399         template_override = ok_message = error_message = None
400         for key in self.form.keys():
401             if self.FV_TEMPLATE.match(key):
402                 template_override = self.form[key].value
403             elif self.FV_OK_MESSAGE.match(key):
404                 ok_message = self.form[key].value
405                 ok_message = clean_message(ok_message)
406             elif self.FV_ERROR_MESSAGE.match(key):
407                 error_message = self.form[key].value
408                 error_message = clean_message(error_message)
410         # determine the classname and possibly nodeid
411         path = self.path.split('/')
412         if not path or path[0] in ('', 'home', 'index'):
413             if template_override is not None:
414                 self.template = template_override
415             else:
416                 self.template = ''
417             return
418         elif path[0] == '_file':
419             raise SendStaticFile, os.path.join(*path[1:])
420         else:
421             self.classname = path[0]
422             if len(path) > 1:
423                 # send the file identified by the designator in path[0]
424                 raise SendFile, path[0]
426         # see if we got a designator
427         m = dre.match(self.classname)
428         if m:
429             self.classname = m.group(1)
430             self.nodeid = m.group(2)
431             if not self.db.getclass(self.classname).hasnode(self.nodeid):
432                 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
433             # with a designator, we default to item view
434             self.template = 'item'
435         else:
436             # with only a class, we default to index view
437             self.template = 'index'
439         # make sure the classname is valid
440         try:
441             self.db.getclass(self.classname)
442         except KeyError:
443             raise NotFound, self.classname
445         # see if we have a template override
446         if template_override is not None:
447             self.template = template_override
449         # see if we were passed in a message
450         if ok_message:
451             self.ok_message.append(ok_message)
452         if error_message:
453             self.error_message.append(error_message)
455     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
456         ''' Serve the file from the content property of the designated item.
457         '''
458         m = dre.match(str(designator))
459         if not m:
460             raise NotFound, str(designator)
461         classname, nodeid = m.group(1), m.group(2)
462         if classname != 'file':
463             raise NotFound, designator
465         # we just want to serve up the file named
466         file = self.db.file
467         self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
468         self.write(file.get(nodeid, 'content'))
470     def serve_static_file(self, file):
471         ims = None
472         # see if there's an if-modified-since...
473         if hasattr(self.request, 'headers'):
474             ims = self.request.headers.getheader('if-modified-since')
475         elif self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
476             # cgi will put the header in the env var
477             ims = self.env['HTTP_IF_MODIFIED_SINCE']
478         filename = os.path.join(self.instance.config.TEMPLATES, file)
479         lmt = os.stat(filename)[stat.ST_MTIME]
480         if ims:
481             ims = rfc822.parsedate(ims)[:6]
482             lmtt = time.gmtime(lmt)[:6]
483             if lmtt <= ims:
484                 raise NotModified
486         # we just want to serve up the file named
487         file = str(file)
488         mt = mimetypes.guess_type(file)[0]
489         if not mt:
490             if file.endswith('.css'):
491                 mt = 'text/css'
492             else:
493                 mt = 'text/plain'
494         self.additional_headers['Content-Type'] = mt
495         self.additional_headers['Last-Modifed'] = rfc822.formatdate(lmt)
496         self.write(open(filename, 'rb').read())
498     def renderContext(self):
499         ''' Return a PageTemplate for the named page
500         '''
501         name = self.classname
502         extension = self.template
503         pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
505         # catch errors so we can handle PT rendering errors more nicely
506         args = {
507             'ok_message': self.ok_message,
508             'error_message': self.error_message
509         }
510         try:
511             # let the template render figure stuff out
512             return pt.render(self, None, None, **args)
513         except NoTemplate, message:
514             return '<strong>%s</strong>'%message
515         except:
516             # everything else
517             return cgitb.pt_html()
519     # these are the actions that are available
520     actions = (
521         ('edit',     'editItemAction'),
522         ('editcsv',  'editCSVAction'),
523         ('new',      'newItemAction'),
524         ('register', 'registerAction'),
525         ('confrego', 'confRegoAction'),
526         ('passrst',  'passResetAction'),
527         ('login',    'loginAction'),
528         ('logout',   'logout_action'),
529         ('search',   'searchAction'),
530         ('retire',   'retireAction'),
531         ('show',     'showAction'),
532     )
533     def handle_action(self):
534         ''' Determine whether there should be an Action called.
536             The action is defined by the form variable :action which
537             identifies the method on this object to call. The actions
538             are defined in the "actions" sequence on this class.
539         '''
540         if self.form.has_key(':action'):
541             action = self.form[':action'].value.lower()
542         elif self.form.has_key('@action'):
543             action = self.form['@action'].value.lower()
544         else:
545             return None
546         try:
547             # get the action, validate it
548             for name, method in self.actions:
549                 if name == action:
550                     break
551             else:
552                 raise ValueError, 'No such action "%s"'%action
553             # call the mapped action
554             getattr(self, method)()
555         except Redirect:
556             raise
557         except Unauthorised:
558             raise
560     def write(self, content):
561         if not self.headers_done:
562             self.header()
563         self.request.wfile.write(content)
565     def header(self, headers=None, response=None):
566         '''Put up the appropriate header.
567         '''
568         if headers is None:
569             headers = {'Content-Type':'text/html'}
570         if response is None:
571             response = self.response_code
573         # update with additional info
574         headers.update(self.additional_headers)
576         if not headers.has_key('Content-Type'):
577             headers['Content-Type'] = 'text/html'
578         self.request.send_response(response)
579         for entry in headers.items():
580             self.request.send_header(*entry)
581         self.request.end_headers()
582         self.headers_done = 1
583         if self.debug:
584             self.headers_sent = headers
586     def set_cookie(self, user):
587         ''' Set up a session cookie for the user and store away the user's
588             login info against the session.
589         '''
590         # TODO generate a much, much stronger session key ;)
591         self.session = binascii.b2a_base64(repr(random.random())).strip()
593         # clean up the base64
594         if self.session[-1] == '=':
595             if self.session[-2] == '=':
596                 self.session = self.session[:-2]
597             else:
598                 self.session = self.session[:-1]
600         # insert the session in the sessiondb
601         self.db.sessions.set(self.session, user=user, last_use=time.time())
603         # and commit immediately
604         self.db.sessions.commit()
606         # expire us in a long, long time
607         expire = Cookie._getdate(86400*365)
609         # generate the cookie path - make sure it has a trailing '/'
610         self.additional_headers['Set-Cookie'] = \
611           '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
612             expire, self.cookie_path)
614     def make_user_anonymous(self):
615         ''' Make us anonymous
617             This method used to handle non-existence of the 'anonymous'
618             user, but that user is mandatory now.
619         '''
620         self.userid = self.db.user.lookup('anonymous')
621         self.user = 'anonymous'
623     def opendb(self, user):
624         ''' Open the database.
625         '''
626         # open the db if the user has changed
627         if not hasattr(self, 'db') or user != self.db.journaltag:
628             if hasattr(self, 'db'):
629                 self.db.close()
630             self.db = self.instance.open(user)
632     #
633     # Actions
634     #
635     def loginAction(self):
636         ''' Attempt to log a user in.
638             Sets up a session for the user which contains the login
639             credentials.
640         '''
641         # we need the username at a minimum
642         if not self.form.has_key('__login_name'):
643             self.error_message.append(_('Username required'))
644             return
646         # get the login info
647         self.user = self.form['__login_name'].value
648         if self.form.has_key('__login_password'):
649             password = self.form['__login_password'].value
650         else:
651             password = ''
653         # make sure the user exists
654         try:
655             self.userid = self.db.user.lookup(self.user)
656         except KeyError:
657             name = self.user
658             self.error_message.append(_('No such user "%(name)s"')%locals())
659             self.make_user_anonymous()
660             return
662         # verify the password
663         if not self.verifyPassword(self.userid, password):
664             self.make_user_anonymous()
665             self.error_message.append(_('Incorrect password'))
666             return
668         # make sure we're allowed to be here
669         if not self.loginPermission():
670             self.make_user_anonymous()
671             self.error_message.append(_("You do not have permission to login"))
672             return
674         # now we're OK, re-open the database for real, using the user
675         self.opendb(self.user)
677         # set the session cookie
678         self.set_cookie(self.user)
680     def verifyPassword(self, userid, password):
681         ''' Verify the password that the user has supplied
682         '''
683         stored = self.db.user.get(self.userid, 'password')
684         if password == stored:
685             return 1
686         if not password and not stored:
687             return 1
688         return 0
690     def loginPermission(self):
691         ''' Determine whether the user has permission to log in.
693             Base behaviour is to check the user has "Web Access".
694         ''' 
695         if not self.db.security.hasPermission('Web Access', self.userid):
696             return 0
697         return 1
699     def logout_action(self):
700         ''' Make us really anonymous - nuke the cookie too
701         '''
702         # log us out
703         self.make_user_anonymous()
705         # construct the logout cookie
706         now = Cookie._getdate()
707         self.additional_headers['Set-Cookie'] = \
708            '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.cookie_name,
709             now, self.cookie_path)
711         # Let the user know what's going on
712         self.ok_message.append(_('You are logged out'))
714     def registerAction(self):
715         '''Attempt to create a new user based on the contents of the form
716         and then set the cookie.
718         return 1 on successful login
719         '''
720         # parse the props from the form
721         try:
722             props = self.parsePropsFromForm()[0][('user', None)]
723         except (ValueError, KeyError), message:
724             self.error_message.append(_('Error: ') + str(message))
725             return
727         # make sure we're allowed to register
728         if not self.registerPermission(props):
729             raise Unauthorised, _("You do not have permission to register")
731         try:
732             self.db.user.lookup(props['username'])
733             self.error_message.append('Error: A user with the username "%s" '
734                 'already exists'%props['username'])
735             return
736         except KeyError:
737             pass
739         # generate the one-time-key and store the props for later
740         otk = ''.join([random.choice(chars) for x in range(32)])
741         for propname, proptype in self.db.user.getprops().items():
742             value = props.get(propname, None)
743             if value is None:
744                 pass
745             elif isinstance(proptype, hyperdb.Date):
746                 props[propname] = str(value)
747             elif isinstance(proptype, hyperdb.Interval):
748                 props[propname] = str(value)
749             elif isinstance(proptype, hyperdb.Password):
750                 props[propname] = str(value)
751         props['__time'] = time.time()
752         self.db.otks.set(otk, **props)
754         # send the email
755         tracker_name = self.db.config.TRACKER_NAME
756         subject = 'Complete your registration to %s'%tracker_name
757         body = '''
758 To complete your registration of the user "%(name)s" with %(tracker)s,
759 please visit the following URL:
761    %(url)s?@action=confrego&otk=%(otk)s
762 '''%{'name': props['username'], 'tracker': tracker_name, 'url': self.base,
763                 'otk': otk}
764         if not self.sendEmail(props['address'], subject, body):
765             return
767         # commit changes to the database
768         self.db.commit()
770         # redirect to the "you're almost there" page
771         raise Redirect, '%suser?@template=rego_progress'%self.base
773     def sendEmail(self, to, subject, content):
774         # send email to the user's email address
775         message = StringIO.StringIO()
776         writer = MimeWriter.MimeWriter(message)
777         tracker_name = self.db.config.TRACKER_NAME
778         writer.addheader('Subject', encode_header(subject))
779         writer.addheader('To', to)
780         writer.addheader('From', roundupdb.straddr((tracker_name,
781             self.db.config.ADMIN_EMAIL)))
782         writer.addheader('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000",
783             time.gmtime()))
784         # add a uniquely Roundup header to help filtering
785         writer.addheader('X-Roundup-Name', tracker_name)
786         # avoid email loops
787         writer.addheader('X-Roundup-Loop', 'hello')
788         writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
789         body = writer.startbody('text/plain; charset=utf-8')
791         # message body, encoded quoted-printable
792         content = StringIO.StringIO(content)
793         quopri.encode(content, body, 0)
795         if SENDMAILDEBUG:
796             # don't send - just write to a file
797             open(SENDMAILDEBUG, 'a').write('FROM: %s\nTO: %s\n%s\n'%(
798                 self.db.config.ADMIN_EMAIL,
799                 ', '.join(to),message.getvalue()))
800         else:
801             # now try to send the message
802             try:
803                 # send the message as admin so bounces are sent there
804                 # instead of to roundup
805                 smtp = openSMTPConnection(self.db.config)
806                 smtp.sendmail(self.db.config.ADMIN_EMAIL, [to],
807                     message.getvalue())
808             except socket.error, value:
809                 self.error_message.append("Error: couldn't send email: "
810                     "mailhost %s"%value)
811                 return 0
812             except smtplib.SMTPException, msg:
813                 self.error_message.append("Error: couldn't send email: %s"%msg)
814                 return 0
815         return 1
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         # pull the rego information out of the otk database
833         otk = self.form['otk'].value
834         props = self.db.otks.getall(otk)
835         for propname, proptype in self.db.user.getprops().items():
836             value = props.get(propname, None)
837             if value is None:
838                 pass
839             elif isinstance(proptype, hyperdb.Date):
840                 props[propname] = date.Date(value)
841             elif isinstance(proptype, hyperdb.Interval):
842                 props[propname] = date.Interval(value)
843             elif isinstance(proptype, hyperdb.Password):
844                 props[propname] = password.Password()
845                 props[propname].unpack(value)
847         # re-open the database as "admin"
848         if self.user != 'admin':
849             self.opendb('admin')
851         # create the new user
852         cl = self.db.user
853 # XXX we need to make the "default" page be able to display errors!
854         try:
855             props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
856             del props['__time']
857             self.userid = cl.create(**props)
858             # clear the props from the otk database
859             self.db.otks.destroy(otk)
860             self.db.commit()
861         except (ValueError, KeyError), message:
862             self.error_message.append(str(message))
863             return
865         # log the new user in
866         self.user = cl.get(self.userid, 'username')
867         # re-open the database for real, using the user
868         self.opendb(self.user)
870         # if we have a session, update it
871         if hasattr(self, 'session'):
872             self.db.sessions.set(self.session, user=self.user,
873                 last_use=time.time())
874         else:
875             # new session cookie
876             self.set_cookie(self.user)
878         # nice message
879         message = _('You are now registered, welcome!')
881         # redirect to the user's page
882         raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
883             self.userid, urllib.quote(message))
885     def passResetAction(self):
886         ''' Handle password reset requests.
888             Presence of either "name" or "address" generate email.
889             Presense of "otk" performs the reset.
890         '''
891         if self.form.has_key('otk'):
892             # pull the rego information out of the otk database
893             otk = self.form['otk'].value
894             uid = self.db.otks.get(otk, 'uid')
895             if uid is None:
896                 self.error_message.append('Invalid One Time Key!')
897                 return
899             # re-open the database as "admin"
900             if self.user != 'admin':
901                 self.opendb('admin')
903             # change the password
904             newpw = password.generatePassword()
906             cl = self.db.user
907 # XXX we need to make the "default" page be able to display errors!
908             try:
909                 # set the password
910                 cl.set(uid, password=password.Password(newpw))
911                 # clear the props from the otk database
912                 self.db.otks.destroy(otk)
913                 self.db.commit()
914             except (ValueError, KeyError), message:
915                 self.error_message.append(str(message))
916                 return
918             # user info
919             address = self.db.user.get(uid, 'address')
920             name = self.db.user.get(uid, 'username')
922             # send the email
923             tracker_name = self.db.config.TRACKER_NAME
924             subject = 'Password reset for %s'%tracker_name
925             body = '''
926 The password has been reset for username "%(name)s".
928 Your password is now: %(password)s
929 '''%{'name': name, 'password': newpw}
930             if not self.sendEmail(address, subject, body):
931                 return
933             self.ok_message.append('Password reset and email sent to %s'%address)
934             return
936         # no OTK, so now figure the user
937         if self.form.has_key('username'):
938             name = self.form['username'].value
939             try:
940                 uid = self.db.user.lookup(name)
941             except KeyError:
942                 self.error_message.append('Unknown username')
943                 return
944             address = self.db.user.get(uid, 'address')
945         elif self.form.has_key('address'):
946             address = self.form['address'].value
947             uid = uidFromAddress(self.db, ('', address), create=0)
948             if not uid:
949                 self.error_message.append('Unknown email address')
950                 return
951             name = self.db.user.get(uid, 'username')
952         else:
953             self.error_message.append('You need to specify a username '
954                 'or address')
955             return
957         # generate the one-time-key and store the props for later
958         otk = ''.join([random.choice(chars) for x in range(32)])
959         self.db.otks.set(otk, uid=uid, __time=time.time())
961         # send the email
962         tracker_name = self.db.config.TRACKER_NAME
963         subject = 'Confirm reset of password for %s'%tracker_name
964         body = '''
965 Someone, perhaps you, has requested that the password be changed for your
966 username, "%(name)s". If you wish to proceed with the change, please follow
967 the link below:
969   %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
971 You should then receive another email with the new password.
972 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
973         if not self.sendEmail(address, subject, body):
974             return
976         self.ok_message.append('Email sent to %s'%address)
978     def editItemAction(self):
979         ''' Perform an edit of an item in the database.
981            See parsePropsFromForm and _editnodes for special variables
982         '''
983         # parse the props from the form
984         try:
985             props, links = self.parsePropsFromForm()
986         except (ValueError, KeyError), message:
987             self.error_message.append(_('Error: ') + str(message))
988             return
990         # handle the props
991         try:
992             message = self._editnodes(props, links)
993         except (ValueError, KeyError, IndexError), message:
994             self.error_message.append(_('Error: ') + str(message))
995             return
997         # commit now that all the tricky stuff is done
998         self.db.commit()
1000         # redirect to the item's edit page
1001         raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
1002             self.classname, self.nodeid, urllib.quote(message),
1003             urllib.quote(self.template))
1005     def editItemPermission(self, props):
1006         ''' Determine whether the user has permission to edit this item.
1008             Base behaviour is to check the user can edit this class. If we're
1009             editing the "user" class, users are allowed to edit their own
1010             details. Unless it's the "roles" property, which requires the
1011             special Permission "Web Roles".
1012         '''
1013         # if this is a user node and the user is editing their own node, then
1014         # we're OK
1015         has = self.db.security.hasPermission
1016         if self.classname == 'user':
1017             # reject if someone's trying to edit "roles" and doesn't have the
1018             # right permission.
1019             if props.has_key('roles') and not has('Web Roles', self.userid,
1020                     'user'):
1021                 return 0
1022             # if the item being edited is the current user, we're ok
1023             if self.nodeid == self.userid:
1024                 return 1
1025         if self.db.security.hasPermission('Edit', self.userid, self.classname):
1026             return 1
1027         return 0
1029     def newItemAction(self):
1030         ''' Add a new item to the database.
1032             This follows the same form as the editItemAction, with the same
1033             special form values.
1034         '''
1035         # parse the props from the form
1036         try:
1037             props, links = self.parsePropsFromForm()
1038         except (ValueError, KeyError), message:
1039             self.error_message.append(_('Error: ') + str(message))
1040             return
1042         # handle the props - edit or create
1043         try:
1044             # when it hits the None element, it'll set self.nodeid
1045             messages = self._editnodes(props, links)
1047         except (ValueError, KeyError, IndexError), message:
1048             # these errors might just be indicative of user dumbness
1049             self.error_message.append(_('Error: ') + str(message))
1050             return
1052         # commit now that all the tricky stuff is done
1053         self.db.commit()
1055         # redirect to the new item's page
1056         raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
1057             self.classname, self.nodeid, urllib.quote(messages),
1058             urllib.quote(self.template))
1060     def newItemPermission(self, props):
1061         ''' Determine whether the user has permission to create (edit) this
1062             item.
1064             Base behaviour is to check the user can edit this class. No
1065             additional property checks are made. Additionally, new user items
1066             may be created if the user has the "Web Registration" Permission.
1067         '''
1068         has = self.db.security.hasPermission
1069         if self.classname == 'user' and has('Web Registration', self.userid,
1070                 'user'):
1071             return 1
1072         if has('Edit', self.userid, self.classname):
1073             return 1
1074         return 0
1077     #
1078     #  Utility methods for editing
1079     #
1080     def _editnodes(self, all_props, all_links, newids=None):
1081         ''' Use the props in all_props to perform edit and creation, then
1082             use the link specs in all_links to do linking.
1083         '''
1084         # figure dependencies and re-work links
1085         deps = {}
1086         links = {}
1087         for cn, nodeid, propname, vlist in all_links:
1088             if not all_props.has_key((cn, nodeid)):
1089                 # link item to link to doesn't (and won't) exist
1090                 continue
1091             for value in vlist:
1092                 if not all_props.has_key(value):
1093                     # link item to link to doesn't (and won't) exist
1094                     continue
1095                 deps.setdefault((cn, nodeid), []).append(value)
1096                 links.setdefault(value, []).append((cn, nodeid, propname))
1098         # figure chained dependencies ordering
1099         order = []
1100         done = {}
1101         # loop detection
1102         change = 0
1103         while len(all_props) != len(done):
1104             for needed in all_props.keys():
1105                 if done.has_key(needed):
1106                     continue
1107                 tlist = deps.get(needed, [])
1108                 for target in tlist:
1109                     if not done.has_key(target):
1110                         break
1111                 else:
1112                     done[needed] = 1
1113                     order.append(needed)
1114                     change = 1
1115             if not change:
1116                 raise ValueError, 'linking must not loop!'
1118         # now, edit / create
1119         m = []
1120         for needed in order:
1121             props = all_props[needed]
1122             if not props:
1123                 # nothing to do
1124                 continue
1125             cn, nodeid = needed
1127             if nodeid is not None and int(nodeid) > 0:
1128                 # make changes to the node
1129                 props = self._changenode(cn, nodeid, props)
1131                 # and some nice feedback for the user
1132                 if props:
1133                     info = ', '.join(props.keys())
1134                     m.append('%s %s %s edited ok'%(cn, nodeid, info))
1135                 else:
1136                     m.append('%s %s - nothing changed'%(cn, nodeid))
1137             else:
1138                 assert props
1140                 # make a new node
1141                 newid = self._createnode(cn, props)
1142                 if nodeid is None:
1143                     self.nodeid = newid
1144                 nodeid = newid
1146                 # and some nice feedback for the user
1147                 m.append('%s %s created'%(cn, newid))
1149             # fill in new ids in links
1150             if links.has_key(needed):
1151                 for linkcn, linkid, linkprop in links[needed]:
1152                     props = all_props[(linkcn, linkid)]
1153                     cl = self.db.classes[linkcn]
1154                     propdef = cl.getprops()[linkprop]
1155                     if not props.has_key(linkprop):
1156                         if linkid is None or linkid.startswith('-'):
1157                             # linking to a new item
1158                             if isinstance(propdef, hyperdb.Multilink):
1159                                 props[linkprop] = [newid]
1160                             else:
1161                                 props[linkprop] = newid
1162                         else:
1163                             # linking to an existing item
1164                             if isinstance(propdef, hyperdb.Multilink):
1165                                 existing = cl.get(linkid, linkprop)[:]
1166                                 existing.append(nodeid)
1167                                 props[linkprop] = existing
1168                             else:
1169                                 props[linkprop] = newid
1171         return '<br>'.join(m)
1173     def _changenode(self, cn, nodeid, props):
1174         ''' change the node based on the contents of the form
1175         '''
1176         # check for permission
1177         if not self.editItemPermission(props):
1178             raise Unauthorised, 'You do not have permission to edit %s'%cn
1180         # make the changes
1181         cl = self.db.classes[cn]
1182         return cl.set(nodeid, **props)
1184     def _createnode(self, cn, props):
1185         ''' create a node based on the contents of the form
1186         '''
1187         # check for permission
1188         if not self.newItemPermission(props):
1189             raise Unauthorised, 'You do not have permission to create %s'%cn
1191         # create the node and return its id
1192         cl = self.db.classes[cn]
1193         return cl.create(**props)
1195     # 
1196     # More actions
1197     #
1198     def editCSVAction(self):
1199         ''' Performs an edit of all of a class' items in one go.
1201             The "rows" CGI var defines the CSV-formatted entries for the
1202             class. New nodes are identified by the ID 'X' (or any other
1203             non-existent ID) and removed lines are retired.
1204         '''
1205         # this is per-class only
1206         if not self.editCSVPermission():
1207             self.error_message.append(
1208                 _('You do not have permission to edit %s' %self.classname))
1210         # get the CSV module
1211         try:
1212             import csv
1213         except ImportError:
1214             self.error_message.append(_(
1215                 'Sorry, you need the csv module to use this function.<br>\n'
1216                 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
1217             return
1219         cl = self.db.classes[self.classname]
1220         idlessprops = cl.getprops(protected=0).keys()
1221         idlessprops.sort()
1222         props = ['id'] + idlessprops
1224         # do the edit
1225         rows = self.form['rows'].value.splitlines()
1226         p = csv.parser()
1227         found = {}
1228         line = 0
1229         for row in rows[1:]:
1230             line += 1
1231             values = p.parse(row)
1232             # not a complete row, keep going
1233             if not values: continue
1235             # skip property names header
1236             if values == props:
1237                 continue
1239             # extract the nodeid
1240             nodeid, values = values[0], values[1:]
1241             found[nodeid] = 1
1243             # see if the node exists
1244             if cl.hasnode(nodeid):
1245                 exists = 1
1246             else:
1247                 exists = 0
1249             # confirm correct weight
1250             if len(idlessprops) != len(values):
1251                 self.error_message.append(
1252                     _('Not enough values on line %(line)s')%{'line':line})
1253                 return
1255             # extract the new values
1256             d = {}
1257             for name, value in zip(idlessprops, values):
1258                 prop = cl.properties[name]
1259                 value = value.strip()
1260                 # only add the property if it has a value
1261                 if value:
1262                     # if it's a multilink, split it
1263                     if isinstance(prop, hyperdb.Multilink):
1264                         value = value.split(':')
1265                     d[name] = value
1266                 elif exists:
1267                     # nuke the existing value
1268                     if isinstance(prop, hyperdb.Multilink):
1269                         d[name] = []
1270                     else:
1271                         d[name] = None
1273             # perform the edit
1274             if exists:
1275                 # edit existing
1276                 cl.set(nodeid, **d)
1277             else:
1278                 # new node
1279                 found[cl.create(**d)] = 1
1281         # retire the removed entries
1282         for nodeid in cl.list():
1283             if not found.has_key(nodeid):
1284                 cl.retire(nodeid)
1286         # all OK
1287         self.db.commit()
1289         self.ok_message.append(_('Items edited OK'))
1291     def editCSVPermission(self):
1292         ''' Determine whether the user has permission to edit this class.
1294             Base behaviour is to check the user can edit this class.
1295         ''' 
1296         if not self.db.security.hasPermission('Edit', self.userid,
1297                 self.classname):
1298             return 0
1299         return 1
1301     def searchAction(self, wcre=re.compile(r'[\s,]+')):
1302         ''' Mangle some of the form variables.
1304             Set the form ":filter" variable based on the values of the
1305             filter variables - if they're set to anything other than
1306             "dontcare" then add them to :filter.
1308             Handle the ":queryname" variable and save off the query to
1309             the user's query list.
1311             Split any String query values on whitespace and comma.
1312         '''
1313         # generic edit is per-class only
1314         if not self.searchPermission():
1315             self.error_message.append(
1316                 _('You do not have permission to search %s' %self.classname))
1318         # add a faked :filter form variable for each filtering prop
1319         props = self.db.classes[self.classname].getprops()
1320         queryname = ''
1321         for key in self.form.keys():
1322             # special vars
1323             if self.FV_QUERYNAME.match(key):
1324                 queryname = self.form[key].value.strip()
1325                 continue
1327             if not props.has_key(key):
1328                 continue
1329             if isinstance(self.form[key], type([])):
1330                 # search for at least one entry which is not empty
1331                 for minifield in self.form[key]:
1332                     if minifield.value:
1333                         break
1334                 else:
1335                     continue
1336             else:
1337                 if not self.form[key].value:
1338                     continue
1339                 if isinstance(props[key], hyperdb.String):
1340                     v = self.form[key].value
1341                     l = token.token_split(v)
1342                     if len(l) > 1 or l[0] != v:
1343                         self.form.value.remove(self.form[key])
1344                         # replace the single value with the split list
1345                         for v in l:
1346                             self.form.value.append(cgi.MiniFieldStorage(key, v))
1348             self.form.value.append(cgi.MiniFieldStorage('@filter', key))
1350         # handle saving the query params
1351         if queryname:
1352             # parse the environment and figure what the query _is_
1353             req = HTMLRequest(self)
1354             url = req.indexargs_href('', {})
1356             # handle editing an existing query
1357             try:
1358                 qid = self.db.query.lookup(queryname)
1359                 self.db.query.set(qid, klass=self.classname, url=url)
1360             except KeyError:
1361                 # create a query
1362                 qid = self.db.query.create(name=queryname,
1363                     klass=self.classname, url=url)
1365                 # and add it to the user's query multilink
1366                 queries = self.db.user.get(self.userid, 'queries')
1367                 queries.append(qid)
1368                 self.db.user.set(self.userid, queries=queries)
1370             # commit the query change to the database
1371             self.db.commit()
1373     def searchPermission(self):
1374         ''' Determine whether the user has permission to search this class.
1376             Base behaviour is to check the user can view this class.
1377         ''' 
1378         if not self.db.security.hasPermission('View', self.userid,
1379                 self.classname):
1380             return 0
1381         return 1
1384     def retireAction(self):
1385         ''' Retire the context item.
1386         '''
1387         # if we want to view the index template now, then unset the nodeid
1388         # context info (a special-case for retire actions on the index page)
1389         nodeid = self.nodeid
1390         if self.template == 'index':
1391             self.nodeid = None
1393         # generic edit is per-class only
1394         if not self.retirePermission():
1395             self.error_message.append(
1396                 _('You do not have permission to retire %s' %self.classname))
1397             return
1399         # make sure we don't try to retire admin or anonymous
1400         if self.classname == 'user' and \
1401                 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1402             self.error_message.append(
1403                 _('You may not retire the admin or anonymous user'))
1404             return
1406         # do the retire
1407         self.db.getclass(self.classname).retire(nodeid)
1408         self.db.commit()
1410         self.ok_message.append(
1411             _('%(classname)s %(itemid)s has been retired')%{
1412                 'classname': self.classname.capitalize(), 'itemid': nodeid})
1414     def retirePermission(self):
1415         ''' Determine whether the user has permission to retire this class.
1417             Base behaviour is to check the user can edit this class.
1418         ''' 
1419         if not self.db.security.hasPermission('Edit', self.userid,
1420                 self.classname):
1421             return 0
1422         return 1
1425     def showAction(self, typere=re.compile('[@:]type'),
1426             numre=re.compile('[@:]number')):
1427         ''' Show a node of a particular class/id
1428         '''
1429         t = n = ''
1430         for key in self.form.keys():
1431             if typere.match(key):
1432                 t = self.form[key].value.strip()
1433             elif numre.match(key):
1434                 n = self.form[key].value.strip()
1435         if not t:
1436             raise ValueError, 'Invalid %s number'%t
1437         url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1438         raise Redirect, url
1440     def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1441         ''' Item properties and their values are edited with html FORM
1442             variables and their values. You can:
1444             - Change the value of some property of the current item.
1445             - Create a new item of any class, and edit the new item's
1446               properties,
1447             - Attach newly created items to a multilink property of the
1448               current item.
1449             - Remove items from a multilink property of the current item.
1450             - Specify that some properties are required for the edit
1451               operation to be successful.
1453             In the following, <bracketed> values are variable, "@" may be
1454             either ":" or "@", and other text "required" is fixed.
1456             Most properties are specified as form variables:
1458              <propname>
1459               - property on the current context item
1461              <designator>"@"<propname>
1462               - property on the indicated item (for editing related
1463                 information)
1465             Designators name a specific item of a class.
1467             <classname><N>
1469                 Name an existing item of class <classname>.
1471             <classname>"-"<N>
1473                 Name the <N>th new item of class <classname>. If the form
1474                 submission is successful, a new item of <classname> is
1475                 created. Within the submitted form, a particular
1476                 designator of this form always refers to the same new
1477                 item.
1479             Once we have determined the "propname", we look at it to see
1480             if it's special:
1482             @required
1483                 The associated form value is a comma-separated list of
1484                 property names that must be specified when the form is
1485                 submitted for the edit operation to succeed.  
1487                 When the <designator> is missing, the properties are
1488                 for the current context item.  When <designator> is
1489                 present, they are for the item specified by
1490                 <designator>.
1492                 The "@required" specifier must come before any of the
1493                 properties it refers to are assigned in the form.
1495             @remove@<propname>=id(s) or @add@<propname>=id(s)
1496                 The "@add@" and "@remove@" edit actions apply only to
1497                 Multilink properties.  The form value must be a
1498                 comma-separate list of keys for the class specified by
1499                 the simple form variable.  The listed items are added
1500                 to (respectively, removed from) the specified
1501                 property.
1503             @link@<propname>=<designator>
1504                 If the edit action is "@link@", the simple form
1505                 variable must specify a Link or Multilink property.
1506                 The form value is a comma-separated list of
1507                 designators.  The item corresponding to each
1508                 designator is linked to the property given by simple
1509                 form variable.  These are collected up and returned in
1510                 all_links.
1512             None of the above (ie. just a simple form value)
1513                 The value of the form variable is converted
1514                 appropriately, depending on the type of the property.
1516                 For a Link('klass') property, the form value is a
1517                 single key for 'klass', where the key field is
1518                 specified in dbinit.py.  
1520                 For a Multilink('klass') property, the form value is a
1521                 comma-separated list of keys for 'klass', where the
1522                 key field is specified in dbinit.py.  
1524                 Note that for simple-form-variables specifiying Link
1525                 and Multilink properties, the linked-to class must
1526                 have a key field.
1528                 For a String() property specifying a filename, the
1529                 file named by the form value is uploaded. This means we
1530                 try to set additional properties "filename" and "type" (if
1531                 they are valid for the class).  Otherwise, the property
1532                 is set to the form value.
1534                 For Date(), Interval(), Boolean(), and Number()
1535                 properties, the form value is converted to the
1536                 appropriate
1538             Any of the form variables may be prefixed with a classname or
1539             designator.
1541             Two special form values are supported for backwards
1542             compatibility:
1544             @note
1545                 This is equivalent to::
1547                     @link@messages=msg-1
1548                     @msg-1@content=value
1550                 except that in addition, the "author" and "date"
1551                 properties of "msg-1" are set to the userid of the
1552                 submitter, and the current time, respectively.
1554             @file
1555                 This is equivalent to::
1557                     @link@files=file-1
1558                     @file-1@content=value
1560                 The String content value is handled as described above for
1561                 file uploads.
1563             If both the "@note" and "@file" form variables are
1564             specified, the action::
1566                     @link@msg-1@files=file-1
1568             is also performed.
1570             We also check that FileClass items have a "content" property with
1571             actual content, otherwise we remove them from all_props before
1572             returning.
1574             The return from this method is a dict of 
1575                 (classname, id): properties
1576             ... this dict _always_ has an entry for the current context,
1577             even if it's empty (ie. a submission for an existing issue that
1578             doesn't result in any changes would return {('issue','123'): {}})
1579             The id may be None, which indicates that an item should be
1580             created.
1581         '''
1582         # some very useful variables
1583         db = self.db
1584         form = self.form
1586         if not hasattr(self, 'FV_SPECIAL'):
1587             # generate the regexp for handling special form values
1588             classes = '|'.join(db.classes.keys())
1589             # specials for parsePropsFromForm
1590             # handle the various forms (see unit tests)
1591             self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1592             self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1594         # these indicate the default class / item
1595         default_cn = self.classname
1596         default_cl = self.db.classes[default_cn]
1597         default_nodeid = self.nodeid
1599         # we'll store info about the individual class/item edit in these
1600         all_required = {}       # required props per class/item
1601         all_props = {}          # props present per class/item
1602         all_propdef = {}        # note - only one entry per class
1603         all_links = []          # as many as are required
1605         # we should always return something, even empty, for the context
1606         all_props[(default_cn, default_nodeid)] = {}
1608         keys = form.keys()
1609         timezone = db.getUserTimezone()
1611         # sentinels for the :note and :file props
1612         have_note = have_file = 0
1614         # extract the usable form labels from the form
1615         matches = []
1616         for key in keys:
1617             m = self.FV_SPECIAL.match(key)
1618             if m:
1619                 matches.append((key, m.groupdict()))
1621         # now handle the matches
1622         for key, d in matches:
1623             if d['classname']:
1624                 # we got a designator
1625                 cn = d['classname']
1626                 cl = self.db.classes[cn]
1627                 nodeid = d['id']
1628                 propname = d['propname']
1629             elif d['note']:
1630                 # the special note field
1631                 cn = 'msg'
1632                 cl = self.db.classes[cn]
1633                 nodeid = '-1'
1634                 propname = 'content'
1635                 all_links.append((default_cn, default_nodeid, 'messages',
1636                     [('msg', '-1')]))
1637                 have_note = 1
1638             elif d['file']:
1639                 # the special file field
1640                 cn = 'file'
1641                 cl = self.db.classes[cn]
1642                 nodeid = '-1'
1643                 propname = 'content'
1644                 all_links.append((default_cn, default_nodeid, 'files',
1645                     [('file', '-1')]))
1646                 have_file = 1
1647             else:
1648                 # default
1649                 cn = default_cn
1650                 cl = default_cl
1651                 nodeid = default_nodeid
1652                 propname = d['propname']
1654             # the thing this value relates to is...
1655             this = (cn, nodeid)
1657             # get more info about the class, and the current set of
1658             # form props for it
1659             if not all_propdef.has_key(cn):
1660                 all_propdef[cn] = cl.getprops()
1661             propdef = all_propdef[cn]
1662             if not all_props.has_key(this):
1663                 all_props[this] = {}
1664             props = all_props[this]
1666             # is this a link command?
1667             if d['link']:
1668                 value = []
1669                 for entry in extractFormList(form[key]):
1670                     m = self.FV_DESIGNATOR.match(entry)
1671                     if not m:
1672                         raise ValueError, \
1673                             'link "%s" value "%s" not a designator'%(key, entry)
1674                     value.append((m.group(1), m.group(2)))
1676                 # make sure the link property is valid
1677                 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1678                         not isinstance(propdef[propname], hyperdb.Link)):
1679                     raise ValueError, '%s %s is not a link or '\
1680                         'multilink property'%(cn, propname)
1682                 all_links.append((cn, nodeid, propname, value))
1683                 continue
1685             # detect the special ":required" variable
1686             if d['required']:
1687                 all_required[this] = extractFormList(form[key])
1688                 continue
1690             # see if we're performing a special multilink action
1691             mlaction = 'set'
1692             if d['remove']:
1693                 mlaction = 'remove'
1694             elif d['add']:
1695                 mlaction = 'add'
1697             # does the property exist?
1698             if not propdef.has_key(propname):
1699                 if mlaction != 'set':
1700                     raise ValueError, 'You have submitted a %s action for'\
1701                         ' the property "%s" which doesn\'t exist'%(mlaction,
1702                         propname)
1703                 # the form element is probably just something we don't care
1704                 # about - ignore it
1705                 continue
1706             proptype = propdef[propname]
1708             # Get the form value. This value may be a MiniFieldStorage or a list
1709             # of MiniFieldStorages.
1710             value = form[key]
1712             # handle unpacking of the MiniFieldStorage / list form value
1713             if isinstance(proptype, hyperdb.Multilink):
1714                 value = extractFormList(value)
1715             else:
1716                 # multiple values are not OK
1717                 if isinstance(value, type([])):
1718                     raise ValueError, 'You have submitted more than one value'\
1719                         ' for the %s property'%propname
1720                 # value might be a file upload...
1721                 if not hasattr(value, 'filename') or value.filename is None:
1722                     # nope, pull out the value and strip it
1723                     value = value.value.strip()
1725             # now that we have the props field, we need a teensy little
1726             # extra bit of help for the old :note field...
1727             if d['note'] and value:
1728                 props['author'] = self.db.getuid()
1729                 props['date'] = date.Date()
1731             # handle by type now
1732             if isinstance(proptype, hyperdb.Password):
1733                 if not value:
1734                     # ignore empty password values
1735                     continue
1736                 for key, d in matches:
1737                     if d['confirm'] and d['propname'] == propname:
1738                         confirm = form[key]
1739                         break
1740                 else:
1741                     raise ValueError, 'Password and confirmation text do '\
1742                         'not match'
1743                 if isinstance(confirm, type([])):
1744                     raise ValueError, 'You have submitted more than one value'\
1745                         ' for the %s property'%propname
1746                 if value != confirm.value:
1747                     raise ValueError, 'Password and confirmation text do '\
1748                         'not match'
1749                 value = password.Password(value)
1751             elif isinstance(proptype, hyperdb.Link):
1752                 # see if it's the "no selection" choice
1753                 if value == '-1' or not value:
1754                     # if we're creating, just don't include this property
1755                     if not nodeid or nodeid.startswith('-'):
1756                         continue
1757                     value = None
1758                 else:
1759                     # handle key values
1760                     link = proptype.classname
1761                     if not num_re.match(value):
1762                         try:
1763                             value = db.classes[link].lookup(value)
1764                         except KeyError:
1765                             raise ValueError, _('property "%(propname)s": '
1766                                 '%(value)s not a %(classname)s')%{
1767                                 'propname': propname, 'value': value,
1768                                 'classname': link}
1769                         except TypeError, message:
1770                             raise ValueError, _('you may only enter ID values '
1771                                 'for property "%(propname)s": %(message)s')%{
1772                                 'propname': propname, 'message': message}
1773             elif isinstance(proptype, hyperdb.Multilink):
1774                 # perform link class key value lookup if necessary
1775                 link = proptype.classname
1776                 link_cl = db.classes[link]
1777                 l = []
1778                 for entry in value:
1779                     if not entry: continue
1780                     if not num_re.match(entry):
1781                         try:
1782                             entry = link_cl.lookup(entry)
1783                         except KeyError:
1784                             raise ValueError, _('property "%(propname)s": '
1785                                 '"%(value)s" not an entry of %(classname)s')%{
1786                                 'propname': propname, 'value': entry,
1787                                 'classname': link}
1788                         except TypeError, message:
1789                             raise ValueError, _('you may only enter ID values '
1790                                 'for property "%(propname)s": %(message)s')%{
1791                                 'propname': propname, 'message': message}
1792                     l.append(entry)
1793                 l.sort()
1795                 # now use that list of ids to modify the multilink
1796                 if mlaction == 'set':
1797                     value = l
1798                 else:
1799                     # we're modifying the list - get the current list of ids
1800                     if props.has_key(propname):
1801                         existing = props[propname]
1802                     elif nodeid and not nodeid.startswith('-'):
1803                         existing = cl.get(nodeid, propname, [])
1804                     else:
1805                         existing = []
1807                     # now either remove or add
1808                     if mlaction == 'remove':
1809                         # remove - handle situation where the id isn't in
1810                         # the list
1811                         for entry in l:
1812                             try:
1813                                 existing.remove(entry)
1814                             except ValueError:
1815                                 raise ValueError, _('property "%(propname)s": '
1816                                     '"%(value)s" not currently in list')%{
1817                                     'propname': propname, 'value': entry}
1818                     else:
1819                         # add - easy, just don't dupe
1820                         for entry in l:
1821                             if entry not in existing:
1822                                 existing.append(entry)
1823                     value = existing
1824                     value.sort()
1826             elif value == '':
1827                 # if we're creating, just don't include this property
1828                 if not nodeid or nodeid.startswith('-'):
1829                     continue
1830                 # other types should be None'd if there's no value
1831                 value = None
1832             else:
1833                 # handle ValueErrors for all these in a similar fashion
1834                 try:
1835                     if isinstance(proptype, hyperdb.String):
1836                         if (hasattr(value, 'filename') and
1837                                 value.filename is not None):
1838                             # skip if the upload is empty
1839                             if not value.filename:
1840                                 continue
1841                             # this String is actually a _file_
1842                             # try to determine the file content-type
1843                             fn = value.filename.split('\\')[-1]
1844                             if propdef.has_key('name'):
1845                                 props['name'] = fn
1846                             # use this info as the type/filename properties
1847                             if propdef.has_key('type'):
1848                                 props['type'] = mimetypes.guess_type(fn)[0]
1849                                 if not props['type']:
1850                                     props['type'] = "application/octet-stream"
1851                             # finally, read the content
1852                             value = value.value
1853                         else:
1854                             # normal String fix the CRLF/CR -> LF stuff
1855                             value = fixNewlines(value)
1857                     elif isinstance(proptype, hyperdb.Date):
1858                         value = date.Date(value, offset=timezone)
1859                     elif isinstance(proptype, hyperdb.Interval):
1860                         value = date.Interval(value)
1861                     elif isinstance(proptype, hyperdb.Boolean):
1862                         value = value.lower() in ('yes', 'true', 'on', '1')
1863                     elif isinstance(proptype, hyperdb.Number):
1864                         value = float(value)
1865                 except ValueError, msg:
1866                     raise ValueError, _('Error with %s property: %s')%(
1867                         propname, msg)
1869             # get the old value
1870             if nodeid and not nodeid.startswith('-'):
1871                 try:
1872                     existing = cl.get(nodeid, propname)
1873                 except KeyError:
1874                     # this might be a new property for which there is
1875                     # no existing value
1876                     if not propdef.has_key(propname):
1877                         raise
1879                 # make sure the existing multilink is sorted
1880                 if isinstance(proptype, hyperdb.Multilink):
1881                     existing.sort()
1883                 # "missing" existing values may not be None
1884                 if not existing:
1885                     if isinstance(proptype, hyperdb.String) and not existing:
1886                         # some backends store "missing" Strings as empty strings
1887                         existing = None
1888                     elif isinstance(proptype, hyperdb.Number) and not existing:
1889                         # some backends store "missing" Numbers as 0 :(
1890                         existing = 0
1891                     elif isinstance(proptype, hyperdb.Boolean) and not existing:
1892                         # likewise Booleans
1893                         existing = 0
1895                 # if changed, set it
1896                 if value != existing:
1897                     props[propname] = value
1898             else:
1899                 # don't bother setting empty/unset values
1900                 if value is None:
1901                     continue
1902                 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1903                     continue
1904                 elif isinstance(proptype, hyperdb.String) and value == '':
1905                     continue
1907                 props[propname] = value
1909         # check to see if we need to specially link a file to the note
1910         if have_note and have_file:
1911             all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1913         # see if all the required properties have been supplied
1914         s = []
1915         for thing, required in all_required.items():
1916             # register the values we got
1917             got = all_props.get(thing, {})
1918             for entry in required:
1919                 if got.get(entry, ''):
1920                     required.remove(entry)
1922             # any required values not present?
1923             if not required:
1924                 continue
1925             if len(required) > 1:
1926                 p = 'properties'
1927             else:
1928                 p = 'property'
1929             s.append('Required %s %s %s not supplied'%(thing[0], p,
1930                 ', '.join(required)))
1931         if s:
1932             raise ValueError, '\n'.join(s)
1934         # check that FileClass entries have a "content" property with
1935         # content, otherwise remove them
1936         for (cn, id), props in all_props.items():
1937             cl = self.db.classes[cn]
1938             if not isinstance(cl, hyperdb.FileClass):
1939                 continue
1940             # we also don't want to create FileClass items with no content
1941             if not props.get('content', ''):
1942                 del all_props[(cn, id)]
1943         return all_props, all_links
1945 def fixNewlines(text):
1946     ''' Homogenise line endings.
1948         Different web clients send different line ending values, but
1949         other systems (eg. email) don't necessarily handle those line
1950         endings. Our solution is to convert all line endings to LF.
1951     '''
1952     text = text.replace('\r\n', '\n')
1953     return text.replace('\r', '\n')
1955 def extractFormList(value):
1956     ''' Extract a list of values from the form value.
1958         It may be one of:
1959          [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
1960          MiniFieldStorage('value,value,...')
1961          MiniFieldStorage('value')
1962     '''
1963     # multiple values are OK
1964     if isinstance(value, type([])):
1965         # it's a list of MiniFieldStorages - join then into
1966         values = ','.join([i.value.strip() for i in value])
1967     else:
1968         # it's a MiniFieldStorage, but may be a comma-separated list
1969         # of values
1970         values = value.value
1972     value = [i.strip() for i in values.split(',')]
1974     # filter out the empty bits
1975     return filter(None, value)