684d8c595c140110d5991e113724bfdc97b2f2fb
1 # $Id: client.py,v 1.129 2003-08-12 01:26:08 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
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 file = self.db.file
477 self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
478 self.write(file.get(nodeid, 'content'))
480 def serve_static_file(self, file):
481 ims = None
482 # see if there's an if-modified-since...
483 if hasattr(self.request, 'headers'):
484 ims = self.request.headers.getheader('if-modified-since')
485 elif self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
486 # cgi will put the header in the env var
487 ims = self.env['HTTP_IF_MODIFIED_SINCE']
488 filename = os.path.join(self.instance.config.TEMPLATES, file)
489 lmt = os.stat(filename)[stat.ST_MTIME]
490 if ims:
491 ims = rfc822.parsedate(ims)[:6]
492 lmtt = time.gmtime(lmt)[:6]
493 if lmtt <= ims:
494 raise NotModified
496 # we just want to serve up the file named
497 file = str(file)
498 mt = mimetypes.guess_type(file)[0]
499 if not mt:
500 if file.endswith('.css'):
501 mt = 'text/css'
502 else:
503 mt = 'text/plain'
504 self.additional_headers['Content-Type'] = mt
505 self.additional_headers['Last-Modifed'] = rfc822.formatdate(lmt)
506 self.write(open(filename, 'rb').read())
508 def renderContext(self):
509 ''' Return a PageTemplate for the named page
510 '''
511 name = self.classname
512 extension = self.template
513 pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
515 # catch errors so we can handle PT rendering errors more nicely
516 args = {
517 'ok_message': self.ok_message,
518 'error_message': self.error_message
519 }
520 try:
521 # let the template render figure stuff out
522 return pt.render(self, None, None, **args)
523 except NoTemplate, message:
524 return '<strong>%s</strong>'%message
525 except:
526 # everything else
527 return cgitb.pt_html()
529 # these are the actions that are available
530 actions = (
531 ('edit', 'editItemAction'),
532 ('editcsv', 'editCSVAction'),
533 ('new', 'newItemAction'),
534 ('register', 'registerAction'),
535 ('confrego', 'confRegoAction'),
536 ('passrst', 'passResetAction'),
537 ('login', 'loginAction'),
538 ('logout', 'logout_action'),
539 ('search', 'searchAction'),
540 ('retire', 'retireAction'),
541 ('show', 'showAction'),
542 )
543 def handle_action(self):
544 ''' Determine whether there should be an Action called.
546 The action is defined by the form variable :action which
547 identifies the method on this object to call. The actions
548 are defined in the "actions" sequence on this class.
549 '''
550 if self.form.has_key(':action'):
551 action = self.form[':action'].value.lower()
552 elif self.form.has_key('@action'):
553 action = self.form['@action'].value.lower()
554 else:
555 return None
556 try:
557 # get the action, validate it
558 for name, method in self.actions:
559 if name == action:
560 break
561 else:
562 raise ValueError, 'No such action "%s"'%action
563 # call the mapped action
564 getattr(self, method)()
565 except Redirect:
566 raise
567 except Unauthorised:
568 raise
570 def write(self, content):
571 if not self.headers_done:
572 self.header()
573 self.request.wfile.write(content)
575 def header(self, headers=None, response=None):
576 '''Put up the appropriate header.
577 '''
578 if headers is None:
579 headers = {'Content-Type':'text/html'}
580 if response is None:
581 response = self.response_code
583 # update with additional info
584 headers.update(self.additional_headers)
586 if not headers.has_key('Content-Type'):
587 headers['Content-Type'] = 'text/html'
588 self.request.send_response(response)
589 for entry in headers.items():
590 self.request.send_header(*entry)
591 self.request.end_headers()
592 self.headers_done = 1
593 if self.debug:
594 self.headers_sent = headers
596 def set_cookie(self, user):
597 ''' Set up a session cookie for the user and store away the user's
598 login info against the session.
599 '''
600 # TODO generate a much, much stronger session key ;)
601 self.session = binascii.b2a_base64(repr(random.random())).strip()
603 # clean up the base64
604 if self.session[-1] == '=':
605 if self.session[-2] == '=':
606 self.session = self.session[:-2]
607 else:
608 self.session = self.session[:-1]
610 # insert the session in the sessiondb
611 self.db.sessions.set(self.session, user=user, last_use=time.time())
613 # and commit immediately
614 self.db.sessions.commit()
616 # expire us in a long, long time
617 expire = Cookie._getdate(86400*365)
619 # generate the cookie path - make sure it has a trailing '/'
620 self.additional_headers['Set-Cookie'] = \
621 '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
622 expire, self.cookie_path)
624 def make_user_anonymous(self):
625 ''' Make us anonymous
627 This method used to handle non-existence of the 'anonymous'
628 user, but that user is mandatory now.
629 '''
630 self.userid = self.db.user.lookup('anonymous')
631 self.user = 'anonymous'
633 def opendb(self, user):
634 ''' Open the database.
635 '''
636 # open the db if the user has changed
637 if not hasattr(self, 'db') or user != self.db.journaltag:
638 if hasattr(self, 'db'):
639 self.db.close()
640 self.db = self.instance.open(user)
642 #
643 # Actions
644 #
645 def loginAction(self):
646 ''' Attempt to log a user in.
648 Sets up a session for the user which contains the login
649 credentials.
650 '''
651 # we need the username at a minimum
652 if not self.form.has_key('__login_name'):
653 self.error_message.append(_('Username required'))
654 return
656 # get the login info
657 self.user = self.form['__login_name'].value
658 if self.form.has_key('__login_password'):
659 password = self.form['__login_password'].value
660 else:
661 password = ''
663 # make sure the user exists
664 try:
665 self.userid = self.db.user.lookup(self.user)
666 except KeyError:
667 name = self.user
668 self.error_message.append(_('No such user "%(name)s"')%locals())
669 self.make_user_anonymous()
670 return
672 # verify the password
673 if not self.verifyPassword(self.userid, password):
674 self.make_user_anonymous()
675 self.error_message.append(_('Incorrect password'))
676 return
678 # make sure we're allowed to be here
679 if not self.loginPermission():
680 self.make_user_anonymous()
681 self.error_message.append(_("You do not have permission to login"))
682 return
684 # now we're OK, re-open the database for real, using the user
685 self.opendb(self.user)
687 # set the session cookie
688 self.set_cookie(self.user)
690 def verifyPassword(self, userid, password):
691 ''' Verify the password that the user has supplied
692 '''
693 stored = self.db.user.get(self.userid, 'password')
694 if password == stored:
695 return 1
696 if not password and not stored:
697 return 1
698 return 0
700 def loginPermission(self):
701 ''' Determine whether the user has permission to log in.
703 Base behaviour is to check the user has "Web Access".
704 '''
705 if not self.db.security.hasPermission('Web Access', self.userid):
706 return 0
707 return 1
709 def logout_action(self):
710 ''' Make us really anonymous - nuke the cookie too
711 '''
712 # log us out
713 self.make_user_anonymous()
715 # construct the logout cookie
716 now = Cookie._getdate()
717 self.additional_headers['Set-Cookie'] = \
718 '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.cookie_name,
719 now, self.cookie_path)
721 # Let the user know what's going on
722 self.ok_message.append(_('You are logged out'))
724 def registerAction(self):
725 '''Attempt to create a new user based on the contents of the form
726 and then set the cookie.
728 return 1 on successful login
729 '''
730 # parse the props from the form
731 try:
732 props = self.parsePropsFromForm()[0][('user', None)]
733 except (ValueError, KeyError), message:
734 self.error_message.append(_('Error: ') + str(message))
735 return
737 # make sure we're allowed to register
738 if not self.registerPermission(props):
739 raise Unauthorised, _("You do not have permission to register")
741 try:
742 self.db.user.lookup(props['username'])
743 self.error_message.append('Error: A user with the username "%s" '
744 'already exists'%props['username'])
745 return
746 except KeyError:
747 pass
749 # generate the one-time-key and store the props for later
750 otk = ''.join([random.choice(chars) for x in range(32)])
751 for propname, proptype in self.db.user.getprops().items():
752 value = props.get(propname, None)
753 if value is None:
754 pass
755 elif isinstance(proptype, hyperdb.Date):
756 props[propname] = str(value)
757 elif isinstance(proptype, hyperdb.Interval):
758 props[propname] = str(value)
759 elif isinstance(proptype, hyperdb.Password):
760 props[propname] = str(value)
761 props['__time'] = time.time()
762 self.db.otks.set(otk, **props)
764 # send the email
765 tracker_name = self.db.config.TRACKER_NAME
766 subject = 'Complete your registration to %s'%tracker_name
767 body = '''
768 To complete your registration of the user "%(name)s" with %(tracker)s,
769 please visit the following URL:
771 %(url)s?@action=confrego&otk=%(otk)s
772 '''%{'name': props['username'], 'tracker': tracker_name, 'url': self.base,
773 'otk': otk}
774 if not self.sendEmail(props['address'], subject, body):
775 return
777 # commit changes to the database
778 self.db.commit()
780 # redirect to the "you're almost there" page
781 raise Redirect, '%suser?@template=rego_progress'%self.base
783 def sendEmail(self, to, subject, content):
784 # send email to the user's email address
785 message = StringIO.StringIO()
786 writer = MimeWriter.MimeWriter(message)
787 tracker_name = self.db.config.TRACKER_NAME
788 writer.addheader('Subject', encode_header(subject))
789 writer.addheader('To', to)
790 writer.addheader('From', roundupdb.straddr((tracker_name,
791 self.db.config.ADMIN_EMAIL)))
792 writer.addheader('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000",
793 time.gmtime()))
794 # add a uniquely Roundup header to help filtering
795 writer.addheader('X-Roundup-Name', tracker_name)
796 # avoid email loops
797 writer.addheader('X-Roundup-Loop', 'hello')
798 writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
799 body = writer.startbody('text/plain; charset=utf-8')
801 # message body, encoded quoted-printable
802 content = StringIO.StringIO(content)
803 quopri.encode(content, body, 0)
805 if SENDMAILDEBUG:
806 # don't send - just write to a file
807 open(SENDMAILDEBUG, 'a').write('FROM: %s\nTO: %s\n%s\n'%(
808 self.db.config.ADMIN_EMAIL,
809 ', '.join(to),message.getvalue()))
810 else:
811 # now try to send the message
812 try:
813 # send the message as admin so bounces are sent there
814 # instead of to roundup
815 smtp = openSMTPConnection(self.db.config)
816 smtp.sendmail(self.db.config.ADMIN_EMAIL, [to],
817 message.getvalue())
818 except socket.error, value:
819 self.error_message.append("Error: couldn't send email: "
820 "mailhost %s"%value)
821 return 0
822 except smtplib.SMTPException, msg:
823 self.error_message.append("Error: couldn't send email: %s"%msg)
824 return 0
825 return 1
827 def registerPermission(self, props):
828 ''' Determine whether the user has permission to register
830 Base behaviour is to check the user has "Web Registration".
831 '''
832 # registration isn't allowed to supply roles
833 if props.has_key('roles'):
834 return 0
835 if self.db.security.hasPermission('Web Registration', self.userid):
836 return 1
837 return 0
839 def confRegoAction(self):
840 ''' Grab the OTK, use it to load up the new user details
841 '''
842 # pull the rego information out of the otk database
843 otk = self.form['otk'].value
844 props = self.db.otks.getall(otk)
845 for propname, proptype in self.db.user.getprops().items():
846 value = props.get(propname, None)
847 if value is None:
848 pass
849 elif isinstance(proptype, hyperdb.Date):
850 props[propname] = date.Date(value)
851 elif isinstance(proptype, hyperdb.Interval):
852 props[propname] = date.Interval(value)
853 elif isinstance(proptype, hyperdb.Password):
854 props[propname] = password.Password()
855 props[propname].unpack(value)
857 # re-open the database as "admin"
858 if self.user != 'admin':
859 self.opendb('admin')
861 # create the new user
862 cl = self.db.user
863 # XXX we need to make the "default" page be able to display errors!
864 try:
865 props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
866 del props['__time']
867 self.userid = cl.create(**props)
868 # clear the props from the otk database
869 self.db.otks.destroy(otk)
870 self.db.commit()
871 except (ValueError, KeyError), message:
872 self.error_message.append(str(message))
873 return
875 # log the new user in
876 self.user = cl.get(self.userid, 'username')
877 # re-open the database for real, using the user
878 self.opendb(self.user)
880 # if we have a session, update it
881 if hasattr(self, 'session'):
882 self.db.sessions.set(self.session, user=self.user,
883 last_use=time.time())
884 else:
885 # new session cookie
886 self.set_cookie(self.user)
888 # nice message
889 message = _('You are now registered, welcome!')
891 # redirect to the user's page
892 raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
893 self.userid, urllib.quote(message))
895 def passResetAction(self):
896 ''' Handle password reset requests.
898 Presence of either "name" or "address" generate email.
899 Presense of "otk" performs the reset.
900 '''
901 if self.form.has_key('otk'):
902 # pull the rego information out of the otk database
903 otk = self.form['otk'].value
904 uid = self.db.otks.get(otk, 'uid')
905 if uid is None:
906 self.error_message.append('Invalid One Time Key!')
907 return
909 # re-open the database as "admin"
910 if self.user != 'admin':
911 self.opendb('admin')
913 # change the password
914 newpw = password.generatePassword()
916 cl = self.db.user
917 # XXX we need to make the "default" page be able to display errors!
918 try:
919 # set the password
920 cl.set(uid, password=password.Password(newpw))
921 # clear the props from the otk database
922 self.db.otks.destroy(otk)
923 self.db.commit()
924 except (ValueError, KeyError), message:
925 self.error_message.append(str(message))
926 return
928 # user info
929 address = self.db.user.get(uid, 'address')
930 name = self.db.user.get(uid, 'username')
932 # send the email
933 tracker_name = self.db.config.TRACKER_NAME
934 subject = 'Password reset for %s'%tracker_name
935 body = '''
936 The password has been reset for username "%(name)s".
938 Your password is now: %(password)s
939 '''%{'name': name, 'password': newpw}
940 if not self.sendEmail(address, subject, body):
941 return
943 self.ok_message.append('Password reset and email sent to %s'%address)
944 return
946 # no OTK, so now figure the user
947 if self.form.has_key('username'):
948 name = self.form['username'].value
949 try:
950 uid = self.db.user.lookup(name)
951 except KeyError:
952 self.error_message.append('Unknown username')
953 return
954 address = self.db.user.get(uid, 'address')
955 elif self.form.has_key('address'):
956 address = self.form['address'].value
957 uid = uidFromAddress(self.db, ('', address), create=0)
958 if not uid:
959 self.error_message.append('Unknown email address')
960 return
961 name = self.db.user.get(uid, 'username')
962 else:
963 self.error_message.append('You need to specify a username '
964 'or address')
965 return
967 # generate the one-time-key and store the props for later
968 otk = ''.join([random.choice(chars) for x in range(32)])
969 self.db.otks.set(otk, uid=uid, __time=time.time())
971 # send the email
972 tracker_name = self.db.config.TRACKER_NAME
973 subject = 'Confirm reset of password for %s'%tracker_name
974 body = '''
975 Someone, perhaps you, has requested that the password be changed for your
976 username, "%(name)s". If you wish to proceed with the change, please follow
977 the link below:
979 %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
981 You should then receive another email with the new password.
982 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
983 if not self.sendEmail(address, subject, body):
984 return
986 self.ok_message.append('Email sent to %s'%address)
988 def editItemAction(self):
989 ''' Perform an edit of an item in the database.
991 See parsePropsFromForm and _editnodes for special variables
992 '''
993 # parse the props from the form
994 try:
995 props, links = self.parsePropsFromForm()
996 except (ValueError, KeyError), message:
997 self.error_message.append(_('Error: ') + str(message))
998 return
1000 # handle the props
1001 try:
1002 message = self._editnodes(props, links)
1003 except (ValueError, KeyError, IndexError), message:
1004 self.error_message.append(_('Error: ') + str(message))
1005 return
1007 # commit now that all the tricky stuff is done
1008 self.db.commit()
1010 # redirect to the item's edit page
1011 raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
1012 self.classname, self.nodeid, urllib.quote(message),
1013 urllib.quote(self.template))
1015 def editItemPermission(self, props):
1016 ''' Determine whether the user has permission to edit this item.
1018 Base behaviour is to check the user can edit this class. If we're
1019 editing the "user" class, users are allowed to edit their own
1020 details. Unless it's the "roles" property, which requires the
1021 special Permission "Web Roles".
1022 '''
1023 # if this is a user node and the user is editing their own node, then
1024 # we're OK
1025 has = self.db.security.hasPermission
1026 if self.classname == 'user':
1027 # reject if someone's trying to edit "roles" and doesn't have the
1028 # right permission.
1029 if props.has_key('roles') and not has('Web Roles', self.userid,
1030 'user'):
1031 return 0
1032 # if the item being edited is the current user, we're ok
1033 if self.nodeid == self.userid:
1034 return 1
1035 if self.db.security.hasPermission('Edit', self.userid, self.classname):
1036 return 1
1037 return 0
1039 def newItemAction(self):
1040 ''' Add a new item to the database.
1042 This follows the same form as the editItemAction, with the same
1043 special form values.
1044 '''
1045 # parse the props from the form
1046 try:
1047 props, links = self.parsePropsFromForm()
1048 except (ValueError, KeyError), message:
1049 self.error_message.append(_('Error: ') + str(message))
1050 return
1052 # handle the props - edit or create
1053 try:
1054 # when it hits the None element, it'll set self.nodeid
1055 messages = self._editnodes(props, links)
1057 except (ValueError, KeyError, IndexError), message:
1058 # these errors might just be indicative of user dumbness
1059 self.error_message.append(_('Error: ') + str(message))
1060 return
1062 # commit now that all the tricky stuff is done
1063 self.db.commit()
1065 # redirect to the new item's page
1066 raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
1067 self.classname, self.nodeid, urllib.quote(messages),
1068 urllib.quote(self.template))
1070 def newItemPermission(self, props):
1071 ''' Determine whether the user has permission to create (edit) this
1072 item.
1074 Base behaviour is to check the user can edit this class. No
1075 additional property checks are made. Additionally, new user items
1076 may be created if the user has the "Web Registration" Permission.
1077 '''
1078 has = self.db.security.hasPermission
1079 if self.classname == 'user' and has('Web Registration', self.userid,
1080 'user'):
1081 return 1
1082 if has('Edit', self.userid, self.classname):
1083 return 1
1084 return 0
1087 #
1088 # Utility methods for editing
1089 #
1090 def _editnodes(self, all_props, all_links, newids=None):
1091 ''' Use the props in all_props to perform edit and creation, then
1092 use the link specs in all_links to do linking.
1093 '''
1094 # figure dependencies and re-work links
1095 deps = {}
1096 links = {}
1097 for cn, nodeid, propname, vlist in all_links:
1098 if not all_props.has_key((cn, nodeid)):
1099 # link item to link to doesn't (and won't) exist
1100 continue
1101 for value in vlist:
1102 if not all_props.has_key(value):
1103 # link item to link to doesn't (and won't) exist
1104 continue
1105 deps.setdefault((cn, nodeid), []).append(value)
1106 links.setdefault(value, []).append((cn, nodeid, propname))
1108 # figure chained dependencies ordering
1109 order = []
1110 done = {}
1111 # loop detection
1112 change = 0
1113 while len(all_props) != len(done):
1114 for needed in all_props.keys():
1115 if done.has_key(needed):
1116 continue
1117 tlist = deps.get(needed, [])
1118 for target in tlist:
1119 if not done.has_key(target):
1120 break
1121 else:
1122 done[needed] = 1
1123 order.append(needed)
1124 change = 1
1125 if not change:
1126 raise ValueError, 'linking must not loop!'
1128 # now, edit / create
1129 m = []
1130 for needed in order:
1131 props = all_props[needed]
1132 if not props:
1133 # nothing to do
1134 continue
1135 cn, nodeid = needed
1137 if nodeid is not None and int(nodeid) > 0:
1138 # make changes to the node
1139 props = self._changenode(cn, nodeid, props)
1141 # and some nice feedback for the user
1142 if props:
1143 info = ', '.join(props.keys())
1144 m.append('%s %s %s edited ok'%(cn, nodeid, info))
1145 else:
1146 m.append('%s %s - nothing changed'%(cn, nodeid))
1147 else:
1148 assert props
1150 # make a new node
1151 newid = self._createnode(cn, props)
1152 if nodeid is None:
1153 self.nodeid = newid
1154 nodeid = newid
1156 # and some nice feedback for the user
1157 m.append('%s %s created'%(cn, newid))
1159 # fill in new ids in links
1160 if links.has_key(needed):
1161 for linkcn, linkid, linkprop in links[needed]:
1162 props = all_props[(linkcn, linkid)]
1163 cl = self.db.classes[linkcn]
1164 propdef = cl.getprops()[linkprop]
1165 if not props.has_key(linkprop):
1166 if linkid is None or linkid.startswith('-'):
1167 # linking to a new item
1168 if isinstance(propdef, hyperdb.Multilink):
1169 props[linkprop] = [newid]
1170 else:
1171 props[linkprop] = newid
1172 else:
1173 # linking to an existing item
1174 if isinstance(propdef, hyperdb.Multilink):
1175 existing = cl.get(linkid, linkprop)[:]
1176 existing.append(nodeid)
1177 props[linkprop] = existing
1178 else:
1179 props[linkprop] = newid
1181 return '<br>'.join(m)
1183 def _changenode(self, cn, nodeid, props):
1184 ''' change the node based on the contents of the form
1185 '''
1186 # check for permission
1187 if not self.editItemPermission(props):
1188 raise Unauthorised, 'You do not have permission to edit %s'%cn
1190 # make the changes
1191 cl = self.db.classes[cn]
1192 return cl.set(nodeid, **props)
1194 def _createnode(self, cn, props):
1195 ''' create a node based on the contents of the form
1196 '''
1197 # check for permission
1198 if not self.newItemPermission(props):
1199 raise Unauthorised, 'You do not have permission to create %s'%cn
1201 # create the node and return its id
1202 cl = self.db.classes[cn]
1203 return cl.create(**props)
1205 #
1206 # More actions
1207 #
1208 def editCSVAction(self):
1209 ''' Performs an edit of all of a class' items in one go.
1211 The "rows" CGI var defines the CSV-formatted entries for the
1212 class. New nodes are identified by the ID 'X' (or any other
1213 non-existent ID) and removed lines are retired.
1214 '''
1215 # this is per-class only
1216 if not self.editCSVPermission():
1217 self.error_message.append(
1218 _('You do not have permission to edit %s' %self.classname))
1220 # get the CSV module
1221 try:
1222 import csv
1223 except ImportError:
1224 self.error_message.append(_(
1225 'Sorry, you need the csv module to use this function.<br>\n'
1226 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
1227 return
1229 cl = self.db.classes[self.classname]
1230 idlessprops = cl.getprops(protected=0).keys()
1231 idlessprops.sort()
1232 props = ['id'] + idlessprops
1234 # do the edit
1235 rows = self.form['rows'].value.splitlines()
1236 p = csv.parser()
1237 found = {}
1238 line = 0
1239 for row in rows[1:]:
1240 line += 1
1241 values = p.parse(row)
1242 # not a complete row, keep going
1243 if not values: continue
1245 # skip property names header
1246 if values == props:
1247 continue
1249 # extract the nodeid
1250 nodeid, values = values[0], values[1:]
1251 found[nodeid] = 1
1253 # see if the node exists
1254 if cl.hasnode(nodeid):
1255 exists = 1
1256 else:
1257 exists = 0
1259 # confirm correct weight
1260 if len(idlessprops) != len(values):
1261 self.error_message.append(
1262 _('Not enough values on line %(line)s')%{'line':line})
1263 return
1265 # extract the new values
1266 d = {}
1267 for name, value in zip(idlessprops, values):
1268 prop = cl.properties[name]
1269 value = value.strip()
1270 # only add the property if it has a value
1271 if value:
1272 # if it's a multilink, split it
1273 if isinstance(prop, hyperdb.Multilink):
1274 value = value.split(':')
1275 elif isinstance(prop, hyperdb.Password):
1276 value = password.Password(value)
1277 elif isinstance(prop, hyperdb.Interval):
1278 value = date.Interval(value)
1279 elif isinstance(prop, hyperdb.Date):
1280 value = date.Date(value)
1281 elif isinstance(prop, hyperdb.Boolean):
1282 value = value.lower() in ('yes', 'true', 'on', '1')
1283 elif isinstance(prop, hyperdb.Number):
1284 value = float(value)
1285 d[name] = value
1286 elif exists:
1287 # nuke the existing value
1288 if isinstance(prop, hyperdb.Multilink):
1289 d[name] = []
1290 else:
1291 d[name] = None
1293 # perform the edit
1294 if exists:
1295 # edit existing
1296 cl.set(nodeid, **d)
1297 else:
1298 # new node
1299 found[cl.create(**d)] = 1
1301 # retire the removed entries
1302 for nodeid in cl.list():
1303 if not found.has_key(nodeid):
1304 cl.retire(nodeid)
1306 # all OK
1307 self.db.commit()
1309 self.ok_message.append(_('Items edited OK'))
1311 def editCSVPermission(self):
1312 ''' Determine whether the user has permission to edit this class.
1314 Base behaviour is to check the user can edit this class.
1315 '''
1316 if not self.db.security.hasPermission('Edit', self.userid,
1317 self.classname):
1318 return 0
1319 return 1
1321 def searchAction(self, wcre=re.compile(r'[\s,]+')):
1322 ''' Mangle some of the form variables.
1324 Set the form ":filter" variable based on the values of the
1325 filter variables - if they're set to anything other than
1326 "dontcare" then add them to :filter.
1328 Handle the ":queryname" variable and save off the query to
1329 the user's query list.
1331 Split any String query values on whitespace and comma.
1332 '''
1333 # generic edit is per-class only
1334 if not self.searchPermission():
1335 self.error_message.append(
1336 _('You do not have permission to search %s' %self.classname))
1338 # add a faked :filter form variable for each filtering prop
1339 props = self.db.classes[self.classname].getprops()
1340 queryname = ''
1341 for key in self.form.keys():
1342 # special vars
1343 if self.FV_QUERYNAME.match(key):
1344 queryname = self.form[key].value.strip()
1345 continue
1347 if not props.has_key(key):
1348 continue
1349 if isinstance(self.form[key], type([])):
1350 # search for at least one entry which is not empty
1351 for minifield in self.form[key]:
1352 if minifield.value:
1353 break
1354 else:
1355 continue
1356 else:
1357 if not self.form[key].value:
1358 continue
1359 if isinstance(props[key], hyperdb.String):
1360 v = self.form[key].value
1361 l = token.token_split(v)
1362 if len(l) > 1 or l[0] != v:
1363 self.form.value.remove(self.form[key])
1364 # replace the single value with the split list
1365 for v in l:
1366 self.form.value.append(cgi.MiniFieldStorage(key, v))
1368 self.form.value.append(cgi.MiniFieldStorage('@filter', key))
1370 # handle saving the query params
1371 if queryname:
1372 # parse the environment and figure what the query _is_
1373 req = HTMLRequest(self)
1375 # The [1:] strips off the '?' character, it isn't part of the
1376 # query string.
1377 url = req.indexargs_href('', {})[1:]
1379 # handle editing an existing query
1380 try:
1381 qid = self.db.query.lookup(queryname)
1382 self.db.query.set(qid, klass=self.classname, url=url)
1383 except KeyError:
1384 # create a query
1385 qid = self.db.query.create(name=queryname,
1386 klass=self.classname, url=url)
1388 # and add it to the user's query multilink
1389 queries = self.db.user.get(self.userid, 'queries')
1390 queries.append(qid)
1391 self.db.user.set(self.userid, queries=queries)
1393 # commit the query change to the database
1394 self.db.commit()
1396 def searchPermission(self):
1397 ''' Determine whether the user has permission to search this class.
1399 Base behaviour is to check the user can view this class.
1400 '''
1401 if not self.db.security.hasPermission('View', self.userid,
1402 self.classname):
1403 return 0
1404 return 1
1407 def retireAction(self):
1408 ''' Retire the context item.
1409 '''
1410 # if we want to view the index template now, then unset the nodeid
1411 # context info (a special-case for retire actions on the index page)
1412 nodeid = self.nodeid
1413 if self.template == 'index':
1414 self.nodeid = None
1416 # generic edit is per-class only
1417 if not self.retirePermission():
1418 self.error_message.append(
1419 _('You do not have permission to retire %s' %self.classname))
1420 return
1422 # make sure we don't try to retire admin or anonymous
1423 if self.classname == 'user' and \
1424 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1425 self.error_message.append(
1426 _('You may not retire the admin or anonymous user'))
1427 return
1429 # do the retire
1430 self.db.getclass(self.classname).retire(nodeid)
1431 self.db.commit()
1433 self.ok_message.append(
1434 _('%(classname)s %(itemid)s has been retired')%{
1435 'classname': self.classname.capitalize(), 'itemid': nodeid})
1437 def retirePermission(self):
1438 ''' Determine whether the user has permission to retire this class.
1440 Base behaviour is to check the user can edit this class.
1441 '''
1442 if not self.db.security.hasPermission('Edit', self.userid,
1443 self.classname):
1444 return 0
1445 return 1
1448 def showAction(self, typere=re.compile('[@:]type'),
1449 numre=re.compile('[@:]number')):
1450 ''' Show a node of a particular class/id
1451 '''
1452 t = n = ''
1453 for key in self.form.keys():
1454 if typere.match(key):
1455 t = self.form[key].value.strip()
1456 elif numre.match(key):
1457 n = self.form[key].value.strip()
1458 if not t:
1459 raise ValueError, 'Invalid %s number'%t
1460 url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1461 raise Redirect, url
1463 def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1464 ''' Item properties and their values are edited with html FORM
1465 variables and their values. You can:
1467 - Change the value of some property of the current item.
1468 - Create a new item of any class, and edit the new item's
1469 properties,
1470 - Attach newly created items to a multilink property of the
1471 current item.
1472 - Remove items from a multilink property of the current item.
1473 - Specify that some properties are required for the edit
1474 operation to be successful.
1476 In the following, <bracketed> values are variable, "@" may be
1477 either ":" or "@", and other text "required" is fixed.
1479 Most properties are specified as form variables:
1481 <propname>
1482 - property on the current context item
1484 <designator>"@"<propname>
1485 - property on the indicated item (for editing related
1486 information)
1488 Designators name a specific item of a class.
1490 <classname><N>
1492 Name an existing item of class <classname>.
1494 <classname>"-"<N>
1496 Name the <N>th new item of class <classname>. If the form
1497 submission is successful, a new item of <classname> is
1498 created. Within the submitted form, a particular
1499 designator of this form always refers to the same new
1500 item.
1502 Once we have determined the "propname", we look at it to see
1503 if it's special:
1505 @required
1506 The associated form value is a comma-separated list of
1507 property names that must be specified when the form is
1508 submitted for the edit operation to succeed.
1510 When the <designator> is missing, the properties are
1511 for the current context item. When <designator> is
1512 present, they are for the item specified by
1513 <designator>.
1515 The "@required" specifier must come before any of the
1516 properties it refers to are assigned in the form.
1518 @remove@<propname>=id(s) or @add@<propname>=id(s)
1519 The "@add@" and "@remove@" edit actions apply only to
1520 Multilink properties. The form value must be a
1521 comma-separate list of keys for the class specified by
1522 the simple form variable. The listed items are added
1523 to (respectively, removed from) the specified
1524 property.
1526 @link@<propname>=<designator>
1527 If the edit action is "@link@", the simple form
1528 variable must specify a Link or Multilink property.
1529 The form value is a comma-separated list of
1530 designators. The item corresponding to each
1531 designator is linked to the property given by simple
1532 form variable. These are collected up and returned in
1533 all_links.
1535 None of the above (ie. just a simple form value)
1536 The value of the form variable is converted
1537 appropriately, depending on the type of the property.
1539 For a Link('klass') property, the form value is a
1540 single key for 'klass', where the key field is
1541 specified in dbinit.py.
1543 For a Multilink('klass') property, the form value is a
1544 comma-separated list of keys for 'klass', where the
1545 key field is specified in dbinit.py.
1547 Note that for simple-form-variables specifiying Link
1548 and Multilink properties, the linked-to class must
1549 have a key field.
1551 For a String() property specifying a filename, the
1552 file named by the form value is uploaded. This means we
1553 try to set additional properties "filename" and "type" (if
1554 they are valid for the class). Otherwise, the property
1555 is set to the form value.
1557 For Date(), Interval(), Boolean(), and Number()
1558 properties, the form value is converted to the
1559 appropriate
1561 Any of the form variables may be prefixed with a classname or
1562 designator.
1564 Two special form values are supported for backwards
1565 compatibility:
1567 @note
1568 This is equivalent to::
1570 @link@messages=msg-1
1571 @msg-1@content=value
1573 except that in addition, the "author" and "date"
1574 properties of "msg-1" are set to the userid of the
1575 submitter, and the current time, respectively.
1577 @file
1578 This is equivalent to::
1580 @link@files=file-1
1581 @file-1@content=value
1583 The String content value is handled as described above for
1584 file uploads.
1586 If both the "@note" and "@file" form variables are
1587 specified, the action::
1589 @link@msg-1@files=file-1
1591 is also performed.
1593 We also check that FileClass items have a "content" property with
1594 actual content, otherwise we remove them from all_props before
1595 returning.
1597 The return from this method is a dict of
1598 (classname, id): properties
1599 ... this dict _always_ has an entry for the current context,
1600 even if it's empty (ie. a submission for an existing issue that
1601 doesn't result in any changes would return {('issue','123'): {}})
1602 The id may be None, which indicates that an item should be
1603 created.
1604 '''
1605 # some very useful variables
1606 db = self.db
1607 form = self.form
1609 if not hasattr(self, 'FV_SPECIAL'):
1610 # generate the regexp for handling special form values
1611 classes = '|'.join(db.classes.keys())
1612 # specials for parsePropsFromForm
1613 # handle the various forms (see unit tests)
1614 self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1615 self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1617 # these indicate the default class / item
1618 default_cn = self.classname
1619 default_cl = self.db.classes[default_cn]
1620 default_nodeid = self.nodeid
1622 # we'll store info about the individual class/item edit in these
1623 all_required = {} # required props per class/item
1624 all_props = {} # props to set per class/item
1625 got_props = {} # props received per class/item
1626 all_propdef = {} # note - only one entry per class
1627 all_links = [] # as many as are required
1629 # we should always return something, even empty, for the context
1630 all_props[(default_cn, default_nodeid)] = {}
1632 keys = form.keys()
1633 timezone = db.getUserTimezone()
1635 # sentinels for the :note and :file props
1636 have_note = have_file = 0
1638 # extract the usable form labels from the form
1639 matches = []
1640 for key in keys:
1641 m = self.FV_SPECIAL.match(key)
1642 if m:
1643 matches.append((key, m.groupdict()))
1645 # now handle the matches
1646 for key, d in matches:
1647 if d['classname']:
1648 # we got a designator
1649 cn = d['classname']
1650 cl = self.db.classes[cn]
1651 nodeid = d['id']
1652 propname = d['propname']
1653 elif d['note']:
1654 # the special note field
1655 cn = 'msg'
1656 cl = self.db.classes[cn]
1657 nodeid = '-1'
1658 propname = 'content'
1659 all_links.append((default_cn, default_nodeid, 'messages',
1660 [('msg', '-1')]))
1661 have_note = 1
1662 elif d['file']:
1663 # the special file field
1664 cn = 'file'
1665 cl = self.db.classes[cn]
1666 nodeid = '-1'
1667 propname = 'content'
1668 all_links.append((default_cn, default_nodeid, 'files',
1669 [('file', '-1')]))
1670 have_file = 1
1671 else:
1672 # default
1673 cn = default_cn
1674 cl = default_cl
1675 nodeid = default_nodeid
1676 propname = d['propname']
1678 # the thing this value relates to is...
1679 this = (cn, nodeid)
1681 # get more info about the class, and the current set of
1682 # form props for it
1683 if not all_propdef.has_key(cn):
1684 all_propdef[cn] = cl.getprops()
1685 propdef = all_propdef[cn]
1686 if not all_props.has_key(this):
1687 all_props[this] = {}
1688 props = all_props[this]
1689 if not got_props.has_key(this):
1690 got_props[this] = {}
1692 # is this a link command?
1693 if d['link']:
1694 value = []
1695 for entry in extractFormList(form[key]):
1696 m = self.FV_DESIGNATOR.match(entry)
1697 if not m:
1698 raise ValueError, \
1699 'link "%s" value "%s" not a designator'%(key, entry)
1700 value.append((m.group(1), m.group(2)))
1702 # make sure the link property is valid
1703 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1704 not isinstance(propdef[propname], hyperdb.Link)):
1705 raise ValueError, '%s %s is not a link or '\
1706 'multilink property'%(cn, propname)
1708 all_links.append((cn, nodeid, propname, value))
1709 continue
1711 # detect the special ":required" variable
1712 if d['required']:
1713 all_required[this] = extractFormList(form[key])
1714 continue
1716 # see if we're performing a special multilink action
1717 mlaction = 'set'
1718 if d['remove']:
1719 mlaction = 'remove'
1720 elif d['add']:
1721 mlaction = 'add'
1723 # does the property exist?
1724 if not propdef.has_key(propname):
1725 if mlaction != 'set':
1726 raise ValueError, 'You have submitted a %s action for'\
1727 ' the property "%s" which doesn\'t exist'%(mlaction,
1728 propname)
1729 # the form element is probably just something we don't care
1730 # about - ignore it
1731 continue
1732 proptype = propdef[propname]
1734 # Get the form value. This value may be a MiniFieldStorage or a list
1735 # of MiniFieldStorages.
1736 value = form[key]
1738 # handle unpacking of the MiniFieldStorage / list form value
1739 if isinstance(proptype, hyperdb.Multilink):
1740 value = extractFormList(value)
1741 else:
1742 # multiple values are not OK
1743 if isinstance(value, type([])):
1744 raise ValueError, 'You have submitted more than one value'\
1745 ' for the %s property'%propname
1746 # value might be a file upload...
1747 if not hasattr(value, 'filename') or value.filename is None:
1748 # nope, pull out the value and strip it
1749 value = value.value.strip()
1751 # now that we have the props field, we need a teensy little
1752 # extra bit of help for the old :note field...
1753 if d['note'] and value:
1754 props['author'] = self.db.getuid()
1755 props['date'] = date.Date()
1757 # handle by type now
1758 if isinstance(proptype, hyperdb.Password):
1759 if not value:
1760 # ignore empty password values
1761 continue
1762 for key, d in matches:
1763 if d['confirm'] and d['propname'] == propname:
1764 confirm = form[key]
1765 break
1766 else:
1767 raise ValueError, 'Password and confirmation text do '\
1768 'not match'
1769 if isinstance(confirm, type([])):
1770 raise ValueError, 'You have submitted more than one value'\
1771 ' for the %s property'%propname
1772 if value != confirm.value:
1773 raise ValueError, 'Password and confirmation text do '\
1774 'not match'
1775 value = password.Password(value)
1777 elif isinstance(proptype, hyperdb.Link):
1778 # see if it's the "no selection" choice
1779 if value == '-1' or not value:
1780 # if we're creating, just don't include this property
1781 if not nodeid or nodeid.startswith('-'):
1782 continue
1783 value = None
1784 else:
1785 # handle key values
1786 link = proptype.classname
1787 if not num_re.match(value):
1788 try:
1789 value = db.classes[link].lookup(value)
1790 except KeyError:
1791 raise ValueError, _('property "%(propname)s": '
1792 '%(value)s not a %(classname)s')%{
1793 'propname': propname, 'value': value,
1794 'classname': link}
1795 except TypeError, message:
1796 raise ValueError, _('you may only enter ID values '
1797 'for property "%(propname)s": %(message)s')%{
1798 'propname': propname, 'message': message}
1799 elif isinstance(proptype, hyperdb.Multilink):
1800 # perform link class key value lookup if necessary
1801 link = proptype.classname
1802 link_cl = db.classes[link]
1803 l = []
1804 for entry in value:
1805 if not entry: continue
1806 if not num_re.match(entry):
1807 try:
1808 entry = link_cl.lookup(entry)
1809 except KeyError:
1810 raise ValueError, _('property "%(propname)s": '
1811 '"%(value)s" not an entry of %(classname)s')%{
1812 'propname': propname, 'value': entry,
1813 'classname': link}
1814 except TypeError, message:
1815 raise ValueError, _('you may only enter ID values '
1816 'for property "%(propname)s": %(message)s')%{
1817 'propname': propname, 'message': message}
1818 l.append(entry)
1819 l.sort()
1821 # now use that list of ids to modify the multilink
1822 if mlaction == 'set':
1823 value = l
1824 else:
1825 # we're modifying the list - get the current list of ids
1826 if props.has_key(propname):
1827 existing = props[propname]
1828 elif nodeid and not nodeid.startswith('-'):
1829 existing = cl.get(nodeid, propname, [])
1830 else:
1831 existing = []
1833 # now either remove or add
1834 if mlaction == 'remove':
1835 # remove - handle situation where the id isn't in
1836 # the list
1837 for entry in l:
1838 try:
1839 existing.remove(entry)
1840 except ValueError:
1841 raise ValueError, _('property "%(propname)s": '
1842 '"%(value)s" not currently in list')%{
1843 'propname': propname, 'value': entry}
1844 else:
1845 # add - easy, just don't dupe
1846 for entry in l:
1847 if entry not in existing:
1848 existing.append(entry)
1849 value = existing
1850 value.sort()
1852 elif value == '':
1853 # if we're creating, just don't include this property
1854 if not nodeid or nodeid.startswith('-'):
1855 continue
1856 # other types should be None'd if there's no value
1857 value = None
1858 else:
1859 # handle ValueErrors for all these in a similar fashion
1860 try:
1861 if isinstance(proptype, hyperdb.String):
1862 if (hasattr(value, 'filename') and
1863 value.filename is not None):
1864 # skip if the upload is empty
1865 if not value.filename:
1866 continue
1867 # this String is actually a _file_
1868 # try to determine the file content-type
1869 fn = value.filename.split('\\')[-1]
1870 if propdef.has_key('name'):
1871 props['name'] = fn
1872 # use this info as the type/filename properties
1873 if propdef.has_key('type'):
1874 props['type'] = mimetypes.guess_type(fn)[0]
1875 if not props['type']:
1876 props['type'] = "application/octet-stream"
1877 # finally, read the content
1878 value = value.value
1879 else:
1880 # normal String fix the CRLF/CR -> LF stuff
1881 value = fixNewlines(value)
1883 elif isinstance(proptype, hyperdb.Date):
1884 value = date.Date(value, offset=timezone)
1885 elif isinstance(proptype, hyperdb.Interval):
1886 value = date.Interval(value)
1887 elif isinstance(proptype, hyperdb.Boolean):
1888 value = value.lower() in ('yes', 'true', 'on', '1')
1889 elif isinstance(proptype, hyperdb.Number):
1890 value = float(value)
1891 except ValueError, msg:
1892 raise ValueError, _('Error with %s property: %s')%(
1893 propname, msg)
1895 # register that we got this property
1896 if value:
1897 got_props[this][propname] = 1
1899 # get the old value
1900 if nodeid and not nodeid.startswith('-'):
1901 try:
1902 existing = cl.get(nodeid, propname)
1903 except KeyError:
1904 # this might be a new property for which there is
1905 # no existing value
1906 if not propdef.has_key(propname):
1907 raise
1909 # make sure the existing multilink is sorted
1910 if isinstance(proptype, hyperdb.Multilink):
1911 existing.sort()
1913 # "missing" existing values may not be None
1914 if not existing:
1915 if isinstance(proptype, hyperdb.String) and not existing:
1916 # some backends store "missing" Strings as empty strings
1917 existing = None
1918 elif isinstance(proptype, hyperdb.Number) and not existing:
1919 # some backends store "missing" Numbers as 0 :(
1920 existing = 0
1921 elif isinstance(proptype, hyperdb.Boolean) and not existing:
1922 # likewise Booleans
1923 existing = 0
1925 # if changed, set it
1926 if value != existing:
1927 props[propname] = value
1928 else:
1929 # don't bother setting empty/unset values
1930 if value is None:
1931 continue
1932 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1933 continue
1934 elif isinstance(proptype, hyperdb.String) and value == '':
1935 continue
1937 props[propname] = value
1939 # check to see if we need to specially link a file to the note
1940 if have_note and have_file:
1941 all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1943 # see if all the required properties have been supplied
1944 s = []
1945 for thing, required in all_required.items():
1946 # register the values we got
1947 got = got_props.get(thing, {})
1948 for entry in required[:]:
1949 if got.has_key(entry):
1950 required.remove(entry)
1952 # any required values not present?
1953 if not required:
1954 continue
1956 # tell the user to entry the values required
1957 if len(required) > 1:
1958 p = 'properties'
1959 else:
1960 p = 'property'
1961 s.append('Required %s %s %s not supplied'%(thing[0], p,
1962 ', '.join(required)))
1963 if s:
1964 raise ValueError, '\n'.join(s)
1966 # When creating a FileClass node, it should have a non-empty content
1967 # property to be created. When editing a FileClass node, it should
1968 # either have a non-empty content property or no property at all. In
1969 # the latter case, nothing will change.
1970 for (cn, id), props in all_props.items():
1971 if isinstance(self.db.classes[cn], hyperdb.FileClass):
1972 if id == '-1':
1973 if not props.get('content', ''):
1974 del all_props[(cn, id)]
1975 elif props.has_key('content') and not props['content']:
1976 raise ValueError, _('File is empty')
1977 return all_props, all_links
1979 def fixNewlines(text):
1980 ''' Homogenise line endings.
1982 Different web clients send different line ending values, but
1983 other systems (eg. email) don't necessarily handle those line
1984 endings. Our solution is to convert all line endings to LF.
1985 '''
1986 text = text.replace('\r\n', '\n')
1987 return text.replace('\r', '\n')
1989 def extractFormList(value):
1990 ''' Extract a list of values from the form value.
1992 It may be one of:
1993 [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
1994 MiniFieldStorage('value,value,...')
1995 MiniFieldStorage('value')
1996 '''
1997 # multiple values are OK
1998 if isinstance(value, type([])):
1999 # it's a list of MiniFieldStorages - join then into
2000 values = ','.join([i.value.strip() for i in value])
2001 else:
2002 # it's a MiniFieldStorage, but may be a comma-separated list
2003 # of values
2004 values = value.value
2006 value = [i.strip() for i in values.split(',')]
2008 # filter out the empty bits
2009 return filter(None, value)