Coverage for cc_modules/cc_pyramid.py: 75%

1"""

2 camcops_server/cc_modules/cc_pyramid.py

3

4===============================================================================

5

6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.

7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).

8

9 This file is part of CamCOPS.

10

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.

15

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.

20

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/>.

23

24===============================================================================

25

26**Functions for the Pyramid web framework.**

27

28"""

29

30 from enum import Enum

31 import logging

32 import os

33 import pprint

34 import re

35 import sys

36 from typing import (

37 Any,

38 Callable,

39 Dict,

40 List,

41 Optional,

42 Sequence,

43 Tuple,

44 Type,

45 TYPE_CHECKING,

46 Union,

47)

48 from urllib.parse import urlencode

49

50 from cardinal_pythonlib.logs import BraceStyleAdapter

51 from cardinal_pythonlib.wsgi.constants import WsgiEnvVar

52 from mako.filters import html_escape

53 from mako.lookup import TemplateLookup

54 from paginate import Page

55 from pyramid.authentication import IAuthenticationPolicy

56 from pyramid.authorization import IAuthorizationPolicy

57 from pyramid.config import Configurator

58 from pyramid.httpexceptions import HTTPFound

59 from pyramid.interfaces import ILocation, ISession

60 from pyramid.request import Request

61 from pyramid.security import (

62 Allowed,

63 Denied,

64 Authenticated,

65 Everyone,

66 PermitsResult,

67)

68 from pyramid.session import JSONSerializer, SignedCookieSessionFactory

69 from pyramid_mako import (

70 MakoLookupTemplateRenderer,

71 MakoRendererFactory,

72 MakoRenderingException,

73 reraise,

74 text_error_template,

75)

76 from sqlalchemy.orm import Query

77 from sqlalchemy.sql.selectable import Select

78 from zope.interface import implementer

79

80 from camcops_server.cc_modules.cc_baseconstants import TEMPLATE_DIR

81 from camcops_server.cc_modules.cc_cache import cache_region_static

82 from camcops_server.cc_modules.cc_constants import DEFAULT_ROWS_PER_PAGE

83

84 if TYPE_CHECKING:

85 from camcops_server.cc_modules.cc_request import CamcopsRequest

86

87 log = BraceStyleAdapter(logging.getLogger(__name__))

88

89

90# =============================================================================

91# Debugging options

92# =============================================================================

93

94 DEBUG_ADD_ROUTES = False

95 DEBUG_EFFECTIVE_PRINCIPALS = False

96 DEBUG_TEMPLATE_PARAMETERS = False

97# ... logs more information about template creation

98 DEBUG_TEMPLATE_SOURCE = False

99# ... writes the templates in their compiled-to-Python version to a debugging

100# directory (see below), which is very informative.

101 DEBUGGING_MAKO_DIR = os.path.expanduser("~/tmp/camcops_mako_template_source")

102

103 if 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!")

112

113

114# =============================================================================

115# Constants

116# =============================================================================

117

118 COOKIE_NAME = "camcops"

119

120

121 class 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 """

127

128 SESSION_ID = "session_id"

129 SESSION_TOKEN = "session_token"

130

131

132 class 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 """

137

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"

146

147

148 class ViewParam(object):

149 """

150 View parameter constants.

151

152 Used in the following situations:

153

154 - as parameter names for parameterized URLs (via RoutePath to Pyramid's

155 route configuration, then fetched from the matchdict);

156

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 """

161

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"

287

288

289 class 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 """

295

296 # Delivery methods

297 DOWNLOAD = "download"

298 EMAIL = "email"

299 IMMEDIATELY = "immediately"

300

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"

314

315 # What to download

316 EVERYTHING = "everything"

317 SPECIFIC_TASKS_GROUPS = "specific_tasks_groups"

318 USE_SESSION_FILTER = "use_session_filter"

319

320

321# =============================================================================

322# Flash message queues

323# =============================================================================

324

325

326 class FlashQueue:

327 """

328 Predefined flash (alert) message queues for Bootstrap; see

329 https://getbootstrap.com/docs/3.3/components/#alerts.

330 """

331

332 SUCCESS = "success"

333 INFO = "info"

334 WARNING = "warning"

335 DANGER = "danger"

336

337

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

344

345 MAKO_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)

395

396

397 class CamcopsMakoLookupTemplateRenderer(MakoLookupTemplateRenderer):

398 r"""

399 A Mako template renderer that, when called:

400

401 (a) loads the Mako template

402 (b) shoves any other keys we specify into its dictionary

403

404 Typical incoming parameters look like:

405

406 .. code-block:: none

407

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 }

419

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:

423

424 .. code-block:: python

425

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 }

436

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`.

440

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"/>``).

444

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:

449

450 .. code-block:: none

451

452 ## db_user_info.mako

453 <%page args="offer_main_menu=False"/>

454

455 <%!

456 module_level_thing = context.kwargs # module-level block; will crash

457 %>

458

459 <%

460 thing = context.kwargs["request"] # normal Python block; works

461 %>

462

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>

472

473 looks like this:

474

475 .. code-block:: python

476

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 = []

489

490 module_level_thing = context.kwargs # module-level block; will crash

491

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')

500

501 thing = context.kwargs["request"] # normal Python block; works

502

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()

520

521 '''

522 __M_BEGIN_METADATA

523 {"filename": ...} # edited

524 __M_END_METADATA

525 '''

526

527 """ # noqa

528

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()))

535

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

548

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")

554

555 # Add the special "_" translation function

556 request = system["request"] # type: CamcopsRequest

557 system["_"] = request.gettext

558

559 # Check if 'context' in the dictionary

560 context = system.pop("context", None)

561

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

566

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

585

586 # noinspection PyUnboundLocalVariable

587 return result

588

589

590 class CamcopsMakoRendererFactory(MakoRendererFactory):

591 """

592 A Mako renderer factory to use :class:`CamcopsMakoLookupTemplateRenderer`.

593 """

594

595 # noinspection PyTypeChecker

596 renderer_factory = staticmethod(CamcopsMakoLookupTemplateRenderer)

597

598

599 def camcops_add_mako_renderer(config: Configurator, extension: str) -> None:

600 """

601 Registers a renderer factory for a given template file type.

602

603 Replacement for :func:`add_mako_renderer` from ``pyramid_mako``, so we can

604 use our own lookup.

605

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

611

612

613# =============================================================================

614# URL/route helpers

615# =============================================================================

616

617 RE_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

621

622

623 def valid_replacement_marker(marker: str) -> bool:

624 """

625 Is a string suitable for use as a parameter name in a templatized URL?

626

627 (That is: is it free of odd characters?)

628

629 See :class:`UrlParam`.

630 """

631 return RE_VALID_REPLACEMENT_MARKER.match(marker) is not None

632

633

634 class UrlParamType(Enum):

635 """

636 Enum for building templatized URLs.

637 See :class:`UrlParam`.

638 """

639

640 STRING = 1

641 POSITIVE_INTEGER = 2

642 PLAIN_STRING = 3

643

644

645 class UrlParam(object):

646 """

647 Represents a parameter within a URL. For example:

648

649 .. code-block:: python

650

651 from camcops_server.cc_modules.cc_pyramid import *

652 p = UrlParam("patient_id", UrlParamType.POSITIVE_INTEGER)

653 p.markerdef() # '{patient_id:\\d+}'

654

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

658

659 See also :class:`RoutePath`.

660

661 """

662

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)

675

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")

688

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 + "}"

698

699

700 def 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`.

704

705 Args:

706 base: the base path, to which we will append parameter templates

707 *args: a number of :class:`UrlParam` objects.

708

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)

717

718

719# =============================================================================

720# Routes

721# =============================================================================

722

723

724# Class to collect constants together

725# See also http://xion.io/post/code/python-enums-are-ok.html

726 class Routes(object):

727 """

728 Names of Pyramid routes.

729

730 - Used by the ``@view_config(route_name=...)`` decorator.

731 - Configured via :class:`RouteCollection` / :class:`RoutePath` to the

732 Pyramid route configurator.

733

734 Note: these are internal names, not (necessarily) URL paths. For those, see

735 RouteCollection.

736 """

737

738 # Hard-coded special paths

739 STATIC = "static"

740

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"

850

851

852 class RoutePath(object):

853 r"""

854 Class to hold a route/path pair.

855

856 - Pyramid route names are just strings used internally for convenience.

857

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

864

865 """

866

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

878

879

880 MASTER_ROUTE_WEBVIEW = "/"

881 MASTER_ROUTE_CLIENT_API = "/api"

882 MASTER_ROUTE_CLIENT_API_ALIAS = "/database" # legacy path

883

884 STATIC_CAMCOPS_PACKAGE_PATH = "camcops_server.static:"

885# ... the "static" package (directory with __init__.py) within the

886# "camcops_server" owning package

887 STATIC_BOOTSTRAP_ICONS_PATH = (

888 STATIC_CAMCOPS_PACKAGE_PATH + "bootstrap-icons-1.7.0"

889)

890

891

892# noinspection PyUnusedLocal

893 def 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

897

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

904

905

906 def _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 )

917

918

919 def _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 )

923

924

925 def _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 )

932

933

934 class RouteCollection(object):

935 """

936 All routes, with their paths, for CamCOPS.

937 They will be auto-read by :func:`all_routes`.

938

939 To make a URL on the fly, use :func:`Request.route_url` or

940 :func:`CamcopsRequest.route_url_params`.

941

942 To associate a view with a route, use the Pyramid ``@view_config``

943 decorator.

944 """

945

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 )

953

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)

1004

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 )

1025

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)

1093

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 ]

1108

1109

1110# =============================================================================

1111# Pyramid HTTP session handling

1112# =============================================================================

1113

1114

1115 def 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.

1119

1120 - An example is in :class:`pyramid.session.SignedCookieSessionFactory`.

1121 - A session factory has the signature [1]:

1122

1123 .. code-block:: none

1124

1125 sessionfactory(req: CamcopsRequest) -> session_object

1126

1127 - ... where session "is a namespace" [2]

1128 - ... but more concretely, "implements the pyramid.interfaces.ISession

1129 interface"

1130

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.

1133

1134 [1] https://docs.pylonsproject.org/projects/pyramid/en/latest/glossary.html#term-session-factory

1135

1136 [2] https://docs.pylonsproject.org/projects/pyramid/en/latest/glossary.html#term-session

1137 """ # noqa

1138

1139 def factory(req: "CamcopsRequest") -> ISession:

1140 """

1141 How does the session write the cookies to the response? Like this:

1142

1143 .. code-block:: none

1144

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)

1155

1156 def _set_cookie(self, response):

1157 # ...

1158 response.set_cookie(...)

1159

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)

1184

1185 return factory

1186

1187

1188# =============================================================================

1189# Authentication; authorization (permissions)

1190# =============================================================================

1191

1192

1193 class Permission(object):

1194 """

1195 Pyramid permission values.

1196

1197 - Permissions are strings.

1198 - For "logged in", use ``pyramid.security.Authenticated``

1199 """

1200

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"

1209

1210

1211@implementer(IAuthenticationPolicy)

1212 class CamcopsAuthenticationPolicy(object):

1213 """

1214 CamCOPS authentication policy.

1215

1216 See

1217

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

1226

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

1233

1234 # noinspection PyUnusedLocal

1235 @staticmethod

1236 def unauthenticated_userid(request: "CamcopsRequest") -> Optional[int]:

1237 """

1238 Returns the user ID of the unauthenticated user.

1239

1240 We don't allow users to be identified but not authenticated, so we

1241 return ``None``.

1242 """

1243 return None

1244

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

1272

1273 # noinspection PyUnusedLocal

1274 @staticmethod

1275 def remember(

1276 request: "CamcopsRequest", userid: int, **kw: Any

1277 ) -> List[Tuple[str, str]]:

1278 return []

1279

1280 # noinspection PyUnusedLocal

1281 @staticmethod

1282 def forget(request: "CamcopsRequest") -> List[Tuple[str, str]]:

1283 return []

1284

1285

1286@implementer(IAuthorizationPolicy)

1287 class CamcopsAuthorizationPolicy(object):

1288 """

1289 CamCOPS authorization policy.

1290 """

1291

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 )

1302

1303 return Denied(

1304 f"DENIED: permission {permission} not in principals "

1305 f"{principals}"

1306 )

1307

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

1313

1314

1315# =============================================================================

1316# Icons

1317# =============================================================================

1318

1319

1320 def 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.

1331

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

1370

1371

1372 def 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.

1388

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}"

1440

1441

1442 def 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}"

1486

1487

1488 class Icons:

1489 """

1490 Constants for Bootstrap icons. See https://icons.getbootstrap.com/.

1491 See also include_bootstrap_icons.rst; must match.

1492 """

1493

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"

1582

1583

1584# =============================================================================

1585# Pagination

1586# =============================================================================

1587# WebHelpers 1.3 doesn't support Python 3.5.

1588# The successor to webhelpers.paginate appears to be paginate.

1589

1590

1591 class 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.

1595

1596 (But it will perform a ``COUNT(*)`` for the query before fetching it via

1597 ``LIMIT/OFFSET``.)

1598

1599 See:

1600

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

1605

1606 def __init__(self, query: Query) -> None:

1607 self.query = query

1608

1609 def __getitem__(self, cut: slice) -> List[Any]:

1610 """

1611 Return a range of objects of an :class:`sqlalchemy.orm.query.Query`

1612 object.

1613

1614 Will apply LIMIT/OFFSET to fetch only what we need.

1615 """

1616 return self.query[cut]

1617

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()

1624

1625

1626# DEFAULT_NAV_START = "&lt;&lt;"

1627 DEFAULT_NAV_START = icon_html(Icons.NAVIGATE_START, alt="Start")

1628# DEFAULT_NAV_END = "&gt;&gt;"

1629 DEFAULT_NAV_END = icon_html(Icons.NAVIGATE_END, alt="End")

1630# DEFAULT_NAV_BACKWARD = "&lt;"

1631 DEFAULT_NAV_BACKWARD = icon_html(Icons.NAVIGATE_BACKWARD, alt="Backward")

1632# DEFAULT_NAV_FORWARD = '&gt;'

1633 DEFAULT_NAV_FORWARD = icon_html(Icons.NAVIGATE_FORWARD, alt="Forward")

1634

1635

1636 class 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".

1640

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 """

1645

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 = "&hellip;",

1657 **kwargs: Any,

1658 ) -> None:

1659 """

1660 See :class:`paginate.Page`. Additional arguments:

1661

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 = ""

1691

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`.

1710

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 )

1735

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 )

1746

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 = "&lt;&lt;",

1755 symbol_last: str = "&gt;&gt;",

1756 symbol_previous: str = "&lt;",

1757 symbol_next: str = "&gt;",

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.

1764

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

1773

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

1781

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]

1805

1806 if leftmost_page is None or rightmost_page is None:

1807 return nav_items

1808

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 }

1816

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 )

1830

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 )

1861

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 )

1875

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

1900

1901

1902 class SqlalchemyOrmPage(CamcopsPage):

1903 """

1904 A pagination page that paginates SQLAlchemy ORM queries efficiently.

1905 """

1906

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 )

1931

1932

1933# From webhelpers.paginate (which is broken on Python 3.5, but good),

1934# modified a bit:

1935

1936

1937 def 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.

1946

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.

1949

1950 ``path`` is the current URL path, with or without a "scheme://host" prefix.

1951

1952 ``params`` is the current query parameters as a dict or dict-like object.

1953

1954 ``page`` is the target page number.

1955

1956 If ``partial`` is true, set query param 'partial=1'. This is to for AJAX

1957 calls requesting a partial page.

1958

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)

1970

1971

1972 class PageUrl(object):

1973 """

1974 A page URL generator for WebOb-compatible Request objects.

1975

1976 I derive new URLs based on the current URL but overriding the 'page'

1977 query parameter.

1978

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 """

1983

1984 def __init__(self, request: "Request", qualified: bool = False):

1985 """

1986 ``request`` is a WebOb-compatible ``Request`` object.

1987

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

1996

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)

2006

2007

2008# =============================================================================

2009# Debugging requests and responses

2010# =============================================================================

2011

2012

2013 def 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()

2026

2027

2028 class HTTPFoundDebugVersion(HTTPFound):

2029 """

2030 A debugging version of :class:`HTTPFound`, for debugging redirections.

2031 """

2032

2033 def __init__(self, location: str = "", **kwargs: Any) -> None:

2034 log.debug("Redirecting to {!r}", location)

2035 super().__init__(location, **kwargs)