Code

bugfixes to rego/password reset
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.109 2003-03-20 04:02:52 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()
227             # now render the page
229             # we don't want clients caching our dynamic pages
230             self.additional_headers['Cache-Control'] = 'no-cache'
231             self.additional_headers['Pragma'] = 'no-cache'
232             self.additional_headers['Expires'] = 'Thu, 1 Jan 1970 00:00:00 GMT'
234             # render the content
235             self.write(self.renderContext())
236         except Redirect, url:
237             # let's redirect - if the url isn't None, then we need to do
238             # the headers, otherwise the headers have been set before the
239             # exception was raised
240             if url:
241                 self.additional_headers['Location'] = url
242                 self.response_code = 302
243             self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
244         except SendFile, designator:
245             self.serve_file(designator)
246         except SendStaticFile, file:
247             try:
248                 self.serve_static_file(str(file))
249             except NotModified:
250                 # send the 304 response
251                 self.request.send_response(304)
252                 self.request.end_headers()
253         except Unauthorised, message:
254             self.classname = None
255             self.template = ''
256             self.error_message.append(message)
257             self.write(self.renderContext())
258         except NotFound:
259             # pass through
260             raise
261         except:
262             # everything else
263             self.write(cgitb.html())
265     def clean_sessions(self):
266         ''' Age sessions, remove when they haven't been used for a week.
267         
268             Do it only once an hour.
270             Note: also cleans One Time Keys, and other "session" based
271             stuff.
272         '''
273         sessions = self.db.sessions
274         last_clean = sessions.get('last_clean', 'last_use') or 0
276         week = 60*60*24*7
277         hour = 60*60
278         now = time.time()
279         if now - last_clean > hour:
280             # remove aged sessions
281             for sessid in sessions.list():
282                 interval = now - sessions.get(sessid, 'last_use')
283                 if interval > week:
284                     sessions.destroy(sessid)
285             # remove aged otks
286             otks = self.db.otks
287             for sessid in otks.list():
288                 interval = now - otks.get(sessid, '__time')
289                 if interval > week:
290                     otks.destroy(sessid)
291             sessions.set('last_clean', last_use=time.time())
293     def determine_user(self):
294         ''' Determine who the user is
295         '''
296         # determine the uid to use
297         self.opendb('admin')
298         # clean age sessions
299         self.clean_sessions()
300         # make sure we have the session Class
301         sessions = self.db.sessions
303         # look up the user session cookie
304         cookie = Cookie.SimpleCookie(self.env.get('HTTP_COOKIE', ''))
305         user = 'anonymous'
307         # bump the "revision" of the cookie since the format changed
308         if (cookie.has_key(self.cookie_name) and
309                 cookie[self.cookie_name].value != 'deleted'):
311             # get the session key from the cookie
312             self.session = cookie[self.cookie_name].value
313             # get the user from the session
314             try:
315                 # update the lifetime datestamp
316                 sessions.set(self.session, last_use=time.time())
317                 sessions.commit()
318                 user = sessions.get(self.session, 'user')
319             except KeyError:
320                 user = 'anonymous'
322         # sanity check on the user still being valid, getting the userid
323         # at the same time
324         try:
325             self.userid = self.db.user.lookup(user)
326         except (KeyError, TypeError):
327             user = 'anonymous'
329         # make sure the anonymous user is valid if we're using it
330         if user == 'anonymous':
331             self.make_user_anonymous()
332         else:
333             self.user = user
335         # reopen the database as the correct user
336         self.opendb(self.user)
338     def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
339         ''' Determine the context of this page from the URL:
341             The URL path after the instance identifier is examined. The path
342             is generally only one entry long.
344             - if there is no path, then we are in the "home" context.
345             * if the path is "_file", then the additional path entry
346               specifies the filename of a static file we're to serve up
347               from the instance "html" directory. Raises a SendStaticFile
348               exception.
349             - if there is something in the path (eg "issue"), it identifies
350               the tracker class we're to display.
351             - if the path is an item designator (eg "issue123"), then we're
352               to display a specific item.
353             * if the path starts with an item designator and is longer than
354               one entry, then we're assumed to be handling an item of a
355               FileClass, and the extra path information gives the filename
356               that the client is going to label the download with (ie
357               "file123/image.png" is nicer to download than "file123"). This
358               raises a SendFile exception.
360             Both of the "*" types of contexts stop before we bother to
361             determine the template we're going to use. That's because they
362             don't actually use templates.
364             The template used is specified by the :template CGI variable,
365             which defaults to:
367              only classname suplied:          "index"
368              full item designator supplied:   "item"
370             We set:
371              self.classname  - the class to display, can be None
372              self.template   - the template to render the current context with
373              self.nodeid     - the nodeid of the class we're displaying
374         '''
375         # default the optional variables
376         self.classname = None
377         self.nodeid = None
379         # see if a template or messages are specified
380         template_override = ok_message = error_message = None
381         for key in self.form.keys():
382             if self.FV_TEMPLATE.match(key):
383                 template_override = self.form[key].value
384             elif self.FV_OK_MESSAGE.match(key):
385                 ok_message = self.form[key].value
386             elif self.FV_ERROR_MESSAGE.match(key):
387                 error_message = self.form[key].value
389         # determine the classname and possibly nodeid
390         path = self.path.split('/')
391         if not path or path[0] in ('', 'home', 'index'):
392             if template_override is not None:
393                 self.template = template_override
394             else:
395                 self.template = ''
396             return
397         elif path[0] == '_file':
398             raise SendStaticFile, os.path.join(*path[1:])
399         else:
400             self.classname = path[0]
401             if len(path) > 1:
402                 # send the file identified by the designator in path[0]
403                 raise SendFile, path[0]
405         # see if we got a designator
406         m = dre.match(self.classname)
407         if m:
408             self.classname = m.group(1)
409             self.nodeid = m.group(2)
410             if not self.db.getclass(self.classname).hasnode(self.nodeid):
411                 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
412             # with a designator, we default to item view
413             self.template = 'item'
414         else:
415             # with only a class, we default to index view
416             self.template = 'index'
418         # make sure the classname is valid
419         try:
420             self.db.getclass(self.classname)
421         except KeyError:
422             raise NotFound, self.classname
424         # see if we have a template override
425         if template_override is not None:
426             self.template = template_override
428         # see if we were passed in a message
429         if ok_message:
430             self.ok_message.append(ok_message)
431         if error_message:
432             self.error_message.append(error_message)
434     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
435         ''' Serve the file from the content property of the designated item.
436         '''
437         m = dre.match(str(designator))
438         if not m:
439             raise NotFound, str(designator)
440         classname, nodeid = m.group(1), m.group(2)
441         if classname != 'file':
442             raise NotFound, designator
444         # we just want to serve up the file named
445         file = self.db.file
446         self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
447         self.write(file.get(nodeid, 'content'))
449     def serve_static_file(self, file):
450         ims = None
451         # see if there's an if-modified-since...
452         if hasattr(self.request, 'headers'):
453             ims = self.request.headers.getheader('if-modified-since')
454         elif self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
455             # cgi will put the header in the env var
456             ims = self.env['HTTP_IF_MODIFIED_SINCE']
457         filename = os.path.join(self.instance.config.TEMPLATES, file)
458         lmt = os.stat(filename)[stat.ST_MTIME]
459         if ims:
460             ims = rfc822.parsedate(ims)[:6]
461             lmtt = time.gmtime(lmt)[:6]
462             if lmtt <= ims:
463                 raise NotModified
465         # we just want to serve up the file named
466         mt = mimetypes.guess_type(str(file))[0]
467         if not mt:
468             mt = 'text/plain'
469         self.additional_headers['Content-Type'] = mt
470         self.additional_headers['Last-Modifed'] = rfc822.formatdate(lmt)
471         self.write(open(filename, 'rb').read())
473     def renderContext(self):
474         ''' Return a PageTemplate for the named page
475         '''
476         name = self.classname
477         extension = self.template
478         pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
480         # catch errors so we can handle PT rendering errors more nicely
481         args = {
482             'ok_message': self.ok_message,
483             'error_message': self.error_message
484         }
485         try:
486             # let the template render figure stuff out
487             return pt.render(self, None, None, **args)
488         except NoTemplate, message:
489             return '<strong>%s</strong>'%message
490         except:
491             # everything else
492             return cgitb.pt_html()
494     # these are the actions that are available
495     actions = (
496         ('edit',     'editItemAction'),
497         ('editcsv',  'editCSVAction'),
498         ('new',      'newItemAction'),
499         ('register', 'registerAction'),
500         ('confrego', 'confRegoAction'),
501         ('passrst',  'passResetAction'),
502         ('login',    'loginAction'),
503         ('logout',   'logout_action'),
504         ('search',   'searchAction'),
505         ('retire',   'retireAction'),
506         ('show',     'showAction'),
507     )
508     def handle_action(self):
509         ''' Determine whether there should be an Action called.
511             The action is defined by the form variable :action which
512             identifies the method on this object to call. The actions
513             are defined in the "actions" sequence on this class.
514         '''
515         if self.form.has_key(':action'):
516             action = self.form[':action'].value.lower()
517         elif self.form.has_key('@action'):
518             action = self.form['@action'].value.lower()
519         else:
520             return None
521         try:
522             # get the action, validate it
523             for name, method in self.actions:
524                 if name == action:
525                     break
526             else:
527                 raise ValueError, 'No such action "%s"'%action
528             # call the mapped action
529             getattr(self, method)()
530         except Redirect:
531             raise
532         except Unauthorised:
533             raise
535     def write(self, content):
536         if not self.headers_done:
537             self.header()
538         self.request.wfile.write(content)
540     def header(self, headers=None, response=None):
541         '''Put up the appropriate header.
542         '''
543         if headers is None:
544             headers = {'Content-Type':'text/html'}
545         if response is None:
546             response = self.response_code
548         # update with additional info
549         headers.update(self.additional_headers)
551         if not headers.has_key('Content-Type'):
552             headers['Content-Type'] = 'text/html'
553         self.request.send_response(response)
554         for entry in headers.items():
555             self.request.send_header(*entry)
556         self.request.end_headers()
557         self.headers_done = 1
558         if self.debug:
559             self.headers_sent = headers
561     def set_cookie(self, user):
562         ''' Set up a session cookie for the user and store away the user's
563             login info against the session.
564         '''
565         # TODO generate a much, much stronger session key ;)
566         self.session = binascii.b2a_base64(repr(random.random())).strip()
568         # clean up the base64
569         if self.session[-1] == '=':
570             if self.session[-2] == '=':
571                 self.session = self.session[:-2]
572             else:
573                 self.session = self.session[:-1]
575         # insert the session in the sessiondb
576         self.db.sessions.set(self.session, user=user, last_use=time.time())
578         # and commit immediately
579         self.db.sessions.commit()
581         # expire us in a long, long time
582         expire = Cookie._getdate(86400*365)
584         # generate the cookie path - make sure it has a trailing '/'
585         self.additional_headers['Set-Cookie'] = \
586           '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
587             expire, self.cookie_path)
589     def make_user_anonymous(self):
590         ''' Make us anonymous
592             This method used to handle non-existence of the 'anonymous'
593             user, but that user is mandatory now.
594         '''
595         self.userid = self.db.user.lookup('anonymous')
596         self.user = 'anonymous'
598     def opendb(self, user):
599         ''' Open the database.
600         '''
601         # open the db if the user has changed
602         if not hasattr(self, 'db') or user != self.db.journaltag:
603             if hasattr(self, 'db'):
604                 self.db.close()
605             self.db = self.instance.open(user)
607     #
608     # Actions
609     #
610     def loginAction(self):
611         ''' Attempt to log a user in.
613             Sets up a session for the user which contains the login
614             credentials.
615         '''
616         # we need the username at a minimum
617         if not self.form.has_key('__login_name'):
618             self.error_message.append(_('Username required'))
619             return
621         # get the login info
622         self.user = self.form['__login_name'].value
623         if self.form.has_key('__login_password'):
624             password = self.form['__login_password'].value
625         else:
626             password = ''
628         # make sure the user exists
629         try:
630             self.userid = self.db.user.lookup(self.user)
631         except KeyError:
632             name = self.user
633             self.error_message.append(_('No such user "%(name)s"')%locals())
634             self.make_user_anonymous()
635             return
637         # verify the password
638         if not self.verifyPassword(self.userid, password):
639             self.make_user_anonymous()
640             self.error_message.append(_('Incorrect password'))
641             return
643         # make sure we're allowed to be here
644         if not self.loginPermission():
645             self.make_user_anonymous()
646             self.error_message.append(_("You do not have permission to login"))
647             return
649         # now we're OK, re-open the database for real, using the user
650         self.opendb(self.user)
652         # set the session cookie
653         self.set_cookie(self.user)
655     def verifyPassword(self, userid, password):
656         ''' Verify the password that the user has supplied
657         '''
658         stored = self.db.user.get(self.userid, 'password')
659         if password == stored:
660             return 1
661         if not password and not stored:
662             return 1
663         return 0
665     def loginPermission(self):
666         ''' Determine whether the user has permission to log in.
668             Base behaviour is to check the user has "Web Access".
669         ''' 
670         if not self.db.security.hasPermission('Web Access', self.userid):
671             return 0
672         return 1
674     def logout_action(self):
675         ''' Make us really anonymous - nuke the cookie too
676         '''
677         # log us out
678         self.make_user_anonymous()
680         # construct the logout cookie
681         now = Cookie._getdate()
682         self.additional_headers['Set-Cookie'] = \
683            '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.cookie_name,
684             now, self.cookie_path)
686         # Let the user know what's going on
687         self.ok_message.append(_('You are logged out'))
689     chars = string.letters+string.digits
690     def registerAction(self):
691         '''Attempt to create a new user based on the contents of the form
692         and then set the cookie.
694         return 1 on successful login
695         '''
696         # parse the props from the form
697         try:
698             props = self.parsePropsFromForm()[0][('user', None)]
699         except (ValueError, KeyError), message:
700             self.error_message.append(_('Error: ') + str(message))
701             return
703         # make sure we're allowed to register
704         if not self.registerPermission(props):
705             raise Unauthorised, _("You do not have permission to register")
707         try:
708             self.db.user.lookup(props['username'])
709             self.error_message.append('Error: A user with the username "%s" '
710                 'already exists'%props['username'])
711             return
712         except KeyError:
713             pass
715         # generate the one-time-key and store the props for later
716         otk = ''.join([random.choice(self.chars) for x in range(32)])
717         for propname, proptype in self.db.user.getprops().items():
718             value = props.get(propname, None)
719             if value is None:
720                 pass
721             elif isinstance(proptype, hyperdb.Date):
722                 props[propname] = str(value)
723             elif isinstance(proptype, hyperdb.Interval):
724                 props[propname] = str(value)
725             elif isinstance(proptype, hyperdb.Password):
726                 props[propname] = str(value)
727         props['__time'] = time.time()
728         self.db.otks.set(otk, **props)
730         # send the email
731         tracker_name = self.db.config.TRACKER_NAME
732         subject = 'Complete your registration to %s'%tracker_name
733         body = '''
734 To complete your registration of the user "%(name)s" with %(tracker)s,
735 please visit the following URL:
737    %(url)s?@action=confrego&otk=%(otk)s
738 '''%{'name': props['username'], 'tracker': tracker_name, 'url': self.base,
739                 'otk': otk}
740         if not self.sendEmail(props['address'], subject, body):
741             return
743         # commit changes to the database
744         self.db.commit()
746         # redirect to the "you're almost there" page
747         raise Redirect, '%suser?@template=rego_progress'%self.base
749     def sendEmail(self, to, subject, content):
750         # send email to the user's email address
751         message = StringIO.StringIO()
752         writer = MimeWriter.MimeWriter(message)
753         tracker_name = self.db.config.TRACKER_NAME
754         writer.addheader('Subject', encode_header(subject))
755         writer.addheader('To', to)
756         writer.addheader('From', roundupdb.straddr((tracker_name,
757             self.db.config.ADMIN_EMAIL)))
758         writer.addheader('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000",
759             time.gmtime()))
760         # add a uniquely Roundup header to help filtering
761         writer.addheader('X-Roundup-Name', tracker_name)
762         # avoid email loops
763         writer.addheader('X-Roundup-Loop', 'hello')
764         writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
765         body = writer.startbody('text/plain; charset=utf-8')
767         # message body, encoded quoted-printable
768         content = StringIO.StringIO(content)
769         quopri.encode(content, body, 0)
771         if SENDMAILDEBUG:
772             # don't send - just write to a file
773             open(SENDMAILDEBUG, 'a').write('FROM: %s\nTO: %s\n%s\n'%(
774                 self.db.config.ADMIN_EMAIL,
775                 ', '.join(to),message.getvalue()))
776         else:
777             # now try to send the message
778             try:
779                 # send the message as admin so bounces are sent there
780                 # instead of to roundup
781                 smtp = smtplib.SMTP(self.db.config.MAILHOST)
782                 smtp.sendmail(self.db.config.ADMIN_EMAIL, [to],
783                     message.getvalue())
784             except socket.error, value:
785                 self.error_message.append("Error: couldn't send email: "
786                     "mailhost %s"%value)
787                 return 0
788             except smtplib.SMTPException, msg:
789                 self.error_message.append("Error: couldn't send email: %s"%msg)
790                 return 0
791         return 1
793     def registerPermission(self, props):
794         ''' Determine whether the user has permission to register
796             Base behaviour is to check the user has "Web Registration".
797         '''
798         # registration isn't allowed to supply roles
799         if props.has_key('roles'):
800             return 0
801         if self.db.security.hasPermission('Web Registration', self.userid):
802             return 1
803         return 0
805     def confRegoAction(self):
806         ''' Grab the OTK, use it to load up the new user details
807         '''
808         # pull the rego information out of the otk database
809         otk = self.form['otk'].value
810         props = self.db.otks.getall(otk)
811         for propname, proptype in self.db.user.getprops().items():
812             value = props.get(propname, None)
813             if value is None:
814                 pass
815             elif isinstance(proptype, hyperdb.Date):
816                 props[propname] = date.Date(value)
817             elif isinstance(proptype, hyperdb.Interval):
818                 props[propname] = date.Interval(value)
819             elif isinstance(proptype, hyperdb.Password):
820                 props[propname] = password.Password()
821                 props[propname].unpack(value)
823         # re-open the database as "admin"
824         if self.user != 'admin':
825             self.opendb('admin')
827         # create the new user
828         cl = self.db.user
829 # XXX we need to make the "default" page be able to display errors!
830         try:
831             props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
832             del props['__time']
833             self.userid = cl.create(**props)
834             # clear the props from the otk database
835             self.db.otks.destroy(otk)
836             self.db.commit()
837         except (ValueError, KeyError), message:
838             self.error_message.append(str(message))
839             return
841         # log the new user in
842         self.user = cl.get(self.userid, 'username')
843         # re-open the database for real, using the user
844         self.opendb(self.user)
846         # if we have a session, update it
847         if hasattr(self, 'session'):
848             self.db.sessions.set(self.session, user=self.user,
849                 last_use=time.time())
850         else:
851             # new session cookie
852             self.set_cookie(self.user)
854         # nice message
855         message = _('You are now registered, welcome!')
857         # redirect to the user's page
858         raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
859             self.userid, urllib.quote(message))
861     def passResetAction(self):
862         ''' Handle password reset requests.
864             Presence of either "name" or "address" generate email.
865             Presense of "otk" performs the reset.
866         '''
867         if self.form.has_key('otk'):
868             # pull the rego information out of the otk database
869             otk = self.form['otk'].value
870             uid = self.db.otks.get(otk, 'uid')
872             # re-open the database as "admin"
873             if self.user != 'admin':
874                 self.opendb('admin')
876             # change the password
877             newpw = ''.join([random.choice(self.chars) for x in range(8)])
879             cl = self.db.user
880 # XXX we need to make the "default" page be able to display errors!
881             try:
882                 # set the password
883                 cl.set(uid, password=password.Password(newpw))
884                 # clear the props from the otk database
885                 self.db.otks.destroy(otk)
886                 self.db.commit()
887             except (ValueError, KeyError), message:
888                 self.error_message.append(str(message))
889                 return
891             # user info
892             address = self.db.user.get(uid, 'address')
893             name = self.db.user.get(uid, 'username')
895             # send the email
896             tracker_name = self.db.config.TRACKER_NAME
897             subject = 'Password reset for %s'%tracker_name
898             body = '''
899 The password has been reset for username "%(name)s".
901 Your password is now: %(password)s
902 '''%{'name': name, 'password': newpw}
903             if not self.sendEmail(address, subject, body):
904                 return
906             self.ok_message.append('Password reset and email sent to %s'%address)
907             return
909         # no OTK, so now figure the user
910         if self.form.has_key('username'):
911             name = self.form['username'].value
912             try:
913                 uid = self.db.user.lookup(name)
914             except KeyError:
915                 self.error_message.append('Unknown username')
916                 return
917             address = self.db.user.get(uid, 'address')
918         elif self.form.has_key('address'):
919             address = self.form['address'].value
920             uid = uidFromAddress(self.db, ('', address), create=0)
921             if not uid:
922                 self.error_message.append('Unknown email address')
923                 return
924             name = self.db.user.get(uid, 'username')
925         else:
926             self.error_message.append('You need to specify a username '
927                 'or address')
928             return
930         # generate the one-time-key and store the props for later
931         otk = ''.join([random.choice(self.chars) for x in range(32)])
932         self.db.otks.set(otk, uid=uid, __time=time.time())
934         # send the email
935         tracker_name = self.db.config.TRACKER_NAME
936         subject = 'Confirm reset of password for %s'%tracker_name
937         body = '''
938 Someone, perhaps you, has requested that the password be changed for your
939 username, "%(name)s". If you wish to proceed with the change, please follow
940 the link below:
942   %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
944 You should then receive another email with the new password.
945 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
946         if not self.sendEmail(address, subject, body):
947             return
949         self.ok_message.append('Email sent to %s'%address)
951     def editItemAction(self):
952         ''' Perform an edit of an item in the database.
954            See parsePropsFromForm and _editnodes for special variables
955         '''
956         # parse the props from the form
957         try:
958             props, links = self.parsePropsFromForm()
959         except (ValueError, KeyError), message:
960             self.error_message.append(_('Error: ') + str(message))
961             return
963         # handle the props
964         try:
965             message = self._editnodes(props, links)
966         except (ValueError, KeyError, IndexError), message:
967             self.error_message.append(_('Error: ') + str(message))
968             return
970         # commit now that all the tricky stuff is done
971         self.db.commit()
973         # redirect to the item's edit page
974         raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
975             self.classname, self.nodeid, urllib.quote(message),
976             urllib.quote(self.template))
978     def editItemPermission(self, props):
979         ''' Determine whether the user has permission to edit this item.
981             Base behaviour is to check the user can edit this class. If we're
982             editing the "user" class, users are allowed to edit their own
983             details. Unless it's the "roles" property, which requires the
984             special Permission "Web Roles".
985         '''
986         # if this is a user node and the user is editing their own node, then
987         # we're OK
988         has = self.db.security.hasPermission
989         if self.classname == 'user':
990             # reject if someone's trying to edit "roles" and doesn't have the
991             # right permission.
992             if props.has_key('roles') and not has('Web Roles', self.userid,
993                     'user'):
994                 return 0
995             # if the item being edited is the current user, we're ok
996             if self.nodeid == self.userid:
997                 return 1
998         if self.db.security.hasPermission('Edit', self.userid, self.classname):
999             return 1
1000         return 0
1002     def newItemAction(self):
1003         ''' Add a new item to the database.
1005             This follows the same form as the editItemAction, with the same
1006             special form values.
1007         '''
1008         # parse the props from the form
1009         try:
1010             props, links = self.parsePropsFromForm()
1011         except (ValueError, KeyError), message:
1012             self.error_message.append(_('Error: ') + str(message))
1013             return
1015         # handle the props - edit or create
1016         try:
1017             # when it hits the None element, it'll set self.nodeid
1018             messages = self._editnodes(props, links)
1020         except (ValueError, KeyError, IndexError), message:
1021             # these errors might just be indicative of user dumbness
1022             self.error_message.append(_('Error: ') + str(message))
1023             return
1025         # commit now that all the tricky stuff is done
1026         self.db.commit()
1028         # redirect to the new item's page
1029         raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
1030             self.classname, self.nodeid, urllib.quote(messages),
1031             urllib.quote(self.template))
1033     def newItemPermission(self, props):
1034         ''' Determine whether the user has permission to create (edit) this
1035             item.
1037             Base behaviour is to check the user can edit this class. No
1038             additional property checks are made. Additionally, new user items
1039             may be created if the user has the "Web Registration" Permission.
1040         '''
1041         has = self.db.security.hasPermission
1042         if self.classname == 'user' and has('Web Registration', self.userid,
1043                 'user'):
1044             return 1
1045         if has('Edit', self.userid, self.classname):
1046             return 1
1047         return 0
1050     #
1051     #  Utility methods for editing
1052     #
1053     def _editnodes(self, all_props, all_links, newids=None):
1054         ''' Use the props in all_props to perform edit and creation, then
1055             use the link specs in all_links to do linking.
1056         '''
1057         # figure dependencies and re-work links
1058         deps = {}
1059         links = {}
1060         for cn, nodeid, propname, vlist in all_links:
1061             if not all_props.has_key((cn, nodeid)):
1062                 # link item to link to doesn't (and won't) exist
1063                 continue
1064             for value in vlist:
1065                 if not all_props.has_key(value):
1066                     # link item to link to doesn't (and won't) exist
1067                     continue
1068                 deps.setdefault((cn, nodeid), []).append(value)
1069                 links.setdefault(value, []).append((cn, nodeid, propname))
1071         # figure chained dependencies ordering
1072         order = []
1073         done = {}
1074         # loop detection
1075         change = 0
1076         while len(all_props) != len(done):
1077             for needed in all_props.keys():
1078                 if done.has_key(needed):
1079                     continue
1080                 tlist = deps.get(needed, [])
1081                 for target in tlist:
1082                     if not done.has_key(target):
1083                         break
1084                 else:
1085                     done[needed] = 1
1086                     order.append(needed)
1087                     change = 1
1088             if not change:
1089                 raise ValueError, 'linking must not loop!'
1091         # now, edit / create
1092         m = []
1093         for needed in order:
1094             props = all_props[needed]
1095             if not props:
1096                 # nothing to do
1097                 continue
1098             cn, nodeid = needed
1100             if nodeid is not None and int(nodeid) > 0:
1101                 # make changes to the node
1102                 props = self._changenode(cn, nodeid, props)
1104                 # and some nice feedback for the user
1105                 if props:
1106                     info = ', '.join(props.keys())
1107                     m.append('%s %s %s edited ok'%(cn, nodeid, info))
1108                 else:
1109                     m.append('%s %s - nothing changed'%(cn, nodeid))
1110             else:
1111                 assert props
1113                 # make a new node
1114                 newid = self._createnode(cn, props)
1115                 if nodeid is None:
1116                     self.nodeid = newid
1117                 nodeid = newid
1119                 # and some nice feedback for the user
1120                 m.append('%s %s created'%(cn, newid))
1122             # fill in new ids in links
1123             if links.has_key(needed):
1124                 for linkcn, linkid, linkprop in links[needed]:
1125                     props = all_props[(linkcn, linkid)]
1126                     cl = self.db.classes[linkcn]
1127                     propdef = cl.getprops()[linkprop]
1128                     if not props.has_key(linkprop):
1129                         if linkid is None or linkid.startswith('-'):
1130                             # linking to a new item
1131                             if isinstance(propdef, hyperdb.Multilink):
1132                                 props[linkprop] = [newid]
1133                             else:
1134                                 props[linkprop] = newid
1135                         else:
1136                             # linking to an existing item
1137                             if isinstance(propdef, hyperdb.Multilink):
1138                                 existing = cl.get(linkid, linkprop)[:]
1139                                 existing.append(nodeid)
1140                                 props[linkprop] = existing
1141                             else:
1142                                 props[linkprop] = newid
1144         return '<br>'.join(m)
1146     def _changenode(self, cn, nodeid, props):
1147         ''' change the node based on the contents of the form
1148         '''
1149         # check for permission
1150         if not self.editItemPermission(props):
1151             raise Unauthorised, 'You do not have permission to edit %s'%cn
1153         # make the changes
1154         cl = self.db.classes[cn]
1155         return cl.set(nodeid, **props)
1157     def _createnode(self, cn, props):
1158         ''' create a node based on the contents of the form
1159         '''
1160         # check for permission
1161         if not self.newItemPermission(props):
1162             raise Unauthorised, 'You do not have permission to create %s'%cn
1164         # create the node and return its id
1165         cl = self.db.classes[cn]
1166         return cl.create(**props)
1168     # 
1169     # More actions
1170     #
1171     def editCSVAction(self):
1172         ''' Performs an edit of all of a class' items in one go.
1174             The "rows" CGI var defines the CSV-formatted entries for the
1175             class. New nodes are identified by the ID 'X' (or any other
1176             non-existent ID) and removed lines are retired.
1177         '''
1178         # this is per-class only
1179         if not self.editCSVPermission():
1180             self.error_message.append(
1181                 _('You do not have permission to edit %s' %self.classname))
1183         # get the CSV module
1184         try:
1185             import csv
1186         except ImportError:
1187             self.error_message.append(_(
1188                 'Sorry, you need the csv module to use this function.<br>\n'
1189                 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
1190             return
1192         cl = self.db.classes[self.classname]
1193         idlessprops = cl.getprops(protected=0).keys()
1194         idlessprops.sort()
1195         props = ['id'] + idlessprops
1197         # do the edit
1198         rows = self.form['rows'].value.splitlines()
1199         p = csv.parser()
1200         found = {}
1201         line = 0
1202         for row in rows[1:]:
1203             line += 1
1204             values = p.parse(row)
1205             # not a complete row, keep going
1206             if not values: continue
1208             # skip property names header
1209             if values == props:
1210                 continue
1212             # extract the nodeid
1213             nodeid, values = values[0], values[1:]
1214             found[nodeid] = 1
1216             # see if the node exists
1217             if cl.hasnode(nodeid):
1218                 exists = 1
1219             else:
1220                 exists = 0
1222             # confirm correct weight
1223             if len(idlessprops) != len(values):
1224                 self.error_message.append(
1225                     _('Not enough values on line %(line)s')%{'line':line})
1226                 return
1228             # extract the new values
1229             d = {}
1230             for name, value in zip(idlessprops, values):
1231                 prop = cl.properties[name]
1232                 value = value.strip()
1233                 # only add the property if it has a value
1234                 if value:
1235                     # if it's a multilink, split it
1236                     if isinstance(prop, hyperdb.Multilink):
1237                         value = value.split(':')
1238                     d[name] = value
1239                 elif exists:
1240                     # nuke the existing value
1241                     if isinstance(prop, hyperdb.Multilink):
1242                         d[name] = []
1243                     else:
1244                         d[name] = None
1246             # perform the edit
1247             if exists:
1248                 # edit existing
1249                 cl.set(nodeid, **d)
1250             else:
1251                 # new node
1252                 found[cl.create(**d)] = 1
1254         # retire the removed entries
1255         for nodeid in cl.list():
1256             if not found.has_key(nodeid):
1257                 cl.retire(nodeid)
1259         # all OK
1260         self.db.commit()
1262         self.ok_message.append(_('Items edited OK'))
1264     def editCSVPermission(self):
1265         ''' Determine whether the user has permission to edit this class.
1267             Base behaviour is to check the user can edit this class.
1268         ''' 
1269         if not self.db.security.hasPermission('Edit', self.userid,
1270                 self.classname):
1271             return 0
1272         return 1
1274     def searchAction(self):
1275         ''' Mangle some of the form variables.
1277             Set the form ":filter" variable based on the values of the
1278             filter variables - if they're set to anything other than
1279             "dontcare" then add them to :filter.
1281             Also handle the ":queryname" variable and save off the query to
1282             the user's query list.
1283         '''
1284         # generic edit is per-class only
1285         if not self.searchPermission():
1286             self.error_message.append(
1287                 _('You do not have permission to search %s' %self.classname))
1289         # add a faked :filter form variable for each filtering prop
1290         props = self.db.classes[self.classname].getprops()
1291         queryname = ''
1292         for key in self.form.keys():
1293             # special vars
1294             if self.FV_QUERYNAME.match(key):
1295                 queryname = self.form[key].value.strip()
1296                 continue
1298             if not props.has_key(key):
1299                 continue
1300             if isinstance(self.form[key], type([])):
1301                 # search for at least one entry which is not empty
1302                 for minifield in self.form[key]:
1303                     if minifield.value:
1304                         break
1305                 else:
1306                     continue
1307             else:
1308                 if not self.form[key].value: continue
1309             self.form.value.append(cgi.MiniFieldStorage('@filter', key))
1311         # handle saving the query params
1312         if queryname:
1313             # parse the environment and figure what the query _is_
1314             req = HTMLRequest(self)
1315             url = req.indexargs_href('', {})
1317             # handle editing an existing query
1318             try:
1319                 qid = self.db.query.lookup(queryname)
1320                 self.db.query.set(qid, klass=self.classname, url=url)
1321             except KeyError:
1322                 # create a query
1323                 qid = self.db.query.create(name=queryname,
1324                     klass=self.classname, url=url)
1326                 # and add it to the user's query multilink
1327                 queries = self.db.user.get(self.userid, 'queries')
1328                 queries.append(qid)
1329                 self.db.user.set(self.userid, queries=queries)
1331             # commit the query change to the database
1332             self.db.commit()
1334     def searchPermission(self):
1335         ''' Determine whether the user has permission to search this class.
1337             Base behaviour is to check the user can view this class.
1338         ''' 
1339         if not self.db.security.hasPermission('View', self.userid,
1340                 self.classname):
1341             return 0
1342         return 1
1345     def retireAction(self):
1346         ''' Retire the context item.
1347         '''
1348         # if we want to view the index template now, then unset the nodeid
1349         # context info (a special-case for retire actions on the index page)
1350         nodeid = self.nodeid
1351         if self.template == 'index':
1352             self.nodeid = None
1354         # generic edit is per-class only
1355         if not self.retirePermission():
1356             self.error_message.append(
1357                 _('You do not have permission to retire %s' %self.classname))
1358             return
1360         # make sure we don't try to retire admin or anonymous
1361         if self.classname == 'user' and \
1362                 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1363             self.error_message.append(
1364                 _('You may not retire the admin or anonymous user'))
1365             return
1367         # do the retire
1368         self.db.getclass(self.classname).retire(nodeid)
1369         self.db.commit()
1371         self.ok_message.append(
1372             _('%(classname)s %(itemid)s has been retired')%{
1373                 'classname': self.classname.capitalize(), 'itemid': nodeid})
1375     def retirePermission(self):
1376         ''' Determine whether the user has permission to retire this class.
1378             Base behaviour is to check the user can edit this class.
1379         ''' 
1380         if not self.db.security.hasPermission('Edit', self.userid,
1381                 self.classname):
1382             return 0
1383         return 1
1386     def showAction(self, typere=re.compile('[@:]type'),
1387             numre=re.compile('[@:]number')):
1388         ''' Show a node of a particular class/id
1389         '''
1390         t = n = ''
1391         for key in self.form.keys():
1392             if typere.match(key):
1393                 t = self.form[key].value.strip()
1394             elif numre.match(key):
1395                 n = self.form[key].value.strip()
1396         if not t:
1397             raise ValueError, 'Invalid %s number'%t
1398         url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1399         raise Redirect, url
1401     def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1402         ''' Pull properties out of the form.
1404             In the following, <bracketed> values are variable, ":" may be
1405             one of ":" or "@", and other text "required" is fixed.
1407             Properties are specified as form variables:
1409              <propname>
1410               - property on the current context item
1412              <designator>:<propname>
1413               - property on the indicated item
1415              <classname>-<N>:<propname>
1416               - property on the Nth new item of classname
1418             Once we have determined the "propname", we check to see if it
1419             is one of the special form values:
1421              :required
1422               The named property values must be supplied or a ValueError
1423               will be raised.
1425              :remove:<propname>=id(s)
1426               The ids will be removed from the multilink property.
1428              :add:<propname>=id(s)
1429               The ids will be added to the multilink property.
1431              :link:<propname>=<designator>
1432               Used to add a link to new items created during edit.
1433               These are collected up and returned in all_links. This will
1434               result in an additional linking operation (either Link set or
1435               Multilink append) after the edit/create is done using
1436               all_props in _editnodes. The <propname> on the current item
1437               will be set/appended the id of the newly created item of
1438               class <designator> (where <designator> must be
1439               <classname>-<N>).
1441             Any of the form variables may be prefixed with a classname or
1442             designator.
1444             The return from this method is a dict of 
1445                 (classname, id): properties
1446             ... this dict _always_ has an entry for the current context,
1447             even if it's empty (ie. a submission for an existing issue that
1448             doesn't result in any changes would return {('issue','123'): {}})
1449             The id may be None, which indicates that an item should be
1450             created.
1452             If a String property's form value is a file upload, then we
1453             try to set additional properties "filename" and "type" (if
1454             they are valid for the class).
1456             Two special form values are supported for backwards
1457             compatibility:
1458              :note - create a message (with content, author and date), link
1459                      to the context item. This is ALWAYS desginated "msg-1".
1460              :file - create a file, attach to the current item and any
1461                      message created by :note. This is ALWAYS designated
1462                      "file-1".
1464             We also check that FileClass items have a "content" property with
1465             actual content, otherwise we remove them from all_props before
1466             returning.
1467         '''
1468         # some very useful variables
1469         db = self.db
1470         form = self.form
1472         if not hasattr(self, 'FV_SPECIAL'):
1473             # generate the regexp for handling special form values
1474             classes = '|'.join(db.classes.keys())
1475             # specials for parsePropsFromForm
1476             # handle the various forms (see unit tests)
1477             self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1478             self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1480         # these indicate the default class / item
1481         default_cn = self.classname
1482         default_cl = self.db.classes[default_cn]
1483         default_nodeid = self.nodeid
1485         # we'll store info about the individual class/item edit in these
1486         all_required = {}       # one entry per class/item
1487         all_props = {}          # one entry per class/item
1488         all_propdef = {}        # note - only one entry per class
1489         all_links = []          # as many as are required
1491         # we should always return something, even empty, for the context
1492         all_props[(default_cn, default_nodeid)] = {}
1494         keys = form.keys()
1495         timezone = db.getUserTimezone()
1497         # sentinels for the :note and :file props
1498         have_note = have_file = 0
1500         # extract the usable form labels from the form
1501         matches = []
1502         for key in keys:
1503             m = self.FV_SPECIAL.match(key)
1504             if m:
1505                 matches.append((key, m.groupdict()))
1507         # now handle the matches
1508         for key, d in matches:
1509             if d['classname']:
1510                 # we got a designator
1511                 cn = d['classname']
1512                 cl = self.db.classes[cn]
1513                 nodeid = d['id']
1514                 propname = d['propname']
1515             elif d['note']:
1516                 # the special note field
1517                 cn = 'msg'
1518                 cl = self.db.classes[cn]
1519                 nodeid = '-1'
1520                 propname = 'content'
1521                 all_links.append((default_cn, default_nodeid, 'messages',
1522                     [('msg', '-1')]))
1523                 have_note = 1
1524             elif d['file']:
1525                 # the special file field
1526                 cn = 'file'
1527                 cl = self.db.classes[cn]
1528                 nodeid = '-1'
1529                 propname = 'content'
1530                 all_links.append((default_cn, default_nodeid, 'files',
1531                     [('file', '-1')]))
1532                 have_file = 1
1533             else:
1534                 # default
1535                 cn = default_cn
1536                 cl = default_cl
1537                 nodeid = default_nodeid
1538                 propname = d['propname']
1540             # the thing this value relates to is...
1541             this = (cn, nodeid)
1543             # get more info about the class, and the current set of
1544             # form props for it
1545             if not all_propdef.has_key(cn):
1546                 all_propdef[cn] = cl.getprops()
1547             propdef = all_propdef[cn]
1548             if not all_props.has_key(this):
1549                 all_props[this] = {}
1550             props = all_props[this]
1552             # is this a link command?
1553             if d['link']:
1554                 value = []
1555                 for entry in extractFormList(form[key]):
1556                     m = self.FV_DESIGNATOR.match(entry)
1557                     if not m:
1558                         raise ValueError, \
1559                             'link "%s" value "%s" not a designator'%(key, entry)
1560                     value.append((m.group(1), m.group(2)))
1562                 # make sure the link property is valid
1563                 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1564                         not isinstance(propdef[propname], hyperdb.Link)):
1565                     raise ValueError, '%s %s is not a link or '\
1566                         'multilink property'%(cn, propname)
1568                 all_links.append((cn, nodeid, propname, value))
1569                 continue
1571             # detect the special ":required" variable
1572             if d['required']:
1573                 all_required[this] = extractFormList(form[key])
1574                 continue
1576             # get the required values list
1577             if not all_required.has_key(this):
1578                 all_required[this] = []
1579             required = all_required[this]
1581             # see if we're performing a special multilink action
1582             mlaction = 'set'
1583             if d['remove']:
1584                 mlaction = 'remove'
1585             elif d['add']:
1586                 mlaction = 'add'
1588             # does the property exist?
1589             if not propdef.has_key(propname):
1590                 if mlaction != 'set':
1591                     raise ValueError, 'You have submitted a %s action for'\
1592                         ' the property "%s" which doesn\'t exist'%(mlaction,
1593                         propname)
1594                 # the form element is probably just something we don't care
1595                 # about - ignore it
1596                 continue
1597             proptype = propdef[propname]
1599             # Get the form value. This value may be a MiniFieldStorage or a list
1600             # of MiniFieldStorages.
1601             value = form[key]
1603             # handle unpacking of the MiniFieldStorage / list form value
1604             if isinstance(proptype, hyperdb.Multilink):
1605                 value = extractFormList(value)
1606             else:
1607                 # multiple values are not OK
1608                 if isinstance(value, type([])):
1609                     raise ValueError, 'You have submitted more than one value'\
1610                         ' for the %s property'%propname
1611                 # value might be a file upload...
1612                 if not hasattr(value, 'filename') or value.filename is None:
1613                     # nope, pull out the value and strip it
1614                     value = value.value.strip()
1616             # now that we have the props field, we need a teensy little
1617             # extra bit of help for the old :note field...
1618             if d['note'] and value:
1619                 props['author'] = self.db.getuid()
1620                 props['date'] = date.Date()
1622             # handle by type now
1623             if isinstance(proptype, hyperdb.Password):
1624                 if not value:
1625                     # ignore empty password values
1626                     continue
1627                 for key, d in matches:
1628                     if d['confirm'] and d['propname'] == propname:
1629                         confirm = form[key]
1630                         break
1631                 else:
1632                     raise ValueError, 'Password and confirmation text do '\
1633                         'not match'
1634                 if isinstance(confirm, type([])):
1635                     raise ValueError, 'You have submitted more than one value'\
1636                         ' for the %s property'%propname
1637                 if value != confirm.value:
1638                     raise ValueError, 'Password and confirmation text do '\
1639                         'not match'
1640                 value = password.Password(value)
1642             elif isinstance(proptype, hyperdb.Link):
1643                 # see if it's the "no selection" choice
1644                 if value == '-1' or not value:
1645                     # if we're creating, just don't include this property
1646                     if not nodeid or nodeid.startswith('-'):
1647                         continue
1648                     value = None
1649                 else:
1650                     # handle key values
1651                     link = proptype.classname
1652                     if not num_re.match(value):
1653                         try:
1654                             value = db.classes[link].lookup(value)
1655                         except KeyError:
1656                             raise ValueError, _('property "%(propname)s": '
1657                                 '%(value)s not a %(classname)s')%{
1658                                 'propname': propname, 'value': value,
1659                                 'classname': link}
1660                         except TypeError, message:
1661                             raise ValueError, _('you may only enter ID values '
1662                                 'for property "%(propname)s": %(message)s')%{
1663                                 'propname': propname, 'message': message}
1664             elif isinstance(proptype, hyperdb.Multilink):
1665                 # perform link class key value lookup if necessary
1666                 link = proptype.classname
1667                 link_cl = db.classes[link]
1668                 l = []
1669                 for entry in value:
1670                     if not entry: continue
1671                     if not num_re.match(entry):
1672                         try:
1673                             entry = link_cl.lookup(entry)
1674                         except KeyError:
1675                             raise ValueError, _('property "%(propname)s": '
1676                                 '"%(value)s" not an entry of %(classname)s')%{
1677                                 'propname': propname, 'value': entry,
1678                                 'classname': link}
1679                         except TypeError, message:
1680                             raise ValueError, _('you may only enter ID values '
1681                                 'for property "%(propname)s": %(message)s')%{
1682                                 'propname': propname, 'message': message}
1683                     l.append(entry)
1684                 l.sort()
1686                 # now use that list of ids to modify the multilink
1687                 if mlaction == 'set':
1688                     value = l
1689                 else:
1690                     # we're modifying the list - get the current list of ids
1691                     if props.has_key(propname):
1692                         existing = props[propname]
1693                     elif nodeid and not nodeid.startswith('-'):
1694                         existing = cl.get(nodeid, propname, [])
1695                     else:
1696                         existing = []
1698                     # now either remove or add
1699                     if mlaction == 'remove':
1700                         # remove - handle situation where the id isn't in
1701                         # the list
1702                         for entry in l:
1703                             try:
1704                                 existing.remove(entry)
1705                             except ValueError:
1706                                 raise ValueError, _('property "%(propname)s": '
1707                                     '"%(value)s" not currently in list')%{
1708                                     'propname': propname, 'value': entry}
1709                     else:
1710                         # add - easy, just don't dupe
1711                         for entry in l:
1712                             if entry not in existing:
1713                                 existing.append(entry)
1714                     value = existing
1715                     value.sort()
1717             elif value == '':
1718                 # if we're creating, just don't include this property
1719                 if not nodeid or nodeid.startswith('-'):
1720                     continue
1721                 # other types should be None'd if there's no value
1722                 value = None
1723             else:
1724                 if isinstance(proptype, hyperdb.String):
1725                     if (hasattr(value, 'filename') and
1726                             value.filename is not None):
1727                         # skip if the upload is empty
1728                         if not value.filename:
1729                             continue
1730                         # this String is actually a _file_
1731                         # try to determine the file content-type
1732                         filename = value.filename.split('\\')[-1]
1733                         if propdef.has_key('name'):
1734                             props['name'] = filename
1735                         # use this info as the type/filename properties
1736                         if propdef.has_key('type'):
1737                             props['type'] = mimetypes.guess_type(filename)[0]
1738                             if not props['type']:
1739                                 props['type'] = "application/octet-stream"
1740                         # finally, read the content
1741                         value = value.value
1742                     else:
1743                         # normal String fix the CRLF/CR -> LF stuff
1744                         value = fixNewlines(value)
1746                 elif isinstance(proptype, hyperdb.Date):
1747                     value = date.Date(value, offset=timezone)
1748                 elif isinstance(proptype, hyperdb.Interval):
1749                     value = date.Interval(value)
1750                 elif isinstance(proptype, hyperdb.Boolean):
1751                     value = value.lower() in ('yes', 'true', 'on', '1')
1752                 elif isinstance(proptype, hyperdb.Number):
1753                     value = float(value)
1755             # get the old value
1756             if nodeid and not nodeid.startswith('-'):
1757                 try:
1758                     existing = cl.get(nodeid, propname)
1759                 except KeyError:
1760                     # this might be a new property for which there is
1761                     # no existing value
1762                     if not propdef.has_key(propname):
1763                         raise
1765                 # make sure the existing multilink is sorted
1766                 if isinstance(proptype, hyperdb.Multilink):
1767                     existing.sort()
1769                 # "missing" existing values may not be None
1770                 if not existing:
1771                     if isinstance(proptype, hyperdb.String) and not existing:
1772                         # some backends store "missing" Strings as empty strings
1773                         existing = None
1774                     elif isinstance(proptype, hyperdb.Number) and not existing:
1775                         # some backends store "missing" Numbers as 0 :(
1776                         existing = 0
1777                     elif isinstance(proptype, hyperdb.Boolean) and not existing:
1778                         # likewise Booleans
1779                         existing = 0
1781                 # if changed, set it
1782                 if value != existing:
1783                     props[propname] = value
1784             else:
1785                 # don't bother setting empty/unset values
1786                 if value is None:
1787                     continue
1788                 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1789                     continue
1790                 elif isinstance(proptype, hyperdb.String) and value == '':
1791                     continue
1793                 props[propname] = value
1795             # register this as received if required?
1796             if propname in required and value is not None:
1797                 required.remove(propname)
1799         # check to see if we need to specially link a file to the note
1800         if have_note and have_file:
1801             all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1803         # see if all the required properties have been supplied
1804         s = []
1805         for thing, required in all_required.items():
1806             if not required:
1807                 continue
1808             if len(required) > 1:
1809                 p = 'properties'
1810             else:
1811                 p = 'property'
1812             s.append('Required %s %s %s not supplied'%(thing[0], p,
1813                 ', '.join(required)))
1814         if s:
1815             raise ValueError, '\n'.join(s)
1817         # check that FileClass entries have a "content" property with
1818         # content, otherwise remove them
1819         for (cn, id), props in all_props.items():
1820             cl = self.db.classes[cn]
1821             if not isinstance(cl, hyperdb.FileClass):
1822                 continue
1823             # we also don't want to create FileClass items with no content
1824             if not props.get('content', ''):
1825                 del all_props[(cn, id)]
1826         return all_props, all_links
1828 def fixNewlines(text):
1829     ''' Homogenise line endings.
1831         Different web clients send different line ending values, but
1832         other systems (eg. email) don't necessarily handle those line
1833         endings. Our solution is to convert all line endings to LF.
1834     '''
1835     text = text.replace('\r\n', '\n')
1836     return text.replace('\r', '\n')
1838 def extractFormList(value):
1839     ''' Extract a list of values from the form value.
1841         It may be one of:
1842          [MiniFieldStorage, MiniFieldStorage, ...]
1843          MiniFieldStorage('value,value,...')
1844          MiniFieldStorage('value')
1845     '''
1846     # multiple values are OK
1847     if isinstance(value, type([])):
1848         # it's a list of MiniFieldStorages
1849         value = [i.value.strip() for i in value]
1850     else:
1851         # it's a MiniFieldStorage, but may be a comma-separated list
1852         # of values
1853         value = [i.strip() for i in value.value.split(',')]
1855     # filter out the empty bits
1856     return filter(None, value)