1 # $Id: client.py,v 1.40 2002-09-19 02:37:41 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 opendb(self, user):
478 ''' Open the database.
479 '''
480 # open the db if the user has changed
481 if not hasattr(self, 'db') or user != self.db.journaltag:
482 if hasattr(self, 'db'):
483 self.db.close()
484 self.db = self.instance.open(user)
486 #
487 # Actions
488 #
489 def loginAction(self):
490 ''' Attempt to log a user in.
492 Sets up a session for the user which contains the login
493 credentials.
494 '''
495 # we need the username at a minimum
496 if not self.form.has_key('__login_name'):
497 self.error_message.append(_('Username required'))
498 return
500 self.user = self.form['__login_name'].value
501 # re-open the database for real, using the user
502 self.opendb(self.user)
503 if self.form.has_key('__login_password'):
504 password = self.form['__login_password'].value
505 else:
506 password = ''
507 # make sure the user exists
508 try:
509 self.userid = self.db.user.lookup(self.user)
510 except KeyError:
511 name = self.user
512 self.make_user_anonymous()
513 self.error_message.append(_('No such user "%(name)s"')%locals())
514 return
516 # and that the password is correct
517 pw = self.db.user.get(self.userid, 'password')
518 if password != pw:
519 self.make_user_anonymous()
520 self.error_message.append(_('Incorrect password'))
521 return
523 # make sure we're allowed to be here
524 if not self.loginPermission():
525 self.make_user_anonymous()
526 raise Unauthorised, _("You do not have permission to login")
528 # set the session cookie
529 self.set_cookie(self.user, password)
531 def loginPermission(self):
532 ''' Determine whether the user has permission to log in.
534 Base behaviour is to check the user has "Web Access".
535 '''
536 if not self.db.security.hasPermission('Web Access', self.userid):
537 return 0
538 return 1
540 def logout_action(self):
541 ''' Make us really anonymous - nuke the cookie too
542 '''
543 # log us out
544 self.make_user_anonymous()
546 # construct the logout cookie
547 now = Cookie._getdate()
548 path = '/'.join((self.env['SCRIPT_NAME'], self.env['TRACKER_NAME'],
549 ''))
550 self.additional_headers['Set-Cookie'] = \
551 'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)
553 # Let the user know what's going on
554 self.ok_message.append(_('You are logged out'))
556 def registerAction(self):
557 '''Attempt to create a new user based on the contents of the form
558 and then set the cookie.
560 return 1 on successful login
561 '''
562 # create the new user
563 cl = self.db.user
565 # parse the props from the form
566 try:
567 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
568 except (ValueError, KeyError), message:
569 self.error_message.append(_('Error: ') + str(message))
570 return
572 # make sure we're allowed to register
573 if not self.registerPermission(props):
574 raise Unauthorised, _("You do not have permission to register")
576 # re-open the database as "admin"
577 if self.user != 'admin':
578 self.opendb('admin')
580 # create the new user
581 cl = self.db.user
582 try:
583 props = parsePropsFromForm(self.db, cl, self.form)
584 props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
585 self.userid = cl.create(**props)
586 self.db.commit()
587 except ValueError, message:
588 self.error_message.append(message)
589 return
591 # log the new user in
592 self.user = cl.get(self.userid, 'username')
593 # re-open the database for real, using the user
594 self.opendb(self.user)
595 password = self.db.user.get(self.userid, 'password')
596 self.set_cookie(self.user, password)
598 # nice message
599 message = _('You are now registered, welcome!')
601 # redirect to the item's edit page
602 raise Redirect, '%s%s%s?:ok_message=%s'%(
603 self.base, self.classname, self.userid, urllib.quote(message))
605 def registerPermission(self, props):
606 ''' Determine whether the user has permission to register
608 Base behaviour is to check the user has "Web Registration".
609 '''
610 # registration isn't allowed to supply roles
611 if props.has_key('roles'):
612 return 0
613 if self.db.security.hasPermission('Web Registration', self.userid):
614 return 1
615 return 0
617 def editItemAction(self):
618 ''' Perform an edit of an item in the database.
620 Some special form elements:
622 :link=designator:property
623 :multilink=designator:property
624 The value specifies a node designator and the property on that
625 node to add _this_ node to as a link or multilink.
626 :note
627 Create a message and attach it to the current node's
628 "messages" property.
629 :file
630 Create a file and attach it to the current node's
631 "files" property. Attach the file to the message created from
632 the :note if it's supplied.
634 :required=property,property,...
635 The named properties are required to be filled in the form.
637 '''
638 cl = self.db.classes[self.classname]
640 # parse the props from the form
641 try:
642 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
643 except (ValueError, KeyError), message:
644 self.error_message.append(_('Error: ') + str(message))
645 return
647 # check permission
648 if not self.editItemPermission(props):
649 self.error_message.append(
650 _('You do not have permission to edit %(classname)s'%
651 self.__dict__))
652 return
654 # perform the edit
655 try:
656 # make changes to the node
657 props = self._changenode(props)
658 # handle linked nodes
659 self._post_editnode(self.nodeid)
660 except (ValueError, KeyError), message:
661 self.error_message.append(_('Error: ') + str(message))
662 return
664 # commit now that all the tricky stuff is done
665 self.db.commit()
667 # and some nice feedback for the user
668 if props:
669 message = _('%(changes)s edited ok')%{'changes':
670 ', '.join(props.keys())}
671 elif self.form.has_key(':note') and self.form[':note'].value:
672 message = _('note added')
673 elif (self.form.has_key(':file') and self.form[':file'].filename):
674 message = _('file added')
675 else:
676 message = _('nothing changed')
678 # redirect to the item's edit page
679 raise Redirect, '%s%s%s?:ok_message=%s'%(self.base, self.classname,
680 self.nodeid, urllib.quote(message))
682 def editItemPermission(self, props):
683 ''' Determine whether the user has permission to edit this item.
685 Base behaviour is to check the user can edit this class. If we're
686 editing the "user" class, users are allowed to edit their own
687 details. Unless it's the "roles" property, which requires the
688 special Permission "Web Roles".
689 '''
690 # if this is a user node and the user is editing their own node, then
691 # we're OK
692 has = self.db.security.hasPermission
693 if self.classname == 'user':
694 # reject if someone's trying to edit "roles" and doesn't have the
695 # right permission.
696 if props.has_key('roles') and not has('Web Roles', self.userid,
697 'user'):
698 return 0
699 # if the item being edited is the current user, we're ok
700 if self.nodeid == self.userid:
701 return 1
702 if self.db.security.hasPermission('Edit', self.userid, self.classname):
703 return 1
704 return 0
706 def newItemAction(self):
707 ''' Add a new item to the database.
709 This follows the same form as the editItemAction, with the same
710 special form values.
711 '''
712 cl = self.db.classes[self.classname]
714 # parse the props from the form
715 try:
716 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
717 except (ValueError, KeyError), message:
718 self.error_message.append(_('Error: ') + str(message))
719 return
721 if not self.newItemPermission(props):
722 self.error_message.append(
723 _('You do not have permission to create %s' %self.classname))
725 # create a little extra message for anticipated :link / :multilink
726 if self.form.has_key(':multilink'):
727 link = self.form[':multilink'].value
728 elif self.form.has_key(':link'):
729 link = self.form[':multilink'].value
730 else:
731 link = None
732 xtra = ''
733 if link:
734 designator, linkprop = link.split(':')
735 xtra = ' for <a href="%s">%s</a>'%(designator, designator)
737 try:
738 # do the create
739 nid = self._createnode(props)
741 # handle linked nodes
742 self._post_editnode(nid)
744 # commit now that all the tricky stuff is done
745 self.db.commit()
747 # render the newly created item
748 self.nodeid = nid
750 # and some nice feedback for the user
751 message = _('%(classname)s created ok')%self.__dict__ + xtra
752 except (ValueError, KeyError), message:
753 self.error_message.append(_('Error: ') + str(message))
754 return
755 except:
756 # oops
757 self.db.rollback()
758 s = StringIO.StringIO()
759 traceback.print_exc(None, s)
760 self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
761 return
763 # redirect to the new item's page
764 raise Redirect, '%s%s%s?:ok_message=%s'%(self.base, self.classname,
765 nid, urllib.quote(message))
767 def newItemPermission(self, props):
768 ''' Determine whether the user has permission to create (edit) this
769 item.
771 Base behaviour is to check the user can edit this class. No
772 additional property checks are made. Additionally, new user items
773 may be created if the user has the "Web Registration" Permission.
774 '''
775 has = self.db.security.hasPermission
776 if self.classname == 'user' and has('Web Registration', self.userid,
777 'user'):
778 return 1
779 if has('Edit', self.userid, self.classname):
780 return 1
781 return 0
783 def editCSVAction(self):
784 ''' Performs an edit of all of a class' items in one go.
786 The "rows" CGI var defines the CSV-formatted entries for the
787 class. New nodes are identified by the ID 'X' (or any other
788 non-existent ID) and removed lines are retired.
789 '''
790 # this is per-class only
791 if not self.editCSVPermission():
792 self.error_message.append(
793 _('You do not have permission to edit %s' %self.classname))
795 # get the CSV module
796 try:
797 import csv
798 except ImportError:
799 self.error_message.append(_(
800 'Sorry, you need the csv module to use this function.<br>\n'
801 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
802 return
804 cl = self.db.classes[self.classname]
805 idlessprops = cl.getprops(protected=0).keys()
806 idlessprops.sort()
807 props = ['id'] + idlessprops
809 # do the edit
810 rows = self.form['rows'].value.splitlines()
811 p = csv.parser()
812 found = {}
813 line = 0
814 for row in rows[1:]:
815 line += 1
816 values = p.parse(row)
817 # not a complete row, keep going
818 if not values: continue
820 # skip property names header
821 if values == props:
822 continue
824 # extract the nodeid
825 nodeid, values = values[0], values[1:]
826 found[nodeid] = 1
828 # confirm correct weight
829 if len(idlessprops) != len(values):
830 self.error_message.append(
831 _('Not enough values on line %(line)s')%{'line':line})
832 return
834 # extract the new values
835 d = {}
836 for name, value in zip(idlessprops, values):
837 value = value.strip()
838 # only add the property if it has a value
839 if value:
840 # if it's a multilink, split it
841 if isinstance(cl.properties[name], hyperdb.Multilink):
842 value = value.split(':')
843 d[name] = value
845 # perform the edit
846 if cl.hasnode(nodeid):
847 # edit existing
848 cl.set(nodeid, **d)
849 else:
850 # new node
851 found[cl.create(**d)] = 1
853 # retire the removed entries
854 for nodeid in cl.list():
855 if not found.has_key(nodeid):
856 cl.retire(nodeid)
858 # all OK
859 self.db.commit()
861 self.ok_message.append(_('Items edited OK'))
863 def editCSVPermission(self):
864 ''' Determine whether the user has permission to edit this class.
866 Base behaviour is to check the user can edit this class.
867 '''
868 if not self.db.security.hasPermission('Edit', self.userid,
869 self.classname):
870 return 0
871 return 1
873 def searchAction(self):
874 ''' Mangle some of the form variables.
876 Set the form ":filter" variable based on the values of the
877 filter variables - if they're set to anything other than
878 "dontcare" then add them to :filter.
880 Also handle the ":queryname" variable and save off the query to
881 the user's query list.
882 '''
883 # generic edit is per-class only
884 if not self.searchPermission():
885 self.error_message.append(
886 _('You do not have permission to search %s' %self.classname))
888 # add a faked :filter form variable for each filtering prop
889 props = self.db.classes[self.classname].getprops()
890 for key in self.form.keys():
891 if not props.has_key(key): continue
892 if not self.form[key].value: continue
893 self.form.value.append(cgi.MiniFieldStorage(':filter', key))
895 # handle saving the query params
896 if self.form.has_key(':queryname'):
897 queryname = self.form[':queryname'].value.strip()
898 if queryname:
899 # parse the environment and figure what the query _is_
900 req = HTMLRequest(self)
901 url = req.indexargs_href('', {})
903 # handle editing an existing query
904 try:
905 qid = self.db.query.lookup(queryname)
906 self.db.query.set(qid, klass=self.classname, url=url)
907 except KeyError:
908 # create a query
909 qid = self.db.query.create(name=queryname,
910 klass=self.classname, url=url)
912 # and add it to the user's query multilink
913 queries = self.db.user.get(self.userid, 'queries')
914 queries.append(qid)
915 self.db.user.set(self.userid, queries=queries)
917 # commit the query change to the database
918 self.db.commit()
920 def searchPermission(self):
921 ''' Determine whether the user has permission to search this class.
923 Base behaviour is to check the user can view this class.
924 '''
925 if not self.db.security.hasPermission('View', self.userid,
926 self.classname):
927 return 0
928 return 1
930 def remove_action(self, dre=re.compile(r'([^\d]+)(\d+)')):
931 # XXX I believe this could be handled by a regular edit action that
932 # just sets the multilink...
933 target = self.index_arg(':target')[0]
934 m = dre.match(target)
935 if m:
936 classname = m.group(1)
937 nodeid = m.group(2)
938 cl = self.db.getclass(classname)
939 cl.retire(nodeid)
940 # now take care of the reference
941 parentref = self.index_arg(':multilink')[0]
942 parent, prop = parentref.split(':')
943 m = dre.match(parent)
944 if m:
945 self.classname = m.group(1)
946 self.nodeid = m.group(2)
947 cl = self.db.getclass(self.classname)
948 value = cl.get(self.nodeid, prop)
949 value.remove(nodeid)
950 cl.set(self.nodeid, **{prop:value})
951 func = getattr(self, 'show%s'%self.classname)
952 return func()
953 else:
954 raise NotFound, parent
955 else:
956 raise NotFound, target
958 #
959 # Utility methods for editing
960 #
961 def _changenode(self, props):
962 ''' change the node based on the contents of the form
963 '''
964 cl = self.db.classes[self.classname]
966 # create the message
967 message, files = self._handle_message()
968 if message:
969 props['messages'] = cl.get(self.nodeid, 'messages') + [message]
970 if files:
971 props['files'] = cl.get(self.nodeid, 'files') + files
973 # make the changes
974 return cl.set(self.nodeid, **props)
976 def _createnode(self, props):
977 ''' create a node based on the contents of the form
978 '''
979 cl = self.db.classes[self.classname]
981 # check for messages and files
982 message, files = self._handle_message()
983 if message:
984 props['messages'] = [message]
985 if files:
986 props['files'] = files
987 # create the node and return it's id
988 return cl.create(**props)
990 def _handle_message(self):
991 ''' generate an edit message
992 '''
993 # handle file attachments
994 files = []
995 if self.form.has_key(':file'):
996 file = self.form[':file']
997 if file.filename:
998 filename = file.filename.split('\\')[-1]
999 mime_type = mimetypes.guess_type(filename)[0]
1000 if not mime_type:
1001 mime_type = "application/octet-stream"
1002 # create the new file entry
1003 files.append(self.db.file.create(type=mime_type,
1004 name=filename, content=file.file.read()))
1006 # we don't want to do a message if none of the following is true...
1007 cn = self.classname
1008 cl = self.db.classes[self.classname]
1009 props = cl.getprops()
1010 note = None
1011 # in a nutshell, don't do anything if there's no note or there's no
1012 # NOSY
1013 if self.form.has_key(':note'):
1014 note = self.form[':note'].value.strip()
1015 if not note:
1016 return None, files
1017 if not props.has_key('messages'):
1018 return None, files
1019 if not isinstance(props['messages'], hyperdb.Multilink):
1020 return None, files
1021 if not props['messages'].classname == 'msg':
1022 return None, files
1023 if not (self.form.has_key('nosy') or note):
1024 return None, files
1026 # handle the note
1027 if '\n' in note:
1028 summary = re.split(r'\n\r?', note)[0]
1029 else:
1030 summary = note
1031 m = ['%s\n'%note]
1033 # handle the messageid
1034 # TODO: handle inreplyto
1035 messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
1036 self.classname, self.instance.config.MAIL_DOMAIN)
1038 # now create the message, attaching the files
1039 content = '\n'.join(m)
1040 message_id = self.db.msg.create(author=self.userid,
1041 recipients=[], date=date.Date('.'), summary=summary,
1042 content=content, files=files, messageid=messageid)
1044 # update the messages property
1045 return message_id, files
1047 def _post_editnode(self, nid):
1048 '''Do the linking part of the node creation.
1050 If a form element has :link or :multilink appended to it, its
1051 value specifies a node designator and the property on that node
1052 to add _this_ node to as a link or multilink.
1054 This is typically used on, eg. the file upload page to indicated
1055 which issue to link the file to.
1057 TODO: I suspect that this and newfile will go away now that
1058 there's the ability to upload a file using the issue :file form
1059 element!
1060 '''
1061 cn = self.classname
1062 cl = self.db.classes[cn]
1063 # link if necessary
1064 keys = self.form.keys()
1065 for key in keys:
1066 if key == ':multilink':
1067 value = self.form[key].value
1068 if type(value) != type([]): value = [value]
1069 for value in value:
1070 designator, property = value.split(':')
1071 link, nodeid = hyperdb.splitDesignator(designator)
1072 link = self.db.classes[link]
1073 # take a dupe of the list so we're not changing the cache
1074 value = link.get(nodeid, property)[:]
1075 value.append(nid)
1076 link.set(nodeid, **{property: value})
1077 elif key == ':link':
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 link.set(nodeid, **{property: nid})
1087 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
1088 ''' Pull properties for the given class out of the form.
1090 If a ":required" parameter is supplied, then the names property values
1091 must be supplied or a ValueError will be raised.
1092 '''
1093 required = []
1094 if form.has_key(':required'):
1095 value = form[':required']
1096 if isinstance(value, type([])):
1097 required = [i.value.strip() for i in value]
1098 else:
1099 required = [i.strip() for i in value.value.split(',')]
1101 props = {}
1102 keys = form.keys()
1103 properties = cl.getprops()
1104 for key in keys:
1105 if not properties.has_key(key):
1106 continue
1107 proptype = properties[key]
1109 # Get the form value. This value may be a MiniFieldStorage or a list
1110 # of MiniFieldStorages.
1111 value = form[key]
1113 # make sure non-multilinks only get one value
1114 if not isinstance(proptype, hyperdb.Multilink):
1115 if isinstance(value, type([])):
1116 raise ValueError, 'You have submitted more than one value'\
1117 ' for the %s property'%key
1118 # we've got a MiniFieldStorage, so pull out the value and strip
1119 # surrounding whitespace
1120 value = value.value.strip()
1122 if isinstance(proptype, hyperdb.String):
1123 if not value:
1124 continue
1125 elif isinstance(proptype, hyperdb.Password):
1126 if not value:
1127 # ignore empty password values
1128 continue
1129 if not form.has_key('%s:confirm'%key):
1130 raise ValueError, 'Password and confirmation text do not match'
1131 confirm = form['%s:confirm'%key]
1132 if isinstance(confirm, type([])):
1133 raise ValueError, 'You have submitted more than one value'\
1134 ' for the %s property'%key
1135 if value != confirm.value:
1136 raise ValueError, 'Password and confirmation text do not match'
1137 value = password.Password(value)
1138 elif isinstance(proptype, hyperdb.Date):
1139 if value:
1140 value = date.Date(form[key].value.strip())
1141 else:
1142 value = None
1143 elif isinstance(proptype, hyperdb.Interval):
1144 if value:
1145 value = date.Interval(form[key].value.strip())
1146 else:
1147 value = None
1148 elif isinstance(proptype, hyperdb.Link):
1149 # see if it's the "no selection" choice
1150 if value == '-1':
1151 value = None
1152 else:
1153 # handle key values
1154 link = proptype.classname
1155 if not num_re.match(value):
1156 try:
1157 value = db.classes[link].lookup(value)
1158 except KeyError:
1159 raise ValueError, _('property "%(propname)s": '
1160 '%(value)s not a %(classname)s')%{'propname':key,
1161 'value': value, 'classname': link}
1162 except TypeError, message:
1163 raise ValueError, _('you may only enter ID values '
1164 'for property "%(propname)s": %(message)s')%{
1165 'propname':key, 'message': message}
1166 elif isinstance(proptype, hyperdb.Multilink):
1167 if isinstance(value, type([])):
1168 # it's a list of MiniFieldStorages
1169 value = [i.value.strip() for i in value]
1170 else:
1171 # it's a MiniFieldStorage, but may be a comma-separated list
1172 # of values
1173 value = [i.strip() for i in value.value.split(',')]
1174 link = proptype.classname
1175 l = []
1176 for entry in map(str, value):
1177 if entry == '': continue
1178 if not num_re.match(entry):
1179 try:
1180 entry = db.classes[link].lookup(entry)
1181 except KeyError:
1182 raise ValueError, _('property "%(propname)s": '
1183 '"%(value)s" not an entry of %(classname)s')%{
1184 'propname':key, 'value': entry, 'classname': link}
1185 except TypeError, message:
1186 raise ValueError, _('you may only enter ID values '
1187 'for property "%(propname)s": %(message)s')%{
1188 'propname':key, 'message': message}
1189 l.append(entry)
1190 l.sort()
1191 value = l
1192 elif isinstance(proptype, hyperdb.Boolean):
1193 props[key] = value = value.lower() in ('yes', 'true', 'on', '1')
1194 elif isinstance(proptype, hyperdb.Number):
1195 props[key] = value = int(value)
1197 # register this as received if required
1198 if key in required:
1199 required.remove(key)
1201 # get the old value
1202 if nodeid:
1203 try:
1204 existing = cl.get(nodeid, key)
1205 except KeyError:
1206 # this might be a new property for which there is no existing
1207 # value
1208 if not properties.has_key(key): raise
1210 # if changed, set it
1211 if value != existing:
1212 props[key] = value
1213 else:
1214 props[key] = value
1216 # see if all the required properties have been supplied
1217 if required:
1218 if len(required) > 1:
1219 p = 'properties'
1220 else:
1221 p = 'property'
1222 raise ValueError, 'Required %s %s not supplied'%(p, ', '.join(required))
1224 return props