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