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