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