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

1""" 

2camcops_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 

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 

49 

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 

79 

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 

83 

84if TYPE_CHECKING: 

85 from camcops_server.cc_modules.cc_request import CamcopsRequest 

86 

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

88 

89 

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

91# Debugging options 

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

93 

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

102 

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

112 

113 

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

115# Constants 

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

117 

118COOKIE_NAME = "camcops" 

119 

120 

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

127 

128 SESSION_ID = "session_id" 

129 SESSION_TOKEN = "session_token" 

130 

131 

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

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 

148class 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 

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

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 

326class 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 

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) 

395 

396 

397class 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 

590class CamcopsMakoRendererFactory(MakoRendererFactory): 

591 """ 

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

593 """ 

594 

595 # noinspection PyTypeChecker 

596 renderer_factory = staticmethod(CamcopsMakoLookupTemplateRenderer) 

597 

598 

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

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 

621 

622 

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

634class 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 

645class 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 

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

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 

726class 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 

852class 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 

880MASTER_ROUTE_WEBVIEW = "/" 

881MASTER_ROUTE_CLIENT_API = "/api" 

882MASTER_ROUTE_CLIENT_API_ALIAS = "/database" # legacy path 

883 

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) 

890 

891 

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 

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 

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 ) 

917 

918 

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 ) 

923 

924 

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 ) 

932 

933 

934class 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 

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. 

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 

1193class 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) 

1212class 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) 

1287class 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 

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. 

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 

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. 

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 

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

1486 

1487 

1488class 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 

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. 

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

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

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

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

1630# DEFAULT_NAV_BACKWARD = "&lt;" 

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

1632# DEFAULT_NAV_FORWARD = '&gt;' 

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

1634 

1635 

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

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 

1902class 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 

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. 

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 

1972class 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 

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

2026 

2027 

2028class 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)