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