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