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