1 # $Id: client.py,v 1.130 2003-08-13 23:51:59 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 # figure out the context and desired content template
236 # do this first so we don't authenticate for static files
237 # Note: this method opens the database as "admin" in order to
238 # perform context checks
239 self.determine_context()
241 # make sure we're identified (even anonymously)
242 self.determine_user()
244 # possibly handle a form submit action (may change self.classname
245 # and self.template, and may also append error/ok_messages)
246 self.handle_action()
248 # now render the page
249 # we don't want clients caching our dynamic pages
250 self.additional_headers['Cache-Control'] = 'no-cache'
251 # Pragma: no-cache makes Mozilla and its ilk double-load all pages!!
252 # self.additional_headers['Pragma'] = 'no-cache'
254 # expire this page 5 seconds from now
255 date = rfc822.formatdate(time.time() + 5)
256 self.additional_headers['Expires'] = date
258 # render the content
259 self.write(self.renderContext())
260 except Redirect, url:
261 # let's redirect - if the url isn't None, then we need to do
262 # the headers, otherwise the headers have been set before the
263 # exception was raised
264 if url:
265 self.additional_headers['Location'] = url
266 self.response_code = 302
267 self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
268 except SendFile, designator:
269 self.serve_file(designator)
270 except SendStaticFile, file:
271 try:
272 self.serve_static_file(str(file))
273 except NotModified:
274 # send the 304 response
275 self.request.send_response(304)
276 self.request.end_headers()
277 except Unauthorised, message:
278 self.classname = None
279 self.template = ''
280 self.error_message.append(message)
281 self.write(self.renderContext())
282 except NotFound:
283 # pass through
284 raise
285 except:
286 # everything else
287 self.write(cgitb.html())
289 def clean_sessions(self):
290 ''' Age sessions, remove when they haven't been used for a week.
292 Do it only once an hour.
294 Note: also cleans One Time Keys, and other "session" based
295 stuff.
296 '''
297 sessions = self.db.sessions
298 last_clean = sessions.get('last_clean', 'last_use') or 0
300 week = 60*60*24*7
301 hour = 60*60
302 now = time.time()
303 if now - last_clean > hour:
304 # remove aged sessions
305 for sessid in sessions.list():
306 interval = now - sessions.get(sessid, 'last_use')
307 if interval > week:
308 sessions.destroy(sessid)
309 # remove aged otks
310 otks = self.db.otks
311 for sessid in otks.list():
312 interval = now - otks.get(sessid, '__time')
313 if interval > week:
314 otks.destroy(sessid)
315 sessions.set('last_clean', last_use=time.time())
317 def determine_user(self):
318 ''' Determine who the user is
319 '''
320 # open the database as admin
321 self.opendb('admin')
323 # clean age sessions
324 self.clean_sessions()
326 # make sure we have the session Class
327 sessions = self.db.sessions
329 # look up the user session cookie
330 cookie = Cookie.SimpleCookie(self.env.get('HTTP_COOKIE', ''))
331 user = 'anonymous'
333 # bump the "revision" of the cookie since the format changed
334 if (cookie.has_key(self.cookie_name) and
335 cookie[self.cookie_name].value != 'deleted'):
337 # get the session key from the cookie
338 self.session = cookie[self.cookie_name].value
339 # get the user from the session
340 try:
341 # update the lifetime datestamp
342 sessions.set(self.session, last_use=time.time())
343 sessions.commit()
344 user = sessions.get(self.session, 'user')
345 except KeyError:
346 user = 'anonymous'
348 # sanity check on the user still being valid, getting the userid
349 # at the same time
350 try:
351 self.userid = self.db.user.lookup(user)
352 except (KeyError, TypeError):
353 user = 'anonymous'
355 # make sure the anonymous user is valid if we're using it
356 if user == 'anonymous':
357 self.make_user_anonymous()
358 else:
359 self.user = user
361 # reopen the database as the correct user
362 self.opendb(self.user)
364 def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
365 ''' Determine the context of this page from the URL:
367 The URL path after the instance identifier is examined. The path
368 is generally only one entry long.
370 - if there is no path, then we are in the "home" context.
371 * if the path is "_file", then the additional path entry
372 specifies the filename of a static file we're to serve up
373 from the instance "html" directory. Raises a SendStaticFile
374 exception.
375 - if there is something in the path (eg "issue"), it identifies
376 the tracker class we're to display.
377 - if the path is an item designator (eg "issue123"), then we're
378 to display a specific item.
379 * if the path starts with an item designator and is longer than
380 one entry, then we're assumed to be handling an item of a
381 FileClass, and the extra path information gives the filename
382 that the client is going to label the download with (ie
383 "file123/image.png" is nicer to download than "file123"). This
384 raises a SendFile exception.
386 Both of the "*" types of contexts stop before we bother to
387 determine the template we're going to use. That's because they
388 don't actually use templates.
390 The template used is specified by the :template CGI variable,
391 which defaults to:
393 only classname suplied: "index"
394 full item designator supplied: "item"
396 We set:
397 self.classname - the class to display, can be None
398 self.template - the template to render the current context with
399 self.nodeid - the nodeid of the class we're displaying
400 '''
401 # default the optional variables
402 self.classname = None
403 self.nodeid = None
405 # see if a template or messages are specified
406 template_override = ok_message = error_message = None
407 for key in self.form.keys():
408 if self.FV_TEMPLATE.match(key):
409 template_override = self.form[key].value
410 elif self.FV_OK_MESSAGE.match(key):
411 ok_message = self.form[key].value
412 ok_message = clean_message(ok_message)
413 elif self.FV_ERROR_MESSAGE.match(key):
414 error_message = self.form[key].value
415 error_message = clean_message(error_message)
417 # determine the classname and possibly nodeid
418 path = self.path.split('/')
419 if not path or path[0] in ('', 'home', 'index'):
420 if template_override is not None:
421 self.template = template_override
422 else:
423 self.template = ''
424 return
425 elif path[0] == '_file':
426 raise SendStaticFile, os.path.join(*path[1:])
427 else:
428 self.classname = path[0]
429 if len(path) > 1:
430 # send the file identified by the designator in path[0]
431 raise SendFile, path[0]
433 # we need the db for further context stuff - open it as admin
434 self.opendb('admin')
436 # see if we got a designator
437 m = dre.match(self.classname)
438 if m:
439 self.classname = m.group(1)
440 self.nodeid = m.group(2)
441 if not self.db.getclass(self.classname).hasnode(self.nodeid):
442 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
443 # with a designator, we default to item view
444 self.template = 'item'
445 else:
446 # with only a class, we default to index view
447 self.template = 'index'
449 # make sure the classname is valid
450 try:
451 self.db.getclass(self.classname)
452 except KeyError:
453 raise NotFound, self.classname
455 # see if we have a template override
456 if template_override is not None:
457 self.template = template_override
459 # see if we were passed in a message
460 if ok_message:
461 self.ok_message.append(ok_message)
462 if error_message:
463 self.error_message.append(error_message)
465 def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
466 ''' Serve the file from the content property of the designated item.
467 '''
468 m = dre.match(str(designator))
469 if not m:
470 raise NotFound, str(designator)
471 classname, nodeid = m.group(1), m.group(2)
472 if classname != 'file':
473 raise NotFound, designator
475 # we just want to serve up the file named
476 self.opendb('admin')
477 file = self.db.file
478 self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
479 self.write(file.get(nodeid, 'content'))
481 def serve_static_file(self, file):
482 ims = None
483 # see if there's an if-modified-since...
484 if hasattr(self.request, 'headers'):
485 ims = self.request.headers.getheader('if-modified-since')
486 elif self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
487 # cgi will put the header in the env var
488 ims = self.env['HTTP_IF_MODIFIED_SINCE']
489 filename = os.path.join(self.instance.config.TEMPLATES, file)
490 lmt = os.stat(filename)[stat.ST_MTIME]
491 if ims:
492 ims = rfc822.parsedate(ims)[:6]
493 lmtt = time.gmtime(lmt)[:6]
494 if lmtt <= ims:
495 raise NotModified
497 # we just want to serve up the file named
498 file = str(file)
499 mt = mimetypes.guess_type(file)[0]
500 if not mt:
501 if file.endswith('.css'):
502 mt = 'text/css'
503 else:
504 mt = 'text/plain'
505 self.additional_headers['Content-Type'] = mt
506 self.additional_headers['Last-Modifed'] = rfc822.formatdate(lmt)
507 self.write(open(filename, 'rb').read())
509 def renderContext(self):
510 ''' Return a PageTemplate for the named page
511 '''
512 name = self.classname
513 extension = self.template
514 pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
516 # catch errors so we can handle PT rendering errors more nicely
517 args = {
518 'ok_message': self.ok_message,
519 'error_message': self.error_message
520 }
521 try:
522 # let the template render figure stuff out
523 return pt.render(self, None, None, **args)
524 except NoTemplate, message:
525 return '<strong>%s</strong>'%message
526 except:
527 # everything else
528 return cgitb.pt_html()
530 # these are the actions that are available
531 actions = (
532 ('edit', 'editItemAction'),
533 ('editcsv', 'editCSVAction'),
534 ('new', 'newItemAction'),
535 ('register', 'registerAction'),
536 ('confrego', 'confRegoAction'),
537 ('passrst', 'passResetAction'),
538 ('login', 'loginAction'),
539 ('logout', 'logout_action'),
540 ('search', 'searchAction'),
541 ('retire', 'retireAction'),
542 ('show', 'showAction'),
543 )
544 def handle_action(self):
545 ''' Determine whether there should be an Action called.
547 The action is defined by the form variable :action which
548 identifies the method on this object to call. The actions
549 are defined in the "actions" sequence on this class.
550 '''
551 if self.form.has_key(':action'):
552 action = self.form[':action'].value.lower()
553 elif self.form.has_key('@action'):
554 action = self.form['@action'].value.lower()
555 else:
556 return None
557 try:
558 # get the action, validate it
559 for name, method in self.actions:
560 if name == action:
561 break
562 else:
563 raise ValueError, 'No such action "%s"'%action
564 # call the mapped action
565 getattr(self, method)()
566 except Redirect:
567 raise
568 except Unauthorised:
569 raise
571 def write(self, content):
572 if not self.headers_done:
573 self.header()
574 self.request.wfile.write(content)
576 def header(self, headers=None, response=None):
577 '''Put up the appropriate header.
578 '''
579 if headers is None:
580 headers = {'Content-Type':'text/html'}
581 if response is None:
582 response = self.response_code
584 # update with additional info
585 headers.update(self.additional_headers)
587 if not headers.has_key('Content-Type'):
588 headers['Content-Type'] = 'text/html'
589 self.request.send_response(response)
590 for entry in headers.items():
591 self.request.send_header(*entry)
592 self.request.end_headers()
593 self.headers_done = 1
594 if self.debug:
595 self.headers_sent = headers
597 def set_cookie(self, user):
598 ''' Set up a session cookie for the user and store away the user's
599 login info against the session.
600 '''
601 # TODO generate a much, much stronger session key ;)
602 self.session = binascii.b2a_base64(repr(random.random())).strip()
604 # clean up the base64
605 if self.session[-1] == '=':
606 if self.session[-2] == '=':
607 self.session = self.session[:-2]
608 else:
609 self.session = self.session[:-1]
611 # insert the session in the sessiondb
612 self.db.sessions.set(self.session, user=user, last_use=time.time())
614 # and commit immediately
615 self.db.sessions.commit()
617 # expire us in a long, long time
618 expire = Cookie._getdate(86400*365)
620 # generate the cookie path - make sure it has a trailing '/'
621 self.additional_headers['Set-Cookie'] = \
622 '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
623 expire, self.cookie_path)
625 def make_user_anonymous(self):
626 ''' Make us anonymous
628 This method used to handle non-existence of the 'anonymous'
629 user, but that user is mandatory now.
630 '''
631 self.userid = self.db.user.lookup('anonymous')
632 self.user = 'anonymous'
634 def opendb(self, user):
635 ''' Open the database.
636 '''
637 # open the db if the user has changed
638 if not hasattr(self, 'db') or user != self.db.journaltag:
639 if hasattr(self, 'db'):
640 self.db.close()
641 self.db = self.instance.open(user)
643 #
644 # Actions
645 #
646 def loginAction(self):
647 ''' Attempt to log a user in.
649 Sets up a session for the user which contains the login
650 credentials.
651 '''
652 # we need the username at a minimum
653 if not self.form.has_key('__login_name'):
654 self.error_message.append(_('Username required'))
655 return
657 # get the login info
658 self.user = self.form['__login_name'].value
659 if self.form.has_key('__login_password'):
660 password = self.form['__login_password'].value
661 else:
662 password = ''
664 # make sure the user exists
665 try:
666 self.userid = self.db.user.lookup(self.user)
667 except KeyError:
668 name = self.user
669 self.error_message.append(_('No such user "%(name)s"')%locals())
670 self.make_user_anonymous()
671 return
673 # verify the password
674 if not self.verifyPassword(self.userid, password):
675 self.make_user_anonymous()
676 self.error_message.append(_('Incorrect password'))
677 return
679 # make sure we're allowed to be here
680 if not self.loginPermission():
681 self.make_user_anonymous()
682 self.error_message.append(_("You do not have permission to login"))
683 return
685 # now we're OK, re-open the database for real, using the user
686 self.opendb(self.user)
688 # set the session cookie
689 self.set_cookie(self.user)
691 def verifyPassword(self, userid, password):
692 ''' Verify the password that the user has supplied
693 '''
694 stored = self.db.user.get(self.userid, 'password')
695 if password == stored:
696 return 1
697 if not password and not stored:
698 return 1
699 return 0
701 def loginPermission(self):
702 ''' Determine whether the user has permission to log in.
704 Base behaviour is to check the user has "Web Access".
705 '''
706 if not self.db.security.hasPermission('Web Access', self.userid):
707 return 0
708 return 1
710 def logout_action(self):
711 ''' Make us really anonymous - nuke the cookie too
712 '''
713 # log us out
714 self.make_user_anonymous()
716 # construct the logout cookie
717 now = Cookie._getdate()
718 self.additional_headers['Set-Cookie'] = \
719 '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.cookie_name,
720 now, self.cookie_path)
722 # Let the user know what's going on
723 self.ok_message.append(_('You are logged out'))
725 def registerAction(self):
726 '''Attempt to create a new user based on the contents of the form
727 and then set the cookie.
729 return 1 on successful login
730 '''
731 # parse the props from the form
732 try:
733 props = self.parsePropsFromForm()[0][('user', None)]
734 except (ValueError, KeyError), message:
735 self.error_message.append(_('Error: ') + str(message))
736 return
738 # make sure we're allowed to register
739 if not self.registerPermission(props):
740 raise Unauthorised, _("You do not have permission to register")
742 try:
743 self.db.user.lookup(props['username'])
744 self.error_message.append('Error: A user with the username "%s" '
745 'already exists'%props['username'])
746 return
747 except KeyError:
748 pass
750 # generate the one-time-key and store the props for later
751 otk = ''.join([random.choice(chars) for x in range(32)])
752 for propname, proptype in self.db.user.getprops().items():
753 value = props.get(propname, None)
754 if value is None:
755 pass
756 elif isinstance(proptype, hyperdb.Date):
757 props[propname] = str(value)
758 elif isinstance(proptype, hyperdb.Interval):
759 props[propname] = str(value)
760 elif isinstance(proptype, hyperdb.Password):
761 props[propname] = str(value)
762 props['__time'] = time.time()
763 self.db.otks.set(otk, **props)
765 # send the email
766 tracker_name = self.db.config.TRACKER_NAME
767 subject = 'Complete your registration to %s'%tracker_name
768 body = '''
769 To complete your registration of the user "%(name)s" with %(tracker)s,
770 please visit the following URL:
772 %(url)s?@action=confrego&otk=%(otk)s
773 '''%{'name': props['username'], 'tracker': tracker_name, 'url': self.base,
774 'otk': otk}
775 if not self.sendEmail(props['address'], subject, body):
776 return
778 # commit changes to the database
779 self.db.commit()
781 # redirect to the "you're almost there" page
782 raise Redirect, '%suser?@template=rego_progress'%self.base
784 def sendEmail(self, to, subject, content):
785 # send email to the user's email address
786 message = StringIO.StringIO()
787 writer = MimeWriter.MimeWriter(message)
788 tracker_name = self.db.config.TRACKER_NAME
789 writer.addheader('Subject', encode_header(subject))
790 writer.addheader('To', to)
791 writer.addheader('From', roundupdb.straddr((tracker_name,
792 self.db.config.ADMIN_EMAIL)))
793 writer.addheader('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000",
794 time.gmtime()))
795 # add a uniquely Roundup header to help filtering
796 writer.addheader('X-Roundup-Name', tracker_name)
797 # avoid email loops
798 writer.addheader('X-Roundup-Loop', 'hello')
799 writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
800 body = writer.startbody('text/plain; charset=utf-8')
802 # message body, encoded quoted-printable
803 content = StringIO.StringIO(content)
804 quopri.encode(content, body, 0)
806 if SENDMAILDEBUG:
807 # don't send - just write to a file
808 open(SENDMAILDEBUG, 'a').write('FROM: %s\nTO: %s\n%s\n'%(
809 self.db.config.ADMIN_EMAIL,
810 ', '.join(to),message.getvalue()))
811 else:
812 # now try to send the message
813 try:
814 # send the message as admin so bounces are sent there
815 # instead of to roundup
816 smtp = openSMTPConnection(self.db.config)
817 smtp.sendmail(self.db.config.ADMIN_EMAIL, [to],
818 message.getvalue())
819 except socket.error, value:
820 self.error_message.append("Error: couldn't send email: "
821 "mailhost %s"%value)
822 return 0
823 except smtplib.SMTPException, msg:
824 self.error_message.append("Error: couldn't send email: %s"%msg)
825 return 0
826 return 1
828 def registerPermission(self, props):
829 ''' Determine whether the user has permission to register
831 Base behaviour is to check the user has "Web Registration".
832 '''
833 # registration isn't allowed to supply roles
834 if props.has_key('roles'):
835 return 0
836 if self.db.security.hasPermission('Web Registration', self.userid):
837 return 1
838 return 0
840 def confRegoAction(self):
841 ''' Grab the OTK, use it to load up the new user details
842 '''
843 # pull the rego information out of the otk database
844 otk = self.form['otk'].value
845 props = self.db.otks.getall(otk)
846 for propname, proptype in self.db.user.getprops().items():
847 value = props.get(propname, None)
848 if value is None:
849 pass
850 elif isinstance(proptype, hyperdb.Date):
851 props[propname] = date.Date(value)
852 elif isinstance(proptype, hyperdb.Interval):
853 props[propname] = date.Interval(value)
854 elif isinstance(proptype, hyperdb.Password):
855 props[propname] = password.Password()
856 props[propname].unpack(value)
858 # re-open the database as "admin"
859 if self.user != 'admin':
860 self.opendb('admin')
862 # create the new user
863 cl = self.db.user
864 # XXX we need to make the "default" page be able to display errors!
865 try:
866 props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
867 del props['__time']
868 self.userid = cl.create(**props)
869 # clear the props from the otk database
870 self.db.otks.destroy(otk)
871 self.db.commit()
872 except (ValueError, KeyError), message:
873 self.error_message.append(str(message))
874 return
876 # log the new user in
877 self.user = cl.get(self.userid, 'username')
878 # re-open the database for real, using the user
879 self.opendb(self.user)
881 # if we have a session, update it
882 if hasattr(self, 'session'):
883 self.db.sessions.set(self.session, user=self.user,
884 last_use=time.time())
885 else:
886 # new session cookie
887 self.set_cookie(self.user)
889 # nice message
890 message = _('You are now registered, welcome!')
892 # redirect to the user's page
893 raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
894 self.userid, urllib.quote(message))
896 def passResetAction(self):
897 ''' Handle password reset requests.
899 Presence of either "name" or "address" generate email.
900 Presense of "otk" performs the reset.
901 '''
902 if self.form.has_key('otk'):
903 # pull the rego information out of the otk database
904 otk = self.form['otk'].value
905 uid = self.db.otks.get(otk, 'uid')
906 if uid is None:
907 self.error_message.append('Invalid One Time Key!')
908 return
910 # re-open the database as "admin"
911 if self.user != 'admin':
912 self.opendb('admin')
914 # change the password
915 newpw = password.generatePassword()
917 cl = self.db.user
918 # XXX we need to make the "default" page be able to display errors!
919 try:
920 # set the password
921 cl.set(uid, password=password.Password(newpw))
922 # clear the props from the otk database
923 self.db.otks.destroy(otk)
924 self.db.commit()
925 except (ValueError, KeyError), message:
926 self.error_message.append(str(message))
927 return
929 # user info
930 address = self.db.user.get(uid, 'address')
931 name = self.db.user.get(uid, 'username')
933 # send the email
934 tracker_name = self.db.config.TRACKER_NAME
935 subject = 'Password reset for %s'%tracker_name
936 body = '''
937 The password has been reset for username "%(name)s".
939 Your password is now: %(password)s
940 '''%{'name': name, 'password': newpw}
941 if not self.sendEmail(address, subject, body):
942 return
944 self.ok_message.append('Password reset and email sent to %s'%address)
945 return
947 # no OTK, so now figure the user
948 if self.form.has_key('username'):
949 name = self.form['username'].value
950 try:
951 uid = self.db.user.lookup(name)
952 except KeyError:
953 self.error_message.append('Unknown username')
954 return
955 address = self.db.user.get(uid, 'address')
956 elif self.form.has_key('address'):
957 address = self.form['address'].value
958 uid = uidFromAddress(self.db, ('', address), create=0)
959 if not uid:
960 self.error_message.append('Unknown email address')
961 return
962 name = self.db.user.get(uid, 'username')
963 else:
964 self.error_message.append('You need to specify a username '
965 'or address')
966 return
968 # generate the one-time-key and store the props for later
969 otk = ''.join([random.choice(chars) for x in range(32)])
970 self.db.otks.set(otk, uid=uid, __time=time.time())
972 # send the email
973 tracker_name = self.db.config.TRACKER_NAME
974 subject = 'Confirm reset of password for %s'%tracker_name
975 body = '''
976 Someone, perhaps you, has requested that the password be changed for your
977 username, "%(name)s". If you wish to proceed with the change, please follow
978 the link below:
980 %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
982 You should then receive another email with the new password.
983 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
984 if not self.sendEmail(address, subject, body):
985 return
987 self.ok_message.append('Email sent to %s'%address)
989 def editItemAction(self):
990 ''' Perform an edit of an item in the database.
992 See parsePropsFromForm and _editnodes for special variables
993 '''
994 # parse the props from the form
995 try:
996 props, links = self.parsePropsFromForm()
997 except (ValueError, KeyError), message:
998 self.error_message.append(_('Error: ') + str(message))
999 return
1001 # handle the props
1002 try:
1003 message = self._editnodes(props, links)
1004 except (ValueError, KeyError, IndexError), message:
1005 self.error_message.append(_('Error: ') + str(message))
1006 return
1008 # commit now that all the tricky stuff is done
1009 self.db.commit()
1011 # redirect to the item's edit page
1012 raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
1013 self.classname, self.nodeid, urllib.quote(message),
1014 urllib.quote(self.template))
1016 def editItemPermission(self, props):
1017 ''' Determine whether the user has permission to edit this item.
1019 Base behaviour is to check the user can edit this class. If we're
1020 editing the "user" class, users are allowed to edit their own
1021 details. Unless it's the "roles" property, which requires the
1022 special Permission "Web Roles".
1023 '''
1024 # if this is a user node and the user is editing their own node, then
1025 # we're OK
1026 has = self.db.security.hasPermission
1027 if self.classname == 'user':
1028 # reject if someone's trying to edit "roles" and doesn't have the
1029 # right permission.
1030 if props.has_key('roles') and not has('Web Roles', self.userid,
1031 'user'):
1032 return 0
1033 # if the item being edited is the current user, we're ok
1034 if self.nodeid == self.userid:
1035 return 1
1036 if self.db.security.hasPermission('Edit', self.userid, self.classname):
1037 return 1
1038 return 0
1040 def newItemAction(self):
1041 ''' Add a new item to the database.
1043 This follows the same form as the editItemAction, with the same
1044 special form values.
1045 '''
1046 # parse the props from the form
1047 try:
1048 props, links = self.parsePropsFromForm()
1049 except (ValueError, KeyError), message:
1050 self.error_message.append(_('Error: ') + str(message))
1051 return
1053 # handle the props - edit or create
1054 try:
1055 # when it hits the None element, it'll set self.nodeid
1056 messages = self._editnodes(props, links)
1058 except (ValueError, KeyError, IndexError), message:
1059 # these errors might just be indicative of user dumbness
1060 self.error_message.append(_('Error: ') + str(message))
1061 return
1063 # commit now that all the tricky stuff is done
1064 self.db.commit()
1066 # redirect to the new item's page
1067 raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
1068 self.classname, self.nodeid, urllib.quote(messages),
1069 urllib.quote(self.template))
1071 def newItemPermission(self, props):
1072 ''' Determine whether the user has permission to create (edit) this
1073 item.
1075 Base behaviour is to check the user can edit this class. No
1076 additional property checks are made. Additionally, new user items
1077 may be created if the user has the "Web Registration" Permission.
1078 '''
1079 has = self.db.security.hasPermission
1080 if self.classname == 'user' and has('Web Registration', self.userid,
1081 'user'):
1082 return 1
1083 if has('Edit', self.userid, self.classname):
1084 return 1
1085 return 0
1088 #
1089 # Utility methods for editing
1090 #
1091 def _editnodes(self, all_props, all_links, newids=None):
1092 ''' Use the props in all_props to perform edit and creation, then
1093 use the link specs in all_links to do linking.
1094 '''
1095 # figure dependencies and re-work links
1096 deps = {}
1097 links = {}
1098 for cn, nodeid, propname, vlist in all_links:
1099 if not all_props.has_key((cn, nodeid)):
1100 # link item to link to doesn't (and won't) exist
1101 continue
1102 for value in vlist:
1103 if not all_props.has_key(value):
1104 # link item to link to doesn't (and won't) exist
1105 continue
1106 deps.setdefault((cn, nodeid), []).append(value)
1107 links.setdefault(value, []).append((cn, nodeid, propname))
1109 # figure chained dependencies ordering
1110 order = []
1111 done = {}
1112 # loop detection
1113 change = 0
1114 while len(all_props) != len(done):
1115 for needed in all_props.keys():
1116 if done.has_key(needed):
1117 continue
1118 tlist = deps.get(needed, [])
1119 for target in tlist:
1120 if not done.has_key(target):
1121 break
1122 else:
1123 done[needed] = 1
1124 order.append(needed)
1125 change = 1
1126 if not change:
1127 raise ValueError, 'linking must not loop!'
1129 # now, edit / create
1130 m = []
1131 for needed in order:
1132 props = all_props[needed]
1133 if not props:
1134 # nothing to do
1135 continue
1136 cn, nodeid = needed
1138 if nodeid is not None and int(nodeid) > 0:
1139 # make changes to the node
1140 props = self._changenode(cn, nodeid, props)
1142 # and some nice feedback for the user
1143 if props:
1144 info = ', '.join(props.keys())
1145 m.append('%s %s %s edited ok'%(cn, nodeid, info))
1146 else:
1147 m.append('%s %s - nothing changed'%(cn, nodeid))
1148 else:
1149 assert props
1151 # make a new node
1152 newid = self._createnode(cn, props)
1153 if nodeid is None:
1154 self.nodeid = newid
1155 nodeid = newid
1157 # and some nice feedback for the user
1158 m.append('%s %s created'%(cn, newid))
1160 # fill in new ids in links
1161 if links.has_key(needed):
1162 for linkcn, linkid, linkprop in links[needed]:
1163 props = all_props[(linkcn, linkid)]
1164 cl = self.db.classes[linkcn]
1165 propdef = cl.getprops()[linkprop]
1166 if not props.has_key(linkprop):
1167 if linkid is None or linkid.startswith('-'):
1168 # linking to a new item
1169 if isinstance(propdef, hyperdb.Multilink):
1170 props[linkprop] = [newid]
1171 else:
1172 props[linkprop] = newid
1173 else:
1174 # linking to an existing item
1175 if isinstance(propdef, hyperdb.Multilink):
1176 existing = cl.get(linkid, linkprop)[:]
1177 existing.append(nodeid)
1178 props[linkprop] = existing
1179 else:
1180 props[linkprop] = newid
1182 return '<br>'.join(m)
1184 def _changenode(self, cn, nodeid, props):
1185 ''' change the node based on the contents of the form
1186 '''
1187 # check for permission
1188 if not self.editItemPermission(props):
1189 raise Unauthorised, 'You do not have permission to edit %s'%cn
1191 # make the changes
1192 cl = self.db.classes[cn]
1193 return cl.set(nodeid, **props)
1195 def _createnode(self, cn, props):
1196 ''' create a node based on the contents of the form
1197 '''
1198 # check for permission
1199 if not self.newItemPermission(props):
1200 raise Unauthorised, 'You do not have permission to create %s'%cn
1202 # create the node and return its id
1203 cl = self.db.classes[cn]
1204 return cl.create(**props)
1206 #
1207 # More actions
1208 #
1209 def editCSVAction(self):
1210 ''' Performs an edit of all of a class' items in one go.
1212 The "rows" CGI var defines the CSV-formatted entries for the
1213 class. New nodes are identified by the ID 'X' (or any other
1214 non-existent ID) and removed lines are retired.
1215 '''
1216 # this is per-class only
1217 if not self.editCSVPermission():
1218 self.error_message.append(
1219 _('You do not have permission to edit %s' %self.classname))
1221 # get the CSV module
1222 try:
1223 import csv
1224 except ImportError:
1225 self.error_message.append(_(
1226 'Sorry, you need the csv module to use this function.<br>\n'
1227 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
1228 return
1230 cl = self.db.classes[self.classname]
1231 idlessprops = cl.getprops(protected=0).keys()
1232 idlessprops.sort()
1233 props = ['id'] + idlessprops
1235 # do the edit
1236 rows = self.form['rows'].value.splitlines()
1237 p = csv.parser()
1238 found = {}
1239 line = 0
1240 for row in rows[1:]:
1241 line += 1
1242 values = p.parse(row)
1243 # not a complete row, keep going
1244 if not values: continue
1246 # skip property names header
1247 if values == props:
1248 continue
1250 # extract the nodeid
1251 nodeid, values = values[0], values[1:]
1252 found[nodeid] = 1
1254 # see if the node exists
1255 if cl.hasnode(nodeid):
1256 exists = 1
1257 else:
1258 exists = 0
1260 # confirm correct weight
1261 if len(idlessprops) != len(values):
1262 self.error_message.append(
1263 _('Not enough values on line %(line)s')%{'line':line})
1264 return
1266 # extract the new values
1267 d = {}
1268 for name, value in zip(idlessprops, values):
1269 prop = cl.properties[name]
1270 value = value.strip()
1271 # only add the property if it has a value
1272 if value:
1273 # if it's a multilink, split it
1274 if isinstance(prop, hyperdb.Multilink):
1275 value = value.split(':')
1276 elif isinstance(prop, hyperdb.Password):
1277 value = password.Password(value)
1278 elif isinstance(prop, hyperdb.Interval):
1279 value = date.Interval(value)
1280 elif isinstance(prop, hyperdb.Date):
1281 value = date.Date(value)
1282 elif isinstance(prop, hyperdb.Boolean):
1283 value = value.lower() in ('yes', 'true', 'on', '1')
1284 elif isinstance(prop, hyperdb.Number):
1285 value = float(value)
1286 d[name] = value
1287 elif exists:
1288 # nuke the existing value
1289 if isinstance(prop, hyperdb.Multilink):
1290 d[name] = []
1291 else:
1292 d[name] = None
1294 # perform the edit
1295 if exists:
1296 # edit existing
1297 cl.set(nodeid, **d)
1298 else:
1299 # new node
1300 found[cl.create(**d)] = 1
1302 # retire the removed entries
1303 for nodeid in cl.list():
1304 if not found.has_key(nodeid):
1305 cl.retire(nodeid)
1307 # all OK
1308 self.db.commit()
1310 self.ok_message.append(_('Items edited OK'))
1312 def editCSVPermission(self):
1313 ''' Determine whether the user has permission to edit this class.
1315 Base behaviour is to check the user can edit this class.
1316 '''
1317 if not self.db.security.hasPermission('Edit', self.userid,
1318 self.classname):
1319 return 0
1320 return 1
1322 def searchAction(self, wcre=re.compile(r'[\s,]+')):
1323 ''' Mangle some of the form variables.
1325 Set the form ":filter" variable based on the values of the
1326 filter variables - if they're set to anything other than
1327 "dontcare" then add them to :filter.
1329 Handle the ":queryname" variable and save off the query to
1330 the user's query list.
1332 Split any String query values on whitespace and comma.
1333 '''
1334 # generic edit is per-class only
1335 if not self.searchPermission():
1336 self.error_message.append(
1337 _('You do not have permission to search %s' %self.classname))
1339 # add a faked :filter form variable for each filtering prop
1340 props = self.db.classes[self.classname].getprops()
1341 queryname = ''
1342 for key in self.form.keys():
1343 # special vars
1344 if self.FV_QUERYNAME.match(key):
1345 queryname = self.form[key].value.strip()
1346 continue
1348 if not props.has_key(key):
1349 continue
1350 if isinstance(self.form[key], type([])):
1351 # search for at least one entry which is not empty
1352 for minifield in self.form[key]:
1353 if minifield.value:
1354 break
1355 else:
1356 continue
1357 else:
1358 if not self.form[key].value:
1359 continue
1360 if isinstance(props[key], hyperdb.String):
1361 v = self.form[key].value
1362 l = token.token_split(v)
1363 if len(l) > 1 or l[0] != v:
1364 self.form.value.remove(self.form[key])
1365 # replace the single value with the split list
1366 for v in l:
1367 self.form.value.append(cgi.MiniFieldStorage(key, v))
1369 self.form.value.append(cgi.MiniFieldStorage('@filter', key))
1371 # handle saving the query params
1372 if queryname:
1373 # parse the environment and figure what the query _is_
1374 req = HTMLRequest(self)
1376 # The [1:] strips off the '?' character, it isn't part of the
1377 # query string.
1378 url = req.indexargs_href('', {})[1:]
1380 # handle editing an existing query
1381 try:
1382 qid = self.db.query.lookup(queryname)
1383 self.db.query.set(qid, klass=self.classname, url=url)
1384 except KeyError:
1385 # create a query
1386 qid = self.db.query.create(name=queryname,
1387 klass=self.classname, url=url)
1389 # and add it to the user's query multilink
1390 queries = self.db.user.get(self.userid, 'queries')
1391 queries.append(qid)
1392 self.db.user.set(self.userid, queries=queries)
1394 # commit the query change to the database
1395 self.db.commit()
1397 def searchPermission(self):
1398 ''' Determine whether the user has permission to search this class.
1400 Base behaviour is to check the user can view this class.
1401 '''
1402 if not self.db.security.hasPermission('View', self.userid,
1403 self.classname):
1404 return 0
1405 return 1
1408 def retireAction(self):
1409 ''' Retire the context item.
1410 '''
1411 # if we want to view the index template now, then unset the nodeid
1412 # context info (a special-case for retire actions on the index page)
1413 nodeid = self.nodeid
1414 if self.template == 'index':
1415 self.nodeid = None
1417 # generic edit is per-class only
1418 if not self.retirePermission():
1419 self.error_message.append(
1420 _('You do not have permission to retire %s' %self.classname))
1421 return
1423 # make sure we don't try to retire admin or anonymous
1424 if self.classname == 'user' and \
1425 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1426 self.error_message.append(
1427 _('You may not retire the admin or anonymous user'))
1428 return
1430 # do the retire
1431 self.db.getclass(self.classname).retire(nodeid)
1432 self.db.commit()
1434 self.ok_message.append(
1435 _('%(classname)s %(itemid)s has been retired')%{
1436 'classname': self.classname.capitalize(), 'itemid': nodeid})
1438 def retirePermission(self):
1439 ''' Determine whether the user has permission to retire this class.
1441 Base behaviour is to check the user can edit this class.
1442 '''
1443 if not self.db.security.hasPermission('Edit', self.userid,
1444 self.classname):
1445 return 0
1446 return 1
1449 def showAction(self, typere=re.compile('[@:]type'),
1450 numre=re.compile('[@:]number')):
1451 ''' Show a node of a particular class/id
1452 '''
1453 t = n = ''
1454 for key in self.form.keys():
1455 if typere.match(key):
1456 t = self.form[key].value.strip()
1457 elif numre.match(key):
1458 n = self.form[key].value.strip()
1459 if not t:
1460 raise ValueError, 'Invalid %s number'%t
1461 url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1462 raise Redirect, url
1464 def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1465 ''' Item properties and their values are edited with html FORM
1466 variables and their values. You can:
1468 - Change the value of some property of the current item.
1469 - Create a new item of any class, and edit the new item's
1470 properties,
1471 - Attach newly created items to a multilink property of the
1472 current item.
1473 - Remove items from a multilink property of the current item.
1474 - Specify that some properties are required for the edit
1475 operation to be successful.
1477 In the following, <bracketed> values are variable, "@" may be
1478 either ":" or "@", and other text "required" is fixed.
1480 Most properties are specified as form variables:
1482 <propname>
1483 - property on the current context item
1485 <designator>"@"<propname>
1486 - property on the indicated item (for editing related
1487 information)
1489 Designators name a specific item of a class.
1491 <classname><N>
1493 Name an existing item of class <classname>.
1495 <classname>"-"<N>
1497 Name the <N>th new item of class <classname>. If the form
1498 submission is successful, a new item of <classname> is
1499 created. Within the submitted form, a particular
1500 designator of this form always refers to the same new
1501 item.
1503 Once we have determined the "propname", we look at it to see
1504 if it's special:
1506 @required
1507 The associated form value is a comma-separated list of
1508 property names that must be specified when the form is
1509 submitted for the edit operation to succeed.
1511 When the <designator> is missing, the properties are
1512 for the current context item. When <designator> is
1513 present, they are for the item specified by
1514 <designator>.
1516 The "@required" specifier must come before any of the
1517 properties it refers to are assigned in the form.
1519 @remove@<propname>=id(s) or @add@<propname>=id(s)
1520 The "@add@" and "@remove@" edit actions apply only to
1521 Multilink properties. The form value must be a
1522 comma-separate list of keys for the class specified by
1523 the simple form variable. The listed items are added
1524 to (respectively, removed from) the specified
1525 property.
1527 @link@<propname>=<designator>
1528 If the edit action is "@link@", the simple form
1529 variable must specify a Link or Multilink property.
1530 The form value is a comma-separated list of
1531 designators. The item corresponding to each
1532 designator is linked to the property given by simple
1533 form variable. These are collected up and returned in
1534 all_links.
1536 None of the above (ie. just a simple form value)
1537 The value of the form variable is converted
1538 appropriately, depending on the type of the property.
1540 For a Link('klass') property, the form value is a
1541 single key for 'klass', where the key field is
1542 specified in dbinit.py.
1544 For a Multilink('klass') property, the form value is a
1545 comma-separated list of keys for 'klass', where the
1546 key field is specified in dbinit.py.
1548 Note that for simple-form-variables specifiying Link
1549 and Multilink properties, the linked-to class must
1550 have a key field.
1552 For a String() property specifying a filename, the
1553 file named by the form value is uploaded. This means we
1554 try to set additional properties "filename" and "type" (if
1555 they are valid for the class). Otherwise, the property
1556 is set to the form value.
1558 For Date(), Interval(), Boolean(), and Number()
1559 properties, the form value is converted to the
1560 appropriate
1562 Any of the form variables may be prefixed with a classname or
1563 designator.
1565 Two special form values are supported for backwards
1566 compatibility:
1568 @note
1569 This is equivalent to::
1571 @link@messages=msg-1
1572 @msg-1@content=value
1574 except that in addition, the "author" and "date"
1575 properties of "msg-1" are set to the userid of the
1576 submitter, and the current time, respectively.
1578 @file
1579 This is equivalent to::
1581 @link@files=file-1
1582 @file-1@content=value
1584 The String content value is handled as described above for
1585 file uploads.
1587 If both the "@note" and "@file" form variables are
1588 specified, the action::
1590 @link@msg-1@files=file-1
1592 is also performed.
1594 We also check that FileClass items have a "content" property with
1595 actual content, otherwise we remove them from all_props before
1596 returning.
1598 The return from this method is a dict of
1599 (classname, id): properties
1600 ... this dict _always_ has an entry for the current context,
1601 even if it's empty (ie. a submission for an existing issue that
1602 doesn't result in any changes would return {('issue','123'): {}})
1603 The id may be None, which indicates that an item should be
1604 created.
1605 '''
1606 # some very useful variables
1607 db = self.db
1608 form = self.form
1610 if not hasattr(self, 'FV_SPECIAL'):
1611 # generate the regexp for handling special form values
1612 classes = '|'.join(db.classes.keys())
1613 # specials for parsePropsFromForm
1614 # handle the various forms (see unit tests)
1615 self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1616 self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1618 # these indicate the default class / item
1619 default_cn = self.classname
1620 default_cl = self.db.classes[default_cn]
1621 default_nodeid = self.nodeid
1623 # we'll store info about the individual class/item edit in these
1624 all_required = {} # required props per class/item
1625 all_props = {} # props to set per class/item
1626 got_props = {} # props received per class/item
1627 all_propdef = {} # note - only one entry per class
1628 all_links = [] # as many as are required
1630 # we should always return something, even empty, for the context
1631 all_props[(default_cn, default_nodeid)] = {}
1633 keys = form.keys()
1634 timezone = db.getUserTimezone()
1636 # sentinels for the :note and :file props
1637 have_note = have_file = 0
1639 # extract the usable form labels from the form
1640 matches = []
1641 for key in keys:
1642 m = self.FV_SPECIAL.match(key)
1643 if m:
1644 matches.append((key, m.groupdict()))
1646 # now handle the matches
1647 for key, d in matches:
1648 if d['classname']:
1649 # we got a designator
1650 cn = d['classname']
1651 cl = self.db.classes[cn]
1652 nodeid = d['id']
1653 propname = d['propname']
1654 elif d['note']:
1655 # the special note field
1656 cn = 'msg'
1657 cl = self.db.classes[cn]
1658 nodeid = '-1'
1659 propname = 'content'
1660 all_links.append((default_cn, default_nodeid, 'messages',
1661 [('msg', '-1')]))
1662 have_note = 1
1663 elif d['file']:
1664 # the special file field
1665 cn = 'file'
1666 cl = self.db.classes[cn]
1667 nodeid = '-1'
1668 propname = 'content'
1669 all_links.append((default_cn, default_nodeid, 'files',
1670 [('file', '-1')]))
1671 have_file = 1
1672 else:
1673 # default
1674 cn = default_cn
1675 cl = default_cl
1676 nodeid = default_nodeid
1677 propname = d['propname']
1679 # the thing this value relates to is...
1680 this = (cn, nodeid)
1682 # get more info about the class, and the current set of
1683 # form props for it
1684 if not all_propdef.has_key(cn):
1685 all_propdef[cn] = cl.getprops()
1686 propdef = all_propdef[cn]
1687 if not all_props.has_key(this):
1688 all_props[this] = {}
1689 props = all_props[this]
1690 if not got_props.has_key(this):
1691 got_props[this] = {}
1693 # is this a link command?
1694 if d['link']:
1695 value = []
1696 for entry in extractFormList(form[key]):
1697 m = self.FV_DESIGNATOR.match(entry)
1698 if not m:
1699 raise ValueError, \
1700 'link "%s" value "%s" not a designator'%(key, entry)
1701 value.append((m.group(1), m.group(2)))
1703 # make sure the link property is valid
1704 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1705 not isinstance(propdef[propname], hyperdb.Link)):
1706 raise ValueError, '%s %s is not a link or '\
1707 'multilink property'%(cn, propname)
1709 all_links.append((cn, nodeid, propname, value))
1710 continue
1712 # detect the special ":required" variable
1713 if d['required']:
1714 all_required[this] = extractFormList(form[key])
1715 continue
1717 # see if we're performing a special multilink action
1718 mlaction = 'set'
1719 if d['remove']:
1720 mlaction = 'remove'
1721 elif d['add']:
1722 mlaction = 'add'
1724 # does the property exist?
1725 if not propdef.has_key(propname):
1726 if mlaction != 'set':
1727 raise ValueError, 'You have submitted a %s action for'\
1728 ' the property "%s" which doesn\'t exist'%(mlaction,
1729 propname)
1730 # the form element is probably just something we don't care
1731 # about - ignore it
1732 continue
1733 proptype = propdef[propname]
1735 # Get the form value. This value may be a MiniFieldStorage or a list
1736 # of MiniFieldStorages.
1737 value = form[key]
1739 # handle unpacking of the MiniFieldStorage / list form value
1740 if isinstance(proptype, hyperdb.Multilink):
1741 value = extractFormList(value)
1742 else:
1743 # multiple values are not OK
1744 if isinstance(value, type([])):
1745 raise ValueError, 'You have submitted more than one value'\
1746 ' for the %s property'%propname
1747 # value might be a file upload...
1748 if not hasattr(value, 'filename') or value.filename is None:
1749 # nope, pull out the value and strip it
1750 value = value.value.strip()
1752 # now that we have the props field, we need a teensy little
1753 # extra bit of help for the old :note field...
1754 if d['note'] and value:
1755 props['author'] = self.db.getuid()
1756 props['date'] = date.Date()
1758 # handle by type now
1759 if isinstance(proptype, hyperdb.Password):
1760 if not value:
1761 # ignore empty password values
1762 continue
1763 for key, d in matches:
1764 if d['confirm'] and d['propname'] == propname:
1765 confirm = form[key]
1766 break
1767 else:
1768 raise ValueError, 'Password and confirmation text do '\
1769 'not match'
1770 if isinstance(confirm, type([])):
1771 raise ValueError, 'You have submitted more than one value'\
1772 ' for the %s property'%propname
1773 if value != confirm.value:
1774 raise ValueError, 'Password and confirmation text do '\
1775 'not match'
1776 value = password.Password(value)
1778 elif isinstance(proptype, hyperdb.Link):
1779 # see if it's the "no selection" choice
1780 if value == '-1' or not value:
1781 # if we're creating, just don't include this property
1782 if not nodeid or nodeid.startswith('-'):
1783 continue
1784 value = None
1785 else:
1786 # handle key values
1787 link = proptype.classname
1788 if not num_re.match(value):
1789 try:
1790 value = db.classes[link].lookup(value)
1791 except KeyError:
1792 raise ValueError, _('property "%(propname)s": '
1793 '%(value)s not a %(classname)s')%{
1794 'propname': propname, 'value': value,
1795 'classname': link}
1796 except TypeError, message:
1797 raise ValueError, _('you may only enter ID values '
1798 'for property "%(propname)s": %(message)s')%{
1799 'propname': propname, 'message': message}
1800 elif isinstance(proptype, hyperdb.Multilink):
1801 # perform link class key value lookup if necessary
1802 link = proptype.classname
1803 link_cl = db.classes[link]
1804 l = []
1805 for entry in value:
1806 if not entry: continue
1807 if not num_re.match(entry):
1808 try:
1809 entry = link_cl.lookup(entry)
1810 except KeyError:
1811 raise ValueError, _('property "%(propname)s": '
1812 '"%(value)s" not an entry of %(classname)s')%{
1813 'propname': propname, 'value': entry,
1814 'classname': link}
1815 except TypeError, message:
1816 raise ValueError, _('you may only enter ID values '
1817 'for property "%(propname)s": %(message)s')%{
1818 'propname': propname, 'message': message}
1819 l.append(entry)
1820 l.sort()
1822 # now use that list of ids to modify the multilink
1823 if mlaction == 'set':
1824 value = l
1825 else:
1826 # we're modifying the list - get the current list of ids
1827 if props.has_key(propname):
1828 existing = props[propname]
1829 elif nodeid and not nodeid.startswith('-'):
1830 existing = cl.get(nodeid, propname, [])
1831 else:
1832 existing = []
1834 # now either remove or add
1835 if mlaction == 'remove':
1836 # remove - handle situation where the id isn't in
1837 # the list
1838 for entry in l:
1839 try:
1840 existing.remove(entry)
1841 except ValueError:
1842 raise ValueError, _('property "%(propname)s": '
1843 '"%(value)s" not currently in list')%{
1844 'propname': propname, 'value': entry}
1845 else:
1846 # add - easy, just don't dupe
1847 for entry in l:
1848 if entry not in existing:
1849 existing.append(entry)
1850 value = existing
1851 value.sort()
1853 elif value == '':
1854 # if we're creating, just don't include this property
1855 if not nodeid or nodeid.startswith('-'):
1856 continue
1857 # other types should be None'd if there's no value
1858 value = None
1859 else:
1860 # handle ValueErrors for all these in a similar fashion
1861 try:
1862 if isinstance(proptype, hyperdb.String):
1863 if (hasattr(value, 'filename') and
1864 value.filename is not None):
1865 # skip if the upload is empty
1866 if not value.filename:
1867 continue
1868 # this String is actually a _file_
1869 # try to determine the file content-type
1870 fn = value.filename.split('\\')[-1]
1871 if propdef.has_key('name'):
1872 props['name'] = fn
1873 # use this info as the type/filename properties
1874 if propdef.has_key('type'):
1875 props['type'] = mimetypes.guess_type(fn)[0]
1876 if not props['type']:
1877 props['type'] = "application/octet-stream"
1878 # finally, read the content
1879 value = value.value
1880 else:
1881 # normal String fix the CRLF/CR -> LF stuff
1882 value = fixNewlines(value)
1884 elif isinstance(proptype, hyperdb.Date):
1885 value = date.Date(value, offset=timezone)
1886 elif isinstance(proptype, hyperdb.Interval):
1887 value = date.Interval(value)
1888 elif isinstance(proptype, hyperdb.Boolean):
1889 value = value.lower() in ('yes', 'true', 'on', '1')
1890 elif isinstance(proptype, hyperdb.Number):
1891 value = float(value)
1892 except ValueError, msg:
1893 raise ValueError, _('Error with %s property: %s')%(
1894 propname, msg)
1896 # register that we got this property
1897 if value:
1898 got_props[this][propname] = 1
1900 # get the old value
1901 if nodeid and not nodeid.startswith('-'):
1902 try:
1903 existing = cl.get(nodeid, propname)
1904 except KeyError:
1905 # this might be a new property for which there is
1906 # no existing value
1907 if not propdef.has_key(propname):
1908 raise
1910 # make sure the existing multilink is sorted
1911 if isinstance(proptype, hyperdb.Multilink):
1912 existing.sort()
1914 # "missing" existing values may not be None
1915 if not existing:
1916 if isinstance(proptype, hyperdb.String) and not existing:
1917 # some backends store "missing" Strings as empty strings
1918 existing = None
1919 elif isinstance(proptype, hyperdb.Number) and not existing:
1920 # some backends store "missing" Numbers as 0 :(
1921 existing = 0
1922 elif isinstance(proptype, hyperdb.Boolean) and not existing:
1923 # likewise Booleans
1924 existing = 0
1926 # if changed, set it
1927 if value != existing:
1928 props[propname] = value
1929 else:
1930 # don't bother setting empty/unset values
1931 if value is None:
1932 continue
1933 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1934 continue
1935 elif isinstance(proptype, hyperdb.String) and value == '':
1936 continue
1938 props[propname] = value
1940 # check to see if we need to specially link a file to the note
1941 if have_note and have_file:
1942 all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1944 # see if all the required properties have been supplied
1945 s = []
1946 for thing, required in all_required.items():
1947 # register the values we got
1948 got = got_props.get(thing, {})
1949 for entry in required[:]:
1950 if got.has_key(entry):
1951 required.remove(entry)
1953 # any required values not present?
1954 if not required:
1955 continue
1957 # tell the user to entry the values required
1958 if len(required) > 1:
1959 p = 'properties'
1960 else:
1961 p = 'property'
1962 s.append('Required %s %s %s not supplied'%(thing[0], p,
1963 ', '.join(required)))
1964 if s:
1965 raise ValueError, '\n'.join(s)
1967 # When creating a FileClass node, it should have a non-empty content
1968 # property to be created. When editing a FileClass node, it should
1969 # either have a non-empty content property or no property at all. In
1970 # the latter case, nothing will change.
1971 for (cn, id), props in all_props.items():
1972 if isinstance(self.db.classes[cn], hyperdb.FileClass):
1973 if id == '-1':
1974 if not props.get('content', ''):
1975 del all_props[(cn, id)]
1976 elif props.has_key('content') and not props['content']:
1977 raise ValueError, _('File is empty')
1978 return all_props, all_links
1980 def fixNewlines(text):
1981 ''' Homogenise line endings.
1983 Different web clients send different line ending values, but
1984 other systems (eg. email) don't necessarily handle those line
1985 endings. Our solution is to convert all line endings to LF.
1986 '''
1987 text = text.replace('\r\n', '\n')
1988 return text.replace('\r', '\n')
1990 def extractFormList(value):
1991 ''' Extract a list of values from the form value.
1993 It may be one of:
1994 [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
1995 MiniFieldStorage('value,value,...')
1996 MiniFieldStorage('value')
1997 '''
1998 # multiple values are OK
1999 if isinstance(value, type([])):
2000 # it's a list of MiniFieldStorages - join then into
2001 values = ','.join([i.value.strip() for i in value])
2002 else:
2003 # it's a MiniFieldStorage, but may be a comma-separated list
2004 # of values
2005 values = value.value
2007 value = [i.strip() for i in values.split(',')]
2009 # filter out the empty bits
2010 return filter(None, value)