1 # $Id: client.py,v 1.124 2003-06-24 05:00:43 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 # clean age sessions
321 self.clean_sessions()
322 # make sure we have the session Class
323 sessions = self.db.sessions
325 # look up the user session cookie
326 cookie = Cookie.SimpleCookie(self.env.get('HTTP_COOKIE', ''))
327 user = 'anonymous'
329 # bump the "revision" of the cookie since the format changed
330 if (cookie.has_key(self.cookie_name) and
331 cookie[self.cookie_name].value != 'deleted'):
333 # get the session key from the cookie
334 self.session = cookie[self.cookie_name].value
335 # get the user from the session
336 try:
337 # update the lifetime datestamp
338 sessions.set(self.session, last_use=time.time())
339 sessions.commit()
340 user = sessions.get(self.session, 'user')
341 except KeyError:
342 user = 'anonymous'
344 # sanity check on the user still being valid, getting the userid
345 # at the same time
346 try:
347 self.userid = self.db.user.lookup(user)
348 except (KeyError, TypeError):
349 user = 'anonymous'
351 # make sure the anonymous user is valid if we're using it
352 if user == 'anonymous':
353 self.make_user_anonymous()
354 else:
355 self.user = user
357 # reopen the database as the correct user
358 self.opendb(self.user)
360 def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
361 ''' Determine the context of this page from the URL:
363 The URL path after the instance identifier is examined. The path
364 is generally only one entry long.
366 - if there is no path, then we are in the "home" context.
367 * if the path is "_file", then the additional path entry
368 specifies the filename of a static file we're to serve up
369 from the instance "html" directory. Raises a SendStaticFile
370 exception.
371 - if there is something in the path (eg "issue"), it identifies
372 the tracker class we're to display.
373 - if the path is an item designator (eg "issue123"), then we're
374 to display a specific item.
375 * if the path starts with an item designator and is longer than
376 one entry, then we're assumed to be handling an item of a
377 FileClass, and the extra path information gives the filename
378 that the client is going to label the download with (ie
379 "file123/image.png" is nicer to download than "file123"). This
380 raises a SendFile exception.
382 Both of the "*" types of contexts stop before we bother to
383 determine the template we're going to use. That's because they
384 don't actually use templates.
386 The template used is specified by the :template CGI variable,
387 which defaults to:
389 only classname suplied: "index"
390 full item designator supplied: "item"
392 We set:
393 self.classname - the class to display, can be None
394 self.template - the template to render the current context with
395 self.nodeid - the nodeid of the class we're displaying
396 '''
397 # default the optional variables
398 self.classname = None
399 self.nodeid = None
401 # see if a template or messages are specified
402 template_override = ok_message = error_message = None
403 for key in self.form.keys():
404 if self.FV_TEMPLATE.match(key):
405 template_override = self.form[key].value
406 elif self.FV_OK_MESSAGE.match(key):
407 ok_message = self.form[key].value
408 ok_message = clean_message(ok_message)
409 elif self.FV_ERROR_MESSAGE.match(key):
410 error_message = self.form[key].value
411 error_message = clean_message(error_message)
413 # determine the classname and possibly nodeid
414 path = self.path.split('/')
415 if not path or path[0] in ('', 'home', 'index'):
416 if template_override is not None:
417 self.template = template_override
418 else:
419 self.template = ''
420 return
421 elif path[0] == '_file':
422 raise SendStaticFile, os.path.join(*path[1:])
423 else:
424 self.classname = path[0]
425 if len(path) > 1:
426 # send the file identified by the designator in path[0]
427 raise SendFile, path[0]
429 # we need the db for further context stuff - open it as admin
430 self.opendb('admin')
432 # see if we got a designator
433 m = dre.match(self.classname)
434 if m:
435 self.classname = m.group(1)
436 self.nodeid = m.group(2)
437 if not self.db.getclass(self.classname).hasnode(self.nodeid):
438 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
439 # with a designator, we default to item view
440 self.template = 'item'
441 else:
442 # with only a class, we default to index view
443 self.template = 'index'
445 # make sure the classname is valid
446 try:
447 self.db.getclass(self.classname)
448 except KeyError:
449 raise NotFound, self.classname
451 # see if we have a template override
452 if template_override is not None:
453 self.template = template_override
455 # see if we were passed in a message
456 if ok_message:
457 self.ok_message.append(ok_message)
458 if error_message:
459 self.error_message.append(error_message)
461 def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
462 ''' Serve the file from the content property of the designated item.
463 '''
464 m = dre.match(str(designator))
465 if not m:
466 raise NotFound, str(designator)
467 classname, nodeid = m.group(1), m.group(2)
468 if classname != 'file':
469 raise NotFound, designator
471 # we just want to serve up the file named
472 file = self.db.file
473 self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
474 self.write(file.get(nodeid, 'content'))
476 def serve_static_file(self, file):
477 ims = None
478 # see if there's an if-modified-since...
479 if hasattr(self.request, 'headers'):
480 ims = self.request.headers.getheader('if-modified-since')
481 elif self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
482 # cgi will put the header in the env var
483 ims = self.env['HTTP_IF_MODIFIED_SINCE']
484 filename = os.path.join(self.instance.config.TEMPLATES, file)
485 lmt = os.stat(filename)[stat.ST_MTIME]
486 if ims:
487 ims = rfc822.parsedate(ims)[:6]
488 lmtt = time.gmtime(lmt)[:6]
489 if lmtt <= ims:
490 raise NotModified
492 # we just want to serve up the file named
493 file = str(file)
494 mt = mimetypes.guess_type(file)[0]
495 if not mt:
496 if file.endswith('.css'):
497 mt = 'text/css'
498 else:
499 mt = 'text/plain'
500 self.additional_headers['Content-Type'] = mt
501 self.additional_headers['Last-Modifed'] = rfc822.formatdate(lmt)
502 self.write(open(filename, 'rb').read())
504 def renderContext(self):
505 ''' Return a PageTemplate for the named page
506 '''
507 name = self.classname
508 extension = self.template
509 pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
511 # catch errors so we can handle PT rendering errors more nicely
512 args = {
513 'ok_message': self.ok_message,
514 'error_message': self.error_message
515 }
516 try:
517 # let the template render figure stuff out
518 return pt.render(self, None, None, **args)
519 except NoTemplate, message:
520 return '<strong>%s</strong>'%message
521 except:
522 # everything else
523 return cgitb.pt_html()
525 # these are the actions that are available
526 actions = (
527 ('edit', 'editItemAction'),
528 ('editcsv', 'editCSVAction'),
529 ('new', 'newItemAction'),
530 ('register', 'registerAction'),
531 ('confrego', 'confRegoAction'),
532 ('passrst', 'passResetAction'),
533 ('login', 'loginAction'),
534 ('logout', 'logout_action'),
535 ('search', 'searchAction'),
536 ('retire', 'retireAction'),
537 ('show', 'showAction'),
538 )
539 def handle_action(self):
540 ''' Determine whether there should be an Action called.
542 The action is defined by the form variable :action which
543 identifies the method on this object to call. The actions
544 are defined in the "actions" sequence on this class.
545 '''
546 if self.form.has_key(':action'):
547 action = self.form[':action'].value.lower()
548 elif self.form.has_key('@action'):
549 action = self.form['@action'].value.lower()
550 else:
551 return None
552 try:
553 # get the action, validate it
554 for name, method in self.actions:
555 if name == action:
556 break
557 else:
558 raise ValueError, 'No such action "%s"'%action
559 # call the mapped action
560 getattr(self, method)()
561 except Redirect:
562 raise
563 except Unauthorised:
564 raise
566 def write(self, content):
567 if not self.headers_done:
568 self.header()
569 self.request.wfile.write(content)
571 def header(self, headers=None, response=None):
572 '''Put up the appropriate header.
573 '''
574 if headers is None:
575 headers = {'Content-Type':'text/html'}
576 if response is None:
577 response = self.response_code
579 # update with additional info
580 headers.update(self.additional_headers)
582 if not headers.has_key('Content-Type'):
583 headers['Content-Type'] = 'text/html'
584 self.request.send_response(response)
585 for entry in headers.items():
586 self.request.send_header(*entry)
587 self.request.end_headers()
588 self.headers_done = 1
589 if self.debug:
590 self.headers_sent = headers
592 def set_cookie(self, user):
593 ''' Set up a session cookie for the user and store away the user's
594 login info against the session.
595 '''
596 # TODO generate a much, much stronger session key ;)
597 self.session = binascii.b2a_base64(repr(random.random())).strip()
599 # clean up the base64
600 if self.session[-1] == '=':
601 if self.session[-2] == '=':
602 self.session = self.session[:-2]
603 else:
604 self.session = self.session[:-1]
606 # insert the session in the sessiondb
607 self.db.sessions.set(self.session, user=user, last_use=time.time())
609 # and commit immediately
610 self.db.sessions.commit()
612 # expire us in a long, long time
613 expire = Cookie._getdate(86400*365)
615 # generate the cookie path - make sure it has a trailing '/'
616 self.additional_headers['Set-Cookie'] = \
617 '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
618 expire, self.cookie_path)
620 def make_user_anonymous(self):
621 ''' Make us anonymous
623 This method used to handle non-existence of the 'anonymous'
624 user, but that user is mandatory now.
625 '''
626 self.userid = self.db.user.lookup('anonymous')
627 self.user = 'anonymous'
629 def opendb(self, user):
630 ''' Open the database.
631 '''
632 # open the db if the user has changed
633 if not hasattr(self, 'db') or user != self.db.journaltag:
634 if hasattr(self, 'db'):
635 self.db.close()
636 self.db = self.instance.open(user)
638 #
639 # Actions
640 #
641 def loginAction(self):
642 ''' Attempt to log a user in.
644 Sets up a session for the user which contains the login
645 credentials.
646 '''
647 # we need the username at a minimum
648 if not self.form.has_key('__login_name'):
649 self.error_message.append(_('Username required'))
650 return
652 # get the login info
653 self.user = self.form['__login_name'].value
654 if self.form.has_key('__login_password'):
655 password = self.form['__login_password'].value
656 else:
657 password = ''
659 # make sure the user exists
660 try:
661 self.userid = self.db.user.lookup(self.user)
662 except KeyError:
663 name = self.user
664 self.error_message.append(_('No such user "%(name)s"')%locals())
665 self.make_user_anonymous()
666 return
668 # verify the password
669 if not self.verifyPassword(self.userid, password):
670 self.make_user_anonymous()
671 self.error_message.append(_('Incorrect password'))
672 return
674 # make sure we're allowed to be here
675 if not self.loginPermission():
676 self.make_user_anonymous()
677 self.error_message.append(_("You do not have permission to login"))
678 return
680 # now we're OK, re-open the database for real, using the user
681 self.opendb(self.user)
683 # set the session cookie
684 self.set_cookie(self.user)
686 def verifyPassword(self, userid, password):
687 ''' Verify the password that the user has supplied
688 '''
689 stored = self.db.user.get(self.userid, 'password')
690 if password == stored:
691 return 1
692 if not password and not stored:
693 return 1
694 return 0
696 def loginPermission(self):
697 ''' Determine whether the user has permission to log in.
699 Base behaviour is to check the user has "Web Access".
700 '''
701 if not self.db.security.hasPermission('Web Access', self.userid):
702 return 0
703 return 1
705 def logout_action(self):
706 ''' Make us really anonymous - nuke the cookie too
707 '''
708 # log us out
709 self.make_user_anonymous()
711 # construct the logout cookie
712 now = Cookie._getdate()
713 self.additional_headers['Set-Cookie'] = \
714 '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.cookie_name,
715 now, self.cookie_path)
717 # Let the user know what's going on
718 self.ok_message.append(_('You are logged out'))
720 def registerAction(self):
721 '''Attempt to create a new user based on the contents of the form
722 and then set the cookie.
724 return 1 on successful login
725 '''
726 # parse the props from the form
727 try:
728 props = self.parsePropsFromForm()[0][('user', None)]
729 except (ValueError, KeyError), message:
730 self.error_message.append(_('Error: ') + str(message))
731 return
733 # make sure we're allowed to register
734 if not self.registerPermission(props):
735 raise Unauthorised, _("You do not have permission to register")
737 try:
738 self.db.user.lookup(props['username'])
739 self.error_message.append('Error: A user with the username "%s" '
740 'already exists'%props['username'])
741 return
742 except KeyError:
743 pass
745 # generate the one-time-key and store the props for later
746 otk = ''.join([random.choice(chars) for x in range(32)])
747 for propname, proptype in self.db.user.getprops().items():
748 value = props.get(propname, None)
749 if value is None:
750 pass
751 elif isinstance(proptype, hyperdb.Date):
752 props[propname] = str(value)
753 elif isinstance(proptype, hyperdb.Interval):
754 props[propname] = str(value)
755 elif isinstance(proptype, hyperdb.Password):
756 props[propname] = str(value)
757 props['__time'] = time.time()
758 self.db.otks.set(otk, **props)
760 # send the email
761 tracker_name = self.db.config.TRACKER_NAME
762 subject = 'Complete your registration to %s'%tracker_name
763 body = '''
764 To complete your registration of the user "%(name)s" with %(tracker)s,
765 please visit the following URL:
767 %(url)s?@action=confrego&otk=%(otk)s
768 '''%{'name': props['username'], 'tracker': tracker_name, 'url': self.base,
769 'otk': otk}
770 if not self.sendEmail(props['address'], subject, body):
771 return
773 # commit changes to the database
774 self.db.commit()
776 # redirect to the "you're almost there" page
777 raise Redirect, '%suser?@template=rego_progress'%self.base
779 def sendEmail(self, to, subject, content):
780 # send email to the user's email address
781 message = StringIO.StringIO()
782 writer = MimeWriter.MimeWriter(message)
783 tracker_name = self.db.config.TRACKER_NAME
784 writer.addheader('Subject', encode_header(subject))
785 writer.addheader('To', to)
786 writer.addheader('From', roundupdb.straddr((tracker_name,
787 self.db.config.ADMIN_EMAIL)))
788 writer.addheader('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000",
789 time.gmtime()))
790 # add a uniquely Roundup header to help filtering
791 writer.addheader('X-Roundup-Name', tracker_name)
792 # avoid email loops
793 writer.addheader('X-Roundup-Loop', 'hello')
794 writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
795 body = writer.startbody('text/plain; charset=utf-8')
797 # message body, encoded quoted-printable
798 content = StringIO.StringIO(content)
799 quopri.encode(content, body, 0)
801 if SENDMAILDEBUG:
802 # don't send - just write to a file
803 open(SENDMAILDEBUG, 'a').write('FROM: %s\nTO: %s\n%s\n'%(
804 self.db.config.ADMIN_EMAIL,
805 ', '.join(to),message.getvalue()))
806 else:
807 # now try to send the message
808 try:
809 # send the message as admin so bounces are sent there
810 # instead of to roundup
811 smtp = openSMTPConnection(self.db.config)
812 smtp.sendmail(self.db.config.ADMIN_EMAIL, [to],
813 message.getvalue())
814 except socket.error, value:
815 self.error_message.append("Error: couldn't send email: "
816 "mailhost %s"%value)
817 return 0
818 except smtplib.SMTPException, msg:
819 self.error_message.append("Error: couldn't send email: %s"%msg)
820 return 0
821 return 1
823 def registerPermission(self, props):
824 ''' Determine whether the user has permission to register
826 Base behaviour is to check the user has "Web Registration".
827 '''
828 # registration isn't allowed to supply roles
829 if props.has_key('roles'):
830 return 0
831 if self.db.security.hasPermission('Web Registration', self.userid):
832 return 1
833 return 0
835 def confRegoAction(self):
836 ''' Grab the OTK, use it to load up the new user details
837 '''
838 # pull the rego information out of the otk database
839 otk = self.form['otk'].value
840 props = self.db.otks.getall(otk)
841 for propname, proptype in self.db.user.getprops().items():
842 value = props.get(propname, None)
843 if value is None:
844 pass
845 elif isinstance(proptype, hyperdb.Date):
846 props[propname] = date.Date(value)
847 elif isinstance(proptype, hyperdb.Interval):
848 props[propname] = date.Interval(value)
849 elif isinstance(proptype, hyperdb.Password):
850 props[propname] = password.Password()
851 props[propname].unpack(value)
853 # re-open the database as "admin"
854 if self.user != 'admin':
855 self.opendb('admin')
857 # create the new user
858 cl = self.db.user
859 # XXX we need to make the "default" page be able to display errors!
860 try:
861 props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
862 del props['__time']
863 self.userid = cl.create(**props)
864 # clear the props from the otk database
865 self.db.otks.destroy(otk)
866 self.db.commit()
867 except (ValueError, KeyError), message:
868 self.error_message.append(str(message))
869 return
871 # log the new user in
872 self.user = cl.get(self.userid, 'username')
873 # re-open the database for real, using the user
874 self.opendb(self.user)
876 # if we have a session, update it
877 if hasattr(self, 'session'):
878 self.db.sessions.set(self.session, user=self.user,
879 last_use=time.time())
880 else:
881 # new session cookie
882 self.set_cookie(self.user)
884 # nice message
885 message = _('You are now registered, welcome!')
887 # redirect to the user's page
888 raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
889 self.userid, urllib.quote(message))
891 def passResetAction(self):
892 ''' Handle password reset requests.
894 Presence of either "name" or "address" generate email.
895 Presense of "otk" performs the reset.
896 '''
897 if self.form.has_key('otk'):
898 # pull the rego information out of the otk database
899 otk = self.form['otk'].value
900 uid = self.db.otks.get(otk, 'uid')
901 if uid is None:
902 self.error_message.append('Invalid One Time Key!')
903 return
905 # re-open the database as "admin"
906 if self.user != 'admin':
907 self.opendb('admin')
909 # change the password
910 newpw = password.generatePassword()
912 cl = self.db.user
913 # XXX we need to make the "default" page be able to display errors!
914 try:
915 # set the password
916 cl.set(uid, password=password.Password(newpw))
917 # clear the props from the otk database
918 self.db.otks.destroy(otk)
919 self.db.commit()
920 except (ValueError, KeyError), message:
921 self.error_message.append(str(message))
922 return
924 # user info
925 address = self.db.user.get(uid, 'address')
926 name = self.db.user.get(uid, 'username')
928 # send the email
929 tracker_name = self.db.config.TRACKER_NAME
930 subject = 'Password reset for %s'%tracker_name
931 body = '''
932 The password has been reset for username "%(name)s".
934 Your password is now: %(password)s
935 '''%{'name': name, 'password': newpw}
936 if not self.sendEmail(address, subject, body):
937 return
939 self.ok_message.append('Password reset and email sent to %s'%address)
940 return
942 # no OTK, so now figure the user
943 if self.form.has_key('username'):
944 name = self.form['username'].value
945 try:
946 uid = self.db.user.lookup(name)
947 except KeyError:
948 self.error_message.append('Unknown username')
949 return
950 address = self.db.user.get(uid, 'address')
951 elif self.form.has_key('address'):
952 address = self.form['address'].value
953 uid = uidFromAddress(self.db, ('', address), create=0)
954 if not uid:
955 self.error_message.append('Unknown email address')
956 return
957 name = self.db.user.get(uid, 'username')
958 else:
959 self.error_message.append('You need to specify a username '
960 'or address')
961 return
963 # generate the one-time-key and store the props for later
964 otk = ''.join([random.choice(chars) for x in range(32)])
965 self.db.otks.set(otk, uid=uid, __time=time.time())
967 # send the email
968 tracker_name = self.db.config.TRACKER_NAME
969 subject = 'Confirm reset of password for %s'%tracker_name
970 body = '''
971 Someone, perhaps you, has requested that the password be changed for your
972 username, "%(name)s". If you wish to proceed with the change, please follow
973 the link below:
975 %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
977 You should then receive another email with the new password.
978 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
979 if not self.sendEmail(address, subject, body):
980 return
982 self.ok_message.append('Email sent to %s'%address)
984 def editItemAction(self):
985 ''' Perform an edit of an item in the database.
987 See parsePropsFromForm and _editnodes for special variables
988 '''
989 # parse the props from the form
990 try:
991 props, links = self.parsePropsFromForm()
992 except (ValueError, KeyError), message:
993 self.error_message.append(_('Error: ') + str(message))
994 return
996 # handle the props
997 try:
998 message = self._editnodes(props, links)
999 except (ValueError, KeyError, IndexError), message:
1000 self.error_message.append(_('Error: ') + str(message))
1001 return
1003 # commit now that all the tricky stuff is done
1004 self.db.commit()
1006 # redirect to the item's edit page
1007 raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
1008 self.classname, self.nodeid, urllib.quote(message),
1009 urllib.quote(self.template))
1011 def editItemPermission(self, props):
1012 ''' Determine whether the user has permission to edit this item.
1014 Base behaviour is to check the user can edit this class. If we're
1015 editing the "user" class, users are allowed to edit their own
1016 details. Unless it's the "roles" property, which requires the
1017 special Permission "Web Roles".
1018 '''
1019 # if this is a user node and the user is editing their own node, then
1020 # we're OK
1021 has = self.db.security.hasPermission
1022 if self.classname == 'user':
1023 # reject if someone's trying to edit "roles" and doesn't have the
1024 # right permission.
1025 if props.has_key('roles') and not has('Web Roles', self.userid,
1026 'user'):
1027 return 0
1028 # if the item being edited is the current user, we're ok
1029 if self.nodeid == self.userid:
1030 return 1
1031 if self.db.security.hasPermission('Edit', self.userid, self.classname):
1032 return 1
1033 return 0
1035 def newItemAction(self):
1036 ''' Add a new item to the database.
1038 This follows the same form as the editItemAction, with the same
1039 special form values.
1040 '''
1041 # parse the props from the form
1042 try:
1043 props, links = self.parsePropsFromForm()
1044 except (ValueError, KeyError), message:
1045 self.error_message.append(_('Error: ') + str(message))
1046 return
1048 # handle the props - edit or create
1049 try:
1050 # when it hits the None element, it'll set self.nodeid
1051 messages = self._editnodes(props, links)
1053 except (ValueError, KeyError, IndexError), message:
1054 # these errors might just be indicative of user dumbness
1055 self.error_message.append(_('Error: ') + str(message))
1056 return
1058 # commit now that all the tricky stuff is done
1059 self.db.commit()
1061 # redirect to the new item's page
1062 raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
1063 self.classname, self.nodeid, urllib.quote(messages),
1064 urllib.quote(self.template))
1066 def newItemPermission(self, props):
1067 ''' Determine whether the user has permission to create (edit) this
1068 item.
1070 Base behaviour is to check the user can edit this class. No
1071 additional property checks are made. Additionally, new user items
1072 may be created if the user has the "Web Registration" Permission.
1073 '''
1074 has = self.db.security.hasPermission
1075 if self.classname == 'user' and has('Web Registration', self.userid,
1076 'user'):
1077 return 1
1078 if has('Edit', self.userid, self.classname):
1079 return 1
1080 return 0
1083 #
1084 # Utility methods for editing
1085 #
1086 def _editnodes(self, all_props, all_links, newids=None):
1087 ''' Use the props in all_props to perform edit and creation, then
1088 use the link specs in all_links to do linking.
1089 '''
1090 # figure dependencies and re-work links
1091 deps = {}
1092 links = {}
1093 for cn, nodeid, propname, vlist in all_links:
1094 if not all_props.has_key((cn, nodeid)):
1095 # link item to link to doesn't (and won't) exist
1096 continue
1097 for value in vlist:
1098 if not all_props.has_key(value):
1099 # link item to link to doesn't (and won't) exist
1100 continue
1101 deps.setdefault((cn, nodeid), []).append(value)
1102 links.setdefault(value, []).append((cn, nodeid, propname))
1104 # figure chained dependencies ordering
1105 order = []
1106 done = {}
1107 # loop detection
1108 change = 0
1109 while len(all_props) != len(done):
1110 for needed in all_props.keys():
1111 if done.has_key(needed):
1112 continue
1113 tlist = deps.get(needed, [])
1114 for target in tlist:
1115 if not done.has_key(target):
1116 break
1117 else:
1118 done[needed] = 1
1119 order.append(needed)
1120 change = 1
1121 if not change:
1122 raise ValueError, 'linking must not loop!'
1124 # now, edit / create
1125 m = []
1126 for needed in order:
1127 props = all_props[needed]
1128 if not props:
1129 # nothing to do
1130 continue
1131 cn, nodeid = needed
1133 if nodeid is not None and int(nodeid) > 0:
1134 # make changes to the node
1135 props = self._changenode(cn, nodeid, props)
1137 # and some nice feedback for the user
1138 if props:
1139 info = ', '.join(props.keys())
1140 m.append('%s %s %s edited ok'%(cn, nodeid, info))
1141 else:
1142 m.append('%s %s - nothing changed'%(cn, nodeid))
1143 else:
1144 assert props
1146 # make a new node
1147 newid = self._createnode(cn, props)
1148 if nodeid is None:
1149 self.nodeid = newid
1150 nodeid = newid
1152 # and some nice feedback for the user
1153 m.append('%s %s created'%(cn, newid))
1155 # fill in new ids in links
1156 if links.has_key(needed):
1157 for linkcn, linkid, linkprop in links[needed]:
1158 props = all_props[(linkcn, linkid)]
1159 cl = self.db.classes[linkcn]
1160 propdef = cl.getprops()[linkprop]
1161 if not props.has_key(linkprop):
1162 if linkid is None or linkid.startswith('-'):
1163 # linking to a new item
1164 if isinstance(propdef, hyperdb.Multilink):
1165 props[linkprop] = [newid]
1166 else:
1167 props[linkprop] = newid
1168 else:
1169 # linking to an existing item
1170 if isinstance(propdef, hyperdb.Multilink):
1171 existing = cl.get(linkid, linkprop)[:]
1172 existing.append(nodeid)
1173 props[linkprop] = existing
1174 else:
1175 props[linkprop] = newid
1177 return '<br>'.join(m)
1179 def _changenode(self, cn, nodeid, props):
1180 ''' change the node based on the contents of the form
1181 '''
1182 # check for permission
1183 if not self.editItemPermission(props):
1184 raise Unauthorised, 'You do not have permission to edit %s'%cn
1186 # make the changes
1187 cl = self.db.classes[cn]
1188 return cl.set(nodeid, **props)
1190 def _createnode(self, cn, props):
1191 ''' create a node based on the contents of the form
1192 '''
1193 # check for permission
1194 if not self.newItemPermission(props):
1195 raise Unauthorised, 'You do not have permission to create %s'%cn
1197 # create the node and return its id
1198 cl = self.db.classes[cn]
1199 return cl.create(**props)
1201 #
1202 # More actions
1203 #
1204 def editCSVAction(self):
1205 ''' Performs an edit of all of a class' items in one go.
1207 The "rows" CGI var defines the CSV-formatted entries for the
1208 class. New nodes are identified by the ID 'X' (or any other
1209 non-existent ID) and removed lines are retired.
1210 '''
1211 # this is per-class only
1212 if not self.editCSVPermission():
1213 self.error_message.append(
1214 _('You do not have permission to edit %s' %self.classname))
1216 # get the CSV module
1217 try:
1218 import csv
1219 except ImportError:
1220 self.error_message.append(_(
1221 'Sorry, you need the csv module to use this function.<br>\n'
1222 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
1223 return
1225 cl = self.db.classes[self.classname]
1226 idlessprops = cl.getprops(protected=0).keys()
1227 idlessprops.sort()
1228 props = ['id'] + idlessprops
1230 # do the edit
1231 rows = self.form['rows'].value.splitlines()
1232 p = csv.parser()
1233 found = {}
1234 line = 0
1235 for row in rows[1:]:
1236 line += 1
1237 values = p.parse(row)
1238 # not a complete row, keep going
1239 if not values: continue
1241 # skip property names header
1242 if values == props:
1243 continue
1245 # extract the nodeid
1246 nodeid, values = values[0], values[1:]
1247 found[nodeid] = 1
1249 # see if the node exists
1250 if cl.hasnode(nodeid):
1251 exists = 1
1252 else:
1253 exists = 0
1255 # confirm correct weight
1256 if len(idlessprops) != len(values):
1257 self.error_message.append(
1258 _('Not enough values on line %(line)s')%{'line':line})
1259 return
1261 # extract the new values
1262 d = {}
1263 for name, value in zip(idlessprops, values):
1264 prop = cl.properties[name]
1265 value = value.strip()
1266 # only add the property if it has a value
1267 if value:
1268 # if it's a multilink, split it
1269 if isinstance(prop, hyperdb.Multilink):
1270 value = value.split(':')
1271 d[name] = value
1272 elif exists:
1273 # nuke the existing value
1274 if isinstance(prop, hyperdb.Multilink):
1275 d[name] = []
1276 else:
1277 d[name] = None
1279 # perform the edit
1280 if exists:
1281 # edit existing
1282 cl.set(nodeid, **d)
1283 else:
1284 # new node
1285 found[cl.create(**d)] = 1
1287 # retire the removed entries
1288 for nodeid in cl.list():
1289 if not found.has_key(nodeid):
1290 cl.retire(nodeid)
1292 # all OK
1293 self.db.commit()
1295 self.ok_message.append(_('Items edited OK'))
1297 def editCSVPermission(self):
1298 ''' Determine whether the user has permission to edit this class.
1300 Base behaviour is to check the user can edit this class.
1301 '''
1302 if not self.db.security.hasPermission('Edit', self.userid,
1303 self.classname):
1304 return 0
1305 return 1
1307 def searchAction(self, wcre=re.compile(r'[\s,]+')):
1308 ''' Mangle some of the form variables.
1310 Set the form ":filter" variable based on the values of the
1311 filter variables - if they're set to anything other than
1312 "dontcare" then add them to :filter.
1314 Handle the ":queryname" variable and save off the query to
1315 the user's query list.
1317 Split any String query values on whitespace and comma.
1318 '''
1319 # generic edit is per-class only
1320 if not self.searchPermission():
1321 self.error_message.append(
1322 _('You do not have permission to search %s' %self.classname))
1324 # add a faked :filter form variable for each filtering prop
1325 props = self.db.classes[self.classname].getprops()
1326 queryname = ''
1327 for key in self.form.keys():
1328 # special vars
1329 if self.FV_QUERYNAME.match(key):
1330 queryname = self.form[key].value.strip()
1331 continue
1333 if not props.has_key(key):
1334 continue
1335 if isinstance(self.form[key], type([])):
1336 # search for at least one entry which is not empty
1337 for minifield in self.form[key]:
1338 if minifield.value:
1339 break
1340 else:
1341 continue
1342 else:
1343 if not self.form[key].value:
1344 continue
1345 if isinstance(props[key], hyperdb.String):
1346 v = self.form[key].value
1347 l = token.token_split(v)
1348 if len(l) > 1 or l[0] != v:
1349 self.form.value.remove(self.form[key])
1350 # replace the single value with the split list
1351 for v in l:
1352 self.form.value.append(cgi.MiniFieldStorage(key, v))
1354 self.form.value.append(cgi.MiniFieldStorage('@filter', key))
1356 # handle saving the query params
1357 if queryname:
1358 # parse the environment and figure what the query _is_
1359 req = HTMLRequest(self)
1360 url = req.indexargs_href('', {})
1362 # handle editing an existing query
1363 try:
1364 qid = self.db.query.lookup(queryname)
1365 self.db.query.set(qid, klass=self.classname, url=url)
1366 except KeyError:
1367 # create a query
1368 qid = self.db.query.create(name=queryname,
1369 klass=self.classname, url=url)
1371 # and add it to the user's query multilink
1372 queries = self.db.user.get(self.userid, 'queries')
1373 queries.append(qid)
1374 self.db.user.set(self.userid, queries=queries)
1376 # commit the query change to the database
1377 self.db.commit()
1379 def searchPermission(self):
1380 ''' Determine whether the user has permission to search this class.
1382 Base behaviour is to check the user can view this class.
1383 '''
1384 if not self.db.security.hasPermission('View', self.userid,
1385 self.classname):
1386 return 0
1387 return 1
1390 def retireAction(self):
1391 ''' Retire the context item.
1392 '''
1393 # if we want to view the index template now, then unset the nodeid
1394 # context info (a special-case for retire actions on the index page)
1395 nodeid = self.nodeid
1396 if self.template == 'index':
1397 self.nodeid = None
1399 # generic edit is per-class only
1400 if not self.retirePermission():
1401 self.error_message.append(
1402 _('You do not have permission to retire %s' %self.classname))
1403 return
1405 # make sure we don't try to retire admin or anonymous
1406 if self.classname == 'user' and \
1407 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1408 self.error_message.append(
1409 _('You may not retire the admin or anonymous user'))
1410 return
1412 # do the retire
1413 self.db.getclass(self.classname).retire(nodeid)
1414 self.db.commit()
1416 self.ok_message.append(
1417 _('%(classname)s %(itemid)s has been retired')%{
1418 'classname': self.classname.capitalize(), 'itemid': nodeid})
1420 def retirePermission(self):
1421 ''' Determine whether the user has permission to retire this class.
1423 Base behaviour is to check the user can edit this class.
1424 '''
1425 if not self.db.security.hasPermission('Edit', self.userid,
1426 self.classname):
1427 return 0
1428 return 1
1431 def showAction(self, typere=re.compile('[@:]type'),
1432 numre=re.compile('[@:]number')):
1433 ''' Show a node of a particular class/id
1434 '''
1435 t = n = ''
1436 for key in self.form.keys():
1437 if typere.match(key):
1438 t = self.form[key].value.strip()
1439 elif numre.match(key):
1440 n = self.form[key].value.strip()
1441 if not t:
1442 raise ValueError, 'Invalid %s number'%t
1443 url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1444 raise Redirect, url
1446 def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1447 ''' Item properties and their values are edited with html FORM
1448 variables and their values. You can:
1450 - Change the value of some property of the current item.
1451 - Create a new item of any class, and edit the new item's
1452 properties,
1453 - Attach newly created items to a multilink property of the
1454 current item.
1455 - Remove items from a multilink property of the current item.
1456 - Specify that some properties are required for the edit
1457 operation to be successful.
1459 In the following, <bracketed> values are variable, "@" may be
1460 either ":" or "@", and other text "required" is fixed.
1462 Most properties are specified as form variables:
1464 <propname>
1465 - property on the current context item
1467 <designator>"@"<propname>
1468 - property on the indicated item (for editing related
1469 information)
1471 Designators name a specific item of a class.
1473 <classname><N>
1475 Name an existing item of class <classname>.
1477 <classname>"-"<N>
1479 Name the <N>th new item of class <classname>. If the form
1480 submission is successful, a new item of <classname> is
1481 created. Within the submitted form, a particular
1482 designator of this form always refers to the same new
1483 item.
1485 Once we have determined the "propname", we look at it to see
1486 if it's special:
1488 @required
1489 The associated form value is a comma-separated list of
1490 property names that must be specified when the form is
1491 submitted for the edit operation to succeed.
1493 When the <designator> is missing, the properties are
1494 for the current context item. When <designator> is
1495 present, they are for the item specified by
1496 <designator>.
1498 The "@required" specifier must come before any of the
1499 properties it refers to are assigned in the form.
1501 @remove@<propname>=id(s) or @add@<propname>=id(s)
1502 The "@add@" and "@remove@" edit actions apply only to
1503 Multilink properties. The form value must be a
1504 comma-separate list of keys for the class specified by
1505 the simple form variable. The listed items are added
1506 to (respectively, removed from) the specified
1507 property.
1509 @link@<propname>=<designator>
1510 If the edit action is "@link@", the simple form
1511 variable must specify a Link or Multilink property.
1512 The form value is a comma-separated list of
1513 designators. The item corresponding to each
1514 designator is linked to the property given by simple
1515 form variable. These are collected up and returned in
1516 all_links.
1518 None of the above (ie. just a simple form value)
1519 The value of the form variable is converted
1520 appropriately, depending on the type of the property.
1522 For a Link('klass') property, the form value is a
1523 single key for 'klass', where the key field is
1524 specified in dbinit.py.
1526 For a Multilink('klass') property, the form value is a
1527 comma-separated list of keys for 'klass', where the
1528 key field is specified in dbinit.py.
1530 Note that for simple-form-variables specifiying Link
1531 and Multilink properties, the linked-to class must
1532 have a key field.
1534 For a String() property specifying a filename, the
1535 file named by the form value is uploaded. This means we
1536 try to set additional properties "filename" and "type" (if
1537 they are valid for the class). Otherwise, the property
1538 is set to the form value.
1540 For Date(), Interval(), Boolean(), and Number()
1541 properties, the form value is converted to the
1542 appropriate
1544 Any of the form variables may be prefixed with a classname or
1545 designator.
1547 Two special form values are supported for backwards
1548 compatibility:
1550 @note
1551 This is equivalent to::
1553 @link@messages=msg-1
1554 @msg-1@content=value
1556 except that in addition, the "author" and "date"
1557 properties of "msg-1" are set to the userid of the
1558 submitter, and the current time, respectively.
1560 @file
1561 This is equivalent to::
1563 @link@files=file-1
1564 @file-1@content=value
1566 The String content value is handled as described above for
1567 file uploads.
1569 If both the "@note" and "@file" form variables are
1570 specified, the action::
1572 @link@msg-1@files=file-1
1574 is also performed.
1576 We also check that FileClass items have a "content" property with
1577 actual content, otherwise we remove them from all_props before
1578 returning.
1580 The return from this method is a dict of
1581 (classname, id): properties
1582 ... this dict _always_ has an entry for the current context,
1583 even if it's empty (ie. a submission for an existing issue that
1584 doesn't result in any changes would return {('issue','123'): {}})
1585 The id may be None, which indicates that an item should be
1586 created.
1587 '''
1588 # some very useful variables
1589 db = self.db
1590 form = self.form
1592 if not hasattr(self, 'FV_SPECIAL'):
1593 # generate the regexp for handling special form values
1594 classes = '|'.join(db.classes.keys())
1595 # specials for parsePropsFromForm
1596 # handle the various forms (see unit tests)
1597 self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1598 self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1600 # these indicate the default class / item
1601 default_cn = self.classname
1602 default_cl = self.db.classes[default_cn]
1603 default_nodeid = self.nodeid
1605 # we'll store info about the individual class/item edit in these
1606 all_required = {} # required props per class/item
1607 all_props = {} # props to set per class/item
1608 got_props = {} # props received per class/item
1609 all_propdef = {} # note - only one entry per class
1610 all_links = [] # as many as are required
1612 # we should always return something, even empty, for the context
1613 all_props[(default_cn, default_nodeid)] = {}
1615 keys = form.keys()
1616 timezone = db.getUserTimezone()
1618 # sentinels for the :note and :file props
1619 have_note = have_file = 0
1621 # extract the usable form labels from the form
1622 matches = []
1623 for key in keys:
1624 m = self.FV_SPECIAL.match(key)
1625 if m:
1626 matches.append((key, m.groupdict()))
1628 # now handle the matches
1629 for key, d in matches:
1630 if d['classname']:
1631 # we got a designator
1632 cn = d['classname']
1633 cl = self.db.classes[cn]
1634 nodeid = d['id']
1635 propname = d['propname']
1636 elif d['note']:
1637 # the special note field
1638 cn = 'msg'
1639 cl = self.db.classes[cn]
1640 nodeid = '-1'
1641 propname = 'content'
1642 all_links.append((default_cn, default_nodeid, 'messages',
1643 [('msg', '-1')]))
1644 have_note = 1
1645 elif d['file']:
1646 # the special file field
1647 cn = 'file'
1648 cl = self.db.classes[cn]
1649 nodeid = '-1'
1650 propname = 'content'
1651 all_links.append((default_cn, default_nodeid, 'files',
1652 [('file', '-1')]))
1653 have_file = 1
1654 else:
1655 # default
1656 cn = default_cn
1657 cl = default_cl
1658 nodeid = default_nodeid
1659 propname = d['propname']
1661 # the thing this value relates to is...
1662 this = (cn, nodeid)
1664 # get more info about the class, and the current set of
1665 # form props for it
1666 if not all_propdef.has_key(cn):
1667 all_propdef[cn] = cl.getprops()
1668 propdef = all_propdef[cn]
1669 if not all_props.has_key(this):
1670 all_props[this] = {}
1671 props = all_props[this]
1672 if not got_props.has_key(this):
1673 got_props[this] = {}
1675 # is this a link command?
1676 if d['link']:
1677 value = []
1678 for entry in extractFormList(form[key]):
1679 m = self.FV_DESIGNATOR.match(entry)
1680 if not m:
1681 raise ValueError, \
1682 'link "%s" value "%s" not a designator'%(key, entry)
1683 value.append((m.group(1), m.group(2)))
1685 # make sure the link property is valid
1686 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1687 not isinstance(propdef[propname], hyperdb.Link)):
1688 raise ValueError, '%s %s is not a link or '\
1689 'multilink property'%(cn, propname)
1691 all_links.append((cn, nodeid, propname, value))
1692 continue
1694 # detect the special ":required" variable
1695 if d['required']:
1696 all_required[this] = extractFormList(form[key])
1697 continue
1699 # see if we're performing a special multilink action
1700 mlaction = 'set'
1701 if d['remove']:
1702 mlaction = 'remove'
1703 elif d['add']:
1704 mlaction = 'add'
1706 # does the property exist?
1707 if not propdef.has_key(propname):
1708 if mlaction != 'set':
1709 raise ValueError, 'You have submitted a %s action for'\
1710 ' the property "%s" which doesn\'t exist'%(mlaction,
1711 propname)
1712 # the form element is probably just something we don't care
1713 # about - ignore it
1714 continue
1715 proptype = propdef[propname]
1717 # Get the form value. This value may be a MiniFieldStorage or a list
1718 # of MiniFieldStorages.
1719 value = form[key]
1721 # handle unpacking of the MiniFieldStorage / list form value
1722 if isinstance(proptype, hyperdb.Multilink):
1723 value = extractFormList(value)
1724 else:
1725 # multiple values are not OK
1726 if isinstance(value, type([])):
1727 raise ValueError, 'You have submitted more than one value'\
1728 ' for the %s property'%propname
1729 # value might be a file upload...
1730 if not hasattr(value, 'filename') or value.filename is None:
1731 # nope, pull out the value and strip it
1732 value = value.value.strip()
1734 # now that we have the props field, we need a teensy little
1735 # extra bit of help for the old :note field...
1736 if d['note'] and value:
1737 props['author'] = self.db.getuid()
1738 props['date'] = date.Date()
1740 # handle by type now
1741 if isinstance(proptype, hyperdb.Password):
1742 if not value:
1743 # ignore empty password values
1744 continue
1745 for key, d in matches:
1746 if d['confirm'] and d['propname'] == propname:
1747 confirm = form[key]
1748 break
1749 else:
1750 raise ValueError, 'Password and confirmation text do '\
1751 'not match'
1752 if isinstance(confirm, type([])):
1753 raise ValueError, 'You have submitted more than one value'\
1754 ' for the %s property'%propname
1755 if value != confirm.value:
1756 raise ValueError, 'Password and confirmation text do '\
1757 'not match'
1758 value = password.Password(value)
1760 elif isinstance(proptype, hyperdb.Link):
1761 # see if it's the "no selection" choice
1762 if value == '-1' or not value:
1763 # if we're creating, just don't include this property
1764 if not nodeid or nodeid.startswith('-'):
1765 continue
1766 value = None
1767 else:
1768 # handle key values
1769 link = proptype.classname
1770 if not num_re.match(value):
1771 try:
1772 value = db.classes[link].lookup(value)
1773 except KeyError:
1774 raise ValueError, _('property "%(propname)s": '
1775 '%(value)s not a %(classname)s')%{
1776 'propname': propname, 'value': value,
1777 'classname': link}
1778 except TypeError, message:
1779 raise ValueError, _('you may only enter ID values '
1780 'for property "%(propname)s": %(message)s')%{
1781 'propname': propname, 'message': message}
1782 elif isinstance(proptype, hyperdb.Multilink):
1783 # perform link class key value lookup if necessary
1784 link = proptype.classname
1785 link_cl = db.classes[link]
1786 l = []
1787 for entry in value:
1788 if not entry: continue
1789 if not num_re.match(entry):
1790 try:
1791 entry = link_cl.lookup(entry)
1792 except KeyError:
1793 raise ValueError, _('property "%(propname)s": '
1794 '"%(value)s" not an entry of %(classname)s')%{
1795 'propname': propname, 'value': entry,
1796 'classname': link}
1797 except TypeError, message:
1798 raise ValueError, _('you may only enter ID values '
1799 'for property "%(propname)s": %(message)s')%{
1800 'propname': propname, 'message': message}
1801 l.append(entry)
1802 l.sort()
1804 # now use that list of ids to modify the multilink
1805 if mlaction == 'set':
1806 value = l
1807 else:
1808 # we're modifying the list - get the current list of ids
1809 if props.has_key(propname):
1810 existing = props[propname]
1811 elif nodeid and not nodeid.startswith('-'):
1812 existing = cl.get(nodeid, propname, [])
1813 else:
1814 existing = []
1816 # now either remove or add
1817 if mlaction == 'remove':
1818 # remove - handle situation where the id isn't in
1819 # the list
1820 for entry in l:
1821 try:
1822 existing.remove(entry)
1823 except ValueError:
1824 raise ValueError, _('property "%(propname)s": '
1825 '"%(value)s" not currently in list')%{
1826 'propname': propname, 'value': entry}
1827 else:
1828 # add - easy, just don't dupe
1829 for entry in l:
1830 if entry not in existing:
1831 existing.append(entry)
1832 value = existing
1833 value.sort()
1835 elif value == '':
1836 # if we're creating, just don't include this property
1837 if not nodeid or nodeid.startswith('-'):
1838 continue
1839 # other types should be None'd if there's no value
1840 value = None
1841 else:
1842 # handle ValueErrors for all these in a similar fashion
1843 try:
1844 if isinstance(proptype, hyperdb.String):
1845 if (hasattr(value, 'filename') and
1846 value.filename is not None):
1847 # skip if the upload is empty
1848 if not value.filename:
1849 continue
1850 # this String is actually a _file_
1851 # try to determine the file content-type
1852 fn = value.filename.split('\\')[-1]
1853 if propdef.has_key('name'):
1854 props['name'] = fn
1855 # use this info as the type/filename properties
1856 if propdef.has_key('type'):
1857 props['type'] = mimetypes.guess_type(fn)[0]
1858 if not props['type']:
1859 props['type'] = "application/octet-stream"
1860 # finally, read the content
1861 value = value.value
1862 else:
1863 # normal String fix the CRLF/CR -> LF stuff
1864 value = fixNewlines(value)
1866 elif isinstance(proptype, hyperdb.Date):
1867 value = date.Date(value, offset=timezone)
1868 elif isinstance(proptype, hyperdb.Interval):
1869 value = date.Interval(value)
1870 elif isinstance(proptype, hyperdb.Boolean):
1871 value = value.lower() in ('yes', 'true', 'on', '1')
1872 elif isinstance(proptype, hyperdb.Number):
1873 value = float(value)
1874 except ValueError, msg:
1875 raise ValueError, _('Error with %s property: %s')%(
1876 propname, msg)
1878 # register that we got this property
1879 if value:
1880 got_props[this][propname] = 1
1882 # get the old value
1883 if nodeid and not nodeid.startswith('-'):
1884 try:
1885 existing = cl.get(nodeid, propname)
1886 except KeyError:
1887 # this might be a new property for which there is
1888 # no existing value
1889 if not propdef.has_key(propname):
1890 raise
1892 # make sure the existing multilink is sorted
1893 if isinstance(proptype, hyperdb.Multilink):
1894 existing.sort()
1896 # "missing" existing values may not be None
1897 if not existing:
1898 if isinstance(proptype, hyperdb.String) and not existing:
1899 # some backends store "missing" Strings as empty strings
1900 existing = None
1901 elif isinstance(proptype, hyperdb.Number) and not existing:
1902 # some backends store "missing" Numbers as 0 :(
1903 existing = 0
1904 elif isinstance(proptype, hyperdb.Boolean) and not existing:
1905 # likewise Booleans
1906 existing = 0
1908 # if changed, set it
1909 if value != existing:
1910 props[propname] = value
1911 else:
1912 # don't bother setting empty/unset values
1913 if value is None:
1914 continue
1915 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1916 continue
1917 elif isinstance(proptype, hyperdb.String) and value == '':
1918 continue
1920 props[propname] = value
1922 # check to see if we need to specially link a file to the note
1923 if have_note and have_file:
1924 all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1926 # see if all the required properties have been supplied
1927 s = []
1928 for thing, required in all_required.items():
1929 # register the values we got
1930 got = got_props.get(thing, {})
1931 for entry in required[:]:
1932 if got.has_key(entry):
1933 required.remove(entry)
1935 # any required values not present?
1936 if not required:
1937 continue
1939 # tell the user to entry the values required
1940 if len(required) > 1:
1941 p = 'properties'
1942 else:
1943 p = 'property'
1944 s.append('Required %s %s %s not supplied'%(thing[0], p,
1945 ', '.join(required)))
1946 if s:
1947 raise ValueError, '\n'.join(s)
1949 # check that FileClass entries have a "content" property with
1950 # content, otherwise remove them
1951 for (cn, id), props in all_props.items():
1952 cl = self.db.classes[cn]
1953 if not isinstance(cl, hyperdb.FileClass):
1954 continue
1955 # we also don't want to create FileClass items with no content
1956 if not props.get('content', ''):
1957 del all_props[(cn, id)]
1958 return all_props, all_links
1960 def fixNewlines(text):
1961 ''' Homogenise line endings.
1963 Different web clients send different line ending values, but
1964 other systems (eg. email) don't necessarily handle those line
1965 endings. Our solution is to convert all line endings to LF.
1966 '''
1967 text = text.replace('\r\n', '\n')
1968 return text.replace('\r', '\n')
1970 def extractFormList(value):
1971 ''' Extract a list of values from the form value.
1973 It may be one of:
1974 [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
1975 MiniFieldStorage('value,value,...')
1976 MiniFieldStorage('value')
1977 '''
1978 # multiple values are OK
1979 if isinstance(value, type([])):
1980 # it's a list of MiniFieldStorages - join then into
1981 values = ','.join([i.value.strip() for i in value])
1982 else:
1983 # it's a MiniFieldStorage, but may be a comma-separated list
1984 # of values
1985 values = value.value
1987 value = [i.strip() for i in values.split(',')]
1989 # filter out the empty bits
1990 return filter(None, value)