1 # $Id: client.py,v 1.81 2003-02-12 07:14:29 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 several characters from the set:
88 : @ +
90 '''
92 #
93 # special form variables
94 #
95 FV_TEMPLATE = re.compile(r'[@+:]template')
96 FV_OK_MESSAGE = re.compile(r'[@+:]ok_message')
97 FV_ERROR_MESSAGE = re.compile(r'[@+:]error_message')
99 # specials for parsePropsFromForm
100 FV_REQUIRED = re.compile(r'[@+:]required')
101 FV_ADD = re.compile(r'([@+:])add\1')
102 FV_REMOVE = re.compile(r'([@+:])remove\1')
103 FV_CONFIRM = re.compile(r'.+[@+:]confirm')
105 # post-edi
106 FV_LINK = re.compile(r'[@+:]link')
107 FV_MULTILINK = re.compile(r'[@+:]multilink')
109 # deprecated
110 FV_NOTE = re.compile(r'[@+:]note')
111 FV_FILE = re.compile(r'[@+:]file')
113 # Note: index page stuff doesn't appear here:
114 # columns, sort, sortdir, filter, group, groupdir, search_text,
115 # pagesize, startwith
117 def __init__(self, instance, request, env, form=None):
118 hyperdb.traceMark()
119 self.instance = instance
120 self.request = request
121 self.env = env
123 # save off the path
124 self.path = env['PATH_INFO']
126 # this is the base URL for this tracker
127 self.base = self.instance.config.TRACKER_WEB
129 # this is the "cookie path" for this tracker (ie. the path part of
130 # the "base" url)
131 self.cookie_path = urlparse.urlparse(self.base)[2]
132 self.cookie_name = 'roundup_session_' + re.sub('[^a-zA-Z]', '',
133 self.instance.config.TRACKER_NAME)
135 # see if we need to re-parse the environment for the form (eg Zope)
136 if form is None:
137 self.form = cgi.FieldStorage(environ=env)
138 else:
139 self.form = form
141 # turn debugging on/off
142 try:
143 self.debug = int(env.get("ROUNDUP_DEBUG", 0))
144 except ValueError:
145 # someone gave us a non-int debug level, turn it off
146 self.debug = 0
148 # flag to indicate that the HTTP headers have been sent
149 self.headers_done = 0
151 # additional headers to send with the request - must be registered
152 # before the first write
153 self.additional_headers = {}
154 self.response_code = 200
157 def main(self):
158 ''' Wrap the real main in a try/finally so we always close off the db.
159 '''
160 try:
161 self.inner_main()
162 finally:
163 if hasattr(self, 'db'):
164 self.db.close()
166 def inner_main(self):
167 ''' Process a request.
169 The most common requests are handled like so:
170 1. figure out who we are, defaulting to the "anonymous" user
171 see determine_user
172 2. figure out what the request is for - the context
173 see determine_context
174 3. handle any requested action (item edit, search, ...)
175 see handle_action
176 4. render a template, resulting in HTML output
178 In some situations, exceptions occur:
179 - HTTP Redirect (generally raised by an action)
180 - SendFile (generally raised by determine_context)
181 serve up a FileClass "content" property
182 - SendStaticFile (generally raised by determine_context)
183 serve up a file from the tracker "html" directory
184 - Unauthorised (generally raised by an action)
185 the action is cancelled, the request is rendered and an error
186 message is displayed indicating that permission was not
187 granted for the action to take place
188 - NotFound (raised wherever it needs to be)
189 percolates up to the CGI interface that called the client
190 '''
191 self.ok_message = []
192 self.error_message = []
193 try:
194 # make sure we're identified (even anonymously)
195 self.determine_user()
196 # figure out the context and desired content template
197 self.determine_context()
198 # possibly handle a form submit action (may change self.classname
199 # and self.template, and may also append error/ok_messages)
200 self.handle_action()
201 # now render the page
203 # we don't want clients caching our dynamic pages
204 self.additional_headers['Cache-Control'] = 'no-cache'
205 self.additional_headers['Pragma'] = 'no-cache'
206 self.additional_headers['Expires'] = 'Thu, 1 Jan 1970 00:00:00 GMT'
208 # render the content
209 self.write(self.renderContext())
210 except Redirect, url:
211 # let's redirect - if the url isn't None, then we need to do
212 # the headers, otherwise the headers have been set before the
213 # exception was raised
214 if url:
215 self.additional_headers['Location'] = url
216 self.response_code = 302
217 self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
218 except SendFile, designator:
219 self.serve_file(designator)
220 except SendStaticFile, file:
221 self.serve_static_file(str(file))
222 except Unauthorised, message:
223 self.classname=None
224 self.template=''
225 self.error_message.append(message)
226 self.write(self.renderContext())
227 except NotFound:
228 # pass through
229 raise
230 except:
231 # everything else
232 self.write(cgitb.html())
234 def clean_sessions(self):
235 '''age sessions, remove when they haven't been used for a week.
236 Do it only once an hour'''
237 sessions = self.db.sessions
238 last_clean = sessions.get('last_clean', 'last_use') or 0
240 week = 60*60*24*7
241 hour = 60*60
242 now = time.time()
243 if now - last_clean > hour:
244 # remove age sessions
245 for sessid in sessions.list():
246 print sessid
247 interval = now - sessions.get(sessid, 'last_use')
248 if interval > week:
249 sessions.destroy(sessid)
250 sessions.set('last_clean', last_use=time.time())
252 def determine_user(self):
253 ''' Determine who the user is
254 '''
255 # determine the uid to use
256 self.opendb('admin')
257 # clean age sessions
258 self.clean_sessions()
259 # make sure we have the session Class
260 sessions = self.db.sessions
262 # look up the user session cookie
263 cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
264 user = 'anonymous'
266 # bump the "revision" of the cookie since the format changed
267 if (cookie.has_key(self.cookie_name) and
268 cookie[self.cookie_name].value != 'deleted'):
270 # get the session key from the cookie
271 self.session = cookie[self.cookie_name].value
272 # get the user from the session
273 try:
274 # update the lifetime datestamp
275 sessions.set(self.session, last_use=time.time())
276 sessions.commit()
277 user = sessions.get(self.session, 'user')
278 except KeyError:
279 user = 'anonymous'
281 # sanity check on the user still being valid, getting the userid
282 # at the same time
283 try:
284 self.userid = self.db.user.lookup(user)
285 except (KeyError, TypeError):
286 user = 'anonymous'
288 # make sure the anonymous user is valid if we're using it
289 if user == 'anonymous':
290 self.make_user_anonymous()
291 else:
292 self.user = user
294 # reopen the database as the correct user
295 self.opendb(self.user)
297 def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
298 ''' Determine the context of this page from the URL:
300 The URL path after the instance identifier is examined. The path
301 is generally only one entry long.
303 - if there is no path, then we are in the "home" context.
304 * if the path is "_file", then the additional path entry
305 specifies the filename of a static file we're to serve up
306 from the instance "html" directory. Raises a SendStaticFile
307 exception.
308 - if there is something in the path (eg "issue"), it identifies
309 the tracker class we're to display.
310 - if the path is an item designator (eg "issue123"), then we're
311 to display a specific item.
312 * if the path starts with an item designator and is longer than
313 one entry, then we're assumed to be handling an item of a
314 FileClass, and the extra path information gives the filename
315 that the client is going to label the download with (ie
316 "file123/image.png" is nicer to download than "file123"). This
317 raises a SendFile exception.
319 Both of the "*" types of contexts stop before we bother to
320 determine the template we're going to use. That's because they
321 don't actually use templates.
323 The template used is specified by the :template CGI variable,
324 which defaults to:
326 only classname suplied: "index"
327 full item designator supplied: "item"
329 We set:
330 self.classname - the class to display, can be None
331 self.template - the template to render the current context with
332 self.nodeid - the nodeid of the class we're displaying
333 '''
334 # default the optional variables
335 self.classname = None
336 self.nodeid = None
338 # see if a template or messages are specified
339 template_override = ok_message = error_message = None
340 for key in self.form.keys():
341 if self.FV_TEMPLATE.match(key):
342 template_override = self.form[key].value
343 elif self.FV_OK_MESSAGE.match(key):
344 ok_message = self.form[key].value
345 elif self.FV_ERROR_MESSAGE.match(key):
346 error_message = self.form[key].value
348 # determine the classname and possibly nodeid
349 path = self.path.split('/')
350 if not path or path[0] in ('', 'home', 'index'):
351 if template_override is not None:
352 self.template = template_override
353 else:
354 self.template = ''
355 return
356 elif path[0] == '_file':
357 raise SendStaticFile, path[1]
358 else:
359 self.classname = path[0]
360 if len(path) > 1:
361 # send the file identified by the designator in path[0]
362 raise SendFile, path[0]
364 # see if we got a designator
365 m = dre.match(self.classname)
366 if m:
367 self.classname = m.group(1)
368 self.nodeid = m.group(2)
369 if not self.db.getclass(self.classname).hasnode(self.nodeid):
370 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
371 # with a designator, we default to item view
372 self.template = 'item'
373 else:
374 # with only a class, we default to index view
375 self.template = 'index'
377 # make sure the classname is valid
378 try:
379 self.db.getclass(self.classname)
380 except KeyError:
381 raise NotFound, self.classname
383 # see if we have a template override
384 if template_override is not None:
385 self.template = template_override
387 # see if we were passed in a message
388 if ok_message:
389 self.ok_message.append(ok_message)
390 if error_message:
391 self.error_message.append(error_message)
393 def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
394 ''' Serve the file from the content property of the designated item.
395 '''
396 m = dre.match(str(designator))
397 if not m:
398 raise NotFound, str(designator)
399 classname, nodeid = m.group(1), m.group(2)
400 if classname != 'file':
401 raise NotFound, designator
403 # we just want to serve up the file named
404 file = self.db.file
405 self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
406 self.write(file.get(nodeid, 'content'))
408 def serve_static_file(self, file):
409 # we just want to serve up the file named
410 mt = mimetypes.guess_type(str(file))[0]
411 self.additional_headers['Content-Type'] = mt
412 self.write(open(os.path.join(self.instance.config.TEMPLATES,
413 file)).read())
415 def renderContext(self):
416 ''' Return a PageTemplate for the named page
417 '''
418 name = self.classname
419 extension = self.template
420 pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
422 # catch errors so we can handle PT rendering errors more nicely
423 args = {
424 'ok_message': self.ok_message,
425 'error_message': self.error_message
426 }
427 try:
428 # let the template render figure stuff out
429 return pt.render(self, None, None, **args)
430 except NoTemplate, message:
431 return '<strong>%s</strong>'%message
432 except:
433 # everything else
434 return cgitb.pt_html()
436 # these are the actions that are available
437 actions = (
438 ('edit', 'editItemAction'),
439 ('editCSV', 'editCSVAction'),
440 ('new', 'newItemAction'),
441 ('register', 'registerAction'),
442 ('login', 'loginAction'),
443 ('logout', 'logout_action'),
444 ('search', 'searchAction'),
445 ('retire', 'retireAction'),
446 ('show', 'showAction'),
447 )
448 def handle_action(self):
449 ''' Determine whether there should be an _action called.
451 The action is defined by the form variable :action which
452 identifies the method on this object to call. The four basic
453 actions are defined in the "actions" sequence on this class:
454 "edit" -> self.editItemAction
455 "new" -> self.newItemAction
456 "register" -> self.registerAction
457 "login" -> self.loginAction
458 "logout" -> self.logout_action
459 "search" -> self.searchAction
460 "retire" -> self.retireAction
461 '''
462 if not self.form.has_key(':action'):
463 return None
464 try:
465 # get the action, validate it
466 action = self.form[':action'].value
467 for name, method in self.actions:
468 if name == action:
469 break
470 else:
471 raise ValueError, 'No such action "%s"'%action
473 # call the mapped action
474 getattr(self, method)()
475 except Redirect:
476 raise
477 except Unauthorised:
478 raise
479 except:
480 self.db.rollback()
481 s = StringIO.StringIO()
482 traceback.print_exc(None, s)
483 self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
485 def write(self, content):
486 if not self.headers_done:
487 self.header()
488 self.request.wfile.write(content)
490 def header(self, headers=None, response=None):
491 '''Put up the appropriate header.
492 '''
493 if headers is None:
494 headers = {'Content-Type':'text/html'}
495 if response is None:
496 response = self.response_code
498 # update with additional info
499 headers.update(self.additional_headers)
501 if not headers.has_key('Content-Type'):
502 headers['Content-Type'] = 'text/html'
503 self.request.send_response(response)
504 for entry in headers.items():
505 self.request.send_header(*entry)
506 self.request.end_headers()
507 self.headers_done = 1
508 if self.debug:
509 self.headers_sent = headers
511 def set_cookie(self, user):
512 ''' Set up a session cookie for the user and store away the user's
513 login info against the session.
514 '''
515 # TODO generate a much, much stronger session key ;)
516 self.session = binascii.b2a_base64(repr(random.random())).strip()
518 # clean up the base64
519 if self.session[-1] == '=':
520 if self.session[-2] == '=':
521 self.session = self.session[:-2]
522 else:
523 self.session = self.session[:-1]
525 # insert the session in the sessiondb
526 self.db.sessions.set(self.session, user=user, last_use=time.time())
528 # and commit immediately
529 self.db.sessions.commit()
531 # expire us in a long, long time
532 expire = Cookie._getdate(86400*365)
534 # generate the cookie path - make sure it has a trailing '/'
535 self.additional_headers['Set-Cookie'] = \
536 '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
537 expire, self.cookie_path)
539 def make_user_anonymous(self):
540 ''' Make us anonymous
542 This method used to handle non-existence of the 'anonymous'
543 user, but that user is mandatory now.
544 '''
545 self.userid = self.db.user.lookup('anonymous')
546 self.user = 'anonymous'
548 def opendb(self, user):
549 ''' Open the database.
550 '''
551 # open the db if the user has changed
552 if not hasattr(self, 'db') or user != self.db.journaltag:
553 if hasattr(self, 'db'):
554 self.db.close()
555 self.db = self.instance.open(user)
557 #
558 # Actions
559 #
560 def loginAction(self):
561 ''' Attempt to log a user in.
563 Sets up a session for the user which contains the login
564 credentials.
565 '''
566 # we need the username at a minimum
567 if not self.form.has_key('__login_name'):
568 self.error_message.append(_('Username required'))
569 return
571 # get the login info
572 self.user = self.form['__login_name'].value
573 if self.form.has_key('__login_password'):
574 password = self.form['__login_password'].value
575 else:
576 password = ''
578 # make sure the user exists
579 try:
580 self.userid = self.db.user.lookup(self.user)
581 except KeyError:
582 name = self.user
583 self.error_message.append(_('No such user "%(name)s"')%locals())
584 self.make_user_anonymous()
585 return
587 # verify the password
588 if not self.verifyPassword(self.userid, password):
589 self.make_user_anonymous()
590 self.error_message.append(_('Incorrect password'))
591 return
593 # make sure we're allowed to be here
594 if not self.loginPermission():
595 self.make_user_anonymous()
596 self.error_message.append(_("You do not have permission to login"))
597 return
599 # now we're OK, re-open the database for real, using the user
600 self.opendb(self.user)
602 # set the session cookie
603 self.set_cookie(self.user)
605 def verifyPassword(self, userid, password):
606 ''' Verify the password that the user has supplied
607 '''
608 stored = self.db.user.get(self.userid, 'password')
609 if password == stored:
610 return 1
611 if not password and not stored:
612 return 1
613 return 0
615 def loginPermission(self):
616 ''' Determine whether the user has permission to log in.
618 Base behaviour is to check the user has "Web Access".
619 '''
620 if not self.db.security.hasPermission('Web Access', self.userid):
621 return 0
622 return 1
624 def logout_action(self):
625 ''' Make us really anonymous - nuke the cookie too
626 '''
627 # log us out
628 self.make_user_anonymous()
630 # construct the logout cookie
631 now = Cookie._getdate()
632 self.additional_headers['Set-Cookie'] = \
633 '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.cookie_name,
634 now, self.cookie_path)
636 # Let the user know what's going on
637 self.ok_message.append(_('You are logged out'))
639 def registerAction(self):
640 '''Attempt to create a new user based on the contents of the form
641 and then set the cookie.
643 return 1 on successful login
644 '''
645 # create the new user
646 cl = self.db.user
648 # parse the props from the form
649 try:
650 props = self.parsePropsFromForm()
651 except (ValueError, KeyError), message:
652 self.error_message.append(_('Error: ') + str(message))
653 return
655 # make sure we're allowed to register
656 if not self.registerPermission(props):
657 raise Unauthorised, _("You do not have permission to register")
659 # re-open the database as "admin"
660 if self.user != 'admin':
661 self.opendb('admin')
663 # create the new user
664 cl = self.db.user
665 try:
666 props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
667 self.userid = cl.create(**props['user'])
668 self.db.commit()
669 except (ValueError, KeyError), message:
670 self.error_message.append(message)
671 return
673 # log the new user in
674 self.user = cl.get(self.userid, 'username')
675 # re-open the database for real, using the user
676 self.opendb(self.user)
678 # if we have a session, update it
679 if hasattr(self, 'session'):
680 self.db.sessions.set(self.session, user=self.user,
681 last_use=time.time())
682 else:
683 # new session cookie
684 self.set_cookie(self.user)
686 # nice message
687 message = _('You are now registered, welcome!')
689 # redirect to the item's edit page
690 raise Redirect, '%s%s%s?+ok_message=%s'%(
691 self.base, self.classname, self.userid, urllib.quote(message))
693 def registerPermission(self, props):
694 ''' Determine whether the user has permission to register
696 Base behaviour is to check the user has "Web Registration".
697 '''
698 # registration isn't allowed to supply roles
699 if props.has_key('roles'):
700 return 0
701 if self.db.security.hasPermission('Web Registration', self.userid):
702 return 1
703 return 0
705 def editItemAction(self):
706 ''' Perform an edit of an item in the database.
708 Some special form elements:
710 :link=designator:property
711 :multilink=designator:property
712 The value specifies a node designator and the property on that
713 node to add _this_ node to as a link or multilink.
714 :note
715 Create a message and attach it to the current node's
716 "messages" property.
717 :file
718 Create a file and attach it to the current node's
719 "files" property. Attach the file to the message created from
720 the :note if it's supplied.
722 See parsePropsFromForm for more special variables
723 '''
724 # parse the props from the form
725 try:
726 props = self.parsePropsFromForm()
727 except (ValueError, KeyError), message:
728 self.error_message.append(_('Error: ') + str(message))
729 return
731 # check permission
732 if not self.editItemPermission(props):
733 self.error_message.append(
734 _('You do not have permission to edit %(classname)s'%
735 self.__dict__))
736 return
738 # identify the entry in the props parsed from the form
739 this = self.classname + self.nodeid
741 # perform the edit
742 try:
743 # make changes to the node
744 props = self._changenode(props[this])
745 # handle linked nodes
746 self._post_editnode(self.nodeid)
747 except (ValueError, KeyError, IndexError), message:
748 self.error_message.append(_('Error: ') + str(message))
749 return
751 # commit now that all the tricky stuff is done
752 self.db.commit()
754 # and some nice feedback for the user
755 if props:
756 message = _('%(changes)s edited ok')%{'changes':
757 ', '.join(props.keys())}
758 else:
759 message = _('nothing changed')
761 # redirect to the item's edit page
762 raise Redirect, '%s%s%s?+ok_message=%s'%(self.base, self.classname,
763 self.nodeid, urllib.quote(message))
765 def editItemPermission(self, props):
766 ''' Determine whether the user has permission to edit this item.
768 Base behaviour is to check the user can edit this class. If we're
769 editing the "user" class, users are allowed to edit their own
770 details. Unless it's the "roles" property, which requires the
771 special Permission "Web Roles".
772 '''
773 # if this is a user node and the user is editing their own node, then
774 # we're OK
775 has = self.db.security.hasPermission
776 if self.classname == 'user':
777 # reject if someone's trying to edit "roles" and doesn't have the
778 # right permission.
779 if props.has_key('roles') and not has('Web Roles', self.userid,
780 'user'):
781 return 0
782 # if the item being edited is the current user, we're ok
783 if self.nodeid == self.userid:
784 return 1
785 if self.db.security.hasPermission('Edit', self.userid, self.classname):
786 return 1
787 return 0
789 def newItemAction(self):
790 ''' Add a new item to the database.
792 This follows the same form as the editItemAction, with the same
793 special form values.
794 '''
795 # parse the props from the form
796 try:
797 props = self.parsePropsFromForm()
798 except (ValueError, KeyError), message:
799 self.error_message.append(_('Error: ') + str(message))
800 return
802 if not self.newItemPermission(props):
803 self.error_message.append(
804 _('You do not have permission to create %s' %self.classname))
806 # create a little extra message for anticipated :link / :multilink
807 if self.form.has_key(':multilink'):
808 link = self.form[':multilink'].value
809 elif self.form.has_key(':link'):
810 link = self.form[':multilink'].value
811 else:
812 link = None
813 xtra = ''
814 if link:
815 designator, linkprop = link.split(':')
816 xtra = ' for <a href="%s">%s</a>'%(designator, designator)
818 try:
819 # do the create
820 nid = self._createnode(props[self.classname])
821 except (ValueError, KeyError, IndexError), message:
822 # these errors might just be indicative of user dumbness
823 self.error_message.append(_('Error: ') + str(message))
824 return
825 except:
826 # oops
827 self.db.rollback()
828 s = StringIO.StringIO()
829 traceback.print_exc(None, s)
830 self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
831 return
833 try:
834 # handle linked nodes
835 self._post_editnode(nid)
837 # commit now that all the tricky stuff is done
838 self.db.commit()
840 # render the newly created item
841 self.nodeid = nid
843 # and some nice feedback for the user
844 message = _('%(classname)s created ok')%self.__dict__ + xtra
845 except:
846 # oops
847 self.db.rollback()
848 s = StringIO.StringIO()
849 traceback.print_exc(None, s)
850 self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
851 return
853 # redirect to the new item's page
854 raise Redirect, '%s%s%s?:ok_message=%s'%(self.base, self.classname,
855 nid, urllib.quote(message))
857 def newItemPermission(self, props):
858 ''' Determine whether the user has permission to create (edit) this
859 item.
861 Base behaviour is to check the user can edit this class. No
862 additional property checks are made. Additionally, new user items
863 may be created if the user has the "Web Registration" Permission.
864 '''
865 has = self.db.security.hasPermission
866 if self.classname == 'user' and has('Web Registration', self.userid,
867 'user'):
868 return 1
869 if has('Edit', self.userid, self.classname):
870 return 1
871 return 0
873 def editCSVAction(self):
874 ''' Performs an edit of all of a class' items in one go.
876 The "rows" CGI var defines the CSV-formatted entries for the
877 class. New nodes are identified by the ID 'X' (or any other
878 non-existent ID) and removed lines are retired.
879 '''
880 # this is per-class only
881 if not self.editCSVPermission():
882 self.error_message.append(
883 _('You do not have permission to edit %s' %self.classname))
885 # get the CSV module
886 try:
887 import csv
888 except ImportError:
889 self.error_message.append(_(
890 'Sorry, you need the csv module to use this function.<br>\n'
891 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
892 return
894 cl = self.db.classes[self.classname]
895 idlessprops = cl.getprops(protected=0).keys()
896 idlessprops.sort()
897 props = ['id'] + idlessprops
899 # do the edit
900 rows = self.form['rows'].value.splitlines()
901 p = csv.parser()
902 found = {}
903 line = 0
904 for row in rows[1:]:
905 line += 1
906 values = p.parse(row)
907 # not a complete row, keep going
908 if not values: continue
910 # skip property names header
911 if values == props:
912 continue
914 # extract the nodeid
915 nodeid, values = values[0], values[1:]
916 found[nodeid] = 1
918 # confirm correct weight
919 if len(idlessprops) != len(values):
920 self.error_message.append(
921 _('Not enough values on line %(line)s')%{'line':line})
922 return
924 # extract the new values
925 d = {}
926 for name, value in zip(idlessprops, values):
927 value = value.strip()
928 # only add the property if it has a value
929 if value:
930 # if it's a multilink, split it
931 if isinstance(cl.properties[name], hyperdb.Multilink):
932 value = value.split(':')
933 d[name] = value
935 # perform the edit
936 if cl.hasnode(nodeid):
937 # edit existing
938 cl.set(nodeid, **d)
939 else:
940 # new node
941 found[cl.create(**d)] = 1
943 # retire the removed entries
944 for nodeid in cl.list():
945 if not found.has_key(nodeid):
946 cl.retire(nodeid)
948 # all OK
949 self.db.commit()
951 self.ok_message.append(_('Items edited OK'))
953 def editCSVPermission(self):
954 ''' Determine whether the user has permission to edit this class.
956 Base behaviour is to check the user can edit this class.
957 '''
958 if not self.db.security.hasPermission('Edit', self.userid,
959 self.classname):
960 return 0
961 return 1
963 def searchAction(self):
964 ''' Mangle some of the form variables.
966 Set the form ":filter" variable based on the values of the
967 filter variables - if they're set to anything other than
968 "dontcare" then add them to :filter.
970 Also handle the ":queryname" variable and save off the query to
971 the user's query list.
972 '''
973 # generic edit is per-class only
974 if not self.searchPermission():
975 self.error_message.append(
976 _('You do not have permission to search %s' %self.classname))
978 # add a faked :filter form variable for each filtering prop
979 props = self.db.classes[self.classname].getprops()
980 for key in self.form.keys():
981 if not props.has_key(key): continue
982 if isinstance(self.form[key], type([])):
983 # search for at least one entry which is not empty
984 for minifield in self.form[key]:
985 if minifield.value:
986 break
987 else:
988 continue
989 else:
990 if not self.form[key].value: continue
991 self.form.value.append(cgi.MiniFieldStorage(':filter', key))
993 # handle saving the query params
994 if self.form.has_key(':queryname'):
995 queryname = self.form[':queryname'].value.strip()
996 if queryname:
997 # parse the environment and figure what the query _is_
998 req = HTMLRequest(self)
999 url = req.indexargs_href('', {})
1001 # handle editing an existing query
1002 try:
1003 qid = self.db.query.lookup(queryname)
1004 self.db.query.set(qid, klass=self.classname, url=url)
1005 except KeyError:
1006 # create a query
1007 qid = self.db.query.create(name=queryname,
1008 klass=self.classname, url=url)
1010 # and add it to the user's query multilink
1011 queries = self.db.user.get(self.userid, 'queries')
1012 queries.append(qid)
1013 self.db.user.set(self.userid, queries=queries)
1015 # commit the query change to the database
1016 self.db.commit()
1018 def searchPermission(self):
1019 ''' Determine whether the user has permission to search this class.
1021 Base behaviour is to check the user can view this class.
1022 '''
1023 if not self.db.security.hasPermission('View', self.userid,
1024 self.classname):
1025 return 0
1026 return 1
1028 def retireAction(self):
1029 ''' Retire the context item.
1030 '''
1031 # if we want to view the index template now, then unset the nodeid
1032 # context info (a special-case for retire actions on the index page)
1033 nodeid = self.nodeid
1034 if self.template == 'index':
1035 self.nodeid = None
1037 # generic edit is per-class only
1038 if not self.retirePermission():
1039 self.error_message.append(
1040 _('You do not have permission to retire %s' %self.classname))
1041 return
1043 # make sure we don't try to retire admin or anonymous
1044 if self.classname == 'user' and \
1045 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1046 self.error_message.append(
1047 _('You may not retire the admin or anonymous user'))
1048 return
1050 # do the retire
1051 self.db.getclass(self.classname).retire(nodeid)
1052 self.db.commit()
1054 self.ok_message.append(
1055 _('%(classname)s %(itemid)s has been retired')%{
1056 'classname': self.classname.capitalize(), 'itemid': nodeid})
1058 def retirePermission(self):
1059 ''' Determine whether the user has permission to retire this class.
1061 Base behaviour is to check the user can edit this class.
1062 '''
1063 if not self.db.security.hasPermission('Edit', self.userid,
1064 self.classname):
1065 return 0
1066 return 1
1069 def showAction(self):
1070 ''' Show a node
1071 '''
1072 t = self.form[':type'].value
1073 n = self.form[':number'].value
1074 url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1075 raise Redirect, url
1078 #
1079 # Utility methods for editing
1080 #
1081 def _changenode(self, props):
1082 ''' change the node based on the contents of the form
1083 '''
1084 cl = self.db.classes[self.classname]
1086 # create the message
1087 message, files = self._handle_message()
1088 if message:
1089 props['messages'] = cl.get(self.nodeid, 'messages') + [message]
1090 if files:
1091 props['files'] = cl.get(self.nodeid, 'files') + files
1093 # make the changes
1094 return cl.set(self.nodeid, **props)
1096 def _createnode(self, props):
1097 ''' create a node based on the contents of the form
1098 '''
1099 cl = self.db.classes[self.classname]
1101 # check for messages and files
1102 message, files = self._handle_message()
1103 if message:
1104 props['messages'] = [message]
1105 if files:
1106 props['files'] = files
1107 # create the node and return it's id
1108 return cl.create(**props)
1110 def _handle_message(self):
1111 ''' generate an edit message
1112 '''
1113 # handle file attachments
1114 files = []
1115 if self.form.has_key(':file'):
1116 file = self.form[':file']
1118 # if there's a filename, then we create a file
1119 if file.filename:
1120 # see if there are any file properties we should set
1121 file_props={};
1122 if self.form.has_key(':file_fields'):
1123 for field in self.form[':file_fields'].value.split(','):
1124 if self.form.has_key(field):
1125 if field.startswith("file_"):
1126 file_props[field[5:]] = self.form[field].value
1127 else :
1128 file_props[field] = self.form[field].value
1130 # try to determine the file content-type
1131 filename = file.filename.split('\\')[-1]
1132 mime_type = mimetypes.guess_type(filename)[0]
1133 if not mime_type:
1134 mime_type = "application/octet-stream"
1136 # create the new file entry
1137 files.append(self.db.file.create(type=mime_type,
1138 name=filename, content=file.file.read(), **file_props))
1140 # we don't want to do a message if none of the following is true...
1141 cn = self.classname
1142 cl = self.db.classes[self.classname]
1143 props = cl.getprops()
1144 note = None
1145 # in a nutshell, don't do anything if there's no note or there's no
1146 # NOSY
1147 if self.form.has_key(':note'):
1148 # fix the CRLF/CR -> LF stuff
1149 note = fixNewlines(self.form[':note'].value.strip())
1150 if not note:
1151 return None, files
1152 if not props.has_key('messages'):
1153 return None, files
1154 if not isinstance(props['messages'], hyperdb.Multilink):
1155 return None, files
1156 if not props['messages'].classname == 'msg':
1157 return None, files
1158 if not (self.form.has_key('nosy') or note):
1159 return None, files
1161 # handle the note
1162 if '\n' in note:
1163 summary = re.split(r'\n\r?', note)[0]
1164 else:
1165 summary = note
1166 m = ['%s\n'%note]
1168 # handle the messageid
1169 # TODO: handle inreplyto
1170 messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
1171 self.classname, self.instance.config.MAIL_DOMAIN)
1173 # see if there are any message properties we should set
1174 msg_props={};
1175 if self.form.has_key(':msg_fields'):
1176 for field in self.form[':msg_fields'].value.split(','):
1177 if self.form.has_key(field):
1178 if field.startswith("msg_"):
1179 msg_props[field[4:]] = self.form[field].value
1180 else :
1181 msg_props[field] = self.form[field].value
1183 # now create the message, attaching the files
1184 content = '\n'.join(m)
1185 message_id = self.db.msg.create(author=self.userid,
1186 recipients=[], date=date.Date('.'), summary=summary,
1187 content=content, files=files, messageid=messageid, **msg_props)
1189 # update the messages property
1190 return message_id, files
1192 def _post_editnode(self, nid):
1193 '''Do the linking part of the node creation.
1195 If a form element has :link or :multilink appended to it, its
1196 value specifies a node designator and the property on that node
1197 to add _this_ node to as a link or multilink.
1199 This is typically used on, eg. the file upload page to indicated
1200 which issue to link the file to.
1201 '''
1202 cn = self.classname
1203 cl = self.db.classes[cn]
1204 # link if necessary
1205 keys = self.form.keys()
1206 for key in keys:
1207 if key == ':multilink':
1208 value = self.form[key].value
1209 if type(value) != type([]): value = [value]
1210 for value in value:
1211 designator, property = value.split(':')
1212 link, nodeid = hyperdb.splitDesignator(designator)
1213 link = self.db.classes[link]
1214 # take a dupe of the list so we're not changing the cache
1215 value = link.get(nodeid, property)[:]
1216 value.append(nid)
1217 link.set(nodeid, **{property: value})
1218 elif key == ':link':
1219 value = self.form[key].value
1220 if type(value) != type([]): value = [value]
1221 for value in value:
1222 designator, property = value.split(':')
1223 link, nodeid = hyperdb.splitDesignator(designator)
1224 link = self.db.classes[link]
1225 link.set(nodeid, **{property: nid})
1227 def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1228 ''' Pull properties for the given class out of the form.
1230 If a ":required" parameter is supplied, then the names
1231 property values must be supplied or a ValueError will be raised.
1233 Other special form values:
1234 :remove:<propname>=id(s)
1235 The ids will be removed from the multilink property.
1236 :add:<propname>=id(s)
1237 The ids will be added to the multilink property.
1239 Note: the colon may be one of: : @ +
1241 Any of the form variables may be prefixed with a classname or
1242 designator.
1244 The return from this method is a dict of
1245 classname|designator: properties
1246 ... this dict _always_ has an entry for the current context,
1247 even if it's empty (ie. a submission for an existing issue that
1248 doesn't result in any changes would return {'issue123': {}})
1249 '''
1250 # some very useful variables
1251 db = self.db
1252 form = self.form
1254 if not hasattr(self, 'FV_CLASSSPEC'):
1255 # generate the regexp for detecting
1256 # <classname|designator>[@:+]property
1257 classes = '|'.join(db.classes.keys())
1258 self.FV_CLASSSPEC = re.compile(r'(%s)[@+:](.+)$'%classes)
1259 self.FV_ITEMSPEC = re.compile(r'(%s)(\d+)[@+:](.+)$'%classes)
1261 # these indicate the default class / item
1262 default_cn = self.classname
1263 default_cl = self.db.classes[default_cn]
1264 default_nodeid = str(self.nodeid or '')
1266 # we'll store info about the individual class/item edit in these
1267 all_required = {} # one entry per class/item
1268 all_props = {} # one entry per class/item
1269 all_propdef = {} # note - only one entry per class
1271 # we should always return something, even empty, for the context
1272 all_props[default_cn+default_nodeid] = {}
1274 keys = form.keys()
1275 timezone = db.getUserTimezone()
1277 for key in keys:
1278 # see if this value modifies a different class/item to the default
1279 m = self.FV_CLASSSPEC.match(key)
1280 if m:
1281 # we got a classname
1282 cn = m.group(1)
1283 cl = self.db.classes[cn]
1284 nodeid = ''
1285 propname = m.group(2)
1286 else:
1287 m = self.FV_ITEMSPEC.match(key)
1288 if m:
1289 # we got a designator
1290 cn = m.group(1)
1291 cl = self.db.classes[cn]
1292 nodeid = m.group(2)
1293 propname = m.group(3)
1294 else:
1295 # default
1296 cn = default_cn
1297 cl = default_cl
1298 nodeid = default_nodeid
1299 propname = key
1301 # the thing this value relates to is...
1302 this = cn+nodeid
1304 # get more info about the class, and the current set of
1305 # form props for it
1306 if not all_propdef.has_key(cn):
1307 all_propdef[cn] = cl.getprops()
1308 propdef = all_propdef[cn]
1309 if not all_props.has_key(this):
1310 all_props[this] = {}
1311 props = all_props[this]
1313 # detect the special ":required" variable
1314 if self.FV_REQUIRED.match(key):
1315 value = form[key]
1316 if isinstance(value, type([])):
1317 required = [i.value.strip() for i in value]
1318 else:
1319 required = [i.strip() for i in value.value.split(',')]
1320 all_required[this] = required
1321 continue
1323 # get the required values list
1324 if not all_required.has_key(this):
1325 all_required[this] = []
1326 required = all_required[this]
1328 # see if we're performing a special multilink action
1329 mlaction = 'set'
1330 if self.FV_REMOVE.match(propname):
1331 propname = propname[8:]
1332 mlaction = 'remove'
1333 elif self.FV_ADD.match(propname):
1334 propname = propname[5:]
1335 mlaction = 'add'
1337 # does the property exist?
1338 if not propdef.has_key(propname):
1339 if mlaction != 'set':
1340 raise ValueError, 'You have submitted a %s action for'\
1341 ' the property "%s" which doesn\'t exist'%(mlaction,
1342 propname)
1343 continue
1344 proptype = propdef[propname]
1346 # Get the form value. This value may be a MiniFieldStorage or a list
1347 # of MiniFieldStorages.
1348 value = form[key]
1350 # handle unpacking of the MiniFieldStorage / list form value
1351 if isinstance(proptype, hyperdb.Multilink):
1352 # multiple values are OK
1353 if isinstance(value, type([])):
1354 # it's a list of MiniFieldStorages
1355 value = [i.value.strip() for i in value]
1356 else:
1357 # it's a MiniFieldStorage, but may be a comma-separated list
1358 # of values
1359 value = [i.strip() for i in value.value.split(',')]
1361 # filter out the empty bits
1362 value = filter(None, value)
1363 else:
1364 # multiple values are not OK
1365 if isinstance(value, type([])):
1366 raise ValueError, 'You have submitted more than one value'\
1367 ' for the %s property'%propname
1368 # we've got a MiniFieldStorage, so pull out the value and strip
1369 # surrounding whitespace
1370 value = value.value.strip()
1372 # handle by type now
1373 if isinstance(proptype, hyperdb.Password):
1374 if not value:
1375 # ignore empty password values
1376 continue
1377 for key in keys:
1378 if self.FV_CONFIRM.match(key):
1379 confirm = form[key]
1380 break
1381 else:
1382 raise ValueError, 'Password and confirmation text do '\
1383 'not match'
1384 if isinstance(confirm, type([])):
1385 raise ValueError, 'You have submitted more than one value'\
1386 ' for the %s property'%propname
1387 if value != confirm.value:
1388 raise ValueError, 'Password and confirmation text do '\
1389 'not match'
1390 value = password.Password(value)
1392 elif isinstance(proptype, hyperdb.Link):
1393 # see if it's the "no selection" choice
1394 if value == '-1' or not value:
1395 # if we're creating, just don't include this property
1396 if not nodeid:
1397 continue
1398 value = None
1399 else:
1400 # handle key values
1401 link = proptype.classname
1402 if not num_re.match(value):
1403 try:
1404 value = db.classes[link].lookup(value)
1405 except KeyError:
1406 raise ValueError, _('property "%(propname)s": '
1407 '%(value)s not a %(classname)s')%{
1408 'propname': propname, 'value': value,
1409 'classname': link}
1410 except TypeError, message:
1411 raise ValueError, _('you may only enter ID values '
1412 'for property "%(propname)s": %(message)s')%{
1413 'propname': propname, 'message': message}
1414 elif isinstance(proptype, hyperdb.Multilink):
1415 # perform link class key value lookup if necessary
1416 link = proptype.classname
1417 link_cl = db.classes[link]
1418 l = []
1419 for entry in value:
1420 if not entry: continue
1421 if not num_re.match(entry):
1422 try:
1423 entry = link_cl.lookup(entry)
1424 except KeyError:
1425 raise ValueError, _('property "%(propname)s": '
1426 '"%(value)s" not an entry of %(classname)s')%{
1427 'propname': propname, 'value': entry,
1428 'classname': link}
1429 except TypeError, message:
1430 raise ValueError, _('you may only enter ID values '
1431 'for property "%(propname)s": %(message)s')%{
1432 'propname': propname, 'message': message}
1433 l.append(entry)
1434 l.sort()
1436 # now use that list of ids to modify the multilink
1437 if mlaction == 'set':
1438 value = l
1439 else:
1440 # we're modifying the list - get the current list of ids
1441 if props.has_key(propname):
1442 existing = props[propname]
1443 elif nodeid:
1444 existing = cl.get(nodeid, propname, [])
1445 else:
1446 existing = []
1448 # now either remove or add
1449 if mlaction == 'remove':
1450 # remove - handle situation where the id isn't in
1451 # the list
1452 for entry in l:
1453 try:
1454 existing.remove(entry)
1455 except ValueError:
1456 raise ValueError, _('property "%(propname)s": '
1457 '"%(value)s" not currently in list')%{
1458 'propname': propname, 'value': entry}
1459 else:
1460 # add - easy, just don't dupe
1461 for entry in l:
1462 if entry not in existing:
1463 existing.append(entry)
1464 value = existing
1465 value.sort()
1467 # other types should be None'd if there's no value
1468 elif value:
1469 if isinstance(proptype, hyperdb.String):
1470 # fix the CRLF/CR -> LF stuff
1471 value = fixNewlines(value)
1472 elif isinstance(proptype, hyperdb.Date):
1473 value = date.Date(value, offset=timezone)
1474 elif isinstance(proptype, hyperdb.Interval):
1475 value = date.Interval(value)
1476 elif isinstance(proptype, hyperdb.Boolean):
1477 value = value.lower() in ('yes', 'true', 'on', '1')
1478 elif isinstance(proptype, hyperdb.Number):
1479 value = float(value)
1480 else:
1481 # if we're creating, just don't include this property
1482 if not nodeid:
1483 continue
1484 value = None
1486 # get the old value
1487 if nodeid:
1488 try:
1489 existing = cl.get(nodeid, propname)
1490 except KeyError:
1491 # this might be a new property for which there is
1492 # no existing value
1493 if not propdef.has_key(propname):
1494 raise
1496 # make sure the existing multilink is sorted
1497 if isinstance(proptype, hyperdb.Multilink):
1498 existing.sort()
1500 # "missing" existing values may not be None
1501 if not existing:
1502 if isinstance(proptype, hyperdb.String) and not existing:
1503 # some backends store "missing" Strings as empty strings
1504 existing = None
1505 elif isinstance(proptype, hyperdb.Number) and not existing:
1506 # some backends store "missing" Numbers as 0 :(
1507 existing = 0
1508 elif isinstance(proptype, hyperdb.Boolean) and not existing:
1509 # likewise Booleans
1510 existing = 0
1512 # if changed, set it
1513 if value != existing:
1514 props[propname] = value
1515 else:
1516 # don't bother setting empty/unset values
1517 if value is None:
1518 continue
1519 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1520 continue
1521 elif isinstance(proptype, hyperdb.String) and value == '':
1522 continue
1524 props[propname] = value
1526 # register this as received if required?
1527 if propname in required and value is not None:
1528 required.remove(propname)
1530 # see if all the required properties have been supplied
1531 s = []
1532 for thing, required in all_required.items():
1533 if not required:
1534 continue
1535 if len(required) > 1:
1536 p = 'properties'
1537 else:
1538 p = 'property'
1539 s.append('Required %s %s %s not supplied'%(thing, p,
1540 ', '.join(required)))
1541 if s:
1542 raise ValueError, '\n'.join(s)
1544 return all_props
1546 def fixNewlines(text):
1547 ''' Homogenise line endings.
1549 Different web clients send different line ending values, but
1550 other systems (eg. email) don't necessarily handle those line
1551 endings. Our solution is to convert all line endings to LF.
1552 '''
1553 text = text.replace('\r\n', '\n')
1554 return text.replace('\r', '\n')