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