03eaf1102c14436c9cc85723a02b60bf95d037b9
1 # $Id: client.py,v 1.54 2002-10-17 06:11:25 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 '''
667 cl = self.db.classes[self.classname]
669 # parse the props from the form
670 try:
671 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
672 except (ValueError, KeyError), message:
673 self.error_message.append(_('Error: ') + str(message))
674 return
676 # check permission
677 if not self.editItemPermission(props):
678 self.error_message.append(
679 _('You do not have permission to edit %(classname)s'%
680 self.__dict__))
681 return
683 # perform the edit
684 try:
685 # make changes to the node
686 props = self._changenode(props)
687 # handle linked nodes
688 self._post_editnode(self.nodeid)
689 except (ValueError, KeyError), message:
690 self.error_message.append(_('Error: ') + str(message))
691 return
693 # commit now that all the tricky stuff is done
694 self.db.commit()
696 # and some nice feedback for the user
697 if props:
698 message = _('%(changes)s edited ok')%{'changes':
699 ', '.join(props.keys())}
700 elif self.form.has_key(':note') and self.form[':note'].value:
701 message = _('note added')
702 elif (self.form.has_key(':file') and self.form[':file'].filename):
703 message = _('file added')
704 else:
705 message = _('nothing changed')
707 # redirect to the item's edit page
708 raise Redirect, '%s%s%s?:ok_message=%s'%(self.base, self.classname,
709 self.nodeid, urllib.quote(message))
711 def editItemPermission(self, props):
712 ''' Determine whether the user has permission to edit this item.
714 Base behaviour is to check the user can edit this class. If we're
715 editing the "user" class, users are allowed to edit their own
716 details. Unless it's the "roles" property, which requires the
717 special Permission "Web Roles".
718 '''
719 # if this is a user node and the user is editing their own node, then
720 # we're OK
721 has = self.db.security.hasPermission
722 if self.classname == 'user':
723 # reject if someone's trying to edit "roles" and doesn't have the
724 # right permission.
725 if props.has_key('roles') and not has('Web Roles', self.userid,
726 'user'):
727 return 0
728 # if the item being edited is the current user, we're ok
729 if self.nodeid == self.userid:
730 return 1
731 if self.db.security.hasPermission('Edit', self.userid, self.classname):
732 return 1
733 return 0
735 def newItemAction(self):
736 ''' Add a new item to the database.
738 This follows the same form as the editItemAction, with the same
739 special form values.
740 '''
741 cl = self.db.classes[self.classname]
743 # parse the props from the form
744 try:
745 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
746 except (ValueError, KeyError), message:
747 self.error_message.append(_('Error: ') + str(message))
748 return
750 if not self.newItemPermission(props):
751 self.error_message.append(
752 _('You do not have permission to create %s' %self.classname))
754 # create a little extra message for anticipated :link / :multilink
755 if self.form.has_key(':multilink'):
756 link = self.form[':multilink'].value
757 elif self.form.has_key(':link'):
758 link = self.form[':multilink'].value
759 else:
760 link = None
761 xtra = ''
762 if link:
763 designator, linkprop = link.split(':')
764 xtra = ' for <a href="%s">%s</a>'%(designator, designator)
766 try:
767 # do the create
768 nid = self._createnode(props)
770 # handle linked nodes
771 self._post_editnode(nid)
773 # commit now that all the tricky stuff is done
774 self.db.commit()
776 # render the newly created item
777 self.nodeid = nid
779 # and some nice feedback for the user
780 message = _('%(classname)s created ok')%self.__dict__ + xtra
781 except (ValueError, KeyError), message:
782 self.error_message.append(_('Error: ') + str(message))
783 return
784 except:
785 # oops
786 self.db.rollback()
787 s = StringIO.StringIO()
788 traceback.print_exc(None, s)
789 self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
790 return
792 # redirect to the new item's page
793 raise Redirect, '%s%s%s?:ok_message=%s'%(self.base, self.classname,
794 nid, urllib.quote(message))
796 def newItemPermission(self, props):
797 ''' Determine whether the user has permission to create (edit) this
798 item.
800 Base behaviour is to check the user can edit this class. No
801 additional property checks are made. Additionally, new user items
802 may be created if the user has the "Web Registration" Permission.
803 '''
804 has = self.db.security.hasPermission
805 if self.classname == 'user' and has('Web Registration', self.userid,
806 'user'):
807 return 1
808 if has('Edit', self.userid, self.classname):
809 return 1
810 return 0
812 def editCSVAction(self):
813 ''' Performs an edit of all of a class' items in one go.
815 The "rows" CGI var defines the CSV-formatted entries for the
816 class. New nodes are identified by the ID 'X' (or any other
817 non-existent ID) and removed lines are retired.
818 '''
819 # this is per-class only
820 if not self.editCSVPermission():
821 self.error_message.append(
822 _('You do not have permission to edit %s' %self.classname))
824 # get the CSV module
825 try:
826 import csv
827 except ImportError:
828 self.error_message.append(_(
829 'Sorry, you need the csv module to use this function.<br>\n'
830 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
831 return
833 cl = self.db.classes[self.classname]
834 idlessprops = cl.getprops(protected=0).keys()
835 idlessprops.sort()
836 props = ['id'] + idlessprops
838 # do the edit
839 rows = self.form['rows'].value.splitlines()
840 p = csv.parser()
841 found = {}
842 line = 0
843 for row in rows[1:]:
844 line += 1
845 values = p.parse(row)
846 # not a complete row, keep going
847 if not values: continue
849 # skip property names header
850 if values == props:
851 continue
853 # extract the nodeid
854 nodeid, values = values[0], values[1:]
855 found[nodeid] = 1
857 # confirm correct weight
858 if len(idlessprops) != len(values):
859 self.error_message.append(
860 _('Not enough values on line %(line)s')%{'line':line})
861 return
863 # extract the new values
864 d = {}
865 for name, value in zip(idlessprops, values):
866 value = value.strip()
867 # only add the property if it has a value
868 if value:
869 # if it's a multilink, split it
870 if isinstance(cl.properties[name], hyperdb.Multilink):
871 value = value.split(':')
872 d[name] = value
874 # perform the edit
875 if cl.hasnode(nodeid):
876 # edit existing
877 cl.set(nodeid, **d)
878 else:
879 # new node
880 found[cl.create(**d)] = 1
882 # retire the removed entries
883 for nodeid in cl.list():
884 if not found.has_key(nodeid):
885 cl.retire(nodeid)
887 # all OK
888 self.db.commit()
890 self.ok_message.append(_('Items edited OK'))
892 def editCSVPermission(self):
893 ''' Determine whether the user has permission to edit this class.
895 Base behaviour is to check the user can edit this class.
896 '''
897 if not self.db.security.hasPermission('Edit', self.userid,
898 self.classname):
899 return 0
900 return 1
902 def searchAction(self):
903 ''' Mangle some of the form variables.
905 Set the form ":filter" variable based on the values of the
906 filter variables - if they're set to anything other than
907 "dontcare" then add them to :filter.
909 Also handle the ":queryname" variable and save off the query to
910 the user's query list.
911 '''
912 # generic edit is per-class only
913 if not self.searchPermission():
914 self.error_message.append(
915 _('You do not have permission to search %s' %self.classname))
917 # add a faked :filter form variable for each filtering prop
918 props = self.db.classes[self.classname].getprops()
919 for key in self.form.keys():
920 if not props.has_key(key): continue
921 if not self.form[key].value: continue
922 self.form.value.append(cgi.MiniFieldStorage(':filter', key))
924 # handle saving the query params
925 if self.form.has_key(':queryname'):
926 queryname = self.form[':queryname'].value.strip()
927 if queryname:
928 # parse the environment and figure what the query _is_
929 req = HTMLRequest(self)
930 url = req.indexargs_href('', {})
932 # handle editing an existing query
933 try:
934 qid = self.db.query.lookup(queryname)
935 self.db.query.set(qid, klass=self.classname, url=url)
936 except KeyError:
937 # create a query
938 qid = self.db.query.create(name=queryname,
939 klass=self.classname, url=url)
941 # and add it to the user's query multilink
942 queries = self.db.user.get(self.userid, 'queries')
943 queries.append(qid)
944 self.db.user.set(self.userid, queries=queries)
946 # commit the query change to the database
947 self.db.commit()
949 def searchPermission(self):
950 ''' Determine whether the user has permission to search this class.
952 Base behaviour is to check the user can view this class.
953 '''
954 if not self.db.security.hasPermission('View', self.userid,
955 self.classname):
956 return 0
957 return 1
959 def retireAction(self):
960 ''' Retire the context item.
961 '''
962 # if we want to view the index template now, then unset the nodeid
963 # context info (a special-case for retire actions on the index page)
964 nodeid = self.nodeid
965 if self.template == 'index':
966 self.nodeid = None
968 # generic edit is per-class only
969 if not self.retirePermission():
970 self.error_message.append(
971 _('You do not have permission to retire %s' %self.classname))
972 return
974 # make sure we don't try to retire admin or anonymous
975 if self.classname == 'user' and \
976 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
977 self.error_message.append(
978 _('You may not retire the admin or anonymous user'))
979 return
981 # do the retire
982 self.db.getclass(self.classname).retire(nodeid)
983 self.db.commit()
985 self.ok_message.append(
986 _('%(classname)s %(itemid)s has been retired')%{
987 'classname': self.classname.capitalize(), 'itemid': nodeid})
989 def retirePermission(self):
990 ''' Determine whether the user has permission to retire this class.
992 Base behaviour is to check the user can edit this class.
993 '''
994 if not self.db.security.hasPermission('Edit', self.userid,
995 self.classname):
996 return 0
997 return 1
1000 #
1001 # Utility methods for editing
1002 #
1003 def _changenode(self, props):
1004 ''' change the node based on the contents of the form
1005 '''
1006 cl = self.db.classes[self.classname]
1008 # create the message
1009 message, files = self._handle_message()
1010 if message:
1011 props['messages'] = cl.get(self.nodeid, 'messages') + [message]
1012 if files:
1013 props['files'] = cl.get(self.nodeid, 'files') + files
1015 # make the changes
1016 return cl.set(self.nodeid, **props)
1018 def _createnode(self, props):
1019 ''' create a node based on the contents of the form
1020 '''
1021 cl = self.db.classes[self.classname]
1023 # check for messages and files
1024 message, files = self._handle_message()
1025 if message:
1026 props['messages'] = [message]
1027 if files:
1028 props['files'] = files
1029 # create the node and return it's id
1030 return cl.create(**props)
1032 def _handle_message(self):
1033 ''' generate an edit message
1034 '''
1035 # handle file attachments
1036 files = []
1037 if self.form.has_key(':file'):
1038 file = self.form[':file']
1039 if file.filename:
1040 filename = file.filename.split('\\')[-1]
1041 mime_type = mimetypes.guess_type(filename)[0]
1042 if not mime_type:
1043 mime_type = "application/octet-stream"
1044 # create the new file entry
1045 files.append(self.db.file.create(type=mime_type,
1046 name=filename, content=file.file.read()))
1048 # we don't want to do a message if none of the following is true...
1049 cn = self.classname
1050 cl = self.db.classes[self.classname]
1051 props = cl.getprops()
1052 note = None
1053 # in a nutshell, don't do anything if there's no note or there's no
1054 # NOSY
1055 if self.form.has_key(':note'):
1056 # fix the CRLF/CR -> LF stuff
1057 note = fixNewlines(self.form[':note'].value.strip())
1058 if not note:
1059 return None, files
1060 if not props.has_key('messages'):
1061 return None, files
1062 if not isinstance(props['messages'], hyperdb.Multilink):
1063 return None, files
1064 if not props['messages'].classname == 'msg':
1065 return None, files
1066 if not (self.form.has_key('nosy') or note):
1067 return None, files
1069 # handle the note
1070 if '\n' in note:
1071 summary = re.split(r'\n\r?', note)[0]
1072 else:
1073 summary = note
1074 m = ['%s\n'%note]
1076 # handle the messageid
1077 # TODO: handle inreplyto
1078 messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
1079 self.classname, self.instance.config.MAIL_DOMAIN)
1081 # now create the message, attaching the files
1082 content = '\n'.join(m)
1083 message_id = self.db.msg.create(author=self.userid,
1084 recipients=[], date=date.Date('.'), summary=summary,
1085 content=content, files=files, messageid=messageid)
1087 # update the messages property
1088 return message_id, files
1090 def _post_editnode(self, nid):
1091 '''Do the linking part of the node creation.
1093 If a form element has :link or :multilink appended to it, its
1094 value specifies a node designator and the property on that node
1095 to add _this_ node to as a link or multilink.
1097 This is typically used on, eg. the file upload page to indicated
1098 which issue to link the file to.
1100 TODO: I suspect that this and newfile will go away now that
1101 there's the ability to upload a file using the issue :file form
1102 element!
1103 '''
1104 cn = self.classname
1105 cl = self.db.classes[cn]
1106 # link if necessary
1107 keys = self.form.keys()
1108 for key in keys:
1109 if key == ':multilink':
1110 value = self.form[key].value
1111 if type(value) != type([]): value = [value]
1112 for value in value:
1113 designator, property = value.split(':')
1114 link, nodeid = hyperdb.splitDesignator(designator)
1115 link = self.db.classes[link]
1116 # take a dupe of the list so we're not changing the cache
1117 value = link.get(nodeid, property)[:]
1118 value.append(nid)
1119 link.set(nodeid, **{property: value})
1120 elif key == ':link':
1121 value = self.form[key].value
1122 if type(value) != type([]): value = [value]
1123 for value in value:
1124 designator, property = value.split(':')
1125 link, nodeid = hyperdb.splitDesignator(designator)
1126 link = self.db.classes[link]
1127 link.set(nodeid, **{property: nid})
1129 def fixNewlines(text):
1130 ''' Homogenise line endings.
1132 Different web clients send different line ending values, but
1133 other systems (eg. email) don't necessarily handle those line
1134 endings. Our solution is to convert all line endings to LF.
1135 '''
1136 text = text.replace('\r\n', '\n')
1137 return text.replace('\r', '\n')
1139 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
1140 ''' Pull properties for the given class out of the form.
1142 If a ":required" parameter is supplied, then the names property values
1143 must be supplied or a ValueError will be raised.
1144 '''
1145 required = []
1146 if form.has_key(':required'):
1147 value = form[':required']
1148 if isinstance(value, type([])):
1149 required = [i.value.strip() for i in value]
1150 else:
1151 required = [i.strip() for i in value.value.split(',')]
1153 props = {}
1154 keys = form.keys()
1155 properties = cl.getprops()
1156 for key in keys:
1157 if not properties.has_key(key):
1158 continue
1159 proptype = properties[key]
1161 # Get the form value. This value may be a MiniFieldStorage or a list
1162 # of MiniFieldStorages.
1163 value = form[key]
1165 # make sure non-multilinks only get one value
1166 if not isinstance(proptype, hyperdb.Multilink):
1167 if isinstance(value, type([])):
1168 raise ValueError, 'You have submitted more than one value'\
1169 ' for the %s property'%key
1170 # we've got a MiniFieldStorage, so pull out the value and strip
1171 # surrounding whitespace
1172 value = value.value.strip()
1174 if isinstance(proptype, hyperdb.String):
1175 if not value:
1176 continue
1177 # fix the CRLF/CR -> LF stuff
1178 value = fixNewlines(value)
1179 elif isinstance(proptype, hyperdb.Password):
1180 if not value:
1181 # ignore empty password values
1182 continue
1183 if not form.has_key('%s:confirm'%key):
1184 raise ValueError, 'Password and confirmation text do not match'
1185 confirm = form['%s:confirm'%key]
1186 if isinstance(confirm, type([])):
1187 raise ValueError, 'You have submitted more than one value'\
1188 ' for the %s property'%key
1189 if value != confirm.value:
1190 raise ValueError, 'Password and confirmation text do not match'
1191 value = password.Password(value)
1192 elif isinstance(proptype, hyperdb.Date):
1193 if value:
1194 value = date.Date(form[key].value.strip())
1195 else:
1196 continue
1197 elif isinstance(proptype, hyperdb.Interval):
1198 if value:
1199 value = date.Interval(form[key].value.strip())
1200 else:
1201 continue
1202 elif isinstance(proptype, hyperdb.Link):
1203 # see if it's the "no selection" choice
1204 if value == '-1':
1205 value = None
1206 else:
1207 # handle key values
1208 link = proptype.classname
1209 if not num_re.match(value):
1210 try:
1211 value = db.classes[link].lookup(value)
1212 except KeyError:
1213 raise ValueError, _('property "%(propname)s": '
1214 '%(value)s not a %(classname)s')%{'propname':key,
1215 'value': value, 'classname': link}
1216 except TypeError, message:
1217 raise ValueError, _('you may only enter ID values '
1218 'for property "%(propname)s": %(message)s')%{
1219 'propname':key, 'message': message}
1220 elif isinstance(proptype, hyperdb.Multilink):
1221 if isinstance(value, type([])):
1222 # it's a list of MiniFieldStorages
1223 value = [i.value.strip() for i in value]
1224 else:
1225 # it's a MiniFieldStorage, but may be a comma-separated list
1226 # of values
1227 value = [i.strip() for i in value.value.split(',')]
1228 link = proptype.classname
1229 l = []
1230 for entry in map(str, value):
1231 if entry == '': continue
1232 if not num_re.match(entry):
1233 try:
1234 entry = db.classes[link].lookup(entry)
1235 except KeyError:
1236 raise ValueError, _('property "%(propname)s": '
1237 '"%(value)s" not an entry of %(classname)s')%{
1238 'propname':key, 'value': entry, 'classname': link}
1239 except TypeError, message:
1240 raise ValueError, _('you may only enter ID values '
1241 'for property "%(propname)s": %(message)s')%{
1242 'propname':key, 'message': message}
1243 l.append(entry)
1244 l.sort()
1245 value = l
1246 elif isinstance(proptype, hyperdb.Boolean):
1247 props[key] = value = value.lower() in ('yes', 'true', 'on', '1')
1248 elif isinstance(proptype, hyperdb.Number):
1249 props[key] = value = int(value)
1251 # register this as received if required?
1252 if key in required and value is not None:
1253 required.remove(key)
1255 # get the old value
1256 if nodeid:
1257 try:
1258 existing = cl.get(nodeid, key)
1259 except KeyError:
1260 # this might be a new property for which there is no existing
1261 # value
1262 if not properties.has_key(key): raise
1264 # if changed, set it
1265 if value != existing:
1266 props[key] = value
1267 else:
1268 props[key] = value
1270 # see if all the required properties have been supplied
1271 if required:
1272 if len(required) > 1:
1273 p = 'properties'
1274 else:
1275 p = 'property'
1276 raise ValueError, 'Required %s %s not supplied'%(p, ', '.join(required))
1278 return props