Coverage for cc_modules/cc_pyramid.py : 73%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1#!/usr/bin/env python
3"""
4camcops_server/cc_modules/cc_pyramid.py
6===============================================================================
8 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com).
10 This file is part of CamCOPS.
12 CamCOPS is free software: you can redistribute it and/or modify
13 it under the terms of the GNU General Public License as published by
14 the Free Software Foundation, either version 3 of the License, or
15 (at your option) any later version.
17 CamCOPS is distributed in the hope that it will be useful,
18 but WITHOUT ANY WARRANTY; without even the implied warranty of
19 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20 GNU General Public License for more details.
22 You should have received a copy of the GNU General Public License
23 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.
25===============================================================================
27**Functions for the Pyramid web framework.**
29"""
31from enum import Enum
32import logging
33import os
34import pprint
35import re
36import sys
37from typing import (Any, Callable, Dict, List, Optional, Sequence, Tuple,
38 Type, TYPE_CHECKING, Union)
39from urllib.parse import urlencode
41# from cardinal_pythonlib.debugging import get_caller_stack_info
42from cardinal_pythonlib.logs import BraceStyleAdapter
43from cardinal_pythonlib.wsgi.constants import WsgiEnvVar
44from mako.lookup import TemplateLookup
45from paginate import Page
46from pyramid.authentication import IAuthenticationPolicy
47from pyramid.authorization import IAuthorizationPolicy
48from pyramid.config import Configurator
49from pyramid.httpexceptions import HTTPFound
50from pyramid.interfaces import ILocation, ISession
51from pyramid.request import Request
52from pyramid.security import (
53 Allowed,
54 Denied,
55 Authenticated,
56 Everyone,
57 PermitsResult,
58)
59from pyramid.session import SignedCookieSessionFactory
60from pyramid_mako import (
61 MakoLookupTemplateRenderer,
62 MakoRendererFactory,
63 MakoRenderingException,
64 reraise,
65 text_error_template,
66)
67from sqlalchemy.orm import Query
68from sqlalchemy.sql.selectable import Select
69from zope.interface import implementer
71from camcops_server.cc_modules.cc_baseconstants import TEMPLATE_DIR
72from camcops_server.cc_modules.cc_cache import cache_region_static
73from camcops_server.cc_modules.cc_constants import DEFAULT_ROWS_PER_PAGE
75if TYPE_CHECKING:
76 from camcops_server.cc_modules.cc_request import CamcopsRequest
78log = BraceStyleAdapter(logging.getLogger(__name__))
80# =============================================================================
81# Debugging options
82# =============================================================================
84DEBUG_ADD_ROUTES = False
85DEBUG_EFFECTIVE_PRINCIPALS = False
86DEBUG_TEMPLATE_PARAMETERS = False
87# ... logs more information about template creation
88DEBUG_TEMPLATE_SOURCE = False
89# ... writes the templates in their compiled-to-Python version to a debugging
90# directory (see below), which is very informative.
91DEBUGGING_MAKO_DIR = os.path.expanduser("~/tmp/camcops_mako_template_source")
93if any([DEBUG_ADD_ROUTES,
94 DEBUG_EFFECTIVE_PRINCIPALS,
95 DEBUG_TEMPLATE_PARAMETERS,
96 DEBUG_TEMPLATE_SOURCE]):
97 log.warning("Debugging options enabled!")
100# =============================================================================
101# Constants
102# =============================================================================
104COOKIE_NAME = 'camcops'
107class CookieKey:
108 """
109 Keys for HTTP cookies. We keep this to the absolute minimum; cookies
110 contain enough detail to look up a session on the server, and then
111 everything else is looked up on the server side.
112 """
113 SESSION_ID = 'session_id'
114 SESSION_TOKEN = 'session_token'
117class FormAction(object):
118 """
119 Action values for HTML forms. These values generally end up as the ``name``
120 attribute (and sometimes also the ``value`` attribute) of an HTML button.
121 """
122 CANCEL = 'cancel'
123 CLEAR_FILTERS = 'clear_filters'
124 DELETE = 'delete'
125 FINALIZE = 'finalize'
126 SET_FILTERS = 'set_filters'
127 SUBMIT = 'submit' # the default for many forms
128 SUBMIT_TASKS_PER_PAGE = 'submit_tpp'
129 REFRESH_TASKS = 'refresh_tasks'
132class RequestMethod(object):
133 """
134 Constants to distinguish HTTP GET from HTTP POST requests.
135 """
136 GET = "GET"
137 POST = "POST"
140class ViewParam(object):
141 """
142 View parameter constants.
144 Used in the following situations:
146 - as parameter names for parameterized URLs (via RoutePath to Pyramid's
147 route configuration, then fetched from the matchdict);
149 - as form parameter names (often with some duplication as the attribute
150 names of deform Form objects, because to avoid duplication would involve
151 metaclass mess).
152 """
153 # QUERY = "_query" # built in to Pyramid
154 ADDRESS = "address"
155 ADD_SPECIAL_NOTE = "add_special_note"
156 ADMIN = "admin"
157 AGE_MINIMUM = "age_minimum"
158 AGE_MAXIMUM = "age_maximum"
159 ALL_TASKS = "all_tasks"
160 ANONYMISE = "anonymise"
161 CLINICIAN_CONFIRMATION = "clinician_confirmation"
162 CSRF_TOKEN = "csrf_token"
163 DATABASE_TITLE = "database_title"
164 DELIVERY_MODE = "delivery_mode"
165 DESCRIPTION = "description"
166 DEVICE_ID = "device_id"
167 DEVICE_IDS = "device_ids"
168 DIALECT = "dialect"
169 DIAGNOSES_INCLUSION = "diagnoses_inclusion"
170 DIAGNOSES_EXCLUSION = "diagnoses_exclusion"
171 DUMP_METHOD = "dump_method"
172 DOB = "dob"
173 DUE_FROM = "due_from"
174 DUE_WITHIN = "due_within"
175 EMAIL = "email"
176 EMAIL_SUBJECT = "email_subject"
177 EMAIL_TEMPLATE = "email_template"
178 END_DATETIME = "end_datetime"
179 INCLUDE_AUTO_GENERATED = "include_auto_generated"
180 FILENAME = "filename"
181 FINALIZE_POLICY = "finalize_policy"
182 FORENAME = "forename"
183 FULLNAME = "fullname"
184 GP = "gp"
185 GROUPADMIN = "groupadmin"
186 GROUP_ID = "group_id"
187 GROUP_IDS = "group_ids"
188 HL7_ID_TYPE = "hl7_id_type"
189 HL7_ASSIGNING_AUTHORITY = "hl7_assigning_authority"
190 ID = "id" # generic PK
191 ID_DEFINITIONS = "id_definitions"
192 ID_REFERENCES = "id_references"
193 IDNUM_VALUE = "idnum_value"
194 INCLUDE_BLOBS = "include_blobs"
195 INCLUDE_CALCULATED = "include_calculated"
196 INCLUDE_COMMENTS = "include_comments"
197 INCLUDE_INFORMATION_SCHEMA_COLUMNS = "include_information_schema_columns"
198 INCLUDE_PATIENT = "include_patient"
199 INCLUDE_SNOMED = "include_snomed"
200 IP_USE = "ip_use"
201 LANGUAGE = "language"
202 MANUAL = "manual"
203 MAY_ADD_NOTES = "may_add_notes"
204 MAY_DUMP_DATA = "may_dump_data"
205 MAY_REGISTER_DEVICES = "may_register_devices"
206 MAY_RUN_REPORTS = "may_run_reports"
207 MAY_UPLOAD = "may_upload"
208 MAY_USE_WEBVIEWER = "may_use_webviewer"
209 MUST_CHANGE_PASSWORD = "must_change_password"
210 NAME = "name"
211 NOTE = "note"
212 NOTE_ID = "note_id"
213 NEW_PASSWORD = "new_password"
214 OLD_PASSWORD = "old_password"
215 OTHER = "other"
216 COMPLETE_ONLY = "complete_only"
217 PAGE = "page"
218 PASSWORD = "password"
219 PATIENT_ID_PER_ROW = "patient_id_per_row"
220 PATIENT_TASK_SCHEDULE_ID = "patient_task_schedule_id"
221 RECIPIENT_NAME = "recipient_name"
222 REDIRECT_URL = "redirect_url"
223 REPORT_ID = "report_id"
224 REMOTE_IP_ADDR = "remote_ip_addr"
225 ROWS_PER_PAGE = "rows_per_page"
226 SCHEDULE_ID = "schedule_id"
227 SCHEDULE_ITEM_ID = "schedule_item_id"
228 SERVER_PK = "server_pk"
229 SETTINGS = "settings"
230 SEX = "sex"
231 SHORT_DESCRIPTION = "short_description"
232 SORT = "sort"
233 SOURCE = "source"
234 SQLITE_METHOD = "sqlite_method"
235 START_DATETIME = "start_datetime"
236 SUPERUSER = "superuser"
237 SURNAME = "surname"
238 TABLE_NAME = "table_name"
239 TASKS = "tasks"
240 TASK_SCHEDULES = "task_schedules"
241 TEXT_CONTENTS = "text_contents"
242 TRUNCATE = "truncate"
243 UPLOAD_GROUP_ID = "upload_group_id"
244 UPLOAD_POLICY = "upload_policy"
245 USER_GROUP_MEMBERSHIP_ID = "user_group_membership_id"
246 USER_ID = "user_id"
247 USER_IDS = "user_ids"
248 USERNAME = "username"
249 VALIDATION_METHOD = "validation_method"
250 VIA_INDEX = "via_index"
251 VIEW_ALL_PATIENTS_WHEN_UNFILTERED = "view_all_patients_when_unfiltered"
252 VIEWTYPE = "viewtype"
253 WHICH_IDNUM = "which_idnum"
254 WHAT = "what"
255 WHEN = "when"
256 WHO = "who"
259class ViewArg(object):
260 """
261 String used as view arguments. For example,
262 :class:`camcops_server.cc_modules.cc_forms.DumpTypeSelector` represents its
263 choices (inside an HTTP POST request) as values from this class.
264 """
265 # Delivery methods
266 DOWNLOAD = "download"
267 EMAIL = "email"
268 IMMEDIATELY = "immediately"
270 # Output types
271 HTML = "html"
272 ODS = "ods"
273 PDF = "pdf"
274 PDFHTML = "pdfhtml" # the HTML to create a PDF
275 R = "r"
276 SQL = "sql"
277 SQLITE = "sqlite"
278 TSV = "tsv"
279 TSV_ZIP = "tsv_zip"
280 XLSX = "xlsx"
281 XML = "xml"
283 # What to download
284 EVERYTHING = "everything"
285 SPECIFIC_TASKS_GROUPS = "specific_tasks_groups"
286 USE_SESSION_FILTER = "use_session_filter"
289# =============================================================================
290# Templates
291# =============================================================================
292# Adaptation of a small part of pyramid_mako, so we can use our own Mako
293# TemplateLookup, and thus dogpile.cache. See
294# https://github.com/Pylons/pyramid_mako/blob/master/pyramid_mako/__init__.py
296MAKO_LOOKUP = TemplateLookup(
297 directories=[
298 os.path.join(TEMPLATE_DIR, "base"),
299 os.path.join(TEMPLATE_DIR, "css"),
300 os.path.join(TEMPLATE_DIR, "menu"),
301 os.path.join(TEMPLATE_DIR, "snippets"),
302 os.path.join(TEMPLATE_DIR, "taskcommon"),
303 os.path.join(TEMPLATE_DIR, "tasks"),
304 os.path.join(TEMPLATE_DIR, "test"),
305 ],
307 input_encoding="utf-8",
308 output_encoding="utf-8",
310 module_directory=DEBUGGING_MAKO_DIR if DEBUG_TEMPLATE_SOURCE else None,
312 # strict_undefined=True, # raise error immediately upon typos
313 # ... tradeoff; there are good and bad things about this!
314 # One bad thing about strict_undefined=True is that a child (inheriting)
315 # template must supply all variables used by its parent (inherited)
316 # template, EVEN IF it replaces entirely the <%block> of the parent that
317 # uses those variables.
319 # -------------------------------------------------------------------------
320 # Template default filtering
321 # -------------------------------------------------------------------------
323 default_filters=["h"],
325 # -------------------------------------------------------------------------
326 # Template caching
327 # -------------------------------------------------------------------------
328 # http://dogpilecache.readthedocs.io/en/latest/api.html#module-dogpile.cache.plugins.mako_cache # noqa
329 # http://docs.makotemplates.org/en/latest/caching.html#cache-arguments
331 cache_impl="dogpile.cache",
332 cache_args={
333 "regions": {"local": cache_region_static},
334 },
336 # Now, in Mako templates, use:
337 # cached="True" cache_region="local" cache_key="SOME_CACHE_KEY"
338 # on <%page>, <%def>, and <%block> regions.
339 # It is VITAL that you specify "name", and that it be appropriately
340 # unique, or there'll be a cache conflict.
341 # The easy way is:
342 # cached="True" cache_region="local" cache_key="${self.filename}"
343 # ^^^^^^^^^^^^^^^^
344 # No!
345 # ... with ${self.filename} you can get an inheritance deadlock:
346 # See https://bitbucket.org/zzzeek/mako/issues/269/inheritance-related-cache-deadlock-when # noqa
347 #
348 # HOWEVER, note also: it is the CONTENT that is cached. You can cause some
349 # right disasters with this. Only stuff producing entirely STATIC content
350 # should be cached. "base.mako" isn't static - it calls back to its
351 # children; and if you cache it, one request produces results for an
352 # entirely different request. Similarly for lots of other things like
353 # "task.mako".
354 # SO, THERE IS NOT MUCH REASON HERE TO USE TEMPLATE CACHING.
355)
358class CamcopsMakoLookupTemplateRenderer(MakoLookupTemplateRenderer):
359 r"""
360 A Mako template renderer that, when called:
362 (a) loads the Mako template
363 (b) shoves any other keys we specify into its dictionary
365 Typical incoming parameters look like:
367 .. code-block:: none
369 spec = 'some_template.mako'
370 value = {'comment': None}
371 system = {
372 'context': <pyramid.traversal.DefaultRootFactory ...>,
373 'get_csrf_token': functools.partial(<function get_csrf_token ... >, ...>),
374 'renderer_info': <pyramid.renderers.RendererHelper ...>,
375 'renderer_name': 'some_template.mako',
376 'req': <CamcopsRequest ...>,
377 'request': <CamcopsRequest ...>,
378 'view': None
379 }
381 Showing the incoming call stack info (see commented-out code) indicates
382 that ``req`` and ``request`` (etc.) join at, and are explicitly introduced
383 by, :func:`pyramid.renderers.render`. That function includes this code:
385 .. code-block:: python
387 if system_values is None:
388 system_values = {
389 'view':None,
390 'renderer_name':self.name, # b/c
391 'renderer_info':self,
392 'context':getattr(request, 'context', None),
393 'request':request,
394 'req':request,
395 'get_csrf_token':partial(get_csrf_token, request),
396 }
398 So that means, for example, that ``req`` and ``request`` are both always
399 present in Mako templates as long as the ``request`` parameter was passed
400 to :func:`pyramid.renderers.render_to_response`.
402 What about a view configured with ``@view_config(...,
403 renderer="somefile.mako")``? Yes, that too (and anything included via
404 ``<%include file="otherfile.mako"/>``).
406 However, note that ``req`` and ``request`` are only available in the Mako
407 evaluation blocks, e.g. via ``${req.someattr}`` or via Python blocks like
408 ``<% %>`` -- not via Python blocks like ``<%! %>``, because the actual
409 Python generated by a Mako template like this:
411 .. code-block:: none
413 ## db_user_info.mako
414 <%page args="offer_main_menu=False"/>
416 <%!
417 module_level_thing = context.kwargs # module-level block; will crash
418 %>
420 <%
421 thing = context.kwargs["request"] # normal Python block; works
422 %>
424 <div>
425 Database: <b>${ request.database_title | h }</b>.
426 %if request.camcops_session.username:
427 Logged in as <b>${request.camcops_session.username | h}</b>.
428 %endif
429 %if offer_main_menu:
430 <%include file="to_main_menu.mako"/>
431 %endif
432 </div>
434 looks like this:
436 .. code-block:: python
438 from mako import runtime, filters, cache
439 UNDEFINED = runtime.UNDEFINED
440 STOP_RENDERING = runtime.STOP_RENDERING
441 __M_dict_builtin = dict
442 __M_locals_builtin = locals
443 _magic_number = 10
444 _modified_time = 1557179054.2796485
445 _enable_loop = True
446 _template_filename = '...' # edited
447 _template_uri = 'db_user_info.mako'
448 _source_encoding = 'utf-8'
449 _exports = []
451 module_level_thing = context.kwargs # module-level block; will crash
453 def render_body(context,offer_main_menu=False,**pageargs):
454 __M_caller = context.caller_stack._push_frame()
455 try:
456 __M_locals = __M_dict_builtin(offer_main_menu=offer_main_menu,pageargs=pageargs)
457 request = context.get('request', UNDEFINED)
458 __M_writer = context.writer()
459 __M_writer('\n\n')
460 __M_writer('\n\n')
462 thing = context.kwargs["request"] # normal Python block; works
464 __M_locals_builtin_stored = __M_locals_builtin()
465 __M_locals.update(__M_dict_builtin([(__M_key, __M_locals_builtin_stored[__M_key]) for __M_key in ['thing'] if __M_key in __M_locals_builtin_stored]))
466 __M_writer('\n\n<div>\n Database: <b>')
467 __M_writer(filters.html_escape(str( request.database_title )))
468 __M_writer('</b>.\n')
469 if request.camcops_session.username:
470 __M_writer(' Logged in as <b>')
471 __M_writer(filters.html_escape(str(request.camcops_session.username )))
472 __M_writer('</b>.\n')
473 if offer_main_menu:
474 __M_writer(' ')
475 runtime._include_file(context, 'to_main_menu.mako', _template_uri)
476 __M_writer('\n')
477 __M_writer('</div>\n')
478 return ''
479 finally:
480 context.caller_stack._pop_frame()
482 '''
483 __M_BEGIN_METADATA
484 {"filename": ...} # edited
485 __M_END_METADATA
486 '''
488 """ # noqa
489 def __call__(self, value: Dict[str, Any], system: Dict[str, Any]) -> str:
490 if DEBUG_TEMPLATE_PARAMETERS:
491 log.debug("spec: {!r}", self.spec)
492 log.debug("value: {}", pprint.pformat(value))
493 log.debug("system: {}", pprint.pformat(system))
494 # log.critical("\n{}", "\n ".join(get_caller_stack_info()))
496 # ---------------------------------------------------------------------
497 # RNC extra values:
498 # ---------------------------------------------------------------------
499 # Note that <%! ... %> Python blocks are not themselves inherited.
500 # So putting "import" calls in base.mako doesn't deliver the following
501 # as ever-present variable. Instead, plumb them in like this:
502 #
503 # system['Routes'] = Routes
504 # system['ViewArg'] = ViewArg
505 # system['ViewParam'] = ViewParam
506 #
507 # ... except that we're better off with an import in the template
509 # Update the system dictionary with the values from the user
510 try:
511 system.update(value)
512 except (TypeError, ValueError):
513 raise ValueError('renderer was passed non-dictionary as value')
515 # Add the special "_" translation function
516 request = system["request"] # type: CamcopsRequest
517 system["_"] = request.gettext
519 # Check if 'context' in the dictionary
520 context = system.pop('context', None)
522 # Rename 'context' to '_context' because Mako internally already has a
523 # variable named 'context'
524 if context is not None:
525 system['_context'] = context
527 template = self.template
528 if self.defname is not None:
529 template = template.get_def(self.defname)
530 # noinspection PyBroadException
531 try:
532 if DEBUG_TEMPLATE_PARAMETERS:
533 log.debug("final dict to template: {}", pprint.pformat(system))
534 result = template.render_unicode(**system)
535 except Exception:
536 try:
537 exc_info = sys.exc_info()
538 errtext = text_error_template().render(error=exc_info[1],
539 traceback=exc_info[2])
540 reraise(MakoRenderingException(errtext), None, exc_info[2])
541 finally:
542 # noinspection PyUnboundLocalVariable
543 del exc_info
545 # noinspection PyUnboundLocalVariable
546 return result
549class CamcopsMakoRendererFactory(MakoRendererFactory):
550 """
551 A Mako renderer factory to use :class:`CamcopsMakoLookupTemplateRenderer`.
552 """
553 # noinspection PyTypeChecker
554 renderer_factory = staticmethod(CamcopsMakoLookupTemplateRenderer)
557def camcops_add_mako_renderer(config: Configurator, extension: str) -> None:
558 """
559 Registers a renderer factory for a given template file type.
561 Replacement for :func:`add_mako_renderer` from ``pyramid_mako``, so we can
562 use our own lookup.
564 The ``extension`` parameter is a filename extension (e.g. ".mako").
565 """
566 renderer_factory = CamcopsMakoRendererFactory() # our special function
567 renderer_factory.lookup = MAKO_LOOKUP # our lookup information
568 config.add_renderer(extension, renderer_factory) # a Pyramid function
571# =============================================================================
572# URL/route helpers
573# =============================================================================
575RE_VALID_REPLACEMENT_MARKER = re.compile("^[a-zA-Z_][a-zA-Z0-9_]*$")
576# All characters must be a-z, A-Z, _, or 0-9.
577# First character must not be a digit.
578# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/urldispatch.html#route-pattern-syntax # noqa
581def valid_replacement_marker(marker: str) -> bool:
582 """
583 Is a string suitable for use as a parameter name in a templatized URL?
585 (That is: is it free of odd characters?)
587 See :class:`UrlParam`.
588 """
589 return RE_VALID_REPLACEMENT_MARKER.match(marker) is not None
592class UrlParamType(Enum):
593 """
594 Enum for building templatized URLs.
595 See :class:`UrlParam`.
596 """
597 STRING = 1
598 POSITIVE_INTEGER = 2
599 PLAIN_STRING = 3
602class UrlParam(object):
603 """
604 Represents a parameter within a URL. For example:
606 .. code-block:: python
608 from camcops_server.cc_modules.cc_pyramid import *
609 p = UrlParam("patient_id", UrlParamType.POSITIVE_INTEGER)
610 p.markerdef() # '{patient_id:\\d+}'
612 These fragments are suitable for building into a URL for use with Pyramid's
613 URL Dispatch system:
614 https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/urldispatch.html
616 See also :class:`RoutePath`.
618 """ # noqa
619 def __init__(self, name: str,
620 paramtype: UrlParamType == UrlParamType.PLAIN_STRING) -> None:
621 """
622 Args:
623 name: the name of the parameter
624 paramtype: the type (e.g. string? positive integer), defined via
625 the :class:`UrlParamType` enum.
626 """
627 self.name = name
628 self.paramtype = paramtype
629 assert valid_replacement_marker(name), (
630 "UrlParam: invalid replacement marker: " + repr(name)
631 )
633 def regex(self) -> str:
634 """
635 Returns text for a regular expression to capture the parameter value.
636 """
637 if self.paramtype == UrlParamType.STRING:
638 return ''
639 elif self.paramtype == UrlParamType.POSITIVE_INTEGER:
640 return r'\d+' # digits
641 elif self.paramtype == UrlParamType.PLAIN_STRING:
642 return r'[a-zA-Z0-9_]+'
643 else:
644 raise AssertionError("Bug in UrlParam")
646 def markerdef(self) -> str:
647 """
648 Returns the string to use in building the URL.
649 """
650 marker = self.name
651 r = self.regex()
652 if r:
653 marker += ':' + r
654 return '{' + marker + '}'
657def make_url_path(base: str, *args: UrlParam) -> str:
658 """
659 Makes a URL path for use with the Pyramid URL dispatch system.
660 See :class:`UrlParam`.
662 Args:
663 base: the base path, to which we will append parameter templates
664 *args: a number of :class:`UrlParam` objects.
666 Returns:
667 the URL path, beginning with ``/``
668 """
669 parts = [] # type: List[str]
670 if not base.startswith("/"):
671 parts.append("/")
672 parts += [base] + [arg.markerdef() for arg in args]
673 return "/".join(parts)
676# =============================================================================
677# Routes
678# =============================================================================
680# Class to collect constants together
681# See also http://xion.io/post/code/python-enums-are-ok.html
682class Routes(object):
683 """
684 Names of Pyramid routes.
686 - Used by the ``@view_config(route_name=...)`` decorator.
687 - Configured via :class:`RouteCollection` / :class:`RoutePath` to the
688 Pyramid route configurator.
689 """
690 # Hard-coded special paths
691 STATIC = "static"
693 # Other
694 ADD_GROUP = "add_group"
695 ADD_ID_DEFINITION = "add_id_definition"
696 ADD_PATIENT = "add_patient"
697 ADD_SPECIAL_NOTE = "add_special_note"
698 ADD_TASK_SCHEDULE = "add_task_schedule"
699 ADD_TASK_SCHEDULE_ITEM = "add_task_schedule_item"
700 ADD_USER = "add_user"
701 AUDIT_MENU = "audit_menu"
702 BASIC_DUMP = "basic_dump"
703 CHANGE_OTHER_PASSWORD = "change_other_password"
704 CHANGE_OWN_PASSWORD = "change_own_password"
705 CHOOSE_CTV = "choose_ctv"
706 CHOOSE_TRACKER = "choose_tracker"
707 CLIENT_API = "client_api"
708 CLIENT_API_ALIAS = "client_api_alias"
709 CRASH = "crash"
710 CTV = "ctv"
711 DELETE_FILE = "delete_file"
712 DELETE_GROUP = "delete_group"
713 DELETE_ID_DEFINITION = "delete_id_definition"
714 DELETE_PATIENT = "delete_patient"
715 DELETE_SERVER_CREATED_PATIENT = "delete_server_created_patient"
716 DELETE_SPECIAL_NOTE = "delete_special_note"
717 DELETE_TASK_SCHEDULE = "delete_task_schedule"
718 DELETE_TASK_SCHEDULE_ITEM = "delete_task_schedule_item"
719 DELETE_USER = "delete_user"
720 DEVELOPER = "developer"
721 DOWNLOAD_AREA = "download_area"
722 DOWNLOAD_FILE = "download_file"
723 EDIT_GROUP = "edit_group"
724 EDIT_ID_DEFINITION = "edit_id_definition"
725 EDIT_FINALIZED_PATIENT = "edit_finalized_patient"
726 EDIT_SERVER_CREATED_PATIENT = "edit_server_created_patient"
727 EDIT_SERVER_SETTINGS = "edit_server_settings"
728 EDIT_TASK_SCHEDULE = "edit_task_schedule"
729 EDIT_TASK_SCHEDULE_ITEM = "edit_task_schedule_item"
730 EDIT_USER = "edit_user"
731 EDIT_USER_GROUP_MEMBERSHIP = "edit_user_group_membership"
732 ERASE_TASK_LEAVING_PLACEHOLDER = "erase_task_leaving_placeholder"
733 ERASE_TASK_ENTIRELY = "erase_task_entirely"
734 FHIR_PATIENT_ID = "fhir_patient_id/{which_idnum:\\d+}"
735 FHIR_QUESTIONNAIRE_ID = "fhir_questionnaire_id"
736 FHIR_QUESTIONNAIRE_RESPONSE_ID = "fhir_questionnaire_response_id/{tablename}" # noqa: E501
737 FORCIBLY_FINALIZE = "forcibly_finalize"
738 HOME = "home"
739 LOGIN = "login"
740 LOGOUT = "logout"
741 OFFER_AUDIT_TRAIL = "offer_audit_trail"
742 OFFER_EXPORTED_TASK_LIST = "offer_exported_task_list"
743 OFFER_REGENERATE_SUMMARIES = "offer_regenerate_summary_tables"
744 OFFER_REPORT = "offer_report"
745 OFFER_SQL_DUMP = "offer_sql_dump"
746 OFFER_TERMS = "offer_terms"
747 OFFER_BASIC_DUMP = "offer_basic_dump"
748 REPORT = "report"
749 REPORTS_MENU = "reports_menu"
750 SET_FILTERS = "set_filters"
751 SET_OTHER_USER_UPLOAD_GROUP = "set_other_user_upload_group"
752 SET_OWN_USER_UPLOAD_GROUP = "set_user_upload_group"
753 SQL_DUMP = "sql_dump"
754 TASK = "task"
755 TESTPAGE_PRIVATE_1 = "testpage_private_1"
756 TESTPAGE_PRIVATE_2 = "testpage_private_2"
757 TESTPAGE_PRIVATE_3 = "testpage_private_3"
758 TESTPAGE_PRIVATE_4 = "testpage_private_4"
759 TESTPAGE_PUBLIC_1 = "testpage_public_1"
760 TRACKER = "tracker"
761 UNLOCK_USER = "unlock_user"
762 VIEW_ALL_USERS = "view_all_users"
763 VIEW_AUDIT_TRAIL = "view_audit_trail"
764 VIEW_DDL = "view_ddl"
765 VIEW_EMAIL = "view_email"
766 VIEW_EXPORT_RECIPIENT = "view_export_recipient"
767 VIEW_EXPORTED_TASK = "view_exported_task"
768 VIEW_EXPORTED_TASK_LIST = "view_exported_task_list"
769 VIEW_EXPORTED_TASK_EMAIL = "view_exported_task_email"
770 VIEW_EXPORTED_TASK_FILE_GROUP = "view_exported_task_file_group"
771 VIEW_EXPORTED_TASK_HL7_MESSAGE = "view_exported_task_hl7_message"
772 VIEW_GROUPS = "view_groups"
773 VIEW_ID_DEFINITIONS = "view_id_definitions"
774 VIEW_OWN_USER_INFO = "view_own_user_info"
775 VIEW_PATIENT_TASK_SCHEDULE = "view_patient_task_schedule"
776 VIEW_PATIENT_TASK_SCHEDULES = "view_patient_task_schedules"
777 VIEW_SERVER_INFO = "view_server_info"
778 VIEW_TASKS = "view_tasks"
779 VIEW_TASK_SCHEDULES = "view_task_schedules"
780 VIEW_TASK_SCHEDULE_ITEMS = "view_task_schedule_items"
781 VIEW_USER = "view_user"
782 VIEW_USER_EMAIL_ADDRESSES = "view_user_email_addresses"
783 XLSX_DUMP = "xlsx_dump"
786class RoutePath(object):
787 r"""
788 Class to hold a route/path pair.
790 - Pyramid route names are just strings used internally for convenience.
791 - Pyramid URL paths are URL fragments, like ``'/thing'``, and can contain
792 placeholders, like ``'/thing/{bork_id}'``, which will result in the
793 ``request.matchdict`` object containing a ``'bork_id'`` key. Those can be
794 further constrained by regular expressions, like
795 ``'/thing/{bork_id:\d+}'`` to restrict to digits.
797 """
798 def __init__(self, route: str, path: str = "",
799 ignore_in_all_routes: bool = False) -> None:
800 self.route = route
801 self.path = path or "/" + route
802 self.ignore_in_all_routes = ignore_in_all_routes
805MASTER_ROUTE_WEBVIEW = "/"
806MASTER_ROUTE_CLIENT_API = "/api"
807MASTER_ROUTE_CLIENT_API_ALIAS = "/database"
808STATIC_CAMCOPS_PACKAGE_PATH = "camcops_server.static:"
809# ... the "static" package (directory with __init__.py) within the
810# "camcops_server" owning package
813class RouteCollection(object):
814 """
815 All routes, with their paths, for CamCOPS.
816 They will be auto-read by :func:`all_routes`.
818 To make a URL on the fly, use :func:`Request.route_url` or
819 :func:`CamcopsRequest.route_url_params`.
821 To associate a view with a route, use the Pyramid ``@view_config``
822 decorator.
823 """
824 # Hard-coded special paths
825 DEBUG_TOOLBAR = RoutePath('debug_toolbar', '/_debug_toolbar/',
826 ignore_in_all_routes=True) # hard-coded path
827 STATIC = RoutePath(Routes.STATIC, "", # path ignored
828 ignore_in_all_routes=True)
830 # Implemented
831 ADD_GROUP = RoutePath(Routes.ADD_GROUP)
832 ADD_ID_DEFINITION = RoutePath(Routes.ADD_ID_DEFINITION)
833 ADD_PATIENT = RoutePath(Routes.ADD_PATIENT)
834 ADD_SPECIAL_NOTE = RoutePath(Routes.ADD_SPECIAL_NOTE)
835 ADD_TASK_SCHEDULE = RoutePath(Routes.ADD_TASK_SCHEDULE)
836 ADD_TASK_SCHEDULE_ITEM = RoutePath(Routes.ADD_TASK_SCHEDULE_ITEM)
837 ADD_USER = RoutePath(Routes.ADD_USER)
838 AUDIT_MENU = RoutePath(Routes.AUDIT_MENU)
839 BASIC_DUMP = RoutePath(Routes.BASIC_DUMP)
840 CHANGE_OTHER_PASSWORD = RoutePath(Routes.CHANGE_OTHER_PASSWORD)
841 CHANGE_OWN_PASSWORD = RoutePath(Routes.CHANGE_OWN_PASSWORD)
842 CHOOSE_CTV = RoutePath(Routes.CHOOSE_CTV)
843 CHOOSE_TRACKER = RoutePath(Routes.CHOOSE_TRACKER)
844 CLIENT_API = RoutePath(Routes.CLIENT_API, MASTER_ROUTE_CLIENT_API)
845 CLIENT_API_ALIAS = RoutePath(Routes.CLIENT_API_ALIAS,
846 MASTER_ROUTE_CLIENT_API_ALIAS)
847 CRASH = RoutePath(Routes.CRASH)
848 CTV = RoutePath(Routes.CTV)
849 DELETE_FILE = RoutePath(Routes.DELETE_FILE)
850 DELETE_GROUP = RoutePath(Routes.DELETE_GROUP)
851 DELETE_ID_DEFINITION = RoutePath(Routes.DELETE_ID_DEFINITION)
852 DELETE_PATIENT = RoutePath(Routes.DELETE_PATIENT)
853 DELETE_SERVER_CREATED_PATIENT = RoutePath(
854 Routes.DELETE_SERVER_CREATED_PATIENT
855 )
856 DELETE_SPECIAL_NOTE = RoutePath(Routes.DELETE_SPECIAL_NOTE)
857 DELETE_TASK_SCHEDULE = RoutePath(Routes.DELETE_TASK_SCHEDULE)
858 DELETE_TASK_SCHEDULE_ITEM = RoutePath(Routes.DELETE_TASK_SCHEDULE_ITEM)
859 DELETE_USER = RoutePath(Routes.DELETE_USER)
860 DEVELOPER = RoutePath(Routes.DEVELOPER)
861 DOWNLOAD_AREA = RoutePath(Routes.DOWNLOAD_AREA)
862 DOWNLOAD_FILE = RoutePath(Routes.DOWNLOAD_FILE)
863 EDIT_GROUP = RoutePath(Routes.EDIT_GROUP)
864 EDIT_ID_DEFINITION = RoutePath(Routes.EDIT_ID_DEFINITION)
865 EDIT_FINALIZED_PATIENT = RoutePath(Routes.EDIT_FINALIZED_PATIENT)
866 EDIT_SERVER_CREATED_PATIENT = RoutePath(Routes.EDIT_SERVER_CREATED_PATIENT)
867 EDIT_SERVER_SETTINGS = RoutePath(Routes.EDIT_SERVER_SETTINGS)
868 EDIT_TASK_SCHEDULE = RoutePath(Routes.EDIT_TASK_SCHEDULE)
869 EDIT_TASK_SCHEDULE_ITEM = RoutePath(Routes.EDIT_TASK_SCHEDULE_ITEM)
870 EDIT_USER = RoutePath(Routes.EDIT_USER)
871 EDIT_USER_GROUP_MEMBERSHIP = RoutePath(Routes.EDIT_USER_GROUP_MEMBERSHIP)
872 ERASE_TASK_LEAVING_PLACEHOLDER = RoutePath(Routes.ERASE_TASK_LEAVING_PLACEHOLDER) # noqa
873 ERASE_TASK_ENTIRELY = RoutePath(Routes.ERASE_TASK_ENTIRELY)
874 # TODO: FHIR Routes don't currently go anywhere
875 FHIR_PATIENT_ID = RoutePath(Routes.FHIR_PATIENT_ID)
876 FHIR_QUESTIONNAIRE_ID = RoutePath(Routes.FHIR_QUESTIONNAIRE_ID)
877 FHIR_QUESTIONNAIRE_RESPONSE_ID = RoutePath(Routes.FHIR_QUESTIONNAIRE_RESPONSE_ID) # noqa: E501
878 FORCIBLY_FINALIZE = RoutePath(Routes.FORCIBLY_FINALIZE)
879 HOME = RoutePath(Routes.HOME, MASTER_ROUTE_WEBVIEW) # mounted at "/"
880 LOGIN = RoutePath(Routes.LOGIN)
881 LOGOUT = RoutePath(Routes.LOGOUT)
882 OFFER_AUDIT_TRAIL = RoutePath(Routes.OFFER_AUDIT_TRAIL)
883 OFFER_EXPORTED_TASK_LIST = RoutePath(Routes.OFFER_EXPORTED_TASK_LIST)
884 OFFER_REPORT = RoutePath(Routes.OFFER_REPORT)
885 OFFER_SQL_DUMP = RoutePath(Routes.OFFER_SQL_DUMP)
886 OFFER_TERMS = RoutePath(Routes.OFFER_TERMS)
887 OFFER_BASIC_DUMP = RoutePath(Routes.OFFER_BASIC_DUMP)
888 REPORT = RoutePath(Routes.REPORT)
889 REPORTS_MENU = RoutePath(Routes.REPORTS_MENU)
890 SET_FILTERS = RoutePath(Routes.SET_FILTERS)
891 SET_OTHER_USER_UPLOAD_GROUP = RoutePath(Routes.SET_OTHER_USER_UPLOAD_GROUP)
892 SET_OWN_USER_UPLOAD_GROUP = RoutePath(Routes.SET_OWN_USER_UPLOAD_GROUP)
893 SQL_DUMP = RoutePath(Routes.SQL_DUMP)
894 TASK = RoutePath(Routes.TASK)
895 TESTPAGE_PRIVATE_1 = RoutePath(Routes.TESTPAGE_PRIVATE_1)
896 TESTPAGE_PRIVATE_2 = RoutePath(Routes.TESTPAGE_PRIVATE_2)
897 TESTPAGE_PRIVATE_3 = RoutePath(Routes.TESTPAGE_PRIVATE_3)
898 TESTPAGE_PRIVATE_4 = RoutePath(Routes.TESTPAGE_PRIVATE_4)
899 TESTPAGE_PUBLIC_1 = RoutePath(Routes.TESTPAGE_PUBLIC_1)
900 TRACKER = RoutePath(Routes.TRACKER)
901 UNLOCK_USER = RoutePath(Routes.UNLOCK_USER)
902 VIEW_ALL_USERS = RoutePath(Routes.VIEW_ALL_USERS)
903 VIEW_AUDIT_TRAIL = RoutePath(Routes.VIEW_AUDIT_TRAIL)
904 VIEW_DDL = RoutePath(Routes.VIEW_DDL)
905 VIEW_EMAIL = RoutePath(Routes.VIEW_EMAIL)
906 VIEW_EXPORT_RECIPIENT = RoutePath(Routes.VIEW_EXPORT_RECIPIENT)
907 VIEW_EXPORTED_TASK = RoutePath(Routes.VIEW_EXPORTED_TASK)
908 VIEW_EXPORTED_TASK_LIST = RoutePath(Routes.VIEW_EXPORTED_TASK_LIST)
909 VIEW_EXPORTED_TASK_EMAIL = RoutePath(Routes.VIEW_EXPORTED_TASK_EMAIL)
910 VIEW_EXPORTED_TASK_FILE_GROUP = RoutePath(Routes.VIEW_EXPORTED_TASK_FILE_GROUP) # noqa
911 VIEW_EXPORTED_TASK_HL7_MESSAGE = RoutePath(Routes.VIEW_EXPORTED_TASK_HL7_MESSAGE) # noqa
912 VIEW_GROUPS = RoutePath(Routes.VIEW_GROUPS)
913 VIEW_ID_DEFINITIONS = RoutePath(Routes.VIEW_ID_DEFINITIONS)
914 VIEW_OWN_USER_INFO = RoutePath(Routes.VIEW_OWN_USER_INFO)
915 VIEW_PATIENT_TASK_SCHEDULE = RoutePath(Routes.VIEW_PATIENT_TASK_SCHEDULE)
916 VIEW_PATIENT_TASK_SCHEDULES = RoutePath(Routes.VIEW_PATIENT_TASK_SCHEDULES)
917 VIEW_SERVER_INFO = RoutePath(Routes.VIEW_SERVER_INFO)
918 VIEW_TASKS = RoutePath(Routes.VIEW_TASKS)
919 VIEW_TASK_SCHEDULES = RoutePath(Routes.VIEW_TASK_SCHEDULES)
920 VIEW_TASK_SCHEDULE_ITEMS = RoutePath(Routes.VIEW_TASK_SCHEDULE_ITEMS)
921 VIEW_USER = RoutePath(Routes.VIEW_USER)
922 VIEW_USER_EMAIL_ADDRESSES = RoutePath(Routes.VIEW_USER_EMAIL_ADDRESSES)
923 XLSX_DUMP = RoutePath(Routes.XLSX_DUMP)
925 @classmethod
926 def all_routes(cls) -> List[RoutePath]:
927 """
928 Fetch all routes for CamCOPS.
929 """
930 return [
931 v for k, v in cls.__dict__.items()
932 if not (k.startswith('_') or # class hidden things
933 k == 'all_routes' or # this function
934 v.ignore_in_all_routes) # explicitly ignored
935 ]
938# =============================================================================
939# Pyramid HTTP session handling
940# =============================================================================
942def get_session_factory() -> Callable[["CamcopsRequest"], ISession]:
943 """
944 We have to give a Pyramid request a way of making an HTTP session.
945 We must return a session factory.
947 - An example is in :class:`pyramid.session.SignedCookieSessionFactory`.
948 - A session factory has the signature [1]:
950 .. code-block:: none
952 sessionfactory(req: CamcopsRequest) -> session_object
954 - ... where session "is a namespace" [2]
955 - ... but more concretely, "implements the pyramid.interfaces.ISession
956 interface"
958 - We want to be able to make the session by reading the
959 :class:`camcops_server.cc_modules.cc_config.CamcopsConfig` from the request.
961 [1] https://docs.pylonsproject.org/projects/pyramid/en/latest/glossary.html#term-session-factory
963 [2] https://docs.pylonsproject.org/projects/pyramid/en/latest/glossary.html#term-session
964 """ # noqa
966 def factory(req: "CamcopsRequest") -> ISession:
967 """
968 How does the session write the cookies to the response? Like this:
970 .. code-block:: none
972 SignedCookieSessionFactory
973 BaseCookieSessionFactory # pyramid/session.py
974 CookieSession
975 def changed():
976 if not self._dirty:
977 self._dirty = True
978 def set_cookie_callback(request, response):
979 self._set_cookie(response)
980 # ...
981 self.request.add_response_callback(set_cookie_callback)
983 def _set_cookie(self, response):
984 # ...
985 response.set_cookie(...)
987 """ # noqa
988 cfg = req.config
989 secure_cookies = not cfg.allow_insecure_cookies
990 pyramid_factory = SignedCookieSessionFactory(
991 secret=cfg.session_cookie_secret,
992 hashalg='sha512', # the default
993 salt='camcops_pyramid_session.',
994 cookie_name=COOKIE_NAME,
995 max_age=None, # browser scope; session cookie
996 path='/', # the default
997 domain=None, # the default
998 secure=secure_cookies,
999 httponly=secure_cookies,
1000 timeout=None, # we handle timeouts at the database level instead
1001 reissue_time=0, # default; reissue cookie at every request
1002 set_on_exception=True, # (default) cookie even if exception raised
1003 serializer=None, # (default) use pyramid.session.PickleSerializer
1004 # As max_age and expires are left at their default of None, these
1005 # are session cookies.
1006 )
1007 return pyramid_factory(req)
1009 return factory
1012# =============================================================================
1013# Authentication; authorization (permissions)
1014# =============================================================================
1016class Permission(object):
1017 """
1018 Pyramid permission values.
1020 - Permissions are strings.
1021 - For "logged in", use ``pyramid.security.Authenticated``
1022 """
1023 GROUPADMIN = "groupadmin"
1024 HAPPY = "happy" # logged in, can use webview, no need to change p/w, agreed to terms # noqa
1025 MUST_AGREE_TERMS = "must_agree_terms"
1026 MUST_CHANGE_PASSWORD = "must_change_password"
1027 SUPERUSER = "superuser"
1030@implementer(IAuthenticationPolicy)
1031class CamcopsAuthenticationPolicy(object):
1032 """
1033 CamCOPS authentication policy.
1035 See
1037 - https://docs.pylonsproject.org/projects/pyramid/en/latest/tutorials/wiki2/authorization.html
1038 - https://docs.pylonsproject.org/projects/pyramid-cookbook/en/latest/auth/custom.html
1039 - Don't actually inherit from :class:`IAuthenticationPolicy`; it ends up in
1040 the :class:`zope.interface.interface.InterfaceClass` metaclass and then
1041 breaks with "zope.interface.exceptions.InvalidInterface: Concrete
1042 attribute, ..."
1043 - But ``@implementer`` does the trick.
1044 """ # noqa
1046 @staticmethod
1047 def authenticated_userid(request: "CamcopsRequest") -> Optional[int]:
1048 """
1049 Returns the user ID of the authenticated user.
1050 """
1051 return request.user_id
1053 # noinspection PyUnusedLocal
1054 @staticmethod
1055 def unauthenticated_userid(request: "CamcopsRequest") -> Optional[int]:
1056 """
1057 Returns the user ID of the unauthenticated user.
1059 We don't allow users to be identified but not authenticated, so we
1060 return ``None``.
1061 """
1062 return None
1064 @staticmethod
1065 def effective_principals(request: "CamcopsRequest") -> List[str]:
1066 """
1067 Returns a list of strings indicating permissions that the current user
1068 has.
1069 """
1070 principals = [Everyone]
1071 user = request.user
1072 if user is not None:
1073 principals += [Authenticated, 'u:%s' % user.id]
1074 if user.may_use_webviewer:
1075 if user.must_change_password:
1076 principals.append(Permission.MUST_CHANGE_PASSWORD)
1077 elif user.must_agree_terms:
1078 principals.append(Permission.MUST_AGREE_TERMS)
1079 else:
1080 principals.append(Permission.HAPPY)
1081 if user.superuser:
1082 principals.append(Permission.SUPERUSER)
1083 if user.authorized_as_groupadmin:
1084 principals.append(Permission.GROUPADMIN)
1085 # principals.extend(('g:%s' % g.name for g in user.groups))
1086 if DEBUG_EFFECTIVE_PRINCIPALS:
1087 log.debug("effective_principals: {!r}", principals)
1088 return principals
1090 # noinspection PyUnusedLocal
1091 @staticmethod
1092 def remember(request: "CamcopsRequest",
1093 userid: int,
1094 **kw) -> List[Tuple[str, str]]:
1095 return []
1097 # noinspection PyUnusedLocal
1098 @staticmethod
1099 def forget(request: "CamcopsRequest") -> List[Tuple[str, str]]:
1100 return []
1103@implementer(IAuthorizationPolicy)
1104class CamcopsAuthorizationPolicy(object):
1105 """
1106 CamCOPS authorization policy.
1107 """
1108 # noinspection PyUnusedLocal
1109 @staticmethod
1110 def permits(context: ILocation, principals: List[str], permission: str) \
1111 -> PermitsResult:
1112 if permission in principals:
1113 return Allowed(f"ALLOWED: permission {permission} present in "
1114 f"principals {principals}")
1116 return Denied(f"DENIED: permission {permission} not in principals "
1117 f"{principals}")
1119 @staticmethod
1120 def principals_allowed_by_permission(context: ILocation,
1121 permission: str) -> List[str]:
1122 raise NotImplementedError() # don't care about this method
1125# =============================================================================
1126# Pagination
1127# =============================================================================
1128# WebHelpers 1.3 doesn't support Python 3.5.
1129# The successor to webhelpers.paginate appears to be paginate.
1131class SqlalchemyOrmQueryWrapper(object):
1132 """
1133 Wrapper class to access elements of an SQLAlchemy ORM query in an efficient
1134 way for pagination. We only ask the database for what we need.
1136 (But it will perform a ``COUNT(*)`` for the query before fetching it via
1137 ``LIMIT/OFFSET``.)
1139 See:
1141 - https://docs.pylonsproject.org/projects/pylons-webframework/en/latest/helpers.html
1142 - https://docs.pylonsproject.org/projects/webhelpers/en/latest/modules/paginate.html
1143 - https://github.com/Pylons/paginate
1144 """ # noqa
1145 def __init__(self, query: Query) -> None:
1146 self.query = query
1148 def __getitem__(self, cut: slice) -> List[Any]:
1149 """
1150 Return a range of objects of an :class:`sqlalchemy.orm.query.Query`
1151 object.
1153 Will apply LIMIT/OFFSET to fetch only what we need.
1154 """
1155 return self.query[cut]
1157 def __len__(self) -> int:
1158 """
1159 Count the number of objects in an :class:`sqlalchemy.orm.query.Query``
1160 object.
1161 """
1162 return self.query.count()
1165class CamcopsPage(Page):
1166 """
1167 Pagination class, for HTML views that display, for example,
1168 items 1-20 and buttons like "page 2", "next page", "last page".
1170 - Fixes a bug in paginate: it slices its collection BEFORE it realizes that
1171 the page number is out of range.
1172 - Also, it uses ``..`` for an ellipsis, which is just wrong.
1173 """
1174 # noinspection PyShadowingBuiltins
1175 def __init__(self,
1176 collection: Union[Sequence[Any], Query, Select],
1177 url_maker: Callable[[int], str],
1178 request: "CamcopsRequest",
1179 page: int = 1,
1180 items_per_page: int = 20,
1181 item_count: int = None,
1182 wrapper_class: Type[Any] = None,
1183 ellipsis: str = "…",
1184 **kwargs) -> None:
1185 """
1186 See :class:`paginate.Page`. Additional arguments:
1188 Args:
1189 ellipsis: HTML text to use as the ellipsis marker
1190 """
1191 self.request = request
1192 self.ellipsis = ellipsis
1193 page = max(1, page)
1194 if item_count is None:
1195 if wrapper_class:
1196 item_count = len(wrapper_class(collection))
1197 else:
1198 item_count = len(collection)
1199 n_pages = ((item_count - 1) // items_per_page) + 1
1200 page = min(page, n_pages)
1201 super().__init__(
1202 collection=collection,
1203 page=page,
1204 items_per_page=items_per_page,
1205 item_count=item_count,
1206 wrapper_class=wrapper_class,
1207 url_maker=url_maker,
1208 **kwargs
1209 )
1210 # Original defines attributes outside __init__, so:
1211 self.radius = 2
1212 self.curpage_attr = {} # type: Dict[str, str]
1213 self.separator = ""
1214 self.link_attr = {} # type: Dict[str, str]
1215 self.dotdot_attr = {} # type: Dict[str, str]
1216 self.url = ""
1218 # noinspection PyShadowingBuiltins
1219 def pager(self,
1220 format: str = None,
1221 url: str = None,
1222 show_if_single_page: bool = True, # see below!
1223 separator: str = ' ',
1224 symbol_first: str = '<<',
1225 symbol_last: str = '>>',
1226 symbol_previous: str = '<',
1227 symbol_next: str = '>',
1228 link_attr: Dict[str, str] = None,
1229 curpage_attr: Dict[str, str] = None,
1230 dotdot_attr: Dict[str, str] = None,
1231 link_tag: Callable[[Dict[str, str]], str] = None):
1232 """
1233 See :func:`paginate.Page.pager`.
1235 The reason for the default for ``show_if_single_page`` being ``True``
1236 is that it's possible otherwise to think you've lost your tasks. For
1237 example: (1) have 99 tasks; (2) view 50/page; (3) go to page 2; (4) set
1238 number per page to 100. Or simply use the URL to go beyond the end.
1239 """
1240 format = format or self.default_pager_pattern()
1241 link_attr = link_attr or {} # type: Dict[str, str]
1242 curpage_attr = curpage_attr or {} # type: Dict[str, str]
1243 # dotdot_attr = dotdot_attr or {} # type: Dict[str, str]
1244 # dotdot_attr = dotdot_attr or {'class': 'pager_dotdot'} # our default!
1245 return super().pager(
1246 format=format,
1247 url=url,
1248 show_if_single_page=show_if_single_page,
1249 separator=separator,
1250 symbol_first=symbol_first,
1251 symbol_last=symbol_last,
1252 symbol_previous=symbol_previous,
1253 symbol_next=symbol_next,
1254 link_attr=link_attr,
1255 curpage_attr=curpage_attr,
1256 dotdot_attr=dotdot_attr,
1257 link_tag=link_tag,
1258 )
1260 def default_pager_pattern(self) -> str:
1261 """
1262 Allows internationalization of the pager pattern.
1263 """
1264 _ = self.request.gettext
1265 xlated = _("Page $page of $page_count; total $item_count records")
1266 return (
1267 f"({xlated}) "
1268 f"[ $link_first $link_previous ~3~ $link_next $link_last ]"
1269 )
1271 # noinspection PyShadowingBuiltins
1272 def link_map(self,
1273 format: str = '~2~',
1274 url: str = None,
1275 show_if_single_page: bool = False,
1276 separator: str = ' ',
1277 symbol_first: str = '<<',
1278 symbol_last: str = '>>',
1279 symbol_previous: str = '<',
1280 symbol_next: str = '>',
1281 link_attr: Dict[str, str] = None,
1282 curpage_attr: Dict[str, str] = None,
1283 dotdot_attr: Dict[str, str] = None):
1284 """
1285 See equivalent in superclass.
1287 Fixes bugs (e.g. mutable default arguments) and nasties (e.g.
1288 enforcing ".." for the ellipsis) in the original.
1289 """
1290 self.curpage_attr = curpage_attr or {} # type: Dict[str, str]
1291 self.separator = separator
1292 self.link_attr = link_attr or {} # type: Dict[str, str]
1293 self.dotdot_attr = dotdot_attr or {} # type: Dict[str, str]
1294 self.url = url
1296 regex_res = re.search(r'~(\d+)~', format)
1297 if regex_res:
1298 radius = regex_res.group(1)
1299 else:
1300 radius = 2
1301 radius = int(radius)
1302 self.radius = radius
1304 # Compute the first and last page number within the radius
1305 # e.g. '1 .. 5 6 [7] 8 9 .. 12'
1306 # -> leftmost_page = 5
1307 # -> rightmost_page = 9
1308 leftmost_page = (
1309 max(self.first_page, (self.page - radius))
1310 if self.first_page else None
1311 ) # type: Optional[int]
1312 rightmost_page = (
1313 min(self.last_page, (self.page+radius))
1314 if self.last_page else None
1315 ) # type: Optional[int]
1316 nav_items = {
1317 "first_page": None,
1318 "last_page": None,
1319 "previous_page": None,
1320 "next_page": None,
1321 "current_page": None,
1322 "radius": self.radius,
1323 "range_pages": []
1324 } # type: Dict[str, Any]
1326 if leftmost_page is None or rightmost_page is None:
1327 return nav_items
1329 nav_items["first_page"] = {
1330 "type": "first_page",
1331 "value": symbol_first,
1332 "attrs": self.link_attr,
1333 "number": self.first_page,
1334 "href": self.url_maker(self.first_page)
1335 }
1337 # Insert dots if there are pages between the first page
1338 # and the currently displayed page range
1339 if leftmost_page - self.first_page > 1:
1340 # Wrap in a SPAN tag if dotdot_attr is set
1341 nav_items["range_pages"].append({
1342 "type": "span",
1343 "value": self.ellipsis,
1344 "attrs": self.dotdot_attr,
1345 "href": "",
1346 "number": None
1347 })
1349 for thispage in range(leftmost_page, rightmost_page + 1):
1350 # Highlight the current page number and do not use a link
1351 if thispage == self.page:
1352 # Wrap in a SPAN tag if curpage_attr is set
1353 nav_items["range_pages"].append({
1354 "type": "current_page",
1355 "value": str(thispage),
1356 "number": thispage,
1357 "attrs": self.curpage_attr,
1358 "href": self.url_maker(thispage)
1359 })
1360 nav_items["current_page"] = {
1361 "value": thispage,
1362 "attrs": self.curpage_attr,
1363 "type": "current_page",
1364 "href": self.url_maker(thispage)
1365 }
1366 # Otherwise create just a link to that page
1367 else:
1368 nav_items["range_pages"].append({
1369 "type": "page",
1370 "value": str(thispage),
1371 "number": thispage,
1372 "attrs": self.link_attr,
1373 "href": self.url_maker(thispage)
1374 })
1376 # Insert dots if there are pages between the displayed
1377 # page numbers and the end of the page range
1378 if self.last_page - rightmost_page > 1:
1379 # Wrap in a SPAN tag if dotdot_attr is set
1380 nav_items["range_pages"].append({
1381 "type": "span",
1382 "value": self.ellipsis,
1383 "attrs": self.dotdot_attr,
1384 "href": "",
1385 "number": None
1386 })
1388 # Create a link to the very last page (unless we are on the last
1389 # page or there would be no need to insert '..' spacers)
1390 nav_items["last_page"] = {
1391 "type": "last_page",
1392 "value": symbol_last,
1393 "attrs": self.link_attr,
1394 "href": self.url_maker(self.last_page),
1395 "number": self.last_page
1396 }
1397 nav_items["previous_page"] = {
1398 "type": "previous_page",
1399 "value": symbol_previous,
1400 "attrs": self.link_attr,
1401 "number": self.previous_page or self.first_page,
1402 "href": self.url_maker(self.previous_page or self.first_page)
1403 }
1404 nav_items["next_page"] = {
1405 "type": "next_page",
1406 "value": symbol_next,
1407 "attrs": self.link_attr,
1408 "number": self.next_page or self.last_page,
1409 "href": self.url_maker(self.next_page or self.last_page)
1410 }
1411 return nav_items
1414class SqlalchemyOrmPage(CamcopsPage):
1415 """
1416 A pagination page that paginates SQLAlchemy ORM queries efficiently.
1417 """
1418 def __init__(self,
1419 query: Query,
1420 url_maker: Callable[[int], str],
1421 request: "CamcopsRequest",
1422 page: int = 1,
1423 items_per_page: int = DEFAULT_ROWS_PER_PAGE,
1424 item_count: int = None,
1425 **kwargs) -> None:
1426 # Since views may accidentally throw strings our way:
1427 assert isinstance(page, int)
1428 assert isinstance(items_per_page, int)
1429 assert isinstance(item_count, int) or item_count is None
1430 super().__init__(
1431 collection=query,
1432 request=request,
1433 page=page,
1434 items_per_page=items_per_page,
1435 item_count=item_count,
1436 wrapper_class=SqlalchemyOrmQueryWrapper,
1437 url_maker=url_maker,
1438 **kwargs
1439 )
1442# From webhelpers.paginate (which is broken on Python 3.5, but good),
1443# modified a bit:
1445def make_page_url(path: str, params: Dict[str, str], page: int,
1446 partial: bool = False, sort: bool = True) -> str:
1447 """
1448 A helper function for URL generators.
1450 I assemble a URL from its parts. I assume that a link to a certain page is
1451 done by overriding the 'page' query parameter.
1453 ``path`` is the current URL path, with or without a "scheme://host" prefix.
1455 ``params`` is the current query parameters as a dict or dict-like object.
1457 ``page`` is the target page number.
1459 If ``partial`` is true, set query param 'partial=1'. This is to for AJAX
1460 calls requesting a partial page.
1462 If ``sort`` is true (default), the parameters will be sorted. Otherwise
1463 they'll be in whatever order the dict iterates them.
1464 """
1465 params = params.copy()
1466 params["page"] = str(page)
1467 if partial:
1468 params["partial"] = "1"
1469 if sort:
1470 params = sorted(params.items())
1471 qs = urlencode(params, True) # was urllib.urlencode, but changed in Py3.5
1472 return "%s?%s" % (path, qs)
1475class PageUrl(object):
1476 """
1477 A page URL generator for WebOb-compatible Request objects.
1479 I derive new URLs based on the current URL but overriding the 'page'
1480 query parameter.
1482 I'm suitable for Pyramid, Pylons, and TurboGears, as well as any other
1483 framework whose Request object has 'application_url', 'path', and 'GET'
1484 attributes that behave the same way as ``webob.Request``'s.
1485 """
1487 def __init__(self, request: "Request", qualified: bool = False):
1488 """
1489 ``request`` is a WebOb-compatible ``Request`` object.
1491 If ``qualified`` is false (default), generated URLs will have just the
1492 path and query string. If true, the "scheme://host" prefix will be
1493 included. The default is false to match traditional usage, and to avoid
1494 generating unuseable URLs behind reverse proxies (e.g., Apache's
1495 mod_proxy).
1496 """
1497 self.request = request
1498 self.qualified = qualified
1500 def __call__(self, page: int, partial: bool = False) -> str:
1501 """
1502 Generate a URL for the specified page.
1503 """
1504 if self.qualified:
1505 path = self.request.application_url
1506 else:
1507 path = self.request.path
1508 return make_page_url(path, self.request.GET, page, partial)
1511# =============================================================================
1512# Debugging requests and responses
1513# =============================================================================
1515def get_body_from_request(req: Request) -> bytes:
1516 """
1517 Debugging function to read the body from an HTTP request.
1518 May not work and will warn accordingly. Use Wireshark to be sure
1519 (https://www.wireshark.org/).
1520 """
1521 log.warning("Attempting to read body from request -- but a previous read "
1522 "may have left this empty. Consider using Wireshark!")
1523 wsgi_input = req.environ[WsgiEnvVar.WSGI_INPUT]
1524 # ... under gunicorn, is an instance of gunicorn.http.body.Body
1525 return wsgi_input.read()
1528class HTTPFoundDebugVersion(HTTPFound):
1529 """
1530 A debugging version of :class:`HTTPFound`, for debugging redirections.
1531 """
1532 def __init__(self, location: str = '', **kwargs) -> None:
1533 log.debug("Redirecting to {!r}", location)
1534 super().__init__(location, **kwargs)