Code

Form handling now performs all actions (property setting including linking)
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.92 2003-02-18 03:58:18 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 Unauthorised(ValueError):
19     pass
21 class NotFound(ValueError):
22     pass
24 class Redirect(Exception):
25     pass
27 class SendFile(Exception):
28     ' Sent a file from the database '
30 class SendStaticFile(Exception):
31     ' Send a static file from the instance html directory '
33 def initialiseSecurity(security):
34     ''' Create some Permissions and Roles on the security object
36         This function is directly invoked by security.Security.__init__()
37         as a part of the Security object instantiation.
38     '''
39     security.addPermission(name="Web Registration",
40         description="User may register through the web")
41     p = security.addPermission(name="Web Access",
42         description="User may access the web interface")
43     security.addPermissionToRole('Admin', p)
45     # doing Role stuff through the web - make sure Admin can
46     p = security.addPermission(name="Web Roles",
47         description="User may manipulate user Roles through the web")
48     security.addPermissionToRole('Admin', p)
50 class Client:
51     ''' Instantiate to handle one CGI request.
53     See inner_main for request processing.
55     Client attributes at instantiation:
56         "path" is the PATH_INFO inside the instance (with no leading '/')
57         "base" is the base URL for the instance
58         "form" is the cgi form, an instance of FieldStorage from the standard
59                cgi module
60         "additional_headers" is a dictionary of additional HTTP headers that
61                should be sent to the client
62         "response_code" is the HTTP response code to send to the client
64     During the processing of a request, the following attributes are used:
65         "error_message" holds a list of error messages
66         "ok_message" holds a list of OK messages
67         "session" is the current user session id
68         "user" is the current user's name
69         "userid" is the current user's id
70         "template" is the current :template context
71         "classname" is the current class context name
72         "nodeid" is the current context item id
74     User Identification:
75      If the user has no login cookie, then they are anonymous and are logged
76      in as that user. This typically gives them all Permissions assigned to the
77      Anonymous Role.
79      Once a user logs in, they are assigned a session. The Client instance
80      keeps the nodeid of the session as the "session" attribute.
83     Special form variables:
84      Note that in various places throughout this code, special form
85      variables of the form :<name> are used. The colon (":") part may
86      actually be one of either ":" or "@".
87     '''
89     #
90     # special form variables
91     #
92     FV_TEMPLATE = re.compile(r'[@:]template')
93     FV_OK_MESSAGE = re.compile(r'[@:]ok_message')
94     FV_ERROR_MESSAGE = re.compile(r'[@:]error_message')
96     # edit form variable handling (see unit tests)
97     FV_LABELS = r'''
98        ^(
99          (?P<note>[@:]note)|
100          (?P<file>[@:]file)|
101          (
102           ((?P<classname>%s)(?P<id>[-\d]+))?  # optional leading designator
103           ((?P<required>[@:]required$)|       # :required
104            (
105             (
106              (?P<add>[@:]add[@:])|            # :add:<prop>
107              (?P<remove>[@:]remove[@:])|      # :remove:<prop>
108              (?P<confirm>[@:]confirm[@:])|    # :confirm:<prop>
109              (?P<link>[@:]link[@:])|          # :link:<prop>
110              ([@:])                           # just a separator
111             )?
112             (?P<propname>[^@:]+)             # <prop>
113            )
114           )
115          )
116         )$'''
118     # Note: index page stuff doesn't appear here:
119     # columns, sort, sortdir, filter, group, groupdir, search_text,
120     # pagesize, startwith
122     def __init__(self, instance, request, env, form=None):
123         hyperdb.traceMark()
124         self.instance = instance
125         self.request = request
126         self.env = env
128         # save off the path
129         self.path = env['PATH_INFO']
131         # this is the base URL for this tracker
132         self.base = self.instance.config.TRACKER_WEB
134         # this is the "cookie path" for this tracker (ie. the path part of
135         # the "base" url)
136         self.cookie_path = urlparse.urlparse(self.base)[2]
137         self.cookie_name = 'roundup_session_' + re.sub('[^a-zA-Z]', '',
138             self.instance.config.TRACKER_NAME)
140         # see if we need to re-parse the environment for the form (eg Zope)
141         if form is None:
142             self.form = cgi.FieldStorage(environ=env)
143         else:
144             self.form = form
146         # turn debugging on/off
147         try:
148             self.debug = int(env.get("ROUNDUP_DEBUG", 0))
149         except ValueError:
150             # someone gave us a non-int debug level, turn it off
151             self.debug = 0
153         # flag to indicate that the HTTP headers have been sent
154         self.headers_done = 0
156         # additional headers to send with the request - must be registered
157         # before the first write
158         self.additional_headers = {}
159         self.response_code = 200
162     def main(self):
163         ''' Wrap the real main in a try/finally so we always close off the db.
164         '''
165         try:
166             self.inner_main()
167         finally:
168             if hasattr(self, 'db'):
169                 self.db.close()
171     def inner_main(self):
172         ''' Process a request.
174             The most common requests are handled like so:
175             1. figure out who we are, defaulting to the "anonymous" user
176                see determine_user
177             2. figure out what the request is for - the context
178                see determine_context
179             3. handle any requested action (item edit, search, ...)
180                see handle_action
181             4. render a template, resulting in HTML output
183             In some situations, exceptions occur:
184             - HTTP Redirect  (generally raised by an action)
185             - SendFile       (generally raised by determine_context)
186               serve up a FileClass "content" property
187             - SendStaticFile (generally raised by determine_context)
188               serve up a file from the tracker "html" directory
189             - Unauthorised   (generally raised by an action)
190               the action is cancelled, the request is rendered and an error
191               message is displayed indicating that permission was not
192               granted for the action to take place
193             - NotFound       (raised wherever it needs to be)
194               percolates up to the CGI interface that called the client
195         '''
196         self.ok_message = []
197         self.error_message = []
198         try:
199             # make sure we're identified (even anonymously)
200             self.determine_user()
201             # figure out the context and desired content template
202             self.determine_context()
203             # possibly handle a form submit action (may change self.classname
204             # and self.template, and may also append error/ok_messages)
205             self.handle_action()
206             # now render the page
208             # we don't want clients caching our dynamic pages
209             self.additional_headers['Cache-Control'] = 'no-cache'
210             self.additional_headers['Pragma'] = 'no-cache'
211             self.additional_headers['Expires'] = 'Thu, 1 Jan 1970 00:00:00 GMT'
213             # render the content
214             self.write(self.renderContext())
215         except Redirect, url:
216             # let's redirect - if the url isn't None, then we need to do
217             # the headers, otherwise the headers have been set before the
218             # exception was raised
219             if url:
220                 self.additional_headers['Location'] = url
221                 self.response_code = 302
222             self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
223         except SendFile, designator:
224             self.serve_file(designator)
225         except SendStaticFile, file:
226             self.serve_static_file(str(file))
227         except Unauthorised, message:
228             self.classname=None
229             self.template=''
230             self.error_message.append(message)
231             self.write(self.renderContext())
232         except NotFound:
233             # pass through
234             raise
235         except:
236             # everything else
237             self.write(cgitb.html())
239     def clean_sessions(self):
240         '''age sessions, remove when they haven't been used for a week.
241         Do it only once an hour'''
242         sessions = self.db.sessions
243         last_clean = sessions.get('last_clean', 'last_use') or 0
245         week = 60*60*24*7
246         hour = 60*60
247         now = time.time()
248         if now - last_clean > hour:
249             # remove age sessions
250             for sessid in sessions.list():
251                 interval = now - sessions.get(sessid, 'last_use')
252                 if interval > week:
253                     sessions.destroy(sessid)
254             sessions.set('last_clean', last_use=time.time())
256     def determine_user(self):
257         ''' Determine who the user is
258         '''
259         # determine the uid to use
260         self.opendb('admin')
261         # clean age sessions
262         self.clean_sessions()
263         # make sure we have the session Class
264         sessions = self.db.sessions
266         # look up the user session cookie
267         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
268         user = 'anonymous'
270         # bump the "revision" of the cookie since the format changed
271         if (cookie.has_key(self.cookie_name) and
272                 cookie[self.cookie_name].value != 'deleted'):
274             # get the session key from the cookie
275             self.session = cookie[self.cookie_name].value
276             # get the user from the session
277             try:
278                 # update the lifetime datestamp
279                 sessions.set(self.session, last_use=time.time())
280                 sessions.commit()
281                 user = sessions.get(self.session, 'user')
282             except KeyError:
283                 user = 'anonymous'
285         # sanity check on the user still being valid, getting the userid
286         # at the same time
287         try:
288             self.userid = self.db.user.lookup(user)
289         except (KeyError, TypeError):
290             user = 'anonymous'
292         # make sure the anonymous user is valid if we're using it
293         if user == 'anonymous':
294             self.make_user_anonymous()
295         else:
296             self.user = user
298         # reopen the database as the correct user
299         self.opendb(self.user)
301     def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
302         ''' Determine the context of this page from the URL:
304             The URL path after the instance identifier is examined. The path
305             is generally only one entry long.
307             - if there is no path, then we are in the "home" context.
308             * if the path is "_file", then the additional path entry
309               specifies the filename of a static file we're to serve up
310               from the instance "html" directory. Raises a SendStaticFile
311               exception.
312             - if there is something in the path (eg "issue"), it identifies
313               the tracker class we're to display.
314             - if the path is an item designator (eg "issue123"), then we're
315               to display a specific item.
316             * if the path starts with an item designator and is longer than
317               one entry, then we're assumed to be handling an item of a
318               FileClass, and the extra path information gives the filename
319               that the client is going to label the download with (ie
320               "file123/image.png" is nicer to download than "file123"). This
321               raises a SendFile exception.
323             Both of the "*" types of contexts stop before we bother to
324             determine the template we're going to use. That's because they
325             don't actually use templates.
327             The template used is specified by the :template CGI variable,
328             which defaults to:
330              only classname suplied:          "index"
331              full item designator supplied:   "item"
333             We set:
334              self.classname  - the class to display, can be None
335              self.template   - the template to render the current context with
336              self.nodeid     - the nodeid of the class we're displaying
337         '''
338         # default the optional variables
339         self.classname = None
340         self.nodeid = None
342         # see if a template or messages are specified
343         template_override = ok_message = error_message = None
344         for key in self.form.keys():
345             if self.FV_TEMPLATE.match(key):
346                 template_override = self.form[key].value
347             elif self.FV_OK_MESSAGE.match(key):
348                 ok_message = self.form[key].value
349             elif self.FV_ERROR_MESSAGE.match(key):
350                 error_message = self.form[key].value
352         # determine the classname and possibly nodeid
353         path = self.path.split('/')
354         if not path or path[0] in ('', 'home', 'index'):
355             if template_override is not None:
356                 self.template = template_override
357             else:
358                 self.template = ''
359             return
360         elif path[0] == '_file':
361             raise SendStaticFile, os.path.join(*path[1:])
362         else:
363             self.classname = path[0]
364             if len(path) > 1:
365                 # send the file identified by the designator in path[0]
366                 raise SendFile, path[0]
368         # see if we got a designator
369         m = dre.match(self.classname)
370         if m:
371             self.classname = m.group(1)
372             self.nodeid = m.group(2)
373             if not self.db.getclass(self.classname).hasnode(self.nodeid):
374                 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
375             # with a designator, we default to item view
376             self.template = 'item'
377         else:
378             # with only a class, we default to index view
379             self.template = 'index'
381         # make sure the classname is valid
382         try:
383             self.db.getclass(self.classname)
384         except KeyError:
385             raise NotFound, self.classname
387         # see if we have a template override
388         if template_override is not None:
389             self.template = template_override
391         # see if we were passed in a message
392         if ok_message:
393             self.ok_message.append(ok_message)
394         if error_message:
395             self.error_message.append(error_message)
397     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
398         ''' Serve the file from the content property of the designated item.
399         '''
400         m = dre.match(str(designator))
401         if not m:
402             raise NotFound, str(designator)
403         classname, nodeid = m.group(1), m.group(2)
404         if classname != 'file':
405             raise NotFound, designator
407         # we just want to serve up the file named
408         file = self.db.file
409         self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
410         self.write(file.get(nodeid, 'content'))
412     def serve_static_file(self, file):
413         # we just want to serve up the file named
414         mt = mimetypes.guess_type(str(file))[0]
415         self.additional_headers['Content-Type'] = mt
416         self.write(open(os.path.join(self.instance.config.TEMPLATES,
417             file)).read())
419     def renderContext(self):
420         ''' Return a PageTemplate for the named page
421         '''
422         name = self.classname
423         extension = self.template
424         pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
426         # catch errors so we can handle PT rendering errors more nicely
427         args = {
428             'ok_message': self.ok_message,
429             'error_message': self.error_message
430         }
431         try:
432             # let the template render figure stuff out
433             return pt.render(self, None, None, **args)
434         except NoTemplate, message:
435             return '<strong>%s</strong>'%message
436         except:
437             # everything else
438             return cgitb.pt_html()
440     # these are the actions that are available
441     actions = (
442         ('edit',     'editItemAction'),
443         ('editCSV',  'editCSVAction'),
444         ('new',      'newItemAction'),
445         ('register', 'registerAction'),
446         ('login',    'loginAction'),
447         ('logout',   'logout_action'),
448         ('search',   'searchAction'),
449         ('retire',   'retireAction'),
450         ('show',     'showAction'),
451     )
452     def handle_action(self):
453         ''' Determine whether there should be an _action called.
455             The action is defined by the form variable :action which
456             identifies the method on this object to call. The four basic
457             actions are defined in the "actions" sequence on this class:
458              "edit"      -> self.editItemAction
459              "new"       -> self.newItemAction
460              "register"  -> self.registerAction
461              "login"     -> self.loginAction
462              "logout"    -> self.logout_action
463              "search"    -> self.searchAction
464              "retire"    -> self.retireAction
465         '''
466         if not self.form.has_key(':action'):
467             return None
468         try:
469             # get the action, validate it
470             action = self.form[':action'].value
471             for name, method in self.actions:
472                 if name == action:
473                     break
474             else:
475                 raise ValueError, 'No such action "%s"'%action
477             # call the mapped action
478             getattr(self, method)()
479         except Redirect:
480             raise
481         except Unauthorised:
482             raise
483         except:
484             self.db.rollback()
485             s = StringIO.StringIO()
486             traceback.print_exc(None, s)
487             self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
489     def write(self, content):
490         if not self.headers_done:
491             self.header()
492         self.request.wfile.write(content)
494     def header(self, headers=None, response=None):
495         '''Put up the appropriate header.
496         '''
497         if headers is None:
498             headers = {'Content-Type':'text/html'}
499         if response is None:
500             response = self.response_code
502         # update with additional info
503         headers.update(self.additional_headers)
505         if not headers.has_key('Content-Type'):
506             headers['Content-Type'] = 'text/html'
507         self.request.send_response(response)
508         for entry in headers.items():
509             self.request.send_header(*entry)
510         self.request.end_headers()
511         self.headers_done = 1
512         if self.debug:
513             self.headers_sent = headers
515     def set_cookie(self, user):
516         ''' Set up a session cookie for the user and store away the user's
517             login info against the session.
518         '''
519         # TODO generate a much, much stronger session key ;)
520         self.session = binascii.b2a_base64(repr(random.random())).strip()
522         # clean up the base64
523         if self.session[-1] == '=':
524             if self.session[-2] == '=':
525                 self.session = self.session[:-2]
526             else:
527                 self.session = self.session[:-1]
529         # insert the session in the sessiondb
530         self.db.sessions.set(self.session, user=user, last_use=time.time())
532         # and commit immediately
533         self.db.sessions.commit()
535         # expire us in a long, long time
536         expire = Cookie._getdate(86400*365)
538         # generate the cookie path - make sure it has a trailing '/'
539         self.additional_headers['Set-Cookie'] = \
540           '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
541             expire, self.cookie_path)
543     def make_user_anonymous(self):
544         ''' Make us anonymous
546             This method used to handle non-existence of the 'anonymous'
547             user, but that user is mandatory now.
548         '''
549         self.userid = self.db.user.lookup('anonymous')
550         self.user = 'anonymous'
552     def opendb(self, user):
553         ''' Open the database.
554         '''
555         # open the db if the user has changed
556         if not hasattr(self, 'db') or user != self.db.journaltag:
557             if hasattr(self, 'db'):
558                 self.db.close()
559             self.db = self.instance.open(user)
561     #
562     # Actions
563     #
564     def loginAction(self):
565         ''' Attempt to log a user in.
567             Sets up a session for the user which contains the login
568             credentials.
569         '''
570         # we need the username at a minimum
571         if not self.form.has_key('__login_name'):
572             self.error_message.append(_('Username required'))
573             return
575         # get the login info
576         self.user = self.form['__login_name'].value
577         if self.form.has_key('__login_password'):
578             password = self.form['__login_password'].value
579         else:
580             password = ''
582         # make sure the user exists
583         try:
584             self.userid = self.db.user.lookup(self.user)
585         except KeyError:
586             name = self.user
587             self.error_message.append(_('No such user "%(name)s"')%locals())
588             self.make_user_anonymous()
589             return
591         # verify the password
592         if not self.verifyPassword(self.userid, password):
593             self.make_user_anonymous()
594             self.error_message.append(_('Incorrect password'))
595             return
597         # make sure we're allowed to be here
598         if not self.loginPermission():
599             self.make_user_anonymous()
600             self.error_message.append(_("You do not have permission to login"))
601             return
603         # now we're OK, re-open the database for real, using the user
604         self.opendb(self.user)
606         # set the session cookie
607         self.set_cookie(self.user)
609     def verifyPassword(self, userid, password):
610         ''' Verify the password that the user has supplied
611         '''
612         stored = self.db.user.get(self.userid, 'password')
613         if password == stored:
614             return 1
615         if not password and not stored:
616             return 1
617         return 0
619     def loginPermission(self):
620         ''' Determine whether the user has permission to log in.
622             Base behaviour is to check the user has "Web Access".
623         ''' 
624         if not self.db.security.hasPermission('Web Access', self.userid):
625             return 0
626         return 1
628     def logout_action(self):
629         ''' Make us really anonymous - nuke the cookie too
630         '''
631         # log us out
632         self.make_user_anonymous()
634         # construct the logout cookie
635         now = Cookie._getdate()
636         self.additional_headers['Set-Cookie'] = \
637            '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.cookie_name,
638             now, self.cookie_path)
640         # Let the user know what's going on
641         self.ok_message.append(_('You are logged out'))
643     def registerAction(self):
644         '''Attempt to create a new user based on the contents of the form
645         and then set the cookie.
647         return 1 on successful login
648         '''
649         # create the new user
650         cl = self.db.user
652         # parse the props from the form
653         try:
654             props = self.parsePropsFromForm()
655         except (ValueError, KeyError), message:
656             self.error_message.append(_('Error: ') + str(message))
657             return
659         # make sure we're allowed to register
660         if not self.registerPermission(props):
661             raise Unauthorised, _("You do not have permission to register")
663         # re-open the database as "admin"
664         if self.user != 'admin':
665             self.opendb('admin')
666             
667         # create the new user
668         cl = self.db.user
669         try:
670             props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
671             self.userid = cl.create(**props['user'])
672             self.db.commit()
673         except (ValueError, KeyError), message:
674             self.error_message.append(message)
675             return
677         # log the new user in
678         self.user = cl.get(self.userid, 'username')
679         # re-open the database for real, using the user
680         self.opendb(self.user)
682         # if we have a session, update it
683         if hasattr(self, 'session'):
684             self.db.sessions.set(self.session, user=self.user,
685                 last_use=time.time())
686         else:
687             # new session cookie
688             self.set_cookie(self.user)
690         # nice message
691         message = _('You are now registered, welcome!')
693         # redirect to the item's edit page
694         raise Redirect, '%s%s%s?+ok_message=%s'%(
695             self.base, self.classname, self.userid,  urllib.quote(message))
697     def registerPermission(self, props):
698         ''' Determine whether the user has permission to register
700             Base behaviour is to check the user has "Web Registration".
701         '''
702         # registration isn't allowed to supply roles
703         if props.has_key('roles'):
704             return 0
705         if self.db.security.hasPermission('Web Registration', self.userid):
706             return 1
707         return 0
709     def editItemAction(self):
710         ''' Perform an edit of an item in the database.
712            See parsePropsFromForm and _editnodes for special variables
713         '''
714         # parse the props from the form
715         if 1:
716 #        try:
717             props, links = self.parsePropsFromForm()
718 #        except (ValueError, KeyError), message:
719 #            self.error_message.append(_('Error: ') + str(message))
720 #            return
722         # handle the props
723         if 1:
724 #        try:
725             message = self._editnodes(props, links)
726 #        except (ValueError, KeyError, IndexError), message:
727 #            self.error_message.append(_('Error: ') + str(message))
728 #            return
730         # commit now that all the tricky stuff is done
731         self.db.commit()
733         # redirect to the item's edit page
734         raise Redirect, '%s%s%s?@ok_message=%s'%(self.base, self.classname,
735             self.nodeid,  urllib.quote(message))
737     def editItemPermission(self, props):
738         ''' Determine whether the user has permission to edit this item.
740             Base behaviour is to check the user can edit this class. If we're
741             editing the "user" class, users are allowed to edit their own
742             details. Unless it's the "roles" property, which requires the
743             special Permission "Web Roles".
744         '''
745         # if this is a user node and the user is editing their own node, then
746         # we're OK
747         has = self.db.security.hasPermission
748         if self.classname == 'user':
749             # reject if someone's trying to edit "roles" and doesn't have the
750             # right permission.
751             if props.has_key('roles') and not has('Web Roles', self.userid,
752                     'user'):
753                 return 0
754             # if the item being edited is the current user, we're ok
755             if self.nodeid == self.userid:
756                 return 1
757         if self.db.security.hasPermission('Edit', self.userid, self.classname):
758             return 1
759         return 0
761     def newItemAction(self):
762         ''' Add a new item to the database.
764             This follows the same form as the editItemAction, with the same
765             special form values.
766         '''
767         # parse the props from the form
768 # XXX reinstate exception handling
769 #        try:
770         if 1:
771             props, links = self.parsePropsFromForm()
772 #        except (ValueError, KeyError), message:
773 #            self.error_message.append(_('Error: ') + str(message))
774 #            return
776         # handle the props - edit or create
777 # XXX reinstate exception handling
778 #        try:
779         if 1:
780             # create the context here
781 #            cn = self.classname
782 #            nid = self._createnode(cn, props[(cn, None)])
783 #            del props[(cn, None)]
785             # when it hits the None element, it'll set self.nodeid
786             messages = self._editnodes(props, links) #, {(cn, None): nid})
788 #        except (ValueError, KeyError, IndexError), message:
789 #            # these errors might just be indicative of user dumbness
790 #            self.error_message.append(_('Error: ') + str(message))
791 #            return
793         # commit now that all the tricky stuff is done
794         self.db.commit()
796         # redirect to the new item's page
797         raise Redirect, '%s%s%s?@ok_message=%s'%(self.base, self.classname,
798             self.nodeid, urllib.quote(messages))
800     def newItemPermission(self, props):
801         ''' Determine whether the user has permission to create (edit) this
802             item.
804             Base behaviour is to check the user can edit this class. No
805             additional property checks are made. Additionally, new user items
806             may be created if the user has the "Web Registration" Permission.
807         '''
808         has = self.db.security.hasPermission
809         if self.classname == 'user' and has('Web Registration', self.userid,
810                 'user'):
811             return 1
812         if has('Edit', self.userid, self.classname):
813             return 1
814         return 0
817     #
818     #  Utility methods for editing
819     #
820     def _editnodes(self, all_props, all_links, newids=None):
821         ''' Use the props in all_props to perform edit and creation, then
822             use the link specs in all_links to do linking.
823         '''
824         # figure dependencies and re-work links
825         deps = {}
826         links = {}
827         for cn, nodeid, propname, vlist in all_links:
828             for value in vlist:
829                 deps.setdefault((cn, nodeid), []).append(value)
830                 links.setdefault(value, []).append((cn, nodeid, propname))
832         # figure chained dependencies ordering
833         order = []
834         done = {}
835         # loop detection
836         change = 0
837         while len(all_props) != len(done):
838             for needed in all_props.keys():
839                 if done.has_key(needed):
840                     continue
841                 tlist = deps.get(needed, [])
842                 for target in tlist:
843                     if not done.has_key(target):
844                         break
845                 else:
846                     done[needed] = 1
847                     order.append(needed)
848                     change = 1
849             if not change:
850                 raise ValueError, 'linking must not loop!'
852         # now, edit / create
853         m = []
854         for needed in order:
855             props = all_props[needed]
856             cn, nodeid = needed
858             if nodeid is not None and int(nodeid) > 0:
859                 # make changes to the node
860                 props = self._changenode(cn, nodeid, props)
862                 # and some nice feedback for the user
863                 if props:
864                     info = ', '.join(props.keys())
865                     m.append('%s %s %s edited ok'%(cn, nodeid, info))
866                 else:
867                     m.append('%s %s - nothing changed'%(cn, nodeid))
868             else:
869                 assert props
871                 # make a new node
872                 newid = self._createnode(cn, props)
873                 if nodeid is None:
874                     self.nodeid = newid
875                 nodeid = newid
877                 # and some nice feedback for the user
878                 m.append('%s %s created'%(cn, newid))
880             # fill in new ids in links
881             if links.has_key(needed):
882                 for linkcn, linkid, linkprop in links[needed]:
883                     props = all_props[(linkcn, linkid)]
884                     cl = self.db.classes[linkcn]
885                     propdef = cl.getprops()[linkprop]
886                     if not props.has_key(linkprop):
887                         if linkid is None or linkid.startswith('-'):
888                             # linking to a new item
889                             if isinstance(propdef, hyperdb.Multilink):
890                                 props[linkprop] = [newid]
891                             else:
892                                 props[linkprop] = newid
893                         else:
894                             # linking to an existing item
895                             if isinstance(propdef, hyperdb.Multilink):
896                                 existing = cl.get(linkid, linkprop)[:]
897                                 existing.append(nodeid)
898                                 props[linkprop] = existing
899                             else:
900                                 props[linkprop] = newid
902         return '<br>'.join(m)
904     def _changenode(self, cn, nodeid, props):
905         ''' change the node based on the contents of the form
906         '''
907         # check for permission
908         if not self.editItemPermission(props):
909             raise PermissionError, 'You do not have permission to edit %s'%cn
911         # make the changes
912         cl = self.db.classes[cn]
913         return cl.set(nodeid, **props)
915     def _createnode(self, cn, props):
916         ''' create a node based on the contents of the form
917         '''
918         # check for permission
919         if not self.newItemPermission(props):
920             raise PermissionError, 'You do not have permission to create %s'%cn
922         # create the node and return its id
923         cl = self.db.classes[cn]
924         return cl.create(**props)
926     # 
927     # More actions
928     #
929     def editCSVAction(self):
930         ''' Performs an edit of all of a class' items in one go.
932             The "rows" CGI var defines the CSV-formatted entries for the
933             class. New nodes are identified by the ID 'X' (or any other
934             non-existent ID) and removed lines are retired.
935         '''
936         # this is per-class only
937         if not self.editCSVPermission():
938             self.error_message.append(
939                 _('You do not have permission to edit %s' %self.classname))
941         # get the CSV module
942         try:
943             import csv
944         except ImportError:
945             self.error_message.append(_(
946                 'Sorry, you need the csv module to use this function.<br>\n'
947                 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
948             return
950         cl = self.db.classes[self.classname]
951         idlessprops = cl.getprops(protected=0).keys()
952         idlessprops.sort()
953         props = ['id'] + idlessprops
955         # do the edit
956         rows = self.form['rows'].value.splitlines()
957         p = csv.parser()
958         found = {}
959         line = 0
960         for row in rows[1:]:
961             line += 1
962             values = p.parse(row)
963             # not a complete row, keep going
964             if not values: continue
966             # skip property names header
967             if values == props:
968                 continue
970             # extract the nodeid
971             nodeid, values = values[0], values[1:]
972             found[nodeid] = 1
974             # confirm correct weight
975             if len(idlessprops) != len(values):
976                 self.error_message.append(
977                     _('Not enough values on line %(line)s')%{'line':line})
978                 return
980             # extract the new values
981             d = {}
982             for name, value in zip(idlessprops, values):
983                 value = value.strip()
984                 # only add the property if it has a value
985                 if value:
986                     # if it's a multilink, split it
987                     if isinstance(cl.properties[name], hyperdb.Multilink):
988                         value = value.split(':')
989                     d[name] = value
991             # perform the edit
992             if cl.hasnode(nodeid):
993                 # edit existing
994                 cl.set(nodeid, **d)
995             else:
996                 # new node
997                 found[cl.create(**d)] = 1
999         # retire the removed entries
1000         for nodeid in cl.list():
1001             if not found.has_key(nodeid):
1002                 cl.retire(nodeid)
1004         # all OK
1005         self.db.commit()
1007         self.ok_message.append(_('Items edited OK'))
1009     def editCSVPermission(self):
1010         ''' Determine whether the user has permission to edit this class.
1012             Base behaviour is to check the user can edit this class.
1013         ''' 
1014         if not self.db.security.hasPermission('Edit', self.userid,
1015                 self.classname):
1016             return 0
1017         return 1
1019     def searchAction(self):
1020         ''' Mangle some of the form variables.
1022             Set the form ":filter" variable based on the values of the
1023             filter variables - if they're set to anything other than
1024             "dontcare" then add them to :filter.
1026             Also handle the ":queryname" variable and save off the query to
1027             the user's query list.
1028         '''
1029         # generic edit is per-class only
1030         if not self.searchPermission():
1031             self.error_message.append(
1032                 _('You do not have permission to search %s' %self.classname))
1034         # add a faked :filter form variable for each filtering prop
1035 # XXX migrate to new : @ + 
1036         props = self.db.classes[self.classname].getprops()
1037         for key in self.form.keys():
1038             if not props.has_key(key): continue
1039             if isinstance(self.form[key], type([])):
1040                 # search for at least one entry which is not empty
1041                 for minifield in self.form[key]:
1042                     if minifield.value:
1043                         break
1044                 else:
1045                     continue
1046             else:
1047                 if not self.form[key].value: continue
1048             self.form.value.append(cgi.MiniFieldStorage(':filter', key))
1050         # handle saving the query params
1051         if self.form.has_key(':queryname'):
1052             queryname = self.form[':queryname'].value.strip()
1053             if queryname:
1054                 # parse the environment and figure what the query _is_
1055                 req = HTMLRequest(self)
1056                 url = req.indexargs_href('', {})
1058                 # handle editing an existing query
1059                 try:
1060                     qid = self.db.query.lookup(queryname)
1061                     self.db.query.set(qid, klass=self.classname, url=url)
1062                 except KeyError:
1063                     # create a query
1064                     qid = self.db.query.create(name=queryname,
1065                         klass=self.classname, url=url)
1067                     # and add it to the user's query multilink
1068                     queries = self.db.user.get(self.userid, 'queries')
1069                     queries.append(qid)
1070                     self.db.user.set(self.userid, queries=queries)
1072                 # commit the query change to the database
1073                 self.db.commit()
1075     def searchPermission(self):
1076         ''' Determine whether the user has permission to search this class.
1078             Base behaviour is to check the user can view this class.
1079         ''' 
1080         if not self.db.security.hasPermission('View', self.userid,
1081                 self.classname):
1082             return 0
1083         return 1
1086     def retireAction(self):
1087         ''' Retire the context item.
1088         '''
1089         # if we want to view the index template now, then unset the nodeid
1090         # context info (a special-case for retire actions on the index page)
1091         nodeid = self.nodeid
1092         if self.template == 'index':
1093             self.nodeid = None
1095         # generic edit is per-class only
1096         if not self.retirePermission():
1097             self.error_message.append(
1098                 _('You do not have permission to retire %s' %self.classname))
1099             return
1101         # make sure we don't try to retire admin or anonymous
1102         if self.classname == 'user' and \
1103                 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1104             self.error_message.append(
1105                 _('You may not retire the admin or anonymous user'))
1106             return
1108         # do the retire
1109         self.db.getclass(self.classname).retire(nodeid)
1110         self.db.commit()
1112         self.ok_message.append(
1113             _('%(classname)s %(itemid)s has been retired')%{
1114                 'classname': self.classname.capitalize(), 'itemid': nodeid})
1116     def retirePermission(self):
1117         ''' Determine whether the user has permission to retire this class.
1119             Base behaviour is to check the user can edit this class.
1120         ''' 
1121         if not self.db.security.hasPermission('Edit', self.userid,
1122                 self.classname):
1123             return 0
1124         return 1
1127     def showAction(self):
1128         ''' Show a node
1129         '''
1130 # XXX allow : @ +
1131         t = self.form[':type'].value
1132         n = self.form[':number'].value
1133         url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1134         raise Redirect, url
1136     def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1137         ''' Pull properties out of the form.
1139             In the following, <bracketed> values are variable, ":" may be
1140             one of ":" or "@", and other text "required" is fixed.
1142             Properties are specified as form variables:
1144              <propname>
1145               - property on the current context item
1147              <designator>:<propname>
1148               - property on the indicated item
1150              <classname>-<N>:<propname>
1151               - property on the Nth new item of classname
1153             Once we have determined the "propname", we check to see if it
1154             is one of the special form values:
1156              :required
1157               The named property values must be supplied or a ValueError
1158               will be raised.
1160              :remove:<propname>=id(s)
1161               The ids will be removed from the multilink property.
1163              :add:<propname>=id(s)
1164               The ids will be added to the multilink property.
1166              :link:<propname>=<designator>
1167               Used to add a link to new items created during edit.
1168               These are collected up and returned in all_links. This will
1169               result in an additional linking operation (either Link set or
1170               Multilink append) after the edit/create is done using
1171               all_props in _editnodes. The <propname> on the current item
1172               will be set/appended the id of the newly created item of
1173               class <designator> (where <designator> must be
1174               <classname>-<N>).
1176             Any of the form variables may be prefixed with a classname or
1177             designator.
1179             The return from this method is a dict of 
1180                 (classname, id): properties
1181             ... this dict _always_ has an entry for the current context,
1182             even if it's empty (ie. a submission for an existing issue that
1183             doesn't result in any changes would return {('issue','123'): {}})
1184             The id may be None, which indicates that an item should be
1185             created.
1187             If a String property's form value is a file upload, then we
1188             try to set additional properties "filename" and "type" (if
1189             they are valid for the class).
1191             Two special form values are supported for backwards
1192             compatibility:
1193              :note - create a message (with content, author and date), link
1194                      to the context item. This is ALWAYS desginated "msg-1".
1195              :file - create a file, attach to the current item and any
1196                      message created by :note. This is ALWAYS designated
1197                      "file-1".
1199             We also check that FileClass items have a "content" property with
1200             actual content, otherwise we remove them from all_props before
1201             returning.
1202         '''
1203         # some very useful variables
1204         db = self.db
1205         form = self.form
1207         if not hasattr(self, 'FV_SPECIAL'):
1208             # generate the regexp for handling special form values
1209             classes = '|'.join(db.classes.keys())
1210             # specials for parsePropsFromForm
1211             # handle the various forms (see unit tests)
1212             self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1213             self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1215         # these indicate the default class / item
1216         default_cn = self.classname
1217         default_cl = self.db.classes[default_cn]
1218         default_nodeid = self.nodeid
1220         # we'll store info about the individual class/item edit in these
1221         all_required = {}       # one entry per class/item
1222         all_props = {}          # one entry per class/item
1223         all_propdef = {}        # note - only one entry per class
1224         all_links = []          # as many as are required
1226         # we should always return something, even empty, for the context
1227         all_props[(default_cn, default_nodeid)] = {}
1229         keys = form.keys()
1230         timezone = db.getUserTimezone()
1232         # sentinels for the :note and :file props
1233         have_note = have_file = 0
1235         # extract the usable form labels from the form
1236         matches = []
1237         for key in keys:
1238             m = self.FV_SPECIAL.match(key)
1239             if m:
1240                 matches.append((key, m.groupdict()))
1242         # now handle the matches
1243         for key, d in matches:
1244             if d['classname']:
1245                 # we got a designator
1246                 cn = d['classname']
1247                 cl = self.db.classes[cn]
1248                 nodeid = d['id']
1249                 propname = d['propname']
1250             elif d['note']:
1251                 # the special note field
1252                 cn = 'msg'
1253                 cl = self.db.classes[cn]
1254                 nodeid = '-1'
1255                 propname = 'content'
1256                 all_links.append((default_cn, default_nodeid, 'messages',
1257                     [('msg', '-1')]))
1258                 have_note = 1
1259             elif d['file']:
1260                 # the special file field
1261                 cn = 'file'
1262                 cl = self.db.classes[cn]
1263                 nodeid = '-1'
1264                 propname = 'content'
1265                 all_links.append((default_cn, default_nodeid, 'files',
1266                     [('file', '-1')]))
1267                 have_file = 1
1268             else:
1269                 # default
1270                 cn = default_cn
1271                 cl = default_cl
1272                 nodeid = default_nodeid
1273                 propname = d['propname']
1275             # the thing this value relates to is...
1276             this = (cn, nodeid)
1278             # get more info about the class, and the current set of
1279             # form props for it
1280             if not all_propdef.has_key(cn):
1281                 all_propdef[cn] = cl.getprops()
1282             propdef = all_propdef[cn]
1283             if not all_props.has_key(this):
1284                 all_props[this] = {}
1285             props = all_props[this]
1287             # is this a link command?
1288             if d['link']:
1289                 value = []
1290                 for entry in extractFormList(form[key]):
1291                     m = self.FV_DESIGNATOR.match(entry)
1292                     if not m:
1293                         raise ValueError, \
1294                             'link "%s" value "%s" not a designator'%(key, entry)
1295                     value.append((m.group(1), m.group(2)))
1297                 # make sure the link property is valid
1298                 if (not isinstance(propdef, hyperdb.Multilink) and
1299                         not isinstance(propdef, hyperdb.Link)):
1300                     raise ValueError, '%s %s is not a link or '\
1301                         'multilink property'%(cn, propname)
1303                 all_links.append((cn, nodeid, propname, value))
1304                 continue
1306             # detect the special ":required" variable
1307             if d['required']:
1308                 all_required[this] = extractFormList(form[key])
1309                 continue
1311             # get the required values list
1312             if not all_required.has_key(this):
1313                 all_required[this] = []
1314             required = all_required[this]
1316             # see if we're performing a special multilink action
1317             mlaction = 'set'
1318             if d['remove']:
1319                 mlaction = 'remove'
1320             elif d['add']:
1321                 mlaction = 'add'
1323             # does the property exist?
1324             if not propdef.has_key(propname):
1325                 if mlaction != 'set':
1326                     raise ValueError, 'You have submitted a %s action for'\
1327                         ' the property "%s" which doesn\'t exist'%(mlaction,
1328                         propname)
1329                 # the form element is probably just something we don't care
1330                 # about - ignore it
1331                 continue
1332             proptype = propdef[propname]
1334             # Get the form value. This value may be a MiniFieldStorage or a list
1335             # of MiniFieldStorages.
1336             value = form[key]
1338             # handle unpacking of the MiniFieldStorage / list form value
1339             if isinstance(proptype, hyperdb.Multilink):
1340                 value = extractFormList(value)
1341             else:
1342                 # multiple values are not OK
1343                 if isinstance(value, type([])):
1344                     raise ValueError, 'You have submitted more than one value'\
1345                         ' for the %s property'%propname
1346                 # value might be a file upload...
1347                 if not hasattr(value, 'filename') or value.filename is None:
1348                     # nope, pull out the value and strip it
1349                     value = value.value.strip()
1351             # now that we have the props field, we need a teensy little
1352             # extra bit of help for the old :note field...
1353             if d['note'] and value:
1354                 props['author'] = self.db.getuid()
1355                 props['date'] = date.Date()
1357             # handle by type now
1358             if isinstance(proptype, hyperdb.Password):
1359                 if not value:
1360                     # ignore empty password values
1361                     continue
1362                 for key, d in matches:
1363                     if d['confirm'] and d['propname'] == propname:
1364                         confirm = form[key]
1365                         break
1366                 else:
1367                     raise ValueError, 'Password and confirmation text do '\
1368                         'not match'
1369                 if isinstance(confirm, type([])):
1370                     raise ValueError, 'You have submitted more than one value'\
1371                         ' for the %s property'%propname
1372                 if value != confirm.value:
1373                     raise ValueError, 'Password and confirmation text do '\
1374                         'not match'
1375                 value = password.Password(value)
1377             elif isinstance(proptype, hyperdb.Link):
1378                 # see if it's the "no selection" choice
1379                 if value == '-1' or not value:
1380                     # if we're creating, just don't include this property
1381                     if not nodeid or nodeid.startswith('-'):
1382                         continue
1383                     value = None
1384                 else:
1385                     # handle key values
1386                     link = proptype.classname
1387                     if not num_re.match(value):
1388                         try:
1389                             value = db.classes[link].lookup(value)
1390                         except KeyError:
1391                             raise ValueError, _('property "%(propname)s": '
1392                                 '%(value)s not a %(classname)s')%{
1393                                 'propname': propname, 'value': value,
1394                                 'classname': link}
1395                         except TypeError, message:
1396                             raise ValueError, _('you may only enter ID values '
1397                                 'for property "%(propname)s": %(message)s')%{
1398                                 'propname': propname, 'message': message}
1399             elif isinstance(proptype, hyperdb.Multilink):
1400                 # perform link class key value lookup if necessary
1401                 link = proptype.classname
1402                 link_cl = db.classes[link]
1403                 l = []
1404                 for entry in value:
1405                     if not entry: continue
1406                     if not num_re.match(entry):
1407                         try:
1408                             entry = link_cl.lookup(entry)
1409                         except KeyError:
1410                             raise ValueError, _('property "%(propname)s": '
1411                                 '"%(value)s" not an entry of %(classname)s')%{
1412                                 'propname': propname, 'value': entry,
1413                                 'classname': link}
1414                         except TypeError, message:
1415                             raise ValueError, _('you may only enter ID values '
1416                                 'for property "%(propname)s": %(message)s')%{
1417                                 'propname': propname, 'message': message}
1418                     l.append(entry)
1419                 l.sort()
1421                 # now use that list of ids to modify the multilink
1422                 if mlaction == 'set':
1423                     value = l
1424                 else:
1425                     # we're modifying the list - get the current list of ids
1426                     if props.has_key(propname):
1427                         existing = props[propname]
1428                     elif nodeid and not nodeid.startswith('-'):
1429                         existing = cl.get(nodeid, propname, [])
1430                     else:
1431                         existing = []
1433                     # now either remove or add
1434                     if mlaction == 'remove':
1435                         # remove - handle situation where the id isn't in
1436                         # the list
1437                         for entry in l:
1438                             try:
1439                                 existing.remove(entry)
1440                             except ValueError:
1441                                 raise ValueError, _('property "%(propname)s": '
1442                                     '"%(value)s" not currently in list')%{
1443                                     'propname': propname, 'value': entry}
1444                     else:
1445                         # add - easy, just don't dupe
1446                         for entry in l:
1447                             if entry not in existing:
1448                                 existing.append(entry)
1449                     value = existing
1450                     value.sort()
1452             elif value == '':
1453                 # if we're creating, just don't include this property
1454                 if not nodeid or nodeid.startswith('-'):
1455                     continue
1456                 # other types should be None'd if there's no value
1457                 value = None
1458             else:
1459                 if isinstance(proptype, hyperdb.String):
1460                     if (hasattr(value, 'filename') and
1461                             value.filename is not None):
1462                         # skip if the upload is empty
1463                         if not value.filename:
1464                             continue
1465                         # this String is actually a _file_
1466                         # try to determine the file content-type
1467                         filename = value.filename.split('\\')[-1]
1468                         if propdef.has_key('name'):
1469                             props['name'] = filename
1470                         # use this info as the type/filename properties
1471                         if propdef.has_key('type'):
1472                             props['type'] = mimetypes.guess_type(filename)[0]
1473                             if not props['type']:
1474                                 props['type'] = "application/octet-stream"
1475                         # finally, read the content
1476                         value = value.value
1477                     else:
1478                         # normal String fix the CRLF/CR -> LF stuff
1479                         value = fixNewlines(value)
1481                 elif isinstance(proptype, hyperdb.Date):
1482                     value = date.Date(value, offset=timezone)
1483                 elif isinstance(proptype, hyperdb.Interval):
1484                     value = date.Interval(value)
1485                 elif isinstance(proptype, hyperdb.Boolean):
1486                     value = value.lower() in ('yes', 'true', 'on', '1')
1487                 elif isinstance(proptype, hyperdb.Number):
1488                     value = float(value)
1490             # get the old value
1491             if nodeid and not nodeid.startswith('-'):
1492                 try:
1493                     existing = cl.get(nodeid, propname)
1494                 except KeyError:
1495                     # this might be a new property for which there is
1496                     # no existing value
1497                     if not propdef.has_key(propname):
1498                         raise
1500                 # make sure the existing multilink is sorted
1501                 if isinstance(proptype, hyperdb.Multilink):
1502                     existing.sort()
1504                 # "missing" existing values may not be None
1505                 if not existing:
1506                     if isinstance(proptype, hyperdb.String) and not existing:
1507                         # some backends store "missing" Strings as empty strings
1508                         existing = None
1509                     elif isinstance(proptype, hyperdb.Number) and not existing:
1510                         # some backends store "missing" Numbers as 0 :(
1511                         existing = 0
1512                     elif isinstance(proptype, hyperdb.Boolean) and not existing:
1513                         # likewise Booleans
1514                         existing = 0
1516                 # if changed, set it
1517                 if value != existing:
1518                     props[propname] = value
1519             else:
1520                 # don't bother setting empty/unset values
1521                 if value is None:
1522                     continue
1523                 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1524                     continue
1525                 elif isinstance(proptype, hyperdb.String) and value == '':
1526                     continue
1528                 props[propname] = value
1530             # register this as received if required?
1531             if propname in required and value is not None:
1532                 required.remove(propname)
1534         # check to see if we need to specially link a file to the note
1535         if have_note and have_file:
1536             all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1538         # see if all the required properties have been supplied
1539         s = []
1540         for thing, required in all_required.items():
1541             if not required:
1542                 continue
1543             if len(required) > 1:
1544                 p = 'properties'
1545             else:
1546                 p = 'property'
1547             s.append('Required %s %s %s not supplied'%(thing[0], p,
1548                 ', '.join(required)))
1549         if s:
1550             raise ValueError, '\n'.join(s)
1552         # check that FileClass entries have a "content" property with
1553         # content, otherwise remove them
1554         for (cn, id), props in all_props.items():
1555             cl = self.db.classes[cn]
1556             if not isinstance(cl, hyperdb.FileClass):
1557                 continue
1558             if not props.get('content', ''):
1559                 del all_props[(cn, id)]
1561         # clean up the links, removing ones that aren't possible
1562         l = []
1563         for entry in all_links:
1564             (cn, nodeid, propname, destlist) = entry
1565             source = (cn, nodeid)
1566             if not all_props.has_key(source) or not all_props[source]:
1567                 # nothing to create - don't try to link
1568                 continue
1569                 # nothing to create - don't try to link
1570                 continue
1571             for dest in destlist[:]:
1572                 if not all_props.has_key(dest) or not all_props[dest]:
1573                     destlist.remove(dest)
1574             l.append(entry)
1576         return all_props, l
1578 def fixNewlines(text):
1579     ''' Homogenise line endings.
1581         Different web clients send different line ending values, but
1582         other systems (eg. email) don't necessarily handle those line
1583         endings. Our solution is to convert all line endings to LF.
1584     '''
1585     text = text.replace('\r\n', '\n')
1586     return text.replace('\r', '\n')
1588 def extractFormList(value):
1589     ''' Extract a list of values from the form value.
1591         It may be one of:
1592          [MiniFieldStorage, MiniFieldStorage, ...]
1593          MiniFieldStorage('value,value,...')
1594          MiniFieldStorage('value')
1595     '''
1596     # multiple values are OK
1597     if isinstance(value, type([])):
1598         # it's a list of MiniFieldStorages
1599         value = [i.value.strip() for i in value]
1600     else:
1601         # it's a MiniFieldStorage, but may be a comma-separated list
1602         # of values
1603         value = [i.strip() for i in value.value.split(',')]
1605     # filter out the empty bits
1606     return filter(None, value)