Code

663f0c764d429ce0dd22d043b182907768dbbc3a
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.15 2002-09-05 23:39:12 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
14 from roundup.cgi import cgitb
16 from 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['INSTANCE_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     def main(self):
103         ''' Process a request.
105             The most common requests are handled like so:
106             1. figure out who we are, defaulting to the "anonymous" user
107                see determine_user
108             2. figure out what the request is for - the context
109                see determine_context
110             3. handle any requested action (item edit, search, ...)
111                see handle_action
112             4. render a template, resulting in HTML output
114             In some situations, exceptions occur:
115             - HTTP Redirect  (generally raised by an action)
116             - SendFile       (generally raised by determine_context)
117               serve up a FileClass "content" property
118             - SendStaticFile (generally raised by determine_context)
119               serve up a file from the tracker "html" directory
120             - Unauthorised   (generally raised by an action)
121               the action is cancelled, the request is rendered and an error
122               message is displayed indicating that permission was not
123               granted for the action to take place
124             - NotFound       (raised wherever it needs to be)
125               percolates up to the CGI interface that called the client
126         '''
127         self.content_action = None
128         self.ok_message = []
129         self.error_message = []
130         try:
131             # make sure we're identified (even anonymously)
132             self.determine_user()
133             # figure out the context and desired content template
134             self.determine_context()
135             # possibly handle a form submit action (may change self.classname
136             # and self.template, and may also append error/ok_messages)
137             self.handle_action()
138             # now render the page
139             if self.form.has_key(':contentonly'):
140                 # just the content
141                 self.write(self.content())
142             else:
143                 # render the content inside the page template
144                 self.write(self.renderTemplate('page', '',
145                     ok_message=self.ok_message,
146                     error_message=self.error_message))
147         except Redirect, url:
148             # let's redirect - if the url isn't None, then we need to do
149             # the headers, otherwise the headers have been set before the
150             # exception was raised
151             if url:
152                 self.header({'Location': url}, response=302)
153         except SendFile, designator:
154             self.serve_file(designator)
155         except SendStaticFile, file:
156             self.serve_static_file(str(file))
157         except Unauthorised, message:
158             self.write(self.renderTemplate('page', '', error_message=message))
159         except:
160             # everything else
161             self.write(cgitb.html())
163     def determine_user(self):
164         ''' Determine who the user is
165         '''
166         # determine the uid to use
167         self.opendb('admin')
169         # make sure we have the session Class
170         sessions = self.db.sessions
172         # age sessions, remove when they haven't been used for a week
173         # TODO: this shouldn't be done every access
174         week = 60*60*24*7
175         now = time.time()
176         for sessid in sessions.list():
177             interval = now - sessions.get(sessid, 'last_use')
178             if interval > week:
179                 sessions.destroy(sessid)
181         # look up the user session cookie
182         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
183         user = 'anonymous'
185         # bump the "revision" of the cookie since the format changed
186         if (cookie.has_key('roundup_user_2') and
187                 cookie['roundup_user_2'].value != 'deleted'):
189             # get the session key from the cookie
190             self.session = cookie['roundup_user_2'].value
191             # get the user from the session
192             try:
193                 # update the lifetime datestamp
194                 sessions.set(self.session, last_use=time.time())
195                 sessions.commit()
196                 user = sessions.get(self.session, 'user')
197             except KeyError:
198                 user = 'anonymous'
200         # sanity check on the user still being valid, getting the userid
201         # at the same time
202         try:
203             self.userid = self.db.user.lookup(user)
204         except (KeyError, TypeError):
205             user = 'anonymous'
207         # make sure the anonymous user is valid if we're using it
208         if user == 'anonymous':
209             self.make_user_anonymous()
210         else:
211             self.user = user
213         # reopen the database as the correct user
214         self.opendb(self.user)
216     def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
217         ''' Determine the context of this page from the URL:
219             The URL path after the instance identifier is examined. The path
220             is generally only one entry long.
222             - if there is no path, then we are in the "home" context.
223             * if the path is "_file", then the additional path entry
224               specifies the filename of a static file we're to serve up
225               from the instance "html" directory. Raises a SendStaticFile
226               exception.
227             - if there is something in the path (eg "issue"), it identifies
228               the tracker class we're to display.
229             - if the path is an item designator (eg "issue123"), then we're
230               to display a specific item.
231             * if the path starts with an item designator and is longer than
232               one entry, then we're assumed to be handling an item of a
233               FileClass, and the extra path information gives the filename
234               that the client is going to label the download with (ie
235               "file123/image.png" is nicer to download than "file123"). This
236               raises a SendFile exception.
238             Both of the "*" types of contexts stop before we bother to
239             determine the template we're going to use. That's because they
240             don't actually use templates.
242             The template used is specified by the :template CGI variable,
243             which defaults to:
245              only classname suplied:          "index"
246              full item designator supplied:   "item"
248             We set:
249              self.classname  - the class to display, can be None
250              self.template   - the template to render the current context with
251              self.nodeid     - the nodeid of the class we're displaying
252         '''
253         # default the optional variables
254         self.classname = None
255         self.nodeid = None
257         # determine the classname and possibly nodeid
258         path = self.split_path
259         if not path or path[0] in ('', 'home', 'index'):
260             if self.form.has_key(':template'):
261                 self.template = self.form[':template'].value
262             else:
263                 self.template = ''
264             return
265         elif path[0] == '_file':
266             raise SendStaticFile, path[1]
267         else:
268             self.classname = path[0]
269             if len(path) > 1:
270                 # send the file identified by the designator in path[0]
271                 raise SendFile, path[0]
273         # see if we got a designator
274         m = dre.match(self.classname)
275         if m:
276             self.classname = m.group(1)
277             self.nodeid = m.group(2)
278             # with a designator, we default to item view
279             self.template = 'item'
280         else:
281             # with only a class, we default to index view
282             self.template = 'index'
284         # see if we have a template override
285         if self.form.has_key(':template'):
286             self.template = self.form[':template'].value
289         # see if we were passed in a message
290         if self.form.has_key(':ok_message'):
291             self.ok_message.append(self.form[':ok_message'].value)
292         if self.form.has_key(':error_message'):
293             self.error_message.append(self.form[':error_message'].value)
295     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
296         ''' Serve the file from the content property of the designated item.
297         '''
298         m = dre.match(str(designator))
299         if not m:
300             raise NotFound, str(designator)
301         classname, nodeid = m.group(1), m.group(2)
302         if classname != 'file':
303             raise NotFound, designator
305         # we just want to serve up the file named
306         file = self.db.file
307         self.header({'Content-Type': file.get(nodeid, 'type')})
308         self.write(file.get(nodeid, 'content'))
310     def serve_static_file(self, file):
311         # we just want to serve up the file named
312         mt = mimetypes.guess_type(str(file))[0]
313         self.header({'Content-Type': mt})
314         self.write(open(os.path.join(self.instance.TEMPLATES, file)).read())
316     def renderTemplate(self, name, extension, **kwargs):
317         ''' Return a PageTemplate for the named page
318         '''
319         pt = getTemplate(self.instance.TEMPLATES, name, extension)
320         # XXX handle PT rendering errors here more nicely
321         try:
322             # let the template render figure stuff out
323             return pt.render(self, None, None, **kwargs)
324         except PageTemplate.PTRuntimeError, message:
325             return '<strong>%s</strong><ol>%s</ol>'%(message,
326                 '<li>'.join(pt._v_errors))
327         except:
328             # everything else
329             return cgitb.html()
331     def content(self):
332         ''' Callback used by the page template to render the content of 
333             the page.
335             If we don't have a specific class to display, that is none was
336             determined in determine_context(), then we display a "home"
337             template.
338         '''
339         # now render the page content using the template we determined in
340         # determine_context
341         if self.classname is None:
342             name = 'home'
343         else:
344             name = self.classname
345         return self.renderTemplate(self.classname, self.template)
347     # these are the actions that are available
348     actions = {
349         'edit':     'editItemAction',
350         'editCSV':  'editCSVAction',
351         'new':      'newItemAction',
352         'register': 'registerAction',
353         'login':    'login_action',
354         'logout':   'logout_action',
355         'search':   'searchAction',
356     }
357     def handle_action(self):
358         ''' Determine whether there should be an _action called.
360             The action is defined by the form variable :action which
361             identifies the method on this object to call. The four basic
362             actions are defined in the "actions" dictionary on this class:
363              "edit"      -> self.editItemAction
364              "new"       -> self.newItemAction
365              "register"  -> self.registerAction
366              "login"     -> self.login_action
367              "logout"    -> self.logout_action
368              "search"    -> self.searchAction
370         '''
371         if not self.form.has_key(':action'):
372             return None
373         try:
374             # get the action, validate it
375             action = self.form[':action'].value
376             if not self.actions.has_key(action):
377                 raise ValueError, 'No such action "%s"'%action
379             # call the mapped action
380             getattr(self, self.actions[action])()
381         except Redirect:
382             raise
383         except:
384             self.db.rollback()
385             s = StringIO.StringIO()
386             traceback.print_exc(None, s)
387             self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
389     def write(self, content):
390         if not self.headers_done:
391             self.header()
392         self.request.wfile.write(content)
394     def header(self, headers=None, response=200):
395         '''Put up the appropriate header.
396         '''
397         if headers is None:
398             headers = {'Content-Type':'text/html'}
399         if not headers.has_key('Content-Type'):
400             headers['Content-Type'] = 'text/html'
401         self.request.send_response(response)
402         for entry in headers.items():
403             self.request.send_header(*entry)
404         self.request.end_headers()
405         self.headers_done = 1
406         if self.debug:
407             self.headers_sent = headers
409     def set_cookie(self, user, password):
410         # TODO generate a much, much stronger session key ;)
411         self.session = binascii.b2a_base64(repr(time.time())).strip()
413         # clean up the base64
414         if self.session[-1] == '=':
415             if self.session[-2] == '=':
416                 self.session = self.session[:-2]
417             else:
418                 self.session = self.session[:-1]
420         # insert the session in the sessiondb
421         self.db.sessions.set(self.session, user=user, last_use=time.time())
423         # and commit immediately
424         self.db.sessions.commit()
426         # expire us in a long, long time
427         expire = Cookie._getdate(86400*365)
429         # generate the cookie path - make sure it has a trailing '/'
430         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
431             ''))
432         self.header({'Set-Cookie': 'roundup_user_2=%s; expires=%s; Path=%s;'%(
433             self.session, expire, path)})
435     def make_user_anonymous(self):
436         ''' Make us anonymous
438             This method used to handle non-existence of the 'anonymous'
439             user, but that user is mandatory now.
440         '''
441         self.userid = self.db.user.lookup('anonymous')
442         self.user = 'anonymous'
444     def logout(self):
445         ''' Make us really anonymous - nuke the cookie too
446         '''
447         self.make_user_anonymous()
449         # construct the logout cookie
450         now = Cookie._getdate()
451         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
452             ''))
453         self.header({'Set-Cookie':
454             'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
455             path)})
456         self.login()
458     def opendb(self, user):
459         ''' Open the database.
460         '''
461         # open the db if the user has changed
462         if not hasattr(self, 'db') or user != self.db.journaltag:
463             self.db = self.instance.open(user)
465     #
466     # Actions
467     #
468     def login_action(self):
469         ''' Attempt to log a user in and set the cookie
470         '''
471         # we need the username at a minimum
472         if not self.form.has_key('__login_name'):
473             self.error_message.append(_('Username required'))
474             return
476         self.user = self.form['__login_name'].value
477         # re-open the database for real, using the user
478         self.opendb(self.user)
479         if self.form.has_key('__login_password'):
480             password = self.form['__login_password'].value
481         else:
482             password = ''
483         # make sure the user exists
484         try:
485             self.userid = self.db.user.lookup(self.user)
486         except KeyError:
487             name = self.user
488             self.make_user_anonymous()
489             self.error_message.append(_('No such user "%(name)s"')%locals())
490             return
492         # and that the password is correct
493         pw = self.db.user.get(self.userid, 'password')
494         if password != pw:
495             self.make_user_anonymous()
496             self.error_message.append(_('Incorrect password'))
497             return
499         # set the session cookie
500         self.set_cookie(self.user, password)
502     def logout_action(self):
503         ''' Make us really anonymous - nuke the cookie too
504         '''
505         # log us out
506         self.make_user_anonymous()
508         # construct the logout cookie
509         now = Cookie._getdate()
510         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
511             ''))
512         self.header(headers={'Set-Cookie':
513           'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)})
515         # Let the user know what's going on
516         self.ok_message.append(_('You are logged out'))
518     def registerAction(self):
519         '''Attempt to create a new user based on the contents of the form
520         and then set the cookie.
522         return 1 on successful login
523         '''
524         # create the new user
525         cl = self.db.user
527         # parse the props from the form
528         try:
529             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
530         except (ValueError, KeyError), message:
531             self.error_message.append(_('Error: ') + str(message))
532             return
534         # make sure we're allowed to register
535         if not self.registerPermission(props):
536             raise Unauthorised, _("You do not have permission to register")
538         # re-open the database as "admin"
539         if self.user != 'admin':
540             self.opendb('admin')
541             
542         # create the new user
543         cl = self.db.user
544         try:
545             props = parsePropsFromForm(self.db, cl, self.form)
546             props['roles'] = self.instance.NEW_WEB_USER_ROLES
547             self.userid = cl.create(**props)
548             self.db.commit()
549         except ValueError, message:
550             self.error_message.append(message)
552         # log the new user in
553         self.user = cl.get(self.userid, 'username')
554         # re-open the database for real, using the user
555         self.opendb(self.user)
556         password = self.db.user.get(self.userid, 'password')
557         self.set_cookie(self.user, password)
559         # nice message
560         self.ok_message.append(_('You are now registered, welcome!'))
562     def registerPermission(self, props):
563         ''' Determine whether the user has permission to register
565             Base behaviour is to check the user has "Web Registration".
566         '''
567         # registration isn't allowed to supply roles
568         if props.has_key('roles'):
569             return 0
570         if self.db.security.hasPermission('Web Registration', self.userid):
571             return 1
572         return 0
574     def editItemAction(self):
575         ''' Perform an edit of an item in the database.
577             Some special form elements:
579             :link=designator:property
580             :multilink=designator:property
581              The value specifies a node designator and the property on that
582              node to add _this_ node to as a link or multilink.
583             __note
584              Create a message and attach it to the current node's
585              "messages" property.
586             __file
587              Create a file and attach it to the current node's
588              "files" property. Attach the file to the message created from
589              the __note if it's supplied.
591             :required=property,property,...
592              The named properties are required to be filled in the form.
594         '''
595         cl = self.db.classes[self.classname]
597         # parse the props from the form
598         try:
599             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
600         except (ValueError, KeyError), message:
601             self.error_message.append(_('Error: ') + str(message))
602             return
604         # check permission
605         if not self.editItemPermission(props):
606             self.error_message.append(
607                 _('You do not have permission to edit %(classname)s'%
608                 self.__dict__))
609             return
611         # perform the edit
612         try:
613             # make changes to the node
614             props = self._changenode(props)
615             # handle linked nodes 
616             self._post_editnode(self.nodeid)
617         except (ValueError, KeyError), message:
618             self.error_message.append(_('Error: ') + str(message))
619             return
621         # commit now that all the tricky stuff is done
622         self.db.commit()
624         # and some nice feedback for the user
625         if props:
626             message = _('%(changes)s edited ok')%{'changes':
627                 ', '.join(props.keys())}
628         elif self.form.has_key('__note') and self.form['__note'].value:
629             message = _('note added')
630         elif (self.form.has_key('__file') and self.form['__file'].filename):
631             message = _('file added')
632         else:
633             message = _('nothing changed')
635         # redirect to the item's edit page
636         raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
637             self.nodeid,  urllib.quote(message))
639     def editItemPermission(self, props):
640         ''' Determine whether the user has permission to edit this item.
642             Base behaviour is to check the user can edit this class. If we're
643             editing the "user" class, users are allowed to edit their own
644             details. Unless it's the "roles" property, which requires the
645             special Permission "Web Roles".
646         '''
647         # if this is a user node and the user is editing their own node, then
648         # we're OK
649         has = self.db.security.hasPermission
650         if self.classname == 'user':
651             # reject if someone's trying to edit "roles" and doesn't have the
652             # right permission.
653             if props.has_key('roles') and not has('Web Roles', self.userid,
654                     'user'):
655                 return 0
656             # if the item being edited is the current user, we're ok
657             if self.nodeid == self.userid:
658                 return 1
659         if self.db.security.hasPermission('Edit', self.userid, self.classname):
660             return 1
661         return 0
663     def newItemAction(self):
664         ''' Add a new item to the database.
666             This follows the same form as the editItemAction, with the same
667             special form values.
668         '''
669         cl = self.db.classes[self.classname]
671         # parse the props from the form
672         try:
673             props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
674         except (ValueError, KeyError), message:
675             self.error_message.append(_('Error: ') + str(message))
676             return
678         if not self.newItemPermission(props):
679             self.error_message.append(
680                 _('You do not have permission to create %s' %self.classname))
682         # create a little extra message for anticipated :link / :multilink
683         if self.form.has_key(':multilink'):
684             link = self.form[':multilink'].value
685         elif self.form.has_key(':link'):
686             link = self.form[':multilink'].value
687         else:
688             link = None
689             xtra = ''
690         if link:
691             designator, linkprop = link.split(':')
692             xtra = ' for <a href="%s">%s</a>'%(designator, designator)
694         try:
695             # do the create
696             nid = self._createnode(props)
698             # handle linked nodes 
699             self._post_editnode(nid)
701             # commit now that all the tricky stuff is done
702             self.db.commit()
704             # render the newly created item
705             self.nodeid = nid
707             # and some nice feedback for the user
708             message = _('%(classname)s created ok')%self.__dict__ + xtra
709         except (ValueError, KeyError), message:
710             self.error_message.append(_('Error: ') + str(message))
711             return
712         except:
713             # oops
714             self.db.rollback()
715             s = StringIO.StringIO()
716             traceback.print_exc(None, s)
717             self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
718             return
720         # redirect to the new item's page
721         raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
722             nid,  urllib.quote(message))
724     def newItemPermission(self, props):
725         ''' Determine whether the user has permission to create (edit) this
726             item.
728             Base behaviour is to check the user can edit this class. No
729             additional property checks are made. Additionally, new user items
730             may be created if the user has the "Web Registration" Permission.
731         '''
732         has = self.db.security.hasPermission
733         if self.classname == 'user' and has('Web Registration', self.userid,
734                 'user'):
735             return 1
736         if has('Edit', self.userid, self.classname):
737             return 1
738         return 0
740     def editCSVAction(self):
741         ''' Performs an edit of all of a class' items in one go.
743             The "rows" CGI var defines the CSV-formatted entries for the
744             class. New nodes are identified by the ID 'X' (or any other
745             non-existent ID) and removed lines are retired.
746         '''
747         # this is per-class only
748         if not self.editCSVPermission():
749             self.error_message.append(
750                 _('You do not have permission to edit %s' %self.classname))
752         # get the CSV module
753         try:
754             import csv
755         except ImportError:
756             self.error_message.append(_(
757                 'Sorry, you need the csv module to use this function.<br>\n'
758                 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
759             return
761         cl = self.db.classes[self.classname]
762         idlessprops = cl.getprops(protected=0).keys()
763         idlessprops.sort()
764         props = ['id'] + idlessprops
766         # do the edit
767         rows = self.form['rows'].value.splitlines()
768         p = csv.parser()
769         found = {}
770         line = 0
771         for row in rows[1:]:
772             line += 1
773             values = p.parse(row)
774             # not a complete row, keep going
775             if not values: continue
777             # skip property names header
778             if values == props:
779                 continue
781             # extract the nodeid
782             nodeid, values = values[0], values[1:]
783             found[nodeid] = 1
785             # confirm correct weight
786             if len(idlessprops) != len(values):
787                 self.error_message.append(
788                     _('Not enough values on line %(line)s')%{'line':line})
789                 return
791             # extract the new values
792             d = {}
793             for name, value in zip(idlessprops, values):
794                 value = value.strip()
795                 # only add the property if it has a value
796                 if value:
797                     # if it's a multilink, split it
798                     if isinstance(cl.properties[name], hyperdb.Multilink):
799                         value = value.split(':')
800                     d[name] = value
802             # perform the edit
803             if cl.hasnode(nodeid):
804                 # edit existing
805                 cl.set(nodeid, **d)
806             else:
807                 # new node
808                 found[cl.create(**d)] = 1
810         # retire the removed entries
811         for nodeid in cl.list():
812             if not found.has_key(nodeid):
813                 cl.retire(nodeid)
815         # all OK
816         self.db.commit()
818         self.ok_message.append(_('Items edited OK'))
820     def editCSVPermission(self):
821         ''' Determine whether the user has permission to edit this class.
823             Base behaviour is to check the user can edit this class.
824         ''' 
825         if not self.db.security.hasPermission('Edit', self.userid,
826                 self.classname):
827             return 0
828         return 1
830     def searchAction(self):
831         ''' Mangle some of the form variables.
833             Set the form ":filter" variable based on the values of the
834             filter variables - if they're set to anything other than
835             "dontcare" then add them to :filter.
837             Also handle the ":queryname" variable and save off the query to
838             the user's query list.
839         '''
840         # generic edit is per-class only
841         if not self.searchPermission():
842             self.error_message.append(
843                 _('You do not have permission to search %s' %self.classname))
845         # add a faked :filter form variable for each filtering prop
846         props = self.db.classes[self.classname].getprops()
847         for key in self.form.keys():
848             if not props.has_key(key): continue
849             if not self.form[key].value: continue
850             self.form.value.append(cgi.MiniFieldStorage(':filter', key))
852         # handle saving the query params
853         if self.form.has_key(':queryname'):
854             queryname = self.form[':queryname'].value.strip()
855             if queryname:
856                 # parse the environment and figure what the query _is_
857                 req = HTMLRequest(self)
858                 url = req.indexargs_href('', {})
860                 # handle editing an existing query
861                 try:
862                     qid = self.db.query.lookup(queryname)
863                     self.db.query.set(qid, klass=self.classname, url=url)
864                 except KeyError:
865                     # create a query
866                     qid = self.db.query.create(name=queryname,
867                         klass=self.classname, url=url)
869                     # and add it to the user's query multilink
870                     queries = self.db.user.get(self.userid, 'queries')
871                     queries.append(qid)
872                     self.db.user.set(self.userid, queries=queries)
874                 # commit the query change to the database
875                 self.db.commit()
878     def searchPermission(self):
879         ''' Determine whether the user has permission to search this class.
881             Base behaviour is to check the user can view this class.
882         ''' 
883         if not self.db.security.hasPermission('View', self.userid,
884                 self.classname):
885             return 0
886         return 1
888     def XXXremove_action(self,  dre=re.compile(r'([^\d]+)(\d+)')):
889         # XXX I believe this could be handled by a regular edit action that
890         # just sets the multilink...
891         # XXX handle this !
892         target = self.index_arg(':target')[0]
893         m = dre.match(target)
894         if m:
895             classname = m.group(1)
896             nodeid = m.group(2)
897             cl = self.db.getclass(classname)
898             cl.retire(nodeid)
899             # now take care of the reference
900             parentref =  self.index_arg(':multilink')[0]
901             parent, prop = parentref.split(':')
902             m = dre.match(parent)
903             if m:
904                 self.classname = m.group(1)
905                 self.nodeid = m.group(2)
906                 cl = self.db.getclass(self.classname)
907                 value = cl.get(self.nodeid, prop)
908                 value.remove(nodeid)
909                 cl.set(self.nodeid, **{prop:value})
910                 func = getattr(self, 'show%s'%self.classname)
911                 return func()
912             else:
913                 raise NotFound, parent
914         else:
915             raise NotFound, target
917     #
918     #  Utility methods for editing
919     #
920     def _changenode(self, props):
921         ''' change the node based on the contents of the form
922         '''
923         cl = self.db.classes[self.classname]
925         # create the message
926         message, files = self._handle_message()
927         if message:
928             props['messages'] = cl.get(self.nodeid, 'messages') + [message]
929         if files:
930             props['files'] = cl.get(self.nodeid, 'files') + files
932         # make the changes
933         return cl.set(self.nodeid, **props)
935     def _createnode(self, props):
936         ''' create a node based on the contents of the form
937         '''
938         cl = self.db.classes[self.classname]
940         # check for messages and files
941         message, files = self._handle_message()
942         if message:
943             props['messages'] = [message]
944         if files:
945             props['files'] = files
946         # create the node and return it's id
947         return cl.create(**props)
949     def _handle_message(self):
950         ''' generate an edit message
951         '''
952         # handle file attachments 
953         files = []
954         if self.form.has_key('__file'):
955             file = self.form['__file']
956             if file.filename:
957                 filename = file.filename.split('\\')[-1]
958                 mime_type = mimetypes.guess_type(filename)[0]
959                 if not mime_type:
960                     mime_type = "application/octet-stream"
961                 # create the new file entry
962                 files.append(self.db.file.create(type=mime_type,
963                     name=filename, content=file.file.read()))
965         # we don't want to do a message if none of the following is true...
966         cn = self.classname
967         cl = self.db.classes[self.classname]
968         props = cl.getprops()
969         note = None
970         # in a nutshell, don't do anything if there's no note or there's no
971         # NOSY
972         if self.form.has_key('__note'):
973             note = self.form['__note'].value.strip()
974         if not note:
975             return None, files
976         if not props.has_key('messages'):
977             return None, files
978         if not isinstance(props['messages'], hyperdb.Multilink):
979             return None, files
980         if not props['messages'].classname == 'msg':
981             return None, files
982         if not (self.form.has_key('nosy') or note):
983             return None, files
985         # handle the note
986         if '\n' in note:
987             summary = re.split(r'\n\r?', note)[0]
988         else:
989             summary = note
990         m = ['%s\n'%note]
992         # handle the messageid
993         # TODO: handle inreplyto
994         messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
995             self.classname, self.instance.MAIL_DOMAIN)
997         # now create the message, attaching the files
998         content = '\n'.join(m)
999         message_id = self.db.msg.create(author=self.userid,
1000             recipients=[], date=date.Date('.'), summary=summary,
1001             content=content, files=files, messageid=messageid)
1003         # update the messages property
1004         return message_id, files
1006     def _post_editnode(self, nid):
1007         '''Do the linking part of the node creation.
1009            If a form element has :link or :multilink appended to it, its
1010            value specifies a node designator and the property on that node
1011            to add _this_ node to as a link or multilink.
1013            This is typically used on, eg. the file upload page to indicated
1014            which issue to link the file to.
1016            TODO: I suspect that this and newfile will go away now that
1017            there's the ability to upload a file using the issue __file form
1018            element!
1019         '''
1020         cn = self.classname
1021         cl = self.db.classes[cn]
1022         # link if necessary
1023         keys = self.form.keys()
1024         for key in keys:
1025             if key == ':multilink':
1026                 value = self.form[key].value
1027                 if type(value) != type([]): value = [value]
1028                 for value in value:
1029                     designator, property = value.split(':')
1030                     link, nodeid = hyperdb.splitDesignator(designator)
1031                     link = self.db.classes[link]
1032                     # take a dupe of the list so we're not changing the cache
1033                     value = link.get(nodeid, property)[:]
1034                     value.append(nid)
1035                     link.set(nodeid, **{property: value})
1036             elif key == ':link':
1037                 value = self.form[key].value
1038                 if type(value) != type([]): value = [value]
1039                 for value in value:
1040                     designator, property = value.split(':')
1041                     link, nodeid = hyperdb.splitDesignator(designator)
1042                     link = self.db.classes[link]
1043                     link.set(nodeid, **{property: nid})
1046 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
1047     ''' Pull properties for the given class out of the form.
1049         If a ":required" parameter is supplied, then the names property values
1050         must be supplied or a ValueError will be raised.
1051     '''
1052     required = []
1053     if form.has_key(':required'):
1054         value = form[':required']
1055         if isinstance(value, type([])):
1056             required = [i.value.strip() for i in value]
1057         else:
1058             required = [i.strip() for i in value.value.split(',')]
1060     props = {}
1061     keys = form.keys()
1062     for key in keys:
1063         if not cl.properties.has_key(key):
1064             continue
1065         proptype = cl.properties[key]
1067         # Get the form value. This value may be a MiniFieldStorage or a list
1068         # of MiniFieldStorages.
1069         value = form[key]
1071         # make sure non-multilinks only get one value
1072         if not isinstance(proptype, hyperdb.Multilink):
1073             if isinstance(value, type([])):
1074                 raise ValueError, 'You have submitted more than one value'\
1075                     ' for the %s property'%key
1076             # we've got a MiniFieldStorage, so pull out the value and strip
1077             # surrounding whitespace
1078             value = value.value.strip()
1080         if isinstance(proptype, hyperdb.String):
1081             pass
1082             value = form[key].value.strip()
1083         elif isinstance(proptype, hyperdb.Password):
1084             if not value:
1085                 # ignore empty password values
1086                 continue
1087             value = password.Password(value)
1088         elif isinstance(proptype, hyperdb.Date):
1089             if value:
1090                 value = date.Date(form[key].value.strip())
1091             else:
1092                 value = None
1093         elif isinstance(proptype, hyperdb.Interval):
1094             if value:
1095                 value = date.Interval(form[key].value.strip())
1096             else:
1097                 value = None
1098         elif isinstance(proptype, hyperdb.Link):
1099             # see if it's the "no selection" choice
1100             if value == '-1':
1101                 value = None
1102             else:
1103                 # handle key values
1104                 link = cl.properties[key].classname
1105                 if not num_re.match(value):
1106                     try:
1107                         value = db.classes[link].lookup(value)
1108                     except KeyError:
1109                         raise ValueError, _('property "%(propname)s": '
1110                             '%(value)s not a %(classname)s')%{'propname':key, 
1111                             'value': value, 'classname': link}
1112         elif isinstance(proptype, hyperdb.Multilink):
1113             if isinstance(value, type([])):
1114                 # it's a list of MiniFieldStorages
1115                 value = [i.value.strip() for i in value]
1116             else:
1117                 # it's a MiniFieldStorage, but may be a comma-separated list
1118                 # of values
1119                 value = [i.strip() for i in value.value.split(',')]
1120             link = cl.properties[key].classname
1121             l = []
1122             for entry in map(str, value):
1123                 if entry == '': continue
1124                 if not num_re.match(entry):
1125                     try:
1126                         entry = db.classes[link].lookup(entry)
1127                     except KeyError:
1128                         raise ValueError, _('property "%(propname)s": '
1129                             '"%(value)s" not an entry of %(classname)s')%{
1130                             'propname':key, 'value': entry, 'classname': link}
1131                 l.append(entry)
1132             l.sort()
1133             value = l
1134         elif isinstance(proptype, hyperdb.Boolean):
1135             props[key] = value = value.lower() in ('yes', 'true', 'on', '1')
1136         elif isinstance(proptype, hyperdb.Number):
1137             props[key] = value = int(value)
1139         # get the old value
1140         if nodeid:
1141             try:
1142                 existing = cl.get(nodeid, key)
1143             except KeyError:
1144                 # this might be a new property for which there is no existing
1145                 # value
1146                 if not cl.properties.has_key(key): raise
1148             # if changed, set it
1149             if value != existing:
1150                 props[key] = value
1151         else:
1152             props[key] = value
1154     # see if all the required properties have been supplied
1155     l = []
1156     for property in required:
1157         if not props.has_key(property):
1158             l.append(property)
1159     if l:
1160         raise ValueError, 'Required properties %s not supplied'%(', '.join(l))
1162     return props