1 # $Id: client.py,v 1.32 2002-09-13 00:08:44 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 # with a designator, we default to item view
301 self.template = 'item'
302 else:
303 # with only a class, we default to index view
304 self.template = 'index'
306 # see if we have a template override
307 if self.form.has_key(':template'):
308 self.template = self.form[':template'].value
310 # see if we were passed in a message
311 if self.form.has_key(':ok_message'):
312 self.ok_message.append(self.form[':ok_message'].value)
313 if self.form.has_key(':error_message'):
314 self.error_message.append(self.form[':error_message'].value)
316 def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
317 ''' Serve the file from the content property of the designated item.
318 '''
319 m = dre.match(str(designator))
320 if not m:
321 raise NotFound, str(designator)
322 classname, nodeid = m.group(1), m.group(2)
323 if classname != 'file':
324 raise NotFound, designator
326 # we just want to serve up the file named
327 file = self.db.file
328 self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
329 self.write(file.get(nodeid, 'content'))
331 def serve_static_file(self, file):
332 # we just want to serve up the file named
333 mt = mimetypes.guess_type(str(file))[0]
334 self.additional_headers['Content-Type'] = mt
335 self.write(open(os.path.join(self.instance.config.TEMPLATES,
336 file)).read())
338 def renderTemplate(self, name, extension, **kwargs):
339 ''' Return a PageTemplate for the named page
340 '''
341 pt = getTemplate(self.instance.config.TEMPLATES, name, extension)
342 # catch errors so we can handle PT rendering errors more nicely
343 try:
344 # let the template render figure stuff out
345 return pt.render(self, None, None, **kwargs)
346 except PageTemplate.PTRuntimeError, message:
347 return '<strong>%s</strong><ol>%s</ol>'%(message,
348 '<li>'.join(pt._v_errors))
349 except NoTemplate, message:
350 return '<strong>%s</strong>'%message
351 except:
352 # everything else
353 return cgitb.pt_html()
355 def content(self):
356 ''' Callback used by the page template to render the content of
357 the page.
359 If we don't have a specific class to display, that is none was
360 determined in determine_context(), then we display a "home"
361 template.
362 '''
363 # now render the page content using the template we determined in
364 # determine_context
365 if self.classname is None:
366 name = 'home'
367 else:
368 name = self.classname
369 return self.renderTemplate(self.classname, self.template)
371 # these are the actions that are available
372 actions = {
373 'edit': 'editItemAction',
374 'editCSV': 'editCSVAction',
375 'new': 'newItemAction',
376 'register': 'registerAction',
377 'login': 'loginAction',
378 'logout': 'logout_action',
379 'search': 'searchAction',
380 }
381 def handle_action(self):
382 ''' Determine whether there should be an _action called.
384 The action is defined by the form variable :action which
385 identifies the method on this object to call. The four basic
386 actions are defined in the "actions" dictionary on this class:
387 "edit" -> self.editItemAction
388 "new" -> self.newItemAction
389 "register" -> self.registerAction
390 "login" -> self.loginAction
391 "logout" -> self.logout_action
392 "search" -> self.searchAction
394 '''
395 if not self.form.has_key(':action'):
396 return None
397 try:
398 # get the action, validate it
399 action = self.form[':action'].value
400 if not self.actions.has_key(action):
401 raise ValueError, 'No such action "%s"'%action
403 # call the mapped action
404 getattr(self, self.actions[action])()
405 except Redirect:
406 raise
407 except Unauthorised:
408 raise
409 except:
410 self.db.rollback()
411 s = StringIO.StringIO()
412 traceback.print_exc(None, s)
413 self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
415 def write(self, content):
416 if not self.headers_done:
417 self.header()
418 self.request.wfile.write(content)
420 def header(self, headers=None, response=None):
421 '''Put up the appropriate header.
422 '''
423 if headers is None:
424 headers = {'Content-Type':'text/html'}
425 if response is None:
426 response = self.response_code
428 # update with additional info
429 headers.update(self.additional_headers)
431 if not headers.has_key('Content-Type'):
432 headers['Content-Type'] = 'text/html'
433 self.request.send_response(response)
434 for entry in headers.items():
435 self.request.send_header(*entry)
436 self.request.end_headers()
437 self.headers_done = 1
438 if self.debug:
439 self.headers_sent = headers
441 def set_cookie(self, user, password):
442 # TODO generate a much, much stronger session key ;)
443 self.session = binascii.b2a_base64(repr(random.random())).strip()
445 # clean up the base64
446 if self.session[-1] == '=':
447 if self.session[-2] == '=':
448 self.session = self.session[:-2]
449 else:
450 self.session = self.session[:-1]
452 # insert the session in the sessiondb
453 self.db.sessions.set(self.session, user=user, last_use=time.time())
455 # and commit immediately
456 self.db.sessions.commit()
458 # expire us in a long, long time
459 expire = Cookie._getdate(86400*365)
461 # generate the cookie path - make sure it has a trailing '/'
462 path = '/'.join((self.env['SCRIPT_NAME'], self.env['TRACKER_NAME'],
463 ''))
464 self.additional_headers['Set-Cookie'] = \
465 'roundup_user_2=%s; expires=%s; Path=%s;'%(self.session, expire, path)
467 def make_user_anonymous(self):
468 ''' Make us anonymous
470 This method used to handle non-existence of the 'anonymous'
471 user, but that user is mandatory now.
472 '''
473 self.userid = self.db.user.lookup('anonymous')
474 self.user = 'anonymous'
476 def logout(self):
477 ''' Make us really anonymous - nuke the cookie too
478 '''
479 self.make_user_anonymous()
481 # construct the logout cookie
482 now = Cookie._getdate()
483 path = '/'.join((self.env['SCRIPT_NAME'], self.env['TRACKER_NAME'],
484 ''))
485 self.additional_headers['Set-Cookie'] = \
486 'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)
487 self.login()
489 def opendb(self, user):
490 ''' Open the database.
491 '''
492 # open the db if the user has changed
493 if not hasattr(self, 'db') or user != self.db.journaltag:
494 self.db = self.instance.open(user)
496 #
497 # Actions
498 #
499 def loginAction(self):
500 ''' Attempt to log a user in.
502 Sets up a session for the user which contains the login
503 credentials.
504 '''
505 # we need the username at a minimum
506 if not self.form.has_key('__login_name'):
507 self.error_message.append(_('Username required'))
508 return
510 self.user = self.form['__login_name'].value
511 # re-open the database for real, using the user
512 self.opendb(self.user)
513 if self.form.has_key('__login_password'):
514 password = self.form['__login_password'].value
515 else:
516 password = ''
517 # make sure the user exists
518 try:
519 self.userid = self.db.user.lookup(self.user)
520 except KeyError:
521 name = self.user
522 self.make_user_anonymous()
523 self.error_message.append(_('No such user "%(name)s"')%locals())
524 return
526 # and that the password is correct
527 pw = self.db.user.get(self.userid, 'password')
528 if password != pw:
529 self.make_user_anonymous()
530 self.error_message.append(_('Incorrect password'))
531 return
533 # make sure we're allowed to be here
534 if not self.loginPermission():
535 self.make_user_anonymous()
536 raise Unauthorised, _("You do not have permission to login")
538 # set the session cookie
539 self.set_cookie(self.user, password)
541 def loginPermission(self):
542 ''' Determine whether the user has permission to log in.
544 Base behaviour is to check the user has "Web Access".
545 '''
546 if not self.db.security.hasPermission('Web Access', self.userid):
547 return 0
548 return 1
550 def logout_action(self):
551 ''' Make us really anonymous - nuke the cookie too
552 '''
553 # log us out
554 self.make_user_anonymous()
556 # construct the logout cookie
557 now = Cookie._getdate()
558 path = '/'.join((self.env['SCRIPT_NAME'], self.env['TRACKER_NAME'],
559 ''))
560 self.additional_headers['Set-Cookie'] = \
561 'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)
563 # Let the user know what's going on
564 self.ok_message.append(_('You are logged out'))
566 def registerAction(self):
567 '''Attempt to create a new user based on the contents of the form
568 and then set the cookie.
570 return 1 on successful login
571 '''
572 # create the new user
573 cl = self.db.user
575 # parse the props from the form
576 try:
577 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
578 except (ValueError, KeyError), message:
579 self.error_message.append(_('Error: ') + str(message))
580 return
582 # make sure we're allowed to register
583 if not self.registerPermission(props):
584 raise Unauthorised, _("You do not have permission to register")
586 # re-open the database as "admin"
587 if self.user != 'admin':
588 self.opendb('admin')
590 # create the new user
591 cl = self.db.user
592 try:
593 props = parsePropsFromForm(self.db, cl, self.form)
594 props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
595 self.userid = cl.create(**props)
596 self.db.commit()
597 except ValueError, message:
598 self.error_message.append(message)
600 # log the new user in
601 self.user = cl.get(self.userid, 'username')
602 # re-open the database for real, using the user
603 self.opendb(self.user)
604 password = self.db.user.get(self.userid, 'password')
605 self.set_cookie(self.user, password)
607 # nice message
608 message = _('You are now registered, welcome!')
610 # redirect to the item's edit page
611 raise Redirect, '%s/%s%s?:ok_message=%s'%(
612 self.base, self.classname, self.userid, urllib.quote(message))
614 def registerPermission(self, props):
615 ''' Determine whether the user has permission to register
617 Base behaviour is to check the user has "Web Registration".
618 '''
619 # registration isn't allowed to supply roles
620 if props.has_key('roles'):
621 return 0
622 if self.db.security.hasPermission('Web Registration', self.userid):
623 return 1
624 return 0
626 def editItemAction(self):
627 ''' Perform an edit of an item in the database.
629 Some special form elements:
631 :link=designator:property
632 :multilink=designator:property
633 The value specifies a node designator and the property on that
634 node to add _this_ node to as a link or multilink.
635 :note
636 Create a message and attach it to the current node's
637 "messages" property.
638 :file
639 Create a file and attach it to the current node's
640 "files" property. Attach the file to the message created from
641 the :note if it's supplied.
643 :required=property,property,...
644 The named properties are required to be filled in the form.
646 '''
647 cl = self.db.classes[self.classname]
649 # parse the props from the form
650 try:
651 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
652 except (ValueError, KeyError), message:
653 self.error_message.append(_('Error: ') + str(message))
654 return
656 # check permission
657 if not self.editItemPermission(props):
658 self.error_message.append(
659 _('You do not have permission to edit %(classname)s'%
660 self.__dict__))
661 return
663 # perform the edit
664 try:
665 # make changes to the node
666 props = self._changenode(props)
667 # handle linked nodes
668 self._post_editnode(self.nodeid)
669 except (ValueError, KeyError), message:
670 self.error_message.append(_('Error: ') + str(message))
671 return
673 # commit now that all the tricky stuff is done
674 self.db.commit()
676 # and some nice feedback for the user
677 if props:
678 message = _('%(changes)s edited ok')%{'changes':
679 ', '.join(props.keys())}
680 elif self.form.has_key(':note') and self.form[':note'].value:
681 message = _('note added')
682 elif (self.form.has_key(':file') and self.form[':file'].filename):
683 message = _('file added')
684 else:
685 message = _('nothing changed')
687 # redirect to the item's edit page
688 raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
689 self.nodeid, urllib.quote(message))
691 def editItemPermission(self, props):
692 ''' Determine whether the user has permission to edit this item.
694 Base behaviour is to check the user can edit this class. If we're
695 editing the "user" class, users are allowed to edit their own
696 details. Unless it's the "roles" property, which requires the
697 special Permission "Web Roles".
698 '''
699 # if this is a user node and the user is editing their own node, then
700 # we're OK
701 has = self.db.security.hasPermission
702 if self.classname == 'user':
703 # reject if someone's trying to edit "roles" and doesn't have the
704 # right permission.
705 if props.has_key('roles') and not has('Web Roles', self.userid,
706 'user'):
707 return 0
708 # if the item being edited is the current user, we're ok
709 if self.nodeid == self.userid:
710 return 1
711 if self.db.security.hasPermission('Edit', self.userid, self.classname):
712 return 1
713 return 0
715 def newItemAction(self):
716 ''' Add a new item to the database.
718 This follows the same form as the editItemAction, with the same
719 special form values.
720 '''
721 cl = self.db.classes[self.classname]
723 # parse the props from the form
724 try:
725 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
726 except (ValueError, KeyError), message:
727 self.error_message.append(_('Error: ') + str(message))
728 return
730 if not self.newItemPermission(props):
731 self.error_message.append(
732 _('You do not have permission to create %s' %self.classname))
734 # create a little extra message for anticipated :link / :multilink
735 if self.form.has_key(':multilink'):
736 link = self.form[':multilink'].value
737 elif self.form.has_key(':link'):
738 link = self.form[':multilink'].value
739 else:
740 link = None
741 xtra = ''
742 if link:
743 designator, linkprop = link.split(':')
744 xtra = ' for <a href="%s">%s</a>'%(designator, designator)
746 try:
747 # do the create
748 nid = self._createnode(props)
750 # handle linked nodes
751 self._post_editnode(nid)
753 # commit now that all the tricky stuff is done
754 self.db.commit()
756 # render the newly created item
757 self.nodeid = nid
759 # and some nice feedback for the user
760 message = _('%(classname)s created ok')%self.__dict__ + xtra
761 except (ValueError, KeyError), message:
762 self.error_message.append(_('Error: ') + str(message))
763 return
764 except:
765 # oops
766 self.db.rollback()
767 s = StringIO.StringIO()
768 traceback.print_exc(None, s)
769 self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
770 return
772 # redirect to the new item's page
773 raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
774 nid, urllib.quote(message))
776 def newItemPermission(self, props):
777 ''' Determine whether the user has permission to create (edit) this
778 item.
780 Base behaviour is to check the user can edit this class. No
781 additional property checks are made. Additionally, new user items
782 may be created if the user has the "Web Registration" Permission.
783 '''
784 has = self.db.security.hasPermission
785 if self.classname == 'user' and has('Web Registration', self.userid,
786 'user'):
787 return 1
788 if has('Edit', self.userid, self.classname):
789 return 1
790 return 0
792 def editCSVAction(self):
793 ''' Performs an edit of all of a class' items in one go.
795 The "rows" CGI var defines the CSV-formatted entries for the
796 class. New nodes are identified by the ID 'X' (or any other
797 non-existent ID) and removed lines are retired.
798 '''
799 # this is per-class only
800 if not self.editCSVPermission():
801 self.error_message.append(
802 _('You do not have permission to edit %s' %self.classname))
804 # get the CSV module
805 try:
806 import csv
807 except ImportError:
808 self.error_message.append(_(
809 'Sorry, you need the csv module to use this function.<br>\n'
810 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
811 return
813 cl = self.db.classes[self.classname]
814 idlessprops = cl.getprops(protected=0).keys()
815 idlessprops.sort()
816 props = ['id'] + idlessprops
818 # do the edit
819 rows = self.form['rows'].value.splitlines()
820 p = csv.parser()
821 found = {}
822 line = 0
823 for row in rows[1:]:
824 line += 1
825 values = p.parse(row)
826 # not a complete row, keep going
827 if not values: continue
829 # skip property names header
830 if values == props:
831 continue
833 # extract the nodeid
834 nodeid, values = values[0], values[1:]
835 found[nodeid] = 1
837 # confirm correct weight
838 if len(idlessprops) != len(values):
839 self.error_message.append(
840 _('Not enough values on line %(line)s')%{'line':line})
841 return
843 # extract the new values
844 d = {}
845 for name, value in zip(idlessprops, values):
846 value = value.strip()
847 # only add the property if it has a value
848 if value:
849 # if it's a multilink, split it
850 if isinstance(cl.properties[name], hyperdb.Multilink):
851 value = value.split(':')
852 d[name] = value
854 # perform the edit
855 if cl.hasnode(nodeid):
856 # edit existing
857 cl.set(nodeid, **d)
858 else:
859 # new node
860 found[cl.create(**d)] = 1
862 # retire the removed entries
863 for nodeid in cl.list():
864 if not found.has_key(nodeid):
865 cl.retire(nodeid)
867 # all OK
868 self.db.commit()
870 self.ok_message.append(_('Items edited OK'))
872 def editCSVPermission(self):
873 ''' Determine whether the user has permission to edit this class.
875 Base behaviour is to check the user can edit this class.
876 '''
877 if not self.db.security.hasPermission('Edit', self.userid,
878 self.classname):
879 return 0
880 return 1
882 def searchAction(self):
883 ''' Mangle some of the form variables.
885 Set the form ":filter" variable based on the values of the
886 filter variables - if they're set to anything other than
887 "dontcare" then add them to :filter.
889 Also handle the ":queryname" variable and save off the query to
890 the user's query list.
891 '''
892 # generic edit is per-class only
893 if not self.searchPermission():
894 self.error_message.append(
895 _('You do not have permission to search %s' %self.classname))
897 # add a faked :filter form variable for each filtering prop
898 props = self.db.classes[self.classname].getprops()
899 for key in self.form.keys():
900 if not props.has_key(key): continue
901 if not self.form[key].value: continue
902 self.form.value.append(cgi.MiniFieldStorage(':filter', key))
904 # handle saving the query params
905 if self.form.has_key(':queryname'):
906 queryname = self.form[':queryname'].value.strip()
907 if queryname:
908 # parse the environment and figure what the query _is_
909 req = HTMLRequest(self)
910 url = req.indexargs_href('', {})
912 # handle editing an existing query
913 try:
914 qid = self.db.query.lookup(queryname)
915 self.db.query.set(qid, klass=self.classname, url=url)
916 except KeyError:
917 # create a query
918 qid = self.db.query.create(name=queryname,
919 klass=self.classname, url=url)
921 # and add it to the user's query multilink
922 queries = self.db.user.get(self.userid, 'queries')
923 queries.append(qid)
924 self.db.user.set(self.userid, queries=queries)
926 # commit the query change to the database
927 self.db.commit()
929 def searchPermission(self):
930 ''' Determine whether the user has permission to search this class.
932 Base behaviour is to check the user can view this class.
933 '''
934 if not self.db.security.hasPermission('View', self.userid,
935 self.classname):
936 return 0
937 return 1
939 def remove_action(self, dre=re.compile(r'([^\d]+)(\d+)')):
940 # XXX I believe this could be handled by a regular edit action that
941 # just sets the multilink...
942 target = self.index_arg(':target')[0]
943 m = dre.match(target)
944 if m:
945 classname = m.group(1)
946 nodeid = m.group(2)
947 cl = self.db.getclass(classname)
948 cl.retire(nodeid)
949 # now take care of the reference
950 parentref = self.index_arg(':multilink')[0]
951 parent, prop = parentref.split(':')
952 m = dre.match(parent)
953 if m:
954 self.classname = m.group(1)
955 self.nodeid = m.group(2)
956 cl = self.db.getclass(self.classname)
957 value = cl.get(self.nodeid, prop)
958 value.remove(nodeid)
959 cl.set(self.nodeid, **{prop:value})
960 func = getattr(self, 'show%s'%self.classname)
961 return func()
962 else:
963 raise NotFound, parent
964 else:
965 raise NotFound, target
967 #
968 # Utility methods for editing
969 #
970 def _changenode(self, props):
971 ''' change the node based on the contents of the form
972 '''
973 cl = self.db.classes[self.classname]
975 # create the message
976 message, files = self._handle_message()
977 if message:
978 props['messages'] = cl.get(self.nodeid, 'messages') + [message]
979 if files:
980 props['files'] = cl.get(self.nodeid, 'files') + files
982 # make the changes
983 return cl.set(self.nodeid, **props)
985 def _createnode(self, props):
986 ''' create a node based on the contents of the form
987 '''
988 cl = self.db.classes[self.classname]
990 # check for messages and files
991 message, files = self._handle_message()
992 if message:
993 props['messages'] = [message]
994 if files:
995 props['files'] = files
996 # create the node and return it's id
997 return cl.create(**props)
999 def _handle_message(self):
1000 ''' generate an edit message
1001 '''
1002 # handle file attachments
1003 files = []
1004 if self.form.has_key(':file'):
1005 file = self.form[':file']
1006 if file.filename:
1007 filename = file.filename.split('\\')[-1]
1008 mime_type = mimetypes.guess_type(filename)[0]
1009 if not mime_type:
1010 mime_type = "application/octet-stream"
1011 # create the new file entry
1012 files.append(self.db.file.create(type=mime_type,
1013 name=filename, content=file.file.read()))
1015 # we don't want to do a message if none of the following is true...
1016 cn = self.classname
1017 cl = self.db.classes[self.classname]
1018 props = cl.getprops()
1019 note = None
1020 # in a nutshell, don't do anything if there's no note or there's no
1021 # NOSY
1022 if self.form.has_key(':note'):
1023 note = self.form[':note'].value.strip()
1024 if not note:
1025 return None, files
1026 if not props.has_key('messages'):
1027 return None, files
1028 if not isinstance(props['messages'], hyperdb.Multilink):
1029 return None, files
1030 if not props['messages'].classname == 'msg':
1031 return None, files
1032 if not (self.form.has_key('nosy') or note):
1033 return None, files
1035 # handle the note
1036 if '\n' in note:
1037 summary = re.split(r'\n\r?', note)[0]
1038 else:
1039 summary = note
1040 m = ['%s\n'%note]
1042 # handle the messageid
1043 # TODO: handle inreplyto
1044 messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
1045 self.classname, self.instance.config.MAIL_DOMAIN)
1047 # now create the message, attaching the files
1048 content = '\n'.join(m)
1049 message_id = self.db.msg.create(author=self.userid,
1050 recipients=[], date=date.Date('.'), summary=summary,
1051 content=content, files=files, messageid=messageid)
1053 # update the messages property
1054 return message_id, files
1056 def _post_editnode(self, nid):
1057 '''Do the linking part of the node creation.
1059 If a form element has :link or :multilink appended to it, its
1060 value specifies a node designator and the property on that node
1061 to add _this_ node to as a link or multilink.
1063 This is typically used on, eg. the file upload page to indicated
1064 which issue to link the file to.
1066 TODO: I suspect that this and newfile will go away now that
1067 there's the ability to upload a file using the issue :file form
1068 element!
1069 '''
1070 cn = self.classname
1071 cl = self.db.classes[cn]
1072 # link if necessary
1073 keys = self.form.keys()
1074 for key in keys:
1075 if key == ':multilink':
1076 value = self.form[key].value
1077 if type(value) != type([]): value = [value]
1078 for value in value:
1079 designator, property = value.split(':')
1080 link, nodeid = hyperdb.splitDesignator(designator)
1081 link = self.db.classes[link]
1082 # take a dupe of the list so we're not changing the cache
1083 value = link.get(nodeid, property)[:]
1084 value.append(nid)
1085 link.set(nodeid, **{property: value})
1086 elif key == ':link':
1087 value = self.form[key].value
1088 if type(value) != type([]): value = [value]
1089 for value in value:
1090 designator, property = value.split(':')
1091 link, nodeid = hyperdb.splitDesignator(designator)
1092 link = self.db.classes[link]
1093 link.set(nodeid, **{property: nid})
1096 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
1097 ''' Pull properties for the given class out of the form.
1099 If a ":required" parameter is supplied, then the names property values
1100 must be supplied or a ValueError will be raised.
1101 '''
1102 required = []
1103 if form.has_key(':required'):
1104 value = form[':required']
1105 if isinstance(value, type([])):
1106 required = [i.value.strip() for i in value]
1107 else:
1108 required = [i.strip() for i in value.value.split(',')]
1110 props = {}
1111 keys = form.keys()
1112 for key in keys:
1113 if not cl.properties.has_key(key):
1114 continue
1115 proptype = cl.properties[key]
1117 # Get the form value. This value may be a MiniFieldStorage or a list
1118 # of MiniFieldStorages.
1119 value = form[key]
1121 # make sure non-multilinks only get one value
1122 if not isinstance(proptype, hyperdb.Multilink):
1123 if isinstance(value, type([])):
1124 raise ValueError, 'You have submitted more than one value'\
1125 ' for the %s property'%key
1126 # we've got a MiniFieldStorage, so pull out the value and strip
1127 # surrounding whitespace
1128 value = value.value.strip()
1130 if isinstance(proptype, hyperdb.String):
1131 if not value:
1132 continue
1133 elif isinstance(proptype, hyperdb.Password):
1134 if not value:
1135 # ignore empty password values
1136 continue
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 = cl.properties[key].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 = cl.properties[key].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 cl.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