1 # $Id: client.py,v 1.92 2003-02-18 03:58:18 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 ''' Instantiate to handle one CGI request.
53 See inner_main for request processing.
55 Client attributes at instantiation:
56 "path" is the PATH_INFO inside the instance (with no leading '/')
57 "base" is the base URL for the instance
58 "form" is the cgi form, an instance of FieldStorage from the standard
59 cgi module
60 "additional_headers" is a dictionary of additional HTTP headers that
61 should be sent to the client
62 "response_code" is the HTTP response code to send to the client
64 During the processing of a request, the following attributes are used:
65 "error_message" holds a list of error messages
66 "ok_message" holds a list of OK messages
67 "session" is the current user session id
68 "user" is the current user's name
69 "userid" is the current user's id
70 "template" is the current :template context
71 "classname" is the current class context name
72 "nodeid" is the current context item id
74 User Identification:
75 If the user has no login cookie, then they are anonymous and are logged
76 in as that user. This typically gives them all Permissions assigned to the
77 Anonymous Role.
79 Once a user logs in, they are assigned a session. The Client instance
80 keeps the nodeid of the session as the "session" attribute.
83 Special form variables:
84 Note that in various places throughout this code, special form
85 variables of the form :<name> are used. The colon (":") part may
86 actually be one of either ":" or "@".
87 '''
89 #
90 # special form variables
91 #
92 FV_TEMPLATE = re.compile(r'[@:]template')
93 FV_OK_MESSAGE = re.compile(r'[@:]ok_message')
94 FV_ERROR_MESSAGE = re.compile(r'[@:]error_message')
96 # edit form variable handling (see unit tests)
97 FV_LABELS = r'''
98 ^(
99 (?P<note>[@:]note)|
100 (?P<file>[@:]file)|
101 (
102 ((?P<classname>%s)(?P<id>[-\d]+))? # optional leading designator
103 ((?P<required>[@:]required$)| # :required
104 (
105 (
106 (?P<add>[@:]add[@:])| # :add:<prop>
107 (?P<remove>[@:]remove[@:])| # :remove:<prop>
108 (?P<confirm>[@:]confirm[@:])| # :confirm:<prop>
109 (?P<link>[@:]link[@:])| # :link:<prop>
110 ([@:]) # just a separator
111 )?
112 (?P<propname>[^@:]+) # <prop>
113 )
114 )
115 )
116 )$'''
118 # Note: index page stuff doesn't appear here:
119 # columns, sort, sortdir, filter, group, groupdir, search_text,
120 # pagesize, startwith
122 def __init__(self, instance, request, env, form=None):
123 hyperdb.traceMark()
124 self.instance = instance
125 self.request = request
126 self.env = env
128 # save off the path
129 self.path = env['PATH_INFO']
131 # this is the base URL for this tracker
132 self.base = self.instance.config.TRACKER_WEB
134 # this is the "cookie path" for this tracker (ie. the path part of
135 # the "base" url)
136 self.cookie_path = urlparse.urlparse(self.base)[2]
137 self.cookie_name = 'roundup_session_' + re.sub('[^a-zA-Z]', '',
138 self.instance.config.TRACKER_NAME)
140 # see if we need to re-parse the environment for the form (eg Zope)
141 if form is None:
142 self.form = cgi.FieldStorage(environ=env)
143 else:
144 self.form = form
146 # turn debugging on/off
147 try:
148 self.debug = int(env.get("ROUNDUP_DEBUG", 0))
149 except ValueError:
150 # someone gave us a non-int debug level, turn it off
151 self.debug = 0
153 # flag to indicate that the HTTP headers have been sent
154 self.headers_done = 0
156 # additional headers to send with the request - must be registered
157 # before the first write
158 self.additional_headers = {}
159 self.response_code = 200
162 def main(self):
163 ''' Wrap the real main in a try/finally so we always close off the db.
164 '''
165 try:
166 self.inner_main()
167 finally:
168 if hasattr(self, 'db'):
169 self.db.close()
171 def inner_main(self):
172 ''' Process a request.
174 The most common requests are handled like so:
175 1. figure out who we are, defaulting to the "anonymous" user
176 see determine_user
177 2. figure out what the request is for - the context
178 see determine_context
179 3. handle any requested action (item edit, search, ...)
180 see handle_action
181 4. render a template, resulting in HTML output
183 In some situations, exceptions occur:
184 - HTTP Redirect (generally raised by an action)
185 - SendFile (generally raised by determine_context)
186 serve up a FileClass "content" property
187 - SendStaticFile (generally raised by determine_context)
188 serve up a file from the tracker "html" directory
189 - Unauthorised (generally raised by an action)
190 the action is cancelled, the request is rendered and an error
191 message is displayed indicating that permission was not
192 granted for the action to take place
193 - NotFound (raised wherever it needs to be)
194 percolates up to the CGI interface that called the client
195 '''
196 self.ok_message = []
197 self.error_message = []
198 try:
199 # make sure we're identified (even anonymously)
200 self.determine_user()
201 # figure out the context and desired content template
202 self.determine_context()
203 # possibly handle a form submit action (may change self.classname
204 # and self.template, and may also append error/ok_messages)
205 self.handle_action()
206 # now render the page
208 # we don't want clients caching our dynamic pages
209 self.additional_headers['Cache-Control'] = 'no-cache'
210 self.additional_headers['Pragma'] = 'no-cache'
211 self.additional_headers['Expires'] = 'Thu, 1 Jan 1970 00:00:00 GMT'
213 # render the content
214 self.write(self.renderContext())
215 except Redirect, url:
216 # let's redirect - if the url isn't None, then we need to do
217 # the headers, otherwise the headers have been set before the
218 # exception was raised
219 if url:
220 self.additional_headers['Location'] = url
221 self.response_code = 302
222 self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
223 except SendFile, designator:
224 self.serve_file(designator)
225 except SendStaticFile, file:
226 self.serve_static_file(str(file))
227 except Unauthorised, message:
228 self.classname=None
229 self.template=''
230 self.error_message.append(message)
231 self.write(self.renderContext())
232 except NotFound:
233 # pass through
234 raise
235 except:
236 # everything else
237 self.write(cgitb.html())
239 def clean_sessions(self):
240 '''age sessions, remove when they haven't been used for a week.
241 Do it only once an hour'''
242 sessions = self.db.sessions
243 last_clean = sessions.get('last_clean', 'last_use') or 0
245 week = 60*60*24*7
246 hour = 60*60
247 now = time.time()
248 if now - last_clean > hour:
249 # remove age sessions
250 for sessid in sessions.list():
251 interval = now - sessions.get(sessid, 'last_use')
252 if interval > week:
253 sessions.destroy(sessid)
254 sessions.set('last_clean', last_use=time.time())
256 def determine_user(self):
257 ''' Determine who the user is
258 '''
259 # determine the uid to use
260 self.opendb('admin')
261 # clean age sessions
262 self.clean_sessions()
263 # make sure we have the session Class
264 sessions = self.db.sessions
266 # look up the user session cookie
267 cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
268 user = 'anonymous'
270 # bump the "revision" of the cookie since the format changed
271 if (cookie.has_key(self.cookie_name) and
272 cookie[self.cookie_name].value != 'deleted'):
274 # get the session key from the cookie
275 self.session = cookie[self.cookie_name].value
276 # get the user from the session
277 try:
278 # update the lifetime datestamp
279 sessions.set(self.session, last_use=time.time())
280 sessions.commit()
281 user = sessions.get(self.session, 'user')
282 except KeyError:
283 user = 'anonymous'
285 # sanity check on the user still being valid, getting the userid
286 # at the same time
287 try:
288 self.userid = self.db.user.lookup(user)
289 except (KeyError, TypeError):
290 user = 'anonymous'
292 # make sure the anonymous user is valid if we're using it
293 if user == 'anonymous':
294 self.make_user_anonymous()
295 else:
296 self.user = user
298 # reopen the database as the correct user
299 self.opendb(self.user)
301 def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
302 ''' Determine the context of this page from the URL:
304 The URL path after the instance identifier is examined. The path
305 is generally only one entry long.
307 - if there is no path, then we are in the "home" context.
308 * if the path is "_file", then the additional path entry
309 specifies the filename of a static file we're to serve up
310 from the instance "html" directory. Raises a SendStaticFile
311 exception.
312 - if there is something in the path (eg "issue"), it identifies
313 the tracker class we're to display.
314 - if the path is an item designator (eg "issue123"), then we're
315 to display a specific item.
316 * if the path starts with an item designator and is longer than
317 one entry, then we're assumed to be handling an item of a
318 FileClass, and the extra path information gives the filename
319 that the client is going to label the download with (ie
320 "file123/image.png" is nicer to download than "file123"). This
321 raises a SendFile exception.
323 Both of the "*" types of contexts stop before we bother to
324 determine the template we're going to use. That's because they
325 don't actually use templates.
327 The template used is specified by the :template CGI variable,
328 which defaults to:
330 only classname suplied: "index"
331 full item designator supplied: "item"
333 We set:
334 self.classname - the class to display, can be None
335 self.template - the template to render the current context with
336 self.nodeid - the nodeid of the class we're displaying
337 '''
338 # default the optional variables
339 self.classname = None
340 self.nodeid = None
342 # see if a template or messages are specified
343 template_override = ok_message = error_message = None
344 for key in self.form.keys():
345 if self.FV_TEMPLATE.match(key):
346 template_override = self.form[key].value
347 elif self.FV_OK_MESSAGE.match(key):
348 ok_message = self.form[key].value
349 elif self.FV_ERROR_MESSAGE.match(key):
350 error_message = self.form[key].value
352 # determine the classname and possibly nodeid
353 path = self.path.split('/')
354 if not path or path[0] in ('', 'home', 'index'):
355 if template_override is not None:
356 self.template = template_override
357 else:
358 self.template = ''
359 return
360 elif path[0] == '_file':
361 raise SendStaticFile, os.path.join(*path[1:])
362 else:
363 self.classname = path[0]
364 if len(path) > 1:
365 # send the file identified by the designator in path[0]
366 raise SendFile, path[0]
368 # see if we got a designator
369 m = dre.match(self.classname)
370 if m:
371 self.classname = m.group(1)
372 self.nodeid = m.group(2)
373 if not self.db.getclass(self.classname).hasnode(self.nodeid):
374 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
375 # with a designator, we default to item view
376 self.template = 'item'
377 else:
378 # with only a class, we default to index view
379 self.template = 'index'
381 # make sure the classname is valid
382 try:
383 self.db.getclass(self.classname)
384 except KeyError:
385 raise NotFound, self.classname
387 # see if we have a template override
388 if template_override is not None:
389 self.template = template_override
391 # see if we were passed in a message
392 if ok_message:
393 self.ok_message.append(ok_message)
394 if error_message:
395 self.error_message.append(error_message)
397 def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
398 ''' Serve the file from the content property of the designated item.
399 '''
400 m = dre.match(str(designator))
401 if not m:
402 raise NotFound, str(designator)
403 classname, nodeid = m.group(1), m.group(2)
404 if classname != 'file':
405 raise NotFound, designator
407 # we just want to serve up the file named
408 file = self.db.file
409 self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
410 self.write(file.get(nodeid, 'content'))
412 def serve_static_file(self, file):
413 # we just want to serve up the file named
414 mt = mimetypes.guess_type(str(file))[0]
415 self.additional_headers['Content-Type'] = mt
416 self.write(open(os.path.join(self.instance.config.TEMPLATES,
417 file)).read())
419 def renderContext(self):
420 ''' Return a PageTemplate for the named page
421 '''
422 name = self.classname
423 extension = self.template
424 pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
426 # catch errors so we can handle PT rendering errors more nicely
427 args = {
428 'ok_message': self.ok_message,
429 'error_message': self.error_message
430 }
431 try:
432 # let the template render figure stuff out
433 return pt.render(self, None, None, **args)
434 except NoTemplate, message:
435 return '<strong>%s</strong>'%message
436 except:
437 # everything else
438 return cgitb.pt_html()
440 # these are the actions that are available
441 actions = (
442 ('edit', 'editItemAction'),
443 ('editCSV', 'editCSVAction'),
444 ('new', 'newItemAction'),
445 ('register', 'registerAction'),
446 ('login', 'loginAction'),
447 ('logout', 'logout_action'),
448 ('search', 'searchAction'),
449 ('retire', 'retireAction'),
450 ('show', 'showAction'),
451 )
452 def handle_action(self):
453 ''' Determine whether there should be an _action called.
455 The action is defined by the form variable :action which
456 identifies the method on this object to call. The four basic
457 actions are defined in the "actions" sequence on this class:
458 "edit" -> self.editItemAction
459 "new" -> self.newItemAction
460 "register" -> self.registerAction
461 "login" -> self.loginAction
462 "logout" -> self.logout_action
463 "search" -> self.searchAction
464 "retire" -> self.retireAction
465 '''
466 if not self.form.has_key(':action'):
467 return None
468 try:
469 # get the action, validate it
470 action = self.form[':action'].value
471 for name, method in self.actions:
472 if name == action:
473 break
474 else:
475 raise ValueError, 'No such action "%s"'%action
477 # call the mapped action
478 getattr(self, method)()
479 except Redirect:
480 raise
481 except Unauthorised:
482 raise
483 except:
484 self.db.rollback()
485 s = StringIO.StringIO()
486 traceback.print_exc(None, s)
487 self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
489 def write(self, content):
490 if not self.headers_done:
491 self.header()
492 self.request.wfile.write(content)
494 def header(self, headers=None, response=None):
495 '''Put up the appropriate header.
496 '''
497 if headers is None:
498 headers = {'Content-Type':'text/html'}
499 if response is None:
500 response = self.response_code
502 # update with additional info
503 headers.update(self.additional_headers)
505 if not headers.has_key('Content-Type'):
506 headers['Content-Type'] = 'text/html'
507 self.request.send_response(response)
508 for entry in headers.items():
509 self.request.send_header(*entry)
510 self.request.end_headers()
511 self.headers_done = 1
512 if self.debug:
513 self.headers_sent = headers
515 def set_cookie(self, user):
516 ''' Set up a session cookie for the user and store away the user's
517 login info against the session.
518 '''
519 # TODO generate a much, much stronger session key ;)
520 self.session = binascii.b2a_base64(repr(random.random())).strip()
522 # clean up the base64
523 if self.session[-1] == '=':
524 if self.session[-2] == '=':
525 self.session = self.session[:-2]
526 else:
527 self.session = self.session[:-1]
529 # insert the session in the sessiondb
530 self.db.sessions.set(self.session, user=user, last_use=time.time())
532 # and commit immediately
533 self.db.sessions.commit()
535 # expire us in a long, long time
536 expire = Cookie._getdate(86400*365)
538 # generate the cookie path - make sure it has a trailing '/'
539 self.additional_headers['Set-Cookie'] = \
540 '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
541 expire, self.cookie_path)
543 def make_user_anonymous(self):
544 ''' Make us anonymous
546 This method used to handle non-existence of the 'anonymous'
547 user, but that user is mandatory now.
548 '''
549 self.userid = self.db.user.lookup('anonymous')
550 self.user = 'anonymous'
552 def opendb(self, user):
553 ''' Open the database.
554 '''
555 # open the db if the user has changed
556 if not hasattr(self, 'db') or user != self.db.journaltag:
557 if hasattr(self, 'db'):
558 self.db.close()
559 self.db = self.instance.open(user)
561 #
562 # Actions
563 #
564 def loginAction(self):
565 ''' Attempt to log a user in.
567 Sets up a session for the user which contains the login
568 credentials.
569 '''
570 # we need the username at a minimum
571 if not self.form.has_key('__login_name'):
572 self.error_message.append(_('Username required'))
573 return
575 # get the login info
576 self.user = self.form['__login_name'].value
577 if self.form.has_key('__login_password'):
578 password = self.form['__login_password'].value
579 else:
580 password = ''
582 # make sure the user exists
583 try:
584 self.userid = self.db.user.lookup(self.user)
585 except KeyError:
586 name = self.user
587 self.error_message.append(_('No such user "%(name)s"')%locals())
588 self.make_user_anonymous()
589 return
591 # verify the password
592 if not self.verifyPassword(self.userid, password):
593 self.make_user_anonymous()
594 self.error_message.append(_('Incorrect password'))
595 return
597 # make sure we're allowed to be here
598 if not self.loginPermission():
599 self.make_user_anonymous()
600 self.error_message.append(_("You do not have permission to login"))
601 return
603 # now we're OK, re-open the database for real, using the user
604 self.opendb(self.user)
606 # set the session cookie
607 self.set_cookie(self.user)
609 def verifyPassword(self, userid, password):
610 ''' Verify the password that the user has supplied
611 '''
612 stored = self.db.user.get(self.userid, 'password')
613 if password == stored:
614 return 1
615 if not password and not stored:
616 return 1
617 return 0
619 def loginPermission(self):
620 ''' Determine whether the user has permission to log in.
622 Base behaviour is to check the user has "Web Access".
623 '''
624 if not self.db.security.hasPermission('Web Access', self.userid):
625 return 0
626 return 1
628 def logout_action(self):
629 ''' Make us really anonymous - nuke the cookie too
630 '''
631 # log us out
632 self.make_user_anonymous()
634 # construct the logout cookie
635 now = Cookie._getdate()
636 self.additional_headers['Set-Cookie'] = \
637 '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.cookie_name,
638 now, self.cookie_path)
640 # Let the user know what's going on
641 self.ok_message.append(_('You are logged out'))
643 def registerAction(self):
644 '''Attempt to create a new user based on the contents of the form
645 and then set the cookie.
647 return 1 on successful login
648 '''
649 # create the new user
650 cl = self.db.user
652 # parse the props from the form
653 try:
654 props = self.parsePropsFromForm()
655 except (ValueError, KeyError), message:
656 self.error_message.append(_('Error: ') + str(message))
657 return
659 # make sure we're allowed to register
660 if not self.registerPermission(props):
661 raise Unauthorised, _("You do not have permission to register")
663 # re-open the database as "admin"
664 if self.user != 'admin':
665 self.opendb('admin')
667 # create the new user
668 cl = self.db.user
669 try:
670 props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
671 self.userid = cl.create(**props['user'])
672 self.db.commit()
673 except (ValueError, KeyError), message:
674 self.error_message.append(message)
675 return
677 # log the new user in
678 self.user = cl.get(self.userid, 'username')
679 # re-open the database for real, using the user
680 self.opendb(self.user)
682 # if we have a session, update it
683 if hasattr(self, 'session'):
684 self.db.sessions.set(self.session, user=self.user,
685 last_use=time.time())
686 else:
687 # new session cookie
688 self.set_cookie(self.user)
690 # nice message
691 message = _('You are now registered, welcome!')
693 # redirect to the item's edit page
694 raise Redirect, '%s%s%s?+ok_message=%s'%(
695 self.base, self.classname, self.userid, urllib.quote(message))
697 def registerPermission(self, props):
698 ''' Determine whether the user has permission to register
700 Base behaviour is to check the user has "Web Registration".
701 '''
702 # registration isn't allowed to supply roles
703 if props.has_key('roles'):
704 return 0
705 if self.db.security.hasPermission('Web Registration', self.userid):
706 return 1
707 return 0
709 def editItemAction(self):
710 ''' Perform an edit of an item in the database.
712 See parsePropsFromForm and _editnodes for special variables
713 '''
714 # parse the props from the form
715 if 1:
716 # try:
717 props, links = self.parsePropsFromForm()
718 # except (ValueError, KeyError), message:
719 # self.error_message.append(_('Error: ') + str(message))
720 # return
722 # handle the props
723 if 1:
724 # try:
725 message = self._editnodes(props, links)
726 # except (ValueError, KeyError, IndexError), message:
727 # self.error_message.append(_('Error: ') + str(message))
728 # return
730 # commit now that all the tricky stuff is done
731 self.db.commit()
733 # redirect to the item's edit page
734 raise Redirect, '%s%s%s?@ok_message=%s'%(self.base, self.classname,
735 self.nodeid, urllib.quote(message))
737 def editItemPermission(self, props):
738 ''' Determine whether the user has permission to edit this item.
740 Base behaviour is to check the user can edit this class. If we're
741 editing the "user" class, users are allowed to edit their own
742 details. Unless it's the "roles" property, which requires the
743 special Permission "Web Roles".
744 '''
745 # if this is a user node and the user is editing their own node, then
746 # we're OK
747 has = self.db.security.hasPermission
748 if self.classname == 'user':
749 # reject if someone's trying to edit "roles" and doesn't have the
750 # right permission.
751 if props.has_key('roles') and not has('Web Roles', self.userid,
752 'user'):
753 return 0
754 # if the item being edited is the current user, we're ok
755 if self.nodeid == self.userid:
756 return 1
757 if self.db.security.hasPermission('Edit', self.userid, self.classname):
758 return 1
759 return 0
761 def newItemAction(self):
762 ''' Add a new item to the database.
764 This follows the same form as the editItemAction, with the same
765 special form values.
766 '''
767 # parse the props from the form
768 # XXX reinstate exception handling
769 # try:
770 if 1:
771 props, links = self.parsePropsFromForm()
772 # except (ValueError, KeyError), message:
773 # self.error_message.append(_('Error: ') + str(message))
774 # return
776 # handle the props - edit or create
777 # XXX reinstate exception handling
778 # try:
779 if 1:
780 # create the context here
781 # cn = self.classname
782 # nid = self._createnode(cn, props[(cn, None)])
783 # del props[(cn, None)]
785 # when it hits the None element, it'll set self.nodeid
786 messages = self._editnodes(props, links) #, {(cn, None): nid})
788 # except (ValueError, KeyError, IndexError), message:
789 # # these errors might just be indicative of user dumbness
790 # self.error_message.append(_('Error: ') + str(message))
791 # return
793 # commit now that all the tricky stuff is done
794 self.db.commit()
796 # redirect to the new item's page
797 raise Redirect, '%s%s%s?@ok_message=%s'%(self.base, self.classname,
798 self.nodeid, urllib.quote(messages))
800 def newItemPermission(self, props):
801 ''' Determine whether the user has permission to create (edit) this
802 item.
804 Base behaviour is to check the user can edit this class. No
805 additional property checks are made. Additionally, new user items
806 may be created if the user has the "Web Registration" Permission.
807 '''
808 has = self.db.security.hasPermission
809 if self.classname == 'user' and has('Web Registration', self.userid,
810 'user'):
811 return 1
812 if has('Edit', self.userid, self.classname):
813 return 1
814 return 0
817 #
818 # Utility methods for editing
819 #
820 def _editnodes(self, all_props, all_links, newids=None):
821 ''' Use the props in all_props to perform edit and creation, then
822 use the link specs in all_links to do linking.
823 '''
824 # figure dependencies and re-work links
825 deps = {}
826 links = {}
827 for cn, nodeid, propname, vlist in all_links:
828 for value in vlist:
829 deps.setdefault((cn, nodeid), []).append(value)
830 links.setdefault(value, []).append((cn, nodeid, propname))
832 # figure chained dependencies ordering
833 order = []
834 done = {}
835 # loop detection
836 change = 0
837 while len(all_props) != len(done):
838 for needed in all_props.keys():
839 if done.has_key(needed):
840 continue
841 tlist = deps.get(needed, [])
842 for target in tlist:
843 if not done.has_key(target):
844 break
845 else:
846 done[needed] = 1
847 order.append(needed)
848 change = 1
849 if not change:
850 raise ValueError, 'linking must not loop!'
852 # now, edit / create
853 m = []
854 for needed in order:
855 props = all_props[needed]
856 cn, nodeid = needed
858 if nodeid is not None and int(nodeid) > 0:
859 # make changes to the node
860 props = self._changenode(cn, nodeid, props)
862 # and some nice feedback for the user
863 if props:
864 info = ', '.join(props.keys())
865 m.append('%s %s %s edited ok'%(cn, nodeid, info))
866 else:
867 m.append('%s %s - nothing changed'%(cn, nodeid))
868 else:
869 assert props
871 # make a new node
872 newid = self._createnode(cn, props)
873 if nodeid is None:
874 self.nodeid = newid
875 nodeid = newid
877 # and some nice feedback for the user
878 m.append('%s %s created'%(cn, newid))
880 # fill in new ids in links
881 if links.has_key(needed):
882 for linkcn, linkid, linkprop in links[needed]:
883 props = all_props[(linkcn, linkid)]
884 cl = self.db.classes[linkcn]
885 propdef = cl.getprops()[linkprop]
886 if not props.has_key(linkprop):
887 if linkid is None or linkid.startswith('-'):
888 # linking to a new item
889 if isinstance(propdef, hyperdb.Multilink):
890 props[linkprop] = [newid]
891 else:
892 props[linkprop] = newid
893 else:
894 # linking to an existing item
895 if isinstance(propdef, hyperdb.Multilink):
896 existing = cl.get(linkid, linkprop)[:]
897 existing.append(nodeid)
898 props[linkprop] = existing
899 else:
900 props[linkprop] = newid
902 return '<br>'.join(m)
904 def _changenode(self, cn, nodeid, props):
905 ''' change the node based on the contents of the form
906 '''
907 # check for permission
908 if not self.editItemPermission(props):
909 raise PermissionError, 'You do not have permission to edit %s'%cn
911 # make the changes
912 cl = self.db.classes[cn]
913 return cl.set(nodeid, **props)
915 def _createnode(self, cn, props):
916 ''' create a node based on the contents of the form
917 '''
918 # check for permission
919 if not self.newItemPermission(props):
920 raise PermissionError, 'You do not have permission to create %s'%cn
922 # create the node and return its id
923 cl = self.db.classes[cn]
924 return cl.create(**props)
926 #
927 # More actions
928 #
929 def editCSVAction(self):
930 ''' Performs an edit of all of a class' items in one go.
932 The "rows" CGI var defines the CSV-formatted entries for the
933 class. New nodes are identified by the ID 'X' (or any other
934 non-existent ID) and removed lines are retired.
935 '''
936 # this is per-class only
937 if not self.editCSVPermission():
938 self.error_message.append(
939 _('You do not have permission to edit %s' %self.classname))
941 # get the CSV module
942 try:
943 import csv
944 except ImportError:
945 self.error_message.append(_(
946 'Sorry, you need the csv module to use this function.<br>\n'
947 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
948 return
950 cl = self.db.classes[self.classname]
951 idlessprops = cl.getprops(protected=0).keys()
952 idlessprops.sort()
953 props = ['id'] + idlessprops
955 # do the edit
956 rows = self.form['rows'].value.splitlines()
957 p = csv.parser()
958 found = {}
959 line = 0
960 for row in rows[1:]:
961 line += 1
962 values = p.parse(row)
963 # not a complete row, keep going
964 if not values: continue
966 # skip property names header
967 if values == props:
968 continue
970 # extract the nodeid
971 nodeid, values = values[0], values[1:]
972 found[nodeid] = 1
974 # confirm correct weight
975 if len(idlessprops) != len(values):
976 self.error_message.append(
977 _('Not enough values on line %(line)s')%{'line':line})
978 return
980 # extract the new values
981 d = {}
982 for name, value in zip(idlessprops, values):
983 value = value.strip()
984 # only add the property if it has a value
985 if value:
986 # if it's a multilink, split it
987 if isinstance(cl.properties[name], hyperdb.Multilink):
988 value = value.split(':')
989 d[name] = value
991 # perform the edit
992 if cl.hasnode(nodeid):
993 # edit existing
994 cl.set(nodeid, **d)
995 else:
996 # new node
997 found[cl.create(**d)] = 1
999 # retire the removed entries
1000 for nodeid in cl.list():
1001 if not found.has_key(nodeid):
1002 cl.retire(nodeid)
1004 # all OK
1005 self.db.commit()
1007 self.ok_message.append(_('Items edited OK'))
1009 def editCSVPermission(self):
1010 ''' Determine whether the user has permission to edit this class.
1012 Base behaviour is to check the user can edit this class.
1013 '''
1014 if not self.db.security.hasPermission('Edit', self.userid,
1015 self.classname):
1016 return 0
1017 return 1
1019 def searchAction(self):
1020 ''' Mangle some of the form variables.
1022 Set the form ":filter" variable based on the values of the
1023 filter variables - if they're set to anything other than
1024 "dontcare" then add them to :filter.
1026 Also handle the ":queryname" variable and save off the query to
1027 the user's query list.
1028 '''
1029 # generic edit is per-class only
1030 if not self.searchPermission():
1031 self.error_message.append(
1032 _('You do not have permission to search %s' %self.classname))
1034 # add a faked :filter form variable for each filtering prop
1035 # XXX migrate to new : @ +
1036 props = self.db.classes[self.classname].getprops()
1037 for key in self.form.keys():
1038 if not props.has_key(key): continue
1039 if isinstance(self.form[key], type([])):
1040 # search for at least one entry which is not empty
1041 for minifield in self.form[key]:
1042 if minifield.value:
1043 break
1044 else:
1045 continue
1046 else:
1047 if not self.form[key].value: continue
1048 self.form.value.append(cgi.MiniFieldStorage(':filter', key))
1050 # handle saving the query params
1051 if self.form.has_key(':queryname'):
1052 queryname = self.form[':queryname'].value.strip()
1053 if queryname:
1054 # parse the environment and figure what the query _is_
1055 req = HTMLRequest(self)
1056 url = req.indexargs_href('', {})
1058 # handle editing an existing query
1059 try:
1060 qid = self.db.query.lookup(queryname)
1061 self.db.query.set(qid, klass=self.classname, url=url)
1062 except KeyError:
1063 # create a query
1064 qid = self.db.query.create(name=queryname,
1065 klass=self.classname, url=url)
1067 # and add it to the user's query multilink
1068 queries = self.db.user.get(self.userid, 'queries')
1069 queries.append(qid)
1070 self.db.user.set(self.userid, queries=queries)
1072 # commit the query change to the database
1073 self.db.commit()
1075 def searchPermission(self):
1076 ''' Determine whether the user has permission to search this class.
1078 Base behaviour is to check the user can view this class.
1079 '''
1080 if not self.db.security.hasPermission('View', self.userid,
1081 self.classname):
1082 return 0
1083 return 1
1086 def retireAction(self):
1087 ''' Retire the context item.
1088 '''
1089 # if we want to view the index template now, then unset the nodeid
1090 # context info (a special-case for retire actions on the index page)
1091 nodeid = self.nodeid
1092 if self.template == 'index':
1093 self.nodeid = None
1095 # generic edit is per-class only
1096 if not self.retirePermission():
1097 self.error_message.append(
1098 _('You do not have permission to retire %s' %self.classname))
1099 return
1101 # make sure we don't try to retire admin or anonymous
1102 if self.classname == 'user' and \
1103 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1104 self.error_message.append(
1105 _('You may not retire the admin or anonymous user'))
1106 return
1108 # do the retire
1109 self.db.getclass(self.classname).retire(nodeid)
1110 self.db.commit()
1112 self.ok_message.append(
1113 _('%(classname)s %(itemid)s has been retired')%{
1114 'classname': self.classname.capitalize(), 'itemid': nodeid})
1116 def retirePermission(self):
1117 ''' Determine whether the user has permission to retire this class.
1119 Base behaviour is to check the user can edit this class.
1120 '''
1121 if not self.db.security.hasPermission('Edit', self.userid,
1122 self.classname):
1123 return 0
1124 return 1
1127 def showAction(self):
1128 ''' Show a node
1129 '''
1130 # XXX allow : @ +
1131 t = self.form[':type'].value
1132 n = self.form[':number'].value
1133 url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1134 raise Redirect, url
1136 def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1137 ''' Pull properties out of the form.
1139 In the following, <bracketed> values are variable, ":" may be
1140 one of ":" or "@", and other text "required" is fixed.
1142 Properties are specified as form variables:
1144 <propname>
1145 - property on the current context item
1147 <designator>:<propname>
1148 - property on the indicated item
1150 <classname>-<N>:<propname>
1151 - property on the Nth new item of classname
1153 Once we have determined the "propname", we check to see if it
1154 is one of the special form values:
1156 :required
1157 The named property values must be supplied or a ValueError
1158 will be raised.
1160 :remove:<propname>=id(s)
1161 The ids will be removed from the multilink property.
1163 :add:<propname>=id(s)
1164 The ids will be added to the multilink property.
1166 :link:<propname>=<designator>
1167 Used to add a link to new items created during edit.
1168 These are collected up and returned in all_links. This will
1169 result in an additional linking operation (either Link set or
1170 Multilink append) after the edit/create is done using
1171 all_props in _editnodes. The <propname> on the current item
1172 will be set/appended the id of the newly created item of
1173 class <designator> (where <designator> must be
1174 <classname>-<N>).
1176 Any of the form variables may be prefixed with a classname or
1177 designator.
1179 The return from this method is a dict of
1180 (classname, id): properties
1181 ... this dict _always_ has an entry for the current context,
1182 even if it's empty (ie. a submission for an existing issue that
1183 doesn't result in any changes would return {('issue','123'): {}})
1184 The id may be None, which indicates that an item should be
1185 created.
1187 If a String property's form value is a file upload, then we
1188 try to set additional properties "filename" and "type" (if
1189 they are valid for the class).
1191 Two special form values are supported for backwards
1192 compatibility:
1193 :note - create a message (with content, author and date), link
1194 to the context item. This is ALWAYS desginated "msg-1".
1195 :file - create a file, attach to the current item and any
1196 message created by :note. This is ALWAYS designated
1197 "file-1".
1199 We also check that FileClass items have a "content" property with
1200 actual content, otherwise we remove them from all_props before
1201 returning.
1202 '''
1203 # some very useful variables
1204 db = self.db
1205 form = self.form
1207 if not hasattr(self, 'FV_SPECIAL'):
1208 # generate the regexp for handling special form values
1209 classes = '|'.join(db.classes.keys())
1210 # specials for parsePropsFromForm
1211 # handle the various forms (see unit tests)
1212 self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1213 self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1215 # these indicate the default class / item
1216 default_cn = self.classname
1217 default_cl = self.db.classes[default_cn]
1218 default_nodeid = self.nodeid
1220 # we'll store info about the individual class/item edit in these
1221 all_required = {} # one entry per class/item
1222 all_props = {} # one entry per class/item
1223 all_propdef = {} # note - only one entry per class
1224 all_links = [] # as many as are required
1226 # we should always return something, even empty, for the context
1227 all_props[(default_cn, default_nodeid)] = {}
1229 keys = form.keys()
1230 timezone = db.getUserTimezone()
1232 # sentinels for the :note and :file props
1233 have_note = have_file = 0
1235 # extract the usable form labels from the form
1236 matches = []
1237 for key in keys:
1238 m = self.FV_SPECIAL.match(key)
1239 if m:
1240 matches.append((key, m.groupdict()))
1242 # now handle the matches
1243 for key, d in matches:
1244 if d['classname']:
1245 # we got a designator
1246 cn = d['classname']
1247 cl = self.db.classes[cn]
1248 nodeid = d['id']
1249 propname = d['propname']
1250 elif d['note']:
1251 # the special note field
1252 cn = 'msg'
1253 cl = self.db.classes[cn]
1254 nodeid = '-1'
1255 propname = 'content'
1256 all_links.append((default_cn, default_nodeid, 'messages',
1257 [('msg', '-1')]))
1258 have_note = 1
1259 elif d['file']:
1260 # the special file field
1261 cn = 'file'
1262 cl = self.db.classes[cn]
1263 nodeid = '-1'
1264 propname = 'content'
1265 all_links.append((default_cn, default_nodeid, 'files',
1266 [('file', '-1')]))
1267 have_file = 1
1268 else:
1269 # default
1270 cn = default_cn
1271 cl = default_cl
1272 nodeid = default_nodeid
1273 propname = d['propname']
1275 # the thing this value relates to is...
1276 this = (cn, nodeid)
1278 # get more info about the class, and the current set of
1279 # form props for it
1280 if not all_propdef.has_key(cn):
1281 all_propdef[cn] = cl.getprops()
1282 propdef = all_propdef[cn]
1283 if not all_props.has_key(this):
1284 all_props[this] = {}
1285 props = all_props[this]
1287 # is this a link command?
1288 if d['link']:
1289 value = []
1290 for entry in extractFormList(form[key]):
1291 m = self.FV_DESIGNATOR.match(entry)
1292 if not m:
1293 raise ValueError, \
1294 'link "%s" value "%s" not a designator'%(key, entry)
1295 value.append((m.group(1), m.group(2)))
1297 # make sure the link property is valid
1298 if (not isinstance(propdef, hyperdb.Multilink) and
1299 not isinstance(propdef, hyperdb.Link)):
1300 raise ValueError, '%s %s is not a link or '\
1301 'multilink property'%(cn, propname)
1303 all_links.append((cn, nodeid, propname, value))
1304 continue
1306 # detect the special ":required" variable
1307 if d['required']:
1308 all_required[this] = extractFormList(form[key])
1309 continue
1311 # get the required values list
1312 if not all_required.has_key(this):
1313 all_required[this] = []
1314 required = all_required[this]
1316 # see if we're performing a special multilink action
1317 mlaction = 'set'
1318 if d['remove']:
1319 mlaction = 'remove'
1320 elif d['add']:
1321 mlaction = 'add'
1323 # does the property exist?
1324 if not propdef.has_key(propname):
1325 if mlaction != 'set':
1326 raise ValueError, 'You have submitted a %s action for'\
1327 ' the property "%s" which doesn\'t exist'%(mlaction,
1328 propname)
1329 # the form element is probably just something we don't care
1330 # about - ignore it
1331 continue
1332 proptype = propdef[propname]
1334 # Get the form value. This value may be a MiniFieldStorage or a list
1335 # of MiniFieldStorages.
1336 value = form[key]
1338 # handle unpacking of the MiniFieldStorage / list form value
1339 if isinstance(proptype, hyperdb.Multilink):
1340 value = extractFormList(value)
1341 else:
1342 # multiple values are not OK
1343 if isinstance(value, type([])):
1344 raise ValueError, 'You have submitted more than one value'\
1345 ' for the %s property'%propname
1346 # value might be a file upload...
1347 if not hasattr(value, 'filename') or value.filename is None:
1348 # nope, pull out the value and strip it
1349 value = value.value.strip()
1351 # now that we have the props field, we need a teensy little
1352 # extra bit of help for the old :note field...
1353 if d['note'] and value:
1354 props['author'] = self.db.getuid()
1355 props['date'] = date.Date()
1357 # handle by type now
1358 if isinstance(proptype, hyperdb.Password):
1359 if not value:
1360 # ignore empty password values
1361 continue
1362 for key, d in matches:
1363 if d['confirm'] and d['propname'] == propname:
1364 confirm = form[key]
1365 break
1366 else:
1367 raise ValueError, 'Password and confirmation text do '\
1368 'not match'
1369 if isinstance(confirm, type([])):
1370 raise ValueError, 'You have submitted more than one value'\
1371 ' for the %s property'%propname
1372 if value != confirm.value:
1373 raise ValueError, 'Password and confirmation text do '\
1374 'not match'
1375 value = password.Password(value)
1377 elif isinstance(proptype, hyperdb.Link):
1378 # see if it's the "no selection" choice
1379 if value == '-1' or not value:
1380 # if we're creating, just don't include this property
1381 if not nodeid or nodeid.startswith('-'):
1382 continue
1383 value = None
1384 else:
1385 # handle key values
1386 link = proptype.classname
1387 if not num_re.match(value):
1388 try:
1389 value = db.classes[link].lookup(value)
1390 except KeyError:
1391 raise ValueError, _('property "%(propname)s": '
1392 '%(value)s not a %(classname)s')%{
1393 'propname': propname, 'value': value,
1394 'classname': link}
1395 except TypeError, message:
1396 raise ValueError, _('you may only enter ID values '
1397 'for property "%(propname)s": %(message)s')%{
1398 'propname': propname, 'message': message}
1399 elif isinstance(proptype, hyperdb.Multilink):
1400 # perform link class key value lookup if necessary
1401 link = proptype.classname
1402 link_cl = db.classes[link]
1403 l = []
1404 for entry in value:
1405 if not entry: continue
1406 if not num_re.match(entry):
1407 try:
1408 entry = link_cl.lookup(entry)
1409 except KeyError:
1410 raise ValueError, _('property "%(propname)s": '
1411 '"%(value)s" not an entry of %(classname)s')%{
1412 'propname': propname, 'value': entry,
1413 'classname': link}
1414 except TypeError, message:
1415 raise ValueError, _('you may only enter ID values '
1416 'for property "%(propname)s": %(message)s')%{
1417 'propname': propname, 'message': message}
1418 l.append(entry)
1419 l.sort()
1421 # now use that list of ids to modify the multilink
1422 if mlaction == 'set':
1423 value = l
1424 else:
1425 # we're modifying the list - get the current list of ids
1426 if props.has_key(propname):
1427 existing = props[propname]
1428 elif nodeid and not nodeid.startswith('-'):
1429 existing = cl.get(nodeid, propname, [])
1430 else:
1431 existing = []
1433 # now either remove or add
1434 if mlaction == 'remove':
1435 # remove - handle situation where the id isn't in
1436 # the list
1437 for entry in l:
1438 try:
1439 existing.remove(entry)
1440 except ValueError:
1441 raise ValueError, _('property "%(propname)s": '
1442 '"%(value)s" not currently in list')%{
1443 'propname': propname, 'value': entry}
1444 else:
1445 # add - easy, just don't dupe
1446 for entry in l:
1447 if entry not in existing:
1448 existing.append(entry)
1449 value = existing
1450 value.sort()
1452 elif value == '':
1453 # if we're creating, just don't include this property
1454 if not nodeid or nodeid.startswith('-'):
1455 continue
1456 # other types should be None'd if there's no value
1457 value = None
1458 else:
1459 if isinstance(proptype, hyperdb.String):
1460 if (hasattr(value, 'filename') and
1461 value.filename is not None):
1462 # skip if the upload is empty
1463 if not value.filename:
1464 continue
1465 # this String is actually a _file_
1466 # try to determine the file content-type
1467 filename = value.filename.split('\\')[-1]
1468 if propdef.has_key('name'):
1469 props['name'] = filename
1470 # use this info as the type/filename properties
1471 if propdef.has_key('type'):
1472 props['type'] = mimetypes.guess_type(filename)[0]
1473 if not props['type']:
1474 props['type'] = "application/octet-stream"
1475 # finally, read the content
1476 value = value.value
1477 else:
1478 # normal String fix the CRLF/CR -> LF stuff
1479 value = fixNewlines(value)
1481 elif isinstance(proptype, hyperdb.Date):
1482 value = date.Date(value, offset=timezone)
1483 elif isinstance(proptype, hyperdb.Interval):
1484 value = date.Interval(value)
1485 elif isinstance(proptype, hyperdb.Boolean):
1486 value = value.lower() in ('yes', 'true', 'on', '1')
1487 elif isinstance(proptype, hyperdb.Number):
1488 value = float(value)
1490 # get the old value
1491 if nodeid and not nodeid.startswith('-'):
1492 try:
1493 existing = cl.get(nodeid, propname)
1494 except KeyError:
1495 # this might be a new property for which there is
1496 # no existing value
1497 if not propdef.has_key(propname):
1498 raise
1500 # make sure the existing multilink is sorted
1501 if isinstance(proptype, hyperdb.Multilink):
1502 existing.sort()
1504 # "missing" existing values may not be None
1505 if not existing:
1506 if isinstance(proptype, hyperdb.String) and not existing:
1507 # some backends store "missing" Strings as empty strings
1508 existing = None
1509 elif isinstance(proptype, hyperdb.Number) and not existing:
1510 # some backends store "missing" Numbers as 0 :(
1511 existing = 0
1512 elif isinstance(proptype, hyperdb.Boolean) and not existing:
1513 # likewise Booleans
1514 existing = 0
1516 # if changed, set it
1517 if value != existing:
1518 props[propname] = value
1519 else:
1520 # don't bother setting empty/unset values
1521 if value is None:
1522 continue
1523 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1524 continue
1525 elif isinstance(proptype, hyperdb.String) and value == '':
1526 continue
1528 props[propname] = value
1530 # register this as received if required?
1531 if propname in required and value is not None:
1532 required.remove(propname)
1534 # check to see if we need to specially link a file to the note
1535 if have_note and have_file:
1536 all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1538 # see if all the required properties have been supplied
1539 s = []
1540 for thing, required in all_required.items():
1541 if not required:
1542 continue
1543 if len(required) > 1:
1544 p = 'properties'
1545 else:
1546 p = 'property'
1547 s.append('Required %s %s %s not supplied'%(thing[0], p,
1548 ', '.join(required)))
1549 if s:
1550 raise ValueError, '\n'.join(s)
1552 # check that FileClass entries have a "content" property with
1553 # content, otherwise remove them
1554 for (cn, id), props in all_props.items():
1555 cl = self.db.classes[cn]
1556 if not isinstance(cl, hyperdb.FileClass):
1557 continue
1558 if not props.get('content', ''):
1559 del all_props[(cn, id)]
1561 # clean up the links, removing ones that aren't possible
1562 l = []
1563 for entry in all_links:
1564 (cn, nodeid, propname, destlist) = entry
1565 source = (cn, nodeid)
1566 if not all_props.has_key(source) or not all_props[source]:
1567 # nothing to create - don't try to link
1568 continue
1569 # nothing to create - don't try to link
1570 continue
1571 for dest in destlist[:]:
1572 if not all_props.has_key(dest) or not all_props[dest]:
1573 destlist.remove(dest)
1574 l.append(entry)
1576 return all_props, l
1578 def fixNewlines(text):
1579 ''' Homogenise line endings.
1581 Different web clients send different line ending values, but
1582 other systems (eg. email) don't necessarily handle those line
1583 endings. Our solution is to convert all line endings to LF.
1584 '''
1585 text = text.replace('\r\n', '\n')
1586 return text.replace('\r', '\n')
1588 def extractFormList(value):
1589 ''' Extract a list of values from the form value.
1591 It may be one of:
1592 [MiniFieldStorage, MiniFieldStorage, ...]
1593 MiniFieldStorage('value,value,...')
1594 MiniFieldStorage('value')
1595 '''
1596 # multiple values are OK
1597 if isinstance(value, type([])):
1598 # it's a list of MiniFieldStorages
1599 value = [i.value.strip() for i in value]
1600 else:
1601 # it's a MiniFieldStorage, but may be a comma-separated list
1602 # of values
1603 value = [i.strip() for i in value.value.split(',')]
1605 # filter out the empty bits
1606 return filter(None, value)