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