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