1 # $Id: client.py,v 1.5 2002-09-01 23:57:53 richard Exp $
3 __doc__ = """
4 WWW request handler (also used in the stand-alone server).
5 """
7 import os, 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 RoundupPageTemplate
14 from roundup.cgi import cgitb
15 from PageTemplates import PageTemplate
17 class Unauthorised(ValueError):
18 pass
20 class NotFound(ValueError):
21 pass
23 class Redirect(Exception):
24 pass
26 class SendFile(Exception):
27 ' Sent a file from the database '
29 class SendStaticFile(Exception):
30 ' Send a static file from the instance html directory '
32 def initialiseSecurity(security):
33 ''' Create some Permissions and Roles on the security object
35 This function is directly invoked by security.Security.__init__()
36 as a part of the Security object instantiation.
37 '''
38 security.addPermission(name="Web Registration",
39 description="User may register through the web")
40 p = security.addPermission(name="Web Access",
41 description="User may access the web interface")
42 security.addPermissionToRole('Admin', p)
44 # doing Role stuff through the web - make sure Admin can
45 p = security.addPermission(name="Web Roles",
46 description="User may manipulate user Roles through the web")
47 security.addPermissionToRole('Admin', p)
49 class Client:
50 '''
51 A note about login
52 ------------------
54 If the user has no login cookie, then they are anonymous. There
55 are two levels of anonymous use. If there is no 'anonymous' user, there
56 is no login at all and the database is opened in read-only mode. If the
57 'anonymous' user exists, the user is logged in using that user (though
58 there is no cookie). This allows them to modify the database, and all
59 modifications are attributed to the 'anonymous' user.
61 Once a user logs in, they are assigned a session. The Client instance
62 keeps the nodeid of the session as the "session" attribute.
64 Client attributes:
65 "url" is the current url path
66 "path" is the PATH_INFO inside the instance
67 "base" is the base URL for the instance
68 '''
70 def __init__(self, instance, request, env, form=None):
71 hyperdb.traceMark()
72 self.instance = instance
73 self.request = request
74 self.env = env
76 self.path = env['PATH_INFO']
77 self.split_path = self.path.split('/')
78 self.instance_path_name = env['INSTANCE_NAME']
80 # this is the base URL for this instance
81 url = self.env['SCRIPT_NAME'] + '/' + self.instance_path_name
82 self.base = urlparse.urlunparse(('http', env['HTTP_HOST'], url,
83 None, None, None))
85 # request.path is the full request path
86 x, x, path, x, x, x = urlparse.urlparse(request.path)
87 self.url = urlparse.urlunparse(('http', env['HTTP_HOST'], path,
88 None, None, None))
90 if form is None:
91 self.form = cgi.FieldStorage(environ=env)
92 else:
93 self.form = form
94 self.headers_done = 0
95 try:
96 self.debug = int(env.get("ROUNDUP_DEBUG", 0))
97 except ValueError:
98 # someone gave us a non-int debug level, turn it off
99 self.debug = 0
101 def main(self):
102 ''' Wrap the request and handle unauthorised requests
103 '''
104 self.content_action = None
105 self.ok_message = []
106 self.error_message = []
107 try:
108 # make sure we're identified (even anonymously)
109 self.determine_user()
110 # figure out the context and desired content template
111 self.determine_context()
112 # possibly handle a form submit action (may change self.message
113 # and self.template_name)
114 self.handle_action()
115 # now render the page
116 self.write(self.template('page', ok_message=self.ok_message,
117 error_message=self.error_message))
118 except Redirect, url:
119 # let's redirect - if the url isn't None, then we need to do
120 # the headers, otherwise the headers have been set before the
121 # exception was raised
122 if url:
123 self.header({'Location': url}, response=302)
124 except SendFile, designator:
125 self.serve_file(designator)
126 except SendStaticFile, file:
127 self.serve_static_file(file)
128 except Unauthorised, message:
129 self.write(self.template('page.unauthorised',
130 error_message=message))
131 except:
132 # everything else
133 self.write(cgitb.html())
135 def determine_user(self):
136 ''' Determine who the user is
137 '''
138 # determine the uid to use
139 self.opendb('admin')
141 # make sure we have the session Class
142 sessions = self.db.sessions
144 # age sessions, remove when they haven't been used for a week
145 # TODO: this shouldn't be done every access
146 week = 60*60*24*7
147 now = time.time()
148 for sessid in sessions.list():
149 interval = now - sessions.get(sessid, 'last_use')
150 if interval > week:
151 sessions.destroy(sessid)
153 # look up the user session cookie
154 cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
155 user = 'anonymous'
157 if (cookie.has_key('roundup_user') and
158 cookie['roundup_user'].value != 'deleted'):
160 # get the session key from the cookie
161 self.session = cookie['roundup_user'].value
162 # get the user from the session
163 try:
164 # update the lifetime datestamp
165 sessions.set(self.session, last_use=time.time())
166 sessions.commit()
167 user = sessions.get(self.session, 'user')
168 except KeyError:
169 user = 'anonymous'
171 # sanity check on the user still being valid, getting the userid
172 # at the same time
173 try:
174 self.userid = self.db.user.lookup(user)
175 except (KeyError, TypeError):
176 user = 'anonymous'
178 # make sure the anonymous user is valid if we're using it
179 if user == 'anonymous':
180 self.make_user_anonymous()
181 else:
182 self.user = user
184 # reopen the database as the correct user
185 self.opendb(self.user)
187 def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
188 ''' Determine the context of this page:
190 home (default if no url is given)
191 classname
192 designator (classname and nodeid)
194 The desired template to be rendered is also determined There
195 are two exceptional contexts:
197 _file - serve up a static file
198 path len > 1 - serve up a FileClass content
199 (the additional path gives the browser a
200 nicer filename to save as)
202 The template used is specified by the :template CGI variable,
203 which defaults to:
204 only classname suplied: "index"
205 full item designator supplied: "item"
207 We set:
208 self.classname
209 self.nodeid
210 self.template_name
211 '''
212 # default the optional variables
213 self.classname = None
214 self.nodeid = None
216 # determine the classname and possibly nodeid
217 path = self.split_path
218 if not path or path[0] in ('', 'home', 'index'):
219 if self.form.has_key(':template'):
220 self.template_type = self.form[':template'].value
221 self.template_name = 'home' + '.' + self.template_type
222 else:
223 self.template_type = ''
224 self.template_name = 'home'
225 return
226 elif path[0] == '_file':
227 raise SendStaticFile, path[1]
228 else:
229 self.classname = path[0]
230 if len(path) > 1:
231 # send the file identified by the designator in path[0]
232 raise SendFile, path[0]
234 # see if we got a designator
235 m = dre.match(self.classname)
236 if m:
237 self.classname = m.group(1)
238 self.nodeid = m.group(2)
239 # with a designator, we default to item view
240 self.template_type = 'item'
241 else:
242 # with only a class, we default to index view
243 self.template_type = 'index'
245 # see if we have a template override
246 if self.form.has_key(':template'):
247 self.template_type = self.form[':template'].value
250 # see if we were passed in a message
251 if self.form.has_key(':ok_message'):
252 self.ok_message.append(self.form[':ok_message'].value)
253 if self.form.has_key(':error_message'):
254 self.error_message.append(self.form[':error_message'].value)
256 # we have the template name now
257 self.template_name = self.classname + '.' + self.template_type
259 def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
260 ''' Serve the file from the content property of the designated item.
261 '''
262 m = dre.match(str(designator))
263 if not m:
264 raise NotFound, str(designator)
265 classname, nodeid = m.group(1), m.group(2)
266 if classname != 'file':
267 raise NotFound, designator
269 # we just want to serve up the file named
270 file = self.db.file
271 self.header({'Content-Type': file.get(nodeid, 'type')})
272 self.write(file.get(nodeid, 'content'))
274 def serve_static_file(self, file):
275 # we just want to serve up the file named
276 mt = mimetypes.guess_type(str(file))[0]
277 self.header({'Content-Type': mt})
278 self.write(open('/tmp/test/html/%s'%file).read())
280 def template(self, name, **kwargs):
281 ''' Return a PageTemplate for the named page
282 '''
283 pt = RoundupPageTemplate(self)
284 # make errors nicer
285 pt.id = name
286 pt.write(open('/tmp/test/html/%s'%name).read())
287 # XXX handle PT rendering errors here nicely
288 try:
289 return pt.render(**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.
300 '''
301 # now render the page content using the template we determined in
302 # determine_context
303 return self.template(self.template_name)
305 # these are the actions that are available
306 actions = {
307 'edit': 'editItemAction',
308 'new': 'newItemAction',
309 'register': 'registerAction',
310 'login': 'login_action',
311 'logout': 'logout_action',
312 'search': 'searchAction',
313 }
314 def handle_action(self):
315 ''' Determine whether there should be an _action called.
317 The action is defined by the form variable :action which
318 identifies the method on this object to call. The four basic
319 actions are defined in the "actions" dictionary on this class:
320 "edit" -> self.editItemAction
321 "new" -> self.newItemAction
322 "register" -> self.registerAction
323 "login" -> self.login_action
324 "logout" -> self.logout_action
325 "search" -> self.searchAction
327 '''
328 if not self.form.has_key(':action'):
329 return None
330 try:
331 # get the action, validate it
332 action = self.form[':action'].value
333 if not self.actions.has_key(action):
334 raise ValueError, 'No such action "%s"'%action
336 # call the mapped action
337 getattr(self, self.actions[action])()
338 except Redirect:
339 raise
340 except:
341 self.db.rollback()
342 s = StringIO.StringIO()
343 traceback.print_exc(None, s)
344 self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
346 def write(self, content):
347 if not self.headers_done:
348 self.header()
349 self.request.wfile.write(content)
351 def header(self, headers=None, response=200):
352 '''Put up the appropriate header.
353 '''
354 if headers is None:
355 headers = {'Content-Type':'text/html'}
356 if not headers.has_key('Content-Type'):
357 headers['Content-Type'] = 'text/html'
358 self.request.send_response(response)
359 for entry in headers.items():
360 self.request.send_header(*entry)
361 self.request.end_headers()
362 self.headers_done = 1
363 if self.debug:
364 self.headers_sent = headers
366 def set_cookie(self, user, password):
367 # TODO generate a much, much stronger session key ;)
368 self.session = binascii.b2a_base64(repr(time.time())).strip()
370 # clean up the base64
371 if self.session[-1] == '=':
372 if self.session[-2] == '=':
373 self.session = self.session[:-2]
374 else:
375 self.session = self.session[:-1]
377 # insert the session in the sessiondb
378 self.db.sessions.set(self.session, user=user, last_use=time.time())
380 # and commit immediately
381 self.db.sessions.commit()
383 # expire us in a long, long time
384 expire = Cookie._getdate(86400*365)
386 # generate the cookie path - make sure it has a trailing '/'
387 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
388 ''))
389 self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;'%(
390 self.session, expire, path)})
392 def make_user_anonymous(self):
393 ''' Make us anonymous
395 This method used to handle non-existence of the 'anonymous'
396 user, but that user is mandatory now.
397 '''
398 self.userid = self.db.user.lookup('anonymous')
399 self.user = 'anonymous'
401 def logout(self):
402 ''' Make us really anonymous - nuke the cookie too
403 '''
404 self.make_user_anonymous()
406 # construct the logout cookie
407 now = Cookie._getdate()
408 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
409 ''))
410 self.header({'Set-Cookie':
411 'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
412 path)})
413 self.login()
415 def opendb(self, user):
416 ''' Open the database.
417 '''
418 # open the db if the user has changed
419 if not hasattr(self, 'db') or user != self.db.journaltag:
420 self.db = self.instance.open(user)
422 #
423 # Actions
424 #
425 def login_action(self):
426 ''' Attempt to log a user in and set the cookie
427 '''
428 # we need the username at a minimum
429 if not self.form.has_key('__login_name'):
430 self.error_message.append(_('Username required'))
431 return
433 self.user = self.form['__login_name'].value
434 # re-open the database for real, using the user
435 self.opendb(self.user)
436 if self.form.has_key('__login_password'):
437 password = self.form['__login_password'].value
438 else:
439 password = ''
440 # make sure the user exists
441 try:
442 self.userid = self.db.user.lookup(self.user)
443 except KeyError:
444 name = self.user
445 self.make_user_anonymous()
446 self.error_message.append(_('No such user "%(name)s"')%locals())
447 return
449 # and that the password is correct
450 pw = self.db.user.get(self.userid, 'password')
451 if password != pw:
452 self.make_user_anonymous()
453 self.error_message.append(_('Incorrect password'))
454 return
456 # set the session cookie
457 self.set_cookie(self.user, password)
459 def logout_action(self):
460 ''' Make us really anonymous - nuke the cookie too
461 '''
462 # log us out
463 self.make_user_anonymous()
465 # construct the logout cookie
466 now = Cookie._getdate()
467 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
468 ''))
469 self.header(headers={'Set-Cookie':
470 'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)})
472 # Let the user know what's going on
473 self.ok_message.append(_('You are logged out'))
475 def registerAction(self):
476 '''Attempt to create a new user based on the contents of the form
477 and then set the cookie.
479 return 1 on successful login
480 '''
481 # create the new user
482 cl = self.db.user
484 # parse the props from the form
485 try:
486 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
487 except (ValueError, KeyError), message:
488 self.error_message.append(_('Error: ') + str(message))
489 return
491 # make sure we're allowed to register
492 if not self.registerPermission(props):
493 raise Unauthorised, _("You do not have permission to register")
495 # re-open the database as "admin"
496 if self.user != 'admin':
497 self.opendb('admin')
499 # create the new user
500 cl = self.db.user
501 try:
502 props = parsePropsFromForm(self.db, cl, self.form)
503 props['roles'] = self.instance.NEW_WEB_USER_ROLES
504 self.userid = cl.create(**props)
505 self.db.commit()
506 except ValueError, message:
507 self.error_message.append(message)
509 # log the new user in
510 self.user = cl.get(self.userid, 'username')
511 # re-open the database for real, using the user
512 self.opendb(self.user)
513 password = self.db.user.get(self.userid, 'password')
514 self.set_cookie(self.user, password)
516 # nice message
517 self.ok_message.append(_('You are now registered, welcome!'))
519 def registerPermission(self, props):
520 ''' Determine whether the user has permission to register
522 Base behaviour is to check the user has "Web Registration".
523 '''
524 # registration isn't allowed to supply roles
525 if props.has_key('roles'):
526 return 0
527 if self.db.security.hasPermission('Web Registration', self.userid):
528 return 1
529 return 0
531 def editItemAction(self):
532 ''' Perform an edit of an item in the database.
534 Some special form elements:
536 :link=designator:property
537 :multilink=designator:property
538 The value specifies a node designator and the property on that
539 node to add _this_ node to as a link or multilink.
540 __note
541 Create a message and attach it to the current node's
542 "messages" property.
543 __file
544 Create a file and attach it to the current node's
545 "files" property. Attach the file to the message created from
546 the __note if it's supplied.
547 '''
548 cl = self.db.classes[self.classname]
550 # parse the props from the form
551 try:
552 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
553 except (ValueError, KeyError), message:
554 self.error_message.append(_('Error: ') + str(message))
555 return
557 # check permission
558 if not self.editItemPermission(props):
559 self.error_message.append(
560 _('You do not have permission to edit %(classname)s'%
561 self.__dict__))
562 return
564 # perform the edit
565 try:
566 # make changes to the node
567 props = self._changenode(props)
568 # handle linked nodes
569 self._post_editnode(self.nodeid)
570 except (ValueError, KeyError), message:
571 self.error_message.append(_('Error: ') + str(message))
572 return
574 # commit now that all the tricky stuff is done
575 self.db.commit()
577 # and some nice feedback for the user
578 if props:
579 message = _('%(changes)s edited ok')%{'changes':
580 ', '.join(props.keys())}
581 elif self.form.has_key('__note') and self.form['__note'].value:
582 message = _('note added')
583 elif (self.form.has_key('__file') and self.form['__file'].filename):
584 message = _('file added')
585 else:
586 message = _('nothing changed')
588 # redirect to the item's edit page
589 raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
590 self.nodeid, urllib.quote(message))
592 def editItemPermission(self, props):
593 ''' Determine whether the user has permission to edit this item.
595 Base behaviour is to check the user can edit this class. If we're
596 editing the "user" class, users are allowed to edit their own
597 details. Unless it's the "roles" property, which requires the
598 special Permission "Web Roles".
599 '''
600 # if this is a user node and the user is editing their own node, then
601 # we're OK
602 has = self.db.security.hasPermission
603 if self.classname == 'user':
604 # reject if someone's trying to edit "roles" and doesn't have the
605 # right permission.
606 if props.has_key('roles') and not has('Web Roles', self.userid,
607 'user'):
608 return 0
609 # if the item being edited is the current user, we're ok
610 if self.nodeid == self.userid:
611 return 1
612 if self.db.security.hasPermission('Edit', self.userid, self.classname):
613 return 1
614 return 0
616 def newItemAction(self):
617 ''' Add a new item to the database.
619 This follows the same form as the editItemAction
620 '''
621 cl = self.db.classes[self.classname]
623 # parse the props from the form
624 try:
625 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
626 except (ValueError, KeyError), message:
627 self.error_message.append(_('Error: ') + str(message))
628 return
630 if not self.newItemPermission(props):
631 self.error_message.append(
632 _('You do not have permission to create %s' %self.classname))
634 # XXX
635 # cl = self.db.classes[cn]
636 # if self.form.has_key(':multilink'):
637 # link = self.form[':multilink'].value
638 # designator, linkprop = link.split(':')
639 # xtra = ' for <a href="%s">%s</a>' % (designator, designator)
640 # else:
641 # xtra = ''
643 try:
644 # do the create
645 nid = self._createnode(props)
647 # handle linked nodes
648 self._post_editnode(nid)
650 # commit now that all the tricky stuff is done
651 self.db.commit()
653 # render the newly created item
654 self.nodeid = nid
656 # and some nice feedback for the user
657 message = _('%(classname)s created ok')%self.__dict__
658 except (ValueError, KeyError), message:
659 self.error_message.append(_('Error: ') + str(message))
660 return
661 except:
662 # oops
663 self.db.rollback()
664 s = StringIO.StringIO()
665 traceback.print_exc(None, s)
666 self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
667 return
669 # redirect to the new item's page
670 raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
671 nid, urllib.quote(message))
673 def newItemPermission(self, props):
674 ''' Determine whether the user has permission to create (edit) this
675 item.
677 Base behaviour is to check the user can edit this class. No
678 additional property checks are made. Additionally, new user items
679 may be created if the user has the "Web Registration" Permission.
680 '''
681 has = self.db.security.hasPermission
682 if self.classname == 'user' and has('Web Registration', self.userid,
683 'user'):
684 return 1
685 if has('Edit', self.userid, self.classname):
686 return 1
687 return 0
689 def genericEditAction(self):
690 ''' Performs an edit of all of a class' items in one go.
692 The "rows" CGI var defines the CSV-formatted entries for the
693 class. New nodes are identified by the ID 'X' (or any other
694 non-existent ID) and removed lines are retired.
695 '''
696 # generic edit is per-class only
697 if not self.genericEditPermission():
698 self.error_message.append(
699 _('You do not have permission to edit %s' %self.classname))
701 # get the CSV module
702 try:
703 import csv
704 except ImportError:
705 self.error_message.append(_(
706 'Sorry, you need the csv module to use this function.<br>\n'
707 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
708 return
710 cl = self.db.classes[self.classname]
711 idlessprops = cl.getprops(protected=0).keys()
712 props = ['id'] + idlessprops
714 # do the edit
715 rows = self.form['rows'].value.splitlines()
716 p = csv.parser()
717 found = {}
718 line = 0
719 for row in rows:
720 line += 1
721 values = p.parse(row)
722 # not a complete row, keep going
723 if not values: continue
725 # extract the nodeid
726 nodeid, values = values[0], values[1:]
727 found[nodeid] = 1
729 # confirm correct weight
730 if len(idlessprops) != len(values):
731 message=(_('Not enough values on line %(line)s'%{'line':line}))
732 return
734 # extract the new values
735 d = {}
736 for name, value in zip(idlessprops, values):
737 value = value.strip()
738 # only add the property if it has a value
739 if value:
740 # if it's a multilink, split it
741 if isinstance(cl.properties[name], hyperdb.Multilink):
742 value = value.split(':')
743 d[name] = value
745 # perform the edit
746 if cl.hasnode(nodeid):
747 # edit existing
748 cl.set(nodeid, **d)
749 else:
750 # new node
751 found[cl.create(**d)] = 1
753 # retire the removed entries
754 for nodeid in cl.list():
755 if not found.has_key(nodeid):
756 cl.retire(nodeid)
758 message = _('items edited OK')
760 # redirect to the class' edit page
761 raise Redirect, '%s/%s?:ok_message=%s'%(self.base, self.classname,
762 urllib.quote(message))
764 def genericEditPermission(self):
765 ''' Determine whether the user has permission to edit this class.
767 Base behaviour is to check the user can edit this class.
768 '''
769 if not self.db.security.hasPermission('Edit', self.userid,
770 self.classname):
771 return 0
772 return 1
774 def searchAction(self):
775 ''' Mangle some of the form variables.
777 Set the form ":filter" variable based on the values of the
778 filter variables - if they're set to anything other than
779 "dontcare" then add them to :filter.
780 '''
781 # generic edit is per-class only
782 if not self.searchPermission():
783 self.error_message.append(
784 _('You do not have permission to search %s' %self.classname))
786 # add a faked :filter form variable for each filtering prop
787 props = self.db.classes[self.classname].getprops()
788 for key in self.form.keys():
789 if not props.has_key(key): continue
790 if not self.form[key].value: continue
791 self.form.value.append(cgi.MiniFieldStorage(':filter', key))
793 def searchPermission(self):
794 ''' Determine whether the user has permission to search this class.
796 Base behaviour is to check the user can view this class.
797 '''
798 if not self.db.security.hasPermission('View', self.userid,
799 self.classname):
800 return 0
801 return 1
803 def XXXremove_action(self, dre=re.compile(r'([^\d]+)(\d+)')):
804 # XXX I believe this could be handled by a regular edit action that
805 # just sets the multilink...
806 # XXX handle this !
807 target = self.index_arg(':target')[0]
808 m = dre.match(target)
809 if m:
810 classname = m.group(1)
811 nodeid = m.group(2)
812 cl = self.db.getclass(classname)
813 cl.retire(nodeid)
814 # now take care of the reference
815 parentref = self.index_arg(':multilink')[0]
816 parent, prop = parentref.split(':')
817 m = dre.match(parent)
818 if m:
819 self.classname = m.group(1)
820 self.nodeid = m.group(2)
821 cl = self.db.getclass(self.classname)
822 value = cl.get(self.nodeid, prop)
823 value.remove(nodeid)
824 cl.set(self.nodeid, **{prop:value})
825 func = getattr(self, 'show%s'%self.classname)
826 return func()
827 else:
828 raise NotFound, parent
829 else:
830 raise NotFound, target
832 #
833 # Utility methods for editing
834 #
835 def _changenode(self, props):
836 ''' change the node based on the contents of the form
837 '''
838 cl = self.db.classes[self.classname]
840 # create the message
841 message, files = self._handle_message()
842 if message:
843 props['messages'] = cl.get(self.nodeid, 'messages') + [message]
844 if files:
845 props['files'] = cl.get(self.nodeid, 'files') + files
847 # make the changes
848 return cl.set(self.nodeid, **props)
850 def _createnode(self, props):
851 ''' create a node based on the contents of the form
852 '''
853 cl = self.db.classes[self.classname]
855 # check for messages and files
856 message, files = self._handle_message()
857 if message:
858 props['messages'] = [message]
859 if files:
860 props['files'] = files
861 # create the node and return it's id
862 return cl.create(**props)
864 def _handle_message(self):
865 ''' generate an edit message
866 '''
867 # handle file attachments
868 files = []
869 if self.form.has_key('__file'):
870 file = self.form['__file']
871 if file.filename:
872 filename = file.filename.split('\\')[-1]
873 mime_type = mimetypes.guess_type(filename)[0]
874 if not mime_type:
875 mime_type = "application/octet-stream"
876 # create the new file entry
877 files.append(self.db.file.create(type=mime_type,
878 name=filename, content=file.file.read()))
880 # we don't want to do a message if none of the following is true...
881 cn = self.classname
882 cl = self.db.classes[self.classname]
883 props = cl.getprops()
884 note = None
885 # in a nutshell, don't do anything if there's no note or there's no
886 # NOSY
887 if self.form.has_key('__note'):
888 note = self.form['__note'].value.strip()
889 if not note:
890 return None, files
891 if not props.has_key('messages'):
892 return None, files
893 if not isinstance(props['messages'], hyperdb.Multilink):
894 return None, files
895 if not props['messages'].classname == 'msg':
896 return None, files
897 if not (self.form.has_key('nosy') or note):
898 return None, files
900 # handle the note
901 if '\n' in note:
902 summary = re.split(r'\n\r?', note)[0]
903 else:
904 summary = note
905 m = ['%s\n'%note]
907 # handle the messageid
908 # TODO: handle inreplyto
909 messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
910 self.classname, self.instance.MAIL_DOMAIN)
912 # now create the message, attaching the files
913 content = '\n'.join(m)
914 message_id = self.db.msg.create(author=self.userid,
915 recipients=[], date=date.Date('.'), summary=summary,
916 content=content, files=files, messageid=messageid)
918 # update the messages property
919 return message_id, files
921 def _post_editnode(self, nid):
922 '''Do the linking part of the node creation.
924 If a form element has :link or :multilink appended to it, its
925 value specifies a node designator and the property on that node
926 to add _this_ node to as a link or multilink.
928 This is typically used on, eg. the file upload page to indicated
929 which issue to link the file to.
931 TODO: I suspect that this and newfile will go away now that
932 there's the ability to upload a file using the issue __file form
933 element!
934 '''
935 cn = self.classname
936 cl = self.db.classes[cn]
937 # link if necessary
938 keys = self.form.keys()
939 for key in keys:
940 if key == ':multilink':
941 value = self.form[key].value
942 if type(value) != type([]): value = [value]
943 for value in value:
944 designator, property = value.split(':')
945 link, nodeid = hyperdb.splitDesignator(designator)
946 link = self.db.classes[link]
947 # take a dupe of the list so we're not changing the cache
948 value = link.get(nodeid, property)[:]
949 value.append(nid)
950 link.set(nodeid, **{property: value})
951 elif key == ':link':
952 value = self.form[key].value
953 if type(value) != type([]): value = [value]
954 for value in value:
955 designator, property = value.split(':')
956 link, nodeid = hyperdb.splitDesignator(designator)
957 link = self.db.classes[link]
958 link.set(nodeid, **{property: nid})
961 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
962 '''Pull properties for the given class out of the form.
963 '''
964 props = {}
965 keys = form.keys()
966 for key in keys:
967 if not cl.properties.has_key(key):
968 continue
969 proptype = cl.properties[key]
970 if isinstance(proptype, hyperdb.String):
971 value = form[key].value.strip()
972 elif isinstance(proptype, hyperdb.Password):
973 value = form[key].value.strip()
974 if not value:
975 # ignore empty password values
976 continue
977 value = password.Password(value)
978 elif isinstance(proptype, hyperdb.Date):
979 value = form[key].value.strip()
980 if value:
981 value = date.Date(form[key].value.strip())
982 else:
983 value = None
984 elif isinstance(proptype, hyperdb.Interval):
985 value = form[key].value.strip()
986 if value:
987 value = date.Interval(form[key].value.strip())
988 else:
989 value = None
990 elif isinstance(proptype, hyperdb.Link):
991 value = form[key].value.strip()
992 # see if it's the "no selection" choice
993 if value == '-1':
994 value = None
995 else:
996 # handle key values
997 link = cl.properties[key].classname
998 if not num_re.match(value):
999 try:
1000 value = db.classes[link].lookup(value)
1001 except KeyError:
1002 raise ValueError, _('property "%(propname)s": '
1003 '%(value)s not a %(classname)s')%{'propname':key,
1004 'value': value, 'classname': link}
1005 elif isinstance(proptype, hyperdb.Multilink):
1006 value = form[key]
1007 if hasattr(value, 'value'):
1008 # Quite likely to be a FormItem instance
1009 value = value.value
1010 if not isinstance(value, type([])):
1011 value = [i.strip() for i in value.split(',')]
1012 else:
1013 value = [i.strip() for i in value]
1014 link = cl.properties[key].classname
1015 l = []
1016 for entry in map(str, value):
1017 if entry == '': continue
1018 if not num_re.match(entry):
1019 try:
1020 entry = db.classes[link].lookup(entry)
1021 except KeyError:
1022 raise ValueError, _('property "%(propname)s": '
1023 '"%(value)s" not an entry of %(classname)s')%{
1024 'propname':key, 'value': entry, 'classname': link}
1025 l.append(entry)
1026 l.sort()
1027 value = l
1028 elif isinstance(proptype, hyperdb.Boolean):
1029 value = form[key].value.strip()
1030 props[key] = value = value.lower() in ('yes', 'true', 'on', '1')
1031 elif isinstance(proptype, hyperdb.Number):
1032 value = form[key].value.strip()
1033 props[key] = value = int(value)
1035 # get the old value
1036 if nodeid:
1037 try:
1038 existing = cl.get(nodeid, key)
1039 except KeyError:
1040 # this might be a new property for which there is no existing
1041 # value
1042 if not cl.properties.has_key(key): raise
1044 # if changed, set it
1045 if value != existing:
1046 props[key] = value
1047 else:
1048 props[key] = value
1049 return props