Code

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