Code

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