1 # $Id: client.py,v 1.37 2002-09-16 22:37:26 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 getTemplate, 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 '''
52 A note about login
53 ------------------
55 If the user has no login cookie, then they are anonymous. There
56 are two levels of anonymous use. If there is no 'anonymous' user, there
57 is no login at all and the database is opened in read-only mode. If the
58 'anonymous' user exists, the user is logged in using that user (though
59 there is no cookie). This allows them to modify the database, and all
60 modifications are attributed to the 'anonymous' user.
62 Once a user logs in, they are assigned a session. The Client instance
63 keeps the nodeid of the session as the "session" attribute.
65 Client attributes:
66 "path" is the PATH_INFO inside the instance (with no leading '/')
67 "base" is the base URL for the instance
68 '''
70 def __init__(self, instance, request, env, form=None):
71 hyperdb.traceMark()
72 self.instance = instance
73 self.request = request
74 self.env = env
76 # save off the path
77 self.path = env['PATH_INFO']
79 # this is the base URL for this instance
80 self.base = self.instance.config.TRACKER_WEB
82 # see if we need to re-parse the environment for the form (eg Zope)
83 if form is None:
84 self.form = cgi.FieldStorage(environ=env)
85 else:
86 self.form = form
88 # turn debugging on/off
89 try:
90 self.debug = int(env.get("ROUNDUP_DEBUG", 0))
91 except ValueError:
92 # someone gave us a non-int debug level, turn it off
93 self.debug = 0
95 # flag to indicate that the HTTP headers have been sent
96 self.headers_done = 0
98 # additional headers to send with the request - must be registered
99 # before the first write
100 self.additional_headers = {}
101 self.response_code = 200
103 def main(self):
104 ''' Wrap the real main in a try/finally so we always close off the db.
105 '''
106 try:
107 self.inner_main()
108 finally:
109 if hasattr(self, 'db'):
110 self.db.close()
112 def inner_main(self):
113 ''' Process a request.
115 The most common requests are handled like so:
116 1. figure out who we are, defaulting to the "anonymous" user
117 see determine_user
118 2. figure out what the request is for - the context
119 see determine_context
120 3. handle any requested action (item edit, search, ...)
121 see handle_action
122 4. render a template, resulting in HTML output
124 In some situations, exceptions occur:
125 - HTTP Redirect (generally raised by an action)
126 - SendFile (generally raised by determine_context)
127 serve up a FileClass "content" property
128 - SendStaticFile (generally raised by determine_context)
129 serve up a file from the tracker "html" directory
130 - Unauthorised (generally raised by an action)
131 the action is cancelled, the request is rendered and an error
132 message is displayed indicating that permission was not
133 granted for the action to take place
134 - NotFound (raised wherever it needs to be)
135 percolates up to the CGI interface that called the client
136 '''
137 self.content_action = None
138 self.ok_message = []
139 self.error_message = []
140 try:
141 # make sure we're identified (even anonymously)
142 self.determine_user()
143 # figure out the context and desired content template
144 self.determine_context()
145 # possibly handle a form submit action (may change self.classname
146 # and self.template, and may also append error/ok_messages)
147 self.handle_action()
148 # now render the page
150 # we don't want clients caching our dynamic pages
151 self.additional_headers['Cache-Control'] = 'no-cache'
152 self.additional_headers['Pragma'] = 'no-cache'
153 self.additional_headers['Expires'] = 'Thu, 1 Jan 1970 00:00:00 GMT'
155 if self.form.has_key(':contentonly'):
156 # just the content
157 self.write(self.content())
158 else:
159 # render the content inside the page template
160 self.write(self.renderTemplate('page', '',
161 ok_message=self.ok_message,
162 error_message=self.error_message))
163 except Redirect, url:
164 # let's redirect - if the url isn't None, then we need to do
165 # the headers, otherwise the headers have been set before the
166 # exception was raised
167 if url:
168 self.additional_headers['Location'] = url
169 self.response_code = 302
170 self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
171 except SendFile, designator:
172 self.serve_file(designator)
173 except SendStaticFile, file:
174 self.serve_static_file(str(file))
175 except Unauthorised, message:
176 self.write(self.renderTemplate('page', '', error_message=message))
177 except:
178 # everything else
179 self.write(cgitb.html())
181 def determine_user(self):
182 ''' Determine who the user is
183 '''
184 # determine the uid to use
185 self.opendb('admin')
187 # make sure we have the session Class
188 sessions = self.db.sessions
190 # age sessions, remove when they haven't been used for a week
191 # TODO: this shouldn't be done every access
192 week = 60*60*24*7
193 now = time.time()
194 for sessid in sessions.list():
195 interval = now - sessions.get(sessid, 'last_use')
196 if interval > week:
197 sessions.destroy(sessid)
199 # look up the user session cookie
200 cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
201 user = 'anonymous'
203 # bump the "revision" of the cookie since the format changed
204 if (cookie.has_key('roundup_user_2') and
205 cookie['roundup_user_2'].value != 'deleted'):
207 # get the session key from the cookie
208 self.session = cookie['roundup_user_2'].value
209 # get the user from the session
210 try:
211 # update the lifetime datestamp
212 sessions.set(self.session, last_use=time.time())
213 sessions.commit()
214 user = sessions.get(self.session, 'user')
215 except KeyError:
216 user = 'anonymous'
218 # sanity check on the user still being valid, getting the userid
219 # at the same time
220 try:
221 self.userid = self.db.user.lookup(user)
222 except (KeyError, TypeError):
223 user = 'anonymous'
225 # make sure the anonymous user is valid if we're using it
226 if user == 'anonymous':
227 self.make_user_anonymous()
228 else:
229 self.user = user
231 # reopen the database as the correct user
232 self.opendb(self.user)
234 def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
235 ''' Determine the context of this page from the URL:
237 The URL path after the instance identifier is examined. The path
238 is generally only one entry long.
240 - if there is no path, then we are in the "home" context.
241 * if the path is "_file", then the additional path entry
242 specifies the filename of a static file we're to serve up
243 from the instance "html" directory. Raises a SendStaticFile
244 exception.
245 - if there is something in the path (eg "issue"), it identifies
246 the tracker class we're to display.
247 - if the path is an item designator (eg "issue123"), then we're
248 to display a specific item.
249 * if the path starts with an item designator and is longer than
250 one entry, then we're assumed to be handling an item of a
251 FileClass, and the extra path information gives the filename
252 that the client is going to label the download with (ie
253 "file123/image.png" is nicer to download than "file123"). This
254 raises a SendFile exception.
256 Both of the "*" types of contexts stop before we bother to
257 determine the template we're going to use. That's because they
258 don't actually use templates.
260 The template used is specified by the :template CGI variable,
261 which defaults to:
263 only classname suplied: "index"
264 full item designator supplied: "item"
266 We set:
267 self.classname - the class to display, can be None
268 self.template - the template to render the current context with
269 self.nodeid - the nodeid of the class we're displaying
270 '''
271 # default the optional variables
272 self.classname = None
273 self.nodeid = None
275 # determine the classname and possibly nodeid
276 path = self.path.split('/')
277 if not path or path[0] in ('', 'home', 'index'):
278 if self.form.has_key(':template'):
279 self.template = self.form[':template'].value
280 else:
281 self.template = ''
282 return
283 elif path[0] == '_file':
284 raise SendStaticFile, path[1]
285 else:
286 self.classname = path[0]
287 if len(path) > 1:
288 # send the file identified by the designator in path[0]
289 raise SendFile, path[0]
291 # see if we got a designator
292 m = dre.match(self.classname)
293 if m:
294 self.classname = m.group(1)
295 self.nodeid = m.group(2)
296 if not self.db.getclass(self.classname).hasnode(self.nodeid):
297 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
298 # with a designator, we default to item view
299 self.template = 'item'
300 else:
301 # with only a class, we default to index view
302 self.template = 'index'
304 # see if we have a template override
305 if self.form.has_key(':template'):
306 self.template = self.form[':template'].value
308 # see if we were passed in a message
309 if self.form.has_key(':ok_message'):
310 self.ok_message.append(self.form[':ok_message'].value)
311 if self.form.has_key(':error_message'):
312 self.error_message.append(self.form[':error_message'].value)
314 def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
315 ''' Serve the file from the content property of the designated item.
316 '''
317 m = dre.match(str(designator))
318 if not m:
319 raise NotFound, str(designator)
320 classname, nodeid = m.group(1), m.group(2)
321 if classname != 'file':
322 raise NotFound, designator
324 # we just want to serve up the file named
325 file = self.db.file
326 self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
327 self.write(file.get(nodeid, 'content'))
329 def serve_static_file(self, file):
330 # we just want to serve up the file named
331 mt = mimetypes.guess_type(str(file))[0]
332 self.additional_headers['Content-Type'] = mt
333 self.write(open(os.path.join(self.instance.config.TEMPLATES,
334 file)).read())
336 def renderTemplate(self, name, extension, **kwargs):
337 ''' Return a PageTemplate for the named page
338 '''
339 pt = getTemplate(self.instance.config.TEMPLATES, name, extension)
340 # catch errors so we can handle PT rendering errors more nicely
341 try:
342 # let the template render figure stuff out
343 return pt.render(self, None, None, **kwargs)
344 except PageTemplate.PTRuntimeError, message:
345 return '<strong>%s</strong><ol><li>%s</ol>'%(message,
346 '<li>'.join([cgi.escape(x) for x in pt._v_errors]))
347 except NoTemplate, message:
348 return '<strong>%s</strong>'%message
349 except:
350 # everything else
351 return cgitb.pt_html()
353 def content(self):
354 ''' Callback used by the page template to render the content of
355 the page.
357 If we don't have a specific class to display, that is none was
358 determined in determine_context(), then we display a "home"
359 template.
360 '''
361 # now render the page content using the template we determined in
362 # determine_context
363 if self.classname is None:
364 name = 'home'
365 else:
366 name = self.classname
367 return self.renderTemplate(self.classname, self.template)
369 # these are the actions that are available
370 actions = (
371 ('edit', 'editItemAction'),
372 ('editCSV', 'editCSVAction'),
373 ('new', 'newItemAction'),
374 ('register', 'registerAction'),
375 ('login', 'loginAction'),
376 ('logout', 'logout_action'),
377 ('search', 'searchAction'),
378 )
379 def handle_action(self):
380 ''' Determine whether there should be an _action called.
382 The action is defined by the form variable :action which
383 identifies the method on this object to call. The four basic
384 actions are defined in the "actions" sequence on this class:
385 "edit" -> self.editItemAction
386 "new" -> self.newItemAction
387 "register" -> self.registerAction
388 "login" -> self.loginAction
389 "logout" -> self.logout_action
390 "search" -> self.searchAction
392 '''
393 if not self.form.has_key(':action'):
394 return None
395 try:
396 # get the action, validate it
397 action = self.form[':action'].value
398 for name, method in self.actions:
399 if name == action:
400 break
401 else:
402 raise ValueError, 'No such action "%s"'%action
404 # call the mapped action
405 getattr(self, method)()
406 except Redirect:
407 raise
408 except Unauthorised:
409 raise
410 except:
411 self.db.rollback()
412 s = StringIO.StringIO()
413 traceback.print_exc(None, s)
414 self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
416 def write(self, content):
417 if not self.headers_done:
418 self.header()
419 self.request.wfile.write(content)
421 def header(self, headers=None, response=None):
422 '''Put up the appropriate header.
423 '''
424 if headers is None:
425 headers = {'Content-Type':'text/html'}
426 if response is None:
427 response = self.response_code
429 # update with additional info
430 headers.update(self.additional_headers)
432 if not headers.has_key('Content-Type'):
433 headers['Content-Type'] = 'text/html'
434 self.request.send_response(response)
435 for entry in headers.items():
436 self.request.send_header(*entry)
437 self.request.end_headers()
438 self.headers_done = 1
439 if self.debug:
440 self.headers_sent = headers
442 def set_cookie(self, user, password):
443 # TODO generate a much, much stronger session key ;)
444 self.session = binascii.b2a_base64(repr(random.random())).strip()
446 # clean up the base64
447 if self.session[-1] == '=':
448 if self.session[-2] == '=':
449 self.session = self.session[:-2]
450 else:
451 self.session = self.session[:-1]
453 # insert the session in the sessiondb
454 self.db.sessions.set(self.session, user=user, last_use=time.time())
456 # and commit immediately
457 self.db.sessions.commit()
459 # expire us in a long, long time
460 expire = Cookie._getdate(86400*365)
462 # generate the cookie path - make sure it has a trailing '/'
463 path = '/'.join((self.env['SCRIPT_NAME'], self.env['TRACKER_NAME'],
464 ''))
465 self.additional_headers['Set-Cookie'] = \
466 'roundup_user_2=%s; expires=%s; Path=%s;'%(self.session, expire, path)
468 def make_user_anonymous(self):
469 ''' Make us anonymous
471 This method used to handle non-existence of the 'anonymous'
472 user, but that user is mandatory now.
473 '''
474 self.userid = self.db.user.lookup('anonymous')
475 self.user = 'anonymous'
477 def logout(self):
478 ''' Make us really anonymous - nuke the cookie too
479 '''
480 self.make_user_anonymous()
482 # construct the logout cookie
483 now = Cookie._getdate()
484 path = '/'.join((self.env['SCRIPT_NAME'], self.env['TRACKER_NAME'],
485 ''))
486 self.additional_headers['Set-Cookie'] = \
487 'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)
488 self.login()
490 def opendb(self, user):
491 ''' Open the database.
492 '''
493 # open the db if the user has changed
494 if not hasattr(self, 'db') or user != self.db.journaltag:
495 self.db = self.instance.open(user)
497 #
498 # Actions
499 #
500 def loginAction(self):
501 ''' Attempt to log a user in.
503 Sets up a session for the user which contains the login
504 credentials.
505 '''
506 # we need the username at a minimum
507 if not self.form.has_key('__login_name'):
508 self.error_message.append(_('Username required'))
509 return
511 self.user = self.form['__login_name'].value
512 # re-open the database for real, using the user
513 self.opendb(self.user)
514 if self.form.has_key('__login_password'):
515 password = self.form['__login_password'].value
516 else:
517 password = ''
518 # make sure the user exists
519 try:
520 self.userid = self.db.user.lookup(self.user)
521 except KeyError:
522 name = self.user
523 self.make_user_anonymous()
524 self.error_message.append(_('No such user "%(name)s"')%locals())
525 return
527 # and that the password is correct
528 pw = self.db.user.get(self.userid, 'password')
529 if password != pw:
530 self.make_user_anonymous()
531 self.error_message.append(_('Incorrect password'))
532 return
534 # make sure we're allowed to be here
535 if not self.loginPermission():
536 self.make_user_anonymous()
537 raise Unauthorised, _("You do not have permission to login")
539 # set the session cookie
540 self.set_cookie(self.user, password)
542 def loginPermission(self):
543 ''' Determine whether the user has permission to log in.
545 Base behaviour is to check the user has "Web Access".
546 '''
547 if not self.db.security.hasPermission('Web Access', self.userid):
548 return 0
549 return 1
551 def logout_action(self):
552 ''' Make us really anonymous - nuke the cookie too
553 '''
554 # log us out
555 self.make_user_anonymous()
557 # construct the logout cookie
558 now = Cookie._getdate()
559 path = '/'.join((self.env['SCRIPT_NAME'], self.env['TRACKER_NAME'],
560 ''))
561 self.additional_headers['Set-Cookie'] = \
562 'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)
564 # Let the user know what's going on
565 self.ok_message.append(_('You are logged out'))
567 def registerAction(self):
568 '''Attempt to create a new user based on the contents of the form
569 and then set the cookie.
571 return 1 on successful login
572 '''
573 # create the new user
574 cl = self.db.user
576 # parse the props from the form
577 try:
578 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
579 except (ValueError, KeyError), message:
580 self.error_message.append(_('Error: ') + str(message))
581 return
583 # make sure we're allowed to register
584 if not self.registerPermission(props):
585 raise Unauthorised, _("You do not have permission to register")
587 # re-open the database as "admin"
588 if self.user != 'admin':
589 self.opendb('admin')
591 # create the new user
592 cl = self.db.user
593 try:
594 props = parsePropsFromForm(self.db, cl, self.form)
595 props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
596 self.userid = cl.create(**props)
597 self.db.commit()
598 except ValueError, message:
599 self.error_message.append(message)
600 return
602 # log the new user in
603 self.user = cl.get(self.userid, 'username')
604 # re-open the database for real, using the user
605 self.opendb(self.user)
606 password = self.db.user.get(self.userid, 'password')
607 self.set_cookie(self.user, password)
609 # nice message
610 message = _('You are now registered, welcome!')
612 # redirect to the item's edit page
613 raise Redirect, '%s/%s%s?:ok_message=%s'%(
614 self.base, self.classname, self.userid, urllib.quote(message))
616 def registerPermission(self, props):
617 ''' Determine whether the user has permission to register
619 Base behaviour is to check the user has "Web Registration".
620 '''
621 # registration isn't allowed to supply roles
622 if props.has_key('roles'):
623 return 0
624 if self.db.security.hasPermission('Web Registration', self.userid):
625 return 1
626 return 0
628 def editItemAction(self):
629 ''' Perform an edit of an item in the database.
631 Some special form elements:
633 :link=designator:property
634 :multilink=designator:property
635 The value specifies a node designator and the property on that
636 node to add _this_ node to as a link or multilink.
637 :note
638 Create a message and attach it to the current node's
639 "messages" property.
640 :file
641 Create a file and attach it to the current node's
642 "files" property. Attach the file to the message created from
643 the :note if it's supplied.
645 :required=property,property,...
646 The named properties are required to be filled in the form.
648 '''
649 cl = self.db.classes[self.classname]
651 # parse the props from the form
652 try:
653 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
654 except (ValueError, KeyError), message:
655 self.error_message.append(_('Error: ') + str(message))
656 return
658 # check permission
659 if not self.editItemPermission(props):
660 self.error_message.append(
661 _('You do not have permission to edit %(classname)s'%
662 self.__dict__))
663 return
665 # perform the edit
666 try:
667 # make changes to the node
668 props = self._changenode(props)
669 # handle linked nodes
670 self._post_editnode(self.nodeid)
671 except (ValueError, KeyError), message:
672 self.error_message.append(_('Error: ') + str(message))
673 return
675 # commit now that all the tricky stuff is done
676 self.db.commit()
678 # and some nice feedback for the user
679 if props:
680 message = _('%(changes)s edited ok')%{'changes':
681 ', '.join(props.keys())}
682 elif self.form.has_key(':note') and self.form[':note'].value:
683 message = _('note added')
684 elif (self.form.has_key(':file') and self.form[':file'].filename):
685 message = _('file added')
686 else:
687 message = _('nothing changed')
689 # redirect to the item's edit page
690 raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
691 self.nodeid, urllib.quote(message))
693 def editItemPermission(self, props):
694 ''' Determine whether the user has permission to edit this item.
696 Base behaviour is to check the user can edit this class. If we're
697 editing the "user" class, users are allowed to edit their own
698 details. Unless it's the "roles" property, which requires the
699 special Permission "Web Roles".
700 '''
701 # if this is a user node and the user is editing their own node, then
702 # we're OK
703 has = self.db.security.hasPermission
704 if self.classname == 'user':
705 # reject if someone's trying to edit "roles" and doesn't have the
706 # right permission.
707 if props.has_key('roles') and not has('Web Roles', self.userid,
708 'user'):
709 return 0
710 # if the item being edited is the current user, we're ok
711 if self.nodeid == self.userid:
712 return 1
713 if self.db.security.hasPermission('Edit', self.userid, self.classname):
714 return 1
715 return 0
717 def newItemAction(self):
718 ''' Add a new item to the database.
720 This follows the same form as the editItemAction, with the same
721 special form values.
722 '''
723 cl = self.db.classes[self.classname]
725 # parse the props from the form
726 try:
727 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
728 except (ValueError, KeyError), message:
729 self.error_message.append(_('Error: ') + str(message))
730 return
732 if not self.newItemPermission(props):
733 self.error_message.append(
734 _('You do not have permission to create %s' %self.classname))
736 # create a little extra message for anticipated :link / :multilink
737 if self.form.has_key(':multilink'):
738 link = self.form[':multilink'].value
739 elif self.form.has_key(':link'):
740 link = self.form[':multilink'].value
741 else:
742 link = None
743 xtra = ''
744 if link:
745 designator, linkprop = link.split(':')
746 xtra = ' for <a href="%s">%s</a>'%(designator, designator)
748 try:
749 # do the create
750 nid = self._createnode(props)
752 # handle linked nodes
753 self._post_editnode(nid)
755 # commit now that all the tricky stuff is done
756 self.db.commit()
758 # render the newly created item
759 self.nodeid = nid
761 # and some nice feedback for the user
762 message = _('%(classname)s created ok')%self.__dict__ + xtra
763 except (ValueError, KeyError), message:
764 self.error_message.append(_('Error: ') + str(message))
765 return
766 except:
767 # oops
768 self.db.rollback()
769 s = StringIO.StringIO()
770 traceback.print_exc(None, s)
771 self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
772 return
774 # redirect to the new item's page
775 raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
776 nid, urllib.quote(message))
778 def newItemPermission(self, props):
779 ''' Determine whether the user has permission to create (edit) this
780 item.
782 Base behaviour is to check the user can edit this class. No
783 additional property checks are made. Additionally, new user items
784 may be created if the user has the "Web Registration" Permission.
785 '''
786 has = self.db.security.hasPermission
787 if self.classname == 'user' and has('Web Registration', self.userid,
788 'user'):
789 return 1
790 if has('Edit', self.userid, self.classname):
791 return 1
792 return 0
794 def editCSVAction(self):
795 ''' Performs an edit of all of a class' items in one go.
797 The "rows" CGI var defines the CSV-formatted entries for the
798 class. New nodes are identified by the ID 'X' (or any other
799 non-existent ID) and removed lines are retired.
800 '''
801 # this is per-class only
802 if not self.editCSVPermission():
803 self.error_message.append(
804 _('You do not have permission to edit %s' %self.classname))
806 # get the CSV module
807 try:
808 import csv
809 except ImportError:
810 self.error_message.append(_(
811 'Sorry, you need the csv module to use this function.<br>\n'
812 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
813 return
815 cl = self.db.classes[self.classname]
816 idlessprops = cl.getprops(protected=0).keys()
817 idlessprops.sort()
818 props = ['id'] + idlessprops
820 # do the edit
821 rows = self.form['rows'].value.splitlines()
822 p = csv.parser()
823 found = {}
824 line = 0
825 for row in rows[1:]:
826 line += 1
827 values = p.parse(row)
828 # not a complete row, keep going
829 if not values: continue
831 # skip property names header
832 if values == props:
833 continue
835 # extract the nodeid
836 nodeid, values = values[0], values[1:]
837 found[nodeid] = 1
839 # confirm correct weight
840 if len(idlessprops) != len(values):
841 self.error_message.append(
842 _('Not enough values on line %(line)s')%{'line':line})
843 return
845 # extract the new values
846 d = {}
847 for name, value in zip(idlessprops, values):
848 value = value.strip()
849 # only add the property if it has a value
850 if value:
851 # if it's a multilink, split it
852 if isinstance(cl.properties[name], hyperdb.Multilink):
853 value = value.split(':')
854 d[name] = value
856 # perform the edit
857 if cl.hasnode(nodeid):
858 # edit existing
859 cl.set(nodeid, **d)
860 else:
861 # new node
862 found[cl.create(**d)] = 1
864 # retire the removed entries
865 for nodeid in cl.list():
866 if not found.has_key(nodeid):
867 cl.retire(nodeid)
869 # all OK
870 self.db.commit()
872 self.ok_message.append(_('Items edited OK'))
874 def editCSVPermission(self):
875 ''' Determine whether the user has permission to edit this class.
877 Base behaviour is to check the user can edit this class.
878 '''
879 if not self.db.security.hasPermission('Edit', self.userid,
880 self.classname):
881 return 0
882 return 1
884 def searchAction(self):
885 ''' Mangle some of the form variables.
887 Set the form ":filter" variable based on the values of the
888 filter variables - if they're set to anything other than
889 "dontcare" then add them to :filter.
891 Also handle the ":queryname" variable and save off the query to
892 the user's query list.
893 '''
894 # generic edit is per-class only
895 if not self.searchPermission():
896 self.error_message.append(
897 _('You do not have permission to search %s' %self.classname))
899 # add a faked :filter form variable for each filtering prop
900 props = self.db.classes[self.classname].getprops()
901 for key in self.form.keys():
902 if not props.has_key(key): continue
903 if not self.form[key].value: continue
904 self.form.value.append(cgi.MiniFieldStorage(':filter', key))
906 # handle saving the query params
907 if self.form.has_key(':queryname'):
908 queryname = self.form[':queryname'].value.strip()
909 if queryname:
910 # parse the environment and figure what the query _is_
911 req = HTMLRequest(self)
912 url = req.indexargs_href('', {})
914 # handle editing an existing query
915 try:
916 qid = self.db.query.lookup(queryname)
917 self.db.query.set(qid, klass=self.classname, url=url)
918 except KeyError:
919 # create a query
920 qid = self.db.query.create(name=queryname,
921 klass=self.classname, url=url)
923 # and add it to the user's query multilink
924 queries = self.db.user.get(self.userid, 'queries')
925 queries.append(qid)
926 self.db.user.set(self.userid, queries=queries)
928 # commit the query change to the database
929 self.db.commit()
931 def searchPermission(self):
932 ''' Determine whether the user has permission to search this class.
934 Base behaviour is to check the user can view this class.
935 '''
936 if not self.db.security.hasPermission('View', self.userid,
937 self.classname):
938 return 0
939 return 1
941 def remove_action(self, dre=re.compile(r'([^\d]+)(\d+)')):
942 # XXX I believe this could be handled by a regular edit action that
943 # just sets the multilink...
944 target = self.index_arg(':target')[0]
945 m = dre.match(target)
946 if m:
947 classname = m.group(1)
948 nodeid = m.group(2)
949 cl = self.db.getclass(classname)
950 cl.retire(nodeid)
951 # now take care of the reference
952 parentref = self.index_arg(':multilink')[0]
953 parent, prop = parentref.split(':')
954 m = dre.match(parent)
955 if m:
956 self.classname = m.group(1)
957 self.nodeid = m.group(2)
958 cl = self.db.getclass(self.classname)
959 value = cl.get(self.nodeid, prop)
960 value.remove(nodeid)
961 cl.set(self.nodeid, **{prop:value})
962 func = getattr(self, 'show%s'%self.classname)
963 return func()
964 else:
965 raise NotFound, parent
966 else:
967 raise NotFound, target
969 #
970 # Utility methods for editing
971 #
972 def _changenode(self, props):
973 ''' change the node based on the contents of the form
974 '''
975 cl = self.db.classes[self.classname]
977 # create the message
978 message, files = self._handle_message()
979 if message:
980 props['messages'] = cl.get(self.nodeid, 'messages') + [message]
981 if files:
982 props['files'] = cl.get(self.nodeid, 'files') + files
984 # make the changes
985 return cl.set(self.nodeid, **props)
987 def _createnode(self, props):
988 ''' create a node based on the contents of the form
989 '''
990 cl = self.db.classes[self.classname]
992 # check for messages and files
993 message, files = self._handle_message()
994 if message:
995 props['messages'] = [message]
996 if files:
997 props['files'] = files
998 # create the node and return it's id
999 return cl.create(**props)
1001 def _handle_message(self):
1002 ''' generate an edit message
1003 '''
1004 # handle file attachments
1005 files = []
1006 if self.form.has_key(':file'):
1007 file = self.form[':file']
1008 if file.filename:
1009 filename = file.filename.split('\\')[-1]
1010 mime_type = mimetypes.guess_type(filename)[0]
1011 if not mime_type:
1012 mime_type = "application/octet-stream"
1013 # create the new file entry
1014 files.append(self.db.file.create(type=mime_type,
1015 name=filename, content=file.file.read()))
1017 # we don't want to do a message if none of the following is true...
1018 cn = self.classname
1019 cl = self.db.classes[self.classname]
1020 props = cl.getprops()
1021 note = None
1022 # in a nutshell, don't do anything if there's no note or there's no
1023 # NOSY
1024 if self.form.has_key(':note'):
1025 note = self.form[':note'].value.strip()
1026 if not note:
1027 return None, files
1028 if not props.has_key('messages'):
1029 return None, files
1030 if not isinstance(props['messages'], hyperdb.Multilink):
1031 return None, files
1032 if not props['messages'].classname == 'msg':
1033 return None, files
1034 if not (self.form.has_key('nosy') or note):
1035 return None, files
1037 # handle the note
1038 if '\n' in note:
1039 summary = re.split(r'\n\r?', note)[0]
1040 else:
1041 summary = note
1042 m = ['%s\n'%note]
1044 # handle the messageid
1045 # TODO: handle inreplyto
1046 messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
1047 self.classname, self.instance.config.MAIL_DOMAIN)
1049 # now create the message, attaching the files
1050 content = '\n'.join(m)
1051 message_id = self.db.msg.create(author=self.userid,
1052 recipients=[], date=date.Date('.'), summary=summary,
1053 content=content, files=files, messageid=messageid)
1055 # update the messages property
1056 return message_id, files
1058 def _post_editnode(self, nid):
1059 '''Do the linking part of the node creation.
1061 If a form element has :link or :multilink appended to it, its
1062 value specifies a node designator and the property on that node
1063 to add _this_ node to as a link or multilink.
1065 This is typically used on, eg. the file upload page to indicated
1066 which issue to link the file to.
1068 TODO: I suspect that this and newfile will go away now that
1069 there's the ability to upload a file using the issue :file form
1070 element!
1071 '''
1072 cn = self.classname
1073 cl = self.db.classes[cn]
1074 # link if necessary
1075 keys = self.form.keys()
1076 for key in keys:
1077 if key == ':multilink':
1078 value = self.form[key].value
1079 if type(value) != type([]): value = [value]
1080 for value in value:
1081 designator, property = value.split(':')
1082 link, nodeid = hyperdb.splitDesignator(designator)
1083 link = self.db.classes[link]
1084 # take a dupe of the list so we're not changing the cache
1085 value = link.get(nodeid, property)[:]
1086 value.append(nid)
1087 link.set(nodeid, **{property: value})
1088 elif key == ':link':
1089 value = self.form[key].value
1090 if type(value) != type([]): value = [value]
1091 for value in value:
1092 designator, property = value.split(':')
1093 link, nodeid = hyperdb.splitDesignator(designator)
1094 link = self.db.classes[link]
1095 link.set(nodeid, **{property: nid})
1098 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
1099 ''' Pull properties for the given class out of the form.
1101 If a ":required" parameter is supplied, then the names property values
1102 must be supplied or a ValueError will be raised.
1103 '''
1104 required = []
1105 if form.has_key(':required'):
1106 value = form[':required']
1107 if isinstance(value, type([])):
1108 required = [i.value.strip() for i in value]
1109 else:
1110 required = [i.strip() for i in value.value.split(',')]
1112 props = {}
1113 keys = form.keys()
1114 for key in keys:
1115 if not cl.properties.has_key(key):
1116 continue
1117 proptype = cl.properties[key]
1119 # Get the form value. This value may be a MiniFieldStorage or a list
1120 # of MiniFieldStorages.
1121 value = form[key]
1123 # make sure non-multilinks only get one value
1124 if not isinstance(proptype, hyperdb.Multilink):
1125 if isinstance(value, type([])):
1126 raise ValueError, 'You have submitted more than one value'\
1127 ' for the %s property'%key
1128 # we've got a MiniFieldStorage, so pull out the value and strip
1129 # surrounding whitespace
1130 value = value.value.strip()
1132 if isinstance(proptype, hyperdb.String):
1133 if not value:
1134 continue
1135 elif isinstance(proptype, hyperdb.Password):
1136 if not value:
1137 # ignore empty password values
1138 continue
1139 if not form.has_key('%s:confirm'%key):
1140 raise ValueError, 'Password and confirmation text do not match'
1141 confirm = form['%s:confirm'%key]
1142 if isinstance(confirm, type([])):
1143 raise ValueError, 'You have submitted more than one value'\
1144 ' for the %s property'%key
1145 if value != confirm.value:
1146 raise ValueError, 'Password and confirmation text do not match'
1147 value = password.Password(value)
1148 elif isinstance(proptype, hyperdb.Date):
1149 if value:
1150 value = date.Date(form[key].value.strip())
1151 else:
1152 value = None
1153 elif isinstance(proptype, hyperdb.Interval):
1154 if value:
1155 value = date.Interval(form[key].value.strip())
1156 else:
1157 value = None
1158 elif isinstance(proptype, hyperdb.Link):
1159 # see if it's the "no selection" choice
1160 if value == '-1':
1161 value = None
1162 else:
1163 # handle key values
1164 link = cl.properties[key].classname
1165 if not num_re.match(value):
1166 try:
1167 value = db.classes[link].lookup(value)
1168 except KeyError:
1169 raise ValueError, _('property "%(propname)s": '
1170 '%(value)s not a %(classname)s')%{'propname':key,
1171 'value': value, 'classname': link}
1172 except TypeError, message:
1173 raise ValueError, _('you may only enter ID values '
1174 'for property "%(propname)s": %(message)s')%{
1175 'propname':key, 'message': message}
1176 elif isinstance(proptype, hyperdb.Multilink):
1177 if isinstance(value, type([])):
1178 # it's a list of MiniFieldStorages
1179 value = [i.value.strip() for i in value]
1180 else:
1181 # it's a MiniFieldStorage, but may be a comma-separated list
1182 # of values
1183 value = [i.strip() for i in value.value.split(',')]
1184 link = cl.properties[key].classname
1185 l = []
1186 for entry in map(str, value):
1187 if entry == '': continue
1188 if not num_re.match(entry):
1189 try:
1190 entry = db.classes[link].lookup(entry)
1191 except KeyError:
1192 raise ValueError, _('property "%(propname)s": '
1193 '"%(value)s" not an entry of %(classname)s')%{
1194 'propname':key, 'value': entry, 'classname': link}
1195 except TypeError, message:
1196 raise ValueError, _('you may only enter ID values '
1197 'for property "%(propname)s": %(message)s')%{
1198 'propname':key, 'message': message}
1199 l.append(entry)
1200 l.sort()
1201 value = l
1202 elif isinstance(proptype, hyperdb.Boolean):
1203 props[key] = value = value.lower() in ('yes', 'true', 'on', '1')
1204 elif isinstance(proptype, hyperdb.Number):
1205 props[key] = value = int(value)
1207 # register this as received if required
1208 if key in required:
1209 required.remove(key)
1211 # get the old value
1212 if nodeid:
1213 try:
1214 existing = cl.get(nodeid, key)
1215 except KeyError:
1216 # this might be a new property for which there is no existing
1217 # value
1218 if not cl.properties.has_key(key): raise
1220 # if changed, set it
1221 if value != existing:
1222 props[key] = value
1223 else:
1224 props[key] = value
1226 # see if all the required properties have been supplied
1227 if required:
1228 if len(required) > 1:
1229 p = 'properties'
1230 else:
1231 p = 'property'
1232 raise ValueError, 'Required %s %s not supplied'%(p, ', '.join(required))
1234 return props