Code

don't open the database for static files
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.124 2003-06-24 05:00:43 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             # figure out the context and desired content template
236             # do this first so we don't authenticate for static files
237             # Note: this method opens the database as "admin" in order to
238             # perform context checks
239             self.determine_context()
241             # make sure we're identified (even anonymously)
242             self.determine_user()
244             # possibly handle a form submit action (may change self.classname
245             # and self.template, and may also append error/ok_messages)
246             self.handle_action()
248             # now render the page
249             # we don't want clients caching our dynamic pages
250             self.additional_headers['Cache-Control'] = 'no-cache'
251 # Pragma: no-cache makes Mozilla and its ilk double-load all pages!!
252 #            self.additional_headers['Pragma'] = 'no-cache'
254             # expire this page 5 seconds from now
255             date = rfc822.formatdate(time.time() + 5)
256             self.additional_headers['Expires'] = date
258             # render the content
259             self.write(self.renderContext())
260         except Redirect, url:
261             # let's redirect - if the url isn't None, then we need to do
262             # the headers, otherwise the headers have been set before the
263             # exception was raised
264             if url:
265                 self.additional_headers['Location'] = url
266                 self.response_code = 302
267             self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
268         except SendFile, designator:
269             self.serve_file(designator)
270         except SendStaticFile, file:
271             try:
272                 self.serve_static_file(str(file))
273             except NotModified:
274                 # send the 304 response
275                 self.request.send_response(304)
276                 self.request.end_headers()
277         except Unauthorised, message:
278             self.classname = None
279             self.template = ''
280             self.error_message.append(message)
281             self.write(self.renderContext())
282         except NotFound:
283             # pass through
284             raise
285         except:
286             # everything else
287             self.write(cgitb.html())
289     def clean_sessions(self):
290         ''' Age sessions, remove when they haven't been used for a week.
291         
292             Do it only once an hour.
294             Note: also cleans One Time Keys, and other "session" based
295             stuff.
296         '''
297         sessions = self.db.sessions
298         last_clean = sessions.get('last_clean', 'last_use') or 0
300         week = 60*60*24*7
301         hour = 60*60
302         now = time.time()
303         if now - last_clean > hour:
304             # remove aged sessions
305             for sessid in sessions.list():
306                 interval = now - sessions.get(sessid, 'last_use')
307                 if interval > week:
308                     sessions.destroy(sessid)
309             # remove aged otks
310             otks = self.db.otks
311             for sessid in otks.list():
312                 interval = now - otks.get(sessid, '__time')
313                 if interval > week:
314                     otks.destroy(sessid)
315             sessions.set('last_clean', last_use=time.time())
317     def determine_user(self):
318         ''' Determine who the user is
319         '''
320         # clean age sessions
321         self.clean_sessions()
322         # make sure we have the session Class
323         sessions = self.db.sessions
325         # look up the user session cookie
326         cookie = Cookie.SimpleCookie(self.env.get('HTTP_COOKIE', ''))
327         user = 'anonymous'
329         # bump the "revision" of the cookie since the format changed
330         if (cookie.has_key(self.cookie_name) and
331                 cookie[self.cookie_name].value != 'deleted'):
333             # get the session key from the cookie
334             self.session = cookie[self.cookie_name].value
335             # get the user from the session
336             try:
337                 # update the lifetime datestamp
338                 sessions.set(self.session, last_use=time.time())
339                 sessions.commit()
340                 user = sessions.get(self.session, 'user')
341             except KeyError:
342                 user = 'anonymous'
344         # sanity check on the user still being valid, getting the userid
345         # at the same time
346         try:
347             self.userid = self.db.user.lookup(user)
348         except (KeyError, TypeError):
349             user = 'anonymous'
351         # make sure the anonymous user is valid if we're using it
352         if user == 'anonymous':
353             self.make_user_anonymous()
354         else:
355             self.user = user
357         # reopen the database as the correct user
358         self.opendb(self.user)
360     def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
361         ''' Determine the context of this page from the URL:
363             The URL path after the instance identifier is examined. The path
364             is generally only one entry long.
366             - if there is no path, then we are in the "home" context.
367             * if the path is "_file", then the additional path entry
368               specifies the filename of a static file we're to serve up
369               from the instance "html" directory. Raises a SendStaticFile
370               exception.
371             - if there is something in the path (eg "issue"), it identifies
372               the tracker class we're to display.
373             - if the path is an item designator (eg "issue123"), then we're
374               to display a specific item.
375             * if the path starts with an item designator and is longer than
376               one entry, then we're assumed to be handling an item of a
377               FileClass, and the extra path information gives the filename
378               that the client is going to label the download with (ie
379               "file123/image.png" is nicer to download than "file123"). This
380               raises a SendFile exception.
382             Both of the "*" types of contexts stop before we bother to
383             determine the template we're going to use. That's because they
384             don't actually use templates.
386             The template used is specified by the :template CGI variable,
387             which defaults to:
389              only classname suplied:          "index"
390              full item designator supplied:   "item"
392             We set:
393              self.classname  - the class to display, can be None
394              self.template   - the template to render the current context with
395              self.nodeid     - the nodeid of the class we're displaying
396         '''
397         # default the optional variables
398         self.classname = None
399         self.nodeid = None
401         # see if a template or messages are specified
402         template_override = ok_message = error_message = None
403         for key in self.form.keys():
404             if self.FV_TEMPLATE.match(key):
405                 template_override = self.form[key].value
406             elif self.FV_OK_MESSAGE.match(key):
407                 ok_message = self.form[key].value
408                 ok_message = clean_message(ok_message)
409             elif self.FV_ERROR_MESSAGE.match(key):
410                 error_message = self.form[key].value
411                 error_message = clean_message(error_message)
413         # determine the classname and possibly nodeid
414         path = self.path.split('/')
415         if not path or path[0] in ('', 'home', 'index'):
416             if template_override is not None:
417                 self.template = template_override
418             else:
419                 self.template = ''
420             return
421         elif path[0] == '_file':
422             raise SendStaticFile, os.path.join(*path[1:])
423         else:
424             self.classname = path[0]
425             if len(path) > 1:
426                 # send the file identified by the designator in path[0]
427                 raise SendFile, path[0]
429         # we need the db for further context stuff - open it as admin
430         self.opendb('admin')
432         # see if we got a designator
433         m = dre.match(self.classname)
434         if m:
435             self.classname = m.group(1)
436             self.nodeid = m.group(2)
437             if not self.db.getclass(self.classname).hasnode(self.nodeid):
438                 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
439             # with a designator, we default to item view
440             self.template = 'item'
441         else:
442             # with only a class, we default to index view
443             self.template = 'index'
445         # make sure the classname is valid
446         try:
447             self.db.getclass(self.classname)
448         except KeyError:
449             raise NotFound, self.classname
451         # see if we have a template override
452         if template_override is not None:
453             self.template = template_override
455         # see if we were passed in a message
456         if ok_message:
457             self.ok_message.append(ok_message)
458         if error_message:
459             self.error_message.append(error_message)
461     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
462         ''' Serve the file from the content property of the designated item.
463         '''
464         m = dre.match(str(designator))
465         if not m:
466             raise NotFound, str(designator)
467         classname, nodeid = m.group(1), m.group(2)
468         if classname != 'file':
469             raise NotFound, designator
471         # we just want to serve up the file named
472         file = self.db.file
473         self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
474         self.write(file.get(nodeid, 'content'))
476     def serve_static_file(self, file):
477         ims = None
478         # see if there's an if-modified-since...
479         if hasattr(self.request, 'headers'):
480             ims = self.request.headers.getheader('if-modified-since')
481         elif self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
482             # cgi will put the header in the env var
483             ims = self.env['HTTP_IF_MODIFIED_SINCE']
484         filename = os.path.join(self.instance.config.TEMPLATES, file)
485         lmt = os.stat(filename)[stat.ST_MTIME]
486         if ims:
487             ims = rfc822.parsedate(ims)[:6]
488             lmtt = time.gmtime(lmt)[:6]
489             if lmtt <= ims:
490                 raise NotModified
492         # we just want to serve up the file named
493         file = str(file)
494         mt = mimetypes.guess_type(file)[0]
495         if not mt:
496             if file.endswith('.css'):
497                 mt = 'text/css'
498             else:
499                 mt = 'text/plain'
500         self.additional_headers['Content-Type'] = mt
501         self.additional_headers['Last-Modifed'] = rfc822.formatdate(lmt)
502         self.write(open(filename, 'rb').read())
504     def renderContext(self):
505         ''' Return a PageTemplate for the named page
506         '''
507         name = self.classname
508         extension = self.template
509         pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
511         # catch errors so we can handle PT rendering errors more nicely
512         args = {
513             'ok_message': self.ok_message,
514             'error_message': self.error_message
515         }
516         try:
517             # let the template render figure stuff out
518             return pt.render(self, None, None, **args)
519         except NoTemplate, message:
520             return '<strong>%s</strong>'%message
521         except:
522             # everything else
523             return cgitb.pt_html()
525     # these are the actions that are available
526     actions = (
527         ('edit',     'editItemAction'),
528         ('editcsv',  'editCSVAction'),
529         ('new',      'newItemAction'),
530         ('register', 'registerAction'),
531         ('confrego', 'confRegoAction'),
532         ('passrst',  'passResetAction'),
533         ('login',    'loginAction'),
534         ('logout',   'logout_action'),
535         ('search',   'searchAction'),
536         ('retire',   'retireAction'),
537         ('show',     'showAction'),
538     )
539     def handle_action(self):
540         ''' Determine whether there should be an Action called.
542             The action is defined by the form variable :action which
543             identifies the method on this object to call. The actions
544             are defined in the "actions" sequence on this class.
545         '''
546         if self.form.has_key(':action'):
547             action = self.form[':action'].value.lower()
548         elif self.form.has_key('@action'):
549             action = self.form['@action'].value.lower()
550         else:
551             return None
552         try:
553             # get the action, validate it
554             for name, method in self.actions:
555                 if name == action:
556                     break
557             else:
558                 raise ValueError, 'No such action "%s"'%action
559             # call the mapped action
560             getattr(self, method)()
561         except Redirect:
562             raise
563         except Unauthorised:
564             raise
566     def write(self, content):
567         if not self.headers_done:
568             self.header()
569         self.request.wfile.write(content)
571     def header(self, headers=None, response=None):
572         '''Put up the appropriate header.
573         '''
574         if headers is None:
575             headers = {'Content-Type':'text/html'}
576         if response is None:
577             response = self.response_code
579         # update with additional info
580         headers.update(self.additional_headers)
582         if not headers.has_key('Content-Type'):
583             headers['Content-Type'] = 'text/html'
584         self.request.send_response(response)
585         for entry in headers.items():
586             self.request.send_header(*entry)
587         self.request.end_headers()
588         self.headers_done = 1
589         if self.debug:
590             self.headers_sent = headers
592     def set_cookie(self, user):
593         ''' Set up a session cookie for the user and store away the user's
594             login info against the session.
595         '''
596         # TODO generate a much, much stronger session key ;)
597         self.session = binascii.b2a_base64(repr(random.random())).strip()
599         # clean up the base64
600         if self.session[-1] == '=':
601             if self.session[-2] == '=':
602                 self.session = self.session[:-2]
603             else:
604                 self.session = self.session[:-1]
606         # insert the session in the sessiondb
607         self.db.sessions.set(self.session, user=user, last_use=time.time())
609         # and commit immediately
610         self.db.sessions.commit()
612         # expire us in a long, long time
613         expire = Cookie._getdate(86400*365)
615         # generate the cookie path - make sure it has a trailing '/'
616         self.additional_headers['Set-Cookie'] = \
617           '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
618             expire, self.cookie_path)
620     def make_user_anonymous(self):
621         ''' Make us anonymous
623             This method used to handle non-existence of the 'anonymous'
624             user, but that user is mandatory now.
625         '''
626         self.userid = self.db.user.lookup('anonymous')
627         self.user = 'anonymous'
629     def opendb(self, user):
630         ''' Open the database.
631         '''
632         # open the db if the user has changed
633         if not hasattr(self, 'db') or user != self.db.journaltag:
634             if hasattr(self, 'db'):
635                 self.db.close()
636             self.db = self.instance.open(user)
638     #
639     # Actions
640     #
641     def loginAction(self):
642         ''' Attempt to log a user in.
644             Sets up a session for the user which contains the login
645             credentials.
646         '''
647         # we need the username at a minimum
648         if not self.form.has_key('__login_name'):
649             self.error_message.append(_('Username required'))
650             return
652         # get the login info
653         self.user = self.form['__login_name'].value
654         if self.form.has_key('__login_password'):
655             password = self.form['__login_password'].value
656         else:
657             password = ''
659         # make sure the user exists
660         try:
661             self.userid = self.db.user.lookup(self.user)
662         except KeyError:
663             name = self.user
664             self.error_message.append(_('No such user "%(name)s"')%locals())
665             self.make_user_anonymous()
666             return
668         # verify the password
669         if not self.verifyPassword(self.userid, password):
670             self.make_user_anonymous()
671             self.error_message.append(_('Incorrect password'))
672             return
674         # make sure we're allowed to be here
675         if not self.loginPermission():
676             self.make_user_anonymous()
677             self.error_message.append(_("You do not have permission to login"))
678             return
680         # now we're OK, re-open the database for real, using the user
681         self.opendb(self.user)
683         # set the session cookie
684         self.set_cookie(self.user)
686     def verifyPassword(self, userid, password):
687         ''' Verify the password that the user has supplied
688         '''
689         stored = self.db.user.get(self.userid, 'password')
690         if password == stored:
691             return 1
692         if not password and not stored:
693             return 1
694         return 0
696     def loginPermission(self):
697         ''' Determine whether the user has permission to log in.
699             Base behaviour is to check the user has "Web Access".
700         ''' 
701         if not self.db.security.hasPermission('Web Access', self.userid):
702             return 0
703         return 1
705     def logout_action(self):
706         ''' Make us really anonymous - nuke the cookie too
707         '''
708         # log us out
709         self.make_user_anonymous()
711         # construct the logout cookie
712         now = Cookie._getdate()
713         self.additional_headers['Set-Cookie'] = \
714            '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.cookie_name,
715             now, self.cookie_path)
717         # Let the user know what's going on
718         self.ok_message.append(_('You are logged out'))
720     def registerAction(self):
721         '''Attempt to create a new user based on the contents of the form
722         and then set the cookie.
724         return 1 on successful login
725         '''
726         # parse the props from the form
727         try:
728             props = self.parsePropsFromForm()[0][('user', None)]
729         except (ValueError, KeyError), message:
730             self.error_message.append(_('Error: ') + str(message))
731             return
733         # make sure we're allowed to register
734         if not self.registerPermission(props):
735             raise Unauthorised, _("You do not have permission to register")
737         try:
738             self.db.user.lookup(props['username'])
739             self.error_message.append('Error: A user with the username "%s" '
740                 'already exists'%props['username'])
741             return
742         except KeyError:
743             pass
745         # generate the one-time-key and store the props for later
746         otk = ''.join([random.choice(chars) for x in range(32)])
747         for propname, proptype in self.db.user.getprops().items():
748             value = props.get(propname, None)
749             if value is None:
750                 pass
751             elif isinstance(proptype, hyperdb.Date):
752                 props[propname] = str(value)
753             elif isinstance(proptype, hyperdb.Interval):
754                 props[propname] = str(value)
755             elif isinstance(proptype, hyperdb.Password):
756                 props[propname] = str(value)
757         props['__time'] = time.time()
758         self.db.otks.set(otk, **props)
760         # send the email
761         tracker_name = self.db.config.TRACKER_NAME
762         subject = 'Complete your registration to %s'%tracker_name
763         body = '''
764 To complete your registration of the user "%(name)s" with %(tracker)s,
765 please visit the following URL:
767    %(url)s?@action=confrego&otk=%(otk)s
768 '''%{'name': props['username'], 'tracker': tracker_name, 'url': self.base,
769                 'otk': otk}
770         if not self.sendEmail(props['address'], subject, body):
771             return
773         # commit changes to the database
774         self.db.commit()
776         # redirect to the "you're almost there" page
777         raise Redirect, '%suser?@template=rego_progress'%self.base
779     def sendEmail(self, to, subject, content):
780         # send email to the user's email address
781         message = StringIO.StringIO()
782         writer = MimeWriter.MimeWriter(message)
783         tracker_name = self.db.config.TRACKER_NAME
784         writer.addheader('Subject', encode_header(subject))
785         writer.addheader('To', to)
786         writer.addheader('From', roundupdb.straddr((tracker_name,
787             self.db.config.ADMIN_EMAIL)))
788         writer.addheader('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000",
789             time.gmtime()))
790         # add a uniquely Roundup header to help filtering
791         writer.addheader('X-Roundup-Name', tracker_name)
792         # avoid email loops
793         writer.addheader('X-Roundup-Loop', 'hello')
794         writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
795         body = writer.startbody('text/plain; charset=utf-8')
797         # message body, encoded quoted-printable
798         content = StringIO.StringIO(content)
799         quopri.encode(content, body, 0)
801         if SENDMAILDEBUG:
802             # don't send - just write to a file
803             open(SENDMAILDEBUG, 'a').write('FROM: %s\nTO: %s\n%s\n'%(
804                 self.db.config.ADMIN_EMAIL,
805                 ', '.join(to),message.getvalue()))
806         else:
807             # now try to send the message
808             try:
809                 # send the message as admin so bounces are sent there
810                 # instead of to roundup
811                 smtp = openSMTPConnection(self.db.config)
812                 smtp.sendmail(self.db.config.ADMIN_EMAIL, [to],
813                     message.getvalue())
814             except socket.error, value:
815                 self.error_message.append("Error: couldn't send email: "
816                     "mailhost %s"%value)
817                 return 0
818             except smtplib.SMTPException, msg:
819                 self.error_message.append("Error: couldn't send email: %s"%msg)
820                 return 0
821         return 1
823     def registerPermission(self, props):
824         ''' Determine whether the user has permission to register
826             Base behaviour is to check the user has "Web Registration".
827         '''
828         # registration isn't allowed to supply roles
829         if props.has_key('roles'):
830             return 0
831         if self.db.security.hasPermission('Web Registration', self.userid):
832             return 1
833         return 0
835     def confRegoAction(self):
836         ''' Grab the OTK, use it to load up the new user details
837         '''
838         # pull the rego information out of the otk database
839         otk = self.form['otk'].value
840         props = self.db.otks.getall(otk)
841         for propname, proptype in self.db.user.getprops().items():
842             value = props.get(propname, None)
843             if value is None:
844                 pass
845             elif isinstance(proptype, hyperdb.Date):
846                 props[propname] = date.Date(value)
847             elif isinstance(proptype, hyperdb.Interval):
848                 props[propname] = date.Interval(value)
849             elif isinstance(proptype, hyperdb.Password):
850                 props[propname] = password.Password()
851                 props[propname].unpack(value)
853         # re-open the database as "admin"
854         if self.user != 'admin':
855             self.opendb('admin')
857         # create the new user
858         cl = self.db.user
859 # XXX we need to make the "default" page be able to display errors!
860         try:
861             props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
862             del props['__time']
863             self.userid = cl.create(**props)
864             # clear the props from the otk database
865             self.db.otks.destroy(otk)
866             self.db.commit()
867         except (ValueError, KeyError), message:
868             self.error_message.append(str(message))
869             return
871         # log the new user in
872         self.user = cl.get(self.userid, 'username')
873         # re-open the database for real, using the user
874         self.opendb(self.user)
876         # if we have a session, update it
877         if hasattr(self, 'session'):
878             self.db.sessions.set(self.session, user=self.user,
879                 last_use=time.time())
880         else:
881             # new session cookie
882             self.set_cookie(self.user)
884         # nice message
885         message = _('You are now registered, welcome!')
887         # redirect to the user's page
888         raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
889             self.userid, urllib.quote(message))
891     def passResetAction(self):
892         ''' Handle password reset requests.
894             Presence of either "name" or "address" generate email.
895             Presense of "otk" performs the reset.
896         '''
897         if self.form.has_key('otk'):
898             # pull the rego information out of the otk database
899             otk = self.form['otk'].value
900             uid = self.db.otks.get(otk, 'uid')
901             if uid is None:
902                 self.error_message.append('Invalid One Time Key!')
903                 return
905             # re-open the database as "admin"
906             if self.user != 'admin':
907                 self.opendb('admin')
909             # change the password
910             newpw = password.generatePassword()
912             cl = self.db.user
913 # XXX we need to make the "default" page be able to display errors!
914             try:
915                 # set the password
916                 cl.set(uid, password=password.Password(newpw))
917                 # clear the props from the otk database
918                 self.db.otks.destroy(otk)
919                 self.db.commit()
920             except (ValueError, KeyError), message:
921                 self.error_message.append(str(message))
922                 return
924             # user info
925             address = self.db.user.get(uid, 'address')
926             name = self.db.user.get(uid, 'username')
928             # send the email
929             tracker_name = self.db.config.TRACKER_NAME
930             subject = 'Password reset for %s'%tracker_name
931             body = '''
932 The password has been reset for username "%(name)s".
934 Your password is now: %(password)s
935 '''%{'name': name, 'password': newpw}
936             if not self.sendEmail(address, subject, body):
937                 return
939             self.ok_message.append('Password reset and email sent to %s'%address)
940             return
942         # no OTK, so now figure the user
943         if self.form.has_key('username'):
944             name = self.form['username'].value
945             try:
946                 uid = self.db.user.lookup(name)
947             except KeyError:
948                 self.error_message.append('Unknown username')
949                 return
950             address = self.db.user.get(uid, 'address')
951         elif self.form.has_key('address'):
952             address = self.form['address'].value
953             uid = uidFromAddress(self.db, ('', address), create=0)
954             if not uid:
955                 self.error_message.append('Unknown email address')
956                 return
957             name = self.db.user.get(uid, 'username')
958         else:
959             self.error_message.append('You need to specify a username '
960                 'or address')
961             return
963         # generate the one-time-key and store the props for later
964         otk = ''.join([random.choice(chars) for x in range(32)])
965         self.db.otks.set(otk, uid=uid, __time=time.time())
967         # send the email
968         tracker_name = self.db.config.TRACKER_NAME
969         subject = 'Confirm reset of password for %s'%tracker_name
970         body = '''
971 Someone, perhaps you, has requested that the password be changed for your
972 username, "%(name)s". If you wish to proceed with the change, please follow
973 the link below:
975   %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
977 You should then receive another email with the new password.
978 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
979         if not self.sendEmail(address, subject, body):
980             return
982         self.ok_message.append('Email sent to %s'%address)
984     def editItemAction(self):
985         ''' Perform an edit of an item in the database.
987            See parsePropsFromForm and _editnodes for special variables
988         '''
989         # parse the props from the form
990         try:
991             props, links = self.parsePropsFromForm()
992         except (ValueError, KeyError), message:
993             self.error_message.append(_('Error: ') + str(message))
994             return
996         # handle the props
997         try:
998             message = self._editnodes(props, links)
999         except (ValueError, KeyError, IndexError), message:
1000             self.error_message.append(_('Error: ') + str(message))
1001             return
1003         # commit now that all the tricky stuff is done
1004         self.db.commit()
1006         # redirect to the item's edit page
1007         raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
1008             self.classname, self.nodeid, urllib.quote(message),
1009             urllib.quote(self.template))
1011     def editItemPermission(self, props):
1012         ''' Determine whether the user has permission to edit this item.
1014             Base behaviour is to check the user can edit this class. If we're
1015             editing the "user" class, users are allowed to edit their own
1016             details. Unless it's the "roles" property, which requires the
1017             special Permission "Web Roles".
1018         '''
1019         # if this is a user node and the user is editing their own node, then
1020         # we're OK
1021         has = self.db.security.hasPermission
1022         if self.classname == 'user':
1023             # reject if someone's trying to edit "roles" and doesn't have the
1024             # right permission.
1025             if props.has_key('roles') and not has('Web Roles', self.userid,
1026                     'user'):
1027                 return 0
1028             # if the item being edited is the current user, we're ok
1029             if self.nodeid == self.userid:
1030                 return 1
1031         if self.db.security.hasPermission('Edit', self.userid, self.classname):
1032             return 1
1033         return 0
1035     def newItemAction(self):
1036         ''' Add a new item to the database.
1038             This follows the same form as the editItemAction, with the same
1039             special form values.
1040         '''
1041         # parse the props from the form
1042         try:
1043             props, links = self.parsePropsFromForm()
1044         except (ValueError, KeyError), message:
1045             self.error_message.append(_('Error: ') + str(message))
1046             return
1048         # handle the props - edit or create
1049         try:
1050             # when it hits the None element, it'll set self.nodeid
1051             messages = self._editnodes(props, links)
1053         except (ValueError, KeyError, IndexError), message:
1054             # these errors might just be indicative of user dumbness
1055             self.error_message.append(_('Error: ') + str(message))
1056             return
1058         # commit now that all the tricky stuff is done
1059         self.db.commit()
1061         # redirect to the new item's page
1062         raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
1063             self.classname, self.nodeid, urllib.quote(messages),
1064             urllib.quote(self.template))
1066     def newItemPermission(self, props):
1067         ''' Determine whether the user has permission to create (edit) this
1068             item.
1070             Base behaviour is to check the user can edit this class. No
1071             additional property checks are made. Additionally, new user items
1072             may be created if the user has the "Web Registration" Permission.
1073         '''
1074         has = self.db.security.hasPermission
1075         if self.classname == 'user' and has('Web Registration', self.userid,
1076                 'user'):
1077             return 1
1078         if has('Edit', self.userid, self.classname):
1079             return 1
1080         return 0
1083     #
1084     #  Utility methods for editing
1085     #
1086     def _editnodes(self, all_props, all_links, newids=None):
1087         ''' Use the props in all_props to perform edit and creation, then
1088             use the link specs in all_links to do linking.
1089         '''
1090         # figure dependencies and re-work links
1091         deps = {}
1092         links = {}
1093         for cn, nodeid, propname, vlist in all_links:
1094             if not all_props.has_key((cn, nodeid)):
1095                 # link item to link to doesn't (and won't) exist
1096                 continue
1097             for value in vlist:
1098                 if not all_props.has_key(value):
1099                     # link item to link to doesn't (and won't) exist
1100                     continue
1101                 deps.setdefault((cn, nodeid), []).append(value)
1102                 links.setdefault(value, []).append((cn, nodeid, propname))
1104         # figure chained dependencies ordering
1105         order = []
1106         done = {}
1107         # loop detection
1108         change = 0
1109         while len(all_props) != len(done):
1110             for needed in all_props.keys():
1111                 if done.has_key(needed):
1112                     continue
1113                 tlist = deps.get(needed, [])
1114                 for target in tlist:
1115                     if not done.has_key(target):
1116                         break
1117                 else:
1118                     done[needed] = 1
1119                     order.append(needed)
1120                     change = 1
1121             if not change:
1122                 raise ValueError, 'linking must not loop!'
1124         # now, edit / create
1125         m = []
1126         for needed in order:
1127             props = all_props[needed]
1128             if not props:
1129                 # nothing to do
1130                 continue
1131             cn, nodeid = needed
1133             if nodeid is not None and int(nodeid) > 0:
1134                 # make changes to the node
1135                 props = self._changenode(cn, nodeid, props)
1137                 # and some nice feedback for the user
1138                 if props:
1139                     info = ', '.join(props.keys())
1140                     m.append('%s %s %s edited ok'%(cn, nodeid, info))
1141                 else:
1142                     m.append('%s %s - nothing changed'%(cn, nodeid))
1143             else:
1144                 assert props
1146                 # make a new node
1147                 newid = self._createnode(cn, props)
1148                 if nodeid is None:
1149                     self.nodeid = newid
1150                 nodeid = newid
1152                 # and some nice feedback for the user
1153                 m.append('%s %s created'%(cn, newid))
1155             # fill in new ids in links
1156             if links.has_key(needed):
1157                 for linkcn, linkid, linkprop in links[needed]:
1158                     props = all_props[(linkcn, linkid)]
1159                     cl = self.db.classes[linkcn]
1160                     propdef = cl.getprops()[linkprop]
1161                     if not props.has_key(linkprop):
1162                         if linkid is None or linkid.startswith('-'):
1163                             # linking to a new item
1164                             if isinstance(propdef, hyperdb.Multilink):
1165                                 props[linkprop] = [newid]
1166                             else:
1167                                 props[linkprop] = newid
1168                         else:
1169                             # linking to an existing item
1170                             if isinstance(propdef, hyperdb.Multilink):
1171                                 existing = cl.get(linkid, linkprop)[:]
1172                                 existing.append(nodeid)
1173                                 props[linkprop] = existing
1174                             else:
1175                                 props[linkprop] = newid
1177         return '<br>'.join(m)
1179     def _changenode(self, cn, nodeid, props):
1180         ''' change the node based on the contents of the form
1181         '''
1182         # check for permission
1183         if not self.editItemPermission(props):
1184             raise Unauthorised, 'You do not have permission to edit %s'%cn
1186         # make the changes
1187         cl = self.db.classes[cn]
1188         return cl.set(nodeid, **props)
1190     def _createnode(self, cn, props):
1191         ''' create a node based on the contents of the form
1192         '''
1193         # check for permission
1194         if not self.newItemPermission(props):
1195             raise Unauthorised, 'You do not have permission to create %s'%cn
1197         # create the node and return its id
1198         cl = self.db.classes[cn]
1199         return cl.create(**props)
1201     # 
1202     # More actions
1203     #
1204     def editCSVAction(self):
1205         ''' Performs an edit of all of a class' items in one go.
1207             The "rows" CGI var defines the CSV-formatted entries for the
1208             class. New nodes are identified by the ID 'X' (or any other
1209             non-existent ID) and removed lines are retired.
1210         '''
1211         # this is per-class only
1212         if not self.editCSVPermission():
1213             self.error_message.append(
1214                 _('You do not have permission to edit %s' %self.classname))
1216         # get the CSV module
1217         try:
1218             import csv
1219         except ImportError:
1220             self.error_message.append(_(
1221                 'Sorry, you need the csv module to use this function.<br>\n'
1222                 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
1223             return
1225         cl = self.db.classes[self.classname]
1226         idlessprops = cl.getprops(protected=0).keys()
1227         idlessprops.sort()
1228         props = ['id'] + idlessprops
1230         # do the edit
1231         rows = self.form['rows'].value.splitlines()
1232         p = csv.parser()
1233         found = {}
1234         line = 0
1235         for row in rows[1:]:
1236             line += 1
1237             values = p.parse(row)
1238             # not a complete row, keep going
1239             if not values: continue
1241             # skip property names header
1242             if values == props:
1243                 continue
1245             # extract the nodeid
1246             nodeid, values = values[0], values[1:]
1247             found[nodeid] = 1
1249             # see if the node exists
1250             if cl.hasnode(nodeid):
1251                 exists = 1
1252             else:
1253                 exists = 0
1255             # confirm correct weight
1256             if len(idlessprops) != len(values):
1257                 self.error_message.append(
1258                     _('Not enough values on line %(line)s')%{'line':line})
1259                 return
1261             # extract the new values
1262             d = {}
1263             for name, value in zip(idlessprops, values):
1264                 prop = cl.properties[name]
1265                 value = value.strip()
1266                 # only add the property if it has a value
1267                 if value:
1268                     # if it's a multilink, split it
1269                     if isinstance(prop, hyperdb.Multilink):
1270                         value = value.split(':')
1271                     d[name] = value
1272                 elif exists:
1273                     # nuke the existing value
1274                     if isinstance(prop, hyperdb.Multilink):
1275                         d[name] = []
1276                     else:
1277                         d[name] = None
1279             # perform the edit
1280             if exists:
1281                 # edit existing
1282                 cl.set(nodeid, **d)
1283             else:
1284                 # new node
1285                 found[cl.create(**d)] = 1
1287         # retire the removed entries
1288         for nodeid in cl.list():
1289             if not found.has_key(nodeid):
1290                 cl.retire(nodeid)
1292         # all OK
1293         self.db.commit()
1295         self.ok_message.append(_('Items edited OK'))
1297     def editCSVPermission(self):
1298         ''' Determine whether the user has permission to edit this class.
1300             Base behaviour is to check the user can edit this class.
1301         ''' 
1302         if not self.db.security.hasPermission('Edit', self.userid,
1303                 self.classname):
1304             return 0
1305         return 1
1307     def searchAction(self, wcre=re.compile(r'[\s,]+')):
1308         ''' Mangle some of the form variables.
1310             Set the form ":filter" variable based on the values of the
1311             filter variables - if they're set to anything other than
1312             "dontcare" then add them to :filter.
1314             Handle the ":queryname" variable and save off the query to
1315             the user's query list.
1317             Split any String query values on whitespace and comma.
1318         '''
1319         # generic edit is per-class only
1320         if not self.searchPermission():
1321             self.error_message.append(
1322                 _('You do not have permission to search %s' %self.classname))
1324         # add a faked :filter form variable for each filtering prop
1325         props = self.db.classes[self.classname].getprops()
1326         queryname = ''
1327         for key in self.form.keys():
1328             # special vars
1329             if self.FV_QUERYNAME.match(key):
1330                 queryname = self.form[key].value.strip()
1331                 continue
1333             if not props.has_key(key):
1334                 continue
1335             if isinstance(self.form[key], type([])):
1336                 # search for at least one entry which is not empty
1337                 for minifield in self.form[key]:
1338                     if minifield.value:
1339                         break
1340                 else:
1341                     continue
1342             else:
1343                 if not self.form[key].value:
1344                     continue
1345                 if isinstance(props[key], hyperdb.String):
1346                     v = self.form[key].value
1347                     l = token.token_split(v)
1348                     if len(l) > 1 or l[0] != v:
1349                         self.form.value.remove(self.form[key])
1350                         # replace the single value with the split list
1351                         for v in l:
1352                             self.form.value.append(cgi.MiniFieldStorage(key, v))
1354             self.form.value.append(cgi.MiniFieldStorage('@filter', key))
1356         # handle saving the query params
1357         if queryname:
1358             # parse the environment and figure what the query _is_
1359             req = HTMLRequest(self)
1360             url = req.indexargs_href('', {})
1362             # handle editing an existing query
1363             try:
1364                 qid = self.db.query.lookup(queryname)
1365                 self.db.query.set(qid, klass=self.classname, url=url)
1366             except KeyError:
1367                 # create a query
1368                 qid = self.db.query.create(name=queryname,
1369                     klass=self.classname, url=url)
1371                 # and add it to the user's query multilink
1372                 queries = self.db.user.get(self.userid, 'queries')
1373                 queries.append(qid)
1374                 self.db.user.set(self.userid, queries=queries)
1376             # commit the query change to the database
1377             self.db.commit()
1379     def searchPermission(self):
1380         ''' Determine whether the user has permission to search this class.
1382             Base behaviour is to check the user can view this class.
1383         ''' 
1384         if not self.db.security.hasPermission('View', self.userid,
1385                 self.classname):
1386             return 0
1387         return 1
1390     def retireAction(self):
1391         ''' Retire the context item.
1392         '''
1393         # if we want to view the index template now, then unset the nodeid
1394         # context info (a special-case for retire actions on the index page)
1395         nodeid = self.nodeid
1396         if self.template == 'index':
1397             self.nodeid = None
1399         # generic edit is per-class only
1400         if not self.retirePermission():
1401             self.error_message.append(
1402                 _('You do not have permission to retire %s' %self.classname))
1403             return
1405         # make sure we don't try to retire admin or anonymous
1406         if self.classname == 'user' and \
1407                 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1408             self.error_message.append(
1409                 _('You may not retire the admin or anonymous user'))
1410             return
1412         # do the retire
1413         self.db.getclass(self.classname).retire(nodeid)
1414         self.db.commit()
1416         self.ok_message.append(
1417             _('%(classname)s %(itemid)s has been retired')%{
1418                 'classname': self.classname.capitalize(), 'itemid': nodeid})
1420     def retirePermission(self):
1421         ''' Determine whether the user has permission to retire this class.
1423             Base behaviour is to check the user can edit this class.
1424         ''' 
1425         if not self.db.security.hasPermission('Edit', self.userid,
1426                 self.classname):
1427             return 0
1428         return 1
1431     def showAction(self, typere=re.compile('[@:]type'),
1432             numre=re.compile('[@:]number')):
1433         ''' Show a node of a particular class/id
1434         '''
1435         t = n = ''
1436         for key in self.form.keys():
1437             if typere.match(key):
1438                 t = self.form[key].value.strip()
1439             elif numre.match(key):
1440                 n = self.form[key].value.strip()
1441         if not t:
1442             raise ValueError, 'Invalid %s number'%t
1443         url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1444         raise Redirect, url
1446     def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1447         ''' Item properties and their values are edited with html FORM
1448             variables and their values. You can:
1450             - Change the value of some property of the current item.
1451             - Create a new item of any class, and edit the new item's
1452               properties,
1453             - Attach newly created items to a multilink property of the
1454               current item.
1455             - Remove items from a multilink property of the current item.
1456             - Specify that some properties are required for the edit
1457               operation to be successful.
1459             In the following, <bracketed> values are variable, "@" may be
1460             either ":" or "@", and other text "required" is fixed.
1462             Most properties are specified as form variables:
1464              <propname>
1465               - property on the current context item
1467              <designator>"@"<propname>
1468               - property on the indicated item (for editing related
1469                 information)
1471             Designators name a specific item of a class.
1473             <classname><N>
1475                 Name an existing item of class <classname>.
1477             <classname>"-"<N>
1479                 Name the <N>th new item of class <classname>. If the form
1480                 submission is successful, a new item of <classname> is
1481                 created. Within the submitted form, a particular
1482                 designator of this form always refers to the same new
1483                 item.
1485             Once we have determined the "propname", we look at it to see
1486             if it's special:
1488             @required
1489                 The associated form value is a comma-separated list of
1490                 property names that must be specified when the form is
1491                 submitted for the edit operation to succeed.  
1493                 When the <designator> is missing, the properties are
1494                 for the current context item.  When <designator> is
1495                 present, they are for the item specified by
1496                 <designator>.
1498                 The "@required" specifier must come before any of the
1499                 properties it refers to are assigned in the form.
1501             @remove@<propname>=id(s) or @add@<propname>=id(s)
1502                 The "@add@" and "@remove@" edit actions apply only to
1503                 Multilink properties.  The form value must be a
1504                 comma-separate list of keys for the class specified by
1505                 the simple form variable.  The listed items are added
1506                 to (respectively, removed from) the specified
1507                 property.
1509             @link@<propname>=<designator>
1510                 If the edit action is "@link@", the simple form
1511                 variable must specify a Link or Multilink property.
1512                 The form value is a comma-separated list of
1513                 designators.  The item corresponding to each
1514                 designator is linked to the property given by simple
1515                 form variable.  These are collected up and returned in
1516                 all_links.
1518             None of the above (ie. just a simple form value)
1519                 The value of the form variable is converted
1520                 appropriately, depending on the type of the property.
1522                 For a Link('klass') property, the form value is a
1523                 single key for 'klass', where the key field is
1524                 specified in dbinit.py.  
1526                 For a Multilink('klass') property, the form value is a
1527                 comma-separated list of keys for 'klass', where the
1528                 key field is specified in dbinit.py.  
1530                 Note that for simple-form-variables specifiying Link
1531                 and Multilink properties, the linked-to class must
1532                 have a key field.
1534                 For a String() property specifying a filename, the
1535                 file named by the form value is uploaded. This means we
1536                 try to set additional properties "filename" and "type" (if
1537                 they are valid for the class).  Otherwise, the property
1538                 is set to the form value.
1540                 For Date(), Interval(), Boolean(), and Number()
1541                 properties, the form value is converted to the
1542                 appropriate
1544             Any of the form variables may be prefixed with a classname or
1545             designator.
1547             Two special form values are supported for backwards
1548             compatibility:
1550             @note
1551                 This is equivalent to::
1553                     @link@messages=msg-1
1554                     @msg-1@content=value
1556                 except that in addition, the "author" and "date"
1557                 properties of "msg-1" are set to the userid of the
1558                 submitter, and the current time, respectively.
1560             @file
1561                 This is equivalent to::
1563                     @link@files=file-1
1564                     @file-1@content=value
1566                 The String content value is handled as described above for
1567                 file uploads.
1569             If both the "@note" and "@file" form variables are
1570             specified, the action::
1572                     @link@msg-1@files=file-1
1574             is also performed.
1576             We also check that FileClass items have a "content" property with
1577             actual content, otherwise we remove them from all_props before
1578             returning.
1580             The return from this method is a dict of 
1581                 (classname, id): properties
1582             ... this dict _always_ has an entry for the current context,
1583             even if it's empty (ie. a submission for an existing issue that
1584             doesn't result in any changes would return {('issue','123'): {}})
1585             The id may be None, which indicates that an item should be
1586             created.
1587         '''
1588         # some very useful variables
1589         db = self.db
1590         form = self.form
1592         if not hasattr(self, 'FV_SPECIAL'):
1593             # generate the regexp for handling special form values
1594             classes = '|'.join(db.classes.keys())
1595             # specials for parsePropsFromForm
1596             # handle the various forms (see unit tests)
1597             self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1598             self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1600         # these indicate the default class / item
1601         default_cn = self.classname
1602         default_cl = self.db.classes[default_cn]
1603         default_nodeid = self.nodeid
1605         # we'll store info about the individual class/item edit in these
1606         all_required = {}       # required props per class/item
1607         all_props = {}          # props to set per class/item
1608         got_props = {}          # props received per class/item
1609         all_propdef = {}        # note - only one entry per class
1610         all_links = []          # as many as are required
1612         # we should always return something, even empty, for the context
1613         all_props[(default_cn, default_nodeid)] = {}
1615         keys = form.keys()
1616         timezone = db.getUserTimezone()
1618         # sentinels for the :note and :file props
1619         have_note = have_file = 0
1621         # extract the usable form labels from the form
1622         matches = []
1623         for key in keys:
1624             m = self.FV_SPECIAL.match(key)
1625             if m:
1626                 matches.append((key, m.groupdict()))
1628         # now handle the matches
1629         for key, d in matches:
1630             if d['classname']:
1631                 # we got a designator
1632                 cn = d['classname']
1633                 cl = self.db.classes[cn]
1634                 nodeid = d['id']
1635                 propname = d['propname']
1636             elif d['note']:
1637                 # the special note field
1638                 cn = 'msg'
1639                 cl = self.db.classes[cn]
1640                 nodeid = '-1'
1641                 propname = 'content'
1642                 all_links.append((default_cn, default_nodeid, 'messages',
1643                     [('msg', '-1')]))
1644                 have_note = 1
1645             elif d['file']:
1646                 # the special file field
1647                 cn = 'file'
1648                 cl = self.db.classes[cn]
1649                 nodeid = '-1'
1650                 propname = 'content'
1651                 all_links.append((default_cn, default_nodeid, 'files',
1652                     [('file', '-1')]))
1653                 have_file = 1
1654             else:
1655                 # default
1656                 cn = default_cn
1657                 cl = default_cl
1658                 nodeid = default_nodeid
1659                 propname = d['propname']
1661             # the thing this value relates to is...
1662             this = (cn, nodeid)
1664             # get more info about the class, and the current set of
1665             # form props for it
1666             if not all_propdef.has_key(cn):
1667                 all_propdef[cn] = cl.getprops()
1668             propdef = all_propdef[cn]
1669             if not all_props.has_key(this):
1670                 all_props[this] = {}
1671             props = all_props[this]
1672             if not got_props.has_key(this):
1673                 got_props[this] = {}
1675             # is this a link command?
1676             if d['link']:
1677                 value = []
1678                 for entry in extractFormList(form[key]):
1679                     m = self.FV_DESIGNATOR.match(entry)
1680                     if not m:
1681                         raise ValueError, \
1682                             'link "%s" value "%s" not a designator'%(key, entry)
1683                     value.append((m.group(1), m.group(2)))
1685                 # make sure the link property is valid
1686                 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1687                         not isinstance(propdef[propname], hyperdb.Link)):
1688                     raise ValueError, '%s %s is not a link or '\
1689                         'multilink property'%(cn, propname)
1691                 all_links.append((cn, nodeid, propname, value))
1692                 continue
1694             # detect the special ":required" variable
1695             if d['required']:
1696                 all_required[this] = extractFormList(form[key])
1697                 continue
1699             # see if we're performing a special multilink action
1700             mlaction = 'set'
1701             if d['remove']:
1702                 mlaction = 'remove'
1703             elif d['add']:
1704                 mlaction = 'add'
1706             # does the property exist?
1707             if not propdef.has_key(propname):
1708                 if mlaction != 'set':
1709                     raise ValueError, 'You have submitted a %s action for'\
1710                         ' the property "%s" which doesn\'t exist'%(mlaction,
1711                         propname)
1712                 # the form element is probably just something we don't care
1713                 # about - ignore it
1714                 continue
1715             proptype = propdef[propname]
1717             # Get the form value. This value may be a MiniFieldStorage or a list
1718             # of MiniFieldStorages.
1719             value = form[key]
1721             # handle unpacking of the MiniFieldStorage / list form value
1722             if isinstance(proptype, hyperdb.Multilink):
1723                 value = extractFormList(value)
1724             else:
1725                 # multiple values are not OK
1726                 if isinstance(value, type([])):
1727                     raise ValueError, 'You have submitted more than one value'\
1728                         ' for the %s property'%propname
1729                 # value might be a file upload...
1730                 if not hasattr(value, 'filename') or value.filename is None:
1731                     # nope, pull out the value and strip it
1732                     value = value.value.strip()
1734             # now that we have the props field, we need a teensy little
1735             # extra bit of help for the old :note field...
1736             if d['note'] and value:
1737                 props['author'] = self.db.getuid()
1738                 props['date'] = date.Date()
1740             # handle by type now
1741             if isinstance(proptype, hyperdb.Password):
1742                 if not value:
1743                     # ignore empty password values
1744                     continue
1745                 for key, d in matches:
1746                     if d['confirm'] and d['propname'] == propname:
1747                         confirm = form[key]
1748                         break
1749                 else:
1750                     raise ValueError, 'Password and confirmation text do '\
1751                         'not match'
1752                 if isinstance(confirm, type([])):
1753                     raise ValueError, 'You have submitted more than one value'\
1754                         ' for the %s property'%propname
1755                 if value != confirm.value:
1756                     raise ValueError, 'Password and confirmation text do '\
1757                         'not match'
1758                 value = password.Password(value)
1760             elif isinstance(proptype, hyperdb.Link):
1761                 # see if it's the "no selection" choice
1762                 if value == '-1' or not value:
1763                     # if we're creating, just don't include this property
1764                     if not nodeid or nodeid.startswith('-'):
1765                         continue
1766                     value = None
1767                 else:
1768                     # handle key values
1769                     link = proptype.classname
1770                     if not num_re.match(value):
1771                         try:
1772                             value = db.classes[link].lookup(value)
1773                         except KeyError:
1774                             raise ValueError, _('property "%(propname)s": '
1775                                 '%(value)s not a %(classname)s')%{
1776                                 'propname': propname, 'value': value,
1777                                 'classname': link}
1778                         except TypeError, message:
1779                             raise ValueError, _('you may only enter ID values '
1780                                 'for property "%(propname)s": %(message)s')%{
1781                                 'propname': propname, 'message': message}
1782             elif isinstance(proptype, hyperdb.Multilink):
1783                 # perform link class key value lookup if necessary
1784                 link = proptype.classname
1785                 link_cl = db.classes[link]
1786                 l = []
1787                 for entry in value:
1788                     if not entry: continue
1789                     if not num_re.match(entry):
1790                         try:
1791                             entry = link_cl.lookup(entry)
1792                         except KeyError:
1793                             raise ValueError, _('property "%(propname)s": '
1794                                 '"%(value)s" not an entry of %(classname)s')%{
1795                                 'propname': propname, 'value': entry,
1796                                 'classname': link}
1797                         except TypeError, message:
1798                             raise ValueError, _('you may only enter ID values '
1799                                 'for property "%(propname)s": %(message)s')%{
1800                                 'propname': propname, 'message': message}
1801                     l.append(entry)
1802                 l.sort()
1804                 # now use that list of ids to modify the multilink
1805                 if mlaction == 'set':
1806                     value = l
1807                 else:
1808                     # we're modifying the list - get the current list of ids
1809                     if props.has_key(propname):
1810                         existing = props[propname]
1811                     elif nodeid and not nodeid.startswith('-'):
1812                         existing = cl.get(nodeid, propname, [])
1813                     else:
1814                         existing = []
1816                     # now either remove or add
1817                     if mlaction == 'remove':
1818                         # remove - handle situation where the id isn't in
1819                         # the list
1820                         for entry in l:
1821                             try:
1822                                 existing.remove(entry)
1823                             except ValueError:
1824                                 raise ValueError, _('property "%(propname)s": '
1825                                     '"%(value)s" not currently in list')%{
1826                                     'propname': propname, 'value': entry}
1827                     else:
1828                         # add - easy, just don't dupe
1829                         for entry in l:
1830                             if entry not in existing:
1831                                 existing.append(entry)
1832                     value = existing
1833                     value.sort()
1835             elif value == '':
1836                 # if we're creating, just don't include this property
1837                 if not nodeid or nodeid.startswith('-'):
1838                     continue
1839                 # other types should be None'd if there's no value
1840                 value = None
1841             else:
1842                 # handle ValueErrors for all these in a similar fashion
1843                 try:
1844                     if isinstance(proptype, hyperdb.String):
1845                         if (hasattr(value, 'filename') and
1846                                 value.filename is not None):
1847                             # skip if the upload is empty
1848                             if not value.filename:
1849                                 continue
1850                             # this String is actually a _file_
1851                             # try to determine the file content-type
1852                             fn = value.filename.split('\\')[-1]
1853                             if propdef.has_key('name'):
1854                                 props['name'] = fn
1855                             # use this info as the type/filename properties
1856                             if propdef.has_key('type'):
1857                                 props['type'] = mimetypes.guess_type(fn)[0]
1858                                 if not props['type']:
1859                                     props['type'] = "application/octet-stream"
1860                             # finally, read the content
1861                             value = value.value
1862                         else:
1863                             # normal String fix the CRLF/CR -> LF stuff
1864                             value = fixNewlines(value)
1866                     elif isinstance(proptype, hyperdb.Date):
1867                         value = date.Date(value, offset=timezone)
1868                     elif isinstance(proptype, hyperdb.Interval):
1869                         value = date.Interval(value)
1870                     elif isinstance(proptype, hyperdb.Boolean):
1871                         value = value.lower() in ('yes', 'true', 'on', '1')
1872                     elif isinstance(proptype, hyperdb.Number):
1873                         value = float(value)
1874                 except ValueError, msg:
1875                     raise ValueError, _('Error with %s property: %s')%(
1876                         propname, msg)
1878             # register that we got this property
1879             if value:
1880                 got_props[this][propname] = 1
1882             # get the old value
1883             if nodeid and not nodeid.startswith('-'):
1884                 try:
1885                     existing = cl.get(nodeid, propname)
1886                 except KeyError:
1887                     # this might be a new property for which there is
1888                     # no existing value
1889                     if not propdef.has_key(propname):
1890                         raise
1892                 # make sure the existing multilink is sorted
1893                 if isinstance(proptype, hyperdb.Multilink):
1894                     existing.sort()
1896                 # "missing" existing values may not be None
1897                 if not existing:
1898                     if isinstance(proptype, hyperdb.String) and not existing:
1899                         # some backends store "missing" Strings as empty strings
1900                         existing = None
1901                     elif isinstance(proptype, hyperdb.Number) and not existing:
1902                         # some backends store "missing" Numbers as 0 :(
1903                         existing = 0
1904                     elif isinstance(proptype, hyperdb.Boolean) and not existing:
1905                         # likewise Booleans
1906                         existing = 0
1908                 # if changed, set it
1909                 if value != existing:
1910                     props[propname] = value
1911             else:
1912                 # don't bother setting empty/unset values
1913                 if value is None:
1914                     continue
1915                 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1916                     continue
1917                 elif isinstance(proptype, hyperdb.String) and value == '':
1918                     continue
1920                 props[propname] = value
1922         # check to see if we need to specially link a file to the note
1923         if have_note and have_file:
1924             all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1926         # see if all the required properties have been supplied
1927         s = []
1928         for thing, required in all_required.items():
1929             # register the values we got
1930             got = got_props.get(thing, {})
1931             for entry in required[:]:
1932                 if got.has_key(entry):
1933                     required.remove(entry)
1935             # any required values not present?
1936             if not required:
1937                 continue
1939             # tell the user to entry the values required
1940             if len(required) > 1:
1941                 p = 'properties'
1942             else:
1943                 p = 'property'
1944             s.append('Required %s %s %s not supplied'%(thing[0], p,
1945                 ', '.join(required)))
1946         if s:
1947             raise ValueError, '\n'.join(s)
1949         # check that FileClass entries have a "content" property with
1950         # content, otherwise remove them
1951         for (cn, id), props in all_props.items():
1952             cl = self.db.classes[cn]
1953             if not isinstance(cl, hyperdb.FileClass):
1954                 continue
1955             # we also don't want to create FileClass items with no content
1956             if not props.get('content', ''):
1957                 del all_props[(cn, id)]
1958         return all_props, all_links
1960 def fixNewlines(text):
1961     ''' Homogenise line endings.
1963         Different web clients send different line ending values, but
1964         other systems (eg. email) don't necessarily handle those line
1965         endings. Our solution is to convert all line endings to LF.
1966     '''
1967     text = text.replace('\r\n', '\n')
1968     return text.replace('\r', '\n')
1970 def extractFormList(value):
1971     ''' Extract a list of values from the form value.
1973         It may be one of:
1974          [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
1975          MiniFieldStorage('value,value,...')
1976          MiniFieldStorage('value')
1977     '''
1978     # multiple values are OK
1979     if isinstance(value, type([])):
1980         # it's a list of MiniFieldStorages - join then into
1981         values = ','.join([i.value.strip() for i in value])
1982     else:
1983         # it's a MiniFieldStorage, but may be a comma-separated list
1984         # of values
1985         values = value.value
1987     value = [i.strip() for i in values.split(',')]
1989     # filter out the empty bits
1990     return filter(None, value)