Code

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