Code

python2.3 CSV support, also missing thankyou in index.txt :)
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.132 2003-08-28 04:46:39 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, rcsv
12 from roundup.i18n import _
13 from roundup.cgi.templating import Templates, HTMLRequest, NoTemplate
14 from roundup.cgi import cgitb
15 from roundup.cgi.PageTemplates import PageTemplate
16 from roundup.rfc2822 import encode_header
17 from roundup.mailgw import uidFromAddress, 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         # open the database as admin
321         self.opendb('admin')
323         # clean age sessions
324         self.clean_sessions()
326         # make sure we have the session Class
327         sessions = self.db.sessions
329         # look up the user session cookie
330         cookie = Cookie.SimpleCookie(self.env.get('HTTP_COOKIE', ''))
331         user = 'anonymous'
333         # bump the "revision" of the cookie since the format changed
334         if (cookie.has_key(self.cookie_name) and
335                 cookie[self.cookie_name].value != 'deleted'):
337             # get the session key from the cookie
338             self.session = cookie[self.cookie_name].value
339             # get the user from the session
340             try:
341                 # update the lifetime datestamp
342                 sessions.set(self.session, last_use=time.time())
343                 sessions.commit()
344                 user = sessions.get(self.session, 'user')
345             except KeyError:
346                 user = 'anonymous'
348         # sanity check on the user still being valid, getting the userid
349         # at the same time
350         try:
351             self.userid = self.db.user.lookup(user)
352         except (KeyError, TypeError):
353             user = 'anonymous'
355         # make sure the anonymous user is valid if we're using it
356         if user == 'anonymous':
357             self.make_user_anonymous()
358         else:
359             self.user = user
361         # reopen the database as the correct user
362         self.opendb(self.user)
364     def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
365         ''' Determine the context of this page from the URL:
367             The URL path after the instance identifier is examined. The path
368             is generally only one entry long.
370             - if there is no path, then we are in the "home" context.
371             * if the path is "_file", then the additional path entry
372               specifies the filename of a static file we're to serve up
373               from the instance "html" directory. Raises a SendStaticFile
374               exception.
375             - if there is something in the path (eg "issue"), it identifies
376               the tracker class we're to display.
377             - if the path is an item designator (eg "issue123"), then we're
378               to display a specific item.
379             * if the path starts with an item designator and is longer than
380               one entry, then we're assumed to be handling an item of a
381               FileClass, and the extra path information gives the filename
382               that the client is going to label the download with (ie
383               "file123/image.png" is nicer to download than "file123"). This
384               raises a SendFile exception.
386             Both of the "*" types of contexts stop before we bother to
387             determine the template we're going to use. That's because they
388             don't actually use templates.
390             The template used is specified by the :template CGI variable,
391             which defaults to:
393              only classname suplied:          "index"
394              full item designator supplied:   "item"
396             We set:
397              self.classname  - the class to display, can be None
398              self.template   - the template to render the current context with
399              self.nodeid     - the nodeid of the class we're displaying
400         '''
401         # default the optional variables
402         self.classname = None
403         self.nodeid = None
405         # see if a template or messages are specified
406         template_override = ok_message = error_message = None
407         for key in self.form.keys():
408             if self.FV_TEMPLATE.match(key):
409                 template_override = self.form[key].value
410             elif self.FV_OK_MESSAGE.match(key):
411                 ok_message = self.form[key].value
412                 ok_message = clean_message(ok_message)
413             elif self.FV_ERROR_MESSAGE.match(key):
414                 error_message = self.form[key].value
415                 error_message = clean_message(error_message)
417         # determine the classname and possibly nodeid
418         path = self.path.split('/')
419         if not path or path[0] in ('', 'home', 'index'):
420             if template_override is not None:
421                 self.template = template_override
422             else:
423                 self.template = ''
424             return
425         elif path[0] == '_file':
426             raise SendStaticFile, os.path.join(*path[1:])
427         else:
428             self.classname = path[0]
429             if len(path) > 1:
430                 # send the file identified by the designator in path[0]
431                 raise SendFile, path[0]
433         # we need the db for further context stuff - open it as admin
434         self.opendb('admin')
436         # see if we got a designator
437         m = dre.match(self.classname)
438         if m:
439             self.classname = m.group(1)
440             self.nodeid = m.group(2)
441             if not self.db.getclass(self.classname).hasnode(self.nodeid):
442                 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
443             # with a designator, we default to item view
444             self.template = 'item'
445         else:
446             # with only a class, we default to index view
447             self.template = 'index'
449         # make sure the classname is valid
450         try:
451             self.db.getclass(self.classname)
452         except KeyError:
453             raise NotFound, self.classname
455         # see if we have a template override
456         if template_override is not None:
457             self.template = template_override
459         # see if we were passed in a message
460         if ok_message:
461             self.ok_message.append(ok_message)
462         if error_message:
463             self.error_message.append(error_message)
465     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
466         ''' Serve the file from the content property of the designated item.
467         '''
468         m = dre.match(str(designator))
469         if not m:
470             raise NotFound, str(designator)
471         classname, nodeid = m.group(1), m.group(2)
472         if classname != 'file':
473             raise NotFound, designator
475         # we just want to serve up the file named
476         self.opendb('admin')
477         file = self.db.file
478         self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
479         self.write(file.get(nodeid, 'content'))
481     def serve_static_file(self, file):
482         ims = None
483         # see if there's an if-modified-since...
484         if hasattr(self.request, 'headers'):
485             ims = self.request.headers.getheader('if-modified-since')
486         elif self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
487             # cgi will put the header in the env var
488             ims = self.env['HTTP_IF_MODIFIED_SINCE']
489         filename = os.path.join(self.instance.config.TEMPLATES, file)
490         lmt = os.stat(filename)[stat.ST_MTIME]
491         if ims:
492             ims = rfc822.parsedate(ims)[:6]
493             lmtt = time.gmtime(lmt)[:6]
494             if lmtt <= ims:
495                 raise NotModified
497         # we just want to serve up the file named
498         file = str(file)
499         mt = mimetypes.guess_type(file)[0]
500         if not mt:
501             if file.endswith('.css'):
502                 mt = 'text/css'
503             else:
504                 mt = 'text/plain'
505         self.additional_headers['Content-Type'] = mt
506         self.additional_headers['Last-Modifed'] = rfc822.formatdate(lmt)
507         self.write(open(filename, 'rb').read())
509     def renderContext(self):
510         ''' Return a PageTemplate for the named page
511         '''
512         name = self.classname
513         extension = self.template
514         pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
516         # catch errors so we can handle PT rendering errors more nicely
517         args = {
518             'ok_message': self.ok_message,
519             'error_message': self.error_message
520         }
521         try:
522             # let the template render figure stuff out
523             return pt.render(self, None, None, **args)
524         except NoTemplate, message:
525             return '<strong>%s</strong>'%message
526         except:
527             # everything else
528             return cgitb.pt_html()
530     # these are the actions that are available
531     actions = (
532         ('edit',     'editItemAction'),
533         ('editcsv',  'editCSVAction'),
534         ('new',      'newItemAction'),
535         ('register', 'registerAction'),
536         ('confrego', 'confRegoAction'),
537         ('passrst',  'passResetAction'),
538         ('login',    'loginAction'),
539         ('logout',   'logout_action'),
540         ('search',   'searchAction'),
541         ('retire',   'retireAction'),
542         ('show',     'showAction'),
543     )
544     def handle_action(self):
545         ''' Determine whether there should be an Action called.
547             The action is defined by the form variable :action which
548             identifies the method on this object to call. The actions
549             are defined in the "actions" sequence on this class.
550         '''
551         if self.form.has_key(':action'):
552             action = self.form[':action'].value.lower()
553         elif self.form.has_key('@action'):
554             action = self.form['@action'].value.lower()
555         else:
556             return None
557         try:
558             # get the action, validate it
559             for name, method in self.actions:
560                 if name == action:
561                     break
562             else:
563                 raise ValueError, 'No such action "%s"'%action
564             # call the mapped action
565             getattr(self, method)()
566         except Redirect:
567             raise
568         except Unauthorised:
569             raise
571     def write(self, content):
572         if not self.headers_done:
573             self.header()
574         self.request.wfile.write(content)
576     def header(self, headers=None, response=None):
577         '''Put up the appropriate header.
578         '''
579         if headers is None:
580             headers = {'Content-Type':'text/html'}
581         if response is None:
582             response = self.response_code
584         # update with additional info
585         headers.update(self.additional_headers)
587         if not headers.has_key('Content-Type'):
588             headers['Content-Type'] = 'text/html'
589         self.request.send_response(response)
590         for entry in headers.items():
591             self.request.send_header(*entry)
592         self.request.end_headers()
593         self.headers_done = 1
594         if self.debug:
595             self.headers_sent = headers
597     def set_cookie(self, user):
598         ''' Set up a session cookie for the user and store away the user's
599             login info against the session.
600         '''
601         # TODO generate a much, much stronger session key ;)
602         self.session = binascii.b2a_base64(repr(random.random())).strip()
604         # clean up the base64
605         if self.session[-1] == '=':
606             if self.session[-2] == '=':
607                 self.session = self.session[:-2]
608             else:
609                 self.session = self.session[:-1]
611         # insert the session in the sessiondb
612         self.db.sessions.set(self.session, user=user, last_use=time.time())
614         # and commit immediately
615         self.db.sessions.commit()
617         # expire us in a long, long time
618         expire = Cookie._getdate(86400*365)
620         # generate the cookie path - make sure it has a trailing '/'
621         self.additional_headers['Set-Cookie'] = \
622           '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
623             expire, self.cookie_path)
625     def make_user_anonymous(self):
626         ''' Make us anonymous
628             This method used to handle non-existence of the 'anonymous'
629             user, but that user is mandatory now.
630         '''
631         self.userid = self.db.user.lookup('anonymous')
632         self.user = 'anonymous'
634     def opendb(self, user):
635         ''' Open the database.
636         '''
637         # open the db if the user has changed
638         if not hasattr(self, 'db') or user != self.db.journaltag:
639             if hasattr(self, 'db'):
640                 self.db.close()
641             self.db = self.instance.open(user)
643     #
644     # Actions
645     #
646     def loginAction(self):
647         ''' Attempt to log a user in.
649             Sets up a session for the user which contains the login
650             credentials.
651         '''
652         # we need the username at a minimum
653         if not self.form.has_key('__login_name'):
654             self.error_message.append(_('Username required'))
655             return
657         # get the login info
658         self.user = self.form['__login_name'].value
659         if self.form.has_key('__login_password'):
660             password = self.form['__login_password'].value
661         else:
662             password = ''
664         # make sure the user exists
665         try:
666             self.userid = self.db.user.lookup(self.user)
667         except KeyError:
668             name = self.user
669             self.error_message.append(_('No such user "%(name)s"')%locals())
670             self.make_user_anonymous()
671             return
673         # verify the password
674         if not self.verifyPassword(self.userid, password):
675             self.make_user_anonymous()
676             self.error_message.append(_('Incorrect password'))
677             return
679         # make sure we're allowed to be here
680         if not self.loginPermission():
681             self.make_user_anonymous()
682             self.error_message.append(_("You do not have permission to login"))
683             return
685         # now we're OK, re-open the database for real, using the user
686         self.opendb(self.user)
688         # set the session cookie
689         self.set_cookie(self.user)
691     def verifyPassword(self, userid, password):
692         ''' Verify the password that the user has supplied
693         '''
694         stored = self.db.user.get(self.userid, 'password')
695         if password == stored:
696             return 1
697         if not password and not stored:
698             return 1
699         return 0
701     def loginPermission(self):
702         ''' Determine whether the user has permission to log in.
704             Base behaviour is to check the user has "Web Access".
705         ''' 
706         if not self.db.security.hasPermission('Web Access', self.userid):
707             return 0
708         return 1
710     def logout_action(self):
711         ''' Make us really anonymous - nuke the cookie too
712         '''
713         # log us out
714         self.make_user_anonymous()
716         # construct the logout cookie
717         now = Cookie._getdate()
718         self.additional_headers['Set-Cookie'] = \
719            '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.cookie_name,
720             now, self.cookie_path)
722         # Let the user know what's going on
723         self.ok_message.append(_('You are logged out'))
725     def registerAction(self):
726         '''Attempt to create a new user based on the contents of the form
727         and then set the cookie.
729         return 1 on successful login
730         '''
731         # parse the props from the form
732         try:
733             props = self.parsePropsFromForm()[0][('user', None)]
734         except (ValueError, KeyError), message:
735             self.error_message.append(_('Error: ') + str(message))
736             return
738         # make sure we're allowed to register
739         if not self.registerPermission(props):
740             raise Unauthorised, _("You do not have permission to register")
742         try:
743             self.db.user.lookup(props['username'])
744             self.error_message.append('Error: A user with the username "%s" '
745                 'already exists'%props['username'])
746             return
747         except KeyError:
748             pass
750         # generate the one-time-key and store the props for later
751         otk = ''.join([random.choice(chars) for x in range(32)])
752         for propname, proptype in self.db.user.getprops().items():
753             value = props.get(propname, None)
754             if value is None:
755                 pass
756             elif isinstance(proptype, hyperdb.Date):
757                 props[propname] = str(value)
758             elif isinstance(proptype, hyperdb.Interval):
759                 props[propname] = str(value)
760             elif isinstance(proptype, hyperdb.Password):
761                 props[propname] = str(value)
762         props['__time'] = time.time()
763         self.db.otks.set(otk, **props)
765         # send the email
766         tracker_name = self.db.config.TRACKER_NAME
767         subject = 'Complete your registration to %s'%tracker_name
768         body = '''
769 To complete your registration of the user "%(name)s" with %(tracker)s,
770 please visit the following URL:
772    %(url)s?@action=confrego&otk=%(otk)s
773 '''%{'name': props['username'], 'tracker': tracker_name, 'url': self.base,
774                 'otk': otk}
775         if not self.sendEmail(props['address'], subject, body):
776             return
778         # commit changes to the database
779         self.db.commit()
781         # redirect to the "you're almost there" page
782         raise Redirect, '%suser?@template=rego_progress'%self.base
784     def sendEmail(self, to, subject, content):
785         # send email to the user's email address
786         message = StringIO.StringIO()
787         writer = MimeWriter.MimeWriter(message)
788         tracker_name = self.db.config.TRACKER_NAME
789         writer.addheader('Subject', encode_header(subject))
790         writer.addheader('To', to)
791         writer.addheader('From', roundupdb.straddr((tracker_name,
792             self.db.config.ADMIN_EMAIL)))
793         writer.addheader('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000",
794             time.gmtime()))
795         # add a uniquely Roundup header to help filtering
796         writer.addheader('X-Roundup-Name', tracker_name)
797         # avoid email loops
798         writer.addheader('X-Roundup-Loop', 'hello')
799         writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
800         body = writer.startbody('text/plain; charset=utf-8')
802         # message body, encoded quoted-printable
803         content = StringIO.StringIO(content)
804         quopri.encode(content, body, 0)
806         if SENDMAILDEBUG:
807             # don't send - just write to a file
808             open(SENDMAILDEBUG, 'a').write('FROM: %s\nTO: %s\n%s\n'%(
809                 self.db.config.ADMIN_EMAIL,
810                 ', '.join(to),message.getvalue()))
811         else:
812             # now try to send the message
813             try:
814                 # send the message as admin so bounces are sent there
815                 # instead of to roundup
816                 smtp = openSMTPConnection(self.db.config)
817                 smtp.sendmail(self.db.config.ADMIN_EMAIL, [to],
818                     message.getvalue())
819             except socket.error, value:
820                 self.error_message.append("Error: couldn't send email: "
821                     "mailhost %s"%value)
822                 return 0
823             except smtplib.SMTPException, msg:
824                 self.error_message.append("Error: couldn't send email: %s"%msg)
825                 return 0
826         return 1
828     def registerPermission(self, props):
829         ''' Determine whether the user has permission to register
831             Base behaviour is to check the user has "Web Registration".
832         '''
833         # registration isn't allowed to supply roles
834         if props.has_key('roles'):
835             return 0
836         if self.db.security.hasPermission('Web Registration', self.userid):
837             return 1
838         return 0
840     def confRegoAction(self):
841         ''' Grab the OTK, use it to load up the new user details
842         '''
843         # pull the rego information out of the otk database
844         otk = self.form['otk'].value
845         props = self.db.otks.getall(otk)
846         for propname, proptype in self.db.user.getprops().items():
847             value = props.get(propname, None)
848             if value is None:
849                 pass
850             elif isinstance(proptype, hyperdb.Date):
851                 props[propname] = date.Date(value)
852             elif isinstance(proptype, hyperdb.Interval):
853                 props[propname] = date.Interval(value)
854             elif isinstance(proptype, hyperdb.Password):
855                 props[propname] = password.Password()
856                 props[propname].unpack(value)
858         # re-open the database as "admin"
859         if self.user != 'admin':
860             self.opendb('admin')
862         # create the new user
863         cl = self.db.user
864 # XXX we need to make the "default" page be able to display errors!
865         try:
866             props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
867             del props['__time']
868             self.userid = cl.create(**props)
869             # clear the props from the otk database
870             self.db.otks.destroy(otk)
871             self.db.commit()
872         except (ValueError, KeyError), message:
873             self.error_message.append(str(message))
874             return
876         # log the new user in
877         self.user = cl.get(self.userid, 'username')
878         # re-open the database for real, using the user
879         self.opendb(self.user)
881         # if we have a session, update it
882         if hasattr(self, 'session'):
883             self.db.sessions.set(self.session, user=self.user,
884                 last_use=time.time())
885         else:
886             # new session cookie
887             self.set_cookie(self.user)
889         # nice message
890         message = _('You are now registered, welcome!')
892         # redirect to the user's page
893         raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
894             self.userid, urllib.quote(message))
896     def passResetAction(self):
897         ''' Handle password reset requests.
899             Presence of either "name" or "address" generate email.
900             Presense of "otk" performs the reset.
901         '''
902         if self.form.has_key('otk'):
903             # pull the rego information out of the otk database
904             otk = self.form['otk'].value
905             uid = self.db.otks.get(otk, 'uid')
906             if uid is None:
907                 self.error_message.append('Invalid One Time Key!')
908                 return
910             # re-open the database as "admin"
911             if self.user != 'admin':
912                 self.opendb('admin')
914             # change the password
915             newpw = password.generatePassword()
917             cl = self.db.user
918 # XXX we need to make the "default" page be able to display errors!
919             try:
920                 # set the password
921                 cl.set(uid, password=password.Password(newpw))
922                 # clear the props from the otk database
923                 self.db.otks.destroy(otk)
924                 self.db.commit()
925             except (ValueError, KeyError), message:
926                 self.error_message.append(str(message))
927                 return
929             # user info
930             address = self.db.user.get(uid, 'address')
931             name = self.db.user.get(uid, 'username')
933             # send the email
934             tracker_name = self.db.config.TRACKER_NAME
935             subject = 'Password reset for %s'%tracker_name
936             body = '''
937 The password has been reset for username "%(name)s".
939 Your password is now: %(password)s
940 '''%{'name': name, 'password': newpw}
941             if not self.sendEmail(address, subject, body):
942                 return
944             self.ok_message.append('Password reset and email sent to %s'%address)
945             return
947         # no OTK, so now figure the user
948         if self.form.has_key('username'):
949             name = self.form['username'].value
950             try:
951                 uid = self.db.user.lookup(name)
952             except KeyError:
953                 self.error_message.append('Unknown username')
954                 return
955             address = self.db.user.get(uid, 'address')
956         elif self.form.has_key('address'):
957             address = self.form['address'].value
958             uid = uidFromAddress(self.db, ('', address), create=0)
959             if not uid:
960                 self.error_message.append('Unknown email address')
961                 return
962             name = self.db.user.get(uid, 'username')
963         else:
964             self.error_message.append('You need to specify a username '
965                 'or address')
966             return
968         # generate the one-time-key and store the props for later
969         otk = ''.join([random.choice(chars) for x in range(32)])
970         self.db.otks.set(otk, uid=uid, __time=time.time())
972         # send the email
973         tracker_name = self.db.config.TRACKER_NAME
974         subject = 'Confirm reset of password for %s'%tracker_name
975         body = '''
976 Someone, perhaps you, has requested that the password be changed for your
977 username, "%(name)s". If you wish to proceed with the change, please follow
978 the link below:
980   %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
982 You should then receive another email with the new password.
983 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
984         if not self.sendEmail(address, subject, body):
985             return
987         self.ok_message.append('Email sent to %s'%address)
989     def editItemAction(self):
990         ''' Perform an edit of an item in the database.
992            See parsePropsFromForm and _editnodes for special variables
993         '''
994         # parse the props from the form
995         try:
996             props, links = self.parsePropsFromForm()
997         except (ValueError, KeyError), message:
998             self.error_message.append(_('Parse Error: ') + str(message))
999             return
1001         # handle the props
1002         try:
1003             message = self._editnodes(props, links)
1004         except (ValueError, KeyError, IndexError), message:
1005             self.error_message.append(_('Apply Error: ') + str(message))
1006             return
1008         # commit now that all the tricky stuff is done
1009         self.db.commit()
1011         # redirect to the item's edit page
1012         raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
1013             self.classname, self.nodeid, urllib.quote(message),
1014             urllib.quote(self.template))
1016     def editItemPermission(self, props):
1017         ''' Determine whether the user has permission to edit this item.
1019             Base behaviour is to check the user can edit this class. If we're
1020             editing the "user" class, users are allowed to edit their own
1021             details. Unless it's the "roles" property, which requires the
1022             special Permission "Web Roles".
1023         '''
1024         # if this is a user node and the user is editing their own node, then
1025         # we're OK
1026         has = self.db.security.hasPermission
1027         if self.classname == 'user':
1028             # reject if someone's trying to edit "roles" and doesn't have the
1029             # right permission.
1030             if props.has_key('roles') and not has('Web Roles', self.userid,
1031                     'user'):
1032                 return 0
1033             # if the item being edited is the current user, we're ok
1034             if self.nodeid == self.userid:
1035                 return 1
1036         if self.db.security.hasPermission('Edit', self.userid, self.classname):
1037             return 1
1038         return 0
1040     def newItemAction(self):
1041         ''' Add a new item to the database.
1043             This follows the same form as the editItemAction, with the same
1044             special form values.
1045         '''
1046         # parse the props from the form
1047         try:
1048             props, links = self.parsePropsFromForm()
1049         except (ValueError, KeyError), message:
1050             self.error_message.append(_('Error: ') + str(message))
1051             return
1053         # handle the props - edit or create
1054         try:
1055             # when it hits the None element, it'll set self.nodeid
1056             messages = self._editnodes(props, links)
1058         except (ValueError, KeyError, IndexError), message:
1059             # these errors might just be indicative of user dumbness
1060             self.error_message.append(_('Error: ') + str(message))
1061             return
1063         # commit now that all the tricky stuff is done
1064         self.db.commit()
1066         # redirect to the new item's page
1067         raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
1068             self.classname, self.nodeid, urllib.quote(messages),
1069             urllib.quote(self.template))
1071     def newItemPermission(self, props):
1072         ''' Determine whether the user has permission to create (edit) this
1073             item.
1075             Base behaviour is to check the user can edit this class. No
1076             additional property checks are made. Additionally, new user items
1077             may be created if the user has the "Web Registration" Permission.
1078         '''
1079         has = self.db.security.hasPermission
1080         if self.classname == 'user' and has('Web Registration', self.userid,
1081                 'user'):
1082             return 1
1083         if has('Edit', self.userid, self.classname):
1084             return 1
1085         return 0
1088     #
1089     #  Utility methods for editing
1090     #
1091     def _editnodes(self, all_props, all_links, newids=None):
1092         ''' Use the props in all_props to perform edit and creation, then
1093             use the link specs in all_links to do linking.
1094         '''
1095         # figure dependencies and re-work links
1096         deps = {}
1097         links = {}
1098         for cn, nodeid, propname, vlist in all_links:
1099             if not all_props.has_key((cn, nodeid)):
1100                 # link item to link to doesn't (and won't) exist
1101                 continue
1102             for value in vlist:
1103                 if not all_props.has_key(value):
1104                     # link item to link to doesn't (and won't) exist
1105                     continue
1106                 deps.setdefault((cn, nodeid), []).append(value)
1107                 links.setdefault(value, []).append((cn, nodeid, propname))
1109         # figure chained dependencies ordering
1110         order = []
1111         done = {}
1112         # loop detection
1113         change = 0
1114         while len(all_props) != len(done):
1115             for needed in all_props.keys():
1116                 if done.has_key(needed):
1117                     continue
1118                 tlist = deps.get(needed, [])
1119                 for target in tlist:
1120                     if not done.has_key(target):
1121                         break
1122                 else:
1123                     done[needed] = 1
1124                     order.append(needed)
1125                     change = 1
1126             if not change:
1127                 raise ValueError, 'linking must not loop!'
1129         # now, edit / create
1130         m = []
1131         for needed in order:
1132             props = all_props[needed]
1133             if not props:
1134                 # nothing to do
1135                 continue
1136             cn, nodeid = needed
1138             if nodeid is not None and int(nodeid) > 0:
1139                 # make changes to the node
1140                 props = self._changenode(cn, nodeid, props)
1142                 # and some nice feedback for the user
1143                 if props:
1144                     info = ', '.join(props.keys())
1145                     m.append('%s %s %s edited ok'%(cn, nodeid, info))
1146                 else:
1147                     m.append('%s %s - nothing changed'%(cn, nodeid))
1148             else:
1149                 assert props
1151                 # make a new node
1152                 newid = self._createnode(cn, props)
1153                 if nodeid is None:
1154                     self.nodeid = newid
1155                 nodeid = newid
1157                 # and some nice feedback for the user
1158                 m.append('%s %s created'%(cn, newid))
1160             # fill in new ids in links
1161             if links.has_key(needed):
1162                 for linkcn, linkid, linkprop in links[needed]:
1163                     props = all_props[(linkcn, linkid)]
1164                     cl = self.db.classes[linkcn]
1165                     propdef = cl.getprops()[linkprop]
1166                     if not props.has_key(linkprop):
1167                         if linkid is None or linkid.startswith('-'):
1168                             # linking to a new item
1169                             if isinstance(propdef, hyperdb.Multilink):
1170                                 props[linkprop] = [newid]
1171                             else:
1172                                 props[linkprop] = newid
1173                         else:
1174                             # linking to an existing item
1175                             if isinstance(propdef, hyperdb.Multilink):
1176                                 existing = cl.get(linkid, linkprop)[:]
1177                                 existing.append(nodeid)
1178                                 props[linkprop] = existing
1179                             else:
1180                                 props[linkprop] = newid
1182         return '<br>'.join(m)
1184     def _changenode(self, cn, nodeid, props):
1185         ''' change the node based on the contents of the form
1186         '''
1187         # check for permission
1188         if not self.editItemPermission(props):
1189             raise Unauthorised, 'You do not have permission to edit %s'%cn
1191         # make the changes
1192         cl = self.db.classes[cn]
1193         return cl.set(nodeid, **props)
1195     def _createnode(self, cn, props):
1196         ''' create a node based on the contents of the form
1197         '''
1198         # check for permission
1199         if not self.newItemPermission(props):
1200             raise Unauthorised, 'You do not have permission to create %s'%cn
1202         # create the node and return its id
1203         cl = self.db.classes[cn]
1204         return cl.create(**props)
1206     # 
1207     # More actions
1208     #
1209     def editCSVAction(self):
1210         ''' Performs an edit of all of a class' items in one go.
1212             The "rows" CGI var defines the CSV-formatted entries for the
1213             class. New nodes are identified by the ID 'X' (or any other
1214             non-existent ID) and removed lines are retired.
1215         '''
1216         # this is per-class only
1217         if not self.editCSVPermission():
1218             self.error_message.append(
1219                 _('You do not have permission to edit %s' %self.classname))
1221         # get the CSV module
1222         if rcsv.error:
1223             self.error_message.append(_(rcsv.error))
1224             return
1226         cl = self.db.classes[self.classname]
1227         idlessprops = cl.getprops(protected=0).keys()
1228         idlessprops.sort()
1229         props = ['id'] + idlessprops
1231         # do the edit
1232         rows = StringIO.StringIO(self.form['rows'].value)
1233         reader = rcsv.reader(rows, rcsv.comma_separated)
1234         found = {}
1235         line = 0
1236         for values in reader:
1237             line += 1
1238             if line == 1: continue
1239             # skip property names header
1240             if values == props:
1241                 continue
1243             # extract the nodeid
1244             nodeid, values = values[0], values[1:]
1245             found[nodeid] = 1
1247             # see if the node exists
1248             if nodeid in ('x', 'X') or not cl.hasnode(nodeid):
1249                 exists = 0
1250             else:
1251                 exists = 1
1253             # confirm correct weight
1254             if len(idlessprops) != len(values):
1255                 self.error_message.append(
1256                     _('Not enough values on line %(line)s')%{'line':line})
1257                 return
1259             # extract the new values
1260             d = {}
1261             for name, value in zip(idlessprops, values):
1262                 prop = cl.properties[name]
1263                 value = value.strip()
1264                 # only add the property if it has a value
1265                 if value:
1266                     # if it's a multilink, split it
1267                     if isinstance(prop, hyperdb.Multilink):
1268                         value = value.split(':')
1269                     elif isinstance(prop, hyperdb.Password):
1270                         value = password.Password(value)
1271                     elif isinstance(prop, hyperdb.Interval):
1272                         value = date.Interval(value)
1273                     elif isinstance(prop, hyperdb.Date):
1274                         value = date.Date(value)
1275                     elif isinstance(prop, hyperdb.Boolean):
1276                         value = value.lower() in ('yes', 'true', 'on', '1')
1277                     elif isinstance(prop, hyperdb.Number):
1278                         value = float(value)
1279                     d[name] = value
1280                 elif exists:
1281                     # nuke the existing value
1282                     if isinstance(prop, hyperdb.Multilink):
1283                         d[name] = []
1284                     else:
1285                         d[name] = None
1287             # perform the edit
1288             if exists:
1289                 # edit existing
1290                 cl.set(nodeid, **d)
1291             else:
1292                 # new node
1293                 found[cl.create(**d)] = 1
1295         # retire the removed entries
1296         for nodeid in cl.list():
1297             if not found.has_key(nodeid):
1298                 cl.retire(nodeid)
1300         # all OK
1301         self.db.commit()
1303         self.ok_message.append(_('Items edited OK'))
1305     def editCSVPermission(self):
1306         ''' Determine whether the user has permission to edit this class.
1308             Base behaviour is to check the user can edit this class.
1309         ''' 
1310         if not self.db.security.hasPermission('Edit', self.userid,
1311                 self.classname):
1312             return 0
1313         return 1
1315     def searchAction(self, wcre=re.compile(r'[\s,]+')):
1316         ''' Mangle some of the form variables.
1318             Set the form ":filter" variable based on the values of the
1319             filter variables - if they're set to anything other than
1320             "dontcare" then add them to :filter.
1322             Handle the ":queryname" variable and save off the query to
1323             the user's query list.
1325             Split any String query values on whitespace and comma.
1326         '''
1327         # generic edit is per-class only
1328         if not self.searchPermission():
1329             self.error_message.append(
1330                 _('You do not have permission to search %s' %self.classname))
1332         # add a faked :filter form variable for each filtering prop
1333         props = self.db.classes[self.classname].getprops()
1334         queryname = ''
1335         for key in self.form.keys():
1336             # special vars
1337             if self.FV_QUERYNAME.match(key):
1338                 queryname = self.form[key].value.strip()
1339                 continue
1341             if not props.has_key(key):
1342                 continue
1343             if isinstance(self.form[key], type([])):
1344                 # search for at least one entry which is not empty
1345                 for minifield in self.form[key]:
1346                     if minifield.value:
1347                         break
1348                 else:
1349                     continue
1350             else:
1351                 if not self.form[key].value:
1352                     continue
1353                 if isinstance(props[key], hyperdb.String):
1354                     v = self.form[key].value
1355                     l = token.token_split(v)
1356                     if len(l) > 1 or l[0] != v:
1357                         self.form.value.remove(self.form[key])
1358                         # replace the single value with the split list
1359                         for v in l:
1360                             self.form.value.append(cgi.MiniFieldStorage(key, v))
1362             self.form.value.append(cgi.MiniFieldStorage('@filter', key))
1364         # handle saving the query params
1365         if queryname:
1366             # parse the environment and figure what the query _is_
1367             req = HTMLRequest(self)
1369             # The [1:] strips off the '?' character, it isn't part of the
1370             # query string.
1371             url = req.indexargs_href('', {})[1:]
1373             # handle editing an existing query
1374             try:
1375                 qid = self.db.query.lookup(queryname)
1376                 self.db.query.set(qid, klass=self.classname, url=url)
1377             except KeyError:
1378                 # create a query
1379                 qid = self.db.query.create(name=queryname,
1380                     klass=self.classname, url=url)
1382                 # and add it to the user's query multilink
1383                 queries = self.db.user.get(self.userid, 'queries')
1384                 queries.append(qid)
1385                 self.db.user.set(self.userid, queries=queries)
1387             # commit the query change to the database
1388             self.db.commit()
1390     def searchPermission(self):
1391         ''' Determine whether the user has permission to search this class.
1393             Base behaviour is to check the user can view this class.
1394         ''' 
1395         if not self.db.security.hasPermission('View', self.userid,
1396                 self.classname):
1397             return 0
1398         return 1
1401     def retireAction(self):
1402         ''' Retire the context item.
1403         '''
1404         # if we want to view the index template now, then unset the nodeid
1405         # context info (a special-case for retire actions on the index page)
1406         nodeid = self.nodeid
1407         if self.template == 'index':
1408             self.nodeid = None
1410         # generic edit is per-class only
1411         if not self.retirePermission():
1412             self.error_message.append(
1413                 _('You do not have permission to retire %s' %self.classname))
1414             return
1416         # make sure we don't try to retire admin or anonymous
1417         if self.classname == 'user' and \
1418                 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1419             self.error_message.append(
1420                 _('You may not retire the admin or anonymous user'))
1421             return
1423         # do the retire
1424         self.db.getclass(self.classname).retire(nodeid)
1425         self.db.commit()
1427         self.ok_message.append(
1428             _('%(classname)s %(itemid)s has been retired')%{
1429                 'classname': self.classname.capitalize(), 'itemid': nodeid})
1431     def retirePermission(self):
1432         ''' Determine whether the user has permission to retire this class.
1434             Base behaviour is to check the user can edit this class.
1435         ''' 
1436         if not self.db.security.hasPermission('Edit', self.userid,
1437                 self.classname):
1438             return 0
1439         return 1
1442     def showAction(self, typere=re.compile('[@:]type'),
1443             numre=re.compile('[@:]number')):
1444         ''' Show a node of a particular class/id
1445         '''
1446         t = n = ''
1447         for key in self.form.keys():
1448             if typere.match(key):
1449                 t = self.form[key].value.strip()
1450             elif numre.match(key):
1451                 n = self.form[key].value.strip()
1452         if not t:
1453             raise ValueError, 'Invalid %s number'%t
1454         url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1455         raise Redirect, url
1457     def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1458         ''' Item properties and their values are edited with html FORM
1459             variables and their values. You can:
1461             - Change the value of some property of the current item.
1462             - Create a new item of any class, and edit the new item's
1463               properties,
1464             - Attach newly created items to a multilink property of the
1465               current item.
1466             - Remove items from a multilink property of the current item.
1467             - Specify that some properties are required for the edit
1468               operation to be successful.
1470             In the following, <bracketed> values are variable, "@" may be
1471             either ":" or "@", and other text "required" is fixed.
1473             Most properties are specified as form variables:
1475              <propname>
1476               - property on the current context item
1478              <designator>"@"<propname>
1479               - property on the indicated item (for editing related
1480                 information)
1482             Designators name a specific item of a class.
1484             <classname><N>
1486                 Name an existing item of class <classname>.
1488             <classname>"-"<N>
1490                 Name the <N>th new item of class <classname>. If the form
1491                 submission is successful, a new item of <classname> is
1492                 created. Within the submitted form, a particular
1493                 designator of this form always refers to the same new
1494                 item.
1496             Once we have determined the "propname", we look at it to see
1497             if it's special:
1499             @required
1500                 The associated form value is a comma-separated list of
1501                 property names that must be specified when the form is
1502                 submitted for the edit operation to succeed.  
1504                 When the <designator> is missing, the properties are
1505                 for the current context item.  When <designator> is
1506                 present, they are for the item specified by
1507                 <designator>.
1509                 The "@required" specifier must come before any of the
1510                 properties it refers to are assigned in the form.
1512             @remove@<propname>=id(s) or @add@<propname>=id(s)
1513                 The "@add@" and "@remove@" edit actions apply only to
1514                 Multilink properties.  The form value must be a
1515                 comma-separate list of keys for the class specified by
1516                 the simple form variable.  The listed items are added
1517                 to (respectively, removed from) the specified
1518                 property.
1520             @link@<propname>=<designator>
1521                 If the edit action is "@link@", the simple form
1522                 variable must specify a Link or Multilink property.
1523                 The form value is a comma-separated list of
1524                 designators.  The item corresponding to each
1525                 designator is linked to the property given by simple
1526                 form variable.  These are collected up and returned in
1527                 all_links.
1529             None of the above (ie. just a simple form value)
1530                 The value of the form variable is converted
1531                 appropriately, depending on the type of the property.
1533                 For a Link('klass') property, the form value is a
1534                 single key for 'klass', where the key field is
1535                 specified in dbinit.py.  
1537                 For a Multilink('klass') property, the form value is a
1538                 comma-separated list of keys for 'klass', where the
1539                 key field is specified in dbinit.py.  
1541                 Note that for simple-form-variables specifiying Link
1542                 and Multilink properties, the linked-to class must
1543                 have a key field.
1545                 For a String() property specifying a filename, the
1546                 file named by the form value is uploaded. This means we
1547                 try to set additional properties "filename" and "type" (if
1548                 they are valid for the class).  Otherwise, the property
1549                 is set to the form value.
1551                 For Date(), Interval(), Boolean(), and Number()
1552                 properties, the form value is converted to the
1553                 appropriate
1555             Any of the form variables may be prefixed with a classname or
1556             designator.
1558             Two special form values are supported for backwards
1559             compatibility:
1561             @note
1562                 This is equivalent to::
1564                     @link@messages=msg-1
1565                     @msg-1@content=value
1567                 except that in addition, the "author" and "date"
1568                 properties of "msg-1" are set to the userid of the
1569                 submitter, and the current time, respectively.
1571             @file
1572                 This is equivalent to::
1574                     @link@files=file-1
1575                     @file-1@content=value
1577                 The String content value is handled as described above for
1578                 file uploads.
1580             If both the "@note" and "@file" form variables are
1581             specified, the action::
1583                     @link@msg-1@files=file-1
1585             is also performed.
1587             We also check that FileClass items have a "content" property with
1588             actual content, otherwise we remove them from all_props before
1589             returning.
1591             The return from this method is a dict of 
1592                 (classname, id): properties
1593             ... this dict _always_ has an entry for the current context,
1594             even if it's empty (ie. a submission for an existing issue that
1595             doesn't result in any changes would return {('issue','123'): {}})
1596             The id may be None, which indicates that an item should be
1597             created.
1598         '''
1599         # some very useful variables
1600         db = self.db
1601         form = self.form
1603         if not hasattr(self, 'FV_SPECIAL'):
1604             # generate the regexp for handling special form values
1605             classes = '|'.join(db.classes.keys())
1606             # specials for parsePropsFromForm
1607             # handle the various forms (see unit tests)
1608             self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1609             self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1611         # these indicate the default class / item
1612         default_cn = self.classname
1613         default_cl = self.db.classes[default_cn]
1614         default_nodeid = self.nodeid
1616         # we'll store info about the individual class/item edit in these
1617         all_required = {}       # required props per class/item
1618         all_props = {}          # props to set per class/item
1619         got_props = {}          # props received per class/item
1620         all_propdef = {}        # note - only one entry per class
1621         all_links = []          # as many as are required
1623         # we should always return something, even empty, for the context
1624         all_props[(default_cn, default_nodeid)] = {}
1626         keys = form.keys()
1627         timezone = db.getUserTimezone()
1629         # sentinels for the :note and :file props
1630         have_note = have_file = 0
1632         # extract the usable form labels from the form
1633         matches = []
1634         for key in keys:
1635             m = self.FV_SPECIAL.match(key)
1636             if m:
1637                 matches.append((key, m.groupdict()))
1639         # now handle the matches
1640         for key, d in matches:
1641             if d['classname']:
1642                 # we got a designator
1643                 cn = d['classname']
1644                 cl = self.db.classes[cn]
1645                 nodeid = d['id']
1646                 propname = d['propname']
1647             elif d['note']:
1648                 # the special note field
1649                 cn = 'msg'
1650                 cl = self.db.classes[cn]
1651                 nodeid = '-1'
1652                 propname = 'content'
1653                 all_links.append((default_cn, default_nodeid, 'messages',
1654                     [('msg', '-1')]))
1655                 have_note = 1
1656             elif d['file']:
1657                 # the special file field
1658                 cn = 'file'
1659                 cl = self.db.classes[cn]
1660                 nodeid = '-1'
1661                 propname = 'content'
1662                 all_links.append((default_cn, default_nodeid, 'files',
1663                     [('file', '-1')]))
1664                 have_file = 1
1665             else:
1666                 # default
1667                 cn = default_cn
1668                 cl = default_cl
1669                 nodeid = default_nodeid
1670                 propname = d['propname']
1672             # the thing this value relates to is...
1673             this = (cn, nodeid)
1675             # get more info about the class, and the current set of
1676             # form props for it
1677             if not all_propdef.has_key(cn):
1678                 all_propdef[cn] = cl.getprops()
1679             propdef = all_propdef[cn]
1680             if not all_props.has_key(this):
1681                 all_props[this] = {}
1682             props = all_props[this]
1683             if not got_props.has_key(this):
1684                 got_props[this] = {}
1686             # is this a link command?
1687             if d['link']:
1688                 value = []
1689                 for entry in extractFormList(form[key]):
1690                     m = self.FV_DESIGNATOR.match(entry)
1691                     if not m:
1692                         raise ValueError, \
1693                             'link "%s" value "%s" not a designator'%(key, entry)
1694                     value.append((m.group(1), m.group(2)))
1696                 # make sure the link property is valid
1697                 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1698                         not isinstance(propdef[propname], hyperdb.Link)):
1699                     raise ValueError, '%s %s is not a link or '\
1700                         'multilink property'%(cn, propname)
1702                 all_links.append((cn, nodeid, propname, value))
1703                 continue
1705             # detect the special ":required" variable
1706             if d['required']:
1707                 all_required[this] = extractFormList(form[key])
1708                 continue
1710             # see if we're performing a special multilink action
1711             mlaction = 'set'
1712             if d['remove']:
1713                 mlaction = 'remove'
1714             elif d['add']:
1715                 mlaction = 'add'
1717             # does the property exist?
1718             if not propdef.has_key(propname):
1719                 if mlaction != 'set':
1720                     raise ValueError, 'You have submitted a %s action for'\
1721                         ' the property "%s" which doesn\'t exist'%(mlaction,
1722                         propname)
1723                 # the form element is probably just something we don't care
1724                 # about - ignore it
1725                 continue
1726             proptype = propdef[propname]
1728             # Get the form value. This value may be a MiniFieldStorage or a list
1729             # of MiniFieldStorages.
1730             value = form[key]
1732             # handle unpacking of the MiniFieldStorage / list form value
1733             if isinstance(proptype, hyperdb.Multilink):
1734                 value = extractFormList(value)
1735             else:
1736                 # multiple values are not OK
1737                 if isinstance(value, type([])):
1738                     raise ValueError, 'You have submitted more than one value'\
1739                         ' for the %s property'%propname
1740                 # value might be a file upload...
1741                 if not hasattr(value, 'filename') or value.filename is None:
1742                     # nope, pull out the value and strip it
1743                     value = value.value.strip()
1745             # now that we have the props field, we need a teensy little
1746             # extra bit of help for the old :note field...
1747             if d['note'] and value:
1748                 props['author'] = self.db.getuid()
1749                 props['date'] = date.Date()
1751             # handle by type now
1752             if isinstance(proptype, hyperdb.Password):
1753                 if not value:
1754                     # ignore empty password values
1755                     continue
1756                 for key, d in matches:
1757                     if d['confirm'] and d['propname'] == propname:
1758                         confirm = form[key]
1759                         break
1760                 else:
1761                     raise ValueError, 'Password and confirmation text do '\
1762                         'not match'
1763                 if isinstance(confirm, type([])):
1764                     raise ValueError, 'You have submitted more than one value'\
1765                         ' for the %s property'%propname
1766                 if value != confirm.value:
1767                     raise ValueError, 'Password and confirmation text do '\
1768                         'not match'
1769                 value = password.Password(value)
1771             elif isinstance(proptype, hyperdb.Link):
1772                 # see if it's the "no selection" choice
1773                 if value == '-1' or not value:
1774                     # if we're creating, just don't include this property
1775                     if not nodeid or nodeid.startswith('-'):
1776                         continue
1777                     value = None
1778                 else:
1779                     # handle key values
1780                     link = proptype.classname
1781                     if not num_re.match(value):
1782                         try:
1783                             value = db.classes[link].lookup(value)
1784                         except KeyError:
1785                             raise ValueError, _('property "%(propname)s": '
1786                                 '%(value)s not a %(classname)s')%{
1787                                 'propname': propname, 'value': value,
1788                                 'classname': link}
1789                         except TypeError, message:
1790                             raise ValueError, _('you may only enter ID values '
1791                                 'for property "%(propname)s": %(message)s')%{
1792                                 'propname': propname, 'message': message}
1793             elif isinstance(proptype, hyperdb.Multilink):
1794                 # perform link class key value lookup if necessary
1795                 link = proptype.classname
1796                 link_cl = db.classes[link]
1797                 l = []
1798                 for entry in value:
1799                     if not entry: continue
1800                     if not num_re.match(entry):
1801                         try:
1802                             entry = link_cl.lookup(entry)
1803                         except KeyError:
1804                             raise ValueError, _('property "%(propname)s": '
1805                                 '"%(value)s" not an entry of %(classname)s')%{
1806                                 'propname': propname, 'value': entry,
1807                                 'classname': link}
1808                         except TypeError, message:
1809                             raise ValueError, _('you may only enter ID values '
1810                                 'for property "%(propname)s": %(message)s')%{
1811                                 'propname': propname, 'message': message}
1812                     l.append(entry)
1813                 l.sort()
1815                 # now use that list of ids to modify the multilink
1816                 if mlaction == 'set':
1817                     value = l
1818                 else:
1819                     # we're modifying the list - get the current list of ids
1820                     if props.has_key(propname):
1821                         existing = props[propname]
1822                     elif nodeid and not nodeid.startswith('-'):
1823                         existing = cl.get(nodeid, propname, [])
1824                     else:
1825                         existing = []
1827                     # now either remove or add
1828                     if mlaction == 'remove':
1829                         # remove - handle situation where the id isn't in
1830                         # the list
1831                         for entry in l:
1832                             try:
1833                                 existing.remove(entry)
1834                             except ValueError:
1835                                 raise ValueError, _('property "%(propname)s": '
1836                                     '"%(value)s" not currently in list')%{
1837                                     'propname': propname, 'value': entry}
1838                     else:
1839                         # add - easy, just don't dupe
1840                         for entry in l:
1841                             if entry not in existing:
1842                                 existing.append(entry)
1843                     value = existing
1844                     value.sort()
1846             elif value == '':
1847                 # if we're creating, just don't include this property
1848                 if not nodeid or nodeid.startswith('-'):
1849                     continue
1850                 # other types should be None'd if there's no value
1851                 value = None
1852             else:
1853                 # handle ValueErrors for all these in a similar fashion
1854                 try:
1855                     if isinstance(proptype, hyperdb.String):
1856                         if (hasattr(value, 'filename') and
1857                                 value.filename is not None):
1858                             # skip if the upload is empty
1859                             if not value.filename:
1860                                 continue
1861                             # this String is actually a _file_
1862                             # try to determine the file content-type
1863                             fn = value.filename.split('\\')[-1]
1864                             if propdef.has_key('name'):
1865                                 props['name'] = fn
1866                             # use this info as the type/filename properties
1867                             if propdef.has_key('type'):
1868                                 props['type'] = mimetypes.guess_type(fn)[0]
1869                                 if not props['type']:
1870                                     props['type'] = "application/octet-stream"
1871                             # finally, read the content
1872                             value = value.value
1873                         else:
1874                             # normal String fix the CRLF/CR -> LF stuff
1875                             value = fixNewlines(value)
1877                     elif isinstance(proptype, hyperdb.Date):
1878                         value = date.Date(value, offset=timezone)
1879                     elif isinstance(proptype, hyperdb.Interval):
1880                         value = date.Interval(value)
1881                     elif isinstance(proptype, hyperdb.Boolean):
1882                         value = value.lower() in ('yes', 'true', 'on', '1')
1883                     elif isinstance(proptype, hyperdb.Number):
1884                         value = float(value)
1885                 except ValueError, msg:
1886                     raise ValueError, _('Error with %s property: %s')%(
1887                         propname, msg)
1889             # register that we got this property
1890             if value:
1891                 got_props[this][propname] = 1
1893             # get the old value
1894             if nodeid and not nodeid.startswith('-'):
1895                 try:
1896                     existing = cl.get(nodeid, propname)
1897                 except KeyError:
1898                     # this might be a new property for which there is
1899                     # no existing value
1900                     if not propdef.has_key(propname):
1901                         raise
1903                 # make sure the existing multilink is sorted
1904                 if isinstance(proptype, hyperdb.Multilink):
1905                     existing.sort()
1907                 # "missing" existing values may not be None
1908                 if not existing:
1909                     if isinstance(proptype, hyperdb.String) and not existing:
1910                         # some backends store "missing" Strings as empty strings
1911                         existing = None
1912                     elif isinstance(proptype, hyperdb.Number) and not existing:
1913                         # some backends store "missing" Numbers as 0 :(
1914                         existing = 0
1915                     elif isinstance(proptype, hyperdb.Boolean) and not existing:
1916                         # likewise Booleans
1917                         existing = 0
1919                 # if changed, set it
1920                 if value != existing:
1921                     props[propname] = value
1922             else:
1923                 # don't bother setting empty/unset values
1924                 if value is None:
1925                     continue
1926                 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1927                     continue
1928                 elif isinstance(proptype, hyperdb.String) and value == '':
1929                     continue
1931                 props[propname] = value
1933         # check to see if we need to specially link a file to the note
1934         if have_note and have_file:
1935             all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1937         # see if all the required properties have been supplied
1938         s = []
1939         for thing, required in all_required.items():
1940             # register the values we got
1941             got = got_props.get(thing, {})
1942             for entry in required[:]:
1943                 if got.has_key(entry):
1944                     required.remove(entry)
1946             # any required values not present?
1947             if not required:
1948                 continue
1950             # tell the user to entry the values required
1951             if len(required) > 1:
1952                 p = 'properties'
1953             else:
1954                 p = 'property'
1955             s.append('Required %s %s %s not supplied'%(thing[0], p,
1956                 ', '.join(required)))
1957         if s:
1958             raise ValueError, '\n'.join(s)
1960         # When creating a FileClass node, it should have a non-empty content
1961         # property to be created. When editing a FileClass node, it should
1962         # either have a non-empty content property or no property at all. In
1963         # the latter case, nothing will change.
1964         for (cn, id), props in all_props.items():
1965             if isinstance(self.db.classes[cn], hyperdb.FileClass):
1966                 if id == '-1':
1967                       if not props.get('content', ''):
1968                             del all_props[(cn, id)]
1969                 elif props.has_key('content') and not props['content']:
1970                       raise ValueError, _('File is empty')
1971         return all_props, all_links
1973 def fixNewlines(text):
1974     ''' Homogenise line endings.
1976         Different web clients send different line ending values, but
1977         other systems (eg. email) don't necessarily handle those line
1978         endings. Our solution is to convert all line endings to LF.
1979     '''
1980     text = text.replace('\r\n', '\n')
1981     return text.replace('\r', '\n')
1983 def extractFormList(value):
1984     ''' Extract a list of values from the form value.
1986         It may be one of:
1987          [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
1988          MiniFieldStorage('value,value,...')
1989          MiniFieldStorage('value')
1990     '''
1991     # multiple values are OK
1992     if isinstance(value, type([])):
1993         # it's a list of MiniFieldStorages - join then into
1994         values = ','.join([i.value.strip() for i in value])
1995     else:
1996         # it's a MiniFieldStorage, but may be a comma-separated list
1997         # of values
1998         values = value.value
2000     value = [i.strip() for i in values.split(',')]
2002     # filter out the empty bits
2003     return filter(None, value)