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