1 # $Id: client.py,v 1.132 2003-08-28 04:46:39 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, string
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, openSMTPConnection
19 class HTTPException(Exception):
20 pass
21 class Unauthorised(HTTPException):
22 pass
23 class NotFound(HTTPException):
24 pass
25 class Redirect(HTTPException):
26 pass
27 class NotModified(HTTPException):
28 pass
30 # set to indicate to roundup not to actually _send_ email
31 # this var must contain a file to write the mail to
32 SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '')
34 # used by a couple of routines
35 chars = string.letters+string.digits
37 # XXX actually _use_ FormError
38 class FormError(ValueError):
39 ''' An "expected" exception occurred during form parsing.
40 - ie. something we know can go wrong, and don't want to alarm the
41 user with
43 We trap this at the user interface level and feed back a nice error
44 to the user.
45 '''
46 pass
48 class SendFile(Exception):
49 ''' Send a file from the database '''
51 class SendStaticFile(Exception):
52 ''' Send a static file from the instance html directory '''
54 def initialiseSecurity(security):
55 ''' Create some Permissions and Roles on the security object
57 This function is directly invoked by security.Security.__init__()
58 as a part of the Security object instantiation.
59 '''
60 security.addPermission(name="Web Registration",
61 description="User may register through the web")
62 p = security.addPermission(name="Web Access",
63 description="User may access the web interface")
64 security.addPermissionToRole('Admin', p)
66 # doing Role stuff through the web - make sure Admin can
67 p = security.addPermission(name="Web Roles",
68 description="User may manipulate user Roles through the web")
69 security.addPermissionToRole('Admin', p)
71 # used to clean messages passed through CGI variables - HTML-escape any tag
72 # that isn't <a href="">, <i>, <b> and <br> (including XHTML variants) so
73 # that people can't pass through nasties like <script>, <iframe>, ...
74 CLEAN_MESSAGE_RE = r'(<(/?(.*?)(\s*href="[^"]")?\s*/?)>)'
75 def clean_message(message, mc=re.compile(CLEAN_MESSAGE_RE, re.I)):
76 return mc.sub(clean_message_callback, message)
77 def clean_message_callback(match, ok={'a':1,'i':1,'b':1,'br':1}):
78 ''' Strip all non <a>,<i>,<b> and <br> tags from a string
79 '''
80 if ok.has_key(match.group(3).lower()):
81 return match.group(1)
82 return '<%s>'%match.group(2)
84 class Client:
85 ''' Instantiate to handle one CGI request.
87 See inner_main for request processing.
89 Client attributes at instantiation:
90 "path" is the PATH_INFO inside the instance (with no leading '/')
91 "base" is the base URL for the instance
92 "form" is the cgi form, an instance of FieldStorage from the standard
93 cgi module
94 "additional_headers" is a dictionary of additional HTTP headers that
95 should be sent to the client
96 "response_code" is the HTTP response code to send to the client
98 During the processing of a request, the following attributes are used:
99 "error_message" holds a list of error messages
100 "ok_message" holds a list of OK messages
101 "session" is the current user session id
102 "user" is the current user's name
103 "userid" is the current user's id
104 "template" is the current :template context
105 "classname" is the current class context name
106 "nodeid" is the current context item id
108 User Identification:
109 If the user has no login cookie, then they are anonymous and are logged
110 in as that user. This typically gives them all Permissions assigned to the
111 Anonymous Role.
113 Once a user logs in, they are assigned a session. The Client instance
114 keeps the nodeid of the session as the "session" attribute.
117 Special form variables:
118 Note that in various places throughout this code, special form
119 variables of the form :<name> are used. The colon (":") part may
120 actually be one of either ":" or "@".
121 '''
123 #
124 # special form variables
125 #
126 FV_TEMPLATE = re.compile(r'[@:]template')
127 FV_OK_MESSAGE = re.compile(r'[@:]ok_message')
128 FV_ERROR_MESSAGE = re.compile(r'[@:]error_message')
130 FV_QUERYNAME = re.compile(r'[@:]queryname')
132 # edit form variable handling (see unit tests)
133 FV_LABELS = r'''
134 ^(
135 (?P<note>[@:]note)|
136 (?P<file>[@:]file)|
137 (
138 ((?P<classname>%s)(?P<id>[-\d]+))? # optional leading designator
139 ((?P<required>[@:]required$)| # :required
140 (
141 (
142 (?P<add>[@:]add[@:])| # :add:<prop>
143 (?P<remove>[@:]remove[@:])| # :remove:<prop>
144 (?P<confirm>[@:]confirm[@:])| # :confirm:<prop>
145 (?P<link>[@:]link[@:])| # :link:<prop>
146 ([@:]) # just a separator
147 )?
148 (?P<propname>[^@:]+) # <prop>
149 )
150 )
151 )
152 )$'''
154 # Note: index page stuff doesn't appear here:
155 # columns, sort, sortdir, filter, group, groupdir, search_text,
156 # pagesize, startwith
158 def __init__(self, instance, request, env, form=None):
159 hyperdb.traceMark()
160 self.instance = instance
161 self.request = request
162 self.env = env
164 # save off the path
165 self.path = env['PATH_INFO']
167 # this is the base URL for this tracker
168 self.base = self.instance.config.TRACKER_WEB
170 # this is the "cookie path" for this tracker (ie. the path part of
171 # the "base" url)
172 self.cookie_path = urlparse.urlparse(self.base)[2]
173 self.cookie_name = 'roundup_session_' + re.sub('[^a-zA-Z]', '',
174 self.instance.config.TRACKER_NAME)
176 # see if we need to re-parse the environment for the form (eg Zope)
177 if form is None:
178 self.form = cgi.FieldStorage(environ=env)
179 else:
180 self.form = form
182 # turn debugging on/off
183 try:
184 self.debug = int(env.get("ROUNDUP_DEBUG", 0))
185 except ValueError:
186 # someone gave us a non-int debug level, turn it off
187 self.debug = 0
189 # flag to indicate that the HTTP headers have been sent
190 self.headers_done = 0
192 # additional headers to send with the request - must be registered
193 # before the first write
194 self.additional_headers = {}
195 self.response_code = 200
198 def main(self):
199 ''' Wrap the real main in a try/finally so we always close off the db.
200 '''
201 try:
202 self.inner_main()
203 finally:
204 if hasattr(self, 'db'):
205 self.db.close()
207 def inner_main(self):
208 ''' Process a request.
210 The most common requests are handled like so:
211 1. figure out who we are, defaulting to the "anonymous" user
212 see determine_user
213 2. figure out what the request is for - the context
214 see determine_context
215 3. handle any requested action (item edit, search, ...)
216 see handle_action
217 4. render a template, resulting in HTML output
219 In some situations, exceptions occur:
220 - HTTP Redirect (generally raised by an action)
221 - SendFile (generally raised by determine_context)
222 serve up a FileClass "content" property
223 - SendStaticFile (generally raised by determine_context)
224 serve up a file from the tracker "html" directory
225 - Unauthorised (generally raised by an action)
226 the action is cancelled, the request is rendered and an error
227 message is displayed indicating that permission was not
228 granted for the action to take place
229 - NotFound (raised wherever it needs to be)
230 percolates up to the CGI interface that called the client
231 '''
232 self.ok_message = []
233 self.error_message = []
234 try:
235 # figure out the context and desired content template
236 # do this first so we don't authenticate for static files
237 # Note: this method opens the database as "admin" in order to
238 # perform context checks
239 self.determine_context()
241 # make sure we're identified (even anonymously)
242 self.determine_user()
244 # possibly handle a form submit action (may change self.classname
245 # and self.template, and may also append error/ok_messages)
246 self.handle_action()
248 # now render the page
249 # we don't want clients caching our dynamic pages
250 self.additional_headers['Cache-Control'] = 'no-cache'
251 # Pragma: no-cache makes Mozilla and its ilk double-load all pages!!
252 # self.additional_headers['Pragma'] = 'no-cache'
254 # expire this page 5 seconds from now
255 date = rfc822.formatdate(time.time() + 5)
256 self.additional_headers['Expires'] = date
258 # render the content
259 self.write(self.renderContext())
260 except Redirect, url:
261 # let's redirect - if the url isn't None, then we need to do
262 # the headers, otherwise the headers have been set before the
263 # exception was raised
264 if url:
265 self.additional_headers['Location'] = url
266 self.response_code = 302
267 self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
268 except SendFile, designator:
269 self.serve_file(designator)
270 except SendStaticFile, file:
271 try:
272 self.serve_static_file(str(file))
273 except NotModified:
274 # send the 304 response
275 self.request.send_response(304)
276 self.request.end_headers()
277 except Unauthorised, message:
278 self.classname = None
279 self.template = ''
280 self.error_message.append(message)
281 self.write(self.renderContext())
282 except NotFound:
283 # pass through
284 raise
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
295 stuff.
296 '''
297 sessions = self.db.sessions
298 last_clean = sessions.get('last_clean', 'last_use') or 0
300 week = 60*60*24*7
301 hour = 60*60
302 now = time.time()
303 if now - last_clean > hour:
304 # remove aged sessions
305 for sessid in sessions.list():
306 interval = now - sessions.get(sessid, 'last_use')
307 if interval > week:
308 sessions.destroy(sessid)
309 # remove aged otks
310 otks = self.db.otks
311 for sessid in otks.list():
312 interval = now - otks.get(sessid, '__time')
313 if interval > week:
314 otks.destroy(sessid)
315 sessions.set('last_clean', last_use=time.time())
317 def determine_user(self):
318 ''' Determine who the user is
319 '''
320 # open the database as admin
321 self.opendb('admin')
323 # clean age sessions
324 self.clean_sessions()
326 # make sure we have the session Class
327 sessions = self.db.sessions
329 # look up the user session cookie
330 cookie = Cookie.SimpleCookie(self.env.get('HTTP_COOKIE', ''))
331 user = 'anonymous'
333 # bump the "revision" of the cookie since the format changed
334 if (cookie.has_key(self.cookie_name) and
335 cookie[self.cookie_name].value != 'deleted'):
337 # get the session key from the cookie
338 self.session = cookie[self.cookie_name].value
339 # get the user from the session
340 try:
341 # update the lifetime datestamp
342 sessions.set(self.session, last_use=time.time())
343 sessions.commit()
344 user = sessions.get(self.session, 'user')
345 except KeyError:
346 user = 'anonymous'
348 # sanity check on the user still being valid, getting the userid
349 # at the same time
350 try:
351 self.userid = self.db.user.lookup(user)
352 except (KeyError, TypeError):
353 user = 'anonymous'
355 # make sure the anonymous user is valid if we're using it
356 if user == 'anonymous':
357 self.make_user_anonymous()
358 else:
359 self.user = user
361 # reopen the database as the correct user
362 self.opendb(self.user)
364 def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
365 ''' Determine the context of this page from the URL:
367 The URL path after the instance identifier is examined. The path
368 is generally only one entry long.
370 - if there is no path, then we are in the "home" context.
371 * if the path is "_file", then the additional path entry
372 specifies the filename of a static file we're to serve up
373 from the instance "html" directory. Raises a SendStaticFile
374 exception.
375 - if there is something in the path (eg "issue"), it identifies
376 the tracker class we're to display.
377 - if the path is an item designator (eg "issue123"), then we're
378 to display a specific item.
379 * if the path starts with an item designator and is longer than
380 one entry, then we're assumed to be handling an item of a
381 FileClass, and the extra path information gives the filename
382 that the client is going to label the download with (ie
383 "file123/image.png" is nicer to download than "file123"). This
384 raises a SendFile exception.
386 Both of the "*" types of contexts stop before we bother to
387 determine the template we're going to use. That's because they
388 don't actually use templates.
390 The template used is specified by the :template CGI variable,
391 which defaults to:
393 only classname suplied: "index"
394 full item designator supplied: "item"
396 We set:
397 self.classname - the class to display, can be None
398 self.template - the template to render the current context with
399 self.nodeid - the nodeid of the class we're displaying
400 '''
401 # default the optional variables
402 self.classname = None
403 self.nodeid = None
405 # see if a template or messages are specified
406 template_override = ok_message = error_message = None
407 for key in self.form.keys():
408 if self.FV_TEMPLATE.match(key):
409 template_override = self.form[key].value
410 elif self.FV_OK_MESSAGE.match(key):
411 ok_message = self.form[key].value
412 ok_message = clean_message(ok_message)
413 elif self.FV_ERROR_MESSAGE.match(key):
414 error_message = self.form[key].value
415 error_message = clean_message(error_message)
417 # determine the classname and possibly nodeid
418 path = self.path.split('/')
419 if not path or path[0] in ('', 'home', 'index'):
420 if template_override is not None:
421 self.template = template_override
422 else:
423 self.template = ''
424 return
425 elif path[0] == '_file':
426 raise SendStaticFile, os.path.join(*path[1:])
427 else:
428 self.classname = path[0]
429 if len(path) > 1:
430 # send the file identified by the designator in path[0]
431 raise SendFile, path[0]
433 # we need the db for further context stuff - open it as admin
434 self.opendb('admin')
436 # see if we got a designator
437 m = dre.match(self.classname)
438 if m:
439 self.classname = m.group(1)
440 self.nodeid = m.group(2)
441 if not self.db.getclass(self.classname).hasnode(self.nodeid):
442 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
443 # with a designator, we default to item view
444 self.template = 'item'
445 else:
446 # with only a class, we default to index view
447 self.template = 'index'
449 # make sure the classname is valid
450 try:
451 self.db.getclass(self.classname)
452 except KeyError:
453 raise NotFound, self.classname
455 # see if we have a template override
456 if template_override is not None:
457 self.template = template_override
459 # see if we were passed in a message
460 if ok_message:
461 self.ok_message.append(ok_message)
462 if error_message:
463 self.error_message.append(error_message)
465 def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
466 ''' Serve the file from the content property of the designated item.
467 '''
468 m = dre.match(str(designator))
469 if not m:
470 raise NotFound, str(designator)
471 classname, nodeid = m.group(1), m.group(2)
472 if classname != 'file':
473 raise NotFound, designator
475 # we just want to serve up the file named
476 self.opendb('admin')
477 file = self.db.file
478 self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
479 self.write(file.get(nodeid, 'content'))
481 def serve_static_file(self, file):
482 ims = None
483 # see if there's an if-modified-since...
484 if hasattr(self.request, 'headers'):
485 ims = self.request.headers.getheader('if-modified-since')
486 elif self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
487 # cgi will put the header in the env var
488 ims = self.env['HTTP_IF_MODIFIED_SINCE']
489 filename = os.path.join(self.instance.config.TEMPLATES, file)
490 lmt = os.stat(filename)[stat.ST_MTIME]
491 if ims:
492 ims = rfc822.parsedate(ims)[:6]
493 lmtt = time.gmtime(lmt)[:6]
494 if lmtt <= ims:
495 raise NotModified
497 # we just want to serve up the file named
498 file = str(file)
499 mt = mimetypes.guess_type(file)[0]
500 if not mt:
501 if file.endswith('.css'):
502 mt = 'text/css'
503 else:
504 mt = 'text/plain'
505 self.additional_headers['Content-Type'] = mt
506 self.additional_headers['Last-Modifed'] = rfc822.formatdate(lmt)
507 self.write(open(filename, 'rb').read())
509 def renderContext(self):
510 ''' Return a PageTemplate for the named page
511 '''
512 name = self.classname
513 extension = self.template
514 pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
516 # catch errors so we can handle PT rendering errors more nicely
517 args = {
518 'ok_message': self.ok_message,
519 'error_message': self.error_message
520 }
521 try:
522 # let the template render figure stuff out
523 return pt.render(self, None, None, **args)
524 except NoTemplate, message:
525 return '<strong>%s</strong>'%message
526 except:
527 # everything else
528 return cgitb.pt_html()
530 # these are the actions that are available
531 actions = (
532 ('edit', 'editItemAction'),
533 ('editcsv', 'editCSVAction'),
534 ('new', 'newItemAction'),
535 ('register', 'registerAction'),
536 ('confrego', 'confRegoAction'),
537 ('passrst', 'passResetAction'),
538 ('login', 'loginAction'),
539 ('logout', 'logout_action'),
540 ('search', 'searchAction'),
541 ('retire', 'retireAction'),
542 ('show', 'showAction'),
543 )
544 def handle_action(self):
545 ''' Determine whether there should be an Action called.
547 The action is defined by the form variable :action which
548 identifies the method on this object to call. The actions
549 are defined in the "actions" sequence on this class.
550 '''
551 if self.form.has_key(':action'):
552 action = self.form[':action'].value.lower()
553 elif self.form.has_key('@action'):
554 action = self.form['@action'].value.lower()
555 else:
556 return None
557 try:
558 # get the action, validate it
559 for name, method in self.actions:
560 if name == action:
561 break
562 else:
563 raise ValueError, 'No such action "%s"'%action
564 # call the mapped action
565 getattr(self, method)()
566 except Redirect:
567 raise
568 except Unauthorised:
569 raise
571 def write(self, content):
572 if not self.headers_done:
573 self.header()
574 self.request.wfile.write(content)
576 def header(self, headers=None, response=None):
577 '''Put up the appropriate header.
578 '''
579 if headers is None:
580 headers = {'Content-Type':'text/html'}
581 if response is None:
582 response = self.response_code
584 # update with additional info
585 headers.update(self.additional_headers)
587 if not headers.has_key('Content-Type'):
588 headers['Content-Type'] = 'text/html'
589 self.request.send_response(response)
590 for entry in headers.items():
591 self.request.send_header(*entry)
592 self.request.end_headers()
593 self.headers_done = 1
594 if self.debug:
595 self.headers_sent = headers
597 def set_cookie(self, user):
598 ''' Set up a session cookie for the user and store away the user's
599 login info against the session.
600 '''
601 # TODO generate a much, much stronger session key ;)
602 self.session = binascii.b2a_base64(repr(random.random())).strip()
604 # clean up the base64
605 if self.session[-1] == '=':
606 if self.session[-2] == '=':
607 self.session = self.session[:-2]
608 else:
609 self.session = self.session[:-1]
611 # insert the session in the sessiondb
612 self.db.sessions.set(self.session, user=user, last_use=time.time())
614 # and commit immediately
615 self.db.sessions.commit()
617 # expire us in a long, long time
618 expire = Cookie._getdate(86400*365)
620 # generate the cookie path - make sure it has a trailing '/'
621 self.additional_headers['Set-Cookie'] = \
622 '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
623 expire, self.cookie_path)
625 def make_user_anonymous(self):
626 ''' Make us anonymous
628 This method used to handle non-existence of the 'anonymous'
629 user, but that user is mandatory now.
630 '''
631 self.userid = self.db.user.lookup('anonymous')
632 self.user = 'anonymous'
634 def opendb(self, user):
635 ''' Open the database.
636 '''
637 # open the db if the user has changed
638 if not hasattr(self, 'db') or user != self.db.journaltag:
639 if hasattr(self, 'db'):
640 self.db.close()
641 self.db = self.instance.open(user)
643 #
644 # Actions
645 #
646 def loginAction(self):
647 ''' Attempt to log a user in.
649 Sets up a session for the user which contains the login
650 credentials.
651 '''
652 # we need the username at a minimum
653 if not self.form.has_key('__login_name'):
654 self.error_message.append(_('Username required'))
655 return
657 # get the login info
658 self.user = self.form['__login_name'].value
659 if self.form.has_key('__login_password'):
660 password = self.form['__login_password'].value
661 else:
662 password = ''
664 # make sure the user exists
665 try:
666 self.userid = self.db.user.lookup(self.user)
667 except KeyError:
668 name = self.user
669 self.error_message.append(_('No such user "%(name)s"')%locals())
670 self.make_user_anonymous()
671 return
673 # verify the password
674 if not self.verifyPassword(self.userid, password):
675 self.make_user_anonymous()
676 self.error_message.append(_('Incorrect password'))
677 return
679 # make sure we're allowed to be here
680 if not self.loginPermission():
681 self.make_user_anonymous()
682 self.error_message.append(_("You do not have permission to login"))
683 return
685 # now we're OK, re-open the database for real, using the user
686 self.opendb(self.user)
688 # set the session cookie
689 self.set_cookie(self.user)
691 def verifyPassword(self, userid, password):
692 ''' Verify the password that the user has supplied
693 '''
694 stored = self.db.user.get(self.userid, 'password')
695 if password == stored:
696 return 1
697 if not password and not stored:
698 return 1
699 return 0
701 def loginPermission(self):
702 ''' Determine whether the user has permission to log in.
704 Base behaviour is to check the user has "Web Access".
705 '''
706 if not self.db.security.hasPermission('Web Access', self.userid):
707 return 0
708 return 1
710 def logout_action(self):
711 ''' Make us really anonymous - nuke the cookie too
712 '''
713 # log us out
714 self.make_user_anonymous()
716 # construct the logout cookie
717 now = Cookie._getdate()
718 self.additional_headers['Set-Cookie'] = \
719 '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.cookie_name,
720 now, self.cookie_path)
722 # Let the user know what's going on
723 self.ok_message.append(_('You are logged out'))
725 def registerAction(self):
726 '''Attempt to create a new user based on the contents of the form
727 and then set the cookie.
729 return 1 on successful login
730 '''
731 # parse the props from the form
732 try:
733 props = self.parsePropsFromForm()[0][('user', None)]
734 except (ValueError, KeyError), message:
735 self.error_message.append(_('Error: ') + str(message))
736 return
738 # make sure we're allowed to register
739 if not self.registerPermission(props):
740 raise Unauthorised, _("You do not have permission to register")
742 try:
743 self.db.user.lookup(props['username'])
744 self.error_message.append('Error: A user with the username "%s" '
745 'already exists'%props['username'])
746 return
747 except KeyError:
748 pass
750 # generate the one-time-key and store the props for later
751 otk = ''.join([random.choice(chars) for x in range(32)])
752 for propname, proptype in self.db.user.getprops().items():
753 value = props.get(propname, None)
754 if value is None:
755 pass
756 elif isinstance(proptype, hyperdb.Date):
757 props[propname] = str(value)
758 elif isinstance(proptype, hyperdb.Interval):
759 props[propname] = str(value)
760 elif isinstance(proptype, hyperdb.Password):
761 props[propname] = str(value)
762 props['__time'] = time.time()
763 self.db.otks.set(otk, **props)
765 # send the email
766 tracker_name = self.db.config.TRACKER_NAME
767 subject = 'Complete your registration to %s'%tracker_name
768 body = '''
769 To complete your registration of the user "%(name)s" with %(tracker)s,
770 please visit the following URL:
772 %(url)s?@action=confrego&otk=%(otk)s
773 '''%{'name': props['username'], 'tracker': tracker_name, 'url': self.base,
774 'otk': otk}
775 if not self.sendEmail(props['address'], subject, body):
776 return
778 # commit changes to the database
779 self.db.commit()
781 # redirect to the "you're almost there" page
782 raise Redirect, '%suser?@template=rego_progress'%self.base
784 def sendEmail(self, to, subject, content):
785 # send email to the user's email address
786 message = StringIO.StringIO()
787 writer = MimeWriter.MimeWriter(message)
788 tracker_name = self.db.config.TRACKER_NAME
789 writer.addheader('Subject', encode_header(subject))
790 writer.addheader('To', to)
791 writer.addheader('From', roundupdb.straddr((tracker_name,
792 self.db.config.ADMIN_EMAIL)))
793 writer.addheader('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000",
794 time.gmtime()))
795 # add a uniquely Roundup header to help filtering
796 writer.addheader('X-Roundup-Name', tracker_name)
797 # avoid email loops
798 writer.addheader('X-Roundup-Loop', 'hello')
799 writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
800 body = writer.startbody('text/plain; charset=utf-8')
802 # message body, encoded quoted-printable
803 content = StringIO.StringIO(content)
804 quopri.encode(content, body, 0)
806 if SENDMAILDEBUG:
807 # don't send - just write to a file
808 open(SENDMAILDEBUG, 'a').write('FROM: %s\nTO: %s\n%s\n'%(
809 self.db.config.ADMIN_EMAIL,
810 ', '.join(to),message.getvalue()))
811 else:
812 # now try to send the message
813 try:
814 # send the message as admin so bounces are sent there
815 # instead of to roundup
816 smtp = openSMTPConnection(self.db.config)
817 smtp.sendmail(self.db.config.ADMIN_EMAIL, [to],
818 message.getvalue())
819 except socket.error, value:
820 self.error_message.append("Error: couldn't send email: "
821 "mailhost %s"%value)
822 return 0
823 except smtplib.SMTPException, msg:
824 self.error_message.append("Error: couldn't send email: %s"%msg)
825 return 0
826 return 1
828 def registerPermission(self, props):
829 ''' Determine whether the user has permission to register
831 Base behaviour is to check the user has "Web Registration".
832 '''
833 # registration isn't allowed to supply roles
834 if props.has_key('roles'):
835 return 0
836 if self.db.security.hasPermission('Web Registration', self.userid):
837 return 1
838 return 0
840 def confRegoAction(self):
841 ''' Grab the OTK, use it to load up the new user details
842 '''
843 # pull the rego information out of the otk database
844 otk = self.form['otk'].value
845 props = self.db.otks.getall(otk)
846 for propname, proptype in self.db.user.getprops().items():
847 value = props.get(propname, None)
848 if value is None:
849 pass
850 elif isinstance(proptype, hyperdb.Date):
851 props[propname] = date.Date(value)
852 elif isinstance(proptype, hyperdb.Interval):
853 props[propname] = date.Interval(value)
854 elif isinstance(proptype, hyperdb.Password):
855 props[propname] = password.Password()
856 props[propname].unpack(value)
858 # re-open the database as "admin"
859 if self.user != 'admin':
860 self.opendb('admin')
862 # create the new user
863 cl = self.db.user
864 # XXX we need to make the "default" page be able to display errors!
865 try:
866 props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
867 del props['__time']
868 self.userid = cl.create(**props)
869 # clear the props from the otk database
870 self.db.otks.destroy(otk)
871 self.db.commit()
872 except (ValueError, KeyError), message:
873 self.error_message.append(str(message))
874 return
876 # log the new user in
877 self.user = cl.get(self.userid, 'username')
878 # re-open the database for real, using the user
879 self.opendb(self.user)
881 # if we have a session, update it
882 if hasattr(self, 'session'):
883 self.db.sessions.set(self.session, user=self.user,
884 last_use=time.time())
885 else:
886 # new session cookie
887 self.set_cookie(self.user)
889 # nice message
890 message = _('You are now registered, welcome!')
892 # redirect to the user's page
893 raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
894 self.userid, urllib.quote(message))
896 def passResetAction(self):
897 ''' Handle password reset requests.
899 Presence of either "name" or "address" generate email.
900 Presense of "otk" performs the reset.
901 '''
902 if self.form.has_key('otk'):
903 # pull the rego information out of the otk database
904 otk = self.form['otk'].value
905 uid = self.db.otks.get(otk, 'uid')
906 if uid is None:
907 self.error_message.append('Invalid One Time Key!')
908 return
910 # re-open the database as "admin"
911 if self.user != 'admin':
912 self.opendb('admin')
914 # change the password
915 newpw = password.generatePassword()
917 cl = self.db.user
918 # XXX we need to make the "default" page be able to display errors!
919 try:
920 # set the password
921 cl.set(uid, password=password.Password(newpw))
922 # clear the props from the otk database
923 self.db.otks.destroy(otk)
924 self.db.commit()
925 except (ValueError, KeyError), message:
926 self.error_message.append(str(message))
927 return
929 # user info
930 address = self.db.user.get(uid, 'address')
931 name = self.db.user.get(uid, 'username')
933 # send the email
934 tracker_name = self.db.config.TRACKER_NAME
935 subject = 'Password reset for %s'%tracker_name
936 body = '''
937 The password has been reset for username "%(name)s".
939 Your password is now: %(password)s
940 '''%{'name': name, 'password': newpw}
941 if not self.sendEmail(address, subject, body):
942 return
944 self.ok_message.append('Password reset and email sent to %s'%address)
945 return
947 # no OTK, so now figure the user
948 if self.form.has_key('username'):
949 name = self.form['username'].value
950 try:
951 uid = self.db.user.lookup(name)
952 except KeyError:
953 self.error_message.append('Unknown username')
954 return
955 address = self.db.user.get(uid, 'address')
956 elif self.form.has_key('address'):
957 address = self.form['address'].value
958 uid = uidFromAddress(self.db, ('', address), create=0)
959 if not uid:
960 self.error_message.append('Unknown email address')
961 return
962 name = self.db.user.get(uid, 'username')
963 else:
964 self.error_message.append('You need to specify a username '
965 'or address')
966 return
968 # generate the one-time-key and store the props for later
969 otk = ''.join([random.choice(chars) for x in range(32)])
970 self.db.otks.set(otk, uid=uid, __time=time.time())
972 # send the email
973 tracker_name = self.db.config.TRACKER_NAME
974 subject = 'Confirm reset of password for %s'%tracker_name
975 body = '''
976 Someone, perhaps you, has requested that the password be changed for your
977 username, "%(name)s". If you wish to proceed with the change, please follow
978 the link below:
980 %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
982 You should then receive another email with the new password.
983 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
984 if not self.sendEmail(address, subject, body):
985 return
987 self.ok_message.append('Email sent to %s'%address)
989 def editItemAction(self):
990 ''' Perform an edit of an item in the database.
992 See parsePropsFromForm and _editnodes for special variables
993 '''
994 # parse the props from the form
995 try:
996 props, links = self.parsePropsFromForm()
997 except (ValueError, KeyError), message:
998 self.error_message.append(_('Parse Error: ') + str(message))
999 return
1001 # handle the props
1002 try:
1003 message = self._editnodes(props, links)
1004 except (ValueError, KeyError, IndexError), message:
1005 self.error_message.append(_('Apply Error: ') + str(message))
1006 return
1008 # commit now that all the tricky stuff is done
1009 self.db.commit()
1011 # redirect to the item's edit page
1012 raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
1013 self.classname, self.nodeid, urllib.quote(message),
1014 urllib.quote(self.template))
1016 def editItemPermission(self, props):
1017 ''' Determine whether the user has permission to edit this item.
1019 Base behaviour is to check the user can edit this class. If we're
1020 editing the "user" class, users are allowed to edit their own
1021 details. Unless it's the "roles" property, which requires the
1022 special Permission "Web Roles".
1023 '''
1024 # if this is a user node and the user is editing their own node, then
1025 # we're OK
1026 has = self.db.security.hasPermission
1027 if self.classname == 'user':
1028 # reject if someone's trying to edit "roles" and doesn't have the
1029 # right permission.
1030 if props.has_key('roles') and not has('Web Roles', self.userid,
1031 'user'):
1032 return 0
1033 # if the item being edited is the current user, we're ok
1034 if self.nodeid == self.userid:
1035 return 1
1036 if self.db.security.hasPermission('Edit', self.userid, self.classname):
1037 return 1
1038 return 0
1040 def newItemAction(self):
1041 ''' Add a new item to the database.
1043 This follows the same form as the editItemAction, with the same
1044 special form values.
1045 '''
1046 # parse the props from the form
1047 try:
1048 props, links = self.parsePropsFromForm()
1049 except (ValueError, KeyError), message:
1050 self.error_message.append(_('Error: ') + str(message))
1051 return
1053 # handle the props - edit or create
1054 try:
1055 # when it hits the None element, it'll set self.nodeid
1056 messages = self._editnodes(props, links)
1058 except (ValueError, KeyError, IndexError), message:
1059 # these errors might just be indicative of user dumbness
1060 self.error_message.append(_('Error: ') + str(message))
1061 return
1063 # commit now that all the tricky stuff is done
1064 self.db.commit()
1066 # redirect to the new item's page
1067 raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
1068 self.classname, self.nodeid, urllib.quote(messages),
1069 urllib.quote(self.template))
1071 def newItemPermission(self, props):
1072 ''' Determine whether the user has permission to create (edit) this
1073 item.
1075 Base behaviour is to check the user can edit this class. No
1076 additional property checks are made. Additionally, new user items
1077 may be created if the user has the "Web Registration" Permission.
1078 '''
1079 has = self.db.security.hasPermission
1080 if self.classname == 'user' and has('Web Registration', self.userid,
1081 'user'):
1082 return 1
1083 if has('Edit', self.userid, self.classname):
1084 return 1
1085 return 0
1088 #
1089 # Utility methods for editing
1090 #
1091 def _editnodes(self, all_props, all_links, newids=None):
1092 ''' Use the props in all_props to perform edit and creation, then
1093 use the link specs in all_links to do linking.
1094 '''
1095 # figure dependencies and re-work links
1096 deps = {}
1097 links = {}
1098 for cn, nodeid, propname, vlist in all_links:
1099 if not all_props.has_key((cn, nodeid)):
1100 # link item to link to doesn't (and won't) exist
1101 continue
1102 for value in vlist:
1103 if not all_props.has_key(value):
1104 # link item to link to doesn't (and won't) exist
1105 continue
1106 deps.setdefault((cn, nodeid), []).append(value)
1107 links.setdefault(value, []).append((cn, nodeid, propname))
1109 # figure chained dependencies ordering
1110 order = []
1111 done = {}
1112 # loop detection
1113 change = 0
1114 while len(all_props) != len(done):
1115 for needed in all_props.keys():
1116 if done.has_key(needed):
1117 continue
1118 tlist = deps.get(needed, [])
1119 for target in tlist:
1120 if not done.has_key(target):
1121 break
1122 else:
1123 done[needed] = 1
1124 order.append(needed)
1125 change = 1
1126 if not change:
1127 raise ValueError, 'linking must not loop!'
1129 # now, edit / create
1130 m = []
1131 for needed in order:
1132 props = all_props[needed]
1133 if not props:
1134 # nothing to do
1135 continue
1136 cn, nodeid = needed
1138 if nodeid is not None and int(nodeid) > 0:
1139 # make changes to the node
1140 props = self._changenode(cn, nodeid, props)
1142 # and some nice feedback for the user
1143 if props:
1144 info = ', '.join(props.keys())
1145 m.append('%s %s %s edited ok'%(cn, nodeid, info))
1146 else:
1147 m.append('%s %s - nothing changed'%(cn, nodeid))
1148 else:
1149 assert props
1151 # make a new node
1152 newid = self._createnode(cn, props)
1153 if nodeid is None:
1154 self.nodeid = newid
1155 nodeid = newid
1157 # and some nice feedback for the user
1158 m.append('%s %s created'%(cn, newid))
1160 # fill in new ids in links
1161 if links.has_key(needed):
1162 for linkcn, linkid, linkprop in links[needed]:
1163 props = all_props[(linkcn, linkid)]
1164 cl = self.db.classes[linkcn]
1165 propdef = cl.getprops()[linkprop]
1166 if not props.has_key(linkprop):
1167 if linkid is None or linkid.startswith('-'):
1168 # linking to a new item
1169 if isinstance(propdef, hyperdb.Multilink):
1170 props[linkprop] = [newid]
1171 else:
1172 props[linkprop] = newid
1173 else:
1174 # linking to an existing item
1175 if isinstance(propdef, hyperdb.Multilink):
1176 existing = cl.get(linkid, linkprop)[:]
1177 existing.append(nodeid)
1178 props[linkprop] = existing
1179 else:
1180 props[linkprop] = newid
1182 return '<br>'.join(m)
1184 def _changenode(self, cn, nodeid, props):
1185 ''' change the node based on the contents of the form
1186 '''
1187 # check for permission
1188 if not self.editItemPermission(props):
1189 raise Unauthorised, 'You do not have permission to edit %s'%cn
1191 # make the changes
1192 cl = self.db.classes[cn]
1193 return cl.set(nodeid, **props)
1195 def _createnode(self, cn, props):
1196 ''' create a node based on the contents of the form
1197 '''
1198 # check for permission
1199 if not self.newItemPermission(props):
1200 raise Unauthorised, 'You do not have permission to create %s'%cn
1202 # create the node and return its id
1203 cl = self.db.classes[cn]
1204 return cl.create(**props)
1206 #
1207 # More actions
1208 #
1209 def editCSVAction(self):
1210 ''' Performs an edit of all of a class' items in one go.
1212 The "rows" CGI var defines the CSV-formatted entries for the
1213 class. New nodes are identified by the ID 'X' (or any other
1214 non-existent ID) and removed lines are retired.
1215 '''
1216 # this is per-class only
1217 if not self.editCSVPermission():
1218 self.error_message.append(
1219 _('You do not have permission to edit %s' %self.classname))
1221 # get the CSV module
1222 if rcsv.error:
1223 self.error_message.append(_(rcsv.error))
1224 return
1226 cl = self.db.classes[self.classname]
1227 idlessprops = cl.getprops(protected=0).keys()
1228 idlessprops.sort()
1229 props = ['id'] + idlessprops
1231 # do the edit
1232 rows = StringIO.StringIO(self.form['rows'].value)
1233 reader = rcsv.reader(rows, rcsv.comma_separated)
1234 found = {}
1235 line = 0
1236 for values in reader:
1237 line += 1
1238 if line == 1: continue
1239 # skip property names header
1240 if values == props:
1241 continue
1243 # extract the nodeid
1244 nodeid, values = values[0], values[1:]
1245 found[nodeid] = 1
1247 # see if the node exists
1248 if nodeid in ('x', 'X') or not cl.hasnode(nodeid):
1249 exists = 0
1250 else:
1251 exists = 1
1253 # confirm correct weight
1254 if len(idlessprops) != len(values):
1255 self.error_message.append(
1256 _('Not enough values on line %(line)s')%{'line':line})
1257 return
1259 # extract the new values
1260 d = {}
1261 for name, value in zip(idlessprops, values):
1262 prop = cl.properties[name]
1263 value = value.strip()
1264 # only add the property if it has a value
1265 if value:
1266 # if it's a multilink, split it
1267 if isinstance(prop, hyperdb.Multilink):
1268 value = value.split(':')
1269 elif isinstance(prop, hyperdb.Password):
1270 value = password.Password(value)
1271 elif isinstance(prop, hyperdb.Interval):
1272 value = date.Interval(value)
1273 elif isinstance(prop, hyperdb.Date):
1274 value = date.Date(value)
1275 elif isinstance(prop, hyperdb.Boolean):
1276 value = value.lower() in ('yes', 'true', 'on', '1')
1277 elif isinstance(prop, hyperdb.Number):
1278 value = float(value)
1279 d[name] = value
1280 elif exists:
1281 # nuke the existing value
1282 if isinstance(prop, hyperdb.Multilink):
1283 d[name] = []
1284 else:
1285 d[name] = None
1287 # perform the edit
1288 if exists:
1289 # edit existing
1290 cl.set(nodeid, **d)
1291 else:
1292 # new node
1293 found[cl.create(**d)] = 1
1295 # retire the removed entries
1296 for nodeid in cl.list():
1297 if not found.has_key(nodeid):
1298 cl.retire(nodeid)
1300 # all OK
1301 self.db.commit()
1303 self.ok_message.append(_('Items edited OK'))
1305 def editCSVPermission(self):
1306 ''' Determine whether the user has permission to edit this class.
1308 Base behaviour is to check the user can edit this class.
1309 '''
1310 if not self.db.security.hasPermission('Edit', self.userid,
1311 self.classname):
1312 return 0
1313 return 1
1315 def searchAction(self, wcre=re.compile(r'[\s,]+')):
1316 ''' Mangle some of the form variables.
1318 Set the form ":filter" variable based on the values of the
1319 filter variables - if they're set to anything other than
1320 "dontcare" then add them to :filter.
1322 Handle the ":queryname" variable and save off the query to
1323 the user's query list.
1325 Split any String query values on whitespace and comma.
1326 '''
1327 # generic edit is per-class only
1328 if not self.searchPermission():
1329 self.error_message.append(
1330 _('You do not have permission to search %s' %self.classname))
1332 # add a faked :filter form variable for each filtering prop
1333 props = self.db.classes[self.classname].getprops()
1334 queryname = ''
1335 for key in self.form.keys():
1336 # special vars
1337 if self.FV_QUERYNAME.match(key):
1338 queryname = self.form[key].value.strip()
1339 continue
1341 if not props.has_key(key):
1342 continue
1343 if isinstance(self.form[key], type([])):
1344 # search for at least one entry which is not empty
1345 for minifield in self.form[key]:
1346 if minifield.value:
1347 break
1348 else:
1349 continue
1350 else:
1351 if not self.form[key].value:
1352 continue
1353 if isinstance(props[key], hyperdb.String):
1354 v = self.form[key].value
1355 l = token.token_split(v)
1356 if len(l) > 1 or l[0] != v:
1357 self.form.value.remove(self.form[key])
1358 # replace the single value with the split list
1359 for v in l:
1360 self.form.value.append(cgi.MiniFieldStorage(key, v))
1362 self.form.value.append(cgi.MiniFieldStorage('@filter', key))
1364 # handle saving the query params
1365 if queryname:
1366 # parse the environment and figure what the query _is_
1367 req = HTMLRequest(self)
1369 # The [1:] strips off the '?' character, it isn't part of the
1370 # query string.
1371 url = req.indexargs_href('', {})[1:]
1373 # handle editing an existing query
1374 try:
1375 qid = self.db.query.lookup(queryname)
1376 self.db.query.set(qid, klass=self.classname, url=url)
1377 except KeyError:
1378 # create a query
1379 qid = self.db.query.create(name=queryname,
1380 klass=self.classname, url=url)
1382 # and add it to the user's query multilink
1383 queries = self.db.user.get(self.userid, 'queries')
1384 queries.append(qid)
1385 self.db.user.set(self.userid, queries=queries)
1387 # commit the query change to the database
1388 self.db.commit()
1390 def searchPermission(self):
1391 ''' Determine whether the user has permission to search this class.
1393 Base behaviour is to check the user can view this class.
1394 '''
1395 if not self.db.security.hasPermission('View', self.userid,
1396 self.classname):
1397 return 0
1398 return 1
1401 def retireAction(self):
1402 ''' Retire the context item.
1403 '''
1404 # if we want to view the index template now, then unset the nodeid
1405 # context info (a special-case for retire actions on the index page)
1406 nodeid = self.nodeid
1407 if self.template == 'index':
1408 self.nodeid = None
1410 # generic edit is per-class only
1411 if not self.retirePermission():
1412 self.error_message.append(
1413 _('You do not have permission to retire %s' %self.classname))
1414 return
1416 # make sure we don't try to retire admin or anonymous
1417 if self.classname == 'user' and \
1418 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1419 self.error_message.append(
1420 _('You may not retire the admin or anonymous user'))
1421 return
1423 # do the retire
1424 self.db.getclass(self.classname).retire(nodeid)
1425 self.db.commit()
1427 self.ok_message.append(
1428 _('%(classname)s %(itemid)s has been retired')%{
1429 'classname': self.classname.capitalize(), 'itemid': nodeid})
1431 def retirePermission(self):
1432 ''' Determine whether the user has permission to retire this class.
1434 Base behaviour is to check the user can edit this class.
1435 '''
1436 if not self.db.security.hasPermission('Edit', self.userid,
1437 self.classname):
1438 return 0
1439 return 1
1442 def showAction(self, typere=re.compile('[@:]type'),
1443 numre=re.compile('[@:]number')):
1444 ''' Show a node of a particular class/id
1445 '''
1446 t = n = ''
1447 for key in self.form.keys():
1448 if typere.match(key):
1449 t = self.form[key].value.strip()
1450 elif numre.match(key):
1451 n = self.form[key].value.strip()
1452 if not t:
1453 raise ValueError, 'Invalid %s number'%t
1454 url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1455 raise Redirect, url
1457 def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1458 ''' Item properties and their values are edited with html FORM
1459 variables and their values. You can:
1461 - Change the value of some property of the current item.
1462 - Create a new item of any class, and edit the new item's
1463 properties,
1464 - Attach newly created items to a multilink property of the
1465 current item.
1466 - Remove items from a multilink property of the current item.
1467 - Specify that some properties are required for the edit
1468 operation to be successful.
1470 In the following, <bracketed> values are variable, "@" may be
1471 either ":" or "@", and other text "required" is fixed.
1473 Most properties are specified as form variables:
1475 <propname>
1476 - property on the current context item
1478 <designator>"@"<propname>
1479 - property on the indicated item (for editing related
1480 information)
1482 Designators name a specific item of a class.
1484 <classname><N>
1486 Name an existing item of class <classname>.
1488 <classname>"-"<N>
1490 Name the <N>th new item of class <classname>. If the form
1491 submission is successful, a new item of <classname> is
1492 created. Within the submitted form, a particular
1493 designator of this form always refers to the same new
1494 item.
1496 Once we have determined the "propname", we look at it to see
1497 if it's special:
1499 @required
1500 The associated form value is a comma-separated list of
1501 property names that must be specified when the form is
1502 submitted for the edit operation to succeed.
1504 When the <designator> is missing, the properties are
1505 for the current context item. When <designator> is
1506 present, they are for the item specified by
1507 <designator>.
1509 The "@required" specifier must come before any of the
1510 properties it refers to are assigned in the form.
1512 @remove@<propname>=id(s) or @add@<propname>=id(s)
1513 The "@add@" and "@remove@" edit actions apply only to
1514 Multilink properties. The form value must be a
1515 comma-separate list of keys for the class specified by
1516 the simple form variable. The listed items are added
1517 to (respectively, removed from) the specified
1518 property.
1520 @link@<propname>=<designator>
1521 If the edit action is "@link@", the simple form
1522 variable must specify a Link or Multilink property.
1523 The form value is a comma-separated list of
1524 designators. The item corresponding to each
1525 designator is linked to the property given by simple
1526 form variable. These are collected up and returned in
1527 all_links.
1529 None of the above (ie. just a simple form value)
1530 The value of the form variable is converted
1531 appropriately, depending on the type of the property.
1533 For a Link('klass') property, the form value is a
1534 single key for 'klass', where the key field is
1535 specified in dbinit.py.
1537 For a Multilink('klass') property, the form value is a
1538 comma-separated list of keys for 'klass', where the
1539 key field is specified in dbinit.py.
1541 Note that for simple-form-variables specifiying Link
1542 and Multilink properties, the linked-to class must
1543 have a key field.
1545 For a String() property specifying a filename, the
1546 file named by the form value is uploaded. This means we
1547 try to set additional properties "filename" and "type" (if
1548 they are valid for the class). Otherwise, the property
1549 is set to the form value.
1551 For Date(), Interval(), Boolean(), and Number()
1552 properties, the form value is converted to the
1553 appropriate
1555 Any of the form variables may be prefixed with a classname or
1556 designator.
1558 Two special form values are supported for backwards
1559 compatibility:
1561 @note
1562 This is equivalent to::
1564 @link@messages=msg-1
1565 @msg-1@content=value
1567 except that in addition, the "author" and "date"
1568 properties of "msg-1" are set to the userid of the
1569 submitter, and the current time, respectively.
1571 @file
1572 This is equivalent to::
1574 @link@files=file-1
1575 @file-1@content=value
1577 The String content value is handled as described above for
1578 file uploads.
1580 If both the "@note" and "@file" form variables are
1581 specified, the action::
1583 @link@msg-1@files=file-1
1585 is also performed.
1587 We also check that FileClass items have a "content" property with
1588 actual content, otherwise we remove them from all_props before
1589 returning.
1591 The return from this method is a dict of
1592 (classname, id): properties
1593 ... this dict _always_ has an entry for the current context,
1594 even if it's empty (ie. a submission for an existing issue that
1595 doesn't result in any changes would return {('issue','123'): {}})
1596 The id may be None, which indicates that an item should be
1597 created.
1598 '''
1599 # some very useful variables
1600 db = self.db
1601 form = self.form
1603 if not hasattr(self, 'FV_SPECIAL'):
1604 # generate the regexp for handling special form values
1605 classes = '|'.join(db.classes.keys())
1606 # specials for parsePropsFromForm
1607 # handle the various forms (see unit tests)
1608 self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1609 self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1611 # these indicate the default class / item
1612 default_cn = self.classname
1613 default_cl = self.db.classes[default_cn]
1614 default_nodeid = self.nodeid
1616 # we'll store info about the individual class/item edit in these
1617 all_required = {} # required props per class/item
1618 all_props = {} # props to set per class/item
1619 got_props = {} # props received per class/item
1620 all_propdef = {} # note - only one entry per class
1621 all_links = [] # as many as are required
1623 # we should always return something, even empty, for the context
1624 all_props[(default_cn, default_nodeid)] = {}
1626 keys = form.keys()
1627 timezone = db.getUserTimezone()
1629 # sentinels for the :note and :file props
1630 have_note = have_file = 0
1632 # extract the usable form labels from the form
1633 matches = []
1634 for key in keys:
1635 m = self.FV_SPECIAL.match(key)
1636 if m:
1637 matches.append((key, m.groupdict()))
1639 # now handle the matches
1640 for key, d in matches:
1641 if d['classname']:
1642 # we got a designator
1643 cn = d['classname']
1644 cl = self.db.classes[cn]
1645 nodeid = d['id']
1646 propname = d['propname']
1647 elif d['note']:
1648 # the special note field
1649 cn = 'msg'
1650 cl = self.db.classes[cn]
1651 nodeid = '-1'
1652 propname = 'content'
1653 all_links.append((default_cn, default_nodeid, 'messages',
1654 [('msg', '-1')]))
1655 have_note = 1
1656 elif d['file']:
1657 # the special file field
1658 cn = 'file'
1659 cl = self.db.classes[cn]
1660 nodeid = '-1'
1661 propname = 'content'
1662 all_links.append((default_cn, default_nodeid, 'files',
1663 [('file', '-1')]))
1664 have_file = 1
1665 else:
1666 # default
1667 cn = default_cn
1668 cl = default_cl
1669 nodeid = default_nodeid
1670 propname = d['propname']
1672 # the thing this value relates to is...
1673 this = (cn, nodeid)
1675 # get more info about the class, and the current set of
1676 # form props for it
1677 if not all_propdef.has_key(cn):
1678 all_propdef[cn] = cl.getprops()
1679 propdef = all_propdef[cn]
1680 if not all_props.has_key(this):
1681 all_props[this] = {}
1682 props = all_props[this]
1683 if not got_props.has_key(this):
1684 got_props[this] = {}
1686 # is this a link command?
1687 if d['link']:
1688 value = []
1689 for entry in extractFormList(form[key]):
1690 m = self.FV_DESIGNATOR.match(entry)
1691 if not m:
1692 raise ValueError, \
1693 'link "%s" value "%s" not a designator'%(key, entry)
1694 value.append((m.group(1), m.group(2)))
1696 # make sure the link property is valid
1697 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1698 not isinstance(propdef[propname], hyperdb.Link)):
1699 raise ValueError, '%s %s is not a link or '\
1700 'multilink property'%(cn, propname)
1702 all_links.append((cn, nodeid, propname, value))
1703 continue
1705 # detect the special ":required" variable
1706 if d['required']:
1707 all_required[this] = extractFormList(form[key])
1708 continue
1710 # see if we're performing a special multilink action
1711 mlaction = 'set'
1712 if d['remove']:
1713 mlaction = 'remove'
1714 elif d['add']:
1715 mlaction = 'add'
1717 # does the property exist?
1718 if not propdef.has_key(propname):
1719 if mlaction != 'set':
1720 raise ValueError, 'You have submitted a %s action for'\
1721 ' the property "%s" which doesn\'t exist'%(mlaction,
1722 propname)
1723 # the form element is probably just something we don't care
1724 # about - ignore it
1725 continue
1726 proptype = propdef[propname]
1728 # Get the form value. This value may be a MiniFieldStorage or a list
1729 # of MiniFieldStorages.
1730 value = form[key]
1732 # handle unpacking of the MiniFieldStorage / list form value
1733 if isinstance(proptype, hyperdb.Multilink):
1734 value = extractFormList(value)
1735 else:
1736 # multiple values are not OK
1737 if isinstance(value, type([])):
1738 raise ValueError, 'You have submitted more than one value'\
1739 ' for the %s property'%propname
1740 # value might be a file upload...
1741 if not hasattr(value, 'filename') or value.filename is None:
1742 # nope, pull out the value and strip it
1743 value = value.value.strip()
1745 # now that we have the props field, we need a teensy little
1746 # extra bit of help for the old :note field...
1747 if d['note'] and value:
1748 props['author'] = self.db.getuid()
1749 props['date'] = date.Date()
1751 # handle by type now
1752 if isinstance(proptype, hyperdb.Password):
1753 if not value:
1754 # ignore empty password values
1755 continue
1756 for key, d in matches:
1757 if d['confirm'] and d['propname'] == propname:
1758 confirm = form[key]
1759 break
1760 else:
1761 raise ValueError, 'Password and confirmation text do '\
1762 'not match'
1763 if isinstance(confirm, type([])):
1764 raise ValueError, 'You have submitted more than one value'\
1765 ' for the %s property'%propname
1766 if value != confirm.value:
1767 raise ValueError, 'Password and confirmation text do '\
1768 'not match'
1769 value = password.Password(value)
1771 elif isinstance(proptype, hyperdb.Link):
1772 # see if it's the "no selection" choice
1773 if value == '-1' or not value:
1774 # if we're creating, just don't include this property
1775 if not nodeid or nodeid.startswith('-'):
1776 continue
1777 value = None
1778 else:
1779 # handle key values
1780 link = proptype.classname
1781 if not num_re.match(value):
1782 try:
1783 value = db.classes[link].lookup(value)
1784 except KeyError:
1785 raise ValueError, _('property "%(propname)s": '
1786 '%(value)s not a %(classname)s')%{
1787 'propname': propname, 'value': value,
1788 'classname': link}
1789 except TypeError, message:
1790 raise ValueError, _('you may only enter ID values '
1791 'for property "%(propname)s": %(message)s')%{
1792 'propname': propname, 'message': message}
1793 elif isinstance(proptype, hyperdb.Multilink):
1794 # perform link class key value lookup if necessary
1795 link = proptype.classname
1796 link_cl = db.classes[link]
1797 l = []
1798 for entry in value:
1799 if not entry: continue
1800 if not num_re.match(entry):
1801 try:
1802 entry = link_cl.lookup(entry)
1803 except KeyError:
1804 raise ValueError, _('property "%(propname)s": '
1805 '"%(value)s" not an entry of %(classname)s')%{
1806 'propname': propname, 'value': entry,
1807 'classname': link}
1808 except TypeError, message:
1809 raise ValueError, _('you may only enter ID values '
1810 'for property "%(propname)s": %(message)s')%{
1811 'propname': propname, 'message': message}
1812 l.append(entry)
1813 l.sort()
1815 # now use that list of ids to modify the multilink
1816 if mlaction == 'set':
1817 value = l
1818 else:
1819 # we're modifying the list - get the current list of ids
1820 if props.has_key(propname):
1821 existing = props[propname]
1822 elif nodeid and not nodeid.startswith('-'):
1823 existing = cl.get(nodeid, propname, [])
1824 else:
1825 existing = []
1827 # now either remove or add
1828 if mlaction == 'remove':
1829 # remove - handle situation where the id isn't in
1830 # the list
1831 for entry in l:
1832 try:
1833 existing.remove(entry)
1834 except ValueError:
1835 raise ValueError, _('property "%(propname)s": '
1836 '"%(value)s" not currently in list')%{
1837 'propname': propname, 'value': entry}
1838 else:
1839 # add - easy, just don't dupe
1840 for entry in l:
1841 if entry not in existing:
1842 existing.append(entry)
1843 value = existing
1844 value.sort()
1846 elif value == '':
1847 # if we're creating, just don't include this property
1848 if not nodeid or nodeid.startswith('-'):
1849 continue
1850 # other types should be None'd if there's no value
1851 value = None
1852 else:
1853 # handle ValueErrors for all these in a similar fashion
1854 try:
1855 if isinstance(proptype, hyperdb.String):
1856 if (hasattr(value, 'filename') and
1857 value.filename is not None):
1858 # skip if the upload is empty
1859 if not value.filename:
1860 continue
1861 # this String is actually a _file_
1862 # try to determine the file content-type
1863 fn = value.filename.split('\\')[-1]
1864 if propdef.has_key('name'):
1865 props['name'] = fn
1866 # use this info as the type/filename properties
1867 if propdef.has_key('type'):
1868 props['type'] = mimetypes.guess_type(fn)[0]
1869 if not props['type']:
1870 props['type'] = "application/octet-stream"
1871 # finally, read the content
1872 value = value.value
1873 else:
1874 # normal String fix the CRLF/CR -> LF stuff
1875 value = fixNewlines(value)
1877 elif isinstance(proptype, hyperdb.Date):
1878 value = date.Date(value, offset=timezone)
1879 elif isinstance(proptype, hyperdb.Interval):
1880 value = date.Interval(value)
1881 elif isinstance(proptype, hyperdb.Boolean):
1882 value = value.lower() in ('yes', 'true', 'on', '1')
1883 elif isinstance(proptype, hyperdb.Number):
1884 value = float(value)
1885 except ValueError, msg:
1886 raise ValueError, _('Error with %s property: %s')%(
1887 propname, msg)
1889 # register that we got this property
1890 if value:
1891 got_props[this][propname] = 1
1893 # get the old value
1894 if nodeid and not nodeid.startswith('-'):
1895 try:
1896 existing = cl.get(nodeid, propname)
1897 except KeyError:
1898 # this might be a new property for which there is
1899 # no existing value
1900 if not propdef.has_key(propname):
1901 raise
1903 # make sure the existing multilink is sorted
1904 if isinstance(proptype, hyperdb.Multilink):
1905 existing.sort()
1907 # "missing" existing values may not be None
1908 if not existing:
1909 if isinstance(proptype, hyperdb.String) and not existing:
1910 # some backends store "missing" Strings as empty strings
1911 existing = None
1912 elif isinstance(proptype, hyperdb.Number) and not existing:
1913 # some backends store "missing" Numbers as 0 :(
1914 existing = 0
1915 elif isinstance(proptype, hyperdb.Boolean) and not existing:
1916 # likewise Booleans
1917 existing = 0
1919 # if changed, set it
1920 if value != existing:
1921 props[propname] = value
1922 else:
1923 # don't bother setting empty/unset values
1924 if value is None:
1925 continue
1926 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1927 continue
1928 elif isinstance(proptype, hyperdb.String) and value == '':
1929 continue
1931 props[propname] = value
1933 # check to see if we need to specially link a file to the note
1934 if have_note and have_file:
1935 all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1937 # see if all the required properties have been supplied
1938 s = []
1939 for thing, required in all_required.items():
1940 # register the values we got
1941 got = got_props.get(thing, {})
1942 for entry in required[:]:
1943 if got.has_key(entry):
1944 required.remove(entry)
1946 # any required values not present?
1947 if not required:
1948 continue
1950 # tell the user to entry the values required
1951 if len(required) > 1:
1952 p = 'properties'
1953 else:
1954 p = 'property'
1955 s.append('Required %s %s %s not supplied'%(thing[0], p,
1956 ', '.join(required)))
1957 if s:
1958 raise ValueError, '\n'.join(s)
1960 # When creating a FileClass node, it should have a non-empty content
1961 # property to be created. When editing a FileClass node, it should
1962 # either have a non-empty content property or no property at all. In
1963 # the latter case, nothing will change.
1964 for (cn, id), props in all_props.items():
1965 if isinstance(self.db.classes[cn], hyperdb.FileClass):
1966 if id == '-1':
1967 if not props.get('content', ''):
1968 del all_props[(cn, id)]
1969 elif props.has_key('content') and not props['content']:
1970 raise ValueError, _('File is empty')
1971 return all_props, all_links
1973 def fixNewlines(text):
1974 ''' Homogenise line endings.
1976 Different web clients send different line ending values, but
1977 other systems (eg. email) don't necessarily handle those line
1978 endings. Our solution is to convert all line endings to LF.
1979 '''
1980 text = text.replace('\r\n', '\n')
1981 return text.replace('\r', '\n')
1983 def extractFormList(value):
1984 ''' Extract a list of values from the form value.
1986 It may be one of:
1987 [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
1988 MiniFieldStorage('value,value,...')
1989 MiniFieldStorage('value')
1990 '''
1991 # multiple values are OK
1992 if isinstance(value, type([])):
1993 # it's a list of MiniFieldStorages - join then into
1994 values = ','.join([i.value.strip() for i in value])
1995 else:
1996 # it's a MiniFieldStorage, but may be a comma-separated list
1997 # of values
1998 values = value.value
2000 value = [i.strip() for i in values.split(',')]
2002 # filter out the empty bits
2003 return filter(None, value)