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