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