Code

8825350ae7b6e2d2e63c9f7e1582f1df0d5ea7ae
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.39 2002-09-18 06:33:06 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 getTemplate, 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     '''
52     A note about login
53     ------------------
55     If the user has no login cookie, then they are anonymous. There
56     are two levels of anonymous use. If there is no 'anonymous' user, there
57     is no login at all and the database is opened in read-only mode. If the
58     'anonymous' user exists, the user is logged in using that user (though
59     there is no cookie). This allows them to modify the database, and all
60     modifications are attributed to the 'anonymous' user.
62     Once a user logs in, they are assigned a session. The Client instance
63     keeps the nodeid of the session as the "session" attribute.
65     Client attributes:
66         "path" is the PATH_INFO inside the instance (with no leading '/')
67         "base" is the base URL for the instance
68     '''
70     def __init__(self, instance, request, env, form=None):
71         hyperdb.traceMark()
72         self.instance = instance
73         self.request = request
74         self.env = env
76         # save off the path
77         self.path = env['PATH_INFO']
79         # this is the base URL for this instance
80         self.base = self.instance.config.TRACKER_WEB
82         # see if we need to re-parse the environment for the form (eg Zope)
83         if form is None:
84             self.form = cgi.FieldStorage(environ=env)
85         else:
86             self.form = form
88         # turn debugging on/off
89         try:
90             self.debug = int(env.get("ROUNDUP_DEBUG", 0))
91         except ValueError:
92             # someone gave us a non-int debug level, turn it off
93             self.debug = 0
95         # flag to indicate that the HTTP headers have been sent
96         self.headers_done = 0
98         # additional headers to send with the request - must be registered
99         # before the first write
100         self.additional_headers = {}
101         self.response_code = 200
103     def main(self):
104         ''' Wrap the real main in a try/finally so we always close off the db.
105         '''
106         try:
107             self.inner_main()
108         finally:
109             if hasattr(self, 'db'):
110                 self.db.close()
112     def inner_main(self):
113         ''' Process a request.
115             The most common requests are handled like so:
116             1. figure out who we are, defaulting to the "anonymous" user
117                see determine_user
118             2. figure out what the request is for - the context
119                see determine_context
120             3. handle any requested action (item edit, search, ...)
121                see handle_action
122             4. render a template, resulting in HTML output
124             In some situations, exceptions occur:
125             - HTTP Redirect  (generally raised by an action)
126             - SendFile       (generally raised by determine_context)
127               serve up a FileClass "content" property
128             - SendStaticFile (generally raised by determine_context)
129               serve up a file from the tracker "html" directory
130             - Unauthorised   (generally raised by an action)
131               the action is cancelled, the request is rendered and an error
132               message is displayed indicating that permission was not
133               granted for the action to take place
134             - NotFound       (raised wherever it needs to be)
135               percolates up to the CGI interface that called the client
136         '''
137         self.content_action = None
138         self.ok_message = []
139         self.error_message = []
140         try:
141             # make sure we're identified (even anonymously)
142             self.determine_user()
143             # figure out the context and desired content template
144             self.determine_context()
145             # possibly handle a form submit action (may change self.classname
146             # and self.template, and may also append error/ok_messages)
147             self.handle_action()
148             # now render the page
150             # we don't want clients caching our dynamic pages
151             self.additional_headers['Cache-Control'] = 'no-cache'
152             self.additional_headers['Pragma'] = 'no-cache'
153             self.additional_headers['Expires'] = 'Thu, 1 Jan 1970 00:00:00 GMT'
155             if self.form.has_key(':contentonly'):
156                 # just the content
157                 self.write(self.content())
158             else:
159                 # render the content inside the page template
160                 self.write(self.renderTemplate('page', '',
161                     ok_message=self.ok_message,
162                     error_message=self.error_message))
163         except Redirect, url:
164             # let's redirect - if the url isn't None, then we need to do
165             # the headers, otherwise the headers have been set before the
166             # exception was raised
167             if url:
168                 self.additional_headers['Location'] = url
169                 self.response_code = 302
170             self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
171         except SendFile, designator:
172             self.serve_file(designator)
173         except SendStaticFile, file:
174             self.serve_static_file(str(file))
175         except Unauthorised, message:
176             self.write(self.renderTemplate('page', '', error_message=message))
177         except:
178             # everything else
179             self.write(cgitb.html())
181     def determine_user(self):
182         ''' Determine who the user is
183         '''
184         # determine the uid to use
185         self.opendb('admin')
187         # make sure we have the session Class
188         sessions = self.db.sessions
190         # age sessions, remove when they haven't been used for a week
191         # TODO: this shouldn't be done every access
192         week = 60*60*24*7
193         now = time.time()
194         for sessid in sessions.list():
195             interval = now - sessions.get(sessid, 'last_use')
196             if interval > week:
197                 sessions.destroy(sessid)
199         # look up the user session cookie
200         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
201         user = 'anonymous'
203         # bump the "revision" of the cookie since the format changed
204         if (cookie.has_key('roundup_user_2') and
205                 cookie['roundup_user_2'].value != 'deleted'):
207             # get the session key from the cookie
208             self.session = cookie['roundup_user_2'].value
209             # get the user from the session
210             try:
211                 # update the lifetime datestamp
212                 sessions.set(self.session, last_use=time.time())
213                 sessions.commit()
214                 user = sessions.get(self.session, 'user')
215             except KeyError:
216                 user = 'anonymous'
218         # sanity check on the user still being valid, getting the userid
219         # at the same time
220         try:
221             self.userid = self.db.user.lookup(user)
222         except (KeyError, TypeError):
223             user = 'anonymous'
225         # make sure the anonymous user is valid if we're using it
226         if user == 'anonymous':
227             self.make_user_anonymous()
228         else:
229             self.user = user
231         # reopen the database as the correct user
232         self.opendb(self.user)
234     def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
235         ''' Determine the context of this page from the URL:
237             The URL path after the instance identifier is examined. The path
238             is generally only one entry long.
240             - if there is no path, then we are in the "home" context.
241             * if the path is "_file", then the additional path entry
242               specifies the filename of a static file we're to serve up
243               from the instance "html" directory. Raises a SendStaticFile
244               exception.
245             - if there is something in the path (eg "issue"), it identifies
246               the tracker class we're to display.
247             - if the path is an item designator (eg "issue123"), then we're
248               to display a specific item.
249             * if the path starts with an item designator and is longer than
250               one entry, then we're assumed to be handling an item of a
251               FileClass, and the extra path information gives the filename
252               that the client is going to label the download with (ie
253               "file123/image.png" is nicer to download than "file123"). This
254               raises a SendFile exception.
256             Both of the "*" types of contexts stop before we bother to
257             determine the template we're going to use. That's because they
258             don't actually use templates.
260             The template used is specified by the :template CGI variable,
261             which defaults to:
263              only classname suplied:          "index"
264              full item designator supplied:   "item"
266             We set:
267              self.classname  - the class to display, can be None
268              self.template   - the template to render the current context with
269              self.nodeid     - the nodeid of the class we're displaying
270         '''
271         # default the optional variables
272         self.classname = None
273         self.nodeid = None
275         # determine the classname and possibly nodeid
276         path = self.path.split('/')
277         if not path or path[0] in ('', 'home', 'index'):
278             if self.form.has_key(':template'):
279                 self.template = self.form[':template'].value
280             else:
281                 self.template = ''
282             return
283         elif path[0] == '_file':
284             raise SendStaticFile, path[1]
285         else:
286             self.classname = path[0]
287             if len(path) > 1:
288                 # send the file identified by the designator in path[0]
289                 raise SendFile, path[0]
291         # see if we got a designator
292         m = dre.match(self.classname)
293         if m:
294             self.classname = m.group(1)
295             self.nodeid = m.group(2)
296             if not self.db.getclass(self.classname).hasnode(self.nodeid):
297                 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
298             # with a designator, we default to item view
299             self.template = 'item'
300         else:
301             # with only a class, we default to index view
302             self.template = 'index'
304         # see if we have a template override
305         if self.form.has_key(':template'):
306             self.template = self.form[':template'].value
308         # see if we were passed in a message
309         if self.form.has_key(':ok_message'):
310             self.ok_message.append(self.form[':ok_message'].value)
311         if self.form.has_key(':error_message'):
312             self.error_message.append(self.form[':error_message'].value)
314     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
315         ''' Serve the file from the content property of the designated item.
316         '''
317         m = dre.match(str(designator))
318         if not m:
319             raise NotFound, str(designator)
320         classname, nodeid = m.group(1), m.group(2)
321         if classname != 'file':
322             raise NotFound, designator
324         # we just want to serve up the file named
325         file = self.db.file
326         self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
327         self.write(file.get(nodeid, 'content'))
329     def serve_static_file(self, file):
330         # we just want to serve up the file named
331         mt = mimetypes.guess_type(str(file))[0]
332         self.additional_headers['Content-Type'] = mt
333         self.write(open(os.path.join(self.instance.config.TEMPLATES,
334             file)).read())
336     def renderTemplate(self, name, extension, **kwargs):
337         ''' Return a PageTemplate for the named page
338         '''
339         pt = getTemplate(self.instance.config.TEMPLATES, name, extension)
340         # catch errors so we can handle PT rendering errors more nicely
341         try:
342             # let the template render figure stuff out
343             return pt.render(self, None, None, **kwargs)
344         except PageTemplate.PTRuntimeError, message:
345             return '<strong>%s</strong><ol><li>%s</ol>'%(message,
346                 '<li>'.join([cgi.escape(x) for x in pt._v_errors]))
347         except NoTemplate, message:
348             return '<strong>%s</strong>'%message
349         except:
350             # everything else
351             return cgitb.pt_html()
353     def content(self):
354         ''' Callback used by the page template to render the content of 
355             the page.
357             If we don't have a specific class to display, that is none was
358             determined in determine_context(), then we display a "home"
359             template.
360         '''
361         # now render the page content using the template we determined in
362         # determine_context
363         if self.classname is None:
364             name = 'home'
365         else:
366             name = self.classname
367         return self.renderTemplate(self.classname, self.template)
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, password):
443         # TODO generate a much, much stronger session key ;)
444         self.session = binascii.b2a_base64(repr(random.random())).strip()
446         # clean up the base64
447         if self.session[-1] == '=':
448             if self.session[-2] == '=':
449                 self.session = self.session[:-2]
450             else:
451                 self.session = self.session[:-1]
453         # insert the session in the sessiondb
454         self.db.sessions.set(self.session, user=user, last_use=time.time())
456         # and commit immediately
457         self.db.sessions.commit()
459         # expire us in a long, long time
460         expire = Cookie._getdate(86400*365)
462         # generate the cookie path - make sure it has a trailing '/'
463         path = '/'.join((self.env['SCRIPT_NAME'], self.env['TRACKER_NAME'],
464             ''))
465         self.additional_headers['Set-Cookie'] = \
466           'roundup_user_2=%s; expires=%s; Path=%s;'%(self.session, expire, path)
468     def make_user_anonymous(self):
469         ''' Make us anonymous
471             This method used to handle non-existence of the 'anonymous'
472             user, but that user is mandatory now.
473         '''
474         self.userid = self.db.user.lookup('anonymous')
475         self.user = 'anonymous'
477     def logout(self):
478         ''' Make us really anonymous - nuke the cookie too
479         '''
480         self.make_user_anonymous()
482         # construct the logout cookie
483         now = Cookie._getdate()
484         path = '/'.join((self.env['SCRIPT_NAME'], self.env['TRACKER_NAME'],
485             ''))
486         self.additional_headers['Set-Cookie'] = \
487            'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)
488         self.login()
490     def opendb(self, user):
491         ''' Open the database.
492         '''
493         # open the db if the user has changed
494         if not hasattr(self, 'db') or user != self.db.journaltag:
495             if hasattr(self, 'db'):
496                 self.db.close()
497             self.db = self.instance.open(user)
499     #
500     # Actions
501     #
502     def loginAction(self):
503         ''' Attempt to log a user in.
505             Sets up a session for the user which contains the login
506             credentials.
507         '''
508         # we need the username at a minimum
509         if not self.form.has_key('__login_name'):
510             self.error_message.append(_('Username required'))
511             return
513         self.user = self.form['__login_name'].value
514         # re-open the database for real, using the user
515         self.opendb(self.user)
516         if self.form.has_key('__login_password'):
517             password = self.form['__login_password'].value
518         else:
519             password = ''
520         # make sure the user exists
521         try:
522             self.userid = self.db.user.lookup(self.user)
523         except KeyError:
524             name = self.user
525             self.make_user_anonymous()
526             self.error_message.append(_('No such user "%(name)s"')%locals())
527             return
529         # and that the password is correct
530         pw = self.db.user.get(self.userid, 'password')
531         if password != pw:
532             self.make_user_anonymous()
533             self.error_message.append(_('Incorrect password'))
534             return
536         # make sure we're allowed to be here
537         if not self.loginPermission():
538             self.make_user_anonymous()
539             raise Unauthorised, _("You do not have permission to login")
541         # set the session cookie
542         self.set_cookie(self.user, password)
544     def loginPermission(self):
545         ''' Determine whether the user has permission to log in.
547             Base behaviour is to check the user has "Web Access".
548         ''' 
549         if not self.db.security.hasPermission('Web Access', self.userid):
550             return 0
551         return 1
553     def logout_action(self):
554         ''' Make us really anonymous - nuke the cookie too
555         '''
556         # log us out
557         self.make_user_anonymous()
559         # construct the logout cookie
560         now = Cookie._getdate()
561         path = '/'.join((self.env['SCRIPT_NAME'], self.env['TRACKER_NAME'],
562             ''))
563         self.additional_headers['Set-Cookie'] = \
564            'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)
566         # Let the user know what's going on
567         self.ok_message.append(_('You are logged out'))
569     def registerAction(self):
570         '''Attempt to create a new user based on the contents of the form
571         and then set the cookie.
573         return 1 on successful login
574         '''
575         # create the new user
576         cl = self.db.user
578         # parse the props from the form
579         try:
580             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
581         except (ValueError, KeyError), message:
582             self.error_message.append(_('Error: ') + str(message))
583             return
585         # make sure we're allowed to register
586         if not self.registerPermission(props):
587             raise Unauthorised, _("You do not have permission to register")
589         # re-open the database as "admin"
590         if self.user != 'admin':
591             self.opendb('admin')
592             
593         # create the new user
594         cl = self.db.user
595         try:
596             props = parsePropsFromForm(self.db, cl, self.form)
597             props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
598             self.userid = cl.create(**props)
599             self.db.commit()
600         except ValueError, message:
601             self.error_message.append(message)
602             return
604         # log the new user in
605         self.user = cl.get(self.userid, 'username')
606         # re-open the database for real, using the user
607         self.opendb(self.user)
608         password = self.db.user.get(self.userid, 'password')
609         self.set_cookie(self.user, password)
611         # nice message
612         message = _('You are now registered, welcome!')
614         # redirect to the item's edit page
615         raise Redirect, '%s%s%s?:ok_message=%s'%(
616             self.base, self.classname, self.userid,  urllib.quote(message))
618     def registerPermission(self, props):
619         ''' Determine whether the user has permission to register
621             Base behaviour is to check the user has "Web Registration".
622         '''
623         # registration isn't allowed to supply roles
624         if props.has_key('roles'):
625             return 0
626         if self.db.security.hasPermission('Web Registration', self.userid):
627             return 1
628         return 0
630     def editItemAction(self):
631         ''' Perform an edit of an item in the database.
633             Some special form elements:
635             :link=designator:property
636             :multilink=designator:property
637              The value specifies a node designator and the property on that
638              node to add _this_ node to as a link or multilink.
639             :note
640              Create a message and attach it to the current node's
641              "messages" property.
642             :file
643              Create a file and attach it to the current node's
644              "files" property. Attach the file to the message created from
645              the :note if it's supplied.
647             :required=property,property,...
648              The named properties are required to be filled in the form.
650         '''
651         cl = self.db.classes[self.classname]
653         # parse the props from the form
654         try:
655             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
656         except (ValueError, KeyError), message:
657             self.error_message.append(_('Error: ') + str(message))
658             return
660         # check permission
661         if not self.editItemPermission(props):
662             self.error_message.append(
663                 _('You do not have permission to edit %(classname)s'%
664                 self.__dict__))
665             return
667         # perform the edit
668         try:
669             # make changes to the node
670             props = self._changenode(props)
671             # handle linked nodes 
672             self._post_editnode(self.nodeid)
673         except (ValueError, KeyError), message:
674             self.error_message.append(_('Error: ') + str(message))
675             return
677         # commit now that all the tricky stuff is done
678         self.db.commit()
680         # and some nice feedback for the user
681         if props:
682             message = _('%(changes)s edited ok')%{'changes':
683                 ', '.join(props.keys())}
684         elif self.form.has_key(':note') and self.form[':note'].value:
685             message = _('note added')
686         elif (self.form.has_key(':file') and self.form[':file'].filename):
687             message = _('file added')
688         else:
689             message = _('nothing changed')
691         # redirect to the item's edit page
692         raise Redirect, '%s%s%s?:ok_message=%s'%(self.base, self.classname,
693             self.nodeid,  urllib.quote(message))
695     def editItemPermission(self, props):
696         ''' Determine whether the user has permission to edit this item.
698             Base behaviour is to check the user can edit this class. If we're
699             editing the "user" class, users are allowed to edit their own
700             details. Unless it's the "roles" property, which requires the
701             special Permission "Web Roles".
702         '''
703         # if this is a user node and the user is editing their own node, then
704         # we're OK
705         has = self.db.security.hasPermission
706         if self.classname == 'user':
707             # reject if someone's trying to edit "roles" and doesn't have the
708             # right permission.
709             if props.has_key('roles') and not has('Web Roles', self.userid,
710                     'user'):
711                 return 0
712             # if the item being edited is the current user, we're ok
713             if self.nodeid == self.userid:
714                 return 1
715         if self.db.security.hasPermission('Edit', self.userid, self.classname):
716             return 1
717         return 0
719     def newItemAction(self):
720         ''' Add a new item to the database.
722             This follows the same form as the editItemAction, with the same
723             special form values.
724         '''
725         cl = self.db.classes[self.classname]
727         # parse the props from the form
728         try:
729             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
730         except (ValueError, KeyError), message:
731             self.error_message.append(_('Error: ') + str(message))
732             return
734         if not self.newItemPermission(props):
735             self.error_message.append(
736                 _('You do not have permission to create %s' %self.classname))
738         # create a little extra message for anticipated :link / :multilink
739         if self.form.has_key(':multilink'):
740             link = self.form[':multilink'].value
741         elif self.form.has_key(':link'):
742             link = self.form[':multilink'].value
743         else:
744             link = None
745             xtra = ''
746         if link:
747             designator, linkprop = link.split(':')
748             xtra = ' for <a href="%s">%s</a>'%(designator, designator)
750         try:
751             # do the create
752             nid = self._createnode(props)
754             # handle linked nodes 
755             self._post_editnode(nid)
757             # commit now that all the tricky stuff is done
758             self.db.commit()
760             # render the newly created item
761             self.nodeid = nid
763             # and some nice feedback for the user
764             message = _('%(classname)s created ok')%self.__dict__ + xtra
765         except (ValueError, KeyError), message:
766             self.error_message.append(_('Error: ') + str(message))
767             return
768         except:
769             # oops
770             self.db.rollback()
771             s = StringIO.StringIO()
772             traceback.print_exc(None, s)
773             self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
774             return
776         # redirect to the new item's page
777         raise Redirect, '%s%s%s?:ok_message=%s'%(self.base, self.classname,
778             nid,  urllib.quote(message))
780     def newItemPermission(self, props):
781         ''' Determine whether the user has permission to create (edit) this
782             item.
784             Base behaviour is to check the user can edit this class. No
785             additional property checks are made. Additionally, new user items
786             may be created if the user has the "Web Registration" Permission.
787         '''
788         has = self.db.security.hasPermission
789         if self.classname == 'user' and has('Web Registration', self.userid,
790                 'user'):
791             return 1
792         if has('Edit', self.userid, self.classname):
793             return 1
794         return 0
796     def editCSVAction(self):
797         ''' Performs an edit of all of a class' items in one go.
799             The "rows" CGI var defines the CSV-formatted entries for the
800             class. New nodes are identified by the ID 'X' (or any other
801             non-existent ID) and removed lines are retired.
802         '''
803         # this is per-class only
804         if not self.editCSVPermission():
805             self.error_message.append(
806                 _('You do not have permission to edit %s' %self.classname))
808         # get the CSV module
809         try:
810             import csv
811         except ImportError:
812             self.error_message.append(_(
813                 'Sorry, you need the csv module to use this function.<br>\n'
814                 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
815             return
817         cl = self.db.classes[self.classname]
818         idlessprops = cl.getprops(protected=0).keys()
819         idlessprops.sort()
820         props = ['id'] + idlessprops
822         # do the edit
823         rows = self.form['rows'].value.splitlines()
824         p = csv.parser()
825         found = {}
826         line = 0
827         for row in rows[1:]:
828             line += 1
829             values = p.parse(row)
830             # not a complete row, keep going
831             if not values: continue
833             # skip property names header
834             if values == props:
835                 continue
837             # extract the nodeid
838             nodeid, values = values[0], values[1:]
839             found[nodeid] = 1
841             # confirm correct weight
842             if len(idlessprops) != len(values):
843                 self.error_message.append(
844                     _('Not enough values on line %(line)s')%{'line':line})
845                 return
847             # extract the new values
848             d = {}
849             for name, value in zip(idlessprops, values):
850                 value = value.strip()
851                 # only add the property if it has a value
852                 if value:
853                     # if it's a multilink, split it
854                     if isinstance(cl.properties[name], hyperdb.Multilink):
855                         value = value.split(':')
856                     d[name] = value
858             # perform the edit
859             if cl.hasnode(nodeid):
860                 # edit existing
861                 cl.set(nodeid, **d)
862             else:
863                 # new node
864                 found[cl.create(**d)] = 1
866         # retire the removed entries
867         for nodeid in cl.list():
868             if not found.has_key(nodeid):
869                 cl.retire(nodeid)
871         # all OK
872         self.db.commit()
874         self.ok_message.append(_('Items edited OK'))
876     def editCSVPermission(self):
877         ''' Determine whether the user has permission to edit this class.
879             Base behaviour is to check the user can edit this class.
880         ''' 
881         if not self.db.security.hasPermission('Edit', self.userid,
882                 self.classname):
883             return 0
884         return 1
886     def searchAction(self):
887         ''' Mangle some of the form variables.
889             Set the form ":filter" variable based on the values of the
890             filter variables - if they're set to anything other than
891             "dontcare" then add them to :filter.
893             Also handle the ":queryname" variable and save off the query to
894             the user's query list.
895         '''
896         # generic edit is per-class only
897         if not self.searchPermission():
898             self.error_message.append(
899                 _('You do not have permission to search %s' %self.classname))
901         # add a faked :filter form variable for each filtering prop
902         props = self.db.classes[self.classname].getprops()
903         for key in self.form.keys():
904             if not props.has_key(key): continue
905             if not self.form[key].value: continue
906             self.form.value.append(cgi.MiniFieldStorage(':filter', key))
908         # handle saving the query params
909         if self.form.has_key(':queryname'):
910             queryname = self.form[':queryname'].value.strip()
911             if queryname:
912                 # parse the environment and figure what the query _is_
913                 req = HTMLRequest(self)
914                 url = req.indexargs_href('', {})
916                 # handle editing an existing query
917                 try:
918                     qid = self.db.query.lookup(queryname)
919                     self.db.query.set(qid, klass=self.classname, url=url)
920                 except KeyError:
921                     # create a query
922                     qid = self.db.query.create(name=queryname,
923                         klass=self.classname, url=url)
925                     # and add it to the user's query multilink
926                     queries = self.db.user.get(self.userid, 'queries')
927                     queries.append(qid)
928                     self.db.user.set(self.userid, queries=queries)
930                 # commit the query change to the database
931                 self.db.commit()
933     def searchPermission(self):
934         ''' Determine whether the user has permission to search this class.
936             Base behaviour is to check the user can view this class.
937         ''' 
938         if not self.db.security.hasPermission('View', self.userid,
939                 self.classname):
940             return 0
941         return 1
943     def remove_action(self,  dre=re.compile(r'([^\d]+)(\d+)')):
944         # XXX I believe this could be handled by a regular edit action that
945         # just sets the multilink...
946         target = self.index_arg(':target')[0]
947         m = dre.match(target)
948         if m:
949             classname = m.group(1)
950             nodeid = m.group(2)
951             cl = self.db.getclass(classname)
952             cl.retire(nodeid)
953             # now take care of the reference
954             parentref =  self.index_arg(':multilink')[0]
955             parent, prop = parentref.split(':')
956             m = dre.match(parent)
957             if m:
958                 self.classname = m.group(1)
959                 self.nodeid = m.group(2)
960                 cl = self.db.getclass(self.classname)
961                 value = cl.get(self.nodeid, prop)
962                 value.remove(nodeid)
963                 cl.set(self.nodeid, **{prop:value})
964                 func = getattr(self, 'show%s'%self.classname)
965                 return func()
966             else:
967                 raise NotFound, parent
968         else:
969             raise NotFound, target
971     #
972     #  Utility methods for editing
973     #
974     def _changenode(self, props):
975         ''' change the node based on the contents of the form
976         '''
977         cl = self.db.classes[self.classname]
979         # create the message
980         message, files = self._handle_message()
981         if message:
982             props['messages'] = cl.get(self.nodeid, 'messages') + [message]
983         if files:
984             props['files'] = cl.get(self.nodeid, 'files') + files
986         # make the changes
987         return cl.set(self.nodeid, **props)
989     def _createnode(self, props):
990         ''' create a node based on the contents of the form
991         '''
992         cl = self.db.classes[self.classname]
994         # check for messages and files
995         message, files = self._handle_message()
996         if message:
997             props['messages'] = [message]
998         if files:
999             props['files'] = files
1000         # create the node and return it's id
1001         return cl.create(**props)
1003     def _handle_message(self):
1004         ''' generate an edit message
1005         '''
1006         # handle file attachments 
1007         files = []
1008         if self.form.has_key(':file'):
1009             file = self.form[':file']
1010             if file.filename:
1011                 filename = file.filename.split('\\')[-1]
1012                 mime_type = mimetypes.guess_type(filename)[0]
1013                 if not mime_type:
1014                     mime_type = "application/octet-stream"
1015                 # create the new file entry
1016                 files.append(self.db.file.create(type=mime_type,
1017                     name=filename, content=file.file.read()))
1019         # we don't want to do a message if none of the following is true...
1020         cn = self.classname
1021         cl = self.db.classes[self.classname]
1022         props = cl.getprops()
1023         note = None
1024         # in a nutshell, don't do anything if there's no note or there's no
1025         # NOSY
1026         if self.form.has_key(':note'):
1027             note = self.form[':note'].value.strip()
1028         if not note:
1029             return None, files
1030         if not props.has_key('messages'):
1031             return None, files
1032         if not isinstance(props['messages'], hyperdb.Multilink):
1033             return None, files
1034         if not props['messages'].classname == 'msg':
1035             return None, files
1036         if not (self.form.has_key('nosy') or note):
1037             return None, files
1039         # handle the note
1040         if '\n' in note:
1041             summary = re.split(r'\n\r?', note)[0]
1042         else:
1043             summary = note
1044         m = ['%s\n'%note]
1046         # handle the messageid
1047         # TODO: handle inreplyto
1048         messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
1049             self.classname, self.instance.config.MAIL_DOMAIN)
1051         # now create the message, attaching the files
1052         content = '\n'.join(m)
1053         message_id = self.db.msg.create(author=self.userid,
1054             recipients=[], date=date.Date('.'), summary=summary,
1055             content=content, files=files, messageid=messageid)
1057         # update the messages property
1058         return message_id, files
1060     def _post_editnode(self, nid):
1061         '''Do the linking part of the node creation.
1063            If a form element has :link or :multilink appended to it, its
1064            value specifies a node designator and the property on that node
1065            to add _this_ node to as a link or multilink.
1067            This is typically used on, eg. the file upload page to indicated
1068            which issue to link the file to.
1070            TODO: I suspect that this and newfile will go away now that
1071            there's the ability to upload a file using the issue :file form
1072            element!
1073         '''
1074         cn = self.classname
1075         cl = self.db.classes[cn]
1076         # link if necessary
1077         keys = self.form.keys()
1078         for key in keys:
1079             if key == ':multilink':
1080                 value = self.form[key].value
1081                 if type(value) != type([]): value = [value]
1082                 for value in value:
1083                     designator, property = value.split(':')
1084                     link, nodeid = hyperdb.splitDesignator(designator)
1085                     link = self.db.classes[link]
1086                     # take a dupe of the list so we're not changing the cache
1087                     value = link.get(nodeid, property)[:]
1088                     value.append(nid)
1089                     link.set(nodeid, **{property: value})
1090             elif key == ':link':
1091                 value = self.form[key].value
1092                 if type(value) != type([]): value = [value]
1093                 for value in value:
1094                     designator, property = value.split(':')
1095                     link, nodeid = hyperdb.splitDesignator(designator)
1096                     link = self.db.classes[link]
1097                     link.set(nodeid, **{property: nid})
1100 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
1101     ''' Pull properties for the given class out of the form.
1103         If a ":required" parameter is supplied, then the names property values
1104         must be supplied or a ValueError will be raised.
1105     '''
1106     required = []
1107     if form.has_key(':required'):
1108         value = form[':required']
1109         if isinstance(value, type([])):
1110             required = [i.value.strip() for i in value]
1111         else:
1112             required = [i.strip() for i in value.value.split(',')]
1114     props = {}
1115     keys = form.keys()
1116     properties = cl.getprops()
1117     for key in keys:
1118         if not properties.has_key(key):
1119             continue
1120         proptype = properties[key]
1122         # Get the form value. This value may be a MiniFieldStorage or a list
1123         # of MiniFieldStorages.
1124         value = form[key]
1126         # make sure non-multilinks only get one value
1127         if not isinstance(proptype, hyperdb.Multilink):
1128             if isinstance(value, type([])):
1129                 raise ValueError, 'You have submitted more than one value'\
1130                     ' for the %s property'%key
1131             # we've got a MiniFieldStorage, so pull out the value and strip
1132             # surrounding whitespace
1133             value = value.value.strip()
1135         if isinstance(proptype, hyperdb.String):
1136             if not value:
1137                 continue
1138         elif isinstance(proptype, hyperdb.Password):
1139             if not value:
1140                 # ignore empty password values
1141                 continue
1142             if not form.has_key('%s:confirm'%key):
1143                 raise ValueError, 'Password and confirmation text do not match'
1144             confirm = form['%s:confirm'%key]
1145             if isinstance(confirm, type([])):
1146                 raise ValueError, 'You have submitted more than one value'\
1147                     ' for the %s property'%key
1148             if value != confirm.value:
1149                 raise ValueError, 'Password and confirmation text do not match'
1150             value = password.Password(value)
1151         elif isinstance(proptype, hyperdb.Date):
1152             if value:
1153                 value = date.Date(form[key].value.strip())
1154             else:
1155                 value = None
1156         elif isinstance(proptype, hyperdb.Interval):
1157             if value:
1158                 value = date.Interval(form[key].value.strip())
1159             else:
1160                 value = None
1161         elif isinstance(proptype, hyperdb.Link):
1162             # see if it's the "no selection" choice
1163             if value == '-1':
1164                 value = None
1165             else:
1166                 # handle key values
1167                 link = proptype.classname
1168                 if not num_re.match(value):
1169                     try:
1170                         value = db.classes[link].lookup(value)
1171                     except KeyError:
1172                         raise ValueError, _('property "%(propname)s": '
1173                             '%(value)s not a %(classname)s')%{'propname':key, 
1174                             'value': value, 'classname': link}
1175                     except TypeError, message:
1176                         raise ValueError, _('you may only enter ID values '
1177                             'for property "%(propname)s": %(message)s')%{
1178                             'propname':key, 'message': message}
1179         elif isinstance(proptype, hyperdb.Multilink):
1180             if isinstance(value, type([])):
1181                 # it's a list of MiniFieldStorages
1182                 value = [i.value.strip() for i in value]
1183             else:
1184                 # it's a MiniFieldStorage, but may be a comma-separated list
1185                 # of values
1186                 value = [i.strip() for i in value.value.split(',')]
1187             link = proptype.classname
1188             l = []
1189             for entry in map(str, value):
1190                 if entry == '': continue
1191                 if not num_re.match(entry):
1192                     try:
1193                         entry = db.classes[link].lookup(entry)
1194                     except KeyError:
1195                         raise ValueError, _('property "%(propname)s": '
1196                             '"%(value)s" not an entry of %(classname)s')%{
1197                             'propname':key, 'value': entry, 'classname': link}
1198                     except TypeError, message:
1199                         raise ValueError, _('you may only enter ID values '
1200                             'for property "%(propname)s": %(message)s')%{
1201                             'propname':key, 'message': message}
1202                 l.append(entry)
1203             l.sort()
1204             value = l
1205         elif isinstance(proptype, hyperdb.Boolean):
1206             props[key] = value = value.lower() in ('yes', 'true', 'on', '1')
1207         elif isinstance(proptype, hyperdb.Number):
1208             props[key] = value = int(value)
1210         # register this as received if required
1211         if key in required:
1212             required.remove(key)
1214         # get the old value
1215         if nodeid:
1216             try:
1217                 existing = cl.get(nodeid, key)
1218             except KeyError:
1219                 # this might be a new property for which there is no existing
1220                 # value
1221                 if not properties.has_key(key): raise
1223             # if changed, set it
1224             if value != existing:
1225                 props[key] = value
1226         else:
1227             props[key] = value
1229     # see if all the required properties have been supplied
1230     if required:
1231         if len(required) > 1:
1232             p = 'properties'
1233         else:
1234             p = 'property'
1235         raise ValueError, 'Required %s %s not supplied'%(p, ', '.join(required))
1237     return props