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