Code

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