#!/usr/bin/env python
# encoding: utf-8
"""
gaetk2.handlers.base - default Request Handler for gaetk2.
Created by Maximillian Dornseif on 2010-10-03.
Copyright (c) 2010-2018 HUDORA. All rights reserved.
"""
import inspect
import logging
import os
import time
import urlparse
import jinja2
import webapp2
from google.appengine.api import memcache, users
from google.appengine.api.app_identity import get_application_id
from .. import exc, jinja_filters
from ..tools import hujson2
from ..config import gaetkconfig
from ..config import get_release, is_development, is_production
from ..tools.sentry import sentry_client
try:
# if mixing gaetk1 and gaetk2 we need to use the same module
# to get the rifght thread local storage
from gaetk import gaesessions
except:
from gaetk2 import _gaesessions as gaesessions
logger = logging.getLogger(__name__)
_jinja_env_cache = None
# Your app usually will extend a `BasicHandler` or `DefaultHandler`
# (for added authentication). These based on
# [webapp2.RequestHandler](http://webapp2.readthedocs.io/en/latest/api/webapp2.html#webapp2.RequestHandler)
# See [The webapp2 Guide](http://webapp2.readthedocs.io/en/latest/guide/handlers.html)
# for an introduction.
[docs]class BasicHandler(webapp2.RequestHandler):
"""Generic Handler functionality.
You usually overwrite :meth:`get()` or :meth:`post()` and call
:meth:`render()` in there. See :ref:`gaetk2.handlers` for examples.
For Returning Data to the user you can access the `self.response` object
or use :meth:`return_text` and :meth:`render`. See :meth:`_create_jinja2env`
to understand the jinja2 context being used.
Helper functions are :meth:`abs_url()` and :meth:`is_production`.
See Also:
:meth:`is_sysadmin`, :meth:`is_staff` and :meth:`has_permission`
are meant to work with
:class:`~gaetk2.handlers.authentication.AuthenticationReaderMixin`
Attributes:
credential: authenticated user, see :class:`~gaetk2.handlers.authentication.AuthenticationReaderMixin`
session: current session which is based on https://github.com/dound/gae-sessions.
default_cachingtime (None or int): Class Variable. Which cache headers to generate,
see :meth:`_set_cache_headers`.
debug_hooks (boolean): Class Variable. If set to ``True`` the order in which
hooks are called is logged.
Note:
gaetk2 adds various variables to the template context. Other mixins provide
additional template variables. See the Index :ref:`genindex` under "Template Context"
to get an overview.
These :index:`Template Variables <Template Context>` are provided:
* :index:`request <Template Context; request>`
* :index:`credential <Template Context; credential>`
* :index:`is_staff <Template Context; is_staff>`
* :index:`is_sysadmin <Template Context; is_sysadmin>`
* :index:`gaetk_production <Template Context; gaetk_production>`
* :index:`gaetk_development <Template Context; gaetk_development>`
* :index:`gaetk_app_name <Template Context; gaetk_app_name>`
* :index:`gaeth_version <Template Context; gaeth_version>`
* :index:`gaetk_logout_url <Template Context; gaetk_logout_url>`
.. _handler-hook-mechanism:
Warning:
:class:`BasicHandler` implements a rather unusual way to implement
Multi-Inherance/Mix-Ins. Instead of insisting that every parent class
and everty Mix-In implements all possible methods and calls super on them
:class:`BasicHandler` uses a custom dispatch mechanism which calls all
methods in all parent and sibling classes.
The following functions are called on all parent and sibling classes:
* :meth:`pre_authentication_hook`.
* :meth:`authentication_preflight_hook`.
* :meth:`authentication_hook`.
* :meth:`authorisation_hook`.
* :meth:`method_preperation_hook`.
* :meth:`finished_hook` - called even if a :exc:`exc.HTTPException` < 500 occurs.
:meth:`build_context` is special because the output is "chained".
So the rendering is done with something like the output of
``Child.build_context(Parent.build_context(MixIn.build_context({})))``
:meth:`response_overwrite` and :meth:`finished_overwrite` can be overwritten
to provide special functionality like in :class:`JsonBasicHandler`.
You are encouraged to study the source code of :class:`BasicHandler`!
"""
default_cachingtime = None
debug_hooks = False
def __init__(self, *args, **kwargs):
"""Initialize RequestHandler."""
self.credential = None
self.session = {}
# Careful! `webapp2.RequestHandler` does not call super()!
super(BasicHandler, self).__init__(*args, **kwargs)
# ... so we route arround that
super(webapp2.RequestHandler, self).__init__()
# helper methods
[docs] def abs_url(self, url):
"""Converts an relative into an qualified absolute URL.
Args:
url (str): an path to a web resource.
Returns:
str: A fully qualified url.
Example:
>>> BasicHandler().abs_url('/foo')
'http://server.example.com/foo'
"""
if self.request:
return urlparse.urljoin(self.request.uri, url)
return urlparse.urljoin(os.environ.get('HTTP_ORIGIN', ''), url)
[docs] def is_sysadmin(self):
"""Checks if the current user is logged in as a SysOp/SystemAdministrator.
We use various souces to deterine the Status of the user.
Returns `True` if:
* google.appengine.api.users.is_current_user_admin()
* the request came from `127.0.0.1` local address
* `self.credential.sysadmin == True`
Returns:
boolean: the status of the currently logged in user.
"""
# Google App Engine Administrators
if users.is_current_user_admin():
return True
# Requests from localhost (on dev_appserver) are always admin
if self.request.remote_addr == '127.0.0.1':
return True
# User with Admin permissions via Credential entities
if not hasattr(self, 'credential'):
return False
elif self.credential is None:
return False
return getattr(self.credential, 'sysadmin', False)
[docs] def is_staff(self):
"""Returns if the current user is considered internal.
This means he has access to not only his own but to all
settings pages, etc.
* :meth:`is_sysadmin`
* `self.credential.staff == True`
Returns:
boolean: the status of the currently logged in user is considered internal.
"""
if self.is_sysadmin():
return True
elif self.credential is None:
return False
return getattr(self.credential, 'staff', False)
[docs] def has_permission(self, permission):
"""Checks if user has a given permission."""
if self.credential:
return permission in self.credential.permissions
return False
[docs] def render(self, values, template_name):
"""Render a Jinja2 Template and write it to the client.
If rendering takes an unusual long time this is logged.
Parameters:
values (dict): variables for template context.
template_name (str): name of the template to render.
See also:
:meth:`build_context()`also provides data to the template
context and is often extended by plugins. See
:class:`BasicHandler` docsting for standard template variables.
"""
start = time.time()
self._render_to_fd(values, template_name, self.response.out)
delta = time.time() - start
if delta > 500:
logger.warn("rendering took %d ms", (delta * 1000.0))
[docs] def return_text(self, text, status=200, content_type='text/plain', encoding='utf-8'):
"""Quick and dirty sending of some plaintext to the client.
Parameters:
text (str or unicode): Data to be sent to the cliend. A ``\\n`` is appended.
status (int): status code to be sent to the client. Defaults to 200.
content_type: to be sent to the client in respective header.
encoding: to be used when sending to the client.
"""
self.response.set_status(status)
self.response.headers['Content-Type'] = content_type
if isinstance(text, unicode):
text = text.encode(encoding)
self.response.out.write(text)
self.response.out.write('\n')
# to be overwritten / extended
[docs] def build_context(self, values):
"""Helper to provide additional request-specific values to HTML Templates.
Will be called on all Parents and MixIns, no `super()` needed.
def build_context(self, values):
myvalues = dict(navsection='kunden', ...)
myvalues.update(values)
return myvalues
"""
ret = dict(
request=self.request,
credential=self.credential,
gaetk_logout_url='/gaetk2/auth/logout',
is_staff=self.is_staff(),
is_sysadmin=self.is_sysadmin(),
)
ret.update(values)
return ret
[docs] def _add_jinja2env_globals(self, env):
"""Helper to provide additional Globals to Jinja2 Environment.
This should be considered one time initialisation.
Example::
env.globals['bottommenuurl'] = '/admin/'
env.globals['bottommenuaddon'] = '<i class="fa fa-area-chart"></i> Admin'
env.globals['profiler_includes'] = gae_mini_profiler.templatetags.profiler_includes
"""
if not gaetkconfig.APP_NAME:
gaetkconfig.APP_NAME = get_application_id().capitalize()
env.globals.update(dict(
gaetk_production=is_production(),
gaetk_development=is_development(),
gaetk_release=get_release(),
gaetk_app_name=gaetkconfig.APP_NAME,
gaetk_sentry_dsn=gaetkconfig.SENTRY_PUBLIC_DSN,
))
return env
[docs] def debug(self, message, *args):
"""Detailed logging for development.
This logging will only happen, if :class:`WSGIApplication` was
initialized with ``debug=True``. Is meant for local inspection
of the stack during development.
Messages are prefixed with the method name from where they are called.
"""
if self.app.debug:
curframe = inspect.currentframe()
calframe = inspect.getouterframes(curframe, 2)
callfile = calframe[1][1].split('/')[-1]
callfunc = calframe[1][3]
message = "{} {}(): {}".format(callfile, callfunc, message)
logger.debug(message, *args)
# filename, lineno, function, code_context, index).
# For MixIns:
[docs] def pre_authentication_hook(self, method_name, *args, **kwargs):
"""Might do redirects before even authentication data is loaded.
Called on all parent and sibling classes.
"""
return
[docs] def authentication_preflight_hook(self, method_name, *args, **kwargs):
"""Might load Authentication data from Headers.
Called on all parent and sibling classes.
"""
return
[docs] def authentication_hook(self, method_name, *args, **kwargs):
"""Might verify Authentication data.
Called on all parent and sibling classes.
"""
return
[docs] def authorisation_hook(self, method_name, *args, **kwargs):
"""Might check if authenticated user is authorized.
Called on all parent and sibling classes.
"""
return
[docs] def method_preperation_hook(self, method_name, *args, **kwargs):
"""Is Called just before GEP, POST, PUT, DELETE etc. is called.
Used to provide common data in child classes.
E.g. to set up variables, load Date etc.
"""
return
[docs] def response_overwrite(self, response, method, *args, **kwargs):
"""Function to transform response. To be overwritten."""
return response
[docs] def finished_hook(self, method, *args, **kwargs):
"""To be called at the end of an request."""
[docs] def finished_overwrite(self, response, method, *args, **kwargs):
"""Function to allow logging etc. To be overwritten."""
# not called when exceptions are raised
[docs] def clear_session(self):
"""Terminate the session reliably."""
logger.info("clearing session")
self.session['uid'] = None
if self.session and self.session.is_active():
self.session.terminate()
self.session.regenerate_id()
# internal stuff
[docs] def _create_jinja2env(self):
"""Initialise and return a jinja2 Environment instance."""
global _jinja_env_cache
if not _jinja_env_cache:
env = jinja2.Environment(
loader=jinja2.FileSystemLoader(gaetkconfig.TEMPLATE_DIRS),
auto_reload=False, # unneeded on App Engine production
trim_blocks=True, # first newline after a block is removed
# lstrip_blocks=True,
bytecode_cache=jinja2.MemcachedBytecodeCache(memcache, timeout=3600),
# This needs jinja2 > Version 2.8
autoescape=jinja2.select_autoescape(['html', 'xml']),
)
env.exception_handler = self._jinja2_exception_handler
jinja_filters.register_custom_filters(env)
env.policies['json.dumps_function'] = hujson2.htmlsafe_json_dumps
env = self._add_jinja2env_globals(env)
_jinja_env_cache = env
return _jinja_env_cache
[docs] def _jinja2_exception_handler(self, traceback):
"""Is called during Jinja2 Exception processing to provide logging."""
global _jinja_env_cache
# see http://flask.pocoo.org/snippets/74/
# here we still get the correct traceback information, it will be discarded later on
logger.info('Template globals = %s', getattr(_jinja_env_cache, 'globals', None))
logger.exception("Template Exception %s", traceback.render_as_text())
sentry_client.captureException(exc_info=traceback.exc_info)
[docs] def _render_to_fd(self, values, template_name, fd):
"""Sends the rendered content of a Jinja2 Template to Output.
Per default the template is provided with output of ``build_context(values)``.
"""
env = self._create_jinja2env()
try:
template = env.get_template(template_name)
except jinja2.TemplateNotFound:
# better error reporting - we want to see the name of the base template
raise jinja2.TemplateNotFound(template_name)
except (jinja2.TemplateAssertionError, jinja2.TemplateRuntimeError):
# better logging
logger.debug("values=%r", values)
logger.debug("env.globals=%r", env.globals.keys())
logger.debug("env.filters=%r", env.filters.keys())
raise
# to collect template variables from all Parent-Classes and MisIns.
# this keeps parents from having all to implement the function and
# use `super()`
values = self._reduce_all_inherited('build_context', values)
# for debugging provide access to all variables in gaetk_localcontext
if is_development():
try:
values['gaetk_globalcontext_json'] = hujson2.htmlsafe_json_dumps(env.globals)
values['gaetk_localcontext_json'] = hujson2.htmlsafe_json_dumps(values)
except:
logging.exception('gaetk_*context issue')
try:
template.stream(values).dump(fd, encoding='utf-8')
# we do not want to rely on webob.Response magically transforming unicode
except jinja2.TemplateNotFound: # can happen for includes etc.
# better error reporting
# TODO: https://docs.sentry.io/clientdev/interfaces/template/
logger.info('jinja2 environment: %s', vars(env))
logger.info('template dirs: %s', gaetkconfig.TEMPLATE_DIRS)
raise
# TODO: warn about undeclared variables
# http://jinja.pocoo.org/docs/dev/api/#the-meta-api
# from jinja2 import Environment, PackageLoader, meta
# env = Environment(loader=PackageLoader('gummi', 'templates'))
# template_source = env.loader.get_source(env, 'page_content.html')[0]
# parsed_content = env.parse(template_source)
# meta.find_undeclared_variables(parsed_content)
[docs] def _set_cache_headers(self, caching_time=None):
"""Set Cache Headers.
Parameters:
caching_time (None or int): the number of seconds, the result should
be cachetd at frontend caches. ``None`` means no Caching-Headers.
See also :any:`default_cachingtime`. `0` or negative Values generate
an comand to disable all caching.
"""
ct = self.default_cachingtime
if caching_time is not None:
ct = caching_time
if ct is not None:
if ct > 0:
self.response.headers['Cache-Control'] = 'max-age=%d public' % ct
elif ct <= 0:
self.response.headers['Cache-Control'] = 'no-cache public'
[docs] def _call_all_inherited(self, funcname, *args, **kwargs):
"""In all SuperClasses call `funcname` - if it exists."""
# We don't want to burden all mixins with implementing
# the required methods and calling `super().meth()`
# so we don't use a call chain provided by the
# Parents and MixIns but instead work through them as a list.
# it also reverses the call order
# This code is based in ideas from Guido van Rossum, see
# https://www.python.org/download/releases/2.2/descrintro/#cooperation
for cls in reversed(self.__class__.__mro__):
if funcname in cls.__dict__:
x = cls.__dict__[funcname]
if hasattr(x, "__get__"):
x = x.__get__(self)
if callable(x):
if self.debug_hooks:
logger.debug("calling %s.%s(*%r, **%r)", cls, funcname, args, kwargs)
try:
x(*args, **kwargs)
except BaseException as e:
if not isinstance(e, exc.HTTPException):
logger.exception("failure calling %s.%s(*%r, **%r)", cls, funcname, args, kwargs)
raise
else:
logger.warn("not clallable: %r", x)
[docs] def _reduce_all_inherited(self, funcname, initial):
"""In all SuperClasses call `funcname` with the output of the previus call."""
# We don't want to burden all mixins with mplementing
# the required methods and calling `super().meth()`
# so we don't use a call chaon provided by the
# Parents and MixIns but instead work through them as a list.
# it also reverses the call order
ret = initial
# This code is based in ideas from Guido van Rossum, see
# https://www.python.org/download/releases/2.2/descrintro/#cooperation
for cls in reversed(self.__class__.__mro__):
if funcname in cls.__dict__:
x = cls.__dict__[funcname]
if hasattr(x, "__get__"):
x = x.__get__(self)
if callable(x):
if self.debug_hooks:
logger.debug("reducing %s.%s(%r)", cls, funcname, ret)
try:
ret = x(ret)
except:
logger.debug("error reducing %s.%s(%r)", cls, funcname, ret)
raise
else:
logger.warn("not callable: %r", x)
if ret is None:
raise RuntimeError('%s.%s did not provide a return value' % (cls, funcname))
return ret
[docs] def dispatch(self):
"""Dispatches the requested method fom the WSGI App.
Meant for internal use by the stack."""
request = self.request
method_name = request.route.handler_method
if not method_name:
method_name = webapp2._normalize_handler_method(request.method)
method = getattr(self, method_name, None)
if hasattr(self, '__class__'):
sentry_client.tags_context({
'handler': self.__class__.__name__,
'method': method_name,
})
if method is None:
# 405 Method Not Allowed.
valid = ', '.join(webapp2._get_handler_methods(self))
self.abort(405, headers=[('Allow', valid)])
# The handler only receives *args if no named variables are set.
args, kwargs = request.route_args, request.route_kwargs
if kwargs:
args = ()
# bind session on dispatch (not in __init__)
self.session = gaesessions.get_current_session()
# get_current_session() sometimes returns strange results
if self.session is None:
self.session = object()
try:
self._call_all_inherited('pre_authentication_hook', method_name, *args, **kwargs)
self._call_all_inherited('authentication_preflight_hook', method_name, *args, **kwargs)
self._call_all_inherited('authentication_hook', method_name, *args, **kwargs)
self._call_all_inherited('authorisation_hook', method_name, *args, **kwargs)
self._call_all_inherited('method_preperation_hook', method_name, *args, **kwargs)
response = method(*args, **kwargs)
response = self.response_overwrite(response, method, *args, **kwargs)
except exc.HTTPException as e:
# for HTTP exceptions execute `finished_hooks`
if e.code < 500:
self._call_all_inherited('finished_hook', method_name, *args, **kwargs)
return self.handle_exception(e, self.app.debug)
except BaseException, e:
return self.handle_exception(e, self.app.debug)
if response:
assert isinstance(response, webapp2.Response)
self._set_cache_headers()
self._call_all_inherited('finished_hook', method_name, *args, **kwargs)
self.finished_overwrite(response, method, *args, **kwargs)
return response
[docs] def handle_exception(self, exception, debug):
"""Called if this handler throws an exception during execution.
The default behavior is to re-raise the exception to be handled by
:meth:`WSGIApplication.handle_exception`.
Parameters:
exception: The exception that was thrown.
debug_mode: True if the web application is running in debug mode.
Returns:
response to be sent to the client.
"""
raise
[docs]class JsonBasicHandler(BasicHandler):
"""Handler which is specialized for returning JSON.
Excepts the method to return
* dict(), e.g. `{'foo': bar}`
Dict is converted to JSON. `status` is used as HTTP status code. `cachingtime`
is used to generate a `Cache-Control` header. If `cachingtime is None`, no header
is generated. `cachingtime` defaults to 60 seconds.
"""
# Our default caching is 60s
default_cachingtime = 60
[docs] def serialize(self, content):
"""convert content to JSON."""
return hujson2.dumps(content)
[docs] def response_overwrite(self, response, method, *args, **kwargs):
"""Function to transform response. To be overwritten."""
# do serialisation bef ore generating Content-Type Header so Errors will display nicely
content = self.serialize(response) + '\n'
# If we have gotten a `callback` parameter, we expect that this is a
# [JSONP](http://en.wikipedia.org/wiki/JSONP#JSONP) can and therefore add the padding
if self.request.get('callback', None):
response = "%s (%s)" % (self.request.get('callback', None), response)
self.response.headers['Content-Type'] = 'text/javascript'
else:
self.response.headers['Content-Type'] = 'application/json'
return webapp2.Response(content)