1 # $Id: client.py,v 1.17 2002-09-06 03:21:30 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 # XXX check for web access permission!!!!
501 # set the session cookie
502 self.set_cookie(self.user, password)
504 def logout_action(self):
505 ''' Make us really anonymous - nuke the cookie too
506 '''
507 # log us out
508 self.make_user_anonymous()
510 # construct the logout cookie
511 now = Cookie._getdate()
512 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
513 ''))
514 self.header(headers={'Set-Cookie':
515 'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)})
517 # Let the user know what's going on
518 self.ok_message.append(_('You are logged out'))
520 def registerAction(self):
521 '''Attempt to create a new user based on the contents of the form
522 and then set the cookie.
524 return 1 on successful login
525 '''
526 # create the new user
527 cl = self.db.user
529 # parse the props from the form
530 try:
531 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
532 except (ValueError, KeyError), message:
533 self.error_message.append(_('Error: ') + str(message))
534 return
536 # make sure we're allowed to register
537 if not self.registerPermission(props):
538 raise Unauthorised, _("You do not have permission to register")
540 # re-open the database as "admin"
541 if self.user != 'admin':
542 self.opendb('admin')
544 # create the new user
545 cl = self.db.user
546 try:
547 props = parsePropsFromForm(self.db, cl, self.form)
548 props['roles'] = self.instance.NEW_WEB_USER_ROLES
549 self.userid = cl.create(**props)
550 self.db.commit()
551 except ValueError, message:
552 self.error_message.append(message)
554 # log the new user in
555 self.user = cl.get(self.userid, 'username')
556 # re-open the database for real, using the user
557 self.opendb(self.user)
558 password = self.db.user.get(self.userid, 'password')
559 self.set_cookie(self.user, password)
561 # nice message
562 self.ok_message.append(_('You are now registered, welcome!'))
564 def registerPermission(self, props):
565 ''' Determine whether the user has permission to register
567 Base behaviour is to check the user has "Web Registration".
568 '''
569 # registration isn't allowed to supply roles
570 if props.has_key('roles'):
571 return 0
572 if self.db.security.hasPermission('Web Registration', self.userid):
573 return 1
574 return 0
576 def editItemAction(self):
577 ''' Perform an edit of an item in the database.
579 Some special form elements:
581 :link=designator:property
582 :multilink=designator:property
583 The value specifies a node designator and the property on that
584 node to add _this_ node to as a link or multilink.
585 __note
586 Create a message and attach it to the current node's
587 "messages" property.
588 __file
589 Create a file and attach it to the current node's
590 "files" property. Attach the file to the message created from
591 the __note if it's supplied.
593 :required=property,property,...
594 The named properties are required to be filled in the form.
596 '''
597 cl = self.db.classes[self.classname]
599 # parse the props from the form
600 try:
601 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
602 except (ValueError, KeyError), message:
603 self.error_message.append(_('Error: ') + str(message))
604 return
606 # check permission
607 if not self.editItemPermission(props):
608 self.error_message.append(
609 _('You do not have permission to edit %(classname)s'%
610 self.__dict__))
611 return
613 # perform the edit
614 try:
615 # make changes to the node
616 props = self._changenode(props)
617 # handle linked nodes
618 self._post_editnode(self.nodeid)
619 except (ValueError, KeyError), message:
620 self.error_message.append(_('Error: ') + str(message))
621 return
623 # commit now that all the tricky stuff is done
624 self.db.commit()
626 # and some nice feedback for the user
627 if props:
628 message = _('%(changes)s edited ok')%{'changes':
629 ', '.join(props.keys())}
630 elif self.form.has_key('__note') and self.form['__note'].value:
631 message = _('note added')
632 elif (self.form.has_key('__file') and self.form['__file'].filename):
633 message = _('file added')
634 else:
635 message = _('nothing changed')
637 # redirect to the item's edit page
638 raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
639 self.nodeid, urllib.quote(message))
641 def editItemPermission(self, props):
642 ''' Determine whether the user has permission to edit this item.
644 Base behaviour is to check the user can edit this class. If we're
645 editing the "user" class, users are allowed to edit their own
646 details. Unless it's the "roles" property, which requires the
647 special Permission "Web Roles".
648 '''
649 # if this is a user node and the user is editing their own node, then
650 # we're OK
651 has = self.db.security.hasPermission
652 if self.classname == 'user':
653 # reject if someone's trying to edit "roles" and doesn't have the
654 # right permission.
655 if props.has_key('roles') and not has('Web Roles', self.userid,
656 'user'):
657 return 0
658 # if the item being edited is the current user, we're ok
659 if self.nodeid == self.userid:
660 return 1
661 if self.db.security.hasPermission('Edit', self.userid, self.classname):
662 return 1
663 return 0
665 def newItemAction(self):
666 ''' Add a new item to the database.
668 This follows the same form as the editItemAction, with the same
669 special form values.
670 '''
671 cl = self.db.classes[self.classname]
673 # parse the props from the form
674 try:
675 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
676 except (ValueError, KeyError), message:
677 self.error_message.append(_('Error: ') + str(message))
678 return
680 if not self.newItemPermission(props):
681 self.error_message.append(
682 _('You do not have permission to create %s' %self.classname))
684 # create a little extra message for anticipated :link / :multilink
685 if self.form.has_key(':multilink'):
686 link = self.form[':multilink'].value
687 elif self.form.has_key(':link'):
688 link = self.form[':multilink'].value
689 else:
690 link = None
691 xtra = ''
692 if link:
693 designator, linkprop = link.split(':')
694 xtra = ' for <a href="%s">%s</a>'%(designator, designator)
696 try:
697 # do the create
698 nid = self._createnode(props)
700 # handle linked nodes
701 self._post_editnode(nid)
703 # commit now that all the tricky stuff is done
704 self.db.commit()
706 # render the newly created item
707 self.nodeid = nid
709 # and some nice feedback for the user
710 message = _('%(classname)s created ok')%self.__dict__ + xtra
711 except (ValueError, KeyError), message:
712 self.error_message.append(_('Error: ') + str(message))
713 return
714 except:
715 # oops
716 self.db.rollback()
717 s = StringIO.StringIO()
718 traceback.print_exc(None, s)
719 self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
720 return
722 # redirect to the new item's page
723 raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
724 nid, urllib.quote(message))
726 def newItemPermission(self, props):
727 ''' Determine whether the user has permission to create (edit) this
728 item.
730 Base behaviour is to check the user can edit this class. No
731 additional property checks are made. Additionally, new user items
732 may be created if the user has the "Web Registration" Permission.
733 '''
734 has = self.db.security.hasPermission
735 if self.classname == 'user' and has('Web Registration', self.userid,
736 'user'):
737 return 1
738 if has('Edit', self.userid, self.classname):
739 return 1
740 return 0
742 def editCSVAction(self):
743 ''' Performs an edit of all of a class' items in one go.
745 The "rows" CGI var defines the CSV-formatted entries for the
746 class. New nodes are identified by the ID 'X' (or any other
747 non-existent ID) and removed lines are retired.
748 '''
749 # this is per-class only
750 if not self.editCSVPermission():
751 self.error_message.append(
752 _('You do not have permission to edit %s' %self.classname))
754 # get the CSV module
755 try:
756 import csv
757 except ImportError:
758 self.error_message.append(_(
759 'Sorry, you need the csv module to use this function.<br>\n'
760 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
761 return
763 cl = self.db.classes[self.classname]
764 idlessprops = cl.getprops(protected=0).keys()
765 idlessprops.sort()
766 props = ['id'] + idlessprops
768 # do the edit
769 rows = self.form['rows'].value.splitlines()
770 p = csv.parser()
771 found = {}
772 line = 0
773 for row in rows[1:]:
774 line += 1
775 values = p.parse(row)
776 # not a complete row, keep going
777 if not values: continue
779 # skip property names header
780 if values == props:
781 continue
783 # extract the nodeid
784 nodeid, values = values[0], values[1:]
785 found[nodeid] = 1
787 # confirm correct weight
788 if len(idlessprops) != len(values):
789 self.error_message.append(
790 _('Not enough values on line %(line)s')%{'line':line})
791 return
793 # extract the new values
794 d = {}
795 for name, value in zip(idlessprops, values):
796 value = value.strip()
797 # only add the property if it has a value
798 if value:
799 # if it's a multilink, split it
800 if isinstance(cl.properties[name], hyperdb.Multilink):
801 value = value.split(':')
802 d[name] = value
804 # perform the edit
805 if cl.hasnode(nodeid):
806 # edit existing
807 cl.set(nodeid, **d)
808 else:
809 # new node
810 found[cl.create(**d)] = 1
812 # retire the removed entries
813 for nodeid in cl.list():
814 if not found.has_key(nodeid):
815 cl.retire(nodeid)
817 # all OK
818 self.db.commit()
820 self.ok_message.append(_('Items edited OK'))
822 def editCSVPermission(self):
823 ''' Determine whether the user has permission to edit this class.
825 Base behaviour is to check the user can edit this class.
826 '''
827 if not self.db.security.hasPermission('Edit', self.userid,
828 self.classname):
829 return 0
830 return 1
832 def searchAction(self):
833 ''' Mangle some of the form variables.
835 Set the form ":filter" variable based on the values of the
836 filter variables - if they're set to anything other than
837 "dontcare" then add them to :filter.
839 Also handle the ":queryname" variable and save off the query to
840 the user's query list.
841 '''
842 # generic edit is per-class only
843 if not self.searchPermission():
844 self.error_message.append(
845 _('You do not have permission to search %s' %self.classname))
847 # add a faked :filter form variable for each filtering prop
848 props = self.db.classes[self.classname].getprops()
849 for key in self.form.keys():
850 if not props.has_key(key): continue
851 if not self.form[key].value: continue
852 self.form.value.append(cgi.MiniFieldStorage(':filter', key))
854 # handle saving the query params
855 if self.form.has_key(':queryname'):
856 queryname = self.form[':queryname'].value.strip()
857 if queryname:
858 # parse the environment and figure what the query _is_
859 req = HTMLRequest(self)
860 url = req.indexargs_href('', {})
862 # handle editing an existing query
863 try:
864 qid = self.db.query.lookup(queryname)
865 self.db.query.set(qid, klass=self.classname, url=url)
866 except KeyError:
867 # create a query
868 qid = self.db.query.create(name=queryname,
869 klass=self.classname, url=url)
871 # and add it to the user's query multilink
872 queries = self.db.user.get(self.userid, 'queries')
873 queries.append(qid)
874 self.db.user.set(self.userid, queries=queries)
876 # commit the query change to the database
877 self.db.commit()
880 def searchPermission(self):
881 ''' Determine whether the user has permission to search this class.
883 Base behaviour is to check the user can view this class.
884 '''
885 if not self.db.security.hasPermission('View', self.userid,
886 self.classname):
887 return 0
888 return 1
890 def XXXremove_action(self, dre=re.compile(r'([^\d]+)(\d+)')):
891 # XXX I believe this could be handled by a regular edit action that
892 # just sets the multilink...
893 # XXX handle this !
894 target = self.index_arg(':target')[0]
895 m = dre.match(target)
896 if m:
897 classname = m.group(1)
898 nodeid = m.group(2)
899 cl = self.db.getclass(classname)
900 cl.retire(nodeid)
901 # now take care of the reference
902 parentref = self.index_arg(':multilink')[0]
903 parent, prop = parentref.split(':')
904 m = dre.match(parent)
905 if m:
906 self.classname = m.group(1)
907 self.nodeid = m.group(2)
908 cl = self.db.getclass(self.classname)
909 value = cl.get(self.nodeid, prop)
910 value.remove(nodeid)
911 cl.set(self.nodeid, **{prop:value})
912 func = getattr(self, 'show%s'%self.classname)
913 return func()
914 else:
915 raise NotFound, parent
916 else:
917 raise NotFound, target
919 #
920 # Utility methods for editing
921 #
922 def _changenode(self, props):
923 ''' change the node based on the contents of the form
924 '''
925 cl = self.db.classes[self.classname]
927 # create the message
928 message, files = self._handle_message()
929 if message:
930 props['messages'] = cl.get(self.nodeid, 'messages') + [message]
931 if files:
932 props['files'] = cl.get(self.nodeid, 'files') + files
934 # make the changes
935 return cl.set(self.nodeid, **props)
937 def _createnode(self, props):
938 ''' create a node based on the contents of the form
939 '''
940 cl = self.db.classes[self.classname]
942 # check for messages and files
943 message, files = self._handle_message()
944 if message:
945 props['messages'] = [message]
946 if files:
947 props['files'] = files
948 # create the node and return it's id
949 return cl.create(**props)
951 def _handle_message(self):
952 ''' generate an edit message
953 '''
954 # handle file attachments
955 files = []
956 if self.form.has_key('__file'):
957 file = self.form['__file']
958 if file.filename:
959 filename = file.filename.split('\\')[-1]
960 mime_type = mimetypes.guess_type(filename)[0]
961 if not mime_type:
962 mime_type = "application/octet-stream"
963 # create the new file entry
964 files.append(self.db.file.create(type=mime_type,
965 name=filename, content=file.file.read()))
967 # we don't want to do a message if none of the following is true...
968 cn = self.classname
969 cl = self.db.classes[self.classname]
970 props = cl.getprops()
971 note = None
972 # in a nutshell, don't do anything if there's no note or there's no
973 # NOSY
974 if self.form.has_key('__note'):
975 note = self.form['__note'].value.strip()
976 if not note:
977 return None, files
978 if not props.has_key('messages'):
979 return None, files
980 if not isinstance(props['messages'], hyperdb.Multilink):
981 return None, files
982 if not props['messages'].classname == 'msg':
983 return None, files
984 if not (self.form.has_key('nosy') or note):
985 return None, files
987 # handle the note
988 if '\n' in note:
989 summary = re.split(r'\n\r?', note)[0]
990 else:
991 summary = note
992 m = ['%s\n'%note]
994 # handle the messageid
995 # TODO: handle inreplyto
996 messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
997 self.classname, self.instance.MAIL_DOMAIN)
999 # now create the message, attaching the files
1000 content = '\n'.join(m)
1001 message_id = self.db.msg.create(author=self.userid,
1002 recipients=[], date=date.Date('.'), summary=summary,
1003 content=content, files=files, messageid=messageid)
1005 # update the messages property
1006 return message_id, files
1008 def _post_editnode(self, nid):
1009 '''Do the linking part of the node creation.
1011 If a form element has :link or :multilink appended to it, its
1012 value specifies a node designator and the property on that node
1013 to add _this_ node to as a link or multilink.
1015 This is typically used on, eg. the file upload page to indicated
1016 which issue to link the file to.
1018 TODO: I suspect that this and newfile will go away now that
1019 there's the ability to upload a file using the issue __file form
1020 element!
1021 '''
1022 cn = self.classname
1023 cl = self.db.classes[cn]
1024 # link if necessary
1025 keys = self.form.keys()
1026 for key in keys:
1027 if key == ':multilink':
1028 value = self.form[key].value
1029 if type(value) != type([]): value = [value]
1030 for value in value:
1031 designator, property = value.split(':')
1032 link, nodeid = hyperdb.splitDesignator(designator)
1033 link = self.db.classes[link]
1034 # take a dupe of the list so we're not changing the cache
1035 value = link.get(nodeid, property)[:]
1036 value.append(nid)
1037 link.set(nodeid, **{property: value})
1038 elif key == ':link':
1039 value = self.form[key].value
1040 if type(value) != type([]): value = [value]
1041 for value in value:
1042 designator, property = value.split(':')
1043 link, nodeid = hyperdb.splitDesignator(designator)
1044 link = self.db.classes[link]
1045 link.set(nodeid, **{property: nid})
1048 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
1049 ''' Pull properties for the given class out of the form.
1051 If a ":required" parameter is supplied, then the names property values
1052 must be supplied or a ValueError will be raised.
1053 '''
1054 required = []
1055 if form.has_key(':required'):
1056 value = form[':required']
1057 print 'required', value
1058 if isinstance(value, type([])):
1059 required = [i.value.strip() for i in value]
1060 else:
1061 required = [i.strip() for i in value.value.split(',')]
1063 props = {}
1064 keys = form.keys()
1065 for key in keys:
1066 if not cl.properties.has_key(key):
1067 continue
1068 proptype = cl.properties[key]
1070 # Get the form value. This value may be a MiniFieldStorage or a list
1071 # of MiniFieldStorages.
1072 value = form[key]
1074 # make sure non-multilinks only get one value
1075 if not isinstance(proptype, hyperdb.Multilink):
1076 if isinstance(value, type([])):
1077 raise ValueError, 'You have submitted more than one value'\
1078 ' for the %s property'%key
1079 # we've got a MiniFieldStorage, so pull out the value and strip
1080 # surrounding whitespace
1081 value = value.value.strip()
1083 if isinstance(proptype, hyperdb.String):
1084 if not value:
1085 continue
1086 elif isinstance(proptype, hyperdb.Password):
1087 if not value:
1088 # ignore empty password values
1089 continue
1090 value = password.Password(value)
1091 elif isinstance(proptype, hyperdb.Date):
1092 if value:
1093 value = date.Date(form[key].value.strip())
1094 else:
1095 value = None
1096 elif isinstance(proptype, hyperdb.Interval):
1097 if value:
1098 value = date.Interval(form[key].value.strip())
1099 else:
1100 value = None
1101 elif isinstance(proptype, hyperdb.Link):
1102 # see if it's the "no selection" choice
1103 if value == '-1':
1104 value = None
1105 else:
1106 # handle key values
1107 link = cl.properties[key].classname
1108 if not num_re.match(value):
1109 try:
1110 value = db.classes[link].lookup(value)
1111 except KeyError:
1112 raise ValueError, _('property "%(propname)s": '
1113 '%(value)s not a %(classname)s')%{'propname':key,
1114 'value': value, 'classname': link}
1115 elif isinstance(proptype, hyperdb.Multilink):
1116 if isinstance(value, type([])):
1117 # it's a list of MiniFieldStorages
1118 value = [i.value.strip() for i in value]
1119 else:
1120 # it's a MiniFieldStorage, but may be a comma-separated list
1121 # of values
1122 value = [i.strip() for i in value.value.split(',')]
1123 link = cl.properties[key].classname
1124 l = []
1125 for entry in map(str, value):
1126 if entry == '': continue
1127 if not num_re.match(entry):
1128 try:
1129 entry = db.classes[link].lookup(entry)
1130 except KeyError:
1131 raise ValueError, _('property "%(propname)s": '
1132 '"%(value)s" not an entry of %(classname)s')%{
1133 'propname':key, 'value': entry, 'classname': link}
1134 l.append(entry)
1135 l.sort()
1136 value = l
1137 elif isinstance(proptype, hyperdb.Boolean):
1138 props[key] = value = value.lower() in ('yes', 'true', 'on', '1')
1139 elif isinstance(proptype, hyperdb.Number):
1140 props[key] = value = int(value)
1142 # get the old value
1143 if nodeid:
1144 try:
1145 existing = cl.get(nodeid, key)
1146 except KeyError:
1147 # this might be a new property for which there is no existing
1148 # value
1149 if not cl.properties.has_key(key): raise
1151 # if changed, set it
1152 if value != existing:
1153 props[key] = value
1154 else:
1155 props[key] = value
1157 # see if all the required properties have been supplied
1158 l = []
1159 for property in required:
1160 if not props.has_key(property):
1161 l.append(property)
1162 if l:
1163 raise ValueError, 'Required properties %s not supplied'%(', '.join(l))
1165 return props