1 # $Id: client.py,v 1.151 2004-01-17 01:59:33 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, MimeWriter, smtplib, socket, quopri
9 import stat, rfc822
11 from roundup import roundupdb, date, hyperdb, password, token, rcsv
12 from roundup.i18n import _
13 from roundup.cgi.templating import Templates, HTMLRequest, NoTemplate
14 from roundup.cgi import cgitb
15 from roundup.cgi.PageTemplates import PageTemplate
16 from roundup.rfc2822 import encode_header
17 from roundup.mailgw import uidFromAddress
18 from roundup.mailer import Mailer, MessageSendError
20 class HTTPException(Exception):
21 pass
22 class Unauthorised(HTTPException):
23 pass
24 class NotFound(HTTPException):
25 pass
26 class Redirect(HTTPException):
27 pass
28 class NotModified(HTTPException):
29 pass
31 # used by a couple of routines
32 chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
34 class FormError(ValueError):
35 """ An "expected" exception occurred during form parsing.
36 - ie. something we know can go wrong, and don't want to alarm the
37 user with
39 We trap this at the user interface level and feed back a nice error
40 to the user.
41 """
42 pass
44 class SendFile(Exception):
45 ''' Send a file from the database '''
47 class SendStaticFile(Exception):
48 ''' Send a static file from the instance html directory '''
50 def initialiseSecurity(security):
51 ''' Create some Permissions and Roles on the security object
53 This function is directly invoked by security.Security.__init__()
54 as a part of the Security object instantiation.
55 '''
56 security.addPermission(name="Web Registration",
57 description="User may register through the web")
58 p = security.addPermission(name="Web Access",
59 description="User may access the web interface")
60 security.addPermissionToRole('Admin', p)
62 # doing Role stuff through the web - make sure Admin can
63 p = security.addPermission(name="Web Roles",
64 description="User may manipulate user Roles through the web")
65 security.addPermissionToRole('Admin', p)
67 # used to clean messages passed through CGI variables - HTML-escape any tag
68 # that isn't <a href="">, <i>, <b> and <br> (including XHTML variants) so
69 # that people can't pass through nasties like <script>, <iframe>, ...
70 CLEAN_MESSAGE_RE = r'(<(/?(.*?)(\s*href="[^"]")?\s*/?)>)'
71 def clean_message(message, mc=re.compile(CLEAN_MESSAGE_RE, re.I)):
72 return mc.sub(clean_message_callback, message)
73 def clean_message_callback(match, ok={'a':1,'i':1,'b':1,'br':1}):
74 ''' Strip all non <a>,<i>,<b> and <br> tags from a string
75 '''
76 if ok.has_key(match.group(3).lower()):
77 return match.group(1)
78 return '<%s>'%match.group(2)
80 class Client:
81 ''' Instantiate to handle one CGI request.
83 See inner_main for request processing.
85 Client attributes at instantiation:
86 "path" is the PATH_INFO inside the instance (with no leading '/')
87 "base" is the base URL for the instance
88 "form" is the cgi form, an instance of FieldStorage from the standard
89 cgi module
90 "additional_headers" is a dictionary of additional HTTP headers that
91 should be sent to the client
92 "response_code" is the HTTP response code to send to the client
94 During the processing of a request, the following attributes are used:
95 "error_message" holds a list of error messages
96 "ok_message" holds a list of OK messages
97 "session" is the current user session id
98 "user" is the current user's name
99 "userid" is the current user's id
100 "template" is the current :template context
101 "classname" is the current class context name
102 "nodeid" is the current context item id
104 User Identification:
105 If the user has no login cookie, then they are anonymous and are logged
106 in as that user. This typically gives them all Permissions assigned to the
107 Anonymous Role.
109 Once a user logs in, they are assigned a session. The Client instance
110 keeps the nodeid of the session as the "session" attribute.
113 Special form variables:
114 Note that in various places throughout this code, special form
115 variables of the form :<name> are used. The colon (":") part may
116 actually be one of either ":" or "@".
117 '''
119 #
120 # special form variables
121 #
122 FV_TEMPLATE = re.compile(r'[@:]template')
123 FV_OK_MESSAGE = re.compile(r'[@:]ok_message')
124 FV_ERROR_MESSAGE = re.compile(r'[@:]error_message')
126 FV_QUERYNAME = re.compile(r'[@:]queryname')
128 # edit form variable handling (see unit tests)
129 FV_LABELS = r'''
130 ^(
131 (?P<note>[@:]note)|
132 (?P<file>[@:]file)|
133 (
134 ((?P<classname>%s)(?P<id>[-\d]+))? # optional leading designator
135 ((?P<required>[@:]required$)| # :required
136 (
137 (
138 (?P<add>[@:]add[@:])| # :add:<prop>
139 (?P<remove>[@:]remove[@:])| # :remove:<prop>
140 (?P<confirm>[@:]confirm[@:])| # :confirm:<prop>
141 (?P<link>[@:]link[@:])| # :link:<prop>
142 ([@:]) # just a separator
143 )?
144 (?P<propname>[^@:]+) # <prop>
145 )
146 )
147 )
148 )$'''
150 # Note: index page stuff doesn't appear here:
151 # columns, sort, sortdir, filter, group, groupdir, search_text,
152 # pagesize, startwith
154 def __init__(self, instance, request, env, form=None):
155 hyperdb.traceMark()
156 self.instance = instance
157 self.request = request
158 self.env = env
159 self.mailer = Mailer(instance.config)
161 # save off the path
162 self.path = env['PATH_INFO']
164 # this is the base URL for this tracker
165 self.base = self.instance.config.TRACKER_WEB
167 # this is the "cookie path" for this tracker (ie. the path part of
168 # the "base" url)
169 self.cookie_path = urlparse.urlparse(self.base)[2]
170 self.cookie_name = 'roundup_session_' + re.sub('[^a-zA-Z]', '',
171 self.instance.config.TRACKER_NAME)
173 # see if we need to re-parse the environment for the form (eg Zope)
174 if form is None:
175 self.form = cgi.FieldStorage(environ=env)
176 else:
177 self.form = form
179 # turn debugging on/off
180 try:
181 self.debug = int(env.get("ROUNDUP_DEBUG", 0))
182 except ValueError:
183 # someone gave us a non-int debug level, turn it off
184 self.debug = 0
186 # flag to indicate that the HTTP headers have been sent
187 self.headers_done = 0
189 # additional headers to send with the request - must be registered
190 # before the first write
191 self.additional_headers = {}
192 self.response_code = 200
195 def main(self):
196 ''' Wrap the real main in a try/finally so we always close off the db.
197 '''
198 try:
199 self.inner_main()
200 finally:
201 if hasattr(self, 'db'):
202 self.db.close()
204 def inner_main(self):
205 ''' Process a request.
207 The most common requests are handled like so:
208 1. figure out who we are, defaulting to the "anonymous" user
209 see determine_user
210 2. figure out what the request is for - the context
211 see determine_context
212 3. handle any requested action (item edit, search, ...)
213 see handle_action
214 4. render a template, resulting in HTML output
216 In some situations, exceptions occur:
217 - HTTP Redirect (generally raised by an action)
218 - SendFile (generally raised by determine_context)
219 serve up a FileClass "content" property
220 - SendStaticFile (generally raised by determine_context)
221 serve up a file from the tracker "html" directory
222 - Unauthorised (generally raised by an action)
223 the action is cancelled, the request is rendered and an error
224 message is displayed indicating that permission was not
225 granted for the action to take place
226 - NotFound (raised wherever it needs to be)
227 percolates up to the CGI interface that called the client
228 '''
229 self.ok_message = []
230 self.error_message = []
231 try:
232 # figure out the context and desired content template
233 # do this first so we don't authenticate for static files
234 # Note: this method opens the database as "admin" in order to
235 # perform context checks
236 self.determine_context()
238 # make sure we're identified (even anonymously)
239 self.determine_user()
241 # possibly handle a form submit action (may change self.classname
242 # and self.template, and may also append error/ok_messages)
243 self.handle_action()
245 # now render the page
246 # we don't want clients caching our dynamic pages
247 self.additional_headers['Cache-Control'] = 'no-cache'
248 # Pragma: no-cache makes Mozilla and its ilk double-load all pages!!
249 # self.additional_headers['Pragma'] = 'no-cache'
251 # expire this page 5 seconds from now
252 date = rfc822.formatdate(time.time() + 5)
253 self.additional_headers['Expires'] = date
255 # render the content
256 self.write(self.renderContext())
257 except Redirect, url:
258 # let's redirect - if the url isn't None, then we need to do
259 # the headers, otherwise the headers have been set before the
260 # exception was raised
261 if url:
262 self.additional_headers['Location'] = url
263 self.response_code = 302
264 self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
265 except SendFile, designator:
266 self.serve_file(designator)
267 except SendStaticFile, file:
268 try:
269 self.serve_static_file(str(file))
270 except NotModified:
271 # send the 304 response
272 self.request.send_response(304)
273 self.request.end_headers()
274 except Unauthorised, message:
275 self.classname = None
276 self.template = ''
277 self.error_message.append(message)
278 self.write(self.renderContext())
279 except NotFound:
280 # pass through
281 raise
282 except FormError, e:
283 self.error_message.append(_('Form Error: ') + str(e))
284 self.write(self.renderContext())
285 except:
286 # everything else
287 self.write(cgitb.html())
289 def clean_sessions(self):
290 """Age sessions, remove when they haven't been used for a week.
292 Do it only once an hour.
294 Note: also cleans One Time Keys, and other "session" based stuff.
295 """
296 sessions = self.db.sessions
297 last_clean = sessions.get('last_clean', 'last_use') or 0
299 week = 60*60*24*7
300 hour = 60*60
301 now = time.time()
302 if now - last_clean > hour:
303 # remove aged sessions
304 for sessid in sessions.list():
305 interval = now - sessions.get(sessid, 'last_use')
306 if interval > week:
307 sessions.destroy(sessid)
308 # remove aged otks
309 otks = self.db.otks
310 for sessid in otks.list():
311 interval = now - otks.get(sessid, '__time')
312 if interval > week:
313 otks.destroy(sessid)
314 sessions.set('last_clean', last_use=time.time())
316 def determine_user(self):
317 '''Determine who the user is.
318 '''
319 # open the database as admin
320 self.opendb('admin')
322 # clean age sessions
323 self.clean_sessions()
325 # make sure we have the session Class
326 sessions = self.db.sessions
328 # look up the user session cookie
329 cookie = Cookie.SimpleCookie(self.env.get('HTTP_COOKIE', ''))
330 user = 'anonymous'
332 # bump the "revision" of the cookie since the format changed
333 if (cookie.has_key(self.cookie_name) and
334 cookie[self.cookie_name].value != 'deleted'):
336 # get the session key from the cookie
337 self.session = cookie[self.cookie_name].value
338 # get the user from the session
339 try:
340 # update the lifetime datestamp
341 sessions.set(self.session, last_use=time.time())
342 sessions.commit()
343 user = sessions.get(self.session, 'user')
344 except KeyError:
345 user = 'anonymous'
347 # sanity check on the user still being valid, getting the userid
348 # at the same time
349 try:
350 self.userid = self.db.user.lookup(user)
351 except (KeyError, TypeError):
352 user = 'anonymous'
354 # make sure the anonymous user is valid if we're using it
355 if user == 'anonymous':
356 self.make_user_anonymous()
357 else:
358 self.user = user
360 # reopen the database as the correct user
361 self.opendb(self.user)
363 def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
364 """ Determine the context of this page from the URL:
366 The URL path after the instance identifier is examined. The path
367 is generally only one entry long.
369 - if there is no path, then we are in the "home" context.
370 * if the path is "_file", then the additional path entry
371 specifies the filename of a static file we're to serve up
372 from the instance "html" directory. Raises a SendStaticFile
373 exception.
374 - if there is something in the path (eg "issue"), it identifies
375 the tracker class we're to display.
376 - if the path is an item designator (eg "issue123"), then we're
377 to display a specific item.
378 * if the path starts with an item designator and is longer than
379 one entry, then we're assumed to be handling an item of a
380 FileClass, and the extra path information gives the filename
381 that the client is going to label the download with (ie
382 "file123/image.png" is nicer to download than "file123"). This
383 raises a SendFile exception.
385 Both of the "*" types of contexts stop before we bother to
386 determine the template we're going to use. That's because they
387 don't actually use templates.
389 The template used is specified by the :template CGI variable,
390 which defaults to:
392 only classname suplied: "index"
393 full item designator supplied: "item"
395 We set:
396 self.classname - the class to display, can be None
397 self.template - the template to render the current context with
398 self.nodeid - the nodeid of the class we're displaying
399 """
400 # default the optional variables
401 self.classname = None
402 self.nodeid = None
404 # see if a template or messages are specified
405 template_override = ok_message = error_message = None
406 for key in self.form.keys():
407 if self.FV_TEMPLATE.match(key):
408 template_override = self.form[key].value
409 elif self.FV_OK_MESSAGE.match(key):
410 ok_message = self.form[key].value
411 ok_message = clean_message(ok_message)
412 elif self.FV_ERROR_MESSAGE.match(key):
413 error_message = self.form[key].value
414 error_message = clean_message(error_message)
416 # determine the classname and possibly nodeid
417 path = self.path.split('/')
418 if not path or path[0] in ('', 'home', 'index'):
419 if template_override is not None:
420 self.template = template_override
421 else:
422 self.template = ''
423 return
424 elif path[0] in ('_file', '@@file'):
425 raise SendStaticFile, os.path.join(*path[1:])
426 else:
427 self.classname = path[0]
428 if len(path) > 1:
429 # send the file identified by the designator in path[0]
430 raise SendFile, path[0]
432 # we need the db for further context stuff - open it as admin
433 self.opendb('admin')
435 # see if we got a designator
436 m = dre.match(self.classname)
437 if m:
438 self.classname = m.group(1)
439 self.nodeid = m.group(2)
440 if not self.db.getclass(self.classname).hasnode(self.nodeid):
441 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
442 # with a designator, we default to item view
443 self.template = 'item'
444 else:
445 # with only a class, we default to index view
446 self.template = 'index'
448 # make sure the classname is valid
449 try:
450 self.db.getclass(self.classname)
451 except KeyError:
452 raise NotFound, self.classname
454 # see if we have a template override
455 if template_override is not None:
456 self.template = template_override
458 # see if we were passed in a message
459 if ok_message:
460 self.ok_message.append(ok_message)
461 if error_message:
462 self.error_message.append(error_message)
464 def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
465 ''' Serve the file from the content property of the designated item.
466 '''
467 m = dre.match(str(designator))
468 if not m:
469 raise NotFound, str(designator)
470 classname, nodeid = m.group(1), m.group(2)
472 self.opendb('admin')
473 klass = self.db.getclass(classname)
475 # make sure we have the appropriate properties
476 props = klass.getprops()
477 if not pops.has_key('type'):
478 raise NotFound, designator
479 if not pops.has_key('content'):
480 raise NotFound, designator
482 mime_type = klass.get(nodeid, 'type')
483 content = klass.get(nodeid, 'content')
484 lmt = klass.get(nodeid, 'activity').timestamp()
486 self._serve_file(lmt, mime_type, content)
488 def serve_static_file(self, file):
489 ''' Serve up the file named from the templates dir
490 '''
491 filename = os.path.join(self.instance.config.TEMPLATES, file)
493 # last-modified time
494 lmt = os.stat(filename)[stat.ST_MTIME]
496 # detemine meta-type
497 file = str(file)
498 mime_type = mimetypes.guess_type(file)[0]
499 if not mime_type:
500 if file.endswith('.css'):
501 mime_type = 'text/css'
502 else:
503 mime_type = 'text/plain'
505 # snarf the content
506 f = open(filename, 'rb')
507 try:
508 content = f.read()
509 finally:
510 f.close()
512 self._serve_file(lmt, mime_type, content)
514 def _serve_file(self, last_modified, mime_type, content):
515 ''' guts of serve_file() and serve_static_file()
516 '''
517 ims = None
518 # see if there's an if-modified-since...
519 if hasattr(self.request, 'headers'):
520 ims = self.request.headers.getheader('if-modified-since')
521 elif self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
522 # cgi will put the header in the env var
523 ims = self.env['HTTP_IF_MODIFIED_SINCE']
524 if ims:
525 ims = rfc822.parsedate(ims)[:6]
526 lmtt = time.gmtime(lmt)[:6]
527 if lmtt <= ims:
528 raise NotModified
530 # spit out headers
531 self.additional_headers['Content-Type'] = mime_type
532 self.additional_headers['Content-Length'] = len(content)
533 lmt = rfc822.formatdate(last_modified)
534 self.additional_headers['Last-Modifed'] = lmt
535 self.write(content)
537 def renderContext(self):
538 ''' Return a PageTemplate for the named page
539 '''
540 name = self.classname
541 extension = self.template
542 pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
544 # catch errors so we can handle PT rendering errors more nicely
545 args = {
546 'ok_message': self.ok_message,
547 'error_message': self.error_message
548 }
549 try:
550 # let the template render figure stuff out
551 result = pt.render(self, None, None, **args)
552 self.additional_headers['Content-Type'] = pt.content_type
553 return result
554 except NoTemplate, message:
555 return '<strong>%s</strong>'%message
556 except:
557 # everything else
558 return cgitb.pt_html()
560 # these are the actions that are available
561 actions = (
562 ('edit', 'editItemAction'),
563 ('editcsv', 'editCSVAction'),
564 ('new', 'newItemAction'),
565 ('register', 'registerAction'),
566 ('confrego', 'confRegoAction'),
567 ('passrst', 'passResetAction'),
568 ('login', 'loginAction'),
569 ('logout', 'logout_action'),
570 ('search', 'searchAction'),
571 ('retire', 'retireAction'),
572 ('show', 'showAction'),
573 )
574 def handle_action(self):
575 ''' Determine whether there should be an Action called.
577 The action is defined by the form variable :action which
578 identifies the method on this object to call. The actions
579 are defined in the "actions" sequence on this class.
580 '''
581 if self.form.has_key(':action'):
582 action = self.form[':action'].value.lower()
583 elif self.form.has_key('@action'):
584 action = self.form['@action'].value.lower()
585 else:
586 return None
587 try:
588 # get the action, validate it
589 for name, method in self.actions:
590 if name == action:
591 break
592 else:
593 raise ValueError, 'No such action "%s"'%action
594 # call the mapped action
595 getattr(self, method)()
596 except Redirect:
597 raise
598 except Unauthorised:
599 raise
601 def write(self, content):
602 if not self.headers_done:
603 self.header()
604 self.request.wfile.write(content)
606 def header(self, headers=None, response=None):
607 '''Put up the appropriate header.
608 '''
609 if headers is None:
610 headers = {'Content-Type':'text/html'}
611 if response is None:
612 response = self.response_code
614 # update with additional info
615 headers.update(self.additional_headers)
617 if not headers.has_key('Content-Type'):
618 headers['Content-Type'] = 'text/html'
619 self.request.send_response(response)
620 for entry in headers.items():
621 self.request.send_header(*entry)
622 self.request.end_headers()
623 self.headers_done = 1
624 if self.debug:
625 self.headers_sent = headers
627 def set_cookie(self, user):
628 """Set up a session cookie for the user.
630 Also store away the user's login info against the session.
631 """
632 # TODO generate a much, much stronger session key ;)
633 self.session = binascii.b2a_base64(repr(random.random())).strip()
635 # clean up the base64
636 if self.session[-1] == '=':
637 if self.session[-2] == '=':
638 self.session = self.session[:-2]
639 else:
640 self.session = self.session[:-1]
642 # insert the session in the sessiondb
643 self.db.sessions.set(self.session, user=user, last_use=time.time())
645 # and commit immediately
646 self.db.sessions.commit()
648 # expire us in a long, long time
649 expire = Cookie._getdate(86400*365)
651 # generate the cookie path - make sure it has a trailing '/'
652 self.additional_headers['Set-Cookie'] = \
653 '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
654 expire, self.cookie_path)
656 def make_user_anonymous(self):
657 ''' Make us anonymous
659 This method used to handle non-existence of the 'anonymous'
660 user, but that user is mandatory now.
661 '''
662 self.userid = self.db.user.lookup('anonymous')
663 self.user = 'anonymous'
665 def opendb(self, user):
666 ''' Open the database.
667 '''
668 # open the db if the user has changed
669 if not hasattr(self, 'db') or user != self.db.journaltag:
670 if hasattr(self, 'db'):
671 self.db.close()
672 self.db = self.instance.open(user)
674 #
675 # Actions
676 #
677 def loginAction(self):
678 ''' Attempt to log a user in.
680 Sets up a session for the user which contains the login
681 credentials.
682 '''
683 # we need the username at a minimum
684 if not self.form.has_key('__login_name'):
685 self.error_message.append(_('Username required'))
686 return
688 # get the login info
689 self.user = self.form['__login_name'].value
690 if self.form.has_key('__login_password'):
691 password = self.form['__login_password'].value
692 else:
693 password = ''
695 # make sure the user exists
696 try:
697 self.userid = self.db.user.lookup(self.user)
698 except KeyError:
699 name = self.user
700 self.error_message.append(_('No such user "%(name)s"')%locals())
701 self.make_user_anonymous()
702 return
704 # verify the password
705 if not self.verifyPassword(self.userid, password):
706 self.make_user_anonymous()
707 self.error_message.append(_('Incorrect password'))
708 return
710 # make sure we're allowed to be here
711 if not self.loginPermission():
712 self.make_user_anonymous()
713 self.error_message.append(_("You do not have permission to login"))
714 return
716 # now we're OK, re-open the database for real, using the user
717 self.opendb(self.user)
719 # set the session cookie
720 self.set_cookie(self.user)
722 def verifyPassword(self, userid, password):
723 ''' Verify the password that the user has supplied
724 '''
725 stored = self.db.user.get(self.userid, 'password')
726 if password == stored:
727 return 1
728 if not password and not stored:
729 return 1
730 return 0
732 def loginPermission(self):
733 ''' Determine whether the user has permission to log in.
735 Base behaviour is to check the user has "Web Access".
736 '''
737 if not self.db.security.hasPermission('Web Access', self.userid):
738 return 0
739 return 1
741 def logout_action(self):
742 ''' Make us really anonymous - nuke the cookie too
743 '''
744 # log us out
745 self.make_user_anonymous()
747 # construct the logout cookie
748 now = Cookie._getdate()
749 self.additional_headers['Set-Cookie'] = \
750 '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.cookie_name,
751 now, self.cookie_path)
753 # Let the user know what's going on
754 self.ok_message.append(_('You are logged out'))
756 def registerAction(self):
757 '''Attempt to create a new user based on the contents of the form
758 and then set the cookie.
760 return 1 on successful login
761 '''
762 props = self.parsePropsFromForm()[0][('user', None)]
764 # make sure we're allowed to register
765 if not self.registerPermission(props):
766 raise Unauthorised, _("You do not have permission to register")
768 try:
769 self.db.user.lookup(props['username'])
770 self.error_message.append('Error: A user with the username "%s" '
771 'already exists'%props['username'])
772 return
773 except KeyError:
774 pass
776 # generate the one-time-key and store the props for later
777 otk = ''.join([random.choice(chars) for x in range(32)])
778 for propname, proptype in self.db.user.getprops().items():
779 value = props.get(propname, None)
780 if value is None:
781 pass
782 elif isinstance(proptype, hyperdb.Date):
783 props[propname] = str(value)
784 elif isinstance(proptype, hyperdb.Interval):
785 props[propname] = str(value)
786 elif isinstance(proptype, hyperdb.Password):
787 props[propname] = str(value)
788 props['__time'] = time.time()
789 self.db.otks.set(otk, **props)
791 # send the email
792 tracker_name = self.db.config.TRACKER_NAME
793 tracker_email = self.db.config.TRACKER_EMAIL
794 subject = 'Complete your registration to %s -- key %s' % (tracker_name,
795 otk)
796 body = """To complete your registration of the user "%(name)s" with
797 %(tracker)s, please do one of the following:
799 - send a reply to %(tracker_email)s and maintain the subject line as is (the
800 reply's additional "Re:" is ok),
802 - or visit the following URL:
804 %(url)s?@action=confrego&otk=%(otk)s
805 """ % {'name': props['username'], 'tracker': tracker_name, 'url': self.base,
806 'otk': otk, 'tracker_email': tracker_email}
807 if not self.standard_message([props['address']], subject, body,
808 tracker_email):
809 return
811 # commit changes to the database
812 self.db.commit()
814 # redirect to the "you're almost there" page
815 raise Redirect, '%suser?@template=rego_progress'%self.base
817 def standard_message(self, to, subject, body, author=None):
818 try:
819 self.mailer.standard_message(to, subject, body, author)
820 return 1
821 except MessageSendError, e:
822 self.error_message.append(str(e))
824 def registerPermission(self, props):
825 ''' Determine whether the user has permission to register
827 Base behaviour is to check the user has "Web Registration".
828 '''
829 # registration isn't allowed to supply roles
830 if props.has_key('roles'):
831 return 0
832 if self.db.security.hasPermission('Web Registration', self.userid):
833 return 1
834 return 0
836 def confRegoAction(self):
837 ''' Grab the OTK, use it to load up the new user details
838 '''
839 try:
840 # pull the rego information out of the otk database
841 self.userid = self.db.confirm_registration(self.form['otk'].value)
842 except (ValueError, KeyError), message:
843 # XXX: we need to make the "default" page be able to display errors!
844 self.error_message.append(str(message))
845 return
847 # log the new user in
848 self.user = self.db.user.get(self.userid, 'username')
849 # re-open the database for real, using the user
850 self.opendb(self.user)
852 # if we have a session, update it
853 if hasattr(self, 'session'):
854 self.db.sessions.set(self.session, user=self.user,
855 last_use=time.time())
856 else:
857 # new session cookie
858 self.set_cookie(self.user)
860 # nice message
861 message = _('You are now registered, welcome!')
863 # redirect to the user's page
864 raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
865 self.userid, urllib.quote(message))
867 def passResetAction(self):
868 ''' Handle password reset requests.
870 Presence of either "name" or "address" generate email.
871 Presense of "otk" performs the reset.
872 '''
873 if self.form.has_key('otk'):
874 # pull the rego information out of the otk database
875 otk = self.form['otk'].value
876 uid = self.db.otks.get(otk, 'uid')
877 if uid is None:
878 self.error_message.append("""Invalid One Time Key!
879 (a Mozilla bug may cause this message to show up erroneously,
880 please check your email)""")
881 return
883 # re-open the database as "admin"
884 if self.user != 'admin':
885 self.opendb('admin')
887 # change the password
888 newpw = password.generatePassword()
890 cl = self.db.user
891 # XXX we need to make the "default" page be able to display errors!
892 try:
893 # set the password
894 cl.set(uid, password=password.Password(newpw))
895 # clear the props from the otk database
896 self.db.otks.destroy(otk)
897 self.db.commit()
898 except (ValueError, KeyError), message:
899 self.error_message.append(str(message))
900 return
902 # user info
903 address = self.db.user.get(uid, 'address')
904 name = self.db.user.get(uid, 'username')
906 # send the email
907 tracker_name = self.db.config.TRACKER_NAME
908 subject = 'Password reset for %s'%tracker_name
909 body = '''
910 The password has been reset for username "%(name)s".
912 Your password is now: %(password)s
913 '''%{'name': name, 'password': newpw}
914 if not self.standard_message([address], subject, body):
915 return
917 self.ok_message.append('Password reset and email sent to %s' %
918 address)
919 return
921 # no OTK, so now figure the user
922 if self.form.has_key('username'):
923 name = self.form['username'].value
924 try:
925 uid = self.db.user.lookup(name)
926 except KeyError:
927 self.error_message.append('Unknown username')
928 return
929 address = self.db.user.get(uid, 'address')
930 elif self.form.has_key('address'):
931 address = self.form['address'].value
932 uid = uidFromAddress(self.db, ('', address), create=0)
933 if not uid:
934 self.error_message.append('Unknown email address')
935 return
936 name = self.db.user.get(uid, 'username')
937 else:
938 self.error_message.append('You need to specify a username '
939 'or address')
940 return
942 # generate the one-time-key and store the props for later
943 otk = ''.join([random.choice(chars) for x in range(32)])
944 self.db.otks.set(otk, uid=uid, __time=time.time())
946 # send the email
947 tracker_name = self.db.config.TRACKER_NAME
948 subject = 'Confirm reset of password for %s'%tracker_name
949 body = '''
950 Someone, perhaps you, has requested that the password be changed for your
951 username, "%(name)s". If you wish to proceed with the change, please follow
952 the link below:
954 %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
956 You should then receive another email with the new password.
957 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
958 if not self.standard_message([address], subject, body):
959 return
961 self.ok_message.append('Email sent to %s'%address)
963 def editItemAction(self):
964 ''' Perform an edit of an item in the database.
966 See parsePropsFromForm and _editnodes for special variables
967 '''
968 props, links = self.parsePropsFromForm()
970 # handle the props
971 try:
972 message = self._editnodes(props, links)
973 except (ValueError, KeyError, IndexError), message:
974 self.error_message.append(_('Apply Error: ') + str(message))
975 return
977 # commit now that all the tricky stuff is done
978 self.db.commit()
980 # redirect to the item's edit page
981 raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
982 self.classname, self.nodeid, urllib.quote(message),
983 urllib.quote(self.template))
985 newItemAction = editItemAction
987 def editItemPermission(self, props):
988 """Determine whether the user has permission to edit this item.
990 Base behaviour is to check the user can edit this class. If we're
991 editing the"user" class, users are allowed to edit their own details.
992 Unless it's the "roles" property, which requires the special Permission
993 "Web Roles".
994 """
995 # if this is a user node and the user is editing their own node, then
996 # we're OK
997 has = self.db.security.hasPermission
998 if self.classname == 'user':
999 # reject if someone's trying to edit "roles" and doesn't have the
1000 # right permission.
1001 if props.has_key('roles') and not has('Web Roles', self.userid,
1002 'user'):
1003 return 0
1004 # if the item being edited is the current user, we're ok
1005 if (self.nodeid == self.userid
1006 and self.db.user.get(self.nodeid, 'username') != 'anonymous'):
1007 return 1
1008 if self.db.security.hasPermission('Edit', self.userid, self.classname):
1009 return 1
1010 return 0
1012 def newItemPermission(self, props):
1013 ''' Determine whether the user has permission to create (edit) this
1014 item.
1016 Base behaviour is to check the user can edit this class. No
1017 additional property checks are made. Additionally, new user items
1018 may be created if the user has the "Web Registration" Permission.
1019 '''
1020 has = self.db.security.hasPermission
1021 if self.classname == 'user' and has('Web Registration', self.userid,
1022 'user'):
1023 return 1
1024 if has('Edit', self.userid, self.classname):
1025 return 1
1026 return 0
1029 #
1030 # Utility methods for editing
1031 #
1032 def _editnodes(self, all_props, all_links, newids=None):
1033 ''' Use the props in all_props to perform edit and creation, then
1034 use the link specs in all_links to do linking.
1035 '''
1036 # figure dependencies and re-work links
1037 deps = {}
1038 links = {}
1039 for cn, nodeid, propname, vlist in all_links:
1040 if not all_props.has_key((cn, nodeid)):
1041 # link item to link to doesn't (and won't) exist
1042 continue
1043 for value in vlist:
1044 if not all_props.has_key(value):
1045 # link item to link to doesn't (and won't) exist
1046 continue
1047 deps.setdefault((cn, nodeid), []).append(value)
1048 links.setdefault(value, []).append((cn, nodeid, propname))
1050 # figure chained dependencies ordering
1051 order = []
1052 done = {}
1053 # loop detection
1054 change = 0
1055 while len(all_props) != len(done):
1056 for needed in all_props.keys():
1057 if done.has_key(needed):
1058 continue
1059 tlist = deps.get(needed, [])
1060 for target in tlist:
1061 if not done.has_key(target):
1062 break
1063 else:
1064 done[needed] = 1
1065 order.append(needed)
1066 change = 1
1067 if not change:
1068 raise ValueError, 'linking must not loop!'
1070 # now, edit / create
1071 m = []
1072 for needed in order:
1073 props = all_props[needed]
1074 if not props:
1075 # nothing to do
1076 continue
1077 cn, nodeid = needed
1079 if nodeid is not None and int(nodeid) > 0:
1080 # make changes to the node
1081 props = self._changenode(cn, nodeid, props)
1083 # and some nice feedback for the user
1084 if props:
1085 info = ', '.join(props.keys())
1086 m.append('%s %s %s edited ok'%(cn, nodeid, info))
1087 else:
1088 m.append('%s %s - nothing changed'%(cn, nodeid))
1089 else:
1090 assert props
1092 # make a new node
1093 newid = self._createnode(cn, props)
1094 if nodeid is None:
1095 self.nodeid = newid
1096 nodeid = newid
1098 # and some nice feedback for the user
1099 m.append('%s %s created'%(cn, newid))
1101 # fill in new ids in links
1102 if links.has_key(needed):
1103 for linkcn, linkid, linkprop in links[needed]:
1104 props = all_props[(linkcn, linkid)]
1105 cl = self.db.classes[linkcn]
1106 propdef = cl.getprops()[linkprop]
1107 if not props.has_key(linkprop):
1108 if linkid is None or linkid.startswith('-'):
1109 # linking to a new item
1110 if isinstance(propdef, hyperdb.Multilink):
1111 props[linkprop] = [newid]
1112 else:
1113 props[linkprop] = newid
1114 else:
1115 # linking to an existing item
1116 if isinstance(propdef, hyperdb.Multilink):
1117 existing = cl.get(linkid, linkprop)[:]
1118 existing.append(nodeid)
1119 props[linkprop] = existing
1120 else:
1121 props[linkprop] = newid
1123 return '<br>'.join(m)
1125 def _changenode(self, cn, nodeid, props):
1126 ''' change the node based on the contents of the form
1127 '''
1128 # check for permission
1129 if not self.editItemPermission(props):
1130 raise Unauthorised, 'You do not have permission to edit %s'%cn
1132 # make the changes
1133 cl = self.db.classes[cn]
1134 return cl.set(nodeid, **props)
1136 def _createnode(self, cn, props):
1137 ''' create a node based on the contents of the form
1138 '''
1139 # check for permission
1140 if not self.newItemPermission(props):
1141 raise Unauthorised, 'You do not have permission to create %s'%cn
1143 # create the node and return its id
1144 cl = self.db.classes[cn]
1145 return cl.create(**props)
1147 #
1148 # More actions
1149 #
1150 def editCSVAction(self):
1151 """ Performs an edit of all of a class' items in one go.
1153 The "rows" CGI var defines the CSV-formatted entries for the
1154 class. New nodes are identified by the ID 'X' (or any other
1155 non-existent ID) and removed lines are retired.
1156 """
1157 # this is per-class only
1158 if not self.editCSVPermission():
1159 self.error_message.append(
1160 _('You do not have permission to edit %s' %self.classname))
1161 return
1163 # get the CSV module
1164 if rcsv.error:
1165 self.error_message.append(_(rcsv.error))
1166 return
1168 cl = self.db.classes[self.classname]
1169 idlessprops = cl.getprops(protected=0).keys()
1170 idlessprops.sort()
1171 props = ['id'] + idlessprops
1173 # do the edit
1174 rows = StringIO.StringIO(self.form['rows'].value)
1175 reader = rcsv.reader(rows, rcsv.comma_separated)
1176 found = {}
1177 line = 0
1178 for values in reader:
1179 line += 1
1180 if line == 1: continue
1181 # skip property names header
1182 if values == props:
1183 continue
1185 # extract the nodeid
1186 nodeid, values = values[0], values[1:]
1187 found[nodeid] = 1
1189 # see if the node exists
1190 if nodeid in ('x', 'X') or not cl.hasnode(nodeid):
1191 exists = 0
1192 else:
1193 exists = 1
1195 # confirm correct weight
1196 if len(idlessprops) != len(values):
1197 self.error_message.append(
1198 _('Not enough values on line %(line)s')%{'line':line})
1199 return
1201 # extract the new values
1202 d = {}
1203 for name, value in zip(idlessprops, values):
1204 prop = cl.properties[name]
1205 value = value.strip()
1206 # only add the property if it has a value
1207 if value:
1208 # if it's a multilink, split it
1209 if isinstance(prop, hyperdb.Multilink):
1210 value = value.split(':')
1211 elif isinstance(prop, hyperdb.Password):
1212 value = password.Password(value)
1213 elif isinstance(prop, hyperdb.Interval):
1214 value = date.Interval(value)
1215 elif isinstance(prop, hyperdb.Date):
1216 value = date.Date(value)
1217 elif isinstance(prop, hyperdb.Boolean):
1218 value = value.lower() in ('yes', 'true', 'on', '1')
1219 elif isinstance(prop, hyperdb.Number):
1220 value = float(value)
1221 d[name] = value
1222 elif exists:
1223 # nuke the existing value
1224 if isinstance(prop, hyperdb.Multilink):
1225 d[name] = []
1226 else:
1227 d[name] = None
1229 # perform the edit
1230 if exists:
1231 # edit existing
1232 cl.set(nodeid, **d)
1233 else:
1234 # new node
1235 found[cl.create(**d)] = 1
1237 # retire the removed entries
1238 for nodeid in cl.list():
1239 if not found.has_key(nodeid):
1240 cl.retire(nodeid)
1242 # all OK
1243 self.db.commit()
1245 self.ok_message.append(_('Items edited OK'))
1247 def editCSVPermission(self):
1248 ''' Determine whether the user has permission to edit this class.
1250 Base behaviour is to check the user can edit this class.
1251 '''
1252 if not self.db.security.hasPermission('Edit', self.userid,
1253 self.classname):
1254 return 0
1255 return 1
1257 def searchAction(self, wcre=re.compile(r'[\s,]+')):
1258 ''' Mangle some of the form variables.
1260 Set the form ":filter" variable based on the values of the
1261 filter variables - if they're set to anything other than
1262 "dontcare" then add them to :filter.
1264 Handle the ":queryname" variable and save off the query to
1265 the user's query list.
1267 Split any String query values on whitespace and comma.
1268 '''
1269 # generic edit is per-class only
1270 if not self.searchPermission():
1271 self.error_message.append(
1272 _('You do not have permission to search %s' %self.classname))
1273 return
1275 # add a faked :filter form variable for each filtering prop
1276 props = self.db.classes[self.classname].getprops()
1277 queryname = ''
1278 for key in self.form.keys():
1279 # special vars
1280 if self.FV_QUERYNAME.match(key):
1281 queryname = self.form[key].value.strip()
1282 continue
1284 if not props.has_key(key):
1285 continue
1286 if isinstance(self.form[key], type([])):
1287 # search for at least one entry which is not empty
1288 for minifield in self.form[key]:
1289 if minifield.value:
1290 break
1291 else:
1292 continue
1293 else:
1294 if not self.form[key].value:
1295 continue
1296 if isinstance(props[key], hyperdb.String):
1297 v = self.form[key].value
1298 l = token.token_split(v)
1299 if len(l) > 1 or l[0] != v:
1300 self.form.value.remove(self.form[key])
1301 # replace the single value with the split list
1302 for v in l:
1303 self.form.value.append(cgi.MiniFieldStorage(key, v))
1305 self.form.value.append(cgi.MiniFieldStorage('@filter', key))
1307 # handle saving the query params
1308 if queryname:
1309 # parse the environment and figure what the query _is_
1310 req = HTMLRequest(self)
1312 # The [1:] strips off the '?' character, it isn't part of the
1313 # query string.
1314 url = req.indexargs_href('', {})[1:]
1316 # handle editing an existing query
1317 try:
1318 qid = self.db.query.lookup(queryname)
1319 self.db.query.set(qid, klass=self.classname, url=url)
1320 except KeyError:
1321 # create a query
1322 qid = self.db.query.create(name=queryname,
1323 klass=self.classname, url=url)
1325 # and add it to the user's query multilink
1326 queries = self.db.user.get(self.userid, 'queries')
1327 queries.append(qid)
1328 self.db.user.set(self.userid, queries=queries)
1330 # commit the query change to the database
1331 self.db.commit()
1333 def searchPermission(self):
1334 ''' Determine whether the user has permission to search this class.
1336 Base behaviour is to check the user can view this class.
1337 '''
1338 if not self.db.security.hasPermission('View', self.userid,
1339 self.classname):
1340 return 0
1341 return 1
1344 def retireAction(self):
1345 ''' Retire the context item.
1346 '''
1347 # if we want to view the index template now, then unset the nodeid
1348 # context info (a special-case for retire actions on the index page)
1349 nodeid = self.nodeid
1350 if self.template == 'index':
1351 self.nodeid = None
1353 # generic edit is per-class only
1354 if not self.retirePermission():
1355 self.error_message.append(
1356 _('You do not have permission to retire %s' %self.classname))
1357 return
1359 # make sure we don't try to retire admin or anonymous
1360 if self.classname == 'user' and \
1361 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1362 self.error_message.append(
1363 _('You may not retire the admin or anonymous user'))
1364 return
1366 # do the retire
1367 self.db.getclass(self.classname).retire(nodeid)
1368 self.db.commit()
1370 self.ok_message.append(
1371 _('%(classname)s %(itemid)s has been retired')%{
1372 'classname': self.classname.capitalize(), 'itemid': nodeid})
1374 def retirePermission(self):
1375 ''' Determine whether the user has permission to retire this class.
1377 Base behaviour is to check the user can edit this class.
1378 '''
1379 if not self.db.security.hasPermission('Edit', self.userid,
1380 self.classname):
1381 return 0
1382 return 1
1385 def showAction(self, typere=re.compile('[@:]type'),
1386 numre=re.compile('[@:]number')):
1387 ''' Show a node of a particular class/id
1388 '''
1389 t = n = ''
1390 for key in self.form.keys():
1391 if typere.match(key):
1392 t = self.form[key].value.strip()
1393 elif numre.match(key):
1394 n = self.form[key].value.strip()
1395 if not t:
1396 raise ValueError, 'Invalid %s number'%t
1397 url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1398 raise Redirect, url
1400 def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1401 """ Item properties and their values are edited with html FORM
1402 variables and their values. You can:
1404 - Change the value of some property of the current item.
1405 - Create a new item of any class, and edit the new item's
1406 properties,
1407 - Attach newly created items to a multilink property of the
1408 current item.
1409 - Remove items from a multilink property of the current item.
1410 - Specify that some properties are required for the edit
1411 operation to be successful.
1413 In the following, <bracketed> values are variable, "@" may be
1414 either ":" or "@", and other text "required" is fixed.
1416 Most properties are specified as form variables:
1418 <propname>
1419 - property on the current context item
1421 <designator>"@"<propname>
1422 - property on the indicated item (for editing related
1423 information)
1425 Designators name a specific item of a class.
1427 <classname><N>
1429 Name an existing item of class <classname>.
1431 <classname>"-"<N>
1433 Name the <N>th new item of class <classname>. If the form
1434 submission is successful, a new item of <classname> is
1435 created. Within the submitted form, a particular
1436 designator of this form always refers to the same new
1437 item.
1439 Once we have determined the "propname", we look at it to see
1440 if it's special:
1442 @required
1443 The associated form value is a comma-separated list of
1444 property names that must be specified when the form is
1445 submitted for the edit operation to succeed.
1447 When the <designator> is missing, the properties are
1448 for the current context item. When <designator> is
1449 present, they are for the item specified by
1450 <designator>.
1452 The "@required" specifier must come before any of the
1453 properties it refers to are assigned in the form.
1455 @remove@<propname>=id(s) or @add@<propname>=id(s)
1456 The "@add@" and "@remove@" edit actions apply only to
1457 Multilink properties. The form value must be a
1458 comma-separate list of keys for the class specified by
1459 the simple form variable. The listed items are added
1460 to (respectively, removed from) the specified
1461 property.
1463 @link@<propname>=<designator>
1464 If the edit action is "@link@", the simple form
1465 variable must specify a Link or Multilink property.
1466 The form value is a comma-separated list of
1467 designators. The item corresponding to each
1468 designator is linked to the property given by simple
1469 form variable. These are collected up and returned in
1470 all_links.
1472 None of the above (ie. just a simple form value)
1473 The value of the form variable is converted
1474 appropriately, depending on the type of the property.
1476 For a Link('klass') property, the form value is a
1477 single key for 'klass', where the key field is
1478 specified in dbinit.py.
1480 For a Multilink('klass') property, the form value is a
1481 comma-separated list of keys for 'klass', where the
1482 key field is specified in dbinit.py.
1484 Note that for simple-form-variables specifiying Link
1485 and Multilink properties, the linked-to class must
1486 have a key field.
1488 For a String() property specifying a filename, the
1489 file named by the form value is uploaded. This means we
1490 try to set additional properties "filename" and "type" (if
1491 they are valid for the class). Otherwise, the property
1492 is set to the form value.
1494 For Date(), Interval(), Boolean(), and Number()
1495 properties, the form value is converted to the
1496 appropriate
1498 Any of the form variables may be prefixed with a classname or
1499 designator.
1501 Two special form values are supported for backwards
1502 compatibility:
1504 @note
1505 This is equivalent to::
1507 @link@messages=msg-1
1508 msg-1@content=value
1510 except that in addition, the "author" and "date"
1511 properties of "msg-1" are set to the userid of the
1512 submitter, and the current time, respectively.
1514 @file
1515 This is equivalent to::
1517 @link@files=file-1
1518 file-1@content=value
1520 The String content value is handled as described above for
1521 file uploads.
1523 If both the "@note" and "@file" form variables are
1524 specified, the action::
1526 @link@msg-1@files=file-1
1528 is also performed.
1530 We also check that FileClass items have a "content" property with
1531 actual content, otherwise we remove them from all_props before
1532 returning.
1534 The return from this method is a dict of
1535 (classname, id): properties
1536 ... this dict _always_ has an entry for the current context,
1537 even if it's empty (ie. a submission for an existing issue that
1538 doesn't result in any changes would return {('issue','123'): {}})
1539 The id may be None, which indicates that an item should be
1540 created.
1541 """
1542 # some very useful variables
1543 db = self.db
1544 form = self.form
1546 if not hasattr(self, 'FV_SPECIAL'):
1547 # generate the regexp for handling special form values
1548 classes = '|'.join(db.classes.keys())
1549 # specials for parsePropsFromForm
1550 # handle the various forms (see unit tests)
1551 self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1552 self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1554 # these indicate the default class / item
1555 default_cn = self.classname
1556 default_cl = self.db.classes[default_cn]
1557 default_nodeid = self.nodeid
1559 # we'll store info about the individual class/item edit in these
1560 all_required = {} # required props per class/item
1561 all_props = {} # props to set per class/item
1562 got_props = {} # props received per class/item
1563 all_propdef = {} # note - only one entry per class
1564 all_links = [] # as many as are required
1566 # we should always return something, even empty, for the context
1567 all_props[(default_cn, default_nodeid)] = {}
1569 keys = form.keys()
1570 timezone = db.getUserTimezone()
1572 # sentinels for the :note and :file props
1573 have_note = have_file = 0
1575 # extract the usable form labels from the form
1576 matches = []
1577 for key in keys:
1578 m = self.FV_SPECIAL.match(key)
1579 if m:
1580 matches.append((key, m.groupdict()))
1582 # now handle the matches
1583 for key, d in matches:
1584 if d['classname']:
1585 # we got a designator
1586 cn = d['classname']
1587 cl = self.db.classes[cn]
1588 nodeid = d['id']
1589 propname = d['propname']
1590 elif d['note']:
1591 # the special note field
1592 cn = 'msg'
1593 cl = self.db.classes[cn]
1594 nodeid = '-1'
1595 propname = 'content'
1596 all_links.append((default_cn, default_nodeid, 'messages',
1597 [('msg', '-1')]))
1598 have_note = 1
1599 elif d['file']:
1600 # the special file field
1601 cn = 'file'
1602 cl = self.db.classes[cn]
1603 nodeid = '-1'
1604 propname = 'content'
1605 all_links.append((default_cn, default_nodeid, 'files',
1606 [('file', '-1')]))
1607 have_file = 1
1608 else:
1609 # default
1610 cn = default_cn
1611 cl = default_cl
1612 nodeid = default_nodeid
1613 propname = d['propname']
1615 # the thing this value relates to is...
1616 this = (cn, nodeid)
1618 # get more info about the class, and the current set of
1619 # form props for it
1620 if not all_propdef.has_key(cn):
1621 all_propdef[cn] = cl.getprops()
1622 propdef = all_propdef[cn]
1623 if not all_props.has_key(this):
1624 all_props[this] = {}
1625 props = all_props[this]
1626 if not got_props.has_key(this):
1627 got_props[this] = {}
1629 # is this a link command?
1630 if d['link']:
1631 value = []
1632 for entry in extractFormList(form[key]):
1633 m = self.FV_DESIGNATOR.match(entry)
1634 if not m:
1635 raise FormError, \
1636 'link "%s" value "%s" not a designator'%(key, entry)
1637 value.append((m.group(1), m.group(2)))
1639 # make sure the link property is valid
1640 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1641 not isinstance(propdef[propname], hyperdb.Link)):
1642 raise FormError, '%s %s is not a link or '\
1643 'multilink property'%(cn, propname)
1645 all_links.append((cn, nodeid, propname, value))
1646 continue
1648 # detect the special ":required" variable
1649 if d['required']:
1650 all_required[this] = extractFormList(form[key])
1651 continue
1653 # see if we're performing a special multilink action
1654 mlaction = 'set'
1655 if d['remove']:
1656 mlaction = 'remove'
1657 elif d['add']:
1658 mlaction = 'add'
1660 # does the property exist?
1661 if not propdef.has_key(propname):
1662 if mlaction != 'set':
1663 raise FormError, 'You have submitted a %s action for'\
1664 ' the property "%s" which doesn\'t exist'%(mlaction,
1665 propname)
1666 # the form element is probably just something we don't care
1667 # about - ignore it
1668 continue
1669 proptype = propdef[propname]
1671 # Get the form value. This value may be a MiniFieldStorage or a list
1672 # of MiniFieldStorages.
1673 value = form[key]
1675 # handle unpacking of the MiniFieldStorage / list form value
1676 if isinstance(proptype, hyperdb.Multilink):
1677 value = extractFormList(value)
1678 else:
1679 # multiple values are not OK
1680 if isinstance(value, type([])):
1681 raise FormError, 'You have submitted more than one value'\
1682 ' for the %s property'%propname
1683 # value might be a file upload...
1684 if not hasattr(value, 'filename') or value.filename is None:
1685 # nope, pull out the value and strip it
1686 value = value.value.strip()
1688 # now that we have the props field, we need a teensy little
1689 # extra bit of help for the old :note field...
1690 if d['note'] and value:
1691 props['author'] = self.db.getuid()
1692 props['date'] = date.Date()
1694 # handle by type now
1695 if isinstance(proptype, hyperdb.Password):
1696 if not value:
1697 # ignore empty password values
1698 continue
1699 for key, d in matches:
1700 if d['confirm'] and d['propname'] == propname:
1701 confirm = form[key]
1702 break
1703 else:
1704 raise FormError, 'Password and confirmation text do '\
1705 'not match'
1706 if isinstance(confirm, type([])):
1707 raise FormError, 'You have submitted more than one value'\
1708 ' for the %s property'%propname
1709 if value != confirm.value:
1710 raise FormError, 'Password and confirmation text do '\
1711 'not match'
1712 try:
1713 value = password.Password(value)
1714 except hyperdb.HyperdbValueError, msg:
1715 raise FormError, msg
1717 elif isinstance(proptype, hyperdb.Multilink):
1718 # convert input to list of ids
1719 try:
1720 l = hyperdb.rawToHyperdb(self.db, cl, nodeid,
1721 propname, value)
1722 except hyperdb.HyperdbValueError, msg:
1723 raise FormError, msg
1725 # now use that list of ids to modify the multilink
1726 if mlaction == 'set':
1727 value = l
1728 else:
1729 # we're modifying the list - get the current list of ids
1730 if props.has_key(propname):
1731 existing = props[propname]
1732 elif nodeid and not nodeid.startswith('-'):
1733 existing = cl.get(nodeid, propname, [])
1734 else:
1735 existing = []
1737 # now either remove or add
1738 if mlaction == 'remove':
1739 # remove - handle situation where the id isn't in
1740 # the list
1741 for entry in l:
1742 try:
1743 existing.remove(entry)
1744 except ValueError:
1745 raise FormError, _('property "%(propname)s": '
1746 '"%(value)s" not currently in list')%{
1747 'propname': propname, 'value': entry}
1748 else:
1749 # add - easy, just don't dupe
1750 for entry in l:
1751 if entry not in existing:
1752 existing.append(entry)
1753 value = existing
1754 value.sort()
1756 elif value == '':
1757 # other types should be None'd if there's no value
1758 value = None
1759 else:
1760 # handle all other types
1761 try:
1762 if isinstance(proptype, hyperdb.String):
1763 if (hasattr(value, 'filename') and
1764 value.filename is not None):
1765 # skip if the upload is empty
1766 if not value.filename:
1767 continue
1768 # this String is actually a _file_
1769 # try to determine the file content-type
1770 fn = value.filename.split('\\')[-1]
1771 if propdef.has_key('name'):
1772 props['name'] = fn
1773 # use this info as the type/filename properties
1774 if propdef.has_key('type'):
1775 props['type'] = mimetypes.guess_type(fn)[0]
1776 if not props['type']:
1777 props['type'] = "application/octet-stream"
1778 # finally, read the content RAW
1779 value = value.value
1780 else:
1781 value = hyperdb.rawToHyperdb(self.db, cl,
1782 nodeid, propname, value)
1784 else:
1785 value = hyperdb.rawToHyperdb(self.db, cl, nodeid,
1786 propname, value)
1787 except hyperdb.HyperdbValueError, msg:
1788 raise FormError, msg
1790 # register that we got this property
1791 if value:
1792 got_props[this][propname] = 1
1794 # get the old value
1795 if nodeid and not nodeid.startswith('-'):
1796 try:
1797 existing = cl.get(nodeid, propname)
1798 except KeyError:
1799 # this might be a new property for which there is
1800 # no existing value
1801 if not propdef.has_key(propname):
1802 raise
1803 except IndexError, message:
1804 raise FormError(str(message))
1806 # make sure the existing multilink is sorted
1807 if isinstance(proptype, hyperdb.Multilink):
1808 existing.sort()
1810 # "missing" existing values may not be None
1811 if not existing:
1812 if isinstance(proptype, hyperdb.String) and not existing:
1813 # some backends store "missing" Strings as empty strings
1814 existing = None
1815 elif isinstance(proptype, hyperdb.Number) and not existing:
1816 # some backends store "missing" Numbers as 0 :(
1817 existing = 0
1818 elif isinstance(proptype, hyperdb.Boolean) and not existing:
1819 # likewise Booleans
1820 existing = 0
1822 # if changed, set it
1823 if value != existing:
1824 props[propname] = value
1825 else:
1826 # don't bother setting empty/unset values
1827 if value is None:
1828 continue
1829 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1830 continue
1831 elif isinstance(proptype, hyperdb.String) and value == '':
1832 continue
1834 props[propname] = value
1836 # check to see if we need to specially link a file to the note
1837 if have_note and have_file:
1838 all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1840 # see if all the required properties have been supplied
1841 s = []
1842 for thing, required in all_required.items():
1843 # register the values we got
1844 got = got_props.get(thing, {})
1845 for entry in required[:]:
1846 if got.has_key(entry):
1847 required.remove(entry)
1849 # any required values not present?
1850 if not required:
1851 continue
1853 # tell the user to entry the values required
1854 if len(required) > 1:
1855 p = 'properties'
1856 else:
1857 p = 'property'
1858 s.append('Required %s %s %s not supplied'%(thing[0], p,
1859 ', '.join(required)))
1860 if s:
1861 raise FormError, '\n'.join(s)
1863 # When creating a FileClass node, it should have a non-empty content
1864 # property to be created. When editing a FileClass node, it should
1865 # either have a non-empty content property or no property at all. In
1866 # the latter case, nothing will change.
1867 for (cn, id), props in all_props.items():
1868 if isinstance(self.db.classes[cn], hyperdb.FileClass):
1869 if id == '-1':
1870 if not props.get('content', ''):
1871 del all_props[(cn, id)]
1872 elif props.has_key('content') and not props['content']:
1873 raise FormError, _('File is empty')
1874 return all_props, all_links
1876 def extractFormList(value):
1877 ''' Extract a list of values from the form value.
1879 It may be one of:
1880 [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
1881 MiniFieldStorage('value,value,...')
1882 MiniFieldStorage('value')
1883 '''
1884 # multiple values are OK
1885 if isinstance(value, type([])):
1886 # it's a list of MiniFieldStorages - join then into
1887 values = ','.join([i.value.strip() for i in value])
1888 else:
1889 # it's a MiniFieldStorage, but may be a comma-separated list
1890 # of values
1891 values = value.value
1893 value = [i.strip() for i in values.split(',')]
1895 # filter out the empty bits
1896 return filter(None, value)