Code

aargh, b2 was b0rken
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.118 2003-06-09 23:17:23 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 class Client:
72     ''' Instantiate to handle one CGI request.
74     See inner_main for request processing.
76     Client attributes at instantiation:
77         "path" is the PATH_INFO inside the instance (with no leading '/')
78         "base" is the base URL for the instance
79         "form" is the cgi form, an instance of FieldStorage from the standard
80                cgi module
81         "additional_headers" is a dictionary of additional HTTP headers that
82                should be sent to the client
83         "response_code" is the HTTP response code to send to the client
85     During the processing of a request, the following attributes are used:
86         "error_message" holds a list of error messages
87         "ok_message" holds a list of OK messages
88         "session" is the current user session id
89         "user" is the current user's name
90         "userid" is the current user's id
91         "template" is the current :template context
92         "classname" is the current class context name
93         "nodeid" is the current context item id
95     User Identification:
96      If the user has no login cookie, then they are anonymous and are logged
97      in as that user. This typically gives them all Permissions assigned to the
98      Anonymous Role.
100      Once a user logs in, they are assigned a session. The Client instance
101      keeps the nodeid of the session as the "session" attribute.
104     Special form variables:
105      Note that in various places throughout this code, special form
106      variables of the form :<name> are used. The colon (":") part may
107      actually be one of either ":" or "@".
108     '''
110     #
111     # special form variables
112     #
113     FV_TEMPLATE = re.compile(r'[@:]template')
114     FV_OK_MESSAGE = re.compile(r'[@:]ok_message')
115     FV_ERROR_MESSAGE = re.compile(r'[@:]error_message')
117     FV_QUERYNAME = re.compile(r'[@:]queryname')
119     # edit form variable handling (see unit tests)
120     FV_LABELS = r'''
121        ^(
122          (?P<note>[@:]note)|
123          (?P<file>[@:]file)|
124          (
125           ((?P<classname>%s)(?P<id>[-\d]+))?  # optional leading designator
126           ((?P<required>[@:]required$)|       # :required
127            (
128             (
129              (?P<add>[@:]add[@:])|            # :add:<prop>
130              (?P<remove>[@:]remove[@:])|      # :remove:<prop>
131              (?P<confirm>[@:]confirm[@:])|    # :confirm:<prop>
132              (?P<link>[@:]link[@:])|          # :link:<prop>
133              ([@:])                           # just a separator
134             )?
135             (?P<propname>[^@:]+)             # <prop>
136            )
137           )
138          )
139         )$'''
141     # Note: index page stuff doesn't appear here:
142     # columns, sort, sortdir, filter, group, groupdir, search_text,
143     # pagesize, startwith
145     def __init__(self, instance, request, env, form=None):
146         hyperdb.traceMark()
147         self.instance = instance
148         self.request = request
149         self.env = env
151         # save off the path
152         self.path = env['PATH_INFO']
154         # this is the base URL for this tracker
155         self.base = self.instance.config.TRACKER_WEB
157         # this is the "cookie path" for this tracker (ie. the path part of
158         # the "base" url)
159         self.cookie_path = urlparse.urlparse(self.base)[2]
160         self.cookie_name = 'roundup_session_' + re.sub('[^a-zA-Z]', '',
161             self.instance.config.TRACKER_NAME)
163         # see if we need to re-parse the environment for the form (eg Zope)
164         if form is None:
165             self.form = cgi.FieldStorage(environ=env)
166         else:
167             self.form = form
169         # turn debugging on/off
170         try:
171             self.debug = int(env.get("ROUNDUP_DEBUG", 0))
172         except ValueError:
173             # someone gave us a non-int debug level, turn it off
174             self.debug = 0
176         # flag to indicate that the HTTP headers have been sent
177         self.headers_done = 0
179         # additional headers to send with the request - must be registered
180         # before the first write
181         self.additional_headers = {}
182         self.response_code = 200
185     def main(self):
186         ''' Wrap the real main in a try/finally so we always close off the db.
187         '''
188         try:
189             self.inner_main()
190         finally:
191             if hasattr(self, 'db'):
192                 self.db.close()
194     def inner_main(self):
195         ''' Process a request.
197             The most common requests are handled like so:
198             1. figure out who we are, defaulting to the "anonymous" user
199                see determine_user
200             2. figure out what the request is for - the context
201                see determine_context
202             3. handle any requested action (item edit, search, ...)
203                see handle_action
204             4. render a template, resulting in HTML output
206             In some situations, exceptions occur:
207             - HTTP Redirect  (generally raised by an action)
208             - SendFile       (generally raised by determine_context)
209               serve up a FileClass "content" property
210             - SendStaticFile (generally raised by determine_context)
211               serve up a file from the tracker "html" directory
212             - Unauthorised   (generally raised by an action)
213               the action is cancelled, the request is rendered and an error
214               message is displayed indicating that permission was not
215               granted for the action to take place
216             - NotFound       (raised wherever it needs to be)
217               percolates up to the CGI interface that called the client
218         '''
219         self.ok_message = []
220         self.error_message = []
221         try:
222             # make sure we're identified (even anonymously)
223             self.determine_user()
224             # figure out the context and desired content template
225             self.determine_context()
226             # possibly handle a form submit action (may change self.classname
227             # and self.template, and may also append error/ok_messages)
228             self.handle_action()
230             # now render the page
231             # we don't want clients caching our dynamic pages
232             self.additional_headers['Cache-Control'] = 'no-cache'
233 # Pragma: no-cache makes Mozilla and its ilk double-load all pages!!
234 #            self.additional_headers['Pragma'] = 'no-cache'
236             # expire this page 5 seconds from now
237             date = rfc822.formatdate(time.time() + 5)
238             self.additional_headers['Expires'] = date
240             # render the content
241             self.write(self.renderContext())
242         except Redirect, url:
243             # let's redirect - if the url isn't None, then we need to do
244             # the headers, otherwise the headers have been set before the
245             # exception was raised
246             if url:
247                 self.additional_headers['Location'] = url
248                 self.response_code = 302
249             self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
250         except SendFile, designator:
251             self.serve_file(designator)
252         except SendStaticFile, file:
253             try:
254                 self.serve_static_file(str(file))
255             except NotModified:
256                 # send the 304 response
257                 self.request.send_response(304)
258                 self.request.end_headers()
259         except Unauthorised, message:
260             self.classname = None
261             self.template = ''
262             self.error_message.append(message)
263             self.write(self.renderContext())
264         except NotFound:
265             # pass through
266             raise
267         except:
268             # everything else
269             self.write(cgitb.html())
271     def clean_sessions(self):
272         ''' Age sessions, remove when they haven't been used for a week.
273         
274             Do it only once an hour.
276             Note: also cleans One Time Keys, and other "session" based
277             stuff.
278         '''
279         sessions = self.db.sessions
280         last_clean = sessions.get('last_clean', 'last_use') or 0
282         week = 60*60*24*7
283         hour = 60*60
284         now = time.time()
285         if now - last_clean > hour:
286             # remove aged sessions
287             for sessid in sessions.list():
288                 interval = now - sessions.get(sessid, 'last_use')
289                 if interval > week:
290                     sessions.destroy(sessid)
291             # remove aged otks
292             otks = self.db.otks
293             for sessid in otks.list():
294                 interval = now - otks.get(sessid, '__time')
295                 if interval > week:
296                     otks.destroy(sessid)
297             sessions.set('last_clean', last_use=time.time())
299     def determine_user(self):
300         ''' Determine who the user is
301         '''
302         # determine the uid to use
303         self.opendb('admin')
304         # clean age sessions
305         self.clean_sessions()
306         # make sure we have the session Class
307         sessions = self.db.sessions
309         # look up the user session cookie
310         cookie = Cookie.SimpleCookie(self.env.get('HTTP_COOKIE', ''))
311         user = 'anonymous'
313         # bump the "revision" of the cookie since the format changed
314         if (cookie.has_key(self.cookie_name) and
315                 cookie[self.cookie_name].value != 'deleted'):
317             # get the session key from the cookie
318             self.session = cookie[self.cookie_name].value
319             # get the user from the session
320             try:
321                 # update the lifetime datestamp
322                 sessions.set(self.session, last_use=time.time())
323                 sessions.commit()
324                 user = sessions.get(self.session, 'user')
325             except KeyError:
326                 user = 'anonymous'
328         # sanity check on the user still being valid, getting the userid
329         # at the same time
330         try:
331             self.userid = self.db.user.lookup(user)
332         except (KeyError, TypeError):
333             user = 'anonymous'
335         # make sure the anonymous user is valid if we're using it
336         if user == 'anonymous':
337             self.make_user_anonymous()
338         else:
339             self.user = user
341         # reopen the database as the correct user
342         self.opendb(self.user)
344     def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
345         ''' Determine the context of this page from the URL:
347             The URL path after the instance identifier is examined. The path
348             is generally only one entry long.
350             - if there is no path, then we are in the "home" context.
351             * if the path is "_file", then the additional path entry
352               specifies the filename of a static file we're to serve up
353               from the instance "html" directory. Raises a SendStaticFile
354               exception.
355             - if there is something in the path (eg "issue"), it identifies
356               the tracker class we're to display.
357             - if the path is an item designator (eg "issue123"), then we're
358               to display a specific item.
359             * if the path starts with an item designator and is longer than
360               one entry, then we're assumed to be handling an item of a
361               FileClass, and the extra path information gives the filename
362               that the client is going to label the download with (ie
363               "file123/image.png" is nicer to download than "file123"). This
364               raises a SendFile exception.
366             Both of the "*" types of contexts stop before we bother to
367             determine the template we're going to use. That's because they
368             don't actually use templates.
370             The template used is specified by the :template CGI variable,
371             which defaults to:
373              only classname suplied:          "index"
374              full item designator supplied:   "item"
376             We set:
377              self.classname  - the class to display, can be None
378              self.template   - the template to render the current context with
379              self.nodeid     - the nodeid of the class we're displaying
380         '''
381         # default the optional variables
382         self.classname = None
383         self.nodeid = None
385         # see if a template or messages are specified
386         template_override = ok_message = error_message = None
387         for key in self.form.keys():
388             if self.FV_TEMPLATE.match(key):
389                 template_override = self.form[key].value
390             elif self.FV_OK_MESSAGE.match(key):
391                 ok_message = self.form[key].value
392             elif self.FV_ERROR_MESSAGE.match(key):
393                 error_message = self.form[key].value
395         # determine the classname and possibly nodeid
396         path = self.path.split('/')
397         if not path or path[0] in ('', 'home', 'index'):
398             if template_override is not None:
399                 self.template = template_override
400             else:
401                 self.template = ''
402             return
403         elif path[0] == '_file':
404             raise SendStaticFile, os.path.join(*path[1:])
405         else:
406             self.classname = path[0]
407             if len(path) > 1:
408                 # send the file identified by the designator in path[0]
409                 raise SendFile, path[0]
411         # see if we got a designator
412         m = dre.match(self.classname)
413         if m:
414             self.classname = m.group(1)
415             self.nodeid = m.group(2)
416             if not self.db.getclass(self.classname).hasnode(self.nodeid):
417                 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
418             # with a designator, we default to item view
419             self.template = 'item'
420         else:
421             # with only a class, we default to index view
422             self.template = 'index'
424         # make sure the classname is valid
425         try:
426             self.db.getclass(self.classname)
427         except KeyError:
428             raise NotFound, self.classname
430         # see if we have a template override
431         if template_override is not None:
432             self.template = template_override
434         # see if we were passed in a message
435         if ok_message:
436             self.ok_message.append(ok_message)
437         if error_message:
438             self.error_message.append(error_message)
440     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
441         ''' Serve the file from the content property of the designated item.
442         '''
443         m = dre.match(str(designator))
444         if not m:
445             raise NotFound, str(designator)
446         classname, nodeid = m.group(1), m.group(2)
447         if classname != 'file':
448             raise NotFound, designator
450         # we just want to serve up the file named
451         file = self.db.file
452         self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
453         self.write(file.get(nodeid, 'content'))
455     def serve_static_file(self, file):
456         ims = None
457         # see if there's an if-modified-since...
458         if hasattr(self.request, 'headers'):
459             ims = self.request.headers.getheader('if-modified-since')
460         elif self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
461             # cgi will put the header in the env var
462             ims = self.env['HTTP_IF_MODIFIED_SINCE']
463         filename = os.path.join(self.instance.config.TEMPLATES, file)
464         lmt = os.stat(filename)[stat.ST_MTIME]
465         if ims:
466             ims = rfc822.parsedate(ims)[:6]
467             lmtt = time.gmtime(lmt)[:6]
468             if lmtt <= ims:
469                 raise NotModified
471         # we just want to serve up the file named
472         file = str(file)
473         mt = mimetypes.guess_type(file)[0]
474         if not mt:
475             if file.endswith('.css'):
476                 mt = 'text/css'
477             else:
478                 mt = 'text/plain'
479         self.additional_headers['Content-Type'] = mt
480         self.additional_headers['Last-Modifed'] = rfc822.formatdate(lmt)
481         self.write(open(filename, 'rb').read())
483     def renderContext(self):
484         ''' Return a PageTemplate for the named page
485         '''
486         name = self.classname
487         extension = self.template
488         pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
490         # catch errors so we can handle PT rendering errors more nicely
491         args = {
492             'ok_message': self.ok_message,
493             'error_message': self.error_message
494         }
495         try:
496             # let the template render figure stuff out
497             return pt.render(self, None, None, **args)
498         except NoTemplate, message:
499             return '<strong>%s</strong>'%message
500         except:
501             # everything else
502             return cgitb.pt_html()
504     # these are the actions that are available
505     actions = (
506         ('edit',     'editItemAction'),
507         ('editcsv',  'editCSVAction'),
508         ('new',      'newItemAction'),
509         ('register', 'registerAction'),
510         ('confrego', 'confRegoAction'),
511         ('passrst',  'passResetAction'),
512         ('login',    'loginAction'),
513         ('logout',   'logout_action'),
514         ('search',   'searchAction'),
515         ('retire',   'retireAction'),
516         ('show',     'showAction'),
517     )
518     def handle_action(self):
519         ''' Determine whether there should be an Action called.
521             The action is defined by the form variable :action which
522             identifies the method on this object to call. The actions
523             are defined in the "actions" sequence on this class.
524         '''
525         if self.form.has_key(':action'):
526             action = self.form[':action'].value.lower()
527         elif self.form.has_key('@action'):
528             action = self.form['@action'].value.lower()
529         else:
530             return None
531         try:
532             # get the action, validate it
533             for name, method in self.actions:
534                 if name == action:
535                     break
536             else:
537                 raise ValueError, 'No such action "%s"'%action
538             # call the mapped action
539             getattr(self, method)()
540         except Redirect:
541             raise
542         except Unauthorised:
543             raise
545     def write(self, content):
546         if not self.headers_done:
547             self.header()
548         self.request.wfile.write(content)
550     def header(self, headers=None, response=None):
551         '''Put up the appropriate header.
552         '''
553         if headers is None:
554             headers = {'Content-Type':'text/html'}
555         if response is None:
556             response = self.response_code
558         # update with additional info
559         headers.update(self.additional_headers)
561         if not headers.has_key('Content-Type'):
562             headers['Content-Type'] = 'text/html'
563         self.request.send_response(response)
564         for entry in headers.items():
565             self.request.send_header(*entry)
566         self.request.end_headers()
567         self.headers_done = 1
568         if self.debug:
569             self.headers_sent = headers
571     def set_cookie(self, user):
572         ''' Set up a session cookie for the user and store away the user's
573             login info against the session.
574         '''
575         # TODO generate a much, much stronger session key ;)
576         self.session = binascii.b2a_base64(repr(random.random())).strip()
578         # clean up the base64
579         if self.session[-1] == '=':
580             if self.session[-2] == '=':
581                 self.session = self.session[:-2]
582             else:
583                 self.session = self.session[:-1]
585         # insert the session in the sessiondb
586         self.db.sessions.set(self.session, user=user, last_use=time.time())
588         # and commit immediately
589         self.db.sessions.commit()
591         # expire us in a long, long time
592         expire = Cookie._getdate(86400*365)
594         # generate the cookie path - make sure it has a trailing '/'
595         self.additional_headers['Set-Cookie'] = \
596           '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
597             expire, self.cookie_path)
599     def make_user_anonymous(self):
600         ''' Make us anonymous
602             This method used to handle non-existence of the 'anonymous'
603             user, but that user is mandatory now.
604         '''
605         self.userid = self.db.user.lookup('anonymous')
606         self.user = 'anonymous'
608     def opendb(self, user):
609         ''' Open the database.
610         '''
611         # open the db if the user has changed
612         if not hasattr(self, 'db') or user != self.db.journaltag:
613             if hasattr(self, 'db'):
614                 self.db.close()
615             self.db = self.instance.open(user)
617     #
618     # Actions
619     #
620     def loginAction(self):
621         ''' Attempt to log a user in.
623             Sets up a session for the user which contains the login
624             credentials.
625         '''
626         # we need the username at a minimum
627         if not self.form.has_key('__login_name'):
628             self.error_message.append(_('Username required'))
629             return
631         # get the login info
632         self.user = self.form['__login_name'].value
633         if self.form.has_key('__login_password'):
634             password = self.form['__login_password'].value
635         else:
636             password = ''
638         # make sure the user exists
639         try:
640             self.userid = self.db.user.lookup(self.user)
641         except KeyError:
642             name = self.user
643             self.error_message.append(_('No such user "%(name)s"')%locals())
644             self.make_user_anonymous()
645             return
647         # verify the password
648         if not self.verifyPassword(self.userid, password):
649             self.make_user_anonymous()
650             self.error_message.append(_('Incorrect password'))
651             return
653         # make sure we're allowed to be here
654         if not self.loginPermission():
655             self.make_user_anonymous()
656             self.error_message.append(_("You do not have permission to login"))
657             return
659         # now we're OK, re-open the database for real, using the user
660         self.opendb(self.user)
662         # set the session cookie
663         self.set_cookie(self.user)
665     def verifyPassword(self, userid, password):
666         ''' Verify the password that the user has supplied
667         '''
668         stored = self.db.user.get(self.userid, 'password')
669         if password == stored:
670             return 1
671         if not password and not stored:
672             return 1
673         return 0
675     def loginPermission(self):
676         ''' Determine whether the user has permission to log in.
678             Base behaviour is to check the user has "Web Access".
679         ''' 
680         if not self.db.security.hasPermission('Web Access', self.userid):
681             return 0
682         return 1
684     def logout_action(self):
685         ''' Make us really anonymous - nuke the cookie too
686         '''
687         # log us out
688         self.make_user_anonymous()
690         # construct the logout cookie
691         now = Cookie._getdate()
692         self.additional_headers['Set-Cookie'] = \
693            '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.cookie_name,
694             now, self.cookie_path)
696         # Let the user know what's going on
697         self.ok_message.append(_('You are logged out'))
699     def registerAction(self):
700         '''Attempt to create a new user based on the contents of the form
701         and then set the cookie.
703         return 1 on successful login
704         '''
705         # parse the props from the form
706         try:
707             props = self.parsePropsFromForm()[0][('user', None)]
708         except (ValueError, KeyError), message:
709             self.error_message.append(_('Error: ') + str(message))
710             return
712         # make sure we're allowed to register
713         if not self.registerPermission(props):
714             raise Unauthorised, _("You do not have permission to register")
716         try:
717             self.db.user.lookup(props['username'])
718             self.error_message.append('Error: A user with the username "%s" '
719                 'already exists'%props['username'])
720             return
721         except KeyError:
722             pass
724         # generate the one-time-key and store the props for later
725         otk = ''.join([random.choice(chars) for x in range(32)])
726         for propname, proptype in self.db.user.getprops().items():
727             value = props.get(propname, None)
728             if value is None:
729                 pass
730             elif isinstance(proptype, hyperdb.Date):
731                 props[propname] = str(value)
732             elif isinstance(proptype, hyperdb.Interval):
733                 props[propname] = str(value)
734             elif isinstance(proptype, hyperdb.Password):
735                 props[propname] = str(value)
736         props['__time'] = time.time()
737         self.db.otks.set(otk, **props)
739         # send the email
740         tracker_name = self.db.config.TRACKER_NAME
741         subject = 'Complete your registration to %s'%tracker_name
742         body = '''
743 To complete your registration of the user "%(name)s" with %(tracker)s,
744 please visit the following URL:
746    %(url)s?@action=confrego&otk=%(otk)s
747 '''%{'name': props['username'], 'tracker': tracker_name, 'url': self.base,
748                 'otk': otk}
749         if not self.sendEmail(props['address'], subject, body):
750             return
752         # commit changes to the database
753         self.db.commit()
755         # redirect to the "you're almost there" page
756         raise Redirect, '%suser?@template=rego_progress'%self.base
758     def sendEmail(self, to, subject, content):
759         # send email to the user's email address
760         message = StringIO.StringIO()
761         writer = MimeWriter.MimeWriter(message)
762         tracker_name = self.db.config.TRACKER_NAME
763         writer.addheader('Subject', encode_header(subject))
764         writer.addheader('To', to)
765         writer.addheader('From', roundupdb.straddr((tracker_name,
766             self.db.config.ADMIN_EMAIL)))
767         writer.addheader('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000",
768             time.gmtime()))
769         # add a uniquely Roundup header to help filtering
770         writer.addheader('X-Roundup-Name', tracker_name)
771         # avoid email loops
772         writer.addheader('X-Roundup-Loop', 'hello')
773         writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
774         body = writer.startbody('text/plain; charset=utf-8')
776         # message body, encoded quoted-printable
777         content = StringIO.StringIO(content)
778         quopri.encode(content, body, 0)
780         if SENDMAILDEBUG:
781             # don't send - just write to a file
782             open(SENDMAILDEBUG, 'a').write('FROM: %s\nTO: %s\n%s\n'%(
783                 self.db.config.ADMIN_EMAIL,
784                 ', '.join(to),message.getvalue()))
785         else:
786             # now try to send the message
787             try:
788                 # send the message as admin so bounces are sent there
789                 # instead of to roundup
790                 smtp = openSMTPConnection(self.db.config)
791                 smtp.sendmail(self.db.config.ADMIN_EMAIL, [to],
792                     message.getvalue())
793             except socket.error, value:
794                 self.error_message.append("Error: couldn't send email: "
795                     "mailhost %s"%value)
796                 return 0
797             except smtplib.SMTPException, msg:
798                 self.error_message.append("Error: couldn't send email: %s"%msg)
799                 return 0
800         return 1
802     def registerPermission(self, props):
803         ''' Determine whether the user has permission to register
805             Base behaviour is to check the user has "Web Registration".
806         '''
807         # registration isn't allowed to supply roles
808         if props.has_key('roles'):
809             return 0
810         if self.db.security.hasPermission('Web Registration', self.userid):
811             return 1
812         return 0
814     def confRegoAction(self):
815         ''' Grab the OTK, use it to load up the new user details
816         '''
817         # pull the rego information out of the otk database
818         otk = self.form['otk'].value
819         props = self.db.otks.getall(otk)
820         for propname, proptype in self.db.user.getprops().items():
821             value = props.get(propname, None)
822             if value is None:
823                 pass
824             elif isinstance(proptype, hyperdb.Date):
825                 props[propname] = date.Date(value)
826             elif isinstance(proptype, hyperdb.Interval):
827                 props[propname] = date.Interval(value)
828             elif isinstance(proptype, hyperdb.Password):
829                 props[propname] = password.Password()
830                 props[propname].unpack(value)
832         # re-open the database as "admin"
833         if self.user != 'admin':
834             self.opendb('admin')
836         # create the new user
837         cl = self.db.user
838 # XXX we need to make the "default" page be able to display errors!
839         try:
840             props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
841             del props['__time']
842             self.userid = cl.create(**props)
843             # clear the props from the otk database
844             self.db.otks.destroy(otk)
845             self.db.commit()
846         except (ValueError, KeyError), message:
847             self.error_message.append(str(message))
848             return
850         # log the new user in
851         self.user = cl.get(self.userid, 'username')
852         # re-open the database for real, using the user
853         self.opendb(self.user)
855         # if we have a session, update it
856         if hasattr(self, 'session'):
857             self.db.sessions.set(self.session, user=self.user,
858                 last_use=time.time())
859         else:
860             # new session cookie
861             self.set_cookie(self.user)
863         # nice message
864         message = _('You are now registered, welcome!')
866         # redirect to the user's page
867         raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
868             self.userid, urllib.quote(message))
870     def passResetAction(self):
871         ''' Handle password reset requests.
873             Presence of either "name" or "address" generate email.
874             Presense of "otk" performs the reset.
875         '''
876         if self.form.has_key('otk'):
877             # pull the rego information out of the otk database
878             otk = self.form['otk'].value
879             uid = self.db.otks.get(otk, 'uid')
880             if uid is None:
881                 self.error_message.append('Invalid One Time Key!')
882                 return
884             # re-open the database as "admin"
885             if self.user != 'admin':
886                 self.opendb('admin')
888             # change the password
889             newpw = password.generatePassword()
891             cl = self.db.user
892 # XXX we need to make the "default" page be able to display errors!
893             try:
894                 # set the password
895                 cl.set(uid, password=password.Password(newpw))
896                 # clear the props from the otk database
897                 self.db.otks.destroy(otk)
898                 self.db.commit()
899             except (ValueError, KeyError), message:
900                 self.error_message.append(str(message))
901                 return
903             # user info
904             address = self.db.user.get(uid, 'address')
905             name = self.db.user.get(uid, 'username')
907             # send the email
908             tracker_name = self.db.config.TRACKER_NAME
909             subject = 'Password reset for %s'%tracker_name
910             body = '''
911 The password has been reset for username "%(name)s".
913 Your password is now: %(password)s
914 '''%{'name': name, 'password': newpw}
915             if not self.sendEmail(address, subject, body):
916                 return
918             self.ok_message.append('Password reset and email sent to %s'%address)
919             return
921         # no OTK, so now figure the user
922         if self.form.has_key('username'):
923             name = self.form['username'].value
924             try:
925                 uid = self.db.user.lookup(name)
926             except KeyError:
927                 self.error_message.append('Unknown username')
928                 return
929             address = self.db.user.get(uid, 'address')
930         elif self.form.has_key('address'):
931             address = self.form['address'].value
932             uid = uidFromAddress(self.db, ('', address), create=0)
933             if not uid:
934                 self.error_message.append('Unknown email address')
935                 return
936             name = self.db.user.get(uid, 'username')
937         else:
938             self.error_message.append('You need to specify a username '
939                 'or address')
940             return
942         # generate the one-time-key and store the props for later
943         otk = ''.join([random.choice(chars) for x in range(32)])
944         self.db.otks.set(otk, uid=uid, __time=time.time())
946         # send the email
947         tracker_name = self.db.config.TRACKER_NAME
948         subject = 'Confirm reset of password for %s'%tracker_name
949         body = '''
950 Someone, perhaps you, has requested that the password be changed for your
951 username, "%(name)s". If you wish to proceed with the change, please follow
952 the link below:
954   %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
956 You should then receive another email with the new password.
957 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
958         if not self.sendEmail(address, subject, body):
959             return
961         self.ok_message.append('Email sent to %s'%address)
963     def editItemAction(self):
964         ''' Perform an edit of an item in the database.
966            See parsePropsFromForm and _editnodes for special variables
967         '''
968         # parse the props from the form
969         try:
970             props, links = self.parsePropsFromForm()
971         except (ValueError, KeyError), message:
972             self.error_message.append(_('Error: ') + str(message))
973             return
975         # handle the props
976         try:
977             message = self._editnodes(props, links)
978         except (ValueError, KeyError, IndexError), message:
979             self.error_message.append(_('Error: ') + str(message))
980             return
982         # commit now that all the tricky stuff is done
983         self.db.commit()
985         # redirect to the item's edit page
986         raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
987             self.classname, self.nodeid, urllib.quote(message),
988             urllib.quote(self.template))
990     def editItemPermission(self, props):
991         ''' Determine whether the user has permission to edit this item.
993             Base behaviour is to check the user can edit this class. If we're
994             editing the "user" class, users are allowed to edit their own
995             details. Unless it's the "roles" property, which requires the
996             special Permission "Web Roles".
997         '''
998         # if this is a user node and the user is editing their own node, then
999         # we're OK
1000         has = self.db.security.hasPermission
1001         if self.classname == 'user':
1002             # reject if someone's trying to edit "roles" and doesn't have the
1003             # right permission.
1004             if props.has_key('roles') and not has('Web Roles', self.userid,
1005                     'user'):
1006                 return 0
1007             # if the item being edited is the current user, we're ok
1008             if self.nodeid == self.userid:
1009                 return 1
1010         if self.db.security.hasPermission('Edit', self.userid, self.classname):
1011             return 1
1012         return 0
1014     def newItemAction(self):
1015         ''' Add a new item to the database.
1017             This follows the same form as the editItemAction, with the same
1018             special form values.
1019         '''
1020         # parse the props from the form
1021         try:
1022             props, links = self.parsePropsFromForm()
1023         except (ValueError, KeyError), message:
1024             self.error_message.append(_('Error: ') + str(message))
1025             return
1027         # handle the props - edit or create
1028         try:
1029             # when it hits the None element, it'll set self.nodeid
1030             messages = self._editnodes(props, links)
1032         except (ValueError, KeyError, IndexError), message:
1033             # these errors might just be indicative of user dumbness
1034             self.error_message.append(_('Error: ') + str(message))
1035             return
1037         # commit now that all the tricky stuff is done
1038         self.db.commit()
1040         # redirect to the new item's page
1041         raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
1042             self.classname, self.nodeid, urllib.quote(messages),
1043             urllib.quote(self.template))
1045     def newItemPermission(self, props):
1046         ''' Determine whether the user has permission to create (edit) this
1047             item.
1049             Base behaviour is to check the user can edit this class. No
1050             additional property checks are made. Additionally, new user items
1051             may be created if the user has the "Web Registration" Permission.
1052         '''
1053         has = self.db.security.hasPermission
1054         if self.classname == 'user' and has('Web Registration', self.userid,
1055                 'user'):
1056             return 1
1057         if has('Edit', self.userid, self.classname):
1058             return 1
1059         return 0
1062     #
1063     #  Utility methods for editing
1064     #
1065     def _editnodes(self, all_props, all_links, newids=None):
1066         ''' Use the props in all_props to perform edit and creation, then
1067             use the link specs in all_links to do linking.
1068         '''
1069         # figure dependencies and re-work links
1070         deps = {}
1071         links = {}
1072         for cn, nodeid, propname, vlist in all_links:
1073             if not all_props.has_key((cn, nodeid)):
1074                 # link item to link to doesn't (and won't) exist
1075                 continue
1076             for value in vlist:
1077                 if not all_props.has_key(value):
1078                     # link item to link to doesn't (and won't) exist
1079                     continue
1080                 deps.setdefault((cn, nodeid), []).append(value)
1081                 links.setdefault(value, []).append((cn, nodeid, propname))
1083         # figure chained dependencies ordering
1084         order = []
1085         done = {}
1086         # loop detection
1087         change = 0
1088         while len(all_props) != len(done):
1089             for needed in all_props.keys():
1090                 if done.has_key(needed):
1091                     continue
1092                 tlist = deps.get(needed, [])
1093                 for target in tlist:
1094                     if not done.has_key(target):
1095                         break
1096                 else:
1097                     done[needed] = 1
1098                     order.append(needed)
1099                     change = 1
1100             if not change:
1101                 raise ValueError, 'linking must not loop!'
1103         # now, edit / create
1104         m = []
1105         for needed in order:
1106             props = all_props[needed]
1107             if not props:
1108                 # nothing to do
1109                 continue
1110             cn, nodeid = needed
1112             if nodeid is not None and int(nodeid) > 0:
1113                 # make changes to the node
1114                 props = self._changenode(cn, nodeid, props)
1116                 # and some nice feedback for the user
1117                 if props:
1118                     info = ', '.join(props.keys())
1119                     m.append('%s %s %s edited ok'%(cn, nodeid, info))
1120                 else:
1121                     m.append('%s %s - nothing changed'%(cn, nodeid))
1122             else:
1123                 assert props
1125                 # make a new node
1126                 newid = self._createnode(cn, props)
1127                 if nodeid is None:
1128                     self.nodeid = newid
1129                 nodeid = newid
1131                 # and some nice feedback for the user
1132                 m.append('%s %s created'%(cn, newid))
1134             # fill in new ids in links
1135             if links.has_key(needed):
1136                 for linkcn, linkid, linkprop in links[needed]:
1137                     props = all_props[(linkcn, linkid)]
1138                     cl = self.db.classes[linkcn]
1139                     propdef = cl.getprops()[linkprop]
1140                     if not props.has_key(linkprop):
1141                         if linkid is None or linkid.startswith('-'):
1142                             # linking to a new item
1143                             if isinstance(propdef, hyperdb.Multilink):
1144                                 props[linkprop] = [newid]
1145                             else:
1146                                 props[linkprop] = newid
1147                         else:
1148                             # linking to an existing item
1149                             if isinstance(propdef, hyperdb.Multilink):
1150                                 existing = cl.get(linkid, linkprop)[:]
1151                                 existing.append(nodeid)
1152                                 props[linkprop] = existing
1153                             else:
1154                                 props[linkprop] = newid
1156         return '<br>'.join(m)
1158     def _changenode(self, cn, nodeid, props):
1159         ''' change the node based on the contents of the form
1160         '''
1161         # check for permission
1162         if not self.editItemPermission(props):
1163             raise Unauthorised, 'You do not have permission to edit %s'%cn
1165         # make the changes
1166         cl = self.db.classes[cn]
1167         return cl.set(nodeid, **props)
1169     def _createnode(self, cn, props):
1170         ''' create a node based on the contents of the form
1171         '''
1172         # check for permission
1173         if not self.newItemPermission(props):
1174             raise Unauthorised, 'You do not have permission to create %s'%cn
1176         # create the node and return its id
1177         cl = self.db.classes[cn]
1178         return cl.create(**props)
1180     # 
1181     # More actions
1182     #
1183     def editCSVAction(self):
1184         ''' Performs an edit of all of a class' items in one go.
1186             The "rows" CGI var defines the CSV-formatted entries for the
1187             class. New nodes are identified by the ID 'X' (or any other
1188             non-existent ID) and removed lines are retired.
1189         '''
1190         # this is per-class only
1191         if not self.editCSVPermission():
1192             self.error_message.append(
1193                 _('You do not have permission to edit %s' %self.classname))
1195         # get the CSV module
1196         try:
1197             import csv
1198         except ImportError:
1199             self.error_message.append(_(
1200                 'Sorry, you need the csv module to use this function.<br>\n'
1201                 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
1202             return
1204         cl = self.db.classes[self.classname]
1205         idlessprops = cl.getprops(protected=0).keys()
1206         idlessprops.sort()
1207         props = ['id'] + idlessprops
1209         # do the edit
1210         rows = self.form['rows'].value.splitlines()
1211         p = csv.parser()
1212         found = {}
1213         line = 0
1214         for row in rows[1:]:
1215             line += 1
1216             values = p.parse(row)
1217             # not a complete row, keep going
1218             if not values: continue
1220             # skip property names header
1221             if values == props:
1222                 continue
1224             # extract the nodeid
1225             nodeid, values = values[0], values[1:]
1226             found[nodeid] = 1
1228             # see if the node exists
1229             if cl.hasnode(nodeid):
1230                 exists = 1
1231             else:
1232                 exists = 0
1234             # confirm correct weight
1235             if len(idlessprops) != len(values):
1236                 self.error_message.append(
1237                     _('Not enough values on line %(line)s')%{'line':line})
1238                 return
1240             # extract the new values
1241             d = {}
1242             for name, value in zip(idlessprops, values):
1243                 prop = cl.properties[name]
1244                 value = value.strip()
1245                 # only add the property if it has a value
1246                 if value:
1247                     # if it's a multilink, split it
1248                     if isinstance(prop, hyperdb.Multilink):
1249                         value = value.split(':')
1250                     d[name] = value
1251                 elif exists:
1252                     # nuke the existing value
1253                     if isinstance(prop, hyperdb.Multilink):
1254                         d[name] = []
1255                     else:
1256                         d[name] = None
1258             # perform the edit
1259             if exists:
1260                 # edit existing
1261                 cl.set(nodeid, **d)
1262             else:
1263                 # new node
1264                 found[cl.create(**d)] = 1
1266         # retire the removed entries
1267         for nodeid in cl.list():
1268             if not found.has_key(nodeid):
1269                 cl.retire(nodeid)
1271         # all OK
1272         self.db.commit()
1274         self.ok_message.append(_('Items edited OK'))
1276     def editCSVPermission(self):
1277         ''' Determine whether the user has permission to edit this class.
1279             Base behaviour is to check the user can edit this class.
1280         ''' 
1281         if not self.db.security.hasPermission('Edit', self.userid,
1282                 self.classname):
1283             return 0
1284         return 1
1286     def searchAction(self, wcre=re.compile(r'[\s,]+')):
1287         ''' Mangle some of the form variables.
1289             Set the form ":filter" variable based on the values of the
1290             filter variables - if they're set to anything other than
1291             "dontcare" then add them to :filter.
1293             Handle the ":queryname" variable and save off the query to
1294             the user's query list.
1296             Split any String query values on whitespace and comma.
1297         '''
1298         # generic edit is per-class only
1299         if not self.searchPermission():
1300             self.error_message.append(
1301                 _('You do not have permission to search %s' %self.classname))
1303         # add a faked :filter form variable for each filtering prop
1304         props = self.db.classes[self.classname].getprops()
1305         queryname = ''
1306         for key in self.form.keys():
1307             # special vars
1308             if self.FV_QUERYNAME.match(key):
1309                 queryname = self.form[key].value.strip()
1310                 continue
1312             if not props.has_key(key):
1313                 continue
1314             if isinstance(self.form[key], type([])):
1315                 # search for at least one entry which is not empty
1316                 for minifield in self.form[key]:
1317                     if minifield.value:
1318                         break
1319                 else:
1320                     continue
1321             else:
1322                 if not self.form[key].value:
1323                     continue
1324                 if isinstance(props[key], hyperdb.String):
1325                     v = self.form[key].value
1326                     l = token.token_split(v)
1327                     if len(l) > 1 or l[0] != v:
1328                         self.form.value.remove(self.form[key])
1329                         # replace the single value with the split list
1330                         for v in l:
1331                             self.form.value.append(cgi.MiniFieldStorage(key, v))
1333             self.form.value.append(cgi.MiniFieldStorage('@filter', key))
1335         # handle saving the query params
1336         if queryname:
1337             # parse the environment and figure what the query _is_
1338             req = HTMLRequest(self)
1339             url = req.indexargs_href('', {})
1341             # handle editing an existing query
1342             try:
1343                 qid = self.db.query.lookup(queryname)
1344                 self.db.query.set(qid, klass=self.classname, url=url)
1345             except KeyError:
1346                 # create a query
1347                 qid = self.db.query.create(name=queryname,
1348                     klass=self.classname, url=url)
1350                 # and add it to the user's query multilink
1351                 queries = self.db.user.get(self.userid, 'queries')
1352                 queries.append(qid)
1353                 self.db.user.set(self.userid, queries=queries)
1355             # commit the query change to the database
1356             self.db.commit()
1358     def searchPermission(self):
1359         ''' Determine whether the user has permission to search this class.
1361             Base behaviour is to check the user can view this class.
1362         ''' 
1363         if not self.db.security.hasPermission('View', self.userid,
1364                 self.classname):
1365             return 0
1366         return 1
1369     def retireAction(self):
1370         ''' Retire the context item.
1371         '''
1372         # if we want to view the index template now, then unset the nodeid
1373         # context info (a special-case for retire actions on the index page)
1374         nodeid = self.nodeid
1375         if self.template == 'index':
1376             self.nodeid = None
1378         # generic edit is per-class only
1379         if not self.retirePermission():
1380             self.error_message.append(
1381                 _('You do not have permission to retire %s' %self.classname))
1382             return
1384         # make sure we don't try to retire admin or anonymous
1385         if self.classname == 'user' and \
1386                 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1387             self.error_message.append(
1388                 _('You may not retire the admin or anonymous user'))
1389             return
1391         # do the retire
1392         self.db.getclass(self.classname).retire(nodeid)
1393         self.db.commit()
1395         self.ok_message.append(
1396             _('%(classname)s %(itemid)s has been retired')%{
1397                 'classname': self.classname.capitalize(), 'itemid': nodeid})
1399     def retirePermission(self):
1400         ''' Determine whether the user has permission to retire this class.
1402             Base behaviour is to check the user can edit this class.
1403         ''' 
1404         if not self.db.security.hasPermission('Edit', self.userid,
1405                 self.classname):
1406             return 0
1407         return 1
1410     def showAction(self, typere=re.compile('[@:]type'),
1411             numre=re.compile('[@:]number')):
1412         ''' Show a node of a particular class/id
1413         '''
1414         t = n = ''
1415         for key in self.form.keys():
1416             if typere.match(key):
1417                 t = self.form[key].value.strip()
1418             elif numre.match(key):
1419                 n = self.form[key].value.strip()
1420         if not t:
1421             raise ValueError, 'Invalid %s number'%t
1422         url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1423         raise Redirect, url
1425     def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1426         ''' Item properties and their values are edited with html FORM
1427             variables and their values. You can:
1429             - Change the value of some property of the current item.
1430             - Create a new item of any class, and edit the new item's
1431               properties,
1432             - Attach newly created items to a multilink property of the
1433               current item.
1434             - Remove items from a multilink property of the current item.
1435             - Specify that some properties are required for the edit
1436               operation to be successful.
1438             In the following, <bracketed> values are variable, "@" may be
1439             either ":" or "@", and other text "required" is fixed.
1441             Most properties are specified as form variables:
1443              <propname>
1444               - property on the current context item
1446              <designator>"@"<propname>
1447               - property on the indicated item (for editing related
1448                 information)
1450             Designators name a specific item of a class.
1452             <classname><N>
1454                 Name an existing item of class <classname>.
1456             <classname>"-"<N>
1458                 Name the <N>th new item of class <classname>. If the form
1459                 submission is successful, a new item of <classname> is
1460                 created. Within the submitted form, a particular
1461                 designator of this form always refers to the same new
1462                 item.
1464             Once we have determined the "propname", we look at it to see
1465             if it's special:
1467             @required
1468                 The associated form value is a comma-separated list of
1469                 property names that must be specified when the form is
1470                 submitted for the edit operation to succeed.  
1472                 When the <designator> is missing, the properties are
1473                 for the current context item.  When <designator> is
1474                 present, they are for the item specified by
1475                 <designator>.
1477                 The "@required" specifier must come before any of the
1478                 properties it refers to are assigned in the form.
1480             @remove@<propname>=id(s) or @add@<propname>=id(s)
1481                 The "@add@" and "@remove@" edit actions apply only to
1482                 Multilink properties.  The form value must be a
1483                 comma-separate list of keys for the class specified by
1484                 the simple form variable.  The listed items are added
1485                 to (respectively, removed from) the specified
1486                 property.
1488             @link@<propname>=<designator>
1489                 If the edit action is "@link@", the simple form
1490                 variable must specify a Link or Multilink property.
1491                 The form value is a comma-separated list of
1492                 designators.  The item corresponding to each
1493                 designator is linked to the property given by simple
1494                 form variable.
1496 XXX              Used to add a link to new items created during edit.
1497 XXX              These are collected up and returned in all_links. This will
1498 XXX              result in an additional linking operation (either Link set or
1499 XXX              Multilink append) after the edit/create is done using
1500 XXX              all_props in _editnodes. The <propname> on the current item
1501 XXX              will be set/appended the id of the newly created item of
1502 XXX              class <designator> (where <designator> must be
1503 XXX              <classname>-<N>).
1505             None of the above (ie. just a simple form value)
1506                 The value of the form variable is converted
1507                 appropriately, depending on the type of the property.
1509                 For a Link('klass') property, the form value is a
1510                 single key for 'klass', where the key field is
1511                 specified in dbinit.py.  
1513                 For a Multilink('klass') property, the form value is a
1514                 comma-separated list of keys for 'klass', where the
1515                 key field is specified in dbinit.py.  
1517                 Note that for simple-form-variables specifiying Link
1518                 and Multilink properties, the linked-to class must
1519                 have a key field.
1521                 For a String() property specifying a filename, the
1522                 file named by the form value is uploaded. This means we
1523                 try to set additional properties "filename" and "type" (if
1524                 they are valid for the class).  Otherwise, the property
1525                 is set to the form value.
1527                 For Date(), Interval(), Boolean(), and Number()
1528                 properties, the form value is converted to the
1529                 appropriate
1531             Any of the form variables may be prefixed with a classname or
1532             designator.
1534             Two special form values are supported for backwards
1535             compatibility:
1537             @note
1538                 This is equivalent to::
1540                     @link@messages=msg-1
1541                     @msg-1@content=value
1543                 except that in addition, the "author" and "date"
1544                 properties of "msg-1" are set to the userid of the
1545                 submitter, and the current time, respectively.
1547             @file
1548                 This is equivalent to::
1550                     @link@files=file-1
1551                     @file-1@content=value
1553                 The String content value is handled as described above for
1554                 file uploads.
1556             If both the "@note" and "@file" form variables are
1557             specified, the action::
1559                     @link@msg-1@files=file-1
1561             is also performed.
1563             We also check that FileClass items have a "content" property with
1564             actual content, otherwise we remove them from all_props before
1565             returning.
1567             The return from this method is a dict of 
1568                 (classname, id): properties
1569             ... this dict _always_ has an entry for the current context,
1570             even if it's empty (ie. a submission for an existing issue that
1571             doesn't result in any changes would return {('issue','123'): {}})
1572             The id may be None, which indicates that an item should be
1573             created.
1574         '''
1575         # some very useful variables
1576         db = self.db
1577         form = self.form
1579         if not hasattr(self, 'FV_SPECIAL'):
1580             # generate the regexp for handling special form values
1581             classes = '|'.join(db.classes.keys())
1582             # specials for parsePropsFromForm
1583             # handle the various forms (see unit tests)
1584             self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1585             self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1587         # these indicate the default class / item
1588         default_cn = self.classname
1589         default_cl = self.db.classes[default_cn]
1590         default_nodeid = self.nodeid
1592         # we'll store info about the individual class/item edit in these
1593         all_required = {}       # one entry per class/item
1594         all_props = {}          # one entry per class/item
1595         all_propdef = {}        # note - only one entry per class
1596         all_links = []          # as many as are required
1598         # we should always return something, even empty, for the context
1599         all_props[(default_cn, default_nodeid)] = {}
1601         keys = form.keys()
1602         timezone = db.getUserTimezone()
1604         # sentinels for the :note and :file props
1605         have_note = have_file = 0
1607         # extract the usable form labels from the form
1608         matches = []
1609         for key in keys:
1610             m = self.FV_SPECIAL.match(key)
1611             if m:
1612                 matches.append((key, m.groupdict()))
1614         # now handle the matches
1615         for key, d in matches:
1616             if d['classname']:
1617                 # we got a designator
1618                 cn = d['classname']
1619                 cl = self.db.classes[cn]
1620                 nodeid = d['id']
1621                 propname = d['propname']
1622             elif d['note']:
1623                 # the special note field
1624                 cn = 'msg'
1625                 cl = self.db.classes[cn]
1626                 nodeid = '-1'
1627                 propname = 'content'
1628                 all_links.append((default_cn, default_nodeid, 'messages',
1629                     [('msg', '-1')]))
1630                 have_note = 1
1631             elif d['file']:
1632                 # the special file field
1633                 cn = 'file'
1634                 cl = self.db.classes[cn]
1635                 nodeid = '-1'
1636                 propname = 'content'
1637                 all_links.append((default_cn, default_nodeid, 'files',
1638                     [('file', '-1')]))
1639                 have_file = 1
1640             else:
1641                 # default
1642                 cn = default_cn
1643                 cl = default_cl
1644                 nodeid = default_nodeid
1645                 propname = d['propname']
1647             # the thing this value relates to is...
1648             this = (cn, nodeid)
1650             # get more info about the class, and the current set of
1651             # form props for it
1652             if not all_propdef.has_key(cn):
1653                 all_propdef[cn] = cl.getprops()
1654             propdef = all_propdef[cn]
1655             if not all_props.has_key(this):
1656                 all_props[this] = {}
1657             props = all_props[this]
1659             # is this a link command?
1660             if d['link']:
1661                 value = []
1662                 for entry in extractFormList(form[key]):
1663                     m = self.FV_DESIGNATOR.match(entry)
1664                     if not m:
1665                         raise ValueError, \
1666                             'link "%s" value "%s" not a designator'%(key, entry)
1667                     value.append((m.group(1), m.group(2)))
1669                 # make sure the link property is valid
1670                 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1671                         not isinstance(propdef[propname], hyperdb.Link)):
1672                     raise ValueError, '%s %s is not a link or '\
1673                         'multilink property'%(cn, propname)
1675                 all_links.append((cn, nodeid, propname, value))
1676                 continue
1678             # detect the special ":required" variable
1679             if d['required']:
1680                 all_required[this] = extractFormList(form[key])
1681                 continue
1683             # get the required values list
1684             if not all_required.has_key(this):
1685                 all_required[this] = []
1686             required = all_required[this]
1688             # see if we're performing a special multilink action
1689             mlaction = 'set'
1690             if d['remove']:
1691                 mlaction = 'remove'
1692             elif d['add']:
1693                 mlaction = 'add'
1695             # does the property exist?
1696             if not propdef.has_key(propname):
1697                 if mlaction != 'set':
1698                     raise ValueError, 'You have submitted a %s action for'\
1699                         ' the property "%s" which doesn\'t exist'%(mlaction,
1700                         propname)
1701                 # the form element is probably just something we don't care
1702                 # about - ignore it
1703                 continue
1704             proptype = propdef[propname]
1706             # Get the form value. This value may be a MiniFieldStorage or a list
1707             # of MiniFieldStorages.
1708             value = form[key]
1710             # handle unpacking of the MiniFieldStorage / list form value
1711             if isinstance(proptype, hyperdb.Multilink):
1712                 value = extractFormList(value)
1713             else:
1714                 # multiple values are not OK
1715                 if isinstance(value, type([])):
1716                     raise ValueError, 'You have submitted more than one value'\
1717                         ' for the %s property'%propname
1718                 # value might be a file upload...
1719                 if not hasattr(value, 'filename') or value.filename is None:
1720                     # nope, pull out the value and strip it
1721                     value = value.value.strip()
1723             # now that we have the props field, we need a teensy little
1724             # extra bit of help for the old :note field...
1725             if d['note'] and value:
1726                 props['author'] = self.db.getuid()
1727                 props['date'] = date.Date()
1729             # handle by type now
1730             if isinstance(proptype, hyperdb.Password):
1731                 if not value:
1732                     # ignore empty password values
1733                     continue
1734                 for key, d in matches:
1735                     if d['confirm'] and d['propname'] == propname:
1736                         confirm = form[key]
1737                         break
1738                 else:
1739                     raise ValueError, 'Password and confirmation text do '\
1740                         'not match'
1741                 if isinstance(confirm, type([])):
1742                     raise ValueError, 'You have submitted more than one value'\
1743                         ' for the %s property'%propname
1744                 if value != confirm.value:
1745                     raise ValueError, 'Password and confirmation text do '\
1746                         'not match'
1747                 value = password.Password(value)
1749             elif isinstance(proptype, hyperdb.Link):
1750                 # see if it's the "no selection" choice
1751                 if value == '-1' or not value:
1752                     # if we're creating, just don't include this property
1753                     if not nodeid or nodeid.startswith('-'):
1754                         continue
1755                     value = None
1756                 else:
1757                     # handle key values
1758                     link = proptype.classname
1759                     if not num_re.match(value):
1760                         try:
1761                             value = db.classes[link].lookup(value)
1762                         except KeyError:
1763                             raise ValueError, _('property "%(propname)s": '
1764                                 '%(value)s not a %(classname)s')%{
1765                                 'propname': propname, 'value': value,
1766                                 'classname': link}
1767                         except TypeError, message:
1768                             raise ValueError, _('you may only enter ID values '
1769                                 'for property "%(propname)s": %(message)s')%{
1770                                 'propname': propname, 'message': message}
1771             elif isinstance(proptype, hyperdb.Multilink):
1772                 # perform link class key value lookup if necessary
1773                 link = proptype.classname
1774                 link_cl = db.classes[link]
1775                 l = []
1776                 for entry in value:
1777                     if not entry: continue
1778                     if not num_re.match(entry):
1779                         try:
1780                             entry = link_cl.lookup(entry)
1781                         except KeyError:
1782                             raise ValueError, _('property "%(propname)s": '
1783                                 '"%(value)s" not an entry of %(classname)s')%{
1784                                 'propname': propname, 'value': entry,
1785                                 'classname': link}
1786                         except TypeError, message:
1787                             raise ValueError, _('you may only enter ID values '
1788                                 'for property "%(propname)s": %(message)s')%{
1789                                 'propname': propname, 'message': message}
1790                     l.append(entry)
1791                 l.sort()
1793                 # now use that list of ids to modify the multilink
1794                 if mlaction == 'set':
1795                     value = l
1796                 else:
1797                     # we're modifying the list - get the current list of ids
1798                     if props.has_key(propname):
1799                         existing = props[propname]
1800                     elif nodeid and not nodeid.startswith('-'):
1801                         existing = cl.get(nodeid, propname, [])
1802                     else:
1803                         existing = []
1805                     # now either remove or add
1806                     if mlaction == 'remove':
1807                         # remove - handle situation where the id isn't in
1808                         # the list
1809                         for entry in l:
1810                             try:
1811                                 existing.remove(entry)
1812                             except ValueError:
1813                                 raise ValueError, _('property "%(propname)s": '
1814                                     '"%(value)s" not currently in list')%{
1815                                     'propname': propname, 'value': entry}
1816                     else:
1817                         # add - easy, just don't dupe
1818                         for entry in l:
1819                             if entry not in existing:
1820                                 existing.append(entry)
1821                     value = existing
1822                     value.sort()
1824             elif value == '':
1825                 # if we're creating, just don't include this property
1826                 if not nodeid or nodeid.startswith('-'):
1827                     continue
1828                 # other types should be None'd if there's no value
1829                 value = None
1830             else:
1831                 # handle ValueErrors for all these in a similar fashion
1832                 try:
1833                     if isinstance(proptype, hyperdb.String):
1834                         if (hasattr(value, 'filename') and
1835                                 value.filename is not None):
1836                             # skip if the upload is empty
1837                             if not value.filename:
1838                                 continue
1839                             # this String is actually a _file_
1840                             # try to determine the file content-type
1841                             fn = value.filename.split('\\')[-1]
1842                             if propdef.has_key('name'):
1843                                 props['name'] = fn
1844                             # use this info as the type/filename properties
1845                             if propdef.has_key('type'):
1846                                 props['type'] = mimetypes.guess_type(fn)[0]
1847                                 if not props['type']:
1848                                     props['type'] = "application/octet-stream"
1849                             # finally, read the content
1850                             value = value.value
1851                         else:
1852                             # normal String fix the CRLF/CR -> LF stuff
1853                             value = fixNewlines(value)
1855                     elif isinstance(proptype, hyperdb.Date):
1856                         value = date.Date(value, offset=timezone)
1857                     elif isinstance(proptype, hyperdb.Interval):
1858                         value = date.Interval(value)
1859                     elif isinstance(proptype, hyperdb.Boolean):
1860                         value = value.lower() in ('yes', 'true', 'on', '1')
1861                     elif isinstance(proptype, hyperdb.Number):
1862                         value = float(value)
1863                 except ValueError, msg:
1864                     raise ValueError, _('Error with %s property: %s')%(
1865                         propname, msg)
1867             # get the old value
1868             if nodeid and not nodeid.startswith('-'):
1869                 try:
1870                     existing = cl.get(nodeid, propname)
1871                 except KeyError:
1872                     # this might be a new property for which there is
1873                     # no existing value
1874                     if not propdef.has_key(propname):
1875                         raise
1877                 # make sure the existing multilink is sorted
1878                 if isinstance(proptype, hyperdb.Multilink):
1879                     existing.sort()
1881                 # "missing" existing values may not be None
1882                 if not existing:
1883                     if isinstance(proptype, hyperdb.String) and not existing:
1884                         # some backends store "missing" Strings as empty strings
1885                         existing = None
1886                     elif isinstance(proptype, hyperdb.Number) and not existing:
1887                         # some backends store "missing" Numbers as 0 :(
1888                         existing = 0
1889                     elif isinstance(proptype, hyperdb.Boolean) and not existing:
1890                         # likewise Booleans
1891                         existing = 0
1893                 # if changed, set it
1894                 if value != existing:
1895                     props[propname] = value
1896             else:
1897                 # don't bother setting empty/unset values
1898                 if value is None:
1899                     continue
1900                 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1901                     continue
1902                 elif isinstance(proptype, hyperdb.String) and value == '':
1903                     continue
1905                 props[propname] = value
1907             # register this as received if required?
1908             if propname in required and value is not None:
1909                 required.remove(propname)
1911         # check to see if we need to specially link a file to the note
1912         if have_note and have_file:
1913             all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1915         # see if all the required properties have been supplied
1916         s = []
1917         for thing, required in all_required.items():
1918             if not required:
1919                 continue
1920             if len(required) > 1:
1921                 p = 'properties'
1922             else:
1923                 p = 'property'
1924             s.append('Required %s %s %s not supplied'%(thing[0], p,
1925                 ', '.join(required)))
1926         if s:
1927             raise ValueError, '\n'.join(s)
1929         # check that FileClass entries have a "content" property with
1930         # content, otherwise remove them
1931         for (cn, id), props in all_props.items():
1932             cl = self.db.classes[cn]
1933             if not isinstance(cl, hyperdb.FileClass):
1934                 continue
1935             # we also don't want to create FileClass items with no content
1936             if not props.get('content', ''):
1937                 del all_props[(cn, id)]
1938         return all_props, all_links
1940 def fixNewlines(text):
1941     ''' Homogenise line endings.
1943         Different web clients send different line ending values, but
1944         other systems (eg. email) don't necessarily handle those line
1945         endings. Our solution is to convert all line endings to LF.
1946     '''
1947     text = text.replace('\r\n', '\n')
1948     return text.replace('\r', '\n')
1950 def extractFormList(value):
1951     ''' Extract a list of values from the form value.
1953         It may be one of:
1954          [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
1955          MiniFieldStorage('value,value,...')
1956          MiniFieldStorage('value')
1957     '''
1958     # multiple values are OK
1959     if isinstance(value, type([])):
1960         # it's a list of MiniFieldStorages - join then into
1961         values = ','.join([i.value.strip() for i in value])
1962     else:
1963         # it's a MiniFieldStorage, but may be a comma-separated list
1964         # of values
1965         values = value.value
1967     value = [i.strip() for i in values.split(',')]
1969     # filter out the empty bits
1970     return filter(None, value)