1 # $Id: client.py,v 1.136 2003-09-08 09:28:28 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 subject = 'Complete your registration to %s'%tracker_name
770 body = '''
771 To complete your registration of the user "%(name)s" with %(tracker)s,
772 please visit the following URL:
774 %(url)s?@action=confrego&otk=%(otk)s
775 '''%{'name': props['username'], 'tracker': tracker_name, 'url': self.base,
776 'otk': otk}
777 if not self.standard_message(props['address'], subject, body):
778 return
780 # commit changes to the database
781 self.db.commit()
783 # redirect to the "you're almost there" page
784 raise Redirect, '%suser?@template=rego_progress'%self.base
786 def standard_message(self, to, subject, body):
787 try:
788 self.mailer.standard_message(to, subject, body)
789 return 1
790 except MessageSendException, e:
791 self.error_message.append(str(e))
794 def registerPermission(self, props):
795 ''' Determine whether the user has permission to register
797 Base behaviour is to check the user has "Web Registration".
798 '''
799 # registration isn't allowed to supply roles
800 if props.has_key('roles'):
801 return 0
802 if self.db.security.hasPermission('Web Registration', self.userid):
803 return 1
804 return 0
806 def confRegoAction(self):
807 ''' Grab the OTK, use it to load up the new user details
808 '''
809 try:
810 # pull the rego information out of the otk database
811 self.userid = self.db.confirm_registration(self.form['otk'].value)
812 except (ValueError, KeyError), message:
813 # XXX: we need to make the "default" page be able to display errors!
814 self.error_message.append(str(message))
815 return
817 # log the new user in
818 self.user = self.db.user.get(self.userid, 'username')
819 # re-open the database for real, using the user
820 self.opendb(self.user)
822 # if we have a session, update it
823 if hasattr(self, 'session'):
824 self.db.sessions.set(self.session, user=self.user,
825 last_use=time.time())
826 else:
827 # new session cookie
828 self.set_cookie(self.user)
830 # nice message
831 message = _('You are now registered, welcome!')
833 # redirect to the user's page
834 raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
835 self.userid, urllib.quote(message))
837 def passResetAction(self):
838 ''' Handle password reset requests.
840 Presence of either "name" or "address" generate email.
841 Presense of "otk" performs the reset.
842 '''
843 if self.form.has_key('otk'):
844 # pull the rego information out of the otk database
845 otk = self.form['otk'].value
846 uid = self.db.otks.get(otk, 'uid')
847 if uid is None:
848 self.error_message.append('Invalid One Time Key!')
849 return
851 # re-open the database as "admin"
852 if self.user != 'admin':
853 self.opendb('admin')
855 # change the password
856 newpw = password.generatePassword()
858 cl = self.db.user
859 # XXX we need to make the "default" page be able to display errors!
860 try:
861 # set the password
862 cl.set(uid, password=password.Password(newpw))
863 # clear the props from the otk database
864 self.db.otks.destroy(otk)
865 self.db.commit()
866 except (ValueError, KeyError), message:
867 self.error_message.append(str(message))
868 return
870 # user info
871 address = self.db.user.get(uid, 'address')
872 name = self.db.user.get(uid, 'username')
874 # send the email
875 tracker_name = self.db.config.TRACKER_NAME
876 subject = 'Password reset for %s'%tracker_name
877 body = '''
878 The password has been reset for username "%(name)s".
880 Your password is now: %(password)s
881 '''%{'name': name, 'password': newpw}
882 if not self.standard_message(address, subject, body):
883 return
885 self.ok_message.append('Password reset and email sent to %s'%address)
886 return
888 # no OTK, so now figure the user
889 if self.form.has_key('username'):
890 name = self.form['username'].value
891 try:
892 uid = self.db.user.lookup(name)
893 except KeyError:
894 self.error_message.append('Unknown username')
895 return
896 address = self.db.user.get(uid, 'address')
897 elif self.form.has_key('address'):
898 address = self.form['address'].value
899 uid = uidFromAddress(self.db, ('', address), create=0)
900 if not uid:
901 self.error_message.append('Unknown email address')
902 return
903 name = self.db.user.get(uid, 'username')
904 else:
905 self.error_message.append('You need to specify a username '
906 'or address')
907 return
909 # generate the one-time-key and store the props for later
910 otk = ''.join([random.choice(chars) for x in range(32)])
911 self.db.otks.set(otk, uid=uid, __time=time.time())
913 # send the email
914 tracker_name = self.db.config.TRACKER_NAME
915 subject = 'Confirm reset of password for %s'%tracker_name
916 body = '''
917 Someone, perhaps you, has requested that the password be changed for your
918 username, "%(name)s". If you wish to proceed with the change, please follow
919 the link below:
921 %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
923 You should then receive another email with the new password.
924 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
925 if not self.standard_message(address, subject, body):
926 return
928 self.ok_message.append('Email sent to %s'%address)
930 def editItemAction(self):
931 ''' Perform an edit of an item in the database.
933 See parsePropsFromForm and _editnodes for special variables
934 '''
935 # parse the props from the form
936 try:
937 props, links = self.parsePropsFromForm()
938 except (ValueError, KeyError), message:
939 self.error_message.append(_('Parse Error: ') + str(message))
940 return
942 # handle the props
943 try:
944 message = self._editnodes(props, links)
945 except (ValueError, KeyError, IndexError), message:
946 self.error_message.append(_('Apply Error: ') + str(message))
947 return
949 # commit now that all the tricky stuff is done
950 self.db.commit()
952 # redirect to the item's edit page
953 raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
954 self.classname, self.nodeid, urllib.quote(message),
955 urllib.quote(self.template))
957 def editItemPermission(self, props):
958 ''' Determine whether the user has permission to edit this item.
960 Base behaviour is to check the user can edit this class. If we're
961 editing the "user" class, users are allowed to edit their own
962 details. Unless it's the "roles" property, which requires the
963 special Permission "Web Roles".
964 '''
965 # if this is a user node and the user is editing their own node, then
966 # we're OK
967 has = self.db.security.hasPermission
968 if self.classname == 'user':
969 # reject if someone's trying to edit "roles" and doesn't have the
970 # right permission.
971 if props.has_key('roles') and not has('Web Roles', self.userid,
972 'user'):
973 return 0
974 # if the item being edited is the current user, we're ok
975 if self.nodeid == self.userid:
976 return 1
977 if self.db.security.hasPermission('Edit', self.userid, self.classname):
978 return 1
979 return 0
981 def newItemAction(self):
982 ''' Add a new item to the database.
984 This follows the same form as the editItemAction, with the same
985 special form values.
986 '''
987 # parse the props from the form
988 try:
989 props, links = self.parsePropsFromForm()
990 except (ValueError, KeyError), message:
991 self.error_message.append(_('Error: ') + str(message))
992 return
994 # handle the props - edit or create
995 try:
996 # when it hits the None element, it'll set self.nodeid
997 messages = self._editnodes(props, links)
999 except (ValueError, KeyError, IndexError), message:
1000 # these errors might just be indicative of user dumbness
1001 self.error_message.append(_('Error: ') + str(message))
1002 return
1004 # commit now that all the tricky stuff is done
1005 self.db.commit()
1007 # redirect to the new item's page
1008 raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
1009 self.classname, self.nodeid, urllib.quote(messages),
1010 urllib.quote(self.template))
1012 def newItemPermission(self, props):
1013 ''' Determine whether the user has permission to create (edit) this
1014 item.
1016 Base behaviour is to check the user can edit this class. No
1017 additional property checks are made. Additionally, new user items
1018 may be created if the user has the "Web Registration" Permission.
1019 '''
1020 has = self.db.security.hasPermission
1021 if self.classname == 'user' and has('Web Registration', self.userid,
1022 'user'):
1023 return 1
1024 if has('Edit', self.userid, self.classname):
1025 return 1
1026 return 0
1029 #
1030 # Utility methods for editing
1031 #
1032 def _editnodes(self, all_props, all_links, newids=None):
1033 ''' Use the props in all_props to perform edit and creation, then
1034 use the link specs in all_links to do linking.
1035 '''
1036 # figure dependencies and re-work links
1037 deps = {}
1038 links = {}
1039 for cn, nodeid, propname, vlist in all_links:
1040 if not all_props.has_key((cn, nodeid)):
1041 # link item to link to doesn't (and won't) exist
1042 continue
1043 for value in vlist:
1044 if not all_props.has_key(value):
1045 # link item to link to doesn't (and won't) exist
1046 continue
1047 deps.setdefault((cn, nodeid), []).append(value)
1048 links.setdefault(value, []).append((cn, nodeid, propname))
1050 # figure chained dependencies ordering
1051 order = []
1052 done = {}
1053 # loop detection
1054 change = 0
1055 while len(all_props) != len(done):
1056 for needed in all_props.keys():
1057 if done.has_key(needed):
1058 continue
1059 tlist = deps.get(needed, [])
1060 for target in tlist:
1061 if not done.has_key(target):
1062 break
1063 else:
1064 done[needed] = 1
1065 order.append(needed)
1066 change = 1
1067 if not change:
1068 raise ValueError, 'linking must not loop!'
1070 # now, edit / create
1071 m = []
1072 for needed in order:
1073 props = all_props[needed]
1074 if not props:
1075 # nothing to do
1076 continue
1077 cn, nodeid = needed
1079 if nodeid is not None and int(nodeid) > 0:
1080 # make changes to the node
1081 props = self._changenode(cn, nodeid, props)
1083 # and some nice feedback for the user
1084 if props:
1085 info = ', '.join(props.keys())
1086 m.append('%s %s %s edited ok'%(cn, nodeid, info))
1087 else:
1088 m.append('%s %s - nothing changed'%(cn, nodeid))
1089 else:
1090 assert props
1092 # make a new node
1093 newid = self._createnode(cn, props)
1094 if nodeid is None:
1095 self.nodeid = newid
1096 nodeid = newid
1098 # and some nice feedback for the user
1099 m.append('%s %s created'%(cn, newid))
1101 # fill in new ids in links
1102 if links.has_key(needed):
1103 for linkcn, linkid, linkprop in links[needed]:
1104 props = all_props[(linkcn, linkid)]
1105 cl = self.db.classes[linkcn]
1106 propdef = cl.getprops()[linkprop]
1107 if not props.has_key(linkprop):
1108 if linkid is None or linkid.startswith('-'):
1109 # linking to a new item
1110 if isinstance(propdef, hyperdb.Multilink):
1111 props[linkprop] = [newid]
1112 else:
1113 props[linkprop] = newid
1114 else:
1115 # linking to an existing item
1116 if isinstance(propdef, hyperdb.Multilink):
1117 existing = cl.get(linkid, linkprop)[:]
1118 existing.append(nodeid)
1119 props[linkprop] = existing
1120 else:
1121 props[linkprop] = newid
1123 return '<br>'.join(m)
1125 def _changenode(self, cn, nodeid, props):
1126 ''' change the node based on the contents of the form
1127 '''
1128 # check for permission
1129 if not self.editItemPermission(props):
1130 raise Unauthorised, 'You do not have permission to edit %s'%cn
1132 # make the changes
1133 cl = self.db.classes[cn]
1134 return cl.set(nodeid, **props)
1136 def _createnode(self, cn, props):
1137 ''' create a node based on the contents of the form
1138 '''
1139 # check for permission
1140 if not self.newItemPermission(props):
1141 raise Unauthorised, 'You do not have permission to create %s'%cn
1143 # create the node and return its id
1144 cl = self.db.classes[cn]
1145 return cl.create(**props)
1147 #
1148 # More actions
1149 #
1150 def editCSVAction(self):
1151 ''' Performs an edit of all of a class' items in one go.
1153 The "rows" CGI var defines the CSV-formatted entries for the
1154 class. New nodes are identified by the ID 'X' (or any other
1155 non-existent ID) and removed lines are retired.
1156 '''
1157 # this is per-class only
1158 if not self.editCSVPermission():
1159 self.error_message.append(
1160 _('You do not have permission to edit %s' %self.classname))
1162 # get the CSV module
1163 if rcsv.error:
1164 self.error_message.append(_(rcsv.error))
1165 return
1167 cl = self.db.classes[self.classname]
1168 idlessprops = cl.getprops(protected=0).keys()
1169 idlessprops.sort()
1170 props = ['id'] + idlessprops
1172 # do the edit
1173 rows = StringIO.StringIO(self.form['rows'].value)
1174 reader = rcsv.reader(rows, rcsv.comma_separated)
1175 found = {}
1176 line = 0
1177 for values in reader:
1178 line += 1
1179 if line == 1: continue
1180 # skip property names header
1181 if values == props:
1182 continue
1184 # extract the nodeid
1185 nodeid, values = values[0], values[1:]
1186 found[nodeid] = 1
1188 # see if the node exists
1189 if nodeid in ('x', 'X') or not cl.hasnode(nodeid):
1190 exists = 0
1191 else:
1192 exists = 1
1194 # confirm correct weight
1195 if len(idlessprops) != len(values):
1196 self.error_message.append(
1197 _('Not enough values on line %(line)s')%{'line':line})
1198 return
1200 # extract the new values
1201 d = {}
1202 for name, value in zip(idlessprops, values):
1203 prop = cl.properties[name]
1204 value = value.strip()
1205 # only add the property if it has a value
1206 if value:
1207 # if it's a multilink, split it
1208 if isinstance(prop, hyperdb.Multilink):
1209 value = value.split(':')
1210 elif isinstance(prop, hyperdb.Password):
1211 value = password.Password(value)
1212 elif isinstance(prop, hyperdb.Interval):
1213 value = date.Interval(value)
1214 elif isinstance(prop, hyperdb.Date):
1215 value = date.Date(value)
1216 elif isinstance(prop, hyperdb.Boolean):
1217 value = value.lower() in ('yes', 'true', 'on', '1')
1218 elif isinstance(prop, hyperdb.Number):
1219 value = float(value)
1220 d[name] = value
1221 elif exists:
1222 # nuke the existing value
1223 if isinstance(prop, hyperdb.Multilink):
1224 d[name] = []
1225 else:
1226 d[name] = None
1228 # perform the edit
1229 if exists:
1230 # edit existing
1231 cl.set(nodeid, **d)
1232 else:
1233 # new node
1234 found[cl.create(**d)] = 1
1236 # retire the removed entries
1237 for nodeid in cl.list():
1238 if not found.has_key(nodeid):
1239 cl.retire(nodeid)
1241 # all OK
1242 self.db.commit()
1244 self.ok_message.append(_('Items edited OK'))
1246 def editCSVPermission(self):
1247 ''' Determine whether the user has permission to edit this class.
1249 Base behaviour is to check the user can edit this class.
1250 '''
1251 if not self.db.security.hasPermission('Edit', self.userid,
1252 self.classname):
1253 return 0
1254 return 1
1256 def searchAction(self, wcre=re.compile(r'[\s,]+')):
1257 ''' Mangle some of the form variables.
1259 Set the form ":filter" variable based on the values of the
1260 filter variables - if they're set to anything other than
1261 "dontcare" then add them to :filter.
1263 Handle the ":queryname" variable and save off the query to
1264 the user's query list.
1266 Split any String query values on whitespace and comma.
1267 '''
1268 # generic edit is per-class only
1269 if not self.searchPermission():
1270 self.error_message.append(
1271 _('You do not have permission to search %s' %self.classname))
1273 # add a faked :filter form variable for each filtering prop
1274 props = self.db.classes[self.classname].getprops()
1275 queryname = ''
1276 for key in self.form.keys():
1277 # special vars
1278 if self.FV_QUERYNAME.match(key):
1279 queryname = self.form[key].value.strip()
1280 continue
1282 if not props.has_key(key):
1283 continue
1284 if isinstance(self.form[key], type([])):
1285 # search for at least one entry which is not empty
1286 for minifield in self.form[key]:
1287 if minifield.value:
1288 break
1289 else:
1290 continue
1291 else:
1292 if not self.form[key].value:
1293 continue
1294 if isinstance(props[key], hyperdb.String):
1295 v = self.form[key].value
1296 l = token.token_split(v)
1297 if len(l) > 1 or l[0] != v:
1298 self.form.value.remove(self.form[key])
1299 # replace the single value with the split list
1300 for v in l:
1301 self.form.value.append(cgi.MiniFieldStorage(key, v))
1303 self.form.value.append(cgi.MiniFieldStorage('@filter', key))
1305 # handle saving the query params
1306 if queryname:
1307 # parse the environment and figure what the query _is_
1308 req = HTMLRequest(self)
1310 # The [1:] strips off the '?' character, it isn't part of the
1311 # query string.
1312 url = req.indexargs_href('', {})[1:]
1314 # handle editing an existing query
1315 try:
1316 qid = self.db.query.lookup(queryname)
1317 self.db.query.set(qid, klass=self.classname, url=url)
1318 except KeyError:
1319 # create a query
1320 qid = self.db.query.create(name=queryname,
1321 klass=self.classname, url=url)
1323 # and add it to the user's query multilink
1324 queries = self.db.user.get(self.userid, 'queries')
1325 queries.append(qid)
1326 self.db.user.set(self.userid, queries=queries)
1328 # commit the query change to the database
1329 self.db.commit()
1331 def searchPermission(self):
1332 ''' Determine whether the user has permission to search this class.
1334 Base behaviour is to check the user can view this class.
1335 '''
1336 if not self.db.security.hasPermission('View', self.userid,
1337 self.classname):
1338 return 0
1339 return 1
1342 def retireAction(self):
1343 ''' Retire the context item.
1344 '''
1345 # if we want to view the index template now, then unset the nodeid
1346 # context info (a special-case for retire actions on the index page)
1347 nodeid = self.nodeid
1348 if self.template == 'index':
1349 self.nodeid = None
1351 # generic edit is per-class only
1352 if not self.retirePermission():
1353 self.error_message.append(
1354 _('You do not have permission to retire %s' %self.classname))
1355 return
1357 # make sure we don't try to retire admin or anonymous
1358 if self.classname == 'user' and \
1359 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1360 self.error_message.append(
1361 _('You may not retire the admin or anonymous user'))
1362 return
1364 # do the retire
1365 self.db.getclass(self.classname).retire(nodeid)
1366 self.db.commit()
1368 self.ok_message.append(
1369 _('%(classname)s %(itemid)s has been retired')%{
1370 'classname': self.classname.capitalize(), 'itemid': nodeid})
1372 def retirePermission(self):
1373 ''' Determine whether the user has permission to retire this class.
1375 Base behaviour is to check the user can edit this class.
1376 '''
1377 if not self.db.security.hasPermission('Edit', self.userid,
1378 self.classname):
1379 return 0
1380 return 1
1383 def showAction(self, typere=re.compile('[@:]type'),
1384 numre=re.compile('[@:]number')):
1385 ''' Show a node of a particular class/id
1386 '''
1387 t = n = ''
1388 for key in self.form.keys():
1389 if typere.match(key):
1390 t = self.form[key].value.strip()
1391 elif numre.match(key):
1392 n = self.form[key].value.strip()
1393 if not t:
1394 raise ValueError, 'Invalid %s number'%t
1395 url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1396 raise Redirect, url
1398 def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1399 ''' Item properties and their values are edited with html FORM
1400 variables and their values. You can:
1402 - Change the value of some property of the current item.
1403 - Create a new item of any class, and edit the new item's
1404 properties,
1405 - Attach newly created items to a multilink property of the
1406 current item.
1407 - Remove items from a multilink property of the current item.
1408 - Specify that some properties are required for the edit
1409 operation to be successful.
1411 In the following, <bracketed> values are variable, "@" may be
1412 either ":" or "@", and other text "required" is fixed.
1414 Most properties are specified as form variables:
1416 <propname>
1417 - property on the current context item
1419 <designator>"@"<propname>
1420 - property on the indicated item (for editing related
1421 information)
1423 Designators name a specific item of a class.
1425 <classname><N>
1427 Name an existing item of class <classname>.
1429 <classname>"-"<N>
1431 Name the <N>th new item of class <classname>. If the form
1432 submission is successful, a new item of <classname> is
1433 created. Within the submitted form, a particular
1434 designator of this form always refers to the same new
1435 item.
1437 Once we have determined the "propname", we look at it to see
1438 if it's special:
1440 @required
1441 The associated form value is a comma-separated list of
1442 property names that must be specified when the form is
1443 submitted for the edit operation to succeed.
1445 When the <designator> is missing, the properties are
1446 for the current context item. When <designator> is
1447 present, they are for the item specified by
1448 <designator>.
1450 The "@required" specifier must come before any of the
1451 properties it refers to are assigned in the form.
1453 @remove@<propname>=id(s) or @add@<propname>=id(s)
1454 The "@add@" and "@remove@" edit actions apply only to
1455 Multilink properties. The form value must be a
1456 comma-separate list of keys for the class specified by
1457 the simple form variable. The listed items are added
1458 to (respectively, removed from) the specified
1459 property.
1461 @link@<propname>=<designator>
1462 If the edit action is "@link@", the simple form
1463 variable must specify a Link or Multilink property.
1464 The form value is a comma-separated list of
1465 designators. The item corresponding to each
1466 designator is linked to the property given by simple
1467 form variable. These are collected up and returned in
1468 all_links.
1470 None of the above (ie. just a simple form value)
1471 The value of the form variable is converted
1472 appropriately, depending on the type of the property.
1474 For a Link('klass') property, the form value is a
1475 single key for 'klass', where the key field is
1476 specified in dbinit.py.
1478 For a Multilink('klass') property, the form value is a
1479 comma-separated list of keys for 'klass', where the
1480 key field is specified in dbinit.py.
1482 Note that for simple-form-variables specifiying Link
1483 and Multilink properties, the linked-to class must
1484 have a key field.
1486 For a String() property specifying a filename, the
1487 file named by the form value is uploaded. This means we
1488 try to set additional properties "filename" and "type" (if
1489 they are valid for the class). Otherwise, the property
1490 is set to the form value.
1492 For Date(), Interval(), Boolean(), and Number()
1493 properties, the form value is converted to the
1494 appropriate
1496 Any of the form variables may be prefixed with a classname or
1497 designator.
1499 Two special form values are supported for backwards
1500 compatibility:
1502 @note
1503 This is equivalent to::
1505 @link@messages=msg-1
1506 @msg-1@content=value
1508 except that in addition, the "author" and "date"
1509 properties of "msg-1" are set to the userid of the
1510 submitter, and the current time, respectively.
1512 @file
1513 This is equivalent to::
1515 @link@files=file-1
1516 @file-1@content=value
1518 The String content value is handled as described above for
1519 file uploads.
1521 If both the "@note" and "@file" form variables are
1522 specified, the action::
1524 @link@msg-1@files=file-1
1526 is also performed.
1528 We also check that FileClass items have a "content" property with
1529 actual content, otherwise we remove them from all_props before
1530 returning.
1532 The return from this method is a dict of
1533 (classname, id): properties
1534 ... this dict _always_ has an entry for the current context,
1535 even if it's empty (ie. a submission for an existing issue that
1536 doesn't result in any changes would return {('issue','123'): {}})
1537 The id may be None, which indicates that an item should be
1538 created.
1539 '''
1540 # some very useful variables
1541 db = self.db
1542 form = self.form
1544 if not hasattr(self, 'FV_SPECIAL'):
1545 # generate the regexp for handling special form values
1546 classes = '|'.join(db.classes.keys())
1547 # specials for parsePropsFromForm
1548 # handle the various forms (see unit tests)
1549 self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1550 self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1552 # these indicate the default class / item
1553 default_cn = self.classname
1554 default_cl = self.db.classes[default_cn]
1555 default_nodeid = self.nodeid
1557 # we'll store info about the individual class/item edit in these
1558 all_required = {} # required props per class/item
1559 all_props = {} # props to set per class/item
1560 got_props = {} # props received per class/item
1561 all_propdef = {} # note - only one entry per class
1562 all_links = [] # as many as are required
1564 # we should always return something, even empty, for the context
1565 all_props[(default_cn, default_nodeid)] = {}
1567 keys = form.keys()
1568 timezone = db.getUserTimezone()
1570 # sentinels for the :note and :file props
1571 have_note = have_file = 0
1573 # extract the usable form labels from the form
1574 matches = []
1575 for key in keys:
1576 m = self.FV_SPECIAL.match(key)
1577 if m:
1578 matches.append((key, m.groupdict()))
1580 # now handle the matches
1581 for key, d in matches:
1582 if d['classname']:
1583 # we got a designator
1584 cn = d['classname']
1585 cl = self.db.classes[cn]
1586 nodeid = d['id']
1587 propname = d['propname']
1588 elif d['note']:
1589 # the special note field
1590 cn = 'msg'
1591 cl = self.db.classes[cn]
1592 nodeid = '-1'
1593 propname = 'content'
1594 all_links.append((default_cn, default_nodeid, 'messages',
1595 [('msg', '-1')]))
1596 have_note = 1
1597 elif d['file']:
1598 # the special file field
1599 cn = 'file'
1600 cl = self.db.classes[cn]
1601 nodeid = '-1'
1602 propname = 'content'
1603 all_links.append((default_cn, default_nodeid, 'files',
1604 [('file', '-1')]))
1605 have_file = 1
1606 else:
1607 # default
1608 cn = default_cn
1609 cl = default_cl
1610 nodeid = default_nodeid
1611 propname = d['propname']
1613 # the thing this value relates to is...
1614 this = (cn, nodeid)
1616 # get more info about the class, and the current set of
1617 # form props for it
1618 if not all_propdef.has_key(cn):
1619 all_propdef[cn] = cl.getprops()
1620 propdef = all_propdef[cn]
1621 if not all_props.has_key(this):
1622 all_props[this] = {}
1623 props = all_props[this]
1624 if not got_props.has_key(this):
1625 got_props[this] = {}
1627 # is this a link command?
1628 if d['link']:
1629 value = []
1630 for entry in extractFormList(form[key]):
1631 m = self.FV_DESIGNATOR.match(entry)
1632 if not m:
1633 raise ValueError, \
1634 'link "%s" value "%s" not a designator'%(key, entry)
1635 value.append((m.group(1), m.group(2)))
1637 # make sure the link property is valid
1638 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1639 not isinstance(propdef[propname], hyperdb.Link)):
1640 raise ValueError, '%s %s is not a link or '\
1641 'multilink property'%(cn, propname)
1643 all_links.append((cn, nodeid, propname, value))
1644 continue
1646 # detect the special ":required" variable
1647 if d['required']:
1648 all_required[this] = extractFormList(form[key])
1649 continue
1651 # see if we're performing a special multilink action
1652 mlaction = 'set'
1653 if d['remove']:
1654 mlaction = 'remove'
1655 elif d['add']:
1656 mlaction = 'add'
1658 # does the property exist?
1659 if not propdef.has_key(propname):
1660 if mlaction != 'set':
1661 raise ValueError, 'You have submitted a %s action for'\
1662 ' the property "%s" which doesn\'t exist'%(mlaction,
1663 propname)
1664 # the form element is probably just something we don't care
1665 # about - ignore it
1666 continue
1667 proptype = propdef[propname]
1669 # Get the form value. This value may be a MiniFieldStorage or a list
1670 # of MiniFieldStorages.
1671 value = form[key]
1673 # handle unpacking of the MiniFieldStorage / list form value
1674 if isinstance(proptype, hyperdb.Multilink):
1675 value = extractFormList(value)
1676 else:
1677 # multiple values are not OK
1678 if isinstance(value, type([])):
1679 raise ValueError, 'You have submitted more than one value'\
1680 ' for the %s property'%propname
1681 # value might be a file upload...
1682 if not hasattr(value, 'filename') or value.filename is None:
1683 # nope, pull out the value and strip it
1684 value = value.value.strip()
1686 # now that we have the props field, we need a teensy little
1687 # extra bit of help for the old :note field...
1688 if d['note'] and value:
1689 props['author'] = self.db.getuid()
1690 props['date'] = date.Date()
1692 # handle by type now
1693 if isinstance(proptype, hyperdb.Password):
1694 if not value:
1695 # ignore empty password values
1696 continue
1697 for key, d in matches:
1698 if d['confirm'] and d['propname'] == propname:
1699 confirm = form[key]
1700 break
1701 else:
1702 raise ValueError, 'Password and confirmation text do '\
1703 'not match'
1704 if isinstance(confirm, type([])):
1705 raise ValueError, 'You have submitted more than one value'\
1706 ' for the %s property'%propname
1707 if value != confirm.value:
1708 raise ValueError, 'Password and confirmation text do '\
1709 'not match'
1710 value = password.Password(value)
1712 elif isinstance(proptype, hyperdb.Link):
1713 # see if it's the "no selection" choice
1714 if value == '-1' or not value:
1715 # if we're creating, just don't include this property
1716 if not nodeid or nodeid.startswith('-'):
1717 continue
1718 value = None
1719 else:
1720 # handle key values
1721 link = proptype.classname
1722 if not num_re.match(value):
1723 try:
1724 value = db.classes[link].lookup(value)
1725 except KeyError:
1726 raise ValueError, _('property "%(propname)s": '
1727 '%(value)s not a %(classname)s')%{
1728 'propname': propname, 'value': value,
1729 'classname': link}
1730 except TypeError, message:
1731 raise ValueError, _('you may only enter ID values '
1732 'for property "%(propname)s": %(message)s')%{
1733 'propname': propname, 'message': message}
1734 elif isinstance(proptype, hyperdb.Multilink):
1735 # perform link class key value lookup if necessary
1736 link = proptype.classname
1737 link_cl = db.classes[link]
1738 l = []
1739 for entry in value:
1740 if not entry: continue
1741 if not num_re.match(entry):
1742 try:
1743 entry = link_cl.lookup(entry)
1744 except KeyError:
1745 raise ValueError, _('property "%(propname)s": '
1746 '"%(value)s" not an entry of %(classname)s')%{
1747 'propname': propname, 'value': entry,
1748 'classname': link}
1749 except TypeError, message:
1750 raise ValueError, _('you may only enter ID values '
1751 'for property "%(propname)s": %(message)s')%{
1752 'propname': propname, 'message': message}
1753 l.append(entry)
1754 l.sort()
1756 # now use that list of ids to modify the multilink
1757 if mlaction == 'set':
1758 value = l
1759 else:
1760 # we're modifying the list - get the current list of ids
1761 if props.has_key(propname):
1762 existing = props[propname]
1763 elif nodeid and not nodeid.startswith('-'):
1764 existing = cl.get(nodeid, propname, [])
1765 else:
1766 existing = []
1768 # now either remove or add
1769 if mlaction == 'remove':
1770 # remove - handle situation where the id isn't in
1771 # the list
1772 for entry in l:
1773 try:
1774 existing.remove(entry)
1775 except ValueError:
1776 raise ValueError, _('property "%(propname)s": '
1777 '"%(value)s" not currently in list')%{
1778 'propname': propname, 'value': entry}
1779 else:
1780 # add - easy, just don't dupe
1781 for entry in l:
1782 if entry not in existing:
1783 existing.append(entry)
1784 value = existing
1785 value.sort()
1787 elif value == '':
1788 # if we're creating, just don't include this property
1789 if not nodeid or nodeid.startswith('-'):
1790 continue
1791 # other types should be None'd if there's no value
1792 value = None
1793 else:
1794 # handle ValueErrors for all these in a similar fashion
1795 try:
1796 if isinstance(proptype, hyperdb.String):
1797 if (hasattr(value, 'filename') and
1798 value.filename is not None):
1799 # skip if the upload is empty
1800 if not value.filename:
1801 continue
1802 # this String is actually a _file_
1803 # try to determine the file content-type
1804 fn = value.filename.split('\\')[-1]
1805 if propdef.has_key('name'):
1806 props['name'] = fn
1807 # use this info as the type/filename properties
1808 if propdef.has_key('type'):
1809 props['type'] = mimetypes.guess_type(fn)[0]
1810 if not props['type']:
1811 props['type'] = "application/octet-stream"
1812 # finally, read the content
1813 value = value.value
1814 else:
1815 # normal String fix the CRLF/CR -> LF stuff
1816 value = fixNewlines(value)
1818 elif isinstance(proptype, hyperdb.Date):
1819 value = date.Date(value, offset=timezone)
1820 elif isinstance(proptype, hyperdb.Interval):
1821 value = date.Interval(value)
1822 elif isinstance(proptype, hyperdb.Boolean):
1823 value = value.lower() in ('yes', 'true', 'on', '1')
1824 elif isinstance(proptype, hyperdb.Number):
1825 value = float(value)
1826 except ValueError, msg:
1827 raise ValueError, _('Error with %s property: %s')%(
1828 propname, msg)
1830 # register that we got this property
1831 if value:
1832 got_props[this][propname] = 1
1834 # get the old value
1835 if nodeid and not nodeid.startswith('-'):
1836 try:
1837 existing = cl.get(nodeid, propname)
1838 except KeyError:
1839 # this might be a new property for which there is
1840 # no existing value
1841 if not propdef.has_key(propname):
1842 raise
1844 # make sure the existing multilink is sorted
1845 if isinstance(proptype, hyperdb.Multilink):
1846 existing.sort()
1848 # "missing" existing values may not be None
1849 if not existing:
1850 if isinstance(proptype, hyperdb.String) and not existing:
1851 # some backends store "missing" Strings as empty strings
1852 existing = None
1853 elif isinstance(proptype, hyperdb.Number) and not existing:
1854 # some backends store "missing" Numbers as 0 :(
1855 existing = 0
1856 elif isinstance(proptype, hyperdb.Boolean) and not existing:
1857 # likewise Booleans
1858 existing = 0
1860 # if changed, set it
1861 if value != existing:
1862 props[propname] = value
1863 else:
1864 # don't bother setting empty/unset values
1865 if value is None:
1866 continue
1867 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1868 continue
1869 elif isinstance(proptype, hyperdb.String) and value == '':
1870 continue
1872 props[propname] = value
1874 # check to see if we need to specially link a file to the note
1875 if have_note and have_file:
1876 all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1878 # see if all the required properties have been supplied
1879 s = []
1880 for thing, required in all_required.items():
1881 # register the values we got
1882 got = got_props.get(thing, {})
1883 for entry in required[:]:
1884 if got.has_key(entry):
1885 required.remove(entry)
1887 # any required values not present?
1888 if not required:
1889 continue
1891 # tell the user to entry the values required
1892 if len(required) > 1:
1893 p = 'properties'
1894 else:
1895 p = 'property'
1896 s.append('Required %s %s %s not supplied'%(thing[0], p,
1897 ', '.join(required)))
1898 if s:
1899 raise ValueError, '\n'.join(s)
1901 # When creating a FileClass node, it should have a non-empty content
1902 # property to be created. When editing a FileClass node, it should
1903 # either have a non-empty content property or no property at all. In
1904 # the latter case, nothing will change.
1905 for (cn, id), props in all_props.items():
1906 if isinstance(self.db.classes[cn], hyperdb.FileClass):
1907 if id == '-1':
1908 if not props.get('content', ''):
1909 del all_props[(cn, id)]
1910 elif props.has_key('content') and not props['content']:
1911 raise ValueError, _('File is empty')
1912 return all_props, all_links
1914 def fixNewlines(text):
1915 ''' Homogenise line endings.
1917 Different web clients send different line ending values, but
1918 other systems (eg. email) don't necessarily handle those line
1919 endings. Our solution is to convert all line endings to LF.
1920 '''
1921 text = text.replace('\r\n', '\n')
1922 return text.replace('\r', '\n')
1924 def extractFormList(value):
1925 ''' Extract a list of values from the form value.
1927 It may be one of:
1928 [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
1929 MiniFieldStorage('value,value,...')
1930 MiniFieldStorage('value')
1931 '''
1932 # multiple values are OK
1933 if isinstance(value, type([])):
1934 # it's a list of MiniFieldStorages - join then into
1935 values = ','.join([i.value.strip() for i in value])
1936 else:
1937 # it's a MiniFieldStorage, but may be a comma-separated list
1938 # of values
1939 values = value.value
1941 value = [i.strip() for i in values.split(',')]
1943 # filter out the empty bits
1944 return filter(None, value)