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