Code

removed Pragma: no-cache so that Mozilla and its ilk only load pages once per request!
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.112 2003-04-10 04:32:46 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
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
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', '')
35 # XXX actually _use_ FormError
36 class FormError(ValueError):
37     ''' An "expected" exception occurred during form parsing.
38         - ie. something we know can go wrong, and don't want to alarm the
39           user with
41         We trap this at the user interface level and feed back a nice error
42         to the user.
43     '''
44     pass
46 class SendFile(Exception):
47     ''' Send a file from the database '''
49 class SendStaticFile(Exception):
50     ''' Send a static file from the instance html directory '''
52 def initialiseSecurity(security):
53     ''' Create some Permissions and Roles on the security object
55         This function is directly invoked by security.Security.__init__()
56         as a part of the Security object instantiation.
57     '''
58     security.addPermission(name="Web Registration",
59         description="User may register through the web")
60     p = security.addPermission(name="Web Access",
61         description="User may access the web interface")
62     security.addPermissionToRole('Admin', p)
64     # doing Role stuff through the web - make sure Admin can
65     p = security.addPermission(name="Web Roles",
66         description="User may manipulate user Roles through the web")
67     security.addPermissionToRole('Admin', p)
69 class Client:
70     ''' Instantiate to handle one CGI request.
72     See inner_main for request processing.
74     Client attributes at instantiation:
75         "path" is the PATH_INFO inside the instance (with no leading '/')
76         "base" is the base URL for the instance
77         "form" is the cgi form, an instance of FieldStorage from the standard
78                cgi module
79         "additional_headers" is a dictionary of additional HTTP headers that
80                should be sent to the client
81         "response_code" is the HTTP response code to send to the client
83     During the processing of a request, the following attributes are used:
84         "error_message" holds a list of error messages
85         "ok_message" holds a list of OK messages
86         "session" is the current user session id
87         "user" is the current user's name
88         "userid" is the current user's id
89         "template" is the current :template context
90         "classname" is the current class context name
91         "nodeid" is the current context item id
93     User Identification:
94      If the user has no login cookie, then they are anonymous and are logged
95      in as that user. This typically gives them all Permissions assigned to the
96      Anonymous Role.
98      Once a user logs in, they are assigned a session. The Client instance
99      keeps the nodeid of the session as the "session" attribute.
102     Special form variables:
103      Note that in various places throughout this code, special form
104      variables of the form :<name> are used. The colon (":") part may
105      actually be one of either ":" or "@".
106     '''
108     #
109     # special form variables
110     #
111     FV_TEMPLATE = re.compile(r'[@:]template')
112     FV_OK_MESSAGE = re.compile(r'[@:]ok_message')
113     FV_ERROR_MESSAGE = re.compile(r'[@:]error_message')
115     FV_QUERYNAME = re.compile(r'[@:]queryname')
117     # edit form variable handling (see unit tests)
118     FV_LABELS = r'''
119        ^(
120          (?P<note>[@:]note)|
121          (?P<file>[@:]file)|
122          (
123           ((?P<classname>%s)(?P<id>[-\d]+))?  # optional leading designator
124           ((?P<required>[@:]required$)|       # :required
125            (
126             (
127              (?P<add>[@:]add[@:])|            # :add:<prop>
128              (?P<remove>[@:]remove[@:])|      # :remove:<prop>
129              (?P<confirm>[@:]confirm[@:])|    # :confirm:<prop>
130              (?P<link>[@:]link[@:])|          # :link:<prop>
131              ([@:])                           # just a separator
132             )?
133             (?P<propname>[^@:]+)             # <prop>
134            )
135           )
136          )
137         )$'''
139     # Note: index page stuff doesn't appear here:
140     # columns, sort, sortdir, filter, group, groupdir, search_text,
141     # pagesize, startwith
143     def __init__(self, instance, request, env, form=None):
144         hyperdb.traceMark()
145         self.instance = instance
146         self.request = request
147         self.env = env
149         # save off the path
150         self.path = env['PATH_INFO']
152         # this is the base URL for this tracker
153         self.base = self.instance.config.TRACKER_WEB
155         # this is the "cookie path" for this tracker (ie. the path part of
156         # the "base" url)
157         self.cookie_path = urlparse.urlparse(self.base)[2]
158         self.cookie_name = 'roundup_session_' + re.sub('[^a-zA-Z]', '',
159             self.instance.config.TRACKER_NAME)
161         # see if we need to re-parse the environment for the form (eg Zope)
162         if form is None:
163             self.form = cgi.FieldStorage(environ=env)
164         else:
165             self.form = form
167         # turn debugging on/off
168         try:
169             self.debug = int(env.get("ROUNDUP_DEBUG", 0))
170         except ValueError:
171             # someone gave us a non-int debug level, turn it off
172             self.debug = 0
174         # flag to indicate that the HTTP headers have been sent
175         self.headers_done = 0
177         # additional headers to send with the request - must be registered
178         # before the first write
179         self.additional_headers = {}
180         self.response_code = 200
183     def main(self):
184         ''' Wrap the real main in a try/finally so we always close off the db.
185         '''
186         try:
187             self.inner_main()
188         finally:
189             if hasattr(self, 'db'):
190                 self.db.close()
192     def inner_main(self):
193         ''' Process a request.
195             The most common requests are handled like so:
196             1. figure out who we are, defaulting to the "anonymous" user
197                see determine_user
198             2. figure out what the request is for - the context
199                see determine_context
200             3. handle any requested action (item edit, search, ...)
201                see handle_action
202             4. render a template, resulting in HTML output
204             In some situations, exceptions occur:
205             - HTTP Redirect  (generally raised by an action)
206             - SendFile       (generally raised by determine_context)
207               serve up a FileClass "content" property
208             - SendStaticFile (generally raised by determine_context)
209               serve up a file from the tracker "html" directory
210             - Unauthorised   (generally raised by an action)
211               the action is cancelled, the request is rendered and an error
212               message is displayed indicating that permission was not
213               granted for the action to take place
214             - NotFound       (raised wherever it needs to be)
215               percolates up to the CGI interface that called the client
216         '''
217         self.ok_message = []
218         self.error_message = []
219         try:
220             # make sure we're identified (even anonymously)
221             self.determine_user()
222             # figure out the context and desired content template
223             self.determine_context()
224             # possibly handle a form submit action (may change self.classname
225             # and self.template, and may also append error/ok_messages)
226             self.handle_action()
228             # now render the page
229             # we don't want clients caching our dynamic pages
230             self.additional_headers['Cache-Control'] = 'no-cache'
231 # Pragma: no-cache makes Mozilla and its ilk double-load all pages!!
232 #            self.additional_headers['Pragma'] = 'no-cache'
234             # expire this page 5 seconds from now
235             date = rfc822.formatdate(time.time() + 5)
236             self.additional_headers['Expires'] = date
238             # render the content
239             self.write(self.renderContext())
240         except Redirect, url:
241             # let's redirect - if the url isn't None, then we need to do
242             # the headers, otherwise the headers have been set before the
243             # exception was raised
244             if url:
245                 self.additional_headers['Location'] = url
246                 self.response_code = 302
247             self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
248         except SendFile, designator:
249             self.serve_file(designator)
250         except SendStaticFile, file:
251             try:
252                 self.serve_static_file(str(file))
253             except NotModified:
254                 # send the 304 response
255                 self.request.send_response(304)
256                 self.request.end_headers()
257         except Unauthorised, message:
258             self.classname = None
259             self.template = ''
260             self.error_message.append(message)
261             self.write(self.renderContext())
262         except NotFound:
263             # pass through
264             raise
265         except:
266             # everything else
267             self.write(cgitb.html())
269     def clean_sessions(self):
270         ''' Age sessions, remove when they haven't been used for a week.
271         
272             Do it only once an hour.
274             Note: also cleans One Time Keys, and other "session" based
275             stuff.
276         '''
277         sessions = self.db.sessions
278         last_clean = sessions.get('last_clean', 'last_use') or 0
280         week = 60*60*24*7
281         hour = 60*60
282         now = time.time()
283         if now - last_clean > hour:
284             # remove aged sessions
285             for sessid in sessions.list():
286                 interval = now - sessions.get(sessid, 'last_use')
287                 if interval > week:
288                     sessions.destroy(sessid)
289             # remove aged otks
290             otks = self.db.otks
291             for sessid in otks.list():
292                 interval = now - otks.get(sessid, '__time')
293                 if interval > week:
294                     otks.destroy(sessid)
295             sessions.set('last_clean', last_use=time.time())
297     def determine_user(self):
298         ''' Determine who the user is
299         '''
300         # determine the uid to use
301         self.opendb('admin')
302         # clean age sessions
303         self.clean_sessions()
304         # make sure we have the session Class
305         sessions = self.db.sessions
307         # look up the user session cookie
308         cookie = Cookie.SimpleCookie(self.env.get('HTTP_COOKIE', ''))
309         user = 'anonymous'
311         # bump the "revision" of the cookie since the format changed
312         if (cookie.has_key(self.cookie_name) and
313                 cookie[self.cookie_name].value != 'deleted'):
315             # get the session key from the cookie
316             self.session = cookie[self.cookie_name].value
317             # get the user from the session
318             try:
319                 # update the lifetime datestamp
320                 sessions.set(self.session, last_use=time.time())
321                 sessions.commit()
322                 user = sessions.get(self.session, 'user')
323             except KeyError:
324                 user = 'anonymous'
326         # sanity check on the user still being valid, getting the userid
327         # at the same time
328         try:
329             self.userid = self.db.user.lookup(user)
330         except (KeyError, TypeError):
331             user = 'anonymous'
333         # make sure the anonymous user is valid if we're using it
334         if user == 'anonymous':
335             self.make_user_anonymous()
336         else:
337             self.user = user
339         # reopen the database as the correct user
340         self.opendb(self.user)
342     def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
343         ''' Determine the context of this page from the URL:
345             The URL path after the instance identifier is examined. The path
346             is generally only one entry long.
348             - if there is no path, then we are in the "home" context.
349             * if the path is "_file", then the additional path entry
350               specifies the filename of a static file we're to serve up
351               from the instance "html" directory. Raises a SendStaticFile
352               exception.
353             - if there is something in the path (eg "issue"), it identifies
354               the tracker class we're to display.
355             - if the path is an item designator (eg "issue123"), then we're
356               to display a specific item.
357             * if the path starts with an item designator and is longer than
358               one entry, then we're assumed to be handling an item of a
359               FileClass, and the extra path information gives the filename
360               that the client is going to label the download with (ie
361               "file123/image.png" is nicer to download than "file123"). This
362               raises a SendFile exception.
364             Both of the "*" types of contexts stop before we bother to
365             determine the template we're going to use. That's because they
366             don't actually use templates.
368             The template used is specified by the :template CGI variable,
369             which defaults to:
371              only classname suplied:          "index"
372              full item designator supplied:   "item"
374             We set:
375              self.classname  - the class to display, can be None
376              self.template   - the template to render the current context with
377              self.nodeid     - the nodeid of the class we're displaying
378         '''
379         # default the optional variables
380         self.classname = None
381         self.nodeid = None
383         # see if a template or messages are specified
384         template_override = ok_message = error_message = None
385         for key in self.form.keys():
386             if self.FV_TEMPLATE.match(key):
387                 template_override = self.form[key].value
388             elif self.FV_OK_MESSAGE.match(key):
389                 ok_message = self.form[key].value
390             elif self.FV_ERROR_MESSAGE.match(key):
391                 error_message = self.form[key].value
393         # determine the classname and possibly nodeid
394         path = self.path.split('/')
395         if not path or path[0] in ('', 'home', 'index'):
396             if template_override is not None:
397                 self.template = template_override
398             else:
399                 self.template = ''
400             return
401         elif path[0] == '_file':
402             raise SendStaticFile, os.path.join(*path[1:])
403         else:
404             self.classname = path[0]
405             if len(path) > 1:
406                 # send the file identified by the designator in path[0]
407                 raise SendFile, path[0]
409         # see if we got a designator
410         m = dre.match(self.classname)
411         if m:
412             self.classname = m.group(1)
413             self.nodeid = m.group(2)
414             if not self.db.getclass(self.classname).hasnode(self.nodeid):
415                 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
416             # with a designator, we default to item view
417             self.template = 'item'
418         else:
419             # with only a class, we default to index view
420             self.template = 'index'
422         # make sure the classname is valid
423         try:
424             self.db.getclass(self.classname)
425         except KeyError:
426             raise NotFound, self.classname
428         # see if we have a template override
429         if template_override is not None:
430             self.template = template_override
432         # see if we were passed in a message
433         if ok_message:
434             self.ok_message.append(ok_message)
435         if error_message:
436             self.error_message.append(error_message)
438     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
439         ''' Serve the file from the content property of the designated item.
440         '''
441         m = dre.match(str(designator))
442         if not m:
443             raise NotFound, str(designator)
444         classname, nodeid = m.group(1), m.group(2)
445         if classname != 'file':
446             raise NotFound, designator
448         # we just want to serve up the file named
449         file = self.db.file
450         self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
451         self.write(file.get(nodeid, 'content'))
453     def serve_static_file(self, file):
454         ims = None
455         # see if there's an if-modified-since...
456         if hasattr(self.request, 'headers'):
457             ims = self.request.headers.getheader('if-modified-since')
458         elif self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
459             # cgi will put the header in the env var
460             ims = self.env['HTTP_IF_MODIFIED_SINCE']
461         filename = os.path.join(self.instance.config.TEMPLATES, file)
462         lmt = os.stat(filename)[stat.ST_MTIME]
463         if ims:
464             ims = rfc822.parsedate(ims)[:6]
465             lmtt = time.gmtime(lmt)[:6]
466             if lmtt <= ims:
467                 raise NotModified
469         # we just want to serve up the file named
470         file = str(file)
471         mt = mimetypes.guess_type(file)[0]
472         if not mt:
473             if file.endswith('.css'):
474                 mt = 'text/css'
475             else:
476                 mt = 'text/plain'
477         self.additional_headers['Content-Type'] = mt
478         self.additional_headers['Last-Modifed'] = rfc822.formatdate(lmt)
479         self.write(open(filename, 'rb').read())
481     def renderContext(self):
482         ''' Return a PageTemplate for the named page
483         '''
484         name = self.classname
485         extension = self.template
486         pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
488         # catch errors so we can handle PT rendering errors more nicely
489         args = {
490             'ok_message': self.ok_message,
491             'error_message': self.error_message
492         }
493         try:
494             # let the template render figure stuff out
495             return pt.render(self, None, None, **args)
496         except NoTemplate, message:
497             return '<strong>%s</strong>'%message
498         except:
499             # everything else
500             return cgitb.pt_html()
502     # these are the actions that are available
503     actions = (
504         ('edit',     'editItemAction'),
505         ('editcsv',  'editCSVAction'),
506         ('new',      'newItemAction'),
507         ('register', 'registerAction'),
508         ('confrego', 'confRegoAction'),
509         ('passrst',  'passResetAction'),
510         ('login',    'loginAction'),
511         ('logout',   'logout_action'),
512         ('search',   'searchAction'),
513         ('retire',   'retireAction'),
514         ('show',     'showAction'),
515     )
516     def handle_action(self):
517         ''' Determine whether there should be an Action called.
519             The action is defined by the form variable :action which
520             identifies the method on this object to call. The actions
521             are defined in the "actions" sequence on this class.
522         '''
523         if self.form.has_key(':action'):
524             action = self.form[':action'].value.lower()
525         elif self.form.has_key('@action'):
526             action = self.form['@action'].value.lower()
527         else:
528             return None
529         try:
530             # get the action, validate it
531             for name, method in self.actions:
532                 if name == action:
533                     break
534             else:
535                 raise ValueError, 'No such action "%s"'%action
536             # call the mapped action
537             getattr(self, method)()
538         except Redirect:
539             raise
540         except Unauthorised:
541             raise
543     def write(self, content):
544         if not self.headers_done:
545             self.header()
546         self.request.wfile.write(content)
548     def header(self, headers=None, response=None):
549         '''Put up the appropriate header.
550         '''
551         if headers is None:
552             headers = {'Content-Type':'text/html'}
553         if response is None:
554             response = self.response_code
556         # update with additional info
557         headers.update(self.additional_headers)
559         if not headers.has_key('Content-Type'):
560             headers['Content-Type'] = 'text/html'
561         self.request.send_response(response)
562         for entry in headers.items():
563             self.request.send_header(*entry)
564         self.request.end_headers()
565         self.headers_done = 1
566         if self.debug:
567             self.headers_sent = headers
569     def set_cookie(self, user):
570         ''' Set up a session cookie for the user and store away the user's
571             login info against the session.
572         '''
573         # TODO generate a much, much stronger session key ;)
574         self.session = binascii.b2a_base64(repr(random.random())).strip()
576         # clean up the base64
577         if self.session[-1] == '=':
578             if self.session[-2] == '=':
579                 self.session = self.session[:-2]
580             else:
581                 self.session = self.session[:-1]
583         # insert the session in the sessiondb
584         self.db.sessions.set(self.session, user=user, last_use=time.time())
586         # and commit immediately
587         self.db.sessions.commit()
589         # expire us in a long, long time
590         expire = Cookie._getdate(86400*365)
592         # generate the cookie path - make sure it has a trailing '/'
593         self.additional_headers['Set-Cookie'] = \
594           '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
595             expire, self.cookie_path)
597     def make_user_anonymous(self):
598         ''' Make us anonymous
600             This method used to handle non-existence of the 'anonymous'
601             user, but that user is mandatory now.
602         '''
603         self.userid = self.db.user.lookup('anonymous')
604         self.user = 'anonymous'
606     def opendb(self, user):
607         ''' Open the database.
608         '''
609         # open the db if the user has changed
610         if not hasattr(self, 'db') or user != self.db.journaltag:
611             if hasattr(self, 'db'):
612                 self.db.close()
613             self.db = self.instance.open(user)
615     #
616     # Actions
617     #
618     def loginAction(self):
619         ''' Attempt to log a user in.
621             Sets up a session for the user which contains the login
622             credentials.
623         '''
624         # we need the username at a minimum
625         if not self.form.has_key('__login_name'):
626             self.error_message.append(_('Username required'))
627             return
629         # get the login info
630         self.user = self.form['__login_name'].value
631         if self.form.has_key('__login_password'):
632             password = self.form['__login_password'].value
633         else:
634             password = ''
636         # make sure the user exists
637         try:
638             self.userid = self.db.user.lookup(self.user)
639         except KeyError:
640             name = self.user
641             self.error_message.append(_('No such user "%(name)s"')%locals())
642             self.make_user_anonymous()
643             return
645         # verify the password
646         if not self.verifyPassword(self.userid, password):
647             self.make_user_anonymous()
648             self.error_message.append(_('Incorrect password'))
649             return
651         # make sure we're allowed to be here
652         if not self.loginPermission():
653             self.make_user_anonymous()
654             self.error_message.append(_("You do not have permission to login"))
655             return
657         # now we're OK, re-open the database for real, using the user
658         self.opendb(self.user)
660         # set the session cookie
661         self.set_cookie(self.user)
663     def verifyPassword(self, userid, password):
664         ''' Verify the password that the user has supplied
665         '''
666         stored = self.db.user.get(self.userid, 'password')
667         if password == stored:
668             return 1
669         if not password and not stored:
670             return 1
671         return 0
673     def loginPermission(self):
674         ''' Determine whether the user has permission to log in.
676             Base behaviour is to check the user has "Web Access".
677         ''' 
678         if not self.db.security.hasPermission('Web Access', self.userid):
679             return 0
680         return 1
682     def logout_action(self):
683         ''' Make us really anonymous - nuke the cookie too
684         '''
685         # log us out
686         self.make_user_anonymous()
688         # construct the logout cookie
689         now = Cookie._getdate()
690         self.additional_headers['Set-Cookie'] = \
691            '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.cookie_name,
692             now, self.cookie_path)
694         # Let the user know what's going on
695         self.ok_message.append(_('You are logged out'))
697     chars = string.letters+string.digits
698     def registerAction(self):
699         '''Attempt to create a new user based on the contents of the form
700         and then set the cookie.
702         return 1 on successful login
703         '''
704         # parse the props from the form
705         try:
706             props = self.parsePropsFromForm()[0][('user', None)]
707         except (ValueError, KeyError), message:
708             self.error_message.append(_('Error: ') + str(message))
709             return
711         # make sure we're allowed to register
712         if not self.registerPermission(props):
713             raise Unauthorised, _("You do not have permission to register")
715         try:
716             self.db.user.lookup(props['username'])
717             self.error_message.append('Error: A user with the username "%s" '
718                 'already exists'%props['username'])
719             return
720         except KeyError:
721             pass
723         # generate the one-time-key and store the props for later
724         otk = ''.join([random.choice(self.chars) for x in range(32)])
725         for propname, proptype in self.db.user.getprops().items():
726             value = props.get(propname, None)
727             if value is None:
728                 pass
729             elif isinstance(proptype, hyperdb.Date):
730                 props[propname] = str(value)
731             elif isinstance(proptype, hyperdb.Interval):
732                 props[propname] = str(value)
733             elif isinstance(proptype, hyperdb.Password):
734                 props[propname] = str(value)
735         props['__time'] = time.time()
736         self.db.otks.set(otk, **props)
738         # send the email
739         tracker_name = self.db.config.TRACKER_NAME
740         subject = 'Complete your registration to %s'%tracker_name
741         body = '''
742 To complete your registration of the user "%(name)s" with %(tracker)s,
743 please visit the following URL:
745    %(url)s?@action=confrego&otk=%(otk)s
746 '''%{'name': props['username'], 'tracker': tracker_name, 'url': self.base,
747                 'otk': otk}
748         if not self.sendEmail(props['address'], subject, body):
749             return
751         # commit changes to the database
752         self.db.commit()
754         # redirect to the "you're almost there" page
755         raise Redirect, '%suser?@template=rego_progress'%self.base
757     def sendEmail(self, to, subject, content):
758         # send email to the user's email address
759         message = StringIO.StringIO()
760         writer = MimeWriter.MimeWriter(message)
761         tracker_name = self.db.config.TRACKER_NAME
762         writer.addheader('Subject', encode_header(subject))
763         writer.addheader('To', to)
764         writer.addheader('From', roundupdb.straddr((tracker_name,
765             self.db.config.ADMIN_EMAIL)))
766         writer.addheader('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000",
767             time.gmtime()))
768         # add a uniquely Roundup header to help filtering
769         writer.addheader('X-Roundup-Name', tracker_name)
770         # avoid email loops
771         writer.addheader('X-Roundup-Loop', 'hello')
772         writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
773         body = writer.startbody('text/plain; charset=utf-8')
775         # message body, encoded quoted-printable
776         content = StringIO.StringIO(content)
777         quopri.encode(content, body, 0)
779         if SENDMAILDEBUG:
780             # don't send - just write to a file
781             open(SENDMAILDEBUG, 'a').write('FROM: %s\nTO: %s\n%s\n'%(
782                 self.db.config.ADMIN_EMAIL,
783                 ', '.join(to),message.getvalue()))
784         else:
785             # now try to send the message
786             try:
787                 # send the message as admin so bounces are sent there
788                 # instead of to roundup
789                 smtp = smtplib.SMTP(self.db.config.MAILHOST)
790                 smtp.sendmail(self.db.config.ADMIN_EMAIL, [to],
791                     message.getvalue())
792             except socket.error, value:
793                 self.error_message.append("Error: couldn't send email: "
794                     "mailhost %s"%value)
795                 return 0
796             except smtplib.SMTPException, msg:
797                 self.error_message.append("Error: couldn't send email: %s"%msg)
798                 return 0
799         return 1
801     def registerPermission(self, props):
802         ''' Determine whether the user has permission to register
804             Base behaviour is to check the user has "Web Registration".
805         '''
806         # registration isn't allowed to supply roles
807         if props.has_key('roles'):
808             return 0
809         if self.db.security.hasPermission('Web Registration', self.userid):
810             return 1
811         return 0
813     def confRegoAction(self):
814         ''' Grab the OTK, use it to load up the new user details
815         '''
816         # pull the rego information out of the otk database
817         otk = self.form['otk'].value
818         props = self.db.otks.getall(otk)
819         for propname, proptype in self.db.user.getprops().items():
820             value = props.get(propname, None)
821             if value is None:
822                 pass
823             elif isinstance(proptype, hyperdb.Date):
824                 props[propname] = date.Date(value)
825             elif isinstance(proptype, hyperdb.Interval):
826                 props[propname] = date.Interval(value)
827             elif isinstance(proptype, hyperdb.Password):
828                 props[propname] = password.Password()
829                 props[propname].unpack(value)
831         # re-open the database as "admin"
832         if self.user != 'admin':
833             self.opendb('admin')
835         # create the new user
836         cl = self.db.user
837 # XXX we need to make the "default" page be able to display errors!
838         try:
839             props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
840             del props['__time']
841             self.userid = cl.create(**props)
842             # clear the props from the otk database
843             self.db.otks.destroy(otk)
844             self.db.commit()
845         except (ValueError, KeyError), message:
846             self.error_message.append(str(message))
847             return
849         # log the new user in
850         self.user = cl.get(self.userid, 'username')
851         # re-open the database for real, using the user
852         self.opendb(self.user)
854         # if we have a session, update it
855         if hasattr(self, 'session'):
856             self.db.sessions.set(self.session, user=self.user,
857                 last_use=time.time())
858         else:
859             # new session cookie
860             self.set_cookie(self.user)
862         # nice message
863         message = _('You are now registered, welcome!')
865         # redirect to the user's page
866         raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
867             self.userid, urllib.quote(message))
869     def passResetAction(self):
870         ''' Handle password reset requests.
872             Presence of either "name" or "address" generate email.
873             Presense of "otk" performs the reset.
874         '''
875         if self.form.has_key('otk'):
876             # pull the rego information out of the otk database
877             otk = self.form['otk'].value
878             uid = self.db.otks.get(otk, 'uid')
879             if uid is None:
880                 self.error_message.append('Invalid One Time Key!')
881                 return
883             # re-open the database as "admin"
884             if self.user != 'admin':
885                 self.opendb('admin')
887             # change the password
888             newpw = ''.join([random.choice(self.chars) for x in range(8)])
890             cl = self.db.user
891 # XXX we need to make the "default" page be able to display errors!
892             try:
893                 # set the password
894                 cl.set(uid, password=password.Password(newpw))
895                 # clear the props from the otk database
896                 self.db.otks.destroy(otk)
897                 self.db.commit()
898             except (ValueError, KeyError), message:
899                 self.error_message.append(str(message))
900                 return
902             # user info
903             address = self.db.user.get(uid, 'address')
904             name = self.db.user.get(uid, 'username')
906             # send the email
907             tracker_name = self.db.config.TRACKER_NAME
908             subject = 'Password reset for %s'%tracker_name
909             body = '''
910 The password has been reset for username "%(name)s".
912 Your password is now: %(password)s
913 '''%{'name': name, 'password': newpw}
914             if not self.sendEmail(address, subject, body):
915                 return
917             self.ok_message.append('Password reset and email sent to %s'%address)
918             return
920         # no OTK, so now figure the user
921         if self.form.has_key('username'):
922             name = self.form['username'].value
923             try:
924                 uid = self.db.user.lookup(name)
925             except KeyError:
926                 self.error_message.append('Unknown username')
927                 return
928             address = self.db.user.get(uid, 'address')
929         elif self.form.has_key('address'):
930             address = self.form['address'].value
931             uid = uidFromAddress(self.db, ('', address), create=0)
932             if not uid:
933                 self.error_message.append('Unknown email address')
934                 return
935             name = self.db.user.get(uid, 'username')
936         else:
937             self.error_message.append('You need to specify a username '
938                 'or address')
939             return
941         # generate the one-time-key and store the props for later
942         otk = ''.join([random.choice(self.chars) for x in range(32)])
943         self.db.otks.set(otk, uid=uid, __time=time.time())
945         # send the email
946         tracker_name = self.db.config.TRACKER_NAME
947         subject = 'Confirm reset of password for %s'%tracker_name
948         body = '''
949 Someone, perhaps you, has requested that the password be changed for your
950 username, "%(name)s". If you wish to proceed with the change, please follow
951 the link below:
953   %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
955 You should then receive another email with the new password.
956 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
957         if not self.sendEmail(address, subject, body):
958             return
960         self.ok_message.append('Email sent to %s'%address)
962     def editItemAction(self):
963         ''' Perform an edit of an item in the database.
965            See parsePropsFromForm and _editnodes for special variables
966         '''
967         # parse the props from the form
968         try:
969             props, links = self.parsePropsFromForm()
970         except (ValueError, KeyError), message:
971             self.error_message.append(_('Error: ') + str(message))
972             return
974         # handle the props
975         try:
976             message = self._editnodes(props, links)
977         except (ValueError, KeyError, IndexError), message:
978             self.error_message.append(_('Error: ') + str(message))
979             return
981         # commit now that all the tricky stuff is done
982         self.db.commit()
984         # redirect to the item's edit page
985         raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
986             self.classname, self.nodeid, urllib.quote(message),
987             urllib.quote(self.template))
989     def editItemPermission(self, props):
990         ''' Determine whether the user has permission to edit this item.
992             Base behaviour is to check the user can edit this class. If we're
993             editing the "user" class, users are allowed to edit their own
994             details. Unless it's the "roles" property, which requires the
995             special Permission "Web Roles".
996         '''
997         # if this is a user node and the user is editing their own node, then
998         # we're OK
999         has = self.db.security.hasPermission
1000         if self.classname == 'user':
1001             # reject if someone's trying to edit "roles" and doesn't have the
1002             # right permission.
1003             if props.has_key('roles') and not has('Web Roles', self.userid,
1004                     'user'):
1005                 return 0
1006             # if the item being edited is the current user, we're ok
1007             if self.nodeid == self.userid:
1008                 return 1
1009         if self.db.security.hasPermission('Edit', self.userid, self.classname):
1010             return 1
1011         return 0
1013     def newItemAction(self):
1014         ''' Add a new item to the database.
1016             This follows the same form as the editItemAction, with the same
1017             special form values.
1018         '''
1019         # parse the props from the form
1020         try:
1021             props, links = self.parsePropsFromForm()
1022         except (ValueError, KeyError), message:
1023             self.error_message.append(_('Error: ') + str(message))
1024             return
1026         # handle the props - edit or create
1027         try:
1028             # when it hits the None element, it'll set self.nodeid
1029             messages = self._editnodes(props, links)
1031         except (ValueError, KeyError, IndexError), message:
1032             # these errors might just be indicative of user dumbness
1033             self.error_message.append(_('Error: ') + str(message))
1034             return
1036         # commit now that all the tricky stuff is done
1037         self.db.commit()
1039         # redirect to the new item's page
1040         raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
1041             self.classname, self.nodeid, urllib.quote(messages),
1042             urllib.quote(self.template))
1044     def newItemPermission(self, props):
1045         ''' Determine whether the user has permission to create (edit) this
1046             item.
1048             Base behaviour is to check the user can edit this class. No
1049             additional property checks are made. Additionally, new user items
1050             may be created if the user has the "Web Registration" Permission.
1051         '''
1052         has = self.db.security.hasPermission
1053         if self.classname == 'user' and has('Web Registration', self.userid,
1054                 'user'):
1055             return 1
1056         if has('Edit', self.userid, self.classname):
1057             return 1
1058         return 0
1061     #
1062     #  Utility methods for editing
1063     #
1064     def _editnodes(self, all_props, all_links, newids=None):
1065         ''' Use the props in all_props to perform edit and creation, then
1066             use the link specs in all_links to do linking.
1067         '''
1068         # figure dependencies and re-work links
1069         deps = {}
1070         links = {}
1071         for cn, nodeid, propname, vlist in all_links:
1072             if not all_props.has_key((cn, nodeid)):
1073                 # link item to link to doesn't (and won't) exist
1074                 continue
1075             for value in vlist:
1076                 if not all_props.has_key(value):
1077                     # link item to link to doesn't (and won't) exist
1078                     continue
1079                 deps.setdefault((cn, nodeid), []).append(value)
1080                 links.setdefault(value, []).append((cn, nodeid, propname))
1082         # figure chained dependencies ordering
1083         order = []
1084         done = {}
1085         # loop detection
1086         change = 0
1087         while len(all_props) != len(done):
1088             for needed in all_props.keys():
1089                 if done.has_key(needed):
1090                     continue
1091                 tlist = deps.get(needed, [])
1092                 for target in tlist:
1093                     if not done.has_key(target):
1094                         break
1095                 else:
1096                     done[needed] = 1
1097                     order.append(needed)
1098                     change = 1
1099             if not change:
1100                 raise ValueError, 'linking must not loop!'
1102         # now, edit / create
1103         m = []
1104         for needed in order:
1105             props = all_props[needed]
1106             if not props:
1107                 # nothing to do
1108                 continue
1109             cn, nodeid = needed
1111             if nodeid is not None and int(nodeid) > 0:
1112                 # make changes to the node
1113                 props = self._changenode(cn, nodeid, props)
1115                 # and some nice feedback for the user
1116                 if props:
1117                     info = ', '.join(props.keys())
1118                     m.append('%s %s %s edited ok'%(cn, nodeid, info))
1119                 else:
1120                     m.append('%s %s - nothing changed'%(cn, nodeid))
1121             else:
1122                 assert props
1124                 # make a new node
1125                 newid = self._createnode(cn, props)
1126                 if nodeid is None:
1127                     self.nodeid = newid
1128                 nodeid = newid
1130                 # and some nice feedback for the user
1131                 m.append('%s %s created'%(cn, newid))
1133             # fill in new ids in links
1134             if links.has_key(needed):
1135                 for linkcn, linkid, linkprop in links[needed]:
1136                     props = all_props[(linkcn, linkid)]
1137                     cl = self.db.classes[linkcn]
1138                     propdef = cl.getprops()[linkprop]
1139                     if not props.has_key(linkprop):
1140                         if linkid is None or linkid.startswith('-'):
1141                             # linking to a new item
1142                             if isinstance(propdef, hyperdb.Multilink):
1143                                 props[linkprop] = [newid]
1144                             else:
1145                                 props[linkprop] = newid
1146                         else:
1147                             # linking to an existing item
1148                             if isinstance(propdef, hyperdb.Multilink):
1149                                 existing = cl.get(linkid, linkprop)[:]
1150                                 existing.append(nodeid)
1151                                 props[linkprop] = existing
1152                             else:
1153                                 props[linkprop] = newid
1155         return '<br>'.join(m)
1157     def _changenode(self, cn, nodeid, props):
1158         ''' change the node based on the contents of the form
1159         '''
1160         # check for permission
1161         if not self.editItemPermission(props):
1162             raise Unauthorised, 'You do not have permission to edit %s'%cn
1164         # make the changes
1165         cl = self.db.classes[cn]
1166         return cl.set(nodeid, **props)
1168     def _createnode(self, cn, props):
1169         ''' create a node based on the contents of the form
1170         '''
1171         # check for permission
1172         if not self.newItemPermission(props):
1173             raise Unauthorised, 'You do not have permission to create %s'%cn
1175         # create the node and return its id
1176         cl = self.db.classes[cn]
1177         return cl.create(**props)
1179     # 
1180     # More actions
1181     #
1182     def editCSVAction(self):
1183         ''' Performs an edit of all of a class' items in one go.
1185             The "rows" CGI var defines the CSV-formatted entries for the
1186             class. New nodes are identified by the ID 'X' (or any other
1187             non-existent ID) and removed lines are retired.
1188         '''
1189         # this is per-class only
1190         if not self.editCSVPermission():
1191             self.error_message.append(
1192                 _('You do not have permission to edit %s' %self.classname))
1194         # get the CSV module
1195         try:
1196             import csv
1197         except ImportError:
1198             self.error_message.append(_(
1199                 'Sorry, you need the csv module to use this function.<br>\n'
1200                 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
1201             return
1203         cl = self.db.classes[self.classname]
1204         idlessprops = cl.getprops(protected=0).keys()
1205         idlessprops.sort()
1206         props = ['id'] + idlessprops
1208         # do the edit
1209         rows = self.form['rows'].value.splitlines()
1210         p = csv.parser()
1211         found = {}
1212         line = 0
1213         for row in rows[1:]:
1214             line += 1
1215             values = p.parse(row)
1216             # not a complete row, keep going
1217             if not values: continue
1219             # skip property names header
1220             if values == props:
1221                 continue
1223             # extract the nodeid
1224             nodeid, values = values[0], values[1:]
1225             found[nodeid] = 1
1227             # see if the node exists
1228             if cl.hasnode(nodeid):
1229                 exists = 1
1230             else:
1231                 exists = 0
1233             # confirm correct weight
1234             if len(idlessprops) != len(values):
1235                 self.error_message.append(
1236                     _('Not enough values on line %(line)s')%{'line':line})
1237                 return
1239             # extract the new values
1240             d = {}
1241             for name, value in zip(idlessprops, values):
1242                 prop = cl.properties[name]
1243                 value = value.strip()
1244                 # only add the property if it has a value
1245                 if value:
1246                     # if it's a multilink, split it
1247                     if isinstance(prop, hyperdb.Multilink):
1248                         value = value.split(':')
1249                     d[name] = value
1250                 elif exists:
1251                     # nuke the existing value
1252                     if isinstance(prop, hyperdb.Multilink):
1253                         d[name] = []
1254                     else:
1255                         d[name] = None
1257             # perform the edit
1258             if exists:
1259                 # edit existing
1260                 cl.set(nodeid, **d)
1261             else:
1262                 # new node
1263                 found[cl.create(**d)] = 1
1265         # retire the removed entries
1266         for nodeid in cl.list():
1267             if not found.has_key(nodeid):
1268                 cl.retire(nodeid)
1270         # all OK
1271         self.db.commit()
1273         self.ok_message.append(_('Items edited OK'))
1275     def editCSVPermission(self):
1276         ''' Determine whether the user has permission to edit this class.
1278             Base behaviour is to check the user can edit this class.
1279         ''' 
1280         if not self.db.security.hasPermission('Edit', self.userid,
1281                 self.classname):
1282             return 0
1283         return 1
1285     def searchAction(self):
1286         ''' Mangle some of the form variables.
1288             Set the form ":filter" variable based on the values of the
1289             filter variables - if they're set to anything other than
1290             "dontcare" then add them to :filter.
1292             Also handle the ":queryname" variable and save off the query to
1293             the user's query list.
1294         '''
1295         # generic edit is per-class only
1296         if not self.searchPermission():
1297             self.error_message.append(
1298                 _('You do not have permission to search %s' %self.classname))
1300         # add a faked :filter form variable for each filtering prop
1301         props = self.db.classes[self.classname].getprops()
1302         queryname = ''
1303         for key in self.form.keys():
1304             # special vars
1305             if self.FV_QUERYNAME.match(key):
1306                 queryname = self.form[key].value.strip()
1307                 continue
1309             if not props.has_key(key):
1310                 continue
1311             if isinstance(self.form[key], type([])):
1312                 # search for at least one entry which is not empty
1313                 for minifield in self.form[key]:
1314                     if minifield.value:
1315                         break
1316                 else:
1317                     continue
1318             else:
1319                 if not self.form[key].value:
1320                     continue
1321             self.form.value.append(cgi.MiniFieldStorage('@filter', key))
1323         # handle saving the query params
1324         if queryname:
1325             # parse the environment and figure what the query _is_
1326             req = HTMLRequest(self)
1327             url = req.indexargs_href('', {})
1329             # handle editing an existing query
1330             try:
1331                 qid = self.db.query.lookup(queryname)
1332                 self.db.query.set(qid, klass=self.classname, url=url)
1333             except KeyError:
1334                 # create a query
1335                 qid = self.db.query.create(name=queryname,
1336                     klass=self.classname, url=url)
1338                 # and add it to the user's query multilink
1339                 queries = self.db.user.get(self.userid, 'queries')
1340                 queries.append(qid)
1341                 self.db.user.set(self.userid, queries=queries)
1343             # commit the query change to the database
1344             self.db.commit()
1346     def searchPermission(self):
1347         ''' Determine whether the user has permission to search this class.
1349             Base behaviour is to check the user can view this class.
1350         ''' 
1351         if not self.db.security.hasPermission('View', self.userid,
1352                 self.classname):
1353             return 0
1354         return 1
1357     def retireAction(self):
1358         ''' Retire the context item.
1359         '''
1360         # if we want to view the index template now, then unset the nodeid
1361         # context info (a special-case for retire actions on the index page)
1362         nodeid = self.nodeid
1363         if self.template == 'index':
1364             self.nodeid = None
1366         # generic edit is per-class only
1367         if not self.retirePermission():
1368             self.error_message.append(
1369                 _('You do not have permission to retire %s' %self.classname))
1370             return
1372         # make sure we don't try to retire admin or anonymous
1373         if self.classname == 'user' and \
1374                 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1375             self.error_message.append(
1376                 _('You may not retire the admin or anonymous user'))
1377             return
1379         # do the retire
1380         self.db.getclass(self.classname).retire(nodeid)
1381         self.db.commit()
1383         self.ok_message.append(
1384             _('%(classname)s %(itemid)s has been retired')%{
1385                 'classname': self.classname.capitalize(), 'itemid': nodeid})
1387     def retirePermission(self):
1388         ''' Determine whether the user has permission to retire this class.
1390             Base behaviour is to check the user can edit this class.
1391         ''' 
1392         if not self.db.security.hasPermission('Edit', self.userid,
1393                 self.classname):
1394             return 0
1395         return 1
1398     def showAction(self, typere=re.compile('[@:]type'),
1399             numre=re.compile('[@:]number')):
1400         ''' Show a node of a particular class/id
1401         '''
1402         t = n = ''
1403         for key in self.form.keys():
1404             if typere.match(key):
1405                 t = self.form[key].value.strip()
1406             elif numre.match(key):
1407                 n = self.form[key].value.strip()
1408         if not t:
1409             raise ValueError, 'Invalid %s number'%t
1410         url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1411         raise Redirect, url
1413     def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1414         ''' Pull properties out of the form.
1416             In the following, <bracketed> values are variable, ":" may be
1417             one of ":" or "@", and other text "required" is fixed.
1419             Properties are specified as form variables:
1421              <propname>
1422               - property on the current context item
1424              <designator>:<propname>
1425               - property on the indicated item
1427              <classname>-<N>:<propname>
1428               - property on the Nth new item of classname
1430             Once we have determined the "propname", we check to see if it
1431             is one of the special form values:
1433              :required
1434               The named property values must be supplied or a ValueError
1435               will be raised.
1437              :remove:<propname>=id(s)
1438               The ids will be removed from the multilink property.
1440              :add:<propname>=id(s)
1441               The ids will be added to the multilink property.
1443              :link:<propname>=<designator>
1444               Used to add a link to new items created during edit.
1445               These are collected up and returned in all_links. This will
1446               result in an additional linking operation (either Link set or
1447               Multilink append) after the edit/create is done using
1448               all_props in _editnodes. The <propname> on the current item
1449               will be set/appended the id of the newly created item of
1450               class <designator> (where <designator> must be
1451               <classname>-<N>).
1453             Any of the form variables may be prefixed with a classname or
1454             designator.
1456             The return from this method is a dict of 
1457                 (classname, id): properties
1458             ... this dict _always_ has an entry for the current context,
1459             even if it's empty (ie. a submission for an existing issue that
1460             doesn't result in any changes would return {('issue','123'): {}})
1461             The id may be None, which indicates that an item should be
1462             created.
1464             If a String property's form value is a file upload, then we
1465             try to set additional properties "filename" and "type" (if
1466             they are valid for the class).
1468             Two special form values are supported for backwards
1469             compatibility:
1470              :note - create a message (with content, author and date), link
1471                      to the context item. This is ALWAYS desginated "msg-1".
1472              :file - create a file, attach to the current item and any
1473                      message created by :note. This is ALWAYS designated
1474                      "file-1".
1476             We also check that FileClass items have a "content" property with
1477             actual content, otherwise we remove them from all_props before
1478             returning.
1479         '''
1480         # some very useful variables
1481         db = self.db
1482         form = self.form
1484         if not hasattr(self, 'FV_SPECIAL'):
1485             # generate the regexp for handling special form values
1486             classes = '|'.join(db.classes.keys())
1487             # specials for parsePropsFromForm
1488             # handle the various forms (see unit tests)
1489             self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1490             self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1492         # these indicate the default class / item
1493         default_cn = self.classname
1494         default_cl = self.db.classes[default_cn]
1495         default_nodeid = self.nodeid
1497         # we'll store info about the individual class/item edit in these
1498         all_required = {}       # one entry per class/item
1499         all_props = {}          # one entry per class/item
1500         all_propdef = {}        # note - only one entry per class
1501         all_links = []          # as many as are required
1503         # we should always return something, even empty, for the context
1504         all_props[(default_cn, default_nodeid)] = {}
1506         keys = form.keys()
1507         timezone = db.getUserTimezone()
1509         # sentinels for the :note and :file props
1510         have_note = have_file = 0
1512         # extract the usable form labels from the form
1513         matches = []
1514         for key in keys:
1515             m = self.FV_SPECIAL.match(key)
1516             if m:
1517                 matches.append((key, m.groupdict()))
1519         # now handle the matches
1520         for key, d in matches:
1521             if d['classname']:
1522                 # we got a designator
1523                 cn = d['classname']
1524                 cl = self.db.classes[cn]
1525                 nodeid = d['id']
1526                 propname = d['propname']
1527             elif d['note']:
1528                 # the special note field
1529                 cn = 'msg'
1530                 cl = self.db.classes[cn]
1531                 nodeid = '-1'
1532                 propname = 'content'
1533                 all_links.append((default_cn, default_nodeid, 'messages',
1534                     [('msg', '-1')]))
1535                 have_note = 1
1536             elif d['file']:
1537                 # the special file field
1538                 cn = 'file'
1539                 cl = self.db.classes[cn]
1540                 nodeid = '-1'
1541                 propname = 'content'
1542                 all_links.append((default_cn, default_nodeid, 'files',
1543                     [('file', '-1')]))
1544                 have_file = 1
1545             else:
1546                 # default
1547                 cn = default_cn
1548                 cl = default_cl
1549                 nodeid = default_nodeid
1550                 propname = d['propname']
1552             # the thing this value relates to is...
1553             this = (cn, nodeid)
1555             # get more info about the class, and the current set of
1556             # form props for it
1557             if not all_propdef.has_key(cn):
1558                 all_propdef[cn] = cl.getprops()
1559             propdef = all_propdef[cn]
1560             if not all_props.has_key(this):
1561                 all_props[this] = {}
1562             props = all_props[this]
1564             # is this a link command?
1565             if d['link']:
1566                 value = []
1567                 for entry in extractFormList(form[key]):
1568                     m = self.FV_DESIGNATOR.match(entry)
1569                     if not m:
1570                         raise ValueError, \
1571                             'link "%s" value "%s" not a designator'%(key, entry)
1572                     value.append((m.group(1), m.group(2)))
1574                 # make sure the link property is valid
1575                 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1576                         not isinstance(propdef[propname], hyperdb.Link)):
1577                     raise ValueError, '%s %s is not a link or '\
1578                         'multilink property'%(cn, propname)
1580                 all_links.append((cn, nodeid, propname, value))
1581                 continue
1583             # detect the special ":required" variable
1584             if d['required']:
1585                 all_required[this] = extractFormList(form[key])
1586                 continue
1588             # get the required values list
1589             if not all_required.has_key(this):
1590                 all_required[this] = []
1591             required = all_required[this]
1593             # see if we're performing a special multilink action
1594             mlaction = 'set'
1595             if d['remove']:
1596                 mlaction = 'remove'
1597             elif d['add']:
1598                 mlaction = 'add'
1600             # does the property exist?
1601             if not propdef.has_key(propname):
1602                 if mlaction != 'set':
1603                     raise ValueError, 'You have submitted a %s action for'\
1604                         ' the property "%s" which doesn\'t exist'%(mlaction,
1605                         propname)
1606                 # the form element is probably just something we don't care
1607                 # about - ignore it
1608                 continue
1609             proptype = propdef[propname]
1611             # Get the form value. This value may be a MiniFieldStorage or a list
1612             # of MiniFieldStorages.
1613             value = form[key]
1615             # handle unpacking of the MiniFieldStorage / list form value
1616             if isinstance(proptype, hyperdb.Multilink):
1617                 value = extractFormList(value)
1618             else:
1619                 # multiple values are not OK
1620                 if isinstance(value, type([])):
1621                     raise ValueError, 'You have submitted more than one value'\
1622                         ' for the %s property'%propname
1623                 # value might be a file upload...
1624                 if not hasattr(value, 'filename') or value.filename is None:
1625                     # nope, pull out the value and strip it
1626                     value = value.value.strip()
1628             # now that we have the props field, we need a teensy little
1629             # extra bit of help for the old :note field...
1630             if d['note'] and value:
1631                 props['author'] = self.db.getuid()
1632                 props['date'] = date.Date()
1634             # handle by type now
1635             if isinstance(proptype, hyperdb.Password):
1636                 if not value:
1637                     # ignore empty password values
1638                     continue
1639                 for key, d in matches:
1640                     if d['confirm'] and d['propname'] == propname:
1641                         confirm = form[key]
1642                         break
1643                 else:
1644                     raise ValueError, 'Password and confirmation text do '\
1645                         'not match'
1646                 if isinstance(confirm, type([])):
1647                     raise ValueError, 'You have submitted more than one value'\
1648                         ' for the %s property'%propname
1649                 if value != confirm.value:
1650                     raise ValueError, 'Password and confirmation text do '\
1651                         'not match'
1652                 value = password.Password(value)
1654             elif isinstance(proptype, hyperdb.Link):
1655                 # see if it's the "no selection" choice
1656                 if value == '-1' or not value:
1657                     # if we're creating, just don't include this property
1658                     if not nodeid or nodeid.startswith('-'):
1659                         continue
1660                     value = None
1661                 else:
1662                     # handle key values
1663                     link = proptype.classname
1664                     if not num_re.match(value):
1665                         try:
1666                             value = db.classes[link].lookup(value)
1667                         except KeyError:
1668                             raise ValueError, _('property "%(propname)s": '
1669                                 '%(value)s not a %(classname)s')%{
1670                                 'propname': propname, 'value': value,
1671                                 'classname': link}
1672                         except TypeError, message:
1673                             raise ValueError, _('you may only enter ID values '
1674                                 'for property "%(propname)s": %(message)s')%{
1675                                 'propname': propname, 'message': message}
1676             elif isinstance(proptype, hyperdb.Multilink):
1677                 # perform link class key value lookup if necessary
1678                 link = proptype.classname
1679                 link_cl = db.classes[link]
1680                 l = []
1681                 for entry in value:
1682                     if not entry: continue
1683                     if not num_re.match(entry):
1684                         try:
1685                             entry = link_cl.lookup(entry)
1686                         except KeyError:
1687                             raise ValueError, _('property "%(propname)s": '
1688                                 '"%(value)s" not an entry of %(classname)s')%{
1689                                 'propname': propname, 'value': entry,
1690                                 'classname': link}
1691                         except TypeError, message:
1692                             raise ValueError, _('you may only enter ID values '
1693                                 'for property "%(propname)s": %(message)s')%{
1694                                 'propname': propname, 'message': message}
1695                     l.append(entry)
1696                 l.sort()
1698                 # now use that list of ids to modify the multilink
1699                 if mlaction == 'set':
1700                     value = l
1701                 else:
1702                     # we're modifying the list - get the current list of ids
1703                     if props.has_key(propname):
1704                         existing = props[propname]
1705                     elif nodeid and not nodeid.startswith('-'):
1706                         existing = cl.get(nodeid, propname, [])
1707                     else:
1708                         existing = []
1710                     # now either remove or add
1711                     if mlaction == 'remove':
1712                         # remove - handle situation where the id isn't in
1713                         # the list
1714                         for entry in l:
1715                             try:
1716                                 existing.remove(entry)
1717                             except ValueError:
1718                                 raise ValueError, _('property "%(propname)s": '
1719                                     '"%(value)s" not currently in list')%{
1720                                     'propname': propname, 'value': entry}
1721                     else:
1722                         # add - easy, just don't dupe
1723                         for entry in l:
1724                             if entry not in existing:
1725                                 existing.append(entry)
1726                     value = existing
1727                     value.sort()
1729             elif value == '':
1730                 # if we're creating, just don't include this property
1731                 if not nodeid or nodeid.startswith('-'):
1732                     continue
1733                 # other types should be None'd if there's no value
1734                 value = None
1735             else:
1736                 # handle ValueErrors for all these in a similar fashion
1737                 try:
1738                     if isinstance(proptype, hyperdb.String):
1739                         if (hasattr(value, 'filename') and
1740                                 value.filename is not None):
1741                             # skip if the upload is empty
1742                             if not value.filename:
1743                                 continue
1744                             # this String is actually a _file_
1745                             # try to determine the file content-type
1746                             fn = value.filename.split('\\')[-1]
1747                             if propdef.has_key('name'):
1748                                 props['name'] = fn
1749                             # use this info as the type/filename properties
1750                             if propdef.has_key('type'):
1751                                 props['type'] = mimetypes.guess_type(fn)[0]
1752                                 if not props['type']:
1753                                     props['type'] = "application/octet-stream"
1754                             # finally, read the content
1755                             value = value.value
1756                         else:
1757                             # normal String fix the CRLF/CR -> LF stuff
1758                             value = fixNewlines(value)
1760                     elif isinstance(proptype, hyperdb.Date):
1761                         value = date.Date(value, offset=timezone)
1762                     elif isinstance(proptype, hyperdb.Interval):
1763                         value = date.Interval(value)
1764                     elif isinstance(proptype, hyperdb.Boolean):
1765                         value = value.lower() in ('yes', 'true', 'on', '1')
1766                     elif isinstance(proptype, hyperdb.Number):
1767                         value = float(value)
1768                 except ValueError, msg:
1769                     raise ValueError, _('Error with %s property: %s')%(
1770                         propname, msg)
1772             # get the old value
1773             if nodeid and not nodeid.startswith('-'):
1774                 try:
1775                     existing = cl.get(nodeid, propname)
1776                 except KeyError:
1777                     # this might be a new property for which there is
1778                     # no existing value
1779                     if not propdef.has_key(propname):
1780                         raise
1782                 # make sure the existing multilink is sorted
1783                 if isinstance(proptype, hyperdb.Multilink):
1784                     existing.sort()
1786                 # "missing" existing values may not be None
1787                 if not existing:
1788                     if isinstance(proptype, hyperdb.String) and not existing:
1789                         # some backends store "missing" Strings as empty strings
1790                         existing = None
1791                     elif isinstance(proptype, hyperdb.Number) and not existing:
1792                         # some backends store "missing" Numbers as 0 :(
1793                         existing = 0
1794                     elif isinstance(proptype, hyperdb.Boolean) and not existing:
1795                         # likewise Booleans
1796                         existing = 0
1798                 # if changed, set it
1799                 if value != existing:
1800                     props[propname] = value
1801             else:
1802                 # don't bother setting empty/unset values
1803                 if value is None:
1804                     continue
1805                 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1806                     continue
1807                 elif isinstance(proptype, hyperdb.String) and value == '':
1808                     continue
1810                 props[propname] = value
1812             # register this as received if required?
1813             if propname in required and value is not None:
1814                 required.remove(propname)
1816         # check to see if we need to specially link a file to the note
1817         if have_note and have_file:
1818             all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1820         # see if all the required properties have been supplied
1821         s = []
1822         for thing, required in all_required.items():
1823             if not required:
1824                 continue
1825             if len(required) > 1:
1826                 p = 'properties'
1827             else:
1828                 p = 'property'
1829             s.append('Required %s %s %s not supplied'%(thing[0], p,
1830                 ', '.join(required)))
1831         if s:
1832             raise ValueError, '\n'.join(s)
1834         # check that FileClass entries have a "content" property with
1835         # content, otherwise remove them
1836         for (cn, id), props in all_props.items():
1837             cl = self.db.classes[cn]
1838             if not isinstance(cl, hyperdb.FileClass):
1839                 continue
1840             # we also don't want to create FileClass items with no content
1841             if not props.get('content', ''):
1842                 del all_props[(cn, id)]
1843         return all_props, all_links
1845 def fixNewlines(text):
1846     ''' Homogenise line endings.
1848         Different web clients send different line ending values, but
1849         other systems (eg. email) don't necessarily handle those line
1850         endings. Our solution is to convert all line endings to LF.
1851     '''
1852     text = text.replace('\r\n', '\n')
1853     return text.replace('\r', '\n')
1855 def extractFormList(value):
1856     ''' Extract a list of values from the form value.
1858         It may be one of:
1859          [MiniFieldStorage, MiniFieldStorage, ...]
1860          MiniFieldStorage('value,value,...')
1861          MiniFieldStorage('value')
1862     '''
1863     # multiple values are OK
1864     if isinstance(value, type([])):
1865         # it's a list of MiniFieldStorages
1866         value = [i.value.strip() for i in value]
1867     else:
1868         # it's a MiniFieldStorage, but may be a comma-separated list
1869         # of values
1870         value = [i.strip() for i in value.value.split(',')]
1872     # filter out the empty bits
1873     return filter(None, value)