Code

03eaf1102c14436c9cc85723a02b60bf95d037b9
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.54 2002-10-17 06:11:25 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.
81     '''
83     def __init__(self, instance, request, env, form=None):
84         hyperdb.traceMark()
85         self.instance = instance
86         self.request = request
87         self.env = env
89         # save off the path
90         self.path = env['PATH_INFO']
92         # this is the base URL for this instance
93         self.base = self.instance.config.TRACKER_WEB
95         # see if we need to re-parse the environment for the form (eg Zope)
96         if form is None:
97             self.form = cgi.FieldStorage(environ=env)
98         else:
99             self.form = form
101         # turn debugging on/off
102         try:
103             self.debug = int(env.get("ROUNDUP_DEBUG", 0))
104         except ValueError:
105             # someone gave us a non-int debug level, turn it off
106             self.debug = 0
108         # flag to indicate that the HTTP headers have been sent
109         self.headers_done = 0
111         # additional headers to send with the request - must be registered
112         # before the first write
113         self.additional_headers = {}
114         self.response_code = 200
116     def main(self):
117         ''' Wrap the real main in a try/finally so we always close off the db.
118         '''
119         try:
120             self.inner_main()
121         finally:
122             if hasattr(self, 'db'):
123                 self.db.close()
125     def inner_main(self):
126         ''' Process a request.
128             The most common requests are handled like so:
129             1. figure out who we are, defaulting to the "anonymous" user
130                see determine_user
131             2. figure out what the request is for - the context
132                see determine_context
133             3. handle any requested action (item edit, search, ...)
134                see handle_action
135             4. render a template, resulting in HTML output
137             In some situations, exceptions occur:
138             - HTTP Redirect  (generally raised by an action)
139             - SendFile       (generally raised by determine_context)
140               serve up a FileClass "content" property
141             - SendStaticFile (generally raised by determine_context)
142               serve up a file from the tracker "html" directory
143             - Unauthorised   (generally raised by an action)
144               the action is cancelled, the request is rendered and an error
145               message is displayed indicating that permission was not
146               granted for the action to take place
147             - NotFound       (raised wherever it needs to be)
148               percolates up to the CGI interface that called the client
149         '''
150         self.ok_message = []
151         self.error_message = []
152         try:
153             # make sure we're identified (even anonymously)
154             self.determine_user()
155             # figure out the context and desired content template
156             self.determine_context()
157             # possibly handle a form submit action (may change self.classname
158             # and self.template, and may also append error/ok_messages)
159             self.handle_action()
160             # now render the page
162             # we don't want clients caching our dynamic pages
163             self.additional_headers['Cache-Control'] = 'no-cache'
164             self.additional_headers['Pragma'] = 'no-cache'
165             self.additional_headers['Expires'] = 'Thu, 1 Jan 1970 00:00:00 GMT'
167             # render the content
168             self.write(self.renderContext())
169         except Redirect, url:
170             # let's redirect - if the url isn't None, then we need to do
171             # the headers, otherwise the headers have been set before the
172             # exception was raised
173             if url:
174                 self.additional_headers['Location'] = url
175                 self.response_code = 302
176             self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
177         except SendFile, designator:
178             self.serve_file(designator)
179         except SendStaticFile, file:
180             self.serve_static_file(str(file))
181         except Unauthorised, message:
182             self.classname=None
183             self.template=''
184             self.error_message.append(message)
185             self.write(self.renderContext())
186         except NotFound:
187             # pass through
188             raise
189         except:
190             # everything else
191             self.write(cgitb.html())
193     def determine_user(self):
194         ''' Determine who the user is
195         '''
196         # determine the uid to use
197         self.opendb('admin')
199         # make sure we have the session Class
200         sessions = self.db.sessions
202         # age sessions, remove when they haven't been used for a week
203         # TODO: this shouldn't be done every access
204         week = 60*60*24*7
205         now = time.time()
206         for sessid in sessions.list():
207             interval = now - sessions.get(sessid, 'last_use')
208             if interval > week:
209                 sessions.destroy(sessid)
211         # look up the user session cookie
212         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
213         user = 'anonymous'
215         # bump the "revision" of the cookie since the format changed
216         if (cookie.has_key('roundup_user_2') and
217                 cookie['roundup_user_2'].value != 'deleted'):
219             # get the session key from the cookie
220             self.session = cookie['roundup_user_2'].value
221             # get the user from the session
222             try:
223                 # update the lifetime datestamp
224                 sessions.set(self.session, last_use=time.time())
225                 sessions.commit()
226                 user = sessions.get(self.session, 'user')
227             except KeyError:
228                 user = 'anonymous'
230         # sanity check on the user still being valid, getting the userid
231         # at the same time
232         try:
233             self.userid = self.db.user.lookup(user)
234         except (KeyError, TypeError):
235             user = 'anonymous'
237         # make sure the anonymous user is valid if we're using it
238         if user == 'anonymous':
239             self.make_user_anonymous()
240         else:
241             self.user = user
243         # reopen the database as the correct user
244         self.opendb(self.user)
246     def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
247         ''' Determine the context of this page from the URL:
249             The URL path after the instance identifier is examined. The path
250             is generally only one entry long.
252             - if there is no path, then we are in the "home" context.
253             * if the path is "_file", then the additional path entry
254               specifies the filename of a static file we're to serve up
255               from the instance "html" directory. Raises a SendStaticFile
256               exception.
257             - if there is something in the path (eg "issue"), it identifies
258               the tracker class we're to display.
259             - if the path is an item designator (eg "issue123"), then we're
260               to display a specific item.
261             * if the path starts with an item designator and is longer than
262               one entry, then we're assumed to be handling an item of a
263               FileClass, and the extra path information gives the filename
264               that the client is going to label the download with (ie
265               "file123/image.png" is nicer to download than "file123"). This
266               raises a SendFile exception.
268             Both of the "*" types of contexts stop before we bother to
269             determine the template we're going to use. That's because they
270             don't actually use templates.
272             The template used is specified by the :template CGI variable,
273             which defaults to:
275              only classname suplied:          "index"
276              full item designator supplied:   "item"
278             We set:
279              self.classname  - the class to display, can be None
280              self.template   - the template to render the current context with
281              self.nodeid     - the nodeid of the class we're displaying
282         '''
283         # default the optional variables
284         self.classname = None
285         self.nodeid = None
287         # determine the classname and possibly nodeid
288         path = self.path.split('/')
289         if not path or path[0] in ('', 'home', 'index'):
290             if self.form.has_key(':template'):
291                 self.template = self.form[':template'].value
292             else:
293                 self.template = ''
294             return
295         elif path[0] == '_file':
296             raise SendStaticFile, path[1]
297         else:
298             self.classname = path[0]
299             if len(path) > 1:
300                 # send the file identified by the designator in path[0]
301                 raise SendFile, path[0]
303         # see if we got a designator
304         m = dre.match(self.classname)
305         if m:
306             self.classname = m.group(1)
307             self.nodeid = m.group(2)
308             if not self.db.getclass(self.classname).hasnode(self.nodeid):
309                 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
310             # with a designator, we default to item view
311             self.template = 'item'
312         else:
313             # with only a class, we default to index view
314             self.template = 'index'
316         # make sure the classname is valid
317         try:
318             self.db.getclass(self.classname)
319         except KeyError:
320             raise NotFound, self.classname
322         # see if we have a template override
323         if self.form.has_key(':template'):
324             self.template = self.form[':template'].value
326         # see if we were passed in a message
327         if self.form.has_key(':ok_message'):
328             self.ok_message.append(self.form[':ok_message'].value)
329         if self.form.has_key(':error_message'):
330             self.error_message.append(self.form[':error_message'].value)
332     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
333         ''' Serve the file from the content property of the designated item.
334         '''
335         m = dre.match(str(designator))
336         if not m:
337             raise NotFound, str(designator)
338         classname, nodeid = m.group(1), m.group(2)
339         if classname != 'file':
340             raise NotFound, designator
342         # we just want to serve up the file named
343         file = self.db.file
344         self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
345         self.write(file.get(nodeid, 'content'))
347     def serve_static_file(self, file):
348         # we just want to serve up the file named
349         mt = mimetypes.guess_type(str(file))[0]
350         self.additional_headers['Content-Type'] = mt
351         self.write(open(os.path.join(self.instance.config.TEMPLATES,
352             file)).read())
354     def renderContext(self):
355         ''' Return a PageTemplate for the named page
356         '''
357         name = self.classname
358         extension = self.template
359         pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
361         # catch errors so we can handle PT rendering errors more nicely
362         args = {
363             'ok_message': self.ok_message,
364             'error_message': self.error_message
365         }
366         try:
367             # let the template render figure stuff out
368             return pt.render(self, None, None, **args)
369         except NoTemplate, message:
370             return '<strong>%s</strong>'%message
371         except:
372             # everything else
373             return cgitb.pt_html()
375     # these are the actions that are available
376     actions = (
377         ('edit',     'editItemAction'),
378         ('editCSV',  'editCSVAction'),
379         ('new',      'newItemAction'),
380         ('register', 'registerAction'),
381         ('login',    'loginAction'),
382         ('logout',   'logout_action'),
383         ('search',   'searchAction'),
384         ('retire',   'retireAction'),
385     )
386     def handle_action(self):
387         ''' Determine whether there should be an _action called.
389             The action is defined by the form variable :action which
390             identifies the method on this object to call. The four basic
391             actions are defined in the "actions" sequence on this class:
392              "edit"      -> self.editItemAction
393              "new"       -> self.newItemAction
394              "register"  -> self.registerAction
395              "login"     -> self.loginAction
396              "logout"    -> self.logout_action
397              "search"    -> self.searchAction
398              "retire"    -> self.retireAction
399         '''
400         if not self.form.has_key(':action'):
401             return None
402         try:
403             # get the action, validate it
404             action = self.form[':action'].value
405             for name, method in self.actions:
406                 if name == action:
407                     break
408             else:
409                 raise ValueError, 'No such action "%s"'%action
411             # call the mapped action
412             getattr(self, method)()
413         except Redirect:
414             raise
415         except Unauthorised:
416             raise
417         except:
418             self.db.rollback()
419             s = StringIO.StringIO()
420             traceback.print_exc(None, s)
421             self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
423     def write(self, content):
424         if not self.headers_done:
425             self.header()
426         self.request.wfile.write(content)
428     def header(self, headers=None, response=None):
429         '''Put up the appropriate header.
430         '''
431         if headers is None:
432             headers = {'Content-Type':'text/html'}
433         if response is None:
434             response = self.response_code
436         # update with additional info
437         headers.update(self.additional_headers)
439         if not headers.has_key('Content-Type'):
440             headers['Content-Type'] = 'text/html'
441         self.request.send_response(response)
442         for entry in headers.items():
443             self.request.send_header(*entry)
444         self.request.end_headers()
445         self.headers_done = 1
446         if self.debug:
447             self.headers_sent = headers
449     def set_cookie(self, user):
450         ''' Set up a session cookie for the user and store away the user's
451             login info against the session.
452         '''
453         # TODO generate a much, much stronger session key ;)
454         self.session = binascii.b2a_base64(repr(random.random())).strip()
456         # clean up the base64
457         if self.session[-1] == '=':
458             if self.session[-2] == '=':
459                 self.session = self.session[:-2]
460             else:
461                 self.session = self.session[:-1]
463         # insert the session in the sessiondb
464         self.db.sessions.set(self.session, user=user, last_use=time.time())
466         # and commit immediately
467         self.db.sessions.commit()
469         # expire us in a long, long time
470         expire = Cookie._getdate(86400*365)
472         # generate the cookie path - make sure it has a trailing '/'
473         path = '/'.join((self.env['SCRIPT_NAME'], self.env['TRACKER_NAME'],
474             ''))
475         self.additional_headers['Set-Cookie'] = \
476           'roundup_user_2=%s; expires=%s; Path=%s;'%(self.session, expire, path)
478     def make_user_anonymous(self):
479         ''' Make us anonymous
481             This method used to handle non-existence of the 'anonymous'
482             user, but that user is mandatory now.
483         '''
484         self.userid = self.db.user.lookup('anonymous')
485         self.user = 'anonymous'
487     def opendb(self, user):
488         ''' Open the database.
489         '''
490         # open the db if the user has changed
491         if not hasattr(self, 'db') or user != self.db.journaltag:
492             if hasattr(self, 'db'):
493                 self.db.close()
494             self.db = self.instance.open(user)
496     #
497     # Actions
498     #
499     def loginAction(self):
500         ''' Attempt to log a user in.
502             Sets up a session for the user which contains the login
503             credentials.
504         '''
505         # we need the username at a minimum
506         if not self.form.has_key('__login_name'):
507             self.error_message.append(_('Username required'))
508             return
510         # get the login info
511         self.user = self.form['__login_name'].value
512         if self.form.has_key('__login_password'):
513             password = self.form['__login_password'].value
514         else:
515             password = ''
517         # make sure the user exists
518         try:
519             self.userid = self.db.user.lookup(self.user)
520         except KeyError:
521             name = self.user
522             self.error_message.append(_('No such user "%(name)s"')%locals())
523             self.make_user_anonymous()
524             return
526         # verify the password
527         if not self.verifyPassword(self.userid, password):
528             self.make_user_anonymous()
529             self.error_message.append(_('Incorrect password'))
530             return
532         # make sure we're allowed to be here
533         if not self.loginPermission():
534             self.make_user_anonymous()
535             self.error_message.append(_("You do not have permission to login"))
536             return
538         # now we're OK, re-open the database for real, using the user
539         self.opendb(self.user)
541         # set the session cookie
542         self.set_cookie(self.user)
544     def verifyPassword(self, userid, password):
545         ''' Verify the password that the user has supplied
546         '''
547         stored = self.db.user.get(self.userid, 'password')
548         if password == stored:
549             return 1
550         if not password and not stored:
551             return 1
552         return 0
554     def loginPermission(self):
555         ''' Determine whether the user has permission to log in.
557             Base behaviour is to check the user has "Web Access".
558         ''' 
559         if not self.db.security.hasPermission('Web Access', self.userid):
560             return 0
561         return 1
563     def logout_action(self):
564         ''' Make us really anonymous - nuke the cookie too
565         '''
566         # log us out
567         self.make_user_anonymous()
569         # construct the logout cookie
570         now = Cookie._getdate()
571         path = '/'.join((self.env['SCRIPT_NAME'], self.env['TRACKER_NAME'],
572             ''))
573         self.additional_headers['Set-Cookie'] = \
574            'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)
576         # Let the user know what's going on
577         self.ok_message.append(_('You are logged out'))
579     def registerAction(self):
580         '''Attempt to create a new user based on the contents of the form
581         and then set the cookie.
583         return 1 on successful login
584         '''
585         # create the new user
586         cl = self.db.user
588         # parse the props from the form
589         try:
590             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
591         except (ValueError, KeyError), message:
592             self.error_message.append(_('Error: ') + str(message))
593             return
595         # make sure we're allowed to register
596         if not self.registerPermission(props):
597             raise Unauthorised, _("You do not have permission to register")
599         # re-open the database as "admin"
600         if self.user != 'admin':
601             self.opendb('admin')
602             
603         # create the new user
604         cl = self.db.user
605         try:
606             props = parsePropsFromForm(self.db, cl, self.form)
607             props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
608             self.userid = cl.create(**props)
609             self.db.commit()
610         except (ValueError, KeyError), message:
611             self.error_message.append(message)
612             return
614         # log the new user in
615         self.user = cl.get(self.userid, 'username')
616         # re-open the database for real, using the user
617         self.opendb(self.user)
619         # if we have a session, update it
620         if hasattr(self, 'session'):
621             self.db.sessions.set(self.session, user=self.user,
622                 last_use=time.time())
623         else:
624             # new session cookie
625             self.set_cookie(self.user)
627         # nice message
628         message = _('You are now registered, welcome!')
630         # redirect to the item's edit page
631         raise Redirect, '%s%s%s?:ok_message=%s'%(
632             self.base, self.classname, self.userid,  urllib.quote(message))
634     def registerPermission(self, props):
635         ''' Determine whether the user has permission to register
637             Base behaviour is to check the user has "Web Registration".
638         '''
639         # registration isn't allowed to supply roles
640         if props.has_key('roles'):
641             return 0
642         if self.db.security.hasPermission('Web Registration', self.userid):
643             return 1
644         return 0
646     def editItemAction(self):
647         ''' Perform an edit of an item in the database.
649             Some special form elements:
651             :link=designator:property
652             :multilink=designator:property
653              The value specifies a node designator and the property on that
654              node to add _this_ node to as a link or multilink.
655             :note
656              Create a message and attach it to the current node's
657              "messages" property.
658             :file
659              Create a file and attach it to the current node's
660              "files" property. Attach the file to the message created from
661              the :note if it's supplied.
663             :required=property,property,...
664              The named properties are required to be filled in the form.
666         '''
667         cl = self.db.classes[self.classname]
669         # parse the props from the form
670         try:
671             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
672         except (ValueError, KeyError), message:
673             self.error_message.append(_('Error: ') + str(message))
674             return
676         # check permission
677         if not self.editItemPermission(props):
678             self.error_message.append(
679                 _('You do not have permission to edit %(classname)s'%
680                 self.__dict__))
681             return
683         # perform the edit
684         try:
685             # make changes to the node
686             props = self._changenode(props)
687             # handle linked nodes 
688             self._post_editnode(self.nodeid)
689         except (ValueError, KeyError), message:
690             self.error_message.append(_('Error: ') + str(message))
691             return
693         # commit now that all the tricky stuff is done
694         self.db.commit()
696         # and some nice feedback for the user
697         if props:
698             message = _('%(changes)s edited ok')%{'changes':
699                 ', '.join(props.keys())}
700         elif self.form.has_key(':note') and self.form[':note'].value:
701             message = _('note added')
702         elif (self.form.has_key(':file') and self.form[':file'].filename):
703             message = _('file added')
704         else:
705             message = _('nothing changed')
707         # redirect to the item's edit page
708         raise Redirect, '%s%s%s?:ok_message=%s'%(self.base, self.classname,
709             self.nodeid,  urllib.quote(message))
711     def editItemPermission(self, props):
712         ''' Determine whether the user has permission to edit this item.
714             Base behaviour is to check the user can edit this class. If we're
715             editing the "user" class, users are allowed to edit their own
716             details. Unless it's the "roles" property, which requires the
717             special Permission "Web Roles".
718         '''
719         # if this is a user node and the user is editing their own node, then
720         # we're OK
721         has = self.db.security.hasPermission
722         if self.classname == 'user':
723             # reject if someone's trying to edit "roles" and doesn't have the
724             # right permission.
725             if props.has_key('roles') and not has('Web Roles', self.userid,
726                     'user'):
727                 return 0
728             # if the item being edited is the current user, we're ok
729             if self.nodeid == self.userid:
730                 return 1
731         if self.db.security.hasPermission('Edit', self.userid, self.classname):
732             return 1
733         return 0
735     def newItemAction(self):
736         ''' Add a new item to the database.
738             This follows the same form as the editItemAction, with the same
739             special form values.
740         '''
741         cl = self.db.classes[self.classname]
743         # parse the props from the form
744         try:
745             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
746         except (ValueError, KeyError), message:
747             self.error_message.append(_('Error: ') + str(message))
748             return
750         if not self.newItemPermission(props):
751             self.error_message.append(
752                 _('You do not have permission to create %s' %self.classname))
754         # create a little extra message for anticipated :link / :multilink
755         if self.form.has_key(':multilink'):
756             link = self.form[':multilink'].value
757         elif self.form.has_key(':link'):
758             link = self.form[':multilink'].value
759         else:
760             link = None
761             xtra = ''
762         if link:
763             designator, linkprop = link.split(':')
764             xtra = ' for <a href="%s">%s</a>'%(designator, designator)
766         try:
767             # do the create
768             nid = self._createnode(props)
770             # handle linked nodes 
771             self._post_editnode(nid)
773             # commit now that all the tricky stuff is done
774             self.db.commit()
776             # render the newly created item
777             self.nodeid = nid
779             # and some nice feedback for the user
780             message = _('%(classname)s created ok')%self.__dict__ + xtra
781         except (ValueError, KeyError), message:
782             self.error_message.append(_('Error: ') + str(message))
783             return
784         except:
785             # oops
786             self.db.rollback()
787             s = StringIO.StringIO()
788             traceback.print_exc(None, s)
789             self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
790             return
792         # redirect to the new item's page
793         raise Redirect, '%s%s%s?:ok_message=%s'%(self.base, self.classname,
794             nid,  urllib.quote(message))
796     def newItemPermission(self, props):
797         ''' Determine whether the user has permission to create (edit) this
798             item.
800             Base behaviour is to check the user can edit this class. No
801             additional property checks are made. Additionally, new user items
802             may be created if the user has the "Web Registration" Permission.
803         '''
804         has = self.db.security.hasPermission
805         if self.classname == 'user' and has('Web Registration', self.userid,
806                 'user'):
807             return 1
808         if has('Edit', self.userid, self.classname):
809             return 1
810         return 0
812     def editCSVAction(self):
813         ''' Performs an edit of all of a class' items in one go.
815             The "rows" CGI var defines the CSV-formatted entries for the
816             class. New nodes are identified by the ID 'X' (or any other
817             non-existent ID) and removed lines are retired.
818         '''
819         # this is per-class only
820         if not self.editCSVPermission():
821             self.error_message.append(
822                 _('You do not have permission to edit %s' %self.classname))
824         # get the CSV module
825         try:
826             import csv
827         except ImportError:
828             self.error_message.append(_(
829                 'Sorry, you need the csv module to use this function.<br>\n'
830                 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
831             return
833         cl = self.db.classes[self.classname]
834         idlessprops = cl.getprops(protected=0).keys()
835         idlessprops.sort()
836         props = ['id'] + idlessprops
838         # do the edit
839         rows = self.form['rows'].value.splitlines()
840         p = csv.parser()
841         found = {}
842         line = 0
843         for row in rows[1:]:
844             line += 1
845             values = p.parse(row)
846             # not a complete row, keep going
847             if not values: continue
849             # skip property names header
850             if values == props:
851                 continue
853             # extract the nodeid
854             nodeid, values = values[0], values[1:]
855             found[nodeid] = 1
857             # confirm correct weight
858             if len(idlessprops) != len(values):
859                 self.error_message.append(
860                     _('Not enough values on line %(line)s')%{'line':line})
861                 return
863             # extract the new values
864             d = {}
865             for name, value in zip(idlessprops, values):
866                 value = value.strip()
867                 # only add the property if it has a value
868                 if value:
869                     # if it's a multilink, split it
870                     if isinstance(cl.properties[name], hyperdb.Multilink):
871                         value = value.split(':')
872                     d[name] = value
874             # perform the edit
875             if cl.hasnode(nodeid):
876                 # edit existing
877                 cl.set(nodeid, **d)
878             else:
879                 # new node
880                 found[cl.create(**d)] = 1
882         # retire the removed entries
883         for nodeid in cl.list():
884             if not found.has_key(nodeid):
885                 cl.retire(nodeid)
887         # all OK
888         self.db.commit()
890         self.ok_message.append(_('Items edited OK'))
892     def editCSVPermission(self):
893         ''' Determine whether the user has permission to edit this class.
895             Base behaviour is to check the user can edit this class.
896         ''' 
897         if not self.db.security.hasPermission('Edit', self.userid,
898                 self.classname):
899             return 0
900         return 1
902     def searchAction(self):
903         ''' Mangle some of the form variables.
905             Set the form ":filter" variable based on the values of the
906             filter variables - if they're set to anything other than
907             "dontcare" then add them to :filter.
909             Also handle the ":queryname" variable and save off the query to
910             the user's query list.
911         '''
912         # generic edit is per-class only
913         if not self.searchPermission():
914             self.error_message.append(
915                 _('You do not have permission to search %s' %self.classname))
917         # add a faked :filter form variable for each filtering prop
918         props = self.db.classes[self.classname].getprops()
919         for key in self.form.keys():
920             if not props.has_key(key): continue
921             if not self.form[key].value: continue
922             self.form.value.append(cgi.MiniFieldStorage(':filter', key))
924         # handle saving the query params
925         if self.form.has_key(':queryname'):
926             queryname = self.form[':queryname'].value.strip()
927             if queryname:
928                 # parse the environment and figure what the query _is_
929                 req = HTMLRequest(self)
930                 url = req.indexargs_href('', {})
932                 # handle editing an existing query
933                 try:
934                     qid = self.db.query.lookup(queryname)
935                     self.db.query.set(qid, klass=self.classname, url=url)
936                 except KeyError:
937                     # create a query
938                     qid = self.db.query.create(name=queryname,
939                         klass=self.classname, url=url)
941                     # and add it to the user's query multilink
942                     queries = self.db.user.get(self.userid, 'queries')
943                     queries.append(qid)
944                     self.db.user.set(self.userid, queries=queries)
946                 # commit the query change to the database
947                 self.db.commit()
949     def searchPermission(self):
950         ''' Determine whether the user has permission to search this class.
952             Base behaviour is to check the user can view this class.
953         ''' 
954         if not self.db.security.hasPermission('View', self.userid,
955                 self.classname):
956             return 0
957         return 1
959     def retireAction(self):
960         ''' Retire the context item.
961         '''
962         # if we want to view the index template now, then unset the nodeid
963         # context info (a special-case for retire actions on the index page)
964         nodeid = self.nodeid
965         if self.template == 'index':
966             self.nodeid = None
968         # generic edit is per-class only
969         if not self.retirePermission():
970             self.error_message.append(
971                 _('You do not have permission to retire %s' %self.classname))
972             return
974         # make sure we don't try to retire admin or anonymous
975         if self.classname == 'user' and \
976                 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
977             self.error_message.append(
978                 _('You may not retire the admin or anonymous user'))
979             return
981         # do the retire
982         self.db.getclass(self.classname).retire(nodeid)
983         self.db.commit()
985         self.ok_message.append(
986             _('%(classname)s %(itemid)s has been retired')%{
987                 'classname': self.classname.capitalize(), 'itemid': nodeid})
989     def retirePermission(self):
990         ''' Determine whether the user has permission to retire this class.
992             Base behaviour is to check the user can edit this class.
993         ''' 
994         if not self.db.security.hasPermission('Edit', self.userid,
995                 self.classname):
996             return 0
997         return 1
1000     #
1001     #  Utility methods for editing
1002     #
1003     def _changenode(self, props):
1004         ''' change the node based on the contents of the form
1005         '''
1006         cl = self.db.classes[self.classname]
1008         # create the message
1009         message, files = self._handle_message()
1010         if message:
1011             props['messages'] = cl.get(self.nodeid, 'messages') + [message]
1012         if files:
1013             props['files'] = cl.get(self.nodeid, 'files') + files
1015         # make the changes
1016         return cl.set(self.nodeid, **props)
1018     def _createnode(self, props):
1019         ''' create a node based on the contents of the form
1020         '''
1021         cl = self.db.classes[self.classname]
1023         # check for messages and files
1024         message, files = self._handle_message()
1025         if message:
1026             props['messages'] = [message]
1027         if files:
1028             props['files'] = files
1029         # create the node and return it's id
1030         return cl.create(**props)
1032     def _handle_message(self):
1033         ''' generate an edit message
1034         '''
1035         # handle file attachments 
1036         files = []
1037         if self.form.has_key(':file'):
1038             file = self.form[':file']
1039             if file.filename:
1040                 filename = file.filename.split('\\')[-1]
1041                 mime_type = mimetypes.guess_type(filename)[0]
1042                 if not mime_type:
1043                     mime_type = "application/octet-stream"
1044                 # create the new file entry
1045                 files.append(self.db.file.create(type=mime_type,
1046                     name=filename, content=file.file.read()))
1048         # we don't want to do a message if none of the following is true...
1049         cn = self.classname
1050         cl = self.db.classes[self.classname]
1051         props = cl.getprops()
1052         note = None
1053         # in a nutshell, don't do anything if there's no note or there's no
1054         # NOSY
1055         if self.form.has_key(':note'):
1056             # fix the CRLF/CR -> LF stuff
1057             note = fixNewlines(self.form[':note'].value.strip())
1058         if not note:
1059             return None, files
1060         if not props.has_key('messages'):
1061             return None, files
1062         if not isinstance(props['messages'], hyperdb.Multilink):
1063             return None, files
1064         if not props['messages'].classname == 'msg':
1065             return None, files
1066         if not (self.form.has_key('nosy') or note):
1067             return None, files
1069         # handle the note
1070         if '\n' in note:
1071             summary = re.split(r'\n\r?', note)[0]
1072         else:
1073             summary = note
1074         m = ['%s\n'%note]
1076         # handle the messageid
1077         # TODO: handle inreplyto
1078         messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
1079             self.classname, self.instance.config.MAIL_DOMAIN)
1081         # now create the message, attaching the files
1082         content = '\n'.join(m)
1083         message_id = self.db.msg.create(author=self.userid,
1084             recipients=[], date=date.Date('.'), summary=summary,
1085             content=content, files=files, messageid=messageid)
1087         # update the messages property
1088         return message_id, files
1090     def _post_editnode(self, nid):
1091         '''Do the linking part of the node creation.
1093            If a form element has :link or :multilink appended to it, its
1094            value specifies a node designator and the property on that node
1095            to add _this_ node to as a link or multilink.
1097            This is typically used on, eg. the file upload page to indicated
1098            which issue to link the file to.
1100            TODO: I suspect that this and newfile will go away now that
1101            there's the ability to upload a file using the issue :file form
1102            element!
1103         '''
1104         cn = self.classname
1105         cl = self.db.classes[cn]
1106         # link if necessary
1107         keys = self.form.keys()
1108         for key in keys:
1109             if key == ':multilink':
1110                 value = self.form[key].value
1111                 if type(value) != type([]): value = [value]
1112                 for value in value:
1113                     designator, property = value.split(':')
1114                     link, nodeid = hyperdb.splitDesignator(designator)
1115                     link = self.db.classes[link]
1116                     # take a dupe of the list so we're not changing the cache
1117                     value = link.get(nodeid, property)[:]
1118                     value.append(nid)
1119                     link.set(nodeid, **{property: value})
1120             elif key == ':link':
1121                 value = self.form[key].value
1122                 if type(value) != type([]): value = [value]
1123                 for value in value:
1124                     designator, property = value.split(':')
1125                     link, nodeid = hyperdb.splitDesignator(designator)
1126                     link = self.db.classes[link]
1127                     link.set(nodeid, **{property: nid})
1129 def fixNewlines(text):
1130     ''' Homogenise line endings.
1132         Different web clients send different line ending values, but
1133         other systems (eg. email) don't necessarily handle those line
1134         endings. Our solution is to convert all line endings to LF.
1135     '''
1136     text = text.replace('\r\n', '\n')
1137     return text.replace('\r', '\n')
1139 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
1140     ''' Pull properties for the given class out of the form.
1142         If a ":required" parameter is supplied, then the names property values
1143         must be supplied or a ValueError will be raised.
1144     '''
1145     required = []
1146     if form.has_key(':required'):
1147         value = form[':required']
1148         if isinstance(value, type([])):
1149             required = [i.value.strip() for i in value]
1150         else:
1151             required = [i.strip() for i in value.value.split(',')]
1153     props = {}
1154     keys = form.keys()
1155     properties = cl.getprops()
1156     for key in keys:
1157         if not properties.has_key(key):
1158             continue
1159         proptype = properties[key]
1161         # Get the form value. This value may be a MiniFieldStorage or a list
1162         # of MiniFieldStorages.
1163         value = form[key]
1165         # make sure non-multilinks only get one value
1166         if not isinstance(proptype, hyperdb.Multilink):
1167             if isinstance(value, type([])):
1168                 raise ValueError, 'You have submitted more than one value'\
1169                     ' for the %s property'%key
1170             # we've got a MiniFieldStorage, so pull out the value and strip
1171             # surrounding whitespace
1172             value = value.value.strip()
1174         if isinstance(proptype, hyperdb.String):
1175             if not value:
1176                 continue
1177             # fix the CRLF/CR -> LF stuff
1178             value = fixNewlines(value)
1179         elif isinstance(proptype, hyperdb.Password):
1180             if not value:
1181                 # ignore empty password values
1182                 continue
1183             if not form.has_key('%s:confirm'%key):
1184                 raise ValueError, 'Password and confirmation text do not match'
1185             confirm = form['%s:confirm'%key]
1186             if isinstance(confirm, type([])):
1187                 raise ValueError, 'You have submitted more than one value'\
1188                     ' for the %s property'%key
1189             if value != confirm.value:
1190                 raise ValueError, 'Password and confirmation text do not match'
1191             value = password.Password(value)
1192         elif isinstance(proptype, hyperdb.Date):
1193             if value:
1194                 value = date.Date(form[key].value.strip())
1195             else:
1196                 continue
1197         elif isinstance(proptype, hyperdb.Interval):
1198             if value:
1199                 value = date.Interval(form[key].value.strip())
1200             else:
1201                 continue
1202         elif isinstance(proptype, hyperdb.Link):
1203             # see if it's the "no selection" choice
1204             if value == '-1':
1205                 value = None
1206             else:
1207                 # handle key values
1208                 link = proptype.classname
1209                 if not num_re.match(value):
1210                     try:
1211                         value = db.classes[link].lookup(value)
1212                     except KeyError:
1213                         raise ValueError, _('property "%(propname)s": '
1214                             '%(value)s not a %(classname)s')%{'propname':key, 
1215                             'value': value, 'classname': link}
1216                     except TypeError, message:
1217                         raise ValueError, _('you may only enter ID values '
1218                             'for property "%(propname)s": %(message)s')%{
1219                             'propname':key, 'message': message}
1220         elif isinstance(proptype, hyperdb.Multilink):
1221             if isinstance(value, type([])):
1222                 # it's a list of MiniFieldStorages
1223                 value = [i.value.strip() for i in value]
1224             else:
1225                 # it's a MiniFieldStorage, but may be a comma-separated list
1226                 # of values
1227                 value = [i.strip() for i in value.value.split(',')]
1228             link = proptype.classname
1229             l = []
1230             for entry in map(str, value):
1231                 if entry == '': continue
1232                 if not num_re.match(entry):
1233                     try:
1234                         entry = db.classes[link].lookup(entry)
1235                     except KeyError:
1236                         raise ValueError, _('property "%(propname)s": '
1237                             '"%(value)s" not an entry of %(classname)s')%{
1238                             'propname':key, 'value': entry, 'classname': link}
1239                     except TypeError, message:
1240                         raise ValueError, _('you may only enter ID values '
1241                             'for property "%(propname)s": %(message)s')%{
1242                             'propname':key, 'message': message}
1243                 l.append(entry)
1244             l.sort()
1245             value = l
1246         elif isinstance(proptype, hyperdb.Boolean):
1247             props[key] = value = value.lower() in ('yes', 'true', 'on', '1')
1248         elif isinstance(proptype, hyperdb.Number):
1249             props[key] = value = int(value)
1251         # register this as received if required?
1252         if key in required and value is not None:
1253             required.remove(key)
1255         # get the old value
1256         if nodeid:
1257             try:
1258                 existing = cl.get(nodeid, key)
1259             except KeyError:
1260                 # this might be a new property for which there is no existing
1261                 # value
1262                 if not properties.has_key(key): raise
1264             # if changed, set it
1265             if value != existing:
1266                 props[key] = value
1267         else:
1268             props[key] = value
1270     # see if all the required properties have been supplied
1271     if required:
1272         if len(required) > 1:
1273             p = 'properties'
1274         else:
1275             p = 'property'
1276         raise ValueError, 'Required %s %s not supplied'%(p, ', '.join(required))
1278     return props