#!/usr/bin/env python
# camcops_server/cc_modules/cc_request.py
"""
===============================================================================
Copyright (C) 2012-2018 Rudolf Cardinal (rudolf@pobox.com).
This file is part of CamCOPS.
CamCOPS is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
CamCOPS is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with CamCOPS. If not, see <http://www.gnu.org/licenses/>.
===============================================================================
"""
from contextlib import contextmanager
import logging
import os
from typing import Any, Dict, Generator, List, Optional, Tuple, TYPE_CHECKING
import urllib.parse
from cardinal_pythonlib.datetimefunc import (
coerce_to_pendulum,
coerce_to_pendulum_date,
convert_datetime_to_utc,
format_datetime,
)
from cardinal_pythonlib.logs import BraceStyleAdapter
from cardinal_pythonlib.plot import (
png_img_html_from_pyplot_figure,
svg_html_from_pyplot_figure,
)
import cardinal_pythonlib.rnc_web as ws
from cardinal_pythonlib.wsgi.constants import WsgiEnvVar
from matplotlib.backends.backend_agg import FigureCanvasAgg as FigureCanvas
from matplotlib.figure import Figure
from matplotlib.font_manager import FontProperties
from pendulum import Date, DateTime as Pendulum
from pendulum.parsing.exceptions import ParserError
from pyramid.config import Configurator
from pyramid.decorator import reify
from pyramid.httpexceptions import HTTPException
from pyramid.interfaces import ISession
from pyramid.request import Request
from pyramid.response import Response
from pyramid.testing import DummyRequest
from sqlalchemy.engine.base import Engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm import Session as SqlASession
from webob.multidict import MultiDict
# Note: everything uder the sun imports this file, so keep the intra-package
# imports as minimal as possible.
from .cc_baseconstants import (
ENVVAR_CONFIG_FILE,
DOCUMENTATION_INDEX_FILENAME_STEM,
)
from .cc_config import (
CamcopsConfig,
get_config,
get_config_filename_from_os_env,
)
from .cc_constants import (
CSS_PAGED_MEDIA,
DateFormat,
DEFAULT_PLOT_DPI,
USE_SVG_IN_HTML,
)
from .cc_idnumdef import get_idnum_definitions, IdNumDefinition
# noinspection PyUnresolvedReferences
import camcops_server.cc_modules.cc_plot # import side effects (configure matplotlib) # noqa
from .cc_pyramid import (
camcops_add_mako_renderer,
CamcopsAuthenticationPolicy,
CamcopsAuthorizationPolicy,
CookieKey,
get_session_factory,
Permission,
RequestMethod,
RouteCollection,
STATIC_CAMCOPS_PACKAGE_PATH,
)
from .cc_serversettings import get_server_settings, ServerSettings
from .cc_string import all_extra_strings_as_dicts, APPSTRING_TASKNAME
from .cc_tabletsession import TabletSession
from .cc_user import User
if TYPE_CHECKING:
from matplotlib.axis import Axis
from matplotlib.axes import Axes
# from matplotlib.figure import SubplotBase
from matplotlib.text import Text
from .cc_session import CamcopsSession
log = BraceStyleAdapter(logging.getLogger(__name__))
# =============================================================================
# Debugging options
# =============================================================================
DEBUG_ADD_ROUTES = False
DEBUG_AUTHORIZATION = False
DEBUG_REQUEST_CREATION = False
DEBUG_CAMCOPS_SESSION = False
DEBUG_TABLET_SESSION = False
DEBUG_DBSESSION_MANAGEMENT = False
if any([DEBUG_ADD_ROUTES,
DEBUG_AUTHORIZATION,
DEBUG_REQUEST_CREATION,
DEBUG_CAMCOPS_SESSION,
DEBUG_TABLET_SESSION,
DEBUG_DBSESSION_MANAGEMENT]):
log.warning("Debugging options enabled!")
# =============================================================================
# Modified Request interface, for type checking
# =============================================================================
# https://docs.pylonsproject.org/projects/pyramid_cookbook/en/latest/auth/user_object.html
# https://rollbar.com/blog/using-pyramid-request-factory-to-write-less-code/
#
# ... everything with reify=True is cached, so if we ask for something
# more than once, we keep getting the same thing
# ... https://docs.pylonsproject.org/projects/pyramid/en/latest/api/request.html#pyramid.request.Request.set_property # noqa
[docs]class CamcopsRequest(Request):
def __init__(self, *args, **kwargs):
"""
This is called as the Pyramid request factory; see
``config.set_request_factory(CamcopsRequest)``
What's the best way of handling the database client?
- With Titanium, we were constrained not to use cookies. With Qt, we
have the option.
- But are cookies a good idea?
Probably not; they are somewhat overcomplicated for this.
See also
https://softwareengineering.stackexchange.com/questions/141019/
https://stackoverflow.com/questions/6068113/do-sessions-really-violate-restfulness # noqa
- Let's continue to avoid cookies.
- We don't have to cache any information (we still send username/
password details with each request, and that is RESTful) but it
does save authentication time to do so on calls after the first.
- What we could try to do is:
- look up a session here, at Request creation time;
- add a new session if there wasn't one;
- but allow the database API code to replace that session (BEFORE
it's saved to the database and gains its PK) with another,
determined by the content.
- This gives one more database hit, but avoids the bcrypt time.
"""
super().__init__(*args, **kwargs)
self.use_svg = False
self.add_response_callback(complete_request_add_cookies)
self._camcops_session = None
self._debugging_db_session = None # type: SqlASession # for unit testing only # noqa
self._debugging_user = None # type: User # for unit testing only # noqa
# Don't make the _camcops_session yet; it will want a Registry, and
# we may not have one yet; see command_line_request().
if DEBUG_REQUEST_CREATION:
log.debug("CamcopsRequest.__init__: args={!r}, kwargs={!r}",
args, kwargs)
# -------------------------------------------------------------------------
# CamcopsSession
# -------------------------------------------------------------------------
@property
def camcops_session(self) -> "CamcopsSession":
# Contrast:
# ccsession = request.camcops_session # type: CamcopsSession
# pyramid_session = request.session # type: ISession
if self._camcops_session is None:
from .cc_session import CamcopsSession # delayed import
self._camcops_session = CamcopsSession.get_session_using_cookies(
self)
if DEBUG_CAMCOPS_SESSION:
log.debug("{!r}", self._camcops_session)
return self._camcops_session
def replace_camcops_session(self, ccsession: "CamcopsSession") -> None:
# We may have created a new HTTP session because the request had no
# cookies (added to the DB session but not yet saved), but we might
# then enter the database/tablet upload API and find session details,
# not from the cookies, but from the POST data. At that point, we
# want to replace the session in the Request, without committing the
# first one to disk.
if self._camcops_session is not None:
self.dbsession.expunge(self._camcops_session)
self._camcops_session = ccsession
# -------------------------------------------------------------------------
# Config
# -------------------------------------------------------------------------
[docs] @reify
def config_filename(self) -> str:
"""
Gets the config filename in use.
"""
return get_config_filename_from_os_env()
[docs] @reify
def config(self) -> CamcopsConfig:
"""
Return an instance of CamcopsConfig for the request.
Access it as request.config, with no brackets.
"""
config = get_config(config_filename=self.config_filename)
return config
# -------------------------------------------------------------------------
# Database
# -------------------------------------------------------------------------
@reify
def engine(self) -> Engine:
cfg = self.config
return cfg.get_sqla_engine()
[docs] @reify
def dbsession(self) -> SqlASession:
"""
Return an SQLAlchemy session for the relevant request.
The use of @reify makes this elegant. If and only if a view wants a
database, it can say
.. code-block:: python
dbsession = request.dbsession
and if it requests that, the cleanup callbacks get installed.
"""
session = self.get_bare_dbsession()
def end_sqlalchemy_session(req: Request) -> None:
# noinspection PyProtectedMember
req._finish_dbsession()
# - For command-line pseudo-requests, add_finished_callback is no use,
# because that's called by the Pyramid routing framework.
# - So how do we autocommit a command-line session?
# - Hooking into CamcopsRequest.__del__ did not work: called, yes, but
# object state (e.g. newly inserted User objects) went wrong (e.g.
# the objects had been blanked somehow, or that's what the INSERT
# statements looked like).
# - Use a context manager instead; see below.
self.add_finished_callback(end_sqlalchemy_session)
return session
def _finish_dbsession(self) -> None:
# Do NOT roll back "if req.exception is not None"; that includes
# all sorts of exceptions like HTTPFound, HTTPForbidden, etc.
# See also
# - https://docs.pylonsproject.org/projects/pyramid_cookbook/en/latest/pylons/exceptions.html # noqa
# But they are neatly subclasses of HTTPException, and isinstance()
# deals with None, so:
session = self.dbsession
if (self.exception is not None and
not isinstance(self.exception, HTTPException)):
log.critical(
"Request raised exception that wasn't an HTTPException; "
"rolling back; exception was: {!r}", self.exception)
session.rollback()
else:
if DEBUG_DBSESSION_MANAGEMENT:
log.debug("Committing to database")
session.commit()
if DEBUG_DBSESSION_MANAGEMENT:
log.debug("Closing SQLAlchemy session")
session.close()
def get_bare_dbsession(self) -> SqlASession:
if self._debugging_db_session:
log.debug("Request is using debugging SQLAlchemy session")
return self._debugging_db_session
if DEBUG_DBSESSION_MANAGEMENT:
log.debug("Making SQLAlchemy session")
engine = self.engine
maker = sessionmaker(bind=engine)
session = maker() # type: SqlASession
return session
# -------------------------------------------------------------------------
# TabletSession
# -------------------------------------------------------------------------
[docs] @reify
def tabletsession(self) -> TabletSession:
"""
Request a TabletSession, which is an information structure geared to
client (tablet) database accesses.
If we're using this interface, we also want to ensure we're using
the CamcopsSession for the information provided by the tablet in the
POST request, not anything already loaded/reset via cookies.
"""
from .cc_session import CamcopsSession # delayed import
ts = TabletSession(self)
new_cc_session = CamcopsSession.get_session_for_tablet(ts)
self.replace_camcops_session(new_cc_session)
ts.set_session_id_token(new_cc_session.id, new_cc_session.token)
if DEBUG_TABLET_SESSION:
log.debug("CamcopsRequest: {!r}", self)
log.debug("CamcopsRequest.tabletsession: {!r}", ts)
log.debug("CamcopsRequest.camcops_session: {!r}",
self._camcops_session)
return ts
# -------------------------------------------------------------------------
# Date/time
# -------------------------------------------------------------------------
[docs] @reify
def now(self) -> Pendulum:
"""
Returns the time of the request as an Pendulum object.
(Reified, so a request only ever has one time.)
Exposed as a property.
"""
return Pendulum.now()
[docs] @reify
def now_utc(self) -> Pendulum:
"""
Returns the time of the request as a UTC Pendulum.
"""
p = self.now # type: Pendulum
return convert_datetime_to_utc(p)
@reify
def now_iso8601_era_format(self) -> str:
return format_datetime(self.now, DateFormat.ISO8601)
@property
def today(self) -> Date:
return self.now.date()
# -------------------------------------------------------------------------
# Logos, static files, and other institution-specific stuff
# -------------------------------------------------------------------------
@property
def url_local_institution(self) -> str:
return self.config.local_institution_url
@property
def url_camcops_favicon(self) -> str:
# Cope with reverse proxies, etc.
# https://docs.pylonsproject.org/projects/pyramid/en/latest/api/request.html#pyramid.request.Request.static_url # noqa
return self.static_url(STATIC_CAMCOPS_PACKAGE_PATH +
"favicon_camcops.png")
@property
def url_camcops_logo(self) -> str:
return self.static_url(STATIC_CAMCOPS_PACKAGE_PATH +
"logo_camcops.png")
@property
def url_local_logo(self) -> str:
return self.static_url(STATIC_CAMCOPS_PACKAGE_PATH + "logo_local.png")
@property
def url_camcops_manual_pdf(self) -> str:
return self.static_url(STATIC_CAMCOPS_PACKAGE_PATH +
"documentation_copy/" +
DOCUMENTATION_INDEX_FILENAME_STEM)
# -------------------------------------------------------------------------
# Low-level HTTP information
# -------------------------------------------------------------------------
[docs] @reify
def remote_port(self) -> Optional[int]:
"""
The remote_port variable is an optional WSGI extra provided by some
frameworks, such as mod_wsgi.
The WSGI spec:
- https://www.python.org/dev/peps/pep-0333/
The CGI spec:
- https://en.wikipedia.org/wiki/Common_Gateway_Interface
The Pyramid Request object:
- https://docs.pylonsproject.org/projects/pyramid/en/latest/api/request.html#pyramid.request.Request # noqa
- ... note: that includes remote_addr, but not remote_port.
"""
try:
return int(self.environ.get("REMOTE_PORT", ""))
except (TypeError, ValueError):
return None
# -------------------------------------------------------------------------
# HTTP request convenience functions
# -------------------------------------------------------------------------
def get_str_param(self,
key: str,
default: str = None,
lower: bool = False,
upper: bool = False) -> Optional[str]:
# HTTP parameters are always strings at heart
value = self.params.get(key, default)
if value is None:
return value
if lower:
return value.lower()
if upper:
return value.upper()
return value
def get_str_list_param(self,
key: str,
lower: bool = False,
upper: bool = False) -> List[str]:
values = self.params.getall(key)
if lower:
return [x.lower() for x in values]
if upper:
return [x.upper() for x in values]
return values
def get_int_param(self, key: str, default: int = None) -> Optional[int]:
try:
return int(self.params[key])
except (KeyError, TypeError, ValueError):
return default
def get_int_list_param(self, key: str) -> List[int]:
values = self.params.getall(key)
try:
return [int(x) for x in values]
except (KeyError, TypeError, ValueError):
return []
def get_bool_param(self, key: str, default: bool) -> bool:
try:
param_str = self.params[key].lower()
if param_str in ["true", "t", "1", "yes", "y"]:
return True
elif param_str in ["false", "f", "0", "no", "n"]:
return False
else:
return default
except (AttributeError, KeyError, TypeError, ValueError):
return default
def get_date_param(self, key: str) -> Optional[Date]:
try:
return coerce_to_pendulum_date(self.params[key])
except (KeyError, ParserError, TypeError, ValueError):
return None
def get_datetime_param(self, key: str) -> Optional[Pendulum]:
try:
return coerce_to_pendulum(self.params[key])
except (KeyError, ParserError, TypeError, ValueError):
return None
# -------------------------------------------------------------------------
# Routing
# -------------------------------------------------------------------------
[docs] def route_url_params(self, route_name: str,
paramdict: Dict[str, Any]) -> str:
"""
Provides a simplified interface to Request.route_url when you have
parameters to pass.
It does two things:
(1) convert all params to their str() form;
(2) allow you to pass parameters more easily using a string
parameter name.
The normal Pyramid Request use is:
.. code-block:: python
Request.route_url(route_name, param1=value1, param2=value2)
where "param1" is the literal name of the parameter, but here we can do
.. code-block:: python
CamcopsRequest.route_url_params(route_name, {
PARAM1_NAME: value1_not_necessarily_str,
PARAM2_NAME: value2
})
"""
strparamdict = {k: str(v) for k, v in paramdict.items()}
return self.route_url(route_name, **strparamdict)
# -------------------------------------------------------------------------
# Strings
# -------------------------------------------------------------------------
@reify
def _all_extra_strings(self) -> Dict[str, Dict[str, str]]:
return all_extra_strings_as_dicts(self.config_filename)
[docs] def xstring(self,
taskname: str,
stringname: str,
default: str = None,
provide_default_if_none: bool = True) -> Optional[str]:
"""
Looks up a string from one of the optional extra XML string files.
"""
# For speed, calculate default only if needed:
allstrings = self._all_extra_strings
if taskname in allstrings:
if stringname in allstrings[taskname]:
return allstrings[taskname].get(stringname)
if default is None and provide_default_if_none:
default = "EXTRA_STRING_NOT_FOUND({}.{})".format(taskname,
stringname)
return default
[docs] def wxstring(self,
taskname: str,
stringname: str,
default: str = None,
provide_default_if_none: bool = True) -> Optional[str]:
"""Returns a web-safe version of an xstring (see above)."""
value = self.xstring(taskname, stringname, default,
provide_default_if_none=provide_default_if_none)
if value is None and not provide_default_if_none:
return None
return ws.webify(value)
[docs] def wappstring(self,
stringname: str,
default: str = None,
provide_default_if_none: bool = True) -> Optional[str]:
"""
Returns a web-safe version of an appstring (an app-wide extra string.
"""
value = self.xstring(APPSTRING_TASKNAME, stringname, default,
provide_default_if_none=provide_default_if_none)
if value is None and not provide_default_if_none:
return None
return ws.webify(value)
# -------------------------------------------------------------------------
# PNG versus SVG output, so tasks don't have to care (for e.g. PDF/web)
# -------------------------------------------------------------------------
def prepare_for_pdf_figures(self) -> None:
if CSS_PAGED_MEDIA:
# unlikely -- we use wkhtmltopdf instead now
self.switch_output_to_png()
# ... even weasyprint's SVG handling is inadequate
else:
# This is the main method -- we use wkhtmltopdf these days
self.switch_output_to_svg() # wkhtmltopdf can cope
def prepare_for_html_figures(self) -> None:
self.switch_output_to_svg()
[docs] def switch_output_to_png(self) -> None:
"""Switch server to producing figures in PNG."""
self.use_svg = False
[docs] def switch_output_to_svg(self) -> None:
"""Switch server to producing figures in SVG."""
self.use_svg = True
@staticmethod
def create_figure(**kwargs) -> Figure:
fig = Figure(**kwargs)
# noinspection PyUnusedLocal
canvas = FigureCanvas(fig)
# The canvas will be now available as fig.canvas, since
# FigureCanvasBase.__init__ calls fig.set_canvas(self); similarly, the
# figure is available from the canvas as canvas.figure
# How do we set the font, so the caller doesn't have to?
# The "nasty global" way is:
# matplotlib.rc('font', **fontdict)
# matplotlib.rc('legend', **fontdict)
# or similar. Then matplotlib often works its way round to using its
# global rcParams object, which is Not OK in a multithreaded context.
#
# https://github.com/matplotlib/matplotlib/issues/6514
# https://github.com/matplotlib/matplotlib/issues/6518
#
# The other way is to specify a fontdict with each call, e.g.
# ax.set_xlabel("some label", **fontdict)
# https://stackoverflow.com/questions/21321670/how-to-change-fonts-in-matplotlib-python # noqa
# Relevant calls with explicit "fontdict: Dict" parameters:
# ax.set_xlabel(..., fontdict=XXX, ...)
# ax.set_ylabel(..., fontdict=XXX, ...)
# ax.set_xticklabels(..., fontdict=XXX, ...)
# ax.set_yticklabels(..., fontdict=XXX, ...)
# ax.text(..., fontdict=XXX, ...)
# ax.set_label_text(..., fontdict=XXX, ...)
# ax.set_title(..., fontdict=XXX, ...)
#
# And with "fontproperties: FontProperties"
# sig.suptitle(..., fontproperties=XXX, ...)
#
# And with "prop: FontProperties":
# ax.legend(..., prop=XXX, ...)
#
# Then, some things are automatically plotted...
return fig
@reify
def fontdict(self) -> Dict[str, Any]:
fontsize = self.config.plot_fontsize
return dict(
# http://stackoverflow.com/questions/3899980
# http://matplotlib.org/users/customizing.html
family='sans-serif',
# ... serif, sans-serif, cursive, fantasy, monospace
style='normal', # normal (roman), italic, oblique
variant='normal', # normal, small-caps
weight='normal',
# ... normal [=400], bold [=700], bolder [relative to current],
# lighter [relative], 100, 200, 300, ..., 900
size=fontsize # in pt (default 12)
)
@reify
def fontprops(self) -> FontProperties:
return FontProperties(self.fontdict)
def set_figure_font_sizes(self,
ax: "Axes", # "SubplotBase",
fontdict: Dict[str, Any] = None,
x_ticklabels: bool = True,
y_ticklabels: bool = True) -> None:
final_fontdict = self.fontdict.copy()
if fontdict:
final_fontdict.update(fontdict)
fp = FontProperties(**final_fontdict)
axes = [] # type: List[Axis]
if x_ticklabels: # and hasattr(ax, "xaxis"):
axes.append(ax.xaxis)
if y_ticklabels: # and hasattr(ax, "yaxis"):
axes.append(ax.yaxis)
for axis in axes:
for ticklabel in axis.get_ticklabels(which='both'): # type: Text # I think! # noqa
ticklabel.set_fontproperties(fp)
# -------------------------------------------------------------------------
# Convenience functions for user information
# -------------------------------------------------------------------------
@property
def user(self) -> Optional["User"]:
return self._debugging_user or self.camcops_session.user
@property
def user_id(self) -> Optional[int]:
if self._debugging_user:
return self._debugging_user.user_id
return self.camcops_session.user_id
# -------------------------------------------------------------------------
# ID number definitions
# -------------------------------------------------------------------------
@reify
def idnum_definitions(self) -> List[IdNumDefinition]:
return get_idnum_definitions(self.dbsession) # no longer cached
@reify
def valid_which_idnums(self) -> List[int]:
return [iddef.which_idnum for iddef in self.idnum_definitions]
# ... pre-sorted
def get_idnum_definition(self,
which_idnum: int) -> Optional[IdNumDefinition]:
return next((iddef for iddef in self.idnum_definitions
if iddef.which_idnum == which_idnum), None)
[docs] def get_id_desc(self, which_idnum: int,
default: str = None) -> Optional[str]:
"""Get server's ID description."""
return next((iddef.description for iddef in self.idnum_definitions
if iddef.which_idnum == which_idnum),
default)
[docs] def get_id_shortdesc(self, which_idnum: int,
default: str = None) -> Optional[str]:
"""Get server's short ID description."""
return next((iddef.short_description
for iddef in self.idnum_definitions
if iddef.which_idnum == which_idnum),
default)
# -------------------------------------------------------------------------
# Server settings
# -------------------------------------------------------------------------
@reify
def server_settings(self) -> ServerSettings:
return get_server_settings(self)
@reify
def database_title(self) -> str:
ss = self.server_settings
return ss.database_title or ""
def set_database_title(self, title: str) -> None:
ss = self.server_settings
ss.database_title = title
# noinspection PyUnusedLocal
[docs]def complete_request_add_cookies(req: CamcopsRequest, response: Response):
"""
Finializes the response by adding session cookies.
We do this late so that we can hot-swap the session if we're using the
database/tablet API rather than a human web browser.
Response callbacks are called in the order first-to-most-recently-added.
See pyramid.request.CallbackMethodsMixin.
That looks like we can add a callback in the process of running a callback.
And when we add a cookie to a Pyramid session, that sets a callback.
Let's give it a go...
"""
dbsession = req.dbsession
dbsession.flush() # sets the PK for ccsession, if it wasn't set
# Write the details back to the Pyramid session (will be persisted
# via the Response automatically):
pyramid_session = req.session # type: ISession
ccsession = req.camcops_session
pyramid_session[CookieKey.SESSION_ID] = str(ccsession.id)
pyramid_session[CookieKey.SESSION_TOKEN] = ccsession.token
# ... should cause the ISession to add a callback to add cookies,
# which will be called immediately after this one.
# =============================================================================
# Configurator
# =============================================================================
@contextmanager
def pyramid_configurator_context(debug_toolbar: bool = False) -> Configurator:
# Note this includes settings that transcend the config file.
#
# Most things should be in the config file. This enables us to run multiple
# configs (e.g. multiple CamCOPS databases) through the same process.
# However, some things we need to know right now, to make the WSGI app.
# Here, OS environment variables and command-line switches are appropriate.
# -------------------------------------------------------------------------
# 1. Base app
# -------------------------------------------------------------------------
settings = { # Settings that can't be set directly?
'debug_authorization': DEBUG_AUTHORIZATION,
# ... see https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/security.html#debugging-view-authorization-failures # noqa
}
with Configurator(settings=settings) as config:
# ---------------------------------------------------------------------
# Authentication; authorizaion (permissions)
# ---------------------------------------------------------------------
authentication_policy = CamcopsAuthenticationPolicy()
config.set_authentication_policy(authentication_policy)
# Let's not use ACLAuthorizationPolicy, which checks an access control
# list for a resource hierarchy of objects, but instead:
authorization_policy = CamcopsAuthorizationPolicy()
config.set_authorization_policy(authorization_policy)
config.set_default_permission(Permission.HAPPY)
# ... applies to all SUBSEQUENT view configuration registrations
# ---------------------------------------------------------------------
# Factories
# ---------------------------------------------------------------------
config.set_request_factory(CamcopsRequest)
# ... for request attributes: config, database, etc.
config.set_session_factory(get_session_factory())
# ... for request.session
camcops_add_mako_renderer(config, extension='.mako')
# deform_bootstrap.includeme(config)
# ---------------------------------------------------------------------
# Routes and accompanying views
# ---------------------------------------------------------------------
# Add static views
# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/assets.html#serving-static-assets # noqa
# Hmm. We cannot fail to set up a static file route, because otherwise
# we can't provide URLs to them.
static_filepath = STATIC_CAMCOPS_PACKAGE_PATH
static_name = RouteCollection.STATIC.route
log.debug("... including static files from {!r} at Pyramid static "
"name {!r}", static_filepath, static_name)
# ... does the name needs to start with "/" or the pattern "static/"
# will override the later "deform_static"? Not sure.
config.add_static_view(name=static_name, path=static_filepath)
# Add all the routes:
for pr in RouteCollection.all_routes():
if DEBUG_ADD_ROUTES:
log.info("{} -> {}", pr.route, pr.path)
config.add_route(pr.route, pr.path)
# See also:
# https://stackoverflow.com/questions/19184612/how-to-ensure-urls-generated-by-pyramids-route-url-and-route-path-are-valid # noqa
# Routes added EARLIER have priority. So add this AFTER our custom
# bugfix:
config.add_static_view('/deform_static', 'deform:static/')
# Most views are using @view_config() which calls add_view().
# Scan for @view_config decorators, to map views to routes:
# https://docs.pylonsproject.org/projects/venusian/en/latest/api.html
config.scan("camcops_server.cc_modules")
# ---------------------------------------------------------------------
# Add tweens (inner to outer)
# ---------------------------------------------------------------------
# We will use implicit positioning:
# - https://www.slideshare.net/aconrad/alex-conrad-pyramid-tweens-ploneconf-2011 # noqa
# config.add_tween('camcops_server.camcops.http_session_tween_factory')
# ---------------------------------------------------------------------
# Debug toolbar
# ---------------------------------------------------------------------
if debug_toolbar:
log.debug("Enabling Pyramid debug toolbar")
config.include('pyramid_debugtoolbar') # BEWARE! SIDE EFFECTS
# ... Will trigger an import that hooks events into all
# SQLAlchemy queries. There's a bug somewhere relating to that;
# see notes below relating to the "mergedb" function.
config.add_route(RouteCollection.DEBUG_TOOLBAR.route,
RouteCollection.DEBUG_TOOLBAR.path)
yield config
# =============================================================================
# Debugging requests
# =============================================================================
def make_post_body_from_dict(d: Dict[str, str],
encoding: str = "utf8") -> bytes:
# https://docs.pylonsproject.org/projects/pyramid-cookbook/en/latest/testing/testing_post_curl.html # noqa
txt = urllib.parse.urlencode(query=d)
# ... this encoding mimics how the tablet operates
body = txt.encode(encoding)
return body
[docs]class CamcopsDummyRequest(CamcopsRequest, DummyRequest):
"""
Request class that allows manual manipulation of GET/POST parameters
for debugging.
Notes:
- The important base class is webob.request.BaseRequest.
- self.params is a NestedMultiDict (see webob/multidict.py); these are
intrinsically read-only.
- self.params is also a read-only property. When read, it combines
self.GET and self.POST.
- What we do here is to manipulate the underlying GET/POST data.
"""
_CACHE_KEY = "webob._parsed_query_vars"
_QUERY_STRING_KEY = "QUERY_STRING"
# def __init__(self, *args, **kwargs) -> None:
# super().__init__(*args, **kwargs)
# # Just a technique worth noting:
# #
# # self._original_params_property = CamcopsRequest.params # type: property # noqa
# # self._original_params = self._original_params_property.fget(self) # type: NestedMultiDict # noqa
# # self._fake_params = self._original_params.copy() # type: MultiDict
# # if params:
# # self._fake_params.update(params)
#
# @property
# def params(self):
# log.critical(repr(self._fake_params))
# return self._fake_params
# # Returning the member object allows clients to call
# # dummyreq.params.update(...)
#
# @params.setter
# def params(self, value):
# self._fake_params = value
def set_method_get(self) -> None:
self.method = RequestMethod.GET
def set_method_post(self) -> None:
self.method = RequestMethod.POST
def clear_get_params(self) -> None:
env = self.environ
if self._CACHE_KEY in env:
del env[self._CACHE_KEY]
env[self._QUERY_STRING_KEY] = ""
def add_get_params(self, d: Dict[str, str],
set_method_get: bool = True) -> None:
if not d:
return
# webob.request.BaseRequest.GET reads from self.environ['QUERY_STRING']
paramdict = self.GET.copy() # type: MultiDict
paramdict.update(d)
env = self.environ
# Delete the cached version.
if self._CACHE_KEY in env:
del env[self._CACHE_KEY]
# Write the new version
env[self._QUERY_STRING_KEY] = urllib.parse.urlencode(query=paramdict)
if set_method_get:
self.set_method_get()
def set_get_params(self, d: Dict[str, str],
set_method_get: bool = True) -> None:
self.clear_get_params()
self.add_get_params(d, set_method_get=set_method_get)
def set_post_body(self, body: bytes,
set_method_post: bool = True) -> None:
log.debug("Applying fake POST body: {!r}", body)
self.body = body
self.content_length = len(body)
if set_method_post:
self.set_method_post()
def fake_request_post_from_dict(self,
d: Dict[str, str],
encoding: str = "utf8",
set_method_post: bool = True) -> None:
# webob.request.BaseRequest.POST reads from 'body' (indirectly).
body = make_post_body_from_dict(d, encoding=encoding)
self.set_post_body(body, set_method_post=set_method_post)
_ = """
# A demonstration of the manipulation of superclass properties:
class Test(object):
def __init__(self):
self.a = 3
@property
def b(self):
return 4
class Derived(Test):
def __init__(self):
super().__init__()
self._superclass_b = super().b
self._b = 4
@property
def b(self):
print("Superclass b: {}".format(self._superclass_b.fget(self)))
print("Self _b: {}".format(self._b))
return self._b
@b.setter
def b(self, value):
self._b = value
x = Test()
x.a # 3
x.a = 5
x.a # 5
x.b # 4
x.b = 6 # can't set attribute
y = Derived()
y.a # 3
y.a = 5
y.a # 5
y.b # 4
y.b = 6
y.b # 6
"""
def _get_core_debugging_request() -> CamcopsDummyRequest:
with pyramid_configurator_context(debug_toolbar=False) as config:
req = CamcopsDummyRequest(
environ={
ENVVAR_CONFIG_FILE: os.environ[ENVVAR_CONFIG_FILE],
WsgiEnvVar.PATH_INFO: '/',
WsgiEnvVar.SCRIPT_NAME: '',
WsgiEnvVar.SERVER_NAME: '127.0.0.1',
WsgiEnvVar.SERVER_PORT: '8000',
WsgiEnvVar.WSGI_URL_SCHEME: 'http',
}
)
# ... must pass an actual dict to the "environ" parameter; os.environ
# itself isn't OK ("TypeError: WSGI environ must be a dict; you passed
# environ({'key1': 'value1', ...})
req.registry = config.registry
config.begin(request=req)
return req
[docs]def get_command_line_request() -> CamcopsRequest:
"""
Creates a dummy CamcopsRequest for use on the command line.
Presupposes that os.environ[ENVVAR_CONFIG_FILE] has been set, as it is
in camcops.main().
"""
log.debug("Creating command-line pseudo-request")
req = _get_core_debugging_request()
# If we proceed with an out-of-date database, we will have problems, and
# those problems may not be immediately apparent, which is bad. So:
req.config.assert_database_ok()
return req
[docs]@contextmanager
def command_line_request_context() -> Generator[CamcopsRequest, None, None]:
"""
Request objects are ubiquitous, and allow code to refer to the HTTP
request, config, HTTP session, database session, and so on. Here we make
a special sort of request for use from the command line, and provide it
as a context manager that will COMMIT the database afterwards (because the
normal method, via the Pyramid router, is unavailable).
"""
req = get_command_line_request()
yield req
# noinspection PyProtectedMember
req._finish_dbsession()
[docs]def get_unittest_request(dbsession: SqlASession,
params: Dict[str, Any] = None) -> CamcopsDummyRequest:
"""
Creates a dummy CamcopsRequest for use by unit tests.
Points to an existing database (e.g. SQLite in-memory database).
Presupposes that os.environ[ENVVAR_CONFIG_FILE] has been set, as it is
in camcops.main().
"""
log.debug("Creating unit testing pseudo-request")
req = _get_core_debugging_request()
req.set_get_params(params)
req._debugging_db_session = dbsession
user = User()
user.superuser = True
req._debugging_user = user
return req