Code

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