Code

oops, we really do need a database
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.126 2003-07-21 22:56:54 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         file = self.db.file
477         self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
478         self.write(file.get(nodeid, 'content'))
480     def serve_static_file(self, file):
481         ims = None
482         # see if there's an if-modified-since...
483         if hasattr(self.request, 'headers'):
484             ims = self.request.headers.getheader('if-modified-since')
485         elif self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
486             # cgi will put the header in the env var
487             ims = self.env['HTTP_IF_MODIFIED_SINCE']
488         filename = os.path.join(self.instance.config.TEMPLATES, file)
489         lmt = os.stat(filename)[stat.ST_MTIME]
490         if ims:
491             ims = rfc822.parsedate(ims)[:6]
492             lmtt = time.gmtime(lmt)[:6]
493             if lmtt <= ims:
494                 raise NotModified
496         # we just want to serve up the file named
497         file = str(file)
498         mt = mimetypes.guess_type(file)[0]
499         if not mt:
500             if file.endswith('.css'):
501                 mt = 'text/css'
502             else:
503                 mt = 'text/plain'
504         self.additional_headers['Content-Type'] = mt
505         self.additional_headers['Last-Modifed'] = rfc822.formatdate(lmt)
506         self.write(open(filename, 'rb').read())
508     def renderContext(self):
509         ''' Return a PageTemplate for the named page
510         '''
511         name = self.classname
512         extension = self.template
513         pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
515         # catch errors so we can handle PT rendering errors more nicely
516         args = {
517             'ok_message': self.ok_message,
518             'error_message': self.error_message
519         }
520         try:
521             # let the template render figure stuff out
522             return pt.render(self, None, None, **args)
523         except NoTemplate, message:
524             return '<strong>%s</strong>'%message
525         except:
526             # everything else
527             return cgitb.pt_html()
529     # these are the actions that are available
530     actions = (
531         ('edit',     'editItemAction'),
532         ('editcsv',  'editCSVAction'),
533         ('new',      'newItemAction'),
534         ('register', 'registerAction'),
535         ('confrego', 'confRegoAction'),
536         ('passrst',  'passResetAction'),
537         ('login',    'loginAction'),
538         ('logout',   'logout_action'),
539         ('search',   'searchAction'),
540         ('retire',   'retireAction'),
541         ('show',     'showAction'),
542     )
543     def handle_action(self):
544         ''' Determine whether there should be an Action called.
546             The action is defined by the form variable :action which
547             identifies the method on this object to call. The actions
548             are defined in the "actions" sequence on this class.
549         '''
550         if self.form.has_key(':action'):
551             action = self.form[':action'].value.lower()
552         elif self.form.has_key('@action'):
553             action = self.form['@action'].value.lower()
554         else:
555             return None
556         try:
557             # get the action, validate it
558             for name, method in self.actions:
559                 if name == action:
560                     break
561             else:
562                 raise ValueError, 'No such action "%s"'%action
563             # call the mapped action
564             getattr(self, method)()
565         except Redirect:
566             raise
567         except Unauthorised:
568             raise
570     def write(self, content):
571         if not self.headers_done:
572             self.header()
573         self.request.wfile.write(content)
575     def header(self, headers=None, response=None):
576         '''Put up the appropriate header.
577         '''
578         if headers is None:
579             headers = {'Content-Type':'text/html'}
580         if response is None:
581             response = self.response_code
583         # update with additional info
584         headers.update(self.additional_headers)
586         if not headers.has_key('Content-Type'):
587             headers['Content-Type'] = 'text/html'
588         self.request.send_response(response)
589         for entry in headers.items():
590             self.request.send_header(*entry)
591         self.request.end_headers()
592         self.headers_done = 1
593         if self.debug:
594             self.headers_sent = headers
596     def set_cookie(self, user):
597         ''' Set up a session cookie for the user and store away the user's
598             login info against the session.
599         '''
600         # TODO generate a much, much stronger session key ;)
601         self.session = binascii.b2a_base64(repr(random.random())).strip()
603         # clean up the base64
604         if self.session[-1] == '=':
605             if self.session[-2] == '=':
606                 self.session = self.session[:-2]
607             else:
608                 self.session = self.session[:-1]
610         # insert the session in the sessiondb
611         self.db.sessions.set(self.session, user=user, last_use=time.time())
613         # and commit immediately
614         self.db.sessions.commit()
616         # expire us in a long, long time
617         expire = Cookie._getdate(86400*365)
619         # generate the cookie path - make sure it has a trailing '/'
620         self.additional_headers['Set-Cookie'] = \
621           '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
622             expire, self.cookie_path)
624     def make_user_anonymous(self):
625         ''' Make us anonymous
627             This method used to handle non-existence of the 'anonymous'
628             user, but that user is mandatory now.
629         '''
630         self.userid = self.db.user.lookup('anonymous')
631         self.user = 'anonymous'
633     def opendb(self, user):
634         ''' Open the database.
635         '''
636         # open the db if the user has changed
637         if not hasattr(self, 'db') or user != self.db.journaltag:
638             if hasattr(self, 'db'):
639                 self.db.close()
640             self.db = self.instance.open(user)
642     #
643     # Actions
644     #
645     def loginAction(self):
646         ''' Attempt to log a user in.
648             Sets up a session for the user which contains the login
649             credentials.
650         '''
651         # we need the username at a minimum
652         if not self.form.has_key('__login_name'):
653             self.error_message.append(_('Username required'))
654             return
656         # get the login info
657         self.user = self.form['__login_name'].value
658         if self.form.has_key('__login_password'):
659             password = self.form['__login_password'].value
660         else:
661             password = ''
663         # make sure the user exists
664         try:
665             self.userid = self.db.user.lookup(self.user)
666         except KeyError:
667             name = self.user
668             self.error_message.append(_('No such user "%(name)s"')%locals())
669             self.make_user_anonymous()
670             return
672         # verify the password
673         if not self.verifyPassword(self.userid, password):
674             self.make_user_anonymous()
675             self.error_message.append(_('Incorrect password'))
676             return
678         # make sure we're allowed to be here
679         if not self.loginPermission():
680             self.make_user_anonymous()
681             self.error_message.append(_("You do not have permission to login"))
682             return
684         # now we're OK, re-open the database for real, using the user
685         self.opendb(self.user)
687         # set the session cookie
688         self.set_cookie(self.user)
690     def verifyPassword(self, userid, password):
691         ''' Verify the password that the user has supplied
692         '''
693         stored = self.db.user.get(self.userid, 'password')
694         if password == stored:
695             return 1
696         if not password and not stored:
697             return 1
698         return 0
700     def loginPermission(self):
701         ''' Determine whether the user has permission to log in.
703             Base behaviour is to check the user has "Web Access".
704         ''' 
705         if not self.db.security.hasPermission('Web Access', self.userid):
706             return 0
707         return 1
709     def logout_action(self):
710         ''' Make us really anonymous - nuke the cookie too
711         '''
712         # log us out
713         self.make_user_anonymous()
715         # construct the logout cookie
716         now = Cookie._getdate()
717         self.additional_headers['Set-Cookie'] = \
718            '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.cookie_name,
719             now, self.cookie_path)
721         # Let the user know what's going on
722         self.ok_message.append(_('You are logged out'))
724     def registerAction(self):
725         '''Attempt to create a new user based on the contents of the form
726         and then set the cookie.
728         return 1 on successful login
729         '''
730         # parse the props from the form
731         try:
732             props = self.parsePropsFromForm()[0][('user', None)]
733         except (ValueError, KeyError), message:
734             self.error_message.append(_('Error: ') + str(message))
735             return
737         # make sure we're allowed to register
738         if not self.registerPermission(props):
739             raise Unauthorised, _("You do not have permission to register")
741         try:
742             self.db.user.lookup(props['username'])
743             self.error_message.append('Error: A user with the username "%s" '
744                 'already exists'%props['username'])
745             return
746         except KeyError:
747             pass
749         # generate the one-time-key and store the props for later
750         otk = ''.join([random.choice(chars) for x in range(32)])
751         for propname, proptype in self.db.user.getprops().items():
752             value = props.get(propname, None)
753             if value is None:
754                 pass
755             elif isinstance(proptype, hyperdb.Date):
756                 props[propname] = str(value)
757             elif isinstance(proptype, hyperdb.Interval):
758                 props[propname] = str(value)
759             elif isinstance(proptype, hyperdb.Password):
760                 props[propname] = str(value)
761         props['__time'] = time.time()
762         self.db.otks.set(otk, **props)
764         # send the email
765         tracker_name = self.db.config.TRACKER_NAME
766         subject = 'Complete your registration to %s'%tracker_name
767         body = '''
768 To complete your registration of the user "%(name)s" with %(tracker)s,
769 please visit the following URL:
771    %(url)s?@action=confrego&otk=%(otk)s
772 '''%{'name': props['username'], 'tracker': tracker_name, 'url': self.base,
773                 'otk': otk}
774         if not self.sendEmail(props['address'], subject, body):
775             return
777         # commit changes to the database
778         self.db.commit()
780         # redirect to the "you're almost there" page
781         raise Redirect, '%suser?@template=rego_progress'%self.base
783     def sendEmail(self, to, subject, content):
784         # send email to the user's email address
785         message = StringIO.StringIO()
786         writer = MimeWriter.MimeWriter(message)
787         tracker_name = self.db.config.TRACKER_NAME
788         writer.addheader('Subject', encode_header(subject))
789         writer.addheader('To', to)
790         writer.addheader('From', roundupdb.straddr((tracker_name,
791             self.db.config.ADMIN_EMAIL)))
792         writer.addheader('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000",
793             time.gmtime()))
794         # add a uniquely Roundup header to help filtering
795         writer.addheader('X-Roundup-Name', tracker_name)
796         # avoid email loops
797         writer.addheader('X-Roundup-Loop', 'hello')
798         writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
799         body = writer.startbody('text/plain; charset=utf-8')
801         # message body, encoded quoted-printable
802         content = StringIO.StringIO(content)
803         quopri.encode(content, body, 0)
805         if SENDMAILDEBUG:
806             # don't send - just write to a file
807             open(SENDMAILDEBUG, 'a').write('FROM: %s\nTO: %s\n%s\n'%(
808                 self.db.config.ADMIN_EMAIL,
809                 ', '.join(to),message.getvalue()))
810         else:
811             # now try to send the message
812             try:
813                 # send the message as admin so bounces are sent there
814                 # instead of to roundup
815                 smtp = openSMTPConnection(self.db.config)
816                 smtp.sendmail(self.db.config.ADMIN_EMAIL, [to],
817                     message.getvalue())
818             except socket.error, value:
819                 self.error_message.append("Error: couldn't send email: "
820                     "mailhost %s"%value)
821                 return 0
822             except smtplib.SMTPException, msg:
823                 self.error_message.append("Error: couldn't send email: %s"%msg)
824                 return 0
825         return 1
827     def registerPermission(self, props):
828         ''' Determine whether the user has permission to register
830             Base behaviour is to check the user has "Web Registration".
831         '''
832         # registration isn't allowed to supply roles
833         if props.has_key('roles'):
834             return 0
835         if self.db.security.hasPermission('Web Registration', self.userid):
836             return 1
837         return 0
839     def confRegoAction(self):
840         ''' Grab the OTK, use it to load up the new user details
841         '''
842         # pull the rego information out of the otk database
843         otk = self.form['otk'].value
844         props = self.db.otks.getall(otk)
845         for propname, proptype in self.db.user.getprops().items():
846             value = props.get(propname, None)
847             if value is None:
848                 pass
849             elif isinstance(proptype, hyperdb.Date):
850                 props[propname] = date.Date(value)
851             elif isinstance(proptype, hyperdb.Interval):
852                 props[propname] = date.Interval(value)
853             elif isinstance(proptype, hyperdb.Password):
854                 props[propname] = password.Password()
855                 props[propname].unpack(value)
857         # re-open the database as "admin"
858         if self.user != 'admin':
859             self.opendb('admin')
861         # create the new user
862         cl = self.db.user
863 # XXX we need to make the "default" page be able to display errors!
864         try:
865             props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
866             del props['__time']
867             self.userid = cl.create(**props)
868             # clear the props from the otk database
869             self.db.otks.destroy(otk)
870             self.db.commit()
871         except (ValueError, KeyError), message:
872             self.error_message.append(str(message))
873             return
875         # log the new user in
876         self.user = cl.get(self.userid, 'username')
877         # re-open the database for real, using the user
878         self.opendb(self.user)
880         # if we have a session, update it
881         if hasattr(self, 'session'):
882             self.db.sessions.set(self.session, user=self.user,
883                 last_use=time.time())
884         else:
885             # new session cookie
886             self.set_cookie(self.user)
888         # nice message
889         message = _('You are now registered, welcome!')
891         # redirect to the user's page
892         raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
893             self.userid, urllib.quote(message))
895     def passResetAction(self):
896         ''' Handle password reset requests.
898             Presence of either "name" or "address" generate email.
899             Presense of "otk" performs the reset.
900         '''
901         if self.form.has_key('otk'):
902             # pull the rego information out of the otk database
903             otk = self.form['otk'].value
904             uid = self.db.otks.get(otk, 'uid')
905             if uid is None:
906                 self.error_message.append('Invalid One Time Key!')
907                 return
909             # re-open the database as "admin"
910             if self.user != 'admin':
911                 self.opendb('admin')
913             # change the password
914             newpw = password.generatePassword()
916             cl = self.db.user
917 # XXX we need to make the "default" page be able to display errors!
918             try:
919                 # set the password
920                 cl.set(uid, password=password.Password(newpw))
921                 # clear the props from the otk database
922                 self.db.otks.destroy(otk)
923                 self.db.commit()
924             except (ValueError, KeyError), message:
925                 self.error_message.append(str(message))
926                 return
928             # user info
929             address = self.db.user.get(uid, 'address')
930             name = self.db.user.get(uid, 'username')
932             # send the email
933             tracker_name = self.db.config.TRACKER_NAME
934             subject = 'Password reset for %s'%tracker_name
935             body = '''
936 The password has been reset for username "%(name)s".
938 Your password is now: %(password)s
939 '''%{'name': name, 'password': newpw}
940             if not self.sendEmail(address, subject, body):
941                 return
943             self.ok_message.append('Password reset and email sent to %s'%address)
944             return
946         # no OTK, so now figure the user
947         if self.form.has_key('username'):
948             name = self.form['username'].value
949             try:
950                 uid = self.db.user.lookup(name)
951             except KeyError:
952                 self.error_message.append('Unknown username')
953                 return
954             address = self.db.user.get(uid, 'address')
955         elif self.form.has_key('address'):
956             address = self.form['address'].value
957             uid = uidFromAddress(self.db, ('', address), create=0)
958             if not uid:
959                 self.error_message.append('Unknown email address')
960                 return
961             name = self.db.user.get(uid, 'username')
962         else:
963             self.error_message.append('You need to specify a username '
964                 'or address')
965             return
967         # generate the one-time-key and store the props for later
968         otk = ''.join([random.choice(chars) for x in range(32)])
969         self.db.otks.set(otk, uid=uid, __time=time.time())
971         # send the email
972         tracker_name = self.db.config.TRACKER_NAME
973         subject = 'Confirm reset of password for %s'%tracker_name
974         body = '''
975 Someone, perhaps you, has requested that the password be changed for your
976 username, "%(name)s". If you wish to proceed with the change, please follow
977 the link below:
979   %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
981 You should then receive another email with the new password.
982 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
983         if not self.sendEmail(address, subject, body):
984             return
986         self.ok_message.append('Email sent to %s'%address)
988     def editItemAction(self):
989         ''' Perform an edit of an item in the database.
991            See parsePropsFromForm and _editnodes for special variables
992         '''
993         # parse the props from the form
994         try:
995             props, links = self.parsePropsFromForm()
996         except (ValueError, KeyError), message:
997             self.error_message.append(_('Error: ') + str(message))
998             return
1000         # handle the props
1001         try:
1002             message = self._editnodes(props, links)
1003         except (ValueError, KeyError, IndexError), message:
1004             self.error_message.append(_('Error: ') + str(message))
1005             return
1007         # commit now that all the tricky stuff is done
1008         self.db.commit()
1010         # redirect to the item's edit page
1011         raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
1012             self.classname, self.nodeid, urllib.quote(message),
1013             urllib.quote(self.template))
1015     def editItemPermission(self, props):
1016         ''' Determine whether the user has permission to edit this item.
1018             Base behaviour is to check the user can edit this class. If we're
1019             editing the "user" class, users are allowed to edit their own
1020             details. Unless it's the "roles" property, which requires the
1021             special Permission "Web Roles".
1022         '''
1023         # if this is a user node and the user is editing their own node, then
1024         # we're OK
1025         has = self.db.security.hasPermission
1026         if self.classname == 'user':
1027             # reject if someone's trying to edit "roles" and doesn't have the
1028             # right permission.
1029             if props.has_key('roles') and not has('Web Roles', self.userid,
1030                     'user'):
1031                 return 0
1032             # if the item being edited is the current user, we're ok
1033             if self.nodeid == self.userid:
1034                 return 1
1035         if self.db.security.hasPermission('Edit', self.userid, self.classname):
1036             return 1
1037         return 0
1039     def newItemAction(self):
1040         ''' Add a new item to the database.
1042             This follows the same form as the editItemAction, with the same
1043             special form values.
1044         '''
1045         # parse the props from the form
1046         try:
1047             props, links = self.parsePropsFromForm()
1048         except (ValueError, KeyError), message:
1049             self.error_message.append(_('Error: ') + str(message))
1050             return
1052         # handle the props - edit or create
1053         try:
1054             # when it hits the None element, it'll set self.nodeid
1055             messages = self._editnodes(props, links)
1057         except (ValueError, KeyError, IndexError), message:
1058             # these errors might just be indicative of user dumbness
1059             self.error_message.append(_('Error: ') + str(message))
1060             return
1062         # commit now that all the tricky stuff is done
1063         self.db.commit()
1065         # redirect to the new item's page
1066         raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
1067             self.classname, self.nodeid, urllib.quote(messages),
1068             urllib.quote(self.template))
1070     def newItemPermission(self, props):
1071         ''' Determine whether the user has permission to create (edit) this
1072             item.
1074             Base behaviour is to check the user can edit this class. No
1075             additional property checks are made. Additionally, new user items
1076             may be created if the user has the "Web Registration" Permission.
1077         '''
1078         has = self.db.security.hasPermission
1079         if self.classname == 'user' and has('Web Registration', self.userid,
1080                 'user'):
1081             return 1
1082         if has('Edit', self.userid, self.classname):
1083             return 1
1084         return 0
1087     #
1088     #  Utility methods for editing
1089     #
1090     def _editnodes(self, all_props, all_links, newids=None):
1091         ''' Use the props in all_props to perform edit and creation, then
1092             use the link specs in all_links to do linking.
1093         '''
1094         # figure dependencies and re-work links
1095         deps = {}
1096         links = {}
1097         for cn, nodeid, propname, vlist in all_links:
1098             if not all_props.has_key((cn, nodeid)):
1099                 # link item to link to doesn't (and won't) exist
1100                 continue
1101             for value in vlist:
1102                 if not all_props.has_key(value):
1103                     # link item to link to doesn't (and won't) exist
1104                     continue
1105                 deps.setdefault((cn, nodeid), []).append(value)
1106                 links.setdefault(value, []).append((cn, nodeid, propname))
1108         # figure chained dependencies ordering
1109         order = []
1110         done = {}
1111         # loop detection
1112         change = 0
1113         while len(all_props) != len(done):
1114             for needed in all_props.keys():
1115                 if done.has_key(needed):
1116                     continue
1117                 tlist = deps.get(needed, [])
1118                 for target in tlist:
1119                     if not done.has_key(target):
1120                         break
1121                 else:
1122                     done[needed] = 1
1123                     order.append(needed)
1124                     change = 1
1125             if not change:
1126                 raise ValueError, 'linking must not loop!'
1128         # now, edit / create
1129         m = []
1130         for needed in order:
1131             props = all_props[needed]
1132             if not props:
1133                 # nothing to do
1134                 continue
1135             cn, nodeid = needed
1137             if nodeid is not None and int(nodeid) > 0:
1138                 # make changes to the node
1139                 props = self._changenode(cn, nodeid, props)
1141                 # and some nice feedback for the user
1142                 if props:
1143                     info = ', '.join(props.keys())
1144                     m.append('%s %s %s edited ok'%(cn, nodeid, info))
1145                 else:
1146                     m.append('%s %s - nothing changed'%(cn, nodeid))
1147             else:
1148                 assert props
1150                 # make a new node
1151                 newid = self._createnode(cn, props)
1152                 if nodeid is None:
1153                     self.nodeid = newid
1154                 nodeid = newid
1156                 # and some nice feedback for the user
1157                 m.append('%s %s created'%(cn, newid))
1159             # fill in new ids in links
1160             if links.has_key(needed):
1161                 for linkcn, linkid, linkprop in links[needed]:
1162                     props = all_props[(linkcn, linkid)]
1163                     cl = self.db.classes[linkcn]
1164                     propdef = cl.getprops()[linkprop]
1165                     if not props.has_key(linkprop):
1166                         if linkid is None or linkid.startswith('-'):
1167                             # linking to a new item
1168                             if isinstance(propdef, hyperdb.Multilink):
1169                                 props[linkprop] = [newid]
1170                             else:
1171                                 props[linkprop] = newid
1172                         else:
1173                             # linking to an existing item
1174                             if isinstance(propdef, hyperdb.Multilink):
1175                                 existing = cl.get(linkid, linkprop)[:]
1176                                 existing.append(nodeid)
1177                                 props[linkprop] = existing
1178                             else:
1179                                 props[linkprop] = newid
1181         return '<br>'.join(m)
1183     def _changenode(self, cn, nodeid, props):
1184         ''' change the node based on the contents of the form
1185         '''
1186         # check for permission
1187         if not self.editItemPermission(props):
1188             raise Unauthorised, 'You do not have permission to edit %s'%cn
1190         # make the changes
1191         cl = self.db.classes[cn]
1192         return cl.set(nodeid, **props)
1194     def _createnode(self, cn, props):
1195         ''' create a node based on the contents of the form
1196         '''
1197         # check for permission
1198         if not self.newItemPermission(props):
1199             raise Unauthorised, 'You do not have permission to create %s'%cn
1201         # create the node and return its id
1202         cl = self.db.classes[cn]
1203         return cl.create(**props)
1205     # 
1206     # More actions
1207     #
1208     def editCSVAction(self):
1209         ''' Performs an edit of all of a class' items in one go.
1211             The "rows" CGI var defines the CSV-formatted entries for the
1212             class. New nodes are identified by the ID 'X' (or any other
1213             non-existent ID) and removed lines are retired.
1214         '''
1215         # this is per-class only
1216         if not self.editCSVPermission():
1217             self.error_message.append(
1218                 _('You do not have permission to edit %s' %self.classname))
1220         # get the CSV module
1221         try:
1222             import csv
1223         except ImportError:
1224             self.error_message.append(_(
1225                 'Sorry, you need the csv module to use this function.<br>\n'
1226                 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
1227             return
1229         cl = self.db.classes[self.classname]
1230         idlessprops = cl.getprops(protected=0).keys()
1231         idlessprops.sort()
1232         props = ['id'] + idlessprops
1234         # do the edit
1235         rows = self.form['rows'].value.splitlines()
1236         p = csv.parser()
1237         found = {}
1238         line = 0
1239         for row in rows[1:]:
1240             line += 1
1241             values = p.parse(row)
1242             # not a complete row, keep going
1243             if not values: continue
1245             # skip property names header
1246             if values == props:
1247                 continue
1249             # extract the nodeid
1250             nodeid, values = values[0], values[1:]
1251             found[nodeid] = 1
1253             # see if the node exists
1254             if cl.hasnode(nodeid):
1255                 exists = 1
1256             else:
1257                 exists = 0
1259             # confirm correct weight
1260             if len(idlessprops) != len(values):
1261                 self.error_message.append(
1262                     _('Not enough values on line %(line)s')%{'line':line})
1263                 return
1265             # extract the new values
1266             d = {}
1267             for name, value in zip(idlessprops, values):
1268                 prop = cl.properties[name]
1269                 value = value.strip()
1270                 # only add the property if it has a value
1271                 if value:
1272                     # if it's a multilink, split it
1273                     if isinstance(prop, hyperdb.Multilink):
1274                         value = value.split(':')
1275                     d[name] = value
1276                 elif exists:
1277                     # nuke the existing value
1278                     if isinstance(prop, hyperdb.Multilink):
1279                         d[name] = []
1280                     else:
1281                         d[name] = None
1283             # perform the edit
1284             if exists:
1285                 # edit existing
1286                 cl.set(nodeid, **d)
1287             else:
1288                 # new node
1289                 found[cl.create(**d)] = 1
1291         # retire the removed entries
1292         for nodeid in cl.list():
1293             if not found.has_key(nodeid):
1294                 cl.retire(nodeid)
1296         # all OK
1297         self.db.commit()
1299         self.ok_message.append(_('Items edited OK'))
1301     def editCSVPermission(self):
1302         ''' Determine whether the user has permission to edit this class.
1304             Base behaviour is to check the user can edit this class.
1305         ''' 
1306         if not self.db.security.hasPermission('Edit', self.userid,
1307                 self.classname):
1308             return 0
1309         return 1
1311     def searchAction(self, wcre=re.compile(r'[\s,]+')):
1312         ''' Mangle some of the form variables.
1314             Set the form ":filter" variable based on the values of the
1315             filter variables - if they're set to anything other than
1316             "dontcare" then add them to :filter.
1318             Handle the ":queryname" variable and save off the query to
1319             the user's query list.
1321             Split any String query values on whitespace and comma.
1322         '''
1323         # generic edit is per-class only
1324         if not self.searchPermission():
1325             self.error_message.append(
1326                 _('You do not have permission to search %s' %self.classname))
1328         # add a faked :filter form variable for each filtering prop
1329         props = self.db.classes[self.classname].getprops()
1330         queryname = ''
1331         for key in self.form.keys():
1332             # special vars
1333             if self.FV_QUERYNAME.match(key):
1334                 queryname = self.form[key].value.strip()
1335                 continue
1337             if not props.has_key(key):
1338                 continue
1339             if isinstance(self.form[key], type([])):
1340                 # search for at least one entry which is not empty
1341                 for minifield in self.form[key]:
1342                     if minifield.value:
1343                         break
1344                 else:
1345                     continue
1346             else:
1347                 if not self.form[key].value:
1348                     continue
1349                 if isinstance(props[key], hyperdb.String):
1350                     v = self.form[key].value
1351                     l = token.token_split(v)
1352                     if len(l) > 1 or l[0] != v:
1353                         self.form.value.remove(self.form[key])
1354                         # replace the single value with the split list
1355                         for v in l:
1356                             self.form.value.append(cgi.MiniFieldStorage(key, v))
1358             self.form.value.append(cgi.MiniFieldStorage('@filter', key))
1360         # handle saving the query params
1361         if queryname:
1362             # parse the environment and figure what the query _is_
1363             req = HTMLRequest(self)
1364             url = req.indexargs_href('', {})
1366             # handle editing an existing query
1367             try:
1368                 qid = self.db.query.lookup(queryname)
1369                 self.db.query.set(qid, klass=self.classname, url=url)
1370             except KeyError:
1371                 # create a query
1372                 qid = self.db.query.create(name=queryname,
1373                     klass=self.classname, url=url)
1375                 # and add it to the user's query multilink
1376                 queries = self.db.user.get(self.userid, 'queries')
1377                 queries.append(qid)
1378                 self.db.user.set(self.userid, queries=queries)
1380             # commit the query change to the database
1381             self.db.commit()
1383     def searchPermission(self):
1384         ''' Determine whether the user has permission to search this class.
1386             Base behaviour is to check the user can view this class.
1387         ''' 
1388         if not self.db.security.hasPermission('View', self.userid,
1389                 self.classname):
1390             return 0
1391         return 1
1394     def retireAction(self):
1395         ''' Retire the context item.
1396         '''
1397         # if we want to view the index template now, then unset the nodeid
1398         # context info (a special-case for retire actions on the index page)
1399         nodeid = self.nodeid
1400         if self.template == 'index':
1401             self.nodeid = None
1403         # generic edit is per-class only
1404         if not self.retirePermission():
1405             self.error_message.append(
1406                 _('You do not have permission to retire %s' %self.classname))
1407             return
1409         # make sure we don't try to retire admin or anonymous
1410         if self.classname == 'user' and \
1411                 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1412             self.error_message.append(
1413                 _('You may not retire the admin or anonymous user'))
1414             return
1416         # do the retire
1417         self.db.getclass(self.classname).retire(nodeid)
1418         self.db.commit()
1420         self.ok_message.append(
1421             _('%(classname)s %(itemid)s has been retired')%{
1422                 'classname': self.classname.capitalize(), 'itemid': nodeid})
1424     def retirePermission(self):
1425         ''' Determine whether the user has permission to retire this class.
1427             Base behaviour is to check the user can edit this class.
1428         ''' 
1429         if not self.db.security.hasPermission('Edit', self.userid,
1430                 self.classname):
1431             return 0
1432         return 1
1435     def showAction(self, typere=re.compile('[@:]type'),
1436             numre=re.compile('[@:]number')):
1437         ''' Show a node of a particular class/id
1438         '''
1439         t = n = ''
1440         for key in self.form.keys():
1441             if typere.match(key):
1442                 t = self.form[key].value.strip()
1443             elif numre.match(key):
1444                 n = self.form[key].value.strip()
1445         if not t:
1446             raise ValueError, 'Invalid %s number'%t
1447         url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1448         raise Redirect, url
1450     def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1451         ''' Item properties and their values are edited with html FORM
1452             variables and their values. You can:
1454             - Change the value of some property of the current item.
1455             - Create a new item of any class, and edit the new item's
1456               properties,
1457             - Attach newly created items to a multilink property of the
1458               current item.
1459             - Remove items from a multilink property of the current item.
1460             - Specify that some properties are required for the edit
1461               operation to be successful.
1463             In the following, <bracketed> values are variable, "@" may be
1464             either ":" or "@", and other text "required" is fixed.
1466             Most properties are specified as form variables:
1468              <propname>
1469               - property on the current context item
1471              <designator>"@"<propname>
1472               - property on the indicated item (for editing related
1473                 information)
1475             Designators name a specific item of a class.
1477             <classname><N>
1479                 Name an existing item of class <classname>.
1481             <classname>"-"<N>
1483                 Name the <N>th new item of class <classname>. If the form
1484                 submission is successful, a new item of <classname> is
1485                 created. Within the submitted form, a particular
1486                 designator of this form always refers to the same new
1487                 item.
1489             Once we have determined the "propname", we look at it to see
1490             if it's special:
1492             @required
1493                 The associated form value is a comma-separated list of
1494                 property names that must be specified when the form is
1495                 submitted for the edit operation to succeed.  
1497                 When the <designator> is missing, the properties are
1498                 for the current context item.  When <designator> is
1499                 present, they are for the item specified by
1500                 <designator>.
1502                 The "@required" specifier must come before any of the
1503                 properties it refers to are assigned in the form.
1505             @remove@<propname>=id(s) or @add@<propname>=id(s)
1506                 The "@add@" and "@remove@" edit actions apply only to
1507                 Multilink properties.  The form value must be a
1508                 comma-separate list of keys for the class specified by
1509                 the simple form variable.  The listed items are added
1510                 to (respectively, removed from) the specified
1511                 property.
1513             @link@<propname>=<designator>
1514                 If the edit action is "@link@", the simple form
1515                 variable must specify a Link or Multilink property.
1516                 The form value is a comma-separated list of
1517                 designators.  The item corresponding to each
1518                 designator is linked to the property given by simple
1519                 form variable.  These are collected up and returned in
1520                 all_links.
1522             None of the above (ie. just a simple form value)
1523                 The value of the form variable is converted
1524                 appropriately, depending on the type of the property.
1526                 For a Link('klass') property, the form value is a
1527                 single key for 'klass', where the key field is
1528                 specified in dbinit.py.  
1530                 For a Multilink('klass') property, the form value is a
1531                 comma-separated list of keys for 'klass', where the
1532                 key field is specified in dbinit.py.  
1534                 Note that for simple-form-variables specifiying Link
1535                 and Multilink properties, the linked-to class must
1536                 have a key field.
1538                 For a String() property specifying a filename, the
1539                 file named by the form value is uploaded. This means we
1540                 try to set additional properties "filename" and "type" (if
1541                 they are valid for the class).  Otherwise, the property
1542                 is set to the form value.
1544                 For Date(), Interval(), Boolean(), and Number()
1545                 properties, the form value is converted to the
1546                 appropriate
1548             Any of the form variables may be prefixed with a classname or
1549             designator.
1551             Two special form values are supported for backwards
1552             compatibility:
1554             @note
1555                 This is equivalent to::
1557                     @link@messages=msg-1
1558                     @msg-1@content=value
1560                 except that in addition, the "author" and "date"
1561                 properties of "msg-1" are set to the userid of the
1562                 submitter, and the current time, respectively.
1564             @file
1565                 This is equivalent to::
1567                     @link@files=file-1
1568                     @file-1@content=value
1570                 The String content value is handled as described above for
1571                 file uploads.
1573             If both the "@note" and "@file" form variables are
1574             specified, the action::
1576                     @link@msg-1@files=file-1
1578             is also performed.
1580             We also check that FileClass items have a "content" property with
1581             actual content, otherwise we remove them from all_props before
1582             returning.
1584             The return from this method is a dict of 
1585                 (classname, id): properties
1586             ... this dict _always_ has an entry for the current context,
1587             even if it's empty (ie. a submission for an existing issue that
1588             doesn't result in any changes would return {('issue','123'): {}})
1589             The id may be None, which indicates that an item should be
1590             created.
1591         '''
1592         # some very useful variables
1593         db = self.db
1594         form = self.form
1596         if not hasattr(self, 'FV_SPECIAL'):
1597             # generate the regexp for handling special form values
1598             classes = '|'.join(db.classes.keys())
1599             # specials for parsePropsFromForm
1600             # handle the various forms (see unit tests)
1601             self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1602             self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1604         # these indicate the default class / item
1605         default_cn = self.classname
1606         default_cl = self.db.classes[default_cn]
1607         default_nodeid = self.nodeid
1609         # we'll store info about the individual class/item edit in these
1610         all_required = {}       # required props per class/item
1611         all_props = {}          # props to set per class/item
1612         got_props = {}          # props received per class/item
1613         all_propdef = {}        # note - only one entry per class
1614         all_links = []          # as many as are required
1616         # we should always return something, even empty, for the context
1617         all_props[(default_cn, default_nodeid)] = {}
1619         keys = form.keys()
1620         timezone = db.getUserTimezone()
1622         # sentinels for the :note and :file props
1623         have_note = have_file = 0
1625         # extract the usable form labels from the form
1626         matches = []
1627         for key in keys:
1628             m = self.FV_SPECIAL.match(key)
1629             if m:
1630                 matches.append((key, m.groupdict()))
1632         # now handle the matches
1633         for key, d in matches:
1634             if d['classname']:
1635                 # we got a designator
1636                 cn = d['classname']
1637                 cl = self.db.classes[cn]
1638                 nodeid = d['id']
1639                 propname = d['propname']
1640             elif d['note']:
1641                 # the special note field
1642                 cn = 'msg'
1643                 cl = self.db.classes[cn]
1644                 nodeid = '-1'
1645                 propname = 'content'
1646                 all_links.append((default_cn, default_nodeid, 'messages',
1647                     [('msg', '-1')]))
1648                 have_note = 1
1649             elif d['file']:
1650                 # the special file field
1651                 cn = 'file'
1652                 cl = self.db.classes[cn]
1653                 nodeid = '-1'
1654                 propname = 'content'
1655                 all_links.append((default_cn, default_nodeid, 'files',
1656                     [('file', '-1')]))
1657                 have_file = 1
1658             else:
1659                 # default
1660                 cn = default_cn
1661                 cl = default_cl
1662                 nodeid = default_nodeid
1663                 propname = d['propname']
1665             # the thing this value relates to is...
1666             this = (cn, nodeid)
1668             # get more info about the class, and the current set of
1669             # form props for it
1670             if not all_propdef.has_key(cn):
1671                 all_propdef[cn] = cl.getprops()
1672             propdef = all_propdef[cn]
1673             if not all_props.has_key(this):
1674                 all_props[this] = {}
1675             props = all_props[this]
1676             if not got_props.has_key(this):
1677                 got_props[this] = {}
1679             # is this a link command?
1680             if d['link']:
1681                 value = []
1682                 for entry in extractFormList(form[key]):
1683                     m = self.FV_DESIGNATOR.match(entry)
1684                     if not m:
1685                         raise ValueError, \
1686                             'link "%s" value "%s" not a designator'%(key, entry)
1687                     value.append((m.group(1), m.group(2)))
1689                 # make sure the link property is valid
1690                 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1691                         not isinstance(propdef[propname], hyperdb.Link)):
1692                     raise ValueError, '%s %s is not a link or '\
1693                         'multilink property'%(cn, propname)
1695                 all_links.append((cn, nodeid, propname, value))
1696                 continue
1698             # detect the special ":required" variable
1699             if d['required']:
1700                 all_required[this] = extractFormList(form[key])
1701                 continue
1703             # see if we're performing a special multilink action
1704             mlaction = 'set'
1705             if d['remove']:
1706                 mlaction = 'remove'
1707             elif d['add']:
1708                 mlaction = 'add'
1710             # does the property exist?
1711             if not propdef.has_key(propname):
1712                 if mlaction != 'set':
1713                     raise ValueError, 'You have submitted a %s action for'\
1714                         ' the property "%s" which doesn\'t exist'%(mlaction,
1715                         propname)
1716                 # the form element is probably just something we don't care
1717                 # about - ignore it
1718                 continue
1719             proptype = propdef[propname]
1721             # Get the form value. This value may be a MiniFieldStorage or a list
1722             # of MiniFieldStorages.
1723             value = form[key]
1725             # handle unpacking of the MiniFieldStorage / list form value
1726             if isinstance(proptype, hyperdb.Multilink):
1727                 value = extractFormList(value)
1728             else:
1729                 # multiple values are not OK
1730                 if isinstance(value, type([])):
1731                     raise ValueError, 'You have submitted more than one value'\
1732                         ' for the %s property'%propname
1733                 # value might be a file upload...
1734                 if not hasattr(value, 'filename') or value.filename is None:
1735                     # nope, pull out the value and strip it
1736                     value = value.value.strip()
1738             # now that we have the props field, we need a teensy little
1739             # extra bit of help for the old :note field...
1740             if d['note'] and value:
1741                 props['author'] = self.db.getuid()
1742                 props['date'] = date.Date()
1744             # handle by type now
1745             if isinstance(proptype, hyperdb.Password):
1746                 if not value:
1747                     # ignore empty password values
1748                     continue
1749                 for key, d in matches:
1750                     if d['confirm'] and d['propname'] == propname:
1751                         confirm = form[key]
1752                         break
1753                 else:
1754                     raise ValueError, 'Password and confirmation text do '\
1755                         'not match'
1756                 if isinstance(confirm, type([])):
1757                     raise ValueError, 'You have submitted more than one value'\
1758                         ' for the %s property'%propname
1759                 if value != confirm.value:
1760                     raise ValueError, 'Password and confirmation text do '\
1761                         'not match'
1762                 value = password.Password(value)
1764             elif isinstance(proptype, hyperdb.Link):
1765                 # see if it's the "no selection" choice
1766                 if value == '-1' or not value:
1767                     # if we're creating, just don't include this property
1768                     if not nodeid or nodeid.startswith('-'):
1769                         continue
1770                     value = None
1771                 else:
1772                     # handle key values
1773                     link = proptype.classname
1774                     if not num_re.match(value):
1775                         try:
1776                             value = db.classes[link].lookup(value)
1777                         except KeyError:
1778                             raise ValueError, _('property "%(propname)s": '
1779                                 '%(value)s not a %(classname)s')%{
1780                                 'propname': propname, 'value': value,
1781                                 'classname': link}
1782                         except TypeError, message:
1783                             raise ValueError, _('you may only enter ID values '
1784                                 'for property "%(propname)s": %(message)s')%{
1785                                 'propname': propname, 'message': message}
1786             elif isinstance(proptype, hyperdb.Multilink):
1787                 # perform link class key value lookup if necessary
1788                 link = proptype.classname
1789                 link_cl = db.classes[link]
1790                 l = []
1791                 for entry in value:
1792                     if not entry: continue
1793                     if not num_re.match(entry):
1794                         try:
1795                             entry = link_cl.lookup(entry)
1796                         except KeyError:
1797                             raise ValueError, _('property "%(propname)s": '
1798                                 '"%(value)s" not an entry of %(classname)s')%{
1799                                 'propname': propname, 'value': entry,
1800                                 'classname': link}
1801                         except TypeError, message:
1802                             raise ValueError, _('you may only enter ID values '
1803                                 'for property "%(propname)s": %(message)s')%{
1804                                 'propname': propname, 'message': message}
1805                     l.append(entry)
1806                 l.sort()
1808                 # now use that list of ids to modify the multilink
1809                 if mlaction == 'set':
1810                     value = l
1811                 else:
1812                     # we're modifying the list - get the current list of ids
1813                     if props.has_key(propname):
1814                         existing = props[propname]
1815                     elif nodeid and not nodeid.startswith('-'):
1816                         existing = cl.get(nodeid, propname, [])
1817                     else:
1818                         existing = []
1820                     # now either remove or add
1821                     if mlaction == 'remove':
1822                         # remove - handle situation where the id isn't in
1823                         # the list
1824                         for entry in l:
1825                             try:
1826                                 existing.remove(entry)
1827                             except ValueError:
1828                                 raise ValueError, _('property "%(propname)s": '
1829                                     '"%(value)s" not currently in list')%{
1830                                     'propname': propname, 'value': entry}
1831                     else:
1832                         # add - easy, just don't dupe
1833                         for entry in l:
1834                             if entry not in existing:
1835                                 existing.append(entry)
1836                     value = existing
1837                     value.sort()
1839             elif value == '':
1840                 # if we're creating, just don't include this property
1841                 if not nodeid or nodeid.startswith('-'):
1842                     continue
1843                 # other types should be None'd if there's no value
1844                 value = None
1845             else:
1846                 # handle ValueErrors for all these in a similar fashion
1847                 try:
1848                     if isinstance(proptype, hyperdb.String):
1849                         if (hasattr(value, 'filename') and
1850                                 value.filename is not None):
1851                             # skip if the upload is empty
1852                             if not value.filename:
1853                                 continue
1854                             # this String is actually a _file_
1855                             # try to determine the file content-type
1856                             fn = value.filename.split('\\')[-1]
1857                             if propdef.has_key('name'):
1858                                 props['name'] = fn
1859                             # use this info as the type/filename properties
1860                             if propdef.has_key('type'):
1861                                 props['type'] = mimetypes.guess_type(fn)[0]
1862                                 if not props['type']:
1863                                     props['type'] = "application/octet-stream"
1864                             # finally, read the content
1865                             value = value.value
1866                         else:
1867                             # normal String fix the CRLF/CR -> LF stuff
1868                             value = fixNewlines(value)
1870                     elif isinstance(proptype, hyperdb.Date):
1871                         value = date.Date(value, offset=timezone)
1872                     elif isinstance(proptype, hyperdb.Interval):
1873                         value = date.Interval(value)
1874                     elif isinstance(proptype, hyperdb.Boolean):
1875                         value = value.lower() in ('yes', 'true', 'on', '1')
1876                     elif isinstance(proptype, hyperdb.Number):
1877                         value = float(value)
1878                 except ValueError, msg:
1879                     raise ValueError, _('Error with %s property: %s')%(
1880                         propname, msg)
1882             # register that we got this property
1883             if value:
1884                 got_props[this][propname] = 1
1886             # get the old value
1887             if nodeid and not nodeid.startswith('-'):
1888                 try:
1889                     existing = cl.get(nodeid, propname)
1890                 except KeyError:
1891                     # this might be a new property for which there is
1892                     # no existing value
1893                     if not propdef.has_key(propname):
1894                         raise
1896                 # make sure the existing multilink is sorted
1897                 if isinstance(proptype, hyperdb.Multilink):
1898                     existing.sort()
1900                 # "missing" existing values may not be None
1901                 if not existing:
1902                     if isinstance(proptype, hyperdb.String) and not existing:
1903                         # some backends store "missing" Strings as empty strings
1904                         existing = None
1905                     elif isinstance(proptype, hyperdb.Number) and not existing:
1906                         # some backends store "missing" Numbers as 0 :(
1907                         existing = 0
1908                     elif isinstance(proptype, hyperdb.Boolean) and not existing:
1909                         # likewise Booleans
1910                         existing = 0
1912                 # if changed, set it
1913                 if value != existing:
1914                     props[propname] = value
1915             else:
1916                 # don't bother setting empty/unset values
1917                 if value is None:
1918                     continue
1919                 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1920                     continue
1921                 elif isinstance(proptype, hyperdb.String) and value == '':
1922                     continue
1924                 props[propname] = value
1926         # check to see if we need to specially link a file to the note
1927         if have_note and have_file:
1928             all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1930         # see if all the required properties have been supplied
1931         s = []
1932         for thing, required in all_required.items():
1933             # register the values we got
1934             got = got_props.get(thing, {})
1935             for entry in required[:]:
1936                 if got.has_key(entry):
1937                     required.remove(entry)
1939             # any required values not present?
1940             if not required:
1941                 continue
1943             # tell the user to entry the values required
1944             if len(required) > 1:
1945                 p = 'properties'
1946             else:
1947                 p = 'property'
1948             s.append('Required %s %s %s not supplied'%(thing[0], p,
1949                 ', '.join(required)))
1950         if s:
1951             raise ValueError, '\n'.join(s)
1953         # check that FileClass entries have a "content" property with
1954         # content, otherwise remove them
1955         for (cn, id), props in all_props.items():
1956             cl = self.db.classes[cn]
1957             if not isinstance(cl, hyperdb.FileClass):
1958                 continue
1959             # we also don't want to create FileClass items with no content
1960             if not props.get('content', ''):
1961                 del all_props[(cn, id)]
1962         return all_props, all_links
1964 def fixNewlines(text):
1965     ''' Homogenise line endings.
1967         Different web clients send different line ending values, but
1968         other systems (eg. email) don't necessarily handle those line
1969         endings. Our solution is to convert all line endings to LF.
1970     '''
1971     text = text.replace('\r\n', '\n')
1972     return text.replace('\r', '\n')
1974 def extractFormList(value):
1975     ''' Extract a list of values from the form value.
1977         It may be one of:
1978          [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
1979          MiniFieldStorage('value,value,...')
1980          MiniFieldStorage('value')
1981     '''
1982     # multiple values are OK
1983     if isinstance(value, type([])):
1984         # it's a list of MiniFieldStorages - join then into
1985         values = ','.join([i.value.strip() for i in value])
1986     else:
1987         # it's a MiniFieldStorage, but may be a comma-separated list
1988         # of values
1989         values = value.value
1991     value = [i.strip() for i in values.split(',')]
1993     # filter out the empty bits
1994     return filter(None, value)