Code

fix sf bug 700483
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.104 2003-03-09 22:57:47 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         if 1:
820             props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
821             del props['__time']
822             self.userid = cl.create(**props)
823             # clear the props from the otk database
824             self.db.otks.destroy(otk)
825             self.db.commit()
826 #        except (ValueError, KeyError), message:
827 #            self.error_message.append(str(message))
828 #            return
830         # log the new user in
831         self.user = cl.get(self.userid, 'username')
832         # re-open the database for real, using the user
833         self.opendb(self.user)
835         # if we have a session, update it
836         if hasattr(self, 'session'):
837             self.db.sessions.set(self.session, user=self.user,
838                 last_use=time.time())
839         else:
840             # new session cookie
841             self.set_cookie(self.user)
843         # nice message
844         message = _('You are now registered, welcome!')
846         # redirect to the item's edit page
847         raise Redirect, '%suser%s?@ok_message=%s'%(
848             self.base, self.userid,  urllib.quote(message))
850     def passResetAction(self):
851         ''' Handle password reset requests.
853             Presence of either "name" or "address" generate email.
854             Presense of "otk" performs the reset.
855         '''
856         if self.form.has_key('otk'):
857             # pull the rego information out of the otk database
858             otk = self.form['otk'].value
859             uid = self.db.otks.get(otk, 'uid')
861             # re-open the database as "admin"
862             if self.user != 'admin':
863                 self.opendb('admin')
865             # change the password
866             newpw = ''.join([random.choice(self.chars) for x in range(8)])
868             cl = self.db.user
869     # XXX we need to make the "default" page be able to display errors!
870     #        try:
871             if 1:
872                 # set the password
873                 cl.set(uid, password=password.Password(newpw))
874                 # clear the props from the otk database
875                 self.db.otks.destroy(otk)
876                 self.db.commit()
877     #        except (ValueError, KeyError), message:
878     #            self.error_message.append(str(message))
879     #            return
881             # user info
882             address = self.db.user.get(uid, 'address')
883             name = self.db.user.get(uid, 'username')
885             # send the email
886             tracker_name = self.db.config.TRACKER_NAME
887             subject = 'Password reset for %s'%tracker_name
888             body = '''
889 The password has been reset for username "%(name)s".
891 Your password is now: %(password)s
892 '''%{'name': name, 'password': newpw}
893             if not self.sendEmail(address, subject, body):
894                 return
896             self.ok_message.append('Password reset and email sent to %s'%address)
897             return
899         # no OTK, so now figure the user
900         if self.form.has_key('username'):
901             name = self.form['username'].value
902             try:
903                 uid = self.db.user.lookup(name)
904             except KeyError:
905                 self.error_message.append('Unknown username')
906                 return
907             address = self.db.user.get(uid, 'address')
908         elif self.form.has_key('address'):
909             address = self.form['address'].value
910             uid = uidFromAddress(self.db, ('', address), create=0)
911             if not uid:
912                 self.error_message.append('Unknown email address')
913                 return
914             name = self.db.user.get(uid, 'username')
915         else:
916             self.error_message.append('You need to specify a username '
917                 'or address')
918             return
920         # generate the one-time-key and store the props for later
921         otk = ''.join([random.choice(self.chars) for x in range(32)])
922         self.db.otks.set(otk, uid=uid, __time=time.time())
924         # send the email
925         tracker_name = self.db.config.TRACKER_NAME
926         subject = 'Confirm reset of password for %s'%tracker_name
927         body = '''
928 Someone, perhaps you, has requested that the password be changed for your
929 username, "%(name)s". If you wish to proceed with the change, please follow
930 the link below:
932   %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
934 You should then receive another email with the new password.
935 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
936         if not self.sendEmail(address, subject, body):
937             return
939         self.ok_message.append('Email sent to %s'%address)
941     def editItemAction(self):
942         ''' Perform an edit of an item in the database.
944            See parsePropsFromForm and _editnodes for special variables
945         '''
946         # parse the props from the form
947 # XXX reinstate exception handling
948 #        try:
949         if 1:
950             props, links = self.parsePropsFromForm()
951 #        except (ValueError, KeyError), message:
952 #            self.error_message.append(_('Error: ') + str(message))
953 #            return
955         # handle the props
956 # XXX reinstate exception handling
957 #        try:
958         if 1:
959             message = self._editnodes(props, links)
960 #        except (ValueError, KeyError, IndexError), message:
961 #            self.error_message.append(_('Error: ') + str(message))
962 #            return
964         # commit now that all the tricky stuff is done
965         self.db.commit()
967         # redirect to the item's edit page
968         raise Redirect, '%s%s%s?@ok_message=%s'%(self.base, self.classname,
969             self.nodeid,  urllib.quote(message))
971     def editItemPermission(self, props):
972         ''' Determine whether the user has permission to edit this item.
974             Base behaviour is to check the user can edit this class. If we're
975             editing the "user" class, users are allowed to edit their own
976             details. Unless it's the "roles" property, which requires the
977             special Permission "Web Roles".
978         '''
979         # if this is a user node and the user is editing their own node, then
980         # we're OK
981         has = self.db.security.hasPermission
982         if self.classname == 'user':
983             # reject if someone's trying to edit "roles" and doesn't have the
984             # right permission.
985             if props.has_key('roles') and not has('Web Roles', self.userid,
986                     'user'):
987                 return 0
988             # if the item being edited is the current user, we're ok
989             if self.nodeid == self.userid:
990                 return 1
991         if self.db.security.hasPermission('Edit', self.userid, self.classname):
992             return 1
993         return 0
995     def newItemAction(self):
996         ''' Add a new item to the database.
998             This follows the same form as the editItemAction, with the same
999             special form values.
1000         '''
1001         # parse the props from the form
1002 # XXX reinstate exception handling
1003 #        try:
1004         if 1:
1005             props, links = self.parsePropsFromForm()
1006 #        except (ValueError, KeyError), message:
1007 #            self.error_message.append(_('Error: ') + str(message))
1008 #            return
1010         # handle the props - edit or create
1011 # XXX reinstate exception handling
1012 #        try:
1013         if 1:
1014             # create the context here
1015 #            cn = self.classname
1016 #            nid = self._createnode(cn, props[(cn, None)])
1017 #            del props[(cn, None)]
1019             # when it hits the None element, it'll set self.nodeid
1020             messages = self._editnodes(props, links) #, {(cn, None): nid})
1022 #        except (ValueError, KeyError, IndexError), message:
1023 #            # these errors might just be indicative of user dumbness
1024 #            self.error_message.append(_('Error: ') + str(message))
1025 #            return
1027         # commit now that all the tricky stuff is done
1028         self.db.commit()
1030         # redirect to the new item's page
1031         raise Redirect, '%s%s%s?@ok_message=%s'%(self.base, self.classname,
1032             self.nodeid, urllib.quote(messages))
1034     def newItemPermission(self, props):
1035         ''' Determine whether the user has permission to create (edit) this
1036             item.
1038             Base behaviour is to check the user can edit this class. No
1039             additional property checks are made. Additionally, new user items
1040             may be created if the user has the "Web Registration" Permission.
1041         '''
1042         has = self.db.security.hasPermission
1043         if self.classname == 'user' and has('Web Registration', self.userid,
1044                 'user'):
1045             return 1
1046         if has('Edit', self.userid, self.classname):
1047             return 1
1048         return 0
1051     #
1052     #  Utility methods for editing
1053     #
1054     def _editnodes(self, all_props, all_links, newids=None):
1055         ''' Use the props in all_props to perform edit and creation, then
1056             use the link specs in all_links to do linking.
1057         '''
1058         # figure dependencies and re-work links
1059         deps = {}
1060         links = {}
1061         for cn, nodeid, propname, vlist in all_links:
1062             if not all_props.has_key((cn, nodeid)):
1063                 # link item to link to doesn't (and won't) exist
1064                 continue
1065             for value in vlist:
1066                 if not all_props.has_key(value):
1067                     # link item to link to doesn't (and won't) exist
1068                     continue
1069                 deps.setdefault((cn, nodeid), []).append(value)
1070                 links.setdefault(value, []).append((cn, nodeid, propname))
1072         # figure chained dependencies ordering
1073         order = []
1074         done = {}
1075         # loop detection
1076         change = 0
1077         while len(all_props) != len(done):
1078             for needed in all_props.keys():
1079                 if done.has_key(needed):
1080                     continue
1081                 tlist = deps.get(needed, [])
1082                 for target in tlist:
1083                     if not done.has_key(target):
1084                         break
1085                 else:
1086                     done[needed] = 1
1087                     order.append(needed)
1088                     change = 1
1089             if not change:
1090                 raise ValueError, 'linking must not loop!'
1092         # now, edit / create
1093         m = []
1094         for needed in order:
1095             props = all_props[needed]
1096             if not props:
1097                 # nothing to do
1098                 continue
1099             cn, nodeid = needed
1101             if nodeid is not None and int(nodeid) > 0:
1102                 # make changes to the node
1103                 props = self._changenode(cn, nodeid, props)
1105                 # and some nice feedback for the user
1106                 if props:
1107                     info = ', '.join(props.keys())
1108                     m.append('%s %s %s edited ok'%(cn, nodeid, info))
1109                 else:
1110                     m.append('%s %s - nothing changed'%(cn, nodeid))
1111             else:
1112                 assert props
1114                 # make a new node
1115                 newid = self._createnode(cn, props)
1116                 if nodeid is None:
1117                     self.nodeid = newid
1118                 nodeid = newid
1120                 # and some nice feedback for the user
1121                 m.append('%s %s created'%(cn, newid))
1123             # fill in new ids in links
1124             if links.has_key(needed):
1125                 for linkcn, linkid, linkprop in links[needed]:
1126                     props = all_props[(linkcn, linkid)]
1127                     cl = self.db.classes[linkcn]
1128                     propdef = cl.getprops()[linkprop]
1129                     if not props.has_key(linkprop):
1130                         if linkid is None or linkid.startswith('-'):
1131                             # linking to a new item
1132                             if isinstance(propdef, hyperdb.Multilink):
1133                                 props[linkprop] = [newid]
1134                             else:
1135                                 props[linkprop] = newid
1136                         else:
1137                             # linking to an existing item
1138                             if isinstance(propdef, hyperdb.Multilink):
1139                                 existing = cl.get(linkid, linkprop)[:]
1140                                 existing.append(nodeid)
1141                                 props[linkprop] = existing
1142                             else:
1143                                 props[linkprop] = newid
1145         return '<br>'.join(m)
1147     def _changenode(self, cn, nodeid, props):
1148         ''' change the node based on the contents of the form
1149         '''
1150         # check for permission
1151         if not self.editItemPermission(props):
1152             raise Unauthorised, 'You do not have permission to edit %s'%cn
1154         # make the changes
1155         cl = self.db.classes[cn]
1156         return cl.set(nodeid, **props)
1158     def _createnode(self, cn, props):
1159         ''' create a node based on the contents of the form
1160         '''
1161         # check for permission
1162         if not self.newItemPermission(props):
1163             raise Unauthorised, 'You do not have permission to create %s'%cn
1165         # create the node and return its id
1166         cl = self.db.classes[cn]
1167         return cl.create(**props)
1169     # 
1170     # More actions
1171     #
1172     def editCSVAction(self):
1173         ''' Performs an edit of all of a class' items in one go.
1175             The "rows" CGI var defines the CSV-formatted entries for the
1176             class. New nodes are identified by the ID 'X' (or any other
1177             non-existent ID) and removed lines are retired.
1178         '''
1179         # this is per-class only
1180         if not self.editCSVPermission():
1181             self.error_message.append(
1182                 _('You do not have permission to edit %s' %self.classname))
1184         # get the CSV module
1185         try:
1186             import csv
1187         except ImportError:
1188             self.error_message.append(_(
1189                 'Sorry, you need the csv module to use this function.<br>\n'
1190                 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
1191             return
1193         cl = self.db.classes[self.classname]
1194         idlessprops = cl.getprops(protected=0).keys()
1195         idlessprops.sort()
1196         props = ['id'] + idlessprops
1198         # do the edit
1199         rows = self.form['rows'].value.splitlines()
1200         p = csv.parser()
1201         found = {}
1202         line = 0
1203         for row in rows[1:]:
1204             line += 1
1205             values = p.parse(row)
1206             # not a complete row, keep going
1207             if not values: continue
1209             # skip property names header
1210             if values == props:
1211                 continue
1213             # extract the nodeid
1214             nodeid, values = values[0], values[1:]
1215             found[nodeid] = 1
1217             # confirm correct weight
1218             if len(idlessprops) != len(values):
1219                 self.error_message.append(
1220                     _('Not enough values on line %(line)s')%{'line':line})
1221                 return
1223             # extract the new values
1224             d = {}
1225             for name, value in zip(idlessprops, values):
1226                 value = value.strip()
1227                 # only add the property if it has a value
1228                 if value:
1229                     # if it's a multilink, split it
1230                     if isinstance(cl.properties[name], hyperdb.Multilink):
1231                         value = value.split(':')
1232                     d[name] = value
1234             # perform the edit
1235             if cl.hasnode(nodeid):
1236                 # edit existing
1237                 cl.set(nodeid, **d)
1238             else:
1239                 # new node
1240                 found[cl.create(**d)] = 1
1242         # retire the removed entries
1243         for nodeid in cl.list():
1244             if not found.has_key(nodeid):
1245                 cl.retire(nodeid)
1247         # all OK
1248         self.db.commit()
1250         self.ok_message.append(_('Items edited OK'))
1252     def editCSVPermission(self):
1253         ''' Determine whether the user has permission to edit this class.
1255             Base behaviour is to check the user can edit this class.
1256         ''' 
1257         if not self.db.security.hasPermission('Edit', self.userid,
1258                 self.classname):
1259             return 0
1260         return 1
1262     def searchAction(self):
1263         ''' Mangle some of the form variables.
1265             Set the form ":filter" variable based on the values of the
1266             filter variables - if they're set to anything other than
1267             "dontcare" then add them to :filter.
1269             Also handle the ":queryname" variable and save off the query to
1270             the user's query list.
1271         '''
1272         # generic edit is per-class only
1273         if not self.searchPermission():
1274             self.error_message.append(
1275                 _('You do not have permission to search %s' %self.classname))
1277         # add a faked :filter form variable for each filtering prop
1278         props = self.db.classes[self.classname].getprops()
1279         queryname = ''
1280         for key in self.form.keys():
1281             # special vars
1282             if self.FV_QUERYNAME.match(key):
1283                 queryname = self.form[key].value.strip()
1284                 continue
1286             if not props.has_key(key):
1287                 continue
1288             if isinstance(self.form[key], type([])):
1289                 # search for at least one entry which is not empty
1290                 for minifield in self.form[key]:
1291                     if minifield.value:
1292                         break
1293                 else:
1294                     continue
1295             else:
1296                 if not self.form[key].value: continue
1297             self.form.value.append(cgi.MiniFieldStorage('@filter', key))
1299         # handle saving the query params
1300         if queryname:
1301             # parse the environment and figure what the query _is_
1302             req = HTMLRequest(self)
1303             url = req.indexargs_href('', {})
1305             # handle editing an existing query
1306             try:
1307                 qid = self.db.query.lookup(queryname)
1308                 self.db.query.set(qid, klass=self.classname, url=url)
1309             except KeyError:
1310                 # create a query
1311                 qid = self.db.query.create(name=queryname,
1312                     klass=self.classname, url=url)
1314                 # and add it to the user's query multilink
1315                 queries = self.db.user.get(self.userid, 'queries')
1316                 queries.append(qid)
1317                 self.db.user.set(self.userid, queries=queries)
1319             # commit the query change to the database
1320             self.db.commit()
1322     def searchPermission(self):
1323         ''' Determine whether the user has permission to search this class.
1325             Base behaviour is to check the user can view this class.
1326         ''' 
1327         if not self.db.security.hasPermission('View', self.userid,
1328                 self.classname):
1329             return 0
1330         return 1
1333     def retireAction(self):
1334         ''' Retire the context item.
1335         '''
1336         # if we want to view the index template now, then unset the nodeid
1337         # context info (a special-case for retire actions on the index page)
1338         nodeid = self.nodeid
1339         if self.template == 'index':
1340             self.nodeid = None
1342         # generic edit is per-class only
1343         if not self.retirePermission():
1344             self.error_message.append(
1345                 _('You do not have permission to retire %s' %self.classname))
1346             return
1348         # make sure we don't try to retire admin or anonymous
1349         if self.classname == 'user' and \
1350                 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1351             self.error_message.append(
1352                 _('You may not retire the admin or anonymous user'))
1353             return
1355         # do the retire
1356         self.db.getclass(self.classname).retire(nodeid)
1357         self.db.commit()
1359         self.ok_message.append(
1360             _('%(classname)s %(itemid)s has been retired')%{
1361                 'classname': self.classname.capitalize(), 'itemid': nodeid})
1363     def retirePermission(self):
1364         ''' Determine whether the user has permission to retire this class.
1366             Base behaviour is to check the user can edit this class.
1367         ''' 
1368         if not self.db.security.hasPermission('Edit', self.userid,
1369                 self.classname):
1370             return 0
1371         return 1
1374     def showAction(self, typere=re.compile('[@:]type'),
1375             numre=re.compile('[@:]number')):
1376         ''' Show a node of a particular class/id
1377         '''
1378         t = n = ''
1379         for key in self.form.keys():
1380             if typere.match(key):
1381                 t = self.form[key].value.strip()
1382             elif numre.match(key):
1383                 n = self.form[key].value.strip()
1384         if not t:
1385             raise ValueError, 'Invalid %s number'%t
1386         url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1387         raise Redirect, url
1389     def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1390         ''' Pull properties out of the form.
1392             In the following, <bracketed> values are variable, ":" may be
1393             one of ":" or "@", and other text "required" is fixed.
1395             Properties are specified as form variables:
1397              <propname>
1398               - property on the current context item
1400              <designator>:<propname>
1401               - property on the indicated item
1403              <classname>-<N>:<propname>
1404               - property on the Nth new item of classname
1406             Once we have determined the "propname", we check to see if it
1407             is one of the special form values:
1409              :required
1410               The named property values must be supplied or a ValueError
1411               will be raised.
1413              :remove:<propname>=id(s)
1414               The ids will be removed from the multilink property.
1416              :add:<propname>=id(s)
1417               The ids will be added to the multilink property.
1419              :link:<propname>=<designator>
1420               Used to add a link to new items created during edit.
1421               These are collected up and returned in all_links. This will
1422               result in an additional linking operation (either Link set or
1423               Multilink append) after the edit/create is done using
1424               all_props in _editnodes. The <propname> on the current item
1425               will be set/appended the id of the newly created item of
1426               class <designator> (where <designator> must be
1427               <classname>-<N>).
1429             Any of the form variables may be prefixed with a classname or
1430             designator.
1432             The return from this method is a dict of 
1433                 (classname, id): properties
1434             ... this dict _always_ has an entry for the current context,
1435             even if it's empty (ie. a submission for an existing issue that
1436             doesn't result in any changes would return {('issue','123'): {}})
1437             The id may be None, which indicates that an item should be
1438             created.
1440             If a String property's form value is a file upload, then we
1441             try to set additional properties "filename" and "type" (if
1442             they are valid for the class).
1444             Two special form values are supported for backwards
1445             compatibility:
1446              :note - create a message (with content, author and date), link
1447                      to the context item. This is ALWAYS desginated "msg-1".
1448              :file - create a file, attach to the current item and any
1449                      message created by :note. This is ALWAYS designated
1450                      "file-1".
1452             We also check that FileClass items have a "content" property with
1453             actual content, otherwise we remove them from all_props before
1454             returning.
1455         '''
1456         # some very useful variables
1457         db = self.db
1458         form = self.form
1460         if not hasattr(self, 'FV_SPECIAL'):
1461             # generate the regexp for handling special form values
1462             classes = '|'.join(db.classes.keys())
1463             # specials for parsePropsFromForm
1464             # handle the various forms (see unit tests)
1465             self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1466             self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1468         # these indicate the default class / item
1469         default_cn = self.classname
1470         default_cl = self.db.classes[default_cn]
1471         default_nodeid = self.nodeid
1473         # we'll store info about the individual class/item edit in these
1474         all_required = {}       # one entry per class/item
1475         all_props = {}          # one entry per class/item
1476         all_propdef = {}        # note - only one entry per class
1477         all_links = []          # as many as are required
1479         # we should always return something, even empty, for the context
1480         all_props[(default_cn, default_nodeid)] = {}
1482         keys = form.keys()
1483         timezone = db.getUserTimezone()
1485         # sentinels for the :note and :file props
1486         have_note = have_file = 0
1488         # extract the usable form labels from the form
1489         matches = []
1490         for key in keys:
1491             m = self.FV_SPECIAL.match(key)
1492             if m:
1493                 matches.append((key, m.groupdict()))
1495         # now handle the matches
1496         for key, d in matches:
1497             if d['classname']:
1498                 # we got a designator
1499                 cn = d['classname']
1500                 cl = self.db.classes[cn]
1501                 nodeid = d['id']
1502                 propname = d['propname']
1503             elif d['note']:
1504                 # the special note field
1505                 cn = 'msg'
1506                 cl = self.db.classes[cn]
1507                 nodeid = '-1'
1508                 propname = 'content'
1509                 all_links.append((default_cn, default_nodeid, 'messages',
1510                     [('msg', '-1')]))
1511                 have_note = 1
1512             elif d['file']:
1513                 # the special file field
1514                 cn = 'file'
1515                 cl = self.db.classes[cn]
1516                 nodeid = '-1'
1517                 propname = 'content'
1518                 all_links.append((default_cn, default_nodeid, 'files',
1519                     [('file', '-1')]))
1520                 have_file = 1
1521             else:
1522                 # default
1523                 cn = default_cn
1524                 cl = default_cl
1525                 nodeid = default_nodeid
1526                 propname = d['propname']
1528             # the thing this value relates to is...
1529             this = (cn, nodeid)
1531             # get more info about the class, and the current set of
1532             # form props for it
1533             if not all_propdef.has_key(cn):
1534                 all_propdef[cn] = cl.getprops()
1535             propdef = all_propdef[cn]
1536             if not all_props.has_key(this):
1537                 all_props[this] = {}
1538             props = all_props[this]
1540             # is this a link command?
1541             if d['link']:
1542                 value = []
1543                 for entry in extractFormList(form[key]):
1544                     m = self.FV_DESIGNATOR.match(entry)
1545                     if not m:
1546                         raise ValueError, \
1547                             'link "%s" value "%s" not a designator'%(key, entry)
1548                     value.append((m.group(1), m.group(2)))
1550                 # make sure the link property is valid
1551                 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1552                         not isinstance(propdef[propname], hyperdb.Link)):
1553                     raise ValueError, '%s %s is not a link or '\
1554                         'multilink property'%(cn, propname)
1556                 all_links.append((cn, nodeid, propname, value))
1557                 continue
1559             # detect the special ":required" variable
1560             if d['required']:
1561                 all_required[this] = extractFormList(form[key])
1562                 continue
1564             # get the required values list
1565             if not all_required.has_key(this):
1566                 all_required[this] = []
1567             required = all_required[this]
1569             # see if we're performing a special multilink action
1570             mlaction = 'set'
1571             if d['remove']:
1572                 mlaction = 'remove'
1573             elif d['add']:
1574                 mlaction = 'add'
1576             # does the property exist?
1577             if not propdef.has_key(propname):
1578                 if mlaction != 'set':
1579                     raise ValueError, 'You have submitted a %s action for'\
1580                         ' the property "%s" which doesn\'t exist'%(mlaction,
1581                         propname)
1582                 # the form element is probably just something we don't care
1583                 # about - ignore it
1584                 continue
1585             proptype = propdef[propname]
1587             # Get the form value. This value may be a MiniFieldStorage or a list
1588             # of MiniFieldStorages.
1589             value = form[key]
1591             # handle unpacking of the MiniFieldStorage / list form value
1592             if isinstance(proptype, hyperdb.Multilink):
1593                 value = extractFormList(value)
1594             else:
1595                 # multiple values are not OK
1596                 if isinstance(value, type([])):
1597                     raise ValueError, 'You have submitted more than one value'\
1598                         ' for the %s property'%propname
1599                 # value might be a file upload...
1600                 if not hasattr(value, 'filename') or value.filename is None:
1601                     # nope, pull out the value and strip it
1602                     value = value.value.strip()
1604             # now that we have the props field, we need a teensy little
1605             # extra bit of help for the old :note field...
1606             if d['note'] and value:
1607                 props['author'] = self.db.getuid()
1608                 props['date'] = date.Date()
1610             # handle by type now
1611             if isinstance(proptype, hyperdb.Password):
1612                 if not value:
1613                     # ignore empty password values
1614                     continue
1615                 for key, d in matches:
1616                     if d['confirm'] and d['propname'] == propname:
1617                         confirm = form[key]
1618                         break
1619                 else:
1620                     raise ValueError, 'Password and confirmation text do '\
1621                         'not match'
1622                 if isinstance(confirm, type([])):
1623                     raise ValueError, 'You have submitted more than one value'\
1624                         ' for the %s property'%propname
1625                 if value != confirm.value:
1626                     raise ValueError, 'Password and confirmation text do '\
1627                         'not match'
1628                 value = password.Password(value)
1630             elif isinstance(proptype, hyperdb.Link):
1631                 # see if it's the "no selection" choice
1632                 if value == '-1' or not value:
1633                     # if we're creating, just don't include this property
1634                     if not nodeid or nodeid.startswith('-'):
1635                         continue
1636                     value = None
1637                 else:
1638                     # handle key values
1639                     link = proptype.classname
1640                     if not num_re.match(value):
1641                         try:
1642                             value = db.classes[link].lookup(value)
1643                         except KeyError:
1644                             raise ValueError, _('property "%(propname)s": '
1645                                 '%(value)s not a %(classname)s')%{
1646                                 'propname': propname, 'value': value,
1647                                 'classname': link}
1648                         except TypeError, message:
1649                             raise ValueError, _('you may only enter ID values '
1650                                 'for property "%(propname)s": %(message)s')%{
1651                                 'propname': propname, 'message': message}
1652             elif isinstance(proptype, hyperdb.Multilink):
1653                 # perform link class key value lookup if necessary
1654                 link = proptype.classname
1655                 link_cl = db.classes[link]
1656                 l = []
1657                 for entry in value:
1658                     if not entry: continue
1659                     if not num_re.match(entry):
1660                         try:
1661                             entry = link_cl.lookup(entry)
1662                         except KeyError:
1663                             raise ValueError, _('property "%(propname)s": '
1664                                 '"%(value)s" not an entry of %(classname)s')%{
1665                                 'propname': propname, 'value': entry,
1666                                 'classname': link}
1667                         except TypeError, message:
1668                             raise ValueError, _('you may only enter ID values '
1669                                 'for property "%(propname)s": %(message)s')%{
1670                                 'propname': propname, 'message': message}
1671                     l.append(entry)
1672                 l.sort()
1674                 # now use that list of ids to modify the multilink
1675                 if mlaction == 'set':
1676                     value = l
1677                 else:
1678                     # we're modifying the list - get the current list of ids
1679                     if props.has_key(propname):
1680                         existing = props[propname]
1681                     elif nodeid and not nodeid.startswith('-'):
1682                         existing = cl.get(nodeid, propname, [])
1683                     else:
1684                         existing = []
1686                     # now either remove or add
1687                     if mlaction == 'remove':
1688                         # remove - handle situation where the id isn't in
1689                         # the list
1690                         for entry in l:
1691                             try:
1692                                 existing.remove(entry)
1693                             except ValueError:
1694                                 raise ValueError, _('property "%(propname)s": '
1695                                     '"%(value)s" not currently in list')%{
1696                                     'propname': propname, 'value': entry}
1697                     else:
1698                         # add - easy, just don't dupe
1699                         for entry in l:
1700                             if entry not in existing:
1701                                 existing.append(entry)
1702                     value = existing
1703                     value.sort()
1705             elif value == '':
1706                 # if we're creating, just don't include this property
1707                 if not nodeid or nodeid.startswith('-'):
1708                     continue
1709                 # other types should be None'd if there's no value
1710                 value = None
1711             else:
1712                 if isinstance(proptype, hyperdb.String):
1713                     if (hasattr(value, 'filename') and
1714                             value.filename is not None):
1715                         # skip if the upload is empty
1716                         if not value.filename:
1717                             continue
1718                         # this String is actually a _file_
1719                         # try to determine the file content-type
1720                         filename = value.filename.split('\\')[-1]
1721                         if propdef.has_key('name'):
1722                             props['name'] = filename
1723                         # use this info as the type/filename properties
1724                         if propdef.has_key('type'):
1725                             props['type'] = mimetypes.guess_type(filename)[0]
1726                             if not props['type']:
1727                                 props['type'] = "application/octet-stream"
1728                         # finally, read the content
1729                         value = value.value
1730                     else:
1731                         # normal String fix the CRLF/CR -> LF stuff
1732                         value = fixNewlines(value)
1734                 elif isinstance(proptype, hyperdb.Date):
1735                     value = date.Date(value, offset=timezone)
1736                 elif isinstance(proptype, hyperdb.Interval):
1737                     value = date.Interval(value)
1738                 elif isinstance(proptype, hyperdb.Boolean):
1739                     value = value.lower() in ('yes', 'true', 'on', '1')
1740                 elif isinstance(proptype, hyperdb.Number):
1741                     value = float(value)
1743             # get the old value
1744             if nodeid and not nodeid.startswith('-'):
1745                 try:
1746                     existing = cl.get(nodeid, propname)
1747                 except KeyError:
1748                     # this might be a new property for which there is
1749                     # no existing value
1750                     if not propdef.has_key(propname):
1751                         raise
1753                 # make sure the existing multilink is sorted
1754                 if isinstance(proptype, hyperdb.Multilink):
1755                     existing.sort()
1757                 # "missing" existing values may not be None
1758                 if not existing:
1759                     if isinstance(proptype, hyperdb.String) and not existing:
1760                         # some backends store "missing" Strings as empty strings
1761                         existing = None
1762                     elif isinstance(proptype, hyperdb.Number) and not existing:
1763                         # some backends store "missing" Numbers as 0 :(
1764                         existing = 0
1765                     elif isinstance(proptype, hyperdb.Boolean) and not existing:
1766                         # likewise Booleans
1767                         existing = 0
1769                 # if changed, set it
1770                 if value != existing:
1771                     props[propname] = value
1772             else:
1773                 # don't bother setting empty/unset values
1774                 if value is None:
1775                     continue
1776                 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1777                     continue
1778                 elif isinstance(proptype, hyperdb.String) and value == '':
1779                     continue
1781                 props[propname] = value
1783             # register this as received if required?
1784             if propname in required and value is not None:
1785                 required.remove(propname)
1787         # check to see if we need to specially link a file to the note
1788         if have_note and have_file:
1789             all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1791         # see if all the required properties have been supplied
1792         s = []
1793         for thing, required in all_required.items():
1794             if not required:
1795                 continue
1796             if len(required) > 1:
1797                 p = 'properties'
1798             else:
1799                 p = 'property'
1800             s.append('Required %s %s %s not supplied'%(thing[0], p,
1801                 ', '.join(required)))
1802         if s:
1803             raise ValueError, '\n'.join(s)
1805         # check that FileClass entries have a "content" property with
1806         # content, otherwise remove them
1807         for (cn, id), props in all_props.items():
1808             cl = self.db.classes[cn]
1809             if not isinstance(cl, hyperdb.FileClass):
1810                 continue
1811             # we also don't want to create FileClass items with no content
1812             if not props.get('content', ''):
1813                 del all_props[(cn, id)]
1814         return all_props, all_links
1816 def fixNewlines(text):
1817     ''' Homogenise line endings.
1819         Different web clients send different line ending values, but
1820         other systems (eg. email) don't necessarily handle those line
1821         endings. Our solution is to convert all line endings to LF.
1822     '''
1823     text = text.replace('\r\n', '\n')
1824     return text.replace('\r', '\n')
1826 def extractFormList(value):
1827     ''' Extract a list of values from the form value.
1829         It may be one of:
1830          [MiniFieldStorage, MiniFieldStorage, ...]
1831          MiniFieldStorage('value,value,...')
1832          MiniFieldStorage('value')
1833     '''
1834     # multiple values are OK
1835     if isinstance(value, type([])):
1836         # it's a list of MiniFieldStorages
1837         value = [i.value.strip() for i in value]
1838     else:
1839         # it's a MiniFieldStorage, but may be a comma-separated list
1840         # of values
1841         value = [i.strip() for i in value.value.split(',')]
1843     # filter out the empty bits
1844     return filter(None, value)