Code

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