Code

beginnings of nicer web error handling
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.96 2003-02-20 07:13:14 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
10 from roundup import roundupdb, date, hyperdb, password
11 from roundup.i18n import _
13 from roundup.cgi.templating import Templates, HTMLRequest, NoTemplate
14 from roundup.cgi import cgitb
16 from roundup.cgi.PageTemplates import PageTemplate
18 class HTTPException(Exception):
19       pass
20 class  Unauthorised(HTTPException):
21        pass
22 class  NotFound(HTTPException):
23        pass
24 class  Redirect(HTTPException):
25        pass
27 # XXX actually _use_ FormError
28 class FormError(ValueError):
29     ''' An "expected" exception occurred during form parsing.
30         - ie. something we know can go wrong, and don't want to alarm the
31           user with
33         We trap this at the user interface level and feed back a nice error
34         to the user.
35     '''
36     pass
38 class SendFile(Exception):
39     ''' Send a file from the database '''
41 class SendStaticFile(Exception):
42     ''' Send a static file from the instance html directory '''
44 def initialiseSecurity(security):
45     ''' Create some Permissions and Roles on the security object
47         This function is directly invoked by security.Security.__init__()
48         as a part of the Security object instantiation.
49     '''
50     security.addPermission(name="Web Registration",
51         description="User may register through the web")
52     p = security.addPermission(name="Web Access",
53         description="User may access the web interface")
54     security.addPermissionToRole('Admin', p)
56     # doing Role stuff through the web - make sure Admin can
57     p = security.addPermission(name="Web Roles",
58         description="User may manipulate user Roles through the web")
59     security.addPermissionToRole('Admin', p)
61 class Client:
62     ''' Instantiate to handle one CGI request.
64     See inner_main for request processing.
66     Client attributes at instantiation:
67         "path" is the PATH_INFO inside the instance (with no leading '/')
68         "base" is the base URL for the instance
69         "form" is the cgi form, an instance of FieldStorage from the standard
70                cgi module
71         "additional_headers" is a dictionary of additional HTTP headers that
72                should be sent to the client
73         "response_code" is the HTTP response code to send to the client
75     During the processing of a request, the following attributes are used:
76         "error_message" holds a list of error messages
77         "ok_message" holds a list of OK messages
78         "session" is the current user session id
79         "user" is the current user's name
80         "userid" is the current user's id
81         "template" is the current :template context
82         "classname" is the current class context name
83         "nodeid" is the current context item id
85     User Identification:
86      If the user has no login cookie, then they are anonymous and are logged
87      in as that user. This typically gives them all Permissions assigned to the
88      Anonymous Role.
90      Once a user logs in, they are assigned a session. The Client instance
91      keeps the nodeid of the session as the "session" attribute.
94     Special form variables:
95      Note that in various places throughout this code, special form
96      variables of the form :<name> are used. The colon (":") part may
97      actually be one of either ":" or "@".
98     '''
100     #
101     # special form variables
102     #
103     FV_TEMPLATE = re.compile(r'[@:]template')
104     FV_OK_MESSAGE = re.compile(r'[@:]ok_message')
105     FV_ERROR_MESSAGE = re.compile(r'[@:]error_message')
107     # edit form variable handling (see unit tests)
108     FV_LABELS = r'''
109        ^(
110          (?P<note>[@:]note)|
111          (?P<file>[@:]file)|
112          (
113           ((?P<classname>%s)(?P<id>[-\d]+))?  # optional leading designator
114           ((?P<required>[@:]required$)|       # :required
115            (
116             (
117              (?P<add>[@:]add[@:])|            # :add:<prop>
118              (?P<remove>[@:]remove[@:])|      # :remove:<prop>
119              (?P<confirm>[@:]confirm[@:])|    # :confirm:<prop>
120              (?P<link>[@:]link[@:])|          # :link:<prop>
121              ([@:])                           # just a separator
122             )?
123             (?P<propname>[^@:]+)             # <prop>
124            )
125           )
126          )
127         )$'''
129     # Note: index page stuff doesn't appear here:
130     # columns, sort, sortdir, filter, group, groupdir, search_text,
131     # pagesize, startwith
133     def __init__(self, instance, request, env, form=None):
134         hyperdb.traceMark()
135         self.instance = instance
136         self.request = request
137         self.env = env
139         # save off the path
140         self.path = env['PATH_INFO']
142         # this is the base URL for this tracker
143         self.base = self.instance.config.TRACKER_WEB
145         # this is the "cookie path" for this tracker (ie. the path part of
146         # the "base" url)
147         self.cookie_path = urlparse.urlparse(self.base)[2]
148         self.cookie_name = 'roundup_session_' + re.sub('[^a-zA-Z]', '',
149             self.instance.config.TRACKER_NAME)
151         # see if we need to re-parse the environment for the form (eg Zope)
152         if form is None:
153             self.form = cgi.FieldStorage(environ=env)
154         else:
155             self.form = form
157         # turn debugging on/off
158         try:
159             self.debug = int(env.get("ROUNDUP_DEBUG", 0))
160         except ValueError:
161             # someone gave us a non-int debug level, turn it off
162             self.debug = 0
164         # flag to indicate that the HTTP headers have been sent
165         self.headers_done = 0
167         # additional headers to send with the request - must be registered
168         # before the first write
169         self.additional_headers = {}
170         self.response_code = 200
173     def main(self):
174         ''' Wrap the real main in a try/finally so we always close off the db.
175         '''
176         try:
177             self.inner_main()
178         finally:
179             if hasattr(self, 'db'):
180                 self.db.close()
182     def inner_main(self):
183         ''' Process a request.
185             The most common requests are handled like so:
186             1. figure out who we are, defaulting to the "anonymous" user
187                see determine_user
188             2. figure out what the request is for - the context
189                see determine_context
190             3. handle any requested action (item edit, search, ...)
191                see handle_action
192             4. render a template, resulting in HTML output
194             In some situations, exceptions occur:
195             - HTTP Redirect  (generally raised by an action)
196             - SendFile       (generally raised by determine_context)
197               serve up a FileClass "content" property
198             - SendStaticFile (generally raised by determine_context)
199               serve up a file from the tracker "html" directory
200             - Unauthorised   (generally raised by an action)
201               the action is cancelled, the request is rendered and an error
202               message is displayed indicating that permission was not
203               granted for the action to take place
204             - NotFound       (raised wherever it needs to be)
205               percolates up to the CGI interface that called the client
206         '''
207         self.ok_message = []
208         self.error_message = []
209         try:
210             # make sure we're identified (even anonymously)
211             self.determine_user()
212             # figure out the context and desired content template
213             self.determine_context()
214             # possibly handle a form submit action (may change self.classname
215             # and self.template, and may also append error/ok_messages)
216             self.handle_action()
217             # now render the page
219             # we don't want clients caching our dynamic pages
220             self.additional_headers['Cache-Control'] = 'no-cache'
221             self.additional_headers['Pragma'] = 'no-cache'
222             self.additional_headers['Expires'] = 'Thu, 1 Jan 1970 00:00:00 GMT'
224             # render the content
225             self.write(self.renderContext())
226         except Redirect, url:
227             # let's redirect - if the url isn't None, then we need to do
228             # the headers, otherwise the headers have been set before the
229             # exception was raised
230             if url:
231                 self.additional_headers['Location'] = url
232                 self.response_code = 302
233             self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
234         except SendFile, designator:
235             self.serve_file(designator)
236         except SendStaticFile, file:
237             self.serve_static_file(str(file))
238         except Unauthorised, message:
239             self.classname=None
240             self.template=''
241             self.error_message.append(message)
242             self.write(self.renderContext())
243         except NotFound:
244             # pass through
245             raise
246         except:
247             # everything else
248             self.write(cgitb.html())
250     def clean_sessions(self):
251         '''age sessions, remove when they haven't been used for a week.
252         Do it only once an hour'''
253         sessions = self.db.sessions
254         last_clean = sessions.get('last_clean', 'last_use') or 0
256         week = 60*60*24*7
257         hour = 60*60
258         now = time.time()
259         if now - last_clean > hour:
260             # remove age sessions
261             for sessid in sessions.list():
262                 interval = now - sessions.get(sessid, 'last_use')
263                 if interval > week:
264                     sessions.destroy(sessid)
265             sessions.set('last_clean', last_use=time.time())
267     def determine_user(self):
268         ''' Determine who the user is
269         '''
270         # determine the uid to use
271         self.opendb('admin')
272         # clean age sessions
273         self.clean_sessions()
274         # make sure we have the session Class
275         sessions = self.db.sessions
277         # look up the user session cookie
278         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
279         user = 'anonymous'
281         # bump the "revision" of the cookie since the format changed
282         if (cookie.has_key(self.cookie_name) and
283                 cookie[self.cookie_name].value != 'deleted'):
285             # get the session key from the cookie
286             self.session = cookie[self.cookie_name].value
287             # get the user from the session
288             try:
289                 # update the lifetime datestamp
290                 sessions.set(self.session, last_use=time.time())
291                 sessions.commit()
292                 user = sessions.get(self.session, 'user')
293             except KeyError:
294                 user = 'anonymous'
296         # sanity check on the user still being valid, getting the userid
297         # at the same time
298         try:
299             self.userid = self.db.user.lookup(user)
300         except (KeyError, TypeError):
301             user = 'anonymous'
303         # make sure the anonymous user is valid if we're using it
304         if user == 'anonymous':
305             self.make_user_anonymous()
306         else:
307             self.user = user
309         # reopen the database as the correct user
310         self.opendb(self.user)
312     def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
313         ''' Determine the context of this page from the URL:
315             The URL path after the instance identifier is examined. The path
316             is generally only one entry long.
318             - if there is no path, then we are in the "home" context.
319             * if the path is "_file", then the additional path entry
320               specifies the filename of a static file we're to serve up
321               from the instance "html" directory. Raises a SendStaticFile
322               exception.
323             - if there is something in the path (eg "issue"), it identifies
324               the tracker class we're to display.
325             - if the path is an item designator (eg "issue123"), then we're
326               to display a specific item.
327             * if the path starts with an item designator and is longer than
328               one entry, then we're assumed to be handling an item of a
329               FileClass, and the extra path information gives the filename
330               that the client is going to label the download with (ie
331               "file123/image.png" is nicer to download than "file123"). This
332               raises a SendFile exception.
334             Both of the "*" types of contexts stop before we bother to
335             determine the template we're going to use. That's because they
336             don't actually use templates.
338             The template used is specified by the :template CGI variable,
339             which defaults to:
341              only classname suplied:          "index"
342              full item designator supplied:   "item"
344             We set:
345              self.classname  - the class to display, can be None
346              self.template   - the template to render the current context with
347              self.nodeid     - the nodeid of the class we're displaying
348         '''
349         # default the optional variables
350         self.classname = None
351         self.nodeid = None
353         # see if a template or messages are specified
354         template_override = ok_message = error_message = None
355         for key in self.form.keys():
356             if self.FV_TEMPLATE.match(key):
357                 template_override = self.form[key].value
358             elif self.FV_OK_MESSAGE.match(key):
359                 ok_message = self.form[key].value
360             elif self.FV_ERROR_MESSAGE.match(key):
361                 error_message = self.form[key].value
363         # determine the classname and possibly nodeid
364         path = self.path.split('/')
365         if not path or path[0] in ('', 'home', 'index'):
366             if template_override is not None:
367                 self.template = template_override
368             else:
369                 self.template = ''
370             return
371         elif path[0] == '_file':
372             raise SendStaticFile, os.path.join(*path[1:])
373         else:
374             self.classname = path[0]
375             if len(path) > 1:
376                 # send the file identified by the designator in path[0]
377                 raise SendFile, path[0]
379         # see if we got a designator
380         m = dre.match(self.classname)
381         if m:
382             self.classname = m.group(1)
383             self.nodeid = m.group(2)
384             if not self.db.getclass(self.classname).hasnode(self.nodeid):
385                 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
386             # with a designator, we default to item view
387             self.template = 'item'
388         else:
389             # with only a class, we default to index view
390             self.template = 'index'
392         # make sure the classname is valid
393         try:
394             self.db.getclass(self.classname)
395         except KeyError:
396             raise NotFound, self.classname
398         # see if we have a template override
399         if template_override is not None:
400             self.template = template_override
402         # see if we were passed in a message
403         if ok_message:
404             self.ok_message.append(ok_message)
405         if error_message:
406             self.error_message.append(error_message)
408     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
409         ''' Serve the file from the content property of the designated item.
410         '''
411         m = dre.match(str(designator))
412         if not m:
413             raise NotFound, str(designator)
414         classname, nodeid = m.group(1), m.group(2)
415         if classname != 'file':
416             raise NotFound, designator
418         # we just want to serve up the file named
419         file = self.db.file
420         self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
421         self.write(file.get(nodeid, 'content'))
423     def serve_static_file(self, file):
424         # we just want to serve up the file named
425         mt = mimetypes.guess_type(str(file))[0]
426         self.additional_headers['Content-Type'] = mt
427         self.write(open(os.path.join(self.instance.config.TEMPLATES,
428             file)).read())
430     def renderContext(self):
431         ''' Return a PageTemplate for the named page
432         '''
433         name = self.classname
434         extension = self.template
435         pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
437         # catch errors so we can handle PT rendering errors more nicely
438         args = {
439             'ok_message': self.ok_message,
440             'error_message': self.error_message
441         }
442         try:
443             # let the template render figure stuff out
444             return pt.render(self, None, None, **args)
445         except NoTemplate, message:
446             return '<strong>%s</strong>'%message
447         except:
448             # everything else
449             return cgitb.pt_html()
451     # these are the actions that are available
452     actions = (
453         ('edit',     'editItemAction'),
454         ('editCSV',  'editCSVAction'),
455         ('new',      'newItemAction'),
456         ('register', 'registerAction'),
457         ('login',    'loginAction'),
458         ('logout',   'logout_action'),
459         ('search',   'searchAction'),
460         ('retire',   'retireAction'),
461         ('show',     'showAction'),
462     )
463     def handle_action(self):
464         ''' Determine whether there should be an _action called.
466             The action is defined by the form variable :action which
467             identifies the method on this object to call. The four basic
468             actions are defined in the "actions" sequence on this class:
469              "edit"      -> self.editItemAction
470              "new"       -> self.newItemAction
471              "register"  -> self.registerAction
472              "login"     -> self.loginAction
473              "logout"    -> self.logout_action
474              "search"    -> self.searchAction
475              "retire"    -> self.retireAction
476         '''
477         if not self.form.has_key(':action'):
478             return None
479         try:
480             # get the action, validate it
481             action = self.form[':action'].value
482             for name, method in self.actions:
483                 if name == action:
484                     break
485             else:
486                 raise ValueError, 'No such action "%s"'%action
488             # call the mapped action
489             getattr(self, method)()
490         except Redirect:
491             raise
492         except Unauthorised:
493             raise
494         except:
495             self.db.rollback()
496             s = StringIO.StringIO()
497             traceback.print_exc(None, s)
498             self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
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     def registerAction(self):
655         '''Attempt to create a new user based on the contents of the form
656         and then set the cookie.
658         return 1 on successful login
659         '''
660         # create the new user
661         cl = self.db.user
663         # parse the props from the form
664         try:
665             props = self.parsePropsFromForm()
666         except (ValueError, KeyError), message:
667             self.error_message.append(_('Error: ') + str(message))
668             return
670         # make sure we're allowed to register
671         if not self.registerPermission(props):
672             raise Unauthorised, _("You do not have permission to register")
674         # re-open the database as "admin"
675         if self.user != 'admin':
676             self.opendb('admin')
677             
678         # create the new user
679         cl = self.db.user
680         try:
681             props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
682             self.userid = cl.create(**props['user'])
683             self.db.commit()
684         except (ValueError, KeyError), message:
685             self.error_message.append(message)
686             return
688         # log the new user in
689         self.user = cl.get(self.userid, 'username')
690         # re-open the database for real, using the user
691         self.opendb(self.user)
693         # if we have a session, update it
694         if hasattr(self, 'session'):
695             self.db.sessions.set(self.session, user=self.user,
696                 last_use=time.time())
697         else:
698             # new session cookie
699             self.set_cookie(self.user)
701         # nice message
702         message = _('You are now registered, welcome!')
704         # redirect to the item's edit page
705         raise Redirect, '%s%s%s?+ok_message=%s'%(
706             self.base, self.classname, self.userid,  urllib.quote(message))
708     def registerPermission(self, props):
709         ''' Determine whether the user has permission to register
711             Base behaviour is to check the user has "Web Registration".
712         '''
713         # registration isn't allowed to supply roles
714         if props.has_key('roles'):
715             return 0
716         if self.db.security.hasPermission('Web Registration', self.userid):
717             return 1
718         return 0
720     def editItemAction(self):
721         ''' Perform an edit of an item in the database.
723            See parsePropsFromForm and _editnodes for special variables
724         '''
725         # parse the props from the form
726         if 1:
727 #        try:
728             props, links = self.parsePropsFromForm()
729 #        except (ValueError, KeyError), message:
730 #            self.error_message.append(_('Error: ') + str(message))
731 #            return
733         # handle the props
734         if 1:
735 #        try:
736             message = self._editnodes(props, links)
737 #        except (ValueError, KeyError, IndexError), message:
738 #            self.error_message.append(_('Error: ') + str(message))
739 #            return
741         # commit now that all the tricky stuff is done
742         self.db.commit()
744         # redirect to the item's edit page
745         raise Redirect, '%s%s%s?@ok_message=%s'%(self.base, self.classname,
746             self.nodeid,  urllib.quote(message))
748     def editItemPermission(self, props):
749         ''' Determine whether the user has permission to edit this item.
751             Base behaviour is to check the user can edit this class. If we're
752             editing the "user" class, users are allowed to edit their own
753             details. Unless it's the "roles" property, which requires the
754             special Permission "Web Roles".
755         '''
756         # if this is a user node and the user is editing their own node, then
757         # we're OK
758         has = self.db.security.hasPermission
759         if self.classname == 'user':
760             # reject if someone's trying to edit "roles" and doesn't have the
761             # right permission.
762             if props.has_key('roles') and not has('Web Roles', self.userid,
763                     'user'):
764                 return 0
765             # if the item being edited is the current user, we're ok
766             if self.nodeid == self.userid:
767                 return 1
768         if self.db.security.hasPermission('Edit', self.userid, self.classname):
769             return 1
770         return 0
772     def newItemAction(self):
773         ''' Add a new item to the database.
775             This follows the same form as the editItemAction, with the same
776             special form values.
777         '''
778         # parse the props from the form
779 # XXX reinstate exception handling
780 #        try:
781         if 1:
782             props, links = self.parsePropsFromForm()
783 #        except (ValueError, KeyError), message:
784 #            self.error_message.append(_('Error: ') + str(message))
785 #            return
787         # handle the props - edit or create
788 # XXX reinstate exception handling
789 #        try:
790         if 1:
791             # create the context here
792 #            cn = self.classname
793 #            nid = self._createnode(cn, props[(cn, None)])
794 #            del props[(cn, None)]
796             # when it hits the None element, it'll set self.nodeid
797             messages = self._editnodes(props, links) #, {(cn, None): nid})
799 #        except (ValueError, KeyError, IndexError), message:
800 #            # these errors might just be indicative of user dumbness
801 #            self.error_message.append(_('Error: ') + str(message))
802 #            return
804         # commit now that all the tricky stuff is done
805         self.db.commit()
807         # redirect to the new item's page
808         raise Redirect, '%s%s%s?@ok_message=%s'%(self.base, self.classname,
809             self.nodeid, urllib.quote(messages))
811     def newItemPermission(self, props):
812         ''' Determine whether the user has permission to create (edit) this
813             item.
815             Base behaviour is to check the user can edit this class. No
816             additional property checks are made. Additionally, new user items
817             may be created if the user has the "Web Registration" Permission.
818         '''
819         has = self.db.security.hasPermission
820         if self.classname == 'user' and has('Web Registration', self.userid,
821                 'user'):
822             return 1
823         if has('Edit', self.userid, self.classname):
824             return 1
825         return 0
828     #
829     #  Utility methods for editing
830     #
831     def _editnodes(self, all_props, all_links, newids=None):
832         ''' Use the props in all_props to perform edit and creation, then
833             use the link specs in all_links to do linking.
834         '''
835 #        print '='*75
836 #        print 'ALL_PROPS', all_props
837         # figure dependencies and re-work links
838         deps = {}
839         links = {}
840         for cn, nodeid, propname, vlist in all_links:
841             if not all_props.has_key((cn, nodeid)):
842                 # link item to link to doesn't (and won't) exist
843                 continue
844             for value in vlist:
845                 if not all_props.has_key(value):
846                     # link item to link to doesn't (and won't) exist
847                     continue
848                 deps.setdefault((cn, nodeid), []).append(value)
849                 links.setdefault(value, []).append((cn, nodeid, propname))
851 #        print '*'*75
852 #        print 'LINKS', links
853 #        print 'DEPS', deps
855         # figure chained dependencies ordering
856         order = []
857         done = {}
858         # loop detection
859         change = 0
860         while len(all_props) != len(done):
861             for needed in all_props.keys():
862                 if done.has_key(needed):
863                     continue
864                 tlist = deps.get(needed, [])
865 #                print 'SOLVING', needed, tlist
866                 for target in tlist:
867                     if not done.has_key(target):
868                         break
869                 else:
870 #                    print 'DONE', needed
871                     done[needed] = 1
872                     order.append(needed)
873                     change = 1
874             if not change:
875                 raise ValueError, 'linking must not loop!'
877         # now, edit / create
878         m = []
879         for needed in order:
880             props = all_props[needed]
881             if not props:
882                 # nothing to do
883                 continue
884             cn, nodeid = needed
886             if nodeid is not None and int(nodeid) > 0:
887                 # make changes to the node
888                 props = self._changenode(cn, nodeid, props)
890                 # and some nice feedback for the user
891                 if props:
892                     info = ', '.join(props.keys())
893                     m.append('%s %s %s edited ok'%(cn, nodeid, info))
894                 else:
895                     m.append('%s %s - nothing changed'%(cn, nodeid))
896             else:
897                 assert props
899                 # make a new node
900                 newid = self._createnode(cn, props)
901                 if nodeid is None:
902                     self.nodeid = newid
903                 nodeid = newid
905                 # and some nice feedback for the user
906                 m.append('%s %s created'%(cn, newid))
908             # fill in new ids in links
909             if links.has_key(needed):
910                 for linkcn, linkid, linkprop in links[needed]:
911                     props = all_props[(linkcn, linkid)]
912                     cl = self.db.classes[linkcn]
913                     propdef = cl.getprops()[linkprop]
914                     if not props.has_key(linkprop):
915                         if linkid is None or linkid.startswith('-'):
916                             # linking to a new item
917                             if isinstance(propdef, hyperdb.Multilink):
918                                 props[linkprop] = [newid]
919                             else:
920                                 props[linkprop] = newid
921                         else:
922                             # linking to an existing item
923                             if isinstance(propdef, hyperdb.Multilink):
924                                 existing = cl.get(linkid, linkprop)[:]
925                                 existing.append(nodeid)
926                                 props[linkprop] = existing
927                             else:
928                                 props[linkprop] = newid
930         return '<br>'.join(m)
932     def _changenode(self, cn, nodeid, props):
933         ''' change the node based on the contents of the form
934         '''
935         # check for permission
936         if not self.editItemPermission(props):
937             raise PermissionError, 'You do not have permission to edit %s'%cn
939         # make the changes
940         cl = self.db.classes[cn]
941         return cl.set(nodeid, **props)
943     def _createnode(self, cn, props):
944         ''' create a node based on the contents of the form
945         '''
946         # check for permission
947         if not self.newItemPermission(props):
948             raise PermissionError, 'You do not have permission to create %s'%cn
950         # create the node and return its id
951         cl = self.db.classes[cn]
952         return cl.create(**props)
954     # 
955     # More actions
956     #
957     def editCSVAction(self):
958         ''' Performs an edit of all of a class' items in one go.
960             The "rows" CGI var defines the CSV-formatted entries for the
961             class. New nodes are identified by the ID 'X' (or any other
962             non-existent ID) and removed lines are retired.
963         '''
964         # this is per-class only
965         if not self.editCSVPermission():
966             self.error_message.append(
967                 _('You do not have permission to edit %s' %self.classname))
969         # get the CSV module
970         try:
971             import csv
972         except ImportError:
973             self.error_message.append(_(
974                 'Sorry, you need the csv module to use this function.<br>\n'
975                 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
976             return
978         cl = self.db.classes[self.classname]
979         idlessprops = cl.getprops(protected=0).keys()
980         idlessprops.sort()
981         props = ['id'] + idlessprops
983         # do the edit
984         rows = self.form['rows'].value.splitlines()
985         p = csv.parser()
986         found = {}
987         line = 0
988         for row in rows[1:]:
989             line += 1
990             values = p.parse(row)
991             # not a complete row, keep going
992             if not values: continue
994             # skip property names header
995             if values == props:
996                 continue
998             # extract the nodeid
999             nodeid, values = values[0], values[1:]
1000             found[nodeid] = 1
1002             # confirm correct weight
1003             if len(idlessprops) != len(values):
1004                 self.error_message.append(
1005                     _('Not enough values on line %(line)s')%{'line':line})
1006                 return
1008             # extract the new values
1009             d = {}
1010             for name, value in zip(idlessprops, values):
1011                 value = value.strip()
1012                 # only add the property if it has a value
1013                 if value:
1014                     # if it's a multilink, split it
1015                     if isinstance(cl.properties[name], hyperdb.Multilink):
1016                         value = value.split(':')
1017                     d[name] = value
1019             # perform the edit
1020             if cl.hasnode(nodeid):
1021                 # edit existing
1022                 cl.set(nodeid, **d)
1023             else:
1024                 # new node
1025                 found[cl.create(**d)] = 1
1027         # retire the removed entries
1028         for nodeid in cl.list():
1029             if not found.has_key(nodeid):
1030                 cl.retire(nodeid)
1032         # all OK
1033         self.db.commit()
1035         self.ok_message.append(_('Items edited OK'))
1037     def editCSVPermission(self):
1038         ''' Determine whether the user has permission to edit this class.
1040             Base behaviour is to check the user can edit this class.
1041         ''' 
1042         if not self.db.security.hasPermission('Edit', self.userid,
1043                 self.classname):
1044             return 0
1045         return 1
1047     def searchAction(self):
1048         ''' Mangle some of the form variables.
1050             Set the form ":filter" variable based on the values of the
1051             filter variables - if they're set to anything other than
1052             "dontcare" then add them to :filter.
1054             Also handle the ":queryname" variable and save off the query to
1055             the user's query list.
1056         '''
1057         # generic edit is per-class only
1058         if not self.searchPermission():
1059             self.error_message.append(
1060                 _('You do not have permission to search %s' %self.classname))
1062         # add a faked :filter form variable for each filtering prop
1063 # XXX migrate to new : @ + 
1064         props = self.db.classes[self.classname].getprops()
1065         for key in self.form.keys():
1066             if not props.has_key(key): continue
1067             if isinstance(self.form[key], type([])):
1068                 # search for at least one entry which is not empty
1069                 for minifield in self.form[key]:
1070                     if minifield.value:
1071                         break
1072                 else:
1073                     continue
1074             else:
1075                 if not self.form[key].value: continue
1076             self.form.value.append(cgi.MiniFieldStorage(':filter', key))
1078         # handle saving the query params
1079         if self.form.has_key(':queryname'):
1080             queryname = self.form[':queryname'].value.strip()
1081             if queryname:
1082                 # parse the environment and figure what the query _is_
1083                 req = HTMLRequest(self)
1084                 url = req.indexargs_href('', {})
1086                 # handle editing an existing query
1087                 try:
1088                     qid = self.db.query.lookup(queryname)
1089                     self.db.query.set(qid, klass=self.classname, url=url)
1090                 except KeyError:
1091                     # create a query
1092                     qid = self.db.query.create(name=queryname,
1093                         klass=self.classname, url=url)
1095                     # and add it to the user's query multilink
1096                     queries = self.db.user.get(self.userid, 'queries')
1097                     queries.append(qid)
1098                     self.db.user.set(self.userid, queries=queries)
1100                 # commit the query change to the database
1101                 self.db.commit()
1103     def searchPermission(self):
1104         ''' Determine whether the user has permission to search this class.
1106             Base behaviour is to check the user can view this class.
1107         ''' 
1108         if not self.db.security.hasPermission('View', self.userid,
1109                 self.classname):
1110             return 0
1111         return 1
1114     def retireAction(self):
1115         ''' Retire the context item.
1116         '''
1117         # if we want to view the index template now, then unset the nodeid
1118         # context info (a special-case for retire actions on the index page)
1119         nodeid = self.nodeid
1120         if self.template == 'index':
1121             self.nodeid = None
1123         # generic edit is per-class only
1124         if not self.retirePermission():
1125             self.error_message.append(
1126                 _('You do not have permission to retire %s' %self.classname))
1127             return
1129         # make sure we don't try to retire admin or anonymous
1130         if self.classname == 'user' and \
1131                 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1132             self.error_message.append(
1133                 _('You may not retire the admin or anonymous user'))
1134             return
1136         # do the retire
1137         self.db.getclass(self.classname).retire(nodeid)
1138         self.db.commit()
1140         self.ok_message.append(
1141             _('%(classname)s %(itemid)s has been retired')%{
1142                 'classname': self.classname.capitalize(), 'itemid': nodeid})
1144     def retirePermission(self):
1145         ''' Determine whether the user has permission to retire this class.
1147             Base behaviour is to check the user can edit this class.
1148         ''' 
1149         if not self.db.security.hasPermission('Edit', self.userid,
1150                 self.classname):
1151             return 0
1152         return 1
1155     def showAction(self):
1156         ''' Show a node
1157         '''
1158 # XXX allow : @ +
1159         t = self.form[':type'].value
1160         n = self.form[':number'].value
1161         url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1162         raise Redirect, url
1164     def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1165         ''' Pull properties out of the form.
1167             In the following, <bracketed> values are variable, ":" may be
1168             one of ":" or "@", and other text "required" is fixed.
1170             Properties are specified as form variables:
1172              <propname>
1173               - property on the current context item
1175              <designator>:<propname>
1176               - property on the indicated item
1178              <classname>-<N>:<propname>
1179               - property on the Nth new item of classname
1181             Once we have determined the "propname", we check to see if it
1182             is one of the special form values:
1184              :required
1185               The named property values must be supplied or a ValueError
1186               will be raised.
1188              :remove:<propname>=id(s)
1189               The ids will be removed from the multilink property.
1191              :add:<propname>=id(s)
1192               The ids will be added to the multilink property.
1194              :link:<propname>=<designator>
1195               Used to add a link to new items created during edit.
1196               These are collected up and returned in all_links. This will
1197               result in an additional linking operation (either Link set or
1198               Multilink append) after the edit/create is done using
1199               all_props in _editnodes. The <propname> on the current item
1200               will be set/appended the id of the newly created item of
1201               class <designator> (where <designator> must be
1202               <classname>-<N>).
1204             Any of the form variables may be prefixed with a classname or
1205             designator.
1207             The return from this method is a dict of 
1208                 (classname, id): properties
1209             ... this dict _always_ has an entry for the current context,
1210             even if it's empty (ie. a submission for an existing issue that
1211             doesn't result in any changes would return {('issue','123'): {}})
1212             The id may be None, which indicates that an item should be
1213             created.
1215             If a String property's form value is a file upload, then we
1216             try to set additional properties "filename" and "type" (if
1217             they are valid for the class).
1219             Two special form values are supported for backwards
1220             compatibility:
1221              :note - create a message (with content, author and date), link
1222                      to the context item. This is ALWAYS desginated "msg-1".
1223              :file - create a file, attach to the current item and any
1224                      message created by :note. This is ALWAYS designated
1225                      "file-1".
1227             We also check that FileClass items have a "content" property with
1228             actual content, otherwise we remove them from all_props before
1229             returning.
1230         '''
1231         # some very useful variables
1232         db = self.db
1233         form = self.form
1235         if not hasattr(self, 'FV_SPECIAL'):
1236             # generate the regexp for handling special form values
1237             classes = '|'.join(db.classes.keys())
1238             # specials for parsePropsFromForm
1239             # handle the various forms (see unit tests)
1240             self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1241             self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1243         # these indicate the default class / item
1244         default_cn = self.classname
1245         default_cl = self.db.classes[default_cn]
1246         default_nodeid = self.nodeid
1248         # we'll store info about the individual class/item edit in these
1249         all_required = {}       # one entry per class/item
1250         all_props = {}          # one entry per class/item
1251         all_propdef = {}        # note - only one entry per class
1252         all_links = []          # as many as are required
1254         # we should always return something, even empty, for the context
1255         all_props[(default_cn, default_nodeid)] = {}
1257         keys = form.keys()
1258         timezone = db.getUserTimezone()
1260         # sentinels for the :note and :file props
1261         have_note = have_file = 0
1263         # extract the usable form labels from the form
1264         matches = []
1265         for key in keys:
1266             m = self.FV_SPECIAL.match(key)
1267             if m:
1268                 matches.append((key, m.groupdict()))
1270         # now handle the matches
1271         for key, d in matches:
1272             if d['classname']:
1273                 # we got a designator
1274                 cn = d['classname']
1275                 cl = self.db.classes[cn]
1276                 nodeid = d['id']
1277                 propname = d['propname']
1278             elif d['note']:
1279                 # the special note field
1280                 cn = 'msg'
1281                 cl = self.db.classes[cn]
1282                 nodeid = '-1'
1283                 propname = 'content'
1284                 all_links.append((default_cn, default_nodeid, 'messages',
1285                     [('msg', '-1')]))
1286                 have_note = 1
1287             elif d['file']:
1288                 # the special file field
1289                 cn = 'file'
1290                 cl = self.db.classes[cn]
1291                 nodeid = '-1'
1292                 propname = 'content'
1293                 all_links.append((default_cn, default_nodeid, 'files',
1294                     [('file', '-1')]))
1295                 have_file = 1
1296             else:
1297                 # default
1298                 cn = default_cn
1299                 cl = default_cl
1300                 nodeid = default_nodeid
1301                 propname = d['propname']
1303             # the thing this value relates to is...
1304             this = (cn, nodeid)
1306             # get more info about the class, and the current set of
1307             # form props for it
1308             if not all_propdef.has_key(cn):
1309                 all_propdef[cn] = cl.getprops()
1310             propdef = all_propdef[cn]
1311             if not all_props.has_key(this):
1312                 all_props[this] = {}
1313             props = all_props[this]
1315             # is this a link command?
1316             if d['link']:
1317                 value = []
1318                 for entry in extractFormList(form[key]):
1319                     m = self.FV_DESIGNATOR.match(entry)
1320                     if not m:
1321                         raise ValueError, \
1322                             'link "%s" value "%s" not a designator'%(key, entry)
1323                     value.append((m.group(1), m.group(2)))
1325                 # make sure the link property is valid
1326                 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1327                         not isinstance(propdef[propname], hyperdb.Link)):
1328                     raise ValueError, '%s %s is not a link or '\
1329                         'multilink property'%(cn, propname)
1331                 all_links.append((cn, nodeid, propname, value))
1332                 continue
1334             # detect the special ":required" variable
1335             if d['required']:
1336                 all_required[this] = extractFormList(form[key])
1337                 continue
1339             # get the required values list
1340             if not all_required.has_key(this):
1341                 all_required[this] = []
1342             required = all_required[this]
1344             # see if we're performing a special multilink action
1345             mlaction = 'set'
1346             if d['remove']:
1347                 mlaction = 'remove'
1348             elif d['add']:
1349                 mlaction = 'add'
1351             # does the property exist?
1352             if not propdef.has_key(propname):
1353                 if mlaction != 'set':
1354                     raise ValueError, 'You have submitted a %s action for'\
1355                         ' the property "%s" which doesn\'t exist'%(mlaction,
1356                         propname)
1357                 # the form element is probably just something we don't care
1358                 # about - ignore it
1359                 continue
1360             proptype = propdef[propname]
1362             # Get the form value. This value may be a MiniFieldStorage or a list
1363             # of MiniFieldStorages.
1364             value = form[key]
1366             # handle unpacking of the MiniFieldStorage / list form value
1367             if isinstance(proptype, hyperdb.Multilink):
1368                 value = extractFormList(value)
1369             else:
1370                 # multiple values are not OK
1371                 if isinstance(value, type([])):
1372                     raise ValueError, 'You have submitted more than one value'\
1373                         ' for the %s property'%propname
1374                 # value might be a file upload...
1375                 if not hasattr(value, 'filename') or value.filename is None:
1376                     # nope, pull out the value and strip it
1377                     value = value.value.strip()
1379             # now that we have the props field, we need a teensy little
1380             # extra bit of help for the old :note field...
1381             if d['note'] and value:
1382                 props['author'] = self.db.getuid()
1383                 props['date'] = date.Date()
1385             # handle by type now
1386             if isinstance(proptype, hyperdb.Password):
1387                 if not value:
1388                     # ignore empty password values
1389                     continue
1390                 for key, d in matches:
1391                     if d['confirm'] and d['propname'] == propname:
1392                         confirm = form[key]
1393                         break
1394                 else:
1395                     raise ValueError, 'Password and confirmation text do '\
1396                         'not match'
1397                 if isinstance(confirm, type([])):
1398                     raise ValueError, 'You have submitted more than one value'\
1399                         ' for the %s property'%propname
1400                 if value != confirm.value:
1401                     raise ValueError, 'Password and confirmation text do '\
1402                         'not match'
1403                 value = password.Password(value)
1405             elif isinstance(proptype, hyperdb.Link):
1406                 # see if it's the "no selection" choice
1407                 if value == '-1' or not value:
1408                     # if we're creating, just don't include this property
1409                     if not nodeid or nodeid.startswith('-'):
1410                         continue
1411                     value = None
1412                 else:
1413                     # handle key values
1414                     link = proptype.classname
1415                     if not num_re.match(value):
1416                         try:
1417                             value = db.classes[link].lookup(value)
1418                         except KeyError:
1419                             raise ValueError, _('property "%(propname)s": '
1420                                 '%(value)s not a %(classname)s')%{
1421                                 'propname': propname, 'value': value,
1422                                 'classname': link}
1423                         except TypeError, message:
1424                             raise ValueError, _('you may only enter ID values '
1425                                 'for property "%(propname)s": %(message)s')%{
1426                                 'propname': propname, 'message': message}
1427             elif isinstance(proptype, hyperdb.Multilink):
1428                 # perform link class key value lookup if necessary
1429                 link = proptype.classname
1430                 link_cl = db.classes[link]
1431                 l = []
1432                 for entry in value:
1433                     if not entry: continue
1434                     if not num_re.match(entry):
1435                         try:
1436                             entry = link_cl.lookup(entry)
1437                         except KeyError:
1438                             raise ValueError, _('property "%(propname)s": '
1439                                 '"%(value)s" not an entry of %(classname)s')%{
1440                                 'propname': propname, 'value': entry,
1441                                 'classname': link}
1442                         except TypeError, message:
1443                             raise ValueError, _('you may only enter ID values '
1444                                 'for property "%(propname)s": %(message)s')%{
1445                                 'propname': propname, 'message': message}
1446                     l.append(entry)
1447                 l.sort()
1449                 # now use that list of ids to modify the multilink
1450                 if mlaction == 'set':
1451                     value = l
1452                 else:
1453                     # we're modifying the list - get the current list of ids
1454                     if props.has_key(propname):
1455                         existing = props[propname]
1456                     elif nodeid and not nodeid.startswith('-'):
1457                         existing = cl.get(nodeid, propname, [])
1458                     else:
1459                         existing = []
1461                     # now either remove or add
1462                     if mlaction == 'remove':
1463                         # remove - handle situation where the id isn't in
1464                         # the list
1465                         for entry in l:
1466                             try:
1467                                 existing.remove(entry)
1468                             except ValueError:
1469                                 raise ValueError, _('property "%(propname)s": '
1470                                     '"%(value)s" not currently in list')%{
1471                                     'propname': propname, 'value': entry}
1472                     else:
1473                         # add - easy, just don't dupe
1474                         for entry in l:
1475                             if entry not in existing:
1476                                 existing.append(entry)
1477                     value = existing
1478                     value.sort()
1480             elif value == '':
1481                 # if we're creating, just don't include this property
1482                 if not nodeid or nodeid.startswith('-'):
1483                     continue
1484                 # other types should be None'd if there's no value
1485                 value = None
1486             else:
1487                 if isinstance(proptype, hyperdb.String):
1488                     if (hasattr(value, 'filename') and
1489                             value.filename is not None):
1490                         # skip if the upload is empty
1491                         if not value.filename:
1492                             continue
1493                         # this String is actually a _file_
1494                         # try to determine the file content-type
1495                         filename = value.filename.split('\\')[-1]
1496                         if propdef.has_key('name'):
1497                             props['name'] = filename
1498                         # use this info as the type/filename properties
1499                         if propdef.has_key('type'):
1500                             props['type'] = mimetypes.guess_type(filename)[0]
1501                             if not props['type']:
1502                                 props['type'] = "application/octet-stream"
1503                         # finally, read the content
1504                         value = value.value
1505                     else:
1506                         # normal String fix the CRLF/CR -> LF stuff
1507                         value = fixNewlines(value)
1509                 elif isinstance(proptype, hyperdb.Date):
1510                     value = date.Date(value, offset=timezone)
1511                 elif isinstance(proptype, hyperdb.Interval):
1512                     value = date.Interval(value)
1513                 elif isinstance(proptype, hyperdb.Boolean):
1514                     value = value.lower() in ('yes', 'true', 'on', '1')
1515                 elif isinstance(proptype, hyperdb.Number):
1516                     value = float(value)
1518             # get the old value
1519             if nodeid and not nodeid.startswith('-'):
1520                 try:
1521                     existing = cl.get(nodeid, propname)
1522                 except KeyError:
1523                     # this might be a new property for which there is
1524                     # no existing value
1525                     if not propdef.has_key(propname):
1526                         raise
1528                 # make sure the existing multilink is sorted
1529                 if isinstance(proptype, hyperdb.Multilink):
1530                     existing.sort()
1532                 # "missing" existing values may not be None
1533                 if not existing:
1534                     if isinstance(proptype, hyperdb.String) and not existing:
1535                         # some backends store "missing" Strings as empty strings
1536                         existing = None
1537                     elif isinstance(proptype, hyperdb.Number) and not existing:
1538                         # some backends store "missing" Numbers as 0 :(
1539                         existing = 0
1540                     elif isinstance(proptype, hyperdb.Boolean) and not existing:
1541                         # likewise Booleans
1542                         existing = 0
1544                 # if changed, set it
1545                 if value != existing:
1546                     props[propname] = value
1547             else:
1548                 # don't bother setting empty/unset values
1549                 if value is None:
1550                     continue
1551                 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1552                     continue
1553                 elif isinstance(proptype, hyperdb.String) and value == '':
1554                     continue
1556                 props[propname] = value
1558             # register this as received if required?
1559             if propname in required and value is not None:
1560                 required.remove(propname)
1562         # check to see if we need to specially link a file to the note
1563         if have_note and have_file:
1564             all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1566         # see if all the required properties have been supplied
1567         s = []
1568         for thing, required in all_required.items():
1569             if not required:
1570                 continue
1571             if len(required) > 1:
1572                 p = 'properties'
1573             else:
1574                 p = 'property'
1575             s.append('Required %s %s %s not supplied'%(thing[0], p,
1576                 ', '.join(required)))
1577         if s:
1578             raise ValueError, '\n'.join(s)
1580         # check that FileClass entries have a "content" property with
1581         # content, otherwise remove them
1582         for (cn, id), props in all_props.items():
1583             cl = self.db.classes[cn]
1584             if not isinstance(cl, hyperdb.FileClass):
1585                 continue
1586             # we also don't want to create FileClass items with no content
1587             if not props.get('content', ''):
1588                 del all_props[(cn, id)]
1589         return all_props, all_links
1591 def fixNewlines(text):
1592     ''' Homogenise line endings.
1594         Different web clients send different line ending values, but
1595         other systems (eg. email) don't necessarily handle those line
1596         endings. Our solution is to convert all line endings to LF.
1597     '''
1598     text = text.replace('\r\n', '\n')
1599     return text.replace('\r', '\n')
1601 def extractFormList(value):
1602     ''' Extract a list of values from the form value.
1604         It may be one of:
1605          [MiniFieldStorage, MiniFieldStorage, ...]
1606          MiniFieldStorage('value,value,...')
1607          MiniFieldStorage('value')
1608     '''
1609     # multiple values are OK
1610     if isinstance(value, type([])):
1611         # it's a list of MiniFieldStorages
1612         value = [i.value.strip() for i in value]
1613     else:
1614         # it's a MiniFieldStorage, but may be a comma-separated list
1615         # of values
1616         value = [i.strip() for i in value.value.split(',')]
1618     # filter out the empty bits
1619     return filter(None, value)