Code

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