1 # $Id: client.py,v 1.12 2002-09-05 01:27:42 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:
197 home (default if no url is given)
198 classname
199 designator (classname and nodeid)
201 The desired template to be rendered is also determined There
202 are two exceptional contexts:
204 _file - serve up a static file
205 path len > 1 - serve up a FileClass content
206 (the additional path gives the browser a
207 nicer filename to save as)
209 The template used is specified by the :template CGI variable,
210 which defaults to:
211 only classname suplied: "index"
212 full item designator supplied: "item"
214 We set:
215 self.classname - the class to display, can be None
216 self.template - the template to render the current context with
217 self.nodeid - the nodeid of the class we're displaying
218 '''
219 # default the optional variables
220 self.classname = None
221 self.nodeid = None
223 # determine the classname and possibly nodeid
224 path = self.split_path
225 if not path or path[0] in ('', 'home', 'index'):
226 if self.form.has_key(':template'):
227 self.template = self.form[':template'].value
228 else:
229 self.template = ''
230 return
231 elif path[0] == '_file':
232 raise SendStaticFile, path[1]
233 else:
234 self.classname = path[0]
235 if len(path) > 1:
236 # send the file identified by the designator in path[0]
237 raise SendFile, path[0]
239 # see if we got a designator
240 m = dre.match(self.classname)
241 if m:
242 self.classname = m.group(1)
243 self.nodeid = m.group(2)
244 # with a designator, we default to item view
245 self.template = 'item'
246 else:
247 # with only a class, we default to index view
248 self.template = 'index'
250 # see if we have a template override
251 if self.form.has_key(':template'):
252 self.template = self.form[':template'].value
255 # see if we were passed in a message
256 if self.form.has_key(':ok_message'):
257 self.ok_message.append(self.form[':ok_message'].value)
258 if self.form.has_key(':error_message'):
259 self.error_message.append(self.form[':error_message'].value)
261 def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
262 ''' Serve the file from the content property of the designated item.
263 '''
264 m = dre.match(str(designator))
265 if not m:
266 raise NotFound, str(designator)
267 classname, nodeid = m.group(1), m.group(2)
268 if classname != 'file':
269 raise NotFound, designator
271 # we just want to serve up the file named
272 file = self.db.file
273 self.header({'Content-Type': file.get(nodeid, 'type')})
274 self.write(file.get(nodeid, 'content'))
276 def serve_static_file(self, file):
277 # we just want to serve up the file named
278 mt = mimetypes.guess_type(str(file))[0]
279 self.header({'Content-Type': mt})
280 self.write(open(os.path.join(self.instance.TEMPLATES, file)).read())
282 def renderTemplate(self, name, extension, **kwargs):
283 ''' Return a PageTemplate for the named page
284 '''
285 pt = getTemplate(self.instance.TEMPLATES, name, extension)
286 # XXX handle PT rendering errors here more nicely
287 try:
288 # let the template render figure stuff out
289 return pt.render(self, None, None, **kwargs)
290 except PageTemplate.PTRuntimeError, message:
291 return '<strong>%s</strong><ol>%s</ol>'%(message,
292 '<li>'.join(pt._v_errors))
293 except:
294 # everything else
295 return cgitb.html()
297 def content(self):
298 ''' Callback used by the page template to render the content of
299 the page.
301 If we don't have a specific class to display, that is none was
302 determined in determine_context(), then we display a "home"
303 template.
304 '''
305 # now render the page content using the template we determined in
306 # determine_context
307 if self.classname is None:
308 name = 'home'
309 else:
310 name = self.classname
311 return self.renderTemplate(self.classname, self.template)
313 # these are the actions that are available
314 actions = {
315 'edit': 'editItemAction',
316 'editCSV': 'editCSVAction',
317 'new': 'newItemAction',
318 'register': 'registerAction',
319 'login': 'login_action',
320 'logout': 'logout_action',
321 'search': 'searchAction',
322 }
323 def handle_action(self):
324 ''' Determine whether there should be an _action called.
326 The action is defined by the form variable :action which
327 identifies the method on this object to call. The four basic
328 actions are defined in the "actions" dictionary on this class:
329 "edit" -> self.editItemAction
330 "new" -> self.newItemAction
331 "register" -> self.registerAction
332 "login" -> self.login_action
333 "logout" -> self.logout_action
334 "search" -> self.searchAction
336 '''
337 if not self.form.has_key(':action'):
338 return None
339 try:
340 # get the action, validate it
341 action = self.form[':action'].value
342 if not self.actions.has_key(action):
343 raise ValueError, 'No such action "%s"'%action
345 # call the mapped action
346 getattr(self, self.actions[action])()
347 except Redirect:
348 raise
349 except:
350 self.db.rollback()
351 s = StringIO.StringIO()
352 traceback.print_exc(None, s)
353 self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
355 def write(self, content):
356 if not self.headers_done:
357 self.header()
358 self.request.wfile.write(content)
360 def header(self, headers=None, response=200):
361 '''Put up the appropriate header.
362 '''
363 if headers is None:
364 headers = {'Content-Type':'text/html'}
365 if not headers.has_key('Content-Type'):
366 headers['Content-Type'] = 'text/html'
367 self.request.send_response(response)
368 for entry in headers.items():
369 self.request.send_header(*entry)
370 self.request.end_headers()
371 self.headers_done = 1
372 if self.debug:
373 self.headers_sent = headers
375 def set_cookie(self, user, password):
376 # TODO generate a much, much stronger session key ;)
377 self.session = binascii.b2a_base64(repr(time.time())).strip()
379 # clean up the base64
380 if self.session[-1] == '=':
381 if self.session[-2] == '=':
382 self.session = self.session[:-2]
383 else:
384 self.session = self.session[:-1]
386 # insert the session in the sessiondb
387 self.db.sessions.set(self.session, user=user, last_use=time.time())
389 # and commit immediately
390 self.db.sessions.commit()
392 # expire us in a long, long time
393 expire = Cookie._getdate(86400*365)
395 # generate the cookie path - make sure it has a trailing '/'
396 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
397 ''))
398 self.header({'Set-Cookie': 'roundup_user_2=%s; expires=%s; Path=%s;'%(
399 self.session, expire, path)})
401 def make_user_anonymous(self):
402 ''' Make us anonymous
404 This method used to handle non-existence of the 'anonymous'
405 user, but that user is mandatory now.
406 '''
407 self.userid = self.db.user.lookup('anonymous')
408 self.user = 'anonymous'
410 def logout(self):
411 ''' Make us really anonymous - nuke the cookie too
412 '''
413 self.make_user_anonymous()
415 # construct the logout cookie
416 now = Cookie._getdate()
417 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
418 ''))
419 self.header({'Set-Cookie':
420 'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
421 path)})
422 self.login()
424 def opendb(self, user):
425 ''' Open the database.
426 '''
427 # open the db if the user has changed
428 if not hasattr(self, 'db') or user != self.db.journaltag:
429 self.db = self.instance.open(user)
431 #
432 # Actions
433 #
434 def login_action(self):
435 ''' Attempt to log a user in and set the cookie
436 '''
437 # we need the username at a minimum
438 if not self.form.has_key('__login_name'):
439 self.error_message.append(_('Username required'))
440 return
442 self.user = self.form['__login_name'].value
443 # re-open the database for real, using the user
444 self.opendb(self.user)
445 if self.form.has_key('__login_password'):
446 password = self.form['__login_password'].value
447 else:
448 password = ''
449 # make sure the user exists
450 try:
451 self.userid = self.db.user.lookup(self.user)
452 except KeyError:
453 name = self.user
454 self.make_user_anonymous()
455 self.error_message.append(_('No such user "%(name)s"')%locals())
456 return
458 # and that the password is correct
459 pw = self.db.user.get(self.userid, 'password')
460 if password != pw:
461 self.make_user_anonymous()
462 self.error_message.append(_('Incorrect password'))
463 return
465 # set the session cookie
466 self.set_cookie(self.user, password)
468 def logout_action(self):
469 ''' Make us really anonymous - nuke the cookie too
470 '''
471 # log us out
472 self.make_user_anonymous()
474 # construct the logout cookie
475 now = Cookie._getdate()
476 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
477 ''))
478 self.header(headers={'Set-Cookie':
479 'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)})
481 # Let the user know what's going on
482 self.ok_message.append(_('You are logged out'))
484 def registerAction(self):
485 '''Attempt to create a new user based on the contents of the form
486 and then set the cookie.
488 return 1 on successful login
489 '''
490 # create the new user
491 cl = self.db.user
493 # parse the props from the form
494 try:
495 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
496 except (ValueError, KeyError), message:
497 self.error_message.append(_('Error: ') + str(message))
498 return
500 # make sure we're allowed to register
501 if not self.registerPermission(props):
502 raise Unauthorised, _("You do not have permission to register")
504 # re-open the database as "admin"
505 if self.user != 'admin':
506 self.opendb('admin')
508 # create the new user
509 cl = self.db.user
510 try:
511 props = parsePropsFromForm(self.db, cl, self.form)
512 props['roles'] = self.instance.NEW_WEB_USER_ROLES
513 self.userid = cl.create(**props)
514 self.db.commit()
515 except ValueError, message:
516 self.error_message.append(message)
518 # log the new user in
519 self.user = cl.get(self.userid, 'username')
520 # re-open the database for real, using the user
521 self.opendb(self.user)
522 password = self.db.user.get(self.userid, 'password')
523 self.set_cookie(self.user, password)
525 # nice message
526 self.ok_message.append(_('You are now registered, welcome!'))
528 def registerPermission(self, props):
529 ''' Determine whether the user has permission to register
531 Base behaviour is to check the user has "Web Registration".
532 '''
533 # registration isn't allowed to supply roles
534 if props.has_key('roles'):
535 return 0
536 if self.db.security.hasPermission('Web Registration', self.userid):
537 return 1
538 return 0
540 def editItemAction(self):
541 ''' Perform an edit of an item in the database.
543 Some special form elements:
545 :link=designator:property
546 :multilink=designator:property
547 The value specifies a node designator and the property on that
548 node to add _this_ node to as a link or multilink.
549 __note
550 Create a message and attach it to the current node's
551 "messages" property.
552 __file
553 Create a file and attach it to the current node's
554 "files" property. Attach the file to the message created from
555 the __note if it's supplied.
556 '''
557 cl = self.db.classes[self.classname]
559 # parse the props from the form
560 try:
561 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
562 except (ValueError, KeyError), message:
563 self.error_message.append(_('Error: ') + str(message))
564 return
566 # check permission
567 if not self.editItemPermission(props):
568 self.error_message.append(
569 _('You do not have permission to edit %(classname)s'%
570 self.__dict__))
571 return
573 # perform the edit
574 try:
575 # make changes to the node
576 props = self._changenode(props)
577 # handle linked nodes
578 self._post_editnode(self.nodeid)
579 except (ValueError, KeyError), message:
580 self.error_message.append(_('Error: ') + str(message))
581 return
583 # commit now that all the tricky stuff is done
584 self.db.commit()
586 # and some nice feedback for the user
587 if props:
588 message = _('%(changes)s edited ok')%{'changes':
589 ', '.join(props.keys())}
590 elif self.form.has_key('__note') and self.form['__note'].value:
591 message = _('note added')
592 elif (self.form.has_key('__file') and self.form['__file'].filename):
593 message = _('file added')
594 else:
595 message = _('nothing changed')
597 # redirect to the item's edit page
598 raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
599 self.nodeid, urllib.quote(message))
601 def editItemPermission(self, props):
602 ''' Determine whether the user has permission to edit this item.
604 Base behaviour is to check the user can edit this class. If we're
605 editing the "user" class, users are allowed to edit their own
606 details. Unless it's the "roles" property, which requires the
607 special Permission "Web Roles".
608 '''
609 # if this is a user node and the user is editing their own node, then
610 # we're OK
611 has = self.db.security.hasPermission
612 if self.classname == 'user':
613 # reject if someone's trying to edit "roles" and doesn't have the
614 # right permission.
615 if props.has_key('roles') and not has('Web Roles', self.userid,
616 'user'):
617 return 0
618 # if the item being edited is the current user, we're ok
619 if self.nodeid == self.userid:
620 return 1
621 if self.db.security.hasPermission('Edit', self.userid, self.classname):
622 return 1
623 return 0
625 def newItemAction(self):
626 ''' Add a new item to the database.
628 This follows the same form as the editItemAction
629 '''
630 cl = self.db.classes[self.classname]
632 # parse the props from the form
633 try:
634 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
635 except (ValueError, KeyError), message:
636 self.error_message.append(_('Error: ') + str(message))
637 return
639 if not self.newItemPermission(props):
640 self.error_message.append(
641 _('You do not have permission to create %s' %self.classname))
643 # create a little extra message for anticipated :link / :multilink
644 if self.form.has_key(':multilink'):
645 link = self.form[':multilink'].value
646 elif self.form.has_key(':link'):
647 link = self.form[':multilink'].value
648 else:
649 link = None
650 xtra = ''
651 if link:
652 designator, linkprop = link.split(':')
653 xtra = ' for <a href="%s">%s</a>'%(designator, designator)
655 try:
656 # do the create
657 nid = self._createnode(props)
659 # handle linked nodes
660 self._post_editnode(nid)
662 # commit now that all the tricky stuff is done
663 self.db.commit()
665 # render the newly created item
666 self.nodeid = nid
668 # and some nice feedback for the user
669 message = _('%(classname)s created ok')%self.__dict__ + xtra
670 except (ValueError, KeyError), message:
671 self.error_message.append(_('Error: ') + str(message))
672 return
673 except:
674 # oops
675 self.db.rollback()
676 s = StringIO.StringIO()
677 traceback.print_exc(None, s)
678 self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
679 return
681 # redirect to the new item's page
682 raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
683 nid, urllib.quote(message))
685 def newItemPermission(self, props):
686 ''' Determine whether the user has permission to create (edit) this
687 item.
689 Base behaviour is to check the user can edit this class. No
690 additional property checks are made. Additionally, new user items
691 may be created if the user has the "Web Registration" Permission.
692 '''
693 has = self.db.security.hasPermission
694 if self.classname == 'user' and has('Web Registration', self.userid,
695 'user'):
696 return 1
697 if has('Edit', self.userid, self.classname):
698 return 1
699 return 0
701 def editCSVAction(self):
702 ''' Performs an edit of all of a class' items in one go.
704 The "rows" CGI var defines the CSV-formatted entries for the
705 class. New nodes are identified by the ID 'X' (or any other
706 non-existent ID) and removed lines are retired.
707 '''
708 # this is per-class only
709 if not self.editCSVPermission():
710 self.error_message.append(
711 _('You do not have permission to edit %s' %self.classname))
713 # get the CSV module
714 try:
715 import csv
716 except ImportError:
717 self.error_message.append(_(
718 'Sorry, you need the csv module to use this function.<br>\n'
719 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
720 return
722 cl = self.db.classes[self.classname]
723 idlessprops = cl.getprops(protected=0).keys()
724 idlessprops.sort()
725 props = ['id'] + idlessprops
727 # do the edit
728 rows = self.form['rows'].value.splitlines()
729 p = csv.parser()
730 found = {}
731 line = 0
732 for row in rows[1:]:
733 line += 1
734 values = p.parse(row)
735 # not a complete row, keep going
736 if not values: continue
738 # skip property names header
739 if values == props:
740 continue
742 # extract the nodeid
743 nodeid, values = values[0], values[1:]
744 found[nodeid] = 1
746 # confirm correct weight
747 if len(idlessprops) != len(values):
748 self.error_message.append(
749 _('Not enough values on line %(line)s')%{'line':line})
750 return
752 # extract the new values
753 d = {}
754 for name, value in zip(idlessprops, values):
755 value = value.strip()
756 # only add the property if it has a value
757 if value:
758 # if it's a multilink, split it
759 if isinstance(cl.properties[name], hyperdb.Multilink):
760 value = value.split(':')
761 d[name] = value
763 # perform the edit
764 if cl.hasnode(nodeid):
765 # edit existing
766 cl.set(nodeid, **d)
767 else:
768 # new node
769 found[cl.create(**d)] = 1
771 # retire the removed entries
772 for nodeid in cl.list():
773 if not found.has_key(nodeid):
774 cl.retire(nodeid)
776 # all OK
777 self.db.commit()
779 self.ok_message.append(_('Items edited OK'))
781 def editCSVPermission(self):
782 ''' Determine whether the user has permission to edit this class.
784 Base behaviour is to check the user can edit this class.
785 '''
786 if not self.db.security.hasPermission('Edit', self.userid,
787 self.classname):
788 return 0
789 return 1
791 def searchAction(self):
792 ''' Mangle some of the form variables.
794 Set the form ":filter" variable based on the values of the
795 filter variables - if they're set to anything other than
796 "dontcare" then add them to :filter.
798 Also handle the ":queryname" variable and save off the query to
799 the user's query list.
800 '''
801 # generic edit is per-class only
802 if not self.searchPermission():
803 self.error_message.append(
804 _('You do not have permission to search %s' %self.classname))
806 # add a faked :filter form variable for each filtering prop
807 props = self.db.classes[self.classname].getprops()
808 for key in self.form.keys():
809 if not props.has_key(key): continue
810 if not self.form[key].value: continue
811 self.form.value.append(cgi.MiniFieldStorage(':filter', key))
813 # handle saving the query params
814 if self.form.has_key(':queryname'):
815 queryname = self.form[':queryname'].value.strip()
816 if queryname:
817 # parse the environment and figure what the query _is_
818 req = HTMLRequest(self)
819 url = req.indexargs_href('', {})
821 # handle editing an existing query
822 try:
823 qid = self.db.query.lookup(queryname)
824 self.db.query.set(qid, klass=self.classname, url=url)
825 except KeyError:
826 # create a query
827 qid = self.db.query.create(name=queryname,
828 klass=self.classname, url=url)
830 # and add it to the user's query multilink
831 queries = self.db.user.get(self.userid, 'queries')
832 queries.append(qid)
833 self.db.user.set(self.userid, queries=queries)
835 # commit the query change to the database
836 self.db.commit()
839 def searchPermission(self):
840 ''' Determine whether the user has permission to search this class.
842 Base behaviour is to check the user can view this class.
843 '''
844 if not self.db.security.hasPermission('View', self.userid,
845 self.classname):
846 return 0
847 return 1
849 def XXXremove_action(self, dre=re.compile(r'([^\d]+)(\d+)')):
850 # XXX I believe this could be handled by a regular edit action that
851 # just sets the multilink...
852 # XXX handle this !
853 target = self.index_arg(':target')[0]
854 m = dre.match(target)
855 if m:
856 classname = m.group(1)
857 nodeid = m.group(2)
858 cl = self.db.getclass(classname)
859 cl.retire(nodeid)
860 # now take care of the reference
861 parentref = self.index_arg(':multilink')[0]
862 parent, prop = parentref.split(':')
863 m = dre.match(parent)
864 if m:
865 self.classname = m.group(1)
866 self.nodeid = m.group(2)
867 cl = self.db.getclass(self.classname)
868 value = cl.get(self.nodeid, prop)
869 value.remove(nodeid)
870 cl.set(self.nodeid, **{prop:value})
871 func = getattr(self, 'show%s'%self.classname)
872 return func()
873 else:
874 raise NotFound, parent
875 else:
876 raise NotFound, target
878 #
879 # Utility methods for editing
880 #
881 def _changenode(self, props):
882 ''' change the node based on the contents of the form
883 '''
884 cl = self.db.classes[self.classname]
886 # create the message
887 message, files = self._handle_message()
888 if message:
889 props['messages'] = cl.get(self.nodeid, 'messages') + [message]
890 if files:
891 props['files'] = cl.get(self.nodeid, 'files') + files
893 # make the changes
894 return cl.set(self.nodeid, **props)
896 def _createnode(self, props):
897 ''' create a node based on the contents of the form
898 '''
899 cl = self.db.classes[self.classname]
901 # check for messages and files
902 message, files = self._handle_message()
903 if message:
904 props['messages'] = [message]
905 if files:
906 props['files'] = files
907 # create the node and return it's id
908 return cl.create(**props)
910 def _handle_message(self):
911 ''' generate an edit message
912 '''
913 # handle file attachments
914 files = []
915 if self.form.has_key('__file'):
916 file = self.form['__file']
917 if file.filename:
918 filename = file.filename.split('\\')[-1]
919 mime_type = mimetypes.guess_type(filename)[0]
920 if not mime_type:
921 mime_type = "application/octet-stream"
922 # create the new file entry
923 files.append(self.db.file.create(type=mime_type,
924 name=filename, content=file.file.read()))
926 # we don't want to do a message if none of the following is true...
927 cn = self.classname
928 cl = self.db.classes[self.classname]
929 props = cl.getprops()
930 note = None
931 # in a nutshell, don't do anything if there's no note or there's no
932 # NOSY
933 if self.form.has_key('__note'):
934 note = self.form['__note'].value.strip()
935 if not note:
936 return None, files
937 if not props.has_key('messages'):
938 return None, files
939 if not isinstance(props['messages'], hyperdb.Multilink):
940 return None, files
941 if not props['messages'].classname == 'msg':
942 return None, files
943 if not (self.form.has_key('nosy') or note):
944 return None, files
946 # handle the note
947 if '\n' in note:
948 summary = re.split(r'\n\r?', note)[0]
949 else:
950 summary = note
951 m = ['%s\n'%note]
953 # handle the messageid
954 # TODO: handle inreplyto
955 messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
956 self.classname, self.instance.MAIL_DOMAIN)
958 # now create the message, attaching the files
959 content = '\n'.join(m)
960 message_id = self.db.msg.create(author=self.userid,
961 recipients=[], date=date.Date('.'), summary=summary,
962 content=content, files=files, messageid=messageid)
964 # update the messages property
965 return message_id, files
967 def _post_editnode(self, nid):
968 '''Do the linking part of the node creation.
970 If a form element has :link or :multilink appended to it, its
971 value specifies a node designator and the property on that node
972 to add _this_ node to as a link or multilink.
974 This is typically used on, eg. the file upload page to indicated
975 which issue to link the file to.
977 TODO: I suspect that this and newfile will go away now that
978 there's the ability to upload a file using the issue __file form
979 element!
980 '''
981 cn = self.classname
982 cl = self.db.classes[cn]
983 # link if necessary
984 keys = self.form.keys()
985 for key in keys:
986 if key == ':multilink':
987 value = self.form[key].value
988 if type(value) != type([]): value = [value]
989 for value in value:
990 designator, property = value.split(':')
991 link, nodeid = hyperdb.splitDesignator(designator)
992 link = self.db.classes[link]
993 # take a dupe of the list so we're not changing the cache
994 value = link.get(nodeid, property)[:]
995 value.append(nid)
996 link.set(nodeid, **{property: value})
997 elif key == ':link':
998 value = self.form[key].value
999 if type(value) != type([]): value = [value]
1000 for value in value:
1001 designator, property = value.split(':')
1002 link, nodeid = hyperdb.splitDesignator(designator)
1003 link = self.db.classes[link]
1004 link.set(nodeid, **{property: nid})
1007 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
1008 '''Pull properties for the given class out of the form.
1009 '''
1010 props = {}
1011 keys = form.keys()
1012 for key in keys:
1013 if not cl.properties.has_key(key):
1014 continue
1015 proptype = cl.properties[key]
1016 if isinstance(proptype, hyperdb.String):
1017 value = form[key].value.strip()
1018 elif isinstance(proptype, hyperdb.Password):
1019 value = form[key].value.strip()
1020 if not value:
1021 # ignore empty password values
1022 continue
1023 value = password.Password(value)
1024 elif isinstance(proptype, hyperdb.Date):
1025 value = form[key].value.strip()
1026 if value:
1027 value = date.Date(form[key].value.strip())
1028 else:
1029 value = None
1030 elif isinstance(proptype, hyperdb.Interval):
1031 value = form[key].value.strip()
1032 if value:
1033 value = date.Interval(form[key].value.strip())
1034 else:
1035 value = None
1036 elif isinstance(proptype, hyperdb.Link):
1037 value = form[key].value.strip()
1038 # see if it's the "no selection" choice
1039 if value == '-1':
1040 value = None
1041 else:
1042 # handle key values
1043 link = cl.properties[key].classname
1044 if not num_re.match(value):
1045 try:
1046 value = db.classes[link].lookup(value)
1047 except KeyError:
1048 raise ValueError, _('property "%(propname)s": '
1049 '%(value)s not a %(classname)s')%{'propname':key,
1050 'value': value, 'classname': link}
1051 elif isinstance(proptype, hyperdb.Multilink):
1052 value = form[key]
1053 if not isinstance(value, type([])):
1054 value = [i.strip() for i in value.value.split(',')]
1055 else:
1056 value = [i.value.strip() for i in value]
1057 link = cl.properties[key].classname
1058 l = []
1059 for entry in map(str, value):
1060 if entry == '': continue
1061 if not num_re.match(entry):
1062 try:
1063 entry = db.classes[link].lookup(entry)
1064 except KeyError:
1065 raise ValueError, _('property "%(propname)s": '
1066 '"%(value)s" not an entry of %(classname)s')%{
1067 'propname':key, 'value': entry, 'classname': link}
1068 l.append(entry)
1069 l.sort()
1070 value = l
1071 elif isinstance(proptype, hyperdb.Boolean):
1072 value = form[key].value.strip()
1073 props[key] = value = value.lower() in ('yes', 'true', 'on', '1')
1074 elif isinstance(proptype, hyperdb.Number):
1075 value = form[key].value.strip()
1076 props[key] = value = int(value)
1078 # get the old value
1079 if nodeid:
1080 try:
1081 existing = cl.get(nodeid, key)
1082 except KeyError:
1083 # this might be a new property for which there is no existing
1084 # value
1085 if not cl.properties.has_key(key): raise
1087 # if changed, set it
1088 if value != existing:
1089 props[key] = value
1090 else:
1091 props[key] = value
1092 return props