Code

efe59c4b4c8b0e523f21dcc876170770b1e0ec75
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.139 2003-09-10 13:04:05 jlgijsbers 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
11 from roundup import roundupdb, date, hyperdb, password, token, rcsv
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
18 from roundup.mailer import Mailer, MessageSendError
20 class HTTPException(Exception):
21       pass
22 class  Unauthorised(HTTPException):
23        pass
24 class  NotFound(HTTPException):
25        pass
26 class  Redirect(HTTPException):
27        pass
28 class  NotModified(HTTPException):
29        pass
31 # used by a couple of routines
32 chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
34 # XXX actually _use_ FormError
35 class FormError(ValueError):
36     ''' An "expected" exception occurred during form parsing.
37         - ie. something we know can go wrong, and don't want to alarm the
38           user with
40         We trap this at the user interface level and feed back a nice error
41         to the user.
42     '''
43     pass
45 class SendFile(Exception):
46     ''' Send a file from the database '''
48 class SendStaticFile(Exception):
49     ''' Send a static file from the instance html directory '''
51 def initialiseSecurity(security):
52     ''' Create some Permissions and Roles on the security object
54         This function is directly invoked by security.Security.__init__()
55         as a part of the Security object instantiation.
56     '''
57     security.addPermission(name="Web Registration",
58         description="User may register through the web")
59     p = security.addPermission(name="Web Access",
60         description="User may access the web interface")
61     security.addPermissionToRole('Admin', p)
63     # doing Role stuff through the web - make sure Admin can
64     p = security.addPermission(name="Web Roles",
65         description="User may manipulate user Roles through the web")
66     security.addPermissionToRole('Admin', p)
68 # used to clean messages passed through CGI variables - HTML-escape any tag
69 # that isn't <a href="">, <i>, <b> and <br> (including XHTML variants) so
70 # that people can't pass through nasties like <script>, <iframe>, ...
71 CLEAN_MESSAGE_RE = r'(<(/?(.*?)(\s*href="[^"]")?\s*/?)>)'
72 def clean_message(message, mc=re.compile(CLEAN_MESSAGE_RE, re.I)):
73     return mc.sub(clean_message_callback, message)
74 def clean_message_callback(match, ok={'a':1,'i':1,'b':1,'br':1}):
75     ''' Strip all non <a>,<i>,<b> and <br> tags from a string
76     '''
77     if ok.has_key(match.group(3).lower()):
78         return match.group(1)
79     return '&lt;%s&gt;'%match.group(2)
81 class Client:
82     ''' Instantiate to handle one CGI request.
84     See inner_main for request processing.
86     Client attributes at instantiation:
87         "path" is the PATH_INFO inside the instance (with no leading '/')
88         "base" is the base URL for the instance
89         "form" is the cgi form, an instance of FieldStorage from the standard
90                cgi module
91         "additional_headers" is a dictionary of additional HTTP headers that
92                should be sent to the client
93         "response_code" is the HTTP response code to send to the client
95     During the processing of a request, the following attributes are used:
96         "error_message" holds a list of error messages
97         "ok_message" holds a list of OK messages
98         "session" is the current user session id
99         "user" is the current user's name
100         "userid" is the current user's id
101         "template" is the current :template context
102         "classname" is the current class context name
103         "nodeid" is the current context item id
105     User Identification:
106      If the user has no login cookie, then they are anonymous and are logged
107      in as that user. This typically gives them all Permissions assigned to the
108      Anonymous Role.
110      Once a user logs in, they are assigned a session. The Client instance
111      keeps the nodeid of the session as the "session" attribute.
114     Special form variables:
115      Note that in various places throughout this code, special form
116      variables of the form :<name> are used. The colon (":") part may
117      actually be one of either ":" or "@".
118     '''
120     #
121     # special form variables
122     #
123     FV_TEMPLATE = re.compile(r'[@:]template')
124     FV_OK_MESSAGE = re.compile(r'[@:]ok_message')
125     FV_ERROR_MESSAGE = re.compile(r'[@:]error_message')
127     FV_QUERYNAME = re.compile(r'[@:]queryname')
129     # edit form variable handling (see unit tests)
130     FV_LABELS = r'''
131        ^(
132          (?P<note>[@:]note)|
133          (?P<file>[@:]file)|
134          (
135           ((?P<classname>%s)(?P<id>[-\d]+))?  # optional leading designator
136           ((?P<required>[@:]required$)|       # :required
137            (
138             (
139              (?P<add>[@:]add[@:])|            # :add:<prop>
140              (?P<remove>[@:]remove[@:])|      # :remove:<prop>
141              (?P<confirm>[@:]confirm[@:])|    # :confirm:<prop>
142              (?P<link>[@:]link[@:])|          # :link:<prop>
143              ([@:])                           # just a separator
144             )?
145             (?P<propname>[^@:]+)             # <prop>
146            )
147           )
148          )
149         )$'''
151     # Note: index page stuff doesn't appear here:
152     # columns, sort, sortdir, filter, group, groupdir, search_text,
153     # pagesize, startwith
155     def __init__(self, instance, request, env, form=None):
156         hyperdb.traceMark()
157         self.instance = instance
158         self.request = request
159         self.env = env
160         self.mailer = Mailer(instance.config)
162         # save off the path
163         self.path = env['PATH_INFO']
165         # this is the base URL for this tracker
166         self.base = self.instance.config.TRACKER_WEB
168         # this is the "cookie path" for this tracker (ie. the path part of
169         # the "base" url)
170         self.cookie_path = urlparse.urlparse(self.base)[2]
171         self.cookie_name = 'roundup_session_' + re.sub('[^a-zA-Z]', '',
172             self.instance.config.TRACKER_NAME)
174         # see if we need to re-parse the environment for the form (eg Zope)
175         if form is None:
176             self.form = cgi.FieldStorage(environ=env)
177         else:
178             self.form = form
180         # turn debugging on/off
181         try:
182             self.debug = int(env.get("ROUNDUP_DEBUG", 0))
183         except ValueError:
184             # someone gave us a non-int debug level, turn it off
185             self.debug = 0
187         # flag to indicate that the HTTP headers have been sent
188         self.headers_done = 0
190         # additional headers to send with the request - must be registered
191         # before the first write
192         self.additional_headers = {}
193         self.response_code = 200
196     def main(self):
197         ''' Wrap the real main in a try/finally so we always close off the db.
198         '''
199         try:
200             self.inner_main()
201         finally:
202             if hasattr(self, 'db'):
203                 self.db.close()
205     def inner_main(self):
206         ''' Process a request.
208             The most common requests are handled like so:
209             1. figure out who we are, defaulting to the "anonymous" user
210                see determine_user
211             2. figure out what the request is for - the context
212                see determine_context
213             3. handle any requested action (item edit, search, ...)
214                see handle_action
215             4. render a template, resulting in HTML output
217             In some situations, exceptions occur:
218             - HTTP Redirect  (generally raised by an action)
219             - SendFile       (generally raised by determine_context)
220               serve up a FileClass "content" property
221             - SendStaticFile (generally raised by determine_context)
222               serve up a file from the tracker "html" directory
223             - Unauthorised   (generally raised by an action)
224               the action is cancelled, the request is rendered and an error
225               message is displayed indicating that permission was not
226               granted for the action to take place
227             - NotFound       (raised wherever it needs to be)
228               percolates up to the CGI interface that called the client
229         '''
230         self.ok_message = []
231         self.error_message = []
232         try:
233             # figure out the context and desired content template
234             # do this first so we don't authenticate for static files
235             # Note: this method opens the database as "admin" in order to
236             # perform context checks
237             self.determine_context()
239             # make sure we're identified (even anonymously)
240             self.determine_user()
242             # possibly handle a form submit action (may change self.classname
243             # and self.template, and may also append error/ok_messages)
244             self.handle_action()
246             # now render the page
247             # we don't want clients caching our dynamic pages
248             self.additional_headers['Cache-Control'] = 'no-cache'
249 # Pragma: no-cache makes Mozilla and its ilk double-load all pages!!
250 #            self.additional_headers['Pragma'] = 'no-cache'
252             # expire this page 5 seconds from now
253             date = rfc822.formatdate(time.time() + 5)
254             self.additional_headers['Expires'] = date
256             # render the content
257             self.write(self.renderContext())
258         except Redirect, url:
259             # let's redirect - if the url isn't None, then we need to do
260             # the headers, otherwise the headers have been set before the
261             # exception was raised
262             if url:
263                 self.additional_headers['Location'] = url
264                 self.response_code = 302
265             self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
266         except SendFile, designator:
267             self.serve_file(designator)
268         except SendStaticFile, file:
269             try:
270                 self.serve_static_file(str(file))
271             except NotModified:
272                 # send the 304 response
273                 self.request.send_response(304)
274                 self.request.end_headers()
275         except Unauthorised, message:
276             self.classname = None
277             self.template = ''
278             self.error_message.append(message)
279             self.write(self.renderContext())
280         except NotFound:
281             # pass through
282             raise
283         except:
284             # everything else
285             self.write(cgitb.html())
287     def clean_sessions(self):
288         ''' Age sessions, remove when they haven't been used for a week.
289         
290             Do it only once an hour.
292             Note: also cleans One Time Keys, and other "session" based
293             stuff.
294         '''
295         sessions = self.db.sessions
296         last_clean = sessions.get('last_clean', 'last_use') or 0
298         week = 60*60*24*7
299         hour = 60*60
300         now = time.time()
301         if now - last_clean > hour:
302             # remove aged sessions
303             for sessid in sessions.list():
304                 interval = now - sessions.get(sessid, 'last_use')
305                 if interval > week:
306                     sessions.destroy(sessid)
307             # remove aged otks
308             otks = self.db.otks
309             for sessid in otks.list():
310                 interval = now - otks.get(sessid, '__time')
311                 if interval > week:
312                     otks.destroy(sessid)
313             sessions.set('last_clean', last_use=time.time())
315     def determine_user(self):
316         ''' Determine who the user is
317         '''
318         # open the database as admin
319         self.opendb('admin')
321         # clean age sessions
322         self.clean_sessions()
324         # make sure we have the session Class
325         sessions = self.db.sessions
327         # look up the user session cookie
328         cookie = Cookie.SimpleCookie(self.env.get('HTTP_COOKIE', ''))
329         user = 'anonymous'
331         # bump the "revision" of the cookie since the format changed
332         if (cookie.has_key(self.cookie_name) and
333                 cookie[self.cookie_name].value != 'deleted'):
335             # get the session key from the cookie
336             self.session = cookie[self.cookie_name].value
337             # get the user from the session
338             try:
339                 # update the lifetime datestamp
340                 sessions.set(self.session, last_use=time.time())
341                 sessions.commit()
342                 user = sessions.get(self.session, 'user')
343             except KeyError:
344                 user = 'anonymous'
346         # sanity check on the user still being valid, getting the userid
347         # at the same time
348         try:
349             self.userid = self.db.user.lookup(user)
350         except (KeyError, TypeError):
351             user = 'anonymous'
353         # make sure the anonymous user is valid if we're using it
354         if user == 'anonymous':
355             self.make_user_anonymous()
356         else:
357             self.user = user
359         # reopen the database as the correct user
360         self.opendb(self.user)
362     def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
363         ''' Determine the context of this page from the URL:
365             The URL path after the instance identifier is examined. The path
366             is generally only one entry long.
368             - if there is no path, then we are in the "home" context.
369             * if the path is "_file", then the additional path entry
370               specifies the filename of a static file we're to serve up
371               from the instance "html" directory. Raises a SendStaticFile
372               exception.
373             - if there is something in the path (eg "issue"), it identifies
374               the tracker class we're to display.
375             - if the path is an item designator (eg "issue123"), then we're
376               to display a specific item.
377             * if the path starts with an item designator and is longer than
378               one entry, then we're assumed to be handling an item of a
379               FileClass, and the extra path information gives the filename
380               that the client is going to label the download with (ie
381               "file123/image.png" is nicer to download than "file123"). This
382               raises a SendFile exception.
384             Both of the "*" types of contexts stop before we bother to
385             determine the template we're going to use. That's because they
386             don't actually use templates.
388             The template used is specified by the :template CGI variable,
389             which defaults to:
391              only classname suplied:          "index"
392              full item designator supplied:   "item"
394             We set:
395              self.classname  - the class to display, can be None
396              self.template   - the template to render the current context with
397              self.nodeid     - the nodeid of the class we're displaying
398         '''
399         # default the optional variables
400         self.classname = None
401         self.nodeid = None
403         # see if a template or messages are specified
404         template_override = ok_message = error_message = None
405         for key in self.form.keys():
406             if self.FV_TEMPLATE.match(key):
407                 template_override = self.form[key].value
408             elif self.FV_OK_MESSAGE.match(key):
409                 ok_message = self.form[key].value
410                 ok_message = clean_message(ok_message)
411             elif self.FV_ERROR_MESSAGE.match(key):
412                 error_message = self.form[key].value
413                 error_message = clean_message(error_message)
415         # determine the classname and possibly nodeid
416         path = self.path.split('/')
417         if not path or path[0] in ('', 'home', 'index'):
418             if template_override is not None:
419                 self.template = template_override
420             else:
421                 self.template = ''
422             return
423         elif path[0] == '_file':
424             raise SendStaticFile, os.path.join(*path[1:])
425         else:
426             self.classname = path[0]
427             if len(path) > 1:
428                 # send the file identified by the designator in path[0]
429                 raise SendFile, path[0]
431         # we need the db for further context stuff - open it as admin
432         self.opendb('admin')
434         # see if we got a designator
435         m = dre.match(self.classname)
436         if m:
437             self.classname = m.group(1)
438             self.nodeid = m.group(2)
439             if not self.db.getclass(self.classname).hasnode(self.nodeid):
440                 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
441             # with a designator, we default to item view
442             self.template = 'item'
443         else:
444             # with only a class, we default to index view
445             self.template = 'index'
447         # make sure the classname is valid
448         try:
449             self.db.getclass(self.classname)
450         except KeyError:
451             raise NotFound, self.classname
453         # see if we have a template override
454         if template_override is not None:
455             self.template = template_override
457         # see if we were passed in a message
458         if ok_message:
459             self.ok_message.append(ok_message)
460         if error_message:
461             self.error_message.append(error_message)
463     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
464         ''' Serve the file from the content property of the designated item.
465         '''
466         m = dre.match(str(designator))
467         if not m:
468             raise NotFound, str(designator)
469         classname, nodeid = m.group(1), m.group(2)
470         if classname != 'file':
471             raise NotFound, designator
473         # we just want to serve up the file named
474         self.opendb('admin')
475         file = self.db.file
476         self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
477         self.write(file.get(nodeid, 'content'))
479     def serve_static_file(self, file):
480         ims = None
481         # see if there's an if-modified-since...
482         if hasattr(self.request, 'headers'):
483             ims = self.request.headers.getheader('if-modified-since')
484         elif self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
485             # cgi will put the header in the env var
486             ims = self.env['HTTP_IF_MODIFIED_SINCE']
487         filename = os.path.join(self.instance.config.TEMPLATES, file)
488         lmt = os.stat(filename)[stat.ST_MTIME]
489         if ims:
490             ims = rfc822.parsedate(ims)[:6]
491             lmtt = time.gmtime(lmt)[:6]
492             if lmtt <= ims:
493                 raise NotModified
495         # we just want to serve up the file named
496         file = str(file)
497         mt = mimetypes.guess_type(file)[0]
498         if not mt:
499             if file.endswith('.css'):
500                 mt = 'text/css'
501             else:
502                 mt = 'text/plain'
503         self.additional_headers['Content-Type'] = mt
504         self.additional_headers['Last-Modifed'] = rfc822.formatdate(lmt)
505         self.write(open(filename, 'rb').read())
507     def renderContext(self):
508         ''' Return a PageTemplate for the named page
509         '''
510         name = self.classname
511         extension = self.template
512         pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
514         # catch errors so we can handle PT rendering errors more nicely
515         args = {
516             'ok_message': self.ok_message,
517             'error_message': self.error_message
518         }
519         try:
520             # let the template render figure stuff out
521             return pt.render(self, None, None, **args)
522         except NoTemplate, message:
523             return '<strong>%s</strong>'%message
524         except:
525             # everything else
526             return cgitb.pt_html()
528     # these are the actions that are available
529     actions = (
530         ('edit',     'editItemAction'),
531         ('editcsv',  'editCSVAction'),
532         ('new',      'newItemAction'),
533         ('register', 'registerAction'),
534         ('confrego', 'confRegoAction'),
535         ('passrst',  'passResetAction'),
536         ('login',    'loginAction'),
537         ('logout',   'logout_action'),
538         ('search',   'searchAction'),
539         ('retire',   'retireAction'),
540         ('show',     'showAction'),
541     )
542     def handle_action(self):
543         ''' Determine whether there should be an Action called.
545             The action is defined by the form variable :action which
546             identifies the method on this object to call. The actions
547             are defined in the "actions" sequence on this class.
548         '''
549         if self.form.has_key(':action'):
550             action = self.form[':action'].value.lower()
551         elif self.form.has_key('@action'):
552             action = self.form['@action'].value.lower()
553         else:
554             return None
555         try:
556             # get the action, validate it
557             for name, method in self.actions:
558                 if name == action:
559                     break
560             else:
561                 raise ValueError, 'No such action "%s"'%action
562             # call the mapped action
563             getattr(self, method)()
564         except Redirect:
565             raise
566         except Unauthorised:
567             raise
569     def write(self, content):
570         if not self.headers_done:
571             self.header()
572         self.request.wfile.write(content)
574     def header(self, headers=None, response=None):
575         '''Put up the appropriate header.
576         '''
577         if headers is None:
578             headers = {'Content-Type':'text/html'}
579         if response is None:
580             response = self.response_code
582         # update with additional info
583         headers.update(self.additional_headers)
585         if not headers.has_key('Content-Type'):
586             headers['Content-Type'] = 'text/html'
587         self.request.send_response(response)
588         for entry in headers.items():
589             self.request.send_header(*entry)
590         self.request.end_headers()
591         self.headers_done = 1
592         if self.debug:
593             self.headers_sent = headers
595     def set_cookie(self, user):
596         ''' Set up a session cookie for the user and store away the user's
597             login info against the session.
598         '''
599         # TODO generate a much, much stronger session key ;)
600         self.session = binascii.b2a_base64(repr(random.random())).strip()
602         # clean up the base64
603         if self.session[-1] == '=':
604             if self.session[-2] == '=':
605                 self.session = self.session[:-2]
606             else:
607                 self.session = self.session[:-1]
609         # insert the session in the sessiondb
610         self.db.sessions.set(self.session, user=user, last_use=time.time())
612         # and commit immediately
613         self.db.sessions.commit()
615         # expire us in a long, long time
616         expire = Cookie._getdate(86400*365)
618         # generate the cookie path - make sure it has a trailing '/'
619         self.additional_headers['Set-Cookie'] = \
620           '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
621             expire, self.cookie_path)
623     def make_user_anonymous(self):
624         ''' Make us anonymous
626             This method used to handle non-existence of the 'anonymous'
627             user, but that user is mandatory now.
628         '''
629         self.userid = self.db.user.lookup('anonymous')
630         self.user = 'anonymous'
632     def opendb(self, user):
633         ''' Open the database.
634         '''
635         # open the db if the user has changed
636         if not hasattr(self, 'db') or user != self.db.journaltag:
637             if hasattr(self, 'db'):
638                 self.db.close()
639             self.db = self.instance.open(user)
641     #
642     # Actions
643     #
644     def loginAction(self):
645         ''' Attempt to log a user in.
647             Sets up a session for the user which contains the login
648             credentials.
649         '''
650         # we need the username at a minimum
651         if not self.form.has_key('__login_name'):
652             self.error_message.append(_('Username required'))
653             return
655         # get the login info
656         self.user = self.form['__login_name'].value
657         if self.form.has_key('__login_password'):
658             password = self.form['__login_password'].value
659         else:
660             password = ''
662         # make sure the user exists
663         try:
664             self.userid = self.db.user.lookup(self.user)
665         except KeyError:
666             name = self.user
667             self.error_message.append(_('No such user "%(name)s"')%locals())
668             self.make_user_anonymous()
669             return
671         # verify the password
672         if not self.verifyPassword(self.userid, password):
673             self.make_user_anonymous()
674             self.error_message.append(_('Incorrect password'))
675             return
677         # make sure we're allowed to be here
678         if not self.loginPermission():
679             self.make_user_anonymous()
680             self.error_message.append(_("You do not have permission to login"))
681             return
683         # now we're OK, re-open the database for real, using the user
684         self.opendb(self.user)
686         # set the session cookie
687         self.set_cookie(self.user)
689     def verifyPassword(self, userid, password):
690         ''' Verify the password that the user has supplied
691         '''
692         stored = self.db.user.get(self.userid, 'password')
693         if password == stored:
694             return 1
695         if not password and not stored:
696             return 1
697         return 0
699     def loginPermission(self):
700         ''' Determine whether the user has permission to log in.
702             Base behaviour is to check the user has "Web Access".
703         ''' 
704         if not self.db.security.hasPermission('Web Access', self.userid):
705             return 0
706         return 1
708     def logout_action(self):
709         ''' Make us really anonymous - nuke the cookie too
710         '''
711         # log us out
712         self.make_user_anonymous()
714         # construct the logout cookie
715         now = Cookie._getdate()
716         self.additional_headers['Set-Cookie'] = \
717            '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.cookie_name,
718             now, self.cookie_path)
720         # Let the user know what's going on
721         self.ok_message.append(_('You are logged out'))
723     def registerAction(self):
724         '''Attempt to create a new user based on the contents of the form
725         and then set the cookie.
727         return 1 on successful login
728         '''
729         # parse the props from the form
730         try:
731             props = self.parsePropsFromForm()[0][('user', None)]
732         except (ValueError, KeyError), message:
733             self.error_message.append(_('Error: ') + str(message))
734             return
736         # make sure we're allowed to register
737         if not self.registerPermission(props):
738             raise Unauthorised, _("You do not have permission to register")
740         try:
741             self.db.user.lookup(props['username'])
742             self.error_message.append('Error: A user with the username "%s" '
743                 'already exists'%props['username'])
744             return
745         except KeyError:
746             pass
748         # generate the one-time-key and store the props for later
749         otk = ''.join([random.choice(chars) for x in range(32)])
750         for propname, proptype in self.db.user.getprops().items():
751             value = props.get(propname, None)
752             if value is None:
753                 pass
754             elif isinstance(proptype, hyperdb.Date):
755                 props[propname] = str(value)
756             elif isinstance(proptype, hyperdb.Interval):
757                 props[propname] = str(value)
758             elif isinstance(proptype, hyperdb.Password):
759                 props[propname] = str(value)
760         props['__time'] = time.time()
761         self.db.otks.set(otk, **props)
763         # send the email
764         tracker_name = self.db.config.TRACKER_NAME
765         tracker_email = self.db.config.TRACKER_EMAIL
766         subject = 'Complete your registration to %s -- key %s' % (tracker_name,
767                                                                   otk)
768         body = """To complete your registration of the user "%(name)s" with
769 %(tracker)s, please do one of the following:
771 - send a reply to %(tracker_email)s and maintain the subject line as is (the
772 reply's additional "Re:" is ok),
774 - or visit the following URL:
776    %(url)s?@action=confrego&otk=%(otk)s
777 """ % {'name': props['username'], 'tracker': tracker_name, 'url': self.base,
778        'otk': otk, 'tracker_email': tracker_email}
779         if not self.standard_message(props['address'], subject, body,
780                                      tracker_email):
781             return
783         # commit changes to the database
784         self.db.commit()
786         # redirect to the "you're almost there" page
787         raise Redirect, '%suser?@template=rego_progress'%self.base
789     def standard_message(self, to, subject, body, author=None):
790         try:
791             self.mailer.standard_message(to, subject, body, author)
792             return 1
793         except MessageSendError, e:
794             self.error_message.append(str(e))
795             
796     def registerPermission(self, props):
797         ''' Determine whether the user has permission to register
799             Base behaviour is to check the user has "Web Registration".
800         '''
801         # registration isn't allowed to supply roles
802         if props.has_key('roles'):
803             return 0
804         if self.db.security.hasPermission('Web Registration', self.userid):
805             return 1
806         return 0
808     def confRegoAction(self):
809         ''' Grab the OTK, use it to load up the new user details
810         '''
811         try:
812             # pull the rego information out of the otk database
813             self.userid = self.db.confirm_registration(self.form['otk'].value)
814         except (ValueError, KeyError), message:
815             # XXX: we need to make the "default" page be able to display errors!
816             self.error_message.append(str(message))
817             return
818         
819         # log the new user in
820         self.user = self.db.user.get(self.userid, 'username')
821         # re-open the database for real, using the user
822         self.opendb(self.user)
824         # if we have a session, update it
825         if hasattr(self, 'session'):
826             self.db.sessions.set(self.session, user=self.user,
827                 last_use=time.time())
828         else:
829             # new session cookie
830             self.set_cookie(self.user)
832         # nice message
833         message = _('You are now registered, welcome!')
835         # redirect to the user's page
836         raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
837             self.userid, urllib.quote(message))
839     def passResetAction(self):
840         ''' Handle password reset requests.
842             Presence of either "name" or "address" generate email.
843             Presense of "otk" performs the reset.
844         '''
845         if self.form.has_key('otk'):
846             # pull the rego information out of the otk database
847             otk = self.form['otk'].value
848             uid = self.db.otks.get(otk, 'uid')
849             if uid is None:
850                 self.error_message.append('Invalid One Time Key!')
851                 return
853             # re-open the database as "admin"
854             if self.user != 'admin':
855                 self.opendb('admin')
857             # change the password
858             newpw = password.generatePassword()
860             cl = self.db.user
861 # XXX we need to make the "default" page be able to display errors!
862             try:
863                 # set the password
864                 cl.set(uid, password=password.Password(newpw))
865                 # clear the props from the otk database
866                 self.db.otks.destroy(otk)
867                 self.db.commit()
868             except (ValueError, KeyError), message:
869                 self.error_message.append(str(message))
870                 return
872             # user info
873             address = self.db.user.get(uid, 'address')
874             name = self.db.user.get(uid, 'username')
876             # send the email
877             tracker_name = self.db.config.TRACKER_NAME
878             subject = 'Password reset for %s'%tracker_name
879             body = '''
880 The password has been reset for username "%(name)s".
882 Your password is now: %(password)s
883 '''%{'name': name, 'password': newpw}
884             if not self.standard_message(address, subject, body):
885                 return
887             self.ok_message.append('Password reset and email sent to %s'%address)
888             return
890         # no OTK, so now figure the user
891         if self.form.has_key('username'):
892             name = self.form['username'].value
893             try:
894                 uid = self.db.user.lookup(name)
895             except KeyError:
896                 self.error_message.append('Unknown username')
897                 return
898             address = self.db.user.get(uid, 'address')
899         elif self.form.has_key('address'):
900             address = self.form['address'].value
901             uid = uidFromAddress(self.db, ('', address), create=0)
902             if not uid:
903                 self.error_message.append('Unknown email address')
904                 return
905             name = self.db.user.get(uid, 'username')
906         else:
907             self.error_message.append('You need to specify a username '
908                 'or address')
909             return
911         # generate the one-time-key and store the props for later
912         otk = ''.join([random.choice(chars) for x in range(32)])
913         self.db.otks.set(otk, uid=uid, __time=time.time())
915         # send the email
916         tracker_name = self.db.config.TRACKER_NAME
917         subject = 'Confirm reset of password for %s'%tracker_name
918         body = '''
919 Someone, perhaps you, has requested that the password be changed for your
920 username, "%(name)s". If you wish to proceed with the change, please follow
921 the link below:
923   %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
925 You should then receive another email with the new password.
926 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
927         if not self.standard_message(address, subject, body):
928             return
930         self.ok_message.append('Email sent to %s'%address)
932     def editItemAction(self):
933         ''' Perform an edit of an item in the database.
935            See parsePropsFromForm and _editnodes for special variables
936         '''
937         # parse the props from the form
938         try:
939             props, links = self.parsePropsFromForm()
940         except (ValueError, KeyError), message:
941             self.error_message.append(_('Parse Error: ') + str(message))
942             return
944         # handle the props
945         try:
946             message = self._editnodes(props, links)
947         except (ValueError, KeyError, IndexError), message:
948             self.error_message.append(_('Apply Error: ') + str(message))
949             return
951         # commit now that all the tricky stuff is done
952         self.db.commit()
954         # redirect to the item's edit page
955         raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
956             self.classname, self.nodeid, urllib.quote(message),
957             urllib.quote(self.template))
959     def editItemPermission(self, props):
960         ''' Determine whether the user has permission to edit this item.
962             Base behaviour is to check the user can edit this class. If we're
963             editing the "user" class, users are allowed to edit their own
964             details. Unless it's the "roles" property, which requires the
965             special Permission "Web Roles".
966         '''
967         # if this is a user node and the user is editing their own node, then
968         # we're OK
969         has = self.db.security.hasPermission
970         if self.classname == 'user':
971             # reject if someone's trying to edit "roles" and doesn't have the
972             # right permission.
973             if props.has_key('roles') and not has('Web Roles', self.userid,
974                     'user'):
975                 return 0
976             # if the item being edited is the current user, we're ok
977             if self.nodeid == self.userid:
978                 return 1
979         if self.db.security.hasPermission('Edit', self.userid, self.classname):
980             return 1
981         return 0
983     def newItemAction(self):
984         ''' Add a new item to the database.
986             This follows the same form as the editItemAction, with the same
987             special form values.
988         '''
989         # parse the props from the form
990         try:
991             props, links = self.parsePropsFromForm()
992         except (ValueError, KeyError), message:
993             self.error_message.append(_('Error: ') + str(message))
994             return
996         # handle the props - edit or create
997         try:
998             # when it hits the None element, it'll set self.nodeid
999             messages = self._editnodes(props, links)
1001         except (ValueError, KeyError, IndexError), message:
1002             # these errors might just be indicative of user dumbness
1003             self.error_message.append(_('Error: ') + str(message))
1004             return
1006         # commit now that all the tricky stuff is done
1007         self.db.commit()
1009         # redirect to the new item's page
1010         raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
1011             self.classname, self.nodeid, urllib.quote(messages),
1012             urllib.quote(self.template))
1014     def newItemPermission(self, props):
1015         ''' Determine whether the user has permission to create (edit) this
1016             item.
1018             Base behaviour is to check the user can edit this class. No
1019             additional property checks are made. Additionally, new user items
1020             may be created if the user has the "Web Registration" Permission.
1021         '''
1022         has = self.db.security.hasPermission
1023         if self.classname == 'user' and has('Web Registration', self.userid,
1024                 'user'):
1025             return 1
1026         if has('Edit', self.userid, self.classname):
1027             return 1
1028         return 0
1031     #
1032     #  Utility methods for editing
1033     #
1034     def _editnodes(self, all_props, all_links, newids=None):
1035         ''' Use the props in all_props to perform edit and creation, then
1036             use the link specs in all_links to do linking.
1037         '''
1038         # figure dependencies and re-work links
1039         deps = {}
1040         links = {}
1041         for cn, nodeid, propname, vlist in all_links:
1042             if not all_props.has_key((cn, nodeid)):
1043                 # link item to link to doesn't (and won't) exist
1044                 continue
1045             for value in vlist:
1046                 if not all_props.has_key(value):
1047                     # link item to link to doesn't (and won't) exist
1048                     continue
1049                 deps.setdefault((cn, nodeid), []).append(value)
1050                 links.setdefault(value, []).append((cn, nodeid, propname))
1052         # figure chained dependencies ordering
1053         order = []
1054         done = {}
1055         # loop detection
1056         change = 0
1057         while len(all_props) != len(done):
1058             for needed in all_props.keys():
1059                 if done.has_key(needed):
1060                     continue
1061                 tlist = deps.get(needed, [])
1062                 for target in tlist:
1063                     if not done.has_key(target):
1064                         break
1065                 else:
1066                     done[needed] = 1
1067                     order.append(needed)
1068                     change = 1
1069             if not change:
1070                 raise ValueError, 'linking must not loop!'
1072         # now, edit / create
1073         m = []
1074         for needed in order:
1075             props = all_props[needed]
1076             if not props:
1077                 # nothing to do
1078                 continue
1079             cn, nodeid = needed
1081             if nodeid is not None and int(nodeid) > 0:
1082                 # make changes to the node
1083                 props = self._changenode(cn, nodeid, props)
1085                 # and some nice feedback for the user
1086                 if props:
1087                     info = ', '.join(props.keys())
1088                     m.append('%s %s %s edited ok'%(cn, nodeid, info))
1089                 else:
1090                     m.append('%s %s - nothing changed'%(cn, nodeid))
1091             else:
1092                 assert props
1094                 # make a new node
1095                 newid = self._createnode(cn, props)
1096                 if nodeid is None:
1097                     self.nodeid = newid
1098                 nodeid = newid
1100                 # and some nice feedback for the user
1101                 m.append('%s %s created'%(cn, newid))
1103             # fill in new ids in links
1104             if links.has_key(needed):
1105                 for linkcn, linkid, linkprop in links[needed]:
1106                     props = all_props[(linkcn, linkid)]
1107                     cl = self.db.classes[linkcn]
1108                     propdef = cl.getprops()[linkprop]
1109                     if not props.has_key(linkprop):
1110                         if linkid is None or linkid.startswith('-'):
1111                             # linking to a new item
1112                             if isinstance(propdef, hyperdb.Multilink):
1113                                 props[linkprop] = [newid]
1114                             else:
1115                                 props[linkprop] = newid
1116                         else:
1117                             # linking to an existing item
1118                             if isinstance(propdef, hyperdb.Multilink):
1119                                 existing = cl.get(linkid, linkprop)[:]
1120                                 existing.append(nodeid)
1121                                 props[linkprop] = existing
1122                             else:
1123                                 props[linkprop] = newid
1125         return '<br>'.join(m)
1127     def _changenode(self, cn, nodeid, props):
1128         ''' change the node based on the contents of the form
1129         '''
1130         # check for permission
1131         if not self.editItemPermission(props):
1132             raise Unauthorised, 'You do not have permission to edit %s'%cn
1134         # make the changes
1135         cl = self.db.classes[cn]
1136         return cl.set(nodeid, **props)
1138     def _createnode(self, cn, props):
1139         ''' create a node based on the contents of the form
1140         '''
1141         # check for permission
1142         if not self.newItemPermission(props):
1143             raise Unauthorised, 'You do not have permission to create %s'%cn
1145         # create the node and return its id
1146         cl = self.db.classes[cn]
1147         return cl.create(**props)
1149     # 
1150     # More actions
1151     #
1152     def editCSVAction(self):
1153         ''' Performs an edit of all of a class' items in one go.
1155             The "rows" CGI var defines the CSV-formatted entries for the
1156             class. New nodes are identified by the ID 'X' (or any other
1157             non-existent ID) and removed lines are retired.
1158         '''
1159         # this is per-class only
1160         if not self.editCSVPermission():
1161             self.error_message.append(
1162                 _('You do not have permission to edit %s' %self.classname))
1164         # get the CSV module
1165         if rcsv.error:
1166             self.error_message.append(_(rcsv.error))
1167             return
1169         cl = self.db.classes[self.classname]
1170         idlessprops = cl.getprops(protected=0).keys()
1171         idlessprops.sort()
1172         props = ['id'] + idlessprops
1174         # do the edit
1175         rows = StringIO.StringIO(self.form['rows'].value)
1176         reader = rcsv.reader(rows, rcsv.comma_separated)
1177         found = {}
1178         line = 0
1179         for values in reader:
1180             line += 1
1181             if line == 1: continue
1182             # skip property names header
1183             if values == props:
1184                 continue
1186             # extract the nodeid
1187             nodeid, values = values[0], values[1:]
1188             found[nodeid] = 1
1190             # see if the node exists
1191             if nodeid in ('x', 'X') or not cl.hasnode(nodeid):
1192                 exists = 0
1193             else:
1194                 exists = 1
1196             # confirm correct weight
1197             if len(idlessprops) != len(values):
1198                 self.error_message.append(
1199                     _('Not enough values on line %(line)s')%{'line':line})
1200                 return
1202             # extract the new values
1203             d = {}
1204             for name, value in zip(idlessprops, values):
1205                 prop = cl.properties[name]
1206                 value = value.strip()
1207                 # only add the property if it has a value
1208                 if value:
1209                     # if it's a multilink, split it
1210                     if isinstance(prop, hyperdb.Multilink):
1211                         value = value.split(':')
1212                     elif isinstance(prop, hyperdb.Password):
1213                         value = password.Password(value)
1214                     elif isinstance(prop, hyperdb.Interval):
1215                         value = date.Interval(value)
1216                     elif isinstance(prop, hyperdb.Date):
1217                         value = date.Date(value)
1218                     elif isinstance(prop, hyperdb.Boolean):
1219                         value = value.lower() in ('yes', 'true', 'on', '1')
1220                     elif isinstance(prop, hyperdb.Number):
1221                         value = float(value)
1222                     d[name] = value
1223                 elif exists:
1224                     # nuke the existing value
1225                     if isinstance(prop, hyperdb.Multilink):
1226                         d[name] = []
1227                     else:
1228                         d[name] = None
1230             # perform the edit
1231             if exists:
1232                 # edit existing
1233                 cl.set(nodeid, **d)
1234             else:
1235                 # new node
1236                 found[cl.create(**d)] = 1
1238         # retire the removed entries
1239         for nodeid in cl.list():
1240             if not found.has_key(nodeid):
1241                 cl.retire(nodeid)
1243         # all OK
1244         self.db.commit()
1246         self.ok_message.append(_('Items edited OK'))
1248     def editCSVPermission(self):
1249         ''' Determine whether the user has permission to edit this class.
1251             Base behaviour is to check the user can edit this class.
1252         ''' 
1253         if not self.db.security.hasPermission('Edit', self.userid,
1254                 self.classname):
1255             return 0
1256         return 1
1258     def searchAction(self, wcre=re.compile(r'[\s,]+')):
1259         ''' Mangle some of the form variables.
1261             Set the form ":filter" variable based on the values of the
1262             filter variables - if they're set to anything other than
1263             "dontcare" then add them to :filter.
1265             Handle the ":queryname" variable and save off the query to
1266             the user's query list.
1268             Split any String query values on whitespace and comma.
1269         '''
1270         # generic edit is per-class only
1271         if not self.searchPermission():
1272             self.error_message.append(
1273                 _('You do not have permission to search %s' %self.classname))
1275         # add a faked :filter form variable for each filtering prop
1276         props = self.db.classes[self.classname].getprops()
1277         queryname = ''
1278         for key in self.form.keys():
1279             # special vars
1280             if self.FV_QUERYNAME.match(key):
1281                 queryname = self.form[key].value.strip()
1282                 continue
1284             if not props.has_key(key):
1285                 continue
1286             if isinstance(self.form[key], type([])):
1287                 # search for at least one entry which is not empty
1288                 for minifield in self.form[key]:
1289                     if minifield.value:
1290                         break
1291                 else:
1292                     continue
1293             else:
1294                 if not self.form[key].value:
1295                     continue
1296                 if isinstance(props[key], hyperdb.String):
1297                     v = self.form[key].value
1298                     l = token.token_split(v)
1299                     if len(l) > 1 or l[0] != v:
1300                         self.form.value.remove(self.form[key])
1301                         # replace the single value with the split list
1302                         for v in l:
1303                             self.form.value.append(cgi.MiniFieldStorage(key, v))
1305             self.form.value.append(cgi.MiniFieldStorage('@filter', key))
1307         # handle saving the query params
1308         if queryname:
1309             # parse the environment and figure what the query _is_
1310             req = HTMLRequest(self)
1312             # The [1:] strips off the '?' character, it isn't part of the
1313             # query string.
1314             url = req.indexargs_href('', {})[1:]
1316             # handle editing an existing query
1317             try:
1318                 qid = self.db.query.lookup(queryname)
1319                 self.db.query.set(qid, klass=self.classname, url=url)
1320             except KeyError:
1321                 # create a query
1322                 qid = self.db.query.create(name=queryname,
1323                     klass=self.classname, url=url)
1325                 # and add it to the user's query multilink
1326                 queries = self.db.user.get(self.userid, 'queries')
1327                 queries.append(qid)
1328                 self.db.user.set(self.userid, queries=queries)
1330             # commit the query change to the database
1331             self.db.commit()
1333     def searchPermission(self):
1334         ''' Determine whether the user has permission to search this class.
1336             Base behaviour is to check the user can view this class.
1337         ''' 
1338         if not self.db.security.hasPermission('View', self.userid,
1339                 self.classname):
1340             return 0
1341         return 1
1344     def retireAction(self):
1345         ''' Retire the context item.
1346         '''
1347         # if we want to view the index template now, then unset the nodeid
1348         # context info (a special-case for retire actions on the index page)
1349         nodeid = self.nodeid
1350         if self.template == 'index':
1351             self.nodeid = None
1353         # generic edit is per-class only
1354         if not self.retirePermission():
1355             self.error_message.append(
1356                 _('You do not have permission to retire %s' %self.classname))
1357             return
1359         # make sure we don't try to retire admin or anonymous
1360         if self.classname == 'user' and \
1361                 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1362             self.error_message.append(
1363                 _('You may not retire the admin or anonymous user'))
1364             return
1366         # do the retire
1367         self.db.getclass(self.classname).retire(nodeid)
1368         self.db.commit()
1370         self.ok_message.append(
1371             _('%(classname)s %(itemid)s has been retired')%{
1372                 'classname': self.classname.capitalize(), 'itemid': nodeid})
1374     def retirePermission(self):
1375         ''' Determine whether the user has permission to retire this class.
1377             Base behaviour is to check the user can edit this class.
1378         ''' 
1379         if not self.db.security.hasPermission('Edit', self.userid,
1380                 self.classname):
1381             return 0
1382         return 1
1385     def showAction(self, typere=re.compile('[@:]type'),
1386             numre=re.compile('[@:]number')):
1387         ''' Show a node of a particular class/id
1388         '''
1389         t = n = ''
1390         for key in self.form.keys():
1391             if typere.match(key):
1392                 t = self.form[key].value.strip()
1393             elif numre.match(key):
1394                 n = self.form[key].value.strip()
1395         if not t:
1396             raise ValueError, 'Invalid %s number'%t
1397         url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1398         raise Redirect, url
1400     def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1401         ''' Item properties and their values are edited with html FORM
1402             variables and their values. You can:
1404             - Change the value of some property of the current item.
1405             - Create a new item of any class, and edit the new item's
1406               properties,
1407             - Attach newly created items to a multilink property of the
1408               current item.
1409             - Remove items from a multilink property of the current item.
1410             - Specify that some properties are required for the edit
1411               operation to be successful.
1413             In the following, <bracketed> values are variable, "@" may be
1414             either ":" or "@", and other text "required" is fixed.
1416             Most properties are specified as form variables:
1418              <propname>
1419               - property on the current context item
1421              <designator>"@"<propname>
1422               - property on the indicated item (for editing related
1423                 information)
1425             Designators name a specific item of a class.
1427             <classname><N>
1429                 Name an existing item of class <classname>.
1431             <classname>"-"<N>
1433                 Name the <N>th new item of class <classname>. If the form
1434                 submission is successful, a new item of <classname> is
1435                 created. Within the submitted form, a particular
1436                 designator of this form always refers to the same new
1437                 item.
1439             Once we have determined the "propname", we look at it to see
1440             if it's special:
1442             @required
1443                 The associated form value is a comma-separated list of
1444                 property names that must be specified when the form is
1445                 submitted for the edit operation to succeed.  
1447                 When the <designator> is missing, the properties are
1448                 for the current context item.  When <designator> is
1449                 present, they are for the item specified by
1450                 <designator>.
1452                 The "@required" specifier must come before any of the
1453                 properties it refers to are assigned in the form.
1455             @remove@<propname>=id(s) or @add@<propname>=id(s)
1456                 The "@add@" and "@remove@" edit actions apply only to
1457                 Multilink properties.  The form value must be a
1458                 comma-separate list of keys for the class specified by
1459                 the simple form variable.  The listed items are added
1460                 to (respectively, removed from) the specified
1461                 property.
1463             @link@<propname>=<designator>
1464                 If the edit action is "@link@", the simple form
1465                 variable must specify a Link or Multilink property.
1466                 The form value is a comma-separated list of
1467                 designators.  The item corresponding to each
1468                 designator is linked to the property given by simple
1469                 form variable.  These are collected up and returned in
1470                 all_links.
1472             None of the above (ie. just a simple form value)
1473                 The value of the form variable is converted
1474                 appropriately, depending on the type of the property.
1476                 For a Link('klass') property, the form value is a
1477                 single key for 'klass', where the key field is
1478                 specified in dbinit.py.  
1480                 For a Multilink('klass') property, the form value is a
1481                 comma-separated list of keys for 'klass', where the
1482                 key field is specified in dbinit.py.  
1484                 Note that for simple-form-variables specifiying Link
1485                 and Multilink properties, the linked-to class must
1486                 have a key field.
1488                 For a String() property specifying a filename, the
1489                 file named by the form value is uploaded. This means we
1490                 try to set additional properties "filename" and "type" (if
1491                 they are valid for the class).  Otherwise, the property
1492                 is set to the form value.
1494                 For Date(), Interval(), Boolean(), and Number()
1495                 properties, the form value is converted to the
1496                 appropriate
1498             Any of the form variables may be prefixed with a classname or
1499             designator.
1501             Two special form values are supported for backwards
1502             compatibility:
1504             @note
1505                 This is equivalent to::
1507                     @link@messages=msg-1
1508                     @msg-1@content=value
1510                 except that in addition, the "author" and "date"
1511                 properties of "msg-1" are set to the userid of the
1512                 submitter, and the current time, respectively.
1514             @file
1515                 This is equivalent to::
1517                     @link@files=file-1
1518                     @file-1@content=value
1520                 The String content value is handled as described above for
1521                 file uploads.
1523             If both the "@note" and "@file" form variables are
1524             specified, the action::
1526                     @link@msg-1@files=file-1
1528             is also performed.
1530             We also check that FileClass items have a "content" property with
1531             actual content, otherwise we remove them from all_props before
1532             returning.
1534             The return from this method is a dict of 
1535                 (classname, id): properties
1536             ... this dict _always_ has an entry for the current context,
1537             even if it's empty (ie. a submission for an existing issue that
1538             doesn't result in any changes would return {('issue','123'): {}})
1539             The id may be None, which indicates that an item should be
1540             created.
1541         '''
1542         # some very useful variables
1543         db = self.db
1544         form = self.form
1546         if not hasattr(self, 'FV_SPECIAL'):
1547             # generate the regexp for handling special form values
1548             classes = '|'.join(db.classes.keys())
1549             # specials for parsePropsFromForm
1550             # handle the various forms (see unit tests)
1551             self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1552             self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1554         # these indicate the default class / item
1555         default_cn = self.classname
1556         default_cl = self.db.classes[default_cn]
1557         default_nodeid = self.nodeid
1559         # we'll store info about the individual class/item edit in these
1560         all_required = {}       # required props per class/item
1561         all_props = {}          # props to set per class/item
1562         got_props = {}          # props received per class/item
1563         all_propdef = {}        # note - only one entry per class
1564         all_links = []          # as many as are required
1566         # we should always return something, even empty, for the context
1567         all_props[(default_cn, default_nodeid)] = {}
1569         keys = form.keys()
1570         timezone = db.getUserTimezone()
1572         # sentinels for the :note and :file props
1573         have_note = have_file = 0
1575         # extract the usable form labels from the form
1576         matches = []
1577         for key in keys:
1578             m = self.FV_SPECIAL.match(key)
1579             if m:
1580                 matches.append((key, m.groupdict()))
1582         # now handle the matches
1583         for key, d in matches:
1584             if d['classname']:
1585                 # we got a designator
1586                 cn = d['classname']
1587                 cl = self.db.classes[cn]
1588                 nodeid = d['id']
1589                 propname = d['propname']
1590             elif d['note']:
1591                 # the special note field
1592                 cn = 'msg'
1593                 cl = self.db.classes[cn]
1594                 nodeid = '-1'
1595                 propname = 'content'
1596                 all_links.append((default_cn, default_nodeid, 'messages',
1597                     [('msg', '-1')]))
1598                 have_note = 1
1599             elif d['file']:
1600                 # the special file field
1601                 cn = 'file'
1602                 cl = self.db.classes[cn]
1603                 nodeid = '-1'
1604                 propname = 'content'
1605                 all_links.append((default_cn, default_nodeid, 'files',
1606                     [('file', '-1')]))
1607                 have_file = 1
1608             else:
1609                 # default
1610                 cn = default_cn
1611                 cl = default_cl
1612                 nodeid = default_nodeid
1613                 propname = d['propname']
1615             # the thing this value relates to is...
1616             this = (cn, nodeid)
1618             # get more info about the class, and the current set of
1619             # form props for it
1620             if not all_propdef.has_key(cn):
1621                 all_propdef[cn] = cl.getprops()
1622             propdef = all_propdef[cn]
1623             if not all_props.has_key(this):
1624                 all_props[this] = {}
1625             props = all_props[this]
1626             if not got_props.has_key(this):
1627                 got_props[this] = {}
1629             # is this a link command?
1630             if d['link']:
1631                 value = []
1632                 for entry in extractFormList(form[key]):
1633                     m = self.FV_DESIGNATOR.match(entry)
1634                     if not m:
1635                         raise ValueError, \
1636                             'link "%s" value "%s" not a designator'%(key, entry)
1637                     value.append((m.group(1), m.group(2)))
1639                 # make sure the link property is valid
1640                 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1641                         not isinstance(propdef[propname], hyperdb.Link)):
1642                     raise ValueError, '%s %s is not a link or '\
1643                         'multilink property'%(cn, propname)
1645                 all_links.append((cn, nodeid, propname, value))
1646                 continue
1648             # detect the special ":required" variable
1649             if d['required']:
1650                 all_required[this] = extractFormList(form[key])
1651                 continue
1653             # see if we're performing a special multilink action
1654             mlaction = 'set'
1655             if d['remove']:
1656                 mlaction = 'remove'
1657             elif d['add']:
1658                 mlaction = 'add'
1660             # does the property exist?
1661             if not propdef.has_key(propname):
1662                 if mlaction != 'set':
1663                     raise ValueError, 'You have submitted a %s action for'\
1664                         ' the property "%s" which doesn\'t exist'%(mlaction,
1665                         propname)
1666                 # the form element is probably just something we don't care
1667                 # about - ignore it
1668                 continue
1669             proptype = propdef[propname]
1671             # Get the form value. This value may be a MiniFieldStorage or a list
1672             # of MiniFieldStorages.
1673             value = form[key]
1675             # handle unpacking of the MiniFieldStorage / list form value
1676             if isinstance(proptype, hyperdb.Multilink):
1677                 value = extractFormList(value)
1678             else:
1679                 # multiple values are not OK
1680                 if isinstance(value, type([])):
1681                     raise ValueError, 'You have submitted more than one value'\
1682                         ' for the %s property'%propname
1683                 # value might be a file upload...
1684                 if not hasattr(value, 'filename') or value.filename is None:
1685                     # nope, pull out the value and strip it
1686                     value = value.value.strip()
1688             # now that we have the props field, we need a teensy little
1689             # extra bit of help for the old :note field...
1690             if d['note'] and value:
1691                 props['author'] = self.db.getuid()
1692                 props['date'] = date.Date()
1694             # handle by type now
1695             if isinstance(proptype, hyperdb.Password):
1696                 if not value:
1697                     # ignore empty password values
1698                     continue
1699                 for key, d in matches:
1700                     if d['confirm'] and d['propname'] == propname:
1701                         confirm = form[key]
1702                         break
1703                 else:
1704                     raise ValueError, 'Password and confirmation text do '\
1705                         'not match'
1706                 if isinstance(confirm, type([])):
1707                     raise ValueError, 'You have submitted more than one value'\
1708                         ' for the %s property'%propname
1709                 if value != confirm.value:
1710                     raise ValueError, 'Password and confirmation text do '\
1711                         'not match'
1712                 value = password.Password(value)
1714             elif isinstance(proptype, hyperdb.Link):
1715                 # see if it's the "no selection" choice
1716                 if value == '-1' or not value:
1717                     # if we're creating, just don't include this property
1718                     if not nodeid or nodeid.startswith('-'):
1719                         continue
1720                     value = None
1721                 else:
1722                     # handle key values
1723                     link = proptype.classname
1724                     if not num_re.match(value):
1725                         try:
1726                             value = db.classes[link].lookup(value)
1727                         except KeyError:
1728                             raise ValueError, _('property "%(propname)s": '
1729                                 '%(value)s not a %(classname)s')%{
1730                                 'propname': propname, 'value': value,
1731                                 'classname': link}
1732                         except TypeError, message:
1733                             raise ValueError, _('you may only enter ID values '
1734                                 'for property "%(propname)s": %(message)s')%{
1735                                 'propname': propname, 'message': message}
1736             elif isinstance(proptype, hyperdb.Multilink):
1737                 # perform link class key value lookup if necessary
1738                 link = proptype.classname
1739                 link_cl = db.classes[link]
1740                 l = []
1741                 for entry in value:
1742                     if not entry: continue
1743                     if not num_re.match(entry):
1744                         try:
1745                             entry = link_cl.lookup(entry)
1746                         except KeyError:
1747                             raise ValueError, _('property "%(propname)s": '
1748                                 '"%(value)s" not an entry of %(classname)s')%{
1749                                 'propname': propname, 'value': entry,
1750                                 'classname': link}
1751                         except TypeError, message:
1752                             raise ValueError, _('you may only enter ID values '
1753                                 'for property "%(propname)s": %(message)s')%{
1754                                 'propname': propname, 'message': message}
1755                     l.append(entry)
1756                 l.sort()
1758                 # now use that list of ids to modify the multilink
1759                 if mlaction == 'set':
1760                     value = l
1761                 else:
1762                     # we're modifying the list - get the current list of ids
1763                     if props.has_key(propname):
1764                         existing = props[propname]
1765                     elif nodeid and not nodeid.startswith('-'):
1766                         existing = cl.get(nodeid, propname, [])
1767                     else:
1768                         existing = []
1770                     # now either remove or add
1771                     if mlaction == 'remove':
1772                         # remove - handle situation where the id isn't in
1773                         # the list
1774                         for entry in l:
1775                             try:
1776                                 existing.remove(entry)
1777                             except ValueError:
1778                                 raise ValueError, _('property "%(propname)s": '
1779                                     '"%(value)s" not currently in list')%{
1780                                     'propname': propname, 'value': entry}
1781                     else:
1782                         # add - easy, just don't dupe
1783                         for entry in l:
1784                             if entry not in existing:
1785                                 existing.append(entry)
1786                     value = existing
1787                     value.sort()
1789             elif value == '':
1790                 # if we're creating, just don't include this property
1791                 if not nodeid or nodeid.startswith('-'):
1792                     continue
1793                 # other types should be None'd if there's no value
1794                 value = None
1795             else:
1796                 # handle ValueErrors for all these in a similar fashion
1797                 try:
1798                     if isinstance(proptype, hyperdb.String):
1799                         if (hasattr(value, 'filename') and
1800                                 value.filename is not None):
1801                             # skip if the upload is empty
1802                             if not value.filename:
1803                                 continue
1804                             # this String is actually a _file_
1805                             # try to determine the file content-type
1806                             fn = value.filename.split('\\')[-1]
1807                             if propdef.has_key('name'):
1808                                 props['name'] = fn
1809                             # use this info as the type/filename properties
1810                             if propdef.has_key('type'):
1811                                 props['type'] = mimetypes.guess_type(fn)[0]
1812                                 if not props['type']:
1813                                     props['type'] = "application/octet-stream"
1814                             # finally, read the content
1815                             value = value.value
1816                         else:
1817                             # normal String fix the CRLF/CR -> LF stuff
1818                             value = fixNewlines(value)
1820                     elif isinstance(proptype, hyperdb.Date):
1821                         value = date.Date(value, offset=timezone)
1822                     elif isinstance(proptype, hyperdb.Interval):
1823                         value = date.Interval(value)
1824                     elif isinstance(proptype, hyperdb.Boolean):
1825                         value = value.lower() in ('yes', 'true', 'on', '1')
1826                     elif isinstance(proptype, hyperdb.Number):
1827                         value = float(value)
1828                 except ValueError, msg:
1829                     raise ValueError, _('Error with %s property: %s')%(
1830                         propname, msg)
1832             # register that we got this property
1833             if value:
1834                 got_props[this][propname] = 1
1836             # get the old value
1837             if nodeid and not nodeid.startswith('-'):
1838                 try:
1839                     existing = cl.get(nodeid, propname)
1840                 except KeyError:
1841                     # this might be a new property for which there is
1842                     # no existing value
1843                     if not propdef.has_key(propname):
1844                         raise
1846                 # make sure the existing multilink is sorted
1847                 if isinstance(proptype, hyperdb.Multilink):
1848                     existing.sort()
1850                 # "missing" existing values may not be None
1851                 if not existing:
1852                     if isinstance(proptype, hyperdb.String) and not existing:
1853                         # some backends store "missing" Strings as empty strings
1854                         existing = None
1855                     elif isinstance(proptype, hyperdb.Number) and not existing:
1856                         # some backends store "missing" Numbers as 0 :(
1857                         existing = 0
1858                     elif isinstance(proptype, hyperdb.Boolean) and not existing:
1859                         # likewise Booleans
1860                         existing = 0
1862                 # if changed, set it
1863                 if value != existing:
1864                     props[propname] = value
1865             else:
1866                 # don't bother setting empty/unset values
1867                 if value is None:
1868                     continue
1869                 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1870                     continue
1871                 elif isinstance(proptype, hyperdb.String) and value == '':
1872                     continue
1874                 props[propname] = value
1876         # check to see if we need to specially link a file to the note
1877         if have_note and have_file:
1878             all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1880         # see if all the required properties have been supplied
1881         s = []
1882         for thing, required in all_required.items():
1883             # register the values we got
1884             got = got_props.get(thing, {})
1885             for entry in required[:]:
1886                 if got.has_key(entry):
1887                     required.remove(entry)
1889             # any required values not present?
1890             if not required:
1891                 continue
1893             # tell the user to entry the values required
1894             if len(required) > 1:
1895                 p = 'properties'
1896             else:
1897                 p = 'property'
1898             s.append('Required %s %s %s not supplied'%(thing[0], p,
1899                 ', '.join(required)))
1900         if s:
1901             raise ValueError, '\n'.join(s)
1903         # When creating a FileClass node, it should have a non-empty content
1904         # property to be created. When editing a FileClass node, it should
1905         # either have a non-empty content property or no property at all. In
1906         # the latter case, nothing will change.
1907         for (cn, id), props in all_props.items():
1908             if isinstance(self.db.classes[cn], hyperdb.FileClass):
1909                 if id == '-1':
1910                       if not props.get('content', ''):
1911                             del all_props[(cn, id)]
1912                 elif props.has_key('content') and not props['content']:
1913                       raise ValueError, _('File is empty')
1914         return all_props, all_links
1916 def fixNewlines(text):
1917     ''' Homogenise line endings.
1919         Different web clients send different line ending values, but
1920         other systems (eg. email) don't necessarily handle those line
1921         endings. Our solution is to convert all line endings to LF.
1922     '''
1923     text = text.replace('\r\n', '\n')
1924     return text.replace('\r', '\n')
1926 def extractFormList(value):
1927     ''' Extract a list of values from the form value.
1929         It may be one of:
1930          [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
1931          MiniFieldStorage('value,value,...')
1932          MiniFieldStorage('value')
1933     '''
1934     # multiple values are OK
1935     if isinstance(value, type([])):
1936         # it's a list of MiniFieldStorages - join then into
1937         values = ','.join([i.value.strip() for i in value])
1938     else:
1939         # it's a MiniFieldStorage, but may be a comma-separated list
1940         # of values
1941         values = value.value
1943     value = [i.strip() for i in values.split(',')]
1945     # filter out the empty bits
1946     return filter(None, value)