Code

fix file downloading
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.130 2003-08-13 23:51:59 richard Exp $
3 __doc__ = """
4 WWW request handler (also used in the stand-alone server).
5 """
7 import os, os.path, cgi, StringIO, urlparse, re, traceback, mimetypes, urllib
8 import binascii, Cookie, time, random, MimeWriter, smtplib, socket, quopri
9 import stat, rfc822, string
11 from roundup import roundupdb, date, hyperdb, password, token
12 from roundup.i18n import _
13 from roundup.cgi.templating import Templates, HTMLRequest, NoTemplate
14 from roundup.cgi import cgitb
15 from roundup.cgi.PageTemplates import PageTemplate
16 from roundup.rfc2822 import encode_header
17 from roundup.mailgw import uidFromAddress, openSMTPConnection
19 class HTTPException(Exception):
20       pass
21 class  Unauthorised(HTTPException):
22        pass
23 class  NotFound(HTTPException):
24        pass
25 class  Redirect(HTTPException):
26        pass
27 class  NotModified(HTTPException):
28        pass
30 # set to indicate to roundup not to actually _send_ email
31 # this var must contain a file to write the mail to
32 SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '')
34 # used by a couple of routines
35 chars = string.letters+string.digits
37 # XXX actually _use_ FormError
38 class FormError(ValueError):
39     ''' An "expected" exception occurred during form parsing.
40         - ie. something we know can go wrong, and don't want to alarm the
41           user with
43         We trap this at the user interface level and feed back a nice error
44         to the user.
45     '''
46     pass
48 class SendFile(Exception):
49     ''' Send a file from the database '''
51 class SendStaticFile(Exception):
52     ''' Send a static file from the instance html directory '''
54 def initialiseSecurity(security):
55     ''' Create some Permissions and Roles on the security object
57         This function is directly invoked by security.Security.__init__()
58         as a part of the Security object instantiation.
59     '''
60     security.addPermission(name="Web Registration",
61         description="User may register through the web")
62     p = security.addPermission(name="Web Access",
63         description="User may access the web interface")
64     security.addPermissionToRole('Admin', p)
66     # doing Role stuff through the web - make sure Admin can
67     p = security.addPermission(name="Web Roles",
68         description="User may manipulate user Roles through the web")
69     security.addPermissionToRole('Admin', p)
71 # used to clean messages passed through CGI variables - HTML-escape any tag
72 # that isn't <a href="">, <i>, <b> and <br> (including XHTML variants) so
73 # that people can't pass through nasties like <script>, <iframe>, ...
74 CLEAN_MESSAGE_RE = r'(<(/?(.*?)(\s*href="[^"]")?\s*/?)>)'
75 def clean_message(message, mc=re.compile(CLEAN_MESSAGE_RE, re.I)):
76     return mc.sub(clean_message_callback, message)
77 def clean_message_callback(match, ok={'a':1,'i':1,'b':1,'br':1}):
78     ''' Strip all non <a>,<i>,<b> and <br> tags from a string
79     '''
80     if ok.has_key(match.group(3).lower()):
81         return match.group(1)
82     return '&lt;%s&gt;'%match.group(2)
84 class Client:
85     ''' Instantiate to handle one CGI request.
87     See inner_main for request processing.
89     Client attributes at instantiation:
90         "path" is the PATH_INFO inside the instance (with no leading '/')
91         "base" is the base URL for the instance
92         "form" is the cgi form, an instance of FieldStorage from the standard
93                cgi module
94         "additional_headers" is a dictionary of additional HTTP headers that
95                should be sent to the client
96         "response_code" is the HTTP response code to send to the client
98     During the processing of a request, the following attributes are used:
99         "error_message" holds a list of error messages
100         "ok_message" holds a list of OK messages
101         "session" is the current user session id
102         "user" is the current user's name
103         "userid" is the current user's id
104         "template" is the current :template context
105         "classname" is the current class context name
106         "nodeid" is the current context item id
108     User Identification:
109      If the user has no login cookie, then they are anonymous and are logged
110      in as that user. This typically gives them all Permissions assigned to the
111      Anonymous Role.
113      Once a user logs in, they are assigned a session. The Client instance
114      keeps the nodeid of the session as the "session" attribute.
117     Special form variables:
118      Note that in various places throughout this code, special form
119      variables of the form :<name> are used. The colon (":") part may
120      actually be one of either ":" or "@".
121     '''
123     #
124     # special form variables
125     #
126     FV_TEMPLATE = re.compile(r'[@:]template')
127     FV_OK_MESSAGE = re.compile(r'[@:]ok_message')
128     FV_ERROR_MESSAGE = re.compile(r'[@:]error_message')
130     FV_QUERYNAME = re.compile(r'[@:]queryname')
132     # edit form variable handling (see unit tests)
133     FV_LABELS = r'''
134        ^(
135          (?P<note>[@:]note)|
136          (?P<file>[@:]file)|
137          (
138           ((?P<classname>%s)(?P<id>[-\d]+))?  # optional leading designator
139           ((?P<required>[@:]required$)|       # :required
140            (
141             (
142              (?P<add>[@:]add[@:])|            # :add:<prop>
143              (?P<remove>[@:]remove[@:])|      # :remove:<prop>
144              (?P<confirm>[@:]confirm[@:])|    # :confirm:<prop>
145              (?P<link>[@:]link[@:])|          # :link:<prop>
146              ([@:])                           # just a separator
147             )?
148             (?P<propname>[^@:]+)             # <prop>
149            )
150           )
151          )
152         )$'''
154     # Note: index page stuff doesn't appear here:
155     # columns, sort, sortdir, filter, group, groupdir, search_text,
156     # pagesize, startwith
158     def __init__(self, instance, request, env, form=None):
159         hyperdb.traceMark()
160         self.instance = instance
161         self.request = request
162         self.env = env
164         # save off the path
165         self.path = env['PATH_INFO']
167         # this is the base URL for this tracker
168         self.base = self.instance.config.TRACKER_WEB
170         # this is the "cookie path" for this tracker (ie. the path part of
171         # the "base" url)
172         self.cookie_path = urlparse.urlparse(self.base)[2]
173         self.cookie_name = 'roundup_session_' + re.sub('[^a-zA-Z]', '',
174             self.instance.config.TRACKER_NAME)
176         # see if we need to re-parse the environment for the form (eg Zope)
177         if form is None:
178             self.form = cgi.FieldStorage(environ=env)
179         else:
180             self.form = form
182         # turn debugging on/off
183         try:
184             self.debug = int(env.get("ROUNDUP_DEBUG", 0))
185         except ValueError:
186             # someone gave us a non-int debug level, turn it off
187             self.debug = 0
189         # flag to indicate that the HTTP headers have been sent
190         self.headers_done = 0
192         # additional headers to send with the request - must be registered
193         # before the first write
194         self.additional_headers = {}
195         self.response_code = 200
198     def main(self):
199         ''' Wrap the real main in a try/finally so we always close off the db.
200         '''
201         try:
202             self.inner_main()
203         finally:
204             if hasattr(self, 'db'):
205                 self.db.close()
207     def inner_main(self):
208         ''' Process a request.
210             The most common requests are handled like so:
211             1. figure out who we are, defaulting to the "anonymous" user
212                see determine_user
213             2. figure out what the request is for - the context
214                see determine_context
215             3. handle any requested action (item edit, search, ...)
216                see handle_action
217             4. render a template, resulting in HTML output
219             In some situations, exceptions occur:
220             - HTTP Redirect  (generally raised by an action)
221             - SendFile       (generally raised by determine_context)
222               serve up a FileClass "content" property
223             - SendStaticFile (generally raised by determine_context)
224               serve up a file from the tracker "html" directory
225             - Unauthorised   (generally raised by an action)
226               the action is cancelled, the request is rendered and an error
227               message is displayed indicating that permission was not
228               granted for the action to take place
229             - NotFound       (raised wherever it needs to be)
230               percolates up to the CGI interface that called the client
231         '''
232         self.ok_message = []
233         self.error_message = []
234         try:
235             # figure out the context and desired content template
236             # do this first so we don't authenticate for static files
237             # Note: this method opens the database as "admin" in order to
238             # perform context checks
239             self.determine_context()
241             # make sure we're identified (even anonymously)
242             self.determine_user()
244             # possibly handle a form submit action (may change self.classname
245             # and self.template, and may also append error/ok_messages)
246             self.handle_action()
248             # now render the page
249             # we don't want clients caching our dynamic pages
250             self.additional_headers['Cache-Control'] = 'no-cache'
251 # Pragma: no-cache makes Mozilla and its ilk double-load all pages!!
252 #            self.additional_headers['Pragma'] = 'no-cache'
254             # expire this page 5 seconds from now
255             date = rfc822.formatdate(time.time() + 5)
256             self.additional_headers['Expires'] = date
258             # render the content
259             self.write(self.renderContext())
260         except Redirect, url:
261             # let's redirect - if the url isn't None, then we need to do
262             # the headers, otherwise the headers have been set before the
263             # exception was raised
264             if url:
265                 self.additional_headers['Location'] = url
266                 self.response_code = 302
267             self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
268         except SendFile, designator:
269             self.serve_file(designator)
270         except SendStaticFile, file:
271             try:
272                 self.serve_static_file(str(file))
273             except NotModified:
274                 # send the 304 response
275                 self.request.send_response(304)
276                 self.request.end_headers()
277         except Unauthorised, message:
278             self.classname = None
279             self.template = ''
280             self.error_message.append(message)
281             self.write(self.renderContext())
282         except NotFound:
283             # pass through
284             raise
285         except:
286             # everything else
287             self.write(cgitb.html())
289     def clean_sessions(self):
290         ''' Age sessions, remove when they haven't been used for a week.
291         
292             Do it only once an hour.
294             Note: also cleans One Time Keys, and other "session" based
295             stuff.
296         '''
297         sessions = self.db.sessions
298         last_clean = sessions.get('last_clean', 'last_use') or 0
300         week = 60*60*24*7
301         hour = 60*60
302         now = time.time()
303         if now - last_clean > hour:
304             # remove aged sessions
305             for sessid in sessions.list():
306                 interval = now - sessions.get(sessid, 'last_use')
307                 if interval > week:
308                     sessions.destroy(sessid)
309             # remove aged otks
310             otks = self.db.otks
311             for sessid in otks.list():
312                 interval = now - otks.get(sessid, '__time')
313                 if interval > week:
314                     otks.destroy(sessid)
315             sessions.set('last_clean', last_use=time.time())
317     def determine_user(self):
318         ''' Determine who the user is
319         '''
320         # 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(_('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(_('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         try:
1223             import csv
1224         except ImportError:
1225             self.error_message.append(_(
1226                 'Sorry, you need the csv module to use this function.<br>\n'
1227                 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
1228             return
1230         cl = self.db.classes[self.classname]
1231         idlessprops = cl.getprops(protected=0).keys()
1232         idlessprops.sort()
1233         props = ['id'] + idlessprops
1235         # do the edit
1236         rows = self.form['rows'].value.splitlines()
1237         p = csv.parser()
1238         found = {}
1239         line = 0
1240         for row in rows[1:]:
1241             line += 1
1242             values = p.parse(row)
1243             # not a complete row, keep going
1244             if not values: continue
1246             # skip property names header
1247             if values == props:
1248                 continue
1250             # extract the nodeid
1251             nodeid, values = values[0], values[1:]
1252             found[nodeid] = 1
1254             # see if the node exists
1255             if cl.hasnode(nodeid):
1256                 exists = 1
1257             else:
1258                 exists = 0
1260             # confirm correct weight
1261             if len(idlessprops) != len(values):
1262                 self.error_message.append(
1263                     _('Not enough values on line %(line)s')%{'line':line})
1264                 return
1266             # extract the new values
1267             d = {}
1268             for name, value in zip(idlessprops, values):
1269                 prop = cl.properties[name]
1270                 value = value.strip()
1271                 # only add the property if it has a value
1272                 if value:
1273                     # if it's a multilink, split it
1274                     if isinstance(prop, hyperdb.Multilink):
1275                         value = value.split(':')
1276                     elif isinstance(prop, hyperdb.Password):
1277                         value = password.Password(value)
1278                     elif isinstance(prop, hyperdb.Interval):
1279                         value = date.Interval(value)
1280                     elif isinstance(prop, hyperdb.Date):
1281                         value = date.Date(value)
1282                     elif isinstance(prop, hyperdb.Boolean):
1283                         value = value.lower() in ('yes', 'true', 'on', '1')
1284                     elif isinstance(prop, hyperdb.Number):
1285                         value = float(value)
1286                     d[name] = value
1287                 elif exists:
1288                     # nuke the existing value
1289                     if isinstance(prop, hyperdb.Multilink):
1290                         d[name] = []
1291                     else:
1292                         d[name] = None
1294             # perform the edit
1295             if exists:
1296                 # edit existing
1297                 cl.set(nodeid, **d)
1298             else:
1299                 # new node
1300                 found[cl.create(**d)] = 1
1302         # retire the removed entries
1303         for nodeid in cl.list():
1304             if not found.has_key(nodeid):
1305                 cl.retire(nodeid)
1307         # all OK
1308         self.db.commit()
1310         self.ok_message.append(_('Items edited OK'))
1312     def editCSVPermission(self):
1313         ''' Determine whether the user has permission to edit this class.
1315             Base behaviour is to check the user can edit this class.
1316         ''' 
1317         if not self.db.security.hasPermission('Edit', self.userid,
1318                 self.classname):
1319             return 0
1320         return 1
1322     def searchAction(self, wcre=re.compile(r'[\s,]+')):
1323         ''' Mangle some of the form variables.
1325             Set the form ":filter" variable based on the values of the
1326             filter variables - if they're set to anything other than
1327             "dontcare" then add them to :filter.
1329             Handle the ":queryname" variable and save off the query to
1330             the user's query list.
1332             Split any String query values on whitespace and comma.
1333         '''
1334         # generic edit is per-class only
1335         if not self.searchPermission():
1336             self.error_message.append(
1337                 _('You do not have permission to search %s' %self.classname))
1339         # add a faked :filter form variable for each filtering prop
1340         props = self.db.classes[self.classname].getprops()
1341         queryname = ''
1342         for key in self.form.keys():
1343             # special vars
1344             if self.FV_QUERYNAME.match(key):
1345                 queryname = self.form[key].value.strip()
1346                 continue
1348             if not props.has_key(key):
1349                 continue
1350             if isinstance(self.form[key], type([])):
1351                 # search for at least one entry which is not empty
1352                 for minifield in self.form[key]:
1353                     if minifield.value:
1354                         break
1355                 else:
1356                     continue
1357             else:
1358                 if not self.form[key].value:
1359                     continue
1360                 if isinstance(props[key], hyperdb.String):
1361                     v = self.form[key].value
1362                     l = token.token_split(v)
1363                     if len(l) > 1 or l[0] != v:
1364                         self.form.value.remove(self.form[key])
1365                         # replace the single value with the split list
1366                         for v in l:
1367                             self.form.value.append(cgi.MiniFieldStorage(key, v))
1369             self.form.value.append(cgi.MiniFieldStorage('@filter', key))
1371         # handle saving the query params
1372         if queryname:
1373             # parse the environment and figure what the query _is_
1374             req = HTMLRequest(self)
1376             # The [1:] strips off the '?' character, it isn't part of the
1377             # query string.
1378             url = req.indexargs_href('', {})[1:]
1380             # handle editing an existing query
1381             try:
1382                 qid = self.db.query.lookup(queryname)
1383                 self.db.query.set(qid, klass=self.classname, url=url)
1384             except KeyError:
1385                 # create a query
1386                 qid = self.db.query.create(name=queryname,
1387                     klass=self.classname, url=url)
1389                 # and add it to the user's query multilink
1390                 queries = self.db.user.get(self.userid, 'queries')
1391                 queries.append(qid)
1392                 self.db.user.set(self.userid, queries=queries)
1394             # commit the query change to the database
1395             self.db.commit()
1397     def searchPermission(self):
1398         ''' Determine whether the user has permission to search this class.
1400             Base behaviour is to check the user can view this class.
1401         ''' 
1402         if not self.db.security.hasPermission('View', self.userid,
1403                 self.classname):
1404             return 0
1405         return 1
1408     def retireAction(self):
1409         ''' Retire the context item.
1410         '''
1411         # if we want to view the index template now, then unset the nodeid
1412         # context info (a special-case for retire actions on the index page)
1413         nodeid = self.nodeid
1414         if self.template == 'index':
1415             self.nodeid = None
1417         # generic edit is per-class only
1418         if not self.retirePermission():
1419             self.error_message.append(
1420                 _('You do not have permission to retire %s' %self.classname))
1421             return
1423         # make sure we don't try to retire admin or anonymous
1424         if self.classname == 'user' and \
1425                 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1426             self.error_message.append(
1427                 _('You may not retire the admin or anonymous user'))
1428             return
1430         # do the retire
1431         self.db.getclass(self.classname).retire(nodeid)
1432         self.db.commit()
1434         self.ok_message.append(
1435             _('%(classname)s %(itemid)s has been retired')%{
1436                 'classname': self.classname.capitalize(), 'itemid': nodeid})
1438     def retirePermission(self):
1439         ''' Determine whether the user has permission to retire this class.
1441             Base behaviour is to check the user can edit this class.
1442         ''' 
1443         if not self.db.security.hasPermission('Edit', self.userid,
1444                 self.classname):
1445             return 0
1446         return 1
1449     def showAction(self, typere=re.compile('[@:]type'),
1450             numre=re.compile('[@:]number')):
1451         ''' Show a node of a particular class/id
1452         '''
1453         t = n = ''
1454         for key in self.form.keys():
1455             if typere.match(key):
1456                 t = self.form[key].value.strip()
1457             elif numre.match(key):
1458                 n = self.form[key].value.strip()
1459         if not t:
1460             raise ValueError, 'Invalid %s number'%t
1461         url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1462         raise Redirect, url
1464     def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1465         ''' Item properties and their values are edited with html FORM
1466             variables and their values. You can:
1468             - Change the value of some property of the current item.
1469             - Create a new item of any class, and edit the new item's
1470               properties,
1471             - Attach newly created items to a multilink property of the
1472               current item.
1473             - Remove items from a multilink property of the current item.
1474             - Specify that some properties are required for the edit
1475               operation to be successful.
1477             In the following, <bracketed> values are variable, "@" may be
1478             either ":" or "@", and other text "required" is fixed.
1480             Most properties are specified as form variables:
1482              <propname>
1483               - property on the current context item
1485              <designator>"@"<propname>
1486               - property on the indicated item (for editing related
1487                 information)
1489             Designators name a specific item of a class.
1491             <classname><N>
1493                 Name an existing item of class <classname>.
1495             <classname>"-"<N>
1497                 Name the <N>th new item of class <classname>. If the form
1498                 submission is successful, a new item of <classname> is
1499                 created. Within the submitted form, a particular
1500                 designator of this form always refers to the same new
1501                 item.
1503             Once we have determined the "propname", we look at it to see
1504             if it's special:
1506             @required
1507                 The associated form value is a comma-separated list of
1508                 property names that must be specified when the form is
1509                 submitted for the edit operation to succeed.  
1511                 When the <designator> is missing, the properties are
1512                 for the current context item.  When <designator> is
1513                 present, they are for the item specified by
1514                 <designator>.
1516                 The "@required" specifier must come before any of the
1517                 properties it refers to are assigned in the form.
1519             @remove@<propname>=id(s) or @add@<propname>=id(s)
1520                 The "@add@" and "@remove@" edit actions apply only to
1521                 Multilink properties.  The form value must be a
1522                 comma-separate list of keys for the class specified by
1523                 the simple form variable.  The listed items are added
1524                 to (respectively, removed from) the specified
1525                 property.
1527             @link@<propname>=<designator>
1528                 If the edit action is "@link@", the simple form
1529                 variable must specify a Link or Multilink property.
1530                 The form value is a comma-separated list of
1531                 designators.  The item corresponding to each
1532                 designator is linked to the property given by simple
1533                 form variable.  These are collected up and returned in
1534                 all_links.
1536             None of the above (ie. just a simple form value)
1537                 The value of the form variable is converted
1538                 appropriately, depending on the type of the property.
1540                 For a Link('klass') property, the form value is a
1541                 single key for 'klass', where the key field is
1542                 specified in dbinit.py.  
1544                 For a Multilink('klass') property, the form value is a
1545                 comma-separated list of keys for 'klass', where the
1546                 key field is specified in dbinit.py.  
1548                 Note that for simple-form-variables specifiying Link
1549                 and Multilink properties, the linked-to class must
1550                 have a key field.
1552                 For a String() property specifying a filename, the
1553                 file named by the form value is uploaded. This means we
1554                 try to set additional properties "filename" and "type" (if
1555                 they are valid for the class).  Otherwise, the property
1556                 is set to the form value.
1558                 For Date(), Interval(), Boolean(), and Number()
1559                 properties, the form value is converted to the
1560                 appropriate
1562             Any of the form variables may be prefixed with a classname or
1563             designator.
1565             Two special form values are supported for backwards
1566             compatibility:
1568             @note
1569                 This is equivalent to::
1571                     @link@messages=msg-1
1572                     @msg-1@content=value
1574                 except that in addition, the "author" and "date"
1575                 properties of "msg-1" are set to the userid of the
1576                 submitter, and the current time, respectively.
1578             @file
1579                 This is equivalent to::
1581                     @link@files=file-1
1582                     @file-1@content=value
1584                 The String content value is handled as described above for
1585                 file uploads.
1587             If both the "@note" and "@file" form variables are
1588             specified, the action::
1590                     @link@msg-1@files=file-1
1592             is also performed.
1594             We also check that FileClass items have a "content" property with
1595             actual content, otherwise we remove them from all_props before
1596             returning.
1598             The return from this method is a dict of 
1599                 (classname, id): properties
1600             ... this dict _always_ has an entry for the current context,
1601             even if it's empty (ie. a submission for an existing issue that
1602             doesn't result in any changes would return {('issue','123'): {}})
1603             The id may be None, which indicates that an item should be
1604             created.
1605         '''
1606         # some very useful variables
1607         db = self.db
1608         form = self.form
1610         if not hasattr(self, 'FV_SPECIAL'):
1611             # generate the regexp for handling special form values
1612             classes = '|'.join(db.classes.keys())
1613             # specials for parsePropsFromForm
1614             # handle the various forms (see unit tests)
1615             self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1616             self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1618         # these indicate the default class / item
1619         default_cn = self.classname
1620         default_cl = self.db.classes[default_cn]
1621         default_nodeid = self.nodeid
1623         # we'll store info about the individual class/item edit in these
1624         all_required = {}       # required props per class/item
1625         all_props = {}          # props to set per class/item
1626         got_props = {}          # props received per class/item
1627         all_propdef = {}        # note - only one entry per class
1628         all_links = []          # as many as are required
1630         # we should always return something, even empty, for the context
1631         all_props[(default_cn, default_nodeid)] = {}
1633         keys = form.keys()
1634         timezone = db.getUserTimezone()
1636         # sentinels for the :note and :file props
1637         have_note = have_file = 0
1639         # extract the usable form labels from the form
1640         matches = []
1641         for key in keys:
1642             m = self.FV_SPECIAL.match(key)
1643             if m:
1644                 matches.append((key, m.groupdict()))
1646         # now handle the matches
1647         for key, d in matches:
1648             if d['classname']:
1649                 # we got a designator
1650                 cn = d['classname']
1651                 cl = self.db.classes[cn]
1652                 nodeid = d['id']
1653                 propname = d['propname']
1654             elif d['note']:
1655                 # the special note field
1656                 cn = 'msg'
1657                 cl = self.db.classes[cn]
1658                 nodeid = '-1'
1659                 propname = 'content'
1660                 all_links.append((default_cn, default_nodeid, 'messages',
1661                     [('msg', '-1')]))
1662                 have_note = 1
1663             elif d['file']:
1664                 # the special file field
1665                 cn = 'file'
1666                 cl = self.db.classes[cn]
1667                 nodeid = '-1'
1668                 propname = 'content'
1669                 all_links.append((default_cn, default_nodeid, 'files',
1670                     [('file', '-1')]))
1671                 have_file = 1
1672             else:
1673                 # default
1674                 cn = default_cn
1675                 cl = default_cl
1676                 nodeid = default_nodeid
1677                 propname = d['propname']
1679             # the thing this value relates to is...
1680             this = (cn, nodeid)
1682             # get more info about the class, and the current set of
1683             # form props for it
1684             if not all_propdef.has_key(cn):
1685                 all_propdef[cn] = cl.getprops()
1686             propdef = all_propdef[cn]
1687             if not all_props.has_key(this):
1688                 all_props[this] = {}
1689             props = all_props[this]
1690             if not got_props.has_key(this):
1691                 got_props[this] = {}
1693             # is this a link command?
1694             if d['link']:
1695                 value = []
1696                 for entry in extractFormList(form[key]):
1697                     m = self.FV_DESIGNATOR.match(entry)
1698                     if not m:
1699                         raise ValueError, \
1700                             'link "%s" value "%s" not a designator'%(key, entry)
1701                     value.append((m.group(1), m.group(2)))
1703                 # make sure the link property is valid
1704                 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1705                         not isinstance(propdef[propname], hyperdb.Link)):
1706                     raise ValueError, '%s %s is not a link or '\
1707                         'multilink property'%(cn, propname)
1709                 all_links.append((cn, nodeid, propname, value))
1710                 continue
1712             # detect the special ":required" variable
1713             if d['required']:
1714                 all_required[this] = extractFormList(form[key])
1715                 continue
1717             # see if we're performing a special multilink action
1718             mlaction = 'set'
1719             if d['remove']:
1720                 mlaction = 'remove'
1721             elif d['add']:
1722                 mlaction = 'add'
1724             # does the property exist?
1725             if not propdef.has_key(propname):
1726                 if mlaction != 'set':
1727                     raise ValueError, 'You have submitted a %s action for'\
1728                         ' the property "%s" which doesn\'t exist'%(mlaction,
1729                         propname)
1730                 # the form element is probably just something we don't care
1731                 # about - ignore it
1732                 continue
1733             proptype = propdef[propname]
1735             # Get the form value. This value may be a MiniFieldStorage or a list
1736             # of MiniFieldStorages.
1737             value = form[key]
1739             # handle unpacking of the MiniFieldStorage / list form value
1740             if isinstance(proptype, hyperdb.Multilink):
1741                 value = extractFormList(value)
1742             else:
1743                 # multiple values are not OK
1744                 if isinstance(value, type([])):
1745                     raise ValueError, 'You have submitted more than one value'\
1746                         ' for the %s property'%propname
1747                 # value might be a file upload...
1748                 if not hasattr(value, 'filename') or value.filename is None:
1749                     # nope, pull out the value and strip it
1750                     value = value.value.strip()
1752             # now that we have the props field, we need a teensy little
1753             # extra bit of help for the old :note field...
1754             if d['note'] and value:
1755                 props['author'] = self.db.getuid()
1756                 props['date'] = date.Date()
1758             # handle by type now
1759             if isinstance(proptype, hyperdb.Password):
1760                 if not value:
1761                     # ignore empty password values
1762                     continue
1763                 for key, d in matches:
1764                     if d['confirm'] and d['propname'] == propname:
1765                         confirm = form[key]
1766                         break
1767                 else:
1768                     raise ValueError, 'Password and confirmation text do '\
1769                         'not match'
1770                 if isinstance(confirm, type([])):
1771                     raise ValueError, 'You have submitted more than one value'\
1772                         ' for the %s property'%propname
1773                 if value != confirm.value:
1774                     raise ValueError, 'Password and confirmation text do '\
1775                         'not match'
1776                 value = password.Password(value)
1778             elif isinstance(proptype, hyperdb.Link):
1779                 # see if it's the "no selection" choice
1780                 if value == '-1' or not value:
1781                     # if we're creating, just don't include this property
1782                     if not nodeid or nodeid.startswith('-'):
1783                         continue
1784                     value = None
1785                 else:
1786                     # handle key values
1787                     link = proptype.classname
1788                     if not num_re.match(value):
1789                         try:
1790                             value = db.classes[link].lookup(value)
1791                         except KeyError:
1792                             raise ValueError, _('property "%(propname)s": '
1793                                 '%(value)s not a %(classname)s')%{
1794                                 'propname': propname, 'value': value,
1795                                 'classname': link}
1796                         except TypeError, message:
1797                             raise ValueError, _('you may only enter ID values '
1798                                 'for property "%(propname)s": %(message)s')%{
1799                                 'propname': propname, 'message': message}
1800             elif isinstance(proptype, hyperdb.Multilink):
1801                 # perform link class key value lookup if necessary
1802                 link = proptype.classname
1803                 link_cl = db.classes[link]
1804                 l = []
1805                 for entry in value:
1806                     if not entry: continue
1807                     if not num_re.match(entry):
1808                         try:
1809                             entry = link_cl.lookup(entry)
1810                         except KeyError:
1811                             raise ValueError, _('property "%(propname)s": '
1812                                 '"%(value)s" not an entry of %(classname)s')%{
1813                                 'propname': propname, 'value': entry,
1814                                 'classname': link}
1815                         except TypeError, message:
1816                             raise ValueError, _('you may only enter ID values '
1817                                 'for property "%(propname)s": %(message)s')%{
1818                                 'propname': propname, 'message': message}
1819                     l.append(entry)
1820                 l.sort()
1822                 # now use that list of ids to modify the multilink
1823                 if mlaction == 'set':
1824                     value = l
1825                 else:
1826                     # we're modifying the list - get the current list of ids
1827                     if props.has_key(propname):
1828                         existing = props[propname]
1829                     elif nodeid and not nodeid.startswith('-'):
1830                         existing = cl.get(nodeid, propname, [])
1831                     else:
1832                         existing = []
1834                     # now either remove or add
1835                     if mlaction == 'remove':
1836                         # remove - handle situation where the id isn't in
1837                         # the list
1838                         for entry in l:
1839                             try:
1840                                 existing.remove(entry)
1841                             except ValueError:
1842                                 raise ValueError, _('property "%(propname)s": '
1843                                     '"%(value)s" not currently in list')%{
1844                                     'propname': propname, 'value': entry}
1845                     else:
1846                         # add - easy, just don't dupe
1847                         for entry in l:
1848                             if entry not in existing:
1849                                 existing.append(entry)
1850                     value = existing
1851                     value.sort()
1853             elif value == '':
1854                 # if we're creating, just don't include this property
1855                 if not nodeid or nodeid.startswith('-'):
1856                     continue
1857                 # other types should be None'd if there's no value
1858                 value = None
1859             else:
1860                 # handle ValueErrors for all these in a similar fashion
1861                 try:
1862                     if isinstance(proptype, hyperdb.String):
1863                         if (hasattr(value, 'filename') and
1864                                 value.filename is not None):
1865                             # skip if the upload is empty
1866                             if not value.filename:
1867                                 continue
1868                             # this String is actually a _file_
1869                             # try to determine the file content-type
1870                             fn = value.filename.split('\\')[-1]
1871                             if propdef.has_key('name'):
1872                                 props['name'] = fn
1873                             # use this info as the type/filename properties
1874                             if propdef.has_key('type'):
1875                                 props['type'] = mimetypes.guess_type(fn)[0]
1876                                 if not props['type']:
1877                                     props['type'] = "application/octet-stream"
1878                             # finally, read the content
1879                             value = value.value
1880                         else:
1881                             # normal String fix the CRLF/CR -> LF stuff
1882                             value = fixNewlines(value)
1884                     elif isinstance(proptype, hyperdb.Date):
1885                         value = date.Date(value, offset=timezone)
1886                     elif isinstance(proptype, hyperdb.Interval):
1887                         value = date.Interval(value)
1888                     elif isinstance(proptype, hyperdb.Boolean):
1889                         value = value.lower() in ('yes', 'true', 'on', '1')
1890                     elif isinstance(proptype, hyperdb.Number):
1891                         value = float(value)
1892                 except ValueError, msg:
1893                     raise ValueError, _('Error with %s property: %s')%(
1894                         propname, msg)
1896             # register that we got this property
1897             if value:
1898                 got_props[this][propname] = 1
1900             # get the old value
1901             if nodeid and not nodeid.startswith('-'):
1902                 try:
1903                     existing = cl.get(nodeid, propname)
1904                 except KeyError:
1905                     # this might be a new property for which there is
1906                     # no existing value
1907                     if not propdef.has_key(propname):
1908                         raise
1910                 # make sure the existing multilink is sorted
1911                 if isinstance(proptype, hyperdb.Multilink):
1912                     existing.sort()
1914                 # "missing" existing values may not be None
1915                 if not existing:
1916                     if isinstance(proptype, hyperdb.String) and not existing:
1917                         # some backends store "missing" Strings as empty strings
1918                         existing = None
1919                     elif isinstance(proptype, hyperdb.Number) and not existing:
1920                         # some backends store "missing" Numbers as 0 :(
1921                         existing = 0
1922                     elif isinstance(proptype, hyperdb.Boolean) and not existing:
1923                         # likewise Booleans
1924                         existing = 0
1926                 # if changed, set it
1927                 if value != existing:
1928                     props[propname] = value
1929             else:
1930                 # don't bother setting empty/unset values
1931                 if value is None:
1932                     continue
1933                 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1934                     continue
1935                 elif isinstance(proptype, hyperdb.String) and value == '':
1936                     continue
1938                 props[propname] = value
1940         # check to see if we need to specially link a file to the note
1941         if have_note and have_file:
1942             all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1944         # see if all the required properties have been supplied
1945         s = []
1946         for thing, required in all_required.items():
1947             # register the values we got
1948             got = got_props.get(thing, {})
1949             for entry in required[:]:
1950                 if got.has_key(entry):
1951                     required.remove(entry)
1953             # any required values not present?
1954             if not required:
1955                 continue
1957             # tell the user to entry the values required
1958             if len(required) > 1:
1959                 p = 'properties'
1960             else:
1961                 p = 'property'
1962             s.append('Required %s %s %s not supplied'%(thing[0], p,
1963                 ', '.join(required)))
1964         if s:
1965             raise ValueError, '\n'.join(s)
1967         # When creating a FileClass node, it should have a non-empty content
1968         # property to be created. When editing a FileClass node, it should
1969         # either have a non-empty content property or no property at all. In
1970         # the latter case, nothing will change.
1971         for (cn, id), props in all_props.items():
1972             if isinstance(self.db.classes[cn], hyperdb.FileClass):
1973                 if id == '-1':
1974                       if not props.get('content', ''):
1975                             del all_props[(cn, id)]
1976                 elif props.has_key('content') and not props['content']:
1977                       raise ValueError, _('File is empty')
1978         return all_props, all_links
1980 def fixNewlines(text):
1981     ''' Homogenise line endings.
1983         Different web clients send different line ending values, but
1984         other systems (eg. email) don't necessarily handle those line
1985         endings. Our solution is to convert all line endings to LF.
1986     '''
1987     text = text.replace('\r\n', '\n')
1988     return text.replace('\r', '\n')
1990 def extractFormList(value):
1991     ''' Extract a list of values from the form value.
1993         It may be one of:
1994          [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
1995          MiniFieldStorage('value,value,...')
1996          MiniFieldStorage('value')
1997     '''
1998     # multiple values are OK
1999     if isinstance(value, type([])):
2000         # it's a list of MiniFieldStorages - join then into
2001         values = ','.join([i.value.strip() for i in value])
2002     else:
2003         # it's a MiniFieldStorage, but may be a comma-separated list
2004         # of values
2005         values = value.value
2007     value = [i.strip() for i in values.split(',')]
2009     # filter out the empty bits
2010     return filter(None, value)