1 # $Id: client.py,v 1.89 2003-02-17 06:44:00 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 extra = self._editnodes(props, links, {(cn, None): nid})
786 if extra:
787 extra = '<br>' + extra
789 # now do the rest
790 messages = '%s %s created'%(cn, nid) + extra
791 # except (ValueError, KeyError, IndexError), message:
792 # # these errors might just be indicative of user dumbness
793 # self.error_message.append(_('Error: ') + str(message))
794 # return
796 # commit now that all the tricky stuff is done
797 self.db.commit()
799 # redirect to the new item's page
800 raise Redirect, '%s%s%s?@ok_message=%s'%(self.base, self.classname,
801 nid, urllib.quote(messages))
803 def newItemPermission(self, props):
804 ''' Determine whether the user has permission to create (edit) this
805 item.
807 Base behaviour is to check the user can edit this class. No
808 additional property checks are made. Additionally, new user items
809 may be created if the user has the "Web Registration" Permission.
810 '''
811 has = self.db.security.hasPermission
812 if self.classname == 'user' and has('Web Registration', self.userid,
813 'user'):
814 return 1
815 if has('Edit', self.userid, self.classname):
816 return 1
817 return 0
819 def editCSVAction(self):
820 ''' Performs an edit of all of a class' items in one go.
822 The "rows" CGI var defines the CSV-formatted entries for the
823 class. New nodes are identified by the ID 'X' (or any other
824 non-existent ID) and removed lines are retired.
825 '''
826 # this is per-class only
827 if not self.editCSVPermission():
828 self.error_message.append(
829 _('You do not have permission to edit %s' %self.classname))
831 # get the CSV module
832 try:
833 import csv
834 except ImportError:
835 self.error_message.append(_(
836 'Sorry, you need the csv module to use this function.<br>\n'
837 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
838 return
840 cl = self.db.classes[self.classname]
841 idlessprops = cl.getprops(protected=0).keys()
842 idlessprops.sort()
843 props = ['id'] + idlessprops
845 # do the edit
846 rows = self.form['rows'].value.splitlines()
847 p = csv.parser()
848 found = {}
849 line = 0
850 for row in rows[1:]:
851 line += 1
852 values = p.parse(row)
853 # not a complete row, keep going
854 if not values: continue
856 # skip property names header
857 if values == props:
858 continue
860 # extract the nodeid
861 nodeid, values = values[0], values[1:]
862 found[nodeid] = 1
864 # confirm correct weight
865 if len(idlessprops) != len(values):
866 self.error_message.append(
867 _('Not enough values on line %(line)s')%{'line':line})
868 return
870 # extract the new values
871 d = {}
872 for name, value in zip(idlessprops, values):
873 value = value.strip()
874 # only add the property if it has a value
875 if value:
876 # if it's a multilink, split it
877 if isinstance(cl.properties[name], hyperdb.Multilink):
878 value = value.split(':')
879 d[name] = value
881 # perform the edit
882 if cl.hasnode(nodeid):
883 # edit existing
884 cl.set(nodeid, **d)
885 else:
886 # new node
887 found[cl.create(**d)] = 1
889 # retire the removed entries
890 for nodeid in cl.list():
891 if not found.has_key(nodeid):
892 cl.retire(nodeid)
894 # all OK
895 self.db.commit()
897 self.ok_message.append(_('Items edited OK'))
899 def editCSVPermission(self):
900 ''' Determine whether the user has permission to edit this class.
902 Base behaviour is to check the user can edit this class.
903 '''
904 if not self.db.security.hasPermission('Edit', self.userid,
905 self.classname):
906 return 0
907 return 1
909 def searchAction(self):
910 ''' Mangle some of the form variables.
912 Set the form ":filter" variable based on the values of the
913 filter variables - if they're set to anything other than
914 "dontcare" then add them to :filter.
916 Also handle the ":queryname" variable and save off the query to
917 the user's query list.
918 '''
919 # generic edit is per-class only
920 if not self.searchPermission():
921 self.error_message.append(
922 _('You do not have permission to search %s' %self.classname))
924 # add a faked :filter form variable for each filtering prop
925 # XXX migrate to new : @ +
926 props = self.db.classes[self.classname].getprops()
927 for key in self.form.keys():
928 if not props.has_key(key): continue
929 if isinstance(self.form[key], type([])):
930 # search for at least one entry which is not empty
931 for minifield in self.form[key]:
932 if minifield.value:
933 break
934 else:
935 continue
936 else:
937 if not self.form[key].value: continue
938 self.form.value.append(cgi.MiniFieldStorage(':filter', key))
940 # handle saving the query params
941 if self.form.has_key(':queryname'):
942 queryname = self.form[':queryname'].value.strip()
943 if queryname:
944 # parse the environment and figure what the query _is_
945 req = HTMLRequest(self)
946 url = req.indexargs_href('', {})
948 # handle editing an existing query
949 try:
950 qid = self.db.query.lookup(queryname)
951 self.db.query.set(qid, klass=self.classname, url=url)
952 except KeyError:
953 # create a query
954 qid = self.db.query.create(name=queryname,
955 klass=self.classname, url=url)
957 # and add it to the user's query multilink
958 queries = self.db.user.get(self.userid, 'queries')
959 queries.append(qid)
960 self.db.user.set(self.userid, queries=queries)
962 # commit the query change to the database
963 self.db.commit()
965 def searchPermission(self):
966 ''' Determine whether the user has permission to search this class.
968 Base behaviour is to check the user can view this class.
969 '''
970 if not self.db.security.hasPermission('View', self.userid,
971 self.classname):
972 return 0
973 return 1
976 def retireAction(self):
977 ''' Retire the context item.
978 '''
979 # if we want to view the index template now, then unset the nodeid
980 # context info (a special-case for retire actions on the index page)
981 nodeid = self.nodeid
982 if self.template == 'index':
983 self.nodeid = None
985 # generic edit is per-class only
986 if not self.retirePermission():
987 self.error_message.append(
988 _('You do not have permission to retire %s' %self.classname))
989 return
991 # make sure we don't try to retire admin or anonymous
992 if self.classname == 'user' and \
993 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
994 self.error_message.append(
995 _('You may not retire the admin or anonymous user'))
996 return
998 # do the retire
999 self.db.getclass(self.classname).retire(nodeid)
1000 self.db.commit()
1002 self.ok_message.append(
1003 _('%(classname)s %(itemid)s has been retired')%{
1004 'classname': self.classname.capitalize(), 'itemid': nodeid})
1006 def retirePermission(self):
1007 ''' Determine whether the user has permission to retire this class.
1009 Base behaviour is to check the user can edit this class.
1010 '''
1011 if not self.db.security.hasPermission('Edit', self.userid,
1012 self.classname):
1013 return 0
1014 return 1
1017 def showAction(self):
1018 ''' Show a node
1019 '''
1020 # XXX allow : @ +
1021 t = self.form[':type'].value
1022 n = self.form[':number'].value
1023 url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1024 raise Redirect, url
1027 #
1028 # Utility methods for editing
1029 #
1030 def _editnodes(self, all_props, all_links, newids=None):
1031 ''' Use the props in all_props to perform edit and creation, then
1032 use the link specs in all_links to do linking.
1033 '''
1034 m = []
1035 if newids is None:
1036 newids = {}
1037 for (cn, nodeid), props in all_props.items():
1038 if int(nodeid) > 0:
1039 # make changes to the node
1040 props = self._changenode(cn, nodeid, props)
1042 # and some nice feedback for the user
1043 if props:
1044 info = ', '.join(props.keys())
1045 m.append('%s %s %s edited ok'%(cn, nodeid, info))
1046 else:
1047 m.append('%s %s - nothing changed'%(cn, nodeid))
1048 elif props:
1049 # make a new node
1050 newid = self._createnode(cn, props)
1051 newids[(cn, nodeid)] = newid
1052 nodeid = newid
1054 # and some nice feedback for the user
1055 m.append('%s %s created'%(cn, newid))
1057 # handle linked nodes
1058 keys = self.form.keys()
1059 for cn, nodeid, propname, value in all_links:
1060 cl = self.db.classes[cn]
1061 property = cl.getprops()[propname]
1062 if nodeid is None or nodeid.startswith('-'):
1063 if not newids.has_key((cn, nodeid)):
1064 continue
1065 nodeid = newids[(cn, nodeid)]
1067 # map the desired classnames to their actual created ids
1068 for link in value:
1069 if not newids.has_key(link):
1070 continue
1071 linkid = newids[link]
1072 if isinstance(property, hyperdb.Multilink):
1073 # take a dupe of the list so we're not changing the cache
1074 existing = cl.get(nodeid, propname)[:]
1075 existing.append(linkid)
1076 cl.set(nodeid, **{propname: existing})
1077 elif isinstance(property, hyperdb.Link):
1078 # make the Link set
1079 cl.set(nodeid, **{propname: linkid})
1080 else:
1081 raise ValueError, '%s %s is not a link or multilink '\
1082 'property'%(cn, propname)
1083 m.append('%s %s linked to <a href="%s%s">%s %s</a>'%(
1084 link[0], linkid, cn, nodeid, cn, nodeid))
1086 return '<br>'.join(m)
1088 def _changenode(self, cn, nodeid, props):
1089 ''' change the node based on the contents of the form
1090 '''
1091 # check for permission
1092 if not self.editItemPermission(props):
1093 raise PermissionError, 'You do not have permission to edit %s'%cn
1095 # make the changes
1096 cl = self.db.classes[cn]
1097 return cl.set(nodeid, **props)
1099 def _createnode(self, cn, props):
1100 ''' create a node based on the contents of the form
1101 '''
1102 # check for permission
1103 if not self.newItemPermission(props):
1104 raise PermissionError, 'You do not have permission to create %s'%cn
1106 # create the node and return its id
1107 cl = self.db.classes[cn]
1108 return cl.create(**props)
1110 def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1111 ''' Pull properties out of the form.
1113 In the following, <bracketed> values are variable, ":" may be
1114 one of ":" or "@", and other text "required" is fixed.
1116 Properties are specified as form variables:
1118 <propname>
1119 - property on the current context item
1121 <designator>:<propname>
1122 - property on the indicated item
1124 <classname>-<N>:<propname>
1125 - property on the Nth new item of classname
1127 Once we have determined the "propname", we check to see if it
1128 is one of the special form values:
1130 :required
1131 The named property values must be supplied or a ValueError
1132 will be raised.
1134 :remove:<propname>=id(s)
1135 The ids will be removed from the multilink property.
1137 :add:<propname>=id(s)
1138 The ids will be added to the multilink property.
1140 :link:<propname>=<designator>
1141 Used to add a link to new items created during edit.
1142 These are collected up and returned in all_links. This will
1143 result in an additional linking operation (either Link set or
1144 Multilink append) after the edit/create is done using
1145 all_props in _editnodes. The <propname> on the current item
1146 will be set/appended the id of the newly created item of
1147 class <designator> (where <designator> must be
1148 <classname>-<N>).
1150 Any of the form variables may be prefixed with a classname or
1151 designator.
1153 The return from this method is a dict of
1154 (classname, id): properties
1155 ... this dict _always_ has an entry for the current context,
1156 even if it's empty (ie. a submission for an existing issue that
1157 doesn't result in any changes would return {('issue','123'): {}})
1158 The id may be None, which indicates that an item should be
1159 created.
1161 If a String property's form value is a file upload, then we
1162 try to set additional properties "filename" and "type" (if
1163 they are valid for the class).
1165 Two special form values are supported for backwards
1166 compatibility:
1167 :note - create a message (with content, author and date), link
1168 to the context item. This is ALWAYS desginated "msg-1".
1169 :file - create a file, attach to the current item and any
1170 message created by :note. This is ALWAYS designated
1171 "file-1".
1172 '''
1173 # some very useful variables
1174 db = self.db
1175 form = self.form
1177 if not hasattr(self, 'FV_SPECIAL'):
1178 # generate the regexp for handling special form values
1179 classes = '|'.join(db.classes.keys())
1180 # specials for parsePropsFromForm
1181 # handle the various forms (see unit tests)
1182 self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1183 self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1185 # these indicate the default class / item
1186 default_cn = self.classname
1187 default_cl = self.db.classes[default_cn]
1188 default_nodeid = self.nodeid
1190 # we'll store info about the individual class/item edit in these
1191 all_required = {} # one entry per class/item
1192 all_props = {} # one entry per class/item
1193 all_propdef = {} # note - only one entry per class
1194 all_links = [] # as many as are required
1196 # we should always return something, even empty, for the context
1197 all_props[(default_cn, default_nodeid)] = {}
1199 keys = form.keys()
1200 timezone = db.getUserTimezone()
1202 # sentinels for the :note and :file props
1203 have_note = have_file = 0
1205 # extract the usable form labels from the form
1206 matches = []
1207 for key in keys:
1208 m = self.FV_SPECIAL.match(key)
1209 if m:
1210 matches.append((key, m.groupdict()))
1212 # now handle the matches
1213 for key, d in matches:
1214 if d['classname']:
1215 # we got a designator
1216 cn = d['classname']
1217 cl = self.db.classes[cn]
1218 nodeid = d['id']
1219 propname = d['propname']
1220 elif d['note']:
1221 # the special note field
1222 cn = 'msg'
1223 cl = self.db.classes[cn]
1224 nodeid = '-1'
1225 propname = 'content'
1226 all_links.append((default_cn, default_nodeid, 'messages',
1227 [('msg', '-1')]))
1228 have_note = 1
1229 elif d['file']:
1230 # the special file field
1231 cn = 'file'
1232 cl = self.db.classes[cn]
1233 nodeid = '-1'
1234 propname = 'content'
1235 all_links.append((default_cn, default_nodeid, 'files',
1236 [('file', '-1')]))
1237 have_file = 1
1238 else:
1239 # default
1240 cn = default_cn
1241 cl = default_cl
1242 nodeid = default_nodeid
1243 propname = d['propname']
1245 # the thing this value relates to is...
1246 this = (cn, nodeid)
1248 # get more info about the class, and the current set of
1249 # form props for it
1250 if not all_propdef.has_key(cn):
1251 all_propdef[cn] = cl.getprops()
1252 propdef = all_propdef[cn]
1253 if not all_props.has_key(this):
1254 all_props[this] = {}
1255 props = all_props[this]
1257 # is this a link command?
1258 if d['link']:
1259 value = []
1260 for entry in extractFormList(form[key]):
1261 m = self.FV_DESIGNATOR.match(entry)
1262 if not m:
1263 raise ValueError, \
1264 'link "%s" value "%s" not a designator'%(key, entry)
1265 value.append((m.group(1), m.group(2)))
1266 all_links.append((cn, nodeid, propname, value))
1267 continue
1269 # detect the special ":required" variable
1270 if d['required']:
1271 all_required[this] = extractFormList(form[key])
1272 continue
1274 # get the required values list
1275 if not all_required.has_key(this):
1276 all_required[this] = []
1277 required = all_required[this]
1279 # see if we're performing a special multilink action
1280 mlaction = 'set'
1281 if d['remove']:
1282 mlaction = 'remove'
1283 elif d['add']:
1284 mlaction = 'add'
1286 # does the property exist?
1287 if not propdef.has_key(propname):
1288 if mlaction != 'set':
1289 raise ValueError, 'You have submitted a %s action for'\
1290 ' the property "%s" which doesn\'t exist'%(mlaction,
1291 propname)
1292 # the form element is probably just something we don't care
1293 # about - ignore it
1294 continue
1295 proptype = propdef[propname]
1297 # Get the form value. This value may be a MiniFieldStorage or a list
1298 # of MiniFieldStorages.
1299 value = form[key]
1301 # handle unpacking of the MiniFieldStorage / list form value
1302 if isinstance(proptype, hyperdb.Multilink):
1303 value = extractFormList(value)
1304 else:
1305 # multiple values are not OK
1306 if isinstance(value, type([])):
1307 raise ValueError, 'You have submitted more than one value'\
1308 ' for the %s property'%propname
1309 # value might be a file upload...
1310 if not hasattr(value, 'filename') or value.filename is None:
1311 # nope, pull out the value and strip it
1312 value = value.value.strip()
1314 # now that we have the props field, we need a teensy little
1315 # extra bit of help for the old :note field...
1316 if d['note'] and value:
1317 props['author'] = self.db.getuid()
1318 props['date'] = date.Date()
1320 # handle by type now
1321 if isinstance(proptype, hyperdb.Password):
1322 if not value:
1323 # ignore empty password values
1324 continue
1325 for key, d in matches:
1326 if d['confirm'] and d['propname'] == propname:
1327 confirm = form[key]
1328 break
1329 else:
1330 raise ValueError, 'Password and confirmation text do '\
1331 'not match'
1332 if isinstance(confirm, type([])):
1333 raise ValueError, 'You have submitted more than one value'\
1334 ' for the %s property'%propname
1335 if value != confirm.value:
1336 raise ValueError, 'Password and confirmation text do '\
1337 'not match'
1338 value = password.Password(value)
1340 elif isinstance(proptype, hyperdb.Link):
1341 # see if it's the "no selection" choice
1342 if value == '-1' or not value:
1343 # if we're creating, just don't include this property
1344 if not nodeid or nodeid.startswith('-'):
1345 continue
1346 value = None
1347 else:
1348 # handle key values
1349 link = proptype.classname
1350 if not num_re.match(value):
1351 try:
1352 value = db.classes[link].lookup(value)
1353 except KeyError:
1354 raise ValueError, _('property "%(propname)s": '
1355 '%(value)s not a %(classname)s')%{
1356 'propname': propname, 'value': value,
1357 'classname': link}
1358 except TypeError, message:
1359 raise ValueError, _('you may only enter ID values '
1360 'for property "%(propname)s": %(message)s')%{
1361 'propname': propname, 'message': message}
1362 elif isinstance(proptype, hyperdb.Multilink):
1363 # perform link class key value lookup if necessary
1364 link = proptype.classname
1365 link_cl = db.classes[link]
1366 l = []
1367 for entry in value:
1368 if not entry: continue
1369 if not num_re.match(entry):
1370 try:
1371 entry = link_cl.lookup(entry)
1372 except KeyError:
1373 raise ValueError, _('property "%(propname)s": '
1374 '"%(value)s" not an entry of %(classname)s')%{
1375 'propname': propname, 'value': entry,
1376 'classname': link}
1377 except TypeError, message:
1378 raise ValueError, _('you may only enter ID values '
1379 'for property "%(propname)s": %(message)s')%{
1380 'propname': propname, 'message': message}
1381 l.append(entry)
1382 l.sort()
1384 # now use that list of ids to modify the multilink
1385 if mlaction == 'set':
1386 value = l
1387 else:
1388 # we're modifying the list - get the current list of ids
1389 if props.has_key(propname):
1390 existing = props[propname]
1391 elif nodeid and not nodeid.startswith('-'):
1392 existing = cl.get(nodeid, propname, [])
1393 else:
1394 existing = []
1396 # now either remove or add
1397 if mlaction == 'remove':
1398 # remove - handle situation where the id isn't in
1399 # the list
1400 for entry in l:
1401 try:
1402 existing.remove(entry)
1403 except ValueError:
1404 raise ValueError, _('property "%(propname)s": '
1405 '"%(value)s" not currently in list')%{
1406 'propname': propname, 'value': entry}
1407 else:
1408 # add - easy, just don't dupe
1409 for entry in l:
1410 if entry not in existing:
1411 existing.append(entry)
1412 value = existing
1413 value.sort()
1415 elif value == '':
1416 # if we're creating, just don't include this property
1417 if not nodeid or nodeid.startswith('-'):
1418 continue
1419 # other types should be None'd if there's no value
1420 value = None
1421 else:
1422 if isinstance(proptype, hyperdb.String):
1423 if (hasattr(value, 'filename') and
1424 value.filename is not None):
1425 # skip if the upload is empty
1426 if not value.filename:
1427 continue
1428 # this String is actually a _file_
1429 # try to determine the file content-type
1430 filename = value.filename.split('\\')[-1]
1431 if propdef.has_key('name'):
1432 props['name'] = filename
1433 # use this info as the type/filename properties
1434 if propdef.has_key('type'):
1435 props['type'] = mimetypes.guess_type(filename)[0]
1436 if not props['type']:
1437 props['type'] = "application/octet-stream"
1438 # finally, read the content
1439 value = value.value
1440 else:
1441 # normal String fix the CRLF/CR -> LF stuff
1442 value = fixNewlines(value)
1444 elif isinstance(proptype, hyperdb.Date):
1445 value = date.Date(value, offset=timezone)
1446 elif isinstance(proptype, hyperdb.Interval):
1447 value = date.Interval(value)
1448 elif isinstance(proptype, hyperdb.Boolean):
1449 value = value.lower() in ('yes', 'true', 'on', '1')
1450 elif isinstance(proptype, hyperdb.Number):
1451 value = float(value)
1453 # get the old value
1454 if nodeid and not nodeid.startswith('-'):
1455 try:
1456 existing = cl.get(nodeid, propname)
1457 except KeyError:
1458 # this might be a new property for which there is
1459 # no existing value
1460 if not propdef.has_key(propname):
1461 raise
1463 # make sure the existing multilink is sorted
1464 if isinstance(proptype, hyperdb.Multilink):
1465 existing.sort()
1467 # "missing" existing values may not be None
1468 if not existing:
1469 if isinstance(proptype, hyperdb.String) and not existing:
1470 # some backends store "missing" Strings as empty strings
1471 existing = None
1472 elif isinstance(proptype, hyperdb.Number) and not existing:
1473 # some backends store "missing" Numbers as 0 :(
1474 existing = 0
1475 elif isinstance(proptype, hyperdb.Boolean) and not existing:
1476 # likewise Booleans
1477 existing = 0
1479 # if changed, set it
1480 if value != existing:
1481 props[propname] = value
1482 else:
1483 # don't bother setting empty/unset values
1484 if value is None:
1485 continue
1486 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1487 continue
1488 elif isinstance(proptype, hyperdb.String) and value == '':
1489 continue
1491 props[propname] = value
1493 # register this as received if required?
1494 if propname in required and value is not None:
1495 required.remove(propname)
1497 # check to see if we need to specially link a file to the note
1498 if have_note and have_file:
1499 all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1501 # see if all the required properties have been supplied
1502 s = []
1503 for thing, required in all_required.items():
1504 if not required:
1505 continue
1506 if len(required) > 1:
1507 p = 'properties'
1508 else:
1509 p = 'property'
1510 s.append('Required %s %s %s not supplied'%(thing[0], p,
1511 ', '.join(required)))
1512 if s:
1513 raise ValueError, '\n'.join(s)
1515 return all_props, all_links
1517 def fixNewlines(text):
1518 ''' Homogenise line endings.
1520 Different web clients send different line ending values, but
1521 other systems (eg. email) don't necessarily handle those line
1522 endings. Our solution is to convert all line endings to LF.
1523 '''
1524 text = text.replace('\r\n', '\n')
1525 return text.replace('\r', '\n')
1527 def extractFormList(value):
1528 ''' Extract a list of values from the form value.
1530 It may be one of:
1531 [MiniFieldStorage, MiniFieldStorage, ...]
1532 MiniFieldStorage('value,value,...')
1533 MiniFieldStorage('value')
1534 '''
1535 # multiple values are OK
1536 if isinstance(value, type([])):
1537 # it's a list of MiniFieldStorages
1538 value = [i.value.strip() for i in value]
1539 else:
1540 # it's a MiniFieldStorage, but may be a comma-separated list
1541 # of values
1542 value = [i.strip() for i in value.value.split(',')]
1544 # filter out the empty bits
1545 return filter(None, value)