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
« prev ^ index » next coverage.py v7.8.0, created at 2025-08-27 10:34 -0500
1"""
2crate_anon/crateweb/core/admin.py
4===============================================================================
6 Copyright (C) 2015, University of Cambridge, Department of Psychiatry.
7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
9 This file is part of CRATE.
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.
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.
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/>.
24===============================================================================
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).**
30"""
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.
37import logging
38from typing import Any, Dict, Iterable, List, Tuple
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
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
98log = logging.getLogger(__name__)
101# =============================================================================
102# Research
103# =============================================================================
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 """
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")
145 def get_sql(self, obj: QueryAudit) -> str:
146 return obj.query.sql
148 get_sql.short_description = "SQL"
149 get_sql.admin_order_field = "query__sql"
151 def get_user(self, obj: QueryAudit) -> str:
152 return obj.query.user
154 get_user.short_description = "User"
155 get_user.admin_order_field = "query__user"
157 def get_count_only(self, obj: QueryAudit) -> str:
158 return yesno(obj.count_only)
160 get_count_only.short_description = "Count only?"
161 get_count_only.admin_order_field = "count_only"
163 def get_failed(self, obj: QueryAudit) -> str:
164 return yesno(obj.failed)
166 get_failed.short_description = "Failed?"
167 get_failed.admin_order_field = "failed"
170class PatientExplorerAuditMgrAdmin(ReadOnlyModelAdmin):
171 """
172 Read-only admin view of
173 :class:`crate_anon.crateweb.research.models.PatientExplorerAudit` objects.
174 """
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",)
200 def get_details(self, obj: PatientExplorerAudit) -> str:
201 return str(obj.patient_explorer.patient_multiquery)
203 get_details.short_description = "Multiquery"
204 get_details.admin_order_field = "patient_explorer__patient_multiquery"
206 def get_user(self, obj: PatientExplorerAudit) -> str:
207 return obj.patient_explorer.user
209 get_user.short_description = "User"
210 get_user.admin_order_field = "patient_explorer__user"
212 def get_count_only(self, obj: PatientExplorerAudit) -> str:
213 return yesno(obj.count_only)
215 get_count_only.short_description = "Count only?"
216 get_count_only.admin_order_field = "count_only"
218 def get_failed(self, obj: PatientExplorerAudit) -> str:
219 return yesno(obj.failed)
221 get_failed.short_description = "Failed?"
222 get_failed.admin_order_field = "failed"
225class ArchiveTemplateAuditMgrAdmin(ReadOnlyModelAdmin):
226 """
227 Read-only admin view of
228 :class:`crate_anon.crateweb.research.models.ArchiveTemplateAudit` objects.
229 """
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")
243 def get_user(self, obj: ArchiveTemplateAudit) -> str:
244 return obj.user
246 get_user.short_description = "User"
247 get_user.admin_order_field = "query__user"
250class ArchiveAttachmentAuditMgrAdmin(ReadOnlyModelAdmin):
251 """
252 Read-only admin view of
253 :class:`crate_anon.crateweb.research.models.ArchiveAttachmentAudit`
254 objects.
255 """
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")
269 def get_user(self, obj: ArchiveAttachmentAudit) -> str:
270 return obj.user
272 get_user.short_description = "User"
273 get_user.admin_order_field = "query__user"
276# =============================================================================
277# Consent
278# =============================================================================
280# -----------------------------------------------------------------------------
281# Study
282# -----------------------------------------------------------------------------
285class StudyInline(admin.TabularInline):
286 """
287 Use this to represent :class:`crate_anon.crateweb.consent.models.Study`
288 inline.
289 """
291 model = Study
294class StudyMgrAdmin(admin.ModelAdmin):
295 """
296 RDBM admin view on :class:`crate_anon.crateweb.consent.models.Study`.
297 """
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",)
326class StudyResAdmin(AllStaffReadOnlyModelAdmin):
327 """
328 Researcher admin view on RDBM admin view on
329 :class:`crate_anon.crateweb.consent.models.Study`.
330 """
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")
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)
364# -----------------------------------------------------------------------------
365# Leaflet
366# -----------------------------------------------------------------------------
369class LeafletMgrAdmin(EditOnlyModelAdmin):
370 """
371 RDBM admin view on :class:`crate_anon.crateweb.consent.models.Leaflet`.
372 """
374 fields = ("name", "pdf")
375 readonly_fields = ("name",)
378class LeafletResAdmin(AllStaffReadOnlyModelAdmin):
379 """
380 Researcher admin view on
381 :class:`crate_anon.crateweb.consent.models.Leaflet`.
382 """
384 fields = ("name", "get_pdf")
385 readonly_fields = fields
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 )
396 get_pdf.short_description = "Leaflet PDF"
399# -----------------------------------------------------------------------------
400# E-mail
401# -----------------------------------------------------------------------------
404class EmailSentListFilter(SimpleListFilter):
405 """
406 Filter for :class:`crate_anon.crateweb.consent.models.Email` based on
407 whether they're sent or not.
408 """
410 title = "email sent"
411 parameter_name = "email_sent"
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 )
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)
428class EmailDevAdmin(ReadOnlyModelAdmin):
429 """
430 Developer admin view on :class:`crate_anon.crateweb.consent.models.Email`.
431 """
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
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.
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 )
475 get_view_msg_html.short_description = "Message HTML"
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)
507 get_view_attachments.short_description = "Attachments"
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 )
522 resend.short_description = "Resend selected e-mails"
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 )
529 get_transmissions.short_description = "Transmissions"
531 def get_sent(self, obj: Email) -> bool:
532 return obj.has_been_sent()
534 get_sent.short_description = "Sent"
535 get_sent.boolean = True
537 def get_letter(self, obj: Email) -> str:
538 return mark_safe(admin_view_fk_link(self, obj, "letter"))
540 get_letter.short_description = "Letter"
542 def get_study(self, obj: Email) -> str:
543 return mark_safe(admin_view_fk_link(self, obj, "study"))
545 get_study.short_description = "Study"
547 def get_contact_request(self, obj: Email) -> str:
548 return mark_safe(admin_view_fk_link(self, obj, "contact_request"))
550 get_contact_request.short_description = "Contact request"
553class EmailMgrAdmin(EmailDevAdmin):
554 """
555 RDBM admin view on :class:`crate_anon.crateweb.consent.models.Email`.
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 """
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"]
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
593 @staticmethod
594 def rdbm_may_view(obj: Email) -> bool:
595 return obj.to_patient or obj.to_researcher
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
602 get_restricted_msg_text.short_description = "Message text"
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))
609 get_restricted_msg_html.short_description = "Message HTML"
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))
616 get_restricted_attachments.short_description = "Attachments"
619class EmailResAdmin(EmailDevAdmin):
620 """
621 Researcher admin view on
622 :class:`crate_anon.crateweb.consent.models.Email`.
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 """
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
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)
657 def has_module_permission(self, request: HttpRequest) -> bool:
658 return request.user.is_staff
660 def has_change_permission(
661 self, request: HttpRequest, obj: Email = None
662 ) -> bool:
663 return request.user.is_staff
666# -----------------------------------------------------------------------------
667# Dummy patient source info
668# -----------------------------------------------------------------------------
671class DummyPatientSourceInfoDevAdmin(admin.ModelAdmin):
672 """
673 Developer admin view on
674 :class:`crate_anon.crateweb.consent.models.DummyPatientSourceInfo`.
675 """
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")
732# -----------------------------------------------------------------------------
733# Patient lookup
734# -----------------------------------------------------------------------------
737class PatientLookupDevAdmin(ReadOnlyModelAdmin):
738 """
739 Developer admin view on
740 :class:`crate_anon.crateweb.consent.models.PatientLookup`.
741 """
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")
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 )
835 get_test_views.short_description = "Test views"
838# -----------------------------------------------------------------------------
839# Consent mode
840# -----------------------------------------------------------------------------
843class ConsentModeInline(admin.TabularInline):
844 """
845 Use this to represent
846 :class:`crate_anon.crateweb.consent.models.ConsentMode` inline.
847 """
849 model = ConsentMode
852class ConsentModeAdminForm(forms.ModelForm):
853 """
854 Admin form to edit a
855 :class:`crate_anon.crateweb.consent.models.ConsentMode`.
856 """
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
868class ConsentModeMgrAdmin(AddOnlyModelAdmin):
869 """
870 RDBM admin view on a
871 :class:`crate_anon.crateweb.consent.models.ConsentMode`.
872 """
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.
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"
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 )
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 )
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()
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)
971class ConsentModeDevAdmin(ReadOnlyModelAdmin):
972 """
973 Developer admin view on a
974 :class:`crate_anon.crateweb.consent.models.ConsentMode`.
975 """
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 )
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 )
1034 get_test_views.short_description = "Test views"
1037# -----------------------------------------------------------------------------
1038# Team rep
1039# -----------------------------------------------------------------------------
1042class TeamRepMgrAdmin(admin.ModelAdmin):
1043 """
1044 RDBM admin view on a :class:`crate_anon.crateweb.consent.models.TeamRep`.
1045 """
1047 fields = ("team", "user")
1048 list_display = ("team", "user")
1049 search_fields = ("team",)
1050 form = TeamRepAdminForm
1053# -----------------------------------------------------------------------------
1054# Charity payments
1055# -----------------------------------------------------------------------------
1058class CharityPaymentRecordMgrAdmin(AddOnlyModelAdmin):
1059 """
1060 RDBM admin view on a
1061 :class:`crate_anon.crateweb.consent.models.CharityPaymentRecord`.
1062 """
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"
1072# -----------------------------------------------------------------------------
1073# Contact request
1074# -----------------------------------------------------------------------------
1077class ClinicianRespondedListFilter(SimpleListFilter):
1078 """
1079 Filter for :class:`crate_anon.crateweb.consent.models.ContactRequest` based
1080 on whether the clinician has responded.
1081 """
1083 title = "clinician responded"
1084 parameter_name = "clinician_responded"
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 )
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 )
1105class ContactRequestMgrAdmin(ReadOnlyModelAdmin):
1106 """
1107 RDBM admin view on
1108 :class:`crate_anon.crateweb.consent.models.ContactRequest`.
1109 """
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"
1169 def get_consent_mode(self, obj: ContactRequest) -> ConsentMode:
1170 consent_mode = obj.consent_mode
1171 return consent_mode.consent_mode
1173 get_consent_mode.short_description = "Consent mode"
1175 def get_study(self, obj: ContactRequest) -> str:
1176 return mark_safe(admin_view_fk_link(self, obj, "study"))
1178 get_study.short_description = "Study"
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 ""
1186 get_clinician_email_address.short_description = "Clinician e-mail address"
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
1193 get_clinician_responded.short_description = "Clinician responded"
1194 get_clinician_responded.boolean = True
1196 def get_letters(self, obj: ContactRequest) -> str:
1197 return mark_safe(admin_view_reverse_fk_links(self, obj, "letter_set"))
1199 get_letters.short_description = "Letter(s)"
1201 def get_emails(self, obj: ContactRequest) -> str:
1202 return mark_safe(admin_view_reverse_fk_links(self, obj, "email_set"))
1204 get_emails.short_description = "E-mail(s)"
1207class ContactRequestResAdmin(ContactRequestMgrAdmin):
1208 """
1209 Researcher admin view on
1210 :class:`crate_anon.crateweb.consent.models.ContactRequest`.
1211 """
1213 fields = ContactRequestMgrAdmin.NONCONFIDENTIAL_FIELDS
1214 readonly_fields = fields
1215 list_display = ContactRequestMgrAdmin.NONCONFIDENTIAL_LIST_DISPLAY
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)
1224 def has_module_permission(self, request: HttpRequest) -> bool:
1225 return request.user.is_staff
1227 def has_change_permission(
1228 self, request: HttpRequest, obj: ContactRequest = None
1229 ) -> bool:
1230 return request.user.is_staff
1233class ContactRequestDevAdmin(ContactRequestMgrAdmin):
1234 """
1235 Developer admin view on
1236 :class:`crate_anon.crateweb.consent.models.ContactRequest`.
1237 """
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 )
1265 def get_link_clinician_email(self, obj: ContactRequest) -> str:
1266 return mark_safe(admin_view_reverse_fk_links(self, obj, "email_set"))
1268 get_link_clinician_email.short_description = "E-mail to clinician"
1270 def get_link_clinician_response(self, obj: ContactRequest) -> str:
1271 return mark_safe(admin_view_fk_link(self, obj, "clinician_response"))
1273 get_link_clinician_response.short_description = "Clinician response"
1275 def get_patient_lookup(self, obj: ContactRequest) -> str:
1276 return mark_safe(admin_view_fk_link(self, obj, "patient_lookup"))
1278 get_patient_lookup.short_description = "Patient lookup"
1280 def get_consent_mode(self, obj: ContactRequest) -> str:
1281 return mark_safe(admin_view_fk_link(self, obj, "consent_mode"))
1283 get_consent_mode.short_description = "Consent mode"
1285 def get_letters(self, obj: ContactRequest) -> str:
1286 return mark_safe(admin_view_reverse_fk_links(self, obj, "letter_set"))
1288 get_letters.short_description = "Letter(s)"
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)
1330 get_test_views.short_description = "Test views"
1333# -----------------------------------------------------------------------------
1334# Clinician response
1335# -----------------------------------------------------------------------------
1338class ClinicianResponseDevAdmin(ReadOnlyModelAdmin):
1339 """
1340 Developer admin view on
1341 :class:`crate_anon.crateweb.consent.models.ClinicianResponse`.
1342 """
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"
1363 def get_contact_request(self, obj: ClinicianResponse) -> str:
1364 return mark_safe(admin_view_fk_link(self, obj, "contact_request"))
1366 get_contact_request.short_description = "Contact request"
1369# -----------------------------------------------------------------------------
1370# Patient response
1371# -----------------------------------------------------------------------------
1374class PatientResponseAdminForm(forms.ModelForm):
1375 """
1376 Admin form to edit a
1377 :class:`crate_anon.crateweb.consent.models.PatientResponse`.
1378 """
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
1389class PatientResponseMgrAdmin(EditOnceOnlyModelAdmin):
1390 """
1391 RDBM admin view on a
1392 :class:`crate_anon.crateweb.consent.models.PatientResponse`.
1393 """
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"
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 )
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
1433 def get_contact_request(self, obj: PatientResponse) -> str:
1434 return mark_safe(admin_view_fk_link(self, obj, "contact_request"))
1436 get_contact_request.short_description = "Contact request"
1438 def get_queryset(self, request: HttpRequest) -> QuerySet:
1439 # Restrict to unresponded ones
1440 return super().get_queryset(request).filter(response__isnull=True)
1443class PatientResponseDevAdmin(ReadOnlyModelAdmin):
1444 """
1445 Developer admin view on a
1446 :class:`crate_anon.crateweb.consent.models.PatientResponse`.
1447 """
1449 fields = PatientResponseMgrAdmin.fields
1450 readonly_fields = fields
1451 date_hierarchy = "created_at"
1453 def get_contact_request(self, obj: PatientResponse) -> str:
1454 return mark_safe(admin_view_fk_link(self, obj, "contact_request"))
1456 get_contact_request.short_description = "Contact request"
1459# -----------------------------------------------------------------------------
1460# Letters
1461# -----------------------------------------------------------------------------
1464class LetterSendingStatusFilter(SimpleListFilter):
1465 """
1466 Filter for :class:`crate_anon.crateweb.consent.models.Letter` based on
1467 whether they're sent or not.
1468 """
1470 title = "sending status"
1471 parameter_name = "sending_status"
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 )
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 )
1505class LetterDevAdmin(ReadOnlyModelAdmin):
1506 """
1507 Developer admin view on :class:`crate_anon.crateweb.consent.models.Letter`.
1508 """
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"]
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 )
1557 mark_sent.short_description = "Mark selected letters as printed/sent"
1559 def get_study(self, obj: Letter) -> str:
1560 return mark_safe(admin_view_fk_link(self, obj, "study"))
1562 get_study.short_description = "Study"
1564 def get_contact_request(self, obj: Letter) -> str:
1565 return mark_safe(admin_view_fk_link(self, obj, "contact_request"))
1567 get_contact_request.short_description = "Contact request"
1569 def get_emails(self, obj: Letter) -> str:
1570 return mark_safe(admin_view_reverse_fk_links(self, obj, "email_set"))
1572 get_emails.short_description = "E-mail(s)"
1575class LetterMgrAdmin(LetterDevAdmin):
1576 """
1577 RDBM admin view on :class:`crate_anon.crateweb.consent.models.Letter`.
1578 """
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 )
1588class LetterResAdmin(LetterDevAdmin):
1589 """
1590 Researcher admin view on
1591 :class:`crate_anon.crateweb.consent.models.Letter`.
1593 Restrict to letters visible to a researcher.
1594 """
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
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)
1617 def has_module_permission(self, request: HttpRequest) -> bool:
1618 return request.user.is_staff
1620 def has_change_permission(
1621 self, request: HttpRequest, obj: Letter = None
1622 ) -> bool:
1623 return request.user.is_staff
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 )
1634 get_pdf.short_description = "Letter PDF"
1637# =============================================================================
1638# User profiles
1639# =============================================================================
1642class UserProfileInline(admin.StackedInline):
1643 """
1644 Use this to represent
1645 :class:`crate_anon.crateweb.userprofile.models.UserProfile` inline.
1646 """
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 )
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 )
1685 get_studies_as_lead.short_description = "Studies as lead researcher"
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 )
1693 get_studies_as_researcher.short_description = "Studies as researcher"
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 )
1704 enough_info_for_researcher.short_description = (
1705 "Enough info for researcher status (title, firstname, lastname)?"
1706 )
1707 enough_info_for_researcher.boolean = True
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 """
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 )
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 )
1735 enough_info_for_researcher.short_description = "Enough researcher info?"
1736 enough_info_for_researcher.boolean = True
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
1746class MgrAdminSite(admin.AdminSite):
1747 """
1748 RDBM admin site.
1749 """
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"
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)
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
1792class DevAdminSite(admin.AdminSite):
1793 """
1794 Developer admin site.
1795 """
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"
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)
1831# =============================================================================
1832# Assemble tertiary (researcher) admin site
1833# =============================================================================
1836class ResearcherAdminSite(admin.AdminSite):
1837 """
1838 Researcher admin site.
1839 """
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"
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)
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
1876 From a Werkzeug console in home view:
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.
1884 Solution: add these to relevant ModelAdmin classes:
1886 def has_module_permission(self, request: HttpRequest) -> bool:
1887 return request.user.is_staff
1889 def has_change_permission(self, request: HttpRequest, obj=None) -> bool:
1890 return request.user.is_staff
1891"""