c1c8021c5a79df735fbfea724c3d2f07e14cfed2
1 # $Id: client.py,v 1.60 2002-12-10 06:01:59 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.
81 '''
83 def __init__(self, instance, request, env, form=None):
84 hyperdb.traceMark()
85 self.instance = instance
86 self.request = request
87 self.env = env
89 # save off the path
90 self.path = env['PATH_INFO']
92 # this is the base URL for this instance
93 self.base = self.instance.config.TRACKER_WEB
95 # see if we need to re-parse the environment for the form (eg Zope)
96 if form is None:
97 self.form = cgi.FieldStorage(environ=env)
98 else:
99 self.form = form
101 # turn debugging on/off
102 try:
103 self.debug = int(env.get("ROUNDUP_DEBUG", 0))
104 except ValueError:
105 # someone gave us a non-int debug level, turn it off
106 self.debug = 0
108 # flag to indicate that the HTTP headers have been sent
109 self.headers_done = 0
111 # additional headers to send with the request - must be registered
112 # before the first write
113 self.additional_headers = {}
114 self.response_code = 200
116 def main(self):
117 ''' Wrap the real main in a try/finally so we always close off the db.
118 '''
119 try:
120 self.inner_main()
121 finally:
122 if hasattr(self, 'db'):
123 self.db.close()
125 def inner_main(self):
126 ''' Process a request.
128 The most common requests are handled like so:
129 1. figure out who we are, defaulting to the "anonymous" user
130 see determine_user
131 2. figure out what the request is for - the context
132 see determine_context
133 3. handle any requested action (item edit, search, ...)
134 see handle_action
135 4. render a template, resulting in HTML output
137 In some situations, exceptions occur:
138 - HTTP Redirect (generally raised by an action)
139 - SendFile (generally raised by determine_context)
140 serve up a FileClass "content" property
141 - SendStaticFile (generally raised by determine_context)
142 serve up a file from the tracker "html" directory
143 - Unauthorised (generally raised by an action)
144 the action is cancelled, the request is rendered and an error
145 message is displayed indicating that permission was not
146 granted for the action to take place
147 - NotFound (raised wherever it needs to be)
148 percolates up to the CGI interface that called the client
149 '''
150 self.ok_message = []
151 self.error_message = []
152 try:
153 # make sure we're identified (even anonymously)
154 self.determine_user()
155 # figure out the context and desired content template
156 self.determine_context()
157 # possibly handle a form submit action (may change self.classname
158 # and self.template, and may also append error/ok_messages)
159 self.handle_action()
160 # now render the page
162 # we don't want clients caching our dynamic pages
163 self.additional_headers['Cache-Control'] = 'no-cache'
164 self.additional_headers['Pragma'] = 'no-cache'
165 self.additional_headers['Expires'] = 'Thu, 1 Jan 1970 00:00:00 GMT'
167 # render the content
168 self.write(self.renderContext())
169 except Redirect, url:
170 # let's redirect - if the url isn't None, then we need to do
171 # the headers, otherwise the headers have been set before the
172 # exception was raised
173 if url:
174 self.additional_headers['Location'] = url
175 self.response_code = 302
176 self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
177 except SendFile, designator:
178 self.serve_file(designator)
179 except SendStaticFile, file:
180 self.serve_static_file(str(file))
181 except Unauthorised, message:
182 self.classname=None
183 self.template=''
184 self.error_message.append(message)
185 self.write(self.renderContext())
186 except NotFound:
187 # pass through
188 raise
189 except:
190 # everything else
191 self.write(cgitb.html())
193 def determine_user(self):
194 ''' Determine who the user is
195 '''
196 # determine the uid to use
197 self.opendb('admin')
199 # make sure we have the session Class
200 sessions = self.db.sessions
202 # age sessions, remove when they haven't been used for a week
203 # TODO: this shouldn't be done every access
204 week = 60*60*24*7
205 now = time.time()
206 for sessid in sessions.list():
207 interval = now - sessions.get(sessid, 'last_use')
208 if interval > week:
209 sessions.destroy(sessid)
211 # look up the user session cookie
212 cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
213 user = 'anonymous'
215 # bump the "revision" of the cookie since the format changed
216 if (cookie.has_key('roundup_user_2') and
217 cookie['roundup_user_2'].value != 'deleted'):
219 # get the session key from the cookie
220 self.session = cookie['roundup_user_2'].value
221 # get the user from the session
222 try:
223 # update the lifetime datestamp
224 sessions.set(self.session, last_use=time.time())
225 sessions.commit()
226 user = sessions.get(self.session, 'user')
227 except KeyError:
228 user = 'anonymous'
230 # sanity check on the user still being valid, getting the userid
231 # at the same time
232 try:
233 self.userid = self.db.user.lookup(user)
234 except (KeyError, TypeError):
235 user = 'anonymous'
237 # make sure the anonymous user is valid if we're using it
238 if user == 'anonymous':
239 self.make_user_anonymous()
240 else:
241 self.user = user
243 # reopen the database as the correct user
244 self.opendb(self.user)
246 def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
247 ''' Determine the context of this page from the URL:
249 The URL path after the instance identifier is examined. The path
250 is generally only one entry long.
252 - if there is no path, then we are in the "home" context.
253 * if the path is "_file", then the additional path entry
254 specifies the filename of a static file we're to serve up
255 from the instance "html" directory. Raises a SendStaticFile
256 exception.
257 - if there is something in the path (eg "issue"), it identifies
258 the tracker class we're to display.
259 - if the path is an item designator (eg "issue123"), then we're
260 to display a specific item.
261 * if the path starts with an item designator and is longer than
262 one entry, then we're assumed to be handling an item of a
263 FileClass, and the extra path information gives the filename
264 that the client is going to label the download with (ie
265 "file123/image.png" is nicer to download than "file123"). This
266 raises a SendFile exception.
268 Both of the "*" types of contexts stop before we bother to
269 determine the template we're going to use. That's because they
270 don't actually use templates.
272 The template used is specified by the :template CGI variable,
273 which defaults to:
275 only classname suplied: "index"
276 full item designator supplied: "item"
278 We set:
279 self.classname - the class to display, can be None
280 self.template - the template to render the current context with
281 self.nodeid - the nodeid of the class we're displaying
282 '''
283 # default the optional variables
284 self.classname = None
285 self.nodeid = None
287 # determine the classname and possibly nodeid
288 path = self.path.split('/')
289 if not path or path[0] in ('', 'home', 'index'):
290 if self.form.has_key(':template'):
291 self.template = self.form[':template'].value
292 else:
293 self.template = ''
294 return
295 elif path[0] == '_file':
296 raise SendStaticFile, path[1]
297 else:
298 self.classname = path[0]
299 if len(path) > 1:
300 # send the file identified by the designator in path[0]
301 raise SendFile, path[0]
303 # see if we got a designator
304 m = dre.match(self.classname)
305 if m:
306 self.classname = m.group(1)
307 self.nodeid = m.group(2)
308 if not self.db.getclass(self.classname).hasnode(self.nodeid):
309 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
310 # with a designator, we default to item view
311 self.template = 'item'
312 else:
313 # with only a class, we default to index view
314 self.template = 'index'
316 # make sure the classname is valid
317 try:
318 self.db.getclass(self.classname)
319 except KeyError:
320 raise NotFound, self.classname
322 # see if we have a template override
323 if self.form.has_key(':template'):
324 self.template = self.form[':template'].value
326 # see if we were passed in a message
327 if self.form.has_key(':ok_message'):
328 self.ok_message.append(self.form[':ok_message'].value)
329 if self.form.has_key(':error_message'):
330 self.error_message.append(self.form[':error_message'].value)
332 def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
333 ''' Serve the file from the content property of the designated item.
334 '''
335 m = dre.match(str(designator))
336 if not m:
337 raise NotFound, str(designator)
338 classname, nodeid = m.group(1), m.group(2)
339 if classname != 'file':
340 raise NotFound, designator
342 # we just want to serve up the file named
343 file = self.db.file
344 self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
345 self.write(file.get(nodeid, 'content'))
347 def serve_static_file(self, file):
348 # we just want to serve up the file named
349 mt = mimetypes.guess_type(str(file))[0]
350 self.additional_headers['Content-Type'] = mt
351 self.write(open(os.path.join(self.instance.config.TEMPLATES,
352 file)).read())
354 def renderContext(self):
355 ''' Return a PageTemplate for the named page
356 '''
357 name = self.classname
358 extension = self.template
359 pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
361 # catch errors so we can handle PT rendering errors more nicely
362 args = {
363 'ok_message': self.ok_message,
364 'error_message': self.error_message
365 }
366 try:
367 # let the template render figure stuff out
368 return pt.render(self, None, None, **args)
369 except NoTemplate, message:
370 return '<strong>%s</strong>'%message
371 except:
372 # everything else
373 return cgitb.pt_html()
375 # these are the actions that are available
376 actions = (
377 ('edit', 'editItemAction'),
378 ('editCSV', 'editCSVAction'),
379 ('new', 'newItemAction'),
380 ('register', 'registerAction'),
381 ('login', 'loginAction'),
382 ('logout', 'logout_action'),
383 ('search', 'searchAction'),
384 ('retire', 'retireAction'),
385 )
386 def handle_action(self):
387 ''' Determine whether there should be an _action called.
389 The action is defined by the form variable :action which
390 identifies the method on this object to call. The four basic
391 actions are defined in the "actions" sequence on this class:
392 "edit" -> self.editItemAction
393 "new" -> self.newItemAction
394 "register" -> self.registerAction
395 "login" -> self.loginAction
396 "logout" -> self.logout_action
397 "search" -> self.searchAction
398 "retire" -> self.retireAction
399 '''
400 if not self.form.has_key(':action'):
401 return None
402 try:
403 # get the action, validate it
404 action = self.form[':action'].value
405 for name, method in self.actions:
406 if name == action:
407 break
408 else:
409 raise ValueError, 'No such action "%s"'%action
411 # call the mapped action
412 getattr(self, method)()
413 except Redirect:
414 raise
415 except Unauthorised:
416 raise
417 except:
418 self.db.rollback()
419 s = StringIO.StringIO()
420 traceback.print_exc(None, s)
421 self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
423 def write(self, content):
424 if not self.headers_done:
425 self.header()
426 self.request.wfile.write(content)
428 def header(self, headers=None, response=None):
429 '''Put up the appropriate header.
430 '''
431 if headers is None:
432 headers = {'Content-Type':'text/html'}
433 if response is None:
434 response = self.response_code
436 # update with additional info
437 headers.update(self.additional_headers)
439 if not headers.has_key('Content-Type'):
440 headers['Content-Type'] = 'text/html'
441 self.request.send_response(response)
442 for entry in headers.items():
443 self.request.send_header(*entry)
444 self.request.end_headers()
445 self.headers_done = 1
446 if self.debug:
447 self.headers_sent = headers
449 def set_cookie(self, user):
450 ''' Set up a session cookie for the user and store away the user's
451 login info against the session.
452 '''
453 # TODO generate a much, much stronger session key ;)
454 self.session = binascii.b2a_base64(repr(random.random())).strip()
456 # clean up the base64
457 if self.session[-1] == '=':
458 if self.session[-2] == '=':
459 self.session = self.session[:-2]
460 else:
461 self.session = self.session[:-1]
463 # insert the session in the sessiondb
464 self.db.sessions.set(self.session, user=user, last_use=time.time())
466 # and commit immediately
467 self.db.sessions.commit()
469 # expire us in a long, long time
470 expire = Cookie._getdate(86400*365)
472 # generate the cookie path - make sure it has a trailing '/'
473 path = '/'.join((self.env['SCRIPT_NAME'], self.env['TRACKER_NAME'],
474 ''))
475 self.additional_headers['Set-Cookie'] = \
476 'roundup_user_2=%s; expires=%s; Path=%s;'%(self.session, expire, path)
478 def make_user_anonymous(self):
479 ''' Make us anonymous
481 This method used to handle non-existence of the 'anonymous'
482 user, but that user is mandatory now.
483 '''
484 self.userid = self.db.user.lookup('anonymous')
485 self.user = 'anonymous'
487 def opendb(self, user):
488 ''' Open the database.
489 '''
490 # open the db if the user has changed
491 if not hasattr(self, 'db') or user != self.db.journaltag:
492 if hasattr(self, 'db'):
493 self.db.close()
494 self.db = self.instance.open(user)
496 #
497 # Actions
498 #
499 def loginAction(self):
500 ''' Attempt to log a user in.
502 Sets up a session for the user which contains the login
503 credentials.
504 '''
505 # we need the username at a minimum
506 if not self.form.has_key('__login_name'):
507 self.error_message.append(_('Username required'))
508 return
510 # get the login info
511 self.user = self.form['__login_name'].value
512 if self.form.has_key('__login_password'):
513 password = self.form['__login_password'].value
514 else:
515 password = ''
517 # make sure the user exists
518 try:
519 self.userid = self.db.user.lookup(self.user)
520 except KeyError:
521 name = self.user
522 self.error_message.append(_('No such user "%(name)s"')%locals())
523 self.make_user_anonymous()
524 return
526 # verify the password
527 if not self.verifyPassword(self.userid, password):
528 self.make_user_anonymous()
529 self.error_message.append(_('Incorrect password'))
530 return
532 # make sure we're allowed to be here
533 if not self.loginPermission():
534 self.make_user_anonymous()
535 self.error_message.append(_("You do not have permission to login"))
536 return
538 # now we're OK, re-open the database for real, using the user
539 self.opendb(self.user)
541 # set the session cookie
542 self.set_cookie(self.user)
544 def verifyPassword(self, userid, password):
545 ''' Verify the password that the user has supplied
546 '''
547 stored = self.db.user.get(self.userid, 'password')
548 if password == stored:
549 return 1
550 if not password and not stored:
551 return 1
552 return 0
554 def loginPermission(self):
555 ''' Determine whether the user has permission to log in.
557 Base behaviour is to check the user has "Web Access".
558 '''
559 if not self.db.security.hasPermission('Web Access', self.userid):
560 return 0
561 return 1
563 def logout_action(self):
564 ''' Make us really anonymous - nuke the cookie too
565 '''
566 # log us out
567 self.make_user_anonymous()
569 # construct the logout cookie
570 now = Cookie._getdate()
571 path = '/'.join((self.env['SCRIPT_NAME'], self.env['TRACKER_NAME'],
572 ''))
573 self.additional_headers['Set-Cookie'] = \
574 'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)
576 # Let the user know what's going on
577 self.ok_message.append(_('You are logged out'))
579 def registerAction(self):
580 '''Attempt to create a new user based on the contents of the form
581 and then set the cookie.
583 return 1 on successful login
584 '''
585 # create the new user
586 cl = self.db.user
588 # parse the props from the form
589 try:
590 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
591 except (ValueError, KeyError), message:
592 self.error_message.append(_('Error: ') + str(message))
593 return
595 # make sure we're allowed to register
596 if not self.registerPermission(props):
597 raise Unauthorised, _("You do not have permission to register")
599 # re-open the database as "admin"
600 if self.user != 'admin':
601 self.opendb('admin')
603 # create the new user
604 cl = self.db.user
605 try:
606 props = parsePropsFromForm(self.db, cl, self.form)
607 props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
608 self.userid = cl.create(**props)
609 self.db.commit()
610 except (ValueError, KeyError), message:
611 self.error_message.append(message)
612 return
614 # log the new user in
615 self.user = cl.get(self.userid, 'username')
616 # re-open the database for real, using the user
617 self.opendb(self.user)
619 # if we have a session, update it
620 if hasattr(self, 'session'):
621 self.db.sessions.set(self.session, user=self.user,
622 last_use=time.time())
623 else:
624 # new session cookie
625 self.set_cookie(self.user)
627 # nice message
628 message = _('You are now registered, welcome!')
630 # redirect to the item's edit page
631 raise Redirect, '%s%s%s?:ok_message=%s'%(
632 self.base, self.classname, self.userid, urllib.quote(message))
634 def registerPermission(self, props):
635 ''' Determine whether the user has permission to register
637 Base behaviour is to check the user has "Web Registration".
638 '''
639 # registration isn't allowed to supply roles
640 if props.has_key('roles'):
641 return 0
642 if self.db.security.hasPermission('Web Registration', self.userid):
643 return 1
644 return 0
646 def editItemAction(self):
647 ''' Perform an edit of an item in the database.
649 Some special form elements:
651 :link=designator:property
652 :multilink=designator:property
653 The value specifies a node designator and the property on that
654 node to add _this_ node to as a link or multilink.
655 :note
656 Create a message and attach it to the current node's
657 "messages" property.
658 :file
659 Create a file and attach it to the current node's
660 "files" property. Attach the file to the message created from
661 the :note if it's supplied.
663 :required=property,property,...
664 The named properties are required to be filled in the form.
666 :remove:<propname>=id(s)
667 The ids will be removed from the multilink property.
668 :add:<propname>=id(s)
669 The ids will be added to the multilink property.
671 '''
672 cl = self.db.classes[self.classname]
674 # parse the props from the form
675 try:
676 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
677 except (ValueError, KeyError), message:
678 self.error_message.append(_('Error: ') + str(message))
679 return
681 # check permission
682 if not self.editItemPermission(props):
683 self.error_message.append(
684 _('You do not have permission to edit %(classname)s'%
685 self.__dict__))
686 return
688 # perform the edit
689 try:
690 # make changes to the node
691 props = self._changenode(props)
692 # handle linked nodes
693 self._post_editnode(self.nodeid)
694 except (ValueError, KeyError, IndexError), message:
695 self.error_message.append(_('Error: ') + str(message))
696 return
698 # commit now that all the tricky stuff is done
699 self.db.commit()
701 # and some nice feedback for the user
702 if props:
703 message = _('%(changes)s edited ok')%{'changes':
704 ', '.join(props.keys())}
705 elif self.form.has_key(':note') and self.form[':note'].value:
706 message = _('note added')
707 elif (self.form.has_key(':file') and self.form[':file'].filename):
708 message = _('file added')
709 else:
710 message = _('nothing changed')
712 # redirect to the item's edit page
713 raise Redirect, '%s%s%s?:ok_message=%s'%(self.base, self.classname,
714 self.nodeid, urllib.quote(message))
716 def editItemPermission(self, props):
717 ''' Determine whether the user has permission to edit this item.
719 Base behaviour is to check the user can edit this class. If we're
720 editing the "user" class, users are allowed to edit their own
721 details. Unless it's the "roles" property, which requires the
722 special Permission "Web Roles".
723 '''
724 # if this is a user node and the user is editing their own node, then
725 # we're OK
726 has = self.db.security.hasPermission
727 if self.classname == 'user':
728 # reject if someone's trying to edit "roles" and doesn't have the
729 # right permission.
730 if props.has_key('roles') and not has('Web Roles', self.userid,
731 'user'):
732 return 0
733 # if the item being edited is the current user, we're ok
734 if self.nodeid == self.userid:
735 return 1
736 if self.db.security.hasPermission('Edit', self.userid, self.classname):
737 return 1
738 return 0
740 def newItemAction(self):
741 ''' Add a new item to the database.
743 This follows the same form as the editItemAction, with the same
744 special form values.
745 '''
746 cl = self.db.classes[self.classname]
748 # parse the props from the form
749 try:
750 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
751 except (ValueError, KeyError), message:
752 self.error_message.append(_('Error: ') + str(message))
753 return
755 if not self.newItemPermission(props):
756 self.error_message.append(
757 _('You do not have permission to create %s' %self.classname))
759 # create a little extra message for anticipated :link / :multilink
760 if self.form.has_key(':multilink'):
761 link = self.form[':multilink'].value
762 elif self.form.has_key(':link'):
763 link = self.form[':multilink'].value
764 else:
765 link = None
766 xtra = ''
767 if link:
768 designator, linkprop = link.split(':')
769 xtra = ' for <a href="%s">%s</a>'%(designator, designator)
771 try:
772 # do the create
773 nid = self._createnode(props)
774 except (ValueError, KeyError, IndexError), message:
775 # these errors might just be indicative of user dumbness
776 self.error_message.append(_('Error: ') + str(message))
777 return
778 except:
779 # oops
780 self.db.rollback()
781 s = StringIO.StringIO()
782 traceback.print_exc(None, s)
783 self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
784 return
786 try:
787 # handle linked nodes
788 self._post_editnode(nid)
790 # commit now that all the tricky stuff is done
791 self.db.commit()
793 # render the newly created item
794 self.nodeid = nid
796 # and some nice feedback for the user
797 message = _('%(classname)s created ok')%self.__dict__ + xtra
798 except:
799 # oops
800 self.db.rollback()
801 s = StringIO.StringIO()
802 traceback.print_exc(None, s)
803 self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
804 return
806 # redirect to the new item's page
807 raise Redirect, '%s%s%s?:ok_message=%s'%(self.base, self.classname,
808 nid, urllib.quote(message))
810 def newItemPermission(self, props):
811 ''' Determine whether the user has permission to create (edit) this
812 item.
814 Base behaviour is to check the user can edit this class. No
815 additional property checks are made. Additionally, new user items
816 may be created if the user has the "Web Registration" Permission.
817 '''
818 has = self.db.security.hasPermission
819 if self.classname == 'user' and has('Web Registration', self.userid,
820 'user'):
821 return 1
822 if has('Edit', self.userid, self.classname):
823 return 1
824 return 0
826 def editCSVAction(self):
827 ''' Performs an edit of all of a class' items in one go.
829 The "rows" CGI var defines the CSV-formatted entries for the
830 class. New nodes are identified by the ID 'X' (or any other
831 non-existent ID) and removed lines are retired.
832 '''
833 # this is per-class only
834 if not self.editCSVPermission():
835 self.error_message.append(
836 _('You do not have permission to edit %s' %self.classname))
838 # get the CSV module
839 try:
840 import csv
841 except ImportError:
842 self.error_message.append(_(
843 'Sorry, you need the csv module to use this function.<br>\n'
844 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
845 return
847 cl = self.db.classes[self.classname]
848 idlessprops = cl.getprops(protected=0).keys()
849 idlessprops.sort()
850 props = ['id'] + idlessprops
852 # do the edit
853 rows = self.form['rows'].value.splitlines()
854 p = csv.parser()
855 found = {}
856 line = 0
857 for row in rows[1:]:
858 line += 1
859 values = p.parse(row)
860 # not a complete row, keep going
861 if not values: continue
863 # skip property names header
864 if values == props:
865 continue
867 # extract the nodeid
868 nodeid, values = values[0], values[1:]
869 found[nodeid] = 1
871 # confirm correct weight
872 if len(idlessprops) != len(values):
873 self.error_message.append(
874 _('Not enough values on line %(line)s')%{'line':line})
875 return
877 # extract the new values
878 d = {}
879 for name, value in zip(idlessprops, values):
880 value = value.strip()
881 # only add the property if it has a value
882 if value:
883 # if it's a multilink, split it
884 if isinstance(cl.properties[name], hyperdb.Multilink):
885 value = value.split(':')
886 d[name] = value
888 # perform the edit
889 if cl.hasnode(nodeid):
890 # edit existing
891 cl.set(nodeid, **d)
892 else:
893 # new node
894 found[cl.create(**d)] = 1
896 # retire the removed entries
897 for nodeid in cl.list():
898 if not found.has_key(nodeid):
899 cl.retire(nodeid)
901 # all OK
902 self.db.commit()
904 self.ok_message.append(_('Items edited OK'))
906 def editCSVPermission(self):
907 ''' Determine whether the user has permission to edit this class.
909 Base behaviour is to check the user can edit this class.
910 '''
911 if not self.db.security.hasPermission('Edit', self.userid,
912 self.classname):
913 return 0
914 return 1
916 def searchAction(self):
917 ''' Mangle some of the form variables.
919 Set the form ":filter" variable based on the values of the
920 filter variables - if they're set to anything other than
921 "dontcare" then add them to :filter.
923 Also handle the ":queryname" variable and save off the query to
924 the user's query list.
925 '''
926 # generic edit is per-class only
927 if not self.searchPermission():
928 self.error_message.append(
929 _('You do not have permission to search %s' %self.classname))
931 # add a faked :filter form variable for each filtering prop
932 props = self.db.classes[self.classname].getprops()
933 for key in self.form.keys():
934 if not props.has_key(key): continue
935 if not self.form[key].value: continue
936 self.form.value.append(cgi.MiniFieldStorage(':filter', key))
938 # handle saving the query params
939 if self.form.has_key(':queryname'):
940 queryname = self.form[':queryname'].value.strip()
941 if queryname:
942 # parse the environment and figure what the query _is_
943 req = HTMLRequest(self)
944 url = req.indexargs_href('', {})
946 # handle editing an existing query
947 try:
948 qid = self.db.query.lookup(queryname)
949 self.db.query.set(qid, klass=self.classname, url=url)
950 except KeyError:
951 # create a query
952 qid = self.db.query.create(name=queryname,
953 klass=self.classname, url=url)
955 # and add it to the user's query multilink
956 queries = self.db.user.get(self.userid, 'queries')
957 queries.append(qid)
958 self.db.user.set(self.userid, queries=queries)
960 # commit the query change to the database
961 self.db.commit()
963 def searchPermission(self):
964 ''' Determine whether the user has permission to search this class.
966 Base behaviour is to check the user can view this class.
967 '''
968 if not self.db.security.hasPermission('View', self.userid,
969 self.classname):
970 return 0
971 return 1
973 def retireAction(self):
974 ''' Retire the context item.
975 '''
976 # if we want to view the index template now, then unset the nodeid
977 # context info (a special-case for retire actions on the index page)
978 nodeid = self.nodeid
979 if self.template == 'index':
980 self.nodeid = None
982 # generic edit is per-class only
983 if not self.retirePermission():
984 self.error_message.append(
985 _('You do not have permission to retire %s' %self.classname))
986 return
988 # make sure we don't try to retire admin or anonymous
989 if self.classname == 'user' and \
990 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
991 self.error_message.append(
992 _('You may not retire the admin or anonymous user'))
993 return
995 # do the retire
996 self.db.getclass(self.classname).retire(nodeid)
997 self.db.commit()
999 self.ok_message.append(
1000 _('%(classname)s %(itemid)s has been retired')%{
1001 'classname': self.classname.capitalize(), 'itemid': nodeid})
1003 def retirePermission(self):
1004 ''' Determine whether the user has permission to retire this class.
1006 Base behaviour is to check the user can edit this class.
1007 '''
1008 if not self.db.security.hasPermission('Edit', self.userid,
1009 self.classname):
1010 return 0
1011 return 1
1014 #
1015 # Utility methods for editing
1016 #
1017 def _changenode(self, props):
1018 ''' change the node based on the contents of the form
1019 '''
1020 cl = self.db.classes[self.classname]
1022 # create the message
1023 message, files = self._handle_message()
1024 if message:
1025 props['messages'] = cl.get(self.nodeid, 'messages') + [message]
1026 if files:
1027 props['files'] = cl.get(self.nodeid, 'files') + files
1029 # make the changes
1030 return cl.set(self.nodeid, **props)
1032 def _createnode(self, props):
1033 ''' create a node based on the contents of the form
1034 '''
1035 cl = self.db.classes[self.classname]
1037 # check for messages and files
1038 message, files = self._handle_message()
1039 if message:
1040 props['messages'] = [message]
1041 if files:
1042 props['files'] = files
1043 # create the node and return it's id
1044 return cl.create(**props)
1046 def _handle_message(self):
1047 ''' generate an edit message
1048 '''
1049 # handle file attachments
1050 files = []
1051 if self.form.has_key(':file'):
1052 file = self.form[':file']
1053 if file.filename:
1054 filename = file.filename.split('\\')[-1]
1055 mime_type = mimetypes.guess_type(filename)[0]
1056 if not mime_type:
1057 mime_type = "application/octet-stream"
1058 # create the new file entry
1059 files.append(self.db.file.create(type=mime_type,
1060 name=filename, content=file.file.read()))
1062 # we don't want to do a message if none of the following is true...
1063 cn = self.classname
1064 cl = self.db.classes[self.classname]
1065 props = cl.getprops()
1066 note = None
1067 # in a nutshell, don't do anything if there's no note or there's no
1068 # NOSY
1069 if self.form.has_key(':note'):
1070 # fix the CRLF/CR -> LF stuff
1071 note = fixNewlines(self.form[':note'].value.strip())
1072 if not note:
1073 return None, files
1074 if not props.has_key('messages'):
1075 return None, files
1076 if not isinstance(props['messages'], hyperdb.Multilink):
1077 return None, files
1078 if not props['messages'].classname == 'msg':
1079 return None, files
1080 if not (self.form.has_key('nosy') or note):
1081 return None, files
1083 # handle the note
1084 if '\n' in note:
1085 summary = re.split(r'\n\r?', note)[0]
1086 else:
1087 summary = note
1088 m = ['%s\n'%note]
1090 # handle the messageid
1091 # TODO: handle inreplyto
1092 messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
1093 self.classname, self.instance.config.MAIL_DOMAIN)
1095 # now create the message, attaching the files
1096 content = '\n'.join(m)
1097 message_id = self.db.msg.create(author=self.userid,
1098 recipients=[], date=date.Date('.'), summary=summary,
1099 content=content, files=files, messageid=messageid)
1101 # update the messages property
1102 return message_id, files
1104 def _post_editnode(self, nid):
1105 '''Do the linking part of the node creation.
1107 If a form element has :link or :multilink appended to it, its
1108 value specifies a node designator and the property on that node
1109 to add _this_ node to as a link or multilink.
1111 This is typically used on, eg. the file upload page to indicated
1112 which issue to link the file to.
1114 TODO: I suspect that this and newfile will go away now that
1115 there's the ability to upload a file using the issue :file form
1116 element!
1117 '''
1118 cn = self.classname
1119 cl = self.db.classes[cn]
1120 # link if necessary
1121 keys = self.form.keys()
1122 for key in keys:
1123 if key == ':multilink':
1124 value = self.form[key].value
1125 if type(value) != type([]): value = [value]
1126 for value in value:
1127 designator, property = value.split(':')
1128 link, nodeid = hyperdb.splitDesignator(designator)
1129 link = self.db.classes[link]
1130 # take a dupe of the list so we're not changing the cache
1131 value = link.get(nodeid, property)[:]
1132 value.append(nid)
1133 link.set(nodeid, **{property: value})
1134 elif key == ':link':
1135 value = self.form[key].value
1136 if type(value) != type([]): value = [value]
1137 for value in value:
1138 designator, property = value.split(':')
1139 link, nodeid = hyperdb.splitDesignator(designator)
1140 link = self.db.classes[link]
1141 link.set(nodeid, **{property: nid})
1143 def fixNewlines(text):
1144 ''' Homogenise line endings.
1146 Different web clients send different line ending values, but
1147 other systems (eg. email) don't necessarily handle those line
1148 endings. Our solution is to convert all line endings to LF.
1149 '''
1150 text = text.replace('\r\n', '\n')
1151 return text.replace('\r', '\n')
1153 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
1154 ''' Pull properties for the given class out of the form.
1156 If a ":required" parameter is supplied, then the names property values
1157 must be supplied or a ValueError will be raised.
1159 Other special form values:
1160 :remove:<propname>=id(s)
1161 The ids will be removed from the multilink property.
1162 :add:<propname>=id(s)
1163 The ids will be added to the multilink property.
1164 '''
1165 required = []
1166 if form.has_key(':required'):
1167 value = form[':required']
1168 if isinstance(value, type([])):
1169 required = [i.value.strip() for i in value]
1170 else:
1171 required = [i.strip() for i in value.value.split(',')]
1173 props = {}
1174 keys = form.keys()
1175 properties = cl.getprops()
1176 for key in keys:
1177 # see if we're performing a special multilink action
1178 mlaction = 'set'
1179 if key.startswith(':remove:'):
1180 propname = key[8:]
1181 mlaction = 'remove'
1182 elif key.startswith(':add:'):
1183 propname = key[5:]
1184 mlaction = 'add'
1185 else:
1186 propname = key
1189 # does the property exist?
1190 if not properties.has_key(propname):
1191 if mlaction != 'set':
1192 raise ValueError, 'You have submitted a remove action for'\
1193 ' the property "%s" which doesn\'t exist'%propname
1194 continue
1195 proptype = properties[propname]
1197 # Get the form value. This value may be a MiniFieldStorage or a list
1198 # of MiniFieldStorages.
1199 value = form[key]
1201 # make sure non-multilinks only get one value
1202 if not isinstance(proptype, hyperdb.Multilink):
1203 if isinstance(value, type([])):
1204 raise ValueError, 'You have submitted more than one value'\
1205 ' for the %s property'%propname
1206 # we've got a MiniFieldStorage, so pull out the value and strip
1207 # surrounding whitespace
1208 value = value.value.strip()
1210 if isinstance(proptype, hyperdb.String):
1211 if not value:
1212 continue
1213 # fix the CRLF/CR -> LF stuff
1214 value = fixNewlines(value)
1215 elif isinstance(proptype, hyperdb.Password):
1216 if not value:
1217 # ignore empty password values
1218 continue
1219 if not form.has_key('%s:confirm'%propname):
1220 raise ValueError, 'Password and confirmation text do not match'
1221 confirm = form['%s:confirm'%propname]
1222 if isinstance(confirm, type([])):
1223 raise ValueError, 'You have submitted more than one value'\
1224 ' for the %s property'%propname
1225 if value != confirm.value:
1226 raise ValueError, 'Password and confirmation text do not match'
1227 value = password.Password(value)
1228 elif isinstance(proptype, hyperdb.Date):
1229 if value:
1230 value = date.Date(value)
1231 else:
1232 value = None
1233 elif isinstance(proptype, hyperdb.Interval):
1234 if value:
1235 value = date.Interval(value)
1236 else:
1237 value = None
1238 elif isinstance(proptype, hyperdb.Link):
1239 # see if it's the "no selection" choice
1240 if value == '-1':
1241 value = None
1242 else:
1243 # handle key values
1244 link = proptype.classname
1245 if not num_re.match(value):
1246 try:
1247 value = db.classes[link].lookup(value)
1248 except KeyError:
1249 raise ValueError, _('property "%(propname)s": '
1250 '%(value)s not a %(classname)s')%{
1251 'propname': propname, 'value': value,
1252 'classname': link}
1253 except TypeError, message:
1254 raise ValueError, _('you may only enter ID values '
1255 'for property "%(propname)s": %(message)s')%{
1256 'propname': propname, 'message': message}
1257 elif isinstance(proptype, hyperdb.Multilink):
1258 if isinstance(value, type([])):
1259 # it's a list of MiniFieldStorages
1260 value = [i.value.strip() for i in value]
1261 else:
1262 # it's a MiniFieldStorage, but may be a comma-separated list
1263 # of values
1264 value = [i.strip() for i in value.value.split(',')]
1265 link = proptype.classname
1266 l = []
1267 for entry in map(str, value):
1268 if entry == '': continue
1269 if not num_re.match(entry):
1270 try:
1271 entry = db.classes[link].lookup(entry)
1272 except KeyError:
1273 raise ValueError, _('property "%(propname)s": '
1274 '"%(value)s" not an entry of %(classname)s')%{
1275 'propname': propname, 'value': entry,
1276 'classname': link}
1277 except TypeError, message:
1278 raise ValueError, _('you may only enter ID values '
1279 'for property "%(propname)s": %(message)s')%{
1280 'propname': propname, 'message': message}
1281 l.append(entry)
1282 l.sort()
1284 # now use that list of ids to modify the multilink
1285 if mlaction == 'set':
1286 value = l
1287 else:
1288 # we're modifying the list - get the current list of ids
1289 try:
1290 existing = cl.get(nodeid, propname)
1291 except KeyError:
1292 existing = []
1293 if mlaction == 'remove':
1294 # remove - handle situation where the id isn't in the list
1295 for entry in l:
1296 try:
1297 existing.remove(entry)
1298 except ValueError:
1299 raise ValueError, _('property "%(propname)s": '
1300 '"%(value)s" not currently in list')%{
1301 'propname': propname, 'value': entry}
1302 else:
1303 # add - easy, just don't dupe
1304 for entry in l:
1305 if entry not in existing:
1306 existing.append(entry)
1307 value = existing
1308 value.sort()
1310 elif isinstance(proptype, hyperdb.Boolean):
1311 value = value.lower() in ('yes', 'true', 'on', '1')
1312 elif isinstance(proptype, hyperdb.Number):
1313 value = int(value)
1315 # register this as received if required?
1316 if propname in required and value is not None:
1317 required.remove(propname)
1319 # get the old value
1320 if nodeid:
1321 try:
1322 existing = cl.get(nodeid, propname)
1323 except KeyError:
1324 # this might be a new property for which there is no existing
1325 # value
1326 if not properties.has_key(propname):
1327 raise
1329 # if changed, set it
1330 if value != existing:
1331 props[propname] = value
1332 else:
1333 props[propname] = value
1335 # see if all the required properties have been supplied
1336 if required:
1337 if len(required) > 1:
1338 p = 'properties'
1339 else:
1340 p = 'property'
1341 raise ValueError, 'Required %s %s not supplied'%(p, ', '.join(required))
1343 return props