Code

*** empty log message ***
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.32 2002-09-13 00:08:44 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>%s</ol>'%(message,
348                 '<li>'.join(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" dictionary 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             if not self.actions.has_key(action):
401                 raise ValueError, 'No such action "%s"'%action
403             # call the mapped action
404             getattr(self, self.actions[action])()
405         except Redirect:
406             raise
407         except Unauthorised:
408             raise
409         except:
410             self.db.rollback()
411             s = StringIO.StringIO()
412             traceback.print_exc(None, s)
413             self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
415     def write(self, content):
416         if not self.headers_done:
417             self.header()
418         self.request.wfile.write(content)
420     def header(self, headers=None, response=None):
421         '''Put up the appropriate header.
422         '''
423         if headers is None:
424             headers = {'Content-Type':'text/html'}
425         if response is None:
426             response = self.response_code
428         # update with additional info
429         headers.update(self.additional_headers)
431         if not headers.has_key('Content-Type'):
432             headers['Content-Type'] = 'text/html'
433         self.request.send_response(response)
434         for entry in headers.items():
435             self.request.send_header(*entry)
436         self.request.end_headers()
437         self.headers_done = 1
438         if self.debug:
439             self.headers_sent = headers
441     def set_cookie(self, user, password):
442         # TODO generate a much, much stronger session key ;)
443         self.session = binascii.b2a_base64(repr(random.random())).strip()
445         # clean up the base64
446         if self.session[-1] == '=':
447             if self.session[-2] == '=':
448                 self.session = self.session[:-2]
449             else:
450                 self.session = self.session[:-1]
452         # insert the session in the sessiondb
453         self.db.sessions.set(self.session, user=user, last_use=time.time())
455         # and commit immediately
456         self.db.sessions.commit()
458         # expire us in a long, long time
459         expire = Cookie._getdate(86400*365)
461         # generate the cookie path - make sure it has a trailing '/'
462         path = '/'.join((self.env['SCRIPT_NAME'], self.env['TRACKER_NAME'],
463             ''))
464         self.additional_headers['Set-Cookie'] = \
465           'roundup_user_2=%s; expires=%s; Path=%s;'%(self.session, expire, path)
467     def make_user_anonymous(self):
468         ''' Make us anonymous
470             This method used to handle non-existence of the 'anonymous'
471             user, but that user is mandatory now.
472         '''
473         self.userid = self.db.user.lookup('anonymous')
474         self.user = 'anonymous'
476     def logout(self):
477         ''' Make us really anonymous - nuke the cookie too
478         '''
479         self.make_user_anonymous()
481         # construct the logout cookie
482         now = Cookie._getdate()
483         path = '/'.join((self.env['SCRIPT_NAME'], self.env['TRACKER_NAME'],
484             ''))
485         self.additional_headers['Set-Cookie'] = \
486            'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)
487         self.login()
489     def opendb(self, user):
490         ''' Open the database.
491         '''
492         # open the db if the user has changed
493         if not hasattr(self, 'db') or user != self.db.journaltag:
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         self.user = self.form['__login_name'].value
511         # re-open the database for real, using the user
512         self.opendb(self.user)
513         if self.form.has_key('__login_password'):
514             password = self.form['__login_password'].value
515         else:
516             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.make_user_anonymous()
523             self.error_message.append(_('No such user "%(name)s"')%locals())
524             return
526         # and that the password is correct
527         pw = self.db.user.get(self.userid, 'password')
528         if password != pw:
529             self.make_user_anonymous()
530             self.error_message.append(_('Incorrect password'))
531             return
533         # make sure we're allowed to be here
534         if not self.loginPermission():
535             self.make_user_anonymous()
536             raise Unauthorised, _("You do not have permission to login")
538         # set the session cookie
539         self.set_cookie(self.user, password)
541     def loginPermission(self):
542         ''' Determine whether the user has permission to log in.
544             Base behaviour is to check the user has "Web Access".
545         ''' 
546         if not self.db.security.hasPermission('Web Access', self.userid):
547             return 0
548         return 1
550     def logout_action(self):
551         ''' Make us really anonymous - nuke the cookie too
552         '''
553         # log us out
554         self.make_user_anonymous()
556         # construct the logout cookie
557         now = Cookie._getdate()
558         path = '/'.join((self.env['SCRIPT_NAME'], self.env['TRACKER_NAME'],
559             ''))
560         self.additional_headers['Set-Cookie'] = \
561            'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)
563         # Let the user know what's going on
564         self.ok_message.append(_('You are logged out'))
566     def registerAction(self):
567         '''Attempt to create a new user based on the contents of the form
568         and then set the cookie.
570         return 1 on successful login
571         '''
572         # create the new user
573         cl = self.db.user
575         # parse the props from the form
576         try:
577             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
578         except (ValueError, KeyError), message:
579             self.error_message.append(_('Error: ') + str(message))
580             return
582         # make sure we're allowed to register
583         if not self.registerPermission(props):
584             raise Unauthorised, _("You do not have permission to register")
586         # re-open the database as "admin"
587         if self.user != 'admin':
588             self.opendb('admin')
589             
590         # create the new user
591         cl = self.db.user
592         try:
593             props = parsePropsFromForm(self.db, cl, self.form)
594             props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
595             self.userid = cl.create(**props)
596             self.db.commit()
597         except ValueError, message:
598             self.error_message.append(message)
600         # log the new user in
601         self.user = cl.get(self.userid, 'username')
602         # re-open the database for real, using the user
603         self.opendb(self.user)
604         password = self.db.user.get(self.userid, 'password')
605         self.set_cookie(self.user, password)
607         # nice message
608         message = _('You are now registered, welcome!')
610         # redirect to the item's edit page
611         raise Redirect, '%s/%s%s?:ok_message=%s'%(
612             self.base, self.classname, self.userid,  urllib.quote(message))
614     def registerPermission(self, props):
615         ''' Determine whether the user has permission to register
617             Base behaviour is to check the user has "Web Registration".
618         '''
619         # registration isn't allowed to supply roles
620         if props.has_key('roles'):
621             return 0
622         if self.db.security.hasPermission('Web Registration', self.userid):
623             return 1
624         return 0
626     def editItemAction(self):
627         ''' Perform an edit of an item in the database.
629             Some special form elements:
631             :link=designator:property
632             :multilink=designator:property
633              The value specifies a node designator and the property on that
634              node to add _this_ node to as a link or multilink.
635             :note
636              Create a message and attach it to the current node's
637              "messages" property.
638             :file
639              Create a file and attach it to the current node's
640              "files" property. Attach the file to the message created from
641              the :note if it's supplied.
643             :required=property,property,...
644              The named properties are required to be filled in the form.
646         '''
647         cl = self.db.classes[self.classname]
649         # parse the props from the form
650         try:
651             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
652         except (ValueError, KeyError), message:
653             self.error_message.append(_('Error: ') + str(message))
654             return
656         # check permission
657         if not self.editItemPermission(props):
658             self.error_message.append(
659                 _('You do not have permission to edit %(classname)s'%
660                 self.__dict__))
661             return
663         # perform the edit
664         try:
665             # make changes to the node
666             props = self._changenode(props)
667             # handle linked nodes 
668             self._post_editnode(self.nodeid)
669         except (ValueError, KeyError), message:
670             self.error_message.append(_('Error: ') + str(message))
671             return
673         # commit now that all the tricky stuff is done
674         self.db.commit()
676         # and some nice feedback for the user
677         if props:
678             message = _('%(changes)s edited ok')%{'changes':
679                 ', '.join(props.keys())}
680         elif self.form.has_key(':note') and self.form[':note'].value:
681             message = _('note added')
682         elif (self.form.has_key(':file') and self.form[':file'].filename):
683             message = _('file added')
684         else:
685             message = _('nothing changed')
687         # redirect to the item's edit page
688         raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
689             self.nodeid,  urllib.quote(message))
691     def editItemPermission(self, props):
692         ''' Determine whether the user has permission to edit this item.
694             Base behaviour is to check the user can edit this class. If we're
695             editing the "user" class, users are allowed to edit their own
696             details. Unless it's the "roles" property, which requires the
697             special Permission "Web Roles".
698         '''
699         # if this is a user node and the user is editing their own node, then
700         # we're OK
701         has = self.db.security.hasPermission
702         if self.classname == 'user':
703             # reject if someone's trying to edit "roles" and doesn't have the
704             # right permission.
705             if props.has_key('roles') and not has('Web Roles', self.userid,
706                     'user'):
707                 return 0
708             # if the item being edited is the current user, we're ok
709             if self.nodeid == self.userid:
710                 return 1
711         if self.db.security.hasPermission('Edit', self.userid, self.classname):
712             return 1
713         return 0
715     def newItemAction(self):
716         ''' Add a new item to the database.
718             This follows the same form as the editItemAction, with the same
719             special form values.
720         '''
721         cl = self.db.classes[self.classname]
723         # parse the props from the form
724         try:
725             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
726         except (ValueError, KeyError), message:
727             self.error_message.append(_('Error: ') + str(message))
728             return
730         if not self.newItemPermission(props):
731             self.error_message.append(
732                 _('You do not have permission to create %s' %self.classname))
734         # create a little extra message for anticipated :link / :multilink
735         if self.form.has_key(':multilink'):
736             link = self.form[':multilink'].value
737         elif self.form.has_key(':link'):
738             link = self.form[':multilink'].value
739         else:
740             link = None
741             xtra = ''
742         if link:
743             designator, linkprop = link.split(':')
744             xtra = ' for <a href="%s">%s</a>'%(designator, designator)
746         try:
747             # do the create
748             nid = self._createnode(props)
750             # handle linked nodes 
751             self._post_editnode(nid)
753             # commit now that all the tricky stuff is done
754             self.db.commit()
756             # render the newly created item
757             self.nodeid = nid
759             # and some nice feedback for the user
760             message = _('%(classname)s created ok')%self.__dict__ + xtra
761         except (ValueError, KeyError), message:
762             self.error_message.append(_('Error: ') + str(message))
763             return
764         except:
765             # oops
766             self.db.rollback()
767             s = StringIO.StringIO()
768             traceback.print_exc(None, s)
769             self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
770             return
772         # redirect to the new item's page
773         raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
774             nid,  urllib.quote(message))
776     def newItemPermission(self, props):
777         ''' Determine whether the user has permission to create (edit) this
778             item.
780             Base behaviour is to check the user can edit this class. No
781             additional property checks are made. Additionally, new user items
782             may be created if the user has the "Web Registration" Permission.
783         '''
784         has = self.db.security.hasPermission
785         if self.classname == 'user' and has('Web Registration', self.userid,
786                 'user'):
787             return 1
788         if has('Edit', self.userid, self.classname):
789             return 1
790         return 0
792     def editCSVAction(self):
793         ''' Performs an edit of all of a class' items in one go.
795             The "rows" CGI var defines the CSV-formatted entries for the
796             class. New nodes are identified by the ID 'X' (or any other
797             non-existent ID) and removed lines are retired.
798         '''
799         # this is per-class only
800         if not self.editCSVPermission():
801             self.error_message.append(
802                 _('You do not have permission to edit %s' %self.classname))
804         # get the CSV module
805         try:
806             import csv
807         except ImportError:
808             self.error_message.append(_(
809                 'Sorry, you need the csv module to use this function.<br>\n'
810                 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
811             return
813         cl = self.db.classes[self.classname]
814         idlessprops = cl.getprops(protected=0).keys()
815         idlessprops.sort()
816         props = ['id'] + idlessprops
818         # do the edit
819         rows = self.form['rows'].value.splitlines()
820         p = csv.parser()
821         found = {}
822         line = 0
823         for row in rows[1:]:
824             line += 1
825             values = p.parse(row)
826             # not a complete row, keep going
827             if not values: continue
829             # skip property names header
830             if values == props:
831                 continue
833             # extract the nodeid
834             nodeid, values = values[0], values[1:]
835             found[nodeid] = 1
837             # confirm correct weight
838             if len(idlessprops) != len(values):
839                 self.error_message.append(
840                     _('Not enough values on line %(line)s')%{'line':line})
841                 return
843             # extract the new values
844             d = {}
845             for name, value in zip(idlessprops, values):
846                 value = value.strip()
847                 # only add the property if it has a value
848                 if value:
849                     # if it's a multilink, split it
850                     if isinstance(cl.properties[name], hyperdb.Multilink):
851                         value = value.split(':')
852                     d[name] = value
854             # perform the edit
855             if cl.hasnode(nodeid):
856                 # edit existing
857                 cl.set(nodeid, **d)
858             else:
859                 # new node
860                 found[cl.create(**d)] = 1
862         # retire the removed entries
863         for nodeid in cl.list():
864             if not found.has_key(nodeid):
865                 cl.retire(nodeid)
867         # all OK
868         self.db.commit()
870         self.ok_message.append(_('Items edited OK'))
872     def editCSVPermission(self):
873         ''' Determine whether the user has permission to edit this class.
875             Base behaviour is to check the user can edit this class.
876         ''' 
877         if not self.db.security.hasPermission('Edit', self.userid,
878                 self.classname):
879             return 0
880         return 1
882     def searchAction(self):
883         ''' Mangle some of the form variables.
885             Set the form ":filter" variable based on the values of the
886             filter variables - if they're set to anything other than
887             "dontcare" then add them to :filter.
889             Also handle the ":queryname" variable and save off the query to
890             the user's query list.
891         '''
892         # generic edit is per-class only
893         if not self.searchPermission():
894             self.error_message.append(
895                 _('You do not have permission to search %s' %self.classname))
897         # add a faked :filter form variable for each filtering prop
898         props = self.db.classes[self.classname].getprops()
899         for key in self.form.keys():
900             if not props.has_key(key): continue
901             if not self.form[key].value: continue
902             self.form.value.append(cgi.MiniFieldStorage(':filter', key))
904         # handle saving the query params
905         if self.form.has_key(':queryname'):
906             queryname = self.form[':queryname'].value.strip()
907             if queryname:
908                 # parse the environment and figure what the query _is_
909                 req = HTMLRequest(self)
910                 url = req.indexargs_href('', {})
912                 # handle editing an existing query
913                 try:
914                     qid = self.db.query.lookup(queryname)
915                     self.db.query.set(qid, klass=self.classname, url=url)
916                 except KeyError:
917                     # create a query
918                     qid = self.db.query.create(name=queryname,
919                         klass=self.classname, url=url)
921                     # and add it to the user's query multilink
922                     queries = self.db.user.get(self.userid, 'queries')
923                     queries.append(qid)
924                     self.db.user.set(self.userid, queries=queries)
926                 # commit the query change to the database
927                 self.db.commit()
929     def searchPermission(self):
930         ''' Determine whether the user has permission to search this class.
932             Base behaviour is to check the user can view this class.
933         ''' 
934         if not self.db.security.hasPermission('View', self.userid,
935                 self.classname):
936             return 0
937         return 1
939     def remove_action(self,  dre=re.compile(r'([^\d]+)(\d+)')):
940         # XXX I believe this could be handled by a regular edit action that
941         # just sets the multilink...
942         target = self.index_arg(':target')[0]
943         m = dre.match(target)
944         if m:
945             classname = m.group(1)
946             nodeid = m.group(2)
947             cl = self.db.getclass(classname)
948             cl.retire(nodeid)
949             # now take care of the reference
950             parentref =  self.index_arg(':multilink')[0]
951             parent, prop = parentref.split(':')
952             m = dre.match(parent)
953             if m:
954                 self.classname = m.group(1)
955                 self.nodeid = m.group(2)
956                 cl = self.db.getclass(self.classname)
957                 value = cl.get(self.nodeid, prop)
958                 value.remove(nodeid)
959                 cl.set(self.nodeid, **{prop:value})
960                 func = getattr(self, 'show%s'%self.classname)
961                 return func()
962             else:
963                 raise NotFound, parent
964         else:
965             raise NotFound, target
967     #
968     #  Utility methods for editing
969     #
970     def _changenode(self, props):
971         ''' change the node based on the contents of the form
972         '''
973         cl = self.db.classes[self.classname]
975         # create the message
976         message, files = self._handle_message()
977         if message:
978             props['messages'] = cl.get(self.nodeid, 'messages') + [message]
979         if files:
980             props['files'] = cl.get(self.nodeid, 'files') + files
982         # make the changes
983         return cl.set(self.nodeid, **props)
985     def _createnode(self, props):
986         ''' create a node based on the contents of the form
987         '''
988         cl = self.db.classes[self.classname]
990         # check for messages and files
991         message, files = self._handle_message()
992         if message:
993             props['messages'] = [message]
994         if files:
995             props['files'] = files
996         # create the node and return it's id
997         return cl.create(**props)
999     def _handle_message(self):
1000         ''' generate an edit message
1001         '''
1002         # handle file attachments 
1003         files = []
1004         if self.form.has_key(':file'):
1005             file = self.form[':file']
1006             if file.filename:
1007                 filename = file.filename.split('\\')[-1]
1008                 mime_type = mimetypes.guess_type(filename)[0]
1009                 if not mime_type:
1010                     mime_type = "application/octet-stream"
1011                 # create the new file entry
1012                 files.append(self.db.file.create(type=mime_type,
1013                     name=filename, content=file.file.read()))
1015         # we don't want to do a message if none of the following is true...
1016         cn = self.classname
1017         cl = self.db.classes[self.classname]
1018         props = cl.getprops()
1019         note = None
1020         # in a nutshell, don't do anything if there's no note or there's no
1021         # NOSY
1022         if self.form.has_key(':note'):
1023             note = self.form[':note'].value.strip()
1024         if not note:
1025             return None, files
1026         if not props.has_key('messages'):
1027             return None, files
1028         if not isinstance(props['messages'], hyperdb.Multilink):
1029             return None, files
1030         if not props['messages'].classname == 'msg':
1031             return None, files
1032         if not (self.form.has_key('nosy') or note):
1033             return None, files
1035         # handle the note
1036         if '\n' in note:
1037             summary = re.split(r'\n\r?', note)[0]
1038         else:
1039             summary = note
1040         m = ['%s\n'%note]
1042         # handle the messageid
1043         # TODO: handle inreplyto
1044         messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
1045             self.classname, self.instance.config.MAIL_DOMAIN)
1047         # now create the message, attaching the files
1048         content = '\n'.join(m)
1049         message_id = self.db.msg.create(author=self.userid,
1050             recipients=[], date=date.Date('.'), summary=summary,
1051             content=content, files=files, messageid=messageid)
1053         # update the messages property
1054         return message_id, files
1056     def _post_editnode(self, nid):
1057         '''Do the linking part of the node creation.
1059            If a form element has :link or :multilink appended to it, its
1060            value specifies a node designator and the property on that node
1061            to add _this_ node to as a link or multilink.
1063            This is typically used on, eg. the file upload page to indicated
1064            which issue to link the file to.
1066            TODO: I suspect that this and newfile will go away now that
1067            there's the ability to upload a file using the issue :file form
1068            element!
1069         '''
1070         cn = self.classname
1071         cl = self.db.classes[cn]
1072         # link if necessary
1073         keys = self.form.keys()
1074         for key in keys:
1075             if key == ':multilink':
1076                 value = self.form[key].value
1077                 if type(value) != type([]): value = [value]
1078                 for value in value:
1079                     designator, property = value.split(':')
1080                     link, nodeid = hyperdb.splitDesignator(designator)
1081                     link = self.db.classes[link]
1082                     # take a dupe of the list so we're not changing the cache
1083                     value = link.get(nodeid, property)[:]
1084                     value.append(nid)
1085                     link.set(nodeid, **{property: value})
1086             elif key == ':link':
1087                 value = self.form[key].value
1088                 if type(value) != type([]): value = [value]
1089                 for value in value:
1090                     designator, property = value.split(':')
1091                     link, nodeid = hyperdb.splitDesignator(designator)
1092                     link = self.db.classes[link]
1093                     link.set(nodeid, **{property: nid})
1096 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
1097     ''' Pull properties for the given class out of the form.
1099         If a ":required" parameter is supplied, then the names property values
1100         must be supplied or a ValueError will be raised.
1101     '''
1102     required = []
1103     if form.has_key(':required'):
1104         value = form[':required']
1105         if isinstance(value, type([])):
1106             required = [i.value.strip() for i in value]
1107         else:
1108             required = [i.strip() for i in value.value.split(',')]
1110     props = {}
1111     keys = form.keys()
1112     for key in keys:
1113         if not cl.properties.has_key(key):
1114             continue
1115         proptype = cl.properties[key]
1117         # Get the form value. This value may be a MiniFieldStorage or a list
1118         # of MiniFieldStorages.
1119         value = form[key]
1121         # make sure non-multilinks only get one value
1122         if not isinstance(proptype, hyperdb.Multilink):
1123             if isinstance(value, type([])):
1124                 raise ValueError, 'You have submitted more than one value'\
1125                     ' for the %s property'%key
1126             # we've got a MiniFieldStorage, so pull out the value and strip
1127             # surrounding whitespace
1128             value = value.value.strip()
1130         if isinstance(proptype, hyperdb.String):
1131             if not value:
1132                 continue
1133         elif isinstance(proptype, hyperdb.Password):
1134             if not value:
1135                 # ignore empty password values
1136                 continue
1137             value = password.Password(value)
1138         elif isinstance(proptype, hyperdb.Date):
1139             if value:
1140                 value = date.Date(form[key].value.strip())
1141             else:
1142                 value = None
1143         elif isinstance(proptype, hyperdb.Interval):
1144             if value:
1145                 value = date.Interval(form[key].value.strip())
1146             else:
1147                 value = None
1148         elif isinstance(proptype, hyperdb.Link):
1149             # see if it's the "no selection" choice
1150             if value == '-1':
1151                 value = None
1152             else:
1153                 # handle key values
1154                 link = cl.properties[key].classname
1155                 if not num_re.match(value):
1156                     try:
1157                         value = db.classes[link].lookup(value)
1158                     except KeyError:
1159                         raise ValueError, _('property "%(propname)s": '
1160                             '%(value)s not a %(classname)s')%{'propname':key, 
1161                             'value': value, 'classname': link}
1162                     except TypeError, message:
1163                         raise ValueError, _('you may only enter ID values '
1164                             'for property "%(propname)s": %(message)s')%{
1165                             'propname':key, 'message': message}
1166         elif isinstance(proptype, hyperdb.Multilink):
1167             if isinstance(value, type([])):
1168                 # it's a list of MiniFieldStorages
1169                 value = [i.value.strip() for i in value]
1170             else:
1171                 # it's a MiniFieldStorage, but may be a comma-separated list
1172                 # of values
1173                 value = [i.strip() for i in value.value.split(',')]
1174             link = cl.properties[key].classname
1175             l = []
1176             for entry in map(str, value):
1177                 if entry == '': continue
1178                 if not num_re.match(entry):
1179                     try:
1180                         entry = db.classes[link].lookup(entry)
1181                     except KeyError:
1182                         raise ValueError, _('property "%(propname)s": '
1183                             '"%(value)s" not an entry of %(classname)s')%{
1184                             'propname':key, 'value': entry, 'classname': link}
1185                     except TypeError, message:
1186                         raise ValueError, _('you may only enter ID values '
1187                             'for property "%(propname)s": %(message)s')%{
1188                             'propname':key, 'message': message}
1189                 l.append(entry)
1190             l.sort()
1191             value = l
1192         elif isinstance(proptype, hyperdb.Boolean):
1193             props[key] = value = value.lower() in ('yes', 'true', 'on', '1')
1194         elif isinstance(proptype, hyperdb.Number):
1195             props[key] = value = int(value)
1197         # register this as received if required
1198         if key in required:
1199             required.remove(key)
1201         # get the old value
1202         if nodeid:
1203             try:
1204                 existing = cl.get(nodeid, key)
1205             except KeyError:
1206                 # this might be a new property for which there is no existing
1207                 # value
1208                 if not cl.properties.has_key(key): raise
1210             # if changed, set it
1211             if value != existing:
1212                 props[key] = value
1213         else:
1214             props[key] = value
1216     # see if all the required properties have been supplied
1217     if required:
1218         if len(required) > 1:
1219             p = 'properties'
1220         else:
1221             p = 'property'
1222         raise ValueError, 'Required %s %s not supplied'%(p, ', '.join(required))
1224     return props