1 # $Id: client.py,v 1.35 2002-09-16 05:33:58 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><li>%s</ol>'%(message,
348 '<li>'.join([cgi.escape(x) for x in 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" sequence 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 for name, method in self.actions:
401 if name == action:
402 break
403 else:
404 raise ValueError, 'No such action "%s"'%action
406 # call the mapped action
407 getattr(self, method)()
408 except Redirect:
409 raise
410 except Unauthorised:
411 raise
412 except:
413 self.db.rollback()
414 s = StringIO.StringIO()
415 traceback.print_exc(None, s)
416 self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
418 def write(self, content):
419 if not self.headers_done:
420 self.header()
421 self.request.wfile.write(content)
423 def header(self, headers=None, response=None):
424 '''Put up the appropriate header.
425 '''
426 if headers is None:
427 headers = {'Content-Type':'text/html'}
428 if response is None:
429 response = self.response_code
431 # update with additional info
432 headers.update(self.additional_headers)
434 if not headers.has_key('Content-Type'):
435 headers['Content-Type'] = 'text/html'
436 self.request.send_response(response)
437 for entry in headers.items():
438 self.request.send_header(*entry)
439 self.request.end_headers()
440 self.headers_done = 1
441 if self.debug:
442 self.headers_sent = headers
444 def set_cookie(self, user, password):
445 # TODO generate a much, much stronger session key ;)
446 self.session = binascii.b2a_base64(repr(random.random())).strip()
448 # clean up the base64
449 if self.session[-1] == '=':
450 if self.session[-2] == '=':
451 self.session = self.session[:-2]
452 else:
453 self.session = self.session[:-1]
455 # insert the session in the sessiondb
456 self.db.sessions.set(self.session, user=user, last_use=time.time())
458 # and commit immediately
459 self.db.sessions.commit()
461 # expire us in a long, long time
462 expire = Cookie._getdate(86400*365)
464 # generate the cookie path - make sure it has a trailing '/'
465 path = '/'.join((self.env['SCRIPT_NAME'], self.env['TRACKER_NAME'],
466 ''))
467 self.additional_headers['Set-Cookie'] = \
468 'roundup_user_2=%s; expires=%s; Path=%s;'%(self.session, expire, path)
470 def make_user_anonymous(self):
471 ''' Make us anonymous
473 This method used to handle non-existence of the 'anonymous'
474 user, but that user is mandatory now.
475 '''
476 self.userid = self.db.user.lookup('anonymous')
477 self.user = 'anonymous'
479 def logout(self):
480 ''' Make us really anonymous - nuke the cookie too
481 '''
482 self.make_user_anonymous()
484 # construct the logout cookie
485 now = Cookie._getdate()
486 path = '/'.join((self.env['SCRIPT_NAME'], self.env['TRACKER_NAME'],
487 ''))
488 self.additional_headers['Set-Cookie'] = \
489 'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)
490 self.login()
492 def opendb(self, user):
493 ''' Open the database.
494 '''
495 # open the db if the user has changed
496 if not hasattr(self, 'db') or user != self.db.journaltag:
497 self.db = self.instance.open(user)
499 #
500 # Actions
501 #
502 def loginAction(self):
503 ''' Attempt to log a user in.
505 Sets up a session for the user which contains the login
506 credentials.
507 '''
508 # we need the username at a minimum
509 if not self.form.has_key('__login_name'):
510 self.error_message.append(_('Username required'))
511 return
513 self.user = self.form['__login_name'].value
514 # re-open the database for real, using the user
515 self.opendb(self.user)
516 if self.form.has_key('__login_password'):
517 password = self.form['__login_password'].value
518 else:
519 password = ''
520 # make sure the user exists
521 try:
522 self.userid = self.db.user.lookup(self.user)
523 except KeyError:
524 name = self.user
525 self.make_user_anonymous()
526 self.error_message.append(_('No such user "%(name)s"')%locals())
527 return
529 # and that the password is correct
530 pw = self.db.user.get(self.userid, 'password')
531 if password != pw:
532 self.make_user_anonymous()
533 self.error_message.append(_('Incorrect password'))
534 return
536 # make sure we're allowed to be here
537 if not self.loginPermission():
538 self.make_user_anonymous()
539 raise Unauthorised, _("You do not have permission to login")
541 # set the session cookie
542 self.set_cookie(self.user, password)
544 def loginPermission(self):
545 ''' Determine whether the user has permission to log in.
547 Base behaviour is to check the user has "Web Access".
548 '''
549 if not self.db.security.hasPermission('Web Access', self.userid):
550 return 0
551 return 1
553 def logout_action(self):
554 ''' Make us really anonymous - nuke the cookie too
555 '''
556 # log us out
557 self.make_user_anonymous()
559 # construct the logout cookie
560 now = Cookie._getdate()
561 path = '/'.join((self.env['SCRIPT_NAME'], self.env['TRACKER_NAME'],
562 ''))
563 self.additional_headers['Set-Cookie'] = \
564 'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)
566 # Let the user know what's going on
567 self.ok_message.append(_('You are logged out'))
569 def registerAction(self):
570 '''Attempt to create a new user based on the contents of the form
571 and then set the cookie.
573 return 1 on successful login
574 '''
575 # create the new user
576 cl = self.db.user
578 # parse the props from the form
579 try:
580 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
581 except (ValueError, KeyError), message:
582 self.error_message.append(_('Error: ') + str(message))
583 return
585 # make sure we're allowed to register
586 if not self.registerPermission(props):
587 raise Unauthorised, _("You do not have permission to register")
589 # re-open the database as "admin"
590 if self.user != 'admin':
591 self.opendb('admin')
593 # create the new user
594 cl = self.db.user
595 try:
596 props = parsePropsFromForm(self.db, cl, self.form)
597 props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
598 self.userid = cl.create(**props)
599 self.db.commit()
600 except ValueError, message:
601 self.error_message.append(message)
602 return
604 # log the new user in
605 self.user = cl.get(self.userid, 'username')
606 # re-open the database for real, using the user
607 self.opendb(self.user)
608 password = self.db.user.get(self.userid, 'password')
609 self.set_cookie(self.user, password)
611 # nice message
612 message = _('You are now registered, welcome!')
614 # redirect to the item's edit page
615 raise Redirect, '%s/%s%s?:ok_message=%s'%(
616 self.base, self.classname, self.userid, urllib.quote(message))
618 def registerPermission(self, props):
619 ''' Determine whether the user has permission to register
621 Base behaviour is to check the user has "Web Registration".
622 '''
623 # registration isn't allowed to supply roles
624 if props.has_key('roles'):
625 return 0
626 if self.db.security.hasPermission('Web Registration', self.userid):
627 return 1
628 return 0
630 def editItemAction(self):
631 ''' Perform an edit of an item in the database.
633 Some special form elements:
635 :link=designator:property
636 :multilink=designator:property
637 The value specifies a node designator and the property on that
638 node to add _this_ node to as a link or multilink.
639 :note
640 Create a message and attach it to the current node's
641 "messages" property.
642 :file
643 Create a file and attach it to the current node's
644 "files" property. Attach the file to the message created from
645 the :note if it's supplied.
647 :required=property,property,...
648 The named properties are required to be filled in the form.
650 '''
651 cl = self.db.classes[self.classname]
653 # parse the props from the form
654 try:
655 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
656 except (ValueError, KeyError), message:
657 self.error_message.append(_('Error: ') + str(message))
658 return
660 # check permission
661 if not self.editItemPermission(props):
662 self.error_message.append(
663 _('You do not have permission to edit %(classname)s'%
664 self.__dict__))
665 return
667 # perform the edit
668 try:
669 # make changes to the node
670 props = self._changenode(props)
671 # handle linked nodes
672 self._post_editnode(self.nodeid)
673 except (ValueError, KeyError), message:
674 self.error_message.append(_('Error: ') + str(message))
675 return
677 # commit now that all the tricky stuff is done
678 self.db.commit()
680 # and some nice feedback for the user
681 if props:
682 message = _('%(changes)s edited ok')%{'changes':
683 ', '.join(props.keys())}
684 elif self.form.has_key(':note') and self.form[':note'].value:
685 message = _('note added')
686 elif (self.form.has_key(':file') and self.form[':file'].filename):
687 message = _('file added')
688 else:
689 message = _('nothing changed')
691 # redirect to the item's edit page
692 raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
693 self.nodeid, urllib.quote(message))
695 def editItemPermission(self, props):
696 ''' Determine whether the user has permission to edit this item.
698 Base behaviour is to check the user can edit this class. If we're
699 editing the "user" class, users are allowed to edit their own
700 details. Unless it's the "roles" property, which requires the
701 special Permission "Web Roles".
702 '''
703 # if this is a user node and the user is editing their own node, then
704 # we're OK
705 has = self.db.security.hasPermission
706 if self.classname == 'user':
707 # reject if someone's trying to edit "roles" and doesn't have the
708 # right permission.
709 if props.has_key('roles') and not has('Web Roles', self.userid,
710 'user'):
711 return 0
712 # if the item being edited is the current user, we're ok
713 if self.nodeid == self.userid:
714 return 1
715 if self.db.security.hasPermission('Edit', self.userid, self.classname):
716 return 1
717 return 0
719 def newItemAction(self):
720 ''' Add a new item to the database.
722 This follows the same form as the editItemAction, with the same
723 special form values.
724 '''
725 cl = self.db.classes[self.classname]
727 # parse the props from the form
728 try:
729 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
730 except (ValueError, KeyError), message:
731 self.error_message.append(_('Error: ') + str(message))
732 return
734 if not self.newItemPermission(props):
735 self.error_message.append(
736 _('You do not have permission to create %s' %self.classname))
738 # create a little extra message for anticipated :link / :multilink
739 if self.form.has_key(':multilink'):
740 link = self.form[':multilink'].value
741 elif self.form.has_key(':link'):
742 link = self.form[':multilink'].value
743 else:
744 link = None
745 xtra = ''
746 if link:
747 designator, linkprop = link.split(':')
748 xtra = ' for <a href="%s">%s</a>'%(designator, designator)
750 try:
751 # do the create
752 nid = self._createnode(props)
754 # handle linked nodes
755 self._post_editnode(nid)
757 # commit now that all the tricky stuff is done
758 self.db.commit()
760 # render the newly created item
761 self.nodeid = nid
763 # and some nice feedback for the user
764 message = _('%(classname)s created ok')%self.__dict__ + xtra
765 except (ValueError, KeyError), message:
766 self.error_message.append(_('Error: ') + str(message))
767 return
768 except:
769 # oops
770 self.db.rollback()
771 s = StringIO.StringIO()
772 traceback.print_exc(None, s)
773 self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
774 return
776 # redirect to the new item's page
777 raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
778 nid, urllib.quote(message))
780 def newItemPermission(self, props):
781 ''' Determine whether the user has permission to create (edit) this
782 item.
784 Base behaviour is to check the user can edit this class. No
785 additional property checks are made. Additionally, new user items
786 may be created if the user has the "Web Registration" Permission.
787 '''
788 has = self.db.security.hasPermission
789 if self.classname == 'user' and has('Web Registration', self.userid,
790 'user'):
791 return 1
792 if has('Edit', self.userid, self.classname):
793 return 1
794 return 0
796 def editCSVAction(self):
797 ''' Performs an edit of all of a class' items in one go.
799 The "rows" CGI var defines the CSV-formatted entries for the
800 class. New nodes are identified by the ID 'X' (or any other
801 non-existent ID) and removed lines are retired.
802 '''
803 # this is per-class only
804 if not self.editCSVPermission():
805 self.error_message.append(
806 _('You do not have permission to edit %s' %self.classname))
808 # get the CSV module
809 try:
810 import csv
811 except ImportError:
812 self.error_message.append(_(
813 'Sorry, you need the csv module to use this function.<br>\n'
814 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
815 return
817 cl = self.db.classes[self.classname]
818 idlessprops = cl.getprops(protected=0).keys()
819 idlessprops.sort()
820 props = ['id'] + idlessprops
822 # do the edit
823 rows = self.form['rows'].value.splitlines()
824 p = csv.parser()
825 found = {}
826 line = 0
827 for row in rows[1:]:
828 line += 1
829 values = p.parse(row)
830 # not a complete row, keep going
831 if not values: continue
833 # skip property names header
834 if values == props:
835 continue
837 # extract the nodeid
838 nodeid, values = values[0], values[1:]
839 found[nodeid] = 1
841 # confirm correct weight
842 if len(idlessprops) != len(values):
843 self.error_message.append(
844 _('Not enough values on line %(line)s')%{'line':line})
845 return
847 # extract the new values
848 d = {}
849 for name, value in zip(idlessprops, values):
850 value = value.strip()
851 # only add the property if it has a value
852 if value:
853 # if it's a multilink, split it
854 if isinstance(cl.properties[name], hyperdb.Multilink):
855 value = value.split(':')
856 d[name] = value
858 # perform the edit
859 if cl.hasnode(nodeid):
860 # edit existing
861 cl.set(nodeid, **d)
862 else:
863 # new node
864 found[cl.create(**d)] = 1
866 # retire the removed entries
867 for nodeid in cl.list():
868 if not found.has_key(nodeid):
869 cl.retire(nodeid)
871 # all OK
872 self.db.commit()
874 self.ok_message.append(_('Items edited OK'))
876 def editCSVPermission(self):
877 ''' Determine whether the user has permission to edit this class.
879 Base behaviour is to check the user can edit this class.
880 '''
881 if not self.db.security.hasPermission('Edit', self.userid,
882 self.classname):
883 return 0
884 return 1
886 def searchAction(self):
887 ''' Mangle some of the form variables.
889 Set the form ":filter" variable based on the values of the
890 filter variables - if they're set to anything other than
891 "dontcare" then add them to :filter.
893 Also handle the ":queryname" variable and save off the query to
894 the user's query list.
895 '''
896 # generic edit is per-class only
897 if not self.searchPermission():
898 self.error_message.append(
899 _('You do not have permission to search %s' %self.classname))
901 # add a faked :filter form variable for each filtering prop
902 props = self.db.classes[self.classname].getprops()
903 for key in self.form.keys():
904 if not props.has_key(key): continue
905 if not self.form[key].value: continue
906 self.form.value.append(cgi.MiniFieldStorage(':filter', key))
908 # handle saving the query params
909 if self.form.has_key(':queryname'):
910 queryname = self.form[':queryname'].value.strip()
911 if queryname:
912 # parse the environment and figure what the query _is_
913 req = HTMLRequest(self)
914 url = req.indexargs_href('', {})
916 # handle editing an existing query
917 try:
918 qid = self.db.query.lookup(queryname)
919 self.db.query.set(qid, klass=self.classname, url=url)
920 except KeyError:
921 # create a query
922 qid = self.db.query.create(name=queryname,
923 klass=self.classname, url=url)
925 # and add it to the user's query multilink
926 queries = self.db.user.get(self.userid, 'queries')
927 queries.append(qid)
928 self.db.user.set(self.userid, queries=queries)
930 # commit the query change to the database
931 self.db.commit()
933 def searchPermission(self):
934 ''' Determine whether the user has permission to search this class.
936 Base behaviour is to check the user can view this class.
937 '''
938 if not self.db.security.hasPermission('View', self.userid,
939 self.classname):
940 return 0
941 return 1
943 def remove_action(self, dre=re.compile(r'([^\d]+)(\d+)')):
944 # XXX I believe this could be handled by a regular edit action that
945 # just sets the multilink...
946 target = self.index_arg(':target')[0]
947 m = dre.match(target)
948 if m:
949 classname = m.group(1)
950 nodeid = m.group(2)
951 cl = self.db.getclass(classname)
952 cl.retire(nodeid)
953 # now take care of the reference
954 parentref = self.index_arg(':multilink')[0]
955 parent, prop = parentref.split(':')
956 m = dre.match(parent)
957 if m:
958 self.classname = m.group(1)
959 self.nodeid = m.group(2)
960 cl = self.db.getclass(self.classname)
961 value = cl.get(self.nodeid, prop)
962 value.remove(nodeid)
963 cl.set(self.nodeid, **{prop:value})
964 func = getattr(self, 'show%s'%self.classname)
965 return func()
966 else:
967 raise NotFound, parent
968 else:
969 raise NotFound, target
971 #
972 # Utility methods for editing
973 #
974 def _changenode(self, props):
975 ''' change the node based on the contents of the form
976 '''
977 cl = self.db.classes[self.classname]
979 # create the message
980 message, files = self._handle_message()
981 if message:
982 props['messages'] = cl.get(self.nodeid, 'messages') + [message]
983 if files:
984 props['files'] = cl.get(self.nodeid, 'files') + files
986 # make the changes
987 return cl.set(self.nodeid, **props)
989 def _createnode(self, props):
990 ''' create a node based on the contents of the form
991 '''
992 cl = self.db.classes[self.classname]
994 # check for messages and files
995 message, files = self._handle_message()
996 if message:
997 props['messages'] = [message]
998 if files:
999 props['files'] = files
1000 # create the node and return it's id
1001 return cl.create(**props)
1003 def _handle_message(self):
1004 ''' generate an edit message
1005 '''
1006 # handle file attachments
1007 files = []
1008 if self.form.has_key(':file'):
1009 file = self.form[':file']
1010 if file.filename:
1011 filename = file.filename.split('\\')[-1]
1012 mime_type = mimetypes.guess_type(filename)[0]
1013 if not mime_type:
1014 mime_type = "application/octet-stream"
1015 # create the new file entry
1016 files.append(self.db.file.create(type=mime_type,
1017 name=filename, content=file.file.read()))
1019 # we don't want to do a message if none of the following is true...
1020 cn = self.classname
1021 cl = self.db.classes[self.classname]
1022 props = cl.getprops()
1023 note = None
1024 # in a nutshell, don't do anything if there's no note or there's no
1025 # NOSY
1026 if self.form.has_key(':note'):
1027 note = self.form[':note'].value.strip()
1028 if not note:
1029 return None, files
1030 if not props.has_key('messages'):
1031 return None, files
1032 if not isinstance(props['messages'], hyperdb.Multilink):
1033 return None, files
1034 if not props['messages'].classname == 'msg':
1035 return None, files
1036 if not (self.form.has_key('nosy') or note):
1037 return None, files
1039 # handle the note
1040 if '\n' in note:
1041 summary = re.split(r'\n\r?', note)[0]
1042 else:
1043 summary = note
1044 m = ['%s\n'%note]
1046 # handle the messageid
1047 # TODO: handle inreplyto
1048 messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
1049 self.classname, self.instance.config.MAIL_DOMAIN)
1051 # now create the message, attaching the files
1052 content = '\n'.join(m)
1053 message_id = self.db.msg.create(author=self.userid,
1054 recipients=[], date=date.Date('.'), summary=summary,
1055 content=content, files=files, messageid=messageid)
1057 # update the messages property
1058 return message_id, files
1060 def _post_editnode(self, nid):
1061 '''Do the linking part of the node creation.
1063 If a form element has :link or :multilink appended to it, its
1064 value specifies a node designator and the property on that node
1065 to add _this_ node to as a link or multilink.
1067 This is typically used on, eg. the file upload page to indicated
1068 which issue to link the file to.
1070 TODO: I suspect that this and newfile will go away now that
1071 there's the ability to upload a file using the issue :file form
1072 element!
1073 '''
1074 cn = self.classname
1075 cl = self.db.classes[cn]
1076 # link if necessary
1077 keys = self.form.keys()
1078 for key in keys:
1079 if key == ':multilink':
1080 value = self.form[key].value
1081 if type(value) != type([]): value = [value]
1082 for value in value:
1083 designator, property = value.split(':')
1084 link, nodeid = hyperdb.splitDesignator(designator)
1085 link = self.db.classes[link]
1086 # take a dupe of the list so we're not changing the cache
1087 value = link.get(nodeid, property)[:]
1088 value.append(nid)
1089 link.set(nodeid, **{property: value})
1090 elif key == ':link':
1091 value = self.form[key].value
1092 if type(value) != type([]): value = [value]
1093 for value in value:
1094 designator, property = value.split(':')
1095 link, nodeid = hyperdb.splitDesignator(designator)
1096 link = self.db.classes[link]
1097 link.set(nodeid, **{property: nid})
1100 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
1101 ''' Pull properties for the given class out of the form.
1103 If a ":required" parameter is supplied, then the names property values
1104 must be supplied or a ValueError will be raised.
1105 '''
1106 required = []
1107 if form.has_key(':required'):
1108 value = form[':required']
1109 if isinstance(value, type([])):
1110 required = [i.value.strip() for i in value]
1111 else:
1112 required = [i.strip() for i in value.value.split(',')]
1114 props = {}
1115 keys = form.keys()
1116 for key in keys:
1117 if not cl.properties.has_key(key):
1118 continue
1119 proptype = cl.properties[key]
1121 # Get the form value. This value may be a MiniFieldStorage or a list
1122 # of MiniFieldStorages.
1123 value = form[key]
1125 # make sure non-multilinks only get one value
1126 if not isinstance(proptype, hyperdb.Multilink):
1127 if isinstance(value, type([])):
1128 raise ValueError, 'You have submitted more than one value'\
1129 ' for the %s property'%key
1130 # we've got a MiniFieldStorage, so pull out the value and strip
1131 # surrounding whitespace
1132 value = value.value.strip()
1134 if isinstance(proptype, hyperdb.String):
1135 if not value:
1136 continue
1137 elif isinstance(proptype, hyperdb.Password):
1138 if not value:
1139 # ignore empty password values
1140 continue
1141 if not form.has_key('%s:confirm'%key):
1142 raise ValueError, 'Password and confirmation text do not match'
1143 confirm = form['%s:confirm'%key]
1144 if isinstance(confirm, type([])):
1145 raise ValueError, 'You have submitted more than one value'\
1146 ' for the %s property'%key
1147 if value != confirm.value:
1148 raise ValueError, 'Password and confirmation text do not match'
1149 value = password.Password(value)
1150 elif isinstance(proptype, hyperdb.Date):
1151 if value:
1152 value = date.Date(form[key].value.strip())
1153 else:
1154 value = None
1155 elif isinstance(proptype, hyperdb.Interval):
1156 if value:
1157 value = date.Interval(form[key].value.strip())
1158 else:
1159 value = None
1160 elif isinstance(proptype, hyperdb.Link):
1161 # see if it's the "no selection" choice
1162 if value == '-1':
1163 value = None
1164 else:
1165 # handle key values
1166 link = cl.properties[key].classname
1167 if not num_re.match(value):
1168 try:
1169 value = db.classes[link].lookup(value)
1170 except KeyError:
1171 raise ValueError, _('property "%(propname)s": '
1172 '%(value)s not a %(classname)s')%{'propname':key,
1173 'value': value, 'classname': link}
1174 except TypeError, message:
1175 raise ValueError, _('you may only enter ID values '
1176 'for property "%(propname)s": %(message)s')%{
1177 'propname':key, 'message': message}
1178 elif isinstance(proptype, hyperdb.Multilink):
1179 if isinstance(value, type([])):
1180 # it's a list of MiniFieldStorages
1181 value = [i.value.strip() for i in value]
1182 else:
1183 # it's a MiniFieldStorage, but may be a comma-separated list
1184 # of values
1185 value = [i.strip() for i in value.value.split(',')]
1186 link = cl.properties[key].classname
1187 l = []
1188 for entry in map(str, value):
1189 if entry == '': continue
1190 if not num_re.match(entry):
1191 try:
1192 entry = db.classes[link].lookup(entry)
1193 except KeyError:
1194 raise ValueError, _('property "%(propname)s": '
1195 '"%(value)s" not an entry of %(classname)s')%{
1196 'propname':key, 'value': entry, 'classname': link}
1197 except TypeError, message:
1198 raise ValueError, _('you may only enter ID values '
1199 'for property "%(propname)s": %(message)s')%{
1200 'propname':key, 'message': message}
1201 l.append(entry)
1202 l.sort()
1203 value = l
1204 elif isinstance(proptype, hyperdb.Boolean):
1205 props[key] = value = value.lower() in ('yes', 'true', 'on', '1')
1206 elif isinstance(proptype, hyperdb.Number):
1207 props[key] = value = int(value)
1209 # register this as received if required
1210 if key in required:
1211 required.remove(key)
1213 # get the old value
1214 if nodeid:
1215 try:
1216 existing = cl.get(nodeid, key)
1217 except KeyError:
1218 # this might be a new property for which there is no existing
1219 # value
1220 if not cl.properties.has_key(key): raise
1222 # if changed, set it
1223 if value != existing:
1224 props[key] = value
1225 else:
1226 props[key] = value
1228 # see if all the required properties have been supplied
1229 if required:
1230 if len(required) > 1:
1231 p = 'properties'
1232 else:
1233 p = 'property'
1234 raise ValueError, 'Required %s %s not supplied'%(p, ', '.join(required))
1236 return props