1 """WWW request handler (also used in the stand-alone server).
2 """
3 __docformat__ = 'restructuredtext'
5 import base64, binascii, cgi, codecs, httplib, mimetypes, os
6 import quopri, random, re, rfc822, stat, sys, time, urllib, urlparse
7 import Cookie, socket, errno
8 from Cookie import CookieError, BaseCookie, SimpleCookie
9 from cStringIO import StringIO
11 from roundup import roundupdb, date, hyperdb, password
12 from roundup.cgi import templating, cgitb, TranslationService
13 from roundup.cgi.actions import *
14 from roundup.exceptions import *
15 from roundup.cgi.exceptions import *
16 from roundup.cgi.form_parser import FormParser
17 from roundup.mailer import Mailer, MessageSendError, encode_quopri
18 from roundup.cgi import accept_language
19 from roundup import xmlrpc
21 def initialiseSecurity(security):
22 '''Create some Permissions and Roles on the security object
24 This function is directly invoked by security.Security.__init__()
25 as a part of the Security object instantiation.
26 '''
27 p = security.addPermission(name="Web Access",
28 description="User may access the web interface")
29 security.addPermissionToRole('Admin', p)
31 # doing Role stuff through the web - make sure Admin can
32 # TODO: deprecate this and use a property-based control
33 p = security.addPermission(name="Web Roles",
34 description="User may manipulate user Roles through the web")
35 security.addPermissionToRole('Admin', p)
37 # used to clean messages passed through CGI variables - HTML-escape any tag
38 # that isn't <a href="">, <i>, <b> and <br> (including XHTML variants) so
39 # that people can't pass through nasties like <script>, <iframe>, ...
40 CLEAN_MESSAGE_RE = r'(<(/?(.*?)(\s*href="[^"]")?\s*/?)>)'
41 def clean_message(message, mc=re.compile(CLEAN_MESSAGE_RE, re.I)):
42 return mc.sub(clean_message_callback, message)
43 def clean_message_callback(match, ok={'a':1,'i':1,'b':1,'br':1}):
44 """ Strip all non <a>,<i>,<b> and <br> tags from a string
45 """
46 if ok.has_key(match.group(3).lower()):
47 return match.group(1)
48 return '<%s>'%match.group(2)
51 error_message = ''"""<html><head><title>An error has occurred</title></head>
52 <body><h1>An error has occurred</h1>
53 <p>A problem was encountered processing your request.
54 The tracker maintainers have been notified of the problem.</p>
55 </body></html>"""
58 class LiberalCookie(SimpleCookie):
59 """ Python's SimpleCookie throws an exception if the cookie uses invalid
60 syntax. Other applications on the same server may have done precisely
61 this, preventing roundup from working through no fault of roundup.
62 Numerous other python apps have run into the same problem:
64 trac: http://trac.edgewall.org/ticket/2256
65 mailman: http://bugs.python.org/issue472646
67 This particular implementation comes from trac's solution to the
68 problem. Unfortunately it requires some hackery in SimpleCookie's
69 internals to provide a more liberal __set method.
70 """
71 def load(self, rawdata, ignore_parse_errors=True):
72 if ignore_parse_errors:
73 self.bad_cookies = []
74 self._BaseCookie__set = self._loose_set
75 SimpleCookie.load(self, rawdata)
76 if ignore_parse_errors:
77 self._BaseCookie__set = self._strict_set
78 for key in self.bad_cookies:
79 del self[key]
81 _strict_set = BaseCookie._BaseCookie__set
83 def _loose_set(self, key, real_value, coded_value):
84 try:
85 self._strict_set(key, real_value, coded_value)
86 except CookieError:
87 self.bad_cookies.append(key)
88 dict.__setitem__(self, key, None)
91 class Session:
92 """
93 Needs DB to be already opened by client
95 Session attributes at instantiation:
97 - "client" - reference to client for add_cookie function
98 - "session_db" - session DB manager
99 - "cookie_name" - name of the cookie with session id
100 - "_sid" - session id for current user
101 - "_data" - session data cache
103 session = Session(client)
104 session.set(name=value)
105 value = session.get(name)
107 session.destroy() # delete current session
108 session.clean_up() # clean up session table
110 session.update(set_cookie=True, expire=3600*24*365)
111 # refresh session expiration time, setting persistent
112 # cookie if needed to last for 'expire' seconds
114 """
116 def __init__(self, client):
117 self._data = {}
118 self._sid = None
120 self.client = client
121 self.session_db = client.db.getSessionManager()
123 # parse cookies for session id
124 self.cookie_name = 'roundup_session_%s' % \
125 re.sub('[^a-zA-Z]', '', client.instance.config.TRACKER_NAME)
126 cookies = LiberalCookie(client.env.get('HTTP_COOKIE', ''))
127 if self.cookie_name in cookies:
128 if not self.session_db.exists(cookies[self.cookie_name].value):
129 self._sid = None
130 # remove old cookie
131 self.client.add_cookie(self.cookie_name, None)
132 else:
133 self._sid = cookies[self.cookie_name].value
134 self._data = self.session_db.getall(self._sid)
136 def _gen_sid(self):
137 """ generate a unique session key """
138 while 1:
139 s = '%s%s'%(time.time(), random.random())
140 s = binascii.b2a_base64(s).strip()
141 if not self.session_db.exists(s):
142 break
144 # clean up the base64
145 if s[-1] == '=':
146 if s[-2] == '=':
147 s = s[:-2]
148 else:
149 s = s[:-1]
150 return s
152 def clean_up(self):
153 """Remove expired sessions"""
154 self.session_db.clean()
156 def destroy(self):
157 self.client.add_cookie(self.cookie_name, None)
158 self._data = {}
159 self.session_db.destroy(self._sid)
160 self.client.db.commit()
162 def get(self, name, default=None):
163 return self._data.get(name, default)
165 def set(self, **kwargs):
166 self._data.update(kwargs)
167 if not self._sid:
168 self._sid = self._gen_sid()
169 self.session_db.set(self._sid, **self._data)
170 # add session cookie
171 self.update(set_cookie=True)
173 # XXX added when patching 1.4.4 for backward compatibility
174 # XXX remove
175 self.client.session = self._sid
176 else:
177 self.session_db.set(self._sid, **self._data)
178 self.client.db.commit()
180 def update(self, set_cookie=False, expire=None):
181 """ update timestamp in db to avoid expiration
183 if 'set_cookie' is True, set cookie with 'expire' seconds lifetime
184 if 'expire' is None - session will be closed with the browser
186 XXX the session can be purged within a week even if a cookie
187 lifetime is longer
188 """
189 self.session_db.updateTimestamp(self._sid)
190 self.client.db.commit()
192 if set_cookie:
193 self.client.add_cookie(self.cookie_name, self._sid, expire=expire)
197 class Client:
198 """Instantiate to handle one CGI request.
200 See inner_main for request processing.
202 Client attributes at instantiation:
204 - "path" is the PATH_INFO inside the instance (with no leading '/')
205 - "base" is the base URL for the instance
206 - "form" is the cgi form, an instance of FieldStorage from the standard
207 cgi module
208 - "additional_headers" is a dictionary of additional HTTP headers that
209 should be sent to the client
210 - "response_code" is the HTTP response code to send to the client
211 - "translator" is TranslationService instance
213 During the processing of a request, the following attributes are used:
215 - "db"
216 - "error_message" holds a list of error messages
217 - "ok_message" holds a list of OK messages
218 - "session" is deprecated in favor of session_api (XXX remove)
219 - "session_api" is the interface to store data in session
220 - "user" is the current user's name
221 - "userid" is the current user's id
222 - "template" is the current :template context
223 - "classname" is the current class context name
224 - "nodeid" is the current context item id
226 User Identification:
227 Users that are absent in session data are anonymous and are logged
228 in as that user. This typically gives them all Permissions assigned to the
229 Anonymous Role.
231 Every user is assigned a session. "session_api" is the interface to work
232 with session data.
234 Special form variables:
235 Note that in various places throughout this code, special form
236 variables of the form :<name> are used. The colon (":") part may
237 actually be one of either ":" or "@".
238 """
240 # charset used for data storage and form templates
241 # Note: must be in lower case for comparisons!
242 # XXX take this from instance.config?
243 STORAGE_CHARSET = 'utf-8'
245 #
246 # special form variables
247 #
248 FV_TEMPLATE = re.compile(r'[@:]template')
249 FV_OK_MESSAGE = re.compile(r'[@:]ok_message')
250 FV_ERROR_MESSAGE = re.compile(r'[@:]error_message')
252 # Note: index page stuff doesn't appear here:
253 # columns, sort, sortdir, filter, group, groupdir, search_text,
254 # pagesize, startwith
256 # list of network error codes that shouldn't be reported to tracker admin
257 # (error descriptions from FreeBSD intro(2))
258 IGNORE_NET_ERRORS = (
259 # A write on a pipe, socket or FIFO for which there is
260 # no process to read the data.
261 errno.EPIPE,
262 # A connection was forcibly closed by a peer.
263 # This normally results from a loss of the connection
264 # on the remote socket due to a timeout or a reboot.
265 errno.ECONNRESET,
266 # Software caused connection abort. A connection abort
267 # was caused internal to your host machine.
268 errno.ECONNABORTED,
269 # A connect or send request failed because the connected party
270 # did not properly respond after a period of time.
271 errno.ETIMEDOUT,
272 )
274 def __init__(self, instance, request, env, form=None, translator=None):
275 # re-seed the random number generator
276 random.seed()
277 self.start = time.time()
278 self.instance = instance
279 self.request = request
280 self.env = env
281 self.setTranslator(translator)
282 self.mailer = Mailer(instance.config)
284 # save off the path
285 self.path = env['PATH_INFO']
287 # this is the base URL for this tracker
288 self.base = self.instance.config.TRACKER_WEB
290 # check the tracker_we setting
291 if not self.base.endswith('/'):
292 self.base = self.base + '/'
294 # this is the "cookie path" for this tracker (ie. the path part of
295 # the "base" url)
296 self.cookie_path = urlparse.urlparse(self.base)[2]
297 # cookies to set in http responce
298 # {(path, name): (value, expire)}
299 self._cookies = {}
301 # see if we need to re-parse the environment for the form (eg Zope)
302 if form is None:
303 self.form = cgi.FieldStorage(environ=env)
304 else:
305 self.form = form
307 # turn debugging on/off
308 try:
309 self.debug = int(env.get("ROUNDUP_DEBUG", 0))
310 except ValueError:
311 # someone gave us a non-int debug level, turn it off
312 self.debug = 0
314 # flag to indicate that the HTTP headers have been sent
315 self.headers_done = 0
317 # additional headers to send with the request - must be registered
318 # before the first write
319 self.additional_headers = {}
320 self.response_code = 200
322 # default character set
323 self.charset = self.STORAGE_CHARSET
325 # parse cookies (used for charset lookups)
326 # use our own LiberalCookie to handle bad apps on the same
327 # server that have set cookies that are out of spec
328 self.cookie = LiberalCookie(self.env.get('HTTP_COOKIE', ''))
330 self.user = None
331 self.userid = None
332 self.nodeid = None
333 self.classname = None
334 self.template = None
336 def setTranslator(self, translator=None):
337 """Replace the translation engine
339 'translator'
340 is TranslationService instance.
341 It must define methods 'translate' (TAL-compatible i18n),
342 'gettext' and 'ngettext' (gettext-compatible i18n).
344 If omitted, create default TranslationService.
345 """
346 if translator is None:
347 translator = TranslationService.get_translation(
348 language=self.instance.config["TRACKER_LANGUAGE"],
349 tracker_home=self.instance.config["TRACKER_HOME"])
350 self.translator = translator
351 self._ = self.gettext = translator.gettext
352 self.ngettext = translator.ngettext
354 def main(self):
355 """ Wrap the real main in a try/finally so we always close off the db.
356 """
357 try:
358 if self.env.get('CONTENT_TYPE') == 'text/xml':
359 self.handle_xmlrpc()
360 else:
361 self.inner_main()
362 finally:
363 if hasattr(self, 'db'):
364 self.db.close()
367 def handle_xmlrpc(self):
369 # Pull the raw XML out of the form. The "value" attribute
370 # will be the raw content of the POST request.
371 assert self.form.file
372 input = self.form.value
373 # So that the rest of Roundup can query the form in the
374 # usual way, we create an empty list of fields.
375 self.form.list = []
377 # Set the charset and language, since other parts of
378 # Roundup may depend upon that.
379 self.determine_charset()
380 self.determine_language()
381 # Open the database as the correct user.
382 self.determine_user()
383 self.check_anonymous_access()
385 # Call the appropriate XML-RPC method.
386 handler = xmlrpc.RoundupDispatcher(self.db,
387 self.instance.actions,
388 self.translator,
389 allow_none=True)
390 output = handler.dispatch(input)
392 self.setHeader("Content-Type", "text/xml")
393 self.setHeader("Content-Length", str(len(output)))
394 self.write(output)
396 def inner_main(self):
397 """Process a request.
399 The most common requests are handled like so:
401 1. look for charset and language preferences, set up user locale
402 see determine_charset, determine_language
403 2. figure out who we are, defaulting to the "anonymous" user
404 see determine_user
405 3. figure out what the request is for - the context
406 see determine_context
407 4. handle any requested action (item edit, search, ...)
408 see handle_action
409 5. render a template, resulting in HTML output
411 In some situations, exceptions occur:
413 - HTTP Redirect (generally raised by an action)
414 - SendFile (generally raised by determine_context)
415 serve up a FileClass "content" property
416 - SendStaticFile (generally raised by determine_context)
417 serve up a file from the tracker "html" directory
418 - Unauthorised (generally raised by an action)
419 the action is cancelled, the request is rendered and an error
420 message is displayed indicating that permission was not
421 granted for the action to take place
422 - templating.Unauthorised (templating action not permitted)
423 raised by an attempted rendering of a template when the user
424 doesn't have permission
425 - NotFound (raised wherever it needs to be)
426 percolates up to the CGI interface that called the client
427 """
428 self.ok_message = []
429 self.error_message = []
430 try:
431 self.determine_charset()
432 self.determine_language()
434 try:
435 # make sure we're identified (even anonymously)
436 self.determine_user()
438 # figure out the context and desired content template
439 self.determine_context()
441 # if we've made it this far the context is to a bit of
442 # Roundup's real web interface (not a file being served up)
443 # so do the Anonymous Web Acess check now
444 self.check_anonymous_access()
446 # possibly handle a form submit action (may change self.classname
447 # and self.template, and may also append error/ok_messages)
448 html = self.handle_action()
450 if html:
451 self.write_html(html)
452 return
454 # now render the page
455 # we don't want clients caching our dynamic pages
456 self.additional_headers['Cache-Control'] = 'no-cache'
457 # Pragma: no-cache makes Mozilla and its ilk
458 # double-load all pages!!
459 # self.additional_headers['Pragma'] = 'no-cache'
461 # pages with messages added expire right now
462 # simple views may be cached for a small amount of time
463 # TODO? make page expire time configurable
464 # <rj> always expire pages, as IE just doesn't seem to do the
465 # right thing here :(
466 date = time.time() - 1
467 #if self.error_message or self.ok_message:
468 # date = time.time() - 1
469 #else:
470 # date = time.time() + 5
471 self.additional_headers['Expires'] = rfc822.formatdate(date)
473 # render the content
474 self.write_html(self.renderContext())
475 except SendFile, designator:
476 # The call to serve_file may result in an Unauthorised
477 # exception or a NotModified exception. Those
478 # exceptions will be handled by the outermost set of
479 # exception handlers.
480 self.serve_file(designator)
481 except SendStaticFile, file:
482 self.serve_static_file(str(file))
483 except IOError:
484 # IOErrors here are due to the client disconnecting before
485 # recieving the reply.
486 pass
488 except SeriousError, message:
489 self.write_html(str(message))
490 except Redirect, url:
491 # let's redirect - if the url isn't None, then we need to do
492 # the headers, otherwise the headers have been set before the
493 # exception was raised
494 if url:
495 self.additional_headers['Location'] = str(url)
496 self.response_code = 302
497 self.write_html('Redirecting to <a href="%s">%s</a>'%(url, url))
498 except LoginError, message:
499 # The user tried to log in, but did not provide a valid
500 # username and password. If we support HTTP
501 # authorization, send back a response that will cause the
502 # browser to prompt the user again.
503 if self.instance.config.WEB_HTTP_AUTH:
504 self.response_code = httplib.UNAUTHORIZED
505 realm = self.instance.config.TRACKER_NAME
506 self.setHeader("WWW-Authenticate",
507 "Basic realm=\"%s\"" % realm)
508 else:
509 self.response_code = httplib.FORBIDDEN
510 self.renderFrontPage(message)
511 except Unauthorised, message:
512 # users may always see the front page
513 self.response_code = 403
514 self.renderFrontPage(message)
515 except NotModified:
516 # send the 304 response
517 self.response_code = 304
518 self.header()
519 except NotFound, e:
520 self.response_code = 404
521 self.template = '404'
522 try:
523 cl = self.db.getclass(self.classname)
524 self.write_html(self.renderContext())
525 except KeyError:
526 # we can't map the URL to a class we know about
527 # reraise the NotFound and let roundup_server
528 # handle it
529 raise NotFound, e
530 except FormError, e:
531 self.error_message.append(self._('Form Error: ') + str(e))
532 self.write_html(self.renderContext())
533 except:
534 # Something has gone badly wrong. Therefore, we should
535 # make sure that the response code indicates failure.
536 if self.response_code == httplib.OK:
537 self.response_code = httplib.INTERNAL_SERVER_ERROR
538 # Help the administrator work out what went wrong.
539 html = ("<h1>Traceback</h1>"
540 + cgitb.html(i18n=self.translator)
541 + ("<h1>Environment Variables</h1><table>%s</table>"
542 % cgitb.niceDict("", self.env)))
543 if not self.instance.config.WEB_DEBUG:
544 exc_info = sys.exc_info()
545 subject = "Error: %s" % exc_info[1]
546 self.send_html_to_admin(subject, html)
547 self.write_html(self._(error_message))
548 else:
549 self.write_html(html)
551 def clean_sessions(self):
552 """Deprecated
553 XXX remove
554 """
555 self.clean_up()
557 def clean_up(self):
558 """Remove expired sessions and One Time Keys.
560 Do it only once an hour.
561 """
562 hour = 60*60
563 now = time.time()
565 # XXX: hack - use OTK table to store last_clean time information
566 # 'last_clean' string is used instead of otk key
567 last_clean = self.db.getOTKManager().get('last_clean', 'last_use', 0)
568 if now - last_clean < hour:
569 return
571 self.session_api.clean_up()
572 self.db.getOTKManager().clean()
573 self.db.getOTKManager().set('last_clean', last_use=now)
574 self.db.commit(fail_ok=True)
576 def determine_charset(self):
577 """Look for client charset in the form parameters or browser cookie.
579 If no charset requested by client, use storage charset (utf-8).
581 If the charset is found, and differs from the storage charset,
582 recode all form fields of type 'text/plain'
583 """
584 # look for client charset
585 charset_parameter = 0
586 if self.form.has_key('@charset'):
587 charset = self.form['@charset'].value
588 if charset.lower() == "none":
589 charset = ""
590 charset_parameter = 1
591 elif self.cookie.has_key('roundup_charset'):
592 charset = self.cookie['roundup_charset'].value
593 else:
594 charset = None
595 if charset:
596 # make sure the charset is recognized
597 try:
598 codecs.lookup(charset)
599 except LookupError:
600 self.error_message.append(self._('Unrecognized charset: %r')
601 % charset)
602 charset_parameter = 0
603 else:
604 self.charset = charset.lower()
605 # If we've got a character set in request parameters,
606 # set the browser cookie to keep the preference.
607 # This is done after codecs.lookup to make sure
608 # that we aren't keeping a wrong value.
609 if charset_parameter:
610 self.add_cookie('roundup_charset', charset)
612 # if client charset is different from the storage charset,
613 # recode form fields
614 # XXX this requires FieldStorage from Python library.
615 # mod_python FieldStorage is not supported!
616 if self.charset != self.STORAGE_CHARSET:
617 decoder = codecs.getdecoder(self.charset)
618 encoder = codecs.getencoder(self.STORAGE_CHARSET)
619 re_charref = re.compile('&#([0-9]+|x[0-9a-f]+);', re.IGNORECASE)
620 def _decode_charref(matchobj):
621 num = matchobj.group(1)
622 if num[0].lower() == 'x':
623 uc = int(num[1:], 16)
624 else:
625 uc = int(num)
626 return unichr(uc)
628 for field_name in self.form.keys():
629 field = self.form[field_name]
630 if (field.type == 'text/plain') and not field.filename:
631 try:
632 value = decoder(field.value)[0]
633 except UnicodeError:
634 continue
635 value = re_charref.sub(_decode_charref, value)
636 field.value = encoder(value)[0]
638 def determine_language(self):
639 """Determine the language"""
640 # look for language parameter
641 # then for language cookie
642 # last for the Accept-Language header
643 if self.form.has_key("@language"):
644 language = self.form["@language"].value
645 if language.lower() == "none":
646 language = ""
647 self.add_cookie("roundup_language", language)
648 elif self.cookie.has_key("roundup_language"):
649 language = self.cookie["roundup_language"].value
650 elif self.instance.config["WEB_USE_BROWSER_LANGUAGE"]:
651 hal = self.env.get('HTTP_ACCEPT_LANGUAGE')
652 language = accept_language.parse(hal)
653 else:
654 language = ""
656 self.language = language
657 if language:
658 self.setTranslator(TranslationService.get_translation(
659 language,
660 tracker_home=self.instance.config["TRACKER_HOME"]))
662 def determine_user(self):
663 """Determine who the user is"""
664 self.opendb('admin')
666 # get session data from db
667 # XXX: rename
668 self.session_api = Session(self)
670 # take the opportunity to cleanup expired sessions and otks
671 self.clean_up()
673 user = None
674 # first up, try http authorization if enabled
675 if self.instance.config['WEB_HTTP_AUTH']:
676 if self.env.has_key('REMOTE_USER'):
677 # we have external auth (e.g. by Apache)
678 user = self.env['REMOTE_USER']
679 elif self.env.get('HTTP_AUTHORIZATION', ''):
680 # try handling Basic Auth ourselves
681 auth = self.env['HTTP_AUTHORIZATION']
682 scheme, challenge = auth.split(' ', 1)
683 if scheme.lower() == 'basic':
684 try:
685 decoded = base64.decodestring(challenge)
686 except TypeError:
687 # invalid challenge
688 pass
689 username, password = decoded.split(':')
690 try:
691 login = self.get_action_class('login')(self)
692 login.verifyLogin(username, password)
693 except LoginError, err:
694 self.make_user_anonymous()
695 raise
696 user = username
698 # if user was not set by http authorization, try session lookup
699 if not user:
700 user = self.session_api.get('user')
701 if user:
702 # update session lifetime datestamp
703 self.session_api.update()
705 # if no user name set by http authorization or session lookup
706 # the user is anonymous
707 if not user:
708 user = 'anonymous'
710 # sanity check on the user still being valid,
711 # getting the userid at the same time
712 try:
713 self.userid = self.db.user.lookup(user)
714 except (KeyError, TypeError):
715 user = 'anonymous'
717 # make sure the anonymous user is valid if we're using it
718 if user == 'anonymous':
719 self.make_user_anonymous()
720 else:
721 self.user = user
723 # reopen the database as the correct user
724 self.opendb(self.user)
726 def check_anonymous_access(self):
727 """Check that the Anonymous user is actually allowed to use the web
728 interface and short-circuit all further processing if they're not.
729 """
730 # allow Anonymous to use the "login" and "register" actions (noting
731 # that "register" has its own "Register" permission check)
732 if self.form.has_key(':action'):
733 action = self.form[':action'].value.lower()
734 elif self.form.has_key('@action'):
735 action = self.form['@action'].value.lower()
736 else:
737 action = None
738 if action in ('login', 'register'):
739 return
741 # allow Anonymous to view the "user" "register" template if they're
742 # allowed to register
743 if (self.db.security.hasPermission('Register', self.userid, 'user')
744 and self.classname == 'user' and self.template == 'register'):
745 return
747 # otherwise for everything else
748 if self.user == 'anonymous':
749 if not self.db.security.hasPermission('Web Access', self.userid):
750 raise Unauthorised, self._("Anonymous users are not "
751 "allowed to use the web interface")
753 def opendb(self, username):
754 """Open the database and set the current user.
756 Opens a database once. On subsequent calls only the user is set on
757 the database object the instance.optimize is set. If we are in
758 "Development Mode" (cf. roundup_server) then the database is always
759 re-opened.
760 """
761 # don't do anything if the db is open and the user has not changed
762 if hasattr(self, 'db') and self.db.isCurrentUser(username):
763 return
765 # open the database or only set the user
766 if not hasattr(self, 'db'):
767 self.db = self.instance.open(username)
768 else:
769 if self.instance.optimize:
770 self.db.setCurrentUser(username)
771 else:
772 self.db.close()
773 self.db = self.instance.open(username)
774 # The old session API refers to the closed database;
775 # we can no longer use it.
776 self.session_api = Session(self)
779 def determine_context(self, dre=re.compile(r'([^\d]+)0*(\d+)')):
780 """Determine the context of this page from the URL:
782 The URL path after the instance identifier is examined. The path
783 is generally only one entry long.
785 - if there is no path, then we are in the "home" context.
786 - if the path is "_file", then the additional path entry
787 specifies the filename of a static file we're to serve up
788 from the instance "html" directory. Raises a SendStaticFile
789 exception.(*)
790 - if there is something in the path (eg "issue"), it identifies
791 the tracker class we're to display.
792 - if the path is an item designator (eg "issue123"), then we're
793 to display a specific item.
794 - if the path starts with an item designator and is longer than
795 one entry, then we're assumed to be handling an item of a
796 FileClass, and the extra path information gives the filename
797 that the client is going to label the download with (ie
798 "file123/image.png" is nicer to download than "file123"). This
799 raises a SendFile exception.(*)
801 Both of the "*" types of contexts stop before we bother to
802 determine the template we're going to use. That's because they
803 don't actually use templates.
805 The template used is specified by the :template CGI variable,
806 which defaults to:
808 - only classname suplied: "index"
809 - full item designator supplied: "item"
811 We set:
813 self.classname - the class to display, can be None
815 self.template - the template to render the current context with
817 self.nodeid - the nodeid of the class we're displaying
818 """
819 # default the optional variables
820 self.classname = None
821 self.nodeid = None
823 # see if a template or messages are specified
824 template_override = ok_message = error_message = None
825 for key in self.form.keys():
826 if self.FV_TEMPLATE.match(key):
827 template_override = self.form[key].value
828 elif self.FV_OK_MESSAGE.match(key):
829 ok_message = self.form[key].value
830 ok_message = clean_message(ok_message)
831 elif self.FV_ERROR_MESSAGE.match(key):
832 error_message = self.form[key].value
833 error_message = clean_message(error_message)
835 # see if we were passed in a message
836 if ok_message:
837 self.ok_message.append(ok_message)
838 if error_message:
839 self.error_message.append(error_message)
841 # determine the classname and possibly nodeid
842 path = self.path.split('/')
843 if not path or path[0] in ('', 'home', 'index'):
844 if template_override is not None:
845 self.template = template_override
846 else:
847 self.template = ''
848 return
849 elif path[0] in ('_file', '@@file'):
850 raise SendStaticFile, os.path.join(*path[1:])
851 else:
852 self.classname = path[0]
853 if len(path) > 1:
854 # send the file identified by the designator in path[0]
855 raise SendFile, path[0]
857 # see if we got a designator
858 m = dre.match(self.classname)
859 if m:
860 self.classname = m.group(1)
861 self.nodeid = m.group(2)
862 try:
863 klass = self.db.getclass(self.classname)
864 except KeyError:
865 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
866 if not klass.hasnode(self.nodeid):
867 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
868 # with a designator, we default to item view
869 self.template = 'item'
870 else:
871 # with only a class, we default to index view
872 self.template = 'index'
874 # make sure the classname is valid
875 try:
876 self.db.getclass(self.classname)
877 except KeyError:
878 raise NotFound, self.classname
880 # see if we have a template override
881 if template_override is not None:
882 self.template = template_override
884 def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
885 """ Serve the file from the content property of the designated item.
886 """
887 m = dre.match(str(designator))
888 if not m:
889 raise NotFound, str(designator)
890 classname, nodeid = m.group(1), m.group(2)
892 try:
893 klass = self.db.getclass(classname)
894 except KeyError:
895 # The classname was not valid.
896 raise NotFound, str(designator)
898 # perform the Anonymous user access check
899 self.check_anonymous_access()
901 # make sure we have the appropriate properties
902 props = klass.getprops()
903 if not props.has_key('type'):
904 raise NotFound, designator
905 if not props.has_key('content'):
906 raise NotFound, designator
908 # make sure we have permission
909 if not self.db.security.hasPermission('View', self.userid,
910 classname, 'content', nodeid):
911 raise Unauthorised, self._("You are not allowed to view "
912 "this file.")
914 mime_type = klass.get(nodeid, 'type')
915 # Can happen for msg class:
916 if not mime_type:
917 mime_type = 'text/plain'
919 # if the mime_type is HTML-ish then make sure we're allowed to serve up
920 # HTML-ish content
921 if mime_type in ('text/html', 'text/x-html'):
922 if not self.instance.config['WEB_ALLOW_HTML_FILE']:
923 # do NOT serve the content up as HTML
924 mime_type = 'application/octet-stream'
926 # If this object is a file (i.e., an instance of FileClass),
927 # see if we can find it in the filesystem. If so, we may be
928 # able to use the more-efficient request.sendfile method of
929 # sending the file. If not, just get the "content" property
930 # in the usual way, and use that.
931 content = None
932 filename = None
933 if isinstance(klass, hyperdb.FileClass):
934 try:
935 filename = self.db.filename(classname, nodeid)
936 except AttributeError:
937 # The database doesn't store files in the filesystem
938 # and therefore doesn't provide the "filename" method.
939 pass
940 except IOError:
941 # The file does not exist.
942 pass
943 if not filename:
944 content = klass.get(nodeid, 'content')
946 lmt = klass.get(nodeid, 'activity').timestamp()
948 self._serve_file(lmt, mime_type, content, filename)
950 def serve_static_file(self, file):
951 """ Serve up the file named from the templates dir
952 """
953 # figure the filename - try STATIC_FILES, then TEMPLATES dir
954 for dir_option in ('STATIC_FILES', 'TEMPLATES'):
955 prefix = self.instance.config[dir_option]
956 if not prefix:
957 continue
958 # ensure the load doesn't try to poke outside
959 # of the static files directory
960 prefix = os.path.normpath(prefix)
961 filename = os.path.normpath(os.path.join(prefix, file))
962 if os.path.isfile(filename) and filename.startswith(prefix):
963 break
964 else:
965 raise NotFound, file
967 # last-modified time
968 lmt = os.stat(filename)[stat.ST_MTIME]
970 # detemine meta-type
971 file = str(file)
972 mime_type = mimetypes.guess_type(file)[0]
973 if not mime_type:
974 if file.endswith('.css'):
975 mime_type = 'text/css'
976 else:
977 mime_type = 'text/plain'
979 self._serve_file(lmt, mime_type, '', filename)
981 def _serve_file(self, lmt, mime_type, content=None, filename=None):
982 """ guts of serve_file() and serve_static_file()
983 """
985 # spit out headers
986 self.additional_headers['Content-Type'] = mime_type
987 self.additional_headers['Last-Modified'] = rfc822.formatdate(lmt)
989 ims = None
990 # see if there's an if-modified-since...
991 # XXX see which interfaces set this
992 #if hasattr(self.request, 'headers'):
993 #ims = self.request.headers.getheader('if-modified-since')
994 if self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
995 # cgi will put the header in the env var
996 ims = self.env['HTTP_IF_MODIFIED_SINCE']
997 if ims:
998 ims = rfc822.parsedate(ims)[:6]
999 lmtt = time.gmtime(lmt)[:6]
1000 if lmtt <= ims:
1001 raise NotModified
1003 if filename:
1004 self.write_file(filename)
1005 else:
1006 self.additional_headers['Content-Length'] = str(len(content))
1007 self.write(content)
1009 def send_html_to_admin(self, subject, content):
1011 to = [self.mailer.config.ADMIN_EMAIL]
1012 message = self.mailer.get_standard_message(to, subject)
1013 # delete existing content-type headers
1014 del message['Content-type']
1015 message['Content-type'] = 'text/html; charset=utf-8'
1016 message.set_payload(content)
1017 encode_quopri(message)
1018 self.mailer.smtp_send(to, str(message))
1020 def renderFrontPage(self, message):
1021 """Return the front page of the tracker."""
1023 self.classname = self.nodeid = None
1024 self.template = ''
1025 self.error_message.append(message)
1026 self.write_html(self.renderContext())
1028 def renderContext(self):
1029 """ Return a PageTemplate for the named page
1030 """
1031 name = self.classname
1032 extension = self.template
1034 # catch errors so we can handle PT rendering errors more nicely
1035 args = {
1036 'ok_message': self.ok_message,
1037 'error_message': self.error_message
1038 }
1039 try:
1040 pt = self.instance.templates.get(name, extension)
1041 # let the template render figure stuff out
1042 result = pt.render(self, None, None, **args)
1043 self.additional_headers['Content-Type'] = pt.content_type
1044 if self.env.get('CGI_SHOW_TIMING', ''):
1045 if self.env['CGI_SHOW_TIMING'].upper() == 'COMMENT':
1046 timings = {'starttag': '<!-- ', 'endtag': ' -->'}
1047 else:
1048 timings = {'starttag': '<p>', 'endtag': '</p>'}
1049 timings['seconds'] = time.time()-self.start
1050 s = self._('%(starttag)sTime elapsed: %(seconds)fs%(endtag)s\n'
1051 ) % timings
1052 if hasattr(self.db, 'stats'):
1053 timings.update(self.db.stats)
1054 s += self._("%(starttag)sCache hits: %(cache_hits)d,"
1055 " misses %(cache_misses)d."
1056 " Loading items: %(get_items)f secs."
1057 " Filtering: %(filtering)f secs."
1058 "%(endtag)s\n") % timings
1059 s += '</body>'
1060 result = result.replace('</body>', s)
1061 return result
1062 except templating.NoTemplate, message:
1063 return '<strong>%s</strong>'%message
1064 except templating.Unauthorised, message:
1065 raise Unauthorised, str(message)
1066 except:
1067 # everything else
1068 if self.instance.config.WEB_DEBUG:
1069 return cgitb.pt_html(i18n=self.translator)
1070 exc_info = sys.exc_info()
1071 try:
1072 # If possible, send the HTML page template traceback
1073 # to the administrator.
1074 subject = "Templating Error: %s" % exc_info[1]
1075 self.send_html_to_admin(subject, cgitb.pt_html())
1076 # Now report the error to the user.
1077 return self._(error_message)
1078 except:
1079 # Reraise the original exception. The user will
1080 # receive an error message, and the adminstrator will
1081 # receive a traceback, albeit with less information
1082 # than the one we tried to generate above.
1083 raise exc_info[0], exc_info[1], exc_info[2]
1085 # these are the actions that are available
1086 actions = (
1087 ('edit', EditItemAction),
1088 ('editcsv', EditCSVAction),
1089 ('new', NewItemAction),
1090 ('register', RegisterAction),
1091 ('confrego', ConfRegoAction),
1092 ('passrst', PassResetAction),
1093 ('login', LoginAction),
1094 ('logout', LogoutAction),
1095 ('search', SearchAction),
1096 ('retire', RetireAction),
1097 ('show', ShowAction),
1098 ('export_csv', ExportCSVAction),
1099 )
1100 def handle_action(self):
1101 """ Determine whether there should be an Action called.
1103 The action is defined by the form variable :action which
1104 identifies the method on this object to call. The actions
1105 are defined in the "actions" sequence on this class.
1107 Actions may return a page (by default HTML) to return to the
1108 user, bypassing the usual template rendering.
1110 We explicitly catch Reject and ValueError exceptions and
1111 present their messages to the user.
1112 """
1113 if self.form.has_key(':action'):
1114 action = self.form[':action'].value.lower()
1115 elif self.form.has_key('@action'):
1116 action = self.form['@action'].value.lower()
1117 else:
1118 return None
1120 try:
1121 action_klass = self.get_action_class(action)
1123 # call the mapped action
1124 if isinstance(action_klass, type('')):
1125 # old way of specifying actions
1126 return getattr(self, action_klass)()
1127 else:
1128 return action_klass(self).execute()
1130 except (ValueError, Reject), err:
1131 self.error_message.append(str(err))
1133 def get_action_class(self, action_name):
1134 if (hasattr(self.instance, 'cgi_actions') and
1135 self.instance.cgi_actions.has_key(action_name)):
1136 # tracker-defined action
1137 action_klass = self.instance.cgi_actions[action_name]
1138 else:
1139 # go with a default
1140 for name, action_klass in self.actions:
1141 if name == action_name:
1142 break
1143 else:
1144 raise ValueError, 'No such action "%s"'%action_name
1145 return action_klass
1147 def _socket_op(self, call, *args, **kwargs):
1148 """Execute socket-related operation, catch common network errors
1150 Parameters:
1151 call: a callable to execute
1152 args, kwargs: call arguments
1154 """
1155 try:
1156 call(*args, **kwargs)
1157 except socket.error, err:
1158 err_errno = getattr (err, 'errno', None)
1159 if err_errno is None:
1160 try:
1161 err_errno = err[0]
1162 except TypeError:
1163 pass
1164 if err_errno not in self.IGNORE_NET_ERRORS:
1165 raise
1166 except IOError:
1167 # Apache's mod_python will raise IOError -- without an
1168 # accompanying errno -- when a write to the client fails.
1169 # A common case is that the client has closed the
1170 # connection. There's no way to be certain that this is
1171 # the situation that has occurred here, but that is the
1172 # most likely case.
1173 pass
1175 def write(self, content):
1176 if not self.headers_done:
1177 self.header()
1178 if self.env['REQUEST_METHOD'] != 'HEAD':
1179 self._socket_op(self.request.wfile.write, content)
1181 def write_html(self, content):
1182 if not self.headers_done:
1183 # at this point, we are sure about Content-Type
1184 if not self.additional_headers.has_key('Content-Type'):
1185 self.additional_headers['Content-Type'] = \
1186 'text/html; charset=%s' % self.charset
1187 self.header()
1189 if self.env['REQUEST_METHOD'] == 'HEAD':
1190 # client doesn't care about content
1191 return
1193 if self.charset != self.STORAGE_CHARSET:
1194 # recode output
1195 content = content.decode(self.STORAGE_CHARSET, 'replace')
1196 content = content.encode(self.charset, 'xmlcharrefreplace')
1198 # and write
1199 self._socket_op(self.request.wfile.write, content)
1201 def http_strip(self, content):
1202 """Remove HTTP Linear White Space from 'content'.
1204 'content' -- A string.
1206 returns -- 'content', with all leading and trailing LWS
1207 removed."""
1209 # RFC 2616 2.2: Basic Rules
1210 #
1211 # LWS = [CRLF] 1*( SP | HT )
1212 return content.strip(" \r\n\t")
1214 def http_split(self, content):
1215 """Split an HTTP list.
1217 'content' -- A string, giving a list of items.
1219 returns -- A sequence of strings, containing the elements of
1220 the list."""
1222 # RFC 2616 2.1: Augmented BNF
1223 #
1224 # Grammar productions of the form "#rule" indicate a
1225 # comma-separated list of elements matching "rule". LWS
1226 # is then removed from each element, and empty elements
1227 # removed.
1229 # Split at commas.
1230 elements = content.split(",")
1231 # Remove linear whitespace at either end of the string.
1232 elements = [self.http_strip(e) for e in elements]
1233 # Remove any now-empty elements.
1234 return [e for e in elements if e]
1236 def handle_range_header(self, length, etag):
1237 """Handle the 'Range' and 'If-Range' headers.
1239 'length' -- the length of the content available for the
1240 resource.
1242 'etag' -- the entity tag for this resources.
1244 returns -- If the request headers (including 'Range' and
1245 'If-Range') indicate that only a portion of the entity should
1246 be returned, then the return value is a pair '(offfset,
1247 length)' indicating the first byte and number of bytes of the
1248 content that should be returned to the client. In addition,
1249 this method will set 'self.response_code' to indicate Partial
1250 Content. In all other cases, the return value is 'None'. If
1251 appropriate, 'self.response_code' will be
1252 set to indicate 'REQUESTED_RANGE_NOT_SATISFIABLE'. In that
1253 case, the caller should not send any data to the client."""
1255 # RFC 2616 14.35: Range
1256 #
1257 # See if the Range header is present.
1258 ranges_specifier = self.env.get("HTTP_RANGE")
1259 if ranges_specifier is None:
1260 return None
1261 # RFC 2616 14.27: If-Range
1262 #
1263 # Check to see if there is an If-Range header.
1264 # Because the specification says:
1265 #
1266 # The If-Range header ... MUST be ignored if the request
1267 # does not include a Range header, we check for If-Range
1268 # after checking for Range.
1269 if_range = self.env.get("HTTP_IF_RANGE")
1270 if if_range:
1271 # The grammar for the If-Range header is:
1272 #
1273 # If-Range = "If-Range" ":" ( entity-tag | HTTP-date )
1274 # entity-tag = [ weak ] opaque-tag
1275 # weak = "W/"
1276 # opaque-tag = quoted-string
1277 #
1278 # We only support strong entity tags.
1279 if_range = self.http_strip(if_range)
1280 if (not if_range.startswith('"')
1281 or not if_range.endswith('"')):
1282 return None
1283 # If the condition doesn't match the entity tag, then we
1284 # must send the client the entire file.
1285 if if_range != etag:
1286 return
1287 # The grammar for the Range header value is:
1288 #
1289 # ranges-specifier = byte-ranges-specifier
1290 # byte-ranges-specifier = bytes-unit "=" byte-range-set
1291 # byte-range-set = 1#( byte-range-spec | suffix-byte-range-spec )
1292 # byte-range-spec = first-byte-pos "-" [last-byte-pos]
1293 # first-byte-pos = 1*DIGIT
1294 # last-byte-pos = 1*DIGIT
1295 # suffix-byte-range-spec = "-" suffix-length
1296 # suffix-length = 1*DIGIT
1297 #
1298 # Look for the "=" separating the units from the range set.
1299 specs = ranges_specifier.split("=", 1)
1300 if len(specs) != 2:
1301 return None
1302 # Check that the bytes-unit is in fact "bytes". If it is not,
1303 # we do not know how to process this range.
1304 bytes_unit = self.http_strip(specs[0])
1305 if bytes_unit != "bytes":
1306 return None
1307 # Seperate the range-set into range-specs.
1308 byte_range_set = self.http_strip(specs[1])
1309 byte_range_specs = self.http_split(byte_range_set)
1310 # We only handle exactly one range at this time.
1311 if len(byte_range_specs) != 1:
1312 return None
1313 # Parse the spec.
1314 byte_range_spec = byte_range_specs[0]
1315 pos = byte_range_spec.split("-", 1)
1316 if len(pos) != 2:
1317 return None
1318 # Get the first and last bytes.
1319 first = self.http_strip(pos[0])
1320 last = self.http_strip(pos[1])
1321 # We do not handle suffix ranges.
1322 if not first:
1323 return None
1324 # Convert the first and last positions to integers.
1325 try:
1326 first = int(first)
1327 if last:
1328 last = int(last)
1329 else:
1330 last = length - 1
1331 except:
1332 # The positions could not be parsed as integers.
1333 return None
1334 # Check that the range makes sense.
1335 if (first < 0 or last < 0 or last < first):
1336 return None
1337 if last >= length:
1338 # RFC 2616 10.4.17: 416 Requested Range Not Satisfiable
1339 #
1340 # If there is an If-Range header, RFC 2616 says that we
1341 # should just ignore the invalid Range header.
1342 if if_range:
1343 return None
1344 # Return code 416 with a Content-Range header giving the
1345 # allowable range.
1346 self.response_code = httplib.REQUESTED_RANGE_NOT_SATISFIABLE
1347 self.setHeader("Content-Range", "bytes */%d" % length)
1348 return None
1349 # RFC 2616 10.2.7: 206 Partial Content
1350 #
1351 # Tell the client that we are honoring the Range request by
1352 # indicating that we are providing partial content.
1353 self.response_code = httplib.PARTIAL_CONTENT
1354 # RFC 2616 14.16: Content-Range
1355 #
1356 # Tell the client what data we are providing.
1357 #
1358 # content-range-spec = byte-content-range-spec
1359 # byte-content-range-spec = bytes-unit SP
1360 # byte-range-resp-spec "/"
1361 # ( instance-length | "*" )
1362 # byte-range-resp-spec = (first-byte-pos "-" last-byte-pos)
1363 # | "*"
1364 # instance-length = 1 * DIGIT
1365 self.setHeader("Content-Range",
1366 "bytes %d-%d/%d" % (first, last, length))
1367 return (first, last - first + 1)
1369 def write_file(self, filename):
1370 """Send the contents of 'filename' to the user."""
1372 # Determine the length of the file.
1373 stat_info = os.stat(filename)
1374 length = stat_info[stat.ST_SIZE]
1375 # Assume we will return the entire file.
1376 offset = 0
1377 # If the headers have not already been finalized,
1378 if not self.headers_done:
1379 # RFC 2616 14.19: ETag
1380 #
1381 # Compute the entity tag, in a format similar to that
1382 # used by Apache.
1383 etag = '"%x-%x-%x"' % (stat_info[stat.ST_INO],
1384 length,
1385 stat_info[stat.ST_MTIME])
1386 self.setHeader("ETag", etag)
1387 # RFC 2616 14.5: Accept-Ranges
1388 #
1389 # Let the client know that we will accept range requests.
1390 self.setHeader("Accept-Ranges", "bytes")
1391 # RFC 2616 14.35: Range
1392 #
1393 # If there is a Range header, we may be able to avoid
1394 # sending the entire file.
1395 content_range = self.handle_range_header(length, etag)
1396 if content_range:
1397 offset, length = content_range
1398 # RFC 2616 14.13: Content-Length
1399 #
1400 # Tell the client how much data we are providing.
1401 self.setHeader("Content-Length", str(length))
1402 # Send the HTTP header.
1403 self.header()
1404 # If the client doesn't actually want the body, or if we are
1405 # indicating an invalid range.
1406 if (self.env['REQUEST_METHOD'] == 'HEAD'
1407 or self.response_code == httplib.REQUESTED_RANGE_NOT_SATISFIABLE):
1408 return
1409 # Use the optimized "sendfile" operation, if possible.
1410 if hasattr(self.request, "sendfile"):
1411 self._socket_op(self.request.sendfile, filename, offset, length)
1412 return
1413 # Fallback to the "write" operation.
1414 f = open(filename, 'rb')
1415 try:
1416 if offset:
1417 f.seek(offset)
1418 content = f.read(length)
1419 finally:
1420 f.close()
1421 self.write(content)
1423 def setHeader(self, header, value):
1424 """Override a header to be returned to the user's browser.
1425 """
1426 self.additional_headers[header] = value
1428 def header(self, headers=None, response=None):
1429 """Put up the appropriate header.
1430 """
1431 if headers is None:
1432 headers = {'Content-Type':'text/html; charset=utf-8'}
1433 if response is None:
1434 response = self.response_code
1436 # update with additional info
1437 headers.update(self.additional_headers)
1439 if headers.get('Content-Type', 'text/html') == 'text/html':
1440 headers['Content-Type'] = 'text/html; charset=utf-8'
1442 headers = headers.items()
1444 for ((path, name), (value, expire)) in self._cookies.items():
1445 cookie = "%s=%s; Path=%s;"%(name, value, path)
1446 if expire is not None:
1447 cookie += " expires=%s;"%Cookie._getdate(expire)
1448 headers.append(('Set-Cookie', cookie))
1450 self._socket_op(self.request.start_response, headers, response)
1452 self.headers_done = 1
1453 if self.debug:
1454 self.headers_sent = headers
1456 def add_cookie(self, name, value, expire=86400*365, path=None):
1457 """Set a cookie value to be sent in HTTP headers
1459 Parameters:
1460 name:
1461 cookie name
1462 value:
1463 cookie value
1464 expire:
1465 cookie expiration time (seconds).
1466 If value is empty (meaning "delete cookie"),
1467 expiration time is forced in the past
1468 and this argument is ignored.
1469 If None, the cookie will expire at end-of-session.
1470 If omitted, the cookie will be kept for a year.
1471 path:
1472 cookie path (optional)
1474 """
1475 if path is None:
1476 path = self.cookie_path
1477 if not value:
1478 expire = -1
1479 self._cookies[(path, name)] = (value, expire)
1481 def set_cookie(self, user, expire=None):
1482 """Deprecated. Use session_api calls directly
1484 XXX remove
1485 """
1487 # insert the session in the session db
1488 self.session_api.set(user=user)
1489 # refresh session cookie
1490 self.session_api.update(set_cookie=True, expire=expire)
1492 def make_user_anonymous(self):
1493 """ Make us anonymous
1495 This method used to handle non-existence of the 'anonymous'
1496 user, but that user is mandatory now.
1497 """
1498 self.userid = self.db.user.lookup('anonymous')
1499 self.user = 'anonymous'
1501 def standard_message(self, to, subject, body, author=None):
1502 """Send a standard email message from Roundup.
1504 "to" - recipients list
1505 "subject" - Subject
1506 "body" - Message
1507 "author" - (name, address) tuple or None for admin email
1509 Arguments are passed to the Mailer.standard_message code.
1510 """
1511 try:
1512 self.mailer.standard_message(to, subject, body, author)
1513 except MessageSendError, e:
1514 self.error_message.append(str(e))
1515 return 0
1516 return 1
1518 def parsePropsFromForm(self, create=0):
1519 return FormParser(self).parse(create=create)
1521 # vim: set et sts=4 sw=4 :