1 # $Id: client.py,v 1.138 2003-09-08 21:08:18 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, string
11 from roundup import roundupdb, date, hyperdb, password, token, rcsv
12 from roundup.i18n import _
13 from roundup.cgi.templating import Templates, HTMLRequest, NoTemplate
14 from roundup.cgi import cgitb
15 from roundup.cgi.PageTemplates import PageTemplate
16 from roundup.rfc2822 import encode_header
17 from roundup.mailgw import uidFromAddress
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 if hasattr(string, 'ascii_letters'):
33 chars = string.ascii_letters+string.digits
34 else:
35 # python2.1 doesn't have ascii_letters
36 chars = string.letters+string.digits
38 # XXX actually _use_ FormError
39 class FormError(ValueError):
40 ''' An "expected" exception occurred during form parsing.
41 - ie. something we know can go wrong, and don't want to alarm the
42 user with
44 We trap this at the user interface level and feed back a nice error
45 to the user.
46 '''
47 pass
49 class SendFile(Exception):
50 ''' Send a file from the database '''
52 class SendStaticFile(Exception):
53 ''' Send a static file from the instance html directory '''
55 def initialiseSecurity(security):
56 ''' Create some Permissions and Roles on the security object
58 This function is directly invoked by security.Security.__init__()
59 as a part of the Security object instantiation.
60 '''
61 security.addPermission(name="Web Registration",
62 description="User may register through the web")
63 p = security.addPermission(name="Web Access",
64 description="User may access the web interface")
65 security.addPermissionToRole('Admin', p)
67 # doing Role stuff through the web - make sure Admin can
68 p = security.addPermission(name="Web Roles",
69 description="User may manipulate user Roles through the web")
70 security.addPermissionToRole('Admin', p)
72 # used to clean messages passed through CGI variables - HTML-escape any tag
73 # that isn't <a href="">, <i>, <b> and <br> (including XHTML variants) so
74 # that people can't pass through nasties like <script>, <iframe>, ...
75 CLEAN_MESSAGE_RE = r'(<(/?(.*?)(\s*href="[^"]")?\s*/?)>)'
76 def clean_message(message, mc=re.compile(CLEAN_MESSAGE_RE, re.I)):
77 return mc.sub(clean_message_callback, message)
78 def clean_message_callback(match, ok={'a':1,'i':1,'b':1,'br':1}):
79 ''' Strip all non <a>,<i>,<b> and <br> tags from a string
80 '''
81 if ok.has_key(match.group(3).lower()):
82 return match.group(1)
83 return '<%s>'%match.group(2)
85 class Client:
86 ''' Instantiate to handle one CGI request.
88 See inner_main for request processing.
90 Client attributes at instantiation:
91 "path" is the PATH_INFO inside the instance (with no leading '/')
92 "base" is the base URL for the instance
93 "form" is the cgi form, an instance of FieldStorage from the standard
94 cgi module
95 "additional_headers" is a dictionary of additional HTTP headers that
96 should be sent to the client
97 "response_code" is the HTTP response code to send to the client
99 During the processing of a request, the following attributes are used:
100 "error_message" holds a list of error messages
101 "ok_message" holds a list of OK messages
102 "session" is the current user session id
103 "user" is the current user's name
104 "userid" is the current user's id
105 "template" is the current :template context
106 "classname" is the current class context name
107 "nodeid" is the current context item id
109 User Identification:
110 If the user has no login cookie, then they are anonymous and are logged
111 in as that user. This typically gives them all Permissions assigned to the
112 Anonymous Role.
114 Once a user logs in, they are assigned a session. The Client instance
115 keeps the nodeid of the session as the "session" attribute.
118 Special form variables:
119 Note that in various places throughout this code, special form
120 variables of the form :<name> are used. The colon (":") part may
121 actually be one of either ":" or "@".
122 '''
124 #
125 # special form variables
126 #
127 FV_TEMPLATE = re.compile(r'[@:]template')
128 FV_OK_MESSAGE = re.compile(r'[@:]ok_message')
129 FV_ERROR_MESSAGE = re.compile(r'[@:]error_message')
131 FV_QUERYNAME = re.compile(r'[@:]queryname')
133 # edit form variable handling (see unit tests)
134 FV_LABELS = r'''
135 ^(
136 (?P<note>[@:]note)|
137 (?P<file>[@:]file)|
138 (
139 ((?P<classname>%s)(?P<id>[-\d]+))? # optional leading designator
140 ((?P<required>[@:]required$)| # :required
141 (
142 (
143 (?P<add>[@:]add[@:])| # :add:<prop>
144 (?P<remove>[@:]remove[@:])| # :remove:<prop>
145 (?P<confirm>[@:]confirm[@:])| # :confirm:<prop>
146 (?P<link>[@:]link[@:])| # :link:<prop>
147 ([@:]) # just a separator
148 )?
149 (?P<propname>[^@:]+) # <prop>
150 )
151 )
152 )
153 )$'''
155 # Note: index page stuff doesn't appear here:
156 # columns, sort, sortdir, filter, group, groupdir, search_text,
157 # pagesize, startwith
159 def __init__(self, instance, request, env, form=None):
160 hyperdb.traceMark()
161 self.instance = instance
162 self.request = request
163 self.env = env
164 self.mailer = Mailer(instance.config)
166 # save off the path
167 self.path = env['PATH_INFO']
169 # this is the base URL for this tracker
170 self.base = self.instance.config.TRACKER_WEB
172 # this is the "cookie path" for this tracker (ie. the path part of
173 # the "base" url)
174 self.cookie_path = urlparse.urlparse(self.base)[2]
175 self.cookie_name = 'roundup_session_' + re.sub('[^a-zA-Z]', '',
176 self.instance.config.TRACKER_NAME)
178 # see if we need to re-parse the environment for the form (eg Zope)
179 if form is None:
180 self.form = cgi.FieldStorage(environ=env)
181 else:
182 self.form = form
184 # turn debugging on/off
185 try:
186 self.debug = int(env.get("ROUNDUP_DEBUG", 0))
187 except ValueError:
188 # someone gave us a non-int debug level, turn it off
189 self.debug = 0
191 # flag to indicate that the HTTP headers have been sent
192 self.headers_done = 0
194 # additional headers to send with the request - must be registered
195 # before the first write
196 self.additional_headers = {}
197 self.response_code = 200
200 def main(self):
201 ''' Wrap the real main in a try/finally so we always close off the db.
202 '''
203 try:
204 self.inner_main()
205 finally:
206 if hasattr(self, 'db'):
207 self.db.close()
209 def inner_main(self):
210 ''' Process a request.
212 The most common requests are handled like so:
213 1. figure out who we are, defaulting to the "anonymous" user
214 see determine_user
215 2. figure out what the request is for - the context
216 see determine_context
217 3. handle any requested action (item edit, search, ...)
218 see handle_action
219 4. render a template, resulting in HTML output
221 In some situations, exceptions occur:
222 - HTTP Redirect (generally raised by an action)
223 - SendFile (generally raised by determine_context)
224 serve up a FileClass "content" property
225 - SendStaticFile (generally raised by determine_context)
226 serve up a file from the tracker "html" directory
227 - Unauthorised (generally raised by an action)
228 the action is cancelled, the request is rendered and an error
229 message is displayed indicating that permission was not
230 granted for the action to take place
231 - NotFound (raised wherever it needs to be)
232 percolates up to the CGI interface that called the client
233 '''
234 self.ok_message = []
235 self.error_message = []
236 try:
237 # figure out the context and desired content template
238 # do this first so we don't authenticate for static files
239 # Note: this method opens the database as "admin" in order to
240 # perform context checks
241 self.determine_context()
243 # make sure we're identified (even anonymously)
244 self.determine_user()
246 # possibly handle a form submit action (may change self.classname
247 # and self.template, and may also append error/ok_messages)
248 self.handle_action()
250 # now render the page
251 # we don't want clients caching our dynamic pages
252 self.additional_headers['Cache-Control'] = 'no-cache'
253 # Pragma: no-cache makes Mozilla and its ilk double-load all pages!!
254 # self.additional_headers['Pragma'] = 'no-cache'
256 # expire this page 5 seconds from now
257 date = rfc822.formatdate(time.time() + 5)
258 self.additional_headers['Expires'] = date
260 # render the content
261 self.write(self.renderContext())
262 except Redirect, url:
263 # let's redirect - if the url isn't None, then we need to do
264 # the headers, otherwise the headers have been set before the
265 # exception was raised
266 if url:
267 self.additional_headers['Location'] = url
268 self.response_code = 302
269 self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
270 except SendFile, designator:
271 self.serve_file(designator)
272 except SendStaticFile, file:
273 try:
274 self.serve_static_file(str(file))
275 except NotModified:
276 # send the 304 response
277 self.request.send_response(304)
278 self.request.end_headers()
279 except Unauthorised, message:
280 self.classname = None
281 self.template = ''
282 self.error_message.append(message)
283 self.write(self.renderContext())
284 except NotFound:
285 # pass through
286 raise
287 except:
288 # everything else
289 self.write(cgitb.html())
291 def clean_sessions(self):
292 ''' Age sessions, remove when they haven't been used for a week.
294 Do it only once an hour.
296 Note: also cleans One Time Keys, and other "session" based
297 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 # determine the classname and possibly nodeid
420 path = self.path.split('/')
421 if not path or path[0] in ('', 'home', 'index'):
422 if template_override is not None:
423 self.template = template_override
424 else:
425 self.template = ''
426 return
427 elif path[0] == '_file':
428 raise SendStaticFile, os.path.join(*path[1:])
429 else:
430 self.classname = path[0]
431 if len(path) > 1:
432 # send the file identified by the designator in path[0]
433 raise SendFile, path[0]
435 # we need the db for further context stuff - open it as admin
436 self.opendb('admin')
438 # see if we got a designator
439 m = dre.match(self.classname)
440 if m:
441 self.classname = m.group(1)
442 self.nodeid = m.group(2)
443 if not self.db.getclass(self.classname).hasnode(self.nodeid):
444 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
445 # with a designator, we default to item view
446 self.template = 'item'
447 else:
448 # with only a class, we default to index view
449 self.template = 'index'
451 # make sure the classname is valid
452 try:
453 self.db.getclass(self.classname)
454 except KeyError:
455 raise NotFound, self.classname
457 # see if we have a template override
458 if template_override is not None:
459 self.template = template_override
461 # see if we were passed in a message
462 if ok_message:
463 self.ok_message.append(ok_message)
464 if error_message:
465 self.error_message.append(error_message)
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)
474 if classname != 'file':
475 raise NotFound, designator
477 # we just want to serve up the file named
478 self.opendb('admin')
479 file = self.db.file
480 self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
481 self.write(file.get(nodeid, 'content'))
483 def serve_static_file(self, file):
484 ims = None
485 # see if there's an if-modified-since...
486 if hasattr(self.request, 'headers'):
487 ims = self.request.headers.getheader('if-modified-since')
488 elif self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
489 # cgi will put the header in the env var
490 ims = self.env['HTTP_IF_MODIFIED_SINCE']
491 filename = os.path.join(self.instance.config.TEMPLATES, file)
492 lmt = os.stat(filename)[stat.ST_MTIME]
493 if ims:
494 ims = rfc822.parsedate(ims)[:6]
495 lmtt = time.gmtime(lmt)[:6]
496 if lmtt <= ims:
497 raise NotModified
499 # we just want to serve up the file named
500 file = str(file)
501 mt = mimetypes.guess_type(file)[0]
502 if not mt:
503 if file.endswith('.css'):
504 mt = 'text/css'
505 else:
506 mt = 'text/plain'
507 self.additional_headers['Content-Type'] = mt
508 self.additional_headers['Last-Modifed'] = rfc822.formatdate(lmt)
509 self.write(open(filename, 'rb').read())
511 def renderContext(self):
512 ''' Return a PageTemplate for the named page
513 '''
514 name = self.classname
515 extension = self.template
516 pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
518 # catch errors so we can handle PT rendering errors more nicely
519 args = {
520 'ok_message': self.ok_message,
521 'error_message': self.error_message
522 }
523 try:
524 # let the template render figure stuff out
525 return pt.render(self, None, None, **args)
526 except NoTemplate, message:
527 return '<strong>%s</strong>'%message
528 except:
529 # everything else
530 return cgitb.pt_html()
532 # these are the actions that are available
533 actions = (
534 ('edit', 'editItemAction'),
535 ('editcsv', 'editCSVAction'),
536 ('new', 'newItemAction'),
537 ('register', 'registerAction'),
538 ('confrego', 'confRegoAction'),
539 ('passrst', 'passResetAction'),
540 ('login', 'loginAction'),
541 ('logout', 'logout_action'),
542 ('search', 'searchAction'),
543 ('retire', 'retireAction'),
544 ('show', 'showAction'),
545 )
546 def handle_action(self):
547 ''' Determine whether there should be an Action called.
549 The action is defined by the form variable :action which
550 identifies the method on this object to call. The actions
551 are defined in the "actions" sequence on this class.
552 '''
553 if self.form.has_key(':action'):
554 action = self.form[':action'].value.lower()
555 elif self.form.has_key('@action'):
556 action = self.form['@action'].value.lower()
557 else:
558 return None
559 try:
560 # get the action, validate it
561 for name, method in self.actions:
562 if name == action:
563 break
564 else:
565 raise ValueError, 'No such action "%s"'%action
566 # call the mapped action
567 getattr(self, method)()
568 except Redirect:
569 raise
570 except Unauthorised:
571 raise
573 def write(self, content):
574 if not self.headers_done:
575 self.header()
576 self.request.wfile.write(content)
578 def header(self, headers=None, response=None):
579 '''Put up the appropriate header.
580 '''
581 if headers is None:
582 headers = {'Content-Type':'text/html'}
583 if response is None:
584 response = self.response_code
586 # update with additional info
587 headers.update(self.additional_headers)
589 if not headers.has_key('Content-Type'):
590 headers['Content-Type'] = 'text/html'
591 self.request.send_response(response)
592 for entry in headers.items():
593 self.request.send_header(*entry)
594 self.request.end_headers()
595 self.headers_done = 1
596 if self.debug:
597 self.headers_sent = headers
599 def set_cookie(self, user):
600 ''' Set up a session cookie for the user and store away the user's
601 login info against the session.
602 '''
603 # TODO generate a much, much stronger session key ;)
604 self.session = binascii.b2a_base64(repr(random.random())).strip()
606 # clean up the base64
607 if self.session[-1] == '=':
608 if self.session[-2] == '=':
609 self.session = self.session[:-2]
610 else:
611 self.session = self.session[:-1]
613 # insert the session in the sessiondb
614 self.db.sessions.set(self.session, user=user, last_use=time.time())
616 # and commit immediately
617 self.db.sessions.commit()
619 # expire us in a long, long time
620 expire = Cookie._getdate(86400*365)
622 # generate the cookie path - make sure it has a trailing '/'
623 self.additional_headers['Set-Cookie'] = \
624 '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
625 expire, self.cookie_path)
627 def make_user_anonymous(self):
628 ''' Make us anonymous
630 This method used to handle non-existence of the 'anonymous'
631 user, but that user is mandatory now.
632 '''
633 self.userid = self.db.user.lookup('anonymous')
634 self.user = 'anonymous'
636 def opendb(self, user):
637 ''' Open the database.
638 '''
639 # open the db if the user has changed
640 if not hasattr(self, 'db') or user != self.db.journaltag:
641 if hasattr(self, 'db'):
642 self.db.close()
643 self.db = self.instance.open(user)
645 #
646 # Actions
647 #
648 def loginAction(self):
649 ''' Attempt to log a user in.
651 Sets up a session for the user which contains the login
652 credentials.
653 '''
654 # we need the username at a minimum
655 if not self.form.has_key('__login_name'):
656 self.error_message.append(_('Username required'))
657 return
659 # get the login info
660 self.user = self.form['__login_name'].value
661 if self.form.has_key('__login_password'):
662 password = self.form['__login_password'].value
663 else:
664 password = ''
666 # make sure the user exists
667 try:
668 self.userid = self.db.user.lookup(self.user)
669 except KeyError:
670 name = self.user
671 self.error_message.append(_('No such user "%(name)s"')%locals())
672 self.make_user_anonymous()
673 return
675 # verify the password
676 if not self.verifyPassword(self.userid, password):
677 self.make_user_anonymous()
678 self.error_message.append(_('Incorrect password'))
679 return
681 # make sure we're allowed to be here
682 if not self.loginPermission():
683 self.make_user_anonymous()
684 self.error_message.append(_("You do not have permission to login"))
685 return
687 # now we're OK, re-open the database for real, using the user
688 self.opendb(self.user)
690 # set the session cookie
691 self.set_cookie(self.user)
693 def verifyPassword(self, userid, password):
694 ''' Verify the password that the user has supplied
695 '''
696 stored = self.db.user.get(self.userid, 'password')
697 if password == stored:
698 return 1
699 if not password and not stored:
700 return 1
701 return 0
703 def loginPermission(self):
704 ''' Determine whether the user has permission to log in.
706 Base behaviour is to check the user has "Web Access".
707 '''
708 if not self.db.security.hasPermission('Web Access', self.userid):
709 return 0
710 return 1
712 def logout_action(self):
713 ''' Make us really anonymous - nuke the cookie too
714 '''
715 # log us out
716 self.make_user_anonymous()
718 # construct the logout cookie
719 now = Cookie._getdate()
720 self.additional_headers['Set-Cookie'] = \
721 '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.cookie_name,
722 now, self.cookie_path)
724 # Let the user know what's going on
725 self.ok_message.append(_('You are logged out'))
727 def registerAction(self):
728 '''Attempt to create a new user based on the contents of the form
729 and then set the cookie.
731 return 1 on successful login
732 '''
733 # parse the props from the form
734 try:
735 props = self.parsePropsFromForm()[0][('user', None)]
736 except (ValueError, KeyError), message:
737 self.error_message.append(_('Error: ') + str(message))
738 return
740 # make sure we're allowed to register
741 if not self.registerPermission(props):
742 raise Unauthorised, _("You do not have permission to register")
744 try:
745 self.db.user.lookup(props['username'])
746 self.error_message.append('Error: A user with the username "%s" '
747 'already exists'%props['username'])
748 return
749 except KeyError:
750 pass
752 # generate the one-time-key and store the props for later
753 otk = ''.join([random.choice(chars) for x in range(32)])
754 for propname, proptype in self.db.user.getprops().items():
755 value = props.get(propname, None)
756 if value is None:
757 pass
758 elif isinstance(proptype, hyperdb.Date):
759 props[propname] = str(value)
760 elif isinstance(proptype, hyperdb.Interval):
761 props[propname] = str(value)
762 elif isinstance(proptype, hyperdb.Password):
763 props[propname] = str(value)
764 props['__time'] = time.time()
765 self.db.otks.set(otk, **props)
767 # send the email
768 tracker_name = self.db.config.TRACKER_NAME
769 tracker_email = self.db.config.TRACKER_EMAIL
770 subject = 'Complete your registration to %s -- key %s' % (tracker_name,
771 otk)
772 body = """To complete your registration of the user "%(name)s" with
773 %(tracker)s, please do one of the following:
775 - send a reply to %(tracker_email)s and maintain the subject line as is (the
776 reply's additional "Re:" is ok),
778 - or visit the following URL:
780 %(url)s?@action=confrego&otk=%(otk)s
781 """ % {'name': props['username'], 'tracker': tracker_name, 'url': self.base,
782 'otk': otk, 'tracker_email': tracker_email}
783 if not self.standard_message(props['address'], subject, body,
784 tracker_email):
785 return
787 # commit changes to the database
788 self.db.commit()
790 # redirect to the "you're almost there" page
791 raise Redirect, '%suser?@template=rego_progress'%self.base
793 def standard_message(self, to, subject, body, author=None):
794 try:
795 self.mailer.standard_message(to, subject, body, author)
796 return 1
797 except MessageSendError, e:
798 self.error_message.append(str(e))
800 def registerPermission(self, props):
801 ''' Determine whether the user has permission to register
803 Base behaviour is to check the user has "Web Registration".
804 '''
805 # registration isn't allowed to supply roles
806 if props.has_key('roles'):
807 return 0
808 if self.db.security.hasPermission('Web Registration', self.userid):
809 return 1
810 return 0
812 def confRegoAction(self):
813 ''' Grab the OTK, use it to load up the new user details
814 '''
815 try:
816 # pull the rego information out of the otk database
817 self.userid = self.db.confirm_registration(self.form['otk'].value)
818 except (ValueError, KeyError), message:
819 # XXX: we need to make the "default" page be able to display errors!
820 self.error_message.append(str(message))
821 return
823 # log the new user in
824 self.user = self.db.user.get(self.userid, 'username')
825 # re-open the database for real, using the user
826 self.opendb(self.user)
828 # if we have a session, update it
829 if hasattr(self, 'session'):
830 self.db.sessions.set(self.session, user=self.user,
831 last_use=time.time())
832 else:
833 # new session cookie
834 self.set_cookie(self.user)
836 # nice message
837 message = _('You are now registered, welcome!')
839 # redirect to the user's page
840 raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
841 self.userid, urllib.quote(message))
843 def passResetAction(self):
844 ''' Handle password reset requests.
846 Presence of either "name" or "address" generate email.
847 Presense of "otk" performs the reset.
848 '''
849 if self.form.has_key('otk'):
850 # pull the rego information out of the otk database
851 otk = self.form['otk'].value
852 uid = self.db.otks.get(otk, 'uid')
853 if uid is None:
854 self.error_message.append('Invalid One Time Key!')
855 return
857 # re-open the database as "admin"
858 if self.user != 'admin':
859 self.opendb('admin')
861 # change the password
862 newpw = password.generatePassword()
864 cl = self.db.user
865 # XXX we need to make the "default" page be able to display errors!
866 try:
867 # set the password
868 cl.set(uid, password=password.Password(newpw))
869 # clear the props from the otk database
870 self.db.otks.destroy(otk)
871 self.db.commit()
872 except (ValueError, KeyError), message:
873 self.error_message.append(str(message))
874 return
876 # user info
877 address = self.db.user.get(uid, 'address')
878 name = self.db.user.get(uid, 'username')
880 # send the email
881 tracker_name = self.db.config.TRACKER_NAME
882 subject = 'Password reset for %s'%tracker_name
883 body = '''
884 The password has been reset for username "%(name)s".
886 Your password is now: %(password)s
887 '''%{'name': name, 'password': newpw}
888 if not self.standard_message(address, subject, body):
889 return
891 self.ok_message.append('Password reset and email sent to %s'%address)
892 return
894 # no OTK, so now figure the user
895 if self.form.has_key('username'):
896 name = self.form['username'].value
897 try:
898 uid = self.db.user.lookup(name)
899 except KeyError:
900 self.error_message.append('Unknown username')
901 return
902 address = self.db.user.get(uid, 'address')
903 elif self.form.has_key('address'):
904 address = self.form['address'].value
905 uid = uidFromAddress(self.db, ('', address), create=0)
906 if not uid:
907 self.error_message.append('Unknown email address')
908 return
909 name = self.db.user.get(uid, 'username')
910 else:
911 self.error_message.append('You need to specify a username '
912 'or address')
913 return
915 # generate the one-time-key and store the props for later
916 otk = ''.join([random.choice(chars) for x in range(32)])
917 self.db.otks.set(otk, uid=uid, __time=time.time())
919 # send the email
920 tracker_name = self.db.config.TRACKER_NAME
921 subject = 'Confirm reset of password for %s'%tracker_name
922 body = '''
923 Someone, perhaps you, has requested that the password be changed for your
924 username, "%(name)s". If you wish to proceed with the change, please follow
925 the link below:
927 %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
929 You should then receive another email with the new password.
930 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
931 if not self.standard_message(address, subject, body):
932 return
934 self.ok_message.append('Email sent to %s'%address)
936 def editItemAction(self):
937 ''' Perform an edit of an item in the database.
939 See parsePropsFromForm and _editnodes for special variables
940 '''
941 # parse the props from the form
942 try:
943 props, links = self.parsePropsFromForm()
944 except (ValueError, KeyError), message:
945 self.error_message.append(_('Parse Error: ') + str(message))
946 return
948 # handle the props
949 try:
950 message = self._editnodes(props, links)
951 except (ValueError, KeyError, IndexError), message:
952 self.error_message.append(_('Apply Error: ') + str(message))
953 return
955 # commit now that all the tricky stuff is done
956 self.db.commit()
958 # redirect to the item's edit page
959 raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
960 self.classname, self.nodeid, urllib.quote(message),
961 urllib.quote(self.template))
963 def editItemPermission(self, props):
964 ''' Determine whether the user has permission to edit this item.
966 Base behaviour is to check the user can edit this class. If we're
967 editing the "user" class, users are allowed to edit their own
968 details. Unless it's the "roles" property, which requires the
969 special Permission "Web Roles".
970 '''
971 # if this is a user node and the user is editing their own node, then
972 # we're OK
973 has = self.db.security.hasPermission
974 if self.classname == 'user':
975 # reject if someone's trying to edit "roles" and doesn't have the
976 # right permission.
977 if props.has_key('roles') and not has('Web Roles', self.userid,
978 'user'):
979 return 0
980 # if the item being edited is the current user, we're ok
981 if self.nodeid == self.userid:
982 return 1
983 if self.db.security.hasPermission('Edit', self.userid, self.classname):
984 return 1
985 return 0
987 def newItemAction(self):
988 ''' Add a new item to the database.
990 This follows the same form as the editItemAction, with the same
991 special form values.
992 '''
993 # parse the props from the form
994 try:
995 props, links = self.parsePropsFromForm()
996 except (ValueError, KeyError), message:
997 self.error_message.append(_('Error: ') + str(message))
998 return
1000 # handle the props - edit or create
1001 try:
1002 # when it hits the None element, it'll set self.nodeid
1003 messages = self._editnodes(props, links)
1005 except (ValueError, KeyError, IndexError), message:
1006 # these errors might just be indicative of user dumbness
1007 self.error_message.append(_('Error: ') + str(message))
1008 return
1010 # commit now that all the tricky stuff is done
1011 self.db.commit()
1013 # redirect to the new item's page
1014 raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
1015 self.classname, self.nodeid, urllib.quote(messages),
1016 urllib.quote(self.template))
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))
1168 # get the CSV module
1169 if rcsv.error:
1170 self.error_message.append(_(rcsv.error))
1171 return
1173 cl = self.db.classes[self.classname]
1174 idlessprops = cl.getprops(protected=0).keys()
1175 idlessprops.sort()
1176 props = ['id'] + idlessprops
1178 # do the edit
1179 rows = StringIO.StringIO(self.form['rows'].value)
1180 reader = rcsv.reader(rows, rcsv.comma_separated)
1181 found = {}
1182 line = 0
1183 for values in reader:
1184 line += 1
1185 if line == 1: continue
1186 # skip property names header
1187 if values == props:
1188 continue
1190 # extract the nodeid
1191 nodeid, values = values[0], values[1:]
1192 found[nodeid] = 1
1194 # see if the node exists
1195 if nodeid in ('x', 'X') or not cl.hasnode(nodeid):
1196 exists = 0
1197 else:
1198 exists = 1
1200 # confirm correct weight
1201 if len(idlessprops) != len(values):
1202 self.error_message.append(
1203 _('Not enough values on line %(line)s')%{'line':line})
1204 return
1206 # extract the new values
1207 d = {}
1208 for name, value in zip(idlessprops, values):
1209 prop = cl.properties[name]
1210 value = value.strip()
1211 # only add the property if it has a value
1212 if value:
1213 # if it's a multilink, split it
1214 if isinstance(prop, hyperdb.Multilink):
1215 value = value.split(':')
1216 elif isinstance(prop, hyperdb.Password):
1217 value = password.Password(value)
1218 elif isinstance(prop, hyperdb.Interval):
1219 value = date.Interval(value)
1220 elif isinstance(prop, hyperdb.Date):
1221 value = date.Date(value)
1222 elif isinstance(prop, hyperdb.Boolean):
1223 value = value.lower() in ('yes', 'true', 'on', '1')
1224 elif isinstance(prop, hyperdb.Number):
1225 value = float(value)
1226 d[name] = value
1227 elif exists:
1228 # nuke the existing value
1229 if isinstance(prop, hyperdb.Multilink):
1230 d[name] = []
1231 else:
1232 d[name] = None
1234 # perform the edit
1235 if exists:
1236 # edit existing
1237 cl.set(nodeid, **d)
1238 else:
1239 # new node
1240 found[cl.create(**d)] = 1
1242 # retire the removed entries
1243 for nodeid in cl.list():
1244 if not found.has_key(nodeid):
1245 cl.retire(nodeid)
1247 # all OK
1248 self.db.commit()
1250 self.ok_message.append(_('Items edited OK'))
1252 def editCSVPermission(self):
1253 ''' Determine whether the user has permission to edit this class.
1255 Base behaviour is to check the user can edit this class.
1256 '''
1257 if not self.db.security.hasPermission('Edit', self.userid,
1258 self.classname):
1259 return 0
1260 return 1
1262 def searchAction(self, wcre=re.compile(r'[\s,]+')):
1263 ''' Mangle some of the form variables.
1265 Set the form ":filter" variable based on the values of the
1266 filter variables - if they're set to anything other than
1267 "dontcare" then add them to :filter.
1269 Handle the ":queryname" variable and save off the query to
1270 the user's query list.
1272 Split any String query values on whitespace and comma.
1273 '''
1274 # generic edit is per-class only
1275 if not self.searchPermission():
1276 self.error_message.append(
1277 _('You do not have permission to search %s' %self.classname))
1279 # add a faked :filter form variable for each filtering prop
1280 props = self.db.classes[self.classname].getprops()
1281 queryname = ''
1282 for key in self.form.keys():
1283 # special vars
1284 if self.FV_QUERYNAME.match(key):
1285 queryname = self.form[key].value.strip()
1286 continue
1288 if not props.has_key(key):
1289 continue
1290 if isinstance(self.form[key], type([])):
1291 # search for at least one entry which is not empty
1292 for minifield in self.form[key]:
1293 if minifield.value:
1294 break
1295 else:
1296 continue
1297 else:
1298 if not self.form[key].value:
1299 continue
1300 if isinstance(props[key], hyperdb.String):
1301 v = self.form[key].value
1302 l = token.token_split(v)
1303 if len(l) > 1 or l[0] != v:
1304 self.form.value.remove(self.form[key])
1305 # replace the single value with the split list
1306 for v in l:
1307 self.form.value.append(cgi.MiniFieldStorage(key, v))
1309 self.form.value.append(cgi.MiniFieldStorage('@filter', key))
1311 # handle saving the query params
1312 if queryname:
1313 # parse the environment and figure what the query _is_
1314 req = HTMLRequest(self)
1316 # The [1:] strips off the '?' character, it isn't part of the
1317 # query string.
1318 url = req.indexargs_href('', {})[1:]
1320 # handle editing an existing query
1321 try:
1322 qid = self.db.query.lookup(queryname)
1323 self.db.query.set(qid, klass=self.classname, url=url)
1324 except KeyError:
1325 # create a query
1326 qid = self.db.query.create(name=queryname,
1327 klass=self.classname, url=url)
1329 # and add it to the user's query multilink
1330 queries = self.db.user.get(self.userid, 'queries')
1331 queries.append(qid)
1332 self.db.user.set(self.userid, queries=queries)
1334 # commit the query change to the database
1335 self.db.commit()
1337 def searchPermission(self):
1338 ''' Determine whether the user has permission to search this class.
1340 Base behaviour is to check the user can view this class.
1341 '''
1342 if not self.db.security.hasPermission('View', self.userid,
1343 self.classname):
1344 return 0
1345 return 1
1348 def retireAction(self):
1349 ''' Retire the context item.
1350 '''
1351 # if we want to view the index template now, then unset the nodeid
1352 # context info (a special-case for retire actions on the index page)
1353 nodeid = self.nodeid
1354 if self.template == 'index':
1355 self.nodeid = None
1357 # generic edit is per-class only
1358 if not self.retirePermission():
1359 self.error_message.append(
1360 _('You do not have permission to retire %s' %self.classname))
1361 return
1363 # make sure we don't try to retire admin or anonymous
1364 if self.classname == 'user' and \
1365 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1366 self.error_message.append(
1367 _('You may not retire the admin or anonymous user'))
1368 return
1370 # do the retire
1371 self.db.getclass(self.classname).retire(nodeid)
1372 self.db.commit()
1374 self.ok_message.append(
1375 _('%(classname)s %(itemid)s has been retired')%{
1376 'classname': self.classname.capitalize(), 'itemid': nodeid})
1378 def retirePermission(self):
1379 ''' Determine whether the user has permission to retire this class.
1381 Base behaviour is to check the user can edit this class.
1382 '''
1383 if not self.db.security.hasPermission('Edit', self.userid,
1384 self.classname):
1385 return 0
1386 return 1
1389 def showAction(self, typere=re.compile('[@:]type'),
1390 numre=re.compile('[@:]number')):
1391 ''' Show a node of a particular class/id
1392 '''
1393 t = n = ''
1394 for key in self.form.keys():
1395 if typere.match(key):
1396 t = self.form[key].value.strip()
1397 elif numre.match(key):
1398 n = self.form[key].value.strip()
1399 if not t:
1400 raise ValueError, 'Invalid %s number'%t
1401 url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1402 raise Redirect, url
1404 def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1405 ''' Item properties and their values are edited with html FORM
1406 variables and their values. You can:
1408 - Change the value of some property of the current item.
1409 - Create a new item of any class, and edit the new item's
1410 properties,
1411 - Attach newly created items to a multilink property of the
1412 current item.
1413 - Remove items from a multilink property of the current item.
1414 - Specify that some properties are required for the edit
1415 operation to be successful.
1417 In the following, <bracketed> values are variable, "@" may be
1418 either ":" or "@", and other text "required" is fixed.
1420 Most properties are specified as form variables:
1422 <propname>
1423 - property on the current context item
1425 <designator>"@"<propname>
1426 - property on the indicated item (for editing related
1427 information)
1429 Designators name a specific item of a class.
1431 <classname><N>
1433 Name an existing item of class <classname>.
1435 <classname>"-"<N>
1437 Name the <N>th new item of class <classname>. If the form
1438 submission is successful, a new item of <classname> is
1439 created. Within the submitted form, a particular
1440 designator of this form always refers to the same new
1441 item.
1443 Once we have determined the "propname", we look at it to see
1444 if it's special:
1446 @required
1447 The associated form value is a comma-separated list of
1448 property names that must be specified when the form is
1449 submitted for the edit operation to succeed.
1451 When the <designator> is missing, the properties are
1452 for the current context item. When <designator> is
1453 present, they are for the item specified by
1454 <designator>.
1456 The "@required" specifier must come before any of the
1457 properties it refers to are assigned in the form.
1459 @remove@<propname>=id(s) or @add@<propname>=id(s)
1460 The "@add@" and "@remove@" edit actions apply only to
1461 Multilink properties. The form value must be a
1462 comma-separate list of keys for the class specified by
1463 the simple form variable. The listed items are added
1464 to (respectively, removed from) the specified
1465 property.
1467 @link@<propname>=<designator>
1468 If the edit action is "@link@", the simple form
1469 variable must specify a Link or Multilink property.
1470 The form value is a comma-separated list of
1471 designators. The item corresponding to each
1472 designator is linked to the property given by simple
1473 form variable. These are collected up and returned in
1474 all_links.
1476 None of the above (ie. just a simple form value)
1477 The value of the form variable is converted
1478 appropriately, depending on the type of the property.
1480 For a Link('klass') property, the form value is a
1481 single key for 'klass', where the key field is
1482 specified in dbinit.py.
1484 For a Multilink('klass') property, the form value is a
1485 comma-separated list of keys for 'klass', where the
1486 key field is specified in dbinit.py.
1488 Note that for simple-form-variables specifiying Link
1489 and Multilink properties, the linked-to class must
1490 have a key field.
1492 For a String() property specifying a filename, the
1493 file named by the form value is uploaded. This means we
1494 try to set additional properties "filename" and "type" (if
1495 they are valid for the class). Otherwise, the property
1496 is set to the form value.
1498 For Date(), Interval(), Boolean(), and Number()
1499 properties, the form value is converted to the
1500 appropriate
1502 Any of the form variables may be prefixed with a classname or
1503 designator.
1505 Two special form values are supported for backwards
1506 compatibility:
1508 @note
1509 This is equivalent to::
1511 @link@messages=msg-1
1512 @msg-1@content=value
1514 except that in addition, the "author" and "date"
1515 properties of "msg-1" are set to the userid of the
1516 submitter, and the current time, respectively.
1518 @file
1519 This is equivalent to::
1521 @link@files=file-1
1522 @file-1@content=value
1524 The String content value is handled as described above for
1525 file uploads.
1527 If both the "@note" and "@file" form variables are
1528 specified, the action::
1530 @link@msg-1@files=file-1
1532 is also performed.
1534 We also check that FileClass items have a "content" property with
1535 actual content, otherwise we remove them from all_props before
1536 returning.
1538 The return from this method is a dict of
1539 (classname, id): properties
1540 ... this dict _always_ has an entry for the current context,
1541 even if it's empty (ie. a submission for an existing issue that
1542 doesn't result in any changes would return {('issue','123'): {}})
1543 The id may be None, which indicates that an item should be
1544 created.
1545 '''
1546 # some very useful variables
1547 db = self.db
1548 form = self.form
1550 if not hasattr(self, 'FV_SPECIAL'):
1551 # generate the regexp for handling special form values
1552 classes = '|'.join(db.classes.keys())
1553 # specials for parsePropsFromForm
1554 # handle the various forms (see unit tests)
1555 self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1556 self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1558 # these indicate the default class / item
1559 default_cn = self.classname
1560 default_cl = self.db.classes[default_cn]
1561 default_nodeid = self.nodeid
1563 # we'll store info about the individual class/item edit in these
1564 all_required = {} # required props per class/item
1565 all_props = {} # props to set per class/item
1566 got_props = {} # props received per class/item
1567 all_propdef = {} # note - only one entry per class
1568 all_links = [] # as many as are required
1570 # we should always return something, even empty, for the context
1571 all_props[(default_cn, default_nodeid)] = {}
1573 keys = form.keys()
1574 timezone = db.getUserTimezone()
1576 # sentinels for the :note and :file props
1577 have_note = have_file = 0
1579 # extract the usable form labels from the form
1580 matches = []
1581 for key in keys:
1582 m = self.FV_SPECIAL.match(key)
1583 if m:
1584 matches.append((key, m.groupdict()))
1586 # now handle the matches
1587 for key, d in matches:
1588 if d['classname']:
1589 # we got a designator
1590 cn = d['classname']
1591 cl = self.db.classes[cn]
1592 nodeid = d['id']
1593 propname = d['propname']
1594 elif d['note']:
1595 # the special note field
1596 cn = 'msg'
1597 cl = self.db.classes[cn]
1598 nodeid = '-1'
1599 propname = 'content'
1600 all_links.append((default_cn, default_nodeid, 'messages',
1601 [('msg', '-1')]))
1602 have_note = 1
1603 elif d['file']:
1604 # the special file field
1605 cn = 'file'
1606 cl = self.db.classes[cn]
1607 nodeid = '-1'
1608 propname = 'content'
1609 all_links.append((default_cn, default_nodeid, 'files',
1610 [('file', '-1')]))
1611 have_file = 1
1612 else:
1613 # default
1614 cn = default_cn
1615 cl = default_cl
1616 nodeid = default_nodeid
1617 propname = d['propname']
1619 # the thing this value relates to is...
1620 this = (cn, nodeid)
1622 # get more info about the class, and the current set of
1623 # form props for it
1624 if not all_propdef.has_key(cn):
1625 all_propdef[cn] = cl.getprops()
1626 propdef = all_propdef[cn]
1627 if not all_props.has_key(this):
1628 all_props[this] = {}
1629 props = all_props[this]
1630 if not got_props.has_key(this):
1631 got_props[this] = {}
1633 # is this a link command?
1634 if d['link']:
1635 value = []
1636 for entry in extractFormList(form[key]):
1637 m = self.FV_DESIGNATOR.match(entry)
1638 if not m:
1639 raise ValueError, \
1640 'link "%s" value "%s" not a designator'%(key, entry)
1641 value.append((m.group(1), m.group(2)))
1643 # make sure the link property is valid
1644 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1645 not isinstance(propdef[propname], hyperdb.Link)):
1646 raise ValueError, '%s %s is not a link or '\
1647 'multilink property'%(cn, propname)
1649 all_links.append((cn, nodeid, propname, value))
1650 continue
1652 # detect the special ":required" variable
1653 if d['required']:
1654 all_required[this] = extractFormList(form[key])
1655 continue
1657 # see if we're performing a special multilink action
1658 mlaction = 'set'
1659 if d['remove']:
1660 mlaction = 'remove'
1661 elif d['add']:
1662 mlaction = 'add'
1664 # does the property exist?
1665 if not propdef.has_key(propname):
1666 if mlaction != 'set':
1667 raise ValueError, 'You have submitted a %s action for'\
1668 ' the property "%s" which doesn\'t exist'%(mlaction,
1669 propname)
1670 # the form element is probably just something we don't care
1671 # about - ignore it
1672 continue
1673 proptype = propdef[propname]
1675 # Get the form value. This value may be a MiniFieldStorage or a list
1676 # of MiniFieldStorages.
1677 value = form[key]
1679 # handle unpacking of the MiniFieldStorage / list form value
1680 if isinstance(proptype, hyperdb.Multilink):
1681 value = extractFormList(value)
1682 else:
1683 # multiple values are not OK
1684 if isinstance(value, type([])):
1685 raise ValueError, 'You have submitted more than one value'\
1686 ' for the %s property'%propname
1687 # value might be a file upload...
1688 if not hasattr(value, 'filename') or value.filename is None:
1689 # nope, pull out the value and strip it
1690 value = value.value.strip()
1692 # now that we have the props field, we need a teensy little
1693 # extra bit of help for the old :note field...
1694 if d['note'] and value:
1695 props['author'] = self.db.getuid()
1696 props['date'] = date.Date()
1698 # handle by type now
1699 if isinstance(proptype, hyperdb.Password):
1700 if not value:
1701 # ignore empty password values
1702 continue
1703 for key, d in matches:
1704 if d['confirm'] and d['propname'] == propname:
1705 confirm = form[key]
1706 break
1707 else:
1708 raise ValueError, 'Password and confirmation text do '\
1709 'not match'
1710 if isinstance(confirm, type([])):
1711 raise ValueError, 'You have submitted more than one value'\
1712 ' for the %s property'%propname
1713 if value != confirm.value:
1714 raise ValueError, 'Password and confirmation text do '\
1715 'not match'
1716 value = password.Password(value)
1718 elif isinstance(proptype, hyperdb.Link):
1719 # see if it's the "no selection" choice
1720 if value == '-1' or not value:
1721 # if we're creating, just don't include this property
1722 if not nodeid or nodeid.startswith('-'):
1723 continue
1724 value = None
1725 else:
1726 # handle key values
1727 link = proptype.classname
1728 if not num_re.match(value):
1729 try:
1730 value = db.classes[link].lookup(value)
1731 except KeyError:
1732 raise ValueError, _('property "%(propname)s": '
1733 '%(value)s not a %(classname)s')%{
1734 'propname': propname, 'value': value,
1735 'classname': link}
1736 except TypeError, message:
1737 raise ValueError, _('you may only enter ID values '
1738 'for property "%(propname)s": %(message)s')%{
1739 'propname': propname, 'message': message}
1740 elif isinstance(proptype, hyperdb.Multilink):
1741 # perform link class key value lookup if necessary
1742 link = proptype.classname
1743 link_cl = db.classes[link]
1744 l = []
1745 for entry in value:
1746 if not entry: continue
1747 if not num_re.match(entry):
1748 try:
1749 entry = link_cl.lookup(entry)
1750 except KeyError:
1751 raise ValueError, _('property "%(propname)s": '
1752 '"%(value)s" not an entry of %(classname)s')%{
1753 'propname': propname, 'value': entry,
1754 'classname': link}
1755 except TypeError, message:
1756 raise ValueError, _('you may only enter ID values '
1757 'for property "%(propname)s": %(message)s')%{
1758 'propname': propname, 'message': message}
1759 l.append(entry)
1760 l.sort()
1762 # now use that list of ids to modify the multilink
1763 if mlaction == 'set':
1764 value = l
1765 else:
1766 # we're modifying the list - get the current list of ids
1767 if props.has_key(propname):
1768 existing = props[propname]
1769 elif nodeid and not nodeid.startswith('-'):
1770 existing = cl.get(nodeid, propname, [])
1771 else:
1772 existing = []
1774 # now either remove or add
1775 if mlaction == 'remove':
1776 # remove - handle situation where the id isn't in
1777 # the list
1778 for entry in l:
1779 try:
1780 existing.remove(entry)
1781 except ValueError:
1782 raise ValueError, _('property "%(propname)s": '
1783 '"%(value)s" not currently in list')%{
1784 'propname': propname, 'value': entry}
1785 else:
1786 # add - easy, just don't dupe
1787 for entry in l:
1788 if entry not in existing:
1789 existing.append(entry)
1790 value = existing
1791 value.sort()
1793 elif value == '':
1794 # if we're creating, just don't include this property
1795 if not nodeid or nodeid.startswith('-'):
1796 continue
1797 # other types should be None'd if there's no value
1798 value = None
1799 else:
1800 # handle ValueErrors for all these in a similar fashion
1801 try:
1802 if isinstance(proptype, hyperdb.String):
1803 if (hasattr(value, 'filename') and
1804 value.filename is not None):
1805 # skip if the upload is empty
1806 if not value.filename:
1807 continue
1808 # this String is actually a _file_
1809 # try to determine the file content-type
1810 fn = value.filename.split('\\')[-1]
1811 if propdef.has_key('name'):
1812 props['name'] = fn
1813 # use this info as the type/filename properties
1814 if propdef.has_key('type'):
1815 props['type'] = mimetypes.guess_type(fn)[0]
1816 if not props['type']:
1817 props['type'] = "application/octet-stream"
1818 # finally, read the content
1819 value = value.value
1820 else:
1821 # normal String fix the CRLF/CR -> LF stuff
1822 value = fixNewlines(value)
1824 elif isinstance(proptype, hyperdb.Date):
1825 value = date.Date(value, offset=timezone)
1826 elif isinstance(proptype, hyperdb.Interval):
1827 value = date.Interval(value)
1828 elif isinstance(proptype, hyperdb.Boolean):
1829 value = value.lower() in ('yes', 'true', 'on', '1')
1830 elif isinstance(proptype, hyperdb.Number):
1831 value = float(value)
1832 except ValueError, msg:
1833 raise ValueError, _('Error with %s property: %s')%(
1834 propname, msg)
1836 # register that we got this property
1837 if value:
1838 got_props[this][propname] = 1
1840 # get the old value
1841 if nodeid and not nodeid.startswith('-'):
1842 try:
1843 existing = cl.get(nodeid, propname)
1844 except KeyError:
1845 # this might be a new property for which there is
1846 # no existing value
1847 if not propdef.has_key(propname):
1848 raise
1850 # make sure the existing multilink is sorted
1851 if isinstance(proptype, hyperdb.Multilink):
1852 existing.sort()
1854 # "missing" existing values may not be None
1855 if not existing:
1856 if isinstance(proptype, hyperdb.String) and not existing:
1857 # some backends store "missing" Strings as empty strings
1858 existing = None
1859 elif isinstance(proptype, hyperdb.Number) and not existing:
1860 # some backends store "missing" Numbers as 0 :(
1861 existing = 0
1862 elif isinstance(proptype, hyperdb.Boolean) and not existing:
1863 # likewise Booleans
1864 existing = 0
1866 # if changed, set it
1867 if value != existing:
1868 props[propname] = value
1869 else:
1870 # don't bother setting empty/unset values
1871 if value is None:
1872 continue
1873 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1874 continue
1875 elif isinstance(proptype, hyperdb.String) and value == '':
1876 continue
1878 props[propname] = value
1880 # check to see if we need to specially link a file to the note
1881 if have_note and have_file:
1882 all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1884 # see if all the required properties have been supplied
1885 s = []
1886 for thing, required in all_required.items():
1887 # register the values we got
1888 got = got_props.get(thing, {})
1889 for entry in required[:]:
1890 if got.has_key(entry):
1891 required.remove(entry)
1893 # any required values not present?
1894 if not required:
1895 continue
1897 # tell the user to entry the values required
1898 if len(required) > 1:
1899 p = 'properties'
1900 else:
1901 p = 'property'
1902 s.append('Required %s %s %s not supplied'%(thing[0], p,
1903 ', '.join(required)))
1904 if s:
1905 raise ValueError, '\n'.join(s)
1907 # When creating a FileClass node, it should have a non-empty content
1908 # property to be created. When editing a FileClass node, it should
1909 # either have a non-empty content property or no property at all. In
1910 # the latter case, nothing will change.
1911 for (cn, id), props in all_props.items():
1912 if isinstance(self.db.classes[cn], hyperdb.FileClass):
1913 if id == '-1':
1914 if not props.get('content', ''):
1915 del all_props[(cn, id)]
1916 elif props.has_key('content') and not props['content']:
1917 raise ValueError, _('File is empty')
1918 return all_props, all_links
1920 def fixNewlines(text):
1921 ''' Homogenise line endings.
1923 Different web clients send different line ending values, but
1924 other systems (eg. email) don't necessarily handle those line
1925 endings. Our solution is to convert all line endings to LF.
1926 '''
1927 text = text.replace('\r\n', '\n')
1928 return text.replace('\r', '\n')
1930 def extractFormList(value):
1931 ''' Extract a list of values from the form value.
1933 It may be one of:
1934 [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
1935 MiniFieldStorage('value,value,...')
1936 MiniFieldStorage('value')
1937 '''
1938 # multiple values are OK
1939 if isinstance(value, type([])):
1940 # it's a list of MiniFieldStorages - join then into
1941 values = ','.join([i.value.strip() for i in value])
1942 else:
1943 # it's a MiniFieldStorage, but may be a comma-separated list
1944 # of values
1945 values = value.value
1947 value = [i.strip() for i in values.split(',')]
1949 # filter out the empty bits
1950 return filter(None, value)