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