Coverage for cc_modules/webview.py: 25%

2278 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-11-08 23:14 +0000

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/cc_modules/webview.py 

5 

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

7 

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

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

10 

11 This file is part of CamCOPS. 

12 

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

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

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

16 (at your option) any later version. 

17 

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

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

20 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

21 GNU General Public License for more details. 

22 

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

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

25 

26=============================================================================== 

27 

28**Implements the CamCOPS web front end.** 

29 

30Quick tutorial on Pyramid views: 

31 

32- The configurator registers routes, and routes have URLs associated with 

33 them. Those URLs can be templatized, e.g. to accept numerical parameters. 

34 The configurator associates view callables ("views" for short) with routes, 

35 and one method for doing that is an automatic scan via Venusian for views 

36 decorated with @view_config(). 

37 

38- All views take a Request object and return a Response or raise an exception 

39 that Pyramid will translate into a Response. 

40 

41- Having matched a route, Pyramid uses its "view lookup" process to choose 

42 one from potentially several views. For example, a single route might be 

43 associated with: 

44 

45 .. code-block:: python 

46 

47 @view_config(route_name="myroute") 

48 def myroute_default(req: Request) -> Response: 

49 pass 

50 

51 @view_config(route_name="myroute", request_method="POST") 

52 def myroute_post(req: Request) -> Response: 

53 pass 

54 

55 In this example, POST requests will go to the second; everything else will 

56 go to the first. Pyramid's view lookup rule is essentially: if multiple 

57 views match, choose the one with the most specifiers. 

58 

59- Specifiers include: 

60 

61 .. code-block:: none 

62 

63 route_name=ROUTENAME 

64 

65 the route 

66 

67 request_method="POST" 

68 

69 requires HTTP GET, POST, etc. 

70 

71 request_param="XXX" 

72 

73 ... requires the presence of a GET/POST variable with this name in 

74 the request.params dictionary 

75 

76 request_param="XXX=YYY" 

77 

78 ... requires the presence of a GET/POST variable called XXX whose 

79 value is YYY, in the request.params dictionary 

80 

81 match_param="XXX=YYY" 

82 

83 .. requires the presence of this key/value pair in 

84 request.matchdict, which contains parameters from the URL 

85 

86 https://docs.pylonsproject.org/projects/pyramid/en/latest/api/config.html#pyramid.config.Configurator.add_view # noqa 

87 

88- Getting parameters 

89 

90 .. code-block:: none 

91 

92 request.params 

93 

94 ... parameters from HTTP GET or POST, including both the query 

95 string (as in https://somewhere/path?key=value) and the body (e.g. 

96 POST). 

97 

98 request.matchdict 

99 

100 ... parameters from the URL, via URL dispatch; see 

101 https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/urldispatch.html#urldispatch-chapter # noqa 

102 

103- Regarding rendering: 

104 

105 There might be some simplicity benefits from converting to a template 

106 system like Mako. On the downside, that would entail a bit more work; 

107 likely a minor performance hit (relative to plain Python string rendering); 

108 and a loss of type checking. The type checking is also why we prefer: 

109 

110 .. code-block:: python 

111 

112 html = " ... {param_blah} ...".format(param_blah=PARAM.BLAH) 

113 

114 to 

115 

116 .. code-block:: python 

117 

118 html = " ... {PARAM.BLAH} ...".format(PARAM=PARAM) 

119 

120 as in the first situation, PyCharm will check that "BLAH" is present in 

121 "PARAM", and in the second it won't. Automatic checking is worth a lot. 

122 

123""" 

124 

125from collections import OrderedDict 

126import json 

127import logging 

128import os 

129 

130# from pprint import pformat 

131import time 

132from typing import ( 

133 Any, 

134 cast, 

135 Dict, 

136 List, 

137 NoReturn, 

138 Optional, 

139 Tuple, 

140 Type, 

141 TYPE_CHECKING, 

142) 

143 

144from cardinal_pythonlib.datetimefunc import format_datetime 

145from cardinal_pythonlib.deform_utils import get_head_form_html 

146from cardinal_pythonlib.httpconst import HttpMethod, MimeType 

147from cardinal_pythonlib.logs import BraceStyleAdapter 

148from cardinal_pythonlib.pyramid.responses import ( 

149 BinaryResponse, 

150 JsonResponse, 

151 PdfResponse, 

152 XmlResponse, 

153) 

154from cardinal_pythonlib.sqlalchemy.dialect import ( 

155 get_dialect_name, 

156 SqlaDialectName, 

157) 

158from cardinal_pythonlib.sizeformatter import bytes2human 

159from cardinal_pythonlib.sqlalchemy.orm_inspect import gen_orm_classes_from_base 

160from cardinal_pythonlib.sqlalchemy.orm_query import CountStarSpecializedQuery 

161from cardinal_pythonlib.sqlalchemy.session import get_engine_from_session 

162from deform.exception import ValidationFailure 

163from pendulum import DateTime as Pendulum 

164import pyotp 

165from pyramid.httpexceptions import HTTPBadRequest, HTTPFound, HTTPNotFound 

166from pyramid.view import ( 

167 forbidden_view_config, 

168 notfound_view_config, 

169 view_config, 

170) 

171from pyramid.renderers import render_to_response 

172from pyramid.response import Response 

173from pyramid.security import Authenticated, NO_PERMISSION_REQUIRED 

174import pygments 

175import pygments.lexers 

176import pygments.lexers.sql 

177import pygments.lexers.web 

178import pygments.formatters 

179from sqlalchemy.orm import joinedload, Query 

180from sqlalchemy.sql.functions import func 

181from sqlalchemy.sql.expression import desc, or_, select, update 

182 

183from camcops_server.cc_modules.cc_audit import audit, AuditEntry 

184from camcops_server.cc_modules.cc_all_models import CLIENT_TABLE_MAP 

185from camcops_server.cc_modules.cc_client_api_core import ( 

186 BatchDetails, 

187 get_server_live_records, 

188 UploadTableChanges, 

189 values_preserve_now, 

190) 

191from camcops_server.cc_modules.cc_client_api_helpers import ( 

192 upload_commit_order_sorter, 

193) 

194from camcops_server.cc_modules.cc_constants import ( 

195 CAMCOPS_URL, 

196 DateFormat, 

197 ERA_NOW, 

198 GITHUB_RELEASES_URL, 

199 JSON_INDENT, 

200 MfaMethod, 

201) 

202from camcops_server.cc_modules.cc_db import ( 

203 GenericTabletRecordMixin, 

204 FN_DEVICE_ID, 

205 FN_ERA, 

206 FN_GROUP_ID, 

207 FN_PK, 

208) 

209from camcops_server.cc_modules.cc_device import Device 

210from camcops_server.cc_modules.cc_email import Email 

211from camcops_server.cc_modules.cc_export import ( 

212 DownloadOptions, 

213 make_exporter, 

214 UserDownloadFile, 

215) 

216from camcops_server.cc_modules.cc_exportmodels import ( 

217 ExportedTask, 

218 ExportedTaskEmail, 

219 ExportedTaskFhir, 

220 ExportedTaskFhirEntry, 

221 ExportedTaskFileGroup, 

222 ExportedTaskHL7Message, 

223 ExportedTaskRedcap, 

224) 

225from camcops_server.cc_modules.cc_exportrecipient import ExportRecipient 

226from camcops_server.cc_modules.cc_forms import ( 

227 AddGroupForm, 

228 AddIdDefinitionForm, 

229 AddSpecialNoteForm, 

230 AddUserGroupadminForm, 

231 AddUserSuperuserForm, 

232 AuditTrailForm, 

233 ChangeOtherPasswordForm, 

234 ChangeOwnPasswordForm, 

235 ChooseTrackerForm, 

236 DEFORM_ACCORDION_BUG, 

237 DEFAULT_ROWS_PER_PAGE, 

238 DeleteGroupForm, 

239 DeleteIdDefinitionForm, 

240 DeletePatientChooseForm, 

241 DeletePatientConfirmForm, 

242 DeleteServerCreatedPatientForm, 

243 DeleteSpecialNoteForm, 

244 DeleteTaskScheduleForm, 

245 DeleteTaskScheduleItemForm, 

246 DeleteUserForm, 

247 EDIT_PATIENT_SIMPLE_PARAMS, 

248 EditFinalizedPatientForm, 

249 EditGroupForm, 

250 EditIdDefinitionForm, 

251 EditOtherUserMfaForm, 

252 EditServerCreatedPatientForm, 

253 EditServerSettingsForm, 

254 EditTaskFilterForm, 

255 EditTaskScheduleForm, 

256 EditTaskScheduleItemForm, 

257 EditUserFullForm, 

258 EditUserGroupAdminForm, 

259 EditUserGroupMembershipGroupAdminForm, 

260 EditUserGroupPermissionsFullForm, 

261 EraseTaskForm, 

262 ExportedTaskListForm, 

263 ForciblyFinalizeChooseDeviceForm, 

264 ForciblyFinalizeConfirmForm, 

265 get_sql_dialect_choices, 

266 LoginForm, 

267 MfaHotpEmailForm, 

268 MfaHotpSmsForm, 

269 MfaMethodForm, 

270 MfaTotpForm, 

271 OfferBasicDumpForm, 

272 OfferSqlDumpForm, 

273 OfferTermsForm, 

274 OtpTokenForm, 

275 RefreshTasksForm, 

276 SendEmailForm, 

277 SetUserUploadGroupForm, 

278 TasksPerPageForm, 

279 UserDownloadDeleteForm, 

280 UserFilterForm, 

281 ViewDdlForm, 

282) 

283from camcops_server.cc_modules.cc_group import Group 

284from camcops_server.cc_modules.cc_idnumdef import IdNumDefinition 

285from camcops_server.cc_modules.cc_membership import UserGroupMembership 

286from camcops_server.cc_modules.cc_patient import Patient 

287from camcops_server.cc_modules.cc_patientidnum import PatientIdNum 

288 

289# noinspection PyUnresolvedReferences 

290import camcops_server.cc_modules.cc_plot # import side effects (configure matplotlib) # noqa 

291from camcops_server.cc_modules.cc_pyramid import ( 

292 CamcopsPage, 

293 FlashQueue, 

294 FormAction, 

295 HTTPFoundDebugVersion, 

296 Icons, 

297 PageUrl, 

298 Permission, 

299 Routes, 

300 SqlalchemyOrmPage, 

301 ViewArg, 

302 ViewParam, 

303) 

304from camcops_server.cc_modules.cc_report import get_report_instance 

305from camcops_server.cc_modules.cc_request import CamcopsRequest 

306from camcops_server.cc_modules.cc_simpleobjects import ( 

307 IdNumReference, 

308 TaskExportOptions, 

309) 

310from camcops_server.cc_modules.cc_specialnote import SpecialNote 

311from camcops_server.cc_modules.cc_session import CamcopsSession 

312from camcops_server.cc_modules.cc_sqlalchemy import get_all_ddl 

313from camcops_server.cc_modules.cc_task import ( 

314 tablename_to_task_class_dict, 

315 Task, 

316) 

317from camcops_server.cc_modules.cc_taskcollection import ( 

318 TaskFilter, 

319 TaskCollection, 

320 TaskSortMethod, 

321) 

322from camcops_server.cc_modules.cc_taskfactory import task_factory 

323from camcops_server.cc_modules.cc_taskfilter import ( 

324 task_classes_from_table_names, 

325 TaskClassSortMethod, 

326) 

327from camcops_server.cc_modules.cc_taskindex import ( 

328 PatientIdNumIndexEntry, 

329 TaskIndexEntry, 

330 update_indexes_and_push_exports, 

331) 

332from camcops_server.cc_modules.cc_taskschedule import ( 

333 PatientTaskSchedule, 

334 PatientTaskScheduleEmail, 

335 TaskSchedule, 

336 TaskScheduleItem, 

337 task_schedule_item_sort_order, 

338) 

339from camcops_server.cc_modules.cc_text import SS 

340from camcops_server.cc_modules.cc_tracker import ClinicalTextView, Tracker 

341from camcops_server.cc_modules.cc_user import ( 

342 SecurityAccountLockout, 

343 SecurityLoginFailure, 

344 User, 

345) 

346from camcops_server.cc_modules.cc_validators import ( 

347 validate_download_filename, 

348 validate_export_recipient_name, 

349 validate_ip_address, 

350 validate_task_tablename, 

351 validate_username, 

352) 

353from camcops_server.cc_modules.cc_version import CAMCOPS_SERVER_VERSION 

354from camcops_server.cc_modules.cc_view_classes import ( 

355 CreateView, 

356 DeleteView, 

357 FormView, 

358 FormWizardMixin, 

359 UpdateView, 

360) 

361 

362if TYPE_CHECKING: 

363 # noinspection PyUnresolvedReferences 

364 from deform.form import Form 

365 

366 # noinspection PyUnresolvedReferences 

367 from camcops_server.cc_modules.cc_sqlalchemy import Base 

368 

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

370 

371 

372# ============================================================================= 

373# Debugging options 

374# ============================================================================= 

375 

376DEBUG_REDIRECT = False 

377 

378if DEBUG_REDIRECT: 

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

380 

381if DEBUG_REDIRECT: 

382 HTTPFound = HTTPFoundDebugVersion # noqa: F811 

383 

384 

385# ============================================================================= 

386# Cache control, for the http_cache parameter of view_config etc. 

387# ============================================================================= 

388 

389NEVER_CACHE = 0 

390 

391 

392# ============================================================================= 

393# Constants -- for Mako templates 

394# ============================================================================= 

395# Keys that will be added to a context dictionary that is passed to a Mako 

396# template. For example, a key of "title" can be rendered within the template 

397# as ${title}. Some are used frequently, so we have them here as constants. 

398 

399MAKO_VAR_TITLE = "title" 

400TEMPLATE_GENERIC_FORM = "generic_form.mako" 

401 

402 

403# ============================================================================= 

404# Constants -- mutated into translated phrases 

405# ============================================================================= 

406 

407 

408def errormsg_cannot_dump(req: "CamcopsRequest") -> str: 

409 _ = req.gettext 

410 return _("User not authorized to dump data (for any group).") 

411 

412 

413def errormsg_cannot_report(req: "CamcopsRequest") -> str: 

414 _ = req.gettext 

415 return _("User not authorized to run reports (for any group).") 

416 

417 

418def errormsg_task_live(req: "CamcopsRequest") -> str: 

419 _ = req.gettext 

420 return _("Task is live on tablet; finalize (or force-finalize) first.") 

421 

422 

423# ============================================================================= 

424# Unused 

425# ============================================================================= 

426 

427# def query_result_html_core(req: "CamcopsRequest", 

428# descriptions: Sequence[str], 

429# rows: Sequence[Sequence[Any]], 

430# null_html: str = "<i>NULL</i>") -> str: 

431# return render("query_result_core.mako", 

432# dict(descriptions=descriptions, 

433# rows=rows, 

434# null_html=null_html), 

435# request=req) 

436 

437 

438# def query_result_html_orm(req: "CamcopsRequest", 

439# attrnames: List[str], 

440# descriptions: List[str], 

441# orm_objects: Sequence[Sequence[Any]], 

442# null_html: str = "<i>NULL</i>") -> str: 

443# return render("query_result_orm.mako", 

444# dict(attrnames=attrnames, 

445# descriptions=descriptions, 

446# orm_objects=orm_objects, 

447# null_html=null_html), 

448# request=req) 

449 

450 

451# ============================================================================= 

452# Error views 

453# ============================================================================= 

454 

455# noinspection PyUnusedLocal 

456@notfound_view_config(renderer="not_found.mako", http_cache=NEVER_CACHE) 

457def not_found(req: "CamcopsRequest") -> Dict[str, Any]: 

458 """ 

459 "Page not found" view. 

460 """ 

461 return {"msg": "", "extra_html": ""} 

462 

463 

464# noinspection PyUnusedLocal 

465@view_config( 

466 context=HTTPBadRequest, renderer="bad_request.mako", http_cache=NEVER_CACHE 

467) 

468def bad_request(req: "CamcopsRequest") -> Dict[str, Any]: 

469 """ 

470 "Bad request" view. 

471 

472 NOTE that this view only gets used from 

473 

474 .. code-block:: python 

475 

476 raise HTTPBadRequest("message") 

477 

478 and not 

479 

480 .. code-block:: python 

481 

482 return HTTPBadRequest("message") 

483 

484 ... so always raise it. 

485 """ 

486 return {"msg": "", "extra_html": ""} 

487 

488 

489# ============================================================================= 

490# Test pages 

491# ============================================================================= 

492 

493# noinspection PyUnusedLocal 

494@view_config( 

495 route_name=Routes.TESTPAGE_PUBLIC_1, 

496 permission=NO_PERMISSION_REQUIRED, 

497 http_cache=NEVER_CACHE, 

498) 

499def test_page_1(req: "CamcopsRequest") -> Response: 

500 """ 

501 A public test page with no content. 

502 """ 

503 _ = req.gettext 

504 return Response(_("Hello! This is a public CamCOPS test page.")) 

505 

506 

507# noinspection PyUnusedLocal 

508@view_config( 

509 route_name=Routes.TEST_NHS_NUMBERS, 

510 permission=NO_PERMISSION_REQUIRED, 

511 renderer="test_nhs_numbers.mako", 

512 http_cache=NEVER_CACHE, 

513) 

514def test_nhs_numbers(req: "CamcopsRequest") -> Response: 

515 """ 

516 Random Test NHS numbers for testing 

517 """ 

518 from cardinal_pythonlib.nhs import generate_random_nhs_number 

519 

520 nhs_numbers = [generate_random_nhs_number() for _ in range(10)] 

521 return dict(test_nhs_numbers=nhs_numbers) 

522 

523 

524# noinspection PyUnusedLocal 

525@view_config(route_name=Routes.TESTPAGE_PRIVATE_1, http_cache=NEVER_CACHE) 

526def test_page_private_1(req: "CamcopsRequest") -> Response: 

527 """ 

528 A private test page with no informative content, but which should only 

529 be accessible to authenticated users. 

530 """ 

531 _ = req.gettext 

532 return Response(_("Private test page.")) 

533 

534 

535# noinspection PyUnusedLocal 

536@view_config( 

537 route_name=Routes.TESTPAGE_PRIVATE_2, 

538 permission=Permission.SUPERUSER, 

539 renderer="testpage.mako", 

540 http_cache=NEVER_CACHE, 

541) 

542def test_page_2(req: "CamcopsRequest") -> Dict[str, Any]: 

543 """ 

544 A private test page containing POTENTIALLY SENSITIVE test information, 

545 including environment variables, that should only be accessible to 

546 superusers. 

547 """ 

548 return dict(param1="world") 

549 

550 

551# noinspection PyUnusedLocal 

552@view_config( 

553 route_name=Routes.TESTPAGE_PRIVATE_3, 

554 permission=Permission.SUPERUSER, 

555 renderer="inherit_cache_test_child.mako", 

556 http_cache=NEVER_CACHE, 

557) 

558def test_page_3(req: "CamcopsRequest") -> Dict[str, Any]: 

559 """ 

560 A private test page that tests template inheritance. 

561 """ 

562 return {} 

563 

564 

565# noinspection PyUnusedLocal 

566@view_config( 

567 route_name=Routes.TESTPAGE_PRIVATE_4, 

568 permission=Permission.SUPERUSER, 

569 renderer="test_template_filters.mako", 

570 http_cache=NEVER_CACHE, 

571) 

572def test_page_4(req: "CamcopsRequest") -> Dict[str, Any]: 

573 """ 

574 A private test page that tests Mako filtering. 

575 """ 

576 return dict(test_strings=["plain", "normal <b>bold</b> normal"]) 

577 

578 

579# noinspection PyUnusedLocal,PyTypeChecker 

580@view_config( 

581 route_name=Routes.CRASH, 

582 permission=Permission.SUPERUSER, 

583 http_cache=NEVER_CACHE, 

584) 

585def crash(req: "CamcopsRequest") -> Response: 

586 """ 

587 A view that deliberately raises an exception. 

588 """ 

589 _ = req.gettext 

590 raise RuntimeError( 

591 _("Deliberately crashed. Should not affect other processes.") 

592 ) 

593 

594 

595# noinspection PyUnusedLocal 

596@view_config( 

597 route_name=Routes.DEVELOPER, 

598 permission=Permission.SUPERUSER, 

599 renderer="developer.mako", 

600 http_cache=NEVER_CACHE, 

601) 

602def developer_page(req: "CamcopsRequest") -> Dict[str, Any]: 

603 """ 

604 Shows the developer menu. 

605 """ 

606 return {} 

607 

608 

609# noinspection PyUnusedLocal 

610@view_config( 

611 route_name=Routes.AUDIT_MENU, 

612 permission=Permission.SUPERUSER, 

613 renderer="audit_menu.mako", 

614 http_cache=NEVER_CACHE, 

615) 

616def audit_menu(req: "CamcopsRequest") -> Dict[str, Any]: 

617 """ 

618 Shows the auditing menu. 

619 """ 

620 return {} 

621 

622 

623# ============================================================================= 

624# Authorization: login, logout, login failures, terms/conditions 

625# ============================================================================= 

626 

627# Do NOT use extra parameters for functions decorated with @view_config; 

628# @view_config can take functions like "def view(request)" but also 

629# "def view(context, request)", so if you add additional parameters, it thinks 

630# you're doing the latter and sends parameters accordingly. 

631 

632 

633class MfaMixin(FormWizardMixin): 

634 """ 

635 Enhances FormWizardMixin to include a multi-factor authentication step. 

636 This must be named "mfa" in the subclass, via the ``SELF_MFA`` variable. 

637 

638 This handles: 

639 

640 - Timing out 

641 - Generating, sending and checking the six-digit code used for 

642 authentication 

643 

644 The subclass should: 

645 

646 - Set ``mfa_user`` on the class to be an instance of the User to be 

647 authenticated. 

648 - Call ``handle_authentication_type()`` in the appropriate step. 

649 - Call ``otp_is_valid()`` and ``fail_bad_mfa_code()`` in the appropriate 

650 step. 

651 

652 See ``LoginView`` for an example that works with the yet-to-be-logged-in 

653 user. 

654 See ``ChangeOwnPasswordView`` for an example with the logged-in user. 

655 """ 

656 

657 STEP_PASSWORD = "password" 

658 STEP_MFA = "mfa" 

659 

660 KEY_TITLE_HTML = "title_html" 

661 KEY_INSTRUCTIONS = "instructions" 

662 KEY_MFA_TIME = "mfa_time" 

663 

664 def __init__(self, *args, **kwargs) -> None: 

665 self._mfa_user: Optional[User] = None 

666 super().__init__(*args, **kwargs) 

667 

668 # ------------------------------------------------------------------------- 

669 # mfa_user 

670 # ------------------------------------------------------------------------- 

671 # Set during __init__ by LoggedInUserMfaMixin, or via a more complex 

672 # process by LoginView. 

673 

674 @property 

675 def mfa_user(self) -> Optional[User]: 

676 """ 

677 The user undergoing authentication. 

678 """ 

679 return self._mfa_user 

680 

681 @mfa_user.setter 

682 def mfa_user(self, user: Optional[User]) -> None: 

683 """ 

684 Sets the current user being authenticated. 

685 """ 

686 self._mfa_user = user 

687 

688 # ------------------------------------------------------------------------- 

689 # Dispatch and timeouts 

690 # ------------------------------------------------------------------------- 

691 

692 def dispatch(self) -> Response: 

693 # Docstring in superclass. 

694 if self.timed_out(): 

695 self.fail_timed_out() # will raise 

696 

697 return super().dispatch() 

698 

699 def timed_out(self) -> bool: 

700 """ 

701 Has authentication timed out? 

702 """ 

703 if self.step != self.STEP_MFA: 

704 return False 

705 

706 timeout = self.request.config.mfa_timeout_s 

707 if timeout == 0: 

708 return False 

709 

710 login_time = self.state.get(self.KEY_MFA_TIME) 

711 if login_time is None: 

712 return False 

713 

714 return int(time.time()) > login_time + timeout 

715 

716 # ------------------------------------------------------------------------- 

717 # Extra context for templates 

718 # ------------------------------------------------------------------------- 

719 

720 def get_extra_context(self) -> Dict[str, Any]: 

721 # Docstring in superclass. 

722 if self.step == self.STEP_MFA: 

723 context = { 

724 self.KEY_TITLE_HTML: self.request.icon_text( 

725 icon=self.get_mfa_icon(), text=self.get_mfa_title() 

726 ), 

727 self.KEY_INSTRUCTIONS: self.get_mfa_instructions(), 

728 } 

729 return context 

730 else: 

731 return {} 

732 

733 def get_mfa_icon(self) -> str: 

734 """ 

735 Returns an icon to let the user know which MFA method is being used. 

736 """ 

737 method = self.mfa_user.mfa_method 

738 

739 if method == MfaMethod.TOTP: 

740 return "shield-shaded" 

741 

742 elif method == MfaMethod.HOTP_EMAIL: 

743 return "envelope" 

744 

745 elif method == MfaMethod.HOTP_SMS: 

746 return "chat-left-dots" 

747 

748 else: 

749 return "Error: get_mfa_icon() called for invalid MFA method" 

750 

751 def get_mfa_title(self) -> str: 

752 """ 

753 Returns a title for the page that requests the code itself. 

754 """ 

755 _ = self.request.gettext 

756 method = self.mfa_user.mfa_method 

757 

758 if method == MfaMethod.TOTP: 

759 return _("Authenticate via your authentication app") 

760 

761 elif method == MfaMethod.HOTP_EMAIL: 

762 return _("Authenticate via e-mail") 

763 

764 elif method == MfaMethod.HOTP_SMS: 

765 return _("Authenticate via SMS") 

766 

767 else: 

768 return "Error: get_mfa_title() called for invalid MFA method" 

769 

770 def get_mfa_instructions(self) -> str: 

771 """ 

772 Return user instructions for the relevant MFA method. 

773 """ 

774 _ = self.request.gettext 

775 method = self.mfa_user.mfa_method 

776 

777 if method == MfaMethod.TOTP: 

778 return _( 

779 "Enter the code for CamCOPS displayed on your " 

780 "authentication app." 

781 ) 

782 

783 elif method == MfaMethod.HOTP_EMAIL: 

784 return _("We've sent a code by email to {}.").format( 

785 self.mfa_user.partial_email 

786 ) 

787 

788 elif method == MfaMethod.HOTP_SMS: 

789 return _("We've sent a code by text message to {}").format( 

790 self.mfa_user.partial_phone_number 

791 ) 

792 

793 else: 

794 return "Error: get_mfa_instruction() called for invalid MFA method" 

795 

796 # ------------------------------------------------------------------------- 

797 # MFA handling 

798 # ------------------------------------------------------------------------- 

799 

800 def handle_authentication_type(self) -> None: 

801 """ 

802 Function to be called when we want an MFA code to be created. 

803 """ 

804 mfa_user = self.mfa_user 

805 mfa_user.ensure_mfa_info() 

806 mfa_method = mfa_user.mfa_method 

807 

808 if mfa_method == MfaMethod.TOTP: 

809 # Nothing to do. The app generates the code. 

810 return 

811 

812 # Record the time of code creation: 

813 self.state[self.KEY_MFA_TIME] = int(time.time()) 

814 

815 if mfa_method == MfaMethod.HOTP_EMAIL: 

816 self.send_authentication_email() 

817 elif mfa_method == MfaMethod.HOTP_SMS: 

818 self.send_authentication_sms() 

819 else: 

820 raise ValueError( 

821 f"MfaMixin.handle_authentication_type: " 

822 f"unexpected mfa_method {mfa_method!r}" 

823 ) 

824 

825 def send_authentication_email(self) -> None: 

826 """ 

827 E-mail the code to the user. 

828 """ 

829 _ = self.request.gettext 

830 config = self.request.config 

831 kwargs = dict( 

832 from_addr=config.email_from, 

833 to=self.mfa_user.email, 

834 subject=_("CamCOPS authentication"), 

835 body=self.get_hotp_message(), 

836 content_type=MimeType.TEXT, 

837 ) 

838 

839 email = Email(**kwargs) 

840 success = email.send( 

841 host=config.email_host, 

842 username=config.email_host_username, 

843 password=config.email_host_password, 

844 port=config.email_port, 

845 use_tls=config.email_use_tls, 

846 ) 

847 if success: 

848 msg = _("E-mail sent") 

849 queue = FlashQueue.SUCCESS 

850 else: 

851 msg = _( 

852 "Failed to send e-mail! " 

853 "Please try again or contact your administrator." 

854 ) 

855 queue = FlashQueue.DANGER 

856 self.request.session.flash(msg, queue=queue) 

857 

858 def send_authentication_sms(self) -> None: 

859 """ 

860 Send a code to the user via SMS (text message). 

861 """ 

862 backend = self.request.config.sms_backend 

863 backend.send_sms( 

864 self.mfa_user.raw_phone_number, self.get_hotp_message() 

865 ) 

866 

867 def get_hotp_message(self) -> str: 

868 """ 

869 Return a human-readable message containing an HOTP (HMAC-Based One-Time 

870 Password). 

871 """ 

872 self.mfa_user.hotp_counter += 1 

873 self.request.dbsession.add(self.mfa_user) 

874 _ = self.request.gettext 

875 key = self.mfa_user.mfa_secret_key 

876 assert key, f"Bug: self.mfa_user.mfa_secret_key = {key!r}" 

877 handler = pyotp.HOTP(key) 

878 code = handler.at(self.mfa_user.hotp_counter) 

879 return _("Your CamCOPS verification code is {}").format(code) 

880 

881 def otp_is_valid(self, appstruct: Dict[str, Any]) -> bool: 

882 """ 

883 Is the code being offered by the user the right one? 

884 """ 

885 otp = appstruct.get(ViewParam.ONE_TIME_PASSWORD) 

886 return self.mfa_user.verify_one_time_password(otp) 

887 

888 # ------------------------------------------------------------------------- 

889 # Ways to fail 

890 # ------------------------------------------------------------------------- 

891 

892 def fail_bad_mfa_code(self) -> NoReturn: 

893 """ 

894 Fail because the code was wrong. 

895 """ 

896 _ = self.request.gettext 

897 self.fail(_("You entered an invalid code. Please try again.")) 

898 

899 def fail_timed_out(self) -> NoReturn: 

900 """ 

901 Fail because the process timed out. 

902 """ 

903 _ = self.request.gettext 

904 self.fail(_("Your code expired. Please try again.")) 

905 

906 

907class LoggedInUserMfaMixin(MfaMixin): 

908 """ 

909 Handles multi-factor authentication for the currently logged in user 

910 (everything except :class:`LoginView`). 

911 """ 

912 

913 def __init__(self, *args, **kwargs) -> None: 

914 super().__init__(*args, **kwargs) 

915 self.mfa_user = self.request.user 

916 

917 

918class LoginView(MfaMixin, FormView): 

919 """ 

920 Multi-factor authentication for the login process. 

921 Sequences is: (1) password; (2) MFA, if enabled. 

922 

923 Inheritance (as of 2021-10-06): 

924 

925 - webview.LoginView 

926 

927 - webview.MfaMixin 

928 

929 - cc_view_classes.FormWizardMixin 

930 

931 - cc_view_classes.FormView 

932 

933 - cc_view_classes.TemplateResponseMixin 

934 

935 - cc_view_classes.BaseFormView 

936 

937 - cc_view_classes.FormMixin 

938 

939 - cc_view_classes.ContextMixin 

940 

941 - cc_view_classes.ProcessFormView -- provides ``get()``, ``post()`` 

942 

943 - cc_view_classes.View -- owns ``request``, provides ``dispatch()`` 

944 """ 

945 

946 KEY_MFA_USER_ID = "mfa_user_id" 

947 

948 _mfa_user: Optional[User] 

949 wizard_first_step = MfaMixin.STEP_PASSWORD 

950 wizard_forms = { 

951 MfaMixin.STEP_PASSWORD: LoginForm, # 1. enter username/password 

952 MfaMixin.STEP_MFA: OtpTokenForm, # 2. enter one-time code 

953 } 

954 wizard_templates = { 

955 MfaMixin.STEP_PASSWORD: "login.mako", 

956 MfaMixin.STEP_MFA: "login_token.mako", 

957 } 

958 

959 def __init__(self, *args, **kwargs) -> None: 

960 super().__init__(*args, **kwargs) 

961 

962 # ------------------------------------------------------------------------- 

963 # mfa_user 

964 # ------------------------------------------------------------------------- 

965 # Slightly more complex here, since our user isn't logged in properly yet. 

966 

967 @property 

968 def mfa_user(self) -> Optional[User]: 

969 # Docstring in superclass. 

970 if self._mfa_user is None: 

971 try: 

972 user_id = self.state[self.KEY_MFA_USER_ID] 

973 self.mfa_user = ( 

974 self.request.dbsession.query(User) 

975 .filter(User.id == user_id) 

976 .one_or_none() 

977 ) 

978 except KeyError: 

979 pass 

980 

981 return self._mfa_user 

982 

983 @mfa_user.setter 

984 def mfa_user(self, user: Optional[User]) -> None: 

985 # Docstring in superclass. 

986 self._mfa_user = user 

987 if user is None: 

988 self.state[self.KEY_MFA_USER_ID] = None 

989 return 

990 

991 self.state[self.KEY_MFA_USER_ID] = user.id 

992 

993 # ------------------------------------------------------------------------- 

994 # Content for forms 

995 # ------------------------------------------------------------------------- 

996 

997 def get_form_values(self) -> Dict: 

998 # Docstring in superclass. 

999 return {ViewParam.REDIRECT_URL: self.get_redirect_url()} 

1000 

1001 def get_form_kwargs(self) -> Dict[str, Any]: 

1002 # Docstring in superclass. 

1003 kwargs = super().get_form_kwargs() 

1004 

1005 cfg = self.request.config 

1006 autocomplete_password = not cfg.disable_password_autocomplete 

1007 kwargs["autocomplete_password"] = autocomplete_password 

1008 

1009 return kwargs 

1010 

1011 # ------------------------------------------------------------------------- 

1012 # Form validation, and sequence handling 

1013 # ------------------------------------------------------------------------- 

1014 

1015 def form_valid_process_data( 

1016 self, form: "Form", appstruct: Dict[str, Any] 

1017 ) -> None: 

1018 # Docstring in superclass. 

1019 if self.step == self.STEP_PASSWORD: 

1020 self._form_valid_password(appstruct) 

1021 else: 

1022 self._form_valid_mfa(appstruct) 

1023 

1024 super().form_valid_process_data(form, appstruct) 

1025 

1026 def _form_valid_password(self, appstruct: Dict[str, Any]) -> None: 

1027 """ 

1028 Called when the user has entered a username/password (via a validated 

1029 form). 

1030 """ 

1031 username = appstruct.get(ViewParam.USERNAME) 

1032 

1033 # Is the user locked? 

1034 locked_out_until = SecurityAccountLockout.user_locked_out_until( 

1035 self.request, username 

1036 ) 

1037 if locked_out_until is not None: 

1038 self.fail_locked_out(locked_out_until) # will raise 

1039 

1040 password = appstruct.get(ViewParam.PASSWORD) 

1041 

1042 # Is the username/password combination correct? 

1043 user = User.get_user_from_username_password( 

1044 self.request, username, password 

1045 ) # checks password 

1046 

1047 # Some trade-off between usability and security here. 

1048 # For failed attempts, the user has some idea as to what the problem 

1049 # is. 

1050 if user is None: 

1051 # Unsuccessful. Note that the username may/may not be genuine. 

1052 SecurityLoginFailure.act_on_login_failure(self.request, username) 

1053 # ... may lock the account 

1054 # Now, call audit() before session.logout(), as the latter 

1055 # will wipe the session IP address: 

1056 self.request.camcops_session.logout() 

1057 self.fail_not_authorized() # will raise 

1058 

1059 if not user.may_use_webviewer: 

1060 # This means a user who can upload from tablet but who cannot 

1061 # log in via the web front end. 

1062 self.fail_not_authorized() # will raise 

1063 

1064 self.mfa_user = user 

1065 self._password_next_step() 

1066 self._form_valid_success() 

1067 

1068 def _password_next_step(self) -> None: 

1069 """ 

1070 The user has entered a password correctly; what's the next step? 

1071 """ 

1072 method = self.mfa_user.mfa_method 

1073 if MfaMethod.requires_second_step(method): 

1074 self.step = self.STEP_MFA 

1075 self.handle_authentication_type() 

1076 else: 

1077 self.finish() 

1078 # Guaranteed to be valid; see constructor. 

1079 

1080 def _form_valid_mfa(self, appstruct: Dict[str, Any]) -> None: 

1081 """ 

1082 Called when the user has entered an MFA code (via a validated form). 

1083 """ 

1084 if not self.otp_is_valid(appstruct): 

1085 self.fail_bad_mfa_code() # will raise 

1086 

1087 self.finish() 

1088 self._form_valid_success() 

1089 

1090 def _form_valid_success(self) -> None: 

1091 """ 

1092 Called when the next step has been determined. One possible outcome is 

1093 a successful login. 

1094 """ 

1095 if self.finished(): 

1096 # Successful login. 

1097 self.mfa_user.login( 

1098 self.request 

1099 ) # will clear login failure record 

1100 self.request.camcops_session.login(self.mfa_user) 

1101 audit(self.request, "Login", user_id=self.mfa_user.id) 

1102 

1103 # OK, logged in. 

1104 # Redirect to the main menu, or wherever the user was heading. 

1105 # HOWEVER, that may lead us to a "change password" or "agree terms" 

1106 # page, via the permissions system (Permission.HAPPY or not). 

1107 

1108 # ------------------------------------------------------------------------- 

1109 # Next destinations 

1110 # ------------------------------------------------------------------------- 

1111 

1112 def get_success_url(self) -> str: 

1113 # Docstring in superclass. 

1114 if self.finished(): 

1115 return self.get_redirect_url() 

1116 

1117 return self.request.route_url( 

1118 Routes.LOGIN, 

1119 _query={ViewParam.REDIRECT_URL: self.get_redirect_url()}, 

1120 ) 

1121 

1122 def get_failure_url(self) -> None: 

1123 # Docstring in superclass. 

1124 return self.request.route_url( 

1125 Routes.LOGIN, 

1126 _query={ViewParam.REDIRECT_URL: self.get_redirect_url()}, 

1127 ) 

1128 

1129 def get_redirect_url(self) -> str: 

1130 """ 

1131 We may be logging in after a timeout, in which case we can redirect the 

1132 user back to where they were before. Otherwise, they go to the main 

1133 page. 

1134 """ 

1135 return self.request.get_redirect_url_param( 

1136 ViewParam.REDIRECT_URL, default=self.request.route_url(Routes.HOME) 

1137 ) 

1138 

1139 # ------------------------------------------------------------------------- 

1140 # Ways to fail 

1141 # ------------------------------------------------------------------------- 

1142 

1143 def fail_not_authorized(self) -> NoReturn: 

1144 """ 

1145 Fail because the user has not logged in correctly or is not authorized 

1146 to log in. 

1147 

1148 Pretends to the type checker that it returns a response, so callers can 

1149 use ``return`` for code safety. 

1150 """ 

1151 _ = self.request.gettext 

1152 self.fail( 

1153 _("Invalid username/password (or user not authorized).") 

1154 ) # will raise 

1155 # assert False, "Bug: LoginView.fail_not_authorized() falling through" 

1156 

1157 def fail_locked_out(self, locked_until: Pendulum) -> NoReturn: 

1158 """ 

1159 Raises a failure because the user is locked out. 

1160 

1161 Pretends to the type checker that it returns a response, so callers can 

1162 use ``return`` for code safety. 

1163 """ 

1164 _ = self.request.gettext 

1165 locked_until = format_datetime( 

1166 locked_until, DateFormat.LONG_DATETIME_WITH_DAY, _("(never)") 

1167 ) 

1168 message = _( 

1169 "Account locked until {} due to multiple login failures. " 

1170 "Try again later or contact your administrator." 

1171 ).format(locked_until) 

1172 self.fail(message) # will raise 

1173 # assert False, "Bug: LoginView.fail_locked_out() falling through" 

1174 

1175 

1176@view_config( 

1177 route_name=Routes.LOGIN, 

1178 permission=NO_PERMISSION_REQUIRED, 

1179 http_cache=NEVER_CACHE, 

1180) 

1181def login_view(req: "CamcopsRequest") -> Response: 

1182 """ 

1183 Login view. 

1184 

1185 - GET: presents the login screen 

1186 - POST/submit: attempts to log in (with optional multi-factor 

1187 authentication); 

1188 

1189 - failure: returns a login failure view or an account lockout view 

1190 - success: 

1191 

1192 - redirects to the redirection view if one was specified; 

1193 - redirects to the home view if not. 

1194 """ 

1195 return LoginView(req).dispatch() 

1196 

1197 

1198@view_config( 

1199 route_name=Routes.LOGOUT, 

1200 permission=Authenticated, 

1201 renderer="logged_out.mako", 

1202 http_cache=NEVER_CACHE, 

1203) 

1204def logout(req: "CamcopsRequest") -> Dict[str, Any]: 

1205 """ 

1206 Logs a session out, and returns the "logged out" view. 

1207 """ 

1208 audit(req, "Logout") 

1209 ccsession = req.camcops_session 

1210 ccsession.logout() 

1211 return dict() 

1212 

1213 

1214@view_config( 

1215 route_name=Routes.OFFER_TERMS, 

1216 permission=Authenticated, 

1217 renderer="offer_terms.mako", 

1218 http_cache=NEVER_CACHE, 

1219) 

1220def offer_terms(req: "CamcopsRequest") -> Response: 

1221 """ 

1222 - GET: show terms/conditions and request acknowledgement 

1223 - POST/submit: note the user's agreement; redirect to the home view. 

1224 """ 

1225 form = OfferTermsForm( 

1226 request=req, agree_button_text=req.wsstring(SS.DISCLAIMER_AGREE) 

1227 ) 

1228 

1229 if FormAction.SUBMIT in req.POST: 

1230 req.user.agree_terms(req) 

1231 return HTTPFound(req.route_url(Routes.HOME)) # redirect 

1232 

1233 return render_to_response( 

1234 "offer_terms.mako", 

1235 dict( 

1236 title=req.wsstring(SS.DISCLAIMER_TITLE), 

1237 subtitle=req.wsstring(SS.DISCLAIMER_SUBTITLE), 

1238 content=req.wsstring(SS.DISCLAIMER_CONTENT), 

1239 form=form.render(), 

1240 head_form_html=get_head_form_html(req, [form]), 

1241 ), 

1242 request=req, 

1243 ) 

1244 

1245 

1246@forbidden_view_config(http_cache=NEVER_CACHE) 

1247def forbidden(req: "CamcopsRequest") -> Response: 

1248 """ 

1249 Generic place that Pyramid comes when permission is denied for a view. 

1250 

1251 We will offer one of these: 

1252 

1253 - Must change password? Redirect to "change own password" view. 

1254 - Must agree terms? Redirect to "offer terms" view. 

1255 - Otherwise: a generic "forbidden" view. 

1256 """ 

1257 # I was doing this: 

1258 if req.has_permission(Authenticated): 

1259 user = req.user 

1260 assert user, "Bug! Authenticated but no user...!?" 

1261 if user.must_change_password: 

1262 return HTTPFound(req.route_url(Routes.CHANGE_OWN_PASSWORD)) 

1263 if user.must_agree_terms: 

1264 return HTTPFound(req.route_url(Routes.OFFER_TERMS)) 

1265 if user.must_set_mfa_method(req): 

1266 return HTTPFound(req.route_url(Routes.EDIT_OWN_USER_MFA)) 

1267 # ... but with "raise HTTPFound" instead. 

1268 # BUT there is only one level of exception handling in Pyramid, i.e. you 

1269 # can't raise exceptions from exceptions: 

1270 # https://github.com/Pylons/pyramid/issues/436 

1271 # The simplest way round is to use "return", not "raise". 

1272 

1273 redirect_url = req.url 

1274 # Redirects to login page, with onwards redirection to requested 

1275 # destination once logged in: 

1276 querydict = {ViewParam.REDIRECT_URL: redirect_url} 

1277 return render_to_response( 

1278 "forbidden.mako", dict(querydict=querydict), request=req 

1279 ) 

1280 

1281 

1282# ============================================================================= 

1283# Changing passwords 

1284# ============================================================================= 

1285 

1286 

1287class ChangeOwnPasswordView(LoggedInUserMfaMixin, UpdateView): 

1288 """ 

1289 View to change one's own password. 

1290 

1291 If MFA is enabled, you need to (re-)authenticate via MFA to do so. 

1292 Then, you need to supply your own password to change it (regardless). 

1293 Sequence is therefore (1) MFA, optionally; (2) change password. 

1294 

1295 Most documentation in superclass. 

1296 """ 

1297 

1298 model_form_dict: Dict[str, "Form"] = {} 

1299 STEP_CHANGE_PASSWORD = "change_password" 

1300 

1301 wizard_forms = { 

1302 MfaMixin.STEP_MFA: OtpTokenForm, 

1303 STEP_CHANGE_PASSWORD: ChangeOwnPasswordForm, 

1304 } 

1305 

1306 wizard_templates = { 

1307 MfaMixin.STEP_MFA: "login_token.mako", 

1308 STEP_CHANGE_PASSWORD: "change_own_password.mako", 

1309 } 

1310 

1311 wizard_extra_contexts: Dict[str, Dict[str, Any]] = { 

1312 MfaMixin.STEP_MFA: {}, 

1313 STEP_CHANGE_PASSWORD: {}, 

1314 } 

1315 

1316 def get_first_step(self) -> str: 

1317 if self.request.user.mfa_method == MfaMethod.NO_MFA: 

1318 return self.STEP_CHANGE_PASSWORD 

1319 

1320 return self.STEP_MFA 

1321 

1322 def get(self) -> Response: 

1323 if self.step == self.STEP_MFA: 

1324 self.handle_authentication_type() 

1325 

1326 _ = self.request.gettext 

1327 

1328 if self.request.user.must_change_password: 

1329 self.request.session.flash( 

1330 _("Your password has expired and must be changed."), 

1331 queue=FlashQueue.DANGER, 

1332 ) 

1333 return super().get() 

1334 

1335 def get_object(self) -> User: 

1336 return self.request.user 

1337 

1338 def get_form_kwargs(self) -> Dict[str, Any]: 

1339 kwargs = super().get_form_kwargs() 

1340 kwargs.update(must_differ=True) 

1341 return kwargs 

1342 

1343 def get_success_url(self) -> str: 

1344 if self.finished(): 

1345 return self.request.route_url(Routes.HOME) 

1346 

1347 return self.request.route_url(Routes.CHANGE_OWN_PASSWORD) 

1348 

1349 def get_failure_url(self) -> str: 

1350 return self.request.route_url(Routes.HOME) 

1351 

1352 def form_valid_process_data( 

1353 self, form: "Form", appstruct: Dict[str, Any] 

1354 ) -> None: 

1355 if self.step == self.STEP_MFA: 

1356 if not self.otp_is_valid(appstruct): 

1357 self.fail_bad_mfa_code() # will raise 

1358 

1359 super().form_valid_process_data(form, appstruct) 

1360 

1361 def set_object_properties(self, appstruct: Dict[str, Any]) -> None: 

1362 # Superclass method overridden, not called. 

1363 if self.step == self.STEP_MFA: 

1364 self.step = self.STEP_CHANGE_PASSWORD 

1365 elif self.step == self.STEP_CHANGE_PASSWORD: 

1366 self.set_password(appstruct) 

1367 self.finish() 

1368 else: 

1369 assert f"ChangeOwnPasswordView: bad step {self.step!r}" 

1370 

1371 def set_password(self, appstruct: Dict[str, Any]) -> None: 

1372 """ 

1373 Success; change the user's password. 

1374 """ 

1375 user = cast(User, self.object) 

1376 # ... form has validated old password, etc. 

1377 new_password = appstruct[ViewParam.NEW_PASSWORD] 

1378 user.set_password(self.request, new_password) 

1379 

1380 _ = self.request.gettext 

1381 self.request.session.flash( 

1382 _( 

1383 "You have changed your password. " 

1384 "If you store your password in your CamCOPS tablet " 

1385 "application, remember to change it there as well." 

1386 ), 

1387 queue=FlashQueue.SUCCESS, 

1388 ) 

1389 

1390 

1391@view_config( 

1392 route_name=Routes.CHANGE_OWN_PASSWORD, 

1393 permission=Authenticated, 

1394 http_cache=NEVER_CACHE, 

1395) 

1396def change_own_password(req: "CamcopsRequest") -> Response: 

1397 """ 

1398 For any user: to change their own password. 

1399 

1400 - GET: offer "change own password" view 

1401 - POST/submit: change the password and display success message. 

1402 """ 

1403 view = ChangeOwnPasswordView(req) 

1404 

1405 return view.dispatch() 

1406 

1407 

1408class EditUserAuthenticationView(LoggedInUserMfaMixin, UpdateView): 

1409 """ 

1410 View to edit aspects of another user. 

1411 """ 

1412 

1413 model_form_dict: Dict[str, "Form"] = {} 

1414 object_class = User 

1415 pk_param = ViewParam.USER_ID 

1416 server_pk_name = "id" 

1417 

1418 def get(self) -> Response: 

1419 if self.step == self.STEP_MFA: 

1420 self.handle_authentication_type() 

1421 

1422 return super().get() 

1423 

1424 def get_object(self) -> User: 

1425 user = cast(User, super().get_object()) 

1426 assert_may_edit_user(self.request, user) 

1427 

1428 return user 

1429 

1430 def get_extra_context(self) -> Dict[str, Any]: 

1431 if self.step == self.STEP_MFA: 

1432 return super().get_extra_context() 

1433 

1434 user = cast(User, self.object) 

1435 

1436 return {"username": user.username} 

1437 

1438 def form_valid_process_data( 

1439 self, form: "Form", appstruct: Dict[str, Any] 

1440 ) -> None: 

1441 if self.step == self.STEP_MFA: 

1442 if not self.otp_is_valid(appstruct): 

1443 self.fail_bad_mfa_code() # will raise 

1444 

1445 super().form_valid_process_data(form, appstruct) 

1446 

1447 def get_failure_url(self) -> str: 

1448 return self.request.route_url(Routes.VIEW_ALL_USERS) 

1449 

1450 

1451class ChangeOtherPasswordView(EditUserAuthenticationView): 

1452 """ 

1453 View to change the password for another user. 

1454 """ 

1455 

1456 STEP_CHANGE_PASSWORD = "change_password" 

1457 

1458 wizard_forms = { 

1459 MfaMixin.STEP_MFA: OtpTokenForm, 

1460 STEP_CHANGE_PASSWORD: ChangeOtherPasswordForm, 

1461 } 

1462 

1463 wizard_templates = { 

1464 MfaMixin.STEP_MFA: "login_token.mako", 

1465 STEP_CHANGE_PASSWORD: "change_other_password.mako", 

1466 } 

1467 

1468 def get(self) -> Response: 

1469 if self.get_pk_value() == self.request.user_id: 

1470 raise HTTPFound(self.request.route_url(Routes.CHANGE_OWN_PASSWORD)) 

1471 

1472 return super().get() 

1473 

1474 def get_first_step(self) -> str: 

1475 if self.request.user.mfa_method != MfaMethod.NO_MFA: 

1476 return self.STEP_MFA 

1477 

1478 return self.STEP_CHANGE_PASSWORD 

1479 

1480 def set_object_properties(self, appstruct: Dict[str, Any]) -> None: 

1481 # Superclass method overridden, not called. 

1482 if self.step == self.STEP_CHANGE_PASSWORD: 

1483 self.set_password(appstruct) 

1484 self.finish() 

1485 return 

1486 

1487 if self.step == self.STEP_MFA: 

1488 self.step = self.STEP_CHANGE_PASSWORD 

1489 

1490 def set_password(self, appstruct: Dict[str, Any]) -> None: 

1491 """ 

1492 Success; change the password for the other user. 

1493 """ 

1494 user = cast(User, self.object) 

1495 _ = self.request.gettext 

1496 new_password = appstruct[ViewParam.NEW_PASSWORD] 

1497 user.set_password(self.request, new_password) 

1498 must_change_pw = appstruct.get(ViewParam.MUST_CHANGE_PASSWORD) 

1499 if must_change_pw: 

1500 user.force_password_change() 

1501 self.request.session.flash( 

1502 _("Password changed for user '{username}'").format( 

1503 username=user.username 

1504 ), 

1505 queue=FlashQueue.SUCCESS, 

1506 ) 

1507 

1508 def get_success_url(self) -> str: 

1509 if self.finished(): 

1510 return self.request.route_url(Routes.VIEW_ALL_USERS) 

1511 

1512 user = cast(User, self.object) 

1513 

1514 return self.request.route_url( 

1515 Routes.CHANGE_OTHER_PASSWORD, _query={ViewParam.USER_ID: user.id} 

1516 ) 

1517 

1518 

1519@view_config( 

1520 route_name=Routes.CHANGE_OTHER_PASSWORD, 

1521 permission=Permission.GROUPADMIN, 

1522 http_cache=NEVER_CACHE, 

1523) 

1524def change_other_password(req: "CamcopsRequest") -> Response: 

1525 """ 

1526 For administrators, to change another's password. 

1527 

1528 - GET: offer "change another's password" view (except that if you're 

1529 changing your own password, return :func:`change_own_password`. 

1530 - POST/submit: change the password and display success message. 

1531 """ 

1532 view = ChangeOtherPasswordView(req) 

1533 return view.dispatch() 

1534 

1535 

1536class EditOtherUserMfaView(EditUserAuthenticationView): 

1537 """ 

1538 View to edit the MFA method for another user. Only permits disabling of 

1539 MFA. (If MFA is mandatory, that will require the other user to set their 

1540 MFA method at next logon.) 

1541 """ 

1542 

1543 STEP_OTHER_USER_MFA = "other_user_mfa" 

1544 

1545 wizard_forms = { 

1546 MfaMixin.STEP_MFA: OtpTokenForm, 

1547 STEP_OTHER_USER_MFA: EditOtherUserMfaForm, 

1548 } 

1549 

1550 wizard_templates = { 

1551 MfaMixin.STEP_MFA: "login_token.mako", 

1552 STEP_OTHER_USER_MFA: "edit_other_user_mfa.mako", 

1553 } 

1554 

1555 def get(self) -> Response: 

1556 if self.get_pk_value() == self.request.user_id: 

1557 raise HTTPFound(self.request.route_url(Routes.EDIT_OWN_USER_MFA)) 

1558 

1559 return super().get() 

1560 

1561 def get_first_step(self) -> str: 

1562 if self.request.user.mfa_method != MfaMethod.NO_MFA: 

1563 return self.STEP_MFA 

1564 

1565 return self.STEP_OTHER_USER_MFA 

1566 

1567 def set_object_properties(self, appstruct: Dict[str, Any]) -> None: 

1568 # Superclass method overridden, not called. 

1569 if self.step == self.STEP_OTHER_USER_MFA: 

1570 self.maybe_disable_mfa(appstruct) 

1571 self.finish() 

1572 return 

1573 

1574 if self.step == self.STEP_MFA: 

1575 self.step = self.STEP_OTHER_USER_MFA 

1576 

1577 def maybe_disable_mfa(self, appstruct: Dict[str, Any]) -> None: 

1578 """ 

1579 If our user asked for it, disable MFA for the user being edited. 

1580 """ 

1581 if appstruct.get(ViewParam.DISABLE_MFA): 

1582 user = cast(User, self.object) 

1583 _ = self.request.gettext 

1584 

1585 user.mfa_method = MfaMethod.NO_MFA 

1586 self.request.session.flash( 

1587 _( 

1588 "Multi-factor authentication disabled for user " 

1589 "'{username}'" 

1590 ).format(username=user.username), 

1591 queue=FlashQueue.SUCCESS, 

1592 ) 

1593 

1594 def get_success_url(self) -> str: 

1595 if self.finished(): 

1596 return self.request.route_url(Routes.VIEW_ALL_USERS) 

1597 

1598 user = cast(User, self.object) 

1599 

1600 return self.request.route_url( 

1601 Routes.EDIT_OTHER_USER_MFA, _query={ViewParam.USER_ID: user.id} 

1602 ) 

1603 

1604 

1605@view_config( 

1606 route_name=Routes.EDIT_OTHER_USER_MFA, 

1607 permission=Permission.GROUPADMIN, 

1608 http_cache=NEVER_CACHE, 

1609) 

1610def edit_other_user_mfa(req: "CamcopsRequest") -> Response: 

1611 """ 

1612 For administrators, to change another users's Multi-factor Authentication. 

1613 Currently it is only possible to disable Multi-factor authentication for 

1614 a user. 

1615 

1616 - GET: offer "edit another's MFA" view (except that if you're 

1617 changing your own MFA, return :func:`edit_own_user_mfa`. 

1618 - POST/submit: edit MFA and display success message. 

1619 """ 

1620 view = EditOtherUserMfaView(req) 

1621 return view.dispatch() 

1622 

1623 

1624class EditOwnUserMfaView(LoggedInUserMfaMixin, UpdateView): 

1625 """ 

1626 View to edit your own MFA method. 

1627 

1628 The inheritance (as of 2021-10-06) illustrates a typical situation: 

1629 

1630 SPECIMEN VIEW CLASS: 

1631 

1632 - webview.EditOwnUserMfaView 

1633 

1634 - webview.LoggedInUserMfaMixin 

1635 

1636 - webview.MfaMixin 

1637 

1638 - cc_view_classes.FormWizardMixin -- with typehint for FormMixin -- 

1639 implements ``state``. 

1640 

1641 - cc_view_classes.UpdateView 

1642 

1643 - cc_view_classes.TemplateResponseMixin 

1644 

1645 - cc_view_classes.BaseUpdateView 

1646 

1647 - cc_view_classes.ModelFormMixin -- implements ``form_valid()`` --> 

1648 ``save_object()`` > ``set_object_properties()`` 

1649 

1650 - cc_view_classes.FormMixin -- implements ``form_valid()``, 

1651 ``get_context_data()``, etc. 

1652 

1653 - cc_view_classes.ContextMixin 

1654 

1655 - cc_view_classes.SingleObjectMixin -- implements ``get_object()`` 

1656 etc. 

1657 

1658 - cc_view_classes.ContextMixin 

1659 

1660 - cc_view_classes.ProcessFormView -- implements ``get()``, ``post()`` 

1661 

1662 - cc_view_classes.View -- owns ``request``, implements 

1663 ``dispatch()`` (which calls ``get()``, ``post()``). 

1664 

1665 SPECIMEN FORM WITHIN THAT VIEW: 

1666 

1667 - cc_forms.MfaMethodForm 

1668 

1669 - cc_forms.InformativeNonceForm 

1670 

1671 - cc_forms.InformativeForm 

1672 

1673 - deform.Form 

1674 

1675 If you subclass A(B, C), then B's superclass methods are called before C's: 

1676 https://www.python.org/download/releases/2.3/mro/; 

1677 https://makina-corpus.com/blog/metier/2014/python-tutorial-understanding-python-mro-class-search-path; 

1678 """ # noqa 

1679 

1680 STEP_MFA_METHOD = "mfa_method" 

1681 STEP_TOTP = MfaMethod.TOTP 

1682 STEP_HOTP_EMAIL = MfaMethod.HOTP_EMAIL 

1683 STEP_HOTP_SMS = MfaMethod.HOTP_SMS 

1684 wizard_first_step = STEP_MFA_METHOD 

1685 

1686 wizard_forms = { 

1687 STEP_MFA_METHOD: MfaMethodForm, # 1. choose your MFA method 

1688 STEP_TOTP: MfaTotpForm, # 2a. show TOTP (auth app) QR/alphanumeric code # noqa: E501 

1689 STEP_HOTP_EMAIL: MfaHotpEmailForm, # 2b. choose e-mail address 

1690 STEP_HOTP_SMS: MfaHotpSmsForm, # 2c. choose phone number for SMS 

1691 MfaMixin.STEP_MFA: OtpTokenForm, # 4. request code from user 

1692 } 

1693 

1694 FORM_WITH_TITLE_TEMPLATE = "form_with_title.mako" 

1695 

1696 wizard_templates = { 

1697 STEP_MFA_METHOD: FORM_WITH_TITLE_TEMPLATE, 

1698 STEP_TOTP: FORM_WITH_TITLE_TEMPLATE, 

1699 STEP_HOTP_EMAIL: FORM_WITH_TITLE_TEMPLATE, 

1700 STEP_HOTP_SMS: FORM_WITH_TITLE_TEMPLATE, 

1701 MfaMixin.STEP_MFA: "login_token.mako", 

1702 } 

1703 

1704 hotp_steps = (STEP_HOTP_EMAIL, STEP_HOTP_SMS) 

1705 secret_key_steps = (STEP_TOTP, STEP_HOTP_EMAIL, STEP_HOTP_SMS) 

1706 

1707 def get(self) -> Response: 

1708 if self.step == self.STEP_MFA: 

1709 self.handle_authentication_type() 

1710 

1711 return super().get() 

1712 

1713 def get_model_form_dict(self) -> Dict[str, Any]: 

1714 model_form_dict = {} 

1715 

1716 # Dictionary keys here are attribute names of the User object. 

1717 # Values are form attributes. 

1718 

1719 if self.step == self.STEP_MFA_METHOD: 

1720 model_form_dict["mfa_method"] = ViewParam.MFA_METHOD 

1721 

1722 elif self.step == self.STEP_HOTP_EMAIL: 

1723 model_form_dict["email"] = ViewParam.EMAIL 

1724 

1725 elif self.step == self.STEP_HOTP_SMS: 

1726 model_form_dict["phone_number"] = ViewParam.PHONE_NUMBER 

1727 

1728 if self.step in self.secret_key_steps: 

1729 model_form_dict["mfa_secret_key"] = ViewParam.MFA_SECRET_KEY 

1730 

1731 return model_form_dict 

1732 

1733 def get_object(self) -> User: 

1734 return self.request.user 

1735 

1736 def get_form_values(self) -> Dict[str, Any]: 

1737 # Will call get_model_form_dict() 

1738 form_values = super().get_form_values() 

1739 

1740 if self.step in self.secret_key_steps: 

1741 # Always create a new secret key. This will be written to the 

1742 # user object at the next step, via set_object_properties. 

1743 form_values[ViewParam.MFA_SECRET_KEY] = pyotp.random_base32() 

1744 

1745 return form_values 

1746 

1747 def get_extra_context(self) -> Dict[str, Any]: 

1748 req = self.request 

1749 _ = req.gettext 

1750 if self.step == self.STEP_MFA: 

1751 test_msg = _("Let's test it!") + " " 

1752 context = super().get_extra_context() 

1753 context[self.KEY_INSTRUCTIONS] = ( 

1754 test_msg + self.get_mfa_instructions() 

1755 ) 

1756 return context 

1757 

1758 titles = { 

1759 self.STEP_MFA_METHOD: req.icon_text( 

1760 icon=Icons.MFA, 

1761 text=_("Configure multi-factor authentication settings"), 

1762 ), 

1763 self.STEP_TOTP: req.icon_text( 

1764 icon=Icons.APP_AUTHENTICATOR, 

1765 text=_("Configure authentication with app"), 

1766 ), 

1767 self.STEP_HOTP_EMAIL: req.icon_text( 

1768 icon=Icons.EMAIL_SEND, 

1769 text=_("Configure authentication by email"), 

1770 ), 

1771 self.STEP_HOTP_SMS: req.icon_text( 

1772 icon=Icons.SMS, 

1773 text=_("Configure authentication by text message"), 

1774 ), 

1775 } 

1776 return {MAKO_VAR_TITLE: titles[self.step]} 

1777 

1778 def get_success_url(self) -> str: 

1779 if self.finished(): 

1780 return self.request.route_url(Routes.HOME) 

1781 

1782 return self.request.route_url(Routes.EDIT_OWN_USER_MFA) 

1783 

1784 def get_failure_url(self) -> str: 

1785 # We get here because the user, who has already logged in successfully, 

1786 # has changed their MFA method. Failure doesn't mean they should be 

1787 # logged out instantly -- they may have (for example) misconfigured 

1788 # their phone number, and if they are forcibly logged out now, they are 

1789 # stuffed and require administrator assistance. Instead, we return them 

1790 # to the home screen. 

1791 return self.request.route_url(Routes.HOME) 

1792 

1793 def set_object_properties(self, appstruct: Dict[str, Any]) -> None: 

1794 # Called by ModelFormMixin.form_valid_process_data() -> 

1795 # ModelFormMixin.save_object(). 

1796 

1797 super().set_object_properties(appstruct) 

1798 

1799 if self.step == self.STEP_MFA_METHOD: 

1800 # We are setting the MFA method, including secret key etc. 

1801 user = cast(User, self.object) 

1802 user.set_mfa_method(appstruct.get(ViewParam.MFA_METHOD)) 

1803 

1804 elif self.step == self.STEP_MFA: 

1805 # Code entered. 

1806 if self.otp_is_valid(appstruct): 

1807 _ = self.request.gettext 

1808 self.request.session.flash( 

1809 _("Multi-factor authentication: success!"), 

1810 queue=FlashQueue.SUCCESS, 

1811 ) 

1812 # ... and continue as below 

1813 else: 

1814 return self.fail_bad_mfa_code() 

1815 

1816 self._next_step(appstruct) 

1817 

1818 def _next_step(self, appstruct: Dict[str, Any]) -> None: 

1819 if self.step == self.STEP_MFA_METHOD: 

1820 # The user has just chosen their method. 

1821 # 2. Offer them method-specific options 

1822 mfa_method = appstruct.get(ViewParam.MFA_METHOD) 

1823 if mfa_method == MfaMethod.NO_MFA: 

1824 self.finish() 

1825 else: 

1826 self.step = mfa_method 

1827 

1828 elif self.step in ( 

1829 self.STEP_TOTP, 

1830 self.STEP_HOTP_EMAIL, 

1831 self.STEP_HOTP_SMS, 

1832 ): 

1833 # Coming from one of the method-specific steps. 

1834 # 3. Ask for the authentication code. 

1835 self.step = self.STEP_MFA 

1836 

1837 elif self.step == self.STEP_MFA: 

1838 # Authentication code provided. End. 

1839 self.finish() 

1840 

1841 else: 

1842 raise AssertionError( 

1843 f"EditOwnUserMfaView.next_step(): " f"Bad step {self.step!r}" 

1844 ) 

1845 

1846 

1847@view_config( 

1848 route_name=Routes.EDIT_OWN_USER_MFA, 

1849 permission=Authenticated, 

1850 http_cache=NEVER_CACHE, 

1851) 

1852def edit_own_user_mfa(request: "CamcopsRequest") -> Response: 

1853 """ 

1854 Edit your own MFA method. 

1855 """ 

1856 view = EditOwnUserMfaView(request) 

1857 return view.dispatch() 

1858 

1859 

1860# ============================================================================= 

1861# Main menu; simple information things 

1862# ============================================================================= 

1863 

1864 

1865@view_config( 

1866 route_name=Routes.HOME, renderer="main_menu.mako", http_cache=NEVER_CACHE 

1867) 

1868def main_menu(req: "CamcopsRequest") -> Dict[str, Any]: 

1869 """ 

1870 Main CamCOPS menu view. 

1871 """ 

1872 user = req.user 

1873 result = dict( 

1874 authorized_as_groupadmin=user.authorized_as_groupadmin, 

1875 authorized_as_superuser=user.superuser, 

1876 authorized_for_reports=user.authorized_for_reports, 

1877 authorized_to_dump=user.authorized_to_dump, 

1878 authorized_to_manage_patients=user.authorized_to_manage_patients, 

1879 camcops_url=CAMCOPS_URL, 

1880 now=format_datetime(req.now, DateFormat.SHORT_DATETIME_SECONDS), 

1881 server_version=CAMCOPS_SERVER_VERSION, 

1882 ) 

1883 return result 

1884 

1885 

1886# ============================================================================= 

1887# Tasks 

1888# ============================================================================= 

1889 

1890 

1891def edit_filter( 

1892 req: "CamcopsRequest", task_filter: TaskFilter, redirect_url: str 

1893) -> Response: 

1894 """ 

1895 Edit the task filter for the current user. 

1896 

1897 Args: 

1898 req: the :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

1899 task_filter: the user's 

1900 :class:`camcops_server.cc_modules.cc_taskfilter.TaskFilter` 

1901 redirect_url: URL to redirect (back) to upon success 

1902 """ 

1903 if FormAction.SET_FILTERS in req.POST: 

1904 form = EditTaskFilterForm(request=req) 

1905 try: 

1906 controls = list(req.POST.items()) 

1907 fa = form.validate(controls) 

1908 # ----------------------------------------------------------------- 

1909 # Apply the changes 

1910 # ----------------------------------------------------------------- 

1911 who = fa.get(ViewParam.WHO) 

1912 what = fa.get(ViewParam.WHAT) 

1913 when = fa.get(ViewParam.WHEN) 

1914 admin = fa.get(ViewParam.ADMIN) 

1915 task_filter.surname = who.get(ViewParam.SURNAME) 

1916 task_filter.forename = who.get(ViewParam.FORENAME) 

1917 task_filter.dob = who.get(ViewParam.DOB) 

1918 task_filter.sex = who.get(ViewParam.SEX) 

1919 task_filter.idnum_criteria = [ 

1920 IdNumReference( 

1921 which_idnum=x[ViewParam.WHICH_IDNUM], 

1922 idnum_value=x[ViewParam.IDNUM_VALUE], 

1923 ) 

1924 for x in who.get(ViewParam.ID_REFERENCES) 

1925 ] 

1926 task_filter.task_types = what.get(ViewParam.TASKS) 

1927 task_filter.text_contents = what.get(ViewParam.TEXT_CONTENTS) 

1928 task_filter.complete_only = what.get(ViewParam.COMPLETE_ONLY) 

1929 task_filter.start_datetime = when.get(ViewParam.START_DATETIME) 

1930 task_filter.end_datetime = when.get(ViewParam.END_DATETIME) 

1931 task_filter.device_ids = admin.get(ViewParam.DEVICE_IDS) 

1932 task_filter.adding_user_ids = admin.get(ViewParam.USER_IDS) 

1933 task_filter.group_ids = admin.get(ViewParam.GROUP_IDS) 

1934 

1935 return HTTPFound(redirect_url) 

1936 except ValidationFailure as e: 

1937 rendered_form = e.render() 

1938 else: 

1939 if FormAction.CLEAR_FILTERS in req.POST: 

1940 # skip validation 

1941 task_filter.clear() 

1942 who = { 

1943 ViewParam.SURNAME: task_filter.surname, 

1944 ViewParam.FORENAME: task_filter.forename, 

1945 ViewParam.DOB: task_filter.dob, 

1946 ViewParam.SEX: task_filter.sex or "", 

1947 ViewParam.ID_REFERENCES: [ 

1948 { 

1949 ViewParam.WHICH_IDNUM: x.which_idnum, 

1950 ViewParam.IDNUM_VALUE: x.idnum_value, 

1951 } 

1952 for x in task_filter.idnum_criteria 

1953 ], 

1954 } 

1955 what = { 

1956 ViewParam.TASKS: task_filter.task_types, 

1957 ViewParam.TEXT_CONTENTS: task_filter.text_contents, 

1958 ViewParam.COMPLETE_ONLY: task_filter.complete_only, 

1959 } 

1960 when = { 

1961 ViewParam.START_DATETIME: task_filter.start_datetime, 

1962 ViewParam.END_DATETIME: task_filter.end_datetime, 

1963 } 

1964 admin = { 

1965 ViewParam.DEVICE_IDS: task_filter.device_ids, 

1966 ViewParam.USER_IDS: task_filter.adding_user_ids, 

1967 ViewParam.GROUP_IDS: task_filter.group_ids, 

1968 } 

1969 open_who = any(i for i in who.values()) 

1970 open_what = any(i for i in what.values()) 

1971 open_when = any(i for i in when.values()) 

1972 open_admin = any(i for i in admin.values()) 

1973 fa = { 

1974 ViewParam.WHO: who, 

1975 ViewParam.WHAT: what, 

1976 ViewParam.WHEN: when, 

1977 ViewParam.ADMIN: admin, 

1978 } 

1979 form = EditTaskFilterForm( 

1980 request=req, 

1981 open_admin=open_admin, 

1982 open_what=open_what, 

1983 open_when=open_when, 

1984 open_who=open_who, 

1985 ) 

1986 rendered_form = form.render(fa) 

1987 

1988 return render_to_response( 

1989 "filter_edit.mako", 

1990 dict( 

1991 form=rendered_form, head_form_html=get_head_form_html(req, [form]) 

1992 ), 

1993 request=req, 

1994 ) 

1995 

1996 

1997@view_config(route_name=Routes.SET_FILTERS, http_cache=NEVER_CACHE) 

1998def set_filters(req: "CamcopsRequest") -> Response: 

1999 """ 

2000 View to set the task filters for the current user. 

2001 """ 

2002 redirect_url = req.get_redirect_url_param( 

2003 ViewParam.REDIRECT_URL, req.route_url(Routes.VIEW_TASKS) 

2004 ) 

2005 task_filter = req.camcops_session.get_task_filter() 

2006 return edit_filter(req, task_filter=task_filter, redirect_url=redirect_url) 

2007 

2008 

2009@view_config( 

2010 route_name=Routes.VIEW_TASKS, 

2011 renderer="view_tasks.mako", 

2012 http_cache=NEVER_CACHE, 

2013) 

2014def view_tasks(req: "CamcopsRequest") -> Dict[str, Any]: 

2015 """ 

2016 Main view displaying tasks and applicable filters. 

2017 """ 

2018 ccsession = req.camcops_session 

2019 user = req.user 

2020 taskfilter = ccsession.get_task_filter() 

2021 

2022 # Read from the GET parameters (or in some cases potentially POST but those 

2023 # will be re-read). 

2024 rows_per_page = req.get_int_param( 

2025 ViewParam.ROWS_PER_PAGE, 

2026 ccsession.number_to_view or DEFAULT_ROWS_PER_PAGE, 

2027 ) 

2028 page_num = req.get_int_param(ViewParam.PAGE, 1) 

2029 via_index = req.get_bool_param(ViewParam.VIA_INDEX, True) 

2030 

2031 errors = False 

2032 

2033 # "Number of tasks per page" form 

2034 tpp_form = TasksPerPageForm(request=req) 

2035 if FormAction.SUBMIT_TASKS_PER_PAGE in req.POST: 

2036 try: 

2037 controls = list(req.POST.items()) 

2038 tpp_appstruct = tpp_form.validate(controls) 

2039 rows_per_page = tpp_appstruct.get(ViewParam.ROWS_PER_PAGE) 

2040 ccsession.number_to_view = rows_per_page 

2041 except ValidationFailure: 

2042 errors = True 

2043 rendered_tpp_form = tpp_form.render() 

2044 else: 

2045 tpp_appstruct = {ViewParam.ROWS_PER_PAGE: rows_per_page} 

2046 rendered_tpp_form = tpp_form.render(tpp_appstruct) 

2047 

2048 # Refresh tasks. Slightly pointless. Doesn't need validating. The user 

2049 # could just press the browser's refresh button, but this improves the UI 

2050 # slightly. 

2051 refresh_form = RefreshTasksForm(request=req) 

2052 rendered_refresh_form = refresh_form.render() 

2053 

2054 # Get tasks, unless there have been form errors. 

2055 # In principle, for some filter settings (single task, no "complete" 

2056 # preference...) we could produce an ORM query and use SqlalchemyOrmPage, 

2057 # which would apply LIMIT/OFFSET (or equivalent) to the query, and be 

2058 # very nippy. In practice, this is probably an unusual setting, so we'll 

2059 # simplify things here with a Python list regardless of the settings. 

2060 if errors: 

2061 collection = [] 

2062 else: 

2063 collection = ( 

2064 TaskCollection( # SECURITY APPLIED HERE 

2065 req=req, 

2066 taskfilter=taskfilter, 

2067 sort_method_global=TaskSortMethod.CREATION_DATE_DESC, 

2068 via_index=via_index, 

2069 ).all_tasks_or_indexes_or_query 

2070 or [] 

2071 ) 

2072 paginator = ( 

2073 SqlalchemyOrmPage if isinstance(collection, Query) else CamcopsPage 

2074 ) 

2075 page = paginator( 

2076 collection, 

2077 page=page_num, 

2078 items_per_page=rows_per_page, 

2079 url_maker=PageUrl(req), 

2080 request=req, 

2081 ) 

2082 return dict( 

2083 page=page, 

2084 head_form_html=get_head_form_html(req, [tpp_form, refresh_form]), 

2085 tpp_form=rendered_tpp_form, 

2086 refresh_form=rendered_refresh_form, 

2087 no_patient_selected_and_user_restricted=( 

2088 not user.may_view_all_patients_when_unfiltered 

2089 and not taskfilter.any_specific_patient_filtering() 

2090 ), 

2091 user=user, 

2092 ) 

2093 

2094 

2095@view_config(route_name=Routes.TASK, http_cache=NEVER_CACHE) 

2096def serve_task(req: "CamcopsRequest") -> Response: 

2097 """ 

2098 View that serves an individual task, in a variety of possible formats 

2099 (e.g. HTML, PDF, XML). 

2100 """ 

2101 _ = req.gettext 

2102 viewtype = req.get_str_param(ViewParam.VIEWTYPE, ViewArg.HTML, lower=True) 

2103 tablename = req.get_str_param( 

2104 ViewParam.TABLE_NAME, validator=validate_task_tablename 

2105 ) 

2106 server_pk = req.get_int_param(ViewParam.SERVER_PK) 

2107 anonymise = req.get_bool_param(ViewParam.ANONYMISE, False) 

2108 

2109 task = task_factory(req, tablename, server_pk) # SECURITY APPLIED HERE 

2110 

2111 if task is None: 

2112 raise HTTPNotFound( # raise, don't return 

2113 f"{_('Task not found or not permitted:')} " 

2114 f"tablename={tablename!r}, server_pk={server_pk!r}" 

2115 ) 

2116 

2117 task.audit(req, "Viewed " + viewtype.upper()) 

2118 

2119 if viewtype == ViewArg.HTML: 

2120 return Response(task.get_html(req=req, anonymise=anonymise)) 

2121 elif viewtype == ViewArg.PDF: 

2122 return PdfResponse( 

2123 body=task.get_pdf(req, anonymise=anonymise), 

2124 filename=task.suggested_pdf_filename(req, anonymise=anonymise), 

2125 ) 

2126 elif viewtype == ViewArg.PDFHTML: # debugging option; no direct hyperlink 

2127 return Response(task.get_pdf_html(req, anonymise=anonymise)) 

2128 elif viewtype == ViewArg.XML: 

2129 options = TaskExportOptions( 

2130 xml_include_ancillary=True, 

2131 include_blobs=req.get_bool_param(ViewParam.INCLUDE_BLOBS, True), 

2132 xml_include_comments=req.get_bool_param( 

2133 ViewParam.INCLUDE_COMMENTS, True 

2134 ), 

2135 xml_include_calculated=req.get_bool_param( 

2136 ViewParam.INCLUDE_CALCULATED, True 

2137 ), 

2138 xml_include_patient=req.get_bool_param( 

2139 ViewParam.INCLUDE_PATIENT, True 

2140 ), 

2141 xml_include_plain_columns=True, 

2142 xml_include_snomed=req.get_bool_param( 

2143 ViewParam.INCLUDE_SNOMED, True 

2144 ), 

2145 xml_with_header_comments=True, 

2146 ) 

2147 return XmlResponse(task.get_xml(req=req, options=options)) 

2148 elif viewtype == ViewArg.FHIRJSON: # debugging option 

2149 dummy_recipient = ExportRecipient() 

2150 bundle = task.get_fhir_bundle( 

2151 req, dummy_recipient, skip_docs_if_other_content=True 

2152 ) 

2153 return JsonResponse(json.dumps(bundle.as_json(), indent=JSON_INDENT)) 

2154 else: 

2155 permissible = ( 

2156 ViewArg.FHIRJSON, 

2157 ViewArg.HTML, 

2158 ViewArg.PDF, 

2159 ViewArg.PDFHTML, 

2160 ViewArg.XML, 

2161 ) 

2162 raise HTTPBadRequest( 

2163 f"{_('Bad output type:')} {viewtype!r} " 

2164 f"({_('permissible:')} {permissible!r})" 

2165 ) 

2166 

2167 

2168def view_patient(req: "CamcopsRequest", patient_server_pk: int) -> Response: 

2169 """ 

2170 Primarily for FHIR views: show just a patient's details. 

2171 Must check security carefully for this one. 

2172 """ 

2173 user = req.user 

2174 patient = Patient.get_patient_by_pk(req.dbsession, patient_server_pk) 

2175 if not patient or not patient.user_may_view(user): 

2176 _ = req.gettext 

2177 raise HTTPBadRequest(_("No such patient or not authorized")) 

2178 return render_to_response( 

2179 "patient.mako", 

2180 dict(patient=patient, viewtype=ViewArg.HTML), 

2181 request=req, 

2182 ) 

2183 

2184 

2185# ============================================================================= 

2186# Trackers, CTVs 

2187# ============================================================================= 

2188 

2189 

2190def choose_tracker_or_ctv( 

2191 req: "CamcopsRequest", as_ctv: bool 

2192) -> Dict[str, Any]: 

2193 """ 

2194 Returns a dictionary for a Mako template to configure a 

2195 :class:`camcops_server.cc_modules.cc_tracker.Tracker` or 

2196 :class:`camcops_server.cc_modules.cc_tracker.ClinicalTextView`. 

2197 

2198 Upon success, it redirects to the tracker or CTV view itself, with the 

2199 tracker's parameters embedded as URL parameters. 

2200 

2201 Args: 

2202 req: the :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

2203 as_ctv: CTV, rather than tracker? 

2204 """ 

2205 

2206 form = ChooseTrackerForm(req, as_ctv=as_ctv) # , css_class="form-inline") 

2207 

2208 if FormAction.SUBMIT in req.POST: 

2209 try: 

2210 controls = list(req.POST.items()) 

2211 appstruct = form.validate(controls) 

2212 keys = [ 

2213 ViewParam.WHICH_IDNUM, 

2214 ViewParam.IDNUM_VALUE, 

2215 ViewParam.START_DATETIME, 

2216 ViewParam.END_DATETIME, 

2217 ViewParam.TASKS, 

2218 ViewParam.ALL_TASKS, 

2219 ViewParam.VIA_INDEX, 

2220 ViewParam.VIEWTYPE, 

2221 ] 

2222 querydict = {k: appstruct.get(k) for k in keys} 

2223 # Not so obvious this can be redirected cleanly via POST. 

2224 # It is possible by returning a form that then autosubmits: see 

2225 # https://stackoverflow.com/questions/46582/response-redirect-with-post-instead-of-get # noqa 

2226 # However, since everything's on this server, we could just return 

2227 # an appropriate Response directly. But the request information is 

2228 # not sensitive, so we lose nothing by using a GET redirect: 

2229 raise HTTPFound( 

2230 req.route_url( 

2231 Routes.CTV if as_ctv else Routes.TRACKER, _query=querydict 

2232 ) 

2233 ) 

2234 except ValidationFailure as e: 

2235 rendered_form = e.render() 

2236 else: 

2237 rendered_form = form.render() 

2238 return dict( 

2239 form=rendered_form, head_form_html=get_head_form_html(req, [form]) 

2240 ) 

2241 

2242 

2243@view_config( 

2244 route_name=Routes.CHOOSE_TRACKER, 

2245 renderer="choose_tracker.mako", 

2246 http_cache=NEVER_CACHE, 

2247) 

2248def choose_tracker(req: "CamcopsRequest") -> Dict[str, Any]: 

2249 """ 

2250 View to choose/configure a 

2251 :class:`camcops_server.cc_modules.cc_tracker.Tracker`. 

2252 """ 

2253 return choose_tracker_or_ctv(req, as_ctv=False) 

2254 

2255 

2256@view_config( 

2257 route_name=Routes.CHOOSE_CTV, 

2258 renderer="choose_ctv.mako", 

2259 http_cache=NEVER_CACHE, 

2260) 

2261def choose_ctv(req: "CamcopsRequest") -> Dict[str, Any]: 

2262 """ 

2263 View to choose/configure a 

2264 :class:`camcops_server.cc_modules.cc_tracker.ClinicalTextView`. 

2265 """ 

2266 return choose_tracker_or_ctv(req, as_ctv=True) 

2267 

2268 

2269def serve_tracker_or_ctv(req: "CamcopsRequest", as_ctv: bool) -> Response: 

2270 """ 

2271 Returns a response to show a 

2272 :class:`camcops_server.cc_modules.cc_tracker.Tracker` or 

2273 :class:`camcops_server.cc_modules.cc_tracker.ClinicalTextView`, in a 

2274 variety of formats (e.g. HTML, PDF, XML). 

2275 

2276 Args: 

2277 req: the :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

2278 as_ctv: CTV, rather than tracker? 

2279 """ 

2280 as_tracker = not as_ctv 

2281 _ = req.gettext 

2282 which_idnum = req.get_int_param(ViewParam.WHICH_IDNUM) 

2283 idnum_value = req.get_int_param(ViewParam.IDNUM_VALUE) 

2284 start_datetime = req.get_datetime_param(ViewParam.START_DATETIME) 

2285 end_datetime = req.get_datetime_param(ViewParam.END_DATETIME) 

2286 tasks = req.get_str_list_param( 

2287 ViewParam.TASKS, validator=validate_task_tablename 

2288 ) 

2289 all_tasks = req.get_bool_param(ViewParam.ALL_TASKS, True) 

2290 viewtype = req.get_str_param(ViewParam.VIEWTYPE, ViewArg.HTML) 

2291 via_index = req.get_bool_param(ViewParam.VIA_INDEX, True) 

2292 

2293 if all_tasks: 

2294 task_classes = [] # type: List[Type[Task]] 

2295 else: 

2296 try: 

2297 task_classes = task_classes_from_table_names( 

2298 tasks, sortmethod=TaskClassSortMethod.SHORTNAME 

2299 ) 

2300 except KeyError: 

2301 raise HTTPBadRequest(_("Invalid tasks specified")) 

2302 if as_tracker and not all(c.provides_trackers for c in task_classes): 

2303 raise HTTPBadRequest(_("Not all tasks specified provide trackers")) 

2304 

2305 iddefs = [IdNumReference(which_idnum, idnum_value)] 

2306 

2307 taskfilter = TaskFilter() 

2308 taskfilter.task_types = [ 

2309 tc.__tablename__ for tc in task_classes 

2310 ] # a bit silly... # noqa 

2311 taskfilter.idnum_criteria = iddefs 

2312 taskfilter.start_datetime = start_datetime 

2313 taskfilter.end_datetime = end_datetime 

2314 taskfilter.complete_only = True # trackers require complete tasks 

2315 taskfilter.set_sort_method(TaskClassSortMethod.SHORTNAME) 

2316 taskfilter.tasks_offering_trackers_only = as_tracker 

2317 taskfilter.tasks_with_patient_only = True 

2318 

2319 tracker_ctv_class = ClinicalTextView if as_ctv else Tracker 

2320 tracker = tracker_ctv_class( 

2321 req=req, taskfilter=taskfilter, via_index=via_index 

2322 ) 

2323 

2324 if viewtype == ViewArg.HTML: 

2325 return Response(tracker.get_html()) 

2326 elif viewtype == ViewArg.PDF: 

2327 return PdfResponse( 

2328 body=tracker.get_pdf(), filename=tracker.suggested_pdf_filename() 

2329 ) 

2330 elif viewtype == ViewArg.PDFHTML: # debugging option 

2331 return Response(tracker.get_pdf_html()) 

2332 elif viewtype == ViewArg.XML: 

2333 include_comments = req.get_bool_param(ViewParam.INCLUDE_COMMENTS, True) 

2334 return XmlResponse(tracker.get_xml(include_comments=include_comments)) 

2335 else: 

2336 permissible = [ViewArg.HTML, ViewArg.PDF, ViewArg.PDFHTML, ViewArg.XML] 

2337 raise HTTPBadRequest( 

2338 f"{_('Invalid view type:')} {viewtype!r} " 

2339 f"({_('permissible:')} {permissible!r})" 

2340 ) 

2341 

2342 

2343@view_config(route_name=Routes.TRACKER, http_cache=NEVER_CACHE) 

2344def serve_tracker(req: "CamcopsRequest") -> Response: 

2345 """ 

2346 View to serve a :class:`camcops_server.cc_modules.cc_tracker.Tracker`; see 

2347 :func:`serve_tracker_or_ctv`. 

2348 """ 

2349 return serve_tracker_or_ctv(req, as_ctv=False) 

2350 

2351 

2352@view_config(route_name=Routes.CTV, http_cache=NEVER_CACHE) 

2353def serve_ctv(req: "CamcopsRequest") -> Response: 

2354 """ 

2355 View to serve a 

2356 :class:`camcops_server.cc_modules.cc_tracker.ClinicalTextView`; see 

2357 :func:`serve_tracker_or_ctv`. 

2358 """ 

2359 return serve_tracker_or_ctv(req, as_ctv=True) 

2360 

2361 

2362# ============================================================================= 

2363# Reports 

2364# ============================================================================= 

2365 

2366 

2367@view_config( 

2368 route_name=Routes.REPORTS_MENU, 

2369 renderer="reports_menu.mako", 

2370 http_cache=NEVER_CACHE, 

2371) 

2372def reports_menu(req: "CamcopsRequest") -> Dict[str, Any]: 

2373 """ 

2374 Offer a menu of reports. 

2375 

2376 Note: Reports are not group-specific. 

2377 If you're authorized to see any, you'll see the whole menu. 

2378 (The *data* you get will be restricted to the group's you're authorized 

2379 to run reports for.) 

2380 """ 

2381 if not req.user.authorized_for_reports: 

2382 raise HTTPBadRequest(errormsg_cannot_report(req)) 

2383 return {} 

2384 

2385 

2386@view_config(route_name=Routes.OFFER_REPORT, http_cache=NEVER_CACHE) 

2387def offer_report(req: "CamcopsRequest") -> Response: 

2388 """ 

2389 Offer configuration options for a single report, or (following submission) 

2390 redirect to serve that report (with configuration parameters in the URL). 

2391 """ 

2392 if not req.user.authorized_for_reports: 

2393 raise HTTPBadRequest(errormsg_cannot_report(req)) 

2394 report_id = req.get_str_param(ViewParam.REPORT_ID) 

2395 report = get_report_instance(report_id) 

2396 _ = req.gettext 

2397 if not report: 

2398 raise HTTPBadRequest(f"{_('No such report ID:')} {report_id!r}") 

2399 if report.superuser_only and not req.user.superuser: 

2400 raise HTTPBadRequest( 

2401 f"{_('Report is restricted to the superuser:')} {report_id!r}" 

2402 ) 

2403 form = report.get_form(req) 

2404 if FormAction.SUBMIT in req.POST: 

2405 try: 

2406 controls = list(req.POST.items()) 

2407 appstruct = form.validate(controls) # may raise 

2408 keys = report.get_http_query_keys() 

2409 querydict = {k: appstruct.get(k) for k in keys} 

2410 querydict[ViewParam.REPORT_ID] = report_id 

2411 querydict[ViewParam.PAGE] = 1 

2412 # Send the user to the actual data using GET: this allows page 

2413 # navigation whilst maintaining any report-specific parameters. 

2414 raise HTTPFound(req.route_url(Routes.REPORT, _query=querydict)) 

2415 except ValidationFailure as e: 

2416 rendered_form = e.render() 

2417 else: 

2418 rendered_form = form.render({ViewParam.REPORT_ID: report_id}) 

2419 return render_to_response( 

2420 "report_offer.mako", 

2421 dict( 

2422 report=report, 

2423 form=rendered_form, 

2424 head_form_html=get_head_form_html(req, [form]), 

2425 ), 

2426 request=req, 

2427 ) 

2428 

2429 

2430@view_config(route_name=Routes.REPORT, http_cache=NEVER_CACHE) 

2431def serve_report(req: "CamcopsRequest") -> Response: 

2432 """ 

2433 Serve a configured report. 

2434 """ 

2435 if not req.user.authorized_for_reports: 

2436 raise HTTPBadRequest(errormsg_cannot_report(req)) 

2437 report_id = req.get_str_param(ViewParam.REPORT_ID) 

2438 report = get_report_instance(report_id) 

2439 _ = req.gettext 

2440 if not report: 

2441 raise HTTPBadRequest(f"{_('No such report ID:')} {report_id!r}") 

2442 if report.superuser_only and not req.user.superuser: 

2443 raise HTTPBadRequest( 

2444 f"{_('Report is restricted to the superuser:')} {report_id!r}" 

2445 ) 

2446 

2447 return report.get_response(req) 

2448 

2449 

2450# ============================================================================= 

2451# Research downloads 

2452# ============================================================================= 

2453 

2454 

2455@view_config(route_name=Routes.OFFER_BASIC_DUMP, http_cache=NEVER_CACHE) 

2456def offer_basic_dump(req: "CamcopsRequest") -> Response: 

2457 """ 

2458 View to configure a basic research dump. 

2459 Following submission success, it redirects to a view serving a TSV/ZIP 

2460 dump. 

2461 """ 

2462 if not req.user.authorized_to_dump: 

2463 raise HTTPBadRequest(errormsg_cannot_dump(req)) 

2464 form = OfferBasicDumpForm(request=req) 

2465 if FormAction.SUBMIT in req.POST: 

2466 try: 

2467 controls = list(req.POST.items()) 

2468 appstruct = form.validate(controls) 

2469 manual = appstruct.get(ViewParam.MANUAL) 

2470 querydict = { 

2471 ViewParam.DUMP_METHOD: appstruct.get(ViewParam.DUMP_METHOD), 

2472 ViewParam.SORT: appstruct.get(ViewParam.SORT), 

2473 ViewParam.GROUP_IDS: manual.get(ViewParam.GROUP_IDS), 

2474 ViewParam.TASKS: manual.get(ViewParam.TASKS), 

2475 ViewParam.VIEWTYPE: appstruct.get(ViewParam.VIEWTYPE), 

2476 ViewParam.DELIVERY_MODE: appstruct.get( 

2477 ViewParam.DELIVERY_MODE 

2478 ), 

2479 ViewParam.INCLUDE_SCHEMA: appstruct.get( 

2480 ViewParam.INCLUDE_SCHEMA 

2481 ), 

2482 ViewParam.SIMPLIFIED: appstruct.get(ViewParam.SIMPLIFIED), 

2483 } 

2484 # We could return a response, or redirect via GET. 

2485 # The request is not sensitive, so let's redirect. 

2486 return HTTPFound( 

2487 req.route_url(Routes.BASIC_DUMP, _query=querydict) 

2488 ) 

2489 except ValidationFailure as e: 

2490 rendered_form = e.render() 

2491 else: 

2492 rendered_form = form.render() 

2493 return render_to_response( 

2494 "dump_basic_offer.mako", 

2495 dict( 

2496 form=rendered_form, head_form_html=get_head_form_html(req, [form]) 

2497 ), 

2498 request=req, 

2499 ) 

2500 

2501 

2502def get_dump_collection(req: "CamcopsRequest") -> TaskCollection: 

2503 """ 

2504 Returns the collection of tasks being requested for a dump operation. 

2505 Raises an error if the request is bad. 

2506 """ 

2507 if not req.user.authorized_to_dump: 

2508 raise HTTPBadRequest(errormsg_cannot_dump(req)) 

2509 # ------------------------------------------------------------------------- 

2510 # Get parameters 

2511 # ------------------------------------------------------------------------- 

2512 dump_method = req.get_str_param(ViewParam.DUMP_METHOD) 

2513 group_ids = req.get_int_list_param(ViewParam.GROUP_IDS) 

2514 task_names = req.get_str_list_param( 

2515 ViewParam.TASKS, validator=validate_task_tablename 

2516 ) 

2517 

2518 # ------------------------------------------------------------------------- 

2519 # Select tasks 

2520 # ------------------------------------------------------------------------- 

2521 if dump_method == ViewArg.EVERYTHING: 

2522 taskfilter = TaskFilter() 

2523 elif dump_method == ViewArg.USE_SESSION_FILTER: 

2524 taskfilter = req.camcops_session.get_task_filter() 

2525 elif dump_method == ViewArg.SPECIFIC_TASKS_GROUPS: 

2526 taskfilter = TaskFilter() 

2527 taskfilter.task_types = task_names 

2528 taskfilter.group_ids = group_ids 

2529 else: 

2530 _ = req.gettext 

2531 raise HTTPBadRequest( 

2532 f"{_('Bad parameter:')} " 

2533 f"{ViewParam.DUMP_METHOD}={dump_method!r}" 

2534 ) 

2535 return TaskCollection( 

2536 req=req, 

2537 taskfilter=taskfilter, 

2538 as_dump=True, 

2539 sort_method_by_class=TaskSortMethod.CREATION_DATE_ASC, 

2540 ) 

2541 

2542 

2543@view_config(route_name=Routes.BASIC_DUMP, http_cache=NEVER_CACHE) 

2544def serve_basic_dump(req: "CamcopsRequest") -> Response: 

2545 """ 

2546 View serving a spreadsheet-style basic research dump. 

2547 """ 

2548 # Get view-specific parameters 

2549 simplified = req.get_bool_param(ViewParam.SIMPLIFIED, False) 

2550 sort_by_heading = req.get_bool_param(ViewParam.SORT, False) 

2551 viewtype = req.get_str_param(ViewParam.VIEWTYPE, ViewArg.XLSX, lower=True) 

2552 delivery_mode = req.get_str_param( 

2553 ViewParam.DELIVERY_MODE, ViewArg.EMAIL, lower=True 

2554 ) 

2555 include_schema = req.get_bool_param(ViewParam.INCLUDE_SCHEMA, False) 

2556 

2557 # Get tasks (and perform checks) 

2558 collection = get_dump_collection(req) 

2559 # Create object that knows how to export 

2560 exporter = make_exporter( 

2561 req=req, 

2562 collection=collection, 

2563 options=DownloadOptions( 

2564 # Exporting to spreadsheets 

2565 user_id=req.user_id, 

2566 viewtype=viewtype, 

2567 delivery_mode=delivery_mode, 

2568 spreadsheet_simplified=simplified, 

2569 spreadsheet_sort_by_heading=sort_by_heading, 

2570 include_information_schema_columns=include_schema, 

2571 include_summary_schema=True, 

2572 ), 

2573 ) # may raise 

2574 # Export, or schedule an email/download 

2575 return exporter.immediate_response(req) 

2576 

2577 

2578@view_config(route_name=Routes.OFFER_SQL_DUMP, http_cache=NEVER_CACHE) 

2579def offer_sql_dump(req: "CamcopsRequest") -> Response: 

2580 """ 

2581 View to configure a SQL research dump. 

2582 Following submission success, it redirects to a view serving the SQL dump. 

2583 """ 

2584 if not req.user.authorized_to_dump: 

2585 raise HTTPBadRequest(errormsg_cannot_dump(req)) 

2586 form = OfferSqlDumpForm(request=req) 

2587 if FormAction.SUBMIT in req.POST: 

2588 try: 

2589 controls = list(req.POST.items()) 

2590 appstruct = form.validate(controls) 

2591 manual = appstruct.get(ViewParam.MANUAL) 

2592 querydict = { 

2593 ViewParam.DUMP_METHOD: appstruct.get(ViewParam.DUMP_METHOD), 

2594 ViewParam.SQLITE_METHOD: appstruct.get( 

2595 ViewParam.SQLITE_METHOD 

2596 ), 

2597 ViewParam.INCLUDE_BLOBS: appstruct.get( 

2598 ViewParam.INCLUDE_BLOBS 

2599 ), 

2600 ViewParam.PATIENT_ID_PER_ROW: appstruct.get( 

2601 ViewParam.PATIENT_ID_PER_ROW 

2602 ), 

2603 ViewParam.GROUP_IDS: manual.get(ViewParam.GROUP_IDS), 

2604 ViewParam.TASKS: manual.get(ViewParam.TASKS), 

2605 ViewParam.DELIVERY_MODE: appstruct.get( 

2606 ViewParam.DELIVERY_MODE 

2607 ), 

2608 ViewParam.INCLUDE_SCHEMA: appstruct.get( 

2609 ViewParam.INCLUDE_SCHEMA 

2610 ), 

2611 } 

2612 # We could return a response, or redirect via GET. 

2613 # The request is not sensitive, so let's redirect. 

2614 return HTTPFound(req.route_url(Routes.SQL_DUMP, _query=querydict)) 

2615 except ValidationFailure as e: 

2616 rendered_form = e.render() 

2617 else: 

2618 rendered_form = form.render() 

2619 return render_to_response( 

2620 "dump_sql_offer.mako", 

2621 dict( 

2622 form=rendered_form, head_form_html=get_head_form_html(req, [form]) 

2623 ), 

2624 request=req, 

2625 ) 

2626 

2627 

2628@view_config(route_name=Routes.SQL_DUMP, http_cache=NEVER_CACHE) 

2629def sql_dump(req: "CamcopsRequest") -> Response: 

2630 """ 

2631 View serving an SQL dump in the chosen format (e.g. SQLite binary, SQL). 

2632 """ 

2633 # Get view-specific parameters 

2634 sqlite_method = req.get_str_param(ViewParam.SQLITE_METHOD) 

2635 include_blobs = req.get_bool_param(ViewParam.INCLUDE_BLOBS, False) 

2636 patient_id_per_row = req.get_bool_param(ViewParam.PATIENT_ID_PER_ROW, True) 

2637 delivery_mode = req.get_str_param( 

2638 ViewParam.DELIVERY_MODE, ViewArg.EMAIL, lower=True 

2639 ) 

2640 include_schema = req.get_bool_param(ViewParam.INCLUDE_SCHEMA, False) 

2641 

2642 # Get tasks (and perform checks) 

2643 collection = get_dump_collection(req) 

2644 # Create object that knows how to export 

2645 exporter = make_exporter( 

2646 req=req, 

2647 collection=collection, 

2648 options=DownloadOptions( 

2649 # Exporting to SQL 

2650 user_id=req.user_id, 

2651 viewtype=sqlite_method, 

2652 delivery_mode=delivery_mode, 

2653 db_include_blobs=include_blobs, 

2654 db_patient_id_per_row=patient_id_per_row, 

2655 include_information_schema_columns=include_schema, 

2656 include_summary_schema=include_schema, # doesn't do much for SQL export at present # noqa 

2657 ), 

2658 ) # may raise 

2659 # Export, or schedule an email/download 

2660 return exporter.immediate_response(req) 

2661 

2662 

2663# noinspection PyUnusedLocal 

2664@view_config( 

2665 route_name=Routes.DOWNLOAD_AREA, 

2666 renderer="download_area.mako", 

2667 http_cache=NEVER_CACHE, 

2668) 

2669def download_area(req: "CamcopsRequest") -> Dict[str, Any]: 

2670 """ 

2671 Shows the user download area. 

2672 """ 

2673 userdir = req.user_download_dir 

2674 if userdir: 

2675 files = UserDownloadFile.from_directory_scan( 

2676 directory=userdir, 

2677 permitted_lifespan_min=req.config.user_download_file_lifetime_min, 

2678 req=req, 

2679 ) 

2680 else: 

2681 files = [] # type: List[UserDownloadFile] 

2682 return dict( 

2683 files=files, 

2684 available=bytes2human(req.user_download_bytes_available), 

2685 permitted=bytes2human(req.user_download_bytes_permitted), 

2686 used=bytes2human(req.user_download_bytes_used), 

2687 lifetime_min=req.config.user_download_file_lifetime_min, 

2688 ) 

2689 

2690 

2691@view_config(route_name=Routes.DOWNLOAD_FILE, http_cache=NEVER_CACHE) 

2692def download_file(req: "CamcopsRequest") -> Response: 

2693 """ 

2694 Downloads a file. 

2695 """ 

2696 _ = req.gettext 

2697 filename = req.get_str_param( 

2698 ViewParam.FILENAME, "", validator=validate_download_filename 

2699 ) 

2700 # Security comes here: we do NOT permit any path information in the 

2701 # filename. It MUST be relative to and within the user download directory. 

2702 # We cannot trust the input. 

2703 filename = os.path.basename(filename) 

2704 udf = UserDownloadFile(directory=req.user_download_dir, filename=filename) 

2705 if not udf.exists: 

2706 raise HTTPBadRequest(f'{_("No such file:")} {filename}') 

2707 try: 

2708 return BinaryResponse( 

2709 body=udf.contents, 

2710 filename=udf.filename, 

2711 content_type=MimeType.BINARY, 

2712 as_inline=False, 

2713 ) 

2714 except OSError: 

2715 raise HTTPBadRequest(f'{_("Error reading file:")} {filename}') 

2716 

2717 

2718@view_config( 

2719 route_name=Routes.DELETE_FILE, 

2720 request_method=HttpMethod.POST, 

2721 http_cache=NEVER_CACHE, 

2722) 

2723def delete_file(req: "CamcopsRequest") -> Response: 

2724 """ 

2725 Deletes a file. 

2726 """ 

2727 form = UserDownloadDeleteForm(request=req) 

2728 controls = list(req.POST.items()) 

2729 appstruct = form.validate(controls) # CSRF; may raise ValidationError 

2730 filename = appstruct.get(ViewParam.FILENAME, "") 

2731 # Security comes here: we do NOT permit any path information in the 

2732 # filename. It MUST be relative to and within the user download directory. 

2733 # We cannot trust the input. 

2734 filename = os.path.basename(filename) 

2735 udf = UserDownloadFile(directory=req.user_download_dir, filename=filename) 

2736 if not udf.exists: 

2737 _ = req.gettext 

2738 raise HTTPBadRequest(f'{_("No such file:")} {filename}') 

2739 udf.delete() 

2740 return HTTPFound(req.route_url(Routes.DOWNLOAD_AREA)) # redirect 

2741 

2742 

2743# ============================================================================= 

2744# View DDL (table definitions) 

2745# ============================================================================= 

2746 

2747LEXERMAP = { 

2748 SqlaDialectName.MYSQL: pygments.lexers.sql.MySqlLexer, 

2749 SqlaDialectName.MSSQL: pygments.lexers.sql.SqlLexer, # generic 

2750 SqlaDialectName.ORACLE: pygments.lexers.sql.SqlLexer, # generic 

2751 SqlaDialectName.FIREBIRD: pygments.lexers.sql.SqlLexer, # generic 

2752 SqlaDialectName.POSTGRES: pygments.lexers.sql.PostgresLexer, 

2753 SqlaDialectName.SQLITE: pygments.lexers.sql.SqlLexer, # generic; SqliteConsoleLexer is wrong # noqa 

2754 SqlaDialectName.SYBASE: pygments.lexers.sql.SqlLexer, # generic 

2755} 

2756 

2757 

2758def format_sql_as_html( 

2759 sql: str, dialect: str = SqlaDialectName.MYSQL 

2760) -> Tuple[str, str]: 

2761 """ 

2762 Formats SQL as HTML with CSS. 

2763 """ 

2764 lexer = LEXERMAP[dialect]() 

2765 # noinspection PyUnresolvedReferences 

2766 formatter = pygments.formatters.HtmlFormatter() 

2767 html = pygments.highlight(sql, lexer, formatter) 

2768 css = formatter.get_style_defs(".highlight") 

2769 return html, css 

2770 

2771 

2772@view_config(route_name=Routes.VIEW_DDL, http_cache=NEVER_CACHE) 

2773def view_ddl(req: "CamcopsRequest") -> Response: 

2774 """ 

2775 Inspect table definitions (data definition language, DDL) with field 

2776 comments. 

2777 

2778 2021-04-30: restricted to users with "dump" authority -- not because this 

2779 is a vulnerability, as the penetration testers suggested, but just to make 

2780 it consistent with the menu item for this. 

2781 """ 

2782 if not req.user.authorized_to_dump: 

2783 raise HTTPBadRequest(errormsg_cannot_dump(req)) 

2784 form = ViewDdlForm(request=req) 

2785 if FormAction.SUBMIT in req.POST: 

2786 try: 

2787 controls = list(req.POST.items()) 

2788 appstruct = form.validate(controls) 

2789 dialect = appstruct.get(ViewParam.DIALECT) 

2790 ddl = get_all_ddl(dialect_name=dialect) 

2791 html, css = format_sql_as_html(ddl, dialect) 

2792 return render_to_response( 

2793 "introspect_file.mako", 

2794 dict(css=css, code_html=html), 

2795 request=req, 

2796 ) 

2797 except ValidationFailure as e: 

2798 rendered_form = e.render() 

2799 else: 

2800 rendered_form = form.render() 

2801 current_dialect = get_dialect_name(get_engine_from_session(req.dbsession)) 

2802 sql_dialect_choices = get_sql_dialect_choices(req) 

2803 current_dialect_description = {k: v for k, v in sql_dialect_choices}.get( 

2804 current_dialect, "?" 

2805 ) 

2806 return render_to_response( 

2807 "view_ddl_choose_dialect.mako", 

2808 dict( 

2809 current_dialect=current_dialect, 

2810 current_dialect_description=current_dialect_description, 

2811 form=rendered_form, 

2812 head_form_html=get_head_form_html(req, [form]), 

2813 ), 

2814 request=req, 

2815 ) 

2816 

2817 

2818# ============================================================================= 

2819# View audit trail 

2820# ============================================================================= 

2821 

2822 

2823@view_config( 

2824 route_name=Routes.OFFER_AUDIT_TRAIL, 

2825 permission=Permission.SUPERUSER, 

2826 http_cache=NEVER_CACHE, 

2827) 

2828def offer_audit_trail(req: "CamcopsRequest") -> Response: 

2829 """ 

2830 View to configure how we'll view the audit trail. Once configured, it 

2831 redirects to a view that shows the audit trail (with query parameters in 

2832 the URL). 

2833 """ 

2834 form = AuditTrailForm(request=req) 

2835 if FormAction.SUBMIT in req.POST: 

2836 try: 

2837 controls = list(req.POST.items()) 

2838 appstruct = form.validate(controls) 

2839 keys = [ 

2840 ViewParam.ROWS_PER_PAGE, 

2841 ViewParam.START_DATETIME, 

2842 ViewParam.END_DATETIME, 

2843 ViewParam.SOURCE, 

2844 ViewParam.REMOTE_IP_ADDR, 

2845 ViewParam.USERNAME, 

2846 ViewParam.TABLE_NAME, 

2847 ViewParam.SERVER_PK, 

2848 ViewParam.TRUNCATE, 

2849 ] 

2850 querydict = {k: appstruct.get(k) for k in keys} 

2851 querydict[ViewParam.PAGE] = 1 

2852 # Send the user to the actual data using GET: 

2853 # (the parameters are NOT sensitive) 

2854 raise HTTPFound( 

2855 req.route_url(Routes.VIEW_AUDIT_TRAIL, _query=querydict) 

2856 ) 

2857 except ValidationFailure as e: 

2858 rendered_form = e.render() 

2859 else: 

2860 rendered_form = form.render() 

2861 return render_to_response( 

2862 "audit_trail_choices.mako", 

2863 dict( 

2864 form=rendered_form, head_form_html=get_head_form_html(req, [form]) 

2865 ), 

2866 request=req, 

2867 ) 

2868 

2869 

2870AUDIT_TRUNCATE_AT = 100 

2871 

2872 

2873@view_config( 

2874 route_name=Routes.VIEW_AUDIT_TRAIL, 

2875 permission=Permission.SUPERUSER, 

2876 http_cache=NEVER_CACHE, 

2877) 

2878def view_audit_trail(req: "CamcopsRequest") -> Response: 

2879 """ 

2880 View to serve the audit trail. 

2881 """ 

2882 rows_per_page = req.get_int_param( 

2883 ViewParam.ROWS_PER_PAGE, DEFAULT_ROWS_PER_PAGE 

2884 ) 

2885 start_datetime = req.get_datetime_param(ViewParam.START_DATETIME) 

2886 end_datetime = req.get_datetime_param(ViewParam.END_DATETIME) 

2887 source = req.get_str_param(ViewParam.SOURCE, None) 

2888 remote_addr = req.get_str_param( 

2889 ViewParam.REMOTE_IP_ADDR, None, validator=validate_ip_address 

2890 ) 

2891 username = req.get_str_param( 

2892 ViewParam.USERNAME, None, validator=validate_username 

2893 ) 

2894 table_name = req.get_str_param( 

2895 ViewParam.TABLE_NAME, None, validator=validate_task_tablename 

2896 ) 

2897 server_pk = req.get_int_param(ViewParam.SERVER_PK, None) 

2898 truncate = req.get_bool_param(ViewParam.TRUNCATE, True) 

2899 page_num = req.get_int_param(ViewParam.PAGE, 1) 

2900 

2901 conditions = [] # type: List[str] 

2902 

2903 def add_condition(key: str, value: Any) -> None: 

2904 conditions.append(f"{key} = {value}") 

2905 

2906 dbsession = req.dbsession 

2907 q = dbsession.query(AuditEntry) 

2908 if start_datetime: 

2909 q = q.filter(AuditEntry.when_access_utc >= start_datetime) 

2910 add_condition(ViewParam.START_DATETIME, start_datetime) 

2911 if end_datetime: 

2912 q = q.filter(AuditEntry.when_access_utc < end_datetime) 

2913 add_condition(ViewParam.END_DATETIME, end_datetime) 

2914 if source: 

2915 q = q.filter(AuditEntry.source == source) 

2916 add_condition(ViewParam.SOURCE, source) 

2917 if remote_addr: 

2918 q = q.filter(AuditEntry.remote_addr == remote_addr) 

2919 add_condition(ViewParam.REMOTE_IP_ADDR, remote_addr) 

2920 if username: 

2921 # https://stackoverflow.com/questions/8561470/sqlalchemy-filtering-by-relationship-attribute # noqa 

2922 q = q.join(User).filter(User.username == username) 

2923 add_condition(ViewParam.USERNAME, username) 

2924 if table_name: 

2925 q = q.filter(AuditEntry.table_name == table_name) 

2926 add_condition(ViewParam.TABLE_NAME, table_name) 

2927 if server_pk is not None: 

2928 q = q.filter(AuditEntry.server_pk == server_pk) 

2929 add_condition(ViewParam.SERVER_PK, server_pk) 

2930 

2931 q = q.order_by(desc(AuditEntry.id)) 

2932 

2933 # audit_entries = dbsession.execute(q).fetchall() 

2934 # ... no! That executes to give you row-type results. 

2935 # audit_entries = q.all() 

2936 # ... yes! But let's paginate, too: 

2937 page = SqlalchemyOrmPage( 

2938 query=q, 

2939 page=page_num, 

2940 items_per_page=rows_per_page, 

2941 url_maker=PageUrl(req), 

2942 request=req, 

2943 ) 

2944 return render_to_response( 

2945 "audit_trail_view.mako", 

2946 dict( 

2947 conditions="; ".join(conditions), 

2948 page=page, 

2949 truncate=truncate, 

2950 truncate_at=AUDIT_TRUNCATE_AT, 

2951 ), 

2952 request=req, 

2953 ) 

2954 

2955 

2956# ============================================================================= 

2957# View export logs 

2958# ============================================================================= 

2959# Overview: 

2960# - View exported tasks (ExportedTask) collectively 

2961# ... option to filter by recipient_name 

2962# ... option to filter by date/etc. 

2963# - View exported tasks (ExportedTask) individually 

2964# ... hyperlinks to individual views of: 

2965# Email (not necessary: ExportedTaskEmail) 

2966# ExportRecipient 

2967# ExportedTaskFileGroup 

2968# ExportedTaskHL7Message 

2969 

2970 

2971@view_config( 

2972 route_name=Routes.OFFER_EXPORTED_TASK_LIST, 

2973 permission=Permission.SUPERUSER, 

2974 http_cache=NEVER_CACHE, 

2975) 

2976def offer_exported_task_list(req: "CamcopsRequest") -> Response: 

2977 """ 

2978 View to choose how we'll view the exported task log. 

2979 """ 

2980 form = ExportedTaskListForm(request=req) 

2981 if FormAction.SUBMIT in req.POST: 

2982 try: 

2983 controls = list(req.POST.items()) 

2984 appstruct = form.validate(controls) 

2985 keys = [ 

2986 ViewParam.ROWS_PER_PAGE, 

2987 ViewParam.RECIPIENT_NAME, 

2988 ViewParam.TABLE_NAME, 

2989 ViewParam.SERVER_PK, 

2990 ViewParam.ID, 

2991 ViewParam.START_DATETIME, 

2992 ViewParam.END_DATETIME, 

2993 ] 

2994 querydict = {k: appstruct.get(k) for k in keys} 

2995 querydict[ViewParam.PAGE] = 1 

2996 # Send the user to the actual data using GET 

2997 # (the parameters are NOT sensitive) 

2998 return HTTPFound( 

2999 req.route_url(Routes.VIEW_EXPORTED_TASK_LIST, _query=querydict) 

3000 ) 

3001 except ValidationFailure as e: 

3002 rendered_form = e.render() 

3003 else: 

3004 rendered_form = form.render() 

3005 return render_to_response( 

3006 "exported_task_choose.mako", 

3007 dict( 

3008 form=rendered_form, head_form_html=get_head_form_html(req, [form]) 

3009 ), 

3010 request=req, 

3011 ) 

3012 

3013 

3014@view_config( 

3015 route_name=Routes.VIEW_EXPORTED_TASK_LIST, 

3016 permission=Permission.SUPERUSER, 

3017 http_cache=NEVER_CACHE, 

3018) 

3019def view_exported_task_list(req: "CamcopsRequest") -> Response: 

3020 """ 

3021 View to serve the exported task log. 

3022 """ 

3023 rows_per_page = req.get_int_param( 

3024 ViewParam.ROWS_PER_PAGE, DEFAULT_ROWS_PER_PAGE 

3025 ) 

3026 recipient_name = req.get_str_param( 

3027 ViewParam.RECIPIENT_NAME, 

3028 None, 

3029 validator=validate_export_recipient_name, 

3030 ) 

3031 table_name = req.get_str_param( 

3032 ViewParam.TABLE_NAME, None, validator=validate_task_tablename 

3033 ) 

3034 server_pk = req.get_int_param(ViewParam.SERVER_PK, None) 

3035 et_id = req.get_int_param(ViewParam.ID, None) 

3036 start_datetime = req.get_datetime_param(ViewParam.START_DATETIME) 

3037 end_datetime = req.get_datetime_param(ViewParam.END_DATETIME) 

3038 page_num = req.get_int_param(ViewParam.PAGE, 1) 

3039 

3040 conditions = [] # type: List[str] 

3041 

3042 def add_condition(key: str, value: Any) -> None: 

3043 conditions.append(f"{key} = {value}") 

3044 

3045 dbsession = req.dbsession 

3046 q = dbsession.query(ExportedTask) 

3047 

3048 if recipient_name: 

3049 q = q.join(ExportRecipient).filter( 

3050 ExportRecipient.recipient_name == recipient_name 

3051 ) 

3052 add_condition(ViewParam.RECIPIENT_NAME, recipient_name) 

3053 if table_name: 

3054 q = q.filter(ExportedTask.basetable == table_name) 

3055 add_condition(ViewParam.TABLE_NAME, table_name) 

3056 if server_pk is not None: 

3057 q = q.filter(ExportedTask.task_server_pk == server_pk) 

3058 add_condition(ViewParam.SERVER_PK, server_pk) 

3059 if et_id is not None: 

3060 q = q.filter(ExportedTask.id == et_id) 

3061 add_condition(ViewParam.ID, et_id) 

3062 if start_datetime: 

3063 q = q.filter(ExportedTask.start_at_utc >= start_datetime) 

3064 add_condition(ViewParam.START_DATETIME, start_datetime) 

3065 if end_datetime: 

3066 q = q.filter(ExportedTask.start_at_utc < end_datetime) 

3067 add_condition(ViewParam.END_DATETIME, end_datetime) 

3068 

3069 q = q.order_by(desc(ExportedTask.id)) 

3070 

3071 page = SqlalchemyOrmPage( 

3072 query=q, 

3073 page=page_num, 

3074 items_per_page=rows_per_page, 

3075 url_maker=PageUrl(req), 

3076 request=req, 

3077 ) 

3078 return render_to_response( 

3079 "exported_task_list.mako", 

3080 dict(conditions="; ".join(conditions), page=page), 

3081 request=req, 

3082 ) 

3083 

3084 

3085# ============================================================================= 

3086# View helpers for ORM objects 

3087# ============================================================================= 

3088 

3089 

3090def _view_generic_object_by_id( 

3091 req: "CamcopsRequest", 

3092 cls: Type, 

3093 instance_name_for_mako: str, 

3094 mako_template: str, 

3095) -> Response: 

3096 """ 

3097 Boilerplate code to view an individual SQLAlchemy ORM object. The object 

3098 must have an integer ``id`` field as its primary key, and the ID value must 

3099 be present in the ``ViewParam.ID`` field of the request. 

3100 

3101 Args: 

3102 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

3103 cls: the SQLAlchemy ORM class 

3104 instance_name_for_mako: what will the object be called when it's 

3105 mako_template: Mako template filename 

3106 

3107 Returns: 

3108 :class:`pyramid.response.Response` 

3109 """ 

3110 item_id = req.get_int_param(ViewParam.ID, None) 

3111 dbsession = req.dbsession 

3112 # noinspection PyUnresolvedReferences 

3113 obj = dbsession.query(cls).filter(cls.id == item_id).first() 

3114 if obj is None: 

3115 _ = req.gettext 

3116 raise HTTPBadRequest( 

3117 f"{_('Bad ID for object type')} " f"{cls.__name__}: {item_id}" 

3118 ) 

3119 d = {instance_name_for_mako: obj} 

3120 return render_to_response(mako_template, d, request=req) 

3121 

3122 

3123# ============================================================================= 

3124# Specialized views for ORM objects 

3125# ============================================================================= 

3126 

3127 

3128@view_config( 

3129 route_name=Routes.VIEW_EMAIL, 

3130 permission=Permission.SUPERUSER, 

3131 http_cache=NEVER_CACHE, 

3132) 

3133def view_email(req: "CamcopsRequest") -> Response: 

3134 """ 

3135 View on an individual :class:`camcops_server.cc_modules.cc_email.Email`. 

3136 """ 

3137 return _view_generic_object_by_id( 

3138 req=req, 

3139 cls=Email, 

3140 instance_name_for_mako="email", 

3141 mako_template="view_email.mako", 

3142 ) 

3143 

3144 

3145@view_config( 

3146 route_name=Routes.VIEW_EXPORT_RECIPIENT, 

3147 permission=Permission.SUPERUSER, 

3148 http_cache=NEVER_CACHE, 

3149) 

3150def view_export_recipient(req: "CamcopsRequest") -> Response: 

3151 """ 

3152 View on an individual 

3153 :class:`camcops_server.cc_modules.cc_exportmodels.ExportedTask`. 

3154 """ 

3155 return _view_generic_object_by_id( 

3156 req=req, 

3157 cls=ExportRecipient, 

3158 instance_name_for_mako="recipient", 

3159 mako_template="export_recipient.mako", 

3160 ) 

3161 

3162 

3163@view_config( 

3164 route_name=Routes.VIEW_EXPORTED_TASK, 

3165 permission=Permission.SUPERUSER, 

3166 http_cache=NEVER_CACHE, 

3167) 

3168def view_exported_task(req: "CamcopsRequest") -> Response: 

3169 """ 

3170 View on an individual 

3171 :class:`camcops_server.cc_modules.cc_exportmodels.ExportedTask`. 

3172 """ 

3173 return _view_generic_object_by_id( 

3174 req=req, 

3175 cls=ExportedTask, 

3176 instance_name_for_mako="et", 

3177 mako_template="exported_task.mako", 

3178 ) 

3179 

3180 

3181@view_config( 

3182 route_name=Routes.VIEW_EXPORTED_TASK_EMAIL, 

3183 permission=Permission.SUPERUSER, 

3184 http_cache=NEVER_CACHE, 

3185) 

3186def view_exported_task_email(req: "CamcopsRequest") -> Response: 

3187 """ 

3188 View on an individual 

3189 :class:`camcops_server.cc_modules.cc_exportmodels.ExportedTaskEmail`. 

3190 """ 

3191 return _view_generic_object_by_id( 

3192 req=req, 

3193 cls=ExportedTaskEmail, 

3194 instance_name_for_mako="ete", 

3195 mako_template="exported_task_email.mako", 

3196 ) 

3197 

3198 

3199@view_config( 

3200 route_name=Routes.VIEW_EXPORTED_TASK_FILE_GROUP, 

3201 permission=Permission.SUPERUSER, 

3202 http_cache=NEVER_CACHE, 

3203) 

3204def view_exported_task_file_group(req: "CamcopsRequest") -> Response: 

3205 """ 

3206 View on an individual 

3207 :class:`camcops_server.cc_modules.cc_exportmodels.ExportedTaskFileGroup`. 

3208 """ 

3209 return _view_generic_object_by_id( 

3210 req=req, 

3211 cls=ExportedTaskFileGroup, 

3212 instance_name_for_mako="fg", 

3213 mako_template="exported_task_file_group.mako", 

3214 ) 

3215 

3216 

3217@view_config( 

3218 route_name=Routes.VIEW_EXPORTED_TASK_HL7_MESSAGE, 

3219 permission=Permission.SUPERUSER, 

3220 http_cache=NEVER_CACHE, 

3221) 

3222def view_exported_task_hl7_message(req: "CamcopsRequest") -> Response: 

3223 """ 

3224 View on an individual 

3225 :class:`camcops_server.cc_modules.cc_exportmodels.ExportedTaskHL7Message`. 

3226 """ 

3227 return _view_generic_object_by_id( 

3228 req=req, 

3229 cls=ExportedTaskHL7Message, 

3230 instance_name_for_mako="msg", 

3231 mako_template="exported_task_hl7_message.mako", 

3232 ) 

3233 

3234 

3235@view_config( 

3236 route_name=Routes.VIEW_EXPORTED_TASK_REDCAP, 

3237 permission=Permission.SUPERUSER, 

3238 http_cache=NEVER_CACHE, 

3239) 

3240def view_exported_task_redcap(req: "CamcopsRequest") -> Response: 

3241 """ 

3242 View on an individual 

3243 :class:`camcops_server.cc_modules.cc_exportmodels.ExportedTaskRedcap`. 

3244 """ 

3245 return _view_generic_object_by_id( 

3246 req=req, 

3247 cls=ExportedTaskRedcap, 

3248 instance_name_for_mako="etr", 

3249 mako_template="exported_task_redcap.mako", 

3250 ) 

3251 

3252 

3253@view_config( 

3254 route_name=Routes.VIEW_EXPORTED_TASK_FHIR, 

3255 permission=Permission.SUPERUSER, 

3256 http_cache=NEVER_CACHE, 

3257) 

3258def view_exported_task_fhir(req: "CamcopsRequest") -> Response: 

3259 """ 

3260 View on an individual 

3261 :class:`camcops_server.cc_modules.cc_exportmodels.ExportedTaskRedcap`. 

3262 """ 

3263 return _view_generic_object_by_id( 

3264 req=req, 

3265 cls=ExportedTaskFhir, 

3266 instance_name_for_mako="etf", 

3267 mako_template="exported_task_fhir.mako", 

3268 ) 

3269 

3270 

3271@view_config( 

3272 route_name=Routes.VIEW_EXPORTED_TASK_FHIR_ENTRY, 

3273 permission=Permission.SUPERUSER, 

3274 http_cache=NEVER_CACHE, 

3275) 

3276def view_exported_task_fhir_entry(req: "CamcopsRequest") -> Response: 

3277 """ 

3278 View on an individual 

3279 :class:`camcops_server.cc_modules.cc_exportmodels.ExportedTaskRedcap`. 

3280 """ 

3281 return _view_generic_object_by_id( 

3282 req=req, 

3283 cls=ExportedTaskFhirEntry, 

3284 instance_name_for_mako="etfe", 

3285 mako_template="exported_task_fhir_entry.mako", 

3286 ) 

3287 

3288 

3289# ============================================================================= 

3290# User/server info views 

3291# ============================================================================= 

3292 

3293 

3294@view_config( 

3295 route_name=Routes.VIEW_OWN_USER_INFO, 

3296 renderer="view_own_user_info.mako", 

3297 http_cache=NEVER_CACHE, 

3298) 

3299def view_own_user_info(req: "CamcopsRequest") -> Dict[str, Any]: 

3300 """ 

3301 View to provide information about your own user. 

3302 """ 

3303 groups_page = CamcopsPage( 

3304 req.user.groups, url_maker=PageUrl(req), request=req 

3305 ) 

3306 return dict( 

3307 user=req.user, 

3308 groups_page=groups_page, 

3309 valid_which_idnums=req.valid_which_idnums, 

3310 ) 

3311 

3312 

3313@view_config( 

3314 route_name=Routes.VIEW_SERVER_INFO, 

3315 renderer="view_server_info.mako", 

3316 http_cache=NEVER_CACHE, 

3317) 

3318def view_server_info(req: "CamcopsRequest") -> Dict[str, Any]: 

3319 """ 

3320 View to show the server's ID policies, etc. 

3321 """ 

3322 _ = req.gettext 

3323 now = req.now 

3324 recent_activity = OrderedDict( 

3325 [ 

3326 ( 

3327 _("Last 1 minute"), 

3328 CamcopsSession.n_sessions_active_since( 

3329 req, now.subtract(minutes=1) 

3330 ), 

3331 ), 

3332 ( 

3333 _("Last 5 minutes"), 

3334 CamcopsSession.n_sessions_active_since( 

3335 req, now.subtract(minutes=5) 

3336 ), 

3337 ), 

3338 ( 

3339 _("Last 10 minutes"), 

3340 CamcopsSession.n_sessions_active_since( 

3341 req, now.subtract(minutes=10) 

3342 ), 

3343 ), 

3344 ( 

3345 _("Last 1 hour"), 

3346 CamcopsSession.n_sessions_active_since( 

3347 req, now.subtract(hours=1) 

3348 ), 

3349 ), 

3350 ] 

3351 ) 

3352 return dict( 

3353 idnum_definitions=req.idnum_definitions, 

3354 string_families=req.extrastring_families(), 

3355 recent_activity=recent_activity, 

3356 session_timeout_minutes=req.config.session_timeout_minutes, 

3357 restricted_tasks=req.config.restricted_tasks, 

3358 ) 

3359 

3360 

3361# ============================================================================= 

3362# User management 

3363# ============================================================================= 

3364 

3365 

3366def get_user_from_request_user_id_or_raise(req: "CamcopsRequest") -> User: 

3367 """ 

3368 Returns the :class:`camcops_server.cc_modules.cc_user.User` represented by 

3369 the request's ``ViewParam.USER_ID`` parameter, or raise 

3370 :exc:`HTTPBadRequest`. 

3371 """ 

3372 user_id = req.get_int_param(ViewParam.USER_ID) 

3373 user = User.get_user_by_id(req.dbsession, user_id) 

3374 if not user: 

3375 _ = req.gettext 

3376 raise HTTPBadRequest(f"{_('No such user ID:')} {user_id!r}") 

3377 return user 

3378 

3379 

3380def query_users_that_i_manage(req: "CamcopsRequest") -> Query: 

3381 me = req.user 

3382 return me.managed_users() 

3383 

3384 

3385@view_config( 

3386 route_name=Routes.VIEW_ALL_USERS, 

3387 permission=Permission.GROUPADMIN, 

3388 renderer="users_view.mako", 

3389 http_cache=NEVER_CACHE, 

3390) 

3391def view_all_users(req: "CamcopsRequest") -> Dict[str, Any]: 

3392 """ 

3393 View all users that the current user administers. The view has hyperlinks 

3394 to edit those users too. 

3395 """ 

3396 include_auto_generated = req.get_bool_param( 

3397 ViewParam.INCLUDE_AUTO_GENERATED, False 

3398 ) 

3399 rows_per_page = req.get_int_param( 

3400 ViewParam.ROWS_PER_PAGE, DEFAULT_ROWS_PER_PAGE 

3401 ) 

3402 page_num = req.get_int_param(ViewParam.PAGE, 1) 

3403 q = query_users_that_i_manage(req) 

3404 if not include_auto_generated: 

3405 q = q.filter(User.auto_generated == False) # noqa: E712 

3406 page = SqlalchemyOrmPage( 

3407 query=q, 

3408 page=page_num, 

3409 items_per_page=rows_per_page, 

3410 url_maker=PageUrl(req), 

3411 request=req, 

3412 ) 

3413 

3414 form = UserFilterForm(request=req) 

3415 appstruct = {ViewParam.INCLUDE_AUTO_GENERATED: include_auto_generated} 

3416 rendered_form = form.render(appstruct) 

3417 

3418 return dict( 

3419 page=page, 

3420 head_form_html=get_head_form_html(req, [form]), 

3421 form=rendered_form, 

3422 ) 

3423 

3424 

3425@view_config( 

3426 route_name=Routes.VIEW_USER_EMAIL_ADDRESSES, 

3427 permission=Permission.GROUPADMIN, 

3428 renderer="view_user_email_addresses.mako", 

3429 http_cache=NEVER_CACHE, 

3430) 

3431def view_user_email_addresses(req: "CamcopsRequest") -> Dict[str, Any]: 

3432 """ 

3433 View e-mail addresses of all users that the requesting user is authorized 

3434 to manage. 

3435 """ 

3436 q = query_users_that_i_manage(req).filter( 

3437 User.auto_generated == False # noqa: E712 

3438 ) 

3439 return dict(query=q) 

3440 

3441 

3442def assert_may_edit_user(req: "CamcopsRequest", user: User) -> None: 

3443 """ 

3444 Checks that the requesting user (``req.user``) is allowed to edit the other 

3445 user (``user``). Raises :exc:`HTTPBadRequest` otherwise. 

3446 """ 

3447 may_edit, why_not = req.user.may_edit_user(req, user) 

3448 if not may_edit: 

3449 raise HTTPBadRequest(why_not) 

3450 

3451 

3452def assert_may_administer_group(req: "CamcopsRequest", group_id: int) -> None: 

3453 """ 

3454 Checks that the requesting user (``req.user``) is allowed to adminster the 

3455 specified group (specified by ``group_id``). Raises :exc:`HTTPBadRequest` 

3456 otherwise. 

3457 """ 

3458 if not req.user.may_administer_group(group_id): 

3459 _ = req.gettext 

3460 raise HTTPBadRequest(_("You may not administer this group")) 

3461 

3462 

3463@view_config( 

3464 route_name=Routes.VIEW_USER, 

3465 permission=Permission.GROUPADMIN, 

3466 renderer="view_other_user_info.mako", 

3467 http_cache=NEVER_CACHE, 

3468) 

3469def view_user(req: "CamcopsRequest") -> Dict[str, Any]: 

3470 """ 

3471 View to show details of another user, for administrators. 

3472 """ 

3473 user = get_user_from_request_user_id_or_raise(req) 

3474 assert_may_edit_user(req, user) 

3475 return dict(user=user) 

3476 # Groupadmins may see some information regarding groups that aren't theirs 

3477 # here, but can't alter it. 

3478 

3479 

3480class EditUserBaseView(UpdateView): 

3481 """ 

3482 Django-style view to edit a user and their groups 

3483 """ 

3484 

3485 model_form_dict = { 

3486 "username": ViewParam.USERNAME, 

3487 "fullname": ViewParam.FULLNAME, 

3488 "email": ViewParam.EMAIL, 

3489 "must_change_password": ViewParam.MUST_CHANGE_PASSWORD, 

3490 "language": ViewParam.LANGUAGE, 

3491 } 

3492 object_class = User 

3493 pk_param = ViewParam.USER_ID 

3494 server_pk_name = "id" 

3495 template_name = "user_edit.mako" 

3496 

3497 def get_success_url(self) -> str: 

3498 return self.request.route_url(Routes.VIEW_ALL_USERS) 

3499 

3500 def get_object(self) -> Any: 

3501 user = cast(User, super().get_object()) 

3502 

3503 assert_may_edit_user(self.request, user) 

3504 

3505 return user 

3506 

3507 def set_object_properties(self, appstruct: Dict[str, Any]) -> None: 

3508 user = cast(User, self.object) 

3509 _ = self.request.gettext 

3510 

3511 new_user_name = appstruct.get(ViewParam.USERNAME) 

3512 existing_user = User.get_user_by_name( 

3513 self.request.dbsession, new_user_name 

3514 ) 

3515 if existing_user and existing_user.id != user.id: 

3516 # noinspection PyUnresolvedReferences 

3517 cant_rename_user = _("Can't rename user") 

3518 conflicts = _("that conflicts with an existing user with ID") 

3519 raise HTTPBadRequest( 

3520 f"{cant_rename_user} {user.username!r} (#{user.id!r}) → " 

3521 f"{new_user_name!r}; {conflicts} {existing_user.id!r}" 

3522 ) 

3523 

3524 email = appstruct.get(ViewParam.EMAIL) 

3525 if not email and user.mfa_method == MfaMethod.HOTP_EMAIL: 

3526 message = _( 

3527 "This user's email address is used for multi-factor " 

3528 "authentication. If you want to remove their email " 

3529 "address, you must first disable multi-factor " 

3530 "authentication" 

3531 ) 

3532 

3533 raise HTTPBadRequest(message) 

3534 

3535 super().set_object_properties(appstruct) 

3536 

3537 # Groups that we might change memberships for: 

3538 all_fluid_groups = self.request.user.ids_of_groups_user_is_admin_for 

3539 # All groups that the user is currently in: 

3540 user_group_ids = user.group_ids 

3541 # Group membership we won't touch: 

3542 user_frozen_group_ids = list( 

3543 set(user_group_ids) - set(all_fluid_groups) 

3544 ) 

3545 group_ids = appstruct.get(ViewParam.GROUP_IDS) 

3546 # Add back in the groups we're not going to alter: 

3547 final_group_ids = list(set(group_ids) | set(user_frozen_group_ids)) 

3548 user.set_group_ids(final_group_ids) 

3549 # Also, if the user was uploading to a group that they are now no 

3550 # longer a member of, we need to fix that 

3551 if user.upload_group_id not in final_group_ids: 

3552 user.upload_group_id = None 

3553 

3554 def get_form_values(self) -> Dict[str, Any]: 

3555 # will populate with model_form_dict 

3556 form_values = super().get_form_values() 

3557 

3558 user = cast(User, self.object) 

3559 

3560 # Superusers can do everything, of course. 

3561 # Groupadmins can change group memberships only for groups they control 

3562 # (here: "fluid"). That means that there may be a subset of group 

3563 # memberships for this user that they will neither see nor be able to 

3564 # alter (here: "frozen"). They can also edit only a restricted set of 

3565 # permissions. 

3566 

3567 # Groups that we might change memberships for: 

3568 all_fluid_groups = self.request.user.ids_of_groups_user_is_admin_for 

3569 # All groups that the user is currently in: 

3570 user_group_ids = user.group_ids 

3571 # Group memberships we might alter: 

3572 user_fluid_group_ids = list( 

3573 set(user_group_ids) & set(all_fluid_groups) 

3574 ) 

3575 form_values.update( 

3576 { 

3577 ViewParam.USER_ID: user.id, 

3578 ViewParam.GROUP_IDS: user_fluid_group_ids, 

3579 } 

3580 ) 

3581 

3582 return form_values 

3583 

3584 

3585class EditUserGroupAdminView(EditUserBaseView): 

3586 """ 

3587 For group administrators to edit a user. 

3588 """ 

3589 

3590 form_class = EditUserGroupAdminForm 

3591 

3592 

3593class EditUserSuperUserView(EditUserBaseView): 

3594 """ 

3595 For superusers to edit a user. 

3596 """ 

3597 

3598 form_class = EditUserFullForm 

3599 

3600 def get_model_form_dict(self) -> Dict[str, Any]: 

3601 model_form_dict = super().get_model_form_dict() 

3602 model_form_dict["superuser"] = ViewParam.SUPERUSER 

3603 

3604 return model_form_dict 

3605 

3606 

3607@view_config( 

3608 route_name=Routes.EDIT_USER, 

3609 permission=Permission.GROUPADMIN, 

3610 http_cache=NEVER_CACHE, 

3611) 

3612def edit_user(req: "CamcopsRequest") -> Response: 

3613 """ 

3614 View to edit a user (for administrators). 

3615 """ 

3616 view: EditUserBaseView 

3617 

3618 if req.user.superuser: 

3619 view = EditUserSuperUserView(req) 

3620 else: 

3621 view = EditUserGroupAdminView(req) 

3622 

3623 return view.dispatch() 

3624 

3625 

3626class EditUserGroupMembershipBaseView(UpdateView): 

3627 """ 

3628 Django-style view to edit a user's group membership permissions. 

3629 """ 

3630 

3631 model_form_dict = { 

3632 "may_upload": ViewParam.MAY_UPLOAD, 

3633 "may_register_devices": ViewParam.MAY_REGISTER_DEVICES, 

3634 "may_use_webviewer": ViewParam.MAY_USE_WEBVIEWER, 

3635 "view_all_patients_when_unfiltered": ViewParam.VIEW_ALL_PATIENTS_WHEN_UNFILTERED, # noqa: E501 

3636 "may_dump_data": ViewParam.MAY_DUMP_DATA, 

3637 "may_run_reports": ViewParam.MAY_RUN_REPORTS, 

3638 "may_add_notes": ViewParam.MAY_ADD_NOTES, 

3639 "may_manage_patients": ViewParam.MAY_MANAGE_PATIENTS, 

3640 "may_email_patients": ViewParam.MAY_EMAIL_PATIENTS, 

3641 } 

3642 

3643 object_class = UserGroupMembership 

3644 pk_param = ViewParam.USER_GROUP_MEMBERSHIP_ID 

3645 server_pk_name = "id" 

3646 template_name = "user_edit_group_membership.mako" 

3647 

3648 def get_success_url(self) -> str: 

3649 return self.request.route_url(Routes.VIEW_ALL_USERS) 

3650 

3651 def get_object(self) -> Any: 

3652 # noinspection PyUnresolvedReferences 

3653 ugm = cast(UserGroupMembership, super().get_object()) 

3654 user = ugm.user 

3655 assert_may_edit_user(self.request, user) 

3656 assert_may_administer_group(self.request, ugm.group_id) 

3657 

3658 return ugm 

3659 

3660 

3661class EditUserGroupMembershipSuperUserView(EditUserGroupMembershipBaseView): 

3662 """ 

3663 For superusers to edit a user's group memberships. 

3664 """ 

3665 

3666 form_class = EditUserGroupPermissionsFullForm 

3667 

3668 def get_model_form_dict(self) -> Dict[str, str]: 

3669 model_form_dict = super().get_model_form_dict() 

3670 model_form_dict["groupadmin"] = ViewParam.GROUPADMIN 

3671 

3672 return model_form_dict 

3673 

3674 

3675class EditUserGroupMembershipGroupAdminView(EditUserGroupMembershipBaseView): 

3676 """ 

3677 For group administrators to edit a user's group memberships. 

3678 """ 

3679 

3680 form_class = EditUserGroupMembershipGroupAdminForm 

3681 

3682 

3683@view_config( 

3684 route_name=Routes.EDIT_USER_GROUP_MEMBERSHIP, 

3685 permission=Permission.GROUPADMIN, 

3686 http_cache=NEVER_CACHE, 

3687) 

3688def edit_user_group_membership(req: "CamcopsRequest") -> Response: 

3689 """ 

3690 View to edit the group memberships of a user (for administrators). 

3691 """ 

3692 if req.user.superuser: 

3693 view = EditUserGroupMembershipSuperUserView(req) 

3694 else: 

3695 view = EditUserGroupMembershipGroupAdminView(req) 

3696 

3697 return view.dispatch() 

3698 

3699 

3700def set_user_upload_group( 

3701 req: "CamcopsRequest", user: User, by_another: bool 

3702) -> Response: 

3703 """ 

3704 Provides a view to choose which group a user uploads into. 

3705 

3706 TRUSTS ITS CALLER that this is permitted. 

3707 

3708 Args: 

3709 req: the :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

3710 user: the :class:`camcops_server.cc_modules.cc_user.User` to edit 

3711 by_another: is the current user a superuser/group administrator, i.e. 

3712 another user? Determines the screen we return to afterwards. 

3713 """ 

3714 route_back = Routes.VIEW_ALL_USERS if by_another else Routes.HOME 

3715 if FormAction.CANCEL in req.POST: 

3716 return HTTPFound(req.route_url(route_back)) 

3717 form = SetUserUploadGroupForm(request=req, user=user) 

3718 # ... need to show the groups permitted to THAT user, not OUR user 

3719 if FormAction.SUBMIT in req.POST: 

3720 try: 

3721 controls = list(req.POST.items()) 

3722 appstruct = form.validate(controls) 

3723 # ----------------------------------------------------------------- 

3724 # Apply the changes 

3725 # ----------------------------------------------------------------- 

3726 user.upload_group_id = appstruct.get(ViewParam.UPLOAD_GROUP_ID) 

3727 return HTTPFound(req.route_url(route_back)) 

3728 except ValidationFailure as e: 

3729 rendered_form = e.render() 

3730 else: 

3731 appstruct = { 

3732 ViewParam.USER_ID: user.id, 

3733 ViewParam.UPLOAD_GROUP_ID: user.upload_group_id, 

3734 } 

3735 rendered_form = form.render(appstruct) 

3736 return render_to_response( 

3737 "set_user_upload_group.mako", 

3738 dict( 

3739 user=user, 

3740 form=rendered_form, 

3741 head_form_html=get_head_form_html(req, [form]), 

3742 ), 

3743 request=req, 

3744 ) 

3745 

3746 

3747@view_config( 

3748 route_name=Routes.SET_OWN_USER_UPLOAD_GROUP, http_cache=NEVER_CACHE 

3749) 

3750def set_own_user_upload_group(req: "CamcopsRequest") -> Response: 

3751 """ 

3752 View to set the upload group for your own user. 

3753 """ 

3754 return set_user_upload_group(req, req.user, False) 

3755 

3756 

3757@view_config( 

3758 route_name=Routes.SET_OTHER_USER_UPLOAD_GROUP, 

3759 permission=Permission.GROUPADMIN, 

3760 http_cache=NEVER_CACHE, 

3761) 

3762def set_other_user_upload_group(req: "CamcopsRequest") -> Response: 

3763 """ 

3764 View to set the upload group for another user. 

3765 """ 

3766 user = get_user_from_request_user_id_or_raise(req) 

3767 if user.id != req.user.id: 

3768 assert_may_edit_user(req, user) 

3769 # ... but always OK to edit this for your own user; no such check required 

3770 return set_user_upload_group(req, user, True) 

3771 

3772 

3773# noinspection PyTypeChecker 

3774@view_config( 

3775 route_name=Routes.UNLOCK_USER, 

3776 permission=Permission.GROUPADMIN, 

3777 http_cache=NEVER_CACHE, 

3778) 

3779def unlock_user(req: "CamcopsRequest") -> Response: 

3780 """ 

3781 View to unlock a locked user account. 

3782 """ 

3783 user = get_user_from_request_user_id_or_raise(req) 

3784 assert_may_edit_user(req, user) 

3785 user.enable(req) 

3786 _ = req.gettext 

3787 

3788 req.session.flash( 

3789 _("User {username} enabled").format(username=user.username), 

3790 queue=FlashQueue.SUCCESS, 

3791 ) 

3792 raise HTTPFound(req.route_url(Routes.VIEW_ALL_USERS)) 

3793 

3794 

3795@view_config( 

3796 route_name=Routes.ADD_USER, 

3797 permission=Permission.GROUPADMIN, 

3798 renderer="user_add.mako", 

3799 http_cache=NEVER_CACHE, 

3800) 

3801def add_user(req: "CamcopsRequest") -> Dict[str, Any]: 

3802 """ 

3803 View to add a user. 

3804 """ 

3805 route_back = Routes.VIEW_ALL_USERS 

3806 if FormAction.CANCEL in req.POST: 

3807 raise HTTPFound(req.route_url(route_back)) 

3808 if req.user.superuser: 

3809 form = AddUserSuperuserForm(request=req) 

3810 else: 

3811 form = AddUserGroupadminForm(request=req) 

3812 dbsession = req.dbsession 

3813 if FormAction.SUBMIT in req.POST: 

3814 try: 

3815 controls = list(req.POST.items()) 

3816 appstruct = form.validate(controls) 

3817 # ----------------------------------------------------------------- 

3818 # Add the user 

3819 # ----------------------------------------------------------------- 

3820 user = User() 

3821 user.username = appstruct.get(ViewParam.USERNAME) 

3822 user.set_password(req, appstruct.get(ViewParam.NEW_PASSWORD)) 

3823 user.must_change_password = appstruct.get( 

3824 ViewParam.MUST_CHANGE_PASSWORD 

3825 ) 

3826 # We don't ask for language initially; that can be configured 

3827 # later. But is is a reasonable guess that it should be the same 

3828 # language as used by the person creating the new user. 

3829 user.language = req.language 

3830 if User.get_user_by_name(dbsession, user.username): 

3831 raise HTTPBadRequest( 

3832 f"User with username {user.username!r} already exists!" 

3833 ) 

3834 dbsession.add(user) 

3835 group_ids = appstruct.get(ViewParam.GROUP_IDS) 

3836 for gid in group_ids: 

3837 # noinspection PyUnresolvedReferences 

3838 user.user_group_memberships.append( 

3839 UserGroupMembership(user_id=user.id, group_id=gid) 

3840 ) 

3841 raise HTTPFound(req.route_url(route_back)) 

3842 except ValidationFailure as e: 

3843 rendered_form = e.render() 

3844 else: 

3845 rendered_form = form.render() 

3846 return dict( 

3847 form=rendered_form, head_form_html=get_head_form_html(req, [form]) 

3848 ) 

3849 

3850 

3851def any_records_use_user(req: "CamcopsRequest", user: User) -> bool: 

3852 """ 

3853 Do any records in the database refer to the specified user? 

3854 

3855 (Used when we're thinking about deleting a user; would it leave broken 

3856 references? If so, we will prevent deletion; see :func:`delete_user`.) 

3857 """ 

3858 dbsession = req.dbsession 

3859 user_id = user.id 

3860 # Device? 

3861 q = CountStarSpecializedQuery(Device, session=dbsession).filter( 

3862 or_( 

3863 Device.registered_by_user_id == user_id, 

3864 Device.uploading_user_id == user_id, 

3865 ) 

3866 ) 

3867 if q.count_star() > 0: 

3868 return True 

3869 # SpecialNote? 

3870 q = CountStarSpecializedQuery(SpecialNote, session=dbsession).filter( 

3871 SpecialNote.user_id == user_id 

3872 ) 

3873 if q.count_star() > 0: 

3874 return True 

3875 # Audit trail? 

3876 q = CountStarSpecializedQuery(AuditEntry, session=dbsession).filter( 

3877 AuditEntry.user_id == user_id 

3878 ) 

3879 if q.count_star() > 0: 

3880 return True 

3881 # Uploaded records? 

3882 for cls in gen_orm_classes_from_base( 

3883 GenericTabletRecordMixin 

3884 ): # type: Type[GenericTabletRecordMixin] # noqa 

3885 # noinspection PyProtectedMember 

3886 q = CountStarSpecializedQuery(cls, session=dbsession).filter( 

3887 or_( 

3888 cls._adding_user_id == user_id, 

3889 cls._removing_user_id == user_id, 

3890 cls._preserving_user_id == user_id, 

3891 cls._manually_erasing_user_id == user_id, 

3892 ) 

3893 ) 

3894 if q.count_star() > 0: 

3895 return True 

3896 # No; all clean. 

3897 return False 

3898 

3899 

3900@view_config( 

3901 route_name=Routes.DELETE_USER, 

3902 permission=Permission.GROUPADMIN, 

3903 renderer="user_delete.mako", 

3904 http_cache=NEVER_CACHE, 

3905) 

3906def delete_user(req: "CamcopsRequest") -> Dict[str, Any]: 

3907 """ 

3908 View to delete a user (and make it hard work). 

3909 """ 

3910 if FormAction.CANCEL in req.POST: 

3911 raise HTTPFound(req.route_url(Routes.VIEW_ALL_USERS)) 

3912 user = get_user_from_request_user_id_or_raise(req) 

3913 assert_may_edit_user(req, user) 

3914 form = DeleteUserForm(request=req) 

3915 rendered_form = "" 

3916 error = "" 

3917 _ = req.gettext 

3918 if user.id == req.user.id: 

3919 error = _("Can't delete your own user!") 

3920 elif user.may_use_webviewer or user.may_upload: 

3921 error = _( 

3922 "Unable to delete user: user still has webviewer login " 

3923 "and/or tablet upload permission" 

3924 ) 

3925 elif user.superuser and (not req.user.superuser): 

3926 error = _( 

3927 "Unable to delete user: " "they are a superuser and you are not" 

3928 ) 

3929 elif (not req.user.superuser) and bool( 

3930 set(user.group_ids) - set(req.user.ids_of_groups_user_is_admin_for) 

3931 ): 

3932 error = _( 

3933 "Unable to delete user: " 

3934 "user belongs to groups that you do not administer" 

3935 ) 

3936 else: 

3937 if any_records_use_user(req, user): 

3938 error = _( 

3939 "Unable to delete user; records (or audit trails) refer to " 

3940 "that user. Disable login and upload permissions instead." 

3941 ) 

3942 else: 

3943 if FormAction.DELETE in req.POST: 

3944 try: 

3945 controls = list(req.POST.items()) 

3946 appstruct = form.validate(controls) 

3947 assert appstruct.get(ViewParam.USER_ID) == user.id 

3948 # --------------------------------------------------------- 

3949 # Delete the user and associated objects 

3950 # --------------------------------------------------------- 

3951 # (*) Sessions belonging to this user 

3952 # ... done by modifying its ForeignKey to use "ondelete" 

3953 # (*) user_group_table mapping 

3954 # https://docs.sqlalchemy.org/en/latest/orm/basic_relationships.html#relationships-many-to-many-deletion # noqa 

3955 # Simplest way: 

3956 user.groups = [] # will delete the mapping entries 

3957 # (*) User itself 

3958 req.dbsession.delete(user) 

3959 # Done 

3960 raise HTTPFound(req.route_url(Routes.VIEW_ALL_USERS)) 

3961 except ValidationFailure as e: 

3962 rendered_form = e.render() 

3963 else: 

3964 appstruct = {ViewParam.USER_ID: user.id} 

3965 rendered_form = form.render(appstruct) 

3966 

3967 return dict( 

3968 user=user, 

3969 error=error, 

3970 form=rendered_form, 

3971 head_form_html=get_head_form_html(req, [form]), 

3972 ) 

3973 

3974 

3975# ============================================================================= 

3976# Group management 

3977# ============================================================================= 

3978 

3979 

3980@view_config( 

3981 route_name=Routes.VIEW_GROUPS, 

3982 permission=Permission.SUPERUSER, 

3983 renderer="groups_view.mako", 

3984 http_cache=NEVER_CACHE, 

3985) 

3986def view_groups(req: "CamcopsRequest") -> Dict[str, Any]: 

3987 """ 

3988 View to show all groups (with hyperlinks to edit them). 

3989 Superusers only. 

3990 """ 

3991 rows_per_page = req.get_int_param( 

3992 ViewParam.ROWS_PER_PAGE, DEFAULT_ROWS_PER_PAGE 

3993 ) 

3994 page_num = req.get_int_param(ViewParam.PAGE, 1) 

3995 dbsession = req.dbsession 

3996 groups = ( 

3997 dbsession.query(Group).order_by(Group.name).all() 

3998 ) # type: List[Group] # noqa 

3999 page = CamcopsPage( 

4000 collection=groups, 

4001 page=page_num, 

4002 items_per_page=rows_per_page, 

4003 url_maker=PageUrl(req), 

4004 request=req, 

4005 ) 

4006 

4007 valid_which_idnums = req.valid_which_idnums 

4008 

4009 return dict(groups_page=page, valid_which_idnums=valid_which_idnums) 

4010 

4011 

4012def get_group_from_request_group_id_or_raise(req: "CamcopsRequest") -> Group: 

4013 """ 

4014 Returns the :class:`camcops_server.cc_modules.cc_group.Group` represented 

4015 by the request's ``ViewParam.GROUP_ID`` parameter, or raise 

4016 :exc:`HTTPBadRequest`. 

4017 """ 

4018 group_id = req.get_int_param(ViewParam.GROUP_ID) 

4019 group = None 

4020 if group_id is not None: 

4021 dbsession = req.dbsession 

4022 group = dbsession.query(Group).filter(Group.id == group_id).first() 

4023 if not group: 

4024 _ = req.gettext 

4025 raise HTTPBadRequest(f"{_('No such group ID:')} {group_id!r}") 

4026 return group 

4027 

4028 

4029class EditGroupView(UpdateView): 

4030 """ 

4031 Django-style view to edit a CamCOPS group. 

4032 """ 

4033 

4034 form_class = EditGroupForm 

4035 model_form_dict = { 

4036 "name": ViewParam.NAME, 

4037 "description": ViewParam.DESCRIPTION, 

4038 "upload_policy": ViewParam.UPLOAD_POLICY, 

4039 "finalize_policy": ViewParam.FINALIZE_POLICY, 

4040 } 

4041 object_class = Group 

4042 pk_param = ViewParam.GROUP_ID 

4043 server_pk_name = "id" 

4044 template_name = "group_edit.mako" 

4045 

4046 def get_form_kwargs(self) -> Dict[str, Any]: 

4047 kwargs = super().get_form_kwargs() 

4048 

4049 group = cast(Group, self.object) 

4050 kwargs.update(group=group) 

4051 

4052 return kwargs 

4053 

4054 def get_form_values(self) -> Dict: 

4055 # will populate with model_form_dict 

4056 form_values = super().get_form_values() 

4057 

4058 group = cast(Group, self.object) 

4059 

4060 other_group_ids = list(group.ids_of_other_groups_group_may_see()) 

4061 other_groups = Group.get_groups_from_id_list( 

4062 self.request.dbsession, other_group_ids 

4063 ) 

4064 other_groups.sort(key=lambda g: g.name) 

4065 

4066 form_values.update( 

4067 { 

4068 ViewParam.IP_USE: group.ip_use, 

4069 ViewParam.GROUP_ID: group.id, 

4070 ViewParam.GROUP_IDS: [g.id for g in other_groups], 

4071 } 

4072 ) 

4073 

4074 return form_values 

4075 

4076 def get_success_url(self) -> str: 

4077 return self.request.route_url(Routes.VIEW_GROUPS) 

4078 

4079 def save_object(self, appstruct: Dict[str, Any]) -> None: 

4080 super().save_object(appstruct) 

4081 

4082 group = cast(Group, self.object) 

4083 

4084 # Group cross-references 

4085 group_ids = appstruct.get(ViewParam.GROUP_IDS) 

4086 # The form validation will prevent our own group from being in here 

4087 other_groups = Group.get_groups_from_id_list( 

4088 self.request.dbsession, group_ids 

4089 ) 

4090 group.can_see_other_groups = other_groups 

4091 

4092 ip_use = appstruct.get(ViewParam.IP_USE) 

4093 if group.ip_use is not None: 

4094 ip_use.id = group.ip_use.id 

4095 

4096 group.ip_use = ip_use 

4097 

4098 

4099@view_config( 

4100 route_name=Routes.EDIT_GROUP, 

4101 permission=Permission.SUPERUSER, 

4102 http_cache=NEVER_CACHE, 

4103) 

4104def edit_group(req: "CamcopsRequest") -> Response: 

4105 """ 

4106 View to edit a group. Superusers only. 

4107 """ 

4108 return EditGroupView(req).dispatch() 

4109 

4110 

4111@view_config( 

4112 route_name=Routes.ADD_GROUP, 

4113 permission=Permission.SUPERUSER, 

4114 renderer="group_add.mako", 

4115 http_cache=NEVER_CACHE, 

4116) 

4117def add_group(req: "CamcopsRequest") -> Dict[str, Any]: 

4118 """ 

4119 View to add a group. Superusers only. 

4120 """ 

4121 route_back = Routes.VIEW_GROUPS 

4122 if FormAction.CANCEL in req.POST: 

4123 raise HTTPFound(req.route_url(route_back)) 

4124 form = AddGroupForm(request=req) 

4125 dbsession = req.dbsession 

4126 if FormAction.SUBMIT in req.POST: 

4127 try: 

4128 controls = list(req.POST.items()) 

4129 appstruct = form.validate(controls) 

4130 # ----------------------------------------------------------------- 

4131 # Add the group 

4132 # ----------------------------------------------------------------- 

4133 group = Group() 

4134 group.name = appstruct.get(ViewParam.NAME) 

4135 dbsession.add(group) 

4136 raise HTTPFound(req.route_url(route_back)) 

4137 except ValidationFailure as e: 

4138 rendered_form = e.render() 

4139 else: 

4140 rendered_form = form.render() 

4141 return dict( 

4142 form=rendered_form, head_form_html=get_head_form_html(req, [form]) 

4143 ) 

4144 

4145 

4146def any_records_use_group(req: "CamcopsRequest", group: Group) -> bool: 

4147 """ 

4148 Do any records in the database refer to the specified group? 

4149 

4150 (Used when we're thinking about deleting a group; would it leave broken 

4151 references? If so, we will prevent deletion; see :func:`delete_group`.) 

4152 """ 

4153 dbsession = req.dbsession 

4154 group_id = group.id 

4155 # Our own or users filtering on us? 

4156 # ... doesn't matter; see TaskFilter; stored as a CSV list so not part of 

4157 # database integrity checks. 

4158 # Uploaded records? 

4159 for cls in gen_orm_classes_from_base( 

4160 GenericTabletRecordMixin 

4161 ): # type: Type[GenericTabletRecordMixin] # noqa 

4162 # noinspection PyProtectedMember 

4163 q = CountStarSpecializedQuery(cls, session=dbsession).filter( 

4164 cls._group_id == group_id 

4165 ) 

4166 if q.count_star() > 0: 

4167 return True 

4168 # No; all clean. 

4169 return False 

4170 

4171 

4172@view_config( 

4173 route_name=Routes.DELETE_GROUP, 

4174 permission=Permission.SUPERUSER, 

4175 renderer="group_delete.mako", 

4176 http_cache=NEVER_CACHE, 

4177) 

4178def delete_group(req: "CamcopsRequest") -> Dict[str, Any]: 

4179 """ 

4180 View to delete a group. Superusers only. 

4181 """ 

4182 route_back = Routes.VIEW_GROUPS 

4183 if FormAction.CANCEL in req.POST: 

4184 raise HTTPFound(req.route_url(route_back)) 

4185 group = get_group_from_request_group_id_or_raise(req) 

4186 form = DeleteGroupForm(request=req) 

4187 rendered_form = "" 

4188 error = "" 

4189 _ = req.gettext 

4190 if group.users: 

4191 error = _("Unable to delete group; there are users who are members!") 

4192 else: 

4193 if any_records_use_group(req, group): 

4194 error = _("Unable to delete group; records refer to it.") 

4195 else: 

4196 if FormAction.DELETE in req.POST: 

4197 try: 

4198 controls = list(req.POST.items()) 

4199 appstruct = form.validate(controls) 

4200 assert appstruct.get(ViewParam.GROUP_ID) == group.id 

4201 # --------------------------------------------------------- 

4202 # Delete the group 

4203 # --------------------------------------------------------- 

4204 req.dbsession.delete(group) 

4205 raise HTTPFound(req.route_url(route_back)) 

4206 except ValidationFailure as e: 

4207 rendered_form = e.render() 

4208 else: 

4209 appstruct = {ViewParam.GROUP_ID: group.id} 

4210 rendered_form = form.render(appstruct) 

4211 return dict( 

4212 group=group, 

4213 error=error, 

4214 form=rendered_form, 

4215 head_form_html=get_head_form_html(req, [form]), 

4216 ) 

4217 

4218 

4219# ============================================================================= 

4220# Edit server settings 

4221# ============================================================================= 

4222 

4223 

4224@view_config( 

4225 route_name=Routes.EDIT_SERVER_SETTINGS, 

4226 permission=Permission.SUPERUSER, 

4227 renderer="server_settings_edit.mako", 

4228 http_cache=NEVER_CACHE, 

4229) 

4230def edit_server_settings(req: "CamcopsRequest") -> Dict[str, Any]: 

4231 """ 

4232 View to edit server settings (like the database title). 

4233 """ 

4234 if FormAction.CANCEL in req.POST: 

4235 raise HTTPFound(req.route_url(Routes.HOME)) 

4236 form = EditServerSettingsForm(request=req) 

4237 if FormAction.SUBMIT in req.POST: 

4238 try: 

4239 controls = list(req.POST.items()) 

4240 appstruct = form.validate(controls) 

4241 title = appstruct.get(ViewParam.DATABASE_TITLE) 

4242 # ----------------------------------------------------------------- 

4243 # Apply changes 

4244 # ----------------------------------------------------------------- 

4245 req.set_database_title(title) 

4246 raise HTTPFound(req.route_url(Routes.HOME)) 

4247 except ValidationFailure as e: 

4248 rendered_form = e.render() 

4249 else: 

4250 title = req.database_title 

4251 appstruct = {ViewParam.DATABASE_TITLE: title} 

4252 rendered_form = form.render(appstruct) 

4253 return dict( 

4254 form=rendered_form, head_form_html=get_head_form_html(req, [form]) 

4255 ) 

4256 

4257 

4258@view_config( 

4259 route_name=Routes.VIEW_ID_DEFINITIONS, 

4260 permission=Permission.SUPERUSER, 

4261 renderer="id_definitions_view.mako", 

4262 http_cache=NEVER_CACHE, 

4263) 

4264def view_id_definitions(req: "CamcopsRequest") -> Dict[str, Any]: 

4265 """ 

4266 View to show all ID number definitions (with hyperlinks to edit them). 

4267 Superusers only. 

4268 """ 

4269 return dict(idnum_definitions=req.idnum_definitions) 

4270 

4271 

4272def get_iddef_from_request_which_idnum_or_raise( 

4273 req: "CamcopsRequest", 

4274) -> IdNumDefinition: 

4275 """ 

4276 Returns the :class:`camcops_server.cc_modules.cc_idnumdef.IdNumDefinition` 

4277 represented by the request's ``ViewParam.WHICH_IDNUM`` parameter, or raise 

4278 :exc:`HTTPBadRequest`. 

4279 """ 

4280 which_idnum = req.get_int_param(ViewParam.WHICH_IDNUM) 

4281 iddef = ( 

4282 req.dbsession.query(IdNumDefinition) 

4283 .filter(IdNumDefinition.which_idnum == which_idnum) 

4284 .first() 

4285 ) 

4286 if not iddef: 

4287 _ = req.gettext 

4288 raise HTTPBadRequest(f"{_('No such ID definition:')} {which_idnum!r}") 

4289 return iddef 

4290 

4291 

4292@view_config( 

4293 route_name=Routes.EDIT_ID_DEFINITION, 

4294 permission=Permission.SUPERUSER, 

4295 renderer="id_definition_edit.mako", 

4296 http_cache=NEVER_CACHE, 

4297) 

4298def edit_id_definition(req: "CamcopsRequest") -> Dict[str, Any]: 

4299 """ 

4300 View to edit an ID number definition. Superusers only. 

4301 """ 

4302 route_back = Routes.VIEW_ID_DEFINITIONS 

4303 if FormAction.CANCEL in req.POST: 

4304 raise HTTPFound(req.route_url(route_back)) 

4305 iddef = get_iddef_from_request_which_idnum_or_raise(req) 

4306 form = EditIdDefinitionForm(request=req) 

4307 if FormAction.SUBMIT in req.POST: 

4308 try: 

4309 controls = list(req.POST.items()) 

4310 appstruct = form.validate(controls) 

4311 # ----------------------------------------------------------------- 

4312 # Alter the ID definition 

4313 # ----------------------------------------------------------------- 

4314 iddef.description = appstruct.get(ViewParam.DESCRIPTION) 

4315 iddef.short_description = appstruct.get( 

4316 ViewParam.SHORT_DESCRIPTION 

4317 ) 

4318 iddef.validation_method = appstruct.get( 

4319 ViewParam.VALIDATION_METHOD 

4320 ) 

4321 iddef.hl7_id_type = appstruct.get(ViewParam.HL7_ID_TYPE) 

4322 iddef.hl7_assigning_authority = appstruct.get( 

4323 ViewParam.HL7_ASSIGNING_AUTHORITY 

4324 ) 

4325 iddef.fhir_id_system = appstruct.get(ViewParam.FHIR_ID_SYSTEM) 

4326 # REMOVED # clear_idnum_definition_cache() # SPECIAL 

4327 raise HTTPFound(req.route_url(route_back)) 

4328 except ValidationFailure as e: 

4329 rendered_form = e.render() 

4330 else: 

4331 appstruct = { 

4332 ViewParam.WHICH_IDNUM: iddef.which_idnum, 

4333 ViewParam.DESCRIPTION: iddef.description or "", 

4334 ViewParam.SHORT_DESCRIPTION: iddef.short_description or "", 

4335 ViewParam.VALIDATION_METHOD: iddef.validation_method or "", 

4336 ViewParam.HL7_ID_TYPE: iddef.hl7_id_type or "", 

4337 ViewParam.HL7_ASSIGNING_AUTHORITY: iddef.hl7_assigning_authority 

4338 or "", # noqa 

4339 ViewParam.FHIR_ID_SYSTEM: iddef.fhir_id_system or "", 

4340 } 

4341 rendered_form = form.render(appstruct) 

4342 return dict( 

4343 iddef=iddef, 

4344 form=rendered_form, 

4345 head_form_html=get_head_form_html(req, [form]), 

4346 ) 

4347 

4348 

4349@view_config( 

4350 route_name=Routes.ADD_ID_DEFINITION, 

4351 permission=Permission.SUPERUSER, 

4352 renderer="id_definition_add.mako", 

4353 http_cache=NEVER_CACHE, 

4354) 

4355def add_id_definition(req: "CamcopsRequest") -> Dict[str, Any]: 

4356 """ 

4357 View to add an ID number definition. Superusers only. 

4358 """ 

4359 route_back = Routes.VIEW_ID_DEFINITIONS 

4360 if FormAction.CANCEL in req.POST: 

4361 raise HTTPFound(req.route_url(route_back)) 

4362 form = AddIdDefinitionForm(request=req) 

4363 dbsession = req.dbsession 

4364 if FormAction.SUBMIT in req.POST: 

4365 try: 

4366 controls = list(req.POST.items()) 

4367 appstruct = form.validate(controls) 

4368 iddef = IdNumDefinition( 

4369 which_idnum=appstruct.get(ViewParam.WHICH_IDNUM), 

4370 description=appstruct.get(ViewParam.DESCRIPTION), 

4371 short_description=appstruct.get(ViewParam.SHORT_DESCRIPTION), 

4372 # we skip hl7_id_type at this stage 

4373 # we skip hl7_assigning_authority at this stage 

4374 validation_method=appstruct.get(ViewParam.VALIDATION_METHOD), 

4375 ) 

4376 # ----------------------------------------------------------------- 

4377 # Add ID definition 

4378 # ----------------------------------------------------------------- 

4379 dbsession.add(iddef) 

4380 # REMOVED # clear_idnum_definition_cache() # SPECIAL 

4381 raise HTTPFound(req.route_url(route_back)) 

4382 except ValidationFailure as e: 

4383 rendered_form = e.render() 

4384 else: 

4385 rendered_form = form.render() 

4386 return dict( 

4387 form=rendered_form, head_form_html=get_head_form_html(req, [form]) 

4388 ) 

4389 

4390 

4391def any_records_use_iddef( 

4392 req: "CamcopsRequest", iddef: IdNumDefinition 

4393) -> bool: 

4394 """ 

4395 Do any records in the database refer to the specified ID number definition? 

4396 

4397 (Used when we're thinking about deleting one; would it leave broken 

4398 references? If so, we will prevent deletion; see 

4399 :func:`delete_id_definition`.) 

4400 """ 

4401 # Helpfully, these are only referred to permanently from one place: 

4402 q = CountStarSpecializedQuery(PatientIdNum, session=req.dbsession).filter( 

4403 PatientIdNum.which_idnum == iddef.which_idnum 

4404 ) 

4405 if q.count_star() > 0: 

4406 return True 

4407 # No; all clean. 

4408 return False 

4409 

4410 

4411@view_config( 

4412 route_name=Routes.DELETE_ID_DEFINITION, 

4413 permission=Permission.SUPERUSER, 

4414 renderer="id_definition_delete.mako", 

4415 http_cache=NEVER_CACHE, 

4416) 

4417def delete_id_definition(req: "CamcopsRequest") -> Dict[str, Any]: 

4418 """ 

4419 View to delete an ID number definition. Superusers only. 

4420 """ 

4421 route_back = Routes.VIEW_ID_DEFINITIONS 

4422 if FormAction.CANCEL in req.POST: 

4423 raise HTTPFound(req.route_url(route_back)) 

4424 iddef = get_iddef_from_request_which_idnum_or_raise(req) 

4425 form = DeleteIdDefinitionForm(request=req) 

4426 rendered_form = "" 

4427 error = "" 

4428 if any_records_use_iddef(req, iddef): 

4429 _ = req.gettext 

4430 error = _("Unable to delete ID definition; records refer to it.") 

4431 else: 

4432 if FormAction.DELETE in req.POST: 

4433 try: 

4434 controls = list(req.POST.items()) 

4435 appstruct = form.validate(controls) 

4436 assert ( 

4437 appstruct.get(ViewParam.WHICH_IDNUM) == iddef.which_idnum 

4438 ) 

4439 # ------------------------------------------------------------- 

4440 # Delete ID definition 

4441 # ------------------------------------------------------------- 

4442 req.dbsession.delete(iddef) 

4443 # REMOVED # clear_idnum_definition_cache() # SPECIAL 

4444 raise HTTPFound(req.route_url(route_back)) 

4445 except ValidationFailure as e: 

4446 rendered_form = e.render() 

4447 else: 

4448 appstruct = {ViewParam.WHICH_IDNUM: iddef.which_idnum} 

4449 rendered_form = form.render(appstruct) 

4450 return dict( 

4451 iddef=iddef, 

4452 error=error, 

4453 form=rendered_form, 

4454 head_form_html=get_head_form_html(req, [form]), 

4455 ) 

4456 

4457 

4458# ============================================================================= 

4459# Altering data. Some of the more complex logic is here. 

4460# ============================================================================= 

4461 

4462 

4463@view_config( 

4464 route_name=Routes.ADD_SPECIAL_NOTE, 

4465 renderer="special_note_add.mako", 

4466 http_cache=NEVER_CACHE, 

4467) 

4468def add_special_note(req: "CamcopsRequest") -> Dict[str, Any]: 

4469 """ 

4470 View to add a special note to a task (after confirmation). 

4471 

4472 (Note that users can't add special notes to patients -- those get added 

4473 automatically when a patient is edited. So the context here is always of a 

4474 task.) 

4475 """ 

4476 table_name = req.get_str_param( 

4477 ViewParam.TABLE_NAME, validator=validate_task_tablename 

4478 ) 

4479 server_pk = req.get_int_param(ViewParam.SERVER_PK, None) 

4480 url_back = req.route_url( 

4481 Routes.TASK, 

4482 _query={ 

4483 ViewParam.TABLE_NAME: table_name, 

4484 ViewParam.SERVER_PK: server_pk, 

4485 ViewParam.VIEWTYPE: ViewArg.HTML, 

4486 }, 

4487 ) 

4488 if FormAction.CANCEL in req.POST: 

4489 raise HTTPFound(url_back) 

4490 task = task_factory(req, table_name, server_pk) 

4491 _ = req.gettext 

4492 if task is None: 

4493 raise HTTPBadRequest( 

4494 f"{_('No such task:')} {table_name}, PK={server_pk}" 

4495 ) 

4496 user = req.user 

4497 if not user.authorized_to_add_special_note(task.group_id): 

4498 raise HTTPBadRequest( 

4499 _("Not authorized to add special notes for this task's group") 

4500 ) 

4501 form = AddSpecialNoteForm(request=req) 

4502 if FormAction.SUBMIT in req.POST: 

4503 try: 

4504 controls = list(req.POST.items()) 

4505 appstruct = form.validate(controls) 

4506 note = appstruct.get(ViewParam.NOTE) 

4507 # ----------------------------------------------------------------- 

4508 # Apply special note 

4509 # ----------------------------------------------------------------- 

4510 task.apply_special_note(req, note) 

4511 raise HTTPFound(url_back) 

4512 except ValidationFailure as e: 

4513 rendered_form = e.render() 

4514 else: 

4515 appstruct = { 

4516 ViewParam.TABLE_NAME: table_name, 

4517 ViewParam.SERVER_PK: server_pk, 

4518 } 

4519 rendered_form = form.render(appstruct) 

4520 return dict( 

4521 task=task, 

4522 form=rendered_form, 

4523 head_form_html=get_head_form_html(req, [form]), 

4524 viewtype=ViewArg.HTML, 

4525 ) 

4526 

4527 

4528@view_config( 

4529 route_name=Routes.DELETE_SPECIAL_NOTE, 

4530 renderer="special_note_delete.mako", 

4531 http_cache=NEVER_CACHE, 

4532) 

4533def delete_special_note(req: "CamcopsRequest") -> Dict[str, Any]: 

4534 """ 

4535 View to delete a special note (after confirmation). 

4536 """ 

4537 note_id = req.get_int_param(ViewParam.NOTE_ID, None) 

4538 sn = SpecialNote.get_specialnote_by_id(req.dbsession, note_id) 

4539 _ = req.gettext 

4540 if sn is None: 

4541 raise HTTPBadRequest(f"{_('No such SpecialNote:')} note_id={note_id}") 

4542 if sn.hidden: 

4543 raise HTTPBadRequest( 

4544 f"{_('SpecialNote already deleted/hidden:')} " f"note_id={note_id}" 

4545 ) 

4546 if not sn.user_may_delete_specialnote(req.user): 

4547 raise HTTPBadRequest(_("Not authorized to delete this special note")) 

4548 url_back = req.route_url(Routes.VIEW_TASKS) # default 

4549 if sn.refers_to_patient(): 

4550 # Special note on a patient. 

4551 # We might have come here from any number of tasks relating to this 

4552 # patient. In principle this information is retrievable; in practice it 

4553 # is a considerable faff for a rare operation, since special notes are 

4554 # displayed via special_notes.mako, which only looks at information 

4555 # stored with the note itself. 

4556 pass 

4557 else: 

4558 # Special note on a task. 

4559 task = sn.target_task() 

4560 if task: 

4561 url_back = req.route_url( 

4562 Routes.TASK, 

4563 _query={ 

4564 ViewParam.TABLE_NAME: task.tablename, 

4565 ViewParam.SERVER_PK: task.pk, 

4566 ViewParam.VIEWTYPE: ViewArg.HTML, 

4567 }, 

4568 ) 

4569 if FormAction.CANCEL in req.POST: 

4570 raise HTTPFound(url_back) 

4571 form = DeleteSpecialNoteForm(request=req) 

4572 if FormAction.SUBMIT in req.POST: 

4573 try: 

4574 controls = list(req.POST.items()) 

4575 form.validate(controls) 

4576 # ----------------------------------------------------------------- 

4577 # Delete special note 

4578 # ----------------------------------------------------------------- 

4579 sn.hidden = True 

4580 raise HTTPFound(url_back) 

4581 except ValidationFailure as e: 

4582 rendered_form = e.render() 

4583 else: 

4584 appstruct = {ViewParam.NOTE_ID: note_id} 

4585 rendered_form = form.render(appstruct) 

4586 return dict( 

4587 sn=sn, 

4588 form=rendered_form, 

4589 head_form_html=get_head_form_html(req, [form]), 

4590 ) 

4591 

4592 

4593class EraseTaskBaseView(DeleteView): 

4594 """ 

4595 Django-style view to erase a task. 

4596 """ 

4597 

4598 form_class = EraseTaskForm 

4599 

4600 def get_object(self) -> Any: 

4601 # noinspection PyAttributeOutsideInit 

4602 self.table_name = self.request.get_str_param( 

4603 ViewParam.TABLE_NAME, validator=validate_task_tablename 

4604 ) 

4605 # noinspection PyAttributeOutsideInit 

4606 self.server_pk = self.request.get_int_param(ViewParam.SERVER_PK, None) 

4607 

4608 task = task_factory(self.request, self.table_name, self.server_pk) 

4609 _ = self.request.gettext 

4610 if task is None: 

4611 raise HTTPBadRequest( 

4612 f"{_('No such task:')} {self.table_name}, PK={self.server_pk}" 

4613 ) 

4614 if task.is_live_on_tablet(): 

4615 raise HTTPBadRequest(errormsg_task_live(self.request)) 

4616 self.check_user_is_authorized(task) 

4617 

4618 return task 

4619 

4620 def check_user_is_authorized(self, task: Task) -> None: 

4621 if not self.request.user.authorized_to_erase_tasks(task.group_id): 

4622 _ = self.request.gettext 

4623 raise HTTPBadRequest( 

4624 _("Not authorized to erase tasks for this task's group") 

4625 ) 

4626 

4627 def get_cancel_url(self) -> str: 

4628 return self.request.route_url( 

4629 Routes.TASK, 

4630 _query={ 

4631 ViewParam.TABLE_NAME: self.table_name, 

4632 ViewParam.SERVER_PK: self.server_pk, 

4633 ViewParam.VIEWTYPE: ViewArg.HTML, 

4634 }, 

4635 ) 

4636 

4637 

4638class EraseTaskLeavingPlaceholderView(EraseTaskBaseView): 

4639 """ 

4640 Django-style view to erase data from a task, leaving an empty 

4641 "placeholder". 

4642 """ 

4643 

4644 template_name = "task_erase.mako" 

4645 

4646 def get_object(self) -> Any: 

4647 task = cast(Task, super().get_object()) 

4648 if task.is_erased(): 

4649 _ = self.request.gettext 

4650 raise HTTPBadRequest(_("Task already erased")) 

4651 

4652 return task 

4653 

4654 def delete(self) -> None: 

4655 task = cast(Task, self.object) 

4656 

4657 task.manually_erase(self.request) 

4658 

4659 def get_success_url(self) -> str: 

4660 return self.request.route_url( 

4661 Routes.TASK, 

4662 _query={ 

4663 ViewParam.TABLE_NAME: self.table_name, 

4664 ViewParam.SERVER_PK: self.server_pk, 

4665 ViewParam.VIEWTYPE: ViewArg.HTML, 

4666 }, 

4667 ) 

4668 

4669 

4670class EraseTaskEntirelyView(EraseTaskBaseView): 

4671 """ 

4672 Django-style view to erase (delete) a task entirely. 

4673 """ 

4674 

4675 template_name = "task_erase_entirely.mako" 

4676 

4677 def delete(self) -> None: 

4678 task = cast(Task, self.object) 

4679 

4680 TaskIndexEntry.unindex_task(task, self.request.dbsession) 

4681 task.delete_entirely(self.request) 

4682 

4683 _ = self.request.gettext 

4684 

4685 msg_erased = _("Task erased:") 

4686 

4687 self.request.session.flash( 

4688 f"{msg_erased} ({self.table_name}, server PK {self.server_pk}).", 

4689 queue=FlashQueue.SUCCESS, 

4690 ) 

4691 

4692 def get_success_url(self) -> str: 

4693 return self.request.route_url(Routes.VIEW_TASKS) 

4694 

4695 

4696@view_config( 

4697 route_name=Routes.ERASE_TASK_LEAVING_PLACEHOLDER, 

4698 permission=Permission.GROUPADMIN, 

4699 http_cache=NEVER_CACHE, 

4700) 

4701def erase_task_leaving_placeholder(req: "CamcopsRequest") -> Response: 

4702 """ 

4703 View to wipe all data from a task (after confirmation). 

4704 

4705 Leaves the task record as a placeholder. 

4706 """ 

4707 return EraseTaskLeavingPlaceholderView(req).dispatch() 

4708 

4709 

4710@view_config( 

4711 route_name=Routes.ERASE_TASK_ENTIRELY, 

4712 permission=Permission.GROUPADMIN, 

4713 http_cache=NEVER_CACHE, 

4714) 

4715def erase_task_entirely(req: "CamcopsRequest") -> Response: 

4716 """ 

4717 View to erase a task from the database entirely (after confirmation). 

4718 """ 

4719 return EraseTaskEntirelyView(req).dispatch() 

4720 

4721 

4722@view_config( 

4723 route_name=Routes.DELETE_PATIENT, 

4724 permission=Permission.GROUPADMIN, 

4725 http_cache=NEVER_CACHE, 

4726) 

4727def delete_patient(req: "CamcopsRequest") -> Response: 

4728 """ 

4729 View to delete completely all data for a patient (after confirmation), 

4730 within a specific group. 

4731 """ 

4732 if FormAction.CANCEL in req.POST: 

4733 raise HTTPFound(req.route_url(Routes.HOME)) 

4734 

4735 first_form = DeletePatientChooseForm(request=req) 

4736 second_form = DeletePatientConfirmForm(request=req) 

4737 form = None 

4738 final_phase = False 

4739 if FormAction.SUBMIT in req.POST: 

4740 # FIRST form has been submitted 

4741 form = first_form 

4742 elif FormAction.DELETE in req.POST: 

4743 # SECOND AND FINAL form has been submitted 

4744 form = second_form 

4745 final_phase = True 

4746 _ = req.gettext 

4747 if form is not None: 

4748 try: 

4749 controls = list(req.POST.items()) 

4750 appstruct = form.validate(controls) 

4751 which_idnum = appstruct.get(ViewParam.WHICH_IDNUM) 

4752 idnum_value = appstruct.get(ViewParam.IDNUM_VALUE) 

4753 group_id = appstruct.get(ViewParam.GROUP_ID) 

4754 if group_id not in req.user.ids_of_groups_user_is_admin_for: 

4755 # rare occurrence; form should prevent it; 

4756 # unless superuser has changed status since form was read 

4757 raise HTTPBadRequest(_("You're not an admin for this group")) 

4758 # ----------------------------------------------------------------- 

4759 # Fetch tasks to be deleted. 

4760 # ----------------------------------------------------------------- 

4761 dbsession = req.dbsession 

4762 # Tasks first: 

4763 idnum_ref = IdNumReference( 

4764 which_idnum=which_idnum, idnum_value=idnum_value 

4765 ) 

4766 taskfilter = TaskFilter() 

4767 taskfilter.idnum_criteria = [idnum_ref] 

4768 taskfilter.group_ids = [group_id] 

4769 collection = TaskCollection( 

4770 req=req, 

4771 taskfilter=taskfilter, 

4772 sort_method_global=TaskSortMethod.CREATION_DATE_DESC, 

4773 current_only=False, # unusual option! 

4774 ) 

4775 tasks = collection.all_tasks 

4776 n_tasks = len(tasks) 

4777 patient_lineage_instances = Patient.get_patients_by_idnum( 

4778 dbsession=dbsession, 

4779 which_idnum=which_idnum, 

4780 idnum_value=idnum_value, 

4781 group_id=group_id, 

4782 current_only=False, 

4783 ) 

4784 n_patient_instances = len(patient_lineage_instances) 

4785 

4786 # ----------------------------------------------------------------- 

4787 # Bin out at this stage and offer confirmation page? 

4788 # ----------------------------------------------------------------- 

4789 if not final_phase: 

4790 # New appstruct; we don't want the validation code persisting 

4791 appstruct = { 

4792 ViewParam.WHICH_IDNUM: which_idnum, 

4793 ViewParam.IDNUM_VALUE: idnum_value, 

4794 ViewParam.GROUP_ID: group_id, 

4795 } 

4796 rendered_form = second_form.render(appstruct) 

4797 return render_to_response( 

4798 "patient_delete_confirm.mako", 

4799 dict( 

4800 form=rendered_form, 

4801 tasks=tasks, 

4802 n_patient_instances=n_patient_instances, 

4803 head_form_html=get_head_form_html(req, [form]), 

4804 ), 

4805 request=req, 

4806 ) 

4807 

4808 # ----------------------------------------------------------------- 

4809 # Delete patient and associated tasks 

4810 # ----------------------------------------------------------------- 

4811 for task in tasks: 

4812 TaskIndexEntry.unindex_task(task, req.dbsession) 

4813 task.delete_entirely(req) 

4814 # Then patients: 

4815 for p in patient_lineage_instances: 

4816 PatientIdNumIndexEntry.unindex_patient(p, req.dbsession) 

4817 p.delete_with_dependants(req) 

4818 msg = ( 

4819 f"{_('Patient and associated tasks DELETED from group')} " 

4820 f"{group_id}: idnum{which_idnum} = {idnum_value}. " 

4821 f"{_('Task records deleted:')} {n_tasks}." 

4822 f"{_('Patient records (current and/or old) deleted')} " 

4823 f"{n_patient_instances}." 

4824 ) 

4825 audit(req, msg) 

4826 

4827 req.session.flash(msg, FlashQueue.SUCCESS) 

4828 raise HTTPFound(req.route_url(Routes.HOME)) 

4829 

4830 except ValidationFailure as e: 

4831 rendered_form = e.render() 

4832 else: 

4833 form = first_form 

4834 rendered_form = first_form.render() 

4835 return render_to_response( 

4836 "patient_delete_choose.mako", 

4837 dict( 

4838 form=rendered_form, head_form_html=get_head_form_html(req, [form]) 

4839 ), 

4840 request=req, 

4841 ) 

4842 

4843 

4844@view_config( 

4845 route_name=Routes.FORCIBLY_FINALIZE, 

4846 permission=Permission.GROUPADMIN, 

4847 http_cache=NEVER_CACHE, 

4848) 

4849def forcibly_finalize(req: "CamcopsRequest") -> Response: 

4850 """ 

4851 View to force-finalize all live (``_era == ERA_NOW``) records from a 

4852 device. Available to group administrators if all those records are within 

4853 their groups (otherwise, it's a superuser operation). 

4854 """ 

4855 if FormAction.CANCEL in req.POST: 

4856 return HTTPFound(req.route_url(Routes.HOME)) 

4857 

4858 dbsession = req.dbsession 

4859 first_form = ForciblyFinalizeChooseDeviceForm(request=req) 

4860 second_form = ForciblyFinalizeConfirmForm(request=req) 

4861 form = None 

4862 final_phase = False 

4863 if FormAction.SUBMIT in req.POST: 

4864 # FIRST form has been submitted 

4865 form = first_form 

4866 elif FormAction.FINALIZE in req.POST: 

4867 # SECOND form has been submitted: 

4868 form = second_form 

4869 final_phase = True 

4870 _ = req.gettext 

4871 if form is not None: 

4872 try: 

4873 controls = list(req.POST.items()) 

4874 appstruct = form.validate(controls) 

4875 # log.debug("{}", pformat(appstruct)) 

4876 device_id = appstruct.get(ViewParam.DEVICE_ID) 

4877 device = Device.get_device_by_id(dbsession, device_id) 

4878 if device is None: 

4879 raise HTTPBadRequest(f"{_('No such device:')} {device_id!r}") 

4880 # ----------------------------------------------------------------- 

4881 # If at the first stage, bin out and offer confirmation page 

4882 # ----------------------------------------------------------------- 

4883 if not final_phase: 

4884 appstruct = {ViewParam.DEVICE_ID: device_id} 

4885 rendered_form = second_form.render(appstruct) 

4886 taskfilter = TaskFilter() 

4887 taskfilter.device_ids = [device_id] 

4888 taskfilter.era = ERA_NOW 

4889 collection = TaskCollection( 

4890 req=req, 

4891 taskfilter=taskfilter, 

4892 sort_method_global=TaskSortMethod.CREATION_DATE_DESC, 

4893 current_only=False, # unusual option! 

4894 via_index=False, # required for current_only=False 

4895 ) 

4896 tasks = collection.all_tasks 

4897 return render_to_response( 

4898 "device_forcibly_finalize_confirm.mako", 

4899 dict( 

4900 form=rendered_form, 

4901 tasks=tasks, 

4902 head_form_html=get_head_form_html(req, [form]), 

4903 ), 

4904 request=req, 

4905 ) 

4906 # ----------------------------------------------------------------- 

4907 # Check it's permitted 

4908 # ----------------------------------------------------------------- 

4909 if not req.user.superuser: 

4910 admin_group_ids = req.user.ids_of_groups_user_is_admin_for 

4911 for clienttable in CLIENT_TABLE_MAP.values(): 

4912 # noinspection PyPropertyAccess 

4913 count_query = ( 

4914 select([func.count()]) 

4915 .select_from(clienttable) 

4916 .where(clienttable.c[FN_DEVICE_ID] == device_id) 

4917 .where(clienttable.c[FN_ERA] == ERA_NOW) 

4918 .where( 

4919 clienttable.c[FN_GROUP_ID].notin_(admin_group_ids) 

4920 ) 

4921 ) 

4922 n = dbsession.execute(count_query).scalar() 

4923 if n > 0: 

4924 raise HTTPBadRequest( 

4925 _( 

4926 "Some records for this device are in groups " 

4927 "for which you are not an administrator" 

4928 ) 

4929 ) 

4930 # ----------------------------------------------------------------- 

4931 # Forcibly finalize 

4932 # ----------------------------------------------------------------- 

4933 msgs = [] # type: List[str] 

4934 batchdetails = BatchDetails(batchtime=req.now_utc) 

4935 alltables = sorted( 

4936 CLIENT_TABLE_MAP.values(), key=upload_commit_order_sorter 

4937 ) 

4938 for clienttable in alltables: 

4939 liverecs = get_server_live_records( 

4940 req, device_id, clienttable, current_only=False 

4941 ) 

4942 preservation_pks = [r.server_pk for r in liverecs] 

4943 if not preservation_pks: 

4944 continue 

4945 current_pks = [r.server_pk for r in liverecs if r.current] 

4946 tablechanges = UploadTableChanges(clienttable) 

4947 tablechanges.note_preservation_pks(preservation_pks) 

4948 tablechanges.note_current_pks(current_pks) 

4949 dbsession.execute( 

4950 update(clienttable) 

4951 .where(clienttable.c[FN_PK].in_(preservation_pks)) 

4952 .values( 

4953 values_preserve_now( 

4954 req, batchdetails, forcibly_preserved=True 

4955 ) 

4956 ) 

4957 ) 

4958 update_indexes_and_push_exports( 

4959 req, batchdetails, tablechanges 

4960 ) 

4961 msgs.append(f"{clienttable.name} {preservation_pks}") 

4962 # Field names are different in server-side tables, so they need 

4963 # special handling: 

4964 SpecialNote.forcibly_preserve_special_notes_for_device( 

4965 req, device_id 

4966 ) 

4967 # ----------------------------------------------------------------- 

4968 # Done 

4969 # ----------------------------------------------------------------- 

4970 msg = ( 

4971 f"{_('Live records for device')} {device_id} " 

4972 f"({device.friendly_name}) {_('forcibly finalized')} " 

4973 f"(PKs: {'; '.join(msgs)})" 

4974 ) 

4975 audit(req, msg) 

4976 log.info(msg) 

4977 

4978 req.session.flash(msg, queue=FlashQueue.SUCCESS) 

4979 raise HTTPFound(req.route_url(Routes.HOME)) 

4980 

4981 except ValidationFailure as e: 

4982 rendered_form = e.render() 

4983 else: 

4984 form = first_form 

4985 rendered_form = form.render() # no appstruct 

4986 return render_to_response( 

4987 "device_forcibly_finalize_choose.mako", 

4988 dict( 

4989 form=rendered_form, head_form_html=get_head_form_html(req, [form]) 

4990 ), 

4991 request=req, 

4992 ) 

4993 

4994 

4995# ============================================================================= 

4996# Patient creation/editing (primarily for task scheduling) 

4997# ============================================================================= 

4998 

4999 

5000class PatientMixin(object): 

5001 """ 

5002 Mixin for views involving a patient. 

5003 """ 

5004 

5005 object: Any 

5006 object_class = Patient 

5007 server_pk_name = "_pk" 

5008 

5009 model_form_dict = { 

5010 "forename": ViewParam.FORENAME, 

5011 "surname": ViewParam.SURNAME, 

5012 "dob": ViewParam.DOB, 

5013 "sex": ViewParam.SEX, 

5014 "email": ViewParam.EMAIL, 

5015 "address": ViewParam.ADDRESS, 

5016 "gp": ViewParam.GP, 

5017 "other": ViewParam.OTHER, 

5018 } 

5019 

5020 def get_form_values(self) -> Dict: 

5021 # will populate with model_form_dict 

5022 # noinspection PyUnresolvedReferences 

5023 form_values = super().get_form_values() 

5024 

5025 patient = cast(Patient, self.object) 

5026 

5027 if patient is not None: 

5028 form_values[ViewParam.SERVER_PK] = patient.pk 

5029 form_values[ViewParam.GROUP_ID] = patient.group.id 

5030 form_values[ViewParam.ID_REFERENCES] = [ 

5031 { 

5032 ViewParam.WHICH_IDNUM: pidnum.which_idnum, 

5033 ViewParam.IDNUM_VALUE: pidnum.idnum_value, 

5034 } 

5035 for pidnum in patient.idnums 

5036 ] 

5037 ts_list = [] # type: List[Dict] 

5038 for pts in patient.task_schedules: 

5039 ts_dict = { 

5040 ViewParam.PATIENT_TASK_SCHEDULE_ID: pts.id, 

5041 ViewParam.SCHEDULE_ID: pts.schedule_id, 

5042 ViewParam.START_DATETIME: pts.start_datetime, 

5043 } 

5044 if DEFORM_ACCORDION_BUG: 

5045 ts_dict[ViewParam.SETTINGS] = pts.settings 

5046 else: 

5047 ts_dict[ViewParam.ADVANCED] = { 

5048 ViewParam.SETTINGS: pts.settings 

5049 } 

5050 ts_list.append(ts_dict) 

5051 form_values[ViewParam.TASK_SCHEDULES] = ts_list 

5052 

5053 return form_values 

5054 

5055 

5056class EditPatientBaseView(PatientMixin, UpdateView): 

5057 """ 

5058 View to edit details for a patient. 

5059 """ 

5060 

5061 pk_param = ViewParam.SERVER_PK 

5062 

5063 def get_object(self) -> Any: 

5064 patient = cast(Patient, super().get_object()) 

5065 

5066 _ = self.request.gettext 

5067 

5068 if not patient.group: 

5069 raise HTTPBadRequest(_("Bad patient: not in a group")) 

5070 

5071 if not patient.user_may_edit(self.request): 

5072 raise HTTPBadRequest(_("Not authorized to edit this patient")) 

5073 

5074 return patient 

5075 

5076 def save_object(self, appstruct: Dict[str, Any]) -> None: 

5077 # ----------------------------------------------------------------- 

5078 # Apply edits 

5079 # ----------------------------------------------------------------- 

5080 # Calculate the changes, and apply them to the Patient object 

5081 _ = self.request.gettext 

5082 

5083 patient = cast(Patient, self.object) 

5084 

5085 changes = OrderedDict() # type: OrderedDict 

5086 

5087 self.save_changes(appstruct, changes) 

5088 

5089 if not changes: 

5090 self.request.session.flash( 

5091 f"{_('No changes required for patient record with server PK')} " # noqa 

5092 f"{patient.pk} {_('(all new values matched old values)')}", 

5093 queue=FlashQueue.INFO, 

5094 ) 

5095 return 

5096 

5097 formatted_changes = [] 

5098 

5099 for k, details in changes.items(): 

5100 if len(details) == 1: 

5101 change = f"{k}: {details[0]}" # usually a plain message 

5102 else: 

5103 change = f"{k}: {details[0]!r} → {details[1]!r}" 

5104 

5105 formatted_changes.append(change) 

5106 

5107 # Below here, changes have definitely been made. 

5108 change_msg = ( 

5109 _("Patient details edited. Changes:") 

5110 + " " 

5111 + "; ".join(formatted_changes) 

5112 ) 

5113 

5114 # Apply special note to patient 

5115 patient.apply_special_note(self.request, change_msg, "Patient edited") 

5116 

5117 # Patient details changed, so resend any tasks via HL7 

5118 for task in self.get_affected_tasks(): 

5119 task.cancel_from_export_log(self.request) 

5120 

5121 # Done 

5122 self.request.session.flash( 

5123 f"{_('Amended patient record with server PK')} " 

5124 f"{patient.pk}. " 

5125 f"{_('Changes were:')} {change_msg}", 

5126 queue=FlashQueue.SUCCESS, 

5127 ) 

5128 

5129 def save_changes( 

5130 self, appstruct: Dict[str, Any], changes: OrderedDict 

5131 ) -> None: 

5132 self._save_simple_params(appstruct, changes) 

5133 self._save_idrefs(appstruct, changes) 

5134 

5135 def _save_simple_params( 

5136 self, appstruct: Dict[str, Any], changes: OrderedDict 

5137 ) -> None: 

5138 patient = cast(Patient, self.object) 

5139 for k in EDIT_PATIENT_SIMPLE_PARAMS: 

5140 new_value = appstruct.get(k) 

5141 old_value = getattr(patient, k) 

5142 if new_value == old_value: 

5143 continue 

5144 if new_value in (None, "") and old_value in (None, ""): 

5145 # Nothing really changing! 

5146 continue 

5147 changes[k] = (old_value, new_value) 

5148 setattr(patient, k, new_value) 

5149 

5150 def _save_idrefs( 

5151 self, appstruct: Dict[str, Any], changes: OrderedDict 

5152 ) -> None: 

5153 

5154 # The ID numbers are more complex. 

5155 # log.debug("{}", pformat(appstruct)) 

5156 patient = cast(Patient, self.object) 

5157 new_idrefs = [ 

5158 IdNumReference( 

5159 which_idnum=idrefdict[ViewParam.WHICH_IDNUM], 

5160 idnum_value=idrefdict[ViewParam.IDNUM_VALUE], 

5161 ) 

5162 for idrefdict in appstruct.get(ViewParam.ID_REFERENCES, {}) 

5163 ] 

5164 for idnum in patient.idnums: 

5165 matching_idref = next( 

5166 ( 

5167 idref 

5168 for idref in new_idrefs 

5169 if idref.which_idnum == idnum.which_idnum 

5170 ), 

5171 None, 

5172 ) 

5173 if not matching_idref: 

5174 # Delete ID numbers not present in the new set 

5175 changes[ 

5176 "idnum{} ({})".format( 

5177 idnum.which_idnum, 

5178 self.request.get_id_desc(idnum.which_idnum), 

5179 ) 

5180 ] = (idnum.idnum_value, None) 

5181 idnum.mark_as_deleted(self.request) 

5182 elif matching_idref.idnum_value != idnum.idnum_value: 

5183 # Modify altered ID numbers present in the old + new sets 

5184 changes[ 

5185 "idnum{} ({})".format( 

5186 idnum.which_idnum, 

5187 self.request.get_id_desc(idnum.which_idnum), 

5188 ) 

5189 ] = (idnum.idnum_value, matching_idref.idnum_value) 

5190 new_idnum = PatientIdNum() 

5191 new_idnum.id = idnum.id 

5192 new_idnum.patient_id = idnum.patient_id 

5193 new_idnum.which_idnum = idnum.which_idnum 

5194 new_idnum.idnum_value = matching_idref.idnum_value 

5195 new_idnum.set_predecessor(self.request, idnum) 

5196 

5197 for idref in new_idrefs: 

5198 matching_idnum = next( 

5199 ( 

5200 idnum 

5201 for idnum in patient.idnums 

5202 if idnum.which_idnum == idref.which_idnum 

5203 ), 

5204 None, 

5205 ) 

5206 if not matching_idnum: 

5207 # Create ID numbers where they were absent 

5208 changes[ 

5209 "idnum{} ({})".format( 

5210 idref.which_idnum, 

5211 self.request.get_id_desc(idref.which_idnum), 

5212 ) 

5213 ] = (None, idref.idnum_value) 

5214 # We need to establish an "id" field, which is the PK as 

5215 # seen by the tablet. The tablet has lost interest in these 

5216 # records, since _era != ERA_NOW, so all we have to do is 

5217 # pick a number that's not in use. 

5218 new_idnum = PatientIdNum() 

5219 new_idnum.patient_id = patient.id 

5220 new_idnum.which_idnum = idref.which_idnum 

5221 new_idnum.idnum_value = idref.idnum_value 

5222 new_idnum.create_fresh( 

5223 self.request, 

5224 device_id=patient.device_id, 

5225 era=patient.era, 

5226 group_id=patient.group_id, 

5227 ) 

5228 new_idnum.save_with_next_available_id( 

5229 self.request, patient.device_id, era=patient.era 

5230 ) 

5231 

5232 def get_context_data(self, **kwargs: Any) -> Any: 

5233 # This parameter is (I think) used by Mako templates such as 

5234 # finalized_patient_edit.mako 

5235 # Todo: 

5236 # Potential inefficiency: we fetch tasks regardless of the stage 

5237 # of this form. 

5238 kwargs["tasks"] = self.get_affected_tasks() 

5239 

5240 return super().get_context_data(**kwargs) 

5241 

5242 def get_affected_tasks(self) -> Optional[List[Task]]: 

5243 patient = cast(Patient, self.object) 

5244 

5245 taskfilter = TaskFilter() 

5246 taskfilter.device_ids = [patient.device_id] 

5247 taskfilter.group_ids = [patient.group.id] 

5248 taskfilter.era = patient.era 

5249 collection = TaskCollection( 

5250 req=self.request, 

5251 taskfilter=taskfilter, 

5252 sort_method_global=TaskSortMethod.CREATION_DATE_DESC, 

5253 current_only=False, # unusual option! 

5254 via_index=False, # for current_only=False, or we'll get a warning 

5255 ) 

5256 return collection.all_tasks 

5257 

5258 

5259class EditServerCreatedPatientView(EditPatientBaseView): 

5260 """ 

5261 View to edit a patient created on the server (as part of task scheduling). 

5262 """ 

5263 

5264 template_name = "server_created_patient_edit.mako" 

5265 form_class = EditServerCreatedPatientForm 

5266 

5267 def get_success_url(self) -> str: 

5268 return self.request.route_url(Routes.VIEW_PATIENT_TASK_SCHEDULES) 

5269 

5270 def get_object(self) -> Any: 

5271 patient = cast(Patient, super().get_object()) 

5272 

5273 if not patient.created_on_server(self.request): 

5274 _ = self.request.gettext 

5275 

5276 raise HTTPBadRequest( 

5277 _("Patient is not editable - was not created on the server") 

5278 ) 

5279 

5280 return patient 

5281 

5282 def save_changes( 

5283 self, appstruct: Dict[str, Any], changes: OrderedDict 

5284 ) -> None: 

5285 self._save_group(appstruct, changes) 

5286 super().save_changes(appstruct, changes) 

5287 self._save_task_schedules(appstruct, changes) 

5288 

5289 def _save_group( 

5290 self, appstruct: Dict[str, Any], changes: OrderedDict 

5291 ) -> None: 

5292 patient = cast(Patient, self.object) 

5293 

5294 old_group_id = patient.group.id 

5295 old_group_name = patient.group.name 

5296 new_group_id = appstruct.get(ViewParam.GROUP_ID, None) 

5297 new_group = ( 

5298 self.request.dbsession.query(Group) 

5299 .filter(Group.id == new_group_id) 

5300 .first() 

5301 ) 

5302 

5303 if old_group_id != new_group_id: 

5304 patient._group_id = new_group_id 

5305 changes["group"] = (old_group_name, new_group.name) 

5306 

5307 def _save_task_schedules( 

5308 self, appstruct: Dict[str, Any], changes: OrderedDict 

5309 ) -> None: 

5310 

5311 _ = self.request.gettext 

5312 patient = cast(Patient, self.object) 

5313 ids_to_delete = [pts.id for pts in patient.task_schedules] 

5314 

5315 anything_changed = False 

5316 

5317 for schedule_dict in appstruct.get(ViewParam.TASK_SCHEDULES, {}): 

5318 pts_id = schedule_dict[ViewParam.PATIENT_TASK_SCHEDULE_ID] 

5319 schedule_id = schedule_dict[ViewParam.SCHEDULE_ID] 

5320 start_datetime = schedule_dict[ViewParam.START_DATETIME] 

5321 if DEFORM_ACCORDION_BUG: 

5322 settings = schedule_dict[ViewParam.SETTINGS] 

5323 else: 

5324 settings = schedule_dict[ViewParam.ADVANCED][ 

5325 ViewParam.SETTINGS 

5326 ] # noqa 

5327 

5328 if pts_id is None: 

5329 pts = PatientTaskSchedule() 

5330 pts.patient_pk = patient.pk 

5331 pts.schedule_id = schedule_id 

5332 pts.start_datetime = start_datetime 

5333 pts.settings = settings 

5334 

5335 self.request.dbsession.add(pts) 

5336 anything_changed = True 

5337 else: 

5338 old_pts = ( 

5339 self.request.dbsession.query(PatientTaskSchedule) 

5340 .filter(PatientTaskSchedule.id == pts_id) 

5341 .first() 

5342 ) 

5343 

5344 updates = {} 

5345 if old_pts.start_datetime != start_datetime: 

5346 updates[ 

5347 PatientTaskSchedule.start_datetime 

5348 ] = start_datetime 

5349 

5350 if old_pts.schedule_id != schedule_id: 

5351 updates[PatientTaskSchedule.schedule_id] = schedule_id 

5352 

5353 if old_pts.settings != settings: 

5354 updates[PatientTaskSchedule.settings] = settings 

5355 

5356 if updates: 

5357 anything_changed = True 

5358 self.request.dbsession.query(PatientTaskSchedule).filter( 

5359 PatientTaskSchedule.id == pts_id 

5360 ).update(updates, synchronize_session="fetch") 

5361 

5362 ids_to_delete.remove(pts_id) 

5363 

5364 pts_to_delete = self.request.dbsession.query( 

5365 PatientTaskSchedule 

5366 ).filter(PatientTaskSchedule.id.in_(ids_to_delete)) 

5367 

5368 # Previously we had: 

5369 # pts_to_delete.delete(synchronize_session="fetch") 

5370 # 

5371 # This won't cascade the deletion because we are calling delete() on 

5372 # the query object. We could set up cascade at the database level 

5373 # instead but there is little performance gain here. 

5374 # https://stackoverflow.com/questions/19243964/sqlalchemy-delete-doesnt-cascade 

5375 

5376 for pts in pts_to_delete: 

5377 self.request.dbsession.delete(pts) 

5378 anything_changed = True 

5379 

5380 if anything_changed: 

5381 changes[_("Task schedules")] = (_("Updated"),) 

5382 

5383 

5384class EditFinalizedPatientView(EditPatientBaseView): 

5385 """ 

5386 View to edit a finalized patient. 

5387 """ 

5388 

5389 template_name = "finalized_patient_edit.mako" 

5390 form_class = EditFinalizedPatientForm 

5391 

5392 def __init__( 

5393 self, 

5394 req: CamcopsRequest, 

5395 task_tablename: str = None, 

5396 task_server_pk: int = None, 

5397 ) -> None: 

5398 """ 

5399 The two additional parameters are for returning the user to the task 

5400 from which editing was initiated. 

5401 """ 

5402 super().__init__(req) 

5403 self.task_tablename = task_tablename 

5404 self.task_server_pk = task_server_pk 

5405 

5406 def get_success_url(self) -> str: 

5407 """ 

5408 We got here by editing a patient from an uploaded task, so that's our 

5409 return point. 

5410 """ 

5411 if self.task_tablename and self.task_server_pk: 

5412 return self.request.route_url( 

5413 Routes.TASK, 

5414 _query={ 

5415 ViewParam.TABLE_NAME: self.task_tablename, 

5416 ViewParam.SERVER_PK: self.task_server_pk, 

5417 ViewParam.VIEWTYPE: ViewArg.HTML, 

5418 }, 

5419 ) 

5420 else: 

5421 # Likely in a testing environment! 

5422 return self.request.route_url(Routes.HOME) 

5423 

5424 def get_object(self) -> Any: 

5425 patient = cast(Patient, super().get_object()) 

5426 

5427 if not patient.is_finalized(): 

5428 _ = self.request.gettext 

5429 

5430 raise HTTPBadRequest( 

5431 _( 

5432 "Patient is not editable (likely: not finalized, so a " 

5433 "copy still on a client device)" 

5434 ) 

5435 ) 

5436 

5437 return patient 

5438 

5439 

5440@view_config( 

5441 route_name=Routes.EDIT_FINALIZED_PATIENT, 

5442 permission=Permission.GROUPADMIN, 

5443 http_cache=NEVER_CACHE, 

5444) 

5445def edit_finalized_patient(req: "CamcopsRequest") -> Response: 

5446 """ 

5447 View to edit details for a patient. 

5448 """ 

5449 task_table_name = req.get_str_param( 

5450 ViewParam.BACK_TASK_TABLENAME, validator=validate_task_tablename 

5451 ) 

5452 task_server_pk = req.get_int_param(ViewParam.BACK_TASK_SERVER_PK, None) 

5453 

5454 return EditFinalizedPatientView( 

5455 req, task_tablename=task_table_name, task_server_pk=task_server_pk 

5456 ).dispatch() 

5457 

5458 

5459@view_config( 

5460 route_name=Routes.EDIT_SERVER_CREATED_PATIENT, http_cache=NEVER_CACHE 

5461) 

5462def edit_server_created_patient(req: "CamcopsRequest") -> Response: 

5463 """ 

5464 View to edit details for a patient created on the server (for scheduling 

5465 tasks). 

5466 """ 

5467 return EditServerCreatedPatientView(req).dispatch() 

5468 

5469 

5470class AddPatientView(PatientMixin, CreateView): 

5471 """ 

5472 View to add a patient (for task scheduling). 

5473 """ 

5474 

5475 form_class = EditServerCreatedPatientForm 

5476 template_name = "patient_add.mako" 

5477 

5478 def dispatch(self) -> Response: 

5479 if not self.request.user.authorized_to_manage_patients: 

5480 _ = self.request.gettext 

5481 raise HTTPBadRequest(_("Not authorized to manage patients")) 

5482 

5483 return super().dispatch() 

5484 

5485 def get_success_url(self) -> str: 

5486 return self.request.route_url(Routes.VIEW_PATIENT_TASK_SCHEDULES) 

5487 

5488 def save_object(self, appstruct: Dict[str, Any]) -> None: 

5489 server_device = Device.get_server_device(self.request.dbsession) 

5490 

5491 patient = Patient() 

5492 patient.create_fresh( 

5493 self.request, 

5494 device_id=server_device.id, 

5495 era=ERA_NOW, 

5496 group_id=appstruct.get(ViewParam.GROUP_ID), 

5497 ) 

5498 

5499 for k in EDIT_PATIENT_SIMPLE_PARAMS: 

5500 new_value = appstruct.get(k) 

5501 setattr(patient, k, new_value) 

5502 

5503 patient.save_with_next_available_id(self.request, server_device.id) 

5504 

5505 new_idrefs = [ 

5506 IdNumReference( 

5507 which_idnum=idrefdict[ViewParam.WHICH_IDNUM], 

5508 idnum_value=idrefdict[ViewParam.IDNUM_VALUE], 

5509 ) 

5510 for idrefdict in appstruct.get(ViewParam.ID_REFERENCES) 

5511 ] 

5512 

5513 for idref in new_idrefs: 

5514 new_idnum = PatientIdNum() 

5515 new_idnum.patient_id = patient.id 

5516 new_idnum.which_idnum = idref.which_idnum 

5517 new_idnum.idnum_value = idref.idnum_value 

5518 new_idnum.create_fresh( 

5519 self.request, 

5520 device_id=server_device.id, 

5521 era=ERA_NOW, 

5522 group_id=appstruct.get(ViewParam.GROUP_ID), 

5523 ) 

5524 

5525 new_idnum.save_with_next_available_id( 

5526 self.request, server_device.id 

5527 ) 

5528 

5529 task_schedules = appstruct.get(ViewParam.TASK_SCHEDULES) 

5530 

5531 self.request.dbsession.commit() 

5532 

5533 for task_schedule in task_schedules: 

5534 schedule_id = task_schedule[ViewParam.SCHEDULE_ID] 

5535 start_datetime = task_schedule[ViewParam.START_DATETIME] 

5536 if DEFORM_ACCORDION_BUG: 

5537 settings = task_schedule[ViewParam.SETTINGS] 

5538 else: 

5539 settings = task_schedule[ViewParam.ADVANCED][ 

5540 ViewParam.SETTINGS 

5541 ] # noqa 

5542 patient_task_schedule = PatientTaskSchedule() 

5543 patient_task_schedule.patient_pk = patient.pk 

5544 patient_task_schedule.schedule_id = schedule_id 

5545 patient_task_schedule.start_datetime = start_datetime 

5546 patient_task_schedule.settings = settings 

5547 

5548 self.request.dbsession.add(patient_task_schedule) 

5549 

5550 self.object = patient 

5551 

5552 

5553@view_config(route_name=Routes.ADD_PATIENT, http_cache=NEVER_CACHE) 

5554def add_patient(req: "CamcopsRequest") -> Response: 

5555 """ 

5556 View to add a patient. 

5557 """ 

5558 return AddPatientView(req).dispatch() 

5559 

5560 

5561class DeleteServerCreatedPatientView(DeleteView): 

5562 """ 

5563 View to delete a patient that had been created on the server. 

5564 """ 

5565 

5566 form_class = DeleteServerCreatedPatientForm 

5567 object_class = Patient 

5568 pk_param = ViewParam.SERVER_PK 

5569 server_pk_name = "_pk" 

5570 template_name = TEMPLATE_GENERIC_FORM 

5571 

5572 def get_object(self) -> Any: 

5573 patient = cast(Patient, super().get_object()) 

5574 if not patient.user_may_edit(self.request): 

5575 _ = self.request.gettext 

5576 raise HTTPBadRequest(_("Not authorized to delete this patient")) 

5577 return patient 

5578 

5579 def get_extra_context(self) -> Dict[str, Any]: 

5580 _ = self.request.gettext 

5581 return { 

5582 MAKO_VAR_TITLE: self.request.icon_text( 

5583 icon=Icons.DELETE, text=_("Delete patient") 

5584 ) 

5585 } 

5586 

5587 def get_success_url(self) -> str: 

5588 return self.request.route_url(Routes.VIEW_PATIENT_TASK_SCHEDULES) 

5589 

5590 def delete(self) -> None: 

5591 patient = cast(Patient, self.object) 

5592 

5593 PatientIdNumIndexEntry.unindex_patient(patient, self.request.dbsession) 

5594 

5595 patient.delete_with_dependants(self.request) 

5596 

5597 

5598@view_config( 

5599 route_name=Routes.DELETE_SERVER_CREATED_PATIENT, http_cache=NEVER_CACHE 

5600) 

5601def delete_server_created_patient(req: "CamcopsRequest") -> Response: 

5602 """ 

5603 Page to delete a patient created on the server (as part of task 

5604 scheduling). 

5605 """ 

5606 return DeleteServerCreatedPatientView(req).dispatch() 

5607 

5608 

5609# ============================================================================= 

5610# Task scheduling 

5611# ============================================================================= 

5612 

5613 

5614@view_config( 

5615 route_name=Routes.VIEW_TASK_SCHEDULES, 

5616 permission=Permission.GROUPADMIN, 

5617 renderer="view_task_schedules.mako", 

5618 http_cache=NEVER_CACHE, 

5619) 

5620def view_task_schedules(req: "CamcopsRequest") -> Dict[str, Any]: 

5621 """ 

5622 View whole task schedules. 

5623 """ 

5624 rows_per_page = req.get_int_param( 

5625 ViewParam.ROWS_PER_PAGE, DEFAULT_ROWS_PER_PAGE 

5626 ) 

5627 page_num = req.get_int_param(ViewParam.PAGE, 1) 

5628 group_ids = req.user.ids_of_groups_user_is_admin_for 

5629 q = ( 

5630 req.dbsession.query(TaskSchedule) 

5631 .join(TaskSchedule.group) 

5632 .filter(TaskSchedule.group_id.in_(group_ids)) 

5633 .order_by(Group.name, TaskSchedule.name) 

5634 ) 

5635 page = SqlalchemyOrmPage( 

5636 query=q, 

5637 page=page_num, 

5638 items_per_page=rows_per_page, 

5639 url_maker=PageUrl(req), 

5640 request=req, 

5641 ) 

5642 return dict(page=page) 

5643 

5644 

5645@view_config( 

5646 route_name=Routes.VIEW_TASK_SCHEDULE_ITEMS, 

5647 permission=Permission.GROUPADMIN, 

5648 renderer="view_task_schedule_items.mako", 

5649 http_cache=NEVER_CACHE, 

5650) 

5651def view_task_schedule_items(req: "CamcopsRequest") -> Dict[str, Any]: 

5652 """ 

5653 View items within a task schedule. 

5654 """ 

5655 rows_per_page = req.get_int_param( 

5656 ViewParam.ROWS_PER_PAGE, DEFAULT_ROWS_PER_PAGE 

5657 ) 

5658 page_num = req.get_int_param(ViewParam.PAGE, 1) 

5659 schedule_id = req.get_int_param(ViewParam.SCHEDULE_ID) 

5660 

5661 schedule = ( 

5662 req.dbsession.query(TaskSchedule) 

5663 .filter(TaskSchedule.id == schedule_id) 

5664 .one_or_none() 

5665 ) 

5666 

5667 if schedule is None: 

5668 _ = req.gettext 

5669 raise HTTPBadRequest(_("Schedule does not exist")) 

5670 

5671 q = ( 

5672 req.dbsession.query(TaskScheduleItem) 

5673 .filter(TaskScheduleItem.schedule_id == schedule_id) 

5674 .order_by(*task_schedule_item_sort_order()) 

5675 ) 

5676 page = SqlalchemyOrmPage( 

5677 query=q, 

5678 page=page_num, 

5679 items_per_page=rows_per_page, 

5680 url_maker=PageUrl(req), 

5681 request=req, 

5682 ) 

5683 return dict(page=page, schedule_name=schedule.name) 

5684 

5685 

5686@view_config( 

5687 route_name=Routes.VIEW_PATIENT_TASK_SCHEDULES, 

5688 renderer="view_patient_task_schedules.mako", 

5689 http_cache=NEVER_CACHE, 

5690) 

5691def view_patient_task_schedules(req: "CamcopsRequest") -> Dict[str, Any]: 

5692 """ 

5693 View all patients and their assigned schedules (as well as their access 

5694 keys, etc.). 

5695 """ 

5696 server_device = Device.get_server_device(req.dbsession) 

5697 

5698 rows_per_page = req.get_int_param( 

5699 ViewParam.ROWS_PER_PAGE, DEFAULT_ROWS_PER_PAGE 

5700 ) 

5701 page_num = req.get_int_param(ViewParam.PAGE, 1) 

5702 allowed_group_ids = req.user.ids_of_groups_user_may_manage_patients_in 

5703 # noinspection PyProtectedMember 

5704 q = ( 

5705 req.dbsession.query(Patient) 

5706 .filter(Patient._era == ERA_NOW) 

5707 .filter(Patient._group_id.in_(allowed_group_ids)) 

5708 .filter(Patient._device_id == server_device.id) 

5709 .order_by(Patient.surname, Patient.forename) 

5710 .options(joinedload("task_schedules")) 

5711 .options(joinedload("idnums")) 

5712 ) 

5713 

5714 page = SqlalchemyOrmPage( 

5715 query=q, 

5716 page=page_num, 

5717 items_per_page=rows_per_page, 

5718 url_maker=PageUrl(req), 

5719 request=req, 

5720 ) 

5721 return dict(page=page) 

5722 

5723 

5724@view_config( 

5725 route_name=Routes.VIEW_PATIENT_TASK_SCHEDULE, 

5726 renderer="view_patient_task_schedule.mako", 

5727 http_cache=NEVER_CACHE, 

5728) 

5729def view_patient_task_schedule(req: "CamcopsRequest") -> Dict[str, Any]: 

5730 """ 

5731 View scheduled tasks for one patient's specific task schedule. 

5732 """ 

5733 pts_id = req.get_int_param(ViewParam.PATIENT_TASK_SCHEDULE_ID) 

5734 

5735 pts = ( 

5736 req.dbsession.query(PatientTaskSchedule) 

5737 .filter(PatientTaskSchedule.id == pts_id) 

5738 .options( 

5739 joinedload("patient.idnums"), joinedload("task_schedule.items") 

5740 ) 

5741 .one_or_none() 

5742 ) 

5743 

5744 _ = req.gettext 

5745 if pts is None: 

5746 raise HTTPBadRequest(_("Patient's task schedule does not exist")) 

5747 

5748 if not pts.patient.user_may_edit(req): 

5749 raise HTTPBadRequest(_("Not authorized to manage this patient")) 

5750 

5751 patient_descriptor = pts.patient.prettystr(req) 

5752 

5753 return dict( 

5754 pts=pts, 

5755 patient_descriptor=patient_descriptor, 

5756 schedule_name=pts.task_schedule.name, 

5757 task_list=pts.get_list_of_scheduled_tasks(req), 

5758 ) 

5759 

5760 

5761class TaskScheduleMixin(object): 

5762 """ 

5763 Mixin for viewing/editing a task schedule. 

5764 """ 

5765 

5766 form_class = EditTaskScheduleForm 

5767 model_form_dict = { 

5768 "name": ViewParam.NAME, 

5769 "group_id": ViewParam.GROUP_ID, 

5770 "email_bcc": ViewParam.EMAIL_BCC, 

5771 "email_cc": ViewParam.EMAIL_CC, 

5772 "email_from": ViewParam.EMAIL_FROM, 

5773 "email_subject": ViewParam.EMAIL_SUBJECT, 

5774 "email_template": ViewParam.EMAIL_TEMPLATE, 

5775 } 

5776 object_class = TaskSchedule 

5777 request: "CamcopsRequest" 

5778 server_pk_name = "id" 

5779 template_name = TEMPLATE_GENERIC_FORM 

5780 

5781 def get_success_url(self) -> str: 

5782 return self.request.route_url(Routes.VIEW_TASK_SCHEDULES) 

5783 

5784 def get_object(self) -> Any: 

5785 # noinspection PyUnresolvedReferences 

5786 schedule = cast(TaskSchedule, super().get_object()) 

5787 

5788 if not schedule.user_may_edit(self.request): 

5789 _ = self.request.gettext 

5790 raise HTTPBadRequest( 

5791 _( 

5792 "You a not a group administrator for this " 

5793 "task schedule's group" 

5794 ) 

5795 ) 

5796 

5797 return schedule 

5798 

5799 

5800class AddTaskScheduleView(TaskScheduleMixin, CreateView): 

5801 """ 

5802 Django-style view class to add a task schedule. 

5803 """ 

5804 

5805 def get_extra_context(self) -> Dict[str, Any]: 

5806 _ = self.request.gettext 

5807 return { 

5808 MAKO_VAR_TITLE: self.request.icon_text( 

5809 icon=Icons.TASK_SCHEDULE_ADD, text=_("Add a task schedule") 

5810 ) 

5811 } 

5812 

5813 

5814class EditTaskScheduleView(TaskScheduleMixin, UpdateView): 

5815 """ 

5816 Django-style view class to edit a task schedule. 

5817 """ 

5818 

5819 pk_param = ViewParam.SCHEDULE_ID 

5820 

5821 def get_extra_context(self) -> Dict[str, Any]: 

5822 _ = self.request.gettext 

5823 return { 

5824 MAKO_VAR_TITLE: self.request.icon_text( 

5825 icon=Icons.TASK_SCHEDULE, 

5826 text=_("Edit details for a task schedule"), 

5827 ) 

5828 } 

5829 

5830 

5831class DeleteTaskScheduleView(TaskScheduleMixin, DeleteView): 

5832 """ 

5833 Django-style view class to delete a task schedule. 

5834 """ 

5835 

5836 form_class = DeleteTaskScheduleForm 

5837 pk_param = ViewParam.SCHEDULE_ID 

5838 

5839 def get_extra_context(self) -> Dict[str, Any]: 

5840 _ = self.request.gettext 

5841 return { 

5842 MAKO_VAR_TITLE: self.request.icon_text( 

5843 icon=Icons.DELETE, text=_("Delete a task schedule") 

5844 ) 

5845 } 

5846 

5847 

5848@view_config( 

5849 route_name=Routes.ADD_TASK_SCHEDULE, 

5850 permission=Permission.GROUPADMIN, 

5851 http_cache=NEVER_CACHE, 

5852) 

5853def add_task_schedule(req: "CamcopsRequest") -> Response: 

5854 """ 

5855 View to add a task schedule. 

5856 """ 

5857 return AddTaskScheduleView(req).dispatch() 

5858 

5859 

5860@view_config( 

5861 route_name=Routes.EDIT_TASK_SCHEDULE, permission=Permission.GROUPADMIN 

5862) 

5863def edit_task_schedule(req: "CamcopsRequest") -> Response: 

5864 """ 

5865 View to edit a task schedule. 

5866 """ 

5867 return EditTaskScheduleView(req).dispatch() 

5868 

5869 

5870@view_config( 

5871 route_name=Routes.DELETE_TASK_SCHEDULE, permission=Permission.GROUPADMIN 

5872) 

5873def delete_task_schedule(req: "CamcopsRequest") -> Response: 

5874 """ 

5875 View to delete a task schedule. 

5876 """ 

5877 return DeleteTaskScheduleView(req).dispatch() 

5878 

5879 

5880class TaskScheduleItemMixin(object): 

5881 """ 

5882 Mixin for viewing/editing a task schedule items. 

5883 """ 

5884 

5885 form_class = EditTaskScheduleItemForm 

5886 template_name = TEMPLATE_GENERIC_FORM 

5887 model_form_dict = { 

5888 "schedule_id": ViewParam.SCHEDULE_ID, 

5889 "task_table_name": ViewParam.TABLE_NAME, 

5890 "due_from": ViewParam.DUE_FROM, 

5891 # we need to convert due_within to due_by 

5892 } 

5893 object: Any 

5894 # noinspection PyTypeChecker 

5895 object_class = cast(Type["Base"], TaskScheduleItem) 

5896 pk_param = ViewParam.SCHEDULE_ITEM_ID 

5897 request: "CamcopsRequest" 

5898 server_pk_name = "id" 

5899 

5900 def get_success_url(self) -> str: 

5901 # noinspection PyUnresolvedReferences 

5902 return self.request.route_url( 

5903 Routes.VIEW_TASK_SCHEDULE_ITEMS, 

5904 _query={ViewParam.SCHEDULE_ID: self.get_schedule_id()}, 

5905 ) 

5906 

5907 

5908class EditTaskScheduleItemMixin(TaskScheduleItemMixin): 

5909 """ 

5910 Django-style view class to edit a task schedule item. 

5911 """ 

5912 

5913 def set_object_properties(self, appstruct: Dict[str, Any]) -> None: 

5914 # noinspection PyUnresolvedReferences 

5915 super().set_object_properties(appstruct) 

5916 

5917 due_from = appstruct.get(ViewParam.DUE_FROM) 

5918 due_within = appstruct.get(ViewParam.DUE_WITHIN) 

5919 

5920 setattr(self.object, "due_by", due_from + due_within) 

5921 

5922 def get_schedule(self) -> TaskSchedule: 

5923 # noinspection PyUnresolvedReferences 

5924 schedule_id = self.get_schedule_id() 

5925 

5926 schedule = ( 

5927 self.request.dbsession.query(TaskSchedule) 

5928 .filter(TaskSchedule.id == schedule_id) 

5929 .one_or_none() 

5930 ) 

5931 

5932 if schedule is None: 

5933 _ = self.request.gettext 

5934 raise HTTPBadRequest( 

5935 f"{_('Missing Task Schedule for id')} {schedule_id}" 

5936 ) 

5937 

5938 if not schedule.user_may_edit(self.request): 

5939 _ = self.request.gettext 

5940 raise HTTPBadRequest( 

5941 _( 

5942 "You a not a group administrator for this " 

5943 "task schedule's group" 

5944 ) 

5945 ) 

5946 

5947 return schedule 

5948 

5949 

5950class AddTaskScheduleItemView(EditTaskScheduleItemMixin, CreateView): 

5951 """ 

5952 Django-style view class to add a task schedule item. 

5953 """ 

5954 

5955 def get_extra_context(self) -> Dict[str, Any]: 

5956 _ = self.request.gettext 

5957 

5958 schedule = self.get_schedule() 

5959 

5960 return { 

5961 MAKO_VAR_TITLE: self.request.icon_text( 

5962 icon=Icons.TASK_SCHEDULE_ITEM_ADD, 

5963 text=_("Add an item to the {schedule_name} schedule").format( 

5964 schedule_name=schedule.name 

5965 ), 

5966 ) 

5967 } 

5968 

5969 def get_schedule_id(self) -> int: 

5970 return self.request.get_int_param(ViewParam.SCHEDULE_ID) 

5971 

5972 def get_form_values(self) -> Dict: 

5973 schedule = self.get_schedule() 

5974 

5975 form_values = super().get_form_values() 

5976 form_values[ViewParam.SCHEDULE_ID] = schedule.id 

5977 

5978 return form_values 

5979 

5980 

5981class EditTaskScheduleItemView(EditTaskScheduleItemMixin, UpdateView): 

5982 """ 

5983 Django-style view class to edit a task schedule item. 

5984 """ 

5985 

5986 def get_extra_context(self) -> Dict[str, Any]: 

5987 _ = self.request.gettext 

5988 return { 

5989 MAKO_VAR_TITLE: self.request.icon_text( 

5990 icon=Icons.EDIT, 

5991 text=_("Edit details for a task schedule item"), 

5992 ) 

5993 } 

5994 

5995 def get_schedule_id(self) -> int: 

5996 item = cast(TaskScheduleItem, self.object) 

5997 

5998 return item.schedule_id 

5999 

6000 def get_form_values(self) -> Dict: 

6001 schedule = self.get_schedule() 

6002 

6003 form_values = super().get_form_values() 

6004 form_values[ViewParam.SCHEDULE_ID] = schedule.id 

6005 

6006 item = cast(TaskScheduleItem, self.object) 

6007 due_within = item.due_by - form_values[ViewParam.DUE_FROM] 

6008 form_values[ViewParam.DUE_WITHIN] = due_within 

6009 

6010 return form_values 

6011 

6012 

6013class DeleteTaskScheduleItemView(TaskScheduleItemMixin, DeleteView): 

6014 """ 

6015 Django-style view class to delete a task schedule item. 

6016 """ 

6017 

6018 form_class = DeleteTaskScheduleItemForm 

6019 

6020 def get_extra_context(self) -> Dict[str, Any]: 

6021 _ = self.request.gettext 

6022 return { 

6023 MAKO_VAR_TITLE: self.request.icon_text( 

6024 icon=Icons.DELETE, text=_("Delete a task schedule item") 

6025 ) 

6026 } 

6027 

6028 def get_schedule_id(self) -> int: 

6029 item = cast(TaskScheduleItem, self.object) 

6030 

6031 return item.schedule_id 

6032 

6033 

6034@view_config( 

6035 route_name=Routes.ADD_TASK_SCHEDULE_ITEM, permission=Permission.GROUPADMIN 

6036) 

6037def add_task_schedule_item(req: "CamcopsRequest") -> Response: 

6038 """ 

6039 View to add a task schedule item. 

6040 """ 

6041 return AddTaskScheduleItemView(req).dispatch() 

6042 

6043 

6044@view_config( 

6045 route_name=Routes.EDIT_TASK_SCHEDULE_ITEM, permission=Permission.GROUPADMIN 

6046) 

6047def edit_task_schedule_item(req: "CamcopsRequest") -> Response: 

6048 """ 

6049 View to edit a task schedule item. 

6050 """ 

6051 return EditTaskScheduleItemView(req).dispatch() 

6052 

6053 

6054@view_config( 

6055 route_name=Routes.DELETE_TASK_SCHEDULE_ITEM, 

6056 permission=Permission.GROUPADMIN, 

6057) 

6058def delete_task_schedule_item(req: "CamcopsRequest") -> Response: 

6059 """ 

6060 View to delete a task schedule item. 

6061 """ 

6062 return DeleteTaskScheduleItemView(req).dispatch() 

6063 

6064 

6065@view_config( 

6066 route_name=Routes.CLIENT_API, 

6067 request_method=HttpMethod.GET, 

6068 permission=NO_PERMISSION_REQUIRED, 

6069 renderer="client_api_signposting.mako", 

6070) 

6071@view_config( 

6072 route_name=Routes.CLIENT_API_ALIAS, 

6073 request_method=HttpMethod.GET, 

6074 permission=NO_PERMISSION_REQUIRED, 

6075 renderer="client_api_signposting.mako", 

6076) 

6077def client_api_signposting(req: "CamcopsRequest") -> Dict[str, Any]: 

6078 """ 

6079 Patients are likely to enter the ``/api`` address into a web browser, 

6080 especially if it appears as a hyperlink in an email. If so, that will 

6081 arrive as a ``GET`` request. This page will direct them to download the 

6082 app. 

6083 """ 

6084 return { 

6085 "github_link": req.icon_text( 

6086 icon=Icons.GITHUB, url=GITHUB_RELEASES_URL, text="GitHub" 

6087 ), 

6088 "server_url": req.route_url(Routes.CLIENT_API), 

6089 } 

6090 

6091 

6092class SendPatientEmailBaseView(FormView): 

6093 """ 

6094 Send an e-mail to a patient (such as: "please download the app and register 

6095 with this URL/code"). 

6096 """ 

6097 

6098 form_class = SendEmailForm 

6099 template_name = "send_patient_email.mako" 

6100 

6101 def __init__(self, *args, **kwargs) -> None: 

6102 self._pts = None 

6103 

6104 super().__init__(*args, **kwargs) 

6105 

6106 def dispatch(self) -> Response: 

6107 if not self.request.user.authorized_to_email_patients: 

6108 _ = self.request.gettext 

6109 raise HTTPBadRequest(_("Not authorized to email patients")) 

6110 

6111 return super().dispatch() 

6112 

6113 def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: 

6114 kwargs["pts"] = self._get_patient_task_schedule() 

6115 

6116 return super().get_context_data(**kwargs) 

6117 

6118 def form_valid(self, form: "Form", appstruct: Dict[str, Any]) -> Response: 

6119 config = self.request.config 

6120 

6121 patient_email = appstruct.get(ViewParam.EMAIL) 

6122 

6123 kwargs = dict( 

6124 from_addr=appstruct.get(ViewParam.EMAIL_FROM), 

6125 to=patient_email, 

6126 subject=appstruct.get(ViewParam.EMAIL_SUBJECT), 

6127 body=appstruct.get(ViewParam.EMAIL_BODY), 

6128 content_type=MimeType.HTML, 

6129 ) 

6130 

6131 cc = appstruct.get(ViewParam.EMAIL_CC) 

6132 if cc: 

6133 kwargs["cc"] = cc 

6134 

6135 bcc = appstruct.get(ViewParam.EMAIL_BCC) 

6136 if bcc: 

6137 kwargs["bcc"] = bcc 

6138 

6139 email = Email(**kwargs) 

6140 ok = email.send( 

6141 host=config.email_host, 

6142 username=config.email_host_username, 

6143 password=config.email_host_password, 

6144 port=config.email_port, 

6145 use_tls=config.email_use_tls, 

6146 ) 

6147 if ok: 

6148 self._display_success_message(patient_email) 

6149 else: 

6150 self._display_failure_message(patient_email) 

6151 

6152 self.request.dbsession.add(email) 

6153 self.request.dbsession.flush() 

6154 pts_id = self.request.get_int_param(ViewParam.PATIENT_TASK_SCHEDULE_ID) 

6155 if pts_id is None: 

6156 _ = self.request.gettext 

6157 raise HTTPBadRequest(_("Patient task schedule does not exist")) 

6158 

6159 pts_email = PatientTaskScheduleEmail() 

6160 pts_email.patient_task_schedule_id = pts_id 

6161 pts_email.email_id = email.id 

6162 self.request.dbsession.add(pts_email) 

6163 self.request.dbsession.commit() 

6164 

6165 return super().form_valid(form, appstruct) 

6166 

6167 def _display_success_message(self, patient_email: str) -> None: 

6168 _ = self.request.gettext 

6169 message = _("Email sent to {patient_email}").format( 

6170 patient_email=patient_email 

6171 ) 

6172 

6173 self.request.session.flash(message, queue=FlashQueue.SUCCESS) 

6174 

6175 def _display_failure_message(self, patient_email: str) -> None: 

6176 _ = self.request.gettext 

6177 message = _("Failed to send email to {patient_email}").format( 

6178 patient_email=patient_email 

6179 ) 

6180 

6181 self.request.session.flash(message, queue=FlashQueue.DANGER) 

6182 

6183 def get_form_values(self) -> Dict: 

6184 pts = self._get_patient_task_schedule() 

6185 

6186 if pts is None: 

6187 _ = self.request.gettext 

6188 raise HTTPBadRequest(_("Patient task schedule does not exist")) 

6189 

6190 return { 

6191 ViewParam.EMAIL: pts.patient.email, 

6192 ViewParam.EMAIL_CC: pts.task_schedule.email_cc, 

6193 ViewParam.EMAIL_BCC: pts.task_schedule.email_bcc, 

6194 ViewParam.EMAIL_FROM: pts.task_schedule.email_from, 

6195 ViewParam.EMAIL_SUBJECT: pts.task_schedule.email_subject, 

6196 ViewParam.EMAIL_BODY: pts.email_body(self.request), 

6197 } 

6198 

6199 def _get_patient_task_schedule(self) -> Optional[PatientTaskSchedule]: 

6200 if self._pts is not None: 

6201 return self._pts 

6202 

6203 pts_id = self.request.get_int_param(ViewParam.PATIENT_TASK_SCHEDULE_ID) 

6204 

6205 self._pts = ( 

6206 self.request.dbsession.query(PatientTaskSchedule) 

6207 .filter(PatientTaskSchedule.id == pts_id) 

6208 .one_or_none() 

6209 ) 

6210 

6211 return self._pts 

6212 

6213 

6214class SendEmailFromPatientListView(SendPatientEmailBaseView): 

6215 """ 

6216 Send an e-mail to a patient and return to the patient task schedule list 

6217 view. 

6218 """ 

6219 

6220 def get_success_url(self) -> str: 

6221 return self.request.route_url(Routes.VIEW_PATIENT_TASK_SCHEDULES) 

6222 

6223 

6224class SendEmailFromPatientTaskScheduleView(SendPatientEmailBaseView): 

6225 """ 

6226 Send an e-mail to a patient and return to the task schedule view for that 

6227 specific patient. 

6228 """ 

6229 

6230 def get_success_url(self) -> str: 

6231 pts_id = self.request.get_int_param(ViewParam.PATIENT_TASK_SCHEDULE_ID) 

6232 

6233 return self.request.route_url( 

6234 Routes.VIEW_PATIENT_TASK_SCHEDULE, 

6235 _query={ViewParam.PATIENT_TASK_SCHEDULE_ID: pts_id}, 

6236 ) 

6237 

6238 

6239@view_config( 

6240 route_name=Routes.SEND_EMAIL_FROM_PATIENT_TASK_SCHEDULE, 

6241 http_cache=NEVER_CACHE, 

6242) 

6243def send_email_from_patient_task_schedule(req: "CamcopsRequest") -> Response: 

6244 """ 

6245 View to send an email to a patient from their task schedule page. 

6246 """ 

6247 return SendEmailFromPatientTaskScheduleView(req).dispatch() 

6248 

6249 

6250@view_config( 

6251 route_name=Routes.SEND_EMAIL_FROM_PATIENT_LIST, http_cache=NEVER_CACHE 

6252) 

6253def send_email_from_patient_list(req: "CamcopsRequest") -> Response: 

6254 """ 

6255 View to send an email to a patient from the list of patients. 

6256 """ 

6257 return SendEmailFromPatientListView(req).dispatch() 

6258 

6259 

6260# ============================================================================= 

6261# FHIR identifier "system" information 

6262# ============================================================================= 

6263 

6264 

6265@view_config( 

6266 route_name=Routes.FHIR_PATIENT_ID_SYSTEM, 

6267 request_method=HttpMethod.GET, 

6268 renderer="fhir_patient_id_system.mako", 

6269 http_cache=NEVER_CACHE, 

6270) 

6271def view_fhir_patient_id_system(req: "CamcopsRequest") -> Dict[str, Any]: 

6272 """ 

6273 Placeholder view for FHIR patient identifier "system" types (from the ID 

6274 that we may have provided to a FHIR server). 

6275 

6276 Within each system, the "value" is the actual patient's ID number (not 

6277 part of what we show here). 

6278 """ 

6279 which_idnum = int(req.matchdict[ViewParam.WHICH_IDNUM]) 

6280 if which_idnum not in req.valid_which_idnums: 

6281 _ = req.gettext 

6282 raise HTTPBadRequest( 

6283 f"{_('Unknown patient ID type:')} " f"{which_idnum!r}" 

6284 ) 

6285 return dict(which_idnum=which_idnum) 

6286 

6287 

6288# noinspection PyUnusedLocal 

6289@view_config( 

6290 route_name=Routes.FHIR_QUESTIONNAIRE_SYSTEM, 

6291 request_method=HttpMethod.GET, 

6292 renderer="all_tasks.mako", 

6293 http_cache=NEVER_CACHE, 

6294) 

6295@view_config( 

6296 route_name=Routes.TASK_LIST, 

6297 request_method=HttpMethod.GET, 

6298 renderer="all_tasks.mako", 

6299 http_cache=NEVER_CACHE, 

6300) 

6301def view_task_list(req: "CamcopsRequest") -> Dict[str, Any]: 

6302 """ 

6303 Lists all tasks. 

6304 

6305 Also the placeholder view for FHIR Questionnaire "system". 

6306 There's only one system -- the "value" is the task type. 

6307 """ 

6308 return dict(all_task_classes=Task.all_subclasses_by_tablename()) 

6309 

6310 

6311@view_config( 

6312 route_name=Routes.TASK_DETAILS, 

6313 request_method=HttpMethod.GET, 

6314 renderer="task_details.mako", 

6315 http_cache=NEVER_CACHE, 

6316) 

6317def view_task_details(req: "CamcopsRequest") -> Dict[str, Any]: 

6318 """ 

6319 View details of a specific task type. 

6320 

6321 Used also for for FHIR DocumentReference, Observation,and 

6322 QuestionnaireResponse "system" types. (There's one system per task. Within 

6323 each task, the "value" relates to the specific task PK.) 

6324 """ 

6325 table_name = req.matchdict[ViewParam.TABLE_NAME] 

6326 task_class_dict = tablename_to_task_class_dict() 

6327 if table_name not in task_class_dict: 

6328 _ = req.gettext 

6329 raise HTTPBadRequest(f"{_('Unknown task:')} {table_name!r}") 

6330 task_class = task_class_dict[table_name] 

6331 task_instance = task_class() 

6332 

6333 fhir_aq_items = task_instance.get_fhir_questionnaire(req) 

6334 # ddl = task_instance.get_ddl() 

6335 # ddl_html, ddl_css = format_sql_as_html(ddl) 

6336 

6337 return dict( 

6338 task_class=task_class, 

6339 task_instance=task_instance, 

6340 fhir_aq_items=fhir_aq_items, 

6341 # ddl_html=ddl_html, 

6342 # css=ddl_css, 

6343 ) 

6344 

6345 

6346@view_config( 

6347 route_name=Routes.FHIR_CONDITION, 

6348 request_method=HttpMethod.GET, 

6349 http_cache=NEVER_CACHE, 

6350) 

6351@view_config( 

6352 route_name=Routes.FHIR_DOCUMENT_REFERENCE, 

6353 request_method=HttpMethod.GET, 

6354 http_cache=NEVER_CACHE, 

6355) 

6356@view_config( 

6357 route_name=Routes.FHIR_OBSERVATION, 

6358 request_method=HttpMethod.GET, 

6359 http_cache=NEVER_CACHE, 

6360) 

6361@view_config( 

6362 route_name=Routes.FHIR_PRACTITIONER, 

6363 request_method=HttpMethod.GET, 

6364 http_cache=NEVER_CACHE, 

6365) 

6366@view_config( 

6367 route_name=Routes.FHIR_QUESTIONNAIRE_RESPONSE, 

6368 request_method=HttpMethod.GET, 

6369 http_cache=NEVER_CACHE, 

6370) 

6371def fhir_view_task(req: "CamcopsRequest") -> Response: 

6372 """ 

6373 Retrieve parameters from a FHIR URL referring back to this server, and 

6374 serve the relevant task (as HTML). 

6375 

6376 The "canonical URL" or "business identifier" of a FHIR resource is the 

6377 reference to the master copy -- in this case, our copy. See 

6378 https://www.hl7.org/fhir/datatypes.html#Identifier; 

6379 https://www.hl7.org/fhir/resource.html#identifiers. 

6380 

6381 FHIR identifiers have a "system" (which is a URL) and a "value". I don't 

6382 think that FHIR has a rule for combining the system and value to create a 

6383 full URL. For some (but by no means all) identifiers that we provide to 

6384 FHIR servers, the "system" refers to a CamCOPS task (and the value to some 

6385 attribute of that task, like the answer to a question (value of a field), 

6386 or a fixed string like "patient", and so on. 

6387 """ 

6388 table_name = req.matchdict[ViewParam.TABLE_NAME] 

6389 server_pk = req.matchdict[ViewParam.SERVER_PK] 

6390 return HTTPFound( 

6391 req.route_url( 

6392 Routes.TASK, 

6393 _query={ 

6394 ViewParam.TABLE_NAME: table_name, 

6395 ViewParam.SERVER_PK: server_pk, 

6396 ViewParam.VIEWTYPE: ViewArg.HTML, 

6397 }, 

6398 ) 

6399 ) 

6400 

6401 

6402@view_config( 

6403 route_name=Routes.FHIR_TABLENAME_PK_ID, 

6404 request_method=HttpMethod.GET, 

6405 http_cache=NEVER_CACHE, 

6406) 

6407def fhir_view_tablename_pk(req: "CamcopsRequest") -> Response: 

6408 """ 

6409 Deal with the slightly silly system that just takes a tablename and PK 

6410 directly. Security is key here! 

6411 """ 

6412 table_name = req.matchdict[ViewParam.TABLE_NAME] 

6413 server_pk = req.matchdict[ViewParam.SERVER_PK] 

6414 if table_name == Patient.__tablename__: 

6415 return view_patient(req, server_pk) 

6416 return HTTPFound( 

6417 req.route_url( 

6418 Routes.TASK, 

6419 _query={ 

6420 ViewParam.TABLE_NAME: table_name, 

6421 ViewParam.SERVER_PK: server_pk, 

6422 ViewParam.VIEWTYPE: ViewArg.HTML, 

6423 }, 

6424 ) 

6425 ) 

6426 

6427 

6428# ============================================================================= 

6429# Static assets 

6430# ============================================================================= 

6431# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/assets.html#advanced-static # noqa 

6432 

6433 

6434def debug_form_rendering() -> None: 

6435 r""" 

6436 Test code for form rendering. 

6437 

6438 From the command line: 

6439 

6440 .. code-block:: bash 

6441 

6442 # Start in the CamCOPS source root directory. 

6443 # - Needs the "-f" option to follow forks. 

6444 # - "open" doesn't show all files opened. To see what you need, try 

6445 # strace cat /proc/version 

6446 # - ... which shows that "openat" is most useful. 

6447 

6448 strace -f --trace=openat \ 

6449 python -c 'from camcops_server.cc_modules.webview import debug_form_rendering; debug_form_rendering()' \ 

6450 | grep site-packages \ 

6451 | grep -v "\.pyc" 

6452 

6453 This tells us that the templates are files like: 

6454 

6455 .. code-block:: none 

6456 

6457 site-packages/deform/templates/form.pt 

6458 site-packages/deform/templates/select.pt 

6459 site-packages/deform/templates/textinput.pt 

6460 

6461 On 2020-06-29 we are interested in why a newer (Docker) installation 

6462 renders buggy HTML like: 

6463 

6464 .. code-block:: none 

6465 

6466 <select name="which_idnum" id="deformField2" class=" form-control " multiple="False"> 

6467 <option value="1">CPFT RiO number</option> 

6468 <option value="2">NHS number</option> 

6469 <option value="1000">MyHospital number</option> 

6470 </select> 

6471 

6472 ... the bug being that ``multiple="False"`` is wrong; an HTML boolean 

6473 attribute is false when *absent*, not when set to a certain value (see 

6474 https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes#Boolean_Attributes). 

6475 The ``multiple`` attribute of ``<select>`` is a boolean attribute 

6476 (https://developer.mozilla.org/en-US/docs/Web/HTML/Element/select). 

6477 

6478 The ``select.pt`` file indicates that this is controlled by 

6479 ``tal:attributes`` syntax. TAL is Template Attribution Language 

6480 (https://sharptal.readthedocs.io/en/latest/tal.html). 

6481 

6482 TAL is either provided by Zope (given ZPT files) or Chameleon or both. The 

6483 tracing suggests Chameleon. So the TAL language reference is 

6484 https://chameleon.readthedocs.io/en/latest/reference.html. 

6485 

6486 Chameleon changelog is 

6487 https://github.com/malthe/chameleon/blob/master/CHANGES.rst. 

6488 

6489 Multiple sources for ``tal:attributes`` syntax say that a null value 

6490 (presumably: ``None``) is required to omit the attribute, not a false 

6491 value. 

6492 

6493 """ # noqa 

6494 

6495 import sys 

6496 

6497 from camcops_server.cc_modules.cc_debug import makefunc_trace_unique_calls 

6498 from camcops_server.cc_modules.cc_forms import ChooseTrackerForm 

6499 from camcops_server.cc_modules.cc_request import get_core_debugging_request 

6500 

6501 req = get_core_debugging_request() 

6502 form = ChooseTrackerForm(req, as_ctv=False) 

6503 

6504 sys.settrace(makefunc_trace_unique_calls(file_only=True)) 

6505 _ = form.render() 

6506 sys.settrace(None)