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