Code

170d86bc87a4cd03b0e4a35e3fcebc3e4f6cb417
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.66 2003-01-11 23:52:28 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, IndexError), 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)
774         except (ValueError, KeyError, IndexError), message:
775             # these errors might just be indicative of user dumbness
776             self.error_message.append(_('Error: ') + str(message))
777             return
778         except:
779             # oops
780             self.db.rollback()
781             s = StringIO.StringIO()
782             traceback.print_exc(None, s)
783             self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
784             return
786         try:
787             # handle linked nodes 
788             self._post_editnode(nid)
790             # commit now that all the tricky stuff is done
791             self.db.commit()
793             # render the newly created item
794             self.nodeid = nid
796             # and some nice feedback for the user
797             message = _('%(classname)s created ok')%self.__dict__ + xtra
798         except:
799             # oops
800             self.db.rollback()
801             s = StringIO.StringIO()
802             traceback.print_exc(None, s)
803             self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
804             return
806         # redirect to the new item's page
807         raise Redirect, '%s%s%s?:ok_message=%s'%(self.base, self.classname,
808             nid,  urllib.quote(message))
810     def newItemPermission(self, props):
811         ''' Determine whether the user has permission to create (edit) this
812             item.
814             Base behaviour is to check the user can edit this class. No
815             additional property checks are made. Additionally, new user items
816             may be created if the user has the "Web Registration" Permission.
817         '''
818         has = self.db.security.hasPermission
819         if self.classname == 'user' and has('Web Registration', self.userid,
820                 'user'):
821             return 1
822         if has('Edit', self.userid, self.classname):
823             return 1
824         return 0
826     def editCSVAction(self):
827         ''' Performs an edit of all of a class' items in one go.
829             The "rows" CGI var defines the CSV-formatted entries for the
830             class. New nodes are identified by the ID 'X' (or any other
831             non-existent ID) and removed lines are retired.
832         '''
833         # this is per-class only
834         if not self.editCSVPermission():
835             self.error_message.append(
836                 _('You do not have permission to edit %s' %self.classname))
838         # get the CSV module
839         try:
840             import csv
841         except ImportError:
842             self.error_message.append(_(
843                 'Sorry, you need the csv module to use this function.<br>\n'
844                 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
845             return
847         cl = self.db.classes[self.classname]
848         idlessprops = cl.getprops(protected=0).keys()
849         idlessprops.sort()
850         props = ['id'] + idlessprops
852         # do the edit
853         rows = self.form['rows'].value.splitlines()
854         p = csv.parser()
855         found = {}
856         line = 0
857         for row in rows[1:]:
858             line += 1
859             values = p.parse(row)
860             # not a complete row, keep going
861             if not values: continue
863             # skip property names header
864             if values == props:
865                 continue
867             # extract the nodeid
868             nodeid, values = values[0], values[1:]
869             found[nodeid] = 1
871             # confirm correct weight
872             if len(idlessprops) != len(values):
873                 self.error_message.append(
874                     _('Not enough values on line %(line)s')%{'line':line})
875                 return
877             # extract the new values
878             d = {}
879             for name, value in zip(idlessprops, values):
880                 value = value.strip()
881                 # only add the property if it has a value
882                 if value:
883                     # if it's a multilink, split it
884                     if isinstance(cl.properties[name], hyperdb.Multilink):
885                         value = value.split(':')
886                     d[name] = value
888             # perform the edit
889             if cl.hasnode(nodeid):
890                 # edit existing
891                 cl.set(nodeid, **d)
892             else:
893                 # new node
894                 found[cl.create(**d)] = 1
896         # retire the removed entries
897         for nodeid in cl.list():
898             if not found.has_key(nodeid):
899                 cl.retire(nodeid)
901         # all OK
902         self.db.commit()
904         self.ok_message.append(_('Items edited OK'))
906     def editCSVPermission(self):
907         ''' Determine whether the user has permission to edit this class.
909             Base behaviour is to check the user can edit this class.
910         ''' 
911         if not self.db.security.hasPermission('Edit', self.userid,
912                 self.classname):
913             return 0
914         return 1
916     def searchAction(self):
917         ''' Mangle some of the form variables.
919             Set the form ":filter" variable based on the values of the
920             filter variables - if they're set to anything other than
921             "dontcare" then add them to :filter.
923             Also handle the ":queryname" variable and save off the query to
924             the user's query list.
925         '''
926         # generic edit is per-class only
927         if not self.searchPermission():
928             self.error_message.append(
929                 _('You do not have permission to search %s' %self.classname))
931         # add a faked :filter form variable for each filtering prop
932         props = self.db.classes[self.classname].getprops()
933         for key in self.form.keys():
934             if not props.has_key(key): continue
935             if not self.form[key].value: continue
936             self.form.value.append(cgi.MiniFieldStorage(':filter', key))
938         # handle saving the query params
939         if self.form.has_key(':queryname'):
940             queryname = self.form[':queryname'].value.strip()
941             if queryname:
942                 # parse the environment and figure what the query _is_
943                 req = HTMLRequest(self)
944                 url = req.indexargs_href('', {})
946                 # handle editing an existing query
947                 try:
948                     qid = self.db.query.lookup(queryname)
949                     self.db.query.set(qid, klass=self.classname, url=url)
950                 except KeyError:
951                     # create a query
952                     qid = self.db.query.create(name=queryname,
953                         klass=self.classname, url=url)
955                     # and add it to the user's query multilink
956                     queries = self.db.user.get(self.userid, 'queries')
957                     queries.append(qid)
958                     self.db.user.set(self.userid, queries=queries)
960                 # commit the query change to the database
961                 self.db.commit()
963     def searchPermission(self):
964         ''' Determine whether the user has permission to search this class.
966             Base behaviour is to check the user can view this class.
967         ''' 
968         if not self.db.security.hasPermission('View', self.userid,
969                 self.classname):
970             return 0
971         return 1
973     def retireAction(self):
974         ''' Retire the context item.
975         '''
976         # if we want to view the index template now, then unset the nodeid
977         # context info (a special-case for retire actions on the index page)
978         nodeid = self.nodeid
979         if self.template == 'index':
980             self.nodeid = None
982         # generic edit is per-class only
983         if not self.retirePermission():
984             self.error_message.append(
985                 _('You do not have permission to retire %s' %self.classname))
986             return
988         # make sure we don't try to retire admin or anonymous
989         if self.classname == 'user' and \
990                 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
991             self.error_message.append(
992                 _('You may not retire the admin or anonymous user'))
993             return
995         # do the retire
996         self.db.getclass(self.classname).retire(nodeid)
997         self.db.commit()
999         self.ok_message.append(
1000             _('%(classname)s %(itemid)s has been retired')%{
1001                 'classname': self.classname.capitalize(), 'itemid': nodeid})
1003     def retirePermission(self):
1004         ''' Determine whether the user has permission to retire this class.
1006             Base behaviour is to check the user can edit this class.
1007         ''' 
1008         if not self.db.security.hasPermission('Edit', self.userid,
1009                 self.classname):
1010             return 0
1011         return 1
1014     #
1015     #  Utility methods for editing
1016     #
1017     def _changenode(self, props):
1018         ''' change the node based on the contents of the form
1019         '''
1020         cl = self.db.classes[self.classname]
1022         # create the message
1023         message, files = self._handle_message()
1024         if message:
1025             props['messages'] = cl.get(self.nodeid, 'messages') + [message]
1026         if files:
1027             props['files'] = cl.get(self.nodeid, 'files') + files
1029         # make the changes
1030         return cl.set(self.nodeid, **props)
1032     def _createnode(self, props):
1033         ''' create a node based on the contents of the form
1034         '''
1035         cl = self.db.classes[self.classname]
1037         # check for messages and files
1038         message, files = self._handle_message()
1039         if message:
1040             props['messages'] = [message]
1041         if files:
1042             props['files'] = files
1043         # create the node and return it's id
1044         return cl.create(**props)
1046     def _handle_message(self):
1047         ''' generate an edit message
1048         '''
1049         # handle file attachments 
1050         files = []
1051         if self.form.has_key(':file'):
1052             file = self.form[':file']
1054             # if there's a filename, then we create a file
1055             if file.filename:
1056                 # see if there are any file properties we should set
1057                 file_props={};
1058                 if self.form.has_key(':file_fields'):
1059                     for field in self.form[':file_fields'].value.split(','):
1060                         if self.form.has_key(field):
1061                             if field.startswith("file_"):
1062                                 file_props[field[5:]] = self.form[field].value
1063                             else :
1064                                 file_props[field] = self.form[field].value
1066                 # try to determine the file content-type
1067                 filename = file.filename.split('\\')[-1]
1068                 mime_type = mimetypes.guess_type(filename)[0]
1069                 if not mime_type:
1070                     mime_type = "application/octet-stream"
1072                 # create the new file entry
1073                 files.append(self.db.file.create(type=mime_type,
1074                     name=filename, content=file.file.read(), **file_props))
1076         # we don't want to do a message if none of the following is true...
1077         cn = self.classname
1078         cl = self.db.classes[self.classname]
1079         props = cl.getprops()
1080         note = None
1081         # in a nutshell, don't do anything if there's no note or there's no
1082         # NOSY
1083         if self.form.has_key(':note'):
1084             # fix the CRLF/CR -> LF stuff
1085             note = fixNewlines(self.form[':note'].value.strip())
1086         if not note:
1087             return None, files
1088         if not props.has_key('messages'):
1089             return None, files
1090         if not isinstance(props['messages'], hyperdb.Multilink):
1091             return None, files
1092         if not props['messages'].classname == 'msg':
1093             return None, files
1094         if not (self.form.has_key('nosy') or note):
1095             return None, files
1097         # handle the note
1098         if '\n' in note:
1099             summary = re.split(r'\n\r?', note)[0]
1100         else:
1101             summary = note
1102         m = ['%s\n'%note]
1104         # handle the messageid
1105         # TODO: handle inreplyto
1106         messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
1107             self.classname, self.instance.config.MAIL_DOMAIN)
1109         # see if there are any message properties we should set
1110         msg_props={};
1111         if self.form.has_key(':msg_fields'):
1112             for field in self.form[':msg_fields'].value.split(','):
1113                 if self.form.has_key(field):
1114                     if field.startswith("msg_"):
1115                         msg_props[field[4:]] = self.form[field].value
1116                     else :
1117                         msg_props[field] = self.form[field].value
1119         # now create the message, attaching the files
1120         content = '\n'.join(m)
1121         message_id = self.db.msg.create(author=self.userid,
1122             recipients=[], date=date.Date('.'), summary=summary,
1123             content=content, files=files, messageid=messageid, **msg_props)
1125         # update the messages property
1126         return message_id, files
1128     def _post_editnode(self, nid):
1129         '''Do the linking part of the node creation.
1131            If a form element has :link or :multilink appended to it, its
1132            value specifies a node designator and the property on that node
1133            to add _this_ node to as a link or multilink.
1135            This is typically used on, eg. the file upload page to indicated
1136            which issue to link the file to.
1138            TODO: I suspect that this and newfile will go away now that
1139            there's the ability to upload a file using the issue :file form
1140            element!
1141         '''
1142         cn = self.classname
1143         cl = self.db.classes[cn]
1144         # link if necessary
1145         keys = self.form.keys()
1146         for key in keys:
1147             if key == ':multilink':
1148                 value = self.form[key].value
1149                 if type(value) != type([]): value = [value]
1150                 for value in value:
1151                     designator, property = value.split(':')
1152                     link, nodeid = hyperdb.splitDesignator(designator)
1153                     link = self.db.classes[link]
1154                     # take a dupe of the list so we're not changing the cache
1155                     value = link.get(nodeid, property)[:]
1156                     value.append(nid)
1157                     link.set(nodeid, **{property: value})
1158             elif key == ':link':
1159                 value = self.form[key].value
1160                 if type(value) != type([]): value = [value]
1161                 for value in value:
1162                     designator, property = value.split(':')
1163                     link, nodeid = hyperdb.splitDesignator(designator)
1164                     link = self.db.classes[link]
1165                     link.set(nodeid, **{property: nid})
1167 def fixNewlines(text):
1168     ''' Homogenise line endings.
1170         Different web clients send different line ending values, but
1171         other systems (eg. email) don't necessarily handle those line
1172         endings. Our solution is to convert all line endings to LF.
1173     '''
1174     text = text.replace('\r\n', '\n')
1175     return text.replace('\r', '\n')
1177 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
1178     ''' Pull properties for the given class out of the form.
1180         If a ":required" parameter is supplied, then the names property values
1181         must be supplied or a ValueError will be raised.
1183         Other special form values:
1184          :remove:<propname>=id(s)
1185           The ids will be removed from the multilink property.
1186          :add:<propname>=id(s)
1187           The ids will be added to the multilink property.
1188     '''
1189     required = []
1190     if form.has_key(':required'):
1191         value = form[':required']
1192         if isinstance(value, type([])):
1193             required = [i.value.strip() for i in value]
1194         else:
1195             required = [i.strip() for i in value.value.split(',')]
1197     props = {}
1198     keys = form.keys()
1199     properties = cl.getprops()
1200     existing_cache = {}
1201     for key in keys:
1202         # see if we're performing a special multilink action
1203         mlaction = 'set'
1204         if key.startswith(':remove:'):
1205             propname = key[8:]
1206             mlaction = 'remove'
1207         elif key.startswith(':add:'):
1208             propname = key[5:]
1209             mlaction = 'add'
1210         else:
1211             propname = key
1213         # does the property exist?
1214         if not properties.has_key(propname):
1215             if mlaction == 'remove':
1216                 raise ValueError, 'You have submitted a remove action for'\
1217                     ' the property "%s" which doesn\'t exist'%propname
1218             continue
1219         proptype = properties[propname]
1221         # Get the form value. This value may be a MiniFieldStorage or a list
1222         # of MiniFieldStorages.
1223         value = form[key]
1225         # handle unpacking of the MiniFieldStorage / list form value
1226         if isinstance(proptype, hyperdb.Multilink):
1227             # multiple values are OK
1228             if isinstance(value, type([])):
1229                 # it's a list of MiniFieldStorages
1230                 value = [i.value.strip() for i in value]
1231             else:
1232                 # it's a MiniFieldStorage, but may be a comma-separated list
1233                 # of values
1234                 value = [i.strip() for i in value.value.split(',')]
1235         else:
1236             # multiple values are not OK
1237             if isinstance(value, type([])):
1238                 raise ValueError, 'You have submitted more than one value'\
1239                     ' for the %s property'%propname
1240             # we've got a MiniFieldStorage, so pull out the value and strip
1241             # surrounding whitespace
1242             value = value.value.strip()
1244         if isinstance(proptype, hyperdb.String):
1245             if not value:
1246                 continue
1247             # fix the CRLF/CR -> LF stuff
1248             value = fixNewlines(value)
1249         elif isinstance(proptype, hyperdb.Password):
1250             if not value:
1251                 # ignore empty password values
1252                 continue
1253             if not form.has_key('%s:confirm'%propname):
1254                 raise ValueError, 'Password and confirmation text do not match'
1255             confirm = form['%s:confirm'%propname]
1256             if isinstance(confirm, type([])):
1257                 raise ValueError, 'You have submitted more than one value'\
1258                     ' for the %s property'%propname
1259             if value != confirm.value:
1260                 raise ValueError, 'Password and confirmation text do not match'
1261             value = password.Password(value)
1262         elif isinstance(proptype, hyperdb.Date):
1263             if value:
1264                 value = date.Date(value)
1265             else:
1266                 value = None
1267         elif isinstance(proptype, hyperdb.Interval):
1268             if value:
1269                 value = date.Interval(value)
1270             else:
1271                 value = None
1272         elif isinstance(proptype, hyperdb.Link):
1273             # see if it's the "no selection" choice
1274             if value == '-1':
1275                 # if we're creating, just don't include this property
1276                 if not nodeid:
1277                     continue
1278                 value = None
1279             else:
1280                 # handle key values
1281                 link = proptype.classname
1282                 if not num_re.match(value):
1283                     try:
1284                         value = db.classes[link].lookup(value)
1285                     except KeyError:
1286                         raise ValueError, _('property "%(propname)s": '
1287                             '%(value)s not a %(classname)s')%{
1288                             'propname': propname, 'value': value,
1289                             'classname': link}
1290                     except TypeError, message:
1291                         raise ValueError, _('you may only enter ID values '
1292                             'for property "%(propname)s": %(message)s')%{
1293                             'propname': propname, 'message': message}
1294         elif isinstance(proptype, hyperdb.Multilink):
1295             # perform link class key value lookup if necessary
1296             link = proptype.classname
1297             l = []
1298             for entry in value:
1299                 if not entry: continue
1300                 if not num_re.match(entry):
1301                     try:
1302                         entry = db.classes[link].lookup(entry)
1303                     except KeyError:
1304                         raise ValueError, _('property "%(propname)s": '
1305                             '"%(value)s" not an entry of %(classname)s')%{
1306                             'propname': propname, 'value': entry,
1307                             'classname': link}
1308                     except TypeError, message:
1309                         raise ValueError, _('you may only enter ID values '
1310                             'for property "%(propname)s": %(message)s')%{
1311                             'propname': propname, 'message': message}
1312                 l.append(entry)
1313             l.sort()
1315             # now use that list of ids to modify the multilink
1316             if mlaction == 'set':
1317                 value = l
1318             else:
1319                 # we're modifying the list - get the current list of ids
1320                 if props.has_key(propname):
1321                     existing = props[propname]
1322                 else:
1323                     existing = cl.get(nodeid, propname, [])
1325                 # now either remove or add
1326                 if mlaction == 'remove':
1327                     # remove - handle situation where the id isn't in the list
1328                     for entry in l:
1329                         try:
1330                             existing.remove(entry)
1331                         except ValueError:
1332                             raise ValueError, _('property "%(propname)s": '
1333                                 '"%(value)s" not currently in list')%{
1334                                 'propname': propname, 'value': entry}
1335                 else:
1336                     # add - easy, just don't dupe
1337                     for entry in l:
1338                         if entry not in existing:
1339                             existing.append(entry)
1340                 value = existing
1341                 value.sort()
1343         elif isinstance(proptype, hyperdb.Boolean):
1344             value = value.lower() in ('yes', 'true', 'on', '1')
1345         elif isinstance(proptype, hyperdb.Number):
1346             value = int(value)
1348         # register this as received if required?
1349         if propname in required and value is not None:
1350             required.remove(propname)
1352         # get the old value
1353         if nodeid:
1354             try:
1355                 existing = cl.get(nodeid, propname)
1356             except KeyError:
1357                 # this might be a new property for which there is no existing
1358                 # value
1359                 if not properties.has_key(propname):
1360                     raise
1362             # if changed, set it
1363             if value != existing:
1364                 props[propname] = value
1365         else:
1366             props[propname] = value
1368     # see if all the required properties have been supplied
1369     if required:
1370         if len(required) > 1:
1371             p = 'properties'
1372         else:
1373             p = 'property'
1374         raise ValueError, 'Required %s %s not supplied'%(p, ', '.join(required))
1376     return props