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