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