Code

. password edit now has a confirmation field
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.33 2002-09-15 22:41:15 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)
599             return
601         # log the new user in
602         self.user = cl.get(self.userid, 'username')
603         # re-open the database for real, using the user
604         self.opendb(self.user)
605         password = self.db.user.get(self.userid, 'password')
606         self.set_cookie(self.user, password)
608         # nice message
609         message = _('You are now registered, welcome!')
611         # redirect to the item's edit page
612         raise Redirect, '%s/%s%s?:ok_message=%s'%(
613             self.base, self.classname, self.userid,  urllib.quote(message))
615     def registerPermission(self, props):
616         ''' Determine whether the user has permission to register
618             Base behaviour is to check the user has "Web Registration".
619         '''
620         # registration isn't allowed to supply roles
621         if props.has_key('roles'):
622             return 0
623         if self.db.security.hasPermission('Web Registration', self.userid):
624             return 1
625         return 0
627     def editItemAction(self):
628         ''' Perform an edit of an item in the database.
630             Some special form elements:
632             :link=designator:property
633             :multilink=designator:property
634              The value specifies a node designator and the property on that
635              node to add _this_ node to as a link or multilink.
636             :note
637              Create a message and attach it to the current node's
638              "messages" property.
639             :file
640              Create a file and attach it to the current node's
641              "files" property. Attach the file to the message created from
642              the :note if it's supplied.
644             :required=property,property,...
645              The named properties are required to be filled in the form.
647         '''
648         cl = self.db.classes[self.classname]
650         # parse the props from the form
651         try:
652             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
653         except (ValueError, KeyError), message:
654             self.error_message.append(_('Error: ') + str(message))
655             return
657         # check permission
658         if not self.editItemPermission(props):
659             self.error_message.append(
660                 _('You do not have permission to edit %(classname)s'%
661                 self.__dict__))
662             return
664         # perform the edit
665         try:
666             # make changes to the node
667             props = self._changenode(props)
668             # handle linked nodes 
669             self._post_editnode(self.nodeid)
670         except (ValueError, KeyError), message:
671             self.error_message.append(_('Error: ') + str(message))
672             return
674         # commit now that all the tricky stuff is done
675         self.db.commit()
677         # and some nice feedback for the user
678         if props:
679             message = _('%(changes)s edited ok')%{'changes':
680                 ', '.join(props.keys())}
681         elif self.form.has_key(':note') and self.form[':note'].value:
682             message = _('note added')
683         elif (self.form.has_key(':file') and self.form[':file'].filename):
684             message = _('file added')
685         else:
686             message = _('nothing changed')
688         # redirect to the item's edit page
689         raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
690             self.nodeid,  urllib.quote(message))
692     def editItemPermission(self, props):
693         ''' Determine whether the user has permission to edit this item.
695             Base behaviour is to check the user can edit this class. If we're
696             editing the "user" class, users are allowed to edit their own
697             details. Unless it's the "roles" property, which requires the
698             special Permission "Web Roles".
699         '''
700         # if this is a user node and the user is editing their own node, then
701         # we're OK
702         has = self.db.security.hasPermission
703         if self.classname == 'user':
704             # reject if someone's trying to edit "roles" and doesn't have the
705             # right permission.
706             if props.has_key('roles') and not has('Web Roles', self.userid,
707                     'user'):
708                 return 0
709             # if the item being edited is the current user, we're ok
710             if self.nodeid == self.userid:
711                 return 1
712         if self.db.security.hasPermission('Edit', self.userid, self.classname):
713             return 1
714         return 0
716     def newItemAction(self):
717         ''' Add a new item to the database.
719             This follows the same form as the editItemAction, with the same
720             special form values.
721         '''
722         cl = self.db.classes[self.classname]
724         # parse the props from the form
725         try:
726             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
727         except (ValueError, KeyError), message:
728             self.error_message.append(_('Error: ') + str(message))
729             return
731         if not self.newItemPermission(props):
732             self.error_message.append(
733                 _('You do not have permission to create %s' %self.classname))
735         # create a little extra message for anticipated :link / :multilink
736         if self.form.has_key(':multilink'):
737             link = self.form[':multilink'].value
738         elif self.form.has_key(':link'):
739             link = self.form[':multilink'].value
740         else:
741             link = None
742             xtra = ''
743         if link:
744             designator, linkprop = link.split(':')
745             xtra = ' for <a href="%s">%s</a>'%(designator, designator)
747         try:
748             # do the create
749             nid = self._createnode(props)
751             # handle linked nodes 
752             self._post_editnode(nid)
754             # commit now that all the tricky stuff is done
755             self.db.commit()
757             # render the newly created item
758             self.nodeid = nid
760             # and some nice feedback for the user
761             message = _('%(classname)s created ok')%self.__dict__ + xtra
762         except (ValueError, KeyError), message:
763             self.error_message.append(_('Error: ') + str(message))
764             return
765         except:
766             # oops
767             self.db.rollback()
768             s = StringIO.StringIO()
769             traceback.print_exc(None, s)
770             self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
771             return
773         # redirect to the new item's page
774         raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
775             nid,  urllib.quote(message))
777     def newItemPermission(self, props):
778         ''' Determine whether the user has permission to create (edit) this
779             item.
781             Base behaviour is to check the user can edit this class. No
782             additional property checks are made. Additionally, new user items
783             may be created if the user has the "Web Registration" Permission.
784         '''
785         has = self.db.security.hasPermission
786         if self.classname == 'user' and has('Web Registration', self.userid,
787                 'user'):
788             return 1
789         if has('Edit', self.userid, self.classname):
790             return 1
791         return 0
793     def editCSVAction(self):
794         ''' Performs an edit of all of a class' items in one go.
796             The "rows" CGI var defines the CSV-formatted entries for the
797             class. New nodes are identified by the ID 'X' (or any other
798             non-existent ID) and removed lines are retired.
799         '''
800         # this is per-class only
801         if not self.editCSVPermission():
802             self.error_message.append(
803                 _('You do not have permission to edit %s' %self.classname))
805         # get the CSV module
806         try:
807             import csv
808         except ImportError:
809             self.error_message.append(_(
810                 'Sorry, you need the csv module to use this function.<br>\n'
811                 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
812             return
814         cl = self.db.classes[self.classname]
815         idlessprops = cl.getprops(protected=0).keys()
816         idlessprops.sort()
817         props = ['id'] + idlessprops
819         # do the edit
820         rows = self.form['rows'].value.splitlines()
821         p = csv.parser()
822         found = {}
823         line = 0
824         for row in rows[1:]:
825             line += 1
826             values = p.parse(row)
827             # not a complete row, keep going
828             if not values: continue
830             # skip property names header
831             if values == props:
832                 continue
834             # extract the nodeid
835             nodeid, values = values[0], values[1:]
836             found[nodeid] = 1
838             # confirm correct weight
839             if len(idlessprops) != len(values):
840                 self.error_message.append(
841                     _('Not enough values on line %(line)s')%{'line':line})
842                 return
844             # extract the new values
845             d = {}
846             for name, value in zip(idlessprops, values):
847                 value = value.strip()
848                 # only add the property if it has a value
849                 if value:
850                     # if it's a multilink, split it
851                     if isinstance(cl.properties[name], hyperdb.Multilink):
852                         value = value.split(':')
853                     d[name] = value
855             # perform the edit
856             if cl.hasnode(nodeid):
857                 # edit existing
858                 cl.set(nodeid, **d)
859             else:
860                 # new node
861                 found[cl.create(**d)] = 1
863         # retire the removed entries
864         for nodeid in cl.list():
865             if not found.has_key(nodeid):
866                 cl.retire(nodeid)
868         # all OK
869         self.db.commit()
871         self.ok_message.append(_('Items edited OK'))
873     def editCSVPermission(self):
874         ''' Determine whether the user has permission to edit this class.
876             Base behaviour is to check the user can edit this class.
877         ''' 
878         if not self.db.security.hasPermission('Edit', self.userid,
879                 self.classname):
880             return 0
881         return 1
883     def searchAction(self):
884         ''' Mangle some of the form variables.
886             Set the form ":filter" variable based on the values of the
887             filter variables - if they're set to anything other than
888             "dontcare" then add them to :filter.
890             Also handle the ":queryname" variable and save off the query to
891             the user's query list.
892         '''
893         # generic edit is per-class only
894         if not self.searchPermission():
895             self.error_message.append(
896                 _('You do not have permission to search %s' %self.classname))
898         # add a faked :filter form variable for each filtering prop
899         props = self.db.classes[self.classname].getprops()
900         for key in self.form.keys():
901             if not props.has_key(key): continue
902             if not self.form[key].value: continue
903             self.form.value.append(cgi.MiniFieldStorage(':filter', key))
905         # handle saving the query params
906         if self.form.has_key(':queryname'):
907             queryname = self.form[':queryname'].value.strip()
908             if queryname:
909                 # parse the environment and figure what the query _is_
910                 req = HTMLRequest(self)
911                 url = req.indexargs_href('', {})
913                 # handle editing an existing query
914                 try:
915                     qid = self.db.query.lookup(queryname)
916                     self.db.query.set(qid, klass=self.classname, url=url)
917                 except KeyError:
918                     # create a query
919                     qid = self.db.query.create(name=queryname,
920                         klass=self.classname, url=url)
922                     # and add it to the user's query multilink
923                     queries = self.db.user.get(self.userid, 'queries')
924                     queries.append(qid)
925                     self.db.user.set(self.userid, queries=queries)
927                 # commit the query change to the database
928                 self.db.commit()
930     def searchPermission(self):
931         ''' Determine whether the user has permission to search this class.
933             Base behaviour is to check the user can view this class.
934         ''' 
935         if not self.db.security.hasPermission('View', self.userid,
936                 self.classname):
937             return 0
938         return 1
940     def remove_action(self,  dre=re.compile(r'([^\d]+)(\d+)')):
941         # XXX I believe this could be handled by a regular edit action that
942         # just sets the multilink...
943         target = self.index_arg(':target')[0]
944         m = dre.match(target)
945         if m:
946             classname = m.group(1)
947             nodeid = m.group(2)
948             cl = self.db.getclass(classname)
949             cl.retire(nodeid)
950             # now take care of the reference
951             parentref =  self.index_arg(':multilink')[0]
952             parent, prop = parentref.split(':')
953             m = dre.match(parent)
954             if m:
955                 self.classname = m.group(1)
956                 self.nodeid = m.group(2)
957                 cl = self.db.getclass(self.classname)
958                 value = cl.get(self.nodeid, prop)
959                 value.remove(nodeid)
960                 cl.set(self.nodeid, **{prop:value})
961                 func = getattr(self, 'show%s'%self.classname)
962                 return func()
963             else:
964                 raise NotFound, parent
965         else:
966             raise NotFound, target
968     #
969     #  Utility methods for editing
970     #
971     def _changenode(self, props):
972         ''' change the node based on the contents of the form
973         '''
974         cl = self.db.classes[self.classname]
976         # create the message
977         message, files = self._handle_message()
978         if message:
979             props['messages'] = cl.get(self.nodeid, 'messages') + [message]
980         if files:
981             props['files'] = cl.get(self.nodeid, 'files') + files
983         # make the changes
984         return cl.set(self.nodeid, **props)
986     def _createnode(self, props):
987         ''' create a node based on the contents of the form
988         '''
989         cl = self.db.classes[self.classname]
991         # check for messages and files
992         message, files = self._handle_message()
993         if message:
994             props['messages'] = [message]
995         if files:
996             props['files'] = files
997         # create the node and return it's id
998         return cl.create(**props)
1000     def _handle_message(self):
1001         ''' generate an edit message
1002         '''
1003         # handle file attachments 
1004         files = []
1005         if self.form.has_key(':file'):
1006             file = self.form[':file']
1007             if file.filename:
1008                 filename = file.filename.split('\\')[-1]
1009                 mime_type = mimetypes.guess_type(filename)[0]
1010                 if not mime_type:
1011                     mime_type = "application/octet-stream"
1012                 # create the new file entry
1013                 files.append(self.db.file.create(type=mime_type,
1014                     name=filename, content=file.file.read()))
1016         # we don't want to do a message if none of the following is true...
1017         cn = self.classname
1018         cl = self.db.classes[self.classname]
1019         props = cl.getprops()
1020         note = None
1021         # in a nutshell, don't do anything if there's no note or there's no
1022         # NOSY
1023         if self.form.has_key(':note'):
1024             note = self.form[':note'].value.strip()
1025         if not note:
1026             return None, files
1027         if not props.has_key('messages'):
1028             return None, files
1029         if not isinstance(props['messages'], hyperdb.Multilink):
1030             return None, files
1031         if not props['messages'].classname == 'msg':
1032             return None, files
1033         if not (self.form.has_key('nosy') or note):
1034             return None, files
1036         # handle the note
1037         if '\n' in note:
1038             summary = re.split(r'\n\r?', note)[0]
1039         else:
1040             summary = note
1041         m = ['%s\n'%note]
1043         # handle the messageid
1044         # TODO: handle inreplyto
1045         messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
1046             self.classname, self.instance.config.MAIL_DOMAIN)
1048         # now create the message, attaching the files
1049         content = '\n'.join(m)
1050         message_id = self.db.msg.create(author=self.userid,
1051             recipients=[], date=date.Date('.'), summary=summary,
1052             content=content, files=files, messageid=messageid)
1054         # update the messages property
1055         return message_id, files
1057     def _post_editnode(self, nid):
1058         '''Do the linking part of the node creation.
1060            If a form element has :link or :multilink appended to it, its
1061            value specifies a node designator and the property on that node
1062            to add _this_ node to as a link or multilink.
1064            This is typically used on, eg. the file upload page to indicated
1065            which issue to link the file to.
1067            TODO: I suspect that this and newfile will go away now that
1068            there's the ability to upload a file using the issue :file form
1069            element!
1070         '''
1071         cn = self.classname
1072         cl = self.db.classes[cn]
1073         # link if necessary
1074         keys = self.form.keys()
1075         for key in keys:
1076             if key == ':multilink':
1077                 value = self.form[key].value
1078                 if type(value) != type([]): value = [value]
1079                 for value in value:
1080                     designator, property = value.split(':')
1081                     link, nodeid = hyperdb.splitDesignator(designator)
1082                     link = self.db.classes[link]
1083                     # take a dupe of the list so we're not changing the cache
1084                     value = link.get(nodeid, property)[:]
1085                     value.append(nid)
1086                     link.set(nodeid, **{property: value})
1087             elif key == ':link':
1088                 value = self.form[key].value
1089                 if type(value) != type([]): value = [value]
1090                 for value in value:
1091                     designator, property = value.split(':')
1092                     link, nodeid = hyperdb.splitDesignator(designator)
1093                     link = self.db.classes[link]
1094                     link.set(nodeid, **{property: nid})
1097 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
1098     ''' Pull properties for the given class out of the form.
1100         If a ":required" parameter is supplied, then the names property values
1101         must be supplied or a ValueError will be raised.
1102     '''
1103     required = []
1104     if form.has_key(':required'):
1105         value = form[':required']
1106         if isinstance(value, type([])):
1107             required = [i.value.strip() for i in value]
1108         else:
1109             required = [i.strip() for i in value.value.split(',')]
1111     props = {}
1112     keys = form.keys()
1113     for key in keys:
1114         if not cl.properties.has_key(key):
1115             continue
1116         proptype = cl.properties[key]
1118         # Get the form value. This value may be a MiniFieldStorage or a list
1119         # of MiniFieldStorages.
1120         value = form[key]
1122         # make sure non-multilinks only get one value
1123         if not isinstance(proptype, hyperdb.Multilink):
1124             if isinstance(value, type([])):
1125                 raise ValueError, 'You have submitted more than one value'\
1126                     ' for the %s property'%key
1127             # we've got a MiniFieldStorage, so pull out the value and strip
1128             # surrounding whitespace
1129             value = value.value.strip()
1131         if isinstance(proptype, hyperdb.String):
1132             if not value:
1133                 continue
1134         elif isinstance(proptype, hyperdb.Password):
1135             if not value:
1136                 # ignore empty password values
1137                 continue
1138             if not form.has_key('%s:confirm'%key):
1139                 raise ValueError, 'Password and confirmation text do not match'
1140             confirm = form['%s:confirm'%key]
1141             if isinstance(confirm, type([])):
1142                 raise ValueError, 'You have submitted more than one value'\
1143                     ' for the %s property'%key
1144             if value != confirm.value:
1145                 raise ValueError, 'Password and confirmation text do not match'
1146             value = password.Password(value)
1147         elif isinstance(proptype, hyperdb.Date):
1148             if value:
1149                 value = date.Date(form[key].value.strip())
1150             else:
1151                 value = None
1152         elif isinstance(proptype, hyperdb.Interval):
1153             if value:
1154                 value = date.Interval(form[key].value.strip())
1155             else:
1156                 value = None
1157         elif isinstance(proptype, hyperdb.Link):
1158             # see if it's the "no selection" choice
1159             if value == '-1':
1160                 value = None
1161             else:
1162                 # handle key values
1163                 link = cl.properties[key].classname
1164                 if not num_re.match(value):
1165                     try:
1166                         value = db.classes[link].lookup(value)
1167                     except KeyError:
1168                         raise ValueError, _('property "%(propname)s": '
1169                             '%(value)s not a %(classname)s')%{'propname':key, 
1170                             'value': value, 'classname': link}
1171                     except TypeError, message:
1172                         raise ValueError, _('you may only enter ID values '
1173                             'for property "%(propname)s": %(message)s')%{
1174                             'propname':key, 'message': message}
1175         elif isinstance(proptype, hyperdb.Multilink):
1176             if isinstance(value, type([])):
1177                 # it's a list of MiniFieldStorages
1178                 value = [i.value.strip() for i in value]
1179             else:
1180                 # it's a MiniFieldStorage, but may be a comma-separated list
1181                 # of values
1182                 value = [i.strip() for i in value.value.split(',')]
1183             link = cl.properties[key].classname
1184             l = []
1185             for entry in map(str, value):
1186                 if entry == '': continue
1187                 if not num_re.match(entry):
1188                     try:
1189                         entry = db.classes[link].lookup(entry)
1190                     except KeyError:
1191                         raise ValueError, _('property "%(propname)s": '
1192                             '"%(value)s" not an entry of %(classname)s')%{
1193                             'propname':key, 'value': entry, 'classname': link}
1194                     except TypeError, message:
1195                         raise ValueError, _('you may only enter ID values '
1196                             'for property "%(propname)s": %(message)s')%{
1197                             'propname':key, 'message': message}
1198                 l.append(entry)
1199             l.sort()
1200             value = l
1201         elif isinstance(proptype, hyperdb.Boolean):
1202             props[key] = value = value.lower() in ('yes', 'true', 'on', '1')
1203         elif isinstance(proptype, hyperdb.Number):
1204             props[key] = value = int(value)
1206         # register this as received if required
1207         if key in required:
1208             required.remove(key)
1210         # get the old value
1211         if nodeid:
1212             try:
1213                 existing = cl.get(nodeid, key)
1214             except KeyError:
1215                 # this might be a new property for which there is no existing
1216                 # value
1217                 if not cl.properties.has_key(key): raise
1219             # if changed, set it
1220             if value != existing:
1221                 props[key] = value
1222         else:
1223             props[key] = value
1225     # see if all the required properties have been supplied
1226     if required:
1227         if len(required) > 1:
1228             p = 'properties'
1229         else:
1230             p = 'property'
1231         raise ValueError, 'Required %s %s not supplied'%(p, ', '.join(required))
1233     return props