Code

Don't use locale-dependent string.letters for one time keys: this could be
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.134 2003-09-06 09:45:30 jlgijsbers 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.ascii_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         try:
844             # pull the rego information out of the otk database
845             self.userid = self.db.confirm_registration(self.form['otk'].value)
846         except (ValueError, KeyError), message:
847             # XXX: we need to make the "default" page be able to display errors!
848             self.error_message.append(str(message))
849             return
850         
851         # log the new user in
852         self.user = self.db.user.get(self.userid, 'username')
853         # re-open the database for real, using the user
854         self.opendb(self.user)
856         # if we have a session, update it
857         if hasattr(self, 'session'):
858             self.db.sessions.set(self.session, user=self.user,
859                 last_use=time.time())
860         else:
861             # new session cookie
862             self.set_cookie(self.user)
864         # nice message
865         message = _('You are now registered, welcome!')
867         # redirect to the user's page
868         raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
869             self.userid, urllib.quote(message))
871     def passResetAction(self):
872         ''' Handle password reset requests.
874             Presence of either "name" or "address" generate email.
875             Presense of "otk" performs the reset.
876         '''
877         if self.form.has_key('otk'):
878             # pull the rego information out of the otk database
879             otk = self.form['otk'].value
880             uid = self.db.otks.get(otk, 'uid')
881             if uid is None:
882                 self.error_message.append('Invalid One Time Key!')
883                 return
885             # re-open the database as "admin"
886             if self.user != 'admin':
887                 self.opendb('admin')
889             # change the password
890             newpw = password.generatePassword()
892             cl = self.db.user
893 # XXX we need to make the "default" page be able to display errors!
894             try:
895                 # set the password
896                 cl.set(uid, password=password.Password(newpw))
897                 # clear the props from the otk database
898                 self.db.otks.destroy(otk)
899                 self.db.commit()
900             except (ValueError, KeyError), message:
901                 self.error_message.append(str(message))
902                 return
904             # user info
905             address = self.db.user.get(uid, 'address')
906             name = self.db.user.get(uid, 'username')
908             # send the email
909             tracker_name = self.db.config.TRACKER_NAME
910             subject = 'Password reset for %s'%tracker_name
911             body = '''
912 The password has been reset for username "%(name)s".
914 Your password is now: %(password)s
915 '''%{'name': name, 'password': newpw}
916             if not self.sendEmail(address, subject, body):
917                 return
919             self.ok_message.append('Password reset and email sent to %s'%address)
920             return
922         # no OTK, so now figure the user
923         if self.form.has_key('username'):
924             name = self.form['username'].value
925             try:
926                 uid = self.db.user.lookup(name)
927             except KeyError:
928                 self.error_message.append('Unknown username')
929                 return
930             address = self.db.user.get(uid, 'address')
931         elif self.form.has_key('address'):
932             address = self.form['address'].value
933             uid = uidFromAddress(self.db, ('', address), create=0)
934             if not uid:
935                 self.error_message.append('Unknown email address')
936                 return
937             name = self.db.user.get(uid, 'username')
938         else:
939             self.error_message.append('You need to specify a username '
940                 'or address')
941             return
943         # generate the one-time-key and store the props for later
944         otk = ''.join([random.choice(chars) for x in range(32)])
945         self.db.otks.set(otk, uid=uid, __time=time.time())
947         # send the email
948         tracker_name = self.db.config.TRACKER_NAME
949         subject = 'Confirm reset of password for %s'%tracker_name
950         body = '''
951 Someone, perhaps you, has requested that the password be changed for your
952 username, "%(name)s". If you wish to proceed with the change, please follow
953 the link below:
955   %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
957 You should then receive another email with the new password.
958 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
959         if not self.sendEmail(address, subject, body):
960             return
962         self.ok_message.append('Email sent to %s'%address)
964     def editItemAction(self):
965         ''' Perform an edit of an item in the database.
967            See parsePropsFromForm and _editnodes for special variables
968         '''
969         # parse the props from the form
970         try:
971             props, links = self.parsePropsFromForm()
972         except (ValueError, KeyError), message:
973             self.error_message.append(_('Parse Error: ') + str(message))
974             return
976         # handle the props
977         try:
978             message = self._editnodes(props, links)
979         except (ValueError, KeyError, IndexError), message:
980             self.error_message.append(_('Apply Error: ') + str(message))
981             return
983         # commit now that all the tricky stuff is done
984         self.db.commit()
986         # redirect to the item's edit page
987         raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
988             self.classname, self.nodeid, urllib.quote(message),
989             urllib.quote(self.template))
991     def editItemPermission(self, props):
992         ''' Determine whether the user has permission to edit this item.
994             Base behaviour is to check the user can edit this class. If we're
995             editing the "user" class, users are allowed to edit their own
996             details. Unless it's the "roles" property, which requires the
997             special Permission "Web Roles".
998         '''
999         # if this is a user node and the user is editing their own node, then
1000         # we're OK
1001         has = self.db.security.hasPermission
1002         if self.classname == 'user':
1003             # reject if someone's trying to edit "roles" and doesn't have the
1004             # right permission.
1005             if props.has_key('roles') and not has('Web Roles', self.userid,
1006                     'user'):
1007                 return 0
1008             # if the item being edited is the current user, we're ok
1009             if self.nodeid == self.userid:
1010                 return 1
1011         if self.db.security.hasPermission('Edit', self.userid, self.classname):
1012             return 1
1013         return 0
1015     def newItemAction(self):
1016         ''' Add a new item to the database.
1018             This follows the same form as the editItemAction, with the same
1019             special form values.
1020         '''
1021         # parse the props from the form
1022         try:
1023             props, links = self.parsePropsFromForm()
1024         except (ValueError, KeyError), message:
1025             self.error_message.append(_('Error: ') + str(message))
1026             return
1028         # handle the props - edit or create
1029         try:
1030             # when it hits the None element, it'll set self.nodeid
1031             messages = self._editnodes(props, links)
1033         except (ValueError, KeyError, IndexError), message:
1034             # these errors might just be indicative of user dumbness
1035             self.error_message.append(_('Error: ') + str(message))
1036             return
1038         # commit now that all the tricky stuff is done
1039         self.db.commit()
1041         # redirect to the new item's page
1042         raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
1043             self.classname, self.nodeid, urllib.quote(messages),
1044             urllib.quote(self.template))
1046     def newItemPermission(self, props):
1047         ''' Determine whether the user has permission to create (edit) this
1048             item.
1050             Base behaviour is to check the user can edit this class. No
1051             additional property checks are made. Additionally, new user items
1052             may be created if the user has the "Web Registration" Permission.
1053         '''
1054         has = self.db.security.hasPermission
1055         if self.classname == 'user' and has('Web Registration', self.userid,
1056                 'user'):
1057             return 1
1058         if has('Edit', self.userid, self.classname):
1059             return 1
1060         return 0
1063     #
1064     #  Utility methods for editing
1065     #
1066     def _editnodes(self, all_props, all_links, newids=None):
1067         ''' Use the props in all_props to perform edit and creation, then
1068             use the link specs in all_links to do linking.
1069         '''
1070         # figure dependencies and re-work links
1071         deps = {}
1072         links = {}
1073         for cn, nodeid, propname, vlist in all_links:
1074             if not all_props.has_key((cn, nodeid)):
1075                 # link item to link to doesn't (and won't) exist
1076                 continue
1077             for value in vlist:
1078                 if not all_props.has_key(value):
1079                     # link item to link to doesn't (and won't) exist
1080                     continue
1081                 deps.setdefault((cn, nodeid), []).append(value)
1082                 links.setdefault(value, []).append((cn, nodeid, propname))
1084         # figure chained dependencies ordering
1085         order = []
1086         done = {}
1087         # loop detection
1088         change = 0
1089         while len(all_props) != len(done):
1090             for needed in all_props.keys():
1091                 if done.has_key(needed):
1092                     continue
1093                 tlist = deps.get(needed, [])
1094                 for target in tlist:
1095                     if not done.has_key(target):
1096                         break
1097                 else:
1098                     done[needed] = 1
1099                     order.append(needed)
1100                     change = 1
1101             if not change:
1102                 raise ValueError, 'linking must not loop!'
1104         # now, edit / create
1105         m = []
1106         for needed in order:
1107             props = all_props[needed]
1108             if not props:
1109                 # nothing to do
1110                 continue
1111             cn, nodeid = needed
1113             if nodeid is not None and int(nodeid) > 0:
1114                 # make changes to the node
1115                 props = self._changenode(cn, nodeid, props)
1117                 # and some nice feedback for the user
1118                 if props:
1119                     info = ', '.join(props.keys())
1120                     m.append('%s %s %s edited ok'%(cn, nodeid, info))
1121                 else:
1122                     m.append('%s %s - nothing changed'%(cn, nodeid))
1123             else:
1124                 assert props
1126                 # make a new node
1127                 newid = self._createnode(cn, props)
1128                 if nodeid is None:
1129                     self.nodeid = newid
1130                 nodeid = newid
1132                 # and some nice feedback for the user
1133                 m.append('%s %s created'%(cn, newid))
1135             # fill in new ids in links
1136             if links.has_key(needed):
1137                 for linkcn, linkid, linkprop in links[needed]:
1138                     props = all_props[(linkcn, linkid)]
1139                     cl = self.db.classes[linkcn]
1140                     propdef = cl.getprops()[linkprop]
1141                     if not props.has_key(linkprop):
1142                         if linkid is None or linkid.startswith('-'):
1143                             # linking to a new item
1144                             if isinstance(propdef, hyperdb.Multilink):
1145                                 props[linkprop] = [newid]
1146                             else:
1147                                 props[linkprop] = newid
1148                         else:
1149                             # linking to an existing item
1150                             if isinstance(propdef, hyperdb.Multilink):
1151                                 existing = cl.get(linkid, linkprop)[:]
1152                                 existing.append(nodeid)
1153                                 props[linkprop] = existing
1154                             else:
1155                                 props[linkprop] = newid
1157         return '<br>'.join(m)
1159     def _changenode(self, cn, nodeid, props):
1160         ''' change the node based on the contents of the form
1161         '''
1162         # check for permission
1163         if not self.editItemPermission(props):
1164             raise Unauthorised, 'You do not have permission to edit %s'%cn
1166         # make the changes
1167         cl = self.db.classes[cn]
1168         return cl.set(nodeid, **props)
1170     def _createnode(self, cn, props):
1171         ''' create a node based on the contents of the form
1172         '''
1173         # check for permission
1174         if not self.newItemPermission(props):
1175             raise Unauthorised, 'You do not have permission to create %s'%cn
1177         # create the node and return its id
1178         cl = self.db.classes[cn]
1179         return cl.create(**props)
1181     # 
1182     # More actions
1183     #
1184     def editCSVAction(self):
1185         ''' Performs an edit of all of a class' items in one go.
1187             The "rows" CGI var defines the CSV-formatted entries for the
1188             class. New nodes are identified by the ID 'X' (or any other
1189             non-existent ID) and removed lines are retired.
1190         '''
1191         # this is per-class only
1192         if not self.editCSVPermission():
1193             self.error_message.append(
1194                 _('You do not have permission to edit %s' %self.classname))
1196         # get the CSV module
1197         if rcsv.error:
1198             self.error_message.append(_(rcsv.error))
1199             return
1201         cl = self.db.classes[self.classname]
1202         idlessprops = cl.getprops(protected=0).keys()
1203         idlessprops.sort()
1204         props = ['id'] + idlessprops
1206         # do the edit
1207         rows = StringIO.StringIO(self.form['rows'].value)
1208         reader = rcsv.reader(rows, rcsv.comma_separated)
1209         found = {}
1210         line = 0
1211         for values in reader:
1212             line += 1
1213             if line == 1: continue
1214             # skip property names header
1215             if values == props:
1216                 continue
1218             # extract the nodeid
1219             nodeid, values = values[0], values[1:]
1220             found[nodeid] = 1
1222             # see if the node exists
1223             if nodeid in ('x', 'X') or not cl.hasnode(nodeid):
1224                 exists = 0
1225             else:
1226                 exists = 1
1228             # confirm correct weight
1229             if len(idlessprops) != len(values):
1230                 self.error_message.append(
1231                     _('Not enough values on line %(line)s')%{'line':line})
1232                 return
1234             # extract the new values
1235             d = {}
1236             for name, value in zip(idlessprops, values):
1237                 prop = cl.properties[name]
1238                 value = value.strip()
1239                 # only add the property if it has a value
1240                 if value:
1241                     # if it's a multilink, split it
1242                     if isinstance(prop, hyperdb.Multilink):
1243                         value = value.split(':')
1244                     elif isinstance(prop, hyperdb.Password):
1245                         value = password.Password(value)
1246                     elif isinstance(prop, hyperdb.Interval):
1247                         value = date.Interval(value)
1248                     elif isinstance(prop, hyperdb.Date):
1249                         value = date.Date(value)
1250                     elif isinstance(prop, hyperdb.Boolean):
1251                         value = value.lower() in ('yes', 'true', 'on', '1')
1252                     elif isinstance(prop, hyperdb.Number):
1253                         value = float(value)
1254                     d[name] = value
1255                 elif exists:
1256                     # nuke the existing value
1257                     if isinstance(prop, hyperdb.Multilink):
1258                         d[name] = []
1259                     else:
1260                         d[name] = None
1262             # perform the edit
1263             if exists:
1264                 # edit existing
1265                 cl.set(nodeid, **d)
1266             else:
1267                 # new node
1268                 found[cl.create(**d)] = 1
1270         # retire the removed entries
1271         for nodeid in cl.list():
1272             if not found.has_key(nodeid):
1273                 cl.retire(nodeid)
1275         # all OK
1276         self.db.commit()
1278         self.ok_message.append(_('Items edited OK'))
1280     def editCSVPermission(self):
1281         ''' Determine whether the user has permission to edit this class.
1283             Base behaviour is to check the user can edit this class.
1284         ''' 
1285         if not self.db.security.hasPermission('Edit', self.userid,
1286                 self.classname):
1287             return 0
1288         return 1
1290     def searchAction(self, wcre=re.compile(r'[\s,]+')):
1291         ''' Mangle some of the form variables.
1293             Set the form ":filter" variable based on the values of the
1294             filter variables - if they're set to anything other than
1295             "dontcare" then add them to :filter.
1297             Handle the ":queryname" variable and save off the query to
1298             the user's query list.
1300             Split any String query values on whitespace and comma.
1301         '''
1302         # generic edit is per-class only
1303         if not self.searchPermission():
1304             self.error_message.append(
1305                 _('You do not have permission to search %s' %self.classname))
1307         # add a faked :filter form variable for each filtering prop
1308         props = self.db.classes[self.classname].getprops()
1309         queryname = ''
1310         for key in self.form.keys():
1311             # special vars
1312             if self.FV_QUERYNAME.match(key):
1313                 queryname = self.form[key].value.strip()
1314                 continue
1316             if not props.has_key(key):
1317                 continue
1318             if isinstance(self.form[key], type([])):
1319                 # search for at least one entry which is not empty
1320                 for minifield in self.form[key]:
1321                     if minifield.value:
1322                         break
1323                 else:
1324                     continue
1325             else:
1326                 if not self.form[key].value:
1327                     continue
1328                 if isinstance(props[key], hyperdb.String):
1329                     v = self.form[key].value
1330                     l = token.token_split(v)
1331                     if len(l) > 1 or l[0] != v:
1332                         self.form.value.remove(self.form[key])
1333                         # replace the single value with the split list
1334                         for v in l:
1335                             self.form.value.append(cgi.MiniFieldStorage(key, v))
1337             self.form.value.append(cgi.MiniFieldStorage('@filter', key))
1339         # handle saving the query params
1340         if queryname:
1341             # parse the environment and figure what the query _is_
1342             req = HTMLRequest(self)
1344             # The [1:] strips off the '?' character, it isn't part of the
1345             # query string.
1346             url = req.indexargs_href('', {})[1:]
1348             # handle editing an existing query
1349             try:
1350                 qid = self.db.query.lookup(queryname)
1351                 self.db.query.set(qid, klass=self.classname, url=url)
1352             except KeyError:
1353                 # create a query
1354                 qid = self.db.query.create(name=queryname,
1355                     klass=self.classname, url=url)
1357                 # and add it to the user's query multilink
1358                 queries = self.db.user.get(self.userid, 'queries')
1359                 queries.append(qid)
1360                 self.db.user.set(self.userid, queries=queries)
1362             # commit the query change to the database
1363             self.db.commit()
1365     def searchPermission(self):
1366         ''' Determine whether the user has permission to search this class.
1368             Base behaviour is to check the user can view this class.
1369         ''' 
1370         if not self.db.security.hasPermission('View', self.userid,
1371                 self.classname):
1372             return 0
1373         return 1
1376     def retireAction(self):
1377         ''' Retire the context item.
1378         '''
1379         # if we want to view the index template now, then unset the nodeid
1380         # context info (a special-case for retire actions on the index page)
1381         nodeid = self.nodeid
1382         if self.template == 'index':
1383             self.nodeid = None
1385         # generic edit is per-class only
1386         if not self.retirePermission():
1387             self.error_message.append(
1388                 _('You do not have permission to retire %s' %self.classname))
1389             return
1391         # make sure we don't try to retire admin or anonymous
1392         if self.classname == 'user' and \
1393                 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1394             self.error_message.append(
1395                 _('You may not retire the admin or anonymous user'))
1396             return
1398         # do the retire
1399         self.db.getclass(self.classname).retire(nodeid)
1400         self.db.commit()
1402         self.ok_message.append(
1403             _('%(classname)s %(itemid)s has been retired')%{
1404                 'classname': self.classname.capitalize(), 'itemid': nodeid})
1406     def retirePermission(self):
1407         ''' Determine whether the user has permission to retire this class.
1409             Base behaviour is to check the user can edit this class.
1410         ''' 
1411         if not self.db.security.hasPermission('Edit', self.userid,
1412                 self.classname):
1413             return 0
1414         return 1
1417     def showAction(self, typere=re.compile('[@:]type'),
1418             numre=re.compile('[@:]number')):
1419         ''' Show a node of a particular class/id
1420         '''
1421         t = n = ''
1422         for key in self.form.keys():
1423             if typere.match(key):
1424                 t = self.form[key].value.strip()
1425             elif numre.match(key):
1426                 n = self.form[key].value.strip()
1427         if not t:
1428             raise ValueError, 'Invalid %s number'%t
1429         url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1430         raise Redirect, url
1432     def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1433         ''' Item properties and their values are edited with html FORM
1434             variables and their values. You can:
1436             - Change the value of some property of the current item.
1437             - Create a new item of any class, and edit the new item's
1438               properties,
1439             - Attach newly created items to a multilink property of the
1440               current item.
1441             - Remove items from a multilink property of the current item.
1442             - Specify that some properties are required for the edit
1443               operation to be successful.
1445             In the following, <bracketed> values are variable, "@" may be
1446             either ":" or "@", and other text "required" is fixed.
1448             Most properties are specified as form variables:
1450              <propname>
1451               - property on the current context item
1453              <designator>"@"<propname>
1454               - property on the indicated item (for editing related
1455                 information)
1457             Designators name a specific item of a class.
1459             <classname><N>
1461                 Name an existing item of class <classname>.
1463             <classname>"-"<N>
1465                 Name the <N>th new item of class <classname>. If the form
1466                 submission is successful, a new item of <classname> is
1467                 created. Within the submitted form, a particular
1468                 designator of this form always refers to the same new
1469                 item.
1471             Once we have determined the "propname", we look at it to see
1472             if it's special:
1474             @required
1475                 The associated form value is a comma-separated list of
1476                 property names that must be specified when the form is
1477                 submitted for the edit operation to succeed.  
1479                 When the <designator> is missing, the properties are
1480                 for the current context item.  When <designator> is
1481                 present, they are for the item specified by
1482                 <designator>.
1484                 The "@required" specifier must come before any of the
1485                 properties it refers to are assigned in the form.
1487             @remove@<propname>=id(s) or @add@<propname>=id(s)
1488                 The "@add@" and "@remove@" edit actions apply only to
1489                 Multilink properties.  The form value must be a
1490                 comma-separate list of keys for the class specified by
1491                 the simple form variable.  The listed items are added
1492                 to (respectively, removed from) the specified
1493                 property.
1495             @link@<propname>=<designator>
1496                 If the edit action is "@link@", the simple form
1497                 variable must specify a Link or Multilink property.
1498                 The form value is a comma-separated list of
1499                 designators.  The item corresponding to each
1500                 designator is linked to the property given by simple
1501                 form variable.  These are collected up and returned in
1502                 all_links.
1504             None of the above (ie. just a simple form value)
1505                 The value of the form variable is converted
1506                 appropriately, depending on the type of the property.
1508                 For a Link('klass') property, the form value is a
1509                 single key for 'klass', where the key field is
1510                 specified in dbinit.py.  
1512                 For a Multilink('klass') property, the form value is a
1513                 comma-separated list of keys for 'klass', where the
1514                 key field is specified in dbinit.py.  
1516                 Note that for simple-form-variables specifiying Link
1517                 and Multilink properties, the linked-to class must
1518                 have a key field.
1520                 For a String() property specifying a filename, the
1521                 file named by the form value is uploaded. This means we
1522                 try to set additional properties "filename" and "type" (if
1523                 they are valid for the class).  Otherwise, the property
1524                 is set to the form value.
1526                 For Date(), Interval(), Boolean(), and Number()
1527                 properties, the form value is converted to the
1528                 appropriate
1530             Any of the form variables may be prefixed with a classname or
1531             designator.
1533             Two special form values are supported for backwards
1534             compatibility:
1536             @note
1537                 This is equivalent to::
1539                     @link@messages=msg-1
1540                     @msg-1@content=value
1542                 except that in addition, the "author" and "date"
1543                 properties of "msg-1" are set to the userid of the
1544                 submitter, and the current time, respectively.
1546             @file
1547                 This is equivalent to::
1549                     @link@files=file-1
1550                     @file-1@content=value
1552                 The String content value is handled as described above for
1553                 file uploads.
1555             If both the "@note" and "@file" form variables are
1556             specified, the action::
1558                     @link@msg-1@files=file-1
1560             is also performed.
1562             We also check that FileClass items have a "content" property with
1563             actual content, otherwise we remove them from all_props before
1564             returning.
1566             The return from this method is a dict of 
1567                 (classname, id): properties
1568             ... this dict _always_ has an entry for the current context,
1569             even if it's empty (ie. a submission for an existing issue that
1570             doesn't result in any changes would return {('issue','123'): {}})
1571             The id may be None, which indicates that an item should be
1572             created.
1573         '''
1574         # some very useful variables
1575         db = self.db
1576         form = self.form
1578         if not hasattr(self, 'FV_SPECIAL'):
1579             # generate the regexp for handling special form values
1580             classes = '|'.join(db.classes.keys())
1581             # specials for parsePropsFromForm
1582             # handle the various forms (see unit tests)
1583             self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1584             self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1586         # these indicate the default class / item
1587         default_cn = self.classname
1588         default_cl = self.db.classes[default_cn]
1589         default_nodeid = self.nodeid
1591         # we'll store info about the individual class/item edit in these
1592         all_required = {}       # required props per class/item
1593         all_props = {}          # props to set per class/item
1594         got_props = {}          # props received per class/item
1595         all_propdef = {}        # note - only one entry per class
1596         all_links = []          # as many as are required
1598         # we should always return something, even empty, for the context
1599         all_props[(default_cn, default_nodeid)] = {}
1601         keys = form.keys()
1602         timezone = db.getUserTimezone()
1604         # sentinels for the :note and :file props
1605         have_note = have_file = 0
1607         # extract the usable form labels from the form
1608         matches = []
1609         for key in keys:
1610             m = self.FV_SPECIAL.match(key)
1611             if m:
1612                 matches.append((key, m.groupdict()))
1614         # now handle the matches
1615         for key, d in matches:
1616             if d['classname']:
1617                 # we got a designator
1618                 cn = d['classname']
1619                 cl = self.db.classes[cn]
1620                 nodeid = d['id']
1621                 propname = d['propname']
1622             elif d['note']:
1623                 # the special note field
1624                 cn = 'msg'
1625                 cl = self.db.classes[cn]
1626                 nodeid = '-1'
1627                 propname = 'content'
1628                 all_links.append((default_cn, default_nodeid, 'messages',
1629                     [('msg', '-1')]))
1630                 have_note = 1
1631             elif d['file']:
1632                 # the special file field
1633                 cn = 'file'
1634                 cl = self.db.classes[cn]
1635                 nodeid = '-1'
1636                 propname = 'content'
1637                 all_links.append((default_cn, default_nodeid, 'files',
1638                     [('file', '-1')]))
1639                 have_file = 1
1640             else:
1641                 # default
1642                 cn = default_cn
1643                 cl = default_cl
1644                 nodeid = default_nodeid
1645                 propname = d['propname']
1647             # the thing this value relates to is...
1648             this = (cn, nodeid)
1650             # get more info about the class, and the current set of
1651             # form props for it
1652             if not all_propdef.has_key(cn):
1653                 all_propdef[cn] = cl.getprops()
1654             propdef = all_propdef[cn]
1655             if not all_props.has_key(this):
1656                 all_props[this] = {}
1657             props = all_props[this]
1658             if not got_props.has_key(this):
1659                 got_props[this] = {}
1661             # is this a link command?
1662             if d['link']:
1663                 value = []
1664                 for entry in extractFormList(form[key]):
1665                     m = self.FV_DESIGNATOR.match(entry)
1666                     if not m:
1667                         raise ValueError, \
1668                             'link "%s" value "%s" not a designator'%(key, entry)
1669                     value.append((m.group(1), m.group(2)))
1671                 # make sure the link property is valid
1672                 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1673                         not isinstance(propdef[propname], hyperdb.Link)):
1674                     raise ValueError, '%s %s is not a link or '\
1675                         'multilink property'%(cn, propname)
1677                 all_links.append((cn, nodeid, propname, value))
1678                 continue
1680             # detect the special ":required" variable
1681             if d['required']:
1682                 all_required[this] = extractFormList(form[key])
1683                 continue
1685             # see if we're performing a special multilink action
1686             mlaction = 'set'
1687             if d['remove']:
1688                 mlaction = 'remove'
1689             elif d['add']:
1690                 mlaction = 'add'
1692             # does the property exist?
1693             if not propdef.has_key(propname):
1694                 if mlaction != 'set':
1695                     raise ValueError, 'You have submitted a %s action for'\
1696                         ' the property "%s" which doesn\'t exist'%(mlaction,
1697                         propname)
1698                 # the form element is probably just something we don't care
1699                 # about - ignore it
1700                 continue
1701             proptype = propdef[propname]
1703             # Get the form value. This value may be a MiniFieldStorage or a list
1704             # of MiniFieldStorages.
1705             value = form[key]
1707             # handle unpacking of the MiniFieldStorage / list form value
1708             if isinstance(proptype, hyperdb.Multilink):
1709                 value = extractFormList(value)
1710             else:
1711                 # multiple values are not OK
1712                 if isinstance(value, type([])):
1713                     raise ValueError, 'You have submitted more than one value'\
1714                         ' for the %s property'%propname
1715                 # value might be a file upload...
1716                 if not hasattr(value, 'filename') or value.filename is None:
1717                     # nope, pull out the value and strip it
1718                     value = value.value.strip()
1720             # now that we have the props field, we need a teensy little
1721             # extra bit of help for the old :note field...
1722             if d['note'] and value:
1723                 props['author'] = self.db.getuid()
1724                 props['date'] = date.Date()
1726             # handle by type now
1727             if isinstance(proptype, hyperdb.Password):
1728                 if not value:
1729                     # ignore empty password values
1730                     continue
1731                 for key, d in matches:
1732                     if d['confirm'] and d['propname'] == propname:
1733                         confirm = form[key]
1734                         break
1735                 else:
1736                     raise ValueError, 'Password and confirmation text do '\
1737                         'not match'
1738                 if isinstance(confirm, type([])):
1739                     raise ValueError, 'You have submitted more than one value'\
1740                         ' for the %s property'%propname
1741                 if value != confirm.value:
1742                     raise ValueError, 'Password and confirmation text do '\
1743                         'not match'
1744                 value = password.Password(value)
1746             elif isinstance(proptype, hyperdb.Link):
1747                 # see if it's the "no selection" choice
1748                 if value == '-1' or not value:
1749                     # if we're creating, just don't include this property
1750                     if not nodeid or nodeid.startswith('-'):
1751                         continue
1752                     value = None
1753                 else:
1754                     # handle key values
1755                     link = proptype.classname
1756                     if not num_re.match(value):
1757                         try:
1758                             value = db.classes[link].lookup(value)
1759                         except KeyError:
1760                             raise ValueError, _('property "%(propname)s": '
1761                                 '%(value)s not a %(classname)s')%{
1762                                 'propname': propname, 'value': value,
1763                                 'classname': link}
1764                         except TypeError, message:
1765                             raise ValueError, _('you may only enter ID values '
1766                                 'for property "%(propname)s": %(message)s')%{
1767                                 'propname': propname, 'message': message}
1768             elif isinstance(proptype, hyperdb.Multilink):
1769                 # perform link class key value lookup if necessary
1770                 link = proptype.classname
1771                 link_cl = db.classes[link]
1772                 l = []
1773                 for entry in value:
1774                     if not entry: continue
1775                     if not num_re.match(entry):
1776                         try:
1777                             entry = link_cl.lookup(entry)
1778                         except KeyError:
1779                             raise ValueError, _('property "%(propname)s": '
1780                                 '"%(value)s" not an entry of %(classname)s')%{
1781                                 'propname': propname, 'value': entry,
1782                                 'classname': link}
1783                         except TypeError, message:
1784                             raise ValueError, _('you may only enter ID values '
1785                                 'for property "%(propname)s": %(message)s')%{
1786                                 'propname': propname, 'message': message}
1787                     l.append(entry)
1788                 l.sort()
1790                 # now use that list of ids to modify the multilink
1791                 if mlaction == 'set':
1792                     value = l
1793                 else:
1794                     # we're modifying the list - get the current list of ids
1795                     if props.has_key(propname):
1796                         existing = props[propname]
1797                     elif nodeid and not nodeid.startswith('-'):
1798                         existing = cl.get(nodeid, propname, [])
1799                     else:
1800                         existing = []
1802                     # now either remove or add
1803                     if mlaction == 'remove':
1804                         # remove - handle situation where the id isn't in
1805                         # the list
1806                         for entry in l:
1807                             try:
1808                                 existing.remove(entry)
1809                             except ValueError:
1810                                 raise ValueError, _('property "%(propname)s": '
1811                                     '"%(value)s" not currently in list')%{
1812                                     'propname': propname, 'value': entry}
1813                     else:
1814                         # add - easy, just don't dupe
1815                         for entry in l:
1816                             if entry not in existing:
1817                                 existing.append(entry)
1818                     value = existing
1819                     value.sort()
1821             elif value == '':
1822                 # if we're creating, just don't include this property
1823                 if not nodeid or nodeid.startswith('-'):
1824                     continue
1825                 # other types should be None'd if there's no value
1826                 value = None
1827             else:
1828                 # handle ValueErrors for all these in a similar fashion
1829                 try:
1830                     if isinstance(proptype, hyperdb.String):
1831                         if (hasattr(value, 'filename') and
1832                                 value.filename is not None):
1833                             # skip if the upload is empty
1834                             if not value.filename:
1835                                 continue
1836                             # this String is actually a _file_
1837                             # try to determine the file content-type
1838                             fn = value.filename.split('\\')[-1]
1839                             if propdef.has_key('name'):
1840                                 props['name'] = fn
1841                             # use this info as the type/filename properties
1842                             if propdef.has_key('type'):
1843                                 props['type'] = mimetypes.guess_type(fn)[0]
1844                                 if not props['type']:
1845                                     props['type'] = "application/octet-stream"
1846                             # finally, read the content
1847                             value = value.value
1848                         else:
1849                             # normal String fix the CRLF/CR -> LF stuff
1850                             value = fixNewlines(value)
1852                     elif isinstance(proptype, hyperdb.Date):
1853                         value = date.Date(value, offset=timezone)
1854                     elif isinstance(proptype, hyperdb.Interval):
1855                         value = date.Interval(value)
1856                     elif isinstance(proptype, hyperdb.Boolean):
1857                         value = value.lower() in ('yes', 'true', 'on', '1')
1858                     elif isinstance(proptype, hyperdb.Number):
1859                         value = float(value)
1860                 except ValueError, msg:
1861                     raise ValueError, _('Error with %s property: %s')%(
1862                         propname, msg)
1864             # register that we got this property
1865             if value:
1866                 got_props[this][propname] = 1
1868             # get the old value
1869             if nodeid and not nodeid.startswith('-'):
1870                 try:
1871                     existing = cl.get(nodeid, propname)
1872                 except KeyError:
1873                     # this might be a new property for which there is
1874                     # no existing value
1875                     if not propdef.has_key(propname):
1876                         raise
1878                 # make sure the existing multilink is sorted
1879                 if isinstance(proptype, hyperdb.Multilink):
1880                     existing.sort()
1882                 # "missing" existing values may not be None
1883                 if not existing:
1884                     if isinstance(proptype, hyperdb.String) and not existing:
1885                         # some backends store "missing" Strings as empty strings
1886                         existing = None
1887                     elif isinstance(proptype, hyperdb.Number) and not existing:
1888                         # some backends store "missing" Numbers as 0 :(
1889                         existing = 0
1890                     elif isinstance(proptype, hyperdb.Boolean) and not existing:
1891                         # likewise Booleans
1892                         existing = 0
1894                 # if changed, set it
1895                 if value != existing:
1896                     props[propname] = value
1897             else:
1898                 # don't bother setting empty/unset values
1899                 if value is None:
1900                     continue
1901                 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1902                     continue
1903                 elif isinstance(proptype, hyperdb.String) and value == '':
1904                     continue
1906                 props[propname] = value
1908         # check to see if we need to specially link a file to the note
1909         if have_note and have_file:
1910             all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1912         # see if all the required properties have been supplied
1913         s = []
1914         for thing, required in all_required.items():
1915             # register the values we got
1916             got = got_props.get(thing, {})
1917             for entry in required[:]:
1918                 if got.has_key(entry):
1919                     required.remove(entry)
1921             # any required values not present?
1922             if not required:
1923                 continue
1925             # tell the user to entry the values required
1926             if len(required) > 1:
1927                 p = 'properties'
1928             else:
1929                 p = 'property'
1930             s.append('Required %s %s %s not supplied'%(thing[0], p,
1931                 ', '.join(required)))
1932         if s:
1933             raise ValueError, '\n'.join(s)
1935         # When creating a FileClass node, it should have a non-empty content
1936         # property to be created. When editing a FileClass node, it should
1937         # either have a non-empty content property or no property at all. In
1938         # the latter case, nothing will change.
1939         for (cn, id), props in all_props.items():
1940             if isinstance(self.db.classes[cn], hyperdb.FileClass):
1941                 if id == '-1':
1942                       if not props.get('content', ''):
1943                             del all_props[(cn, id)]
1944                 elif props.has_key('content') and not props['content']:
1945                       raise ValueError, _('File is empty')
1946         return all_props, all_links
1948 def fixNewlines(text):
1949     ''' Homogenise line endings.
1951         Different web clients send different line ending values, but
1952         other systems (eg. email) don't necessarily handle those line
1953         endings. Our solution is to convert all line endings to LF.
1954     '''
1955     text = text.replace('\r\n', '\n')
1956     return text.replace('\r', '\n')
1958 def extractFormList(value):
1959     ''' Extract a list of values from the form value.
1961         It may be one of:
1962          [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
1963          MiniFieldStorage('value,value,...')
1964          MiniFieldStorage('value')
1965     '''
1966     # multiple values are OK
1967     if isinstance(value, type([])):
1968         # it's a list of MiniFieldStorages - join then into
1969         values = ','.join([i.value.strip() for i in value])
1970     else:
1971         # it's a MiniFieldStorage, but may be a comma-separated list
1972         # of values
1973         values = value.value
1975     value = [i.strip() for i in values.split(',')]
1977     # filter out the empty bits
1978     return filter(None, value)