Code

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