Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/cc_modules/webview.py 

5 

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

7 

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

9 

10 This file is part of CamCOPS. 

11 

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

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

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

15 (at your option) any later version. 

16 

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

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

19 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

20 GNU General Public License for more details. 

21 

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

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

24 

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

26 

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

28 

29Quick tutorial on Pyramid views: 

30 

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

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

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

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

35 decorated with @view_config(). 

36 

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

38 that Pyramid will translate into a Response. 

39 

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

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

42 associated with: 

43 

44 .. code-block:: python 

45 

46 @view_config(route_name="myroute") 

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

48 pass 

49 

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

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

52 pass 

53 

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

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

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

57 

58- Specifiers include: 

59 

60 .. code-block:: none 

61 

62 route_name=ROUTENAME 

63 

64 the route 

65 

66 request_method="POST" 

67 

68 requires HTTP GET, POST, etc. 

69 

70 request_param="XXX" 

71 

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

73 the request.params dictionary 

74 

75 request_param="XXX=YYY" 

76 

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

78 value is YYY, in the request.params dictionary 

79 

80 match_param="XXX=YYY" 

81 

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

83 request.matchdict, which contains parameters from the URL 

84 

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

86 

87- Getting parameters 

88 

89 .. code-block:: none 

90 

91 request.params 

92 

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

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

95 POST). 

96 

97 request.matchdict 

98 

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

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

101 

102- Regarding rendering: 

103 

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

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

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

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

108 

109 .. code-block:: python 

110 

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

112 

113 to 

114 

115 .. code-block:: python 

116 

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

118 

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

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

121 

122""" 

123 

124from collections import OrderedDict 

125import logging 

126import os 

127# from pprint import pformat 

128from typing import ( 

129 Any, 

130 cast, 

131 Dict, 

132 List, 

133 Optional, 

134 Type, 

135 TYPE_CHECKING, 

136) 

137 

138from cardinal_pythonlib.datetimefunc import format_datetime 

139from cardinal_pythonlib.deform_utils import get_head_form_html 

140from cardinal_pythonlib.httpconst import MimeType 

141from cardinal_pythonlib.logs import BraceStyleAdapter 

142from cardinal_pythonlib.pyramid.responses import ( 

143 BinaryResponse, 

144 PdfResponse, 

145 XmlResponse, 

146) 

147from cardinal_pythonlib.sqlalchemy.dialect import ( 

148 get_dialect_name, 

149 SqlaDialectName, 

150) 

151from cardinal_pythonlib.sizeformatter import bytes2human 

152from cardinal_pythonlib.sqlalchemy.orm_inspect import gen_orm_classes_from_base 

153from cardinal_pythonlib.sqlalchemy.orm_query import CountStarSpecializedQuery 

154from cardinal_pythonlib.sqlalchemy.session import get_engine_from_session 

155from deform.exception import ValidationFailure 

156from pendulum import DateTime as Pendulum 

157from pyramid.httpexceptions import HTTPBadRequest, HTTPFound, HTTPNotFound 

158from pyramid.view import ( 

159 forbidden_view_config, 

160 notfound_view_config, 

161 view_config, 

162) 

163from pyramid.renderers import render_to_response 

164from pyramid.response import Response 

165from pyramid.security import Authenticated, NO_PERMISSION_REQUIRED 

166import pygments 

167import pygments.lexers 

168import pygments.lexers.sql 

169import pygments.lexers.web 

170import pygments.formatters 

171from sqlalchemy.orm import joinedload, Query 

172from sqlalchemy.sql.functions import func 

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

174 

175from camcops_server.cc_modules.cc_audit import audit, AuditEntry 

176from camcops_server.cc_modules.cc_all_models import CLIENT_TABLE_MAP 

177from camcops_server.cc_modules.cc_client_api_core import ( 

178 BatchDetails, 

179 get_server_live_records, 

180 UploadTableChanges, 

181 values_preserve_now, 

182) 

183from camcops_server.cc_modules.cc_client_api_helpers import ( 

184 upload_commit_order_sorter, 

185) 

186from camcops_server.cc_modules.cc_constants import ( 

187 CAMCOPS_URL, 

188 DateFormat, 

189 ERA_NOW, 

190 MINIMUM_PASSWORD_LENGTH, 

191) 

192from camcops_server.cc_modules.cc_db import ( 

193 GenericTabletRecordMixin, 

194 FN_DEVICE_ID, 

195 FN_ERA, 

196 FN_GROUP_ID, 

197 FN_PK, 

198) 

199from camcops_server.cc_modules.cc_device import Device 

200from camcops_server.cc_modules.cc_email import Email 

201from camcops_server.cc_modules.cc_export import ( 

202 DownloadOptions, 

203 make_exporter, 

204 UserDownloadFile, 

205) 

206from camcops_server.cc_modules.cc_exportmodels import ( 

207 ExportedTask, 

208 ExportedTaskEmail, 

209 ExportedTaskFileGroup, 

210 ExportedTaskHL7Message, 

211) 

212from camcops_server.cc_modules.cc_exportrecipient import ExportRecipient 

213from camcops_server.cc_modules.cc_forms import ( 

214 AddGroupForm, 

215 AddIdDefinitionForm, 

216 AddSpecialNoteForm, 

217 AddUserGroupadminForm, 

218 AddUserSuperuserForm, 

219 AuditTrailForm, 

220 ChangeOtherPasswordForm, 

221 ChangeOwnPasswordForm, 

222 ChooseTrackerForm, 

223 DEFAULT_ROWS_PER_PAGE, 

224 DeleteGroupForm, 

225 DeleteIdDefinitionForm, 

226 DeletePatientChooseForm, 

227 DeletePatientConfirmForm, 

228 DeleteServerCreatedPatientForm, 

229 DeleteSpecialNoteForm, 

230 DeleteTaskScheduleForm, 

231 DeleteTaskScheduleItemForm, 

232 DeleteUserForm, 

233 EditGroupForm, 

234 EDIT_PATIENT_SIMPLE_PARAMS, 

235 EditFinalizedPatientForm, 

236 EditIdDefinitionForm, 

237 EditServerCreatedPatientForm, 

238 EditServerSettingsForm, 

239 EditTaskScheduleForm, 

240 EditTaskScheduleItemForm, 

241 EditUserFullForm, 

242 EditUserGroupAdminForm, 

243 EditUserGroupMembershipGroupAdminForm, 

244 EditUserGroupPermissionsFullForm, 

245 EraseTaskForm, 

246 ExportedTaskListForm, 

247 get_sql_dialect_choices, 

248 ForciblyFinalizeChooseDeviceForm, 

249 ForciblyFinalizeConfirmForm, 

250 LoginForm, 

251 OfferBasicDumpForm, 

252 OfferSqlDumpForm, 

253 OfferTermsForm, 

254 RefreshTasksForm, 

255 SetUserUploadGroupForm, 

256 EditTaskFilterForm, 

257 TasksPerPageForm, 

258 UserDownloadDeleteForm, 

259 UserFilterForm, 

260 ViewDdlForm, 

261) 

262from camcops_server.cc_modules.cc_group import Group 

263from camcops_server.cc_modules.cc_idnumdef import IdNumDefinition 

264from camcops_server.cc_modules.cc_membership import UserGroupMembership 

265from camcops_server.cc_modules.cc_patient import Patient 

266from camcops_server.cc_modules.cc_patientidnum import PatientIdNum 

267# noinspection PyUnresolvedReferences 

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

269from camcops_server.cc_modules.cc_pyramid import ( 

270 CamcopsPage, 

271 FormAction, 

272 HTTPFoundDebugVersion, 

273 PageUrl, 

274 Permission, 

275 Routes, 

276 SqlalchemyOrmPage, 

277 ViewArg, 

278 ViewParam, 

279) 

280from camcops_server.cc_modules.cc_report import get_report_instance 

281from camcops_server.cc_modules.cc_request import CamcopsRequest 

282from camcops_server.cc_modules.cc_simpleobjects import ( 

283 IdNumReference, 

284 TaskExportOptions, 

285) 

286from camcops_server.cc_modules.cc_specialnote import SpecialNote 

287from camcops_server.cc_modules.cc_session import CamcopsSession 

288from camcops_server.cc_modules.cc_sqlalchemy import get_all_ddl 

289from camcops_server.cc_modules.cc_task import Task 

290from camcops_server.cc_modules.cc_taskcollection import ( 

291 TaskFilter, 

292 TaskCollection, 

293 TaskSortMethod, 

294) 

295from camcops_server.cc_modules.cc_taskfactory import task_factory 

296from camcops_server.cc_modules.cc_taskfilter import ( 

297 task_classes_from_table_names, 

298 TaskClassSortMethod, 

299) 

300from camcops_server.cc_modules.cc_taskindex import ( 

301 PatientIdNumIndexEntry, 

302 TaskIndexEntry, 

303 update_indexes_and_push_exports 

304) 

305from camcops_server.cc_modules.cc_taskschedule import ( 

306 PatientTaskSchedule, 

307 TaskSchedule, 

308 TaskScheduleItem, 

309 task_schedule_item_sort_order, 

310) 

311from camcops_server.cc_modules.cc_text import SS 

312from camcops_server.cc_modules.cc_tracker import ClinicalTextView, Tracker 

313from camcops_server.cc_modules.cc_user import ( 

314 SecurityAccountLockout, 

315 SecurityLoginFailure, 

316 User, 

317) 

318from camcops_server.cc_modules.cc_validators import ( 

319 validate_export_recipient_name, 

320 validate_ip_address, 

321 validate_task_tablename, 

322 validate_username, 

323) 

324from camcops_server.cc_modules.cc_version import CAMCOPS_SERVER_VERSION 

325from camcops_server.cc_modules.cc_view_classes import ( 

326 CreateView, 

327 DeleteView, 

328 UpdateView, 

329) 

330 

331if TYPE_CHECKING: 

332 # noinspection PyUnresolvedReferences 

333 from camcops_server.cc_modules.cc_sqlalchemy import Base 

334 

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

336 

337 

338# ============================================================================= 

339# Debugging options 

340# ============================================================================= 

341 

342DEBUG_REDIRECT = False 

343 

344if DEBUG_REDIRECT: 

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

346 

347if DEBUG_REDIRECT: 

348 HTTPFound = HTTPFoundDebugVersion # noqa: F811 

349 

350 

351# ============================================================================= 

352# Flash message queues: https://getbootstrap.com/docs/3.3/components/#alerts 

353# ============================================================================= 

354 

355FLASH_SUCCESS = "success" 

356FLASH_INFO = "info" 

357FLASH_WARNING = "warning" 

358FLASH_DANGER = "danger" 

359 

360 

361# ============================================================================= 

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

363# ============================================================================= 

364 

365NEVER_CACHE = 0 

366 

367 

368# ============================================================================= 

369# Constants -- mutated into translated phrases 

370# ============================================================================= 

371 

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

373 _ = req.gettext 

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

375 

376 

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

378 _ = req.gettext 

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

380 

381 

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

383 _ = req.gettext 

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

385 

386 

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

388# Unused 

389# ============================================================================= 

390 

391# def query_result_html_core(req: "CamcopsRequest", 

392# descriptions: Sequence[str], 

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

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

395# return render("query_result_core.mako", 

396# dict(descriptions=descriptions, 

397# rows=rows, 

398# null_html=null_html), 

399# request=req) 

400 

401 

402# def query_result_html_orm(req: "CamcopsRequest", 

403# attrnames: List[str], 

404# descriptions: List[str], 

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

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

407# return render("query_result_orm.mako", 

408# dict(attrnames=attrnames, 

409# descriptions=descriptions, 

410# orm_objects=orm_objects, 

411# null_html=null_html), 

412# request=req) 

413 

414 

415# ============================================================================= 

416# Error views 

417# ============================================================================= 

418 

419# noinspection PyUnusedLocal 

420@notfound_view_config(renderer="not_found.mako", 

421 http_cache=NEVER_CACHE) 

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

423 """ 

424 "Page not found" view. 

425 """ 

426 return { 

427 "msg": "", 

428 "extra_html": "", 

429 } 

430 

431 

432# noinspection PyUnusedLocal 

433@view_config(context=HTTPBadRequest, 

434 renderer="bad_request.mako", 

435 http_cache=NEVER_CACHE) 

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

437 """ 

438 "Bad request" view. 

439 

440 NOTE that this view only gets used from 

441 

442 .. code-block:: python 

443 

444 raise HTTPBadRequest("message") 

445 

446 and not 

447 

448 .. code-block:: python 

449 

450 return HTTPBadRequest("message") 

451 

452 ... so always raise it. 

453 """ 

454 return { 

455 "msg": "", 

456 "extra_html": "", 

457 } 

458 

459 

460# ============================================================================= 

461# Test pages 

462# ============================================================================= 

463 

464# noinspection PyUnusedLocal 

465@view_config(route_name=Routes.TESTPAGE_PUBLIC_1, 

466 permission=NO_PERMISSION_REQUIRED, 

467 http_cache=NEVER_CACHE) 

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

469 """ 

470 A public test page with no content. 

471 """ 

472 _ = req.gettext 

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

474 

475 

476# noinspection PyUnusedLocal 

477@view_config(route_name=Routes.TESTPAGE_PRIVATE_1, 

478 http_cache=NEVER_CACHE) 

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

480 """ 

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

482 be accessible to authenticated users. 

483 """ 

484 _ = req.gettext 

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

486 

487 

488# noinspection PyUnusedLocal 

489@view_config(route_name=Routes.TESTPAGE_PRIVATE_2, 

490 permission=Permission.SUPERUSER, 

491 renderer="testpage.mako", 

492 http_cache=NEVER_CACHE) 

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

494 """ 

495 A private test page containing POTENTIALLY SENSITIVE test information, 

496 including environment variables, that should only be accessible to 

497 superusers. 

498 """ 

499 return dict(param1="world") 

500 

501 

502# noinspection PyUnusedLocal 

503@view_config(route_name=Routes.TESTPAGE_PRIVATE_3, 

504 permission=Permission.SUPERUSER, 

505 renderer="inherit_cache_test_child.mako", 

506 http_cache=NEVER_CACHE) 

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

508 """ 

509 A private test page that tests template inheritance. 

510 """ 

511 return {} 

512 

513 

514# noinspection PyUnusedLocal 

515@view_config(route_name=Routes.TESTPAGE_PRIVATE_4, 

516 permission=Permission.SUPERUSER, 

517 renderer="test_template_filters.mako", 

518 http_cache=NEVER_CACHE) 

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

520 """ 

521 A private test page that tests Mako filtering. 

522 """ 

523 return dict( 

524 test_strings=[ 

525 "plain", 

526 "normal <b>bold</b> normal", 

527 ], 

528 ) 

529 

530 

531# noinspection PyUnusedLocal,PyTypeChecker 

532@view_config(route_name=Routes.CRASH, 

533 permission=Permission.SUPERUSER, 

534 http_cache=NEVER_CACHE) 

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

536 """ 

537 A view that deliberately raises an exception. 

538 """ 

539 _ = req.gettext 

540 raise RuntimeError(_( 

541 "Deliberately crashed. Should not affect other processes.")) 

542 

543 

544# noinspection PyUnusedLocal 

545@view_config(route_name=Routes.DEVELOPER, 

546 permission=Permission.SUPERUSER, 

547 renderer="developer.mako", 

548 http_cache=NEVER_CACHE) 

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

550 """ 

551 Shows the developer menu. 

552 """ 

553 return {} 

554 

555 

556# noinspection PyUnusedLocal 

557@view_config(route_name=Routes.AUDIT_MENU, 

558 permission=Permission.SUPERUSER, 

559 renderer="audit_menu.mako", 

560 http_cache=NEVER_CACHE) 

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

562 """ 

563 Shows the auditing menu. 

564 """ 

565 return {} 

566 

567 

568# ============================================================================= 

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

570# ============================================================================= 

571 

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

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

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

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

576 

577@view_config(route_name=Routes.LOGIN, 

578 permission=NO_PERMISSION_REQUIRED, 

579 http_cache=NEVER_CACHE) 

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

581 """ 

582 Login view. 

583 

584 - GET: presents the login screen 

585 - POST/submit: attempts to log in; 

586 

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

588 - success: 

589 

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

591 - redirects to the home view if not. 

592 """ 

593 cfg = req.config 

594 autocomplete_password = not cfg.disable_password_autocomplete 

595 

596 form = LoginForm(request=req, autocomplete_password=autocomplete_password) 

597 

598 if FormAction.SUBMIT in req.POST: 

599 try: 

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

601 appstruct = form.validate(controls) 

602 log.debug("Validating user login.") 

603 ccsession = req.camcops_session 

604 username = appstruct.get(ViewParam.USERNAME) 

605 password = appstruct.get(ViewParam.PASSWORD) 

606 redirect_url = appstruct.get(ViewParam.REDIRECT_URL) 

607 # 1. If we don't have a username, let's stop quickly. 

608 if not username: 

609 ccsession.logout() 

610 return login_failed(req) 

611 # 2. Is the user locked? 

612 locked_out_until = SecurityAccountLockout.user_locked_out_until( 

613 req, username) 

614 if locked_out_until is not None: 

615 return account_locked(req, locked_out_until) 

616 # 3. Is the username/password combination correct? 

617 user = User.get_user_from_username_password( 

618 req, username, password) # checks password 

619 if user is not None and user.may_use_webviewer: 

620 # Successful login. 

621 user.login(req) # will clear login failure record 

622 ccsession.login(user) 

623 audit(req, "Login", user_id=user.id) 

624 elif user is not None: 

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

626 # log in via the web front end. 

627 return login_failed(req) 

628 else: 

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

630 SecurityLoginFailure.act_on_login_failure(req, username) 

631 # ... may lock the account 

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

633 # will wipe the session IP address: 

634 ccsession.logout() 

635 return login_failed(req) 

636 

637 # OK, logged in. 

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

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

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

641 

642 if redirect_url: 

643 # log.debug("Redirecting to {!r}", redirect_url) 

644 return HTTPFound(redirect_url) # redirect 

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

646 

647 except ValidationFailure as e: 

648 rendered_form = e.render() 

649 

650 else: 

651 redirect_url = req.get_redirect_url_param(ViewParam.REDIRECT_URL, "") 

652 # ... use default of "", because None gets serialized to "None", which 

653 # would then get read back later as "None". 

654 appstruct = {ViewParam.REDIRECT_URL: redirect_url} 

655 # log.debug("appstruct from GET/POST: {!r}", appstruct) 

656 rendered_form = form.render(appstruct) 

657 

658 return render_to_response( 

659 "login.mako", 

660 dict(form=rendered_form, 

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

662 request=req 

663 ) 

664 

665 

666def login_failed(req: "CamcopsRequest") -> Response: 

667 """ 

668 Response given after login failure. 

669 Returned by :func:`login_view` only. 

670 """ 

671 return render_to_response( 

672 "login_failed.mako", 

673 dict(), 

674 request=req 

675 ) 

676 

677 

678def account_locked(req: "CamcopsRequest", locked_until: Pendulum) -> Response: 

679 """ 

680 Response given when account locked out. 

681 Returned by :func:`login_view` only. 

682 """ 

683 _ = req.gettext 

684 return render_to_response( 

685 "account_locked.mako", 

686 dict( 

687 locked_until=format_datetime(locked_until, 

688 DateFormat.LONG_DATETIME_WITH_DAY, 

689 _("(never)")), 

690 msg="", 

691 extra_html="", 

692 ), 

693 request=req 

694 ) 

695 

696 

697@view_config(route_name=Routes.LOGOUT, 

698 renderer="logged_out.mako", 

699 http_cache=NEVER_CACHE) 

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

701 """ 

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

703 """ 

704 audit(req, "Logout") 

705 ccsession = req.camcops_session 

706 ccsession.logout() 

707 return dict() 

708 

709 

710@view_config(route_name=Routes.OFFER_TERMS, 

711 permission=Authenticated, 

712 renderer="offer_terms.mako", 

713 http_cache=NEVER_CACHE) 

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

715 """ 

716 - GET: show terms/conditions and request acknowledgement 

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

718 """ 

719 form = OfferTermsForm( 

720 request=req, 

721 agree_button_text=req.wsstring(SS.DISCLAIMER_AGREE)) 

722 

723 if FormAction.SUBMIT in req.POST: 

724 req.user.agree_terms(req) 

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

726 

727 return render_to_response( 

728 "offer_terms.mako", 

729 dict( 

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

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

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

733 form=form.render(), 

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

735 ), 

736 request=req 

737 ) 

738 

739 

740@forbidden_view_config(http_cache=NEVER_CACHE) 

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

742 """ 

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

744 

745 We will offer one of these: 

746 

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

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

749 - Otherwise: a generic "forbidden" view. 

750 """ 

751 # I was doing this: 

752 if req.has_permission(Authenticated): 

753 user = req.user 

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

755 if user.must_change_password: 

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

757 if user.must_agree_terms: 

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

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

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

761 # can't raise exceptions from exceptions: 

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

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

764 

765 redirect_url = req.url 

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

767 # destination once logged in: 

768 querydict = {ViewParam.REDIRECT_URL: redirect_url} 

769 return render_to_response("forbidden.mako", 

770 dict(querydict=querydict), 

771 request=req) 

772 

773 

774# ============================================================================= 

775# Changing passwords 

776# ============================================================================= 

777 

778@view_config(route_name=Routes.CHANGE_OWN_PASSWORD, 

779 permission=Authenticated, 

780 http_cache=NEVER_CACHE) 

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

782 """ 

783 For any user: to change their own password. 

784 

785 - GET: offer "change own password" view 

786 - POST/submit: change the password and return :func:`password_changed`. 

787 """ 

788 user = req.user 

789 assert user is not None 

790 expired = user.must_change_password 

791 form = ChangeOwnPasswordForm(request=req, must_differ=True) 

792 if FormAction.SUBMIT in req.POST: 

793 try: 

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

795 appstruct = form.validate(controls) 

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

797 # Change the password 

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

799 new_password = appstruct.get(ViewParam.NEW_PASSWORD) 

800 # ... form will validate old password, etc. 

801 # OK 

802 user.set_password(req, new_password) 

803 return password_changed(req, user.username, own_password=True) 

804 except ValidationFailure as e: 

805 rendered_form = e.render() 

806 else: 

807 rendered_form = form.render() 

808 return render_to_response( 

809 "change_own_password.mako", 

810 dict(form=rendered_form, 

811 expired=expired, 

812 min_pw_length=MINIMUM_PASSWORD_LENGTH, 

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

814 request=req) 

815 

816 

817@view_config(route_name=Routes.CHANGE_OTHER_PASSWORD, 

818 permission=Permission.GROUPADMIN, 

819 renderer="change_other_password.mako", 

820 http_cache=NEVER_CACHE) 

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

822 """ 

823 For administrators, to change another's password. 

824 

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

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

827 - POST/submit: change the password and return :func:`password_changed`. 

828 """ 

829 form = ChangeOtherPasswordForm(request=req) 

830 username = None # for type checker 

831 _ = req.gettext 

832 if FormAction.SUBMIT in req.POST: 

833 try: 

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

835 appstruct = form.validate(controls) 

836 # ----------------------------------------------------------------- 

837 # Change the password 

838 # ----------------------------------------------------------------- 

839 user_id = appstruct.get(ViewParam.USER_ID) 

840 must_change_pw = appstruct.get(ViewParam.MUST_CHANGE_PASSWORD) 

841 new_password = appstruct.get(ViewParam.NEW_PASSWORD) 

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

843 if not user: 

844 raise HTTPBadRequest(f"{_('Missing user for id')} {user_id}") 

845 assert_may_edit_user(req, user) 

846 user.set_password(req, new_password) 

847 if must_change_pw: 

848 user.force_password_change() 

849 return password_changed(req, user.username, own_password=False) 

850 except ValidationFailure as e: 

851 rendered_form = e.render() 

852 else: 

853 user_id = req.get_int_param(ViewParam.USER_ID) 

854 if user_id is None: 

855 raise HTTPBadRequest(f"{_('Improper user_id of')} {user_id!r}") 

856 if user_id == req.user_id: 

857 raise HTTPFound(req.route_url(Routes.CHANGE_OWN_PASSWORD)) 

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

859 if user is None: 

860 raise HTTPBadRequest(f"{_('Missing user for id')} {user_id}") 

861 assert_may_edit_user(req, user) 

862 username = user.username 

863 appstruct = {ViewParam.USER_ID: user_id} 

864 rendered_form = form.render(appstruct) 

865 return render_to_response( 

866 "change_other_password.mako", 

867 dict(username=username, 

868 form=rendered_form, 

869 min_pw_length=MINIMUM_PASSWORD_LENGTH, 

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

871 request=req) 

872 

873 

874def password_changed(req: "CamcopsRequest", 

875 username: str, 

876 own_password: bool) -> Response: 

877 """ 

878 Generic "the password has been changed" view (whether changing your own 

879 or another's password). 

880 

881 Args: 

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

883 username: the username whose password is being changed? 

884 own_password: is the user changing their own password? 

885 """ 

886 return render_to_response("password_changed.mako", 

887 dict(username=username, 

888 own_password=own_password), 

889 request=req) 

890 

891 

892# ============================================================================= 

893# Main menu; simple information things 

894# ============================================================================= 

895 

896@view_config(route_name=Routes.HOME, 

897 renderer="main_menu.mako", 

898 http_cache=NEVER_CACHE) 

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

900 """ 

901 Main CamCOPS menu view. 

902 """ 

903 # log.debug("main_menu: start") 

904 user = req.user 

905 # log.debug("main_menu: middle") 

906 result = dict( 

907 authorized_as_groupadmin=user.authorized_as_groupadmin, 

908 authorized_as_superuser=user.superuser, 

909 authorized_for_reports=user.authorized_for_reports, 

910 authorized_to_dump=user.authorized_to_dump, 

911 camcops_url=CAMCOPS_URL, 

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

913 server_version=CAMCOPS_SERVER_VERSION, 

914 ) 

915 # log.debug("main_menu: returning") 

916 return result 

917 

918 

919# ============================================================================= 

920# Tasks 

921# ============================================================================= 

922 

923def edit_filter(req: "CamcopsRequest", 

924 task_filter: TaskFilter, 

925 redirect_url: str) -> Response: 

926 """ 

927 Edit the task filter for the current user. 

928 

929 Args: 

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

931 task_filter: the user's 

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

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

934 """ 

935 if FormAction.SET_FILTERS in req.POST: 

936 form = EditTaskFilterForm(request=req) 

937 try: 

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

939 fa = form.validate(controls) 

940 # ----------------------------------------------------------------- 

941 # Apply the changes 

942 # ----------------------------------------------------------------- 

943 who = fa.get(ViewParam.WHO) 

944 what = fa.get(ViewParam.WHAT) 

945 when = fa.get(ViewParam.WHEN) 

946 admin = fa.get(ViewParam.ADMIN) 

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

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

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

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

951 task_filter.idnum_criteria = [ 

952 IdNumReference(which_idnum=x[ViewParam.WHICH_IDNUM], 

953 idnum_value=x[ViewParam.IDNUM_VALUE]) 

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

955 ] 

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

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

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

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

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

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

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

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

964 

965 return HTTPFound(redirect_url) 

966 except ValidationFailure as e: 

967 rendered_form = e.render() 

968 else: 

969 if FormAction.CLEAR_FILTERS in req.POST: 

970 # skip validation 

971 task_filter.clear() 

972 who = { 

973 ViewParam.SURNAME: task_filter.surname, 

974 ViewParam.FORENAME: task_filter.forename, 

975 ViewParam.DOB: task_filter.dob, 

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

977 ViewParam.ID_REFERENCES: [ 

978 {ViewParam.WHICH_IDNUM: x.which_idnum, 

979 ViewParam.IDNUM_VALUE: x.idnum_value} 

980 for x in task_filter.idnum_criteria 

981 ], 

982 } 

983 what = { 

984 ViewParam.TASKS: task_filter.task_types, 

985 ViewParam.TEXT_CONTENTS: task_filter.text_contents, 

986 ViewParam.COMPLETE_ONLY: task_filter.complete_only, 

987 } 

988 when = { 

989 ViewParam.START_DATETIME: task_filter.start_datetime, 

990 ViewParam.END_DATETIME: task_filter.end_datetime, 

991 } 

992 admin = { 

993 ViewParam.DEVICE_IDS: task_filter.device_ids, 

994 ViewParam.USER_IDS: task_filter.adding_user_ids, 

995 ViewParam.GROUP_IDS: task_filter.group_ids, 

996 } 

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

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

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

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

1001 fa = { 

1002 ViewParam.WHO: who, 

1003 ViewParam.WHAT: what, 

1004 ViewParam.WHEN: when, 

1005 ViewParam.ADMIN: admin, 

1006 } 

1007 form = EditTaskFilterForm(request=req, 

1008 open_admin=open_admin, 

1009 open_what=open_what, 

1010 open_when=open_when, 

1011 open_who=open_who) 

1012 rendered_form = form.render(fa) 

1013 

1014 return render_to_response( 

1015 "filter_edit.mako", 

1016 dict( 

1017 form=rendered_form, 

1018 head_form_html=get_head_form_html(req, [form]) 

1019 ), 

1020 request=req 

1021 ) 

1022 

1023 

1024@view_config(route_name=Routes.SET_FILTERS, 

1025 http_cache=NEVER_CACHE) 

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

1027 """ 

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

1029 """ 

1030 redirect_url = req.get_redirect_url_param(ViewParam.REDIRECT_URL, 

1031 req.route_url(Routes.VIEW_TASKS)) 

1032 task_filter = req.camcops_session.get_task_filter() 

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

1034 

1035 

1036@view_config(route_name=Routes.VIEW_TASKS, 

1037 renderer="view_tasks.mako", 

1038 http_cache=NEVER_CACHE) 

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

1040 """ 

1041 Main view displaying tasks and applicable filters. 

1042 """ 

1043 ccsession = req.camcops_session 

1044 user = req.user 

1045 taskfilter = ccsession.get_task_filter() 

1046 

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

1048 # will be re-read). 

1049 rows_per_page = req.get_int_param( 

1050 ViewParam.ROWS_PER_PAGE, 

1051 ccsession.number_to_view or DEFAULT_ROWS_PER_PAGE) 

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

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

1054 

1055 errors = False 

1056 

1057 # "Number of tasks per page" form 

1058 tpp_form = TasksPerPageForm(request=req) 

1059 if FormAction.SUBMIT_TASKS_PER_PAGE in req.POST: 

1060 try: 

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

1062 tpp_appstruct = tpp_form.validate(controls) 

1063 rows_per_page = tpp_appstruct.get(ViewParam.ROWS_PER_PAGE) 

1064 ccsession.number_to_view = rows_per_page 

1065 except ValidationFailure: 

1066 errors = True 

1067 rendered_tpp_form = tpp_form.render() 

1068 else: 

1069 tpp_appstruct = {ViewParam.ROWS_PER_PAGE: rows_per_page} 

1070 rendered_tpp_form = tpp_form.render(tpp_appstruct) 

1071 

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

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

1074 # slightly. 

1075 refresh_form = RefreshTasksForm(request=req) 

1076 rendered_refresh_form = refresh_form.render() 

1077 

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

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

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

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

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

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

1084 if errors: 

1085 collection = [] 

1086 else: 

1087 collection = TaskCollection( 

1088 req=req, 

1089 taskfilter=taskfilter, 

1090 sort_method_global=TaskSortMethod.CREATION_DATE_DESC, 

1091 via_index=via_index 

1092 ).all_tasks_or_indexes_or_query or [] 

1093 paginator = SqlalchemyOrmPage if isinstance(collection, Query) else CamcopsPage # noqa 

1094 page = paginator(collection, 

1095 page=page_num, 

1096 items_per_page=rows_per_page, 

1097 url_maker=PageUrl(req), 

1098 request=req) 

1099 return dict( 

1100 page=page, 

1101 head_form_html=get_head_form_html(req, [tpp_form, 

1102 refresh_form]), 

1103 tpp_form=rendered_tpp_form, 

1104 refresh_form=rendered_refresh_form, 

1105 no_patient_selected_and_user_restricted=( 

1106 not user.may_view_all_patients_when_unfiltered and 

1107 not taskfilter.any_specific_patient_filtering() 

1108 ), 

1109 user=user, 

1110 ) 

1111 

1112 

1113@view_config(route_name=Routes.TASK, 

1114 http_cache=NEVER_CACHE) 

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

1116 """ 

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

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

1119 """ 

1120 _ = req.gettext 

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

1122 tablename = req.get_str_param(ViewParam.TABLE_NAME, 

1123 validator=validate_task_tablename) 

1124 server_pk = req.get_int_param(ViewParam.SERVER_PK) 

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

1126 

1127 task = task_factory(req, tablename, server_pk) 

1128 

1129 if task is None: 

1130 return HTTPNotFound( 

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

1132 f"tablename={tablename!r}, server_pk={server_pk!r}") 

1133 

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

1135 

1136 if viewtype == ViewArg.HTML: 

1137 return Response( 

1138 task.get_html(req=req, anonymise=anonymise) 

1139 ) 

1140 elif viewtype == ViewArg.PDF: 

1141 return PdfResponse( 

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

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

1144 ) 

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

1146 return Response( 

1147 task.get_pdf_html(req, anonymise=anonymise) 

1148 ) 

1149 elif viewtype == ViewArg.XML: 

1150 options = TaskExportOptions( 

1151 xml_include_ancillary=True, 

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

1153 xml_include_comments=req.get_bool_param( 

1154 ViewParam.INCLUDE_COMMENTS, True), 

1155 xml_include_calculated=req.get_bool_param( 

1156 ViewParam.INCLUDE_CALCULATED, True), 

1157 xml_include_patient=req.get_bool_param( 

1158 ViewParam.INCLUDE_PATIENT, True), 

1159 xml_include_plain_columns=True, 

1160 xml_include_snomed=req.get_bool_param( 

1161 ViewParam.INCLUDE_SNOMED, True), 

1162 xml_with_header_comments=True, 

1163 ) 

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

1165 else: 

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

1167 raise HTTPBadRequest( 

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

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

1170 

1171 

1172# ============================================================================= 

1173# Trackers, CTVs 

1174# ============================================================================= 

1175 

1176def choose_tracker_or_ctv(req: "CamcopsRequest", 

1177 as_ctv: bool) -> Dict[str, Any]: 

1178 """ 

1179 Returns a dictionary for a Mako template to configure a 

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

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

1182 

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

1184 tracker's parameters embedded as URL parameters. 

1185 

1186 Args: 

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

1188 as_ctv: CTV, rather than tracker? 

1189 """ 

1190 

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

1192 

1193 if FormAction.SUBMIT in req.POST: 

1194 try: 

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

1196 appstruct = form.validate(controls) 

1197 keys = [ 

1198 ViewParam.WHICH_IDNUM, 

1199 ViewParam.IDNUM_VALUE, 

1200 ViewParam.START_DATETIME, 

1201 ViewParam.END_DATETIME, 

1202 ViewParam.TASKS, 

1203 ViewParam.ALL_TASKS, 

1204 ViewParam.VIA_INDEX, 

1205 ViewParam.VIEWTYPE, 

1206 ] 

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

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

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

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

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

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

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

1214 raise HTTPFound(req.route_url( 

1215 Routes.CTV if as_ctv else Routes.TRACKER, 

1216 _query=querydict)) 

1217 except ValidationFailure as e: 

1218 rendered_form = e.render() 

1219 else: 

1220 rendered_form = form.render() 

1221 return dict(form=rendered_form, 

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

1223 

1224 

1225@view_config(route_name=Routes.CHOOSE_TRACKER, 

1226 renderer="choose_tracker.mako", 

1227 http_cache=NEVER_CACHE) 

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

1229 """ 

1230 View to choose/configure a 

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

1232 """ 

1233 return choose_tracker_or_ctv(req, as_ctv=False) 

1234 

1235 

1236@view_config(route_name=Routes.CHOOSE_CTV, 

1237 renderer="choose_ctv.mako", 

1238 http_cache=NEVER_CACHE) 

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

1240 """ 

1241 View to choose/configure a 

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

1243 """ 

1244 return choose_tracker_or_ctv(req, as_ctv=True) 

1245 

1246 

1247def serve_tracker_or_ctv(req: "CamcopsRequest", 

1248 as_ctv: bool) -> Response: 

1249 """ 

1250 Returns a response to show a 

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

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

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

1254 

1255 Args: 

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

1257 as_ctv: CTV, rather than tracker? 

1258 """ 

1259 as_tracker = not as_ctv 

1260 _ = req.gettext 

1261 which_idnum = req.get_int_param(ViewParam.WHICH_IDNUM) 

1262 idnum_value = req.get_int_param(ViewParam.IDNUM_VALUE) 

1263 start_datetime = req.get_datetime_param(ViewParam.START_DATETIME) 

1264 end_datetime = req.get_datetime_param(ViewParam.END_DATETIME) 

1265 tasks = req.get_str_list_param(ViewParam.TASKS, 

1266 validator=validate_task_tablename) 

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

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

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

1270 

1271 if all_tasks: 

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

1273 else: 

1274 try: 

1275 task_classes = task_classes_from_table_names( 

1276 tasks, sortmethod=TaskClassSortMethod.SHORTNAME) 

1277 except KeyError: 

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

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

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

1281 

1282 iddefs = [IdNumReference(which_idnum, idnum_value)] 

1283 

1284 taskfilter = TaskFilter() 

1285 taskfilter.task_types = [tc.__tablename__ for tc in task_classes] # a bit silly... # noqa 

1286 taskfilter.idnum_criteria = iddefs 

1287 taskfilter.start_datetime = start_datetime 

1288 taskfilter.end_datetime = end_datetime 

1289 taskfilter.complete_only = True # trackers require complete tasks 

1290 taskfilter.set_sort_method(TaskClassSortMethod.SHORTNAME) 

1291 taskfilter.tasks_offering_trackers_only = as_tracker 

1292 taskfilter.tasks_with_patient_only = True 

1293 

1294 tracker_ctv_class = ClinicalTextView if as_ctv else Tracker 

1295 tracker = tracker_ctv_class(req=req, taskfilter=taskfilter, 

1296 via_index=via_index) 

1297 

1298 if viewtype == ViewArg.HTML: 

1299 return Response( 

1300 tracker.get_html() 

1301 ) 

1302 elif viewtype == ViewArg.PDF: 

1303 return PdfResponse( 

1304 body=tracker.get_pdf(), 

1305 filename=tracker.suggested_pdf_filename() 

1306 ) 

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

1308 return Response( 

1309 tracker.get_pdf_html() 

1310 ) 

1311 elif viewtype == ViewArg.XML: 

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

1313 return XmlResponse( 

1314 tracker.get_xml(include_comments=include_comments) 

1315 ) 

1316 else: 

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

1318 raise HTTPBadRequest( 

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

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

1321 

1322 

1323@view_config(route_name=Routes.TRACKER, 

1324 http_cache=NEVER_CACHE) 

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

1326 """ 

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

1328 :func:`serve_tracker_or_ctv`. 

1329 """ 

1330 return serve_tracker_or_ctv(req, as_ctv=False) 

1331 

1332 

1333@view_config(route_name=Routes.CTV, 

1334 http_cache=NEVER_CACHE) 

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

1336 """ 

1337 View to serve a 

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

1339 :func:`serve_tracker_or_ctv`. 

1340 """ 

1341 return serve_tracker_or_ctv(req, as_ctv=True) 

1342 

1343 

1344# ============================================================================= 

1345# Reports 

1346# ============================================================================= 

1347 

1348@view_config(route_name=Routes.REPORTS_MENU, 

1349 renderer="reports_menu.mako", 

1350 http_cache=NEVER_CACHE) 

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

1352 """ 

1353 Offer a menu of reports. 

1354 

1355 Note: Reports are not group-specific. 

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

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

1358 to run reports for.) 

1359 """ 

1360 if not req.user.authorized_for_reports: 

1361 raise HTTPBadRequest(errormsg_cannot_report(req)) 

1362 return {} 

1363 

1364 

1365@view_config(route_name=Routes.OFFER_REPORT, 

1366 http_cache=NEVER_CACHE) 

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

1368 """ 

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

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

1371 """ 

1372 if not req.user.authorized_for_reports: 

1373 raise HTTPBadRequest(errormsg_cannot_report(req)) 

1374 report_id = req.get_str_param(ViewParam.REPORT_ID) 

1375 report = get_report_instance(report_id) 

1376 _ = req.gettext 

1377 if not report: 

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

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

1380 raise HTTPBadRequest( 

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

1382 form = report.get_form(req) 

1383 if FormAction.SUBMIT in req.POST: 

1384 try: 

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

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

1387 keys = report.get_http_query_keys() 

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

1389 querydict[ViewParam.REPORT_ID] = report_id 

1390 querydict[ViewParam.PAGE] = 1 

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

1392 # navigation whilst maintaining any report-specific parameters. 

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

1394 except ValidationFailure as e: 

1395 rendered_form = e.render() 

1396 else: 

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

1398 return render_to_response( 

1399 "report_offer.mako", 

1400 dict( 

1401 report=report, 

1402 form=rendered_form, 

1403 head_form_html=get_head_form_html(req, [form]) 

1404 ), 

1405 request=req 

1406 ) 

1407 

1408 

1409@view_config(route_name=Routes.REPORT, 

1410 http_cache=NEVER_CACHE) 

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

1412 """ 

1413 Serve a configured report. 

1414 """ 

1415 if not req.user.authorized_for_reports: 

1416 raise HTTPBadRequest(errormsg_cannot_report(req)) 

1417 report_id = req.get_str_param(ViewParam.REPORT_ID) 

1418 report = get_report_instance(report_id) 

1419 _ = req.gettext 

1420 if not report: 

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

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

1423 raise HTTPBadRequest( 

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

1425 

1426 return report.get_response(req) 

1427 

1428 

1429# ============================================================================= 

1430# Research downloads 

1431# ============================================================================= 

1432 

1433@view_config(route_name=Routes.OFFER_BASIC_DUMP, 

1434 http_cache=NEVER_CACHE) 

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

1436 """ 

1437 View to configure a basic research dump. 

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

1439 dump. 

1440 """ 

1441 if not req.user.authorized_to_dump: 

1442 raise HTTPBadRequest(errormsg_cannot_dump(req)) 

1443 form = OfferBasicDumpForm(request=req) 

1444 if FormAction.SUBMIT in req.POST: 

1445 try: 

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

1447 appstruct = form.validate(controls) 

1448 manual = appstruct.get(ViewParam.MANUAL) 

1449 querydict = { 

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

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

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

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

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

1455 ViewParam.DELIVERY_MODE: appstruct.get(ViewParam.DELIVERY_MODE), 

1456 ViewParam.INCLUDE_INFORMATION_SCHEMA_COLUMNS: appstruct.get( 

1457 ViewParam.INCLUDE_INFORMATION_SCHEMA_COLUMNS), 

1458 } 

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

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

1461 return HTTPFound(req.route_url(Routes.BASIC_DUMP, 

1462 _query=querydict)) 

1463 except ValidationFailure as e: 

1464 rendered_form = e.render() 

1465 else: 

1466 rendered_form = form.render() 

1467 return render_to_response( 

1468 "dump_basic_offer.mako", 

1469 dict(form=rendered_form, 

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

1471 request=req 

1472 ) 

1473 

1474 

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

1476 """ 

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

1478 Raises an error if the request is bad. 

1479 """ 

1480 if not req.user.authorized_to_dump: 

1481 raise HTTPBadRequest(errormsg_cannot_dump(req)) 

1482 # ------------------------------------------------------------------------- 

1483 # Get parameters 

1484 # ------------------------------------------------------------------------- 

1485 dump_method = req.get_str_param(ViewParam.DUMP_METHOD) 

1486 group_ids = req.get_int_list_param(ViewParam.GROUP_IDS) 

1487 task_names = req.get_str_list_param(ViewParam.TASKS, 

1488 validator=validate_task_tablename) 

1489 

1490 # ------------------------------------------------------------------------- 

1491 # Select tasks 

1492 # ------------------------------------------------------------------------- 

1493 if dump_method == ViewArg.EVERYTHING: 

1494 taskfilter = TaskFilter() 

1495 elif dump_method == ViewArg.USE_SESSION_FILTER: 

1496 taskfilter = req.camcops_session.get_task_filter() 

1497 elif dump_method == ViewArg.SPECIFIC_TASKS_GROUPS: 

1498 taskfilter = TaskFilter() 

1499 taskfilter.task_types = task_names 

1500 taskfilter.group_ids = group_ids 

1501 else: 

1502 _ = req.gettext 

1503 raise HTTPBadRequest(f"{_('Bad parameter:')} " 

1504 f"{ViewParam.DUMP_METHOD}={dump_method!r}") 

1505 return TaskCollection( 

1506 req=req, 

1507 taskfilter=taskfilter, 

1508 as_dump=True, 

1509 sort_method_by_class=TaskSortMethod.CREATION_DATE_ASC 

1510 ) 

1511 

1512 

1513@view_config(route_name=Routes.BASIC_DUMP, 

1514 http_cache=NEVER_CACHE) 

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

1516 """ 

1517 View serving a TSV/ZIP basic research dump. 

1518 """ 

1519 # Get view-specific parameters 

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

1521 viewtype = req.get_str_param( 

1522 ViewParam.VIEWTYPE, ViewArg.XLSX, lower=True) 

1523 delivery_mode = req.get_str_param( 

1524 ViewParam.DELIVERY_MODE, ViewArg.EMAIL, lower=True) 

1525 include_information_schema_columns = req.get_bool_param( 

1526 ViewParam.INCLUDE_INFORMATION_SCHEMA_COLUMNS, False) 

1527 

1528 # Get tasks (and perform checks) 

1529 collection = get_dump_collection(req) 

1530 # Create object that knows how to export 

1531 exporter = make_exporter( 

1532 req=req, 

1533 collection=collection, 

1534 options=DownloadOptions( 

1535 user_id=req.user_id, 

1536 viewtype=viewtype, 

1537 delivery_mode=delivery_mode, 

1538 spreadsheet_sort_by_heading=sort_by_heading, 

1539 include_information_schema_columns=include_information_schema_columns # noqa 

1540 ) 

1541 ) # may raise 

1542 # Export, or schedule an email/download 

1543 return exporter.immediate_response(req) 

1544 

1545 

1546@view_config(route_name=Routes.OFFER_SQL_DUMP, 

1547 http_cache=NEVER_CACHE) 

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

1549 """ 

1550 View to configure a SQL research dump. 

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

1552 """ 

1553 if not req.user.authorized_to_dump: 

1554 raise HTTPBadRequest(errormsg_cannot_dump(req)) 

1555 form = OfferSqlDumpForm(request=req) 

1556 if FormAction.SUBMIT in req.POST: 

1557 try: 

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

1559 appstruct = form.validate(controls) 

1560 manual = appstruct.get(ViewParam.MANUAL) 

1561 querydict = { 

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

1563 ViewParam.SQLITE_METHOD: appstruct.get(ViewParam.SQLITE_METHOD), # noqa 

1564 ViewParam.INCLUDE_BLOBS: appstruct.get(ViewParam.INCLUDE_BLOBS), # noqa 

1565 ViewParam.PATIENT_ID_PER_ROW: appstruct.get(ViewParam.PATIENT_ID_PER_ROW), # noqa 

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

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

1568 ViewParam.DELIVERY_MODE: appstruct.get(ViewParam.DELIVERY_MODE), 

1569 ViewParam.INCLUDE_INFORMATION_SCHEMA_COLUMNS: appstruct.get( 

1570 ViewParam.INCLUDE_INFORMATION_SCHEMA_COLUMNS), 

1571 } 

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

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

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

1575 except ValidationFailure as e: 

1576 rendered_form = e.render() 

1577 else: 

1578 rendered_form = form.render() 

1579 return render_to_response( 

1580 "dump_sql_offer.mako", 

1581 dict(form=rendered_form, 

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

1583 request=req 

1584 ) 

1585 

1586 

1587@view_config(route_name=Routes.SQL_DUMP, 

1588 http_cache=NEVER_CACHE) 

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

1590 """ 

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

1592 """ 

1593 # Get view-specific parameters 

1594 sqlite_method = req.get_str_param(ViewParam.SQLITE_METHOD) 

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

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

1597 delivery_mode = req.get_str_param(ViewParam.DELIVERY_MODE, 

1598 ViewArg.EMAIL, lower=True) 

1599 include_information_schema_columns = req.get_bool_param( 

1600 ViewParam.INCLUDE_INFORMATION_SCHEMA_COLUMNS, False) 

1601 

1602 # Get tasks (and perform checks) 

1603 collection = get_dump_collection(req) 

1604 # Create object that knows how to export 

1605 exporter = make_exporter( 

1606 req=req, 

1607 collection=collection, 

1608 options=DownloadOptions( 

1609 user_id=req.user_id, 

1610 viewtype=sqlite_method, 

1611 delivery_mode=delivery_mode, 

1612 db_include_blobs=include_blobs, 

1613 db_patient_id_per_row=patient_id_per_row, 

1614 include_information_schema_columns=include_information_schema_columns # noqa 

1615 ) 

1616 ) # may raise 

1617 # Export, or schedule an email/download 

1618 return exporter.immediate_response(req) 

1619 

1620 

1621# noinspection PyUnusedLocal 

1622@view_config(route_name=Routes.DOWNLOAD_AREA, 

1623 renderer="download_area.mako", 

1624 http_cache=NEVER_CACHE) 

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

1626 """ 

1627 Shows the user download area. 

1628 """ 

1629 userdir = req.user_download_dir 

1630 if userdir: 

1631 files = UserDownloadFile.from_directory_scan( 

1632 directory=userdir, 

1633 permitted_lifespan_min=req.config.user_download_file_lifetime_min, 

1634 req=req) 

1635 else: 

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

1637 return dict( 

1638 files=files, 

1639 available=bytes2human(req.user_download_bytes_available), 

1640 permitted=bytes2human(req.user_download_bytes_permitted), 

1641 used=bytes2human(req.user_download_bytes_used), 

1642 lifetime_min=req.config.user_download_file_lifetime_min, 

1643 ) 

1644 

1645 

1646@view_config(route_name=Routes.DOWNLOAD_FILE, 

1647 http_cache=NEVER_CACHE) 

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

1649 """ 

1650 Downloads a file. 

1651 """ 

1652 _ = req.gettext 

1653 filename = req.get_str_param(ViewParam.FILENAME, "") 

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

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

1656 # We cannot trust the input. 

1657 filename = os.path.basename(filename) 

1658 udf = UserDownloadFile(directory=req.user_download_dir, 

1659 filename=filename) 

1660 if not udf.exists: 

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

1662 try: 

1663 return BinaryResponse( 

1664 body=udf.contents, 

1665 filename=udf.filename, 

1666 content_type=MimeType.BINARY, 

1667 as_inline=False 

1668 ) 

1669 except OSError: 

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

1671 

1672 

1673@view_config(route_name=Routes.DELETE_FILE, 

1674 request_method="POST", 

1675 http_cache=NEVER_CACHE) 

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

1677 """ 

1678 Deletes a file. 

1679 """ 

1680 form = UserDownloadDeleteForm(request=req) 

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

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

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

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

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

1686 # We cannot trust the input. 

1687 filename = os.path.basename(filename) 

1688 udf = UserDownloadFile(directory=req.user_download_dir, 

1689 filename=filename) 

1690 if not udf.exists: 

1691 _ = req.gettext 

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

1693 udf.delete() 

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

1695 

1696 

1697# ============================================================================= 

1698# View DDL (table definitions) 

1699# ============================================================================= 

1700 

1701LEXERMAP = { 

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

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

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

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

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

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

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

1709} 

1710 

1711 

1712@view_config(route_name=Routes.VIEW_DDL, 

1713 http_cache=NEVER_CACHE) 

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

1715 """ 

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

1717 comments. 

1718 

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

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

1721 it consistent with the menu item for this. 

1722 """ 

1723 if not req.user.authorized_to_dump: 

1724 raise HTTPBadRequest(errormsg_cannot_dump(req)) 

1725 form = ViewDdlForm(request=req) 

1726 if FormAction.SUBMIT in req.POST: 

1727 try: 

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

1729 appstruct = form.validate(controls) 

1730 dialect = appstruct.get(ViewParam.DIALECT) 

1731 ddl = get_all_ddl(dialect_name=dialect) 

1732 lexer = LEXERMAP[dialect]() 

1733 # noinspection PyUnresolvedReferences 

1734 formatter = pygments.formatters.HtmlFormatter() 

1735 html = pygments.highlight(ddl, lexer, formatter) 

1736 css = formatter.get_style_defs('.highlight') 

1737 return render_to_response("introspect_file.mako", 

1738 dict(css=css, 

1739 code_html=html), 

1740 request=req) 

1741 except ValidationFailure as e: 

1742 rendered_form = e.render() 

1743 else: 

1744 rendered_form = form.render() 

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

1746 sql_dialect_choices = get_sql_dialect_choices(req) 

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

1748 current_dialect, "?") 

1749 return render_to_response( 

1750 "view_ddl_choose_dialect.mako", 

1751 dict(current_dialect=current_dialect, 

1752 current_dialect_description=current_dialect_description, 

1753 form=rendered_form, 

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

1755 request=req) 

1756 

1757 

1758# ============================================================================= 

1759# View audit trail 

1760# ============================================================================= 

1761 

1762@view_config(route_name=Routes.OFFER_AUDIT_TRAIL, 

1763 permission=Permission.SUPERUSER, 

1764 http_cache=NEVER_CACHE) 

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

1766 """ 

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

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

1769 the URL). 

1770 """ 

1771 form = AuditTrailForm(request=req) 

1772 if FormAction.SUBMIT in req.POST: 

1773 try: 

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

1775 appstruct = form.validate(controls) 

1776 keys = [ 

1777 ViewParam.ROWS_PER_PAGE, 

1778 ViewParam.START_DATETIME, 

1779 ViewParam.END_DATETIME, 

1780 ViewParam.SOURCE, 

1781 ViewParam.REMOTE_IP_ADDR, 

1782 ViewParam.USERNAME, 

1783 ViewParam.TABLE_NAME, 

1784 ViewParam.SERVER_PK, 

1785 ViewParam.TRUNCATE, 

1786 ] 

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

1788 querydict[ViewParam.PAGE] = 1 

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

1790 # (the parameters are NOT sensitive) 

1791 raise HTTPFound(req.route_url(Routes.VIEW_AUDIT_TRAIL, 

1792 _query=querydict)) 

1793 except ValidationFailure as e: 

1794 rendered_form = e.render() 

1795 else: 

1796 rendered_form = form.render() 

1797 return render_to_response( 

1798 "audit_trail_choices.mako", 

1799 dict(form=rendered_form, 

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

1801 request=req) 

1802 

1803 

1804AUDIT_TRUNCATE_AT = 100 

1805 

1806 

1807@view_config(route_name=Routes.VIEW_AUDIT_TRAIL, 

1808 permission=Permission.SUPERUSER, 

1809 http_cache=NEVER_CACHE) 

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

1811 """ 

1812 View to serve the audit trail. 

1813 """ 

1814 rows_per_page = req.get_int_param(ViewParam.ROWS_PER_PAGE, 

1815 DEFAULT_ROWS_PER_PAGE) 

1816 start_datetime = req.get_datetime_param(ViewParam.START_DATETIME) 

1817 end_datetime = req.get_datetime_param(ViewParam.END_DATETIME) 

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

1819 remote_addr = req.get_str_param(ViewParam.REMOTE_IP_ADDR, None, 

1820 validator=validate_ip_address) 

1821 username = req.get_str_param(ViewParam.USERNAME, None, 

1822 validator=validate_username) 

1823 table_name = req.get_str_param(ViewParam.TABLE_NAME, None, 

1824 validator=validate_task_tablename) 

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

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

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

1828 

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

1830 

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

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

1833 

1834 dbsession = req.dbsession 

1835 q = dbsession.query(AuditEntry) 

1836 if start_datetime: 

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

1838 add_condition(ViewParam.START_DATETIME, start_datetime) 

1839 if end_datetime: 

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

1841 add_condition(ViewParam.END_DATETIME, end_datetime) 

1842 if source: 

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

1844 add_condition(ViewParam.SOURCE, source) 

1845 if remote_addr: 

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

1847 add_condition(ViewParam.REMOTE_IP_ADDR, remote_addr) 

1848 if username: 

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

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

1851 add_condition(ViewParam.USERNAME, username) 

1852 if table_name: 

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

1854 add_condition(ViewParam.TABLE_NAME, table_name) 

1855 if server_pk is not None: 

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

1857 add_condition(ViewParam.SERVER_PK, server_pk) 

1858 

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

1860 

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

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

1863 # audit_entries = q.all() 

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

1865 page = SqlalchemyOrmPage(query=q, 

1866 page=page_num, 

1867 items_per_page=rows_per_page, 

1868 url_maker=PageUrl(req), 

1869 request=req) 

1870 return render_to_response("audit_trail_view.mako", 

1871 dict(conditions="; ".join(conditions), 

1872 page=page, 

1873 truncate=truncate, 

1874 truncate_at=AUDIT_TRUNCATE_AT), 

1875 request=req) 

1876 

1877 

1878# ============================================================================= 

1879# View export logs 

1880# ============================================================================= 

1881# Overview: 

1882# - View exported tasks (ExportedTask) collectively 

1883# ... option to filter by recipient_name 

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

1885# - View exported tasks (ExportedTask) individually 

1886# ... hyperlinks to individual views of: 

1887# Email (not necessary: ExportedTaskEmail) 

1888# ExportRecipient 

1889# ExportedTaskFileGroup 

1890# ExportedTaskHL7Message 

1891 

1892@view_config(route_name=Routes.OFFER_EXPORTED_TASK_LIST, 

1893 permission=Permission.SUPERUSER, 

1894 http_cache=NEVER_CACHE) 

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

1896 """ 

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

1898 """ 

1899 form = ExportedTaskListForm(request=req) 

1900 if FormAction.SUBMIT in req.POST: 

1901 try: 

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

1903 appstruct = form.validate(controls) 

1904 keys = [ 

1905 ViewParam.ROWS_PER_PAGE, 

1906 ViewParam.RECIPIENT_NAME, 

1907 ViewParam.TABLE_NAME, 

1908 ViewParam.SERVER_PK, 

1909 ViewParam.ID, 

1910 ViewParam.START_DATETIME, 

1911 ViewParam.END_DATETIME, 

1912 ] 

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

1914 querydict[ViewParam.PAGE] = 1 

1915 # Send the user to the actual data using GET 

1916 # (the parameters are NOT sensitive) 

1917 return HTTPFound(req.route_url(Routes.VIEW_EXPORTED_TASK_LIST, 

1918 _query=querydict)) 

1919 except ValidationFailure as e: 

1920 rendered_form = e.render() 

1921 else: 

1922 rendered_form = form.render() 

1923 return render_to_response( 

1924 "exported_task_choose.mako", 

1925 dict(form=rendered_form, 

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

1927 request=req) 

1928 

1929 

1930@view_config(route_name=Routes.VIEW_EXPORTED_TASK_LIST, 

1931 permission=Permission.SUPERUSER, 

1932 http_cache=NEVER_CACHE) 

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

1934 """ 

1935 View to serve the exported task log. 

1936 """ 

1937 rows_per_page = req.get_int_param(ViewParam.ROWS_PER_PAGE, 

1938 DEFAULT_ROWS_PER_PAGE) 

1939 recipient_name = req.get_str_param( 

1940 ViewParam.RECIPIENT_NAME, None, 

1941 validator=validate_export_recipient_name) 

1942 table_name = req.get_str_param( 

1943 ViewParam.TABLE_NAME, None, 

1944 validator=validate_task_tablename) 

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

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

1947 start_datetime = req.get_datetime_param(ViewParam.START_DATETIME) 

1948 end_datetime = req.get_datetime_param(ViewParam.END_DATETIME) 

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

1950 

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

1952 

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

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

1955 

1956 dbsession = req.dbsession 

1957 q = dbsession.query(ExportedTask) 

1958 

1959 if recipient_name: 

1960 q = ( 

1961 q.join(ExportRecipient) 

1962 .filter(ExportRecipient.recipient_name == recipient_name) 

1963 ) 

1964 add_condition(ViewParam.RECIPIENT_NAME, recipient_name) 

1965 if table_name: 

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

1967 add_condition(ViewParam.TABLE_NAME, table_name) 

1968 if server_pk is not None: 

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

1970 add_condition(ViewParam.SERVER_PK, server_pk) 

1971 if et_id is not None: 

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

1973 add_condition(ViewParam.ID, et_id) 

1974 if start_datetime: 

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

1976 add_condition(ViewParam.START_DATETIME, start_datetime) 

1977 if end_datetime: 

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

1979 add_condition(ViewParam.END_DATETIME, end_datetime) 

1980 

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

1982 

1983 page = SqlalchemyOrmPage(query=q, 

1984 page=page_num, 

1985 items_per_page=rows_per_page, 

1986 url_maker=PageUrl(req), 

1987 request=req) 

1988 return render_to_response("exported_task_list.mako", 

1989 dict(conditions="; ".join(conditions), 

1990 page=page), 

1991 request=req) 

1992 

1993 

1994# ============================================================================= 

1995# View helpers for ORM objects 

1996# ============================================================================= 

1997 

1998def _view_generic_object_by_id(req: "CamcopsRequest", 

1999 cls: Type, 

2000 instance_name_for_mako: str, 

2001 mako_template: str) -> Response: 

2002 """ 

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

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

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

2006 

2007 Args: 

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

2009 cls: the SQLAlchemy ORM class 

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

2011 mako_template: Mako template filename 

2012 

2013 Returns: 

2014 :class:`pyramid.response.Response` 

2015 """ 

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

2017 dbsession = req.dbsession 

2018 # noinspection PyUnresolvedReferences 

2019 obj = ( 

2020 dbsession.query(cls) 

2021 .filter(cls.id == item_id) 

2022 .first() 

2023 ) 

2024 if obj is None: 

2025 _ = req.gettext 

2026 raise HTTPBadRequest(f"{_('Bad ID for object type')} " 

2027 f"{cls.__name__}: {item_id}") 

2028 d = {instance_name_for_mako: obj} 

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

2030 

2031 

2032# ============================================================================= 

2033# Specialized views for ORM objects 

2034# ============================================================================= 

2035 

2036@view_config(route_name=Routes.VIEW_EMAIL, 

2037 permission=Permission.SUPERUSER, 

2038 http_cache=NEVER_CACHE) 

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

2040 """ 

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

2042 """ 

2043 return _view_generic_object_by_id( 

2044 req=req, 

2045 cls=Email, 

2046 instance_name_for_mako="email", 

2047 mako_template="view_email.mako", 

2048 ) 

2049 

2050 

2051@view_config(route_name=Routes.VIEW_EXPORT_RECIPIENT, 

2052 permission=Permission.SUPERUSER, 

2053 http_cache=NEVER_CACHE) 

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

2055 """ 

2056 View on an individual 

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

2058 """ 

2059 return _view_generic_object_by_id( 

2060 req=req, 

2061 cls=ExportRecipient, 

2062 instance_name_for_mako="recipient", 

2063 mako_template="export_recipient.mako", 

2064 ) 

2065 

2066 

2067@view_config(route_name=Routes.VIEW_EXPORTED_TASK, 

2068 permission=Permission.SUPERUSER, 

2069 http_cache=NEVER_CACHE) 

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

2071 """ 

2072 View on an individual 

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

2074 """ 

2075 return _view_generic_object_by_id( 

2076 req=req, 

2077 cls=ExportedTask, 

2078 instance_name_for_mako="et", 

2079 mako_template="exported_task.mako", 

2080 ) 

2081 

2082 

2083@view_config(route_name=Routes.VIEW_EXPORTED_TASK_EMAIL, 

2084 permission=Permission.SUPERUSER, 

2085 http_cache=NEVER_CACHE) 

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

2087 """ 

2088 View on an individual 

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

2090 """ 

2091 return _view_generic_object_by_id( 

2092 req=req, 

2093 cls=ExportedTaskEmail, 

2094 instance_name_for_mako="ete", 

2095 mako_template="exported_task_email.mako", 

2096 ) 

2097 

2098 

2099@view_config(route_name=Routes.VIEW_EXPORTED_TASK_FILE_GROUP, 

2100 permission=Permission.SUPERUSER, 

2101 http_cache=NEVER_CACHE) 

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

2103 """ 

2104 View on an individual 

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

2106 """ 

2107 return _view_generic_object_by_id( 

2108 req=req, 

2109 cls=ExportedTaskFileGroup, 

2110 instance_name_for_mako="fg", 

2111 mako_template="exported_task_file_group.mako", 

2112 ) 

2113 

2114 

2115@view_config(route_name=Routes.VIEW_EXPORTED_TASK_HL7_MESSAGE, 

2116 permission=Permission.SUPERUSER, 

2117 http_cache=NEVER_CACHE) 

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

2119 """ 

2120 View on an individual 

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

2122 """ 

2123 return _view_generic_object_by_id( 

2124 req=req, 

2125 cls=ExportedTaskHL7Message, 

2126 instance_name_for_mako="msg", 

2127 mako_template="exported_task_hl7_message.mako", 

2128 ) 

2129 

2130 

2131# ============================================================================= 

2132# User/server info views 

2133# ============================================================================= 

2134 

2135@view_config(route_name=Routes.VIEW_OWN_USER_INFO, 

2136 renderer="view_own_user_info.mako", 

2137 http_cache=NEVER_CACHE) 

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

2139 """ 

2140 View to provide information about your own user. 

2141 """ 

2142 groups_page = CamcopsPage(req.user.groups, 

2143 url_maker=PageUrl(req), 

2144 request=req) 

2145 return dict(user=req.user, 

2146 groups_page=groups_page, 

2147 valid_which_idnums=req.valid_which_idnums) 

2148 

2149 

2150@view_config(route_name=Routes.VIEW_SERVER_INFO, 

2151 renderer="view_server_info.mako", 

2152 http_cache=NEVER_CACHE) 

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

2154 """ 

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

2156 """ 

2157 _ = req.gettext 

2158 now = req.now 

2159 recent_activity = OrderedDict([ 

2160 (_("Last 1 minute"), CamcopsSession.n_sessions_active_since( 

2161 req, now.subtract(minutes=1))), 

2162 (_("Last 5 minutes"), CamcopsSession.n_sessions_active_since( 

2163 req, now.subtract(minutes=5))), 

2164 (_("Last 10 minutes"), CamcopsSession.n_sessions_active_since( 

2165 req, now.subtract(minutes=10))), 

2166 (_("Last 1 hour"), CamcopsSession.n_sessions_active_since( 

2167 req, now.subtract(hours=1))), 

2168 ]) 

2169 return dict( 

2170 idnum_definitions=req.idnum_definitions, 

2171 string_families=req.extrastring_families(), 

2172 all_task_classes=Task.all_subclasses_by_longname(req), 

2173 recent_activity=recent_activity, 

2174 session_timeout_minutes=req.config.session_timeout_minutes, 

2175 restricted_tasks=req.config.restricted_tasks, 

2176 ) 

2177 

2178 

2179# ============================================================================= 

2180# User management 

2181# ============================================================================= 

2182 

2183EDIT_USER_KEYS_GROUPADMIN = [ 

2184 # SPECIAL HANDLING # ViewParam.USER_ID, 

2185 ViewParam.USERNAME, 

2186 ViewParam.FULLNAME, 

2187 ViewParam.EMAIL, 

2188 ViewParam.MUST_CHANGE_PASSWORD, 

2189 ViewParam.LANGUAGE, 

2190 # SPECIAL HANDLING # ViewParam.GROUP_IDS, 

2191] 

2192EDIT_USER_KEYS_SUPERUSER = EDIT_USER_KEYS_GROUPADMIN + [ 

2193 ViewParam.SUPERUSER, 

2194] 

2195EDIT_USER_GROUP_MEMBERSHIP_KEYS_GROUPADMIN = [ 

2196 ViewParam.MAY_UPLOAD, 

2197 ViewParam.MAY_REGISTER_DEVICES, 

2198 ViewParam.MAY_USE_WEBVIEWER, 

2199 ViewParam.VIEW_ALL_PATIENTS_WHEN_UNFILTERED, 

2200 ViewParam.MAY_DUMP_DATA, 

2201 ViewParam.MAY_RUN_REPORTS, 

2202 ViewParam.MAY_ADD_NOTES, 

2203] 

2204EDIT_USER_GROUP_MEMBERSHIP_KEYS_SUPERUSER = EDIT_USER_GROUP_MEMBERSHIP_KEYS_GROUPADMIN + [ # noqa 

2205 ViewParam.GROUPADMIN, 

2206] 

2207 

2208 

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

2210 """ 

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

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

2213 :exc:`HTTPBadRequest`. 

2214 """ 

2215 user_id = req.get_int_param(ViewParam.USER_ID) 

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

2217 if not user: 

2218 _ = req.gettext 

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

2220 return user 

2221 

2222 

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

2224 me = req.user 

2225 return me.managed_users() 

2226 

2227 

2228@view_config(route_name=Routes.VIEW_ALL_USERS, 

2229 permission=Permission.GROUPADMIN, 

2230 renderer="users_view.mako", 

2231 http_cache=NEVER_CACHE) 

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

2233 """ 

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

2235 to edit those users too. 

2236 """ 

2237 include_auto_generated = req.get_bool_param( 

2238 ViewParam.INCLUDE_AUTO_GENERATED, False 

2239 ) 

2240 rows_per_page = req.get_int_param(ViewParam.ROWS_PER_PAGE, 

2241 DEFAULT_ROWS_PER_PAGE) 

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

2243 q = query_users_that_i_manage(req) 

2244 if not include_auto_generated: 

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

2246 page = SqlalchemyOrmPage(query=q, 

2247 page=page_num, 

2248 items_per_page=rows_per_page, 

2249 url_maker=PageUrl(req), 

2250 request=req) 

2251 

2252 form = UserFilterForm(request=req) 

2253 appstruct = { 

2254 ViewParam.INCLUDE_AUTO_GENERATED: include_auto_generated, 

2255 } 

2256 rendered_form = form.render(appstruct) 

2257 

2258 return dict( 

2259 page=page, 

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

2261 form=rendered_form 

2262 ) 

2263 

2264 

2265@view_config(route_name=Routes.VIEW_USER_EMAIL_ADDRESSES, 

2266 permission=Permission.GROUPADMIN, 

2267 renderer="view_user_email_addresses.mako", 

2268 http_cache=NEVER_CACHE) 

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

2270 """ 

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

2272 to manage. 

2273 """ 

2274 q = query_users_that_i_manage(req).filter( 

2275 User.auto_generated == False # noqa: E712 

2276 ) 

2277 return dict(query=q) 

2278 

2279 

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

2281 """ 

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

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

2284 """ 

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

2286 if not may_edit: 

2287 raise HTTPBadRequest(why_not) 

2288 

2289 

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

2291 """ 

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

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

2294 otherwise. 

2295 """ 

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

2297 _ = req.gettext 

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

2299 

2300 

2301@view_config(route_name=Routes.VIEW_USER, 

2302 permission=Permission.GROUPADMIN, 

2303 renderer="view_other_user_info.mako", 

2304 http_cache=NEVER_CACHE) 

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

2306 """ 

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

2308 """ 

2309 user = get_user_from_request_user_id_or_raise(req) 

2310 assert_may_edit_user(req, user) 

2311 return dict(user=user) 

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

2313 # here, but can't alter it. 

2314 

2315 

2316@view_config(route_name=Routes.EDIT_USER, 

2317 renderer="user_edit.mako", 

2318 permission=Permission.GROUPADMIN, 

2319 http_cache=NEVER_CACHE) 

2320def edit_user(req: "CamcopsRequest") -> Dict[str, Any]: 

2321 """ 

2322 View to edit a user (for administrators). 

2323 """ 

2324 route_back = Routes.VIEW_ALL_USERS 

2325 if FormAction.CANCEL in req.POST: 

2326 raise HTTPFound(req.route_url(route_back)) 

2327 user = get_user_from_request_user_id_or_raise(req) 

2328 assert_may_edit_user(req, user) 

2329 # Superusers can do everything, of course. 

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

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

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

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

2334 # permissions. 

2335 if req.user.superuser: 

2336 form = EditUserFullForm(request=req) 

2337 keys = EDIT_USER_KEYS_SUPERUSER 

2338 else: 

2339 form = EditUserGroupAdminForm(request=req) 

2340 keys = EDIT_USER_KEYS_GROUPADMIN 

2341 # Groups that we might change memberships for: 

2342 all_fluid_groups = req.user.ids_of_groups_user_is_admin_for 

2343 # All groups that the user is currently in: 

2344 user_group_ids = user.group_ids 

2345 # Group membership we won't touch: 

2346 user_frozen_group_ids = list(set(user_group_ids) - set(all_fluid_groups)) 

2347 # Group memberships we might alter: 

2348 user_fluid_group_ids = list(set(user_group_ids) & set(all_fluid_groups)) 

2349 # log.debug( 

2350 # "all_fluid_groups={}, user_group_ids={}, " 

2351 # "user_frozen_group_ids={}, user_fluid_group_ids={}", 

2352 # all_fluid_groups, user_group_ids, 

2353 # user_frozen_group_ids, user_fluid_group_ids 

2354 # ) 

2355 if FormAction.SUBMIT in req.POST: 

2356 try: 

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

2358 appstruct = form.validate(controls) 

2359 # ----------------------------------------------------------------- 

2360 # Apply the edits 

2361 # ----------------------------------------------------------------- 

2362 dbsession = req.dbsession 

2363 new_user_name = appstruct.get(ViewParam.USERNAME) 

2364 existing_user = User.get_user_by_name(dbsession, new_user_name) 

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

2366 # noinspection PyUnresolvedReferences 

2367 _ = req.gettext 

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

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

2370 raise HTTPBadRequest( 

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

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

2373 for k in keys: 

2374 # What follows assumes that the keys are relevant and valid 

2375 # attributes of a User. 

2376 setattr(user, k, appstruct.get(k)) 

2377 group_ids = appstruct.get(ViewParam.GROUP_IDS) 

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

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

2380 user.set_group_ids(final_group_ids) 

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

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

2383 if user.upload_group_id not in final_group_ids: 

2384 user.upload_group_id = None 

2385 raise HTTPFound(req.route_url(route_back)) 

2386 except ValidationFailure as e: 

2387 rendered_form = e.render() 

2388 else: 

2389 appstruct = {k: getattr(user, k) for k in keys} 

2390 appstruct[ViewParam.USER_ID] = user.id 

2391 appstruct[ViewParam.GROUP_IDS] = user_fluid_group_ids 

2392 rendered_form = form.render(appstruct) 

2393 return dict(user=user, 

2394 form=rendered_form, 

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

2396 

2397 

2398@view_config(route_name=Routes.EDIT_USER_GROUP_MEMBERSHIP, 

2399 renderer="user_edit_group_membership.mako", 

2400 permission=Permission.GROUPADMIN, 

2401 http_cache=NEVER_CACHE) 

2402def edit_user_group_membership(req: "CamcopsRequest") -> Dict[str, Any]: 

2403 """ 

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

2405 """ 

2406 route_back = Routes.VIEW_ALL_USERS 

2407 if FormAction.CANCEL in req.POST: 

2408 raise HTTPFound(req.route_url(route_back)) 

2409 ugm_id = req.get_int_param(ViewParam.USER_GROUP_MEMBERSHIP_ID) 

2410 ugm = UserGroupMembership.get_ugm_by_id(req.dbsession, ugm_id) 

2411 if not ugm: 

2412 _ = req.gettext 

2413 raise HTTPBadRequest( 

2414 f"{_('No such UserGroupMembership ID:')} {ugm_id!r}") 

2415 user = ugm.user 

2416 assert_may_edit_user(req, user) 

2417 assert_may_administer_group(req, ugm.group_id) 

2418 if req.user.superuser: 

2419 form = EditUserGroupPermissionsFullForm(request=req) 

2420 keys = EDIT_USER_GROUP_MEMBERSHIP_KEYS_SUPERUSER 

2421 else: 

2422 form = EditUserGroupMembershipGroupAdminForm(request=req) 

2423 keys = EDIT_USER_GROUP_MEMBERSHIP_KEYS_GROUPADMIN 

2424 if FormAction.SUBMIT in req.POST: 

2425 try: 

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

2427 appstruct = form.validate(controls) 

2428 # ----------------------------------------------------------------- 

2429 # Apply the changes 

2430 # ----------------------------------------------------------------- 

2431 for k in keys: 

2432 setattr(ugm, k, appstruct.get(k)) 

2433 raise HTTPFound(req.route_url(route_back)) 

2434 except ValidationFailure as e: 

2435 rendered_form = e.render() 

2436 else: 

2437 appstruct = {k: getattr(ugm, k) for k in keys} 

2438 rendered_form = form.render(appstruct) 

2439 return dict(ugm=ugm, 

2440 form=rendered_form, 

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

2442 

2443 

2444def set_user_upload_group(req: "CamcopsRequest", 

2445 user: User, 

2446 by_another: bool) -> Response: 

2447 """ 

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

2449 

2450 TRUSTS ITS CALLER that this is permitted. 

2451 

2452 Args: 

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

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

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

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

2457 """ 

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

2459 if FormAction.CANCEL in req.POST: 

2460 return HTTPFound(req.route_url(route_back)) 

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

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

2463 if FormAction.SUBMIT in req.POST: 

2464 try: 

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

2466 appstruct = form.validate(controls) 

2467 # ----------------------------------------------------------------- 

2468 # Apply the changes 

2469 # ----------------------------------------------------------------- 

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

2471 return HTTPFound(req.route_url(route_back)) 

2472 except ValidationFailure as e: 

2473 rendered_form = e.render() 

2474 else: 

2475 appstruct = { 

2476 ViewParam.USER_ID: user.id, 

2477 ViewParam.UPLOAD_GROUP_ID: user.upload_group_id 

2478 } 

2479 rendered_form = form.render(appstruct) 

2480 return render_to_response( 

2481 "set_user_upload_group.mako", 

2482 dict(user=user, 

2483 form=rendered_form, 

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

2485 request=req 

2486 ) 

2487 

2488 

2489@view_config(route_name=Routes.SET_OWN_USER_UPLOAD_GROUP, 

2490 http_cache=NEVER_CACHE) 

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

2492 """ 

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

2494 """ 

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

2496 

2497 

2498@view_config(route_name=Routes.SET_OTHER_USER_UPLOAD_GROUP, 

2499 permission=Permission.GROUPADMIN, 

2500 http_cache=NEVER_CACHE) 

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

2502 """ 

2503 View to set the upload group for another user. 

2504 """ 

2505 user = get_user_from_request_user_id_or_raise(req) 

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

2507 assert_may_edit_user(req, user) 

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

2509 return set_user_upload_group(req, user, True) 

2510 

2511 

2512# noinspection PyTypeChecker 

2513@view_config(route_name=Routes.UNLOCK_USER, 

2514 permission=Permission.GROUPADMIN, 

2515 http_cache=NEVER_CACHE) 

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

2517 """ 

2518 View to unlock a locked user account. 

2519 """ 

2520 user = get_user_from_request_user_id_or_raise(req) 

2521 assert_may_edit_user(req, user) 

2522 user.enable(req) 

2523 _ = req.gettext 

2524 

2525 req.session.flash( 

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

2527 queue=FLASH_SUCCESS 

2528 ) 

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

2530 

2531 

2532@view_config(route_name=Routes.ADD_USER, 

2533 permission=Permission.GROUPADMIN, 

2534 renderer="user_add.mako", 

2535 http_cache=NEVER_CACHE) 

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

2537 """ 

2538 View to add a user. 

2539 """ 

2540 route_back = Routes.VIEW_ALL_USERS 

2541 if FormAction.CANCEL in req.POST: 

2542 raise HTTPFound(req.route_url(route_back)) 

2543 if req.user.superuser: 

2544 form = AddUserSuperuserForm(request=req) 

2545 else: 

2546 form = AddUserGroupadminForm(request=req) 

2547 dbsession = req.dbsession 

2548 if FormAction.SUBMIT in req.POST: 

2549 try: 

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

2551 appstruct = form.validate(controls) 

2552 # ----------------------------------------------------------------- 

2553 # Add the user 

2554 # ----------------------------------------------------------------- 

2555 user = User() 

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

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

2558 user.must_change_password = appstruct.get(ViewParam.MUST_CHANGE_PASSWORD) # noqa 

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

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

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

2562 user.language = req.language 

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

2564 raise HTTPBadRequest( 

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

2566 dbsession.add(user) 

2567 group_ids = appstruct.get(ViewParam.GROUP_IDS) 

2568 for gid in group_ids: 

2569 # noinspection PyUnresolvedReferences 

2570 user.user_group_memberships.append(UserGroupMembership( 

2571 user_id=user.id, 

2572 group_id=gid 

2573 )) 

2574 raise HTTPFound(req.route_url(route_back)) 

2575 except ValidationFailure as e: 

2576 rendered_form = e.render() 

2577 else: 

2578 rendered_form = form.render() 

2579 return dict(form=rendered_form, 

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

2581 

2582 

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

2584 """ 

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

2586 

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

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

2589 """ 

2590 dbsession = req.dbsession 

2591 user_id = user.id 

2592 # Device? 

2593 q = CountStarSpecializedQuery(Device, session=dbsession)\ 

2594 .filter(or_(Device.registered_by_user_id == user_id, 

2595 Device.uploading_user_id == user_id)) 

2596 if q.count_star() > 0: 

2597 return True 

2598 # SpecialNote? 

2599 q = CountStarSpecializedQuery(SpecialNote, session=dbsession)\ 

2600 .filter(SpecialNote.user_id == user_id) 

2601 if q.count_star() > 0: 

2602 return True 

2603 # Audit trail? 

2604 q = CountStarSpecializedQuery(AuditEntry, session=dbsession)\ 

2605 .filter(AuditEntry.user_id == user_id) 

2606 if q.count_star() > 0: 

2607 return True 

2608 # Uploaded records? 

2609 for cls in gen_orm_classes_from_base(GenericTabletRecordMixin): # type: Type[GenericTabletRecordMixin] # noqa 

2610 # noinspection PyProtectedMember 

2611 q = CountStarSpecializedQuery(cls, session=dbsession)\ 

2612 .filter(or_(cls._adding_user_id == user_id, 

2613 cls._removing_user_id == user_id, 

2614 cls._preserving_user_id == user_id, 

2615 cls._manually_erasing_user_id == user_id)) 

2616 if q.count_star() > 0: 

2617 return True 

2618 # No; all clean. 

2619 return False 

2620 

2621 

2622@view_config(route_name=Routes.DELETE_USER, 

2623 permission=Permission.GROUPADMIN, 

2624 renderer="user_delete.mako", 

2625 http_cache=NEVER_CACHE) 

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

2627 """ 

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

2629 """ 

2630 if FormAction.CANCEL in req.POST: 

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

2632 user = get_user_from_request_user_id_or_raise(req) 

2633 assert_may_edit_user(req, user) 

2634 form = DeleteUserForm(request=req) 

2635 rendered_form = "" 

2636 error = "" 

2637 _ = req.gettext 

2638 if user.id == req.user.id: 

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

2640 elif user.may_use_webviewer or user.may_upload: 

2641 error = _("Unable to delete user: user still has webviewer login " 

2642 "and/or tablet upload permission") 

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

2644 error = _("Unable to delete user: " 

2645 "they are a superuser and you are not") 

2646 elif ((not req.user.superuser) and 

2647 bool(set(user.group_ids) - 

2648 set(req.user.ids_of_groups_user_is_admin_for))): 

2649 error = _("Unable to delete user: " 

2650 "user belongs to groups that you do not administer") 

2651 else: 

2652 if any_records_use_user(req, user): 

2653 error = _( 

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

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

2656 ) 

2657 else: 

2658 if FormAction.DELETE in req.POST: 

2659 try: 

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

2661 appstruct = form.validate(controls) 

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

2663 # --------------------------------------------------------- 

2664 # Delete the user and associated objects 

2665 # --------------------------------------------------------- 

2666 # (*) Sessions belonging to this user 

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

2668 # (*) user_group_table mapping 

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

2670 # Simplest way: 

2671 user.groups = [] # will delete the mapping entries 

2672 # (*) User itself 

2673 req.dbsession.delete(user) 

2674 # Done 

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

2676 except ValidationFailure as e: 

2677 rendered_form = e.render() 

2678 else: 

2679 appstruct = {ViewParam.USER_ID: user.id} 

2680 rendered_form = form.render(appstruct) 

2681 

2682 return dict(user=user, 

2683 error=error, 

2684 form=rendered_form, 

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

2686 

2687 

2688# ============================================================================= 

2689# Group management 

2690# ============================================================================= 

2691 

2692@view_config(route_name=Routes.VIEW_GROUPS, 

2693 permission=Permission.SUPERUSER, 

2694 renderer="groups_view.mako", 

2695 http_cache=NEVER_CACHE) 

2696def view_groups(req: "CamcopsRequest") -> Dict[str, Any]: 

2697 """ 

2698 View to show all groups (with hyperlinks to edit them). 

2699 Superusers only. 

2700 """ 

2701 rows_per_page = req.get_int_param(ViewParam.ROWS_PER_PAGE, 

2702 DEFAULT_ROWS_PER_PAGE) 

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

2704 dbsession = req.dbsession 

2705 groups = dbsession.query(Group).order_by(Group.name).all() # type: List[Group] # noqa 

2706 page = CamcopsPage(collection=groups, 

2707 page=page_num, 

2708 items_per_page=rows_per_page, 

2709 url_maker=PageUrl(req), 

2710 request=req) 

2711 

2712 valid_which_idnums = req.valid_which_idnums 

2713 

2714 return dict(groups_page=page, 

2715 valid_which_idnums=valid_which_idnums) 

2716 

2717 

2718def get_group_from_request_group_id_or_raise(req: "CamcopsRequest") -> Group: 

2719 """ 

2720 Returns the :class:`camcops_server.cc_modules.cc_group.Group` represented 

2721 by the request's ``ViewParam.GROUP_ID`` parameter, or raise 

2722 :exc:`HTTPBadRequest`. 

2723 """ 

2724 group_id = req.get_int_param(ViewParam.GROUP_ID) 

2725 group = None 

2726 if group_id is not None: 

2727 dbsession = req.dbsession 

2728 group = dbsession.query(Group).filter(Group.id == group_id).first() 

2729 if not group: 

2730 _ = req.gettext 

2731 raise HTTPBadRequest(f"{_('No such group ID:')} {group_id!r}") 

2732 return group 

2733 

2734 

2735class EditGroupView(UpdateView): 

2736 """ 

2737 Django-style view to edit a CamCOPS group. 

2738 """ 

2739 form_class = EditGroupForm 

2740 model_form_dict = { 

2741 "name": ViewParam.NAME, 

2742 "description": ViewParam.DESCRIPTION, 

2743 "upload_policy": ViewParam.UPLOAD_POLICY, 

2744 "finalize_policy": ViewParam.FINALIZE_POLICY, 

2745 } 

2746 object_class = Group 

2747 pk_param = ViewParam.GROUP_ID 

2748 server_pk_name = "id" 

2749 template_name = "group_edit.mako" 

2750 

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

2752 kwargs = super().get_form_kwargs() 

2753 

2754 group = cast(Group, self.object) 

2755 kwargs.update(group=group) 

2756 

2757 return kwargs 

2758 

2759 def get_form_values(self) -> Dict: 

2760 # will populate with model_form_dict 

2761 form_values = super().get_form_values() 

2762 

2763 group = cast(Group, self.object) 

2764 

2765 other_group_ids = list(group.ids_of_other_groups_group_may_see()) 

2766 other_groups = Group.get_groups_from_id_list(self.request.dbsession, 

2767 other_group_ids) 

2768 other_groups.sort(key=lambda g: g.name) 

2769 

2770 form_values.update({ 

2771 ViewParam.IP_USE: group.ip_use, 

2772 ViewParam.GROUP_ID: group.id, 

2773 ViewParam.GROUP_IDS: [g.id for g in other_groups] 

2774 }) 

2775 

2776 return form_values 

2777 

2778 def get_success_url(self) -> str: 

2779 return self.request.route_url(Routes.VIEW_GROUPS) 

2780 

2781 def save_object(self, appstruct: Dict[str, Any]) -> None: 

2782 super().save_object(appstruct) 

2783 

2784 group = cast(Group, self.object) 

2785 

2786 # Group cross-references 

2787 group_ids = appstruct.get(ViewParam.GROUP_IDS) 

2788 # The form validation will prevent our own group from being in here 

2789 other_groups = Group.get_groups_from_id_list(self.request.dbsession, 

2790 group_ids) 

2791 group.can_see_other_groups = other_groups 

2792 

2793 ip_use = appstruct.get(ViewParam.IP_USE) 

2794 if group.ip_use is not None: 

2795 ip_use.id = group.ip_use.id 

2796 

2797 group.ip_use = ip_use 

2798 

2799 

2800@view_config(route_name=Routes.EDIT_GROUP, 

2801 permission=Permission.SUPERUSER, 

2802 http_cache=NEVER_CACHE) 

2803def edit_group(req: "CamcopsRequest") -> Response: 

2804 """ 

2805 View to edit a group. Superusers only. 

2806 """ 

2807 return EditGroupView(req).dispatch() 

2808 

2809 

2810@view_config(route_name=Routes.ADD_GROUP, 

2811 permission=Permission.SUPERUSER, 

2812 renderer="group_add.mako", 

2813 http_cache=NEVER_CACHE) 

2814def add_group(req: "CamcopsRequest") -> Dict[str, Any]: 

2815 """ 

2816 View to add a group. Superusers only. 

2817 """ 

2818 route_back = Routes.VIEW_GROUPS 

2819 if FormAction.CANCEL in req.POST: 

2820 raise HTTPFound(req.route_url(route_back)) 

2821 form = AddGroupForm(request=req) 

2822 dbsession = req.dbsession 

2823 if FormAction.SUBMIT in req.POST: 

2824 try: 

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

2826 appstruct = form.validate(controls) 

2827 # ----------------------------------------------------------------- 

2828 # Add the group 

2829 # ----------------------------------------------------------------- 

2830 group = Group() 

2831 group.name = appstruct.get(ViewParam.NAME) 

2832 dbsession.add(group) 

2833 raise HTTPFound(req.route_url(route_back)) 

2834 except ValidationFailure as e: 

2835 rendered_form = e.render() 

2836 else: 

2837 rendered_form = form.render() 

2838 return dict(form=rendered_form, 

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

2840 

2841 

2842def any_records_use_group(req: "CamcopsRequest", group: Group) -> bool: 

2843 """ 

2844 Do any records in the database refer to the specified group? 

2845 

2846 (Used when we're thinking about deleting a group; would it leave broken 

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

2848 """ 

2849 dbsession = req.dbsession 

2850 group_id = group.id 

2851 # Our own or users filtering on us? 

2852 # ... doesn't matter; see TaskFilter; stored as a CSV list so not part of 

2853 # database integrity checks. 

2854 # Uploaded records? 

2855 for cls in gen_orm_classes_from_base(GenericTabletRecordMixin): # type: Type[GenericTabletRecordMixin] # noqa 

2856 # noinspection PyProtectedMember 

2857 q = CountStarSpecializedQuery(cls, session=dbsession)\ 

2858 .filter(cls._group_id == group_id) 

2859 if q.count_star() > 0: 

2860 return True 

2861 # No; all clean. 

2862 return False 

2863 

2864 

2865@view_config(route_name=Routes.DELETE_GROUP, 

2866 permission=Permission.SUPERUSER, 

2867 renderer="group_delete.mako", 

2868 http_cache=NEVER_CACHE) 

2869def delete_group(req: "CamcopsRequest") -> Dict[str, Any]: 

2870 """ 

2871 View to delete a group. Superusers only. 

2872 """ 

2873 route_back = Routes.VIEW_GROUPS 

2874 if FormAction.CANCEL in req.POST: 

2875 raise HTTPFound(req.route_url(route_back)) 

2876 group = get_group_from_request_group_id_or_raise(req) 

2877 form = DeleteGroupForm(request=req) 

2878 rendered_form = "" 

2879 error = "" 

2880 _ = req.gettext 

2881 if group.users: 

2882 error = _("Unable to delete group; there are users who are members!") 

2883 else: 

2884 if any_records_use_group(req, group): 

2885 error = _("Unable to delete group; records refer to it.") 

2886 else: 

2887 if FormAction.DELETE in req.POST: 

2888 try: 

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

2890 appstruct = form.validate(controls) 

2891 assert appstruct.get(ViewParam.GROUP_ID) == group.id 

2892 # --------------------------------------------------------- 

2893 # Delete the group 

2894 # --------------------------------------------------------- 

2895 req.dbsession.delete(group) 

2896 raise HTTPFound(req.route_url(route_back)) 

2897 except ValidationFailure as e: 

2898 rendered_form = e.render() 

2899 else: 

2900 appstruct = {ViewParam.GROUP_ID: group.id} 

2901 rendered_form = form.render(appstruct) 

2902 return dict(group=group, 

2903 error=error, 

2904 form=rendered_form, 

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

2906 

2907 

2908# ============================================================================= 

2909# Edit server settings 

2910# ============================================================================= 

2911 

2912@view_config(route_name=Routes.EDIT_SERVER_SETTINGS, 

2913 permission=Permission.SUPERUSER, 

2914 renderer="server_settings_edit.mako", 

2915 http_cache=NEVER_CACHE) 

2916def edit_server_settings(req: "CamcopsRequest") -> Dict[str, Any]: 

2917 """ 

2918 View to edit server settings (like the database title). 

2919 """ 

2920 if FormAction.CANCEL in req.POST: 

2921 raise HTTPFound(req.route_url(Routes.HOME)) 

2922 form = EditServerSettingsForm(request=req) 

2923 if FormAction.SUBMIT in req.POST: 

2924 try: 

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

2926 appstruct = form.validate(controls) 

2927 title = appstruct.get(ViewParam.DATABASE_TITLE) 

2928 # ----------------------------------------------------------------- 

2929 # Apply changes 

2930 # ----------------------------------------------------------------- 

2931 req.set_database_title(title) 

2932 raise HTTPFound(req.route_url(Routes.HOME)) 

2933 except ValidationFailure as e: 

2934 rendered_form = e.render() 

2935 else: 

2936 title = req.database_title 

2937 appstruct = {ViewParam.DATABASE_TITLE: title} 

2938 rendered_form = form.render(appstruct) 

2939 return dict(form=rendered_form, 

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

2941 

2942 

2943@view_config(route_name=Routes.VIEW_ID_DEFINITIONS, 

2944 permission=Permission.SUPERUSER, 

2945 renderer="id_definitions_view.mako", 

2946 http_cache=NEVER_CACHE) 

2947def view_id_definitions(req: "CamcopsRequest") -> Dict[str, Any]: 

2948 """ 

2949 View to show all ID number definitions (with hyperlinks to edit them). 

2950 Superusers only. 

2951 """ 

2952 return dict( 

2953 idnum_definitions=req.idnum_definitions, 

2954 ) 

2955 

2956 

2957def get_iddef_from_request_which_idnum_or_raise( 

2958 req: "CamcopsRequest") -> IdNumDefinition: 

2959 """ 

2960 Returns the :class:`camcops_server.cc_modules.cc_idnumdef.IdNumDefinition` 

2961 represented by the request's ``ViewParam.WHICH_IDNUM`` parameter, or raise 

2962 :exc:`HTTPBadRequest`. 

2963 """ 

2964 which_idnum = req.get_int_param(ViewParam.WHICH_IDNUM) 

2965 iddef = req.dbsession.query(IdNumDefinition)\ 

2966 .filter(IdNumDefinition.which_idnum == which_idnum)\ 

2967 .first() 

2968 if not iddef: 

2969 _ = req.gettext 

2970 raise HTTPBadRequest(f"{_('No such ID definition:')} {which_idnum!r}") 

2971 return iddef 

2972 

2973 

2974@view_config(route_name=Routes.EDIT_ID_DEFINITION, 

2975 permission=Permission.SUPERUSER, 

2976 renderer="id_definition_edit.mako", 

2977 http_cache=NEVER_CACHE) 

2978def edit_id_definition(req: "CamcopsRequest") -> Dict[str, Any]: 

2979 """ 

2980 View to edit an ID number definition. Superusers only. 

2981 """ 

2982 route_back = Routes.VIEW_ID_DEFINITIONS 

2983 if FormAction.CANCEL in req.POST: 

2984 raise HTTPFound(req.route_url(route_back)) 

2985 iddef = get_iddef_from_request_which_idnum_or_raise(req) 

2986 form = EditIdDefinitionForm(request=req) 

2987 if FormAction.SUBMIT in req.POST: 

2988 try: 

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

2990 appstruct = form.validate(controls) 

2991 # ----------------------------------------------------------------- 

2992 # Alter the ID definition 

2993 # ----------------------------------------------------------------- 

2994 iddef.description = appstruct.get(ViewParam.DESCRIPTION) 

2995 iddef.short_description = appstruct.get(ViewParam.SHORT_DESCRIPTION) # noqa 

2996 iddef.validation_method = appstruct.get(ViewParam.VALIDATION_METHOD) # noqa 

2997 iddef.hl7_id_type = appstruct.get(ViewParam.HL7_ID_TYPE) 

2998 iddef.hl7_assigning_authority = appstruct.get(ViewParam.HL7_ASSIGNING_AUTHORITY) # noqa 

2999 # REMOVED # clear_idnum_definition_cache() # SPECIAL 

3000 raise HTTPFound(req.route_url(route_back)) 

3001 except ValidationFailure as e: 

3002 rendered_form = e.render() 

3003 else: 

3004 appstruct = { 

3005 ViewParam.WHICH_IDNUM: iddef.which_idnum, 

3006 ViewParam.DESCRIPTION: iddef.description or "", 

3007 ViewParam.SHORT_DESCRIPTION: iddef.short_description or "", 

3008 ViewParam.VALIDATION_METHOD: iddef.validation_method or "", 

3009 ViewParam.HL7_ID_TYPE: iddef.hl7_id_type or "", 

3010 ViewParam.HL7_ASSIGNING_AUTHORITY: iddef.hl7_assigning_authority or "", # noqa 

3011 } 

3012 rendered_form = form.render(appstruct) 

3013 return dict(iddef=iddef, 

3014 form=rendered_form, 

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

3016 

3017 

3018@view_config(route_name=Routes.ADD_ID_DEFINITION, 

3019 permission=Permission.SUPERUSER, 

3020 renderer="id_definition_add.mako", 

3021 http_cache=NEVER_CACHE) 

3022def add_id_definition(req: "CamcopsRequest") -> Dict[str, Any]: 

3023 """ 

3024 View to add an ID number definition. Superusers only. 

3025 """ 

3026 route_back = Routes.VIEW_ID_DEFINITIONS 

3027 if FormAction.CANCEL in req.POST: 

3028 raise HTTPFound(req.route_url(route_back)) 

3029 form = AddIdDefinitionForm(request=req) 

3030 dbsession = req.dbsession 

3031 if FormAction.SUBMIT in req.POST: 

3032 try: 

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

3034 appstruct = form.validate(controls) 

3035 iddef = IdNumDefinition( 

3036 which_idnum=appstruct.get(ViewParam.WHICH_IDNUM), 

3037 description=appstruct.get(ViewParam.DESCRIPTION), 

3038 short_description=appstruct.get(ViewParam.SHORT_DESCRIPTION), 

3039 # we skip hl7_id_type at this stage 

3040 # we skip hl7_assigning_authority at this stage 

3041 validation_method=appstruct.get(ViewParam.VALIDATION_METHOD), 

3042 ) 

3043 # ----------------------------------------------------------------- 

3044 # Add ID definition 

3045 # ----------------------------------------------------------------- 

3046 dbsession.add(iddef) 

3047 # REMOVED # clear_idnum_definition_cache() # SPECIAL 

3048 raise HTTPFound(req.route_url(route_back)) 

3049 except ValidationFailure as e: 

3050 rendered_form = e.render() 

3051 else: 

3052 rendered_form = form.render() 

3053 return dict(form=rendered_form, 

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

3055 

3056 

3057def any_records_use_iddef(req: "CamcopsRequest", 

3058 iddef: IdNumDefinition) -> bool: 

3059 """ 

3060 Do any records in the database refer to the specified ID number definition? 

3061 

3062 (Used when we're thinking about deleting one; would it leave broken 

3063 references? If so, we will prevent deletion; see 

3064 :func:`delete_id_definition`.) 

3065 """ 

3066 # Helpfully, these are only referred to permanently from one place: 

3067 q = CountStarSpecializedQuery(PatientIdNum, session=req.dbsession)\ 

3068 .filter(PatientIdNum.which_idnum == iddef.which_idnum) 

3069 if q.count_star() > 0: 

3070 return True 

3071 # No; all clean. 

3072 return False 

3073 

3074 

3075@view_config(route_name=Routes.DELETE_ID_DEFINITION, 

3076 permission=Permission.SUPERUSER, 

3077 renderer="id_definition_delete.mako", 

3078 http_cache=NEVER_CACHE) 

3079def delete_id_definition(req: "CamcopsRequest") -> Dict[str, Any]: 

3080 """ 

3081 View to delete an ID number definition. Superusers only. 

3082 """ 

3083 route_back = Routes.VIEW_ID_DEFINITIONS 

3084 if FormAction.CANCEL in req.POST: 

3085 raise HTTPFound(req.route_url(route_back)) 

3086 iddef = get_iddef_from_request_which_idnum_or_raise(req) 

3087 form = DeleteIdDefinitionForm(request=req) 

3088 rendered_form = "" 

3089 error = "" 

3090 if any_records_use_iddef(req, iddef): 

3091 _ = req.gettext 

3092 error = _("Unable to delete ID definition; records refer to it.") 

3093 else: 

3094 if FormAction.DELETE in req.POST: 

3095 try: 

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

3097 appstruct = form.validate(controls) 

3098 assert appstruct.get(ViewParam.WHICH_IDNUM) == iddef.which_idnum # noqa 

3099 # ------------------------------------------------------------- 

3100 # Delete ID definition 

3101 # ------------------------------------------------------------- 

3102 req.dbsession.delete(iddef) 

3103 # REMOVED # clear_idnum_definition_cache() # SPECIAL 

3104 raise HTTPFound(req.route_url(route_back)) 

3105 except ValidationFailure as e: 

3106 rendered_form = e.render() 

3107 else: 

3108 appstruct = {ViewParam.WHICH_IDNUM: iddef.which_idnum} 

3109 rendered_form = form.render(appstruct) 

3110 return dict(iddef=iddef, 

3111 error=error, 

3112 form=rendered_form, 

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

3114 

3115 

3116# ============================================================================= 

3117# Altering data. Some of the more complex logic is here. 

3118# ============================================================================= 

3119 

3120@view_config(route_name=Routes.ADD_SPECIAL_NOTE, 

3121 renderer="special_note_add.mako", 

3122 http_cache=NEVER_CACHE) 

3123def add_special_note(req: "CamcopsRequest") -> Dict[str, Any]: 

3124 """ 

3125 View to add a special note to a task (after confirmation). 

3126 """ 

3127 table_name = req.get_str_param(ViewParam.TABLE_NAME, 

3128 validator=validate_task_tablename) 

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

3130 url_back = req.route_url( 

3131 Routes.TASK, 

3132 _query={ 

3133 ViewParam.TABLE_NAME: table_name, 

3134 ViewParam.SERVER_PK: server_pk, 

3135 ViewParam.VIEWTYPE: ViewArg.HTML, 

3136 } 

3137 ) 

3138 if FormAction.CANCEL in req.POST: 

3139 raise HTTPFound(url_back) 

3140 task = task_factory(req, table_name, server_pk) 

3141 _ = req.gettext 

3142 if task is None: 

3143 raise HTTPBadRequest( 

3144 f"{_('No such task:')} {table_name}, PK={server_pk}") 

3145 user = req.user 

3146 if not user.authorized_to_add_special_note(task.group_id): 

3147 raise HTTPBadRequest( 

3148 _("Not authorized to add special notes for this task's group")) 

3149 form = AddSpecialNoteForm(request=req) 

3150 if FormAction.SUBMIT in req.POST: 

3151 try: 

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

3153 appstruct = form.validate(controls) 

3154 note = appstruct.get(ViewParam.NOTE) 

3155 # ----------------------------------------------------------------- 

3156 # Apply special note 

3157 # ----------------------------------------------------------------- 

3158 task.apply_special_note(req, note) 

3159 raise HTTPFound(url_back) 

3160 except ValidationFailure as e: 

3161 rendered_form = e.render() 

3162 else: 

3163 appstruct = { 

3164 ViewParam.TABLE_NAME: table_name, 

3165 ViewParam.SERVER_PK: server_pk, 

3166 } 

3167 rendered_form = form.render(appstruct) 

3168 return dict(task=task, 

3169 form=rendered_form, 

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

3171 viewtype=ViewArg.HTML) 

3172 

3173 

3174@view_config(route_name=Routes.DELETE_SPECIAL_NOTE, 

3175 renderer="special_note_delete.mako", 

3176 http_cache=NEVER_CACHE) 

3177def delete_special_note(req: "CamcopsRequest") -> Dict[str, Any]: 

3178 """ 

3179 View to delete a special note (after confirmation). 

3180 """ 

3181 note_id = req.get_int_param(ViewParam.NOTE_ID, None) 

3182 url_back = req.route_url(Routes.HOME) 

3183 # ... too fiddly to be more precise as we could be routing back to the task 

3184 # relating to a patient relating to this special note 

3185 if FormAction.CANCEL in req.POST: 

3186 raise HTTPFound(url_back) 

3187 sn = SpecialNote.get_specialnote_by_id(req.dbsession, note_id) 

3188 _ = req.gettext 

3189 if sn is None: 

3190 raise HTTPBadRequest(f"{_('No such SpecialNote:')} note_id={note_id}") 

3191 if sn.hidden: 

3192 raise HTTPBadRequest(f"{_('SpecialNote already deleted/hidden:')} " 

3193 f"note_id={note_id}") 

3194 if not sn.user_may_delete_specialnote(req.user): 

3195 raise HTTPBadRequest(_("Not authorized to delete this special note")) 

3196 form = DeleteSpecialNoteForm(request=req) 

3197 if FormAction.SUBMIT in req.POST: 

3198 try: 

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

3200 form.validate(controls) 

3201 # ----------------------------------------------------------------- 

3202 # Delete special note 

3203 # ----------------------------------------------------------------- 

3204 sn.hidden = True 

3205 raise HTTPFound(url_back) 

3206 except ValidationFailure as e: 

3207 rendered_form = e.render() 

3208 else: 

3209 appstruct = { 

3210 ViewParam.NOTE_ID: note_id, 

3211 } 

3212 rendered_form = form.render(appstruct) 

3213 return dict(sn=sn, 

3214 form=rendered_form, 

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

3216 

3217 

3218class EraseTaskBaseView(DeleteView): 

3219 """ 

3220 Django-style view to erase a task. 

3221 """ 

3222 form_class = EraseTaskForm 

3223 

3224 def get_object(self) -> Any: 

3225 # noinspection PyAttributeOutsideInit 

3226 self.table_name = self.request.get_str_param( 

3227 ViewParam.TABLE_NAME, validator=validate_task_tablename) 

3228 # noinspection PyAttributeOutsideInit 

3229 self.server_pk = self.request.get_int_param(ViewParam.SERVER_PK, None) 

3230 

3231 task = task_factory(self.request, self.table_name, self.server_pk) 

3232 _ = self.request.gettext 

3233 if task is None: 

3234 raise HTTPBadRequest( 

3235 f"{_('No such task:')} {self.table_name}, PK={self.server_pk}") 

3236 if task.is_live_on_tablet(): 

3237 raise HTTPBadRequest(errormsg_task_live(self.request)) 

3238 self.check_user_is_authorized(task) 

3239 

3240 return task 

3241 

3242 def check_user_is_authorized(self, task: Task) -> None: 

3243 if not self.request.user.authorized_to_erase_tasks(task.group_id): 

3244 _ = self.request.gettext 

3245 raise HTTPBadRequest( 

3246 _("Not authorized to erase tasks for this task's group")) 

3247 

3248 def get_cancel_url(self) -> str: 

3249 return self.request.route_url( 

3250 Routes.TASK, 

3251 _query={ 

3252 ViewParam.TABLE_NAME: self.table_name, 

3253 ViewParam.SERVER_PK: self.server_pk, 

3254 ViewParam.VIEWTYPE: ViewArg.HTML, 

3255 } 

3256 ) 

3257 

3258 

3259class EraseTaskLeavingPlaceholderView(EraseTaskBaseView): 

3260 """ 

3261 Django-style view to erase data from a task, leaving an empty 

3262 "placeholder". 

3263 """ 

3264 template_name = "task_erase.mako" 

3265 

3266 def get_object(self) -> Any: 

3267 task = cast(Task, super().get_object()) 

3268 if task.is_erased(): 

3269 _ = self.request.gettext 

3270 raise HTTPBadRequest(_("Task already erased")) 

3271 

3272 return task 

3273 

3274 def delete(self) -> None: 

3275 task = cast(Task, self.object) 

3276 

3277 task.manually_erase(self.request) 

3278 

3279 def get_success_url(self) -> str: 

3280 return self.request.route_url( 

3281 Routes.TASK, 

3282 _query={ 

3283 ViewParam.TABLE_NAME: self.table_name, 

3284 ViewParam.SERVER_PK: self.server_pk, 

3285 ViewParam.VIEWTYPE: ViewArg.HTML, 

3286 } 

3287 ) 

3288 

3289 

3290class EraseTaskEntirelyView(EraseTaskBaseView): 

3291 """ 

3292 Django-style view to erase (delete) a task entirely. 

3293 """ 

3294 template_name = "task_erase_entirely.mako" 

3295 

3296 def delete(self) -> None: 

3297 task = cast(Task, self.object) 

3298 

3299 TaskIndexEntry.unindex_task(task, self.request.dbsession) 

3300 task.delete_entirely(self.request) 

3301 

3302 _ = self.request.gettext 

3303 

3304 msg_erased = _("Task erased:") 

3305 

3306 self.request.session.flash( 

3307 f"{msg_erased} ({self.table_name}, server PK {self.server_pk}).", 

3308 queue=FLASH_SUCCESS 

3309 ) 

3310 

3311 def get_success_url(self) -> str: 

3312 return self.request.route_url(Routes.VIEW_TASKS) 

3313 

3314 

3315@view_config(route_name=Routes.ERASE_TASK_LEAVING_PLACEHOLDER, 

3316 permission=Permission.GROUPADMIN, 

3317 http_cache=NEVER_CACHE) 

3318def erase_task_leaving_placeholder(req: "CamcopsRequest") -> Response: 

3319 """ 

3320 View to wipe all data from a task (after confirmation). 

3321 

3322 Leaves the task record as a placeholder. 

3323 """ 

3324 return EraseTaskLeavingPlaceholderView(req).dispatch() 

3325 

3326 

3327@view_config(route_name=Routes.ERASE_TASK_ENTIRELY, 

3328 permission=Permission.GROUPADMIN, 

3329 http_cache=NEVER_CACHE) 

3330def erase_task_entirely(req: "CamcopsRequest") -> Response: 

3331 """ 

3332 View to erase a task from the database entirely (after confirmation). 

3333 """ 

3334 return EraseTaskEntirelyView(req).dispatch() 

3335 

3336 

3337@view_config(route_name=Routes.DELETE_PATIENT, 

3338 permission=Permission.GROUPADMIN, 

3339 http_cache=NEVER_CACHE) 

3340def delete_patient(req: "CamcopsRequest") -> Response: 

3341 """ 

3342 View to delete completely all data for a patient (after confirmation), 

3343 within a specific group. 

3344 """ 

3345 if FormAction.CANCEL in req.POST: 

3346 raise HTTPFound(req.route_url(Routes.HOME)) 

3347 

3348 first_form = DeletePatientChooseForm(request=req) 

3349 second_form = DeletePatientConfirmForm(request=req) 

3350 form = None 

3351 final_phase = False 

3352 if FormAction.SUBMIT in req.POST: 

3353 # FIRST form has been submitted 

3354 form = first_form 

3355 elif FormAction.DELETE in req.POST: 

3356 # SECOND AND FINAL form has been submitted 

3357 form = second_form 

3358 final_phase = True 

3359 _ = req.gettext 

3360 if form is not None: 

3361 try: 

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

3363 appstruct = form.validate(controls) 

3364 which_idnum = appstruct.get(ViewParam.WHICH_IDNUM) 

3365 idnum_value = appstruct.get(ViewParam.IDNUM_VALUE) 

3366 group_id = appstruct.get(ViewParam.GROUP_ID) 

3367 if group_id not in req.user.ids_of_groups_user_is_admin_for: 

3368 # rare occurrence; form should prevent it; 

3369 # unless superuser has changed status since form was read 

3370 raise HTTPBadRequest(_("You're not an admin for this group")) 

3371 # ----------------------------------------------------------------- 

3372 # Fetch tasks to be deleted. 

3373 # ----------------------------------------------------------------- 

3374 dbsession = req.dbsession 

3375 # Tasks first: 

3376 idnum_ref = IdNumReference(which_idnum=which_idnum, 

3377 idnum_value=idnum_value) 

3378 taskfilter = TaskFilter() 

3379 taskfilter.idnum_criteria = [idnum_ref] 

3380 taskfilter.group_ids = [group_id] 

3381 collection = TaskCollection( 

3382 req=req, 

3383 taskfilter=taskfilter, 

3384 sort_method_global=TaskSortMethod.CREATION_DATE_DESC, 

3385 current_only=False # unusual option! 

3386 ) 

3387 tasks = collection.all_tasks 

3388 n_tasks = len(tasks) 

3389 patient_lineage_instances = Patient.get_patients_by_idnum( 

3390 dbsession=dbsession, 

3391 which_idnum=which_idnum, 

3392 idnum_value=idnum_value, 

3393 group_id=group_id, 

3394 current_only=False 

3395 ) 

3396 n_patient_instances = len(patient_lineage_instances) 

3397 

3398 # ----------------------------------------------------------------- 

3399 # Bin out at this stage and offer confirmation page? 

3400 # ----------------------------------------------------------------- 

3401 if not final_phase: 

3402 # New appstruct; we don't want the validation code persisting 

3403 appstruct = { 

3404 ViewParam.WHICH_IDNUM: which_idnum, 

3405 ViewParam.IDNUM_VALUE: idnum_value, 

3406 ViewParam.GROUP_ID: group_id, 

3407 } 

3408 rendered_form = second_form.render(appstruct) 

3409 return render_to_response( 

3410 "patient_delete_confirm.mako", 

3411 dict( 

3412 form=rendered_form, 

3413 tasks=tasks, 

3414 n_patient_instances=n_patient_instances, 

3415 head_form_html=get_head_form_html(req, [form]) 

3416 ), 

3417 request=req 

3418 ) 

3419 

3420 # ----------------------------------------------------------------- 

3421 # Delete patient and associated tasks 

3422 # ----------------------------------------------------------------- 

3423 for task in tasks: 

3424 TaskIndexEntry.unindex_task(task, req.dbsession) 

3425 task.delete_entirely(req) 

3426 # Then patients: 

3427 for p in patient_lineage_instances: 

3428 PatientIdNumIndexEntry.unindex_patient(p, req.dbsession) 

3429 p.delete_with_dependants(req) 

3430 msg = ( 

3431 f"{_('Patient and associated tasks DELETED from group')} " 

3432 f"{group_id}: idnum{which_idnum} = {idnum_value}. " 

3433 f"{_('Task records deleted:')} {n_tasks}." 

3434 f"{_('Patient records (current and/or old) deleted')} " 

3435 f"{n_patient_instances}." 

3436 ) 

3437 audit(req, msg) 

3438 

3439 req.session.flash(msg, FLASH_SUCCESS) 

3440 raise HTTPFound(req.route_url(Routes.HOME)) 

3441 

3442 except ValidationFailure as e: 

3443 rendered_form = e.render() 

3444 else: 

3445 form = first_form 

3446 rendered_form = first_form.render() 

3447 return render_to_response( 

3448 "patient_delete_choose.mako", 

3449 dict( 

3450 form=rendered_form, 

3451 head_form_html=get_head_form_html(req, [form]) 

3452 ), 

3453 request=req 

3454 ) 

3455 

3456 

3457@view_config(route_name=Routes.FORCIBLY_FINALIZE, 

3458 permission=Permission.GROUPADMIN, 

3459 http_cache=NEVER_CACHE) 

3460def forcibly_finalize(req: "CamcopsRequest") -> Response: 

3461 """ 

3462 View to force-finalize all live (``_era == ERA_NOW``) records from a 

3463 device. Available to group administrators if all those records are within 

3464 their groups (otherwise, it's a superuser operation). 

3465 """ 

3466 if FormAction.CANCEL in req.POST: 

3467 return HTTPFound(req.route_url(Routes.HOME)) 

3468 

3469 dbsession = req.dbsession 

3470 first_form = ForciblyFinalizeChooseDeviceForm(request=req) 

3471 second_form = ForciblyFinalizeConfirmForm(request=req) 

3472 form = None 

3473 final_phase = False 

3474 if FormAction.SUBMIT in req.POST: 

3475 # FIRST form has been submitted 

3476 form = first_form 

3477 elif FormAction.FINALIZE in req.POST: 

3478 # SECOND form has been submitted: 

3479 form = second_form 

3480 final_phase = True 

3481 _ = req.gettext 

3482 if form is not None: 

3483 try: 

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

3485 appstruct = form.validate(controls) 

3486 # log.debug("{}", pformat(appstruct)) 

3487 device_id = appstruct.get(ViewParam.DEVICE_ID) 

3488 device = Device.get_device_by_id(dbsession, device_id) 

3489 if device is None: 

3490 raise HTTPBadRequest(f"{_('No such device:')} {device_id!r}") 

3491 # ----------------------------------------------------------------- 

3492 # If at the first stage, bin out and offer confirmation page 

3493 # ----------------------------------------------------------------- 

3494 if not final_phase: 

3495 appstruct = {ViewParam.DEVICE_ID: device_id} 

3496 rendered_form = second_form.render(appstruct) 

3497 taskfilter = TaskFilter() 

3498 taskfilter.device_ids = [device_id] 

3499 taskfilter.era = ERA_NOW 

3500 collection = TaskCollection( 

3501 req=req, 

3502 taskfilter=taskfilter, 

3503 sort_method_global=TaskSortMethod.CREATION_DATE_DESC, 

3504 current_only=False, # unusual option! 

3505 via_index=False # required for current_only=False 

3506 ) 

3507 tasks = collection.all_tasks 

3508 return render_to_response( 

3509 "device_forcibly_finalize_confirm.mako", 

3510 dict(form=rendered_form, 

3511 tasks=tasks, 

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

3513 request=req 

3514 ) 

3515 # ----------------------------------------------------------------- 

3516 # Check it's permitted 

3517 # ----------------------------------------------------------------- 

3518 if not req.user.superuser: 

3519 admin_group_ids = req.user.ids_of_groups_user_is_admin_for 

3520 for clienttable in CLIENT_TABLE_MAP.values(): 

3521 # noinspection PyPropertyAccess 

3522 count_query = ( 

3523 select([func.count()]) 

3524 .select_from(clienttable) 

3525 .where(clienttable.c[FN_DEVICE_ID] == device_id) 

3526 .where(clienttable.c[FN_ERA] == ERA_NOW) 

3527 .where(clienttable.c[FN_GROUP_ID].notin_(admin_group_ids)) # noqa 

3528 ) 

3529 n = dbsession.execute(count_query).scalar() 

3530 if n > 0: 

3531 raise HTTPBadRequest( 

3532 _("Some records for this device are in groups for " 

3533 "which you are not an administrator")) 

3534 # ----------------------------------------------------------------- 

3535 # Forcibly finalize 

3536 # ----------------------------------------------------------------- 

3537 msgs = [] # type: List[str] 

3538 batchdetails = BatchDetails(batchtime=req.now_utc) 

3539 alltables = sorted(CLIENT_TABLE_MAP.values(), 

3540 key=upload_commit_order_sorter) 

3541 for clienttable in alltables: 

3542 liverecs = get_server_live_records( 

3543 req, device_id, clienttable, current_only=False) 

3544 preservation_pks = [r.server_pk for r in liverecs] 

3545 if not preservation_pks: 

3546 continue 

3547 current_pks = [r.server_pk for r in liverecs if r.current] 

3548 tablechanges = UploadTableChanges(clienttable) 

3549 tablechanges.note_preservation_pks(preservation_pks) 

3550 tablechanges.note_current_pks(current_pks) 

3551 dbsession.execute( 

3552 update(clienttable) 

3553 .where(clienttable.c[FN_PK].in_(preservation_pks)) 

3554 .values(values_preserve_now(req, batchdetails, 

3555 forcibly_preserved=True)) 

3556 ) 

3557 update_indexes_and_push_exports(req, batchdetails, tablechanges) 

3558 msgs.append(f"{clienttable.name} {preservation_pks}") 

3559 # Field names are different in server-side tables, so they need 

3560 # special handling: 

3561 SpecialNote.forcibly_preserve_special_notes_for_device(req, 

3562 device_id) 

3563 # ----------------------------------------------------------------- 

3564 # Done 

3565 # ----------------------------------------------------------------- 

3566 msg = ( 

3567 f"{_('Live records for device')} {device_id} " 

3568 f"({device.friendly_name}) {_('forcibly finalized')} " 

3569 f"(PKs: {'; '.join(msgs)})" 

3570 ) 

3571 audit(req, msg) 

3572 log.info(msg) 

3573 

3574 req.session.flash(msg, queue=FLASH_SUCCESS) 

3575 raise HTTPFound(req.route_url(Routes.HOME)) 

3576 

3577 except ValidationFailure as e: 

3578 rendered_form = e.render() 

3579 else: 

3580 form = first_form 

3581 rendered_form = form.render() # no appstruct 

3582 return render_to_response( 

3583 "device_forcibly_finalize_choose.mako", 

3584 dict(form=rendered_form, 

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

3586 request=req 

3587 ) 

3588 

3589 

3590# ============================================================================= 

3591# Patient creation/editing (primarily for task scheduling) 

3592# ============================================================================= 

3593 

3594class PatientMixin(object): 

3595 """ 

3596 Mixin for views involving a patient. 

3597 """ 

3598 object: Any 

3599 object_class = Patient 

3600 server_pk_name = "_pk" 

3601 

3602 model_form_dict = { 

3603 "forename": ViewParam.FORENAME, 

3604 "surname": ViewParam.SURNAME, 

3605 "dob": ViewParam.DOB, 

3606 "sex": ViewParam.SEX, 

3607 "email": ViewParam.EMAIL, 

3608 "address": ViewParam.ADDRESS, 

3609 "gp": ViewParam.GP, 

3610 "other": ViewParam.OTHER, 

3611 } 

3612 

3613 def get_form_values(self) -> Dict: 

3614 # will populate with model_form_dict 

3615 # noinspection PyUnresolvedReferences 

3616 form_values = super().get_form_values() 

3617 

3618 patient = cast(Patient, self.object) 

3619 

3620 if patient is not None: 

3621 form_values[ViewParam.SERVER_PK] = patient.pk 

3622 form_values[ViewParam.GROUP_ID] = patient.group.id 

3623 form_values[ViewParam.ID_REFERENCES] = [ 

3624 {ViewParam.WHICH_IDNUM: pidnum.which_idnum, 

3625 ViewParam.IDNUM_VALUE: pidnum.idnum_value} 

3626 for pidnum in patient.idnums 

3627 ] 

3628 form_values[ViewParam.TASK_SCHEDULES] = [ 

3629 { 

3630 ViewParam.SCHEDULE_ID: pts.schedule_id, 

3631 ViewParam.START_DATETIME: pts.start_datetime, 

3632 ViewParam.SETTINGS: pts.settings, 

3633 } 

3634 for pts in patient.task_schedules 

3635 ] 

3636 

3637 return form_values 

3638 

3639 

3640class EditPatientBaseView(PatientMixin, UpdateView): 

3641 """ 

3642 View to edit details for a patient. 

3643 """ 

3644 pk_param = ViewParam.SERVER_PK 

3645 

3646 def get_object(self) -> Any: 

3647 patient = cast(Patient, super().get_object()) 

3648 

3649 _ = self.request.gettext 

3650 

3651 if not patient.group: 

3652 raise HTTPBadRequest(_("Bad patient: not in a group")) 

3653 

3654 if not patient.user_may_edit(self.request): 

3655 raise HTTPBadRequest(_("Not authorized to edit this patient")) 

3656 

3657 return patient 

3658 

3659 def save_object(self, appstruct: Dict[str, Any]) -> None: 

3660 # ----------------------------------------------------------------- 

3661 # Apply edits 

3662 # ----------------------------------------------------------------- 

3663 # Calculate the changes, and apply them to the Patient object 

3664 _ = self.request.gettext 

3665 

3666 patient = cast(Patient, self.object) 

3667 

3668 changes = OrderedDict() # type: OrderedDict 

3669 

3670 self.save_changes(appstruct, changes) 

3671 

3672 if not changes: 

3673 self.request.session.flash( 

3674 f"{_('No changes required for patient record with server PK')} " # noqa 

3675 f"{patient.pk} {_('(all new values matched old values)')}", 

3676 queue=FLASH_INFO 

3677 ) 

3678 return 

3679 

3680 # Below here, changes have definitely been made. 

3681 change_msg = ( 

3682 _("Patient details edited. Changes:") + " " + "; ".join( 

3683 f"{k}: {old!r} → {new!r}" 

3684 for k, (old, new) in changes.items() 

3685 ) 

3686 ) 

3687 

3688 # Apply special note to patient 

3689 patient.apply_special_note(self.request, change_msg, 

3690 "Patient edited") 

3691 

3692 # Patient details changed, so resend any tasks via HL7 

3693 for task in self.get_affected_tasks(): 

3694 task.cancel_from_export_log(self.request) 

3695 

3696 # Done 

3697 self.request.session.flash( 

3698 f"{_('Amended patient record with server PK')} " 

3699 f"{patient.pk}. " 

3700 f"{_('Changes were:')} {change_msg}", 

3701 queue=FLASH_SUCCESS 

3702 ) 

3703 

3704 def save_changes(self, 

3705 appstruct: Dict[str, Any], changes: OrderedDict) -> None: 

3706 self._save_simple_params(appstruct, changes) 

3707 self._save_idrefs(appstruct, changes) 

3708 

3709 def _save_simple_params(self, 

3710 appstruct: Dict[str, Any], 

3711 changes: OrderedDict) -> None: 

3712 patient = cast(Patient, self.object) 

3713 for k in EDIT_PATIENT_SIMPLE_PARAMS: 

3714 new_value = appstruct.get(k) 

3715 old_value = getattr(patient, k) 

3716 if new_value == old_value: 

3717 continue 

3718 if new_value in [None, ""] and old_value in [None, ""]: 

3719 # Nothing really changing! 

3720 continue 

3721 changes[k] = (old_value, new_value) 

3722 setattr(patient, k, new_value) 

3723 

3724 def _save_idrefs(self, 

3725 appstruct: Dict[str, Any], 

3726 changes: OrderedDict) -> None: 

3727 

3728 # The ID numbers are more complex. 

3729 # log.debug("{}", pformat(appstruct)) 

3730 patient = cast(Patient, self.object) 

3731 new_idrefs = [ 

3732 IdNumReference(which_idnum=idrefdict[ViewParam.WHICH_IDNUM], 

3733 idnum_value=idrefdict[ViewParam.IDNUM_VALUE]) 

3734 for idrefdict in appstruct.get(ViewParam.ID_REFERENCES, {}) 

3735 ] 

3736 for idnum in patient.idnums: 

3737 matching_idref = next( 

3738 (idref for idref in new_idrefs 

3739 if idref.which_idnum == idnum.which_idnum), None) 

3740 if not matching_idref: 

3741 # Delete ID numbers not present in the new set 

3742 changes["idnum{} ({})".format( 

3743 idnum.which_idnum, 

3744 self.request.get_id_desc(idnum.which_idnum)) 

3745 ] = (idnum.idnum_value, None) 

3746 idnum.mark_as_deleted(self.request) 

3747 elif matching_idref.idnum_value != idnum.idnum_value: 

3748 # Modify altered ID numbers present in the old + new sets 

3749 changes["idnum{} ({})".format( 

3750 idnum.which_idnum, 

3751 self.request.get_id_desc(idnum.which_idnum)) 

3752 ] = (idnum.idnum_value, matching_idref.idnum_value) 

3753 new_idnum = PatientIdNum() 

3754 new_idnum.id = idnum.id 

3755 new_idnum.patient_id = idnum.patient_id 

3756 new_idnum.which_idnum = idnum.which_idnum 

3757 new_idnum.idnum_value = matching_idref.idnum_value 

3758 new_idnum.set_predecessor(self.request, idnum) 

3759 

3760 for idref in new_idrefs: 

3761 matching_idnum = next( 

3762 (idnum for idnum in patient.idnums 

3763 if idnum.which_idnum == idref.which_idnum), None) 

3764 if not matching_idnum: 

3765 # Create ID numbers where they were absent 

3766 changes["idnum{} ({})".format( 

3767 idref.which_idnum, 

3768 self.request.get_id_desc(idref.which_idnum)) 

3769 ] = (None, idref.idnum_value) 

3770 # We need to establish an "id" field, which is the PK as 

3771 # seen by the tablet. The tablet has lost interest in these 

3772 # records, since _era != ERA_NOW, so all we have to do is 

3773 # pick a number that's not in use. 

3774 new_idnum = PatientIdNum() 

3775 new_idnum.patient_id = patient.id 

3776 new_idnum.which_idnum = idref.which_idnum 

3777 new_idnum.idnum_value = idref.idnum_value 

3778 new_idnum.create_fresh(self.request, 

3779 device_id=patient.device_id, 

3780 era=patient.era, 

3781 group_id=patient.group_id) 

3782 new_idnum.save_with_next_available_id( 

3783 self.request, 

3784 patient.device_id, 

3785 era=patient.era 

3786 ) 

3787 

3788 def get_context_data(self, **kwargs: Any) -> Any: 

3789 # This parameter is (I think) used by Mako templates such as 

3790 # finalized_patient_edit.mako 

3791 # Todo: 

3792 # Potential inefficiency: we fetch tasks regardless of the stage 

3793 # of this form. 

3794 kwargs["tasks"] = self.get_affected_tasks() 

3795 

3796 return super().get_context_data(**kwargs) 

3797 

3798 def get_affected_tasks(self) -> Optional[List[Task]]: 

3799 patient = cast(Patient, self.object) 

3800 

3801 taskfilter = TaskFilter() 

3802 taskfilter.device_ids = [patient.device_id] 

3803 taskfilter.group_ids = [patient.group.id] 

3804 taskfilter.era = patient.era 

3805 collection = TaskCollection( 

3806 req=self.request, 

3807 taskfilter=taskfilter, 

3808 sort_method_global=TaskSortMethod.CREATION_DATE_DESC, 

3809 current_only=False, # unusual option! 

3810 via_index=False # for current_only=False, or we'll get a warning 

3811 ) 

3812 return collection.all_tasks 

3813 

3814 

3815class EditServerCreatedPatientView(EditPatientBaseView): 

3816 """ 

3817 View to edit a patient created on the server (as part of task scheduling). 

3818 """ 

3819 template_name = "server_created_patient_edit.mako" 

3820 form_class = EditServerCreatedPatientForm 

3821 

3822 def get_success_url(self) -> str: 

3823 return self.request.route_url( 

3824 Routes.VIEW_PATIENT_TASK_SCHEDULES 

3825 ) 

3826 

3827 def get_object(self) -> Any: 

3828 patient = cast(Patient, super().get_object()) 

3829 

3830 if not patient.created_on_server(self.request): 

3831 _ = self.request.gettext 

3832 

3833 raise HTTPBadRequest( 

3834 _("Patient is not editable - was not created on the server")) 

3835 

3836 return patient 

3837 

3838 def save_changes(self, 

3839 appstruct: Dict[str, Any], changes: OrderedDict) -> None: 

3840 self._save_group(appstruct, changes) 

3841 super().save_changes(appstruct, changes) 

3842 self._save_task_schedules(appstruct, changes) 

3843 

3844 def _save_group(self, 

3845 appstruct: Dict[str, Any], changes: OrderedDict) -> None: 

3846 patient = cast(Patient, self.object) 

3847 

3848 old_group_id = patient.group.id 

3849 old_group_name = patient.group.name 

3850 new_group_id = appstruct.get(ViewParam.GROUP_ID, None) 

3851 new_group = self.request.dbsession.query(Group).filter( 

3852 Group.id == new_group_id 

3853 ).first() 

3854 

3855 if old_group_id != new_group_id: 

3856 patient._group_id = new_group_id 

3857 changes["group"] = (old_group_name, new_group.name) 

3858 

3859 def _save_task_schedules(self, 

3860 appstruct: Dict[str, Any], 

3861 changes: OrderedDict) -> None: 

3862 

3863 patient = cast(Patient, self.object) 

3864 new_schedules = { 

3865 schedule_dict[ViewParam.SCHEDULE_ID]: schedule_dict 

3866 for schedule_dict in appstruct.get(ViewParam.TASK_SCHEDULES, {}) 

3867 } 

3868 

3869 schedule_query = self.request.dbsession.query(TaskSchedule) 

3870 schedule_name_dict = {schedule.id: schedule.name 

3871 for schedule in schedule_query} 

3872 

3873 old_schedules = {} 

3874 for pts in patient.task_schedules: 

3875 old_schedules[pts.task_schedule.id] = { 

3876 "start_datetime": pts.start_datetime, 

3877 "settings": pts.settings 

3878 } 

3879 

3880 ids_to_add = new_schedules.keys() - old_schedules.keys() 

3881 ids_to_update = old_schedules.keys() & new_schedules.keys() 

3882 ids_to_delete = old_schedules.keys() - new_schedules.keys() 

3883 

3884 for schedule_id in ids_to_add: 

3885 pts = PatientTaskSchedule() 

3886 pts.patient_pk = patient.pk 

3887 pts.schedule_id = schedule_id 

3888 pts.start_datetime = new_schedules[schedule_id]["start_datetime"] 

3889 pts.settings = new_schedules[schedule_id]["settings"] 

3890 

3891 self.request.dbsession.add(pts) 

3892 changes["schedule{} ({})".format( 

3893 schedule_id, schedule_name_dict[schedule_id] 

3894 )] = ((None, None), (pts.start_datetime, pts.settings)) 

3895 

3896 for schedule_id in ids_to_update: 

3897 updates = {} 

3898 

3899 new_start_datetime = new_schedules[schedule_id]["start_datetime"] 

3900 old_start_datetime = old_schedules[schedule_id]["start_datetime"] 

3901 if new_start_datetime != old_start_datetime: 

3902 updates[PatientTaskSchedule.start_datetime] = new_start_datetime 

3903 

3904 new_settings = new_schedules[schedule_id]["settings"] 

3905 old_settings = old_schedules[schedule_id]["settings"] 

3906 if new_settings != old_settings: 

3907 updates[PatientTaskSchedule.settings] = new_settings 

3908 

3909 if len(updates) > 0: 

3910 self.request.dbsession.query(PatientTaskSchedule).filter( 

3911 PatientTaskSchedule.patient_pk == patient.pk, 

3912 PatientTaskSchedule.schedule_id == schedule_id 

3913 ).update(updates, synchronize_session="fetch") 

3914 

3915 changes["schedule{} ({})".format( 

3916 schedule_id, schedule_name_dict[schedule_id] 

3917 )] = ((old_start_datetime, old_settings), 

3918 (new_start_datetime, new_settings)) 

3919 

3920 self.request.dbsession.query(PatientTaskSchedule).filter( 

3921 PatientTaskSchedule.patient_pk == patient.pk, 

3922 PatientTaskSchedule.schedule_id.in_(ids_to_delete) 

3923 ).delete(synchronize_session="fetch") 

3924 

3925 for schedule_id in ids_to_delete: 

3926 old_start_datetime = old_schedules[schedule_id]["start_datetime"] 

3927 old_settings = old_schedules[schedule_id]["settings"] 

3928 

3929 changes["schedule{} ({})".format( 

3930 schedule_id, schedule_name_dict[schedule_id] 

3931 )] = ((old_start_datetime, old_settings), (None, None)) 

3932 

3933 

3934class EditFinalizedPatientView(EditPatientBaseView): 

3935 """ 

3936 View to edit a finalized patient. 

3937 """ 

3938 template_name = "finalized_patient_edit.mako" 

3939 form_class = EditFinalizedPatientForm 

3940 

3941 def get_success_url(self) -> str: 

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

3943 

3944 def get_object(self) -> Any: 

3945 patient = cast(Patient, super().get_object()) 

3946 

3947 if not patient.is_finalized(): 

3948 _ = self.request.gettext 

3949 

3950 raise HTTPBadRequest( 

3951 _("Patient is not editable (likely: not finalized, so a copy " 

3952 "still on a client device)")) 

3953 

3954 return patient 

3955 

3956 

3957@view_config(route_name=Routes.EDIT_FINALIZED_PATIENT, 

3958 permission=Permission.GROUPADMIN, 

3959 http_cache=NEVER_CACHE) 

3960def edit_finalized_patient(req: "CamcopsRequest") -> Response: 

3961 """ 

3962 View to edit details for a patient. 

3963 """ 

3964 return EditFinalizedPatientView(req).dispatch() 

3965 

3966 

3967@view_config(route_name=Routes.EDIT_SERVER_CREATED_PATIENT, 

3968 permission=Permission.GROUPADMIN, 

3969 http_cache=NEVER_CACHE) 

3970def edit_server_created_patient(req: "CamcopsRequest") -> Response: 

3971 """ 

3972 View to edit details for a patient created on the server (for scheduling 

3973 tasks). 

3974 """ 

3975 return EditServerCreatedPatientView(req).dispatch() 

3976 

3977 

3978class AddPatientView(PatientMixin, CreateView): 

3979 """ 

3980 View to add a patient (for task scheduling). 

3981 """ 

3982 form_class = EditServerCreatedPatientForm 

3983 template_name = "patient_add.mako" 

3984 

3985 def get_success_url(self) -> str: 

3986 return self.request.route_url( 

3987 Routes.VIEW_PATIENT_TASK_SCHEDULES 

3988 ) 

3989 

3990 def save_object(self, appstruct: Dict[str, Any]) -> None: 

3991 server_device = Device.get_server_device( 

3992 self.request.dbsession 

3993 ) 

3994 

3995 patient = Patient() 

3996 patient.create_fresh( 

3997 self.request, 

3998 device_id=server_device.id, 

3999 era=ERA_NOW, 

4000 group_id=appstruct.get(ViewParam.GROUP_ID) 

4001 ) 

4002 

4003 for k in EDIT_PATIENT_SIMPLE_PARAMS: 

4004 new_value = appstruct.get(k) 

4005 setattr(patient, k, new_value) 

4006 

4007 patient.save_with_next_available_id(self.request, server_device.id) 

4008 

4009 new_idrefs = [ 

4010 IdNumReference(which_idnum=idrefdict[ViewParam.WHICH_IDNUM], 

4011 idnum_value=idrefdict[ViewParam.IDNUM_VALUE]) 

4012 for idrefdict in appstruct.get(ViewParam.ID_REFERENCES) 

4013 ] 

4014 

4015 for idref in new_idrefs: 

4016 new_idnum = PatientIdNum() 

4017 new_idnum.patient_id = patient.id 

4018 new_idnum.which_idnum = idref.which_idnum 

4019 new_idnum.idnum_value = idref.idnum_value 

4020 new_idnum.create_fresh( 

4021 self.request, 

4022 device_id=server_device.id, 

4023 era=ERA_NOW, 

4024 group_id=appstruct.get(ViewParam.GROUP_ID) 

4025 ) 

4026 

4027 new_idnum.save_with_next_available_id( 

4028 self.request, server_device.id 

4029 ) 

4030 

4031 task_schedules = appstruct.get(ViewParam.TASK_SCHEDULES) 

4032 

4033 self.request.dbsession.commit() 

4034 

4035 for task_schedule in task_schedules: 

4036 schedule_id = task_schedule[ViewParam.SCHEDULE_ID] 

4037 start_datetime = task_schedule[ViewParam.START_DATETIME] 

4038 settings = task_schedule[ViewParam.SETTINGS] 

4039 patient_task_schedule = PatientTaskSchedule() 

4040 patient_task_schedule.patient_pk = patient.pk 

4041 patient_task_schedule.schedule_id = schedule_id 

4042 patient_task_schedule.start_datetime = start_datetime 

4043 patient_task_schedule.settings = settings 

4044 

4045 self.request.dbsession.add(patient_task_schedule) 

4046 

4047 self.object = patient 

4048 

4049 

4050@view_config(route_name=Routes.ADD_PATIENT, 

4051 permission=Permission.GROUPADMIN, 

4052 http_cache=NEVER_CACHE) 

4053def add_patient(req: "CamcopsRequest") -> Response: 

4054 """ 

4055 View to add a patient. 

4056 """ 

4057 return AddPatientView(req).dispatch() 

4058 

4059 

4060class DeleteServerCreatedPatientView(DeleteView): 

4061 """ 

4062 View to delete a patient that had been created on the server. 

4063 """ 

4064 form_class = DeleteServerCreatedPatientForm 

4065 object_class = Patient 

4066 pk_param = ViewParam.SERVER_PK 

4067 server_pk_name = "_pk" 

4068 template_name = "generic_form.mako" 

4069 

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

4071 _ = self.request.gettext 

4072 return { 

4073 "title": _("Delete patient"), 

4074 } 

4075 

4076 def get_success_url(self) -> str: 

4077 return self.request.route_url( 

4078 Routes.VIEW_PATIENT_TASK_SCHEDULES 

4079 ) 

4080 

4081 def delete(self) -> None: 

4082 patient = cast(Patient, self.object) 

4083 

4084 PatientIdNumIndexEntry.unindex_patient( 

4085 patient, self.request.dbsession 

4086 ) 

4087 

4088 patient.delete_with_dependants(self.request) 

4089 

4090 

4091@view_config(route_name=Routes.DELETE_SERVER_CREATED_PATIENT, 

4092 permission=Permission.GROUPADMIN, 

4093 http_cache=NEVER_CACHE) 

4094def delete_server_created_patient(req: "CamcopsRequest") -> Response: 

4095 """ 

4096 Page to delete a patient created on the server (as part of task 

4097 scheduling). 

4098 """ 

4099 return DeleteServerCreatedPatientView(req).dispatch() 

4100 

4101 

4102# ============================================================================= 

4103# Task scheduling 

4104# ============================================================================= 

4105 

4106@view_config(route_name=Routes.VIEW_TASK_SCHEDULES, 

4107 permission=Permission.GROUPADMIN, 

4108 renderer="view_task_schedules.mako", 

4109 http_cache=NEVER_CACHE) 

4110def view_task_schedules(req: "CamcopsRequest") -> Dict[str, Any]: 

4111 """ 

4112 View whole task schedules. 

4113 """ 

4114 rows_per_page = req.get_int_param(ViewParam.ROWS_PER_PAGE, 

4115 DEFAULT_ROWS_PER_PAGE) 

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

4117 group_ids = req.user.ids_of_groups_user_is_admin_for 

4118 q = req.dbsession.query(TaskSchedule).join(TaskSchedule.group).filter( 

4119 TaskSchedule.group_id.in_(group_ids) 

4120 ).order_by(Group.name, TaskSchedule.name) 

4121 page = SqlalchemyOrmPage(query=q, 

4122 page=page_num, 

4123 items_per_page=rows_per_page, 

4124 url_maker=PageUrl(req), 

4125 request=req) 

4126 return dict(page=page) 

4127 

4128 

4129@view_config(route_name=Routes.VIEW_TASK_SCHEDULE_ITEMS, 

4130 permission=Permission.GROUPADMIN, 

4131 renderer="view_task_schedule_items.mako", 

4132 http_cache=NEVER_CACHE) 

4133def view_task_schedule_items(req: "CamcopsRequest") -> Dict[str, Any]: 

4134 """ 

4135 View items within a task schedule. 

4136 """ 

4137 rows_per_page = req.get_int_param(ViewParam.ROWS_PER_PAGE, 

4138 DEFAULT_ROWS_PER_PAGE) 

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

4140 schedule_id = req.get_int_param(ViewParam.SCHEDULE_ID) 

4141 

4142 schedule = req.dbsession.query(TaskSchedule).filter( 

4143 TaskSchedule.id == schedule_id 

4144 ).one_or_none() 

4145 

4146 if schedule is None: 

4147 _ = req.gettext 

4148 raise HTTPBadRequest(_("Schedule does not exist")) 

4149 

4150 q = req.dbsession.query(TaskScheduleItem).filter( 

4151 TaskScheduleItem.schedule_id == schedule_id 

4152 ).order_by(*task_schedule_item_sort_order()) 

4153 page = SqlalchemyOrmPage(query=q, 

4154 page=page_num, 

4155 items_per_page=rows_per_page, 

4156 url_maker=PageUrl(req), 

4157 request=req) 

4158 return dict(page=page, schedule_name=schedule.name) 

4159 

4160 

4161@view_config(route_name=Routes.VIEW_PATIENT_TASK_SCHEDULES, 

4162 permission=Permission.GROUPADMIN, 

4163 renderer="view_patient_task_schedules.mako", 

4164 http_cache=NEVER_CACHE) 

4165def view_patient_task_schedules(req: "CamcopsRequest") -> Dict[str, Any]: 

4166 """ 

4167 View all patients and their assigned schedules (as well as their access 

4168 keys, etc.). 

4169 """ 

4170 server_device = Device.get_server_device(req.dbsession) 

4171 

4172 rows_per_page = req.get_int_param(ViewParam.ROWS_PER_PAGE, 

4173 DEFAULT_ROWS_PER_PAGE) 

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

4175 allowed_group_ids = req.user.ids_of_groups_user_is_admin_for 

4176 # noinspection PyProtectedMember 

4177 q = ( 

4178 req.dbsession.query(Patient) 

4179 .filter(Patient._era == ERA_NOW) 

4180 .filter(Patient._group_id.in_(allowed_group_ids)) 

4181 .filter(Patient._device_id == server_device.id) 

4182 .order_by(Patient.surname, Patient.forename) 

4183 .options(joinedload("task_schedules")) 

4184 .options(joinedload("idnums")) 

4185 ) 

4186 

4187 page = SqlalchemyOrmPage(query=q, 

4188 page=page_num, 

4189 items_per_page=rows_per_page, 

4190 url_maker=PageUrl(req), 

4191 request=req) 

4192 return dict(page=page) 

4193 

4194 

4195@view_config(route_name=Routes.VIEW_PATIENT_TASK_SCHEDULE, 

4196 permission=Permission.GROUPADMIN, 

4197 renderer="view_patient_task_schedule.mako", 

4198 http_cache=NEVER_CACHE) 

4199def view_patient_task_schedule(req: "CamcopsRequest") -> Dict[str, Any]: 

4200 """ 

4201 View scheduled tasks for one patient's specific task schedule. 

4202 """ 

4203 pts_id = req.get_int_param(ViewParam.PATIENT_TASK_SCHEDULE_ID) 

4204 

4205 pts = req.dbsession.query(PatientTaskSchedule).filter( 

4206 PatientTaskSchedule.id == pts_id).options( 

4207 joinedload("patient.idnums"), 

4208 joinedload("task_schedule.items"), 

4209 ).one_or_none() 

4210 

4211 if pts is None: 

4212 _ = req.gettext 

4213 raise HTTPBadRequest(_("Patient's task schedule does not exist")) 

4214 

4215 patient_descriptor = pts.patient.prettystr(req) 

4216 

4217 return dict( 

4218 patient_descriptor=patient_descriptor, 

4219 schedule_name=pts.task_schedule.name, 

4220 task_list=pts.get_list_of_scheduled_tasks(req), 

4221 ) 

4222 

4223 

4224class TaskScheduleMixin(object): 

4225 """ 

4226 Mixin for viewing/editing a task schedule. 

4227 """ 

4228 form_class = EditTaskScheduleForm 

4229 model_form_dict = { 

4230 "name": ViewParam.NAME, 

4231 "group_id": ViewParam.GROUP_ID, 

4232 "email_subject": ViewParam.EMAIL_SUBJECT, 

4233 "email_template": ViewParam.EMAIL_TEMPLATE, 

4234 } 

4235 object_class = TaskSchedule 

4236 request: "CamcopsRequest" 

4237 server_pk_name = "id" 

4238 template_name = "generic_form.mako" 

4239 

4240 def get_success_url(self) -> str: 

4241 return self.request.route_url( 

4242 Routes.VIEW_TASK_SCHEDULES 

4243 ) 

4244 

4245 def get_object(self) -> Any: 

4246 # noinspection PyUnresolvedReferences 

4247 schedule = cast(TaskSchedule, super().get_object()) 

4248 

4249 if not schedule.user_may_edit(self.request): 

4250 _ = self.request.gettext 

4251 raise HTTPBadRequest(_("You a not a group administrator for this " 

4252 "task schedule's group")) 

4253 

4254 return schedule 

4255 

4256 

4257class AddTaskScheduleView(TaskScheduleMixin, CreateView): 

4258 """ 

4259 Django-style view class to add a task schedule. 

4260 """ 

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

4262 _ = self.request.gettext 

4263 return { 

4264 "title": _("Add a task schedule"), 

4265 } 

4266 

4267 

4268class EditTaskScheduleView(TaskScheduleMixin, UpdateView): 

4269 """ 

4270 Django-style view class to edit a task schedule. 

4271 """ 

4272 pk_param = ViewParam.SCHEDULE_ID 

4273 

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

4275 _ = self.request.gettext 

4276 return { 

4277 "title": _("Edit details for a task schedule"), 

4278 } 

4279 

4280 

4281class DeleteTaskScheduleView(TaskScheduleMixin, DeleteView): 

4282 """ 

4283 Django-style view class to delete a task schedule. 

4284 """ 

4285 form_class = DeleteTaskScheduleForm 

4286 pk_param = ViewParam.SCHEDULE_ID 

4287 

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

4289 _ = self.request.gettext 

4290 return { 

4291 "title": _("Delete a task schedule"), 

4292 } 

4293 

4294 

4295@view_config(route_name=Routes.ADD_TASK_SCHEDULE, 

4296 permission=Permission.GROUPADMIN, 

4297 http_cache=NEVER_CACHE) 

4298def add_task_schedule(req: "CamcopsRequest") -> Response: 

4299 """ 

4300 View to add a task schedule. 

4301 """ 

4302 return AddTaskScheduleView(req).dispatch() 

4303 

4304 

4305@view_config(route_name=Routes.EDIT_TASK_SCHEDULE, 

4306 permission=Permission.GROUPADMIN) 

4307def edit_task_schedule(req: "CamcopsRequest") -> Response: 

4308 """ 

4309 View to edit a task schedule. 

4310 """ 

4311 return EditTaskScheduleView(req).dispatch() 

4312 

4313 

4314@view_config(route_name=Routes.DELETE_TASK_SCHEDULE, 

4315 permission=Permission.GROUPADMIN) 

4316def delete_task_schedule(req: "CamcopsRequest") -> Response: 

4317 """ 

4318 View to delete a task schedule. 

4319 """ 

4320 return DeleteTaskScheduleView(req).dispatch() 

4321 

4322 

4323class TaskScheduleItemMixin(object): 

4324 """ 

4325 Mixin for viewing/editing a task schedule items. 

4326 """ 

4327 form_class = EditTaskScheduleItemForm 

4328 template_name = "generic_form.mako" 

4329 model_form_dict = { 

4330 "schedule_id": ViewParam.SCHEDULE_ID, 

4331 "task_table_name": ViewParam.TABLE_NAME, 

4332 "due_from": ViewParam.DUE_FROM, 

4333 # we need to convert due_within to due_by 

4334 } 

4335 object: Any 

4336 # noinspection PyTypeChecker 

4337 object_class = cast(Type["Base"], TaskScheduleItem) 

4338 pk_param = ViewParam.SCHEDULE_ITEM_ID 

4339 request: "CamcopsRequest" 

4340 server_pk_name = "id" 

4341 

4342 def get_success_url(self) -> str: 

4343 # noinspection PyUnresolvedReferences 

4344 return self.request.route_url( 

4345 Routes.VIEW_TASK_SCHEDULE_ITEMS, 

4346 _query={ 

4347 ViewParam.SCHEDULE_ID: self.get_schedule_id(), 

4348 } 

4349 ) 

4350 

4351 

4352class EditTaskScheduleItemMixin(TaskScheduleItemMixin): 

4353 """ 

4354 Django-style view class to edit a task schedule item. 

4355 """ 

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

4357 # noinspection PyUnresolvedReferences 

4358 super().set_object_properties(appstruct) 

4359 

4360 due_from = appstruct.get(ViewParam.DUE_FROM) 

4361 due_within = appstruct.get(ViewParam.DUE_WITHIN) 

4362 

4363 setattr(self.object, "due_by", due_from + due_within) 

4364 

4365 def get_schedule(self) -> TaskSchedule: 

4366 # noinspection PyUnresolvedReferences 

4367 schedule_id = self.get_schedule_id() 

4368 

4369 schedule = self.request.dbsession.query(TaskSchedule).filter( 

4370 TaskSchedule.id == schedule_id 

4371 ).one_or_none() 

4372 

4373 if schedule is None: 

4374 _ = self.request.gettext 

4375 raise HTTPBadRequest( 

4376 f"{_('Missing Task Schedule for id')} {schedule_id}" 

4377 ) 

4378 

4379 if not schedule.user_may_edit(self.request): 

4380 _ = self.request.gettext 

4381 raise HTTPBadRequest(_("You a not a group administrator for this " 

4382 "task schedule's group")) 

4383 

4384 return schedule 

4385 

4386 

4387class AddTaskScheduleItemView(EditTaskScheduleItemMixin, CreateView): 

4388 """ 

4389 Django-style view class to add a task schedule item. 

4390 """ 

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

4392 _ = self.request.gettext 

4393 

4394 schedule = self.get_schedule() 

4395 

4396 return { 

4397 "title": _("Add an item to the {schedule_name} schedule").format( 

4398 schedule_name=schedule.name), 

4399 } 

4400 

4401 def get_schedule_id(self) -> int: 

4402 return self.request.get_int_param(ViewParam.SCHEDULE_ID) 

4403 

4404 def get_form_values(self) -> Dict: 

4405 schedule = self.get_schedule() 

4406 

4407 form_values = super().get_form_values() 

4408 form_values[ViewParam.SCHEDULE_ID] = schedule.id 

4409 

4410 return form_values 

4411 

4412 

4413class EditTaskScheduleItemView(EditTaskScheduleItemMixin, UpdateView): 

4414 """ 

4415 Django-style view class to edit a task schedule item. 

4416 """ 

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

4418 _ = self.request.gettext 

4419 return { 

4420 "title": _("Edit details for a task schedule item"), 

4421 } 

4422 

4423 def get_schedule_id(self) -> int: 

4424 item = cast(TaskScheduleItem, self.object) 

4425 

4426 return item.schedule_id 

4427 

4428 def get_form_values(self) -> Dict: 

4429 schedule = self.get_schedule() 

4430 

4431 form_values = super().get_form_values() 

4432 form_values[ViewParam.SCHEDULE_ID] = schedule.id 

4433 

4434 item = cast(TaskScheduleItem, self.object) 

4435 due_within = item.due_by - form_values[ViewParam.DUE_FROM] 

4436 form_values[ViewParam.DUE_WITHIN] = due_within 

4437 

4438 return form_values 

4439 

4440 

4441class DeleteTaskScheduleItemView(TaskScheduleItemMixin, DeleteView): 

4442 """ 

4443 Django-style view class to delete a task schedule item. 

4444 """ 

4445 form_class = DeleteTaskScheduleItemForm 

4446 

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

4448 _ = self.request.gettext 

4449 return { 

4450 "title": _("Delete a task schedule item"), 

4451 } 

4452 

4453 def get_schedule_id(self) -> int: 

4454 item = cast(TaskScheduleItem, self.object) 

4455 

4456 return item.schedule_id 

4457 

4458 

4459@view_config(route_name=Routes.ADD_TASK_SCHEDULE_ITEM, 

4460 permission=Permission.GROUPADMIN) 

4461def add_task_schedule_item(req: "CamcopsRequest") -> Response: 

4462 """ 

4463 View to add a task schedule item. 

4464 """ 

4465 return AddTaskScheduleItemView(req).dispatch() 

4466 

4467 

4468@view_config(route_name=Routes.EDIT_TASK_SCHEDULE_ITEM, 

4469 permission=Permission.GROUPADMIN) 

4470def edit_task_schedule_item(req: "CamcopsRequest") -> Response: 

4471 """ 

4472 View to edit a task schedule item. 

4473 """ 

4474 return EditTaskScheduleItemView(req).dispatch() 

4475 

4476 

4477@view_config(route_name=Routes.DELETE_TASK_SCHEDULE_ITEM, 

4478 permission=Permission.GROUPADMIN) 

4479def delete_task_schedule_item(req: "CamcopsRequest") -> Response: 

4480 """ 

4481 View to delete a task schedule item. 

4482 """ 

4483 return DeleteTaskScheduleItemView(req).dispatch() 

4484 

4485 

4486# ============================================================================= 

4487# Static assets 

4488# ============================================================================= 

4489# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/assets.html#advanced-static # noqa 

4490 

4491def debug_form_rendering() -> None: 

4492 r""" 

4493 Test code for form rendering. 

4494 

4495 From the command line: 

4496 

4497 .. code-block:: bash 

4498 

4499 # Start in the CamCOPS source root directory. 

4500 # - Needs the "-f" option to follow forks. 

4501 # - "open" doesn't show all files opened. To see what you need, try 

4502 # strace cat /proc/version 

4503 # - ... which shows that "openat" is most useful. 

4504 

4505 strace -f --trace=openat \ 

4506 python -c 'from camcops_server.cc_modules.webview import debug_form_rendering; debug_form_rendering()' \ 

4507 | grep site-packages \ 

4508 | grep -v "\.pyc" 

4509 

4510 This tells us that the templates are files like: 

4511 

4512 .. code-block:: none 

4513 

4514 site-packages/deform/templates/form.pt 

4515 site-packages/deform/templates/select.pt 

4516 site-packages/deform/templates/textinput.pt 

4517 

4518 On 2020-06-29 we are interested in why a newer (Docker) installation 

4519 renders buggy HTML like: 

4520 

4521 .. code-block:: none 

4522 

4523 <select name="which_idnum" id="deformField2" class=" form-control " multiple="False"> 

4524 <option value="1">CPFT RiO number</option> 

4525 <option value="2">NHS number</option> 

4526 <option value="1000">MyHospital number</option> 

4527 </select> 

4528 

4529 ... the bug being that ``multiple="False"`` is wrong; an HTML boolean 

4530 attribute is false when *absent*, not when set to a certain value (see 

4531 https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes#Boolean_Attributes). 

4532 The ``multiple`` attribute of ``<select>`` is a boolean attribute 

4533 (https://developer.mozilla.org/en-US/docs/Web/HTML/Element/select). 

4534 

4535 The ``select.pt`` file indicates that this is controlled by 

4536 ``tal:attributes`` syntax. TAL is Template Attribution Language 

4537 (https://sharptal.readthedocs.io/en/latest/tal.html). 

4538 

4539 TAL is either provided by Zope (given ZPT files) or Chameleon or both. The 

4540 tracing suggests Chameleon. So the TAL language reference is 

4541 https://chameleon.readthedocs.io/en/latest/reference.html. 

4542 

4543 Chameleon changelog is 

4544 https://github.com/malthe/chameleon/blob/master/CHANGES.rst. 

4545 

4546 Multiple sources for ``tal:attributes`` syntax say that a null value 

4547 (presumably: ``None``) is required to omit the attribute, not a false 

4548 value. 

4549 

4550 """ # noqa 

4551 

4552 import sys 

4553 

4554 from camcops_server.cc_modules.cc_debug import makefunc_trace_unique_calls 

4555 from camcops_server.cc_modules.cc_forms import ChooseTrackerForm 

4556 from camcops_server.cc_modules.cc_request import get_core_debugging_request 

4557 

4558 req = get_core_debugging_request() 

4559 form = ChooseTrackerForm(req, as_ctv=False) 

4560 

4561 sys.settrace(makefunc_trace_unique_calls(file_only=True)) 

4562 _ = form.render() 

4563 sys.settrace(None)