Code

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