1 # $Id: client.py,v 1.88 2003-02-17 01:04:31 richard Exp $
3 __doc__ = """
4 WWW request handler (also used in the stand-alone server).
5 """
7 import os, os.path, cgi, StringIO, urlparse, re, traceback, mimetypes, urllib
8 import binascii, Cookie, time, random
10 from roundup import roundupdb, date, hyperdb, password
11 from roundup.i18n import _
13 from roundup.cgi.templating import 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 # specials for parsePropsFromForm
97 FV_REQUIRED = re.compile(r'[@:]required')
98 FV_ADD = re.compile(r'([@:])add\1')
99 FV_REMOVE = re.compile(r'([@:])remove\1')
100 FV_CONFIRM = re.compile(r'.+[@:]confirm')
101 FV_LINK = re.compile(r'([@:])link\1(.+)')
103 # deprecated
104 FV_NOTE = re.compile(r'[@:]note')
105 FV_FILE = re.compile(r'[@:]file')
107 # Note: index page stuff doesn't appear here:
108 # columns, sort, sortdir, filter, group, groupdir, search_text,
109 # pagesize, startwith
111 def __init__(self, instance, request, env, form=None):
112 hyperdb.traceMark()
113 self.instance = instance
114 self.request = request
115 self.env = env
117 # save off the path
118 self.path = env['PATH_INFO']
120 # this is the base URL for this tracker
121 self.base = self.instance.config.TRACKER_WEB
123 # this is the "cookie path" for this tracker (ie. the path part of
124 # the "base" url)
125 self.cookie_path = urlparse.urlparse(self.base)[2]
126 self.cookie_name = 'roundup_session_' + re.sub('[^a-zA-Z]', '',
127 self.instance.config.TRACKER_NAME)
129 # see if we need to re-parse the environment for the form (eg Zope)
130 if form is None:
131 self.form = cgi.FieldStorage(environ=env)
132 else:
133 self.form = form
135 # turn debugging on/off
136 try:
137 self.debug = int(env.get("ROUNDUP_DEBUG", 0))
138 except ValueError:
139 # someone gave us a non-int debug level, turn it off
140 self.debug = 0
142 # flag to indicate that the HTTP headers have been sent
143 self.headers_done = 0
145 # additional headers to send with the request - must be registered
146 # before the first write
147 self.additional_headers = {}
148 self.response_code = 200
151 def main(self):
152 ''' Wrap the real main in a try/finally so we always close off the db.
153 '''
154 try:
155 self.inner_main()
156 finally:
157 if hasattr(self, 'db'):
158 self.db.close()
160 def inner_main(self):
161 ''' Process a request.
163 The most common requests are handled like so:
164 1. figure out who we are, defaulting to the "anonymous" user
165 see determine_user
166 2. figure out what the request is for - the context
167 see determine_context
168 3. handle any requested action (item edit, search, ...)
169 see handle_action
170 4. render a template, resulting in HTML output
172 In some situations, exceptions occur:
173 - HTTP Redirect (generally raised by an action)
174 - SendFile (generally raised by determine_context)
175 serve up a FileClass "content" property
176 - SendStaticFile (generally raised by determine_context)
177 serve up a file from the tracker "html" directory
178 - Unauthorised (generally raised by an action)
179 the action is cancelled, the request is rendered and an error
180 message is displayed indicating that permission was not
181 granted for the action to take place
182 - NotFound (raised wherever it needs to be)
183 percolates up to the CGI interface that called the client
184 '''
185 self.ok_message = []
186 self.error_message = []
187 try:
188 # make sure we're identified (even anonymously)
189 self.determine_user()
190 # figure out the context and desired content template
191 self.determine_context()
192 # possibly handle a form submit action (may change self.classname
193 # and self.template, and may also append error/ok_messages)
194 self.handle_action()
195 # now render the page
197 # we don't want clients caching our dynamic pages
198 self.additional_headers['Cache-Control'] = 'no-cache'
199 self.additional_headers['Pragma'] = 'no-cache'
200 self.additional_headers['Expires'] = 'Thu, 1 Jan 1970 00:00:00 GMT'
202 # render the content
203 self.write(self.renderContext())
204 except Redirect, url:
205 # let's redirect - if the url isn't None, then we need to do
206 # the headers, otherwise the headers have been set before the
207 # exception was raised
208 if url:
209 self.additional_headers['Location'] = url
210 self.response_code = 302
211 self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
212 except SendFile, designator:
213 self.serve_file(designator)
214 except SendStaticFile, file:
215 self.serve_static_file(str(file))
216 except Unauthorised, message:
217 self.classname=None
218 self.template=''
219 self.error_message.append(message)
220 self.write(self.renderContext())
221 except NotFound:
222 # pass through
223 raise
224 except:
225 # everything else
226 self.write(cgitb.html())
228 def clean_sessions(self):
229 '''age sessions, remove when they haven't been used for a week.
230 Do it only once an hour'''
231 sessions = self.db.sessions
232 last_clean = sessions.get('last_clean', 'last_use') or 0
234 week = 60*60*24*7
235 hour = 60*60
236 now = time.time()
237 if now - last_clean > hour:
238 # remove age sessions
239 for sessid in sessions.list():
240 interval = now - sessions.get(sessid, 'last_use')
241 if interval > week:
242 sessions.destroy(sessid)
243 sessions.set('last_clean', last_use=time.time())
245 def determine_user(self):
246 ''' Determine who the user is
247 '''
248 # determine the uid to use
249 self.opendb('admin')
250 # clean age sessions
251 self.clean_sessions()
252 # make sure we have the session Class
253 sessions = self.db.sessions
255 # look up the user session cookie
256 cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
257 user = 'anonymous'
259 # bump the "revision" of the cookie since the format changed
260 if (cookie.has_key(self.cookie_name) and
261 cookie[self.cookie_name].value != 'deleted'):
263 # get the session key from the cookie
264 self.session = cookie[self.cookie_name].value
265 # get the user from the session
266 try:
267 # update the lifetime datestamp
268 sessions.set(self.session, last_use=time.time())
269 sessions.commit()
270 user = sessions.get(self.session, 'user')
271 except KeyError:
272 user = 'anonymous'
274 # sanity check on the user still being valid, getting the userid
275 # at the same time
276 try:
277 self.userid = self.db.user.lookup(user)
278 except (KeyError, TypeError):
279 user = 'anonymous'
281 # make sure the anonymous user is valid if we're using it
282 if user == 'anonymous':
283 self.make_user_anonymous()
284 else:
285 self.user = user
287 # reopen the database as the correct user
288 self.opendb(self.user)
290 def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
291 ''' Determine the context of this page from the URL:
293 The URL path after the instance identifier is examined. The path
294 is generally only one entry long.
296 - if there is no path, then we are in the "home" context.
297 * if the path is "_file", then the additional path entry
298 specifies the filename of a static file we're to serve up
299 from the instance "html" directory. Raises a SendStaticFile
300 exception.
301 - if there is something in the path (eg "issue"), it identifies
302 the tracker class we're to display.
303 - if the path is an item designator (eg "issue123"), then we're
304 to display a specific item.
305 * if the path starts with an item designator and is longer than
306 one entry, then we're assumed to be handling an item of a
307 FileClass, and the extra path information gives the filename
308 that the client is going to label the download with (ie
309 "file123/image.png" is nicer to download than "file123"). This
310 raises a SendFile exception.
312 Both of the "*" types of contexts stop before we bother to
313 determine the template we're going to use. That's because they
314 don't actually use templates.
316 The template used is specified by the :template CGI variable,
317 which defaults to:
319 only classname suplied: "index"
320 full item designator supplied: "item"
322 We set:
323 self.classname - the class to display, can be None
324 self.template - the template to render the current context with
325 self.nodeid - the nodeid of the class we're displaying
326 '''
327 # default the optional variables
328 self.classname = None
329 self.nodeid = None
331 # see if a template or messages are specified
332 template_override = ok_message = error_message = None
333 for key in self.form.keys():
334 if self.FV_TEMPLATE.match(key):
335 template_override = self.form[key].value
336 elif self.FV_OK_MESSAGE.match(key):
337 ok_message = self.form[key].value
338 elif self.FV_ERROR_MESSAGE.match(key):
339 error_message = self.form[key].value
341 # determine the classname and possibly nodeid
342 path = self.path.split('/')
343 if not path or path[0] in ('', 'home', 'index'):
344 if template_override is not None:
345 self.template = template_override
346 else:
347 self.template = ''
348 return
349 elif path[0] == '_file':
350 raise SendStaticFile, os.path.join(*path[1:])
351 else:
352 self.classname = path[0]
353 if len(path) > 1:
354 # send the file identified by the designator in path[0]
355 raise SendFile, path[0]
357 # see if we got a designator
358 m = dre.match(self.classname)
359 if m:
360 self.classname = m.group(1)
361 self.nodeid = m.group(2)
362 if not self.db.getclass(self.classname).hasnode(self.nodeid):
363 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
364 # with a designator, we default to item view
365 self.template = 'item'
366 else:
367 # with only a class, we default to index view
368 self.template = 'index'
370 # make sure the classname is valid
371 try:
372 self.db.getclass(self.classname)
373 except KeyError:
374 raise NotFound, self.classname
376 # see if we have a template override
377 if template_override is not None:
378 self.template = template_override
380 # see if we were passed in a message
381 if ok_message:
382 self.ok_message.append(ok_message)
383 if error_message:
384 self.error_message.append(error_message)
386 def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
387 ''' Serve the file from the content property of the designated item.
388 '''
389 m = dre.match(str(designator))
390 if not m:
391 raise NotFound, str(designator)
392 classname, nodeid = m.group(1), m.group(2)
393 if classname != 'file':
394 raise NotFound, designator
396 # we just want to serve up the file named
397 file = self.db.file
398 self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
399 self.write(file.get(nodeid, 'content'))
401 def serve_static_file(self, file):
402 # we just want to serve up the file named
403 mt = mimetypes.guess_type(str(file))[0]
404 self.additional_headers['Content-Type'] = mt
405 self.write(open(os.path.join(self.instance.config.TEMPLATES,
406 file)).read())
408 def renderContext(self):
409 ''' Return a PageTemplate for the named page
410 '''
411 name = self.classname
412 extension = self.template
413 pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
415 # catch errors so we can handle PT rendering errors more nicely
416 args = {
417 'ok_message': self.ok_message,
418 'error_message': self.error_message
419 }
420 try:
421 # let the template render figure stuff out
422 return pt.render(self, None, None, **args)
423 except NoTemplate, message:
424 return '<strong>%s</strong>'%message
425 except:
426 # everything else
427 return cgitb.pt_html()
429 # these are the actions that are available
430 actions = (
431 ('edit', 'editItemAction'),
432 ('editCSV', 'editCSVAction'),
433 ('new', 'newItemAction'),
434 ('register', 'registerAction'),
435 ('login', 'loginAction'),
436 ('logout', 'logout_action'),
437 ('search', 'searchAction'),
438 ('retire', 'retireAction'),
439 ('show', 'showAction'),
440 )
441 def handle_action(self):
442 ''' Determine whether there should be an _action called.
444 The action is defined by the form variable :action which
445 identifies the method on this object to call. The four basic
446 actions are defined in the "actions" sequence on this class:
447 "edit" -> self.editItemAction
448 "new" -> self.newItemAction
449 "register" -> self.registerAction
450 "login" -> self.loginAction
451 "logout" -> self.logout_action
452 "search" -> self.searchAction
453 "retire" -> self.retireAction
454 '''
455 if not self.form.has_key(':action'):
456 return None
457 try:
458 # get the action, validate it
459 action = self.form[':action'].value
460 for name, method in self.actions:
461 if name == action:
462 break
463 else:
464 raise ValueError, 'No such action "%s"'%action
466 # call the mapped action
467 getattr(self, method)()
468 except Redirect:
469 raise
470 except Unauthorised:
471 raise
472 except:
473 self.db.rollback()
474 s = StringIO.StringIO()
475 traceback.print_exc(None, s)
476 self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
478 def write(self, content):
479 if not self.headers_done:
480 self.header()
481 self.request.wfile.write(content)
483 def header(self, headers=None, response=None):
484 '''Put up the appropriate header.
485 '''
486 if headers is None:
487 headers = {'Content-Type':'text/html'}
488 if response is None:
489 response = self.response_code
491 # update with additional info
492 headers.update(self.additional_headers)
494 if not headers.has_key('Content-Type'):
495 headers['Content-Type'] = 'text/html'
496 self.request.send_response(response)
497 for entry in headers.items():
498 self.request.send_header(*entry)
499 self.request.end_headers()
500 self.headers_done = 1
501 if self.debug:
502 self.headers_sent = headers
504 def set_cookie(self, user):
505 ''' Set up a session cookie for the user and store away the user's
506 login info against the session.
507 '''
508 # TODO generate a much, much stronger session key ;)
509 self.session = binascii.b2a_base64(repr(random.random())).strip()
511 # clean up the base64
512 if self.session[-1] == '=':
513 if self.session[-2] == '=':
514 self.session = self.session[:-2]
515 else:
516 self.session = self.session[:-1]
518 # insert the session in the sessiondb
519 self.db.sessions.set(self.session, user=user, last_use=time.time())
521 # and commit immediately
522 self.db.sessions.commit()
524 # expire us in a long, long time
525 expire = Cookie._getdate(86400*365)
527 # generate the cookie path - make sure it has a trailing '/'
528 self.additional_headers['Set-Cookie'] = \
529 '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
530 expire, self.cookie_path)
532 def make_user_anonymous(self):
533 ''' Make us anonymous
535 This method used to handle non-existence of the 'anonymous'
536 user, but that user is mandatory now.
537 '''
538 self.userid = self.db.user.lookup('anonymous')
539 self.user = 'anonymous'
541 def opendb(self, user):
542 ''' Open the database.
543 '''
544 # open the db if the user has changed
545 if not hasattr(self, 'db') or user != self.db.journaltag:
546 if hasattr(self, 'db'):
547 self.db.close()
548 self.db = self.instance.open(user)
550 #
551 # Actions
552 #
553 def loginAction(self):
554 ''' Attempt to log a user in.
556 Sets up a session for the user which contains the login
557 credentials.
558 '''
559 # we need the username at a minimum
560 if not self.form.has_key('__login_name'):
561 self.error_message.append(_('Username required'))
562 return
564 # get the login info
565 self.user = self.form['__login_name'].value
566 if self.form.has_key('__login_password'):
567 password = self.form['__login_password'].value
568 else:
569 password = ''
571 # make sure the user exists
572 try:
573 self.userid = self.db.user.lookup(self.user)
574 except KeyError:
575 name = self.user
576 self.error_message.append(_('No such user "%(name)s"')%locals())
577 self.make_user_anonymous()
578 return
580 # verify the password
581 if not self.verifyPassword(self.userid, password):
582 self.make_user_anonymous()
583 self.error_message.append(_('Incorrect password'))
584 return
586 # make sure we're allowed to be here
587 if not self.loginPermission():
588 self.make_user_anonymous()
589 self.error_message.append(_("You do not have permission to login"))
590 return
592 # now we're OK, re-open the database for real, using the user
593 self.opendb(self.user)
595 # set the session cookie
596 self.set_cookie(self.user)
598 def verifyPassword(self, userid, password):
599 ''' Verify the password that the user has supplied
600 '''
601 stored = self.db.user.get(self.userid, 'password')
602 if password == stored:
603 return 1
604 if not password and not stored:
605 return 1
606 return 0
608 def loginPermission(self):
609 ''' Determine whether the user has permission to log in.
611 Base behaviour is to check the user has "Web Access".
612 '''
613 if not self.db.security.hasPermission('Web Access', self.userid):
614 return 0
615 return 1
617 def logout_action(self):
618 ''' Make us really anonymous - nuke the cookie too
619 '''
620 # log us out
621 self.make_user_anonymous()
623 # construct the logout cookie
624 now = Cookie._getdate()
625 self.additional_headers['Set-Cookie'] = \
626 '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.cookie_name,
627 now, self.cookie_path)
629 # Let the user know what's going on
630 self.ok_message.append(_('You are logged out'))
632 def registerAction(self):
633 '''Attempt to create a new user based on the contents of the form
634 and then set the cookie.
636 return 1 on successful login
637 '''
638 # create the new user
639 cl = self.db.user
641 # parse the props from the form
642 try:
643 props = self.parsePropsFromForm()
644 except (ValueError, KeyError), message:
645 self.error_message.append(_('Error: ') + str(message))
646 return
648 # make sure we're allowed to register
649 if not self.registerPermission(props):
650 raise Unauthorised, _("You do not have permission to register")
652 # re-open the database as "admin"
653 if self.user != 'admin':
654 self.opendb('admin')
656 # create the new user
657 cl = self.db.user
658 try:
659 props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
660 self.userid = cl.create(**props['user'])
661 self.db.commit()
662 except (ValueError, KeyError), message:
663 self.error_message.append(message)
664 return
666 # log the new user in
667 self.user = cl.get(self.userid, 'username')
668 # re-open the database for real, using the user
669 self.opendb(self.user)
671 # if we have a session, update it
672 if hasattr(self, 'session'):
673 self.db.sessions.set(self.session, user=self.user,
674 last_use=time.time())
675 else:
676 # new session cookie
677 self.set_cookie(self.user)
679 # nice message
680 message = _('You are now registered, welcome!')
682 # redirect to the item's edit page
683 raise Redirect, '%s%s%s?+ok_message=%s'%(
684 self.base, self.classname, self.userid, urllib.quote(message))
686 def registerPermission(self, props):
687 ''' Determine whether the user has permission to register
689 Base behaviour is to check the user has "Web Registration".
690 '''
691 # registration isn't allowed to supply roles
692 if props.has_key('roles'):
693 return 0
694 if self.db.security.hasPermission('Web Registration', self.userid):
695 return 1
696 return 0
698 def editItemAction(self):
699 ''' Perform an edit of an item in the database.
701 See parsePropsFromForm and _editnodes for special variables
702 '''
703 # parse the props from the form
704 if 1:
705 # try:
706 props, links = self.parsePropsFromForm()
707 # except (ValueError, KeyError), message:
708 # self.error_message.append(_('Error: ') + str(message))
709 # return
711 # handle the props
712 if 1:
713 # try:
714 message = self._editnodes(props, links)
715 # except (ValueError, KeyError, IndexError), message:
716 # self.error_message.append(_('Error: ') + str(message))
717 # return
719 # commit now that all the tricky stuff is done
720 self.db.commit()
722 # redirect to the item's edit page
723 raise Redirect, '%s%s%s?@ok_message=%s'%(self.base, self.classname,
724 self.nodeid, urllib.quote(message))
726 def editItemPermission(self, props):
727 ''' Determine whether the user has permission to edit this item.
729 Base behaviour is to check the user can edit this class. If we're
730 editing the "user" class, users are allowed to edit their own
731 details. Unless it's the "roles" property, which requires the
732 special Permission "Web Roles".
733 '''
734 # if this is a user node and the user is editing their own node, then
735 # we're OK
736 has = self.db.security.hasPermission
737 if self.classname == 'user':
738 # reject if someone's trying to edit "roles" and doesn't have the
739 # right permission.
740 if props.has_key('roles') and not has('Web Roles', self.userid,
741 'user'):
742 return 0
743 # if the item being edited is the current user, we're ok
744 if self.nodeid == self.userid:
745 return 1
746 if self.db.security.hasPermission('Edit', self.userid, self.classname):
747 return 1
748 return 0
750 def newItemAction(self):
751 ''' Add a new item to the database.
753 This follows the same form as the editItemAction, with the same
754 special form values.
755 '''
756 # parse the props from the form
757 # XXX reinstate exception handling
758 # try:
759 if 1:
760 props, links = self.parsePropsFromForm()
761 # except (ValueError, KeyError), message:
762 # self.error_message.append(_('Error: ') + str(message))
763 # return
765 # handle the props - edit or create
766 # XXX reinstate exception handling
767 # try:
768 if 1:
769 # create the context here
770 cn = self.classname
771 nid = self._createnode(cn, props[(cn, None)])
772 del props[(cn, None)]
774 extra = self._editnodes(props, links, {(cn, None): nid})
775 if extra:
776 extra = '<br>' + extra
778 # now do the rest
779 messages = '%s %s created'%(cn, nid) + extra
780 # except (ValueError, KeyError, IndexError), message:
781 # # these errors might just be indicative of user dumbness
782 # self.error_message.append(_('Error: ') + str(message))
783 # return
785 # commit now that all the tricky stuff is done
786 self.db.commit()
788 # redirect to the new item's page
789 raise Redirect, '%s%s%s?@ok_message=%s'%(self.base, self.classname,
790 nid, urllib.quote(messages))
792 def newItemPermission(self, props):
793 ''' Determine whether the user has permission to create (edit) this
794 item.
796 Base behaviour is to check the user can edit this class. No
797 additional property checks are made. Additionally, new user items
798 may be created if the user has the "Web Registration" Permission.
799 '''
800 has = self.db.security.hasPermission
801 if self.classname == 'user' and has('Web Registration', self.userid,
802 'user'):
803 return 1
804 if has('Edit', self.userid, self.classname):
805 return 1
806 return 0
808 def editCSVAction(self):
809 ''' Performs an edit of all of a class' items in one go.
811 The "rows" CGI var defines the CSV-formatted entries for the
812 class. New nodes are identified by the ID 'X' (or any other
813 non-existent ID) and removed lines are retired.
814 '''
815 # this is per-class only
816 if not self.editCSVPermission():
817 self.error_message.append(
818 _('You do not have permission to edit %s' %self.classname))
820 # get the CSV module
821 try:
822 import csv
823 except ImportError:
824 self.error_message.append(_(
825 'Sorry, you need the csv module to use this function.<br>\n'
826 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
827 return
829 cl = self.db.classes[self.classname]
830 idlessprops = cl.getprops(protected=0).keys()
831 idlessprops.sort()
832 props = ['id'] + idlessprops
834 # do the edit
835 rows = self.form['rows'].value.splitlines()
836 p = csv.parser()
837 found = {}
838 line = 0
839 for row in rows[1:]:
840 line += 1
841 values = p.parse(row)
842 # not a complete row, keep going
843 if not values: continue
845 # skip property names header
846 if values == props:
847 continue
849 # extract the nodeid
850 nodeid, values = values[0], values[1:]
851 found[nodeid] = 1
853 # confirm correct weight
854 if len(idlessprops) != len(values):
855 self.error_message.append(
856 _('Not enough values on line %(line)s')%{'line':line})
857 return
859 # extract the new values
860 d = {}
861 for name, value in zip(idlessprops, values):
862 value = value.strip()
863 # only add the property if it has a value
864 if value:
865 # if it's a multilink, split it
866 if isinstance(cl.properties[name], hyperdb.Multilink):
867 value = value.split(':')
868 d[name] = value
870 # perform the edit
871 if cl.hasnode(nodeid):
872 # edit existing
873 cl.set(nodeid, **d)
874 else:
875 # new node
876 found[cl.create(**d)] = 1
878 # retire the removed entries
879 for nodeid in cl.list():
880 if not found.has_key(nodeid):
881 cl.retire(nodeid)
883 # all OK
884 self.db.commit()
886 self.ok_message.append(_('Items edited OK'))
888 def editCSVPermission(self):
889 ''' Determine whether the user has permission to edit this class.
891 Base behaviour is to check the user can edit this class.
892 '''
893 if not self.db.security.hasPermission('Edit', self.userid,
894 self.classname):
895 return 0
896 return 1
898 def searchAction(self):
899 ''' Mangle some of the form variables.
901 Set the form ":filter" variable based on the values of the
902 filter variables - if they're set to anything other than
903 "dontcare" then add them to :filter.
905 Also handle the ":queryname" variable and save off the query to
906 the user's query list.
907 '''
908 # generic edit is per-class only
909 if not self.searchPermission():
910 self.error_message.append(
911 _('You do not have permission to search %s' %self.classname))
913 # add a faked :filter form variable for each filtering prop
914 # XXX migrate to new : @ +
915 props = self.db.classes[self.classname].getprops()
916 for key in self.form.keys():
917 if not props.has_key(key): continue
918 if isinstance(self.form[key], type([])):
919 # search for at least one entry which is not empty
920 for minifield in self.form[key]:
921 if minifield.value:
922 break
923 else:
924 continue
925 else:
926 if not self.form[key].value: continue
927 self.form.value.append(cgi.MiniFieldStorage(':filter', key))
929 # handle saving the query params
930 if self.form.has_key(':queryname'):
931 queryname = self.form[':queryname'].value.strip()
932 if queryname:
933 # parse the environment and figure what the query _is_
934 req = HTMLRequest(self)
935 url = req.indexargs_href('', {})
937 # handle editing an existing query
938 try:
939 qid = self.db.query.lookup(queryname)
940 self.db.query.set(qid, klass=self.classname, url=url)
941 except KeyError:
942 # create a query
943 qid = self.db.query.create(name=queryname,
944 klass=self.classname, url=url)
946 # and add it to the user's query multilink
947 queries = self.db.user.get(self.userid, 'queries')
948 queries.append(qid)
949 self.db.user.set(self.userid, queries=queries)
951 # commit the query change to the database
952 self.db.commit()
954 def searchPermission(self):
955 ''' Determine whether the user has permission to search this class.
957 Base behaviour is to check the user can view this class.
958 '''
959 if not self.db.security.hasPermission('View', self.userid,
960 self.classname):
961 return 0
962 return 1
965 def retireAction(self):
966 ''' Retire the context item.
967 '''
968 # if we want to view the index template now, then unset the nodeid
969 # context info (a special-case for retire actions on the index page)
970 nodeid = self.nodeid
971 if self.template == 'index':
972 self.nodeid = None
974 # generic edit is per-class only
975 if not self.retirePermission():
976 self.error_message.append(
977 _('You do not have permission to retire %s' %self.classname))
978 return
980 # make sure we don't try to retire admin or anonymous
981 if self.classname == 'user' and \
982 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
983 self.error_message.append(
984 _('You may not retire the admin or anonymous user'))
985 return
987 # do the retire
988 self.db.getclass(self.classname).retire(nodeid)
989 self.db.commit()
991 self.ok_message.append(
992 _('%(classname)s %(itemid)s has been retired')%{
993 'classname': self.classname.capitalize(), 'itemid': nodeid})
995 def retirePermission(self):
996 ''' Determine whether the user has permission to retire this class.
998 Base behaviour is to check the user can edit this class.
999 '''
1000 if not self.db.security.hasPermission('Edit', self.userid,
1001 self.classname):
1002 return 0
1003 return 1
1006 def showAction(self):
1007 ''' Show a node
1008 '''
1009 # XXX allow : @ +
1010 t = self.form[':type'].value
1011 n = self.form[':number'].value
1012 url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1013 raise Redirect, url
1016 #
1017 # Utility methods for editing
1018 #
1019 def _editnodes(self, all_props, all_links, newids=None):
1020 ''' Use the props in all_props to perform edit and creation, then
1021 use the link specs in all_links to do linking.
1022 '''
1023 m = []
1024 if newids is None:
1025 newids = {}
1026 for (cn, nodeid), props in all_props.items():
1027 if int(nodeid) > 0:
1028 # make changes to the node
1029 props = self._changenode(cn, nodeid, props)
1031 # and some nice feedback for the user
1032 if props:
1033 info = ', '.join(props.keys())
1034 m.append('%s %s %s edited ok'%(cn, nodeid, info))
1035 else:
1036 m.append('%s %s - nothing changed'%(cn, nodeid))
1037 elif props:
1038 # make a new node
1039 newid = self._createnode(cn, props)
1040 newids[(cn, nodeid)] = newid
1041 nodeid = newid
1043 # and some nice feedback for the user
1044 m.append('%s %s created'%(cn, newid))
1046 # handle linked nodes
1047 keys = self.form.keys()
1048 for cn, nodeid, propname, value in all_links:
1049 cl = self.db.classes[cn]
1050 property = cl.getprops()[propname]
1051 if nodeid is None or nodeid.startswith('-'):
1052 if not newids.has_key((cn, nodeid)):
1053 continue
1054 nodeid = newids[(cn, nodeid)]
1056 # map the desired classnames to their actual created ids
1057 for link in value:
1058 if not newids.has_key(link):
1059 continue
1060 linkid = newids[link]
1061 if isinstance(property, hyperdb.Multilink):
1062 # take a dupe of the list so we're not changing the cache
1063 existing = cl.get(nodeid, propname)[:]
1064 existing.append(linkid)
1065 cl.set(nodeid, **{propname: existing})
1066 elif isinstance(property, hyperdb.Link):
1067 # make the Link set
1068 cl.set(nodeid, **{propname: linkid})
1069 else:
1070 raise ValueError, '%s %s is not a link or multilink '\
1071 'property'%(cn, propname)
1072 m.append('%s %s linked to <a href="%s%s">%s %s</a>'%(
1073 link[0], linkid, cn, nodeid, cn, nodeid))
1075 return '<br>'.join(m)
1077 def _changenode(self, cn, nodeid, props):
1078 ''' change the node based on the contents of the form
1079 '''
1080 # check for permission
1081 if not self.editItemPermission(props):
1082 raise PermissionError, 'You do not have permission to edit %s'%cn
1084 # make the changes
1085 cl = self.db.classes[cn]
1086 return cl.set(nodeid, **props)
1088 def _createnode(self, cn, props):
1089 ''' create a node based on the contents of the form
1090 '''
1091 # check for permission
1092 if not self.newItemPermission(props):
1093 raise PermissionError, 'You do not have permission to create %s'%cn
1095 # create the node and return its id
1096 cl = self.db.classes[cn]
1097 return cl.create(**props)
1099 def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1100 ''' Pull properties for the given class out of the form.
1102 In the following, <bracketed> values are variable, ":" may be
1103 one of ":" or "@", and other text "required" is fixed.
1105 Properties are specified as form variables
1106 <designator>:<propname>
1108 where the propery belongs to the context class or item if the
1109 designator is not specified. The designator may specify a
1110 negative item id value (ie. "issue-1") and a new item of the
1111 specified class will be created for each negative id found.
1113 If a "<designator>:required" parameter is supplied,
1114 then the named property values must be supplied or a
1115 ValueError will be raised.
1117 Other special form values:
1118 [classname|designator]:remove:<propname>=id(s)
1119 The ids will be removed from the multilink property.
1120 [classname|designator]:add:<propname>=id(s)
1121 The ids will be added to the multilink property.
1123 [classname|designator]:link:<propname>=<classname>
1124 Used to add a link to new items created during edit.
1125 These are collected up and returned in all_links. This will
1126 result in an additional linking operation (either Link set or
1127 Multilink append) after the edit/create is done using
1128 all_props in _editnodes. The <propname> on
1129 [classname|designator] will be set/appended the id of the
1130 newly created item of class <classname>.
1132 Note: the colon may be either ":" or "@".
1134 Any of the form variables may be prefixed with a classname or
1135 designator.
1137 The return from this method is a dict of
1138 (classname, id): properties
1139 ... this dict _always_ has an entry for the current context,
1140 even if it's empty (ie. a submission for an existing issue that
1141 doesn't result in any changes would return {('issue','123'): {}})
1142 The id may be None, which indicates that an item should be
1143 created.
1145 If a String property's form value is a file upload, then we
1146 try to set additional properties "filename" and "type" (if
1147 they are valid for the class).
1149 Two special form values are supported for backwards
1150 compatibility:
1151 :note - create a message (with content, author and date), link
1152 to the context item
1153 :file - create a file, attach to the current item and any
1154 message created by :note
1155 '''
1156 # some very useful variables
1157 db = self.db
1158 form = self.form
1160 if not hasattr(self, 'FV_ITEMSPEC'):
1161 # generate the regexp for detecting
1162 # <classname|designator>[@:+]property
1163 classes = '|'.join(db.classes.keys())
1164 self.FV_ITEMSPEC = re.compile(r'(%s)([-\d]+)[@:](.+)$'%classes)
1165 self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1167 # these indicate the default class / item
1168 default_cn = self.classname
1169 default_cl = self.db.classes[default_cn]
1170 default_nodeid = self.nodeid
1172 # we'll store info about the individual class/item edit in these
1173 all_required = {} # one entry per class/item
1174 all_props = {} # one entry per class/item
1175 all_propdef = {} # note - only one entry per class
1176 all_links = [] # as many as are required
1178 # we should always return something, even empty, for the context
1179 all_props[(default_cn, default_nodeid)] = {}
1181 keys = form.keys()
1182 timezone = db.getUserTimezone()
1184 for key in keys:
1185 # see if this value modifies a different item to the default
1186 m = self.FV_ITEMSPEC.match(key)
1187 if m:
1188 # we got a designator
1189 cn = m.group(1)
1190 cl = self.db.classes[cn]
1191 nodeid = m.group(2)
1192 propname = m.group(3)
1193 elif key == ':note':
1194 # backwards compatibility: the special note field
1195 cn = 'msg'
1196 cl = self.db.classes[cn]
1197 nodeid = '-1'
1198 propname = 'content'
1199 all_links.append((default_cn, default_nodeid, 'messages',
1200 [('msg', '-1')]))
1201 elif key == ':file':
1202 # backwards compatibility: the special file field
1203 cn = 'file'
1204 cl = self.db.classes[cn]
1205 nodeid = '-1'
1206 propname = 'content'
1207 all_links.append((default_cn, default_nodeid, 'files',
1208 [('file', '-1')]))
1209 if self.form.has_key(':note'):
1210 all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1211 else:
1212 # default
1213 cn = default_cn
1214 cl = default_cl
1215 nodeid = default_nodeid
1216 propname = key
1218 # the thing this value relates to is...
1219 this = (cn, nodeid)
1221 # is this a link command?
1222 if self.FV_LINK.match(propname):
1223 value = []
1224 for entry in extractFormList(form[key]):
1225 m = self.FV_DESIGNATOR.match(entry)
1226 if not m:
1227 raise ValueError, \
1228 'link "%s" value "%s" not a designator'%(key, entry)
1229 value.append((m.groups(1), m.groups(2)))
1230 all_links.append((cn, nodeid, propname[6:], value))
1232 # get more info about the class, and the current set of
1233 # form props for it
1234 if not all_propdef.has_key(cn):
1235 all_propdef[cn] = cl.getprops()
1236 propdef = all_propdef[cn]
1237 if not all_props.has_key(this):
1238 all_props[this] = {}
1239 props = all_props[this]
1241 # detect the special ":required" variable
1242 if self.FV_REQUIRED.match(key):
1243 all_required[this] = extractFormList(form[key])
1244 continue
1246 # get the required values list
1247 if not all_required.has_key(this):
1248 all_required[this] = []
1249 required = all_required[this]
1251 # see if we're performing a special multilink action
1252 mlaction = 'set'
1253 if self.FV_REMOVE.match(propname):
1254 propname = propname[8:]
1255 mlaction = 'remove'
1256 elif self.FV_ADD.match(propname):
1257 propname = propname[5:]
1258 mlaction = 'add'
1260 # does the property exist?
1261 if not propdef.has_key(propname):
1262 if mlaction != 'set':
1263 raise ValueError, 'You have submitted a %s action for'\
1264 ' the property "%s" which doesn\'t exist'%(mlaction,
1265 propname)
1266 continue
1267 proptype = propdef[propname]
1269 # Get the form value. This value may be a MiniFieldStorage or a list
1270 # of MiniFieldStorages.
1271 value = form[key]
1273 # handle unpacking of the MiniFieldStorage / list form value
1274 if isinstance(proptype, hyperdb.Multilink):
1275 value = extractFormList(value)
1276 else:
1277 # multiple values are not OK
1278 if isinstance(value, type([])):
1279 raise ValueError, 'You have submitted more than one value'\
1280 ' for the %s property'%propname
1281 # value might be a file upload...
1282 if not hasattr(value, 'filename') or value.filename is None:
1283 # nope, pull out the value and strip it
1284 value = value.value.strip()
1286 # now that we have the props field, we need a teensy little
1287 # extra bit of help for the old :note field...
1288 if key == ':note' and value:
1289 props['author'] = self.db.getuid()
1290 props['date'] = date.Date()
1292 # handle by type now
1293 if isinstance(proptype, hyperdb.Password):
1294 if not value:
1295 # ignore empty password values
1296 continue
1297 for key in keys:
1298 if self.FV_CONFIRM.match(key):
1299 confirm = form[key]
1300 break
1301 else:
1302 raise ValueError, 'Password and confirmation text do '\
1303 'not match'
1304 if isinstance(confirm, type([])):
1305 raise ValueError, 'You have submitted more than one value'\
1306 ' for the %s property'%propname
1307 if value != confirm.value:
1308 raise ValueError, 'Password and confirmation text do '\
1309 'not match'
1310 value = password.Password(value)
1312 elif isinstance(proptype, hyperdb.Link):
1313 # see if it's the "no selection" choice
1314 if value == '-1' or not value:
1315 # if we're creating, just don't include this property
1316 if not nodeid or nodeid.startswith('-'):
1317 continue
1318 value = None
1319 else:
1320 # handle key values
1321 link = proptype.classname
1322 if not num_re.match(value):
1323 try:
1324 value = db.classes[link].lookup(value)
1325 except KeyError:
1326 raise ValueError, _('property "%(propname)s": '
1327 '%(value)s not a %(classname)s')%{
1328 'propname': propname, 'value': value,
1329 'classname': link}
1330 except TypeError, message:
1331 raise ValueError, _('you may only enter ID values '
1332 'for property "%(propname)s": %(message)s')%{
1333 'propname': propname, 'message': message}
1334 elif isinstance(proptype, hyperdb.Multilink):
1335 # perform link class key value lookup if necessary
1336 link = proptype.classname
1337 link_cl = db.classes[link]
1338 l = []
1339 for entry in value:
1340 if not entry: continue
1341 if not num_re.match(entry):
1342 try:
1343 entry = link_cl.lookup(entry)
1344 except KeyError:
1345 raise ValueError, _('property "%(propname)s": '
1346 '"%(value)s" not an entry of %(classname)s')%{
1347 'propname': propname, 'value': entry,
1348 'classname': link}
1349 except TypeError, message:
1350 raise ValueError, _('you may only enter ID values '
1351 'for property "%(propname)s": %(message)s')%{
1352 'propname': propname, 'message': message}
1353 l.append(entry)
1354 l.sort()
1356 # now use that list of ids to modify the multilink
1357 if mlaction == 'set':
1358 value = l
1359 else:
1360 # we're modifying the list - get the current list of ids
1361 if props.has_key(propname):
1362 existing = props[propname]
1363 elif nodeid and not nodeid.startswith('-'):
1364 existing = cl.get(nodeid, propname, [])
1365 else:
1366 existing = []
1368 # now either remove or add
1369 if mlaction == 'remove':
1370 # remove - handle situation where the id isn't in
1371 # the list
1372 for entry in l:
1373 try:
1374 existing.remove(entry)
1375 except ValueError:
1376 raise ValueError, _('property "%(propname)s": '
1377 '"%(value)s" not currently in list')%{
1378 'propname': propname, 'value': entry}
1379 else:
1380 # add - easy, just don't dupe
1381 for entry in l:
1382 if entry not in existing:
1383 existing.append(entry)
1384 value = existing
1385 value.sort()
1387 elif value == '':
1388 # if we're creating, just don't include this property
1389 if not nodeid or nodeid.startswith('-'):
1390 continue
1391 # other types should be None'd if there's no value
1392 value = None
1393 else:
1394 if isinstance(proptype, hyperdb.String):
1395 if (hasattr(value, 'filename') and
1396 value.filename is not None):
1397 # skip if the upload is empty
1398 if not value.filename:
1399 continue
1400 # this String is actually a _file_
1401 # try to determine the file content-type
1402 filename = value.filename.split('\\')[-1]
1403 if propdef.has_key('name'):
1404 props['name'] = filename
1405 # use this info as the type/filename properties
1406 if propdef.has_key('type'):
1407 props['type'] = mimetypes.guess_type(filename)[0]
1408 if not props['type']:
1409 props['type'] = "application/octet-stream"
1410 # finally, read the content
1411 value = value.value
1412 else:
1413 # normal String fix the CRLF/CR -> LF stuff
1414 value = fixNewlines(value)
1416 elif isinstance(proptype, hyperdb.Date):
1417 value = date.Date(value, offset=timezone)
1418 elif isinstance(proptype, hyperdb.Interval):
1419 value = date.Interval(value)
1420 elif isinstance(proptype, hyperdb.Boolean):
1421 value = value.lower() in ('yes', 'true', 'on', '1')
1422 elif isinstance(proptype, hyperdb.Number):
1423 value = float(value)
1425 # get the old value
1426 if nodeid and not nodeid.startswith('-'):
1427 try:
1428 existing = cl.get(nodeid, propname)
1429 except KeyError:
1430 # this might be a new property for which there is
1431 # no existing value
1432 if not propdef.has_key(propname):
1433 raise
1435 # make sure the existing multilink is sorted
1436 if isinstance(proptype, hyperdb.Multilink):
1437 existing.sort()
1439 # "missing" existing values may not be None
1440 if not existing:
1441 if isinstance(proptype, hyperdb.String) and not existing:
1442 # some backends store "missing" Strings as empty strings
1443 existing = None
1444 elif isinstance(proptype, hyperdb.Number) and not existing:
1445 # some backends store "missing" Numbers as 0 :(
1446 existing = 0
1447 elif isinstance(proptype, hyperdb.Boolean) and not existing:
1448 # likewise Booleans
1449 existing = 0
1451 # if changed, set it
1452 if value != existing:
1453 props[propname] = value
1454 else:
1455 # don't bother setting empty/unset values
1456 if value is None:
1457 continue
1458 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1459 continue
1460 elif isinstance(proptype, hyperdb.String) and value == '':
1461 continue
1463 props[propname] = value
1465 # register this as received if required?
1466 if propname in required and value is not None:
1467 required.remove(propname)
1469 # see if all the required properties have been supplied
1470 s = []
1471 for thing, required in all_required.items():
1472 if not required:
1473 continue
1474 if len(required) > 1:
1475 p = 'properties'
1476 else:
1477 p = 'property'
1478 s.append('Required %s %s %s not supplied'%(thing[0], p,
1479 ', '.join(required)))
1480 if s:
1481 raise ValueError, '\n'.join(s)
1483 return all_props, all_links
1485 def fixNewlines(text):
1486 ''' Homogenise line endings.
1488 Different web clients send different line ending values, but
1489 other systems (eg. email) don't necessarily handle those line
1490 endings. Our solution is to convert all line endings to LF.
1491 '''
1492 text = text.replace('\r\n', '\n')
1493 return text.replace('\r', '\n')
1495 def extractFormList(value):
1496 ''' Extract a list of values from the form value.
1498 It may be one of:
1499 [MiniFieldStorage, MiniFieldStorage, ...]
1500 MiniFieldStorage('value,value,...')
1501 MiniFieldStorage('value')
1502 '''
1503 # multiple values are OK
1504 if isinstance(value, type([])):
1505 # it's a list of MiniFieldStorages
1506 value = [i.value.strip() for i in value]
1507 else:
1508 # it's a MiniFieldStorage, but may be a comma-separated list
1509 # of values
1510 value = [i.strip() for i in value.value.split(',')]
1512 # filter out the empty bits
1513 return filter(None, value)