1 # $Id: client.py,v 1.8 2002-09-03 03:23:56 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 # and self.template_name)
115 self.handle_action()
116 # now render the page
117 self.write(self.template('page', ok_message=self.ok_message,
118 error_message=self.error_message))
119 except Redirect, url:
120 # let's redirect - if the url isn't None, then we need to do
121 # the headers, otherwise the headers have been set before the
122 # exception was raised
123 if url:
124 self.header({'Location': url}, response=302)
125 except SendFile, designator:
126 self.serve_file(designator)
127 except SendStaticFile, file:
128 self.serve_static_file(file)
129 except Unauthorised, message:
130 self.write(self.template('page.unauthorised',
131 error_message=message))
132 except:
133 # everything else
134 self.write(cgitb.html())
136 def determine_user(self):
137 ''' Determine who the user is
138 '''
139 # determine the uid to use
140 self.opendb('admin')
142 # make sure we have the session Class
143 sessions = self.db.sessions
145 # age sessions, remove when they haven't been used for a week
146 # TODO: this shouldn't be done every access
147 week = 60*60*24*7
148 now = time.time()
149 for sessid in sessions.list():
150 interval = now - sessions.get(sessid, 'last_use')
151 if interval > week:
152 sessions.destroy(sessid)
154 # look up the user session cookie
155 cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
156 user = 'anonymous'
158 # bump the "revision" of the cookie since the format changed
159 if (cookie.has_key('roundup_user_2') and
160 cookie['roundup_user_2'].value != 'deleted'):
162 # get the session key from the cookie
163 self.session = cookie['roundup_user_2'].value
164 # get the user from the session
165 try:
166 # update the lifetime datestamp
167 sessions.set(self.session, last_use=time.time())
168 sessions.commit()
169 user = sessions.get(self.session, 'user')
170 except KeyError:
171 user = 'anonymous'
173 # sanity check on the user still being valid, getting the userid
174 # at the same time
175 try:
176 self.userid = self.db.user.lookup(user)
177 except (KeyError, TypeError):
178 user = 'anonymous'
180 # make sure the anonymous user is valid if we're using it
181 if user == 'anonymous':
182 self.make_user_anonymous()
183 else:
184 self.user = user
186 # reopen the database as the correct user
187 self.opendb(self.user)
189 def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
190 ''' Determine the context of this page:
192 home (default if no url is given)
193 classname
194 designator (classname and nodeid)
196 The desired template to be rendered is also determined There
197 are two exceptional contexts:
199 _file - serve up a static file
200 path len > 1 - serve up a FileClass content
201 (the additional path gives the browser a
202 nicer filename to save as)
204 The template used is specified by the :template CGI variable,
205 which defaults to:
206 only classname suplied: "index"
207 full item designator supplied: "item"
209 We set:
210 self.classname
211 self.nodeid
212 self.template_name
213 '''
214 # default the optional variables
215 self.classname = None
216 self.nodeid = None
218 # determine the classname and possibly nodeid
219 path = self.split_path
220 if not path or path[0] in ('', 'home', 'index'):
221 if self.form.has_key(':template'):
222 self.template_type = self.form[':template'].value
223 self.template_name = 'home' + '.' + self.template_type
224 else:
225 self.template_type = ''
226 self.template_name = 'home'
227 return
228 elif path[0] == '_file':
229 raise SendStaticFile, path[1]
230 else:
231 self.classname = path[0]
232 if len(path) > 1:
233 # send the file identified by the designator in path[0]
234 raise SendFile, path[0]
236 # see if we got a designator
237 m = dre.match(self.classname)
238 if m:
239 self.classname = m.group(1)
240 self.nodeid = m.group(2)
241 # with a designator, we default to item view
242 self.template_type = 'item'
243 else:
244 # with only a class, we default to index view
245 self.template_type = 'index'
247 # see if we have a template override
248 if self.form.has_key(':template'):
249 self.template_type = self.form[':template'].value
252 # see if we were passed in a message
253 if self.form.has_key(':ok_message'):
254 self.ok_message.append(self.form[':ok_message'].value)
255 if self.form.has_key(':error_message'):
256 self.error_message.append(self.form[':error_message'].value)
258 # we have the template name now
259 self.template_name = self.classname + '.' + self.template_type
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 template(self, name, **kwargs):
283 ''' Return a PageTemplate for the named page
284 '''
285 pt = getTemplate(self.instance.TEMPLATES, name)
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.
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_2=%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_2=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_2=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.
781 Also handle the ":queryname" variable and save off the query to
782 the user's query list.
783 '''
784 # generic edit is per-class only
785 if not self.searchPermission():
786 self.error_message.append(
787 _('You do not have permission to search %s' %self.classname))
789 # add a faked :filter form variable for each filtering prop
790 props = self.db.classes[self.classname].getprops()
791 for key in self.form.keys():
792 if not props.has_key(key): continue
793 if not self.form[key].value: continue
794 self.form.value.append(cgi.MiniFieldStorage(':filter', key))
796 # handle saving the query params
797 if self.form.has_key(':queryname'):
798 queryname = self.form[':queryname'].value.strip()
799 if queryname:
800 # parse the environment and figure what the query _is_
801 req = HTMLRequest(self)
802 url = req.indexargs_href('', {})
804 # handle editing an existing query
805 try:
806 qid = self.db.query.lookup(queryname)
807 self.db.query.set(qid, klass=self.classname, url=url)
808 except KeyError:
809 # create a query
810 qid = self.db.query.create(name=queryname,
811 klass=self.classname, url=url)
813 # and add it to the user's query multilink
814 queries = self.db.user.get(self.userid, 'queries')
815 queries.append(qid)
816 self.db.user.set(self.userid, queries=queries)
818 # commit the query change to the database
819 self.db.commit()
822 def searchPermission(self):
823 ''' Determine whether the user has permission to search this class.
825 Base behaviour is to check the user can view this class.
826 '''
827 if not self.db.security.hasPermission('View', self.userid,
828 self.classname):
829 return 0
830 return 1
832 def XXXremove_action(self, dre=re.compile(r'([^\d]+)(\d+)')):
833 # XXX I believe this could be handled by a regular edit action that
834 # just sets the multilink...
835 # XXX handle this !
836 target = self.index_arg(':target')[0]
837 m = dre.match(target)
838 if m:
839 classname = m.group(1)
840 nodeid = m.group(2)
841 cl = self.db.getclass(classname)
842 cl.retire(nodeid)
843 # now take care of the reference
844 parentref = self.index_arg(':multilink')[0]
845 parent, prop = parentref.split(':')
846 m = dre.match(parent)
847 if m:
848 self.classname = m.group(1)
849 self.nodeid = m.group(2)
850 cl = self.db.getclass(self.classname)
851 value = cl.get(self.nodeid, prop)
852 value.remove(nodeid)
853 cl.set(self.nodeid, **{prop:value})
854 func = getattr(self, 'show%s'%self.classname)
855 return func()
856 else:
857 raise NotFound, parent
858 else:
859 raise NotFound, target
861 #
862 # Utility methods for editing
863 #
864 def _changenode(self, props):
865 ''' change the node based on the contents of the form
866 '''
867 cl = self.db.classes[self.classname]
869 # create the message
870 message, files = self._handle_message()
871 if message:
872 props['messages'] = cl.get(self.nodeid, 'messages') + [message]
873 if files:
874 props['files'] = cl.get(self.nodeid, 'files') + files
876 # make the changes
877 return cl.set(self.nodeid, **props)
879 def _createnode(self, props):
880 ''' create a node based on the contents of the form
881 '''
882 cl = self.db.classes[self.classname]
884 # check for messages and files
885 message, files = self._handle_message()
886 if message:
887 props['messages'] = [message]
888 if files:
889 props['files'] = files
890 # create the node and return it's id
891 return cl.create(**props)
893 def _handle_message(self):
894 ''' generate an edit message
895 '''
896 # handle file attachments
897 files = []
898 if self.form.has_key('__file'):
899 file = self.form['__file']
900 if file.filename:
901 filename = file.filename.split('\\')[-1]
902 mime_type = mimetypes.guess_type(filename)[0]
903 if not mime_type:
904 mime_type = "application/octet-stream"
905 # create the new file entry
906 files.append(self.db.file.create(type=mime_type,
907 name=filename, content=file.file.read()))
909 # we don't want to do a message if none of the following is true...
910 cn = self.classname
911 cl = self.db.classes[self.classname]
912 props = cl.getprops()
913 note = None
914 # in a nutshell, don't do anything if there's no note or there's no
915 # NOSY
916 if self.form.has_key('__note'):
917 note = self.form['__note'].value.strip()
918 if not note:
919 return None, files
920 if not props.has_key('messages'):
921 return None, files
922 if not isinstance(props['messages'], hyperdb.Multilink):
923 return None, files
924 if not props['messages'].classname == 'msg':
925 return None, files
926 if not (self.form.has_key('nosy') or note):
927 return None, files
929 # handle the note
930 if '\n' in note:
931 summary = re.split(r'\n\r?', note)[0]
932 else:
933 summary = note
934 m = ['%s\n'%note]
936 # handle the messageid
937 # TODO: handle inreplyto
938 messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
939 self.classname, self.instance.MAIL_DOMAIN)
941 # now create the message, attaching the files
942 content = '\n'.join(m)
943 message_id = self.db.msg.create(author=self.userid,
944 recipients=[], date=date.Date('.'), summary=summary,
945 content=content, files=files, messageid=messageid)
947 # update the messages property
948 return message_id, files
950 def _post_editnode(self, nid):
951 '''Do the linking part of the node creation.
953 If a form element has :link or :multilink appended to it, its
954 value specifies a node designator and the property on that node
955 to add _this_ node to as a link or multilink.
957 This is typically used on, eg. the file upload page to indicated
958 which issue to link the file to.
960 TODO: I suspect that this and newfile will go away now that
961 there's the ability to upload a file using the issue __file form
962 element!
963 '''
964 cn = self.classname
965 cl = self.db.classes[cn]
966 # link if necessary
967 keys = self.form.keys()
968 for key in keys:
969 if key == ':multilink':
970 value = self.form[key].value
971 if type(value) != type([]): value = [value]
972 for value in value:
973 designator, property = value.split(':')
974 link, nodeid = hyperdb.splitDesignator(designator)
975 link = self.db.classes[link]
976 # take a dupe of the list so we're not changing the cache
977 value = link.get(nodeid, property)[:]
978 value.append(nid)
979 link.set(nodeid, **{property: value})
980 elif key == ':link':
981 value = self.form[key].value
982 if type(value) != type([]): value = [value]
983 for value in value:
984 designator, property = value.split(':')
985 link, nodeid = hyperdb.splitDesignator(designator)
986 link = self.db.classes[link]
987 link.set(nodeid, **{property: nid})
990 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
991 '''Pull properties for the given class out of the form.
992 '''
993 props = {}
994 keys = form.keys()
995 for key in keys:
996 if not cl.properties.has_key(key):
997 continue
998 proptype = cl.properties[key]
999 if isinstance(proptype, hyperdb.String):
1000 value = form[key].value.strip()
1001 elif isinstance(proptype, hyperdb.Password):
1002 value = form[key].value.strip()
1003 if not value:
1004 # ignore empty password values
1005 continue
1006 value = password.Password(value)
1007 elif isinstance(proptype, hyperdb.Date):
1008 value = form[key].value.strip()
1009 if value:
1010 value = date.Date(form[key].value.strip())
1011 else:
1012 value = None
1013 elif isinstance(proptype, hyperdb.Interval):
1014 value = form[key].value.strip()
1015 if value:
1016 value = date.Interval(form[key].value.strip())
1017 else:
1018 value = None
1019 elif isinstance(proptype, hyperdb.Link):
1020 value = form[key].value.strip()
1021 # see if it's the "no selection" choice
1022 if value == '-1':
1023 value = None
1024 else:
1025 # handle key values
1026 link = cl.properties[key].classname
1027 if not num_re.match(value):
1028 try:
1029 value = db.classes[link].lookup(value)
1030 except KeyError:
1031 raise ValueError, _('property "%(propname)s": '
1032 '%(value)s not a %(classname)s')%{'propname':key,
1033 'value': value, 'classname': link}
1034 elif isinstance(proptype, hyperdb.Multilink):
1035 value = form[key]
1036 if not isinstance(value, type([])):
1037 value = [i.strip() for i in value.split(',')]
1038 else:
1039 value = [i.value.strip() for i in value]
1040 link = cl.properties[key].classname
1041 l = []
1042 for entry in map(str, value):
1043 if entry == '': continue
1044 if not num_re.match(entry):
1045 try:
1046 entry = db.classes[link].lookup(entry)
1047 except KeyError:
1048 raise ValueError, _('property "%(propname)s": '
1049 '"%(value)s" not an entry of %(classname)s')%{
1050 'propname':key, 'value': entry, 'classname': link}
1051 l.append(entry)
1052 l.sort()
1053 value = l
1054 elif isinstance(proptype, hyperdb.Boolean):
1055 value = form[key].value.strip()
1056 props[key] = value = value.lower() in ('yes', 'true', 'on', '1')
1057 elif isinstance(proptype, hyperdb.Number):
1058 value = form[key].value.strip()
1059 props[key] = value = int(value)
1061 # get the old value
1062 if nodeid:
1063 try:
1064 existing = cl.get(nodeid, key)
1065 except KeyError:
1066 # this might be a new property for which there is no existing
1067 # value
1068 if not cl.properties.has_key(key): raise
1070 # if changed, set it
1071 if value != existing:
1072 props[key] = value
1073 else:
1074 props[key] = value
1075 return props