1 # $Id: client.py,v 1.19 2002-09-06 07:21:31 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 ''' Process a request.
105 The most common requests are handled like so:
106 1. figure out who we are, defaulting to the "anonymous" user
107 see determine_user
108 2. figure out what the request is for - the context
109 see determine_context
110 3. handle any requested action (item edit, search, ...)
111 see handle_action
112 4. render a template, resulting in HTML output
114 In some situations, exceptions occur:
115 - HTTP Redirect (generally raised by an action)
116 - SendFile (generally raised by determine_context)
117 serve up a FileClass "content" property
118 - SendStaticFile (generally raised by determine_context)
119 serve up a file from the tracker "html" directory
120 - Unauthorised (generally raised by an action)
121 the action is cancelled, the request is rendered and an error
122 message is displayed indicating that permission was not
123 granted for the action to take place
124 - NotFound (raised wherever it needs to be)
125 percolates up to the CGI interface that called the client
126 '''
127 self.content_action = None
128 self.ok_message = []
129 self.error_message = []
130 try:
131 # make sure we're identified (even anonymously)
132 self.determine_user()
133 # figure out the context and desired content template
134 self.determine_context()
135 # possibly handle a form submit action (may change self.classname
136 # and self.template, and may also append error/ok_messages)
137 self.handle_action()
138 # now render the page
139 if self.form.has_key(':contentonly'):
140 # just the content
141 self.write(self.content())
142 else:
143 # render the content inside the page template
144 self.write(self.renderTemplate('page', '',
145 ok_message=self.ok_message,
146 error_message=self.error_message))
147 except Redirect, url:
148 # let's redirect - if the url isn't None, then we need to do
149 # the headers, otherwise the headers have been set before the
150 # exception was raised
151 if url:
152 self.header({'Location': url}, response=302)
153 except SendFile, designator:
154 self.serve_file(designator)
155 except SendStaticFile, file:
156 self.serve_static_file(str(file))
157 except Unauthorised, message:
158 self.write(self.renderTemplate('page', '', error_message=message))
159 except:
160 # everything else
161 self.write(cgitb.html())
163 def determine_user(self):
164 ''' Determine who the user is
165 '''
166 # determine the uid to use
167 self.opendb('admin')
169 # make sure we have the session Class
170 sessions = self.db.sessions
172 # age sessions, remove when they haven't been used for a week
173 # TODO: this shouldn't be done every access
174 week = 60*60*24*7
175 now = time.time()
176 for sessid in sessions.list():
177 interval = now - sessions.get(sessid, 'last_use')
178 if interval > week:
179 sessions.destroy(sessid)
181 # look up the user session cookie
182 cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
183 user = 'anonymous'
185 # bump the "revision" of the cookie since the format changed
186 if (cookie.has_key('roundup_user_2') and
187 cookie['roundup_user_2'].value != 'deleted'):
189 # get the session key from the cookie
190 self.session = cookie['roundup_user_2'].value
191 # get the user from the session
192 try:
193 # update the lifetime datestamp
194 sessions.set(self.session, last_use=time.time())
195 sessions.commit()
196 user = sessions.get(self.session, 'user')
197 except KeyError:
198 user = 'anonymous'
200 # sanity check on the user still being valid, getting the userid
201 # at the same time
202 try:
203 self.userid = self.db.user.lookup(user)
204 except (KeyError, TypeError):
205 user = 'anonymous'
207 # make sure the anonymous user is valid if we're using it
208 if user == 'anonymous':
209 self.make_user_anonymous()
210 else:
211 self.user = user
213 # reopen the database as the correct user
214 self.opendb(self.user)
216 def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
217 ''' Determine the context of this page from the URL:
219 The URL path after the instance identifier is examined. The path
220 is generally only one entry long.
222 - if there is no path, then we are in the "home" context.
223 * if the path is "_file", then the additional path entry
224 specifies the filename of a static file we're to serve up
225 from the instance "html" directory. Raises a SendStaticFile
226 exception.
227 - if there is something in the path (eg "issue"), it identifies
228 the tracker class we're to display.
229 - if the path is an item designator (eg "issue123"), then we're
230 to display a specific item.
231 * if the path starts with an item designator and is longer than
232 one entry, then we're assumed to be handling an item of a
233 FileClass, and the extra path information gives the filename
234 that the client is going to label the download with (ie
235 "file123/image.png" is nicer to download than "file123"). This
236 raises a SendFile exception.
238 Both of the "*" types of contexts stop before we bother to
239 determine the template we're going to use. That's because they
240 don't actually use templates.
242 The template used is specified by the :template CGI variable,
243 which defaults to:
245 only classname suplied: "index"
246 full item designator supplied: "item"
248 We set:
249 self.classname - the class to display, can be None
250 self.template - the template to render the current context with
251 self.nodeid - the nodeid of the class we're displaying
252 '''
253 # default the optional variables
254 self.classname = None
255 self.nodeid = None
257 # determine the classname and possibly nodeid
258 path = self.split_path
259 if not path or path[0] in ('', 'home', 'index'):
260 if self.form.has_key(':template'):
261 self.template = self.form[':template'].value
262 else:
263 self.template = ''
264 return
265 elif path[0] == '_file':
266 raise SendStaticFile, path[1]
267 else:
268 self.classname = path[0]
269 if len(path) > 1:
270 # send the file identified by the designator in path[0]
271 raise SendFile, path[0]
273 # see if we got a designator
274 m = dre.match(self.classname)
275 if m:
276 self.classname = m.group(1)
277 self.nodeid = m.group(2)
278 # with a designator, we default to item view
279 self.template = 'item'
280 else:
281 # with only a class, we default to index view
282 self.template = 'index'
284 # see if we have a template override
285 if self.form.has_key(':template'):
286 self.template = self.form[':template'].value
289 # see if we were passed in a message
290 if self.form.has_key(':ok_message'):
291 self.ok_message.append(self.form[':ok_message'].value)
292 if self.form.has_key(':error_message'):
293 self.error_message.append(self.form[':error_message'].value)
295 def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
296 ''' Serve the file from the content property of the designated item.
297 '''
298 m = dre.match(str(designator))
299 if not m:
300 raise NotFound, str(designator)
301 classname, nodeid = m.group(1), m.group(2)
302 if classname != 'file':
303 raise NotFound, designator
305 # we just want to serve up the file named
306 file = self.db.file
307 self.header({'Content-Type': file.get(nodeid, 'type')})
308 self.write(file.get(nodeid, 'content'))
310 def serve_static_file(self, file):
311 # we just want to serve up the file named
312 mt = mimetypes.guess_type(str(file))[0]
313 self.header({'Content-Type': mt})
314 self.write(open(os.path.join(self.instance.TEMPLATES, file)).read())
316 def renderTemplate(self, name, extension, **kwargs):
317 ''' Return a PageTemplate for the named page
318 '''
319 pt = getTemplate(self.instance.TEMPLATES, name, extension)
320 # XXX handle PT rendering errors here more nicely
321 try:
322 # let the template render figure stuff out
323 return pt.render(self, None, None, **kwargs)
324 except PageTemplate.PTRuntimeError, message:
325 return '<strong>%s</strong><ol>%s</ol>'%(message,
326 '<li>'.join(pt._v_errors))
327 except:
328 # everything else
329 return cgitb.pt_html()
331 def content(self):
332 ''' Callback used by the page template to render the content of
333 the page.
335 If we don't have a specific class to display, that is none was
336 determined in determine_context(), then we display a "home"
337 template.
338 '''
339 # now render the page content using the template we determined in
340 # determine_context
341 if self.classname is None:
342 name = 'home'
343 else:
344 name = self.classname
345 return self.renderTemplate(self.classname, self.template)
347 # these are the actions that are available
348 actions = {
349 'edit': 'editItemAction',
350 'editCSV': 'editCSVAction',
351 'new': 'newItemAction',
352 'register': 'registerAction',
353 'login': 'loginAction',
354 'logout': 'logout_action',
355 'search': 'searchAction',
356 }
357 def handle_action(self):
358 ''' Determine whether there should be an _action called.
360 The action is defined by the form variable :action which
361 identifies the method on this object to call. The four basic
362 actions are defined in the "actions" dictionary on this class:
363 "edit" -> self.editItemAction
364 "new" -> self.newItemAction
365 "register" -> self.registerAction
366 "login" -> self.loginAction
367 "logout" -> self.logout_action
368 "search" -> self.searchAction
370 '''
371 if not self.form.has_key(':action'):
372 return None
373 try:
374 # get the action, validate it
375 action = self.form[':action'].value
376 if not self.actions.has_key(action):
377 raise ValueError, 'No such action "%s"'%action
379 # call the mapped action
380 getattr(self, self.actions[action])()
381 except Redirect:
382 raise
383 except Unauthorised:
384 raise
385 except:
386 self.db.rollback()
387 s = StringIO.StringIO()
388 traceback.print_exc(None, s)
389 self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
391 def write(self, content):
392 if not self.headers_done:
393 self.header()
394 self.request.wfile.write(content)
396 def header(self, headers=None, response=200):
397 '''Put up the appropriate header.
398 '''
399 if headers is None:
400 headers = {'Content-Type':'text/html'}
401 if not headers.has_key('Content-Type'):
402 headers['Content-Type'] = 'text/html'
403 self.request.send_response(response)
404 for entry in headers.items():
405 self.request.send_header(*entry)
406 self.request.end_headers()
407 self.headers_done = 1
408 if self.debug:
409 self.headers_sent = headers
411 def set_cookie(self, user, password):
412 # TODO generate a much, much stronger session key ;)
413 self.session = binascii.b2a_base64(repr(time.time())).strip()
415 # clean up the base64
416 if self.session[-1] == '=':
417 if self.session[-2] == '=':
418 self.session = self.session[:-2]
419 else:
420 self.session = self.session[:-1]
422 # insert the session in the sessiondb
423 self.db.sessions.set(self.session, user=user, last_use=time.time())
425 # and commit immediately
426 self.db.sessions.commit()
428 # expire us in a long, long time
429 expire = Cookie._getdate(86400*365)
431 # generate the cookie path - make sure it has a trailing '/'
432 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
433 ''))
434 self.header({'Set-Cookie': 'roundup_user_2=%s; expires=%s; Path=%s;'%(
435 self.session, expire, path)})
437 def make_user_anonymous(self):
438 ''' Make us anonymous
440 This method used to handle non-existence of the 'anonymous'
441 user, but that user is mandatory now.
442 '''
443 self.userid = self.db.user.lookup('anonymous')
444 self.user = 'anonymous'
446 def logout(self):
447 ''' Make us really anonymous - nuke the cookie too
448 '''
449 self.make_user_anonymous()
451 # construct the logout cookie
452 now = Cookie._getdate()
453 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
454 ''))
455 self.header({'Set-Cookie':
456 'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
457 path)})
458 self.login()
460 def opendb(self, user):
461 ''' Open the database.
462 '''
463 # open the db if the user has changed
464 if not hasattr(self, 'db') or user != self.db.journaltag:
465 self.db = self.instance.open(user)
467 #
468 # Actions
469 #
470 def loginAction(self):
471 ''' Attempt to log a user in.
473 Sets up a session for the user which contains the login
474 credentials.
475 '''
476 # we need the username at a minimum
477 if not self.form.has_key('__login_name'):
478 self.error_message.append(_('Username required'))
479 return
481 self.user = self.form['__login_name'].value
482 # re-open the database for real, using the user
483 self.opendb(self.user)
484 if self.form.has_key('__login_password'):
485 password = self.form['__login_password'].value
486 else:
487 password = ''
488 # make sure the user exists
489 try:
490 self.userid = self.db.user.lookup(self.user)
491 except KeyError:
492 name = self.user
493 self.make_user_anonymous()
494 self.error_message.append(_('No such user "%(name)s"')%locals())
495 return
497 # and that the password is correct
498 pw = self.db.user.get(self.userid, 'password')
499 if password != pw:
500 self.make_user_anonymous()
501 self.error_message.append(_('Incorrect password'))
502 return
504 # make sure we're allowed to be here
505 if not self.loginPermission():
506 self.make_user_anonymous()
507 raise Unauthorised, _("You do not have permission to login")
509 # set the session cookie
510 self.set_cookie(self.user, password)
512 def loginPermission(self):
513 ''' Determine whether the user has permission to log in.
515 Base behaviour is to check the user has "Web Access".
516 '''
517 if not self.db.security.hasPermission('Web Access', self.userid):
518 return 0
519 return 1
521 def logout_action(self):
522 ''' Make us really anonymous - nuke the cookie too
523 '''
524 # log us out
525 self.make_user_anonymous()
527 # construct the logout cookie
528 now = Cookie._getdate()
529 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
530 ''))
531 self.header(headers={'Set-Cookie':
532 'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)})
534 # Let the user know what's going on
535 self.ok_message.append(_('You are logged out'))
537 def registerAction(self):
538 '''Attempt to create a new user based on the contents of the form
539 and then set the cookie.
541 return 1 on successful login
542 '''
543 # create the new user
544 cl = self.db.user
546 # parse the props from the form
547 try:
548 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
549 except (ValueError, KeyError), message:
550 self.error_message.append(_('Error: ') + str(message))
551 return
553 # make sure we're allowed to register
554 if not self.registerPermission(props):
555 raise Unauthorised, _("You do not have permission to register")
557 # re-open the database as "admin"
558 if self.user != 'admin':
559 self.opendb('admin')
561 # create the new user
562 cl = self.db.user
563 try:
564 props = parsePropsFromForm(self.db, cl, self.form)
565 props['roles'] = self.instance.NEW_WEB_USER_ROLES
566 self.userid = cl.create(**props)
567 self.db.commit()
568 except ValueError, message:
569 self.error_message.append(message)
571 # log the new user in
572 self.user = cl.get(self.userid, 'username')
573 # re-open the database for real, using the user
574 self.opendb(self.user)
575 password = self.db.user.get(self.userid, 'password')
576 self.set_cookie(self.user, password)
578 # nice message
579 self.ok_message.append(_('You are now registered, welcome!'))
581 def registerPermission(self, props):
582 ''' Determine whether the user has permission to register
584 Base behaviour is to check the user has "Web Registration".
585 '''
586 # registration isn't allowed to supply roles
587 if props.has_key('roles'):
588 return 0
589 if self.db.security.hasPermission('Web Registration', self.userid):
590 return 1
591 return 0
593 def editItemAction(self):
594 ''' Perform an edit of an item in the database.
596 Some special form elements:
598 :link=designator:property
599 :multilink=designator:property
600 The value specifies a node designator and the property on that
601 node to add _this_ node to as a link or multilink.
602 __note
603 Create a message and attach it to the current node's
604 "messages" property.
605 __file
606 Create a file and attach it to the current node's
607 "files" property. Attach the file to the message created from
608 the __note if it's supplied.
610 :required=property,property,...
611 The named properties are required to be filled in the form.
613 '''
614 cl = self.db.classes[self.classname]
616 # parse the props from the form
617 try:
618 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
619 except (ValueError, KeyError), message:
620 self.error_message.append(_('Error: ') + str(message))
621 return
623 # check permission
624 if not self.editItemPermission(props):
625 self.error_message.append(
626 _('You do not have permission to edit %(classname)s'%
627 self.__dict__))
628 return
630 # perform the edit
631 try:
632 # make changes to the node
633 props = self._changenode(props)
634 # handle linked nodes
635 self._post_editnode(self.nodeid)
636 except (ValueError, KeyError), message:
637 self.error_message.append(_('Error: ') + str(message))
638 return
640 # commit now that all the tricky stuff is done
641 self.db.commit()
643 # and some nice feedback for the user
644 if props:
645 message = _('%(changes)s edited ok')%{'changes':
646 ', '.join(props.keys())}
647 elif self.form.has_key('__note') and self.form['__note'].value:
648 message = _('note added')
649 elif (self.form.has_key('__file') and self.form['__file'].filename):
650 message = _('file added')
651 else:
652 message = _('nothing changed')
654 # redirect to the item's edit page
655 raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
656 self.nodeid, urllib.quote(message))
658 def editItemPermission(self, props):
659 ''' Determine whether the user has permission to edit this item.
661 Base behaviour is to check the user can edit this class. If we're
662 editing the "user" class, users are allowed to edit their own
663 details. Unless it's the "roles" property, which requires the
664 special Permission "Web Roles".
665 '''
666 # if this is a user node and the user is editing their own node, then
667 # we're OK
668 has = self.db.security.hasPermission
669 if self.classname == 'user':
670 # reject if someone's trying to edit "roles" and doesn't have the
671 # right permission.
672 if props.has_key('roles') and not has('Web Roles', self.userid,
673 'user'):
674 return 0
675 # if the item being edited is the current user, we're ok
676 if self.nodeid == self.userid:
677 return 1
678 if self.db.security.hasPermission('Edit', self.userid, self.classname):
679 return 1
680 return 0
682 def newItemAction(self):
683 ''' Add a new item to the database.
685 This follows the same form as the editItemAction, with the same
686 special form values.
687 '''
688 cl = self.db.classes[self.classname]
690 # parse the props from the form
691 try:
692 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
693 except (ValueError, KeyError), message:
694 self.error_message.append(_('Error: ') + str(message))
695 return
697 if not self.newItemPermission(props):
698 self.error_message.append(
699 _('You do not have permission to create %s' %self.classname))
701 # create a little extra message for anticipated :link / :multilink
702 if self.form.has_key(':multilink'):
703 link = self.form[':multilink'].value
704 elif self.form.has_key(':link'):
705 link = self.form[':multilink'].value
706 else:
707 link = None
708 xtra = ''
709 if link:
710 designator, linkprop = link.split(':')
711 xtra = ' for <a href="%s">%s</a>'%(designator, designator)
713 try:
714 # do the create
715 nid = self._createnode(props)
717 # handle linked nodes
718 self._post_editnode(nid)
720 # commit now that all the tricky stuff is done
721 self.db.commit()
723 # render the newly created item
724 self.nodeid = nid
726 # and some nice feedback for the user
727 message = _('%(classname)s created ok')%self.__dict__ + xtra
728 except (ValueError, KeyError), message:
729 self.error_message.append(_('Error: ') + str(message))
730 return
731 except:
732 # oops
733 self.db.rollback()
734 s = StringIO.StringIO()
735 traceback.print_exc(None, s)
736 self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
737 return
739 # redirect to the new item's page
740 raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
741 nid, urllib.quote(message))
743 def newItemPermission(self, props):
744 ''' Determine whether the user has permission to create (edit) this
745 item.
747 Base behaviour is to check the user can edit this class. No
748 additional property checks are made. Additionally, new user items
749 may be created if the user has the "Web Registration" Permission.
750 '''
751 has = self.db.security.hasPermission
752 if self.classname == 'user' and has('Web Registration', self.userid,
753 'user'):
754 return 1
755 if has('Edit', self.userid, self.classname):
756 return 1
757 return 0
759 def editCSVAction(self):
760 ''' Performs an edit of all of a class' items in one go.
762 The "rows" CGI var defines the CSV-formatted entries for the
763 class. New nodes are identified by the ID 'X' (or any other
764 non-existent ID) and removed lines are retired.
765 '''
766 # this is per-class only
767 if not self.editCSVPermission():
768 self.error_message.append(
769 _('You do not have permission to edit %s' %self.classname))
771 # get the CSV module
772 try:
773 import csv
774 except ImportError:
775 self.error_message.append(_(
776 'Sorry, you need the csv module to use this function.<br>\n'
777 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
778 return
780 cl = self.db.classes[self.classname]
781 idlessprops = cl.getprops(protected=0).keys()
782 idlessprops.sort()
783 props = ['id'] + idlessprops
785 # do the edit
786 rows = self.form['rows'].value.splitlines()
787 p = csv.parser()
788 found = {}
789 line = 0
790 for row in rows[1:]:
791 line += 1
792 values = p.parse(row)
793 # not a complete row, keep going
794 if not values: continue
796 # skip property names header
797 if values == props:
798 continue
800 # extract the nodeid
801 nodeid, values = values[0], values[1:]
802 found[nodeid] = 1
804 # confirm correct weight
805 if len(idlessprops) != len(values):
806 self.error_message.append(
807 _('Not enough values on line %(line)s')%{'line':line})
808 return
810 # extract the new values
811 d = {}
812 for name, value in zip(idlessprops, values):
813 value = value.strip()
814 # only add the property if it has a value
815 if value:
816 # if it's a multilink, split it
817 if isinstance(cl.properties[name], hyperdb.Multilink):
818 value = value.split(':')
819 d[name] = value
821 # perform the edit
822 if cl.hasnode(nodeid):
823 # edit existing
824 cl.set(nodeid, **d)
825 else:
826 # new node
827 found[cl.create(**d)] = 1
829 # retire the removed entries
830 for nodeid in cl.list():
831 if not found.has_key(nodeid):
832 cl.retire(nodeid)
834 # all OK
835 self.db.commit()
837 self.ok_message.append(_('Items edited OK'))
839 def editCSVPermission(self):
840 ''' Determine whether the user has permission to edit this class.
842 Base behaviour is to check the user can edit this class.
843 '''
844 if not self.db.security.hasPermission('Edit', self.userid,
845 self.classname):
846 return 0
847 return 1
849 def searchAction(self):
850 ''' Mangle some of the form variables.
852 Set the form ":filter" variable based on the values of the
853 filter variables - if they're set to anything other than
854 "dontcare" then add them to :filter.
856 Also handle the ":queryname" variable and save off the query to
857 the user's query list.
858 '''
859 # generic edit is per-class only
860 if not self.searchPermission():
861 self.error_message.append(
862 _('You do not have permission to search %s' %self.classname))
864 # add a faked :filter form variable for each filtering prop
865 props = self.db.classes[self.classname].getprops()
866 for key in self.form.keys():
867 if not props.has_key(key): continue
868 if not self.form[key].value: continue
869 self.form.value.append(cgi.MiniFieldStorage(':filter', key))
871 # handle saving the query params
872 if self.form.has_key(':queryname'):
873 queryname = self.form[':queryname'].value.strip()
874 if queryname:
875 # parse the environment and figure what the query _is_
876 req = HTMLRequest(self)
877 url = req.indexargs_href('', {})
879 # handle editing an existing query
880 try:
881 qid = self.db.query.lookup(queryname)
882 self.db.query.set(qid, klass=self.classname, url=url)
883 except KeyError:
884 # create a query
885 qid = self.db.query.create(name=queryname,
886 klass=self.classname, url=url)
888 # and add it to the user's query multilink
889 queries = self.db.user.get(self.userid, 'queries')
890 queries.append(qid)
891 self.db.user.set(self.userid, queries=queries)
893 # commit the query change to the database
894 self.db.commit()
896 def searchPermission(self):
897 ''' Determine whether the user has permission to search this class.
899 Base behaviour is to check the user can view this class.
900 '''
901 if not self.db.security.hasPermission('View', self.userid,
902 self.classname):
903 return 0
904 return 1
906 def remove_action(self, dre=re.compile(r'([^\d]+)(\d+)')):
907 # XXX I believe this could be handled by a regular edit action that
908 # just sets the multilink...
909 target = self.index_arg(':target')[0]
910 m = dre.match(target)
911 if m:
912 classname = m.group(1)
913 nodeid = m.group(2)
914 cl = self.db.getclass(classname)
915 cl.retire(nodeid)
916 # now take care of the reference
917 parentref = self.index_arg(':multilink')[0]
918 parent, prop = parentref.split(':')
919 m = dre.match(parent)
920 if m:
921 self.classname = m.group(1)
922 self.nodeid = m.group(2)
923 cl = self.db.getclass(self.classname)
924 value = cl.get(self.nodeid, prop)
925 value.remove(nodeid)
926 cl.set(self.nodeid, **{prop:value})
927 func = getattr(self, 'show%s'%self.classname)
928 return func()
929 else:
930 raise NotFound, parent
931 else:
932 raise NotFound, target
934 #
935 # Utility methods for editing
936 #
937 def _changenode(self, props):
938 ''' change the node based on the contents of the form
939 '''
940 cl = self.db.classes[self.classname]
942 # create the message
943 message, files = self._handle_message()
944 if message:
945 props['messages'] = cl.get(self.nodeid, 'messages') + [message]
946 if files:
947 props['files'] = cl.get(self.nodeid, 'files') + files
949 # make the changes
950 return cl.set(self.nodeid, **props)
952 def _createnode(self, props):
953 ''' create a node based on the contents of the form
954 '''
955 cl = self.db.classes[self.classname]
957 # check for messages and files
958 message, files = self._handle_message()
959 if message:
960 props['messages'] = [message]
961 if files:
962 props['files'] = files
963 # create the node and return it's id
964 return cl.create(**props)
966 def _handle_message(self):
967 ''' generate an edit message
968 '''
969 # handle file attachments
970 files = []
971 if self.form.has_key('__file'):
972 file = self.form['__file']
973 if file.filename:
974 filename = file.filename.split('\\')[-1]
975 mime_type = mimetypes.guess_type(filename)[0]
976 if not mime_type:
977 mime_type = "application/octet-stream"
978 # create the new file entry
979 files.append(self.db.file.create(type=mime_type,
980 name=filename, content=file.file.read()))
982 # we don't want to do a message if none of the following is true...
983 cn = self.classname
984 cl = self.db.classes[self.classname]
985 props = cl.getprops()
986 note = None
987 # in a nutshell, don't do anything if there's no note or there's no
988 # NOSY
989 if self.form.has_key('__note'):
990 note = self.form['__note'].value.strip()
991 if not note:
992 return None, files
993 if not props.has_key('messages'):
994 return None, files
995 if not isinstance(props['messages'], hyperdb.Multilink):
996 return None, files
997 if not props['messages'].classname == 'msg':
998 return None, files
999 if not (self.form.has_key('nosy') or note):
1000 return None, files
1002 # handle the note
1003 if '\n' in note:
1004 summary = re.split(r'\n\r?', note)[0]
1005 else:
1006 summary = note
1007 m = ['%s\n'%note]
1009 # handle the messageid
1010 # TODO: handle inreplyto
1011 messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
1012 self.classname, self.instance.MAIL_DOMAIN)
1014 # now create the message, attaching the files
1015 content = '\n'.join(m)
1016 message_id = self.db.msg.create(author=self.userid,
1017 recipients=[], date=date.Date('.'), summary=summary,
1018 content=content, files=files, messageid=messageid)
1020 # update the messages property
1021 return message_id, files
1023 def _post_editnode(self, nid):
1024 '''Do the linking part of the node creation.
1026 If a form element has :link or :multilink appended to it, its
1027 value specifies a node designator and the property on that node
1028 to add _this_ node to as a link or multilink.
1030 This is typically used on, eg. the file upload page to indicated
1031 which issue to link the file to.
1033 TODO: I suspect that this and newfile will go away now that
1034 there's the ability to upload a file using the issue __file form
1035 element!
1036 '''
1037 cn = self.classname
1038 cl = self.db.classes[cn]
1039 # link if necessary
1040 keys = self.form.keys()
1041 for key in keys:
1042 if key == ':multilink':
1043 value = self.form[key].value
1044 if type(value) != type([]): value = [value]
1045 for value in value:
1046 designator, property = value.split(':')
1047 link, nodeid = hyperdb.splitDesignator(designator)
1048 link = self.db.classes[link]
1049 # take a dupe of the list so we're not changing the cache
1050 value = link.get(nodeid, property)[:]
1051 value.append(nid)
1052 link.set(nodeid, **{property: value})
1053 elif key == ':link':
1054 value = self.form[key].value
1055 if type(value) != type([]): value = [value]
1056 for value in value:
1057 designator, property = value.split(':')
1058 link, nodeid = hyperdb.splitDesignator(designator)
1059 link = self.db.classes[link]
1060 link.set(nodeid, **{property: nid})
1063 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
1064 ''' Pull properties for the given class out of the form.
1066 If a ":required" parameter is supplied, then the names property values
1067 must be supplied or a ValueError will be raised.
1068 '''
1069 required = []
1070 print form.keys()
1071 if form.has_key(':required'):
1072 value = form[':required']
1073 print 'required', value
1074 if isinstance(value, type([])):
1075 required = [i.value.strip() for i in value]
1076 else:
1077 required = [i.strip() for i in value.value.split(',')]
1079 props = {}
1080 keys = form.keys()
1081 for key in keys:
1082 if not cl.properties.has_key(key):
1083 continue
1084 proptype = cl.properties[key]
1086 # Get the form value. This value may be a MiniFieldStorage or a list
1087 # of MiniFieldStorages.
1088 value = form[key]
1090 # make sure non-multilinks only get one value
1091 if not isinstance(proptype, hyperdb.Multilink):
1092 if isinstance(value, type([])):
1093 raise ValueError, 'You have submitted more than one value'\
1094 ' for the %s property'%key
1095 # we've got a MiniFieldStorage, so pull out the value and strip
1096 # surrounding whitespace
1097 value = value.value.strip()
1099 if isinstance(proptype, hyperdb.String):
1100 if not value:
1101 continue
1102 elif isinstance(proptype, hyperdb.Password):
1103 if not value:
1104 # ignore empty password values
1105 continue
1106 value = password.Password(value)
1107 elif isinstance(proptype, hyperdb.Date):
1108 if value:
1109 value = date.Date(form[key].value.strip())
1110 else:
1111 value = None
1112 elif isinstance(proptype, hyperdb.Interval):
1113 if value:
1114 value = date.Interval(form[key].value.strip())
1115 else:
1116 value = None
1117 elif isinstance(proptype, hyperdb.Link):
1118 # see if it's the "no selection" choice
1119 if value == '-1':
1120 value = None
1121 else:
1122 # handle key values
1123 link = cl.properties[key].classname
1124 if not num_re.match(value):
1125 try:
1126 value = db.classes[link].lookup(value)
1127 except KeyError:
1128 raise ValueError, _('property "%(propname)s": '
1129 '%(value)s not a %(classname)s')%{'propname':key,
1130 'value': value, 'classname': link}
1131 elif isinstance(proptype, hyperdb.Multilink):
1132 if isinstance(value, type([])):
1133 # it's a list of MiniFieldStorages
1134 value = [i.value.strip() for i in value]
1135 else:
1136 # it's a MiniFieldStorage, but may be a comma-separated list
1137 # of values
1138 value = [i.strip() for i in value.value.split(',')]
1139 link = cl.properties[key].classname
1140 l = []
1141 for entry in map(str, value):
1142 if entry == '': continue
1143 if not num_re.match(entry):
1144 try:
1145 entry = db.classes[link].lookup(entry)
1146 except KeyError:
1147 raise ValueError, _('property "%(propname)s": '
1148 '"%(value)s" not an entry of %(classname)s')%{
1149 'propname':key, 'value': entry, 'classname': link}
1150 l.append(entry)
1151 l.sort()
1152 value = l
1153 elif isinstance(proptype, hyperdb.Boolean):
1154 props[key] = value = value.lower() in ('yes', 'true', 'on', '1')
1155 elif isinstance(proptype, hyperdb.Number):
1156 props[key] = value = int(value)
1158 # register this as received if required
1159 if key in required:
1160 required.remove(key)
1162 # get the old value
1163 if nodeid:
1164 try:
1165 existing = cl.get(nodeid, key)
1166 except KeyError:
1167 # this might be a new property for which there is no existing
1168 # value
1169 if not cl.properties.has_key(key): raise
1171 # if changed, set it
1172 if value != existing:
1173 props[key] = value
1174 else:
1175 props[key] = value
1177 # see if all the required properties have been supplied
1178 if required:
1179 raise ValueError, 'Required properties %s not supplied'%(
1180 ', '.join(required))
1182 return props