1 # $Id: client.py,v 1.11 2002-09-04 04:31:51 richard Exp $
3 __doc__ = """
4 WWW request handler (also used in the stand-alone server).
5 """
7 import os, os.path, cgi, StringIO, urlparse, re, traceback, mimetypes, urllib
8 import binascii, Cookie, time, random
10 from roundup import roundupdb, date, hyperdb, password
11 from roundup.i18n import _
13 from roundup.cgi.templating import getTemplate, HTMLRequest
14 from roundup.cgi import cgitb
16 from PageTemplates import PageTemplate
18 class Unauthorised(ValueError):
19 pass
21 class NotFound(ValueError):
22 pass
24 class Redirect(Exception):
25 pass
27 class SendFile(Exception):
28 ' Sent a file from the database '
30 class SendStaticFile(Exception):
31 ' Send a static file from the instance html directory '
33 def initialiseSecurity(security):
34 ''' Create some Permissions and Roles on the security object
36 This function is directly invoked by security.Security.__init__()
37 as a part of the Security object instantiation.
38 '''
39 security.addPermission(name="Web Registration",
40 description="User may register through the web")
41 p = security.addPermission(name="Web Access",
42 description="User may access the web interface")
43 security.addPermissionToRole('Admin', p)
45 # doing Role stuff through the web - make sure Admin can
46 p = security.addPermission(name="Web Roles",
47 description="User may manipulate user Roles through the web")
48 security.addPermissionToRole('Admin', p)
50 class Client:
51 '''
52 A note about login
53 ------------------
55 If the user has no login cookie, then they are anonymous. There
56 are two levels of anonymous use. If there is no 'anonymous' user, there
57 is no login at all and the database is opened in read-only mode. If the
58 'anonymous' user exists, the user is logged in using that user (though
59 there is no cookie). This allows them to modify the database, and all
60 modifications are attributed to the 'anonymous' user.
62 Once a user logs in, they are assigned a session. The Client instance
63 keeps the nodeid of the session as the "session" attribute.
65 Client attributes:
66 "url" is the current url path
67 "path" is the PATH_INFO inside the instance
68 "base" is the base URL for the instance
69 '''
71 def __init__(self, instance, request, env, form=None):
72 hyperdb.traceMark()
73 self.instance = instance
74 self.request = request
75 self.env = env
77 self.path = env['PATH_INFO']
78 self.split_path = self.path.split('/')
79 self.instance_path_name = env['INSTANCE_NAME']
81 # this is the base URL for this instance
82 url = self.env['SCRIPT_NAME'] + '/' + self.instance_path_name
83 self.base = urlparse.urlunparse(('http', env['HTTP_HOST'], url,
84 None, None, None))
86 # request.path is the full request path
87 x, x, path, x, x, x = urlparse.urlparse(request.path)
88 self.url = urlparse.urlunparse(('http', env['HTTP_HOST'], path,
89 None, None, None))
91 if form is None:
92 self.form = cgi.FieldStorage(environ=env)
93 else:
94 self.form = form
95 self.headers_done = 0
96 try:
97 self.debug = int(env.get("ROUNDUP_DEBUG", 0))
98 except ValueError:
99 # someone gave us a non-int debug level, turn it off
100 self.debug = 0
102 def main(self):
103 ''' Wrap the request and handle unauthorised requests
104 '''
105 self.content_action = None
106 self.ok_message = []
107 self.error_message = []
108 try:
109 # make sure we're identified (even anonymously)
110 self.determine_user()
111 # figure out the context and desired content template
112 self.determine_context()
113 # possibly handle a form submit action (may change self.message,
114 # self.classname and self.template)
115 self.handle_action()
116 # now render the page
117 self.write(self.renderTemplate('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(str(file))
129 except Unauthorised, message:
130 self.write(self.renderTemplate('page', '', error_message=message))
131 except:
132 # everything else
133 self.write(cgitb.html())
135 def determine_user(self):
136 ''' Determine who the user is
137 '''
138 # determine the uid to use
139 self.opendb('admin')
141 # make sure we have the session Class
142 sessions = self.db.sessions
144 # age sessions, remove when they haven't been used for a week
145 # TODO: this shouldn't be done every access
146 week = 60*60*24*7
147 now = time.time()
148 for sessid in sessions.list():
149 interval = now - sessions.get(sessid, 'last_use')
150 if interval > week:
151 sessions.destroy(sessid)
153 # look up the user session cookie
154 cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
155 user = 'anonymous'
157 # bump the "revision" of the cookie since the format changed
158 if (cookie.has_key('roundup_user_2') and
159 cookie['roundup_user_2'].value != 'deleted'):
161 # get the session key from the cookie
162 self.session = cookie['roundup_user_2'].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 - the class to display, can be None
210 self.template - the template to render the current context with
211 self.nodeid - the nodeid of the class we're displaying
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 = self.form[':template'].value
222 else:
223 self.template = ''
224 return
225 elif path[0] == '_file':
226 raise SendStaticFile, path[1]
227 else:
228 self.classname = path[0]
229 if len(path) > 1:
230 # send the file identified by the designator in path[0]
231 raise SendFile, path[0]
233 # see if we got a designator
234 m = dre.match(self.classname)
235 if m:
236 self.classname = m.group(1)
237 self.nodeid = m.group(2)
238 # with a designator, we default to item view
239 self.template = 'item'
240 else:
241 # with only a class, we default to index view
242 self.template = 'index'
244 # see if we have a template override
245 if self.form.has_key(':template'):
246 self.template = self.form[':template'].value
249 # see if we were passed in a message
250 if self.form.has_key(':ok_message'):
251 self.ok_message.append(self.form[':ok_message'].value)
252 if self.form.has_key(':error_message'):
253 self.error_message.append(self.form[':error_message'].value)
255 def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
256 ''' Serve the file from the content property of the designated item.
257 '''
258 m = dre.match(str(designator))
259 if not m:
260 raise NotFound, str(designator)
261 classname, nodeid = m.group(1), m.group(2)
262 if classname != 'file':
263 raise NotFound, designator
265 # we just want to serve up the file named
266 file = self.db.file
267 self.header({'Content-Type': file.get(nodeid, 'type')})
268 self.write(file.get(nodeid, 'content'))
270 def serve_static_file(self, file):
271 # we just want to serve up the file named
272 mt = mimetypes.guess_type(str(file))[0]
273 self.header({'Content-Type': mt})
274 self.write(open(os.path.join(self.instance.TEMPLATES, file)).read())
276 def renderTemplate(self, name, extension, **kwargs):
277 ''' Return a PageTemplate for the named page
278 '''
279 pt = getTemplate(self.instance.TEMPLATES, name, extension)
280 # XXX handle PT rendering errors here more nicely
281 try:
282 # let the template render figure stuff out
283 return pt.render(self, None, None, **kwargs)
284 except PageTemplate.PTRuntimeError, message:
285 return '<strong>%s</strong><ol>%s</ol>'%(message,
286 '<li>'.join(pt._v_errors))
287 except:
288 # everything else
289 return cgitb.html()
291 def content(self):
292 ''' Callback used by the page template to render the content of
293 the page.
295 If we don't have a specific class to display, that is none was
296 determined in determine_context(), then we display a "home"
297 template.
298 '''
299 # now render the page content using the template we determined in
300 # determine_context
301 if self.classname is None:
302 name = 'home'
303 else:
304 name = self.classname
305 return self.renderTemplate(self.classname, self.template)
307 # these are the actions that are available
308 actions = {
309 'edit': 'editItemAction',
310 'editCSV': 'editCSVAction',
311 'new': 'newItemAction',
312 'register': 'registerAction',
313 'login': 'login_action',
314 'logout': 'logout_action',
315 'search': 'searchAction',
316 }
317 def handle_action(self):
318 ''' Determine whether there should be an _action called.
320 The action is defined by the form variable :action which
321 identifies the method on this object to call. The four basic
322 actions are defined in the "actions" dictionary on this class:
323 "edit" -> self.editItemAction
324 "new" -> self.newItemAction
325 "register" -> self.registerAction
326 "login" -> self.login_action
327 "logout" -> self.logout_action
328 "search" -> self.searchAction
330 '''
331 if not self.form.has_key(':action'):
332 return None
333 try:
334 # get the action, validate it
335 action = self.form[':action'].value
336 if not self.actions.has_key(action):
337 raise ValueError, 'No such action "%s"'%action
339 # call the mapped action
340 getattr(self, self.actions[action])()
341 except Redirect:
342 raise
343 except:
344 self.db.rollback()
345 s = StringIO.StringIO()
346 traceback.print_exc(None, s)
347 self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
349 def write(self, content):
350 if not self.headers_done:
351 self.header()
352 self.request.wfile.write(content)
354 def header(self, headers=None, response=200):
355 '''Put up the appropriate header.
356 '''
357 if headers is None:
358 headers = {'Content-Type':'text/html'}
359 if not headers.has_key('Content-Type'):
360 headers['Content-Type'] = 'text/html'
361 self.request.send_response(response)
362 for entry in headers.items():
363 self.request.send_header(*entry)
364 self.request.end_headers()
365 self.headers_done = 1
366 if self.debug:
367 self.headers_sent = headers
369 def set_cookie(self, user, password):
370 # TODO generate a much, much stronger session key ;)
371 self.session = binascii.b2a_base64(repr(time.time())).strip()
373 # clean up the base64
374 if self.session[-1] == '=':
375 if self.session[-2] == '=':
376 self.session = self.session[:-2]
377 else:
378 self.session = self.session[:-1]
380 # insert the session in the sessiondb
381 self.db.sessions.set(self.session, user=user, last_use=time.time())
383 # and commit immediately
384 self.db.sessions.commit()
386 # expire us in a long, long time
387 expire = Cookie._getdate(86400*365)
389 # generate the cookie path - make sure it has a trailing '/'
390 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
391 ''))
392 self.header({'Set-Cookie': 'roundup_user_2=%s; expires=%s; Path=%s;'%(
393 self.session, expire, path)})
395 def make_user_anonymous(self):
396 ''' Make us anonymous
398 This method used to handle non-existence of the 'anonymous'
399 user, but that user is mandatory now.
400 '''
401 self.userid = self.db.user.lookup('anonymous')
402 self.user = 'anonymous'
404 def logout(self):
405 ''' Make us really anonymous - nuke the cookie too
406 '''
407 self.make_user_anonymous()
409 # construct the logout cookie
410 now = Cookie._getdate()
411 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
412 ''))
413 self.header({'Set-Cookie':
414 'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
415 path)})
416 self.login()
418 def opendb(self, user):
419 ''' Open the database.
420 '''
421 # open the db if the user has changed
422 if not hasattr(self, 'db') or user != self.db.journaltag:
423 self.db = self.instance.open(user)
425 #
426 # Actions
427 #
428 def login_action(self):
429 ''' Attempt to log a user in and set the cookie
430 '''
431 # we need the username at a minimum
432 if not self.form.has_key('__login_name'):
433 self.error_message.append(_('Username required'))
434 return
436 self.user = self.form['__login_name'].value
437 # re-open the database for real, using the user
438 self.opendb(self.user)
439 if self.form.has_key('__login_password'):
440 password = self.form['__login_password'].value
441 else:
442 password = ''
443 # make sure the user exists
444 try:
445 self.userid = self.db.user.lookup(self.user)
446 except KeyError:
447 name = self.user
448 self.make_user_anonymous()
449 self.error_message.append(_('No such user "%(name)s"')%locals())
450 return
452 # and that the password is correct
453 pw = self.db.user.get(self.userid, 'password')
454 if password != pw:
455 self.make_user_anonymous()
456 self.error_message.append(_('Incorrect password'))
457 return
459 # set the session cookie
460 self.set_cookie(self.user, password)
462 def logout_action(self):
463 ''' Make us really anonymous - nuke the cookie too
464 '''
465 # log us out
466 self.make_user_anonymous()
468 # construct the logout cookie
469 now = Cookie._getdate()
470 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
471 ''))
472 self.header(headers={'Set-Cookie':
473 'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)})
475 # Let the user know what's going on
476 self.ok_message.append(_('You are logged out'))
478 def registerAction(self):
479 '''Attempt to create a new user based on the contents of the form
480 and then set the cookie.
482 return 1 on successful login
483 '''
484 # create the new user
485 cl = self.db.user
487 # parse the props from the form
488 try:
489 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
490 except (ValueError, KeyError), message:
491 self.error_message.append(_('Error: ') + str(message))
492 return
494 # make sure we're allowed to register
495 if not self.registerPermission(props):
496 raise Unauthorised, _("You do not have permission to register")
498 # re-open the database as "admin"
499 if self.user != 'admin':
500 self.opendb('admin')
502 # create the new user
503 cl = self.db.user
504 try:
505 props = parsePropsFromForm(self.db, cl, self.form)
506 props['roles'] = self.instance.NEW_WEB_USER_ROLES
507 self.userid = cl.create(**props)
508 self.db.commit()
509 except ValueError, message:
510 self.error_message.append(message)
512 # log the new user in
513 self.user = cl.get(self.userid, 'username')
514 # re-open the database for real, using the user
515 self.opendb(self.user)
516 password = self.db.user.get(self.userid, 'password')
517 self.set_cookie(self.user, password)
519 # nice message
520 self.ok_message.append(_('You are now registered, welcome!'))
522 def registerPermission(self, props):
523 ''' Determine whether the user has permission to register
525 Base behaviour is to check the user has "Web Registration".
526 '''
527 # registration isn't allowed to supply roles
528 if props.has_key('roles'):
529 return 0
530 if self.db.security.hasPermission('Web Registration', self.userid):
531 return 1
532 return 0
534 def editItemAction(self):
535 ''' Perform an edit of an item in the database.
537 Some special form elements:
539 :link=designator:property
540 :multilink=designator:property
541 The value specifies a node designator and the property on that
542 node to add _this_ node to as a link or multilink.
543 __note
544 Create a message and attach it to the current node's
545 "messages" property.
546 __file
547 Create a file and attach it to the current node's
548 "files" property. Attach the file to the message created from
549 the __note if it's supplied.
550 '''
551 cl = self.db.classes[self.classname]
553 # parse the props from the form
554 try:
555 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
556 except (ValueError, KeyError), message:
557 self.error_message.append(_('Error: ') + str(message))
558 return
560 # check permission
561 if not self.editItemPermission(props):
562 self.error_message.append(
563 _('You do not have permission to edit %(classname)s'%
564 self.__dict__))
565 return
567 # perform the edit
568 try:
569 # make changes to the node
570 props = self._changenode(props)
571 # handle linked nodes
572 self._post_editnode(self.nodeid)
573 except (ValueError, KeyError), message:
574 self.error_message.append(_('Error: ') + str(message))
575 return
577 # commit now that all the tricky stuff is done
578 self.db.commit()
580 # and some nice feedback for the user
581 if props:
582 message = _('%(changes)s edited ok')%{'changes':
583 ', '.join(props.keys())}
584 elif self.form.has_key('__note') and self.form['__note'].value:
585 message = _('note added')
586 elif (self.form.has_key('__file') and self.form['__file'].filename):
587 message = _('file added')
588 else:
589 message = _('nothing changed')
591 # redirect to the item's edit page
592 raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
593 self.nodeid, urllib.quote(message))
595 def editItemPermission(self, props):
596 ''' Determine whether the user has permission to edit this item.
598 Base behaviour is to check the user can edit this class. If we're
599 editing the "user" class, users are allowed to edit their own
600 details. Unless it's the "roles" property, which requires the
601 special Permission "Web Roles".
602 '''
603 # if this is a user node and the user is editing their own node, then
604 # we're OK
605 has = self.db.security.hasPermission
606 if self.classname == 'user':
607 # reject if someone's trying to edit "roles" and doesn't have the
608 # right permission.
609 if props.has_key('roles') and not has('Web Roles', self.userid,
610 'user'):
611 return 0
612 # if the item being edited is the current user, we're ok
613 if self.nodeid == self.userid:
614 return 1
615 if self.db.security.hasPermission('Edit', self.userid, self.classname):
616 return 1
617 return 0
619 def newItemAction(self):
620 ''' Add a new item to the database.
622 This follows the same form as the editItemAction
623 '''
624 cl = self.db.classes[self.classname]
626 # parse the props from the form
627 try:
628 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
629 except (ValueError, KeyError), message:
630 self.error_message.append(_('Error: ') + str(message))
631 return
633 if not self.newItemPermission(props):
634 self.error_message.append(
635 _('You do not have permission to create %s' %self.classname))
637 # create a little extra message for anticipated :link / :multilink
638 if self.form.has_key(':multilink'):
639 link = self.form[':multilink'].value
640 elif self.form.has_key(':link'):
641 link = self.form[':multilink'].value
642 else:
643 link = None
644 xtra = ''
645 if link:
646 designator, linkprop = link.split(':')
647 xtra = ' for <a href="%s">%s</a>'%(designator, designator)
649 try:
650 # do the create
651 nid = self._createnode(props)
653 # handle linked nodes
654 self._post_editnode(nid)
656 # commit now that all the tricky stuff is done
657 self.db.commit()
659 # render the newly created item
660 self.nodeid = nid
662 # and some nice feedback for the user
663 message = _('%(classname)s created ok')%self.__dict__ + xtra
664 except (ValueError, KeyError), message:
665 self.error_message.append(_('Error: ') + str(message))
666 return
667 except:
668 # oops
669 self.db.rollback()
670 s = StringIO.StringIO()
671 traceback.print_exc(None, s)
672 self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
673 return
675 # redirect to the new item's page
676 raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
677 nid, urllib.quote(message))
679 def newItemPermission(self, props):
680 ''' Determine whether the user has permission to create (edit) this
681 item.
683 Base behaviour is to check the user can edit this class. No
684 additional property checks are made. Additionally, new user items
685 may be created if the user has the "Web Registration" Permission.
686 '''
687 has = self.db.security.hasPermission
688 if self.classname == 'user' and has('Web Registration', self.userid,
689 'user'):
690 return 1
691 if has('Edit', self.userid, self.classname):
692 return 1
693 return 0
695 def editCSVAction(self):
696 ''' Performs an edit of all of a class' items in one go.
698 The "rows" CGI var defines the CSV-formatted entries for the
699 class. New nodes are identified by the ID 'X' (or any other
700 non-existent ID) and removed lines are retired.
701 '''
702 # this is per-class only
703 if not self.editCSVPermission():
704 self.error_message.append(
705 _('You do not have permission to edit %s' %self.classname))
707 # get the CSV module
708 try:
709 import csv
710 except ImportError:
711 self.error_message.append(_(
712 'Sorry, you need the csv module to use this function.<br>\n'
713 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
714 return
716 cl = self.db.classes[self.classname]
717 idlessprops = cl.getprops(protected=0).keys()
718 idlessprops.sort()
719 props = ['id'] + idlessprops
721 # do the edit
722 rows = self.form['rows'].value.splitlines()
723 p = csv.parser()
724 found = {}
725 line = 0
726 for row in rows[1:]:
727 line += 1
728 values = p.parse(row)
729 # not a complete row, keep going
730 if not values: continue
732 # skip property names header
733 if values == props:
734 continue
736 # extract the nodeid
737 nodeid, values = values[0], values[1:]
738 found[nodeid] = 1
740 # confirm correct weight
741 if len(idlessprops) != len(values):
742 self.error_message.append(
743 _('Not enough values on line %(line)s')%{'line':line})
744 return
746 # extract the new values
747 d = {}
748 for name, value in zip(idlessprops, values):
749 value = value.strip()
750 # only add the property if it has a value
751 if value:
752 # if it's a multilink, split it
753 if isinstance(cl.properties[name], hyperdb.Multilink):
754 value = value.split(':')
755 d[name] = value
757 # perform the edit
758 if cl.hasnode(nodeid):
759 # edit existing
760 cl.set(nodeid, **d)
761 else:
762 # new node
763 found[cl.create(**d)] = 1
765 # retire the removed entries
766 for nodeid in cl.list():
767 if not found.has_key(nodeid):
768 cl.retire(nodeid)
770 # all OK
771 self.db.commit()
773 self.ok_message.append(_('Items edited OK'))
775 def editCSVPermission(self):
776 ''' Determine whether the user has permission to edit this class.
778 Base behaviour is to check the user can edit this class.
779 '''
780 if not self.db.security.hasPermission('Edit', self.userid,
781 self.classname):
782 return 0
783 return 1
785 def searchAction(self):
786 ''' Mangle some of the form variables.
788 Set the form ":filter" variable based on the values of the
789 filter variables - if they're set to anything other than
790 "dontcare" then add them to :filter.
792 Also handle the ":queryname" variable and save off the query to
793 the user's query list.
794 '''
795 # generic edit is per-class only
796 if not self.searchPermission():
797 self.error_message.append(
798 _('You do not have permission to search %s' %self.classname))
800 # add a faked :filter form variable for each filtering prop
801 props = self.db.classes[self.classname].getprops()
802 for key in self.form.keys():
803 if not props.has_key(key): continue
804 if not self.form[key].value: continue
805 self.form.value.append(cgi.MiniFieldStorage(':filter', key))
807 # handle saving the query params
808 if self.form.has_key(':queryname'):
809 queryname = self.form[':queryname'].value.strip()
810 if queryname:
811 # parse the environment and figure what the query _is_
812 req = HTMLRequest(self)
813 url = req.indexargs_href('', {})
815 # handle editing an existing query
816 try:
817 qid = self.db.query.lookup(queryname)
818 self.db.query.set(qid, klass=self.classname, url=url)
819 except KeyError:
820 # create a query
821 qid = self.db.query.create(name=queryname,
822 klass=self.classname, url=url)
824 # and add it to the user's query multilink
825 queries = self.db.user.get(self.userid, 'queries')
826 queries.append(qid)
827 self.db.user.set(self.userid, queries=queries)
829 # commit the query change to the database
830 self.db.commit()
833 def searchPermission(self):
834 ''' Determine whether the user has permission to search this class.
836 Base behaviour is to check the user can view this class.
837 '''
838 if not self.db.security.hasPermission('View', self.userid,
839 self.classname):
840 return 0
841 return 1
843 def XXXremove_action(self, dre=re.compile(r'([^\d]+)(\d+)')):
844 # XXX I believe this could be handled by a regular edit action that
845 # just sets the multilink...
846 # XXX handle this !
847 target = self.index_arg(':target')[0]
848 m = dre.match(target)
849 if m:
850 classname = m.group(1)
851 nodeid = m.group(2)
852 cl = self.db.getclass(classname)
853 cl.retire(nodeid)
854 # now take care of the reference
855 parentref = self.index_arg(':multilink')[0]
856 parent, prop = parentref.split(':')
857 m = dre.match(parent)
858 if m:
859 self.classname = m.group(1)
860 self.nodeid = m.group(2)
861 cl = self.db.getclass(self.classname)
862 value = cl.get(self.nodeid, prop)
863 value.remove(nodeid)
864 cl.set(self.nodeid, **{prop:value})
865 func = getattr(self, 'show%s'%self.classname)
866 return func()
867 else:
868 raise NotFound, parent
869 else:
870 raise NotFound, target
872 #
873 # Utility methods for editing
874 #
875 def _changenode(self, props):
876 ''' change the node based on the contents of the form
877 '''
878 cl = self.db.classes[self.classname]
880 # create the message
881 message, files = self._handle_message()
882 if message:
883 props['messages'] = cl.get(self.nodeid, 'messages') + [message]
884 if files:
885 props['files'] = cl.get(self.nodeid, 'files') + files
887 # make the changes
888 return cl.set(self.nodeid, **props)
890 def _createnode(self, props):
891 ''' create a node based on the contents of the form
892 '''
893 cl = self.db.classes[self.classname]
895 # check for messages and files
896 message, files = self._handle_message()
897 if message:
898 props['messages'] = [message]
899 if files:
900 props['files'] = files
901 # create the node and return it's id
902 return cl.create(**props)
904 def _handle_message(self):
905 ''' generate an edit message
906 '''
907 # handle file attachments
908 files = []
909 if self.form.has_key('__file'):
910 file = self.form['__file']
911 if file.filename:
912 filename = file.filename.split('\\')[-1]
913 mime_type = mimetypes.guess_type(filename)[0]
914 if not mime_type:
915 mime_type = "application/octet-stream"
916 # create the new file entry
917 files.append(self.db.file.create(type=mime_type,
918 name=filename, content=file.file.read()))
920 # we don't want to do a message if none of the following is true...
921 cn = self.classname
922 cl = self.db.classes[self.classname]
923 props = cl.getprops()
924 note = None
925 # in a nutshell, don't do anything if there's no note or there's no
926 # NOSY
927 if self.form.has_key('__note'):
928 note = self.form['__note'].value.strip()
929 if not note:
930 return None, files
931 if not props.has_key('messages'):
932 return None, files
933 if not isinstance(props['messages'], hyperdb.Multilink):
934 return None, files
935 if not props['messages'].classname == 'msg':
936 return None, files
937 if not (self.form.has_key('nosy') or note):
938 return None, files
940 # handle the note
941 if '\n' in note:
942 summary = re.split(r'\n\r?', note)[0]
943 else:
944 summary = note
945 m = ['%s\n'%note]
947 # handle the messageid
948 # TODO: handle inreplyto
949 messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
950 self.classname, self.instance.MAIL_DOMAIN)
952 # now create the message, attaching the files
953 content = '\n'.join(m)
954 message_id = self.db.msg.create(author=self.userid,
955 recipients=[], date=date.Date('.'), summary=summary,
956 content=content, files=files, messageid=messageid)
958 # update the messages property
959 return message_id, files
961 def _post_editnode(self, nid):
962 '''Do the linking part of the node creation.
964 If a form element has :link or :multilink appended to it, its
965 value specifies a node designator and the property on that node
966 to add _this_ node to as a link or multilink.
968 This is typically used on, eg. the file upload page to indicated
969 which issue to link the file to.
971 TODO: I suspect that this and newfile will go away now that
972 there's the ability to upload a file using the issue __file form
973 element!
974 '''
975 cn = self.classname
976 cl = self.db.classes[cn]
977 # link if necessary
978 keys = self.form.keys()
979 for key in keys:
980 if key == ':multilink':
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 # take a dupe of the list so we're not changing the cache
988 value = link.get(nodeid, property)[:]
989 value.append(nid)
990 link.set(nodeid, **{property: value})
991 elif key == ':link':
992 value = self.form[key].value
993 if type(value) != type([]): value = [value]
994 for value in value:
995 designator, property = value.split(':')
996 link, nodeid = hyperdb.splitDesignator(designator)
997 link = self.db.classes[link]
998 link.set(nodeid, **{property: nid})
1001 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
1002 '''Pull properties for the given class out of the form.
1003 '''
1004 props = {}
1005 keys = form.keys()
1006 for key in keys:
1007 if not cl.properties.has_key(key):
1008 continue
1009 proptype = cl.properties[key]
1010 if isinstance(proptype, hyperdb.String):
1011 value = form[key].value.strip()
1012 elif isinstance(proptype, hyperdb.Password):
1013 value = form[key].value.strip()
1014 if not value:
1015 # ignore empty password values
1016 continue
1017 value = password.Password(value)
1018 elif isinstance(proptype, hyperdb.Date):
1019 value = form[key].value.strip()
1020 if value:
1021 value = date.Date(form[key].value.strip())
1022 else:
1023 value = None
1024 elif isinstance(proptype, hyperdb.Interval):
1025 value = form[key].value.strip()
1026 if value:
1027 value = date.Interval(form[key].value.strip())
1028 else:
1029 value = None
1030 elif isinstance(proptype, hyperdb.Link):
1031 value = form[key].value.strip()
1032 # see if it's the "no selection" choice
1033 if value == '-1':
1034 value = None
1035 else:
1036 # handle key values
1037 link = cl.properties[key].classname
1038 if not num_re.match(value):
1039 try:
1040 value = db.classes[link].lookup(value)
1041 except KeyError:
1042 raise ValueError, _('property "%(propname)s": '
1043 '%(value)s not a %(classname)s')%{'propname':key,
1044 'value': value, 'classname': link}
1045 elif isinstance(proptype, hyperdb.Multilink):
1046 value = form[key]
1047 if not isinstance(value, type([])):
1048 value = [i.strip() for i in value.value.split(',')]
1049 else:
1050 value = [i.value.strip() for i in value]
1051 link = cl.properties[key].classname
1052 l = []
1053 for entry in map(str, value):
1054 if entry == '': continue
1055 if not num_re.match(entry):
1056 try:
1057 entry = db.classes[link].lookup(entry)
1058 except KeyError:
1059 raise ValueError, _('property "%(propname)s": '
1060 '"%(value)s" not an entry of %(classname)s')%{
1061 'propname':key, 'value': entry, 'classname': link}
1062 l.append(entry)
1063 l.sort()
1064 value = l
1065 elif isinstance(proptype, hyperdb.Boolean):
1066 value = form[key].value.strip()
1067 props[key] = value = value.lower() in ('yes', 'true', 'on', '1')
1068 elif isinstance(proptype, hyperdb.Number):
1069 value = form[key].value.strip()
1070 props[key] = value = int(value)
1072 # get the old value
1073 if nodeid:
1074 try:
1075 existing = cl.get(nodeid, key)
1076 except KeyError:
1077 # this might be a new property for which there is no existing
1078 # value
1079 if not cl.properties.has_key(key): raise
1081 # if changed, set it
1082 if value != existing:
1083 props[key] = value
1084 else:
1085 props[key] = value
1086 return props