Code

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