Coverage for cc_modules/cc_pyramid.py: 75%
803 statements
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
1#!/usr/bin/env python
3"""
4camcops_server/cc_modules/cc_pyramid.py
6===============================================================================
8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
11 This file is part of CamCOPS.
13 CamCOPS is free software: you can redistribute it and/or modify
14 it under the terms of the GNU General Public License as published by
15 the Free Software Foundation, either version 3 of the License, or
16 (at your option) any later version.
18 CamCOPS is distributed in the hope that it will be useful,
19 but WITHOUT ANY WARRANTY; without even the implied warranty of
20 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21 GNU General Public License for more details.
23 You should have received a copy of the GNU General Public License
24 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.
26===============================================================================
28**Functions for the Pyramid web framework.**
30"""
32from enum import Enum
33import logging
34import os
35import pprint
36import re
37import sys
38from typing import (
39 Any,
40 Callable,
41 Dict,
42 List,
43 Optional,
44 Sequence,
45 Tuple,
46 Type,
47 TYPE_CHECKING,
48 Union,
49)
50from urllib.parse import urlencode
52from cardinal_pythonlib.logs import BraceStyleAdapter
53from cardinal_pythonlib.wsgi.constants import WsgiEnvVar
54from mako.filters import html_escape
55from mako.lookup import TemplateLookup
56from paginate import Page
57from pyramid.authentication import IAuthenticationPolicy
58from pyramid.authorization import IAuthorizationPolicy
59from pyramid.config import Configurator
60from pyramid.httpexceptions import HTTPFound
61from pyramid.interfaces import ILocation, ISession
62from pyramid.request import Request
63from pyramid.security import (
64 Allowed,
65 Denied,
66 Authenticated,
67 Everyone,
68 PermitsResult,
69)
70from pyramid.session import JSONSerializer, SignedCookieSessionFactory
71from pyramid_mako import (
72 MakoLookupTemplateRenderer,
73 MakoRendererFactory,
74 MakoRenderingException,
75 reraise,
76 text_error_template,
77)
78from sqlalchemy.orm import Query
79from sqlalchemy.sql.selectable import Select
80from zope.interface import implementer
82from camcops_server.cc_modules.cc_baseconstants import TEMPLATE_DIR
83from camcops_server.cc_modules.cc_cache import cache_region_static
84from camcops_server.cc_modules.cc_constants import DEFAULT_ROWS_PER_PAGE
86if TYPE_CHECKING:
87 from camcops_server.cc_modules.cc_request import CamcopsRequest
89log = BraceStyleAdapter(logging.getLogger(__name__))
92# =============================================================================
93# Debugging options
94# =============================================================================
96DEBUG_ADD_ROUTES = False
97DEBUG_EFFECTIVE_PRINCIPALS = False
98DEBUG_TEMPLATE_PARAMETERS = False
99# ... logs more information about template creation
100DEBUG_TEMPLATE_SOURCE = False
101# ... writes the templates in their compiled-to-Python version to a debugging
102# directory (see below), which is very informative.
103DEBUGGING_MAKO_DIR = os.path.expanduser("~/tmp/camcops_mako_template_source")
105if any(
106 [
107 DEBUG_ADD_ROUTES,
108 DEBUG_EFFECTIVE_PRINCIPALS,
109 DEBUG_TEMPLATE_PARAMETERS,
110 DEBUG_TEMPLATE_SOURCE,
111 ]
112):
113 log.warning("Debugging options enabled!")
116# =============================================================================
117# Constants
118# =============================================================================
120COOKIE_NAME = "camcops"
123class CookieKey:
124 """
125 Keys for HTTP cookies. We keep this to the absolute minimum; cookies
126 contain enough detail to look up a session on the server, and then
127 everything else is looked up on the server side.
128 """
130 SESSION_ID = "session_id"
131 SESSION_TOKEN = "session_token"
134class FormAction(object):
135 """
136 Action values for HTML forms. These values generally end up as the ``name``
137 attribute (and sometimes also the ``value`` attribute) of an HTML button.
138 """
140 CANCEL = "cancel"
141 CLEAR_FILTERS = "clear_filters"
142 DELETE = "delete"
143 FINALIZE = "finalize"
144 SET_FILTERS = "set_filters"
145 SUBMIT = "submit" # the default for many forms
146 SUBMIT_TASKS_PER_PAGE = "submit_tpp"
147 REFRESH_TASKS = "refresh_tasks"
150class ViewParam(object):
151 """
152 View parameter constants.
154 Used in the following situations:
156 - as parameter names for parameterized URLs (via RoutePath to Pyramid's
157 route configuration, then fetched from the matchdict);
159 - as form parameter names (often with some duplication as the attribute
160 names of deform Form objects, because to avoid duplication would involve
161 metaclass mess).
162 """
164 # QUERY = "_query" # built in to Pyramid
165 ADDRESS = "address"
166 ADD_SPECIAL_NOTE = "add_special_note"
167 ADMIN = "admin"
168 ADVANCED = "advanced"
169 AGE_MINIMUM = "age_minimum"
170 AGE_MAXIMUM = "age_maximum"
171 ALL_TASKS = "all_tasks"
172 ANONYMISE = "anonymise"
173 BACK_TASK_TABLENAME = "back_task_tablename"
174 BACK_TASK_SERVER_PK = "back_task_server_pk"
175 BY_MONTH = "by_month"
176 BY_TASK = "by_task"
177 BY_USER = "by_user"
178 BY_YEAR = "by_year"
179 CLINICIAN_CONFIRMATION = "clinician_confirmation"
180 CSRF_TOKEN = "csrf_token"
181 DATABASE_TITLE = "database_title"
182 DELIVERY_MODE = "delivery_mode"
183 DESCRIPTION = "description"
184 DEVICE_ID = "device_id"
185 DEVICE_IDS = "device_ids"
186 DIALECT = "dialect"
187 DIAGNOSES_INCLUSION = "diagnoses_inclusion"
188 DIAGNOSES_EXCLUSION = "diagnoses_exclusion"
189 DISABLE_MFA = "disable_mfa"
190 DUMP_METHOD = "dump_method"
191 DOB = "dob"
192 DUE_FROM = "due_from"
193 DUE_WITHIN = "due_within"
194 EMAIL = "email"
195 EMAIL_BCC = "email_bcc"
196 EMAIL_BODY = "email_body"
197 EMAIL_CC = "email_cc"
198 EMAIL_FROM = "email_from"
199 EMAIL_SUBJECT = "email_subject"
200 EMAIL_TEMPLATE = "email_template"
201 END_DATETIME = "end_datetime"
202 INCLUDE_AUTO_GENERATED = "include_auto_generated"
203 FHIR_ID_SYSTEM = "fhir_id_system"
204 FILENAME = "filename"
205 FINALIZE_POLICY = "finalize_policy"
206 FORENAME = "forename"
207 FULLNAME = "fullname"
208 GP = "gp"
209 GROUPADMIN = "groupadmin"
210 GROUP_ID = "group_id"
211 GROUP_IDS = "group_ids"
212 HL7_ID_TYPE = "hl7_id_type"
213 HL7_ASSIGNING_AUTHORITY = "hl7_assigning_authority"
214 ID = "id" # generic PK
215 ID_DEFINITIONS = "id_definitions"
216 ID_REFERENCES = "id_references"
217 IDNUM_VALUE = "idnum_value"
218 INCLUDE_BLOBS = "include_blobs"
219 INCLUDE_CALCULATED = "include_calculated"
220 INCLUDE_COMMENTS = "include_comments"
221 INCLUDE_PATIENT = "include_patient"
222 INCLUDE_SCHEMA = "include_schema"
223 INCLUDE_SNOMED = "include_snomed"
224 IP_USE = "ip_use"
225 LANGUAGE = "language"
226 MANUAL = "manual"
227 MAY_ADD_NOTES = "may_add_notes"
228 MAY_DUMP_DATA = "may_dump_data"
229 MAY_EMAIL_PATIENTS = "may_email_patients"
230 MAY_MANAGE_PATIENTS = "may_manage_patients"
231 MAY_REGISTER_DEVICES = "may_register_devices"
232 MAY_RUN_REPORTS = "may_run_reports"
233 MAY_UPLOAD = "may_upload"
234 MAY_USE_WEBVIEWER = "may_use_webviewer"
235 MFA_SECRET_KEY = "mfa_secret_key"
236 MFA_METHOD = "mfa_method"
237 MUST_CHANGE_PASSWORD = "must_change_password"
238 NAME = "name"
239 NOTE = "note"
240 NOTE_ID = "note_id"
241 NEW_PASSWORD = "new_password"
242 OLD_PASSWORD = "old_password"
243 ONE_TIME_PASSWORD = "one_time_password"
244 OTHER = "other"
245 COMPLETE_ONLY = "complete_only"
246 PAGE = "page"
247 PASSWORD = "password"
248 PATIENT_ID_PER_ROW = "patient_id_per_row"
249 PATIENT_TASK_SCHEDULE_ID = "patient_task_schedule_id"
250 PHONE_NUMBER = "phone_number"
251 RECIPIENT_NAME = "recipient_name"
252 REDIRECT_URL = "redirect_url"
253 REPORT_ID = "report_id"
254 REMOTE_IP_ADDR = "remote_ip_addr"
255 ROWS_PER_PAGE = "rows_per_page"
256 SCHEDULE_ID = "schedule_id"
257 SCHEDULE_ITEM_ID = "schedule_item_id"
258 SERVER_PK = "server_pk"
259 SETTINGS = "settings"
260 SEX = "sex"
261 SHORT_DESCRIPTION = "short_description"
262 SIMPLIFIED = "simplified"
263 SORT = "sort"
264 SOURCE = "source"
265 SQLITE_METHOD = "sqlite_method"
266 START_DATETIME = "start_datetime"
267 SUPERUSER = "superuser"
268 SURNAME = "surname"
269 TABLE_NAME = "table_name"
270 TASKS = "tasks"
271 TASK_SCHEDULES = "task_schedules"
272 TEXT_CONTENTS = "text_contents"
273 TRUNCATE = "truncate"
274 UPLOAD_GROUP_ID = "upload_group_id"
275 UPLOAD_POLICY = "upload_policy"
276 USER_GROUP_MEMBERSHIP_ID = "user_group_membership_id"
277 USER_ID = "user_id"
278 USER_IDS = "user_ids"
279 USERNAME = "username"
280 VALIDATION_METHOD = "validation_method"
281 VIA_INDEX = "via_index"
282 VIEW_ALL_PATIENTS_WHEN_UNFILTERED = "view_all_patients_when_unfiltered"
283 VIEWTYPE = "viewtype"
284 WHICH_IDNUM = "which_idnum"
285 WHAT = "what"
286 WHEN = "when"
287 WHO = "who"
290class ViewArg(object):
291 """
292 String used as view arguments. For example,
293 :class:`camcops_server.cc_modules.cc_forms.DumpTypeSelector` represents its
294 choices (inside an HTTP POST request) as values from this class.
295 """
297 # Delivery methods
298 DOWNLOAD = "download"
299 EMAIL = "email"
300 IMMEDIATELY = "immediately"
302 # Output types
303 FHIRJSON = "fhirjson"
304 HTML = "html"
305 ODS = "ods"
306 PDF = "pdf"
307 PDFHTML = "pdfhtml" # the HTML to create a PDF
308 R = "r"
309 SQL = "sql"
310 SQLITE = "sqlite"
311 TSV = "tsv"
312 TSV_ZIP = "tsv_zip"
313 XLSX = "xlsx"
314 XML = "xml"
316 # What to download
317 EVERYTHING = "everything"
318 SPECIFIC_TASKS_GROUPS = "specific_tasks_groups"
319 USE_SESSION_FILTER = "use_session_filter"
322# =============================================================================
323# Flash message queues
324# =============================================================================
327class FlashQueue:
328 """
329 Predefined flash (alert) message queues for Bootstrap; see
330 https://getbootstrap.com/docs/3.3/components/#alerts.
331 """
333 SUCCESS = "success"
334 INFO = "info"
335 WARNING = "warning"
336 DANGER = "danger"
339# =============================================================================
340# Templates
341# =============================================================================
342# Adaptation of a small part of pyramid_mako, so we can use our own Mako
343# TemplateLookup, and thus dogpile.cache. See
344# https://github.com/Pylons/pyramid_mako/blob/master/pyramid_mako/__init__.py
346MAKO_LOOKUP = TemplateLookup(
347 directories=[
348 os.path.join(TEMPLATE_DIR, "base"),
349 os.path.join(TEMPLATE_DIR, "css"),
350 os.path.join(TEMPLATE_DIR, "menu"),
351 os.path.join(TEMPLATE_DIR, "snippets"),
352 os.path.join(TEMPLATE_DIR, "taskcommon"),
353 os.path.join(TEMPLATE_DIR, "tasks"),
354 os.path.join(TEMPLATE_DIR, "test"),
355 ],
356 input_encoding="utf-8",
357 output_encoding="utf-8",
358 module_directory=DEBUGGING_MAKO_DIR if DEBUG_TEMPLATE_SOURCE else None,
359 # strict_undefined=True, # raise error immediately upon typos
360 # ... tradeoff; there are good and bad things about this!
361 # One bad thing about strict_undefined=True is that a child (inheriting)
362 # template must supply all variables used by its parent (inherited)
363 # template, EVEN IF it replaces entirely the <%block> of the parent that
364 # uses those variables.
365 # -------------------------------------------------------------------------
366 # Template default filtering
367 # -------------------------------------------------------------------------
368 default_filters=["h"],
369 # -------------------------------------------------------------------------
370 # Template caching
371 # -------------------------------------------------------------------------
372 # http://dogpilecache.readthedocs.io/en/latest/api.html#module-dogpile.cache.plugins.mako_cache # noqa
373 # http://docs.makotemplates.org/en/latest/caching.html#cache-arguments
374 cache_impl="dogpile.cache",
375 cache_args={"regions": {"local": cache_region_static}},
376 # Now, in Mako templates, use:
377 # cached="True" cache_region="local" cache_key="SOME_CACHE_KEY"
378 # on <%page>, <%def>, and <%block> regions.
379 # It is VITAL that you specify "name", and that it be appropriately
380 # unique, or there'll be a cache conflict.
381 # The easy way is:
382 # cached="True" cache_region="local" cache_key="${self.filename}"
383 # ^^^^^^^^^^^^^^^^
384 # No!
385 # ... with ${self.filename} you can get an inheritance deadlock:
386 # See https://bitbucket.org/zzzeek/mako/issues/269/inheritance-related-cache-deadlock-when # noqa
387 #
388 # HOWEVER, note also: it is the CONTENT that is cached. You can cause some
389 # right disasters with this. Only stuff producing entirely STATIC content
390 # should be cached. "base.mako" isn't static - it calls back to its
391 # children; and if you cache it, one request produces results for an
392 # entirely different request. Similarly for lots of other things like
393 # "task.mako".
394 # SO, THERE IS NOT MUCH REASON HERE TO USE TEMPLATE CACHING.
395)
398class CamcopsMakoLookupTemplateRenderer(MakoLookupTemplateRenderer):
399 r"""
400 A Mako template renderer that, when called:
402 (a) loads the Mako template
403 (b) shoves any other keys we specify into its dictionary
405 Typical incoming parameters look like:
407 .. code-block:: none
409 spec = 'some_template.mako'
410 value = {'comment': None}
411 system = {
412 'context': <pyramid.traversal.DefaultRootFactory ...>,
413 'get_csrf_token': functools.partial(<function get_csrf_token ... >, ...>),
414 'renderer_info': <pyramid.renderers.RendererHelper ...>,
415 'renderer_name': 'some_template.mako',
416 'req': <CamcopsRequest ...>,
417 'request': <CamcopsRequest ...>,
418 'view': None
419 }
421 Showing the incoming call stack info (see commented-out code) indicates
422 that ``req`` and ``request`` (etc.) join at, and are explicitly introduced
423 by, :func:`pyramid.renderers.render`. That function includes this code:
425 .. code-block:: python
427 if system_values is None:
428 system_values = {
429 'view':None,
430 'renderer_name':self.name, # b/c
431 'renderer_info':self,
432 'context':getattr(request, 'context', None),
433 'request':request,
434 'req':request,
435 'get_csrf_token':partial(get_csrf_token, request),
436 }
438 So that means, for example, that ``req`` and ``request`` are both always
439 present in Mako templates as long as the ``request`` parameter was passed
440 to :func:`pyramid.renderers.render_to_response`.
442 What about a view configured with ``@view_config(...,
443 renderer="somefile.mako")``? Yes, that too (and anything included via
444 ``<%include file="otherfile.mako"/>``).
446 However, note that ``req`` and ``request`` are only available in the Mako
447 evaluation blocks, e.g. via ``${req.someattr}`` or via Python blocks like
448 ``<% %>`` -- not via Python blocks like ``<%! %>``, because the actual
449 Python generated by a Mako template like this:
451 .. code-block:: none
453 ## db_user_info.mako
454 <%page args="offer_main_menu=False"/>
456 <%!
457 module_level_thing = context.kwargs # module-level block; will crash
458 %>
460 <%
461 thing = context.kwargs["request"] # normal Python block; works
462 %>
464 <div>
465 Database: <b>${ request.database_title | h }</b>.
466 %if request.camcops_session.username:
467 Logged in as <b>${request.camcops_session.username | h}</b>.
468 %endif
469 %if offer_main_menu:
470 <%include file="to_main_menu.mako"/>
471 %endif
472 </div>
474 looks like this:
476 .. code-block:: python
478 from mako import runtime, filters, cache
479 UNDEFINED = runtime.UNDEFINED
480 STOP_RENDERING = runtime.STOP_RENDERING
481 __M_dict_builtin = dict
482 __M_locals_builtin = locals
483 _magic_number = 10
484 _modified_time = 1557179054.2796485
485 _enable_loop = True
486 _template_filename = '...' # edited
487 _template_uri = 'db_user_info.mako'
488 _source_encoding = 'utf-8'
489 _exports = []
491 module_level_thing = context.kwargs # module-level block; will crash
493 def render_body(context,offer_main_menu=False,**pageargs):
494 __M_caller = context.caller_stack._push_frame()
495 try:
496 __M_locals = __M_dict_builtin(offer_main_menu=offer_main_menu,pageargs=pageargs)
497 request = context.get('request', UNDEFINED)
498 __M_writer = context.writer()
499 __M_writer('\n\n')
500 __M_writer('\n\n')
502 thing = context.kwargs["request"] # normal Python block; works
504 __M_locals_builtin_stored = __M_locals_builtin()
505 __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]))
506 __M_writer('\n\n<div>\n Database: <b>')
507 __M_writer(filters.html_escape(str( request.database_title )))
508 __M_writer('</b>.\n')
509 if request.camcops_session.username:
510 __M_writer(' Logged in as <b>')
511 __M_writer(filters.html_escape(str(request.camcops_session.username )))
512 __M_writer('</b>.\n')
513 if offer_main_menu:
514 __M_writer(' ')
515 runtime._include_file(context, 'to_main_menu.mako', _template_uri)
516 __M_writer('\n')
517 __M_writer('</div>\n')
518 return ''
519 finally:
520 context.caller_stack._pop_frame()
522 '''
523 __M_BEGIN_METADATA
524 {"filename": ...} # edited
525 __M_END_METADATA
526 '''
528 """ # noqa
530 def __call__(self, value: Dict[str, Any], system: Dict[str, Any]) -> str:
531 if DEBUG_TEMPLATE_PARAMETERS:
532 log.debug("spec: {!r}", self.spec)
533 log.debug("value: {}", pprint.pformat(value))
534 log.debug("system: {}", pprint.pformat(system))
535 # log.debug("\n{}", "\n ".join(get_caller_stack_info()))
537 # ---------------------------------------------------------------------
538 # RNC extra values:
539 # ---------------------------------------------------------------------
540 # Note that <%! ... %> Python blocks are not themselves inherited.
541 # So putting "import" calls in base.mako doesn't deliver the following
542 # as ever-present variable. Instead, plumb them in like this:
543 #
544 # system['Routes'] = Routes
545 # system['ViewArg'] = ViewArg
546 # system['ViewParam'] = ViewParam
547 #
548 # ... except that we're better off with an import in the template
550 # Update the system dictionary with the values from the user
551 try:
552 system.update(value)
553 except (TypeError, ValueError):
554 raise ValueError("renderer was passed non-dictionary as value")
556 # Add the special "_" translation function
557 request = system["request"] # type: CamcopsRequest
558 system["_"] = request.gettext
560 # Check if 'context' in the dictionary
561 context = system.pop("context", None)
563 # Rename 'context' to '_context' because Mako internally already has a
564 # variable named 'context'
565 if context is not None:
566 system["_context"] = context
568 template = self.template
569 if self.defname is not None:
570 template = template.get_def(self.defname)
571 # noinspection PyBroadException
572 try:
573 if DEBUG_TEMPLATE_PARAMETERS:
574 log.debug("final dict to template: {}", pprint.pformat(system))
575 result = template.render_unicode(**system)
576 except Exception:
577 try:
578 exc_info = sys.exc_info()
579 errtext = text_error_template().render(
580 error=exc_info[1], traceback=exc_info[2]
581 )
582 reraise(MakoRenderingException(errtext), None, exc_info[2])
583 finally:
584 # noinspection PyUnboundLocalVariable
585 del exc_info
587 # noinspection PyUnboundLocalVariable
588 return result
591class CamcopsMakoRendererFactory(MakoRendererFactory):
592 """
593 A Mako renderer factory to use :class:`CamcopsMakoLookupTemplateRenderer`.
594 """
596 # noinspection PyTypeChecker
597 renderer_factory = staticmethod(CamcopsMakoLookupTemplateRenderer)
600def camcops_add_mako_renderer(config: Configurator, extension: str) -> None:
601 """
602 Registers a renderer factory for a given template file type.
604 Replacement for :func:`add_mako_renderer` from ``pyramid_mako``, so we can
605 use our own lookup.
607 The ``extension`` parameter is a filename extension (e.g. ".mako").
608 """
609 renderer_factory = CamcopsMakoRendererFactory() # our special function
610 renderer_factory.lookup = MAKO_LOOKUP # our lookup information
611 config.add_renderer(extension, renderer_factory) # a Pyramid function
614# =============================================================================
615# URL/route helpers
616# =============================================================================
618RE_VALID_REPLACEMENT_MARKER = re.compile("^[a-zA-Z_][a-zA-Z0-9_]*$")
619# All characters must be a-z, A-Z, _, or 0-9.
620# First character must not be a digit.
621# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/urldispatch.html#route-pattern-syntax # noqa
624def valid_replacement_marker(marker: str) -> bool:
625 """
626 Is a string suitable for use as a parameter name in a templatized URL?
628 (That is: is it free of odd characters?)
630 See :class:`UrlParam`.
631 """
632 return RE_VALID_REPLACEMENT_MARKER.match(marker) is not None
635class UrlParamType(Enum):
636 """
637 Enum for building templatized URLs.
638 See :class:`UrlParam`.
639 """
641 STRING = 1
642 POSITIVE_INTEGER = 2
643 PLAIN_STRING = 3
646class UrlParam(object):
647 """
648 Represents a parameter within a URL. For example:
650 .. code-block:: python
652 from camcops_server.cc_modules.cc_pyramid import *
653 p = UrlParam("patient_id", UrlParamType.POSITIVE_INTEGER)
654 p.markerdef() # '{patient_id:\\d+}'
656 These fragments are suitable for building into a URL for use with Pyramid's
657 URL Dispatch system:
658 https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/urldispatch.html
660 See also :class:`RoutePath`.
662 """ # noqa
664 def __init__(
665 self, name: str, paramtype: UrlParamType == UrlParamType.PLAIN_STRING
666 ) -> None:
667 """
668 Args:
669 name: the name of the parameter
670 paramtype: the type (e.g. string? positive integer), defined via
671 the :class:`UrlParamType` enum.
672 """
673 self.name = name
674 self.paramtype = paramtype
675 assert valid_replacement_marker(
676 name
677 ), "UrlParam: invalid replacement marker: " + repr(name)
679 def regex(self) -> str:
680 """
681 Returns text for a regular expression to capture the parameter value.
682 """
683 if self.paramtype == UrlParamType.STRING:
684 return ""
685 elif self.paramtype == UrlParamType.POSITIVE_INTEGER:
686 return r"\d+" # digits
687 elif self.paramtype == UrlParamType.PLAIN_STRING:
688 return r"[a-zA-Z0-9_]+"
689 else:
690 raise AssertionError("Bug in UrlParam")
692 def markerdef(self) -> str:
693 """
694 Returns the string to use in building the URL.
695 """
696 marker = self.name
697 r = self.regex()
698 if r:
699 marker += ":" + r
700 return "{" + marker + "}"
703def make_url_path(base: str, *args: UrlParam) -> str:
704 """
705 Makes a URL path for use with the Pyramid URL dispatch system.
706 See :class:`UrlParam`.
708 Args:
709 base: the base path, to which we will append parameter templates
710 *args: a number of :class:`UrlParam` objects.
712 Returns:
713 the URL path, beginning with ``/``
714 """
715 parts = [] # type: List[str]
716 if not base.startswith("/"):
717 parts.append("/")
718 parts += [base] + [arg.markerdef() for arg in args]
719 return "/".join(parts)
722# =============================================================================
723# Routes
724# =============================================================================
726# Class to collect constants together
727# See also http://xion.io/post/code/python-enums-are-ok.html
728class Routes(object):
729 """
730 Names of Pyramid routes.
732 - Used by the ``@view_config(route_name=...)`` decorator.
733 - Configured via :class:`RouteCollection` / :class:`RoutePath` to the
734 Pyramid route configurator.
735 """
737 # Hard-coded special paths
738 STATIC = "static"
740 # Other
741 ADD_GROUP = "add_group"
742 ADD_ID_DEFINITION = "add_id_definition"
743 ADD_PATIENT = "add_patient"
744 ADD_SPECIAL_NOTE = "add_special_note"
745 ADD_TASK_SCHEDULE = "add_task_schedule"
746 ADD_TASK_SCHEDULE_ITEM = "add_task_schedule_item"
747 ADD_USER = "add_user"
748 AUDIT_MENU = "audit_menu"
749 BASIC_DUMP = "basic_dump"
750 CHANGE_OTHER_PASSWORD = "change_other_password"
751 CHANGE_OWN_PASSWORD = "change_own_password"
752 CHOOSE_CTV = "choose_ctv"
753 CHOOSE_TRACKER = "choose_tracker"
754 CLIENT_API = "client_api"
755 CLIENT_API_ALIAS = "client_api_alias"
756 CRASH = "crash"
757 CTV = "ctv"
758 DELETE_FILE = "delete_file"
759 DELETE_GROUP = "delete_group"
760 DELETE_ID_DEFINITION = "delete_id_definition"
761 DELETE_PATIENT = "delete_patient"
762 DELETE_SERVER_CREATED_PATIENT = "delete_server_created_patient"
763 DELETE_SPECIAL_NOTE = "delete_special_note"
764 DELETE_TASK_SCHEDULE = "delete_task_schedule"
765 DELETE_TASK_SCHEDULE_ITEM = "delete_task_schedule_item"
766 DELETE_USER = "delete_user"
767 DEVELOPER = "developer"
768 DOWNLOAD_AREA = "download_area"
769 DOWNLOAD_FILE = "download_file"
770 EDIT_GROUP = "edit_group"
771 EDIT_ID_DEFINITION = "edit_id_definition"
772 EDIT_FINALIZED_PATIENT = "edit_finalized_patient"
773 EDIT_OTHER_USER_MFA = "edit_other_user_mfa"
774 EDIT_OWN_USER_MFA = "edit_own_user_mfa"
775 EDIT_SERVER_CREATED_PATIENT = "edit_server_created_patient"
776 EDIT_SERVER_SETTINGS = "edit_server_settings"
777 EDIT_TASK_SCHEDULE = "edit_task_schedule"
778 EDIT_TASK_SCHEDULE_ITEM = "edit_task_schedule_item"
779 EDIT_USER = "edit_user"
780 EDIT_USER_AUTHENTICATION = "edit_user_authentication"
781 EDIT_USER_GROUP_MEMBERSHIP = "edit_user_group_membership"
782 ERASE_TASK_LEAVING_PLACEHOLDER = "erase_task_leaving_placeholder"
783 ERASE_TASK_ENTIRELY = "erase_task_entirely"
784 FHIR_CONDITION = "fhir_condition"
785 FHIR_DOCUMENT_REFERENCE = "fhir_document_reference"
786 FHIR_OBSERVATION = "fhir_observation"
787 FHIR_PATIENT_ID_SYSTEM = "fhir_patient_id_system"
788 FHIR_PRACTITIONER = "fhir_practitioner"
789 FHIR_QUESTIONNAIRE_SYSTEM = "fhir_questionnaire"
790 FHIR_QUESTIONNAIRE_RESPONSE = "fhir_questionnaire_response"
791 FHIR_TABLENAME_PK_ID = "fhir_tablename_pk_id"
792 FORCIBLY_FINALIZE = "forcibly_finalize"
793 HOME = "home"
794 LOGIN = "login"
795 LOGOUT = "logout"
796 OFFER_AUDIT_TRAIL = "offer_audit_trail"
797 OFFER_EXPORTED_TASK_LIST = "offer_exported_task_list"
798 OFFER_REGENERATE_SUMMARIES = "offer_regenerate_summary_tables"
799 OFFER_REPORT = "offer_report"
800 OFFER_SQL_DUMP = "offer_sql_dump"
801 OFFER_TERMS = "offer_terms"
802 OFFER_BASIC_DUMP = "offer_basic_dump"
803 REPORT = "report"
804 REPORTS_MENU = "reports_menu"
805 SEND_EMAIL_FROM_PATIENT_LIST = "send_email_from_patient_list"
806 SEND_EMAIL_FROM_PATIENT_TASK_SCHEDULE = (
807 "send_email_from_patient_task_schedule"
808 )
809 SET_FILTERS = "set_filters"
810 SET_OTHER_USER_UPLOAD_GROUP = "set_other_user_upload_group"
811 SET_OWN_USER_UPLOAD_GROUP = "set_user_upload_group"
812 SQL_DUMP = "sql_dump"
813 TASK = "task"
814 TASK_DETAILS = "task_details"
815 TASK_LIST = "task_list"
816 TEST_NHS_NUMBERS = "test_nhs_numbers"
817 TESTPAGE_PRIVATE_1 = "testpage_private_1"
818 TESTPAGE_PRIVATE_2 = "testpage_private_2"
819 TESTPAGE_PRIVATE_3 = "testpage_private_3"
820 TESTPAGE_PRIVATE_4 = "testpage_private_4"
821 TESTPAGE_PUBLIC_1 = "testpage_public_1"
822 TRACKER = "tracker"
823 UNLOCK_USER = "unlock_user"
824 VIEW_ALL_USERS = "view_all_users"
825 VIEW_AUDIT_TRAIL = "view_audit_trail"
826 VIEW_DDL = "view_ddl"
827 VIEW_EMAIL = "view_email"
828 VIEW_EXPORT_RECIPIENT = "view_export_recipient"
829 VIEW_EXPORTED_TASK = "view_exported_task"
830 VIEW_EXPORTED_TASK_LIST = "view_exported_task_list"
831 VIEW_EXPORTED_TASK_EMAIL = "view_exported_task_email"
832 VIEW_EXPORTED_TASK_FHIR = "view_exported_task_fhir"
833 VIEW_EXPORTED_TASK_FHIR_ENTRY = "view_exported_task_fhir_entry"
834 VIEW_EXPORTED_TASK_FILE_GROUP = "view_exported_task_file_group"
835 VIEW_EXPORTED_TASK_HL7_MESSAGE = "view_exported_task_hl7_message"
836 VIEW_EXPORTED_TASK_REDCAP = "view_exported_task_redcap"
837 VIEW_GROUPS = "view_groups"
838 VIEW_ID_DEFINITIONS = "view_id_definitions"
839 VIEW_OWN_USER_INFO = "view_own_user_info"
840 VIEW_PATIENT_TASK_SCHEDULE = "view_patient_task_schedule"
841 VIEW_PATIENT_TASK_SCHEDULES = "view_patient_task_schedules"
842 VIEW_SERVER_INFO = "view_server_info"
843 VIEW_TASKS = "view_tasks"
844 VIEW_TASK_SCHEDULES = "view_task_schedules"
845 VIEW_TASK_SCHEDULE_ITEMS = "view_task_schedule_items"
846 VIEW_USER = "view_user"
847 VIEW_USER_EMAIL_ADDRESSES = "view_user_email_addresses"
848 XLSX_DUMP = "xlsx_dump"
851class RoutePath(object):
852 r"""
853 Class to hold a route/path pair.
855 - Pyramid route names are just strings used internally for convenience.
857 - Pyramid URL paths are URL fragments, like ``'/thing'``, and can contain
858 placeholders, like ``'/thing/{bork_id}'``, which will result in the
859 ``request.matchdict`` object containing a ``'bork_id'`` key. Those can be
860 further constrained by regular expressions, like
861 ``'/thing/{bork_id:\d+}'`` to restrict to digits. See
862 https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/urldispatch.html
864 """ # noqa
866 def __init__(
867 self,
868 route: str,
869 path: str = "",
870 ignore_in_all_routes: bool = False,
871 pregenerator: Callable = None,
872 ) -> None:
873 self.route = route
874 self.path = path or "/" + route
875 self.ignore_in_all_routes = ignore_in_all_routes
876 self.pregenerator = pregenerator
879MASTER_ROUTE_WEBVIEW = "/"
880MASTER_ROUTE_CLIENT_API = "/api"
881MASTER_ROUTE_CLIENT_API_ALIAS = "/database"
883STATIC_CAMCOPS_PACKAGE_PATH = "camcops_server.static:"
884# ... the "static" package (directory with __init__.py) within the
885# "camcops_server" owning package
886STATIC_BOOTSTRAP_ICONS_PATH = (
887 STATIC_CAMCOPS_PACKAGE_PATH + "bootstrap-icons-1.7.0"
888)
891# noinspection PyUnusedLocal
892def pregen_for_fhir(request: Request, elements: Tuple, kw: Dict) -> Tuple:
893 """
894 Pyramid pregenerator, to pre-populate an optional URL keyword (with an
895 empty string, as it happens). See
897 - https://stackoverflow.com/questions/42193305/optional-url-parameter-on-pyramid-route
898 - https://docs.pylonsproject.org/projects/pyramid/en/latest/api/config.html
899 - https://docs.pylonsproject.org/projects/pyramid/en/latest/api/interfaces.html#pyramid.interfaces.IRoutePregenerator
900 """ # noqa
901 kw.setdefault("fhirvalue_with_bar", "")
902 return elements, kw
905def _mk_fhir_optional_value_suffix_route(
906 route: str, path: str = ""
907) -> RoutePath:
908 path = path or "/" + route
909 path_with_optional_value = path + r"{fhirvalue_with_bar:(\|[\w\d/\.]+)?}"
910 # ... allow, optionally, a bar followed by one or more word, digit,
911 # forward slash, or period characters.
912 # This allows FHIR identifier suffixes like path|table/2.4.11
913 return RoutePath(
914 route, path_with_optional_value, pregenerator=pregen_for_fhir
915 )
918def _mk_fhir_tablename_route(route: str) -> RoutePath:
919 return _mk_fhir_optional_value_suffix_route(
920 route, f"/{route}" rf"/{{{ViewParam.TABLE_NAME}:\w+}}"
921 )
924def _mk_fhir_tablename_pk_route(route: str) -> RoutePath:
925 return _mk_fhir_optional_value_suffix_route(
926 route,
927 f"/{route}"
928 rf"/{{{ViewParam.TABLE_NAME}:\w+}}"
929 rf"/{{{ViewParam.SERVER_PK}:\d+}}",
930 )
933class RouteCollection(object):
934 """
935 All routes, with their paths, for CamCOPS.
936 They will be auto-read by :func:`all_routes`.
938 To make a URL on the fly, use :func:`Request.route_url` or
939 :func:`CamcopsRequest.route_url_params`.
941 To associate a view with a route, use the Pyramid ``@view_config``
942 decorator.
943 """
945 # Hard-coded special paths
946 DEBUG_TOOLBAR = RoutePath(
947 "debug_toolbar", "/_debug_toolbar/", ignore_in_all_routes=True
948 ) # hard-coded path
949 STATIC = RoutePath(
950 Routes.STATIC, "", ignore_in_all_routes=True # path ignored
951 )
953 # Implemented
954 ADD_GROUP = RoutePath(Routes.ADD_GROUP)
955 ADD_ID_DEFINITION = RoutePath(Routes.ADD_ID_DEFINITION)
956 ADD_PATIENT = RoutePath(Routes.ADD_PATIENT)
957 ADD_SPECIAL_NOTE = RoutePath(Routes.ADD_SPECIAL_NOTE)
958 ADD_TASK_SCHEDULE = RoutePath(Routes.ADD_TASK_SCHEDULE)
959 ADD_TASK_SCHEDULE_ITEM = RoutePath(Routes.ADD_TASK_SCHEDULE_ITEM)
960 ADD_USER = RoutePath(Routes.ADD_USER)
961 AUDIT_MENU = RoutePath(Routes.AUDIT_MENU)
962 BASIC_DUMP = RoutePath(Routes.BASIC_DUMP)
963 CHANGE_OTHER_PASSWORD = RoutePath(Routes.CHANGE_OTHER_PASSWORD)
964 CHANGE_OWN_PASSWORD = RoutePath(Routes.CHANGE_OWN_PASSWORD)
965 CHOOSE_CTV = RoutePath(Routes.CHOOSE_CTV)
966 CHOOSE_TRACKER = RoutePath(Routes.CHOOSE_TRACKER)
967 CLIENT_API = RoutePath(Routes.CLIENT_API, MASTER_ROUTE_CLIENT_API)
968 CLIENT_API_ALIAS = RoutePath(
969 Routes.CLIENT_API_ALIAS, MASTER_ROUTE_CLIENT_API_ALIAS
970 )
971 CRASH = RoutePath(Routes.CRASH)
972 CTV = RoutePath(Routes.CTV)
973 DELETE_FILE = RoutePath(Routes.DELETE_FILE)
974 DELETE_GROUP = RoutePath(Routes.DELETE_GROUP)
975 DELETE_ID_DEFINITION = RoutePath(Routes.DELETE_ID_DEFINITION)
976 DELETE_PATIENT = RoutePath(Routes.DELETE_PATIENT)
977 DELETE_SERVER_CREATED_PATIENT = RoutePath(
978 Routes.DELETE_SERVER_CREATED_PATIENT
979 )
980 DELETE_SPECIAL_NOTE = RoutePath(Routes.DELETE_SPECIAL_NOTE)
981 DELETE_TASK_SCHEDULE = RoutePath(Routes.DELETE_TASK_SCHEDULE)
982 DELETE_TASK_SCHEDULE_ITEM = RoutePath(Routes.DELETE_TASK_SCHEDULE_ITEM)
983 DELETE_USER = RoutePath(Routes.DELETE_USER)
984 DEVELOPER = RoutePath(Routes.DEVELOPER)
985 DOWNLOAD_AREA = RoutePath(Routes.DOWNLOAD_AREA)
986 DOWNLOAD_FILE = RoutePath(Routes.DOWNLOAD_FILE)
987 EDIT_GROUP = RoutePath(Routes.EDIT_GROUP)
988 EDIT_ID_DEFINITION = RoutePath(Routes.EDIT_ID_DEFINITION)
989 EDIT_FINALIZED_PATIENT = RoutePath(Routes.EDIT_FINALIZED_PATIENT)
990 EDIT_OTHER_USER_MFA = RoutePath(Routes.EDIT_OTHER_USER_MFA)
991 EDIT_OWN_USER_MFA = RoutePath(Routes.EDIT_OWN_USER_MFA)
992 EDIT_SERVER_CREATED_PATIENT = RoutePath(Routes.EDIT_SERVER_CREATED_PATIENT)
993 EDIT_SERVER_SETTINGS = RoutePath(Routes.EDIT_SERVER_SETTINGS)
994 EDIT_TASK_SCHEDULE = RoutePath(Routes.EDIT_TASK_SCHEDULE)
995 EDIT_TASK_SCHEDULE_ITEM = RoutePath(Routes.EDIT_TASK_SCHEDULE_ITEM)
996 EDIT_USER = RoutePath(Routes.EDIT_USER)
997 EDIT_USER_AUTHENTICATION = RoutePath(Routes.EDIT_USER_AUTHENTICATION)
998 EDIT_USER_GROUP_MEMBERSHIP = RoutePath(Routes.EDIT_USER_GROUP_MEMBERSHIP)
999 ERASE_TASK_LEAVING_PLACEHOLDER = RoutePath(
1000 Routes.ERASE_TASK_LEAVING_PLACEHOLDER
1001 )
1002 ERASE_TASK_ENTIRELY = RoutePath(Routes.ERASE_TASK_ENTIRELY)
1004 FHIR_CONDITION = _mk_fhir_tablename_pk_route(Routes.FHIR_CONDITION)
1005 FHIR_DOCUMENT_REFERENCE = _mk_fhir_tablename_pk_route(
1006 Routes.FHIR_DOCUMENT_REFERENCE
1007 )
1008 FHIR_OBSERVATION = _mk_fhir_tablename_pk_route(Routes.FHIR_OBSERVATION)
1009 FHIR_PATIENT_ID_SYSTEM = _mk_fhir_optional_value_suffix_route(
1010 Routes.FHIR_PATIENT_ID_SYSTEM,
1011 f"/{Routes.FHIR_PATIENT_ID_SYSTEM}"
1012 rf"/{{{ViewParam.WHICH_IDNUM}:\d+}}",
1013 )
1014 FHIR_PRACTITIONER = _mk_fhir_tablename_pk_route(Routes.FHIR_PRACTITIONER)
1015 FHIR_QUESTIONNAIRE_SYSTEM = _mk_fhir_optional_value_suffix_route(
1016 Routes.FHIR_QUESTIONNAIRE_SYSTEM
1017 )
1018 FHIR_QUESTIONNAIRE_RESPONSE = _mk_fhir_tablename_pk_route(
1019 Routes.FHIR_QUESTIONNAIRE_RESPONSE
1020 )
1021 FHIR_TABLENAME_PK_ID = _mk_fhir_tablename_pk_route(
1022 Routes.FHIR_TABLENAME_PK_ID
1023 )
1025 FORCIBLY_FINALIZE = RoutePath(Routes.FORCIBLY_FINALIZE)
1026 HOME = RoutePath(Routes.HOME, MASTER_ROUTE_WEBVIEW) # mounted at "/"
1027 LOGIN = RoutePath(Routes.LOGIN)
1028 LOGOUT = RoutePath(Routes.LOGOUT)
1029 OFFER_AUDIT_TRAIL = RoutePath(Routes.OFFER_AUDIT_TRAIL)
1030 OFFER_EXPORTED_TASK_LIST = RoutePath(Routes.OFFER_EXPORTED_TASK_LIST)
1031 OFFER_REPORT = RoutePath(Routes.OFFER_REPORT)
1032 OFFER_SQL_DUMP = RoutePath(Routes.OFFER_SQL_DUMP)
1033 OFFER_TERMS = RoutePath(Routes.OFFER_TERMS)
1034 OFFER_BASIC_DUMP = RoutePath(Routes.OFFER_BASIC_DUMP)
1035 REPORT = RoutePath(Routes.REPORT)
1036 REPORTS_MENU = RoutePath(Routes.REPORTS_MENU)
1037 SEND_EMAIL_FROM_PATIENT_LIST = RoutePath(
1038 Routes.SEND_EMAIL_FROM_PATIENT_LIST
1039 )
1040 SEND_EMAIL_FROM_PATIENT_TASK_SCHEDULE = RoutePath(
1041 Routes.SEND_EMAIL_FROM_PATIENT_TASK_SCHEDULE
1042 )
1043 SET_FILTERS = RoutePath(Routes.SET_FILTERS)
1044 SET_OTHER_USER_UPLOAD_GROUP = RoutePath(Routes.SET_OTHER_USER_UPLOAD_GROUP)
1045 SET_OWN_USER_UPLOAD_GROUP = RoutePath(Routes.SET_OWN_USER_UPLOAD_GROUP)
1046 SQL_DUMP = RoutePath(Routes.SQL_DUMP)
1047 TASK = RoutePath(Routes.TASK)
1048 TASK_DETAILS = RoutePath(
1049 Routes.TASK_DETAILS,
1050 rf"/{Routes.TASK_DETAILS}/{{{ViewParam.TABLE_NAME}}}",
1051 )
1052 TASK_LIST = RoutePath(Routes.TASK_LIST)
1053 TEST_NHS_NUMBERS = RoutePath(Routes.TEST_NHS_NUMBERS)
1054 TESTPAGE_PRIVATE_1 = RoutePath(Routes.TESTPAGE_PRIVATE_1)
1055 TESTPAGE_PRIVATE_2 = RoutePath(Routes.TESTPAGE_PRIVATE_2)
1056 TESTPAGE_PRIVATE_3 = RoutePath(Routes.TESTPAGE_PRIVATE_3)
1057 TESTPAGE_PRIVATE_4 = RoutePath(Routes.TESTPAGE_PRIVATE_4)
1058 TESTPAGE_PUBLIC_1 = RoutePath(Routes.TESTPAGE_PUBLIC_1)
1059 TRACKER = RoutePath(Routes.TRACKER)
1060 UNLOCK_USER = RoutePath(Routes.UNLOCK_USER)
1061 VIEW_ALL_USERS = RoutePath(Routes.VIEW_ALL_USERS)
1062 VIEW_AUDIT_TRAIL = RoutePath(Routes.VIEW_AUDIT_TRAIL)
1063 VIEW_DDL = RoutePath(Routes.VIEW_DDL)
1064 VIEW_EMAIL = RoutePath(Routes.VIEW_EMAIL)
1065 VIEW_EXPORT_RECIPIENT = RoutePath(Routes.VIEW_EXPORT_RECIPIENT)
1066 VIEW_EXPORTED_TASK = RoutePath(Routes.VIEW_EXPORTED_TASK)
1067 VIEW_EXPORTED_TASK_LIST = RoutePath(Routes.VIEW_EXPORTED_TASK_LIST)
1068 VIEW_EXPORTED_TASK_EMAIL = RoutePath(Routes.VIEW_EXPORTED_TASK_EMAIL)
1069 VIEW_EXPORTED_TASK_FHIR = RoutePath(Routes.VIEW_EXPORTED_TASK_FHIR)
1070 VIEW_EXPORTED_TASK_FHIR_ENTRY = RoutePath(
1071 Routes.VIEW_EXPORTED_TASK_FHIR_ENTRY
1072 )
1073 VIEW_EXPORTED_TASK_FILE_GROUP = RoutePath(
1074 Routes.VIEW_EXPORTED_TASK_FILE_GROUP
1075 )
1076 VIEW_EXPORTED_TASK_HL7_MESSAGE = RoutePath(
1077 Routes.VIEW_EXPORTED_TASK_HL7_MESSAGE
1078 )
1079 VIEW_EXPORTED_TASK_REDCAP = RoutePath(Routes.VIEW_EXPORTED_TASK_REDCAP)
1080 VIEW_GROUPS = RoutePath(Routes.VIEW_GROUPS)
1081 VIEW_ID_DEFINITIONS = RoutePath(Routes.VIEW_ID_DEFINITIONS)
1082 VIEW_OWN_USER_INFO = RoutePath(Routes.VIEW_OWN_USER_INFO)
1083 VIEW_PATIENT_TASK_SCHEDULE = RoutePath(Routes.VIEW_PATIENT_TASK_SCHEDULE)
1084 VIEW_PATIENT_TASK_SCHEDULES = RoutePath(Routes.VIEW_PATIENT_TASK_SCHEDULES)
1085 VIEW_SERVER_INFO = RoutePath(Routes.VIEW_SERVER_INFO)
1086 VIEW_TASKS = RoutePath(Routes.VIEW_TASKS)
1087 VIEW_TASK_SCHEDULES = RoutePath(Routes.VIEW_TASK_SCHEDULES)
1088 VIEW_TASK_SCHEDULE_ITEMS = RoutePath(Routes.VIEW_TASK_SCHEDULE_ITEMS)
1089 VIEW_USER = RoutePath(Routes.VIEW_USER)
1090 VIEW_USER_EMAIL_ADDRESSES = RoutePath(Routes.VIEW_USER_EMAIL_ADDRESSES)
1091 XLSX_DUMP = RoutePath(Routes.XLSX_DUMP)
1093 @classmethod
1094 def all_routes(cls) -> List[RoutePath]:
1095 """
1096 Fetch all routes for CamCOPS.
1097 """
1098 return [
1099 v
1100 for k, v in cls.__dict__.items()
1101 if not (
1102 k.startswith("_")
1103 or k == "all_routes" # class hidden things
1104 or v.ignore_in_all_routes # this function
1105 ) # explicitly ignored
1106 ]
1109# =============================================================================
1110# Pyramid HTTP session handling
1111# =============================================================================
1114def get_session_factory() -> Callable[["CamcopsRequest"], ISession]:
1115 """
1116 We have to give a Pyramid request a way of making an HTTP session.
1117 We must return a session factory.
1119 - An example is in :class:`pyramid.session.SignedCookieSessionFactory`.
1120 - A session factory has the signature [1]:
1122 .. code-block:: none
1124 sessionfactory(req: CamcopsRequest) -> session_object
1126 - ... where session "is a namespace" [2]
1127 - ... but more concretely, "implements the pyramid.interfaces.ISession
1128 interface"
1130 - We want to be able to make the session by reading the
1131 :class:`camcops_server.cc_modules.cc_config.CamcopsConfig` from the request.
1133 [1] https://docs.pylonsproject.org/projects/pyramid/en/latest/glossary.html#term-session-factory
1135 [2] https://docs.pylonsproject.org/projects/pyramid/en/latest/glossary.html#term-session
1136 """ # noqa
1138 def factory(req: "CamcopsRequest") -> ISession:
1139 """
1140 How does the session write the cookies to the response? Like this:
1142 .. code-block:: none
1144 SignedCookieSessionFactory
1145 BaseCookieSessionFactory # pyramid/session.py
1146 CookieSession
1147 def changed():
1148 if not self._dirty:
1149 self._dirty = True
1150 def set_cookie_callback(request, response):
1151 self._set_cookie(response)
1152 # ...
1153 self.request.add_response_callback(set_cookie_callback)
1155 def _set_cookie(self, response):
1156 # ...
1157 response.set_cookie(...)
1159 """ # noqa
1160 cfg = req.config
1161 secure_cookies = not cfg.allow_insecure_cookies
1162 pyramid_factory = SignedCookieSessionFactory(
1163 secret=cfg.session_cookie_secret,
1164 hashalg="sha512", # the default
1165 salt="camcops_pyramid_session.",
1166 cookie_name=COOKIE_NAME,
1167 max_age=None, # browser scope; session cookie
1168 path="/", # the default
1169 domain=None, # the default
1170 secure=secure_cookies,
1171 httponly=secure_cookies,
1172 timeout=None, # we handle timeouts at the database level instead
1173 reissue_time=0, # default; reissue cookie at every request
1174 set_on_exception=True, # (default) cookie even if exception raised
1175 serializer=JSONSerializer(),
1176 # ... pyramid.session.PickleSerializer was the default but is
1177 # deprecated as of Pyramid 1.9; the default is
1178 # pyramid.session.JSONSerializer as of Pyramid 2.0.
1179 # As max_age and expires are left at their default of None, these
1180 # are session cookies.
1181 )
1182 return pyramid_factory(req)
1184 return factory
1187# =============================================================================
1188# Authentication; authorization (permissions)
1189# =============================================================================
1192class Permission(object):
1193 """
1194 Pyramid permission values.
1196 - Permissions are strings.
1197 - For "logged in", use ``pyramid.security.Authenticated``
1198 """
1200 GROUPADMIN = "groupadmin"
1201 HAPPY = "happy"
1202 # ... logged in, can use webview, no need to change p/w, agreed to terms,
1203 # a valid MFA method has been set.
1204 MUST_AGREE_TERMS = "must_agree_terms"
1205 MUST_CHANGE_PASSWORD = "must_change_password"
1206 MUST_SET_MFA = "must_set_mfa"
1207 SUPERUSER = "superuser"
1210@implementer(IAuthenticationPolicy)
1211class CamcopsAuthenticationPolicy(object):
1212 """
1213 CamCOPS authentication policy.
1215 See
1217 - https://docs.pylonsproject.org/projects/pyramid/en/latest/tutorials/wiki2/authorization.html
1218 - https://docs.pylonsproject.org/projects/pyramid-cookbook/en/latest/auth/custom.html
1219 - Don't actually inherit from :class:`IAuthenticationPolicy`; it ends up in
1220 the :class:`zope.interface.interface.InterfaceClass` metaclass and then
1221 breaks with "zope.interface.exceptions.InvalidInterface: Concrete
1222 attribute, ..."
1223 - But ``@implementer`` does the trick.
1224 """ # noqa
1226 @staticmethod
1227 def authenticated_userid(request: "CamcopsRequest") -> Optional[int]:
1228 """
1229 Returns the user ID of the authenticated user.
1230 """
1231 return request.user_id
1233 # noinspection PyUnusedLocal
1234 @staticmethod
1235 def unauthenticated_userid(request: "CamcopsRequest") -> Optional[int]:
1236 """
1237 Returns the user ID of the unauthenticated user.
1239 We don't allow users to be identified but not authenticated, so we
1240 return ``None``.
1241 """
1242 return None
1244 @staticmethod
1245 def effective_principals(request: "CamcopsRequest") -> List[str]:
1246 """
1247 Returns a list of strings indicating permissions that the current user
1248 has.
1249 """
1250 principals = [Everyone]
1251 user = request.user
1252 if user is not None:
1253 principals += [Authenticated, "u:%s" % user.id]
1254 if user.may_use_webviewer:
1255 if user.must_change_password:
1256 principals.append(Permission.MUST_CHANGE_PASSWORD)
1257 elif user.must_agree_terms:
1258 principals.append(Permission.MUST_AGREE_TERMS)
1259 elif user.must_set_mfa_method(request):
1260 principals.append(Permission.MUST_SET_MFA)
1261 else:
1262 principals.append(Permission.HAPPY)
1263 if user.superuser:
1264 principals.append(Permission.SUPERUSER)
1265 if user.authorized_as_groupadmin:
1266 principals.append(Permission.GROUPADMIN)
1267 # principals.extend(('g:%s' % g.name for g in user.groups))
1268 if DEBUG_EFFECTIVE_PRINCIPALS:
1269 log.debug("effective_principals: {!r}", principals)
1270 return principals
1272 # noinspection PyUnusedLocal
1273 @staticmethod
1274 def remember(
1275 request: "CamcopsRequest", userid: int, **kw
1276 ) -> List[Tuple[str, str]]:
1277 return []
1279 # noinspection PyUnusedLocal
1280 @staticmethod
1281 def forget(request: "CamcopsRequest") -> List[Tuple[str, str]]:
1282 return []
1285@implementer(IAuthorizationPolicy)
1286class CamcopsAuthorizationPolicy(object):
1287 """
1288 CamCOPS authorization policy.
1289 """
1291 # noinspection PyUnusedLocal
1292 @staticmethod
1293 def permits(
1294 context: ILocation, principals: List[str], permission: str
1295 ) -> PermitsResult:
1296 if permission in principals:
1297 return Allowed(
1298 f"ALLOWED: permission {permission} present in "
1299 f"principals {principals}"
1300 )
1302 return Denied(
1303 f"DENIED: permission {permission} not in principals "
1304 f"{principals}"
1305 )
1307 @staticmethod
1308 def principals_allowed_by_permission(
1309 context: ILocation, permission: str
1310 ) -> List[str]:
1311 raise NotImplementedError() # don't care about this method
1314# =============================================================================
1315# Icons
1316# =============================================================================
1319def icon_html(
1320 icon: str,
1321 alt: str,
1322 url: str = None,
1323 extra_classes: List[str] = None,
1324 extra_styles: List[str] = None,
1325 escape_alt: bool = True,
1326) -> str:
1327 """
1328 Instantiates a Bootstrap icon, usually with a hyperlink. Returns
1329 rendered HTML.
1331 Args:
1332 icon:
1333 Icon name, without ".svg" extension (or "bi-" prefix!).
1334 alt:
1335 Alternative text for image.
1336 url:
1337 Optional URL of hyperlink.
1338 extra_classes:
1339 Optional extra CSS classes for the icon.
1340 extra_styles:
1341 Optional extra CSS styles for the icon (each looks like:
1342 "color: blue").
1343 escape_alt:
1344 HTML-escape the alt text? Default is True.
1345 """
1346 # There are several ways to do this, such as via <img> tags, or via
1347 # web fonts.
1348 # We include bootstrap-icons.css (via base_web.mako), because that
1349 # allows the best resizing (relative to font size) and styling.
1350 # See:
1351 # - https://icons.getbootstrap.com/#usage
1352 # - http://johna.compoutpost.com/blog/1189/how-to-use-the-new-bootstrap-icons-v1-2-web-font/ # noqa
1353 if escape_alt:
1354 alt = html_escape(alt)
1355 i_components = ['role="img"', f'aria-label="{alt}"']
1356 css_classes = [f"bi-{icon}"] # bi = Bootstrap icon
1357 if extra_classes:
1358 css_classes += extra_classes
1359 class_str = " ".join(css_classes)
1360 i_components.append(f'class="{class_str}"')
1361 if extra_styles:
1362 style_str = "; ".join(extra_styles)
1363 i_components.append(f'style="{style_str}"')
1364 image = f'<i {" ".join(i_components)}></i>'
1365 if url:
1366 return f'<a href="{url}">{image}</a>'
1367 else:
1368 return image
1371def icon_text(
1372 icon: str,
1373 text: str,
1374 url: str = None,
1375 alt: str = None,
1376 extra_icon_classes: List[str] = None,
1377 extra_icon_styles: List[str] = None,
1378 extra_a_classes: List[str] = None,
1379 extra_a_styles: List[str] = None,
1380 escape_alt: bool = True,
1381 escape_text: bool = True,
1382 hyperlink_together: bool = False,
1383) -> str:
1384 """
1385 Provide an icon and accompanying text. Usually, both are hyperlinked
1386 (to the same destination URL). Returns rendered HTML.
1388 Args:
1389 icon:
1390 Icon name, without ".svg" extension.
1391 url:
1392 Optional URL of hyperlink.
1393 alt:
1394 Alternative text for image. Will default to the main text.
1395 text:
1396 Main text to display.
1397 extra_icon_classes:
1398 Optional extra CSS classes for the icon.
1399 extra_icon_styles:
1400 Optional extra CSS styles for the icon (each looks like:
1401 "color: blue").
1402 extra_a_classes:
1403 Optional extra CSS classes for the <a> element.
1404 extra_a_styles:
1405 Optional extra CSS styles for the <a> element.
1406 escape_alt:
1407 HTML-escape the alt text?
1408 escape_text:
1409 HTML-escape the main text?
1410 hyperlink_together:
1411 Hyperlink the image and text as one (rather than separately and
1412 adjacent to each other)?
1413 """
1414 i_html = icon_html(
1415 icon=icon,
1416 url=None if hyperlink_together else url,
1417 alt=alt or text,
1418 extra_classes=extra_icon_classes,
1419 extra_styles=extra_icon_styles,
1420 escape_alt=escape_alt,
1421 )
1422 if escape_text:
1423 text = html_escape(text)
1424 if url:
1425 a_components = [f'href="{url}"']
1426 if extra_a_classes:
1427 class_str = " ".join(extra_a_classes)
1428 a_components.append(f'class="{class_str}"')
1429 if extra_a_styles:
1430 style_str = "; ".join(extra_a_styles)
1431 a_components.append(f'style="{style_str}"')
1432 a_options = " ".join(a_components)
1433 if hyperlink_together:
1434 return f"<a {a_options}>{i_html} {text}</a>"
1435 else:
1436 return f"{i_html} <a {a_options}>{text}</a>"
1437 else:
1438 return f"{i_html} {text}"
1441def icons_text(
1442 icons: List[str],
1443 text: str,
1444 url: str = None,
1445 alt: str = None,
1446 extra_icon_classes: List[str] = None,
1447 extra_icon_styles: List[str] = None,
1448 extra_a_classes: List[str] = None,
1449 extra_a_styles: List[str] = None,
1450 escape_alt: bool = True,
1451 escape_text: bool = True,
1452 hyperlink_together: bool = False,
1453) -> str:
1454 """
1455 Multiple-icon version of :func:``icon_text``.
1456 """
1457 i_html = " ".join(
1458 icon_html(
1459 icon=icon,
1460 url=None if hyperlink_together else url,
1461 alt=alt or text,
1462 extra_classes=extra_icon_classes,
1463 extra_styles=extra_icon_styles,
1464 escape_alt=escape_alt,
1465 )
1466 for icon in icons
1467 )
1468 if escape_text:
1469 text = html_escape(text)
1470 if url:
1471 a_components = [f'href="{url}"']
1472 if extra_a_classes:
1473 class_str = " ".join(extra_a_classes)
1474 a_components.append(f'class="{class_str}"')
1475 if extra_a_styles:
1476 style_str = "; ".join(extra_a_styles)
1477 a_components.append(f'style="{style_str}"')
1478 a_options = " ".join(a_components)
1479 if hyperlink_together:
1480 return f"<a {a_options}>{i_html} {text}</a>"
1481 else:
1482 return f"{i_html} <a {a_options}>{text}</a>"
1483 else:
1484 return f"{i_html} {text}"
1487class Icons:
1488 """
1489 Constants for Bootstrap icons. See https://icons.getbootstrap.com/.
1490 See also include_bootstrap_icons.rst; must match.
1491 """
1493 ACTIVITY = "activity"
1494 APP_AUTHENTICATOR = "shield-shaded"
1495 AUDIT_ITEM = "tag"
1496 AUDIT_MENU = "clipboard"
1497 AUDIT_OPTIONS = "clipboard-check"
1498 AUDIT_REPORT = "clipboard-data"
1499 BUSY = "hourglass-split"
1500 COMPLETE = "check"
1501 CTV = "body-text"
1502 DELETE = "trash"
1503 DELETE_MAJOR = "trash-fill"
1504 DEVELOPER = "braces" # braces, bug
1505 DOWNLOAD = "download"
1506 DUE = "alarm"
1507 DUMP_BASIC = "file-spreadsheet"
1508 DUMP_SQL = "server"
1509 EDIT = "pencil"
1510 EMAIL_CONFIGURE = "at"
1511 EMAIL_SEND = "envelope"
1512 EMAIL_VIEW = "envelope-open"
1513 EXPORT_RECIPIENT = "share"
1514 EXPORTED_TASK = "tag-fill"
1515 EXPORTED_TASK_ENTRY_COLLECTION = "tags"
1516 FILTER = "funnel" # better than filter-circle
1517 FORCE_FINALIZE = "bricks"
1518 GITHUB = "github"
1519 GOTO_PREDECESSOR = "arrow-left-square"
1520 GOTO_SUCCESSOR = "arrow-right-square-fill"
1521 GROUP_ADD = "plus-circle"
1522 GROUP_ADMIN = "suit-diamond-fill"
1523 GROUP_EDIT = "box"
1524 GROUPS = "boxes" # change?
1525 HOME = "house-fill"
1526 HTML_ANONYMOUS = "file-richtext"
1527 HTML_IDENTIFIABLE = "file-richtext-fill"
1528 ID_DEFINITION_ADD = "plus-circle" # suboptimal
1529 ID_DEFINITIONS = "123"
1530 INCOMPLETE = "x-circle"
1531 INFO_EXTERNAL = "info-circle-fill"
1532 # ... info-circle-fill? link? box-arrow-up-right?
1533 INFO_INTERNAL = "info-circle"
1534 JSON = "file-text-fill" # braces, file-text-fill
1535 LOGIN = "box-arrow-in-right"
1536 LOGOUT = "box-arrow-right"
1537 MFA = "fingerprint"
1538 MISSING = "x-octagon-fill"
1539 # ... when an icon should have been supplied but wasn't!
1540 NAVIGATE_BACKWARD = "skip-start"
1541 NAVIGATE_END = "skip-forward" # better than skip-end
1542 NAVIGATE_FORWARD = "skip-end"
1543 # ... better than skip-forward, caret-right; "play" is also good but no
1544 # mirror-image version.
1545 NAVIGATE_START = "skip-backward" # better than skip-start
1546 PASSWORD_OTHER = "key"
1547 PASSWORD_OWN = "key-fill"
1548 PATIENT = "person"
1549 PATIENT_ADD = "person-plus"
1550 PATIENT_EDIT = "person-circle"
1551 PATIENTS = "people"
1552 PDF_ANONYMOUS = "file-pdf"
1553 PDF_IDENTIFIABLE = "file-pdf-fill"
1554 REPORT_CONFIG = "bar-chart-line"
1555 REPORT_DETAIL = "file-bar-graph"
1556 REPORTS = "bar-chart-line-fill"
1557 SETTINGS = "gear"
1558 SMS = "chat-left-dots"
1559 SPECIAL_NOTE = "pencil-square"
1560 SUCCESS = "check-circle"
1561 SUPERUSER = "suit-spade-fill"
1562 TASK_SCHEDULE = "journal"
1563 TASK_SCHEDULE_ADD = "journal-plus"
1564 TASK_SCHEDULE_ITEM_ADD = "journal-code"
1565 # ... imperfect, but we use journal-plus for "add schedule"
1566 TASK_SCHEDULE_ITEMS = "journal-text"
1567 TASK_SCHEDULES = "journals"
1568 TRACKERS = "graph-up"
1569 UNKNOWN = "question-circle"
1570 UNLOCK = "unlock"
1571 UPLOAD = "upload"
1572 USER_ADD = "person-plus-fill" # there isn't a person-badge-plus
1573 USER_INFO = "person-badge"
1574 USER_MANAGEMENT = "person-badge-fill"
1575 USER_PERMISSIONS = "person-check"
1576 VIEW_TASKS = "display"
1577 XML = "file-code-fill" # diagram-3-fill
1578 YOU = "heart-fill"
1579 ZOOM_IN = "zoom-in"
1580 ZOOM_OUT = "zoom-out"
1583# =============================================================================
1584# Pagination
1585# =============================================================================
1586# WebHelpers 1.3 doesn't support Python 3.5.
1587# The successor to webhelpers.paginate appears to be paginate.
1590class SqlalchemyOrmQueryWrapper(object):
1591 """
1592 Wrapper class to access elements of an SQLAlchemy ORM query in an efficient
1593 way for pagination. We only ask the database for what we need.
1595 (But it will perform a ``COUNT(*)`` for the query before fetching it via
1596 ``LIMIT/OFFSET``.)
1598 See:
1600 - https://docs.pylonsproject.org/projects/pylons-webframework/en/latest/helpers.html
1601 - https://docs.pylonsproject.org/projects/webhelpers/en/latest/modules/paginate.html
1602 - https://github.com/Pylons/paginate
1603 """ # noqa
1605 def __init__(self, query: Query) -> None:
1606 self.query = query
1608 def __getitem__(self, cut: slice) -> List[Any]:
1609 """
1610 Return a range of objects of an :class:`sqlalchemy.orm.query.Query`
1611 object.
1613 Will apply LIMIT/OFFSET to fetch only what we need.
1614 """
1615 return self.query[cut]
1617 def __len__(self) -> int:
1618 """
1619 Count the number of objects in an :class:`sqlalchemy.orm.query.Query``
1620 object.
1621 """
1622 return self.query.count()
1625# DEFAULT_NAV_START = "<<"
1626DEFAULT_NAV_START = icon_html(Icons.NAVIGATE_START, alt="Start")
1627# DEFAULT_NAV_END = ">>"
1628DEFAULT_NAV_END = icon_html(Icons.NAVIGATE_END, alt="End")
1629# DEFAULT_NAV_BACKWARD = "<"
1630DEFAULT_NAV_BACKWARD = icon_html(Icons.NAVIGATE_BACKWARD, alt="Backward")
1631# DEFAULT_NAV_FORWARD = '>'
1632DEFAULT_NAV_FORWARD = icon_html(Icons.NAVIGATE_FORWARD, alt="Forward")
1635class CamcopsPage(Page):
1636 """
1637 Pagination class, for HTML views that display, for example,
1638 items 1-20 and buttons like "page 2", "next page", "last page".
1640 - Fixes a bug in paginate: it slices its collection BEFORE it realizes that
1641 the page number is out of range.
1642 - Also, it uses ``..`` for an ellipsis, which is just wrong.
1643 """
1645 # noinspection PyShadowingBuiltins
1646 def __init__(
1647 self,
1648 collection: Union[Sequence[Any], Query, Select],
1649 url_maker: Callable[[int], str],
1650 request: "CamcopsRequest",
1651 page: int = 1,
1652 items_per_page: int = 20,
1653 item_count: int = None,
1654 wrapper_class: Type[Any] = None,
1655 ellipsis: str = "…",
1656 **kwargs,
1657 ) -> None:
1658 """
1659 See :class:`paginate.Page`. Additional arguments:
1661 Args:
1662 ellipsis: HTML text to use as the ellipsis marker
1663 """
1664 self.request = request
1665 self.ellipsis = ellipsis
1666 page = max(1, page)
1667 if item_count is None:
1668 if wrapper_class:
1669 item_count = len(wrapper_class(collection))
1670 else:
1671 item_count = len(collection)
1672 n_pages = ((item_count - 1) // items_per_page) + 1
1673 page = min(page, n_pages)
1674 super().__init__(
1675 collection=collection,
1676 page=page,
1677 items_per_page=items_per_page,
1678 item_count=item_count,
1679 wrapper_class=wrapper_class,
1680 url_maker=url_maker,
1681 **kwargs,
1682 )
1683 # Original defines attributes outside __init__, so:
1684 self.radius = 2
1685 self.curpage_attr = {} # type: Dict[str, str]
1686 self.separator = ""
1687 self.link_attr = {} # type: Dict[str, str]
1688 self.dotdot_attr = {} # type: Dict[str, str]
1689 self.url = ""
1691 # noinspection PyShadowingBuiltins
1692 def pager(
1693 self,
1694 format: str = None,
1695 url: str = None,
1696 show_if_single_page: bool = True, # see below!
1697 separator: str = " ",
1698 symbol_first: str = DEFAULT_NAV_START,
1699 symbol_last: str = DEFAULT_NAV_END,
1700 symbol_previous: str = DEFAULT_NAV_BACKWARD,
1701 symbol_next: str = DEFAULT_NAV_FORWARD,
1702 link_attr: Dict[str, str] = None,
1703 curpage_attr: Dict[str, str] = None,
1704 dotdot_attr: Dict[str, str] = None,
1705 link_tag: Callable[[Dict[str, str]], str] = None,
1706 ):
1707 """
1708 See :func:`paginate.Page.pager`.
1710 The reason for the default for ``show_if_single_page`` being ``True``
1711 is that it's possible otherwise to think you've lost your tasks. For
1712 example: (1) have 99 tasks; (2) view 50/page; (3) go to page 2; (4) set
1713 number per page to 100. Or simply use the URL to go beyond the end.
1714 """
1715 format = format or self.default_pager_pattern()
1716 link_attr = link_attr or {} # type: Dict[str, str]
1717 curpage_attr = curpage_attr or {} # type: Dict[str, str]
1718 # dotdot_attr = dotdot_attr or {} # type: Dict[str, str]
1719 # dotdot_attr = dotdot_attr or {'class': 'pager_dotdot'} # our default! # noqa: E501
1720 return super().pager(
1721 format=format,
1722 url=url,
1723 show_if_single_page=show_if_single_page,
1724 separator=separator,
1725 symbol_first=symbol_first,
1726 symbol_last=symbol_last,
1727 symbol_previous=symbol_previous,
1728 symbol_next=symbol_next,
1729 link_attr=link_attr,
1730 curpage_attr=curpage_attr,
1731 dotdot_attr=dotdot_attr,
1732 link_tag=link_tag,
1733 )
1735 def default_pager_pattern(self) -> str:
1736 """
1737 Allows internationalization of the pager pattern.
1738 """
1739 _ = self.request.gettext
1740 xlated = _("Page $page of $page_count; total $item_count records")
1741 return (
1742 f"({xlated}) "
1743 f"[ $link_first $link_previous ~3~ $link_next $link_last ]"
1744 )
1746 # noinspection PyShadowingBuiltins
1747 def link_map(
1748 self,
1749 format: str = "~2~",
1750 url: str = None,
1751 show_if_single_page: bool = False,
1752 separator: str = " ",
1753 symbol_first: str = "<<",
1754 symbol_last: str = ">>",
1755 symbol_previous: str = "<",
1756 symbol_next: str = ">",
1757 link_attr: Dict[str, str] = None,
1758 curpage_attr: Dict[str, str] = None,
1759 dotdot_attr: Dict[str, str] = None,
1760 ):
1761 """
1762 See equivalent in superclass.
1764 Fixes bugs (e.g. mutable default arguments) and nasties (e.g.
1765 enforcing ".." for the ellipsis) in the original.
1766 """
1767 self.curpage_attr = curpage_attr or {} # type: Dict[str, str]
1768 self.separator = separator
1769 self.link_attr = link_attr or {} # type: Dict[str, str]
1770 self.dotdot_attr = dotdot_attr or {} # type: Dict[str, str]
1771 self.url = url
1773 regex_res = re.search(r"~(\d+)~", format)
1774 if regex_res:
1775 radius = regex_res.group(1)
1776 else:
1777 radius = 2
1778 radius = int(radius)
1779 self.radius = radius
1781 # Compute the first and last page number within the radius
1782 # e.g. '1 .. 5 6 [7] 8 9 .. 12'
1783 # -> leftmost_page = 5
1784 # -> rightmost_page = 9
1785 leftmost_page = (
1786 max(self.first_page, (self.page - radius))
1787 if self.first_page
1788 else None
1789 ) # type: Optional[int]
1790 rightmost_page = (
1791 min(self.last_page, (self.page + radius))
1792 if self.last_page
1793 else None
1794 ) # type: Optional[int]
1795 nav_items = {
1796 "first_page": None,
1797 "last_page": None,
1798 "previous_page": None,
1799 "next_page": None,
1800 "current_page": None,
1801 "radius": self.radius,
1802 "range_pages": [],
1803 } # type: Dict[str, Any]
1805 if leftmost_page is None or rightmost_page is None:
1806 return nav_items
1808 nav_items["first_page"] = {
1809 "type": "first_page",
1810 "value": symbol_first,
1811 "attrs": self.link_attr,
1812 "number": self.first_page,
1813 "href": self.url_maker(self.first_page),
1814 }
1816 # Insert dots if there are pages between the first page
1817 # and the currently displayed page range
1818 if leftmost_page - self.first_page > 1:
1819 # Wrap in a SPAN tag if dotdot_attr is set
1820 nav_items["range_pages"].append(
1821 {
1822 "type": "span",
1823 "value": self.ellipsis,
1824 "attrs": self.dotdot_attr,
1825 "href": "",
1826 "number": None,
1827 }
1828 )
1830 for thispage in range(leftmost_page, rightmost_page + 1):
1831 # Highlight the current page number and do not use a link
1832 if thispage == self.page:
1833 # Wrap in a SPAN tag if curpage_attr is set
1834 nav_items["range_pages"].append(
1835 {
1836 "type": "current_page",
1837 "value": str(thispage),
1838 "number": thispage,
1839 "attrs": self.curpage_attr,
1840 "href": self.url_maker(thispage),
1841 }
1842 )
1843 nav_items["current_page"] = {
1844 "value": thispage,
1845 "attrs": self.curpage_attr,
1846 "type": "current_page",
1847 "href": self.url_maker(thispage),
1848 }
1849 # Otherwise create just a link to that page
1850 else:
1851 nav_items["range_pages"].append(
1852 {
1853 "type": "page",
1854 "value": str(thispage),
1855 "number": thispage,
1856 "attrs": self.link_attr,
1857 "href": self.url_maker(thispage),
1858 }
1859 )
1861 # Insert dots if there are pages between the displayed
1862 # page numbers and the end of the page range
1863 if self.last_page - rightmost_page > 1:
1864 # Wrap in a SPAN tag if dotdot_attr is set
1865 nav_items["range_pages"].append(
1866 {
1867 "type": "span",
1868 "value": self.ellipsis,
1869 "attrs": self.dotdot_attr,
1870 "href": "",
1871 "number": None,
1872 }
1873 )
1875 # Create a link to the very last page (unless we are on the last
1876 # page or there would be no need to insert '..' spacers)
1877 nav_items["last_page"] = {
1878 "type": "last_page",
1879 "value": symbol_last,
1880 "attrs": self.link_attr,
1881 "href": self.url_maker(self.last_page),
1882 "number": self.last_page,
1883 }
1884 nav_items["previous_page"] = {
1885 "type": "previous_page",
1886 "value": symbol_previous,
1887 "attrs": self.link_attr,
1888 "number": self.previous_page or self.first_page,
1889 "href": self.url_maker(self.previous_page or self.first_page),
1890 }
1891 nav_items["next_page"] = {
1892 "type": "next_page",
1893 "value": symbol_next,
1894 "attrs": self.link_attr,
1895 "number": self.next_page or self.last_page,
1896 "href": self.url_maker(self.next_page or self.last_page),
1897 }
1898 return nav_items
1901class SqlalchemyOrmPage(CamcopsPage):
1902 """
1903 A pagination page that paginates SQLAlchemy ORM queries efficiently.
1904 """
1906 def __init__(
1907 self,
1908 query: Query,
1909 url_maker: Callable[[int], str],
1910 request: "CamcopsRequest",
1911 page: int = 1,
1912 items_per_page: int = DEFAULT_ROWS_PER_PAGE,
1913 item_count: int = None,
1914 **kwargs,
1915 ) -> None:
1916 # Since views may accidentally throw strings our way:
1917 assert isinstance(page, int)
1918 assert isinstance(items_per_page, int)
1919 assert isinstance(item_count, int) or item_count is None
1920 super().__init__(
1921 collection=query,
1922 request=request,
1923 page=page,
1924 items_per_page=items_per_page,
1925 item_count=item_count,
1926 wrapper_class=SqlalchemyOrmQueryWrapper,
1927 url_maker=url_maker,
1928 **kwargs,
1929 )
1932# From webhelpers.paginate (which is broken on Python 3.5, but good),
1933# modified a bit:
1936def make_page_url(
1937 path: str,
1938 params: Dict[str, str],
1939 page: int,
1940 partial: bool = False,
1941 sort: bool = True,
1942) -> str:
1943 """
1944 A helper function for URL generators.
1946 I assemble a URL from its parts. I assume that a link to a certain page is
1947 done by overriding the 'page' query parameter.
1949 ``path`` is the current URL path, with or without a "scheme://host" prefix.
1951 ``params`` is the current query parameters as a dict or dict-like object.
1953 ``page`` is the target page number.
1955 If ``partial`` is true, set query param 'partial=1'. This is to for AJAX
1956 calls requesting a partial page.
1958 If ``sort`` is true (default), the parameters will be sorted. Otherwise
1959 they'll be in whatever order the dict iterates them.
1960 """
1961 params = params.copy()
1962 params["page"] = str(page)
1963 if partial:
1964 params["partial"] = "1"
1965 if sort:
1966 params = sorted(params.items())
1967 qs = urlencode(params, True) # was urllib.urlencode, but changed in Py3.5
1968 return "%s?%s" % (path, qs)
1971class PageUrl(object):
1972 """
1973 A page URL generator for WebOb-compatible Request objects.
1975 I derive new URLs based on the current URL but overriding the 'page'
1976 query parameter.
1978 I'm suitable for Pyramid, Pylons, and TurboGears, as well as any other
1979 framework whose Request object has 'application_url', 'path', and 'GET'
1980 attributes that behave the same way as ``webob.Request``'s.
1981 """
1983 def __init__(self, request: "Request", qualified: bool = False):
1984 """
1985 ``request`` is a WebOb-compatible ``Request`` object.
1987 If ``qualified`` is false (default), generated URLs will have just the
1988 path and query string. If true, the "scheme://host" prefix will be
1989 included. The default is false to match traditional usage, and to avoid
1990 generating unuseable URLs behind reverse proxies (e.g., Apache's
1991 mod_proxy).
1992 """
1993 self.request = request
1994 self.qualified = qualified
1996 def __call__(self, page: int, partial: bool = False) -> str:
1997 """
1998 Generate a URL for the specified page.
1999 """
2000 if self.qualified:
2001 path = self.request.application_url
2002 else:
2003 path = self.request.path
2004 return make_page_url(path, self.request.GET, page, partial)
2007# =============================================================================
2008# Debugging requests and responses
2009# =============================================================================
2012def get_body_from_request(req: Request) -> bytes:
2013 """
2014 Debugging function to read the body from an HTTP request.
2015 May not work and will warn accordingly. Use Wireshark to be sure
2016 (https://www.wireshark.org/).
2017 """
2018 log.warning(
2019 "Attempting to read body from request -- but a previous read "
2020 "may have left this empty. Consider using Wireshark!"
2021 )
2022 wsgi_input = req.environ[WsgiEnvVar.WSGI_INPUT]
2023 # ... under gunicorn, is an instance of gunicorn.http.body.Body
2024 return wsgi_input.read()
2027class HTTPFoundDebugVersion(HTTPFound):
2028 """
2029 A debugging version of :class:`HTTPFound`, for debugging redirections.
2030 """
2032 def __init__(self, location: str = "", **kwargs) -> None:
2033 log.debug("Redirecting to {!r}", location)
2034 super().__init__(location, **kwargs)