Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/cc_modules/cc_pyramid.py 

5 

6=============================================================================== 

7 

8 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com). 

9 

10 This file is part of CamCOPS. 

11 

12 CamCOPS is free software: you can redistribute it and/or modify 

13 it under the terms of the GNU General Public License as published by 

14 the Free Software Foundation, either version 3 of the License, or 

15 (at your option) any later version. 

16 

17 CamCOPS is distributed in the hope that it will be useful, 

18 but WITHOUT ANY WARRANTY; without even the implied warranty of 

19 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

20 GNU General Public License for more details. 

21 

22 You should have received a copy of the GNU General Public License 

23 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>. 

24 

25=============================================================================== 

26 

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

28 

29""" 

30 

31from enum import Enum 

32import logging 

33import os 

34import pprint 

35import re 

36import sys 

37from typing import (Any, Callable, Dict, List, Optional, Sequence, Tuple, 

38 Type, TYPE_CHECKING, Union) 

39from urllib.parse import urlencode 

40 

41# from cardinal_pythonlib.debugging import get_caller_stack_info 

42from cardinal_pythonlib.logs import BraceStyleAdapter 

43from cardinal_pythonlib.wsgi.constants import WsgiEnvVar 

44from mako.lookup import TemplateLookup 

45from paginate import Page 

46from pyramid.authentication import IAuthenticationPolicy 

47from pyramid.authorization import IAuthorizationPolicy 

48from pyramid.config import Configurator 

49from pyramid.httpexceptions import HTTPFound 

50from pyramid.interfaces import ILocation, ISession 

51from pyramid.request import Request 

52from pyramid.security import ( 

53 Allowed, 

54 Denied, 

55 Authenticated, 

56 Everyone, 

57 PermitsResult, 

58) 

59from pyramid.session import SignedCookieSessionFactory 

60from pyramid_mako import ( 

61 MakoLookupTemplateRenderer, 

62 MakoRendererFactory, 

63 MakoRenderingException, 

64 reraise, 

65 text_error_template, 

66) 

67from sqlalchemy.orm import Query 

68from sqlalchemy.sql.selectable import Select 

69from zope.interface import implementer 

70 

71from camcops_server.cc_modules.cc_baseconstants import TEMPLATE_DIR 

72from camcops_server.cc_modules.cc_cache import cache_region_static 

73from camcops_server.cc_modules.cc_constants import DEFAULT_ROWS_PER_PAGE 

74 

75if TYPE_CHECKING: 

76 from camcops_server.cc_modules.cc_request import CamcopsRequest 

77 

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

79 

80# ============================================================================= 

81# Debugging options 

82# ============================================================================= 

83 

84DEBUG_ADD_ROUTES = False 

85DEBUG_EFFECTIVE_PRINCIPALS = False 

86DEBUG_TEMPLATE_PARAMETERS = False 

87# ... logs more information about template creation 

88DEBUG_TEMPLATE_SOURCE = False 

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

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

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

92 

93if any([DEBUG_ADD_ROUTES, 

94 DEBUG_EFFECTIVE_PRINCIPALS, 

95 DEBUG_TEMPLATE_PARAMETERS, 

96 DEBUG_TEMPLATE_SOURCE]): 

97 log.warning("Debugging options enabled!") 

98 

99 

100# ============================================================================= 

101# Constants 

102# ============================================================================= 

103 

104COOKIE_NAME = 'camcops' 

105 

106 

107class CookieKey: 

108 """ 

109 Keys for HTTP cookies. We keep this to the absolute minimum; cookies 

110 contain enough detail to look up a session on the server, and then 

111 everything else is looked up on the server side. 

112 """ 

113 SESSION_ID = 'session_id' 

114 SESSION_TOKEN = 'session_token' 

115 

116 

117class FormAction(object): 

118 """ 

119 Action values for HTML forms. These values generally end up as the ``name`` 

120 attribute (and sometimes also the ``value`` attribute) of an HTML button. 

121 """ 

122 CANCEL = 'cancel' 

123 CLEAR_FILTERS = 'clear_filters' 

124 DELETE = 'delete' 

125 FINALIZE = 'finalize' 

126 SET_FILTERS = 'set_filters' 

127 SUBMIT = 'submit' # the default for many forms 

128 SUBMIT_TASKS_PER_PAGE = 'submit_tpp' 

129 REFRESH_TASKS = 'refresh_tasks' 

130 

131 

132class RequestMethod(object): 

133 """ 

134 Constants to distinguish HTTP GET from HTTP POST requests. 

135 """ 

136 GET = "GET" 

137 POST = "POST" 

138 

139 

140class ViewParam(object): 

141 """ 

142 View parameter constants. 

143 

144 Used in the following situations: 

145 

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

147 route configuration, then fetched from the matchdict); 

148 

149 - as form parameter names (often with some duplication as the attribute 

150 names of deform Form objects, because to avoid duplication would involve 

151 metaclass mess). 

152 """ 

153 # QUERY = "_query" # built in to Pyramid 

154 ADDRESS = "address" 

155 ADD_SPECIAL_NOTE = "add_special_note" 

156 ADMIN = "admin" 

157 AGE_MINIMUM = "age_minimum" 

158 AGE_MAXIMUM = "age_maximum" 

159 ALL_TASKS = "all_tasks" 

160 ANONYMISE = "anonymise" 

161 CLINICIAN_CONFIRMATION = "clinician_confirmation" 

162 CSRF_TOKEN = "csrf_token" 

163 DATABASE_TITLE = "database_title" 

164 DELIVERY_MODE = "delivery_mode" 

165 DESCRIPTION = "description" 

166 DEVICE_ID = "device_id" 

167 DEVICE_IDS = "device_ids" 

168 DIALECT = "dialect" 

169 DIAGNOSES_INCLUSION = "diagnoses_inclusion" 

170 DIAGNOSES_EXCLUSION = "diagnoses_exclusion" 

171 DUMP_METHOD = "dump_method" 

172 DOB = "dob" 

173 DUE_FROM = "due_from" 

174 DUE_WITHIN = "due_within" 

175 EMAIL = "email" 

176 EMAIL_SUBJECT = "email_subject" 

177 EMAIL_TEMPLATE = "email_template" 

178 END_DATETIME = "end_datetime" 

179 INCLUDE_AUTO_GENERATED = "include_auto_generated" 

180 FILENAME = "filename" 

181 FINALIZE_POLICY = "finalize_policy" 

182 FORENAME = "forename" 

183 FULLNAME = "fullname" 

184 GP = "gp" 

185 GROUPADMIN = "groupadmin" 

186 GROUP_ID = "group_id" 

187 GROUP_IDS = "group_ids" 

188 HL7_ID_TYPE = "hl7_id_type" 

189 HL7_ASSIGNING_AUTHORITY = "hl7_assigning_authority" 

190 ID = "id" # generic PK 

191 ID_DEFINITIONS = "id_definitions" 

192 ID_REFERENCES = "id_references" 

193 IDNUM_VALUE = "idnum_value" 

194 INCLUDE_BLOBS = "include_blobs" 

195 INCLUDE_CALCULATED = "include_calculated" 

196 INCLUDE_COMMENTS = "include_comments" 

197 INCLUDE_INFORMATION_SCHEMA_COLUMNS = "include_information_schema_columns" 

198 INCLUDE_PATIENT = "include_patient" 

199 INCLUDE_SNOMED = "include_snomed" 

200 IP_USE = "ip_use" 

201 LANGUAGE = "language" 

202 MANUAL = "manual" 

203 MAY_ADD_NOTES = "may_add_notes" 

204 MAY_DUMP_DATA = "may_dump_data" 

205 MAY_REGISTER_DEVICES = "may_register_devices" 

206 MAY_RUN_REPORTS = "may_run_reports" 

207 MAY_UPLOAD = "may_upload" 

208 MAY_USE_WEBVIEWER = "may_use_webviewer" 

209 MUST_CHANGE_PASSWORD = "must_change_password" 

210 NAME = "name" 

211 NOTE = "note" 

212 NOTE_ID = "note_id" 

213 NEW_PASSWORD = "new_password" 

214 OLD_PASSWORD = "old_password" 

215 OTHER = "other" 

216 COMPLETE_ONLY = "complete_only" 

217 PAGE = "page" 

218 PASSWORD = "password" 

219 PATIENT_ID_PER_ROW = "patient_id_per_row" 

220 PATIENT_TASK_SCHEDULE_ID = "patient_task_schedule_id" 

221 RECIPIENT_NAME = "recipient_name" 

222 REDIRECT_URL = "redirect_url" 

223 REPORT_ID = "report_id" 

224 REMOTE_IP_ADDR = "remote_ip_addr" 

225 ROWS_PER_PAGE = "rows_per_page" 

226 SCHEDULE_ID = "schedule_id" 

227 SCHEDULE_ITEM_ID = "schedule_item_id" 

228 SERVER_PK = "server_pk" 

229 SETTINGS = "settings" 

230 SEX = "sex" 

231 SHORT_DESCRIPTION = "short_description" 

232 SORT = "sort" 

233 SOURCE = "source" 

234 SQLITE_METHOD = "sqlite_method" 

235 START_DATETIME = "start_datetime" 

236 SUPERUSER = "superuser" 

237 SURNAME = "surname" 

238 TABLE_NAME = "table_name" 

239 TASKS = "tasks" 

240 TASK_SCHEDULES = "task_schedules" 

241 TEXT_CONTENTS = "text_contents" 

242 TRUNCATE = "truncate" 

243 UPLOAD_GROUP_ID = "upload_group_id" 

244 UPLOAD_POLICY = "upload_policy" 

245 USER_GROUP_MEMBERSHIP_ID = "user_group_membership_id" 

246 USER_ID = "user_id" 

247 USER_IDS = "user_ids" 

248 USERNAME = "username" 

249 VALIDATION_METHOD = "validation_method" 

250 VIA_INDEX = "via_index" 

251 VIEW_ALL_PATIENTS_WHEN_UNFILTERED = "view_all_patients_when_unfiltered" 

252 VIEWTYPE = "viewtype" 

253 WHICH_IDNUM = "which_idnum" 

254 WHAT = "what" 

255 WHEN = "when" 

256 WHO = "who" 

257 

258 

259class ViewArg(object): 

260 """ 

261 String used as view arguments. For example, 

262 :class:`camcops_server.cc_modules.cc_forms.DumpTypeSelector` represents its 

263 choices (inside an HTTP POST request) as values from this class. 

264 """ 

265 # Delivery methods 

266 DOWNLOAD = "download" 

267 EMAIL = "email" 

268 IMMEDIATELY = "immediately" 

269 

270 # Output types 

271 HTML = "html" 

272 ODS = "ods" 

273 PDF = "pdf" 

274 PDFHTML = "pdfhtml" # the HTML to create a PDF 

275 R = "r" 

276 SQL = "sql" 

277 SQLITE = "sqlite" 

278 TSV = "tsv" 

279 TSV_ZIP = "tsv_zip" 

280 XLSX = "xlsx" 

281 XML = "xml" 

282 

283 # What to download 

284 EVERYTHING = "everything" 

285 SPECIFIC_TASKS_GROUPS = "specific_tasks_groups" 

286 USE_SESSION_FILTER = "use_session_filter" 

287 

288 

289# ============================================================================= 

290# Templates 

291# ============================================================================= 

292# Adaptation of a small part of pyramid_mako, so we can use our own Mako 

293# TemplateLookup, and thus dogpile.cache. See 

294# https://github.com/Pylons/pyramid_mako/blob/master/pyramid_mako/__init__.py 

295 

296MAKO_LOOKUP = TemplateLookup( 

297 directories=[ 

298 os.path.join(TEMPLATE_DIR, "base"), 

299 os.path.join(TEMPLATE_DIR, "css"), 

300 os.path.join(TEMPLATE_DIR, "menu"), 

301 os.path.join(TEMPLATE_DIR, "snippets"), 

302 os.path.join(TEMPLATE_DIR, "taskcommon"), 

303 os.path.join(TEMPLATE_DIR, "tasks"), 

304 os.path.join(TEMPLATE_DIR, "test"), 

305 ], 

306 

307 input_encoding="utf-8", 

308 output_encoding="utf-8", 

309 

310 module_directory=DEBUGGING_MAKO_DIR if DEBUG_TEMPLATE_SOURCE else None, 

311 

312 # strict_undefined=True, # raise error immediately upon typos 

313 # ... tradeoff; there are good and bad things about this! 

314 # One bad thing about strict_undefined=True is that a child (inheriting) 

315 # template must supply all variables used by its parent (inherited) 

316 # template, EVEN IF it replaces entirely the <%block> of the parent that 

317 # uses those variables. 

318 

319 # ------------------------------------------------------------------------- 

320 # Template default filtering 

321 # ------------------------------------------------------------------------- 

322 

323 default_filters=["h"], 

324 

325 # ------------------------------------------------------------------------- 

326 # Template caching 

327 # ------------------------------------------------------------------------- 

328 # http://dogpilecache.readthedocs.io/en/latest/api.html#module-dogpile.cache.plugins.mako_cache # noqa 

329 # http://docs.makotemplates.org/en/latest/caching.html#cache-arguments 

330 

331 cache_impl="dogpile.cache", 

332 cache_args={ 

333 "regions": {"local": cache_region_static}, 

334 }, 

335 

336 # Now, in Mako templates, use: 

337 # cached="True" cache_region="local" cache_key="SOME_CACHE_KEY" 

338 # on <%page>, <%def>, and <%block> regions. 

339 # It is VITAL that you specify "name", and that it be appropriately 

340 # unique, or there'll be a cache conflict. 

341 # The easy way is: 

342 # cached="True" cache_region="local" cache_key="${self.filename}" 

343 # ^^^^^^^^^^^^^^^^ 

344 # No! 

345 # ... with ${self.filename} you can get an inheritance deadlock: 

346 # See https://bitbucket.org/zzzeek/mako/issues/269/inheritance-related-cache-deadlock-when # noqa 

347 # 

348 # HOWEVER, note also: it is the CONTENT that is cached. You can cause some 

349 # right disasters with this. Only stuff producing entirely STATIC content 

350 # should be cached. "base.mako" isn't static - it calls back to its 

351 # children; and if you cache it, one request produces results for an 

352 # entirely different request. Similarly for lots of other things like 

353 # "task.mako". 

354 # SO, THERE IS NOT MUCH REASON HERE TO USE TEMPLATE CACHING. 

355) 

356 

357 

358class CamcopsMakoLookupTemplateRenderer(MakoLookupTemplateRenderer): 

359 r""" 

360 A Mako template renderer that, when called: 

361 

362 (a) loads the Mako template 

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

364 

365 Typical incoming parameters look like: 

366 

367 .. code-block:: none 

368 

369 spec = 'some_template.mako' 

370 value = {'comment': None} 

371 system = { 

372 'context': <pyramid.traversal.DefaultRootFactory ...>, 

373 'get_csrf_token': functools.partial(<function get_csrf_token ... >, ...>), 

374 'renderer_info': <pyramid.renderers.RendererHelper ...>, 

375 'renderer_name': 'some_template.mako', 

376 'req': <CamcopsRequest ...>, 

377 'request': <CamcopsRequest ...>, 

378 'view': None 

379 } 

380 

381 Showing the incoming call stack info (see commented-out code) indicates 

382 that ``req`` and ``request`` (etc.) join at, and are explicitly introduced 

383 by, :func:`pyramid.renderers.render`. That function includes this code: 

384 

385 .. code-block:: python 

386 

387 if system_values is None: 

388 system_values = { 

389 'view':None, 

390 'renderer_name':self.name, # b/c 

391 'renderer_info':self, 

392 'context':getattr(request, 'context', None), 

393 'request':request, 

394 'req':request, 

395 'get_csrf_token':partial(get_csrf_token, request), 

396 } 

397 

398 So that means, for example, that ``req`` and ``request`` are both always 

399 present in Mako templates as long as the ``request`` parameter was passed 

400 to :func:`pyramid.renderers.render_to_response`. 

401 

402 What about a view configured with ``@view_config(..., 

403 renderer="somefile.mako")``? Yes, that too (and anything included via 

404 ``<%include file="otherfile.mako"/>``). 

405 

406 However, note that ``req`` and ``request`` are only available in the Mako 

407 evaluation blocks, e.g. via ``${req.someattr}`` or via Python blocks like 

408 ``<% %>`` -- not via Python blocks like ``<%! %>``, because the actual 

409 Python generated by a Mako template like this: 

410 

411 .. code-block:: none 

412 

413 ## db_user_info.mako 

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

415 

416 <%! 

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

418 %> 

419 

420 <% 

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

422 %> 

423 

424 <div> 

425 Database: <b>${ request.database_title | h }</b>. 

426 %if request.camcops_session.username: 

427 Logged in as <b>${request.camcops_session.username | h}</b>. 

428 %endif 

429 %if offer_main_menu: 

430 <%include file="to_main_menu.mako"/> 

431 %endif 

432 </div> 

433 

434 looks like this: 

435 

436 .. code-block:: python 

437 

438 from mako import runtime, filters, cache 

439 UNDEFINED = runtime.UNDEFINED 

440 STOP_RENDERING = runtime.STOP_RENDERING 

441 __M_dict_builtin = dict 

442 __M_locals_builtin = locals 

443 _magic_number = 10 

444 _modified_time = 1557179054.2796485 

445 _enable_loop = True 

446 _template_filename = '...' # edited 

447 _template_uri = 'db_user_info.mako' 

448 _source_encoding = 'utf-8' 

449 _exports = [] 

450 

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

452 

453 def render_body(context,offer_main_menu=False,**pageargs): 

454 __M_caller = context.caller_stack._push_frame() 

455 try: 

456 __M_locals = __M_dict_builtin(offer_main_menu=offer_main_menu,pageargs=pageargs) 

457 request = context.get('request', UNDEFINED) 

458 __M_writer = context.writer() 

459 __M_writer('\n\n') 

460 __M_writer('\n\n') 

461 

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

463 

464 __M_locals_builtin_stored = __M_locals_builtin() 

465 __M_locals.update(__M_dict_builtin([(__M_key, __M_locals_builtin_stored[__M_key]) for __M_key in ['thing'] if __M_key in __M_locals_builtin_stored])) 

466 __M_writer('\n\n<div>\n Database: <b>') 

467 __M_writer(filters.html_escape(str( request.database_title ))) 

468 __M_writer('</b>.\n') 

469 if request.camcops_session.username: 

470 __M_writer(' Logged in as <b>') 

471 __M_writer(filters.html_escape(str(request.camcops_session.username ))) 

472 __M_writer('</b>.\n') 

473 if offer_main_menu: 

474 __M_writer(' ') 

475 runtime._include_file(context, 'to_main_menu.mako', _template_uri) 

476 __M_writer('\n') 

477 __M_writer('</div>\n') 

478 return '' 

479 finally: 

480 context.caller_stack._pop_frame() 

481 

482 ''' 

483 __M_BEGIN_METADATA 

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

485 __M_END_METADATA 

486 ''' 

487 

488 """ # noqa 

489 def __call__(self, value: Dict[str, Any], system: Dict[str, Any]) -> str: 

490 if DEBUG_TEMPLATE_PARAMETERS: 

491 log.debug("spec: {!r}", self.spec) 

492 log.debug("value: {}", pprint.pformat(value)) 

493 log.debug("system: {}", pprint.pformat(system)) 

494 # log.critical("\n{}", "\n ".join(get_caller_stack_info())) 

495 

496 # --------------------------------------------------------------------- 

497 # RNC extra values: 

498 # --------------------------------------------------------------------- 

499 # Note that <%! ... %> Python blocks are not themselves inherited. 

500 # So putting "import" calls in base.mako doesn't deliver the following 

501 # as ever-present variable. Instead, plumb them in like this: 

502 # 

503 # system['Routes'] = Routes 

504 # system['ViewArg'] = ViewArg 

505 # system['ViewParam'] = ViewParam 

506 # 

507 # ... except that we're better off with an import in the template 

508 

509 # Update the system dictionary with the values from the user 

510 try: 

511 system.update(value) 

512 except (TypeError, ValueError): 

513 raise ValueError('renderer was passed non-dictionary as value') 

514 

515 # Add the special "_" translation function 

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

517 system["_"] = request.gettext 

518 

519 # Check if 'context' in the dictionary 

520 context = system.pop('context', None) 

521 

522 # Rename 'context' to '_context' because Mako internally already has a 

523 # variable named 'context' 

524 if context is not None: 

525 system['_context'] = context 

526 

527 template = self.template 

528 if self.defname is not None: 

529 template = template.get_def(self.defname) 

530 # noinspection PyBroadException 

531 try: 

532 if DEBUG_TEMPLATE_PARAMETERS: 

533 log.debug("final dict to template: {}", pprint.pformat(system)) 

534 result = template.render_unicode(**system) 

535 except Exception: 

536 try: 

537 exc_info = sys.exc_info() 

538 errtext = text_error_template().render(error=exc_info[1], 

539 traceback=exc_info[2]) 

540 reraise(MakoRenderingException(errtext), None, exc_info[2]) 

541 finally: 

542 # noinspection PyUnboundLocalVariable 

543 del exc_info 

544 

545 # noinspection PyUnboundLocalVariable 

546 return result 

547 

548 

549class CamcopsMakoRendererFactory(MakoRendererFactory): 

550 """ 

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

552 """ 

553 # noinspection PyTypeChecker 

554 renderer_factory = staticmethod(CamcopsMakoLookupTemplateRenderer) 

555 

556 

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

558 """ 

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

560 

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

562 use our own lookup. 

563 

564 The ``extension`` parameter is a filename extension (e.g. ".mako"). 

565 """ 

566 renderer_factory = CamcopsMakoRendererFactory() # our special function 

567 renderer_factory.lookup = MAKO_LOOKUP # our lookup information 

568 config.add_renderer(extension, renderer_factory) # a Pyramid function 

569 

570 

571# ============================================================================= 

572# URL/route helpers 

573# ============================================================================= 

574 

575RE_VALID_REPLACEMENT_MARKER = re.compile("^[a-zA-Z_][a-zA-Z0-9_]*$") 

576# All characters must be a-z, A-Z, _, or 0-9. 

577# First character must not be a digit. 

578# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/urldispatch.html#route-pattern-syntax # noqa 

579 

580 

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

582 """ 

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

584 

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

586 

587 See :class:`UrlParam`. 

588 """ 

589 return RE_VALID_REPLACEMENT_MARKER.match(marker) is not None 

590 

591 

592class UrlParamType(Enum): 

593 """ 

594 Enum for building templatized URLs. 

595 See :class:`UrlParam`. 

596 """ 

597 STRING = 1 

598 POSITIVE_INTEGER = 2 

599 PLAIN_STRING = 3 

600 

601 

602class UrlParam(object): 

603 """ 

604 Represents a parameter within a URL. For example: 

605 

606 .. code-block:: python 

607 

608 from camcops_server.cc_modules.cc_pyramid import * 

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

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

611 

612 These fragments are suitable for building into a URL for use with Pyramid's 

613 URL Dispatch system: 

614 https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/urldispatch.html 

615 

616 See also :class:`RoutePath`. 

617 

618 """ # noqa 

619 def __init__(self, name: str, 

620 paramtype: UrlParamType == UrlParamType.PLAIN_STRING) -> None: 

621 """ 

622 Args: 

623 name: the name of the parameter 

624 paramtype: the type (e.g. string? positive integer), defined via 

625 the :class:`UrlParamType` enum. 

626 """ 

627 self.name = name 

628 self.paramtype = paramtype 

629 assert valid_replacement_marker(name), ( 

630 "UrlParam: invalid replacement marker: " + repr(name) 

631 ) 

632 

633 def regex(self) -> str: 

634 """ 

635 Returns text for a regular expression to capture the parameter value. 

636 """ 

637 if self.paramtype == UrlParamType.STRING: 

638 return '' 

639 elif self.paramtype == UrlParamType.POSITIVE_INTEGER: 

640 return r'\d+' # digits 

641 elif self.paramtype == UrlParamType.PLAIN_STRING: 

642 return r'[a-zA-Z0-9_]+' 

643 else: 

644 raise AssertionError("Bug in UrlParam") 

645 

646 def markerdef(self) -> str: 

647 """ 

648 Returns the string to use in building the URL. 

649 """ 

650 marker = self.name 

651 r = self.regex() 

652 if r: 

653 marker += ':' + r 

654 return '{' + marker + '}' 

655 

656 

657def make_url_path(base: str, *args: UrlParam) -> str: 

658 """ 

659 Makes a URL path for use with the Pyramid URL dispatch system. 

660 See :class:`UrlParam`. 

661 

662 Args: 

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

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

665 

666 Returns: 

667 the URL path, beginning with ``/`` 

668 """ 

669 parts = [] # type: List[str] 

670 if not base.startswith("/"): 

671 parts.append("/") 

672 parts += [base] + [arg.markerdef() for arg in args] 

673 return "/".join(parts) 

674 

675 

676# ============================================================================= 

677# Routes 

678# ============================================================================= 

679 

680# Class to collect constants together 

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

682class Routes(object): 

683 """ 

684 Names of Pyramid routes. 

685 

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

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

688 Pyramid route configurator. 

689 """ 

690 # Hard-coded special paths 

691 STATIC = "static" 

692 

693 # Other 

694 ADD_GROUP = "add_group" 

695 ADD_ID_DEFINITION = "add_id_definition" 

696 ADD_PATIENT = "add_patient" 

697 ADD_SPECIAL_NOTE = "add_special_note" 

698 ADD_TASK_SCHEDULE = "add_task_schedule" 

699 ADD_TASK_SCHEDULE_ITEM = "add_task_schedule_item" 

700 ADD_USER = "add_user" 

701 AUDIT_MENU = "audit_menu" 

702 BASIC_DUMP = "basic_dump" 

703 CHANGE_OTHER_PASSWORD = "change_other_password" 

704 CHANGE_OWN_PASSWORD = "change_own_password" 

705 CHOOSE_CTV = "choose_ctv" 

706 CHOOSE_TRACKER = "choose_tracker" 

707 CLIENT_API = "client_api" 

708 CLIENT_API_ALIAS = "client_api_alias" 

709 CRASH = "crash" 

710 CTV = "ctv" 

711 DELETE_FILE = "delete_file" 

712 DELETE_GROUP = "delete_group" 

713 DELETE_ID_DEFINITION = "delete_id_definition" 

714 DELETE_PATIENT = "delete_patient" 

715 DELETE_SERVER_CREATED_PATIENT = "delete_server_created_patient" 

716 DELETE_SPECIAL_NOTE = "delete_special_note" 

717 DELETE_TASK_SCHEDULE = "delete_task_schedule" 

718 DELETE_TASK_SCHEDULE_ITEM = "delete_task_schedule_item" 

719 DELETE_USER = "delete_user" 

720 DEVELOPER = "developer" 

721 DOWNLOAD_AREA = "download_area" 

722 DOWNLOAD_FILE = "download_file" 

723 EDIT_GROUP = "edit_group" 

724 EDIT_ID_DEFINITION = "edit_id_definition" 

725 EDIT_FINALIZED_PATIENT = "edit_finalized_patient" 

726 EDIT_SERVER_CREATED_PATIENT = "edit_server_created_patient" 

727 EDIT_SERVER_SETTINGS = "edit_server_settings" 

728 EDIT_TASK_SCHEDULE = "edit_task_schedule" 

729 EDIT_TASK_SCHEDULE_ITEM = "edit_task_schedule_item" 

730 EDIT_USER = "edit_user" 

731 EDIT_USER_GROUP_MEMBERSHIP = "edit_user_group_membership" 

732 ERASE_TASK_LEAVING_PLACEHOLDER = "erase_task_leaving_placeholder" 

733 ERASE_TASK_ENTIRELY = "erase_task_entirely" 

734 FHIR_PATIENT_ID = "fhir_patient_id/{which_idnum:\\d+}" 

735 FHIR_QUESTIONNAIRE_ID = "fhir_questionnaire_id" 

736 FHIR_QUESTIONNAIRE_RESPONSE_ID = "fhir_questionnaire_response_id/{tablename}" # noqa: E501 

737 FORCIBLY_FINALIZE = "forcibly_finalize" 

738 HOME = "home" 

739 LOGIN = "login" 

740 LOGOUT = "logout" 

741 OFFER_AUDIT_TRAIL = "offer_audit_trail" 

742 OFFER_EXPORTED_TASK_LIST = "offer_exported_task_list" 

743 OFFER_REGENERATE_SUMMARIES = "offer_regenerate_summary_tables" 

744 OFFER_REPORT = "offer_report" 

745 OFFER_SQL_DUMP = "offer_sql_dump" 

746 OFFER_TERMS = "offer_terms" 

747 OFFER_BASIC_DUMP = "offer_basic_dump" 

748 REPORT = "report" 

749 REPORTS_MENU = "reports_menu" 

750 SET_FILTERS = "set_filters" 

751 SET_OTHER_USER_UPLOAD_GROUP = "set_other_user_upload_group" 

752 SET_OWN_USER_UPLOAD_GROUP = "set_user_upload_group" 

753 SQL_DUMP = "sql_dump" 

754 TASK = "task" 

755 TESTPAGE_PRIVATE_1 = "testpage_private_1" 

756 TESTPAGE_PRIVATE_2 = "testpage_private_2" 

757 TESTPAGE_PRIVATE_3 = "testpage_private_3" 

758 TESTPAGE_PRIVATE_4 = "testpage_private_4" 

759 TESTPAGE_PUBLIC_1 = "testpage_public_1" 

760 TRACKER = "tracker" 

761 UNLOCK_USER = "unlock_user" 

762 VIEW_ALL_USERS = "view_all_users" 

763 VIEW_AUDIT_TRAIL = "view_audit_trail" 

764 VIEW_DDL = "view_ddl" 

765 VIEW_EMAIL = "view_email" 

766 VIEW_EXPORT_RECIPIENT = "view_export_recipient" 

767 VIEW_EXPORTED_TASK = "view_exported_task" 

768 VIEW_EXPORTED_TASK_LIST = "view_exported_task_list" 

769 VIEW_EXPORTED_TASK_EMAIL = "view_exported_task_email" 

770 VIEW_EXPORTED_TASK_FILE_GROUP = "view_exported_task_file_group" 

771 VIEW_EXPORTED_TASK_HL7_MESSAGE = "view_exported_task_hl7_message" 

772 VIEW_GROUPS = "view_groups" 

773 VIEW_ID_DEFINITIONS = "view_id_definitions" 

774 VIEW_OWN_USER_INFO = "view_own_user_info" 

775 VIEW_PATIENT_TASK_SCHEDULE = "view_patient_task_schedule" 

776 VIEW_PATIENT_TASK_SCHEDULES = "view_patient_task_schedules" 

777 VIEW_SERVER_INFO = "view_server_info" 

778 VIEW_TASKS = "view_tasks" 

779 VIEW_TASK_SCHEDULES = "view_task_schedules" 

780 VIEW_TASK_SCHEDULE_ITEMS = "view_task_schedule_items" 

781 VIEW_USER = "view_user" 

782 VIEW_USER_EMAIL_ADDRESSES = "view_user_email_addresses" 

783 XLSX_DUMP = "xlsx_dump" 

784 

785 

786class RoutePath(object): 

787 r""" 

788 Class to hold a route/path pair. 

789 

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

791 - Pyramid URL paths are URL fragments, like ``'/thing'``, and can contain 

792 placeholders, like ``'/thing/{bork_id}'``, which will result in the 

793 ``request.matchdict`` object containing a ``'bork_id'`` key. Those can be 

794 further constrained by regular expressions, like 

795 ``'/thing/{bork_id:\d+}'`` to restrict to digits. 

796 

797 """ 

798 def __init__(self, route: str, path: str = "", 

799 ignore_in_all_routes: bool = False) -> None: 

800 self.route = route 

801 self.path = path or "/" + route 

802 self.ignore_in_all_routes = ignore_in_all_routes 

803 

804 

805MASTER_ROUTE_WEBVIEW = "/" 

806MASTER_ROUTE_CLIENT_API = "/api" 

807MASTER_ROUTE_CLIENT_API_ALIAS = "/database" 

808STATIC_CAMCOPS_PACKAGE_PATH = "camcops_server.static:" 

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

810# "camcops_server" owning package 

811 

812 

813class RouteCollection(object): 

814 """ 

815 All routes, with their paths, for CamCOPS. 

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

817 

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

819 :func:`CamcopsRequest.route_url_params`. 

820 

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

822 decorator. 

823 """ 

824 # Hard-coded special paths 

825 DEBUG_TOOLBAR = RoutePath('debug_toolbar', '/_debug_toolbar/', 

826 ignore_in_all_routes=True) # hard-coded path 

827 STATIC = RoutePath(Routes.STATIC, "", # path ignored 

828 ignore_in_all_routes=True) 

829 

830 # Implemented 

831 ADD_GROUP = RoutePath(Routes.ADD_GROUP) 

832 ADD_ID_DEFINITION = RoutePath(Routes.ADD_ID_DEFINITION) 

833 ADD_PATIENT = RoutePath(Routes.ADD_PATIENT) 

834 ADD_SPECIAL_NOTE = RoutePath(Routes.ADD_SPECIAL_NOTE) 

835 ADD_TASK_SCHEDULE = RoutePath(Routes.ADD_TASK_SCHEDULE) 

836 ADD_TASK_SCHEDULE_ITEM = RoutePath(Routes.ADD_TASK_SCHEDULE_ITEM) 

837 ADD_USER = RoutePath(Routes.ADD_USER) 

838 AUDIT_MENU = RoutePath(Routes.AUDIT_MENU) 

839 BASIC_DUMP = RoutePath(Routes.BASIC_DUMP) 

840 CHANGE_OTHER_PASSWORD = RoutePath(Routes.CHANGE_OTHER_PASSWORD) 

841 CHANGE_OWN_PASSWORD = RoutePath(Routes.CHANGE_OWN_PASSWORD) 

842 CHOOSE_CTV = RoutePath(Routes.CHOOSE_CTV) 

843 CHOOSE_TRACKER = RoutePath(Routes.CHOOSE_TRACKER) 

844 CLIENT_API = RoutePath(Routes.CLIENT_API, MASTER_ROUTE_CLIENT_API) 

845 CLIENT_API_ALIAS = RoutePath(Routes.CLIENT_API_ALIAS, 

846 MASTER_ROUTE_CLIENT_API_ALIAS) 

847 CRASH = RoutePath(Routes.CRASH) 

848 CTV = RoutePath(Routes.CTV) 

849 DELETE_FILE = RoutePath(Routes.DELETE_FILE) 

850 DELETE_GROUP = RoutePath(Routes.DELETE_GROUP) 

851 DELETE_ID_DEFINITION = RoutePath(Routes.DELETE_ID_DEFINITION) 

852 DELETE_PATIENT = RoutePath(Routes.DELETE_PATIENT) 

853 DELETE_SERVER_CREATED_PATIENT = RoutePath( 

854 Routes.DELETE_SERVER_CREATED_PATIENT 

855 ) 

856 DELETE_SPECIAL_NOTE = RoutePath(Routes.DELETE_SPECIAL_NOTE) 

857 DELETE_TASK_SCHEDULE = RoutePath(Routes.DELETE_TASK_SCHEDULE) 

858 DELETE_TASK_SCHEDULE_ITEM = RoutePath(Routes.DELETE_TASK_SCHEDULE_ITEM) 

859 DELETE_USER = RoutePath(Routes.DELETE_USER) 

860 DEVELOPER = RoutePath(Routes.DEVELOPER) 

861 DOWNLOAD_AREA = RoutePath(Routes.DOWNLOAD_AREA) 

862 DOWNLOAD_FILE = RoutePath(Routes.DOWNLOAD_FILE) 

863 EDIT_GROUP = RoutePath(Routes.EDIT_GROUP) 

864 EDIT_ID_DEFINITION = RoutePath(Routes.EDIT_ID_DEFINITION) 

865 EDIT_FINALIZED_PATIENT = RoutePath(Routes.EDIT_FINALIZED_PATIENT) 

866 EDIT_SERVER_CREATED_PATIENT = RoutePath(Routes.EDIT_SERVER_CREATED_PATIENT) 

867 EDIT_SERVER_SETTINGS = RoutePath(Routes.EDIT_SERVER_SETTINGS) 

868 EDIT_TASK_SCHEDULE = RoutePath(Routes.EDIT_TASK_SCHEDULE) 

869 EDIT_TASK_SCHEDULE_ITEM = RoutePath(Routes.EDIT_TASK_SCHEDULE_ITEM) 

870 EDIT_USER = RoutePath(Routes.EDIT_USER) 

871 EDIT_USER_GROUP_MEMBERSHIP = RoutePath(Routes.EDIT_USER_GROUP_MEMBERSHIP) 

872 ERASE_TASK_LEAVING_PLACEHOLDER = RoutePath(Routes.ERASE_TASK_LEAVING_PLACEHOLDER) # noqa 

873 ERASE_TASK_ENTIRELY = RoutePath(Routes.ERASE_TASK_ENTIRELY) 

874 # TODO: FHIR Routes don't currently go anywhere 

875 FHIR_PATIENT_ID = RoutePath(Routes.FHIR_PATIENT_ID) 

876 FHIR_QUESTIONNAIRE_ID = RoutePath(Routes.FHIR_QUESTIONNAIRE_ID) 

877 FHIR_QUESTIONNAIRE_RESPONSE_ID = RoutePath(Routes.FHIR_QUESTIONNAIRE_RESPONSE_ID) # noqa: E501 

878 FORCIBLY_FINALIZE = RoutePath(Routes.FORCIBLY_FINALIZE) 

879 HOME = RoutePath(Routes.HOME, MASTER_ROUTE_WEBVIEW) # mounted at "/" 

880 LOGIN = RoutePath(Routes.LOGIN) 

881 LOGOUT = RoutePath(Routes.LOGOUT) 

882 OFFER_AUDIT_TRAIL = RoutePath(Routes.OFFER_AUDIT_TRAIL) 

883 OFFER_EXPORTED_TASK_LIST = RoutePath(Routes.OFFER_EXPORTED_TASK_LIST) 

884 OFFER_REPORT = RoutePath(Routes.OFFER_REPORT) 

885 OFFER_SQL_DUMP = RoutePath(Routes.OFFER_SQL_DUMP) 

886 OFFER_TERMS = RoutePath(Routes.OFFER_TERMS) 

887 OFFER_BASIC_DUMP = RoutePath(Routes.OFFER_BASIC_DUMP) 

888 REPORT = RoutePath(Routes.REPORT) 

889 REPORTS_MENU = RoutePath(Routes.REPORTS_MENU) 

890 SET_FILTERS = RoutePath(Routes.SET_FILTERS) 

891 SET_OTHER_USER_UPLOAD_GROUP = RoutePath(Routes.SET_OTHER_USER_UPLOAD_GROUP) 

892 SET_OWN_USER_UPLOAD_GROUP = RoutePath(Routes.SET_OWN_USER_UPLOAD_GROUP) 

893 SQL_DUMP = RoutePath(Routes.SQL_DUMP) 

894 TASK = RoutePath(Routes.TASK) 

895 TESTPAGE_PRIVATE_1 = RoutePath(Routes.TESTPAGE_PRIVATE_1) 

896 TESTPAGE_PRIVATE_2 = RoutePath(Routes.TESTPAGE_PRIVATE_2) 

897 TESTPAGE_PRIVATE_3 = RoutePath(Routes.TESTPAGE_PRIVATE_3) 

898 TESTPAGE_PRIVATE_4 = RoutePath(Routes.TESTPAGE_PRIVATE_4) 

899 TESTPAGE_PUBLIC_1 = RoutePath(Routes.TESTPAGE_PUBLIC_1) 

900 TRACKER = RoutePath(Routes.TRACKER) 

901 UNLOCK_USER = RoutePath(Routes.UNLOCK_USER) 

902 VIEW_ALL_USERS = RoutePath(Routes.VIEW_ALL_USERS) 

903 VIEW_AUDIT_TRAIL = RoutePath(Routes.VIEW_AUDIT_TRAIL) 

904 VIEW_DDL = RoutePath(Routes.VIEW_DDL) 

905 VIEW_EMAIL = RoutePath(Routes.VIEW_EMAIL) 

906 VIEW_EXPORT_RECIPIENT = RoutePath(Routes.VIEW_EXPORT_RECIPIENT) 

907 VIEW_EXPORTED_TASK = RoutePath(Routes.VIEW_EXPORTED_TASK) 

908 VIEW_EXPORTED_TASK_LIST = RoutePath(Routes.VIEW_EXPORTED_TASK_LIST) 

909 VIEW_EXPORTED_TASK_EMAIL = RoutePath(Routes.VIEW_EXPORTED_TASK_EMAIL) 

910 VIEW_EXPORTED_TASK_FILE_GROUP = RoutePath(Routes.VIEW_EXPORTED_TASK_FILE_GROUP) # noqa 

911 VIEW_EXPORTED_TASK_HL7_MESSAGE = RoutePath(Routes.VIEW_EXPORTED_TASK_HL7_MESSAGE) # noqa 

912 VIEW_GROUPS = RoutePath(Routes.VIEW_GROUPS) 

913 VIEW_ID_DEFINITIONS = RoutePath(Routes.VIEW_ID_DEFINITIONS) 

914 VIEW_OWN_USER_INFO = RoutePath(Routes.VIEW_OWN_USER_INFO) 

915 VIEW_PATIENT_TASK_SCHEDULE = RoutePath(Routes.VIEW_PATIENT_TASK_SCHEDULE) 

916 VIEW_PATIENT_TASK_SCHEDULES = RoutePath(Routes.VIEW_PATIENT_TASK_SCHEDULES) 

917 VIEW_SERVER_INFO = RoutePath(Routes.VIEW_SERVER_INFO) 

918 VIEW_TASKS = RoutePath(Routes.VIEW_TASKS) 

919 VIEW_TASK_SCHEDULES = RoutePath(Routes.VIEW_TASK_SCHEDULES) 

920 VIEW_TASK_SCHEDULE_ITEMS = RoutePath(Routes.VIEW_TASK_SCHEDULE_ITEMS) 

921 VIEW_USER = RoutePath(Routes.VIEW_USER) 

922 VIEW_USER_EMAIL_ADDRESSES = RoutePath(Routes.VIEW_USER_EMAIL_ADDRESSES) 

923 XLSX_DUMP = RoutePath(Routes.XLSX_DUMP) 

924 

925 @classmethod 

926 def all_routes(cls) -> List[RoutePath]: 

927 """ 

928 Fetch all routes for CamCOPS. 

929 """ 

930 return [ 

931 v for k, v in cls.__dict__.items() 

932 if not (k.startswith('_') or # class hidden things 

933 k == 'all_routes' or # this function 

934 v.ignore_in_all_routes) # explicitly ignored 

935 ] 

936 

937 

938# ============================================================================= 

939# Pyramid HTTP session handling 

940# ============================================================================= 

941 

942def get_session_factory() -> Callable[["CamcopsRequest"], ISession]: 

943 """ 

944 We have to give a Pyramid request a way of making an HTTP session. 

945 We must return a session factory. 

946 

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

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

949 

950 .. code-block:: none 

951 

952 sessionfactory(req: CamcopsRequest) -> session_object 

953 

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

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

956 interface" 

957 

958 - We want to be able to make the session by reading the 

959 :class:`camcops_server.cc_modules.cc_config.CamcopsConfig` from the request. 

960 

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

962 

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

964 """ # noqa 

965 

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

967 """ 

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

969 

970 .. code-block:: none 

971 

972 SignedCookieSessionFactory 

973 BaseCookieSessionFactory # pyramid/session.py 

974 CookieSession 

975 def changed(): 

976 if not self._dirty: 

977 self._dirty = True 

978 def set_cookie_callback(request, response): 

979 self._set_cookie(response) 

980 # ... 

981 self.request.add_response_callback(set_cookie_callback) 

982 

983 def _set_cookie(self, response): 

984 # ... 

985 response.set_cookie(...) 

986 

987 """ # noqa 

988 cfg = req.config 

989 secure_cookies = not cfg.allow_insecure_cookies 

990 pyramid_factory = SignedCookieSessionFactory( 

991 secret=cfg.session_cookie_secret, 

992 hashalg='sha512', # the default 

993 salt='camcops_pyramid_session.', 

994 cookie_name=COOKIE_NAME, 

995 max_age=None, # browser scope; session cookie 

996 path='/', # the default 

997 domain=None, # the default 

998 secure=secure_cookies, 

999 httponly=secure_cookies, 

1000 timeout=None, # we handle timeouts at the database level instead 

1001 reissue_time=0, # default; reissue cookie at every request 

1002 set_on_exception=True, # (default) cookie even if exception raised 

1003 serializer=None, # (default) use pyramid.session.PickleSerializer 

1004 # As max_age and expires are left at their default of None, these 

1005 # are session cookies. 

1006 ) 

1007 return pyramid_factory(req) 

1008 

1009 return factory 

1010 

1011 

1012# ============================================================================= 

1013# Authentication; authorization (permissions) 

1014# ============================================================================= 

1015 

1016class Permission(object): 

1017 """ 

1018 Pyramid permission values. 

1019 

1020 - Permissions are strings. 

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

1022 """ 

1023 GROUPADMIN = "groupadmin" 

1024 HAPPY = "happy" # logged in, can use webview, no need to change p/w, agreed to terms # noqa 

1025 MUST_AGREE_TERMS = "must_agree_terms" 

1026 MUST_CHANGE_PASSWORD = "must_change_password" 

1027 SUPERUSER = "superuser" 

1028 

1029 

1030@implementer(IAuthenticationPolicy) 

1031class CamcopsAuthenticationPolicy(object): 

1032 """ 

1033 CamCOPS authentication policy. 

1034 

1035 See 

1036 

1037 - https://docs.pylonsproject.org/projects/pyramid/en/latest/tutorials/wiki2/authorization.html 

1038 - https://docs.pylonsproject.org/projects/pyramid-cookbook/en/latest/auth/custom.html 

1039 - Don't actually inherit from :class:`IAuthenticationPolicy`; it ends up in 

1040 the :class:`zope.interface.interface.InterfaceClass` metaclass and then 

1041 breaks with "zope.interface.exceptions.InvalidInterface: Concrete 

1042 attribute, ..." 

1043 - But ``@implementer`` does the trick. 

1044 """ # noqa 

1045 

1046 @staticmethod 

1047 def authenticated_userid(request: "CamcopsRequest") -> Optional[int]: 

1048 """ 

1049 Returns the user ID of the authenticated user. 

1050 """ 

1051 return request.user_id 

1052 

1053 # noinspection PyUnusedLocal 

1054 @staticmethod 

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

1056 """ 

1057 Returns the user ID of the unauthenticated user. 

1058 

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

1060 return ``None``. 

1061 """ 

1062 return None 

1063 

1064 @staticmethod 

1065 def effective_principals(request: "CamcopsRequest") -> List[str]: 

1066 """ 

1067 Returns a list of strings indicating permissions that the current user 

1068 has. 

1069 """ 

1070 principals = [Everyone] 

1071 user = request.user 

1072 if user is not None: 

1073 principals += [Authenticated, 'u:%s' % user.id] 

1074 if user.may_use_webviewer: 

1075 if user.must_change_password: 

1076 principals.append(Permission.MUST_CHANGE_PASSWORD) 

1077 elif user.must_agree_terms: 

1078 principals.append(Permission.MUST_AGREE_TERMS) 

1079 else: 

1080 principals.append(Permission.HAPPY) 

1081 if user.superuser: 

1082 principals.append(Permission.SUPERUSER) 

1083 if user.authorized_as_groupadmin: 

1084 principals.append(Permission.GROUPADMIN) 

1085 # principals.extend(('g:%s' % g.name for g in user.groups)) 

1086 if DEBUG_EFFECTIVE_PRINCIPALS: 

1087 log.debug("effective_principals: {!r}", principals) 

1088 return principals 

1089 

1090 # noinspection PyUnusedLocal 

1091 @staticmethod 

1092 def remember(request: "CamcopsRequest", 

1093 userid: int, 

1094 **kw) -> List[Tuple[str, str]]: 

1095 return [] 

1096 

1097 # noinspection PyUnusedLocal 

1098 @staticmethod 

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

1100 return [] 

1101 

1102 

1103@implementer(IAuthorizationPolicy) 

1104class CamcopsAuthorizationPolicy(object): 

1105 """ 

1106 CamCOPS authorization policy. 

1107 """ 

1108 # noinspection PyUnusedLocal 

1109 @staticmethod 

1110 def permits(context: ILocation, principals: List[str], permission: str) \ 

1111 -> PermitsResult: 

1112 if permission in principals: 

1113 return Allowed(f"ALLOWED: permission {permission} present in " 

1114 f"principals {principals}") 

1115 

1116 return Denied(f"DENIED: permission {permission} not in principals " 

1117 f"{principals}") 

1118 

1119 @staticmethod 

1120 def principals_allowed_by_permission(context: ILocation, 

1121 permission: str) -> List[str]: 

1122 raise NotImplementedError() # don't care about this method 

1123 

1124 

1125# ============================================================================= 

1126# Pagination 

1127# ============================================================================= 

1128# WebHelpers 1.3 doesn't support Python 3.5. 

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

1130 

1131class SqlalchemyOrmQueryWrapper(object): 

1132 """ 

1133 Wrapper class to access elements of an SQLAlchemy ORM query in an efficient 

1134 way for pagination. We only ask the database for what we need. 

1135 

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

1137 ``LIMIT/OFFSET``.) 

1138 

1139 See: 

1140 

1141 - https://docs.pylonsproject.org/projects/pylons-webframework/en/latest/helpers.html 

1142 - https://docs.pylonsproject.org/projects/webhelpers/en/latest/modules/paginate.html 

1143 - https://github.com/Pylons/paginate 

1144 """ # noqa 

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

1146 self.query = query 

1147 

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

1149 """ 

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

1151 object. 

1152 

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

1154 """ 

1155 return self.query[cut] 

1156 

1157 def __len__(self) -> int: 

1158 """ 

1159 Count the number of objects in an :class:`sqlalchemy.orm.query.Query`` 

1160 object. 

1161 """ 

1162 return self.query.count() 

1163 

1164 

1165class CamcopsPage(Page): 

1166 """ 

1167 Pagination class, for HTML views that display, for example, 

1168 items 1-20 and buttons like "page 2", "next page", "last page". 

1169 

1170 - Fixes a bug in paginate: it slices its collection BEFORE it realizes that 

1171 the page number is out of range. 

1172 - Also, it uses ``..`` for an ellipsis, which is just wrong. 

1173 """ 

1174 # noinspection PyShadowingBuiltins 

1175 def __init__(self, 

1176 collection: Union[Sequence[Any], Query, Select], 

1177 url_maker: Callable[[int], str], 

1178 request: "CamcopsRequest", 

1179 page: int = 1, 

1180 items_per_page: int = 20, 

1181 item_count: int = None, 

1182 wrapper_class: Type[Any] = None, 

1183 ellipsis: str = "&hellip;", 

1184 **kwargs) -> None: 

1185 """ 

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

1187 

1188 Args: 

1189 ellipsis: HTML text to use as the ellipsis marker 

1190 """ 

1191 self.request = request 

1192 self.ellipsis = ellipsis 

1193 page = max(1, page) 

1194 if item_count is None: 

1195 if wrapper_class: 

1196 item_count = len(wrapper_class(collection)) 

1197 else: 

1198 item_count = len(collection) 

1199 n_pages = ((item_count - 1) // items_per_page) + 1 

1200 page = min(page, n_pages) 

1201 super().__init__( 

1202 collection=collection, 

1203 page=page, 

1204 items_per_page=items_per_page, 

1205 item_count=item_count, 

1206 wrapper_class=wrapper_class, 

1207 url_maker=url_maker, 

1208 **kwargs 

1209 ) 

1210 # Original defines attributes outside __init__, so: 

1211 self.radius = 2 

1212 self.curpage_attr = {} # type: Dict[str, str] 

1213 self.separator = "" 

1214 self.link_attr = {} # type: Dict[str, str] 

1215 self.dotdot_attr = {} # type: Dict[str, str] 

1216 self.url = "" 

1217 

1218 # noinspection PyShadowingBuiltins 

1219 def pager(self, 

1220 format: str = None, 

1221 url: str = None, 

1222 show_if_single_page: bool = True, # see below! 

1223 separator: str = ' ', 

1224 symbol_first: str = '&lt;&lt;', 

1225 symbol_last: str = '&gt;&gt;', 

1226 symbol_previous: str = '&lt;', 

1227 symbol_next: str = '&gt;', 

1228 link_attr: Dict[str, str] = None, 

1229 curpage_attr: Dict[str, str] = None, 

1230 dotdot_attr: Dict[str, str] = None, 

1231 link_tag: Callable[[Dict[str, str]], str] = None): 

1232 """ 

1233 See :func:`paginate.Page.pager`. 

1234 

1235 The reason for the default for ``show_if_single_page`` being ``True`` 

1236 is that it's possible otherwise to think you've lost your tasks. For 

1237 example: (1) have 99 tasks; (2) view 50/page; (3) go to page 2; (4) set 

1238 number per page to 100. Or simply use the URL to go beyond the end. 

1239 """ 

1240 format = format or self.default_pager_pattern() 

1241 link_attr = link_attr or {} # type: Dict[str, str] 

1242 curpage_attr = curpage_attr or {} # type: Dict[str, str] 

1243 # dotdot_attr = dotdot_attr or {} # type: Dict[str, str] 

1244 # dotdot_attr = dotdot_attr or {'class': 'pager_dotdot'} # our default! 

1245 return super().pager( 

1246 format=format, 

1247 url=url, 

1248 show_if_single_page=show_if_single_page, 

1249 separator=separator, 

1250 symbol_first=symbol_first, 

1251 symbol_last=symbol_last, 

1252 symbol_previous=symbol_previous, 

1253 symbol_next=symbol_next, 

1254 link_attr=link_attr, 

1255 curpage_attr=curpage_attr, 

1256 dotdot_attr=dotdot_attr, 

1257 link_tag=link_tag, 

1258 ) 

1259 

1260 def default_pager_pattern(self) -> str: 

1261 """ 

1262 Allows internationalization of the pager pattern. 

1263 """ 

1264 _ = self.request.gettext 

1265 xlated = _("Page $page of $page_count; total $item_count records") 

1266 return ( 

1267 f"({xlated}) " 

1268 f"[ $link_first $link_previous ~3~ $link_next $link_last ]" 

1269 ) 

1270 

1271 # noinspection PyShadowingBuiltins 

1272 def link_map(self, 

1273 format: str = '~2~', 

1274 url: str = None, 

1275 show_if_single_page: bool = False, 

1276 separator: str = ' ', 

1277 symbol_first: str = '&lt;&lt;', 

1278 symbol_last: str = '&gt;&gt;', 

1279 symbol_previous: str = '&lt;', 

1280 symbol_next: str = '&gt;', 

1281 link_attr: Dict[str, str] = None, 

1282 curpage_attr: Dict[str, str] = None, 

1283 dotdot_attr: Dict[str, str] = None): 

1284 """ 

1285 See equivalent in superclass. 

1286 

1287 Fixes bugs (e.g. mutable default arguments) and nasties (e.g. 

1288 enforcing ".." for the ellipsis) in the original. 

1289 """ 

1290 self.curpage_attr = curpage_attr or {} # type: Dict[str, str] 

1291 self.separator = separator 

1292 self.link_attr = link_attr or {} # type: Dict[str, str] 

1293 self.dotdot_attr = dotdot_attr or {} # type: Dict[str, str] 

1294 self.url = url 

1295 

1296 regex_res = re.search(r'~(\d+)~', format) 

1297 if regex_res: 

1298 radius = regex_res.group(1) 

1299 else: 

1300 radius = 2 

1301 radius = int(radius) 

1302 self.radius = radius 

1303 

1304 # Compute the first and last page number within the radius 

1305 # e.g. '1 .. 5 6 [7] 8 9 .. 12' 

1306 # -> leftmost_page = 5 

1307 # -> rightmost_page = 9 

1308 leftmost_page = ( 

1309 max(self.first_page, (self.page - radius)) 

1310 if self.first_page else None 

1311 ) # type: Optional[int] 

1312 rightmost_page = ( 

1313 min(self.last_page, (self.page+radius)) 

1314 if self.last_page else None 

1315 ) # type: Optional[int] 

1316 nav_items = { 

1317 "first_page": None, 

1318 "last_page": None, 

1319 "previous_page": None, 

1320 "next_page": None, 

1321 "current_page": None, 

1322 "radius": self.radius, 

1323 "range_pages": [] 

1324 } # type: Dict[str, Any] 

1325 

1326 if leftmost_page is None or rightmost_page is None: 

1327 return nav_items 

1328 

1329 nav_items["first_page"] = { 

1330 "type": "first_page", 

1331 "value": symbol_first, 

1332 "attrs": self.link_attr, 

1333 "number": self.first_page, 

1334 "href": self.url_maker(self.first_page) 

1335 } 

1336 

1337 # Insert dots if there are pages between the first page 

1338 # and the currently displayed page range 

1339 if leftmost_page - self.first_page > 1: 

1340 # Wrap in a SPAN tag if dotdot_attr is set 

1341 nav_items["range_pages"].append({ 

1342 "type": "span", 

1343 "value": self.ellipsis, 

1344 "attrs": self.dotdot_attr, 

1345 "href": "", 

1346 "number": None 

1347 }) 

1348 

1349 for thispage in range(leftmost_page, rightmost_page + 1): 

1350 # Highlight the current page number and do not use a link 

1351 if thispage == self.page: 

1352 # Wrap in a SPAN tag if curpage_attr is set 

1353 nav_items["range_pages"].append({ 

1354 "type": "current_page", 

1355 "value": str(thispage), 

1356 "number": thispage, 

1357 "attrs": self.curpage_attr, 

1358 "href": self.url_maker(thispage) 

1359 }) 

1360 nav_items["current_page"] = { 

1361 "value": thispage, 

1362 "attrs": self.curpage_attr, 

1363 "type": "current_page", 

1364 "href": self.url_maker(thispage) 

1365 } 

1366 # Otherwise create just a link to that page 

1367 else: 

1368 nav_items["range_pages"].append({ 

1369 "type": "page", 

1370 "value": str(thispage), 

1371 "number": thispage, 

1372 "attrs": self.link_attr, 

1373 "href": self.url_maker(thispage) 

1374 }) 

1375 

1376 # Insert dots if there are pages between the displayed 

1377 # page numbers and the end of the page range 

1378 if self.last_page - rightmost_page > 1: 

1379 # Wrap in a SPAN tag if dotdot_attr is set 

1380 nav_items["range_pages"].append({ 

1381 "type": "span", 

1382 "value": self.ellipsis, 

1383 "attrs": self.dotdot_attr, 

1384 "href": "", 

1385 "number": None 

1386 }) 

1387 

1388 # Create a link to the very last page (unless we are on the last 

1389 # page or there would be no need to insert '..' spacers) 

1390 nav_items["last_page"] = { 

1391 "type": "last_page", 

1392 "value": symbol_last, 

1393 "attrs": self.link_attr, 

1394 "href": self.url_maker(self.last_page), 

1395 "number": self.last_page 

1396 } 

1397 nav_items["previous_page"] = { 

1398 "type": "previous_page", 

1399 "value": symbol_previous, 

1400 "attrs": self.link_attr, 

1401 "number": self.previous_page or self.first_page, 

1402 "href": self.url_maker(self.previous_page or self.first_page) 

1403 } 

1404 nav_items["next_page"] = { 

1405 "type": "next_page", 

1406 "value": symbol_next, 

1407 "attrs": self.link_attr, 

1408 "number": self.next_page or self.last_page, 

1409 "href": self.url_maker(self.next_page or self.last_page) 

1410 } 

1411 return nav_items 

1412 

1413 

1414class SqlalchemyOrmPage(CamcopsPage): 

1415 """ 

1416 A pagination page that paginates SQLAlchemy ORM queries efficiently. 

1417 """ 

1418 def __init__(self, 

1419 query: Query, 

1420 url_maker: Callable[[int], str], 

1421 request: "CamcopsRequest", 

1422 page: int = 1, 

1423 items_per_page: int = DEFAULT_ROWS_PER_PAGE, 

1424 item_count: int = None, 

1425 **kwargs) -> None: 

1426 # Since views may accidentally throw strings our way: 

1427 assert isinstance(page, int) 

1428 assert isinstance(items_per_page, int) 

1429 assert isinstance(item_count, int) or item_count is None 

1430 super().__init__( 

1431 collection=query, 

1432 request=request, 

1433 page=page, 

1434 items_per_page=items_per_page, 

1435 item_count=item_count, 

1436 wrapper_class=SqlalchemyOrmQueryWrapper, 

1437 url_maker=url_maker, 

1438 **kwargs 

1439 ) 

1440 

1441 

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

1443# modified a bit: 

1444 

1445def make_page_url(path: str, params: Dict[str, str], page: int, 

1446 partial: bool = False, sort: bool = True) -> str: 

1447 """ 

1448 A helper function for URL generators. 

1449 

1450 I assemble a URL from its parts. I assume that a link to a certain page is 

1451 done by overriding the 'page' query parameter. 

1452 

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

1454 

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

1456 

1457 ``page`` is the target page number. 

1458 

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

1460 calls requesting a partial page. 

1461 

1462 If ``sort`` is true (default), the parameters will be sorted. Otherwise 

1463 they'll be in whatever order the dict iterates them. 

1464 """ 

1465 params = params.copy() 

1466 params["page"] = str(page) 

1467 if partial: 

1468 params["partial"] = "1" 

1469 if sort: 

1470 params = sorted(params.items()) 

1471 qs = urlencode(params, True) # was urllib.urlencode, but changed in Py3.5 

1472 return "%s?%s" % (path, qs) 

1473 

1474 

1475class PageUrl(object): 

1476 """ 

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

1478 

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

1480 query parameter. 

1481 

1482 I'm suitable for Pyramid, Pylons, and TurboGears, as well as any other 

1483 framework whose Request object has 'application_url', 'path', and 'GET' 

1484 attributes that behave the same way as ``webob.Request``'s. 

1485 """ 

1486 

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

1488 """ 

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

1490 

1491 If ``qualified`` is false (default), generated URLs will have just the 

1492 path and query string. If true, the "scheme://host" prefix will be 

1493 included. The default is false to match traditional usage, and to avoid 

1494 generating unuseable URLs behind reverse proxies (e.g., Apache's 

1495 mod_proxy). 

1496 """ 

1497 self.request = request 

1498 self.qualified = qualified 

1499 

1500 def __call__(self, page: int, partial: bool = False) -> str: 

1501 """ 

1502 Generate a URL for the specified page. 

1503 """ 

1504 if self.qualified: 

1505 path = self.request.application_url 

1506 else: 

1507 path = self.request.path 

1508 return make_page_url(path, self.request.GET, page, partial) 

1509 

1510 

1511# ============================================================================= 

1512# Debugging requests and responses 

1513# ============================================================================= 

1514 

1515def get_body_from_request(req: Request) -> bytes: 

1516 """ 

1517 Debugging function to read the body from an HTTP request. 

1518 May not work and will warn accordingly. Use Wireshark to be sure 

1519 (https://www.wireshark.org/). 

1520 """ 

1521 log.warning("Attempting to read body from request -- but a previous read " 

1522 "may have left this empty. Consider using Wireshark!") 

1523 wsgi_input = req.environ[WsgiEnvVar.WSGI_INPUT] 

1524 # ... under gunicorn, is an instance of gunicorn.http.body.Body 

1525 return wsgi_input.read() 

1526 

1527 

1528class HTTPFoundDebugVersion(HTTPFound): 

1529 """ 

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

1531 """ 

1532 def __init__(self, location: str = '', **kwargs) -> None: 

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

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