Code

- expose the tracker config as a variable for templating
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.52 2002-10-09 01:00:40 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         # see if we have a template override
317         if self.form.has_key(':template'):
318             self.template = self.form[':template'].value
320         # see if we were passed in a message
321         if self.form.has_key(':ok_message'):
322             self.ok_message.append(self.form[':ok_message'].value)
323         if self.form.has_key(':error_message'):
324             self.error_message.append(self.form[':error_message'].value)
326     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
327         ''' Serve the file from the content property of the designated item.
328         '''
329         m = dre.match(str(designator))
330         if not m:
331             raise NotFound, str(designator)
332         classname, nodeid = m.group(1), m.group(2)
333         if classname != 'file':
334             raise NotFound, designator
336         # we just want to serve up the file named
337         file = self.db.file
338         self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
339         self.write(file.get(nodeid, 'content'))
341     def serve_static_file(self, file):
342         # we just want to serve up the file named
343         mt = mimetypes.guess_type(str(file))[0]
344         self.additional_headers['Content-Type'] = mt
345         self.write(open(os.path.join(self.instance.config.TEMPLATES,
346             file)).read())
348     def renderContext(self):
349         ''' Return a PageTemplate for the named page
350         '''
351         name = self.classname
352         extension = self.template
353         pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
355         # catch errors so we can handle PT rendering errors more nicely
356         args = {
357             'ok_message': self.ok_message,
358             'error_message': self.error_message
359         }
360         try:
361             # let the template render figure stuff out
362             return pt.render(self, None, None, **args)
363         except NoTemplate, message:
364             return '<strong>%s</strong>'%message
365         except:
366             # everything else
367             return cgitb.pt_html()
369     # these are the actions that are available
370     actions = (
371         ('edit',     'editItemAction'),
372         ('editCSV',  'editCSVAction'),
373         ('new',      'newItemAction'),
374         ('register', 'registerAction'),
375         ('login',    'loginAction'),
376         ('logout',   'logout_action'),
377         ('search',   'searchAction'),
378     )
379     def handle_action(self):
380         ''' Determine whether there should be an _action called.
382             The action is defined by the form variable :action which
383             identifies the method on this object to call. The four basic
384             actions are defined in the "actions" sequence on this class:
385              "edit"      -> self.editItemAction
386              "new"       -> self.newItemAction
387              "register"  -> self.registerAction
388              "login"     -> self.loginAction
389              "logout"    -> self.logout_action
390              "search"    -> self.searchAction
392         '''
393         if not self.form.has_key(':action'):
394             return None
395         try:
396             # get the action, validate it
397             action = self.form[':action'].value
398             for name, method in self.actions:
399                 if name == action:
400                     break
401             else:
402                 raise ValueError, 'No such action "%s"'%action
404             # call the mapped action
405             getattr(self, method)()
406         except Redirect:
407             raise
408         except Unauthorised:
409             raise
410         except:
411             self.db.rollback()
412             s = StringIO.StringIO()
413             traceback.print_exc(None, s)
414             self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
416     def write(self, content):
417         if not self.headers_done:
418             self.header()
419         self.request.wfile.write(content)
421     def header(self, headers=None, response=None):
422         '''Put up the appropriate header.
423         '''
424         if headers is None:
425             headers = {'Content-Type':'text/html'}
426         if response is None:
427             response = self.response_code
429         # update with additional info
430         headers.update(self.additional_headers)
432         if not headers.has_key('Content-Type'):
433             headers['Content-Type'] = 'text/html'
434         self.request.send_response(response)
435         for entry in headers.items():
436             self.request.send_header(*entry)
437         self.request.end_headers()
438         self.headers_done = 1
439         if self.debug:
440             self.headers_sent = headers
442     def set_cookie(self, user):
443         ''' Set up a session cookie for the user and store away the user's
444             login info against the session.
445         '''
446         # TODO generate a much, much stronger session key ;)
447         self.session = binascii.b2a_base64(repr(random.random())).strip()
449         # clean up the base64
450         if self.session[-1] == '=':
451             if self.session[-2] == '=':
452                 self.session = self.session[:-2]
453             else:
454                 self.session = self.session[:-1]
456         # insert the session in the sessiondb
457         self.db.sessions.set(self.session, user=user, last_use=time.time())
459         # and commit immediately
460         self.db.sessions.commit()
462         # expire us in a long, long time
463         expire = Cookie._getdate(86400*365)
465         # generate the cookie path - make sure it has a trailing '/'
466         path = '/'.join((self.env['SCRIPT_NAME'], self.env['TRACKER_NAME'],
467             ''))
468         self.additional_headers['Set-Cookie'] = \
469           'roundup_user_2=%s; expires=%s; Path=%s;'%(self.session, expire, path)
471     def make_user_anonymous(self):
472         ''' Make us anonymous
474             This method used to handle non-existence of the 'anonymous'
475             user, but that user is mandatory now.
476         '''
477         self.userid = self.db.user.lookup('anonymous')
478         self.user = 'anonymous'
480     def opendb(self, user):
481         ''' Open the database.
482         '''
483         # open the db if the user has changed
484         if not hasattr(self, 'db') or user != self.db.journaltag:
485             if hasattr(self, 'db'):
486                 self.db.close()
487             self.db = self.instance.open(user)
489     #
490     # Actions
491     #
492     def loginAction(self):
493         ''' Attempt to log a user in.
495             Sets up a session for the user which contains the login
496             credentials.
497         '''
498         # we need the username at a minimum
499         if not self.form.has_key('__login_name'):
500             self.error_message.append(_('Username required'))
501             return
503         # get the login info
504         self.user = self.form['__login_name'].value
505         if self.form.has_key('__login_password'):
506             password = self.form['__login_password'].value
507         else:
508             password = ''
510         # make sure the user exists
511         try:
512             self.userid = self.db.user.lookup(self.user)
513         except KeyError:
514             name = self.user
515             self.error_message.append(_('No such user "%(name)s"')%locals())
516             self.make_user_anonymous()
517             return
519         # verify the password
520         if not self.verifyPassword(self.userid, password):
521             self.make_user_anonymous()
522             self.error_message.append(_('Incorrect password'))
523             return
525         # make sure we're allowed to be here
526         if not self.loginPermission():
527             self.make_user_anonymous()
528             self.error_message.append(_("You do not have permission to login"))
529             return
531         # now we're OK, re-open the database for real, using the user
532         self.opendb(self.user)
534         # set the session cookie
535         self.set_cookie(self.user)
537     def verifyPassword(self, userid, password):
538         ''' Verify the password that the user has supplied
539         '''
540         stored = self.db.user.get(self.userid, 'password')
541         if password == stored:
542             return 1
543         if not password and not stored:
544             return 1
545         return 0
547     def loginPermission(self):
548         ''' Determine whether the user has permission to log in.
550             Base behaviour is to check the user has "Web Access".
551         ''' 
552         if not self.db.security.hasPermission('Web Access', self.userid):
553             return 0
554         return 1
556     def logout_action(self):
557         ''' Make us really anonymous - nuke the cookie too
558         '''
559         # log us out
560         self.make_user_anonymous()
562         # construct the logout cookie
563         now = Cookie._getdate()
564         path = '/'.join((self.env['SCRIPT_NAME'], self.env['TRACKER_NAME'],
565             ''))
566         self.additional_headers['Set-Cookie'] = \
567            'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)
569         # Let the user know what's going on
570         self.ok_message.append(_('You are logged out'))
572     def registerAction(self):
573         '''Attempt to create a new user based on the contents of the form
574         and then set the cookie.
576         return 1 on successful login
577         '''
578         # create the new user
579         cl = self.db.user
581         # parse the props from the form
582         try:
583             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
584         except (ValueError, KeyError), message:
585             self.error_message.append(_('Error: ') + str(message))
586             return
588         # make sure we're allowed to register
589         if not self.registerPermission(props):
590             raise Unauthorised, _("You do not have permission to register")
592         # re-open the database as "admin"
593         if self.user != 'admin':
594             self.opendb('admin')
595             
596         # create the new user
597         cl = self.db.user
598         try:
599             props = parsePropsFromForm(self.db, cl, self.form)
600             props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
601             self.userid = cl.create(**props)
602             self.db.commit()
603         except (ValueError, KeyError), message:
604             self.error_message.append(message)
605             return
607         # log the new user in
608         self.user = cl.get(self.userid, 'username')
609         # re-open the database for real, using the user
610         self.opendb(self.user)
612         # if we have a session, update it
613         if hasattr(self, 'session'):
614             self.db.sessions.set(self.session, user=self.user,
615                 last_use=time.time())
616         else:
617             # new session cookie
618             self.set_cookie(self.user)
620         # nice message
621         message = _('You are now registered, welcome!')
623         # redirect to the item's edit page
624         raise Redirect, '%s%s%s?:ok_message=%s'%(
625             self.base, self.classname, self.userid,  urllib.quote(message))
627     def registerPermission(self, props):
628         ''' Determine whether the user has permission to register
630             Base behaviour is to check the user has "Web Registration".
631         '''
632         # registration isn't allowed to supply roles
633         if props.has_key('roles'):
634             return 0
635         if self.db.security.hasPermission('Web Registration', self.userid):
636             return 1
637         return 0
639     def editItemAction(self):
640         ''' Perform an edit of an item in the database.
642             Some special form elements:
644             :link=designator:property
645             :multilink=designator:property
646              The value specifies a node designator and the property on that
647              node to add _this_ node to as a link or multilink.
648             :note
649              Create a message and attach it to the current node's
650              "messages" property.
651             :file
652              Create a file and attach it to the current node's
653              "files" property. Attach the file to the message created from
654              the :note if it's supplied.
656             :required=property,property,...
657              The named properties are required to be filled in the form.
659         '''
660         cl = self.db.classes[self.classname]
662         # parse the props from the form
663         try:
664             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
665         except (ValueError, KeyError), message:
666             self.error_message.append(_('Error: ') + str(message))
667             return
669         # check permission
670         if not self.editItemPermission(props):
671             self.error_message.append(
672                 _('You do not have permission to edit %(classname)s'%
673                 self.__dict__))
674             return
676         # perform the edit
677         try:
678             # make changes to the node
679             props = self._changenode(props)
680             # handle linked nodes 
681             self._post_editnode(self.nodeid)
682         except (ValueError, KeyError), message:
683             self.error_message.append(_('Error: ') + str(message))
684             return
686         # commit now that all the tricky stuff is done
687         self.db.commit()
689         # and some nice feedback for the user
690         if props:
691             message = _('%(changes)s edited ok')%{'changes':
692                 ', '.join(props.keys())}
693         elif self.form.has_key(':note') and self.form[':note'].value:
694             message = _('note added')
695         elif (self.form.has_key(':file') and self.form[':file'].filename):
696             message = _('file added')
697         else:
698             message = _('nothing changed')
700         # redirect to the item's edit page
701         raise Redirect, '%s%s%s?:ok_message=%s'%(self.base, self.classname,
702             self.nodeid,  urllib.quote(message))
704     def editItemPermission(self, props):
705         ''' Determine whether the user has permission to edit this item.
707             Base behaviour is to check the user can edit this class. If we're
708             editing the "user" class, users are allowed to edit their own
709             details. Unless it's the "roles" property, which requires the
710             special Permission "Web Roles".
711         '''
712         # if this is a user node and the user is editing their own node, then
713         # we're OK
714         has = self.db.security.hasPermission
715         if self.classname == 'user':
716             # reject if someone's trying to edit "roles" and doesn't have the
717             # right permission.
718             if props.has_key('roles') and not has('Web Roles', self.userid,
719                     'user'):
720                 return 0
721             # if the item being edited is the current user, we're ok
722             if self.nodeid == self.userid:
723                 return 1
724         if self.db.security.hasPermission('Edit', self.userid, self.classname):
725             return 1
726         return 0
728     def newItemAction(self):
729         ''' Add a new item to the database.
731             This follows the same form as the editItemAction, with the same
732             special form values.
733         '''
734         cl = self.db.classes[self.classname]
736         # parse the props from the form
737         try:
738             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
739         except (ValueError, KeyError), message:
740             self.error_message.append(_('Error: ') + str(message))
741             return
743         if not self.newItemPermission(props):
744             self.error_message.append(
745                 _('You do not have permission to create %s' %self.classname))
747         # create a little extra message for anticipated :link / :multilink
748         if self.form.has_key(':multilink'):
749             link = self.form[':multilink'].value
750         elif self.form.has_key(':link'):
751             link = self.form[':multilink'].value
752         else:
753             link = None
754             xtra = ''
755         if link:
756             designator, linkprop = link.split(':')
757             xtra = ' for <a href="%s">%s</a>'%(designator, designator)
759         try:
760             # do the create
761             nid = self._createnode(props)
763             # handle linked nodes 
764             self._post_editnode(nid)
766             # commit now that all the tricky stuff is done
767             self.db.commit()
769             # render the newly created item
770             self.nodeid = nid
772             # and some nice feedback for the user
773             message = _('%(classname)s created ok')%self.__dict__ + xtra
774         except (ValueError, KeyError), message:
775             self.error_message.append(_('Error: ') + str(message))
776             return
777         except:
778             # oops
779             self.db.rollback()
780             s = StringIO.StringIO()
781             traceback.print_exc(None, s)
782             self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
783             return
785         # redirect to the new item's page
786         raise Redirect, '%s%s%s?:ok_message=%s'%(self.base, self.classname,
787             nid,  urllib.quote(message))
789     def newItemPermission(self, props):
790         ''' Determine whether the user has permission to create (edit) this
791             item.
793             Base behaviour is to check the user can edit this class. No
794             additional property checks are made. Additionally, new user items
795             may be created if the user has the "Web Registration" Permission.
796         '''
797         has = self.db.security.hasPermission
798         if self.classname == 'user' and has('Web Registration', self.userid,
799                 'user'):
800             return 1
801         if has('Edit', self.userid, self.classname):
802             return 1
803         return 0
805     def editCSVAction(self):
806         ''' Performs an edit of all of a class' items in one go.
808             The "rows" CGI var defines the CSV-formatted entries for the
809             class. New nodes are identified by the ID 'X' (or any other
810             non-existent ID) and removed lines are retired.
811         '''
812         # this is per-class only
813         if not self.editCSVPermission():
814             self.error_message.append(
815                 _('You do not have permission to edit %s' %self.classname))
817         # get the CSV module
818         try:
819             import csv
820         except ImportError:
821             self.error_message.append(_(
822                 'Sorry, you need the csv module to use this function.<br>\n'
823                 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
824             return
826         cl = self.db.classes[self.classname]
827         idlessprops = cl.getprops(protected=0).keys()
828         idlessprops.sort()
829         props = ['id'] + idlessprops
831         # do the edit
832         rows = self.form['rows'].value.splitlines()
833         p = csv.parser()
834         found = {}
835         line = 0
836         for row in rows[1:]:
837             line += 1
838             values = p.parse(row)
839             # not a complete row, keep going
840             if not values: continue
842             # skip property names header
843             if values == props:
844                 continue
846             # extract the nodeid
847             nodeid, values = values[0], values[1:]
848             found[nodeid] = 1
850             # confirm correct weight
851             if len(idlessprops) != len(values):
852                 self.error_message.append(
853                     _('Not enough values on line %(line)s')%{'line':line})
854                 return
856             # extract the new values
857             d = {}
858             for name, value in zip(idlessprops, values):
859                 value = value.strip()
860                 # only add the property if it has a value
861                 if value:
862                     # if it's a multilink, split it
863                     if isinstance(cl.properties[name], hyperdb.Multilink):
864                         value = value.split(':')
865                     d[name] = value
867             # perform the edit
868             if cl.hasnode(nodeid):
869                 # edit existing
870                 cl.set(nodeid, **d)
871             else:
872                 # new node
873                 found[cl.create(**d)] = 1
875         # retire the removed entries
876         for nodeid in cl.list():
877             if not found.has_key(nodeid):
878                 cl.retire(nodeid)
880         # all OK
881         self.db.commit()
883         self.ok_message.append(_('Items edited OK'))
885     def editCSVPermission(self):
886         ''' Determine whether the user has permission to edit this class.
888             Base behaviour is to check the user can edit this class.
889         ''' 
890         if not self.db.security.hasPermission('Edit', self.userid,
891                 self.classname):
892             return 0
893         return 1
895     def searchAction(self):
896         ''' Mangle some of the form variables.
898             Set the form ":filter" variable based on the values of the
899             filter variables - if they're set to anything other than
900             "dontcare" then add them to :filter.
902             Also handle the ":queryname" variable and save off the query to
903             the user's query list.
904         '''
905         # generic edit is per-class only
906         if not self.searchPermission():
907             self.error_message.append(
908                 _('You do not have permission to search %s' %self.classname))
910         # add a faked :filter form variable for each filtering prop
911         props = self.db.classes[self.classname].getprops()
912         for key in self.form.keys():
913             if not props.has_key(key): continue
914             if not self.form[key].value: continue
915             self.form.value.append(cgi.MiniFieldStorage(':filter', key))
917         # handle saving the query params
918         if self.form.has_key(':queryname'):
919             queryname = self.form[':queryname'].value.strip()
920             if queryname:
921                 # parse the environment and figure what the query _is_
922                 req = HTMLRequest(self)
923                 url = req.indexargs_href('', {})
925                 # handle editing an existing query
926                 try:
927                     qid = self.db.query.lookup(queryname)
928                     self.db.query.set(qid, klass=self.classname, url=url)
929                 except KeyError:
930                     # create a query
931                     qid = self.db.query.create(name=queryname,
932                         klass=self.classname, url=url)
934                     # and add it to the user's query multilink
935                     queries = self.db.user.get(self.userid, 'queries')
936                     queries.append(qid)
937                     self.db.user.set(self.userid, queries=queries)
939                 # commit the query change to the database
940                 self.db.commit()
942     def searchPermission(self):
943         ''' Determine whether the user has permission to search this class.
945             Base behaviour is to check the user can view this class.
946         ''' 
947         if not self.db.security.hasPermission('View', self.userid,
948                 self.classname):
949             return 0
950         return 1
952     def remove_action(self,  dre=re.compile(r'([^\d]+)(\d+)')):
953         # XXX I believe this could be handled by a regular edit action that
954         # just sets the multilink...
955         target = self.index_arg(':target')[0]
956         m = dre.match(target)
957         if m:
958             classname = m.group(1)
959             nodeid = m.group(2)
960             cl = self.db.getclass(classname)
961             cl.retire(nodeid)
962             # now take care of the reference
963             parentref =  self.index_arg(':multilink')[0]
964             parent, prop = parentref.split(':')
965             m = dre.match(parent)
966             if m:
967                 self.classname = m.group(1)
968                 self.nodeid = m.group(2)
969                 cl = self.db.getclass(self.classname)
970                 value = cl.get(self.nodeid, prop)
971                 value.remove(nodeid)
972                 cl.set(self.nodeid, **{prop:value})
973                 func = getattr(self, 'show%s'%self.classname)
974                 return func()
975             else:
976                 raise NotFound, parent
977         else:
978             raise NotFound, target
980     #
981     #  Utility methods for editing
982     #
983     def _changenode(self, props):
984         ''' change the node based on the contents of the form
985         '''
986         cl = self.db.classes[self.classname]
988         # create the message
989         message, files = self._handle_message()
990         if message:
991             props['messages'] = cl.get(self.nodeid, 'messages') + [message]
992         if files:
993             props['files'] = cl.get(self.nodeid, 'files') + files
995         # make the changes
996         return cl.set(self.nodeid, **props)
998     def _createnode(self, props):
999         ''' create a node based on the contents of the form
1000         '''
1001         cl = self.db.classes[self.classname]
1003         # check for messages and files
1004         message, files = self._handle_message()
1005         if message:
1006             props['messages'] = [message]
1007         if files:
1008             props['files'] = files
1009         # create the node and return it's id
1010         return cl.create(**props)
1012     def _handle_message(self):
1013         ''' generate an edit message
1014         '''
1015         # handle file attachments 
1016         files = []
1017         if self.form.has_key(':file'):
1018             file = self.form[':file']
1019             if file.filename:
1020                 filename = file.filename.split('\\')[-1]
1021                 mime_type = mimetypes.guess_type(filename)[0]
1022                 if not mime_type:
1023                     mime_type = "application/octet-stream"
1024                 # create the new file entry
1025                 files.append(self.db.file.create(type=mime_type,
1026                     name=filename, content=file.file.read()))
1028         # we don't want to do a message if none of the following is true...
1029         cn = self.classname
1030         cl = self.db.classes[self.classname]
1031         props = cl.getprops()
1032         note = None
1033         # in a nutshell, don't do anything if there's no note or there's no
1034         # NOSY
1035         if self.form.has_key(':note'):
1036             # fix the CRLF/CR -> LF stuff
1037             note = fixNewlines(self.form[':note'].value.strip())
1038         if not note:
1039             return None, files
1040         if not props.has_key('messages'):
1041             return None, files
1042         if not isinstance(props['messages'], hyperdb.Multilink):
1043             return None, files
1044         if not props['messages'].classname == 'msg':
1045             return None, files
1046         if not (self.form.has_key('nosy') or note):
1047             return None, files
1049         # handle the note
1050         if '\n' in note:
1051             summary = re.split(r'\n\r?', note)[0]
1052         else:
1053             summary = note
1054         m = ['%s\n'%note]
1056         # handle the messageid
1057         # TODO: handle inreplyto
1058         messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
1059             self.classname, self.instance.config.MAIL_DOMAIN)
1061         # now create the message, attaching the files
1062         content = '\n'.join(m)
1063         message_id = self.db.msg.create(author=self.userid,
1064             recipients=[], date=date.Date('.'), summary=summary,
1065             content=content, files=files, messageid=messageid)
1067         # update the messages property
1068         return message_id, files
1070     def _post_editnode(self, nid):
1071         '''Do the linking part of the node creation.
1073            If a form element has :link or :multilink appended to it, its
1074            value specifies a node designator and the property on that node
1075            to add _this_ node to as a link or multilink.
1077            This is typically used on, eg. the file upload page to indicated
1078            which issue to link the file to.
1080            TODO: I suspect that this and newfile will go away now that
1081            there's the ability to upload a file using the issue :file form
1082            element!
1083         '''
1084         cn = self.classname
1085         cl = self.db.classes[cn]
1086         # link if necessary
1087         keys = self.form.keys()
1088         for key in keys:
1089             if key == ':multilink':
1090                 value = self.form[key].value
1091                 if type(value) != type([]): value = [value]
1092                 for value in value:
1093                     designator, property = value.split(':')
1094                     link, nodeid = hyperdb.splitDesignator(designator)
1095                     link = self.db.classes[link]
1096                     # take a dupe of the list so we're not changing the cache
1097                     value = link.get(nodeid, property)[:]
1098                     value.append(nid)
1099                     link.set(nodeid, **{property: value})
1100             elif key == ':link':
1101                 value = self.form[key].value
1102                 if type(value) != type([]): value = [value]
1103                 for value in value:
1104                     designator, property = value.split(':')
1105                     link, nodeid = hyperdb.splitDesignator(designator)
1106                     link = self.db.classes[link]
1107                     link.set(nodeid, **{property: nid})
1109 def fixNewlines(text):
1110     ''' Homogenise line endings.
1112         Different web clients send different line ending values, but
1113         other systems (eg. email) don't necessarily handle those line
1114         endings. Our solution is to convert all line endings to LF.
1115     '''
1116     text = text.replace('\r\n', '\n')
1117     return text.replace('\r', '\n')
1119 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
1120     ''' Pull properties for the given class out of the form.
1122         If a ":required" parameter is supplied, then the names property values
1123         must be supplied or a ValueError will be raised.
1124     '''
1125     required = []
1126     if form.has_key(':required'):
1127         value = form[':required']
1128         if isinstance(value, type([])):
1129             required = [i.value.strip() for i in value]
1130         else:
1131             required = [i.strip() for i in value.value.split(',')]
1133     props = {}
1134     keys = form.keys()
1135     properties = cl.getprops()
1136     for key in keys:
1137         if not properties.has_key(key):
1138             continue
1139         proptype = properties[key]
1141         # Get the form value. This value may be a MiniFieldStorage or a list
1142         # of MiniFieldStorages.
1143         value = form[key]
1145         # make sure non-multilinks only get one value
1146         if not isinstance(proptype, hyperdb.Multilink):
1147             if isinstance(value, type([])):
1148                 raise ValueError, 'You have submitted more than one value'\
1149                     ' for the %s property'%key
1150             # we've got a MiniFieldStorage, so pull out the value and strip
1151             # surrounding whitespace
1152             value = value.value.strip()
1154         if isinstance(proptype, hyperdb.String):
1155             if not value:
1156                 continue
1157             # fix the CRLF/CR -> LF stuff
1158             value = fixNewlines(value)
1159         elif isinstance(proptype, hyperdb.Password):
1160             if not value:
1161                 # ignore empty password values
1162                 continue
1163             if not form.has_key('%s:confirm'%key):
1164                 raise ValueError, 'Password and confirmation text do not match'
1165             confirm = form['%s:confirm'%key]
1166             if isinstance(confirm, type([])):
1167                 raise ValueError, 'You have submitted more than one value'\
1168                     ' for the %s property'%key
1169             if value != confirm.value:
1170                 raise ValueError, 'Password and confirmation text do not match'
1171             value = password.Password(value)
1172         elif isinstance(proptype, hyperdb.Date):
1173             if value:
1174                 value = date.Date(form[key].value.strip())
1175             else:
1176                 continue
1177         elif isinstance(proptype, hyperdb.Interval):
1178             if value:
1179                 value = date.Interval(form[key].value.strip())
1180             else:
1181                 continue
1182         elif isinstance(proptype, hyperdb.Link):
1183             # see if it's the "no selection" choice
1184             if value == '-1':
1185                 value = None
1186             else:
1187                 # handle key values
1188                 link = proptype.classname
1189                 if not num_re.match(value):
1190                     try:
1191                         value = db.classes[link].lookup(value)
1192                     except KeyError:
1193                         raise ValueError, _('property "%(propname)s": '
1194                             '%(value)s not a %(classname)s')%{'propname':key, 
1195                             'value': value, 'classname': link}
1196                     except TypeError, message:
1197                         raise ValueError, _('you may only enter ID values '
1198                             'for property "%(propname)s": %(message)s')%{
1199                             'propname':key, 'message': message}
1200         elif isinstance(proptype, hyperdb.Multilink):
1201             if isinstance(value, type([])):
1202                 # it's a list of MiniFieldStorages
1203                 value = [i.value.strip() for i in value]
1204             else:
1205                 # it's a MiniFieldStorage, but may be a comma-separated list
1206                 # of values
1207                 value = [i.strip() for i in value.value.split(',')]
1208             link = proptype.classname
1209             l = []
1210             for entry in map(str, value):
1211                 if entry == '': continue
1212                 if not num_re.match(entry):
1213                     try:
1214                         entry = db.classes[link].lookup(entry)
1215                     except KeyError:
1216                         raise ValueError, _('property "%(propname)s": '
1217                             '"%(value)s" not an entry of %(classname)s')%{
1218                             'propname':key, 'value': entry, 'classname': link}
1219                     except TypeError, message:
1220                         raise ValueError, _('you may only enter ID values '
1221                             'for property "%(propname)s": %(message)s')%{
1222                             'propname':key, 'message': message}
1223                 l.append(entry)
1224             l.sort()
1225             value = l
1226         elif isinstance(proptype, hyperdb.Boolean):
1227             props[key] = value = value.lower() in ('yes', 'true', 'on', '1')
1228         elif isinstance(proptype, hyperdb.Number):
1229             props[key] = value = int(value)
1231         # register this as received if required?
1232         if key in required and value is not None:
1233             required.remove(key)
1235         # get the old value
1236         if nodeid:
1237             try:
1238                 existing = cl.get(nodeid, key)
1239             except KeyError:
1240                 # this might be a new property for which there is no existing
1241                 # value
1242                 if not properties.has_key(key): raise
1244             # if changed, set it
1245             if value != existing:
1246                 props[key] = value
1247         else:
1248             props[key] = value
1250     # see if all the required properties have been supplied
1251     if required:
1252         if len(required) > 1:
1253             p = 'properties'
1254         else:
1255             p = 'property'
1256         raise ValueError, 'Required %s %s not supplied'%(p, ', '.join(required))
1258     return props