1 # $Id: client.py,v 1.52 2002-10-09 01:00:40 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 self.error_message.append(_("You do not have permission to login"))
529 return
531 # now we're OK, re-open the database for real, using the user
532 self.opendb(self.user)
534 # set the session cookie
535 self.set_cookie(self.user)
537 def verifyPassword(self, userid, password):
538 ''' Verify the password that the user has supplied
539 '''
540 stored = self.db.user.get(self.userid, 'password')
541 if password == stored:
542 return 1
543 if not password and not stored:
544 return 1
545 return 0
547 def loginPermission(self):
548 ''' Determine whether the user has permission to log in.
550 Base behaviour is to check the user has "Web Access".
551 '''
552 if not self.db.security.hasPermission('Web Access', self.userid):
553 return 0
554 return 1
556 def logout_action(self):
557 ''' Make us really anonymous - nuke the cookie too
558 '''
559 # log us out
560 self.make_user_anonymous()
562 # construct the logout cookie
563 now = Cookie._getdate()
564 path = '/'.join((self.env['SCRIPT_NAME'], self.env['TRACKER_NAME'],
565 ''))
566 self.additional_headers['Set-Cookie'] = \
567 'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)
569 # Let the user know what's going on
570 self.ok_message.append(_('You are logged out'))
572 def registerAction(self):
573 '''Attempt to create a new user based on the contents of the form
574 and then set the cookie.
576 return 1 on successful login
577 '''
578 # create the new user
579 cl = self.db.user
581 # parse the props from the form
582 try:
583 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
584 except (ValueError, KeyError), message:
585 self.error_message.append(_('Error: ') + str(message))
586 return
588 # make sure we're allowed to register
589 if not self.registerPermission(props):
590 raise Unauthorised, _("You do not have permission to register")
592 # re-open the database as "admin"
593 if self.user != 'admin':
594 self.opendb('admin')
596 # create the new user
597 cl = self.db.user
598 try:
599 props = parsePropsFromForm(self.db, cl, self.form)
600 props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
601 self.userid = cl.create(**props)
602 self.db.commit()
603 except (ValueError, KeyError), message:
604 self.error_message.append(message)
605 return
607 # log the new user in
608 self.user = cl.get(self.userid, 'username')
609 # re-open the database for real, using the user
610 self.opendb(self.user)
612 # if we have a session, update it
613 if hasattr(self, 'session'):
614 self.db.sessions.set(self.session, user=self.user,
615 last_use=time.time())
616 else:
617 # new session cookie
618 self.set_cookie(self.user)
620 # nice message
621 message = _('You are now registered, welcome!')
623 # redirect to the item's edit page
624 raise Redirect, '%s%s%s?:ok_message=%s'%(
625 self.base, self.classname, self.userid, urllib.quote(message))
627 def registerPermission(self, props):
628 ''' Determine whether the user has permission to register
630 Base behaviour is to check the user has "Web Registration".
631 '''
632 # registration isn't allowed to supply roles
633 if props.has_key('roles'):
634 return 0
635 if self.db.security.hasPermission('Web Registration', self.userid):
636 return 1
637 return 0
639 def editItemAction(self):
640 ''' Perform an edit of an item in the database.
642 Some special form elements:
644 :link=designator:property
645 :multilink=designator:property
646 The value specifies a node designator and the property on that
647 node to add _this_ node to as a link or multilink.
648 :note
649 Create a message and attach it to the current node's
650 "messages" property.
651 :file
652 Create a file and attach it to the current node's
653 "files" property. Attach the file to the message created from
654 the :note if it's supplied.
656 :required=property,property,...
657 The named properties are required to be filled in the form.
659 '''
660 cl = self.db.classes[self.classname]
662 # parse the props from the form
663 try:
664 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
665 except (ValueError, KeyError), message:
666 self.error_message.append(_('Error: ') + str(message))
667 return
669 # check permission
670 if not self.editItemPermission(props):
671 self.error_message.append(
672 _('You do not have permission to edit %(classname)s'%
673 self.__dict__))
674 return
676 # perform the edit
677 try:
678 # make changes to the node
679 props = self._changenode(props)
680 # handle linked nodes
681 self._post_editnode(self.nodeid)
682 except (ValueError, KeyError), message:
683 self.error_message.append(_('Error: ') + str(message))
684 return
686 # commit now that all the tricky stuff is done
687 self.db.commit()
689 # and some nice feedback for the user
690 if props:
691 message = _('%(changes)s edited ok')%{'changes':
692 ', '.join(props.keys())}
693 elif self.form.has_key(':note') and self.form[':note'].value:
694 message = _('note added')
695 elif (self.form.has_key(':file') and self.form[':file'].filename):
696 message = _('file added')
697 else:
698 message = _('nothing changed')
700 # redirect to the item's edit page
701 raise Redirect, '%s%s%s?:ok_message=%s'%(self.base, self.classname,
702 self.nodeid, urllib.quote(message))
704 def editItemPermission(self, props):
705 ''' Determine whether the user has permission to edit this item.
707 Base behaviour is to check the user can edit this class. If we're
708 editing the "user" class, users are allowed to edit their own
709 details. Unless it's the "roles" property, which requires the
710 special Permission "Web Roles".
711 '''
712 # if this is a user node and the user is editing their own node, then
713 # we're OK
714 has = self.db.security.hasPermission
715 if self.classname == 'user':
716 # reject if someone's trying to edit "roles" and doesn't have the
717 # right permission.
718 if props.has_key('roles') and not has('Web Roles', self.userid,
719 'user'):
720 return 0
721 # if the item being edited is the current user, we're ok
722 if self.nodeid == self.userid:
723 return 1
724 if self.db.security.hasPermission('Edit', self.userid, self.classname):
725 return 1
726 return 0
728 def newItemAction(self):
729 ''' Add a new item to the database.
731 This follows the same form as the editItemAction, with the same
732 special form values.
733 '''
734 cl = self.db.classes[self.classname]
736 # parse the props from the form
737 try:
738 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
739 except (ValueError, KeyError), message:
740 self.error_message.append(_('Error: ') + str(message))
741 return
743 if not self.newItemPermission(props):
744 self.error_message.append(
745 _('You do not have permission to create %s' %self.classname))
747 # create a little extra message for anticipated :link / :multilink
748 if self.form.has_key(':multilink'):
749 link = self.form[':multilink'].value
750 elif self.form.has_key(':link'):
751 link = self.form[':multilink'].value
752 else:
753 link = None
754 xtra = ''
755 if link:
756 designator, linkprop = link.split(':')
757 xtra = ' for <a href="%s">%s</a>'%(designator, designator)
759 try:
760 # do the create
761 nid = self._createnode(props)
763 # handle linked nodes
764 self._post_editnode(nid)
766 # commit now that all the tricky stuff is done
767 self.db.commit()
769 # render the newly created item
770 self.nodeid = nid
772 # and some nice feedback for the user
773 message = _('%(classname)s created ok')%self.__dict__ + xtra
774 except (ValueError, KeyError), message:
775 self.error_message.append(_('Error: ') + str(message))
776 return
777 except:
778 # oops
779 self.db.rollback()
780 s = StringIO.StringIO()
781 traceback.print_exc(None, s)
782 self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
783 return
785 # redirect to the new item's page
786 raise Redirect, '%s%s%s?:ok_message=%s'%(self.base, self.classname,
787 nid, urllib.quote(message))
789 def newItemPermission(self, props):
790 ''' Determine whether the user has permission to create (edit) this
791 item.
793 Base behaviour is to check the user can edit this class. No
794 additional property checks are made. Additionally, new user items
795 may be created if the user has the "Web Registration" Permission.
796 '''
797 has = self.db.security.hasPermission
798 if self.classname == 'user' and has('Web Registration', self.userid,
799 'user'):
800 return 1
801 if has('Edit', self.userid, self.classname):
802 return 1
803 return 0
805 def editCSVAction(self):
806 ''' Performs an edit of all of a class' items in one go.
808 The "rows" CGI var defines the CSV-formatted entries for the
809 class. New nodes are identified by the ID 'X' (or any other
810 non-existent ID) and removed lines are retired.
811 '''
812 # this is per-class only
813 if not self.editCSVPermission():
814 self.error_message.append(
815 _('You do not have permission to edit %s' %self.classname))
817 # get the CSV module
818 try:
819 import csv
820 except ImportError:
821 self.error_message.append(_(
822 'Sorry, you need the csv module to use this function.<br>\n'
823 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
824 return
826 cl = self.db.classes[self.classname]
827 idlessprops = cl.getprops(protected=0).keys()
828 idlessprops.sort()
829 props = ['id'] + idlessprops
831 # do the edit
832 rows = self.form['rows'].value.splitlines()
833 p = csv.parser()
834 found = {}
835 line = 0
836 for row in rows[1:]:
837 line += 1
838 values = p.parse(row)
839 # not a complete row, keep going
840 if not values: continue
842 # skip property names header
843 if values == props:
844 continue
846 # extract the nodeid
847 nodeid, values = values[0], values[1:]
848 found[nodeid] = 1
850 # confirm correct weight
851 if len(idlessprops) != len(values):
852 self.error_message.append(
853 _('Not enough values on line %(line)s')%{'line':line})
854 return
856 # extract the new values
857 d = {}
858 for name, value in zip(idlessprops, values):
859 value = value.strip()
860 # only add the property if it has a value
861 if value:
862 # if it's a multilink, split it
863 if isinstance(cl.properties[name], hyperdb.Multilink):
864 value = value.split(':')
865 d[name] = value
867 # perform the edit
868 if cl.hasnode(nodeid):
869 # edit existing
870 cl.set(nodeid, **d)
871 else:
872 # new node
873 found[cl.create(**d)] = 1
875 # retire the removed entries
876 for nodeid in cl.list():
877 if not found.has_key(nodeid):
878 cl.retire(nodeid)
880 # all OK
881 self.db.commit()
883 self.ok_message.append(_('Items edited OK'))
885 def editCSVPermission(self):
886 ''' Determine whether the user has permission to edit this class.
888 Base behaviour is to check the user can edit this class.
889 '''
890 if not self.db.security.hasPermission('Edit', self.userid,
891 self.classname):
892 return 0
893 return 1
895 def searchAction(self):
896 ''' Mangle some of the form variables.
898 Set the form ":filter" variable based on the values of the
899 filter variables - if they're set to anything other than
900 "dontcare" then add them to :filter.
902 Also handle the ":queryname" variable and save off the query to
903 the user's query list.
904 '''
905 # generic edit is per-class only
906 if not self.searchPermission():
907 self.error_message.append(
908 _('You do not have permission to search %s' %self.classname))
910 # add a faked :filter form variable for each filtering prop
911 props = self.db.classes[self.classname].getprops()
912 for key in self.form.keys():
913 if not props.has_key(key): continue
914 if not self.form[key].value: continue
915 self.form.value.append(cgi.MiniFieldStorage(':filter', key))
917 # handle saving the query params
918 if self.form.has_key(':queryname'):
919 queryname = self.form[':queryname'].value.strip()
920 if queryname:
921 # parse the environment and figure what the query _is_
922 req = HTMLRequest(self)
923 url = req.indexargs_href('', {})
925 # handle editing an existing query
926 try:
927 qid = self.db.query.lookup(queryname)
928 self.db.query.set(qid, klass=self.classname, url=url)
929 except KeyError:
930 # create a query
931 qid = self.db.query.create(name=queryname,
932 klass=self.classname, url=url)
934 # and add it to the user's query multilink
935 queries = self.db.user.get(self.userid, 'queries')
936 queries.append(qid)
937 self.db.user.set(self.userid, queries=queries)
939 # commit the query change to the database
940 self.db.commit()
942 def searchPermission(self):
943 ''' Determine whether the user has permission to search this class.
945 Base behaviour is to check the user can view this class.
946 '''
947 if not self.db.security.hasPermission('View', self.userid,
948 self.classname):
949 return 0
950 return 1
952 def remove_action(self, dre=re.compile(r'([^\d]+)(\d+)')):
953 # XXX I believe this could be handled by a regular edit action that
954 # just sets the multilink...
955 target = self.index_arg(':target')[0]
956 m = dre.match(target)
957 if m:
958 classname = m.group(1)
959 nodeid = m.group(2)
960 cl = self.db.getclass(classname)
961 cl.retire(nodeid)
962 # now take care of the reference
963 parentref = self.index_arg(':multilink')[0]
964 parent, prop = parentref.split(':')
965 m = dre.match(parent)
966 if m:
967 self.classname = m.group(1)
968 self.nodeid = m.group(2)
969 cl = self.db.getclass(self.classname)
970 value = cl.get(self.nodeid, prop)
971 value.remove(nodeid)
972 cl.set(self.nodeid, **{prop:value})
973 func = getattr(self, 'show%s'%self.classname)
974 return func()
975 else:
976 raise NotFound, parent
977 else:
978 raise NotFound, target
980 #
981 # Utility methods for editing
982 #
983 def _changenode(self, props):
984 ''' change the node based on the contents of the form
985 '''
986 cl = self.db.classes[self.classname]
988 # create the message
989 message, files = self._handle_message()
990 if message:
991 props['messages'] = cl.get(self.nodeid, 'messages') + [message]
992 if files:
993 props['files'] = cl.get(self.nodeid, 'files') + files
995 # make the changes
996 return cl.set(self.nodeid, **props)
998 def _createnode(self, props):
999 ''' create a node based on the contents of the form
1000 '''
1001 cl = self.db.classes[self.classname]
1003 # check for messages and files
1004 message, files = self._handle_message()
1005 if message:
1006 props['messages'] = [message]
1007 if files:
1008 props['files'] = files
1009 # create the node and return it's id
1010 return cl.create(**props)
1012 def _handle_message(self):
1013 ''' generate an edit message
1014 '''
1015 # handle file attachments
1016 files = []
1017 if self.form.has_key(':file'):
1018 file = self.form[':file']
1019 if file.filename:
1020 filename = file.filename.split('\\')[-1]
1021 mime_type = mimetypes.guess_type(filename)[0]
1022 if not mime_type:
1023 mime_type = "application/octet-stream"
1024 # create the new file entry
1025 files.append(self.db.file.create(type=mime_type,
1026 name=filename, content=file.file.read()))
1028 # we don't want to do a message if none of the following is true...
1029 cn = self.classname
1030 cl = self.db.classes[self.classname]
1031 props = cl.getprops()
1032 note = None
1033 # in a nutshell, don't do anything if there's no note or there's no
1034 # NOSY
1035 if self.form.has_key(':note'):
1036 # fix the CRLF/CR -> LF stuff
1037 note = fixNewlines(self.form[':note'].value.strip())
1038 if not note:
1039 return None, files
1040 if not props.has_key('messages'):
1041 return None, files
1042 if not isinstance(props['messages'], hyperdb.Multilink):
1043 return None, files
1044 if not props['messages'].classname == 'msg':
1045 return None, files
1046 if not (self.form.has_key('nosy') or note):
1047 return None, files
1049 # handle the note
1050 if '\n' in note:
1051 summary = re.split(r'\n\r?', note)[0]
1052 else:
1053 summary = note
1054 m = ['%s\n'%note]
1056 # handle the messageid
1057 # TODO: handle inreplyto
1058 messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
1059 self.classname, self.instance.config.MAIL_DOMAIN)
1061 # now create the message, attaching the files
1062 content = '\n'.join(m)
1063 message_id = self.db.msg.create(author=self.userid,
1064 recipients=[], date=date.Date('.'), summary=summary,
1065 content=content, files=files, messageid=messageid)
1067 # update the messages property
1068 return message_id, files
1070 def _post_editnode(self, nid):
1071 '''Do the linking part of the node creation.
1073 If a form element has :link or :multilink appended to it, its
1074 value specifies a node designator and the property on that node
1075 to add _this_ node to as a link or multilink.
1077 This is typically used on, eg. the file upload page to indicated
1078 which issue to link the file to.
1080 TODO: I suspect that this and newfile will go away now that
1081 there's the ability to upload a file using the issue :file form
1082 element!
1083 '''
1084 cn = self.classname
1085 cl = self.db.classes[cn]
1086 # link if necessary
1087 keys = self.form.keys()
1088 for key in keys:
1089 if key == ':multilink':
1090 value = self.form[key].value
1091 if type(value) != type([]): value = [value]
1092 for value in value:
1093 designator, property = value.split(':')
1094 link, nodeid = hyperdb.splitDesignator(designator)
1095 link = self.db.classes[link]
1096 # take a dupe of the list so we're not changing the cache
1097 value = link.get(nodeid, property)[:]
1098 value.append(nid)
1099 link.set(nodeid, **{property: value})
1100 elif key == ':link':
1101 value = self.form[key].value
1102 if type(value) != type([]): value = [value]
1103 for value in value:
1104 designator, property = value.split(':')
1105 link, nodeid = hyperdb.splitDesignator(designator)
1106 link = self.db.classes[link]
1107 link.set(nodeid, **{property: nid})
1109 def fixNewlines(text):
1110 ''' Homogenise line endings.
1112 Different web clients send different line ending values, but
1113 other systems (eg. email) don't necessarily handle those line
1114 endings. Our solution is to convert all line endings to LF.
1115 '''
1116 text = text.replace('\r\n', '\n')
1117 return text.replace('\r', '\n')
1119 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
1120 ''' Pull properties for the given class out of the form.
1122 If a ":required" parameter is supplied, then the names property values
1123 must be supplied or a ValueError will be raised.
1124 '''
1125 required = []
1126 if form.has_key(':required'):
1127 value = form[':required']
1128 if isinstance(value, type([])):
1129 required = [i.value.strip() for i in value]
1130 else:
1131 required = [i.strip() for i in value.value.split(',')]
1133 props = {}
1134 keys = form.keys()
1135 properties = cl.getprops()
1136 for key in keys:
1137 if not properties.has_key(key):
1138 continue
1139 proptype = properties[key]
1141 # Get the form value. This value may be a MiniFieldStorage or a list
1142 # of MiniFieldStorages.
1143 value = form[key]
1145 # make sure non-multilinks only get one value
1146 if not isinstance(proptype, hyperdb.Multilink):
1147 if isinstance(value, type([])):
1148 raise ValueError, 'You have submitted more than one value'\
1149 ' for the %s property'%key
1150 # we've got a MiniFieldStorage, so pull out the value and strip
1151 # surrounding whitespace
1152 value = value.value.strip()
1154 if isinstance(proptype, hyperdb.String):
1155 if not value:
1156 continue
1157 # fix the CRLF/CR -> LF stuff
1158 value = fixNewlines(value)
1159 elif isinstance(proptype, hyperdb.Password):
1160 if not value:
1161 # ignore empty password values
1162 continue
1163 if not form.has_key('%s:confirm'%key):
1164 raise ValueError, 'Password and confirmation text do not match'
1165 confirm = form['%s:confirm'%key]
1166 if isinstance(confirm, type([])):
1167 raise ValueError, 'You have submitted more than one value'\
1168 ' for the %s property'%key
1169 if value != confirm.value:
1170 raise ValueError, 'Password and confirmation text do not match'
1171 value = password.Password(value)
1172 elif isinstance(proptype, hyperdb.Date):
1173 if value:
1174 value = date.Date(form[key].value.strip())
1175 else:
1176 continue
1177 elif isinstance(proptype, hyperdb.Interval):
1178 if value:
1179 value = date.Interval(form[key].value.strip())
1180 else:
1181 continue
1182 elif isinstance(proptype, hyperdb.Link):
1183 # see if it's the "no selection" choice
1184 if value == '-1':
1185 value = None
1186 else:
1187 # handle key values
1188 link = proptype.classname
1189 if not num_re.match(value):
1190 try:
1191 value = db.classes[link].lookup(value)
1192 except KeyError:
1193 raise ValueError, _('property "%(propname)s": '
1194 '%(value)s not a %(classname)s')%{'propname':key,
1195 'value': value, 'classname': link}
1196 except TypeError, message:
1197 raise ValueError, _('you may only enter ID values '
1198 'for property "%(propname)s": %(message)s')%{
1199 'propname':key, 'message': message}
1200 elif isinstance(proptype, hyperdb.Multilink):
1201 if isinstance(value, type([])):
1202 # it's a list of MiniFieldStorages
1203 value = [i.value.strip() for i in value]
1204 else:
1205 # it's a MiniFieldStorage, but may be a comma-separated list
1206 # of values
1207 value = [i.strip() for i in value.value.split(',')]
1208 link = proptype.classname
1209 l = []
1210 for entry in map(str, value):
1211 if entry == '': continue
1212 if not num_re.match(entry):
1213 try:
1214 entry = db.classes[link].lookup(entry)
1215 except KeyError:
1216 raise ValueError, _('property "%(propname)s": '
1217 '"%(value)s" not an entry of %(classname)s')%{
1218 'propname':key, 'value': entry, 'classname': link}
1219 except TypeError, message:
1220 raise ValueError, _('you may only enter ID values '
1221 'for property "%(propname)s": %(message)s')%{
1222 'propname':key, 'message': message}
1223 l.append(entry)
1224 l.sort()
1225 value = l
1226 elif isinstance(proptype, hyperdb.Boolean):
1227 props[key] = value = value.lower() in ('yes', 'true', 'on', '1')
1228 elif isinstance(proptype, hyperdb.Number):
1229 props[key] = value = int(value)
1231 # register this as received if required?
1232 if key in required and value is not None:
1233 required.remove(key)
1235 # get the old value
1236 if nodeid:
1237 try:
1238 existing = cl.get(nodeid, key)
1239 except KeyError:
1240 # this might be a new property for which there is no existing
1241 # value
1242 if not properties.has_key(key): raise
1244 # if changed, set it
1245 if value != existing:
1246 props[key] = value
1247 else:
1248 props[key] = value
1250 # see if all the required properties have been supplied
1251 if required:
1252 if len(required) > 1:
1253 p = 'properties'
1254 else:
1255 p = 'property'
1256 raise ValueError, 'Required %s %s not supplied'%(p, ', '.join(required))
1258 return props