Coverage for crateweb/core/admin.py: 72%

559 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-08-27 10:34 -0500

1""" 

2crate_anon/crateweb/core/admin.py 

3 

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

5 

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

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

8 

9 This file is part of CRATE. 

10 

11 CRATE is free software: you can redistribute it and/or modify 

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

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

14 (at your option) any later version. 

15 

16 CRATE is distributed in the hope that it will be useful, 

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

18 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

19 GNU General Public License for more details. 

20 

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

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

23 

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

25 

26**The Django admin site, with parts for researchers (staff), parts for RDBMs 

27(superusers) and parts for developers (superusers with the developer flag 

28set).** 

29 

30""" 

31 

32# NOTE that 

33# - Objects for which a user is not authorized, via get_queryset(), will 

34# causes an Http404 error. 

35# - You can't filter on Python properties via the Django QuerySet ORM. 

36 

37import logging 

38from typing import Any, Dict, Iterable, List, Tuple 

39 

40from cardinal_pythonlib.django.admin import ( 

41 admin_view_fk_link, 

42 admin_view_reverse_fk_links, 

43 disable_bool_icon, 

44) 

45from cardinal_pythonlib.stringfunc import replace_in_list 

46from django import forms 

47from django.conf import settings 

48from django.contrib import admin 

49from django.contrib.admin import SimpleListFilter 

50from django.contrib.auth.models import User 

51from django.contrib.auth.admin import UserAdmin 

52from django.db import transaction 

53from django.db.models import Q, QuerySet 

54from django.http.request import HttpRequest 

55from django.template.defaultfilters import yesno 

56from django.template.loader import render_to_string 

57from django.urls import reverse 

58from django.utils.safestring import mark_safe 

59from django.utils.translation import gettext_lazy 

60 

61from crate_anon.crateweb.config.constants import UrlNames, AdminSiteNames 

62from crate_anon.crateweb.consent.forms import TeamRepAdminForm 

63from crate_anon.crateweb.consent.models import ( 

64 CharityPaymentRecord, 

65 ClinicianResponse, 

66 ConsentMode, 

67 ContactRequest, 

68 Decision, 

69 DummyPatientSourceInfo, 

70 Email, 

71 Leaflet, 

72 Letter, 

73 PatientLookup, 

74 PatientResponse, 

75 Study, 

76 TeamRep, 

77) 

78from crate_anon.crateweb.consent.tasks import ( 

79 process_consent_change, 

80 process_patient_response, 

81 resend_email, 

82) 

83from crate_anon.crateweb.extra.admin import ( 

84 AddOnlyModelAdmin, 

85 AllStaffReadOnlyModelAdmin, 

86 EditOnceOnlyModelAdmin, 

87 EditOnlyModelAdmin, 

88 ReadOnlyModelAdmin, 

89) 

90from crate_anon.crateweb.research.models import ( 

91 ArchiveAttachmentAudit, 

92 ArchiveTemplateAudit, 

93 PatientExplorerAudit, 

94 QueryAudit, 

95) 

96from crate_anon.crateweb.userprofile.models import UserProfile 

97 

98log = logging.getLogger(__name__) 

99 

100 

101# ============================================================================= 

102# Research 

103# ============================================================================= 

104 

105 

106class QueryMgrAdmin(ReadOnlyModelAdmin): 

107 """ 

108 Read-only admin view to inspect SQL queries used by researchers (that is, 

109 to perform a query audit). 

110 """ 

111 

112 model = QueryAudit 

113 # Make all fields read-only (see also ReadOnlyModelAdmin): 

114 readonly_fields = ( 

115 "id", 

116 "when", 

117 "get_user", 

118 "get_sql", 

119 "get_count_only", 

120 "n_records", 

121 "get_failed", 

122 "fail_msg", 

123 ) 

124 fields = readonly_fields # or other things could appear 

125 # Group entries by date conveniently: 

126 date_hierarchy = "when" 

127 # Prefetch related objects (hugely reduces number of SQL queries): 

128 list_select_related = ("query", "query__user") 

129 # What to show in the list: 

130 list_display = ( 

131 "id", 

132 "when", 

133 "get_user", 

134 "get_sql", 

135 "get_count_only", 

136 "n_records", 

137 "get_failed", 

138 "fail_msg", 

139 ) 

140 # Filter on Booleans on the right-hand side: 

141 list_filter = ("count_only", "failed") 

142 # Search text content of these: 

143 search_fields = ("query__sql", "query__user__username") 

144 

145 def get_sql(self, obj: QueryAudit) -> str: 

146 return obj.query.sql 

147 

148 get_sql.short_description = "SQL" 

149 get_sql.admin_order_field = "query__sql" 

150 

151 def get_user(self, obj: QueryAudit) -> str: 

152 return obj.query.user 

153 

154 get_user.short_description = "User" 

155 get_user.admin_order_field = "query__user" 

156 

157 def get_count_only(self, obj: QueryAudit) -> str: 

158 return yesno(obj.count_only) 

159 

160 get_count_only.short_description = "Count only?" 

161 get_count_only.admin_order_field = "count_only" 

162 

163 def get_failed(self, obj: QueryAudit) -> str: 

164 return yesno(obj.failed) 

165 

166 get_failed.short_description = "Failed?" 

167 get_failed.admin_order_field = "failed" 

168 

169 

170class PatientExplorerAuditMgrAdmin(ReadOnlyModelAdmin): 

171 """ 

172 Read-only admin view of 

173 :class:`crate_anon.crateweb.research.models.PatientExplorerAudit` objects. 

174 """ 

175 

176 model = PatientExplorerAudit 

177 # Make all fields read-only (see also ReadOnlyModelAdmin): 

178 readonly_fields = ( 

179 "id", 

180 "when", 

181 "get_user", 

182 "get_details", 

183 "get_count_only", 

184 "n_records", 

185 "get_failed", 

186 "fail_msg", 

187 ) 

188 fields = readonly_fields # or other things could appear 

189 # Group entries by date conveniently: 

190 date_hierarchy = "when" 

191 # Prefetch related objects (hugely reduces number of SQL queries): 

192 list_select_related = ("patient_explorer", "patient_explorer__user") 

193 # What to show in the list: 

194 list_display = readonly_fields 

195 # Filter on Booleans on the right-hand side: 

196 list_filter = ("count_only", "failed") 

197 # Search text content of these: 

198 search_fields = ("patient_explorer__user__username",) 

199 

200 def get_details(self, obj: PatientExplorerAudit) -> str: 

201 return str(obj.patient_explorer.patient_multiquery) 

202 

203 get_details.short_description = "Multiquery" 

204 get_details.admin_order_field = "patient_explorer__patient_multiquery" 

205 

206 def get_user(self, obj: PatientExplorerAudit) -> str: 

207 return obj.patient_explorer.user 

208 

209 get_user.short_description = "User" 

210 get_user.admin_order_field = "patient_explorer__user" 

211 

212 def get_count_only(self, obj: PatientExplorerAudit) -> str: 

213 return yesno(obj.count_only) 

214 

215 get_count_only.short_description = "Count only?" 

216 get_count_only.admin_order_field = "count_only" 

217 

218 def get_failed(self, obj: PatientExplorerAudit) -> str: 

219 return yesno(obj.failed) 

220 

221 get_failed.short_description = "Failed?" 

222 get_failed.admin_order_field = "failed" 

223 

224 

225class ArchiveTemplateAuditMgrAdmin(ReadOnlyModelAdmin): 

226 """ 

227 Read-only admin view of 

228 :class:`crate_anon.crateweb.research.models.ArchiveTemplateAudit` objects. 

229 """ 

230 

231 model = ArchiveTemplateAudit 

232 # Make all fields read-only (see also ReadOnlyModelAdmin): 

233 readonly_fields = ("id", "when", "get_user", "patient_id", "query_string") 

234 fields = readonly_fields # or other things could appear 

235 # Group entries by date conveniently: 

236 date_hierarchy = "when" 

237 # Prefetch related objects (hugely reduces number of SQL queries): 

238 list_select_related = ("user",) 

239 # What to show in the list: 

240 list_display = readonly_fields 

241 search_fields = ("user__username", "patient_id", "query_string") 

242 

243 def get_user(self, obj: ArchiveTemplateAudit) -> str: 

244 return obj.user 

245 

246 get_user.short_description = "User" 

247 get_user.admin_order_field = "query__user" 

248 

249 

250class ArchiveAttachmentAuditMgrAdmin(ReadOnlyModelAdmin): 

251 """ 

252 Read-only admin view of 

253 :class:`crate_anon.crateweb.research.models.ArchiveAttachmentAudit` 

254 objects. 

255 """ 

256 

257 model = ArchiveAttachmentAudit 

258 # Make all fields read-only (see also ReadOnlyModelAdmin): 

259 readonly_fields = ("id", "when", "get_user", "patient_id", "filename") 

260 fields = readonly_fields # or other things could appear 

261 # Group entries by date conveniently: 

262 date_hierarchy = "when" 

263 # Prefetch related objects (hugely reduces number of SQL queries): 

264 list_select_related = ("user",) 

265 # What to show in the list: 

266 list_display = readonly_fields 

267 search_fields = ("user__username", "patient_id", "filename") 

268 

269 def get_user(self, obj: ArchiveAttachmentAudit) -> str: 

270 return obj.user 

271 

272 get_user.short_description = "User" 

273 get_user.admin_order_field = "query__user" 

274 

275 

276# ============================================================================= 

277# Consent 

278# ============================================================================= 

279 

280# ----------------------------------------------------------------------------- 

281# Study 

282# ----------------------------------------------------------------------------- 

283 

284 

285class StudyInline(admin.TabularInline): 

286 """ 

287 Use this to represent :class:`crate_anon.crateweb.consent.models.Study` 

288 inline. 

289 """ 

290 

291 model = Study 

292 

293 

294class StudyMgrAdmin(admin.ModelAdmin): 

295 """ 

296 RDBM admin view on :class:`crate_anon.crateweb.consent.models.Study`. 

297 """ 

298 

299 fields = ( 

300 "institutional_id", 

301 "title", 

302 "lead_researcher", 

303 "registered_at", 

304 "summary", 

305 "summary_is_html", 

306 "search_methods_planned", 

307 "patient_contact", 

308 "include_under_16s", 

309 "include_lack_capacity", 

310 "clinical_trial", 

311 "include_discharged", 

312 "request_direct_approach", 

313 "approved_by_rec", 

314 "rec_reference", 

315 "approved_locally", 

316 "local_approval_at", 

317 "study_details_pdf", 

318 "subject_form_template_pdf", 

319 "researchers", 

320 ) 

321 list_display = ("id", "institutional_id", "title", "lead_researcher") 

322 list_display_links = ("id", "institutional_id", "title") 

323 filter_horizontal = ("researchers",) 

324 

325 

326class StudyResAdmin(AllStaffReadOnlyModelAdmin): 

327 """ 

328 Researcher admin view on RDBM admin view on 

329 :class:`crate_anon.crateweb.consent.models.Study`. 

330 """ 

331 

332 fields = ( 

333 "institutional_id", 

334 "title", 

335 "lead_researcher", 

336 "registered_at", 

337 "summary", 

338 "summary_is_html", 

339 "search_methods_planned", 

340 "patient_contact", 

341 "include_under_16s", 

342 "include_lack_capacity", 

343 "clinical_trial", 

344 "include_discharged", 

345 "request_direct_approach", 

346 "approved_by_rec", 

347 "rec_reference", 

348 "approved_locally", 

349 "local_approval_at", 

350 "study_details_pdf", 

351 "subject_form_template_pdf", 

352 "researchers", 

353 ) 

354 readonly_fields = fields 

355 list_display = ("id", "institutional_id", "title", "lead_researcher") 

356 list_display_links = ("id", "institutional_id", "title") 

357 

358 # Restrict to studies that this researcher is affiliated to 

359 def get_queryset(self, request: HttpRequest) -> QuerySet: 

360 qs = super().get_queryset(request) 

361 return Study.filter_studies_for_researcher(qs, request.user) 

362 

363 

364# ----------------------------------------------------------------------------- 

365# Leaflet 

366# ----------------------------------------------------------------------------- 

367 

368 

369class LeafletMgrAdmin(EditOnlyModelAdmin): 

370 """ 

371 RDBM admin view on :class:`crate_anon.crateweb.consent.models.Leaflet`. 

372 """ 

373 

374 fields = ("name", "pdf") 

375 readonly_fields = ("name",) 

376 

377 

378class LeafletResAdmin(AllStaffReadOnlyModelAdmin): 

379 """ 

380 Researcher admin view on 

381 :class:`crate_anon.crateweb.consent.models.Leaflet`. 

382 """ 

383 

384 fields = ("name", "get_pdf") 

385 readonly_fields = fields 

386 

387 def get_pdf(self, obj: Leaflet) -> str: 

388 if not obj.pdf: 

389 return "(Missing)" 

390 return mark_safe( 

391 '<a href="{}">PDF</a>'.format( 

392 reverse(UrlNames.LEAFLET, args=[obj.name]) 

393 ) 

394 ) 

395 

396 get_pdf.short_description = "Leaflet PDF" 

397 

398 

399# ----------------------------------------------------------------------------- 

400# E-mail 

401# ----------------------------------------------------------------------------- 

402 

403 

404class EmailSentListFilter(SimpleListFilter): 

405 """ 

406 Filter for :class:`crate_anon.crateweb.consent.models.Email` based on 

407 whether they're sent or not. 

408 """ 

409 

410 title = "email sent" 

411 parameter_name = "email_sent" 

412 

413 def lookups( 

414 self, request: HttpRequest, model_admin: admin.ModelAdmin 

415 ) -> Iterable[Tuple[str, str]]: 

416 return ( 

417 ("y", "E-mail sent at least once"), 

418 ("n", "E-mail not sent"), 

419 ) 

420 

421 def queryset(self, request: HttpRequest, queryset: QuerySet) -> QuerySet: 

422 if self.value() == "y": 

423 return queryset.filter(emailtransmission__sent=True).distinct() 

424 if self.value() == "n": 

425 return queryset.exclude(emailtransmission__sent=True) 

426 

427 

428class EmailDevAdmin(ReadOnlyModelAdmin): 

429 """ 

430 Developer admin view on :class:`crate_anon.crateweb.consent.models.Email`. 

431 """ 

432 

433 model = Email 

434 readonly_fields = ( 

435 "id", 

436 "created_at", 

437 "sender", 

438 "recipient", 

439 "subject", 

440 "msg_text", 

441 "get_view_msg_html", 

442 "get_view_attachments", 

443 "to_clinician", 

444 "to_researcher", 

445 "to_patient", 

446 "get_study", 

447 "get_contact_request", 

448 "get_letter", 

449 "get_sent", 

450 "get_transmissions", 

451 ) 

452 fields = readonly_fields # or other things appear 

453 date_hierarchy = "created_at" 

454 list_display = ("id", "created_at", "recipient", "subject", "get_sent") 

455 list_filter = (EmailSentListFilter,) 

456 search_fields = ("recipient", "subject") 

457 actions = ["resend"] 

458 # ... alternative method (per instance): 

459 # https://stackoverflow.com/questions/2805701/is-there-a-way-to-get-custom-django-admin-actions-to-appear-on-the-change-view # noqa: E501 

460 

461 # - We can't use list_select_related for things that have a foreign key to 

462 # us (rather than things we have an FK to). 

463 # - prefetch_related (in the queryset) just uses Python, not SQL. 

464 # - http://blog.roseman.org.uk/2010/01/11/django-patterns-part-2-efficient-reverse-lookups/ # noqa: E501 

465 # Anyway, premature optimization is the root of all evil, and all that. 

466 

467 def get_view_msg_html(self, obj: Email) -> str: 

468 url = reverse(UrlNames.VIEW_EMAIL_HTML, args=[obj.id]) 

469 return mark_safe( 

470 '<a href="{}">View HTML message</a> ({} bytes)'.format( 

471 url, len(obj.msg_html) 

472 ) 

473 ) 

474 

475 get_view_msg_html.short_description = "Message HTML" 

476 

477 def get_view_attachments(self, obj: Email) -> str: 

478 attachments = obj.emailattachment_set.all() 

479 if not attachments: 

480 return "(No attachments)" 

481 html = "" 

482 for i, attachment in enumerate(attachments): 

483 if attachment.exists(): 

484 html += ( 

485 'Attachment {}: <a href="{}"><b>{}</b></a> ' 

486 "({} bytes), sent as <b>{}</b></a><br>" 

487 ).format( 

488 i + 1, 

489 reverse( 

490 UrlNames.VIEW_EMAIL_ATTACHMENT, args=[attachment.id] 

491 ), 

492 attachment.file, 

493 attachment.size(), 

494 attachment.sent_filename, 

495 ) 

496 else: 

497 html += ( 

498 "Attachment {}: <b>{}</b> (missing), " 

499 "sent as <b>{}</b><br>" 

500 ).format( 

501 i + 1, 

502 attachment.file, 

503 attachment.sent_filename, 

504 ) 

505 return mark_safe(html) 

506 

507 get_view_attachments.short_description = "Attachments" 

508 

509 def resend(self, request: HttpRequest, queryset: QuerySet) -> None: 

510 email_ids = [] # type: List[int] 

511 for email in queryset: # type: Email 

512 email_ids.append(email.id) 

513 # transaction.on_commit not required (no changes made to emails) 

514 resend_email.delay(email.id, request.user.id) # Asynchronous 

515 if email_ids: 

516 self.message_user( 

517 request, 

518 f"{len(email_ids)} e-mails were queued for resending: " 

519 f"IDs {str(email_ids)}.", 

520 ) 

521 

522 resend.short_description = "Resend selected e-mails" 

523 

524 def get_transmissions(self, obj: Email) -> str: 

525 return mark_safe( 

526 "<br>".join(str(x) for x in obj.emailtransmission_set.all()) 

527 ) 

528 

529 get_transmissions.short_description = "Transmissions" 

530 

531 def get_sent(self, obj: Email) -> bool: 

532 return obj.has_been_sent() 

533 

534 get_sent.short_description = "Sent" 

535 get_sent.boolean = True 

536 

537 def get_letter(self, obj: Email) -> str: 

538 return mark_safe(admin_view_fk_link(self, obj, "letter")) 

539 

540 get_letter.short_description = "Letter" 

541 

542 def get_study(self, obj: Email) -> str: 

543 return mark_safe(admin_view_fk_link(self, obj, "study")) 

544 

545 get_study.short_description = "Study" 

546 

547 def get_contact_request(self, obj: Email) -> str: 

548 return mark_safe(admin_view_fk_link(self, obj, "contact_request")) 

549 

550 get_contact_request.short_description = "Contact request" 

551 

552 

553class EmailMgrAdmin(EmailDevAdmin): 

554 """ 

555 RDBM admin view on :class:`crate_anon.crateweb.consent.models.Email`. 

556 

557 Restrict to e-mails/information visible to the RDBM. Also, since we're not 

558 inhering from :class:`AllStaffReadOnlyModelAdmin`, give admin read 

559 permissions to all staff. 

560 """ 

561 

562 readonly_fields = ( 

563 "id", 

564 "created_at", 

565 "sender", 

566 "recipient", 

567 "subject", 

568 # subject should not be confidential 

569 # no text, HTML, or attachments 

570 "get_restricted_msg_text", 

571 "get_restricted_msg_html", 

572 "get_restricted_attachments", 

573 "to_clinician", 

574 "to_researcher", 

575 "to_patient", 

576 "get_study", 

577 "get_contact_request", 

578 "get_letter", 

579 "get_sent", 

580 "get_transmissions", 

581 ) 

582 fields = readonly_fields # or other things appear 

583 actions = ["resend"] 

584 

585 def get_queryset(self, request: HttpRequest) -> QuerySet: 

586 qs = ( 

587 super() 

588 .get_queryset(request) 

589 .filter(Q(to_researcher=True) | Q(to_patient=True)) 

590 ) 

591 return qs 

592 

593 @staticmethod 

594 def rdbm_may_view(obj: Email) -> bool: 

595 return obj.to_patient or obj.to_researcher 

596 

597 def get_restricted_msg_text(self, obj: Email) -> str: 

598 if not self.rdbm_may_view(obj): 

599 return "(Not authorized)" 

600 return obj.msg_text 

601 

602 get_restricted_msg_text.short_description = "Message text" 

603 

604 def get_restricted_msg_html(self, obj: Email) -> str: 

605 if not self.rdbm_may_view(obj): 

606 return "(Not authorized)" 

607 return mark_safe(self.get_view_msg_html(obj)) 

608 

609 get_restricted_msg_html.short_description = "Message HTML" 

610 

611 def get_restricted_attachments(self, obj: Email) -> str: 

612 if not self.rdbm_may_view(obj): 

613 return "(Not authorized)" 

614 return mark_safe(self.get_view_attachments(obj)) 

615 

616 get_restricted_attachments.short_description = "Attachments" 

617 

618 

619class EmailResAdmin(EmailDevAdmin): 

620 """ 

621 Researcher admin view on 

622 :class:`crate_anon.crateweb.consent.models.Email`. 

623 

624 Restrict to e-mails visible to a researcher. Also, since we're not inhering 

625 from :class:`AllStaffReadOnlyModelAdmin`, give admin read permissions to 

626 all staff. 

627 """ 

628 

629 readonly_fields = ( 

630 "id", 

631 "created_at", 

632 "sender", 

633 "recipient", 

634 "subject", 

635 "msg_text", 

636 "get_view_msg_html", 

637 "get_view_attachments", 

638 "to_clinician", 

639 "to_researcher", 

640 "to_patient", 

641 "get_study", 

642 "get_contact_request", 

643 "get_letter", 

644 "get_sent", 

645 "get_transmissions", 

646 ) 

647 fields = readonly_fields # or other things appear 

648 actions = None # not [], which allows site-wide things 

649 

650 def get_queryset(self, request: HttpRequest) -> QuerySet: 

651 qs = super().get_queryset(request).filter(to_researcher=True) 

652 studies = Study.filter_studies_for_researcher( 

653 Study.objects.all(), request.user 

654 ) 

655 return qs.filter(study__in=studies) 

656 

657 def has_module_permission(self, request: HttpRequest) -> bool: 

658 return request.user.is_staff 

659 

660 def has_change_permission( 

661 self, request: HttpRequest, obj: Email = None 

662 ) -> bool: 

663 return request.user.is_staff 

664 

665 

666# ----------------------------------------------------------------------------- 

667# Dummy patient source info 

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

669 

670 

671class DummyPatientSourceInfoDevAdmin(admin.ModelAdmin): 

672 """ 

673 Developer admin view on 

674 :class:`crate_anon.crateweb.consent.models.DummyPatientSourceInfo`. 

675 """ 

676 

677 fields = ( 

678 # Patient 

679 "nhs_number", 

680 "pt_dob", 

681 "pt_dod", 

682 "pt_dead", 

683 "pt_discharged", 

684 "pt_discharge_date", 

685 "pt_sex", 

686 "pt_title", 

687 "pt_first_name", 

688 "pt_last_name", 

689 "pt_address_1", 

690 "pt_address_2", 

691 "pt_address_3", 

692 "pt_address_4", 

693 "pt_address_5", 

694 "pt_address_6", 

695 "pt_address_7", 

696 "pt_telephone", 

697 "pt_email", 

698 # GP 

699 "gp_title", 

700 "gp_first_name", 

701 "gp_last_name", 

702 "gp_address_1", 

703 "gp_address_2", 

704 "gp_address_3", 

705 "gp_address_4", 

706 "gp_address_5", 

707 "gp_address_6", 

708 "gp_address_7", 

709 "gp_telephone", 

710 "gp_email", 

711 # Clinician 

712 "clinician_title", 

713 "clinician_first_name", 

714 "clinician_last_name", 

715 "clinician_address_1", 

716 "clinician_address_2", 

717 "clinician_address_3", 

718 "clinician_address_4", 

719 "clinician_address_5", 

720 "clinician_address_6", 

721 "clinician_address_7", 

722 "clinician_telephone", 

723 "clinician_email", 

724 "clinician_is_consultant", 

725 "clinician_signatory_title", 

726 ) 

727 list_display = ("id", "nhs_number", "pt_first_name", "pt_last_name") 

728 list_display_links = ("id", "nhs_number") 

729 search_fields = ("nhs_number", "pt_first_name", "pt_last_name") 

730 

731 

732# ----------------------------------------------------------------------------- 

733# Patient lookup 

734# ----------------------------------------------------------------------------- 

735 

736 

737class PatientLookupDevAdmin(ReadOnlyModelAdmin): 

738 """ 

739 Developer admin view on 

740 :class:`crate_anon.crateweb.consent.models.PatientLookup`. 

741 """ 

742 

743 readonly_fields = ( 

744 # Lookup details 

745 "lookup_at", 

746 "source_db", 

747 "nhs_number", 

748 # Patient 

749 "pt_found", 

750 "pt_local_id_description", 

751 "pt_local_id_number", 

752 "pt_dob", 

753 "pt_dod", 

754 "pt_dead", 

755 "pt_discharged", 

756 "pt_sex", 

757 "pt_title", 

758 "pt_first_name", 

759 "pt_last_name", 

760 "pt_address_1", 

761 "pt_address_2", 

762 "pt_address_3", 

763 "pt_address_4", 

764 "pt_address_5", 

765 "pt_address_6", 

766 "pt_address_7", 

767 "pt_telephone", 

768 "pt_email", 

769 # GP 

770 "gp_found", 

771 "gp_title", 

772 "gp_first_name", 

773 "gp_last_name", 

774 "gp_address_1", 

775 "gp_address_2", 

776 "gp_address_3", 

777 "gp_address_4", 

778 "gp_address_5", 

779 "gp_address_6", 

780 "gp_address_7", 

781 "gp_telephone", 

782 "gp_email", 

783 # Clinician 

784 "clinician_found", 

785 "clinician_title", 

786 "clinician_first_name", 

787 "clinician_last_name", 

788 "clinician_address_1", 

789 "clinician_address_2", 

790 "clinician_address_3", 

791 "clinician_address_4", 

792 "clinician_address_5", 

793 "clinician_address_6", 

794 "clinician_address_7", 

795 "clinician_telephone", 

796 "clinician_email", 

797 "clinician_is_consultant", 

798 "clinician_signatory_title", 

799 # Decisions 

800 "decisions", 

801 "secret_decisions", 

802 # Extras 

803 "get_test_views", 

804 ) 

805 fields = readonly_fields 

806 date_hierarchy = "lookup_at" 

807 list_display = ( 

808 "id", 

809 "nhs_number", 

810 "pt_first_name", 

811 "pt_last_name", 

812 "pt_dob", 

813 ) 

814 search_fields = ("nhs_number", "pt_first_name", "pt_last_name") 

815 

816 def get_test_views(self, obj: PatientLookup) -> str: 

817 return mark_safe( 

818 """ 

819 <a href="{}">Draft letter to patient re first traffic-light 

820 choice (as HTML)</a><br> 

821 <a href="{}">Draft letter to patient re first traffic-light 

822 choice (as PDF)</a> 

823 """.format( 

824 reverse( 

825 UrlNames.DRAFT_FIRST_TRAFFIC_LIGHT_LETTER, 

826 args=[obj.id, "html"], 

827 ), 

828 reverse( 

829 UrlNames.DRAFT_FIRST_TRAFFIC_LIGHT_LETTER, 

830 args=[obj.id, "pdf"], 

831 ), 

832 ) 

833 ) 

834 

835 get_test_views.short_description = "Test views" 

836 

837 

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

839# Consent mode 

840# ----------------------------------------------------------------------------- 

841 

842 

843class ConsentModeInline(admin.TabularInline): 

844 """ 

845 Use this to represent 

846 :class:`crate_anon.crateweb.consent.models.ConsentMode` inline. 

847 """ 

848 

849 model = ConsentMode 

850 

851 

852class ConsentModeAdminForm(forms.ModelForm): 

853 """ 

854 Admin form to edit a 

855 :class:`crate_anon.crateweb.consent.models.ConsentMode`. 

856 """ 

857 

858 def clean(self) -> Dict[str, Any]: 

859 if not self.cleaned_data.get("changed_by_clinician_override"): 

860 kwargs = {} # type: Dict[str, Any] 

861 for field in Decision.FIELDS: 

862 kwargs[field] = self.cleaned_data.get(field) 

863 decision = Decision(**kwargs) 

864 decision.validate_decision() 

865 return self.cleaned_data 

866 

867 

868class ConsentModeMgrAdmin(AddOnlyModelAdmin): 

869 """ 

870 RDBM admin view on a 

871 :class:`crate_anon.crateweb.consent.models.ConsentMode`. 

872 """ 

873 

874 # To switch off the Boolean icons: replace exclude_entirely with 

875 # exclude_entirely_col in the fieldlist, and define the function as: 

876 # 

877 # def exclude_entirely_col(self, obj): 

878 # return obj.exclude_entirely 

879 # exclude_entirely_col.boolean = False 

880 # 

881 # Can use get_fields(self, request, obj=None) and get_readonly_fields(...) 

882 # to customize icon behaviour depending on whether we're adding or 

883 # editing. 

884 

885 form = ConsentModeAdminForm 

886 fields = [ 

887 "nhs_number", 

888 "exclude_entirely", 

889 "consent_mode", 

890 # 'consent_after_discharge', 

891 # 'max_approaches_per_year', 

892 # 'other_requests', 

893 "prefers_email", 

894 "changed_by_clinician_override", 

895 ] + Decision.FIELDS 

896 list_display = ( 

897 "id", 

898 "nhs_number", 

899 "consent_mode", 

900 # 'consent_after_discharge' 

901 "source", 

902 ) 

903 list_display_links = ("id", "nhs_number") 

904 search_fields = ("nhs_number",) 

905 list_filter = ( 

906 "consent_mode", 

907 # 'consent_after_discharge', 

908 "exclude_entirely", 

909 "prefers_email", 

910 ) 

911 date_hierarchy = "created_at" 

912 

913 fields_for_viewing = replace_in_list( 

914 fields, 

915 { 

916 "exclude_entirely": "exclude_entirely2", 

917 # 'consent_after_discharge': 'consent_after_discharge2', 

918 "prefers_email": "prefers_email2", 

919 "changed_by_clinician_override": "changed_by_clinician_override2", 

920 }, 

921 ) 

922 exclude_entirely2 = disable_bool_icon("exclude_entirely", ConsentMode) 

923 consent_after_discharge2 = disable_bool_icon( 

924 "consent_after_discharge", ConsentMode 

925 ) 

926 prefers_email2 = disable_bool_icon("prefers_email", ConsentMode) 

927 changed_by_clinician_override2 = disable_bool_icon( 

928 "changed_by_clinician_override", ConsentMode 

929 ) 

930 

931 # Populate the created_by field automatically, with the two functions below 

932 # https://code.djangoproject.com/wiki/CookBookNewformsAdminAndUser 

933 def save_model( 

934 self, 

935 request: HttpRequest, 

936 obj: ConsentMode, 

937 form: forms.ModelForm, 

938 change: bool, 

939 ) -> None: 

940 obj.current = True 

941 obj.needs_processing = True 

942 obj.created_by = request.user 

943 obj.save() 

944 transaction.on_commit( 

945 lambda: process_consent_change.delay(obj.id) 

946 ) # Asynchronous 

947 # Without transaction.on_commit, we get a RACE CONDITION: 

948 # object is received in the pre-save() state. 

949 self.message_user( 

950 request, 

951 "Consent mode will be changed. You will be e-mailed regarding " 

952 "the letter to patient (+/- re withdrawal of consent to " 

953 "researchers).", 

954 ) 

955 

956 # def save_formset(self, request, form, formset, change): 

957 # if formset.model == ConsentMode: 

958 # instances = formset.save(commit=False) 

959 # for instance in instances: 

960 # instance.created_by = request.user 

961 # instance.save() 

962 # else: 

963 # formset.save() 

964 

965 # Restrict to current ones 

966 def get_queryset(self, request: HttpRequest) -> QuerySet: 

967 qs = super().get_queryset(request) 

968 return qs.filter(current=True) 

969 

970 

971class ConsentModeDevAdmin(ReadOnlyModelAdmin): 

972 """ 

973 Developer admin view on a 

974 :class:`crate_anon.crateweb.consent.models.ConsentMode`. 

975 """ 

976 

977 readonly_fields = ( 

978 [ 

979 "nhs_number", 

980 "current", 

981 "created_at", 

982 "created_by", 

983 "exclude_entirely", 

984 "consent_mode", 

985 "consent_after_discharge", 

986 "max_approaches_per_year", 

987 "other_requests", 

988 "prefers_email", 

989 "changed_by_clinician_override", 

990 "source", 

991 ] 

992 + Decision.FIELDS 

993 + [ 

994 "get_test_views", 

995 ] 

996 ) 

997 fields = readonly_fields 

998 list_display = ( 

999 "id", 

1000 "current", 

1001 "nhs_number", 

1002 "consent_mode", 

1003 # 'consent_after_discharge' 

1004 "source", 

1005 ) 

1006 search_fields = ("nhs_number",) 

1007 list_filter = ( 

1008 "current", 

1009 "consent_mode", 

1010 # 'consent_after_discharge', 

1011 "exclude_entirely", 

1012 "prefers_email", 

1013 ) 

1014 

1015 def get_test_views(self, obj: ConsentMode) -> str: 

1016 return mark_safe( 

1017 """ 

1018 <a href="{}">Draft letter to patient confirming traffic-light 

1019 choice (as HTML)</a><br> 

1020 <a href="{}">Draft letter to patient confirming traffic-light 

1021 choice (as PDF)</a> 

1022 """.format( 

1023 reverse( 

1024 UrlNames.DRAFT_CONFIRM_TRAFFIC_LIGHT_LETTER, 

1025 args=[obj.id, "html"], 

1026 ), 

1027 reverse( 

1028 UrlNames.DRAFT_CONFIRM_TRAFFIC_LIGHT_LETTER, 

1029 args=[obj.id, "pdf"], 

1030 ), 

1031 ) 

1032 ) 

1033 

1034 get_test_views.short_description = "Test views" 

1035 

1036 

1037# ----------------------------------------------------------------------------- 

1038# Team rep 

1039# ----------------------------------------------------------------------------- 

1040 

1041 

1042class TeamRepMgrAdmin(admin.ModelAdmin): 

1043 """ 

1044 RDBM admin view on a :class:`crate_anon.crateweb.consent.models.TeamRep`. 

1045 """ 

1046 

1047 fields = ("team", "user") 

1048 list_display = ("team", "user") 

1049 search_fields = ("team",) 

1050 form = TeamRepAdminForm 

1051 

1052 

1053# ----------------------------------------------------------------------------- 

1054# Charity payments 

1055# ----------------------------------------------------------------------------- 

1056 

1057 

1058class CharityPaymentRecordMgrAdmin(AddOnlyModelAdmin): 

1059 """ 

1060 RDBM admin view on a 

1061 :class:`crate_anon.crateweb.consent.models.CharityPaymentRecord`. 

1062 """ 

1063 

1064 fields = ("payee", "amount") 

1065 fields_for_viewing = fields 

1066 list_display = ("id", "created_at", "payee", "amount") 

1067 list_display_links = ("id", "created_at") 

1068 search_fields = ("payee",) 

1069 date_hierarchy = "created_at" 

1070 

1071 

1072# ----------------------------------------------------------------------------- 

1073# Contact request 

1074# ----------------------------------------------------------------------------- 

1075 

1076 

1077class ClinicianRespondedListFilter(SimpleListFilter): 

1078 """ 

1079 Filter for :class:`crate_anon.crateweb.consent.models.ContactRequest` based 

1080 on whether the clinician has responded. 

1081 """ 

1082 

1083 title = "clinician responded" 

1084 parameter_name = "clinician_responded" 

1085 

1086 def lookups( 

1087 self, request: HttpRequest, model_admin: admin.ModelAdmin 

1088 ) -> Iterable[Tuple[str, str]]: 

1089 return ( 

1090 ("y", "Clinician responded"), 

1091 ("n", "Clinician asked but hasn’t responded"), 

1092 ) 

1093 

1094 def queryset(self, request: HttpRequest, queryset: QuerySet) -> QuerySet: 

1095 if self.value() == "y": 

1096 return queryset.filter(decided_send_to_clinician=True).filter( 

1097 clinician_response__responded=True 

1098 ) 

1099 if self.value() == "n": 

1100 return queryset.filter(decided_send_to_clinician=True).filter( 

1101 clinician_response__responded=False 

1102 ) 

1103 

1104 

1105class ContactRequestMgrAdmin(ReadOnlyModelAdmin): 

1106 """ 

1107 RDBM admin view on 

1108 :class:`crate_anon.crateweb.consent.models.ContactRequest`. 

1109 """ 

1110 

1111 NONCONFIDENTIAL_FIELDS = ( 

1112 "id", 

1113 "created_at", 

1114 "request_by", 

1115 "get_study", 

1116 "request_direct_approach", 

1117 "lookup_nhs_number", 

1118 "lookup_rid", 

1119 "lookup_mrid", 

1120 "processed", 

1121 "get_consent_mode", 

1122 "approaches_in_past_year", 

1123 "decisions", 

1124 "decided_no_action", 

1125 "decided_send_to_researcher", 

1126 "decided_send_to_clinician", 

1127 "clinician_involvement", 

1128 "consent_withdrawn", 

1129 "consent_withdrawn_at", 

1130 "get_letters", 

1131 "get_emails", 

1132 ) 

1133 fields = NONCONFIDENTIAL_FIELDS + ( 

1134 "get_clinician_email_address", 

1135 "get_clinician_responded", 

1136 ) 

1137 readonly_fields = fields 

1138 NONCONFIDENTIAL_LIST_DISPLAY = ( 

1139 "id", 

1140 "created_at", 

1141 "request_by", 

1142 "study", 

1143 "lookup_nhs_number", 

1144 "lookup_rid", 

1145 "lookup_mrid", 

1146 "decided_no_action", 

1147 "decided_send_to_researcher", 

1148 "decided_send_to_clinician", 

1149 "get_clinician_responded", 

1150 ) 

1151 list_display = NONCONFIDENTIAL_LIST_DISPLAY + ( 

1152 "get_clinician_email_address", 

1153 ) 

1154 list_filter = ( 

1155 "decided_no_action", 

1156 "decided_send_to_researcher", 

1157 "decided_send_to_clinician", 

1158 ClinicianRespondedListFilter, 

1159 ) 

1160 list_select_related = ( 

1161 "clinician_response", 

1162 "request_by", 

1163 "study__lead_researcher", 

1164 "patient_lookup", 

1165 "consent_mode", 

1166 ) 

1167 date_hierarchy = "created_at" 

1168 

1169 def get_consent_mode(self, obj: ContactRequest) -> ConsentMode: 

1170 consent_mode = obj.consent_mode 

1171 return consent_mode.consent_mode 

1172 

1173 get_consent_mode.short_description = "Consent mode" 

1174 

1175 def get_study(self, obj: ContactRequest) -> str: 

1176 return mark_safe(admin_view_fk_link(self, obj, "study")) 

1177 

1178 get_study.short_description = "Study" 

1179 

1180 def get_clinician_email_address(self, obj: ContactRequest) -> str: 

1181 if obj.decided_send_to_clinician: 

1182 return obj.patient_lookup.clinician_email 

1183 else: 

1184 return "" 

1185 

1186 get_clinician_email_address.short_description = "Clinician e-mail address" 

1187 

1188 def get_clinician_responded(self, obj: ContactRequest) -> bool: 

1189 if not hasattr(obj, "clinician_response"): 

1190 return False 

1191 return obj.clinician_response.responded 

1192 

1193 get_clinician_responded.short_description = "Clinician responded" 

1194 get_clinician_responded.boolean = True 

1195 

1196 def get_letters(self, obj: ContactRequest) -> str: 

1197 return mark_safe(admin_view_reverse_fk_links(self, obj, "letter_set")) 

1198 

1199 get_letters.short_description = "Letter(s)" 

1200 

1201 def get_emails(self, obj: ContactRequest) -> str: 

1202 return mark_safe(admin_view_reverse_fk_links(self, obj, "email_set")) 

1203 

1204 get_emails.short_description = "E-mail(s)" 

1205 

1206 

1207class ContactRequestResAdmin(ContactRequestMgrAdmin): 

1208 """ 

1209 Researcher admin view on 

1210 :class:`crate_anon.crateweb.consent.models.ContactRequest`. 

1211 """ 

1212 

1213 fields = ContactRequestMgrAdmin.NONCONFIDENTIAL_FIELDS 

1214 readonly_fields = fields 

1215 list_display = ContactRequestMgrAdmin.NONCONFIDENTIAL_LIST_DISPLAY 

1216 

1217 def get_queryset(self, request: HttpRequest) -> QuerySet: 

1218 qs = super().get_queryset(request) 

1219 studies = Study.filter_studies_for_researcher( 

1220 Study.objects.all(), request.user 

1221 ) 

1222 return qs.filter(study__in=studies) 

1223 

1224 def has_module_permission(self, request: HttpRequest) -> bool: 

1225 return request.user.is_staff 

1226 

1227 def has_change_permission( 

1228 self, request: HttpRequest, obj: ContactRequest = None 

1229 ) -> bool: 

1230 return request.user.is_staff 

1231 

1232 

1233class ContactRequestDevAdmin(ContactRequestMgrAdmin): 

1234 """ 

1235 Developer admin view on 

1236 :class:`crate_anon.crateweb.consent.models.ContactRequest`. 

1237 """ 

1238 

1239 fields = ContactRequestMgrAdmin.NONCONFIDENTIAL_FIELDS + ( 

1240 # RDBM can also see 

1241 "get_clinician_email_address", 

1242 "get_link_clinician_email", 

1243 "get_clinician_responded", 

1244 "get_link_clinician_response", 

1245 # properly secret 

1246 "nhs_number", 

1247 "get_patient_lookup", 

1248 # exploratory test views 

1249 "get_test_views", 

1250 ) 

1251 readonly_fields = fields 

1252 list_display = ( 

1253 "id", 

1254 "created_at", 

1255 "request_by", 

1256 "study", 

1257 "nhs_number", 

1258 "decided_no_action", 

1259 "decided_send_to_researcher", 

1260 "decided_send_to_clinician", 

1261 "get_clinician_responded", 

1262 "get_clinician_email_address", 

1263 ) 

1264 

1265 def get_link_clinician_email(self, obj: ContactRequest) -> str: 

1266 return mark_safe(admin_view_reverse_fk_links(self, obj, "email_set")) 

1267 

1268 get_link_clinician_email.short_description = "E-mail to clinician" 

1269 

1270 def get_link_clinician_response(self, obj: ContactRequest) -> str: 

1271 return mark_safe(admin_view_fk_link(self, obj, "clinician_response")) 

1272 

1273 get_link_clinician_response.short_description = "Clinician response" 

1274 

1275 def get_patient_lookup(self, obj: ContactRequest) -> str: 

1276 return mark_safe(admin_view_fk_link(self, obj, "patient_lookup")) 

1277 

1278 get_patient_lookup.short_description = "Patient lookup" 

1279 

1280 def get_consent_mode(self, obj: ContactRequest) -> str: 

1281 return mark_safe(admin_view_fk_link(self, obj, "consent_mode")) 

1282 

1283 get_consent_mode.short_description = "Consent mode" 

1284 

1285 def get_letters(self, obj: ContactRequest) -> str: 

1286 return mark_safe(admin_view_reverse_fk_links(self, obj, "letter_set")) 

1287 

1288 get_letters.short_description = "Letter(s)" 

1289 

1290 def get_test_views(self, obj: ContactRequest) -> str: 

1291 html = """ 

1292 <a href="{}">Draft e-mail to clinician</a><br> 

1293 <a href="{}">Draft letter from clinician to patient re study 

1294 (HTML)</a></br> 

1295 <a href="{}">Draft letter from clinician to patient re study 

1296 (PDF)</a></br> 

1297 <a href="{}">Decision form to patient re study (HTML)</a><br> 

1298 <a href="{}">Decision form to patient re study (PDF)</a><br> 

1299 <a href="{}">Draft approval letter to researcher (HTML)</a><br> 

1300 <a href="{}">Draft approval letter to researcher (PDF)</a><br> 

1301 <a href="{}">Draft approval covering e-mail to researcher</a><br> 

1302 <a href="{}">Draft withdrawal letter to researcher (HTML)</a><br> 

1303 <a href="{}">Draft withdrawal letter to researcher (PDF)</a><br> 

1304 <a href="{}">Draft withdrawal covering e-mail to researcher</a> 

1305 """.format( 

1306 reverse(UrlNames.DRAFT_CLINICIAN_EMAIL, args=[obj.id]), 

1307 reverse( 

1308 UrlNames.DRAFT_LETTER_CLINICIAN_TO_PT_RE_STUDY, 

1309 args=[obj.id, "html"], 

1310 ), 

1311 reverse( 

1312 UrlNames.DRAFT_LETTER_CLINICIAN_TO_PT_RE_STUDY, 

1313 args=[obj.id, "pdf"], 

1314 ), 

1315 reverse( 

1316 UrlNames.DECISION_FORM_TO_PT_RE_STUDY, args=[obj.id, "html"] 

1317 ), 

1318 reverse( 

1319 UrlNames.DECISION_FORM_TO_PT_RE_STUDY, args=[obj.id, "pdf"] 

1320 ), 

1321 reverse(UrlNames.DRAFT_APPROVAL_LETTER, args=[obj.id, "html"]), 

1322 reverse(UrlNames.DRAFT_APPROVAL_LETTER, args=[obj.id, "pdf"]), 

1323 reverse(UrlNames.DRAFT_APPROVAL_EMAIL, args=[obj.id]), 

1324 reverse(UrlNames.DRAFT_WITHDRAWAL_LETTER, args=[obj.id, "html"]), 

1325 reverse(UrlNames.DRAFT_WITHDRAWAL_LETTER, args=[obj.id, "pdf"]), 

1326 reverse(UrlNames.DRAFT_WITHDRAWAL_EMAIL, args=[obj.id]), 

1327 ) 

1328 return mark_safe(html) 

1329 

1330 get_test_views.short_description = "Test views" 

1331 

1332 

1333# ----------------------------------------------------------------------------- 

1334# Clinician response 

1335# ----------------------------------------------------------------------------- 

1336 

1337 

1338class ClinicianResponseDevAdmin(ReadOnlyModelAdmin): 

1339 """ 

1340 Developer admin view on 

1341 :class:`crate_anon.crateweb.consent.models.ClinicianResponse`. 

1342 """ 

1343 

1344 fields = [ 

1345 "created_at", 

1346 "contact_request", 

1347 "token", 

1348 "responded", 

1349 "responded_at", 

1350 "response_route", 

1351 "email_choice", 

1352 "response", 

1353 "veto_reason", 

1354 "ineligible_reason", 

1355 "pt_uncontactable_reason", 

1356 "clinician_confirm_name", 

1357 "charity_amount_due", 

1358 "get_contact_request", 

1359 ] 

1360 readonly_fields = fields 

1361 date_hierarchy = "created_at" 

1362 

1363 def get_contact_request(self, obj: ClinicianResponse) -> str: 

1364 return mark_safe(admin_view_fk_link(self, obj, "contact_request")) 

1365 

1366 get_contact_request.short_description = "Contact request" 

1367 

1368 

1369# ----------------------------------------------------------------------------- 

1370# Patient response 

1371# ----------------------------------------------------------------------------- 

1372 

1373 

1374class PatientResponseAdminForm(forms.ModelForm): 

1375 """ 

1376 Admin form to edit a 

1377 :class:`crate_anon.crateweb.consent.models.PatientResponse`. 

1378 """ 

1379 

1380 def clean(self) -> Dict[str, Any]: 

1381 kwargs = {} # type: Dict[str, Any] 

1382 for field in Decision.FIELDS: 

1383 kwargs[field] = self.cleaned_data.get(field) 

1384 decision = Decision(**kwargs) 

1385 decision.validate_decision() 

1386 return self.cleaned_data 

1387 

1388 

1389class PatientResponseMgrAdmin(EditOnceOnlyModelAdmin): 

1390 """ 

1391 RDBM admin view on a 

1392 :class:`crate_anon.crateweb.consent.models.PatientResponse`. 

1393 """ 

1394 

1395 form = PatientResponseAdminForm 

1396 fields = [ 

1397 "id", 

1398 "created_at", 

1399 "get_contact_request", 

1400 "response", 

1401 ] + Decision.FIELDS 

1402 readonly_fields = ["id", "created_at", "get_contact_request"] 

1403 date_hierarchy = "created_at" 

1404 

1405 # Populate the created_by field automatically, with the two functions below 

1406 def save_model( 

1407 self, 

1408 request: HttpRequest, 

1409 obj: PatientResponse, 

1410 form: forms.ModelForm, 

1411 change: bool, 

1412 ) -> None: 

1413 obj.recorded_by = request.user 

1414 obj.save() 

1415 # log.debug(f"PatientResponse: {modelrepr(obj)}") 

1416 transaction.on_commit( 

1417 lambda: process_patient_response.delay(obj.id) 

1418 ) # Asynchronous 

1419 if obj.response == PatientResponse.YES: 

1420 self.message_user( 

1421 request, 

1422 "Approval to researcher will be generated. You will be " 

1423 "e-mailed if the system can't send it to the researcher.", 

1424 ) 

1425 

1426 def has_change_permission( 

1427 self, request: HttpRequest, obj: PatientResponse = None 

1428 ) -> bool: 

1429 if obj and obj.response: 

1430 return False # already saved 

1431 return True 

1432 

1433 def get_contact_request(self, obj: PatientResponse) -> str: 

1434 return mark_safe(admin_view_fk_link(self, obj, "contact_request")) 

1435 

1436 get_contact_request.short_description = "Contact request" 

1437 

1438 def get_queryset(self, request: HttpRequest) -> QuerySet: 

1439 # Restrict to unresponded ones 

1440 return super().get_queryset(request).filter(response__isnull=True) 

1441 

1442 

1443class PatientResponseDevAdmin(ReadOnlyModelAdmin): 

1444 """ 

1445 Developer admin view on a 

1446 :class:`crate_anon.crateweb.consent.models.PatientResponse`. 

1447 """ 

1448 

1449 fields = PatientResponseMgrAdmin.fields 

1450 readonly_fields = fields 

1451 date_hierarchy = "created_at" 

1452 

1453 def get_contact_request(self, obj: PatientResponse) -> str: 

1454 return mark_safe(admin_view_fk_link(self, obj, "contact_request")) 

1455 

1456 get_contact_request.short_description = "Contact request" 

1457 

1458 

1459# ----------------------------------------------------------------------------- 

1460# Letters 

1461# ----------------------------------------------------------------------------- 

1462 

1463 

1464class LetterSendingStatusFilter(SimpleListFilter): 

1465 """ 

1466 Filter for :class:`crate_anon.crateweb.consent.models.Letter` based on 

1467 whether they're sent or not. 

1468 """ 

1469 

1470 title = "sending status" 

1471 parameter_name = "sending_status" 

1472 

1473 def lookups( 

1474 self, request: HttpRequest, model_admin: admin.ModelAdmin 

1475 ) -> Iterable[Tuple[str, str]]: 

1476 return ( 

1477 ("sent_manually", "Sent manually"), 

1478 ("not_sent_manually", "Not sent manually"), 

1479 ("sent_by_email", "Sent by e-mail"), 

1480 ("not_sent_by_email", "Not sent by e-mail"), 

1481 ("require_sending", "REQUIRE SENDING"), 

1482 ) 

1483 

1484 def queryset(self, request: HttpRequest, queryset: QuerySet) -> QuerySet: 

1485 if self.value() == "sent_manually": 

1486 return queryset.filter(sent_manually_at__isnull=False) 

1487 if self.value() == "not_sent_manually": 

1488 return queryset.filter(sent_manually_at__isnull=True) 

1489 if self.value() == "sent_by_email": 

1490 return queryset.filter( 

1491 email__emailtransmission__sent=True 

1492 ).distinct() 

1493 if self.value() == "not_sent_by_email": 

1494 return queryset.exclude(email__emailtransmission__sent=True) 

1495 if self.value() == "require_sending": 

1496 return ( 

1497 queryset 

1498 # Restrict to letters not sent manually 

1499 .filter(sent_manually_at__isnull=True) 

1500 # Exclude letters successfully sent by e-mail 

1501 .exclude(email__emailtransmission__sent=True) 

1502 ) 

1503 

1504 

1505class LetterDevAdmin(ReadOnlyModelAdmin): 

1506 """ 

1507 Developer admin view on :class:`crate_anon.crateweb.consent.models.Letter`. 

1508 """ 

1509 

1510 fields = ( 

1511 "id", 

1512 "created_at", 

1513 "pdf", 

1514 "to_clinician", 

1515 "to_researcher", 

1516 "to_patient", 

1517 "get_study", 

1518 "get_contact_request", 

1519 "sent_manually_at", 

1520 "get_emails", 

1521 ) 

1522 readonly_fields = fields 

1523 list_display = ( 

1524 "id", 

1525 "created_at", 

1526 "to_clinician", 

1527 "to_researcher", 

1528 "to_patient", 

1529 "study", 

1530 "contact_request", 

1531 "sent_manually_at", 

1532 ) 

1533 list_filter = ( 

1534 LetterSendingStatusFilter, 

1535 "to_clinician", 

1536 "to_researcher", 

1537 "to_patient", 

1538 ) 

1539 list_select_related = ( 

1540 "study__lead_researcher", 

1541 "contact_request", 

1542 ) 

1543 date_hierarchy = "created_at" 

1544 # ... see also https://stackoverflow.com/questions/991926/custom-filter-in-django-admin-on-django-1-3-or-below # noqa: E501 

1545 actions = ["mark_sent"] 

1546 

1547 def mark_sent(self, request: HttpRequest, queryset: QuerySet) -> None: 

1548 ids = [] # type: List[int] 

1549 for letter in queryset: 

1550 letter.mark_sent() 

1551 ids.append(letter.id) 

1552 self.message_user( 

1553 request, 

1554 f"{len(ids)} letter(s) were marked as sent: IDs {str(ids)}.", 

1555 ) 

1556 

1557 mark_sent.short_description = "Mark selected letters as printed/sent" 

1558 

1559 def get_study(self, obj: Letter) -> str: 

1560 return mark_safe(admin_view_fk_link(self, obj, "study")) 

1561 

1562 get_study.short_description = "Study" 

1563 

1564 def get_contact_request(self, obj: Letter) -> str: 

1565 return mark_safe(admin_view_fk_link(self, obj, "contact_request")) 

1566 

1567 get_contact_request.short_description = "Contact request" 

1568 

1569 def get_emails(self, obj: Letter) -> str: 

1570 return mark_safe(admin_view_reverse_fk_links(self, obj, "email_set")) 

1571 

1572 get_emails.short_description = "E-mail(s)" 

1573 

1574 

1575class LetterMgrAdmin(LetterDevAdmin): 

1576 """ 

1577 RDBM admin view on :class:`crate_anon.crateweb.consent.models.Letter`. 

1578 """ 

1579 

1580 def get_queryset(self, request: HttpRequest) -> QuerySet: 

1581 return ( 

1582 super() 

1583 .get_queryset(request) 

1584 .filter(Q(to_researcher=True) | Q(rdbm_may_view=True)) 

1585 ) 

1586 

1587 

1588class LetterResAdmin(LetterDevAdmin): 

1589 """ 

1590 Researcher admin view on 

1591 :class:`crate_anon.crateweb.consent.models.Letter`. 

1592 

1593 Restrict to letters visible to a researcher. 

1594 """ 

1595 

1596 fields = ( 

1597 "id", 

1598 "created_at", 

1599 "get_pdf", 

1600 "to_clinician", 

1601 "to_researcher", 

1602 "to_patient", 

1603 "study", 

1604 "contact_request", 

1605 "sent_manually_at", 

1606 "email", 

1607 ) 

1608 readonly_fields = fields 

1609 

1610 def get_queryset(self, request: HttpRequest) -> QuerySet: 

1611 qs = super().get_queryset(request).filter(to_researcher=True) 

1612 studies = Study.filter_studies_for_researcher( 

1613 Study.objects.all(), request.user 

1614 ) 

1615 return qs.filter(study__in=studies) 

1616 

1617 def has_module_permission(self, request: HttpRequest) -> bool: 

1618 return request.user.is_staff 

1619 

1620 def has_change_permission( 

1621 self, request: HttpRequest, obj: Letter = None 

1622 ) -> bool: 

1623 return request.user.is_staff 

1624 

1625 def get_pdf(self, obj: Letter) -> str: 

1626 if not obj.pdf: 

1627 return "(Missing)" 

1628 return mark_safe( 

1629 '<a href="{}">PDF</a>'.format( 

1630 reverse(UrlNames.LETTER, args=[obj.id]) 

1631 ) 

1632 ) 

1633 

1634 get_pdf.short_description = "Letter PDF" 

1635 

1636 

1637# ============================================================================= 

1638# User profiles 

1639# ============================================================================= 

1640 

1641 

1642class UserProfileInline(admin.StackedInline): 

1643 """ 

1644 Use this to represent 

1645 :class:`crate_anon.crateweb.userprofile.models.UserProfile` inline. 

1646 """ 

1647 

1648 model = UserProfile 

1649 max_num = 1 

1650 can_delete = False 

1651 inlines = [StudyInline] 

1652 fields = ( 

1653 "per_page", 

1654 "line_length", 

1655 "collapse_at_len", 

1656 "collapse_at_n_lines", 

1657 "is_developer", 

1658 "title", 

1659 "telephone", 

1660 "address_1", 

1661 "address_2", 

1662 "address_3", 

1663 "address_4", 

1664 "address_5", 

1665 "address_6", 

1666 "address_7", 

1667 "is_clinician", 

1668 "is_consultant", 

1669 "get_studies_as_lead", 

1670 "get_studies_as_researcher", 

1671 "enough_info_for_researcher", 

1672 ) 

1673 readonly_fields = ( 

1674 "get_studies_as_lead", 

1675 "get_studies_as_researcher", 

1676 "enough_info_for_researcher", 

1677 ) 

1678 

1679 def get_studies_as_lead(self, obj: settings.AUTH_USER_MODEL) -> str: 

1680 studies = obj.user.studies_as_lead.all() 

1681 return mark_safe( 

1682 render_to_string("shortlist_studies.html", {"studies": studies}) 

1683 ) 

1684 

1685 get_studies_as_lead.short_description = "Studies as lead researcher" 

1686 

1687 def get_studies_as_researcher(self, obj: settings.AUTH_USER_MODEL) -> str: 

1688 studies = obj.user.studies_as_researcher.all() 

1689 return mark_safe( 

1690 render_to_string("shortlist_studies.html", {"studies": studies}) 

1691 ) 

1692 

1693 get_studies_as_researcher.short_description = "Studies as researcher" 

1694 

1695 def enough_info_for_researcher( 

1696 self, obj: settings.AUTH_USER_MODEL 

1697 ) -> bool: 

1698 return ( 

1699 bool(obj.title) 

1700 and bool(obj.user.first_name) 

1701 and bool(obj.user.last_name) 

1702 ) 

1703 

1704 enough_info_for_researcher.short_description = ( 

1705 "Enough info for researcher status (title, firstname, lastname)?" 

1706 ) 

1707 enough_info_for_researcher.boolean = True 

1708 

1709 

1710class ExtendedUserMgrAdmin(UserAdmin): 

1711 """ 

1712 RDBM admin view on the Django user class with its associated 

1713 :class:`crate_anon.crateweb.userprofile.models.UserProfile`. 

1714 """ 

1715 

1716 inlines = [UserProfileInline] 

1717 list_display = ( 

1718 "username", 

1719 "email", 

1720 "first_name", 

1721 "last_name", 

1722 "is_staff", 

1723 "enough_info_for_researcher", 

1724 ) 

1725 

1726 def enough_info_for_researcher( 

1727 self, obj: settings.AUTH_USER_MODEL 

1728 ) -> bool: 

1729 return ( 

1730 bool(obj.profile.title) 

1731 and bool(obj.first_name) 

1732 and bool(obj.last_name) 

1733 ) 

1734 

1735 enough_info_for_researcher.short_description = "Enough researcher info?" 

1736 enough_info_for_researcher.boolean = True 

1737 

1738 

1739# ============================================================================= 

1740# Assemble main admin site 

1741# ============================================================================= 

1742# https://stackoverflow.com/questions/4938491/django-admin-change-header-django-administration-text # noqa: E501 

1743# https://stackoverflow.com/questions/3400641/how-do-i-inline-edit-a-django-user-profile-in-the-admin-interface # noqa: E501 

1744 

1745 

1746class MgrAdminSite(admin.AdminSite): 

1747 """ 

1748 RDBM admin site. 

1749 """ 

1750 

1751 # Text to put at the end of each page's <title>. 

1752 site_title = gettext_lazy(settings.RESEARCH_DB_TITLE + " manager admin") 

1753 # Text to put in each page's <h1>. 

1754 site_header = gettext_lazy(settings.RESEARCH_DB_TITLE + ": manager admin") 

1755 # URL for the "View site" link at the top of each admin page. 

1756 site_url = settings.FORCE_SCRIPT_NAME + "/" 

1757 # Text to put at the top of the admin index page. 

1758 index_title = gettext_lazy( 

1759 settings.RESEARCH_DB_TITLE + " site administration for RDBM" 

1760 ) 

1761 index_template = "admin/viewchange_admin_index.html" 

1762 app_index_template = "admin/viewchange_admin_app_index.html" 

1763 

1764 

1765mgr_admin_site = MgrAdminSite(name=AdminSiteNames.MGRADMIN) 

1766mgr_admin_site.disable_action("delete_selected") 

1767# ... particularly for e-mail where we manually specify a bulk action (resend) 

1768# https://docs.djangoproject.com/en/1.8/ref/contrib/admin/actions/ 

1769mgr_admin_site.register(ArchiveAttachmentAudit, ArchiveAttachmentAuditMgrAdmin) 

1770mgr_admin_site.register(ArchiveTemplateAudit, ArchiveTemplateAuditMgrAdmin) 

1771mgr_admin_site.register(CharityPaymentRecord, CharityPaymentRecordMgrAdmin) 

1772mgr_admin_site.register(ConsentMode, ConsentModeMgrAdmin) 

1773mgr_admin_site.register(ContactRequest, ContactRequestMgrAdmin) 

1774mgr_admin_site.register(Email, EmailMgrAdmin) 

1775mgr_admin_site.register(Leaflet, LeafletMgrAdmin) 

1776mgr_admin_site.register(Letter, LetterMgrAdmin) 

1777mgr_admin_site.register(PatientExplorerAudit, PatientExplorerAuditMgrAdmin) 

1778mgr_admin_site.register(PatientResponse, PatientResponseMgrAdmin) 

1779mgr_admin_site.register(QueryAudit, QueryMgrAdmin) 

1780mgr_admin_site.register(Study, StudyMgrAdmin) 

1781mgr_admin_site.register(TeamRep, TeamRepMgrAdmin) 

1782mgr_admin_site.register(User, ExtendedUserMgrAdmin) 

1783 

1784 

1785# ============================================================================= 

1786# Assemble secondary (developer) admin site 

1787# ============================================================================= 

1788# https://stackoverflow.com/questions/4938491/django-admin-change-header-django-administration-text # noqa: E501 

1789# https://stackoverflow.com/questions/3400641/how-do-i-inline-edit-a-django-user-profile-in-the-admin-interface # noqa: E501 

1790 

1791 

1792class DevAdminSite(admin.AdminSite): 

1793 """ 

1794 Developer admin site. 

1795 """ 

1796 

1797 site_title = gettext_lazy(settings.RESEARCH_DB_TITLE + " dev admin") 

1798 site_header = gettext_lazy( 

1799 settings.RESEARCH_DB_TITLE + ": developer admin" 

1800 ) 

1801 site_url = settings.FORCE_SCRIPT_NAME + "/" 

1802 index_title = gettext_lazy( 

1803 settings.RESEARCH_DB_TITLE + " developer administration" 

1804 ) 

1805 index_template = "admin/viewchange_admin_index.html" 

1806 app_index_template = "admin/viewchange_admin_app_index.html" 

1807 

1808 

1809dev_admin_site = DevAdminSite(name=AdminSiteNames.DEVADMIN) 

1810dev_admin_site.disable_action("delete_selected") 

1811# Where no specific DevAdmin version exists, use the MgrAdmin 

1812dev_admin_site.register(ArchiveAttachmentAudit, ArchiveAttachmentAuditMgrAdmin) 

1813dev_admin_site.register(ArchiveTemplateAudit, ArchiveTemplateAuditMgrAdmin) 

1814dev_admin_site.register(CharityPaymentRecord, CharityPaymentRecordMgrAdmin) 

1815dev_admin_site.register(ClinicianResponse, ClinicianResponseDevAdmin) 

1816dev_admin_site.register(ConsentMode, ConsentModeDevAdmin) 

1817dev_admin_site.register(ContactRequest, ContactRequestDevAdmin) 

1818dev_admin_site.register(DummyPatientSourceInfo, DummyPatientSourceInfoDevAdmin) 

1819dev_admin_site.register(Email, EmailDevAdmin) 

1820dev_admin_site.register(Leaflet, LeafletMgrAdmin) 

1821dev_admin_site.register(Letter, LetterDevAdmin) 

1822dev_admin_site.register(PatientExplorerAudit, PatientExplorerAuditMgrAdmin) 

1823dev_admin_site.register(PatientLookup, PatientLookupDevAdmin) 

1824dev_admin_site.register(PatientResponse, PatientResponseDevAdmin) 

1825dev_admin_site.register(QueryAudit, QueryMgrAdmin) 

1826dev_admin_site.register(Study, StudyMgrAdmin) 

1827dev_admin_site.register(TeamRep, TeamRepMgrAdmin) 

1828dev_admin_site.register(User, ExtendedUserMgrAdmin) 

1829 

1830 

1831# ============================================================================= 

1832# Assemble tertiary (researcher) admin site 

1833# ============================================================================= 

1834 

1835 

1836class ResearcherAdminSite(admin.AdminSite): 

1837 """ 

1838 Researcher admin site. 

1839 """ 

1840 

1841 site_title = gettext_lazy( 

1842 settings.RESEARCH_DB_TITLE + " researcher admin views" 

1843 ) 

1844 site_header = gettext_lazy( 

1845 settings.RESEARCH_DB_TITLE + ": researcher admin" 

1846 ) 

1847 site_url = settings.FORCE_SCRIPT_NAME + "/" 

1848 index_title = gettext_lazy("View/manage your studies") 

1849 index_template = "admin/viewchange_admin_index.html" 

1850 app_index_template = "admin/viewchange_admin_app_index.html" 

1851 

1852 

1853res_admin_site = ResearcherAdminSite(name=AdminSiteNames.RESADMIN) 

1854res_admin_site.disable_action("delete_selected") 

1855res_admin_site.register(Study, StudyResAdmin) 

1856res_admin_site.register(Leaflet, LeafletResAdmin) 

1857res_admin_site.register(Email, EmailResAdmin) 

1858res_admin_site.register(Letter, LetterResAdmin) 

1859res_admin_site.register(ContactRequest, ContactRequestResAdmin) 

1860 

1861""" 

1862Problem with non-superusers not seeing any apps: 

1863- https://stackoverflow.com/questions/1929707/django-admin-not-seeing-any-app-permission-problem # noqa: E501 

1864 ... but django.contrib.auth.backends.ModelBackend won't load in INSTALLED_APPS # noqa: E501 

1865- log.debug(f"registered: {res_admin_site.is_registered(Leaflet)!r}") 

1866 ... OK 

1867 ... and anyway, it works for superusers 

1868- app_list is blank in the template; this is set in AdminSite.index() 

1869 (in django/contrib/admin/sites.py) 

1870 So the failure is either in 

1871 model_admin.has_module_permission(request) 

1872 return request.user.has_module_perms(self.opts.app_label) 

1873 or model_admin.get_model_perms(request) 

1874 They're in django/contrib/admin/options.py; ModelAdmin and BaseModelAdmin 

1875 

1876 From a Werkzeug console in home view: 

1877 

1878 from core.admin import LeafletResAdmin 

1879 LeafletResAdmin.has_module_permission(request) 

1880 # ... fails (class not instance!) but shows code, so: 

1881 request.user.has_module_perms('resadmin') 

1882 # ... False - so HERE'S a problem. 

1883 

1884 Solution: add these to relevant ModelAdmin classes: 

1885 

1886 def has_module_permission(self, request: HttpRequest) -> bool: 

1887 return request.user.is_staff 

1888 

1889 def has_change_permission(self, request: HttpRequest, obj=None) -> bool: 

1890 return request.user.is_staff 

1891"""