Code

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