1 # $Id: client.py,v 1.48 2002-09-27 01:04:38 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 '''
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 # render the content
156 self.write(self.renderContext())
157 except Redirect, url:
158 # let's redirect - if the url isn't None, then we need to do
159 # the headers, otherwise the headers have been set before the
160 # exception was raised
161 if url:
162 self.additional_headers['Location'] = url
163 self.response_code = 302
164 self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
165 except SendFile, designator:
166 self.serve_file(designator)
167 except SendStaticFile, file:
168 self.serve_static_file(str(file))
169 except Unauthorised, message:
170 self.write(self.renderTemplate('page', '', error_message=message))
171 except NotFound:
172 # pass through
173 raise
174 except:
175 # everything else
176 self.write(cgitb.html())
178 def determine_user(self):
179 ''' Determine who the user is
180 '''
181 # determine the uid to use
182 self.opendb('admin')
184 # make sure we have the session Class
185 sessions = self.db.sessions
187 # age sessions, remove when they haven't been used for a week
188 # TODO: this shouldn't be done every access
189 week = 60*60*24*7
190 now = time.time()
191 for sessid in sessions.list():
192 interval = now - sessions.get(sessid, 'last_use')
193 if interval > week:
194 sessions.destroy(sessid)
196 # look up the user session cookie
197 cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
198 user = 'anonymous'
200 # bump the "revision" of the cookie since the format changed
201 if (cookie.has_key('roundup_user_2') and
202 cookie['roundup_user_2'].value != 'deleted'):
204 # get the session key from the cookie
205 self.session = cookie['roundup_user_2'].value
206 # get the user from the session
207 try:
208 # update the lifetime datestamp
209 sessions.set(self.session, last_use=time.time())
210 sessions.commit()
211 user = sessions.get(self.session, 'user')
212 except KeyError:
213 user = 'anonymous'
215 # sanity check on the user still being valid, getting the userid
216 # at the same time
217 try:
218 self.userid = self.db.user.lookup(user)
219 except (KeyError, TypeError):
220 user = 'anonymous'
222 # make sure the anonymous user is valid if we're using it
223 if user == 'anonymous':
224 self.make_user_anonymous()
225 else:
226 self.user = user
228 # reopen the database as the correct user
229 self.opendb(self.user)
231 def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
232 ''' Determine the context of this page from the URL:
234 The URL path after the instance identifier is examined. The path
235 is generally only one entry long.
237 - if there is no path, then we are in the "home" context.
238 * if the path is "_file", then the additional path entry
239 specifies the filename of a static file we're to serve up
240 from the instance "html" directory. Raises a SendStaticFile
241 exception.
242 - if there is something in the path (eg "issue"), it identifies
243 the tracker class we're to display.
244 - if the path is an item designator (eg "issue123"), then we're
245 to display a specific item.
246 * if the path starts with an item designator and is longer than
247 one entry, then we're assumed to be handling an item of a
248 FileClass, and the extra path information gives the filename
249 that the client is going to label the download with (ie
250 "file123/image.png" is nicer to download than "file123"). This
251 raises a SendFile exception.
253 Both of the "*" types of contexts stop before we bother to
254 determine the template we're going to use. That's because they
255 don't actually use templates.
257 The template used is specified by the :template CGI variable,
258 which defaults to:
260 only classname suplied: "index"
261 full item designator supplied: "item"
263 We set:
264 self.classname - the class to display, can be None
265 self.template - the template to render the current context with
266 self.nodeid - the nodeid of the class we're displaying
267 '''
268 # default the optional variables
269 self.classname = None
270 self.nodeid = None
272 # determine the classname and possibly nodeid
273 path = self.path.split('/')
274 if not path or path[0] in ('', 'home', 'index'):
275 if self.form.has_key(':template'):
276 self.template = self.form[':template'].value
277 else:
278 self.template = ''
279 return
280 elif path[0] == '_file':
281 raise SendStaticFile, path[1]
282 else:
283 self.classname = path[0]
284 if len(path) > 1:
285 # send the file identified by the designator in path[0]
286 raise SendFile, path[0]
288 # see if we got a designator
289 m = dre.match(self.classname)
290 if m:
291 self.classname = m.group(1)
292 self.nodeid = m.group(2)
293 if not self.db.getclass(self.classname).hasnode(self.nodeid):
294 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
295 # with a designator, we default to item view
296 self.template = 'item'
297 else:
298 # with only a class, we default to index view
299 self.template = 'index'
301 # see if we have a template override
302 if self.form.has_key(':template'):
303 self.template = self.form[':template'].value
305 # see if we were passed in a message
306 if self.form.has_key(':ok_message'):
307 self.ok_message.append(self.form[':ok_message'].value)
308 if self.form.has_key(':error_message'):
309 self.error_message.append(self.form[':error_message'].value)
311 def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
312 ''' Serve the file from the content property of the designated item.
313 '''
314 m = dre.match(str(designator))
315 if not m:
316 raise NotFound, str(designator)
317 classname, nodeid = m.group(1), m.group(2)
318 if classname != 'file':
319 raise NotFound, designator
321 # we just want to serve up the file named
322 file = self.db.file
323 self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
324 self.write(file.get(nodeid, 'content'))
326 def serve_static_file(self, file):
327 # we just want to serve up the file named
328 mt = mimetypes.guess_type(str(file))[0]
329 self.additional_headers['Content-Type'] = mt
330 self.write(open(os.path.join(self.instance.config.TEMPLATES,
331 file)).read())
333 def renderContext(self):
334 ''' Return a PageTemplate for the named page
335 '''
336 name = self.classname
337 extension = self.template
338 pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
340 # catch errors so we can handle PT rendering errors more nicely
341 args = {
342 'ok_message': self.ok_message,
343 'error_message': self.error_message
344 }
345 try:
346 # let the template render figure stuff out
347 return pt.render(self, None, None, **args)
348 except NoTemplate, message:
349 return '<strong>%s</strong>'%message
350 except:
351 # everything else
352 return cgitb.pt_html()
354 # these are the actions that are available
355 actions = (
356 ('edit', 'editItemAction'),
357 ('editCSV', 'editCSVAction'),
358 ('new', 'newItemAction'),
359 ('register', 'registerAction'),
360 ('login', 'loginAction'),
361 ('logout', 'logout_action'),
362 ('search', 'searchAction'),
363 )
364 def handle_action(self):
365 ''' Determine whether there should be an _action called.
367 The action is defined by the form variable :action which
368 identifies the method on this object to call. The four basic
369 actions are defined in the "actions" sequence on this class:
370 "edit" -> self.editItemAction
371 "new" -> self.newItemAction
372 "register" -> self.registerAction
373 "login" -> self.loginAction
374 "logout" -> self.logout_action
375 "search" -> self.searchAction
377 '''
378 if not self.form.has_key(':action'):
379 return None
380 try:
381 # get the action, validate it
382 action = self.form[':action'].value
383 for name, method in self.actions:
384 if name == action:
385 break
386 else:
387 raise ValueError, 'No such action "%s"'%action
389 # call the mapped action
390 getattr(self, method)()
391 except Redirect:
392 raise
393 except Unauthorised:
394 raise
395 except:
396 self.db.rollback()
397 s = StringIO.StringIO()
398 traceback.print_exc(None, s)
399 self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
401 def write(self, content):
402 if not self.headers_done:
403 self.header()
404 self.request.wfile.write(content)
406 def header(self, headers=None, response=None):
407 '''Put up the appropriate header.
408 '''
409 if headers is None:
410 headers = {'Content-Type':'text/html'}
411 if response is None:
412 response = self.response_code
414 # update with additional info
415 headers.update(self.additional_headers)
417 if not headers.has_key('Content-Type'):
418 headers['Content-Type'] = 'text/html'
419 self.request.send_response(response)
420 for entry in headers.items():
421 self.request.send_header(*entry)
422 self.request.end_headers()
423 self.headers_done = 1
424 if self.debug:
425 self.headers_sent = headers
427 def set_cookie(self, user):
428 ''' Set up a session cookie for the user and store away the user's
429 login info against the session.
430 '''
431 # TODO generate a much, much stronger session key ;)
432 self.session = binascii.b2a_base64(repr(random.random())).strip()
434 # clean up the base64
435 if self.session[-1] == '=':
436 if self.session[-2] == '=':
437 self.session = self.session[:-2]
438 else:
439 self.session = self.session[:-1]
441 # insert the session in the sessiondb
442 self.db.sessions.set(self.session, user=user, last_use=time.time())
444 # and commit immediately
445 self.db.sessions.commit()
447 # expire us in a long, long time
448 expire = Cookie._getdate(86400*365)
450 # generate the cookie path - make sure it has a trailing '/'
451 path = '/'.join((self.env['SCRIPT_NAME'], self.env['TRACKER_NAME'],
452 ''))
453 self.additional_headers['Set-Cookie'] = \
454 'roundup_user_2=%s; expires=%s; Path=%s;'%(self.session, expire, path)
456 def make_user_anonymous(self):
457 ''' Make us anonymous
459 This method used to handle non-existence of the 'anonymous'
460 user, but that user is mandatory now.
461 '''
462 self.userid = self.db.user.lookup('anonymous')
463 self.user = 'anonymous'
465 def opendb(self, user):
466 ''' Open the database.
467 '''
468 # open the db if the user has changed
469 if not hasattr(self, 'db') or user != self.db.journaltag:
470 if hasattr(self, 'db'):
471 self.db.close()
472 self.db = self.instance.open(user)
474 #
475 # Actions
476 #
477 def loginAction(self):
478 ''' Attempt to log a user in.
480 Sets up a session for the user which contains the login
481 credentials.
482 '''
483 # we need the username at a minimum
484 if not self.form.has_key('__login_name'):
485 self.error_message.append(_('Username required'))
486 return
488 # get the login info
489 self.user = self.form['__login_name'].value
490 if self.form.has_key('__login_password'):
491 password = self.form['__login_password'].value
492 else:
493 password = ''
495 # make sure the user exists
496 try:
497 self.userid = self.db.user.lookup(self.user)
498 except KeyError:
499 name = self.user
500 self.error_message.append(_('No such user "%(name)s"')%locals())
501 self.make_user_anonymous()
502 return
504 # verify the password
505 if not self.verifyPassword(self.userid, password):
506 self.make_user_anonymous()
507 self.error_message.append(_('Incorrect password'))
508 return
510 # make sure we're allowed to be here
511 if not self.loginPermission():
512 self.make_user_anonymous()
513 raise Unauthorised, _("You do not have permission to login")
515 # now we're OK, re-open the database for real, using the user
516 self.opendb(self.user)
518 # set the session cookie
519 self.set_cookie(self.user)
521 def verifyPassword(self, userid, password):
522 ''' Verify the password that the user has supplied
523 '''
524 return password == self.db.user.get(self.userid, 'password')
526 def loginPermission(self):
527 ''' Determine whether the user has permission to log in.
529 Base behaviour is to check the user has "Web Access".
530 '''
531 if not self.db.security.hasPermission('Web Access', self.userid):
532 return 0
533 return 1
535 def logout_action(self):
536 ''' Make us really anonymous - nuke the cookie too
537 '''
538 # log us out
539 self.make_user_anonymous()
541 # construct the logout cookie
542 now = Cookie._getdate()
543 path = '/'.join((self.env['SCRIPT_NAME'], self.env['TRACKER_NAME'],
544 ''))
545 self.additional_headers['Set-Cookie'] = \
546 'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)
548 # Let the user know what's going on
549 self.ok_message.append(_('You are logged out'))
551 def registerAction(self):
552 '''Attempt to create a new user based on the contents of the form
553 and then set the cookie.
555 return 1 on successful login
556 '''
557 # create the new user
558 cl = self.db.user
560 # parse the props from the form
561 try:
562 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
563 except (ValueError, KeyError), message:
564 self.error_message.append(_('Error: ') + str(message))
565 return
567 # make sure we're allowed to register
568 if not self.registerPermission(props):
569 raise Unauthorised, _("You do not have permission to register")
571 # re-open the database as "admin"
572 if self.user != 'admin':
573 self.opendb('admin')
575 # create the new user
576 cl = self.db.user
577 try:
578 props = parsePropsFromForm(self.db, cl, self.form)
579 props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
580 self.userid = cl.create(**props)
581 self.db.commit()
582 except (ValueError, KeyError), message:
583 self.error_message.append(message)
584 return
586 # log the new user in
587 self.user = cl.get(self.userid, 'username')
588 # re-open the database for real, using the user
589 self.opendb(self.user)
591 # update the user's session
592 if self.session:
593 self.db.sessions.set(self.session, user=self.user,
594 last_use=time.time())
595 else:
596 # new session cookie
597 self.set_cookie(self.user)
599 # nice message
600 message = _('You are now registered, welcome!')
602 # redirect to the item's edit page
603 raise Redirect, '%s%s%s?:ok_message=%s'%(
604 self.base, self.classname, self.userid, urllib.quote(message))
606 def registerPermission(self, props):
607 ''' Determine whether the user has permission to register
609 Base behaviour is to check the user has "Web Registration".
610 '''
611 # registration isn't allowed to supply roles
612 if props.has_key('roles'):
613 return 0
614 if self.db.security.hasPermission('Web Registration', self.userid):
615 return 1
616 return 0
618 def editItemAction(self):
619 ''' Perform an edit of an item in the database.
621 Some special form elements:
623 :link=designator:property
624 :multilink=designator:property
625 The value specifies a node designator and the property on that
626 node to add _this_ node to as a link or multilink.
627 :note
628 Create a message and attach it to the current node's
629 "messages" property.
630 :file
631 Create a file and attach it to the current node's
632 "files" property. Attach the file to the message created from
633 the :note if it's supplied.
635 :required=property,property,...
636 The named properties are required to be filled in the form.
638 '''
639 cl = self.db.classes[self.classname]
641 # parse the props from the form
642 try:
643 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
644 except (ValueError, KeyError), message:
645 self.error_message.append(_('Error: ') + str(message))
646 return
648 # check permission
649 if not self.editItemPermission(props):
650 self.error_message.append(
651 _('You do not have permission to edit %(classname)s'%
652 self.__dict__))
653 return
655 # perform the edit
656 try:
657 # make changes to the node
658 props = self._changenode(props)
659 # handle linked nodes
660 self._post_editnode(self.nodeid)
661 except (ValueError, KeyError), message:
662 self.error_message.append(_('Error: ') + str(message))
663 return
665 # commit now that all the tricky stuff is done
666 self.db.commit()
668 # and some nice feedback for the user
669 if props:
670 message = _('%(changes)s edited ok')%{'changes':
671 ', '.join(props.keys())}
672 elif self.form.has_key(':note') and self.form[':note'].value:
673 message = _('note added')
674 elif (self.form.has_key(':file') and self.form[':file'].filename):
675 message = _('file added')
676 else:
677 message = _('nothing changed')
679 # redirect to the item's edit page
680 raise Redirect, '%s%s%s?:ok_message=%s'%(self.base, self.classname,
681 self.nodeid, urllib.quote(message))
683 def editItemPermission(self, props):
684 ''' Determine whether the user has permission to edit this item.
686 Base behaviour is to check the user can edit this class. If we're
687 editing the "user" class, users are allowed to edit their own
688 details. Unless it's the "roles" property, which requires the
689 special Permission "Web Roles".
690 '''
691 # if this is a user node and the user is editing their own node, then
692 # we're OK
693 has = self.db.security.hasPermission
694 if self.classname == 'user':
695 # reject if someone's trying to edit "roles" and doesn't have the
696 # right permission.
697 if props.has_key('roles') and not has('Web Roles', self.userid,
698 'user'):
699 return 0
700 # if the item being edited is the current user, we're ok
701 if self.nodeid == self.userid:
702 return 1
703 if self.db.security.hasPermission('Edit', self.userid, self.classname):
704 return 1
705 return 0
707 def newItemAction(self):
708 ''' Add a new item to the database.
710 This follows the same form as the editItemAction, with the same
711 special form values.
712 '''
713 cl = self.db.classes[self.classname]
715 # parse the props from the form
716 try:
717 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
718 except (ValueError, KeyError), message:
719 self.error_message.append(_('Error: ') + str(message))
720 return
722 if not self.newItemPermission(props):
723 self.error_message.append(
724 _('You do not have permission to create %s' %self.classname))
726 # create a little extra message for anticipated :link / :multilink
727 if self.form.has_key(':multilink'):
728 link = self.form[':multilink'].value
729 elif self.form.has_key(':link'):
730 link = self.form[':multilink'].value
731 else:
732 link = None
733 xtra = ''
734 if link:
735 designator, linkprop = link.split(':')
736 xtra = ' for <a href="%s">%s</a>'%(designator, designator)
738 try:
739 # do the create
740 nid = self._createnode(props)
742 # handle linked nodes
743 self._post_editnode(nid)
745 # commit now that all the tricky stuff is done
746 self.db.commit()
748 # render the newly created item
749 self.nodeid = nid
751 # and some nice feedback for the user
752 message = _('%(classname)s created ok')%self.__dict__ + xtra
753 except (ValueError, KeyError), message:
754 self.error_message.append(_('Error: ') + str(message))
755 return
756 except:
757 # oops
758 self.db.rollback()
759 s = StringIO.StringIO()
760 traceback.print_exc(None, s)
761 self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
762 return
764 # redirect to the new item's page
765 raise Redirect, '%s%s%s?:ok_message=%s'%(self.base, self.classname,
766 nid, urllib.quote(message))
768 def newItemPermission(self, props):
769 ''' Determine whether the user has permission to create (edit) this
770 item.
772 Base behaviour is to check the user can edit this class. No
773 additional property checks are made. Additionally, new user items
774 may be created if the user has the "Web Registration" Permission.
775 '''
776 has = self.db.security.hasPermission
777 if self.classname == 'user' and has('Web Registration', self.userid,
778 'user'):
779 return 1
780 if has('Edit', self.userid, self.classname):
781 return 1
782 return 0
784 def editCSVAction(self):
785 ''' Performs an edit of all of a class' items in one go.
787 The "rows" CGI var defines the CSV-formatted entries for the
788 class. New nodes are identified by the ID 'X' (or any other
789 non-existent ID) and removed lines are retired.
790 '''
791 # this is per-class only
792 if not self.editCSVPermission():
793 self.error_message.append(
794 _('You do not have permission to edit %s' %self.classname))
796 # get the CSV module
797 try:
798 import csv
799 except ImportError:
800 self.error_message.append(_(
801 'Sorry, you need the csv module to use this function.<br>\n'
802 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
803 return
805 cl = self.db.classes[self.classname]
806 idlessprops = cl.getprops(protected=0).keys()
807 idlessprops.sort()
808 props = ['id'] + idlessprops
810 # do the edit
811 rows = self.form['rows'].value.splitlines()
812 p = csv.parser()
813 found = {}
814 line = 0
815 for row in rows[1:]:
816 line += 1
817 values = p.parse(row)
818 # not a complete row, keep going
819 if not values: continue
821 # skip property names header
822 if values == props:
823 continue
825 # extract the nodeid
826 nodeid, values = values[0], values[1:]
827 found[nodeid] = 1
829 # confirm correct weight
830 if len(idlessprops) != len(values):
831 self.error_message.append(
832 _('Not enough values on line %(line)s')%{'line':line})
833 return
835 # extract the new values
836 d = {}
837 for name, value in zip(idlessprops, values):
838 value = value.strip()
839 # only add the property if it has a value
840 if value:
841 # if it's a multilink, split it
842 if isinstance(cl.properties[name], hyperdb.Multilink):
843 value = value.split(':')
844 d[name] = value
846 # perform the edit
847 if cl.hasnode(nodeid):
848 # edit existing
849 cl.set(nodeid, **d)
850 else:
851 # new node
852 found[cl.create(**d)] = 1
854 # retire the removed entries
855 for nodeid in cl.list():
856 if not found.has_key(nodeid):
857 cl.retire(nodeid)
859 # all OK
860 self.db.commit()
862 self.ok_message.append(_('Items edited OK'))
864 def editCSVPermission(self):
865 ''' Determine whether the user has permission to edit this class.
867 Base behaviour is to check the user can edit this class.
868 '''
869 if not self.db.security.hasPermission('Edit', self.userid,
870 self.classname):
871 return 0
872 return 1
874 def searchAction(self):
875 ''' Mangle some of the form variables.
877 Set the form ":filter" variable based on the values of the
878 filter variables - if they're set to anything other than
879 "dontcare" then add them to :filter.
881 Also handle the ":queryname" variable and save off the query to
882 the user's query list.
883 '''
884 # generic edit is per-class only
885 if not self.searchPermission():
886 self.error_message.append(
887 _('You do not have permission to search %s' %self.classname))
889 # add a faked :filter form variable for each filtering prop
890 props = self.db.classes[self.classname].getprops()
891 for key in self.form.keys():
892 if not props.has_key(key): continue
893 if not self.form[key].value: continue
894 self.form.value.append(cgi.MiniFieldStorage(':filter', key))
896 # handle saving the query params
897 if self.form.has_key(':queryname'):
898 queryname = self.form[':queryname'].value.strip()
899 if queryname:
900 # parse the environment and figure what the query _is_
901 req = HTMLRequest(self)
902 url = req.indexargs_href('', {})
904 # handle editing an existing query
905 try:
906 qid = self.db.query.lookup(queryname)
907 self.db.query.set(qid, klass=self.classname, url=url)
908 except KeyError:
909 # create a query
910 qid = self.db.query.create(name=queryname,
911 klass=self.classname, url=url)
913 # and add it to the user's query multilink
914 queries = self.db.user.get(self.userid, 'queries')
915 queries.append(qid)
916 self.db.user.set(self.userid, queries=queries)
918 # commit the query change to the database
919 self.db.commit()
921 def searchPermission(self):
922 ''' Determine whether the user has permission to search this class.
924 Base behaviour is to check the user can view this class.
925 '''
926 if not self.db.security.hasPermission('View', self.userid,
927 self.classname):
928 return 0
929 return 1
931 def remove_action(self, dre=re.compile(r'([^\d]+)(\d+)')):
932 # XXX I believe this could be handled by a regular edit action that
933 # just sets the multilink...
934 target = self.index_arg(':target')[0]
935 m = dre.match(target)
936 if m:
937 classname = m.group(1)
938 nodeid = m.group(2)
939 cl = self.db.getclass(classname)
940 cl.retire(nodeid)
941 # now take care of the reference
942 parentref = self.index_arg(':multilink')[0]
943 parent, prop = parentref.split(':')
944 m = dre.match(parent)
945 if m:
946 self.classname = m.group(1)
947 self.nodeid = m.group(2)
948 cl = self.db.getclass(self.classname)
949 value = cl.get(self.nodeid, prop)
950 value.remove(nodeid)
951 cl.set(self.nodeid, **{prop:value})
952 func = getattr(self, 'show%s'%self.classname)
953 return func()
954 else:
955 raise NotFound, parent
956 else:
957 raise NotFound, target
959 #
960 # Utility methods for editing
961 #
962 def _changenode(self, props):
963 ''' change the node based on the contents of the form
964 '''
965 cl = self.db.classes[self.classname]
967 # create the message
968 message, files = self._handle_message()
969 if message:
970 props['messages'] = cl.get(self.nodeid, 'messages') + [message]
971 if files:
972 props['files'] = cl.get(self.nodeid, 'files') + files
974 # make the changes
975 return cl.set(self.nodeid, **props)
977 def _createnode(self, props):
978 ''' create a node based on the contents of the form
979 '''
980 cl = self.db.classes[self.classname]
982 # check for messages and files
983 message, files = self._handle_message()
984 if message:
985 props['messages'] = [message]
986 if files:
987 props['files'] = files
988 # create the node and return it's id
989 return cl.create(**props)
991 def _handle_message(self):
992 ''' generate an edit message
993 '''
994 # handle file attachments
995 files = []
996 if self.form.has_key(':file'):
997 file = self.form[':file']
998 if file.filename:
999 filename = file.filename.split('\\')[-1]
1000 mime_type = mimetypes.guess_type(filename)[0]
1001 if not mime_type:
1002 mime_type = "application/octet-stream"
1003 # create the new file entry
1004 files.append(self.db.file.create(type=mime_type,
1005 name=filename, content=file.file.read()))
1007 # we don't want to do a message if none of the following is true...
1008 cn = self.classname
1009 cl = self.db.classes[self.classname]
1010 props = cl.getprops()
1011 note = None
1012 # in a nutshell, don't do anything if there's no note or there's no
1013 # NOSY
1014 if self.form.has_key(':note'):
1015 note = self.form[':note'].value.strip()
1016 if not note:
1017 return None, files
1018 if not props.has_key('messages'):
1019 return None, files
1020 if not isinstance(props['messages'], hyperdb.Multilink):
1021 return None, files
1022 if not props['messages'].classname == 'msg':
1023 return None, files
1024 if not (self.form.has_key('nosy') or note):
1025 return None, files
1027 # handle the note
1028 if '\n' in note:
1029 summary = re.split(r'\n\r?', note)[0]
1030 else:
1031 summary = note
1032 m = ['%s\n'%note]
1034 # handle the messageid
1035 # TODO: handle inreplyto
1036 messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
1037 self.classname, self.instance.config.MAIL_DOMAIN)
1039 # now create the message, attaching the files
1040 content = '\n'.join(m)
1041 message_id = self.db.msg.create(author=self.userid,
1042 recipients=[], date=date.Date('.'), summary=summary,
1043 content=content, files=files, messageid=messageid)
1045 # update the messages property
1046 return message_id, files
1048 def _post_editnode(self, nid):
1049 '''Do the linking part of the node creation.
1051 If a form element has :link or :multilink appended to it, its
1052 value specifies a node designator and the property on that node
1053 to add _this_ node to as a link or multilink.
1055 This is typically used on, eg. the file upload page to indicated
1056 which issue to link the file to.
1058 TODO: I suspect that this and newfile will go away now that
1059 there's the ability to upload a file using the issue :file form
1060 element!
1061 '''
1062 cn = self.classname
1063 cl = self.db.classes[cn]
1064 # link if necessary
1065 keys = self.form.keys()
1066 for key in keys:
1067 if key == ':multilink':
1068 value = self.form[key].value
1069 if type(value) != type([]): value = [value]
1070 for value in value:
1071 designator, property = value.split(':')
1072 link, nodeid = hyperdb.splitDesignator(designator)
1073 link = self.db.classes[link]
1074 # take a dupe of the list so we're not changing the cache
1075 value = link.get(nodeid, property)[:]
1076 value.append(nid)
1077 link.set(nodeid, **{property: value})
1078 elif key == ':link':
1079 value = self.form[key].value
1080 if type(value) != type([]): value = [value]
1081 for value in value:
1082 designator, property = value.split(':')
1083 link, nodeid = hyperdb.splitDesignator(designator)
1084 link = self.db.classes[link]
1085 link.set(nodeid, **{property: nid})
1088 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
1089 ''' Pull properties for the given class out of the form.
1091 If a ":required" parameter is supplied, then the names property values
1092 must be supplied or a ValueError will be raised.
1093 '''
1094 required = []
1095 if form.has_key(':required'):
1096 value = form[':required']
1097 if isinstance(value, type([])):
1098 required = [i.value.strip() for i in value]
1099 else:
1100 required = [i.strip() for i in value.value.split(',')]
1102 props = {}
1103 keys = form.keys()
1104 properties = cl.getprops()
1105 for key in keys:
1106 if not properties.has_key(key):
1107 continue
1108 proptype = properties[key]
1110 # Get the form value. This value may be a MiniFieldStorage or a list
1111 # of MiniFieldStorages.
1112 value = form[key]
1114 # make sure non-multilinks only get one value
1115 if not isinstance(proptype, hyperdb.Multilink):
1116 if isinstance(value, type([])):
1117 raise ValueError, 'You have submitted more than one value'\
1118 ' for the %s property'%key
1119 # we've got a MiniFieldStorage, so pull out the value and strip
1120 # surrounding whitespace
1121 value = value.value.strip()
1123 if isinstance(proptype, hyperdb.String):
1124 if not value:
1125 continue
1126 elif isinstance(proptype, hyperdb.Password):
1127 if not value:
1128 # ignore empty password values
1129 continue
1130 if not form.has_key('%s:confirm'%key):
1131 raise ValueError, 'Password and confirmation text do not match'
1132 confirm = form['%s:confirm'%key]
1133 if isinstance(confirm, type([])):
1134 raise ValueError, 'You have submitted more than one value'\
1135 ' for the %s property'%key
1136 if value != confirm.value:
1137 raise ValueError, 'Password and confirmation text do not match'
1138 value = password.Password(value)
1139 elif isinstance(proptype, hyperdb.Date):
1140 if value:
1141 value = date.Date(form[key].value.strip())
1142 else:
1143 continue
1144 elif isinstance(proptype, hyperdb.Interval):
1145 if value:
1146 value = date.Interval(form[key].value.strip())
1147 else:
1148 continue
1149 elif isinstance(proptype, hyperdb.Link):
1150 # see if it's the "no selection" choice
1151 if value == '-1':
1152 value = None
1153 else:
1154 # handle key values
1155 link = proptype.classname
1156 if not num_re.match(value):
1157 try:
1158 value = db.classes[link].lookup(value)
1159 except KeyError:
1160 raise ValueError, _('property "%(propname)s": '
1161 '%(value)s not a %(classname)s')%{'propname':key,
1162 'value': value, 'classname': link}
1163 except TypeError, message:
1164 raise ValueError, _('you may only enter ID values '
1165 'for property "%(propname)s": %(message)s')%{
1166 'propname':key, 'message': message}
1167 elif isinstance(proptype, hyperdb.Multilink):
1168 if isinstance(value, type([])):
1169 # it's a list of MiniFieldStorages
1170 value = [i.value.strip() for i in value]
1171 else:
1172 # it's a MiniFieldStorage, but may be a comma-separated list
1173 # of values
1174 value = [i.strip() for i in value.value.split(',')]
1175 link = proptype.classname
1176 l = []
1177 for entry in map(str, value):
1178 if entry == '': continue
1179 if not num_re.match(entry):
1180 try:
1181 entry = db.classes[link].lookup(entry)
1182 except KeyError:
1183 raise ValueError, _('property "%(propname)s": '
1184 '"%(value)s" not an entry of %(classname)s')%{
1185 'propname':key, 'value': entry, 'classname': link}
1186 except TypeError, message:
1187 raise ValueError, _('you may only enter ID values '
1188 'for property "%(propname)s": %(message)s')%{
1189 'propname':key, 'message': message}
1190 l.append(entry)
1191 l.sort()
1192 value = l
1193 elif isinstance(proptype, hyperdb.Boolean):
1194 props[key] = value = value.lower() in ('yes', 'true', 'on', '1')
1195 elif isinstance(proptype, hyperdb.Number):
1196 props[key] = value = int(value)
1198 # register this as received if required?
1199 if key in required and value is not None:
1200 required.remove(key)
1202 # get the old value
1203 if nodeid:
1204 try:
1205 existing = cl.get(nodeid, key)
1206 except KeyError:
1207 # this might be a new property for which there is no existing
1208 # value
1209 if not properties.has_key(key): raise
1211 # if changed, set it
1212 if value != existing:
1213 props[key] = value
1214 else:
1215 props[key] = value
1217 # see if all the required properties have been supplied
1218 if required:
1219 if len(required) > 1:
1220 p = 'properties'
1221 else:
1222 p = 'property'
1223 raise ValueError, 'Required %s %s not supplied'%(p, ', '.join(required))
1225 return props