Source code for crate_anon.crateweb.core.admin

#!/usr/bin/env python
# crate_anon/crateweb/core/admin.py

"""
===============================================================================

    Copyright (C) 2015-2018 Rudolf Cardinal (rudolf@pobox.com).

    This file is part of CRATE.

    CRATE is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    CRATE is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with CRATE. If not, see <http://www.gnu.org/licenses/>.

===============================================================================
"""

# NOTE that
# - Objects for which a user is not authorized, via get_queryset(), will
#   causes an Http404 error.
# - You can't filter on Python properties via the Django QuerySet ORM.

import logging
from typing import Any, Dict, Iterable, Tuple

from cardinal_pythonlib.django.admin import (
    admin_view_fk_link,
    admin_view_reverse_fk_links,
    disable_bool_icon,
)
from cardinal_pythonlib.stringfunc import replace_in_list
from django import forms
from django.conf import settings
from django.contrib import admin
from django.contrib.admin import SimpleListFilter
from django.contrib.auth.models import User
from django.contrib.auth.admin import UserAdmin
from django.db import transaction
from django.db.models import Q, QuerySet
from django.http.request import HttpRequest
from django.template.defaultfilters import yesno
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils.translation import ugettext_lazy

from crate_anon.crateweb.extra.admin import (
    AddOnlyModelAdmin,
    AllStaffReadOnlyModelAdmin,
    EditOnceOnlyModelAdmin,
    EditOnlyModelAdmin,
    ReadOnlyModelAdmin,
)
from crate_anon.crateweb.userprofile.models import UserProfile
from crate_anon.crateweb.consent.models import (
    CharityPaymentRecord,
    ClinicianResponse,
    ConsentMode,
    ContactRequest,
    Decision,
    DummyPatientSourceInfo,
    Email,
    Leaflet,
    Letter,
    PatientLookup,
    PatientResponse,
    Study,
    TeamRep,
)
from crate_anon.crateweb.consent.tasks import (
    process_consent_change,
    process_patient_response,
    resend_email,
)
from crate_anon.crateweb.research.models import (
    QueryAudit,
)

log = logging.getLogger(__name__)


# =============================================================================
# Research
# =============================================================================

[docs]class QueryMgrAdmin(ReadOnlyModelAdmin): model = QueryAudit # Make all fields read-only (see also ReadOnlyModelAdmin): readonly_fields = ('id', 'when', 'get_user', 'get_sql', 'get_count_only', 'n_records', 'get_failed', 'fail_msg') fields = readonly_fields # or other things could appear # Group entries by date conveniently: date_hierarchy = 'when' # Prefetch related objects (hugely reduces number of SQL queries): list_select_related = ('query', 'query__user') # What to show in the list: list_display = ('id', 'when', 'get_user', 'get_sql', 'get_count_only', 'n_records', 'get_failed', 'fail_msg') # Filter on Booleans on the right-hand side: list_filter = ('count_only', 'failed') # Search text content of these: search_fields = ('query__sql', 'query__user__username') def get_sql(self, obj: QueryAudit) -> str: return obj.query.sql get_sql.short_description = "SQL" get_sql.admin_order_field = 'query__sql' def get_user(self, obj: QueryAudit) -> str: return obj.query.user get_user.short_description = "User" get_user.admin_order_field = 'query__user' def get_count_only(self, obj: QueryAudit) -> str: return yesno(obj.count_only) get_count_only.short_description = "Count only?" get_count_only.admin_order_field = 'count_only' def get_failed(self, obj: QueryAudit) -> str: return yesno(obj.failed) get_failed.short_description = "Failed?" get_failed.admin_order_field = 'failed'
# ============================================================================= # Consent # ============================================================================= # ----------------------------------------------------------------------------- # Study # -----------------------------------------------------------------------------
[docs]class StudyInline(admin.TabularInline): model = Study
[docs]class StudyMgrAdmin(admin.ModelAdmin): fields = ( 'institutional_id', 'title', 'lead_researcher', 'registered_at', 'summary', 'search_methods_planned', 'patient_contact', 'include_under_16s', 'include_lack_capacity', 'clinical_trial', 'include_discharged', 'request_direct_approach', 'approved_by_rec', 'rec_reference', 'approved_locally', 'local_approval_at', 'study_details_pdf', 'subject_form_template_pdf', 'researchers', ) list_display = ('id', 'institutional_id', 'title', 'lead_researcher') list_display_links = ('id', 'institutional_id', 'title') filter_horizontal = ('researchers', )
[docs]class StudyResAdmin(AllStaffReadOnlyModelAdmin): fields = ( 'institutional_id', 'title', 'lead_researcher', 'registered_at', 'summary', 'search_methods_planned', 'patient_contact', 'include_under_16s', 'include_lack_capacity', 'clinical_trial', 'include_discharged', 'request_direct_approach', 'approved_by_rec', 'rec_reference', 'approved_locally', 'local_approval_at', 'study_details_pdf', 'subject_form_template_pdf', 'researchers', ) readonly_fields = fields list_display = ('id', 'institutional_id', 'title', 'lead_researcher') list_display_links = ('id', 'institutional_id', 'title') # Restrict to studies that this researcher is affiliated to
[docs] def get_queryset(self, request: HttpRequest) -> QuerySet: qs = super().get_queryset(request) return Study.filter_studies_for_researcher(qs, request.user)
# ----------------------------------------------------------------------------- # Leaflet # -----------------------------------------------------------------------------
[docs]class LeafletMgrAdmin(EditOnlyModelAdmin): fields = ('name', 'pdf') readonly_fields = ('name', )
[docs]class LeafletResAdmin(AllStaffReadOnlyModelAdmin): fields = ('name', 'get_pdf') readonly_fields = fields def get_pdf(self, obj: Leaflet) -> str: if not obj.pdf: return "(Missing)" return '<a href="{}">PDF</a>'.format( reverse("leaflet", args=[obj.name])) get_pdf.short_description = "Leaflet PDF" get_pdf.allow_tags = True
# ----------------------------------------------------------------------------- # E-mail # ----------------------------------------------------------------------------- class EmailSentListFilter(SimpleListFilter): title = 'email sent' parameter_name = 'email_sent' def lookups(self, request: HttpRequest, model_admin: admin.ModelAdmin) -> Iterable[Tuple[str, str]]: return ( ('y', "E-mail sent at least once"), ('n', "E-mail not sent"), ) def queryset(self, request: HttpRequest, queryset: QuerySet) -> QuerySet: if self.value() == 'y': return queryset.filter(emailtransmission__sent=True).distinct() if self.value() == 'n': return queryset.exclude(emailtransmission__sent=True)
[docs]class EmailDevAdmin(ReadOnlyModelAdmin): model = Email readonly_fields = ( 'id', 'created_at', 'sender', 'recipient', 'subject', 'msg_text', 'get_view_msg_html', 'get_view_attachments', 'to_clinician', 'to_researcher', 'to_patient', 'get_study', 'get_contact_request', 'get_letter', 'get_sent', 'get_transmissions', ) fields = readonly_fields # or other things appear date_hierarchy = 'created_at' list_display = ('id', 'created_at', 'recipient', 'subject', 'get_sent') list_filter = (EmailSentListFilter, ) search_fields = ('recipient', 'subject') actions = ['resend'] # ... alternative method (per instance): # http://stackoverflow.com/questions/2805701/is-there-a-way-to-get-custom-django-admin-actions-to-appear-on-the-change-view # noqa # - We can't use list_select_related for things that have a foreign key to # us (rather than things we have an FK to). # - prefetch_related (in the queryset) just uses Python, not SQL. # - http://blog.roseman.org.uk/2010/01/11/django-patterns-part-2-efficient-reverse-lookups/ # noqa # Anyway, premature optimization is the root of all evil, and all that. def get_view_msg_html(self, obj: Email) -> str: url = reverse('view_email_html', args=[obj.id]) return '<a href="{}">View HTML message</a> ({} bytes)'.format( url, len(obj.msg_html)) get_view_msg_html.short_description = "Message HTML" get_view_msg_html.allow_tags = True def get_view_attachments(self, obj: Email) -> str: attachments = obj.emailattachment_set.all() if not attachments: return "(No attachments)" html = "" for i, attachment in enumerate(attachments): if attachment.exists(): html += ( 'Attachment {}: <a href="{}"><b>{}</b></a> ' '({} bytes), sent as <b>{}</b></a><br>' ).format( i + 1, reverse('view_email_attachment', args=[attachment.id]), attachment.file, attachment.size(), attachment.sent_filename, ) else: html += ( 'Attachment {}: <b>{}</b> (missing), ' 'sent as <b>{}</b><br>' ).format( i + 1, attachment.file, attachment.sent_filename, ) return html get_view_attachments.short_description = "Attachments" get_view_attachments.allow_tags = True def resend(self, request: HttpRequest, queryset: QuerySet) -> None: email_ids = [] for email in queryset: email_ids.append(email.id) # transaction.on_commit not required (no changes made to emails) resend_email.delay(email.id, request.user.id) # Asynchronous if email_ids: self.message_user( request, "{} e-mails were queued for resending: IDs {}.".format( len(email_ids), str(email_ids))) resend.short_description = "Resend selected e-mails" def get_transmissions(self, obj: Email) -> str: return "<br>".join(str(x) for x in obj.emailtransmission_set.all()) get_transmissions.short_description = "Transmissions" get_transmissions.allow_tags = True def get_sent(self, obj: Email) -> bool: return obj.has_been_sent() get_sent.short_description = "Sent" get_sent.boolean = True def get_letter(self, obj: Email) -> str: return admin_view_fk_link(self, obj, "letter") get_letter.short_description = "Letter" get_letter.allow_tags = True def get_study(self, obj: Email) -> str: return admin_view_fk_link(self, obj, "study") get_study.short_description = "Study" get_study.allow_tags = True def get_contact_request(self, obj: Email) -> str: return admin_view_fk_link(self, obj, "contact_request") get_contact_request.short_description = "Contact request" get_contact_request.allow_tags = True
[docs]class EmailMgrAdmin(EmailDevAdmin): """ Restrict to e-mails/information visible to the RDBM. Also, since we're not inhering from AllStaffReadOnlyModelAdmin, give admin read permissions to all staff. """ readonly_fields = ( 'id', 'created_at', 'sender', 'recipient', 'subject', # subject should not be confidential # no text, HTML, or attachments 'get_restricted_msg_text', 'get_restricted_msg_html', 'get_restricted_attachments', 'to_clinician', 'to_researcher', 'to_patient', 'get_study', 'get_contact_request', 'get_letter', 'get_sent', 'get_transmissions', ) fields = readonly_fields # or other things appear actions = ['resend']
[docs] def get_queryset(self, request: HttpRequest) -> QuerySet: qs = super().get_queryset(request).filter(Q(to_researcher=True) | Q(to_patient=True)) return qs
@staticmethod def rdbm_may_view(obj: Email) -> bool: return obj.to_patient or obj.to_researcher def get_restricted_msg_text(self, obj: Email) -> str: if not self.rdbm_may_view(obj): return "(Not authorized)" return obj.msg_text get_restricted_msg_text.short_description = "Message text" def get_restricted_msg_html(self, obj: Email) -> str: if not self.rdbm_may_view(obj): return "(Not authorized)" return self.get_view_msg_html(obj) get_restricted_msg_html.short_description = "Message HTML" get_restricted_msg_html.allow_tags = True def get_restricted_attachments(self, obj: Email) -> str: if not self.rdbm_may_view(obj): return "(Not authorized)" return self.get_view_attachments(obj) get_restricted_attachments.short_description = "Attachments" get_restricted_attachments.allow_tags = True
[docs]class EmailResAdmin(EmailDevAdmin): """ Restrict to e-mails visible to a researcher. Also, since we're not inhering from AllStaffReadOnlyModelAdmin, give admin read permissions to all staff. """ readonly_fields = ( 'id', 'created_at', 'sender', 'recipient', 'subject', 'msg_text', 'get_view_msg_html', 'get_view_attachments', 'to_clinician', 'to_researcher', 'to_patient', 'get_study', 'get_contact_request', 'get_letter', 'get_sent', 'get_transmissions', ) fields = readonly_fields # or other things appear actions = None # not [], which allows site-wide things
[docs] def get_queryset(self, request: HttpRequest) -> QuerySet: qs = super().get_queryset(request).filter(to_researcher=True) studies = Study.filter_studies_for_researcher(Study.objects.all(), request.user) return qs.filter(study__in=studies)
[docs] def has_module_permission(self, request: HttpRequest) -> bool: return request.user.is_staff
[docs] def has_change_permission(self, request: HttpRequest, obj: Email = None) -> bool: return request.user.is_staff
# ----------------------------------------------------------------------------- # Dummy patient source info # -----------------------------------------------------------------------------
[docs]class DummyPatientSourceInfoDevAdmin(admin.ModelAdmin): fields = ( # Patient 'nhs_number', 'pt_dob', 'pt_dod', 'pt_dead', 'pt_discharged', 'pt_discharge_date', 'pt_sex', 'pt_title', 'pt_first_name', 'pt_last_name', 'pt_address_1', 'pt_address_2', 'pt_address_3', 'pt_address_4', 'pt_address_5', 'pt_address_6', 'pt_address_7', 'pt_telephone', 'pt_email', # GP 'gp_title', 'gp_first_name', 'gp_last_name', 'gp_address_1', 'gp_address_2', 'gp_address_3', 'gp_address_4', 'gp_address_5', 'gp_address_6', 'gp_address_7', 'gp_telephone', 'gp_email', # Clinician 'clinician_title', 'clinician_first_name', 'clinician_last_name', 'clinician_address_1', 'clinician_address_2', 'clinician_address_3', 'clinician_address_4', 'clinician_address_5', 'clinician_address_6', 'clinician_address_7', 'clinician_telephone', 'clinician_email', 'clinician_is_consultant', 'clinician_signatory_title', ) list_display = ('id', 'nhs_number', 'pt_first_name', 'pt_last_name') list_display_links = ('id', 'nhs_number') search_fields = ('nhs_number', 'pt_first_name', 'pt_last_name')
# ----------------------------------------------------------------------------- # Patient lookup # -----------------------------------------------------------------------------
[docs]class PatientLookupDevAdmin(ReadOnlyModelAdmin): readonly_fields = ( # Lookup details 'lookup_at', 'source_db', 'nhs_number', # Patient 'pt_found', 'pt_local_id_description', 'pt_local_id_number', 'pt_dob', 'pt_dod', 'pt_dead', 'pt_discharged', 'pt_sex', 'pt_title', 'pt_first_name', 'pt_last_name', 'pt_address_1', 'pt_address_2', 'pt_address_3', 'pt_address_4', 'pt_address_5', 'pt_address_6', 'pt_address_7', 'pt_telephone', 'pt_email', # GP 'gp_found', 'gp_title', 'gp_first_name', 'gp_last_name', 'gp_address_1', 'gp_address_2', 'gp_address_3', 'gp_address_4', 'gp_address_5', 'gp_address_6', 'gp_address_7', 'gp_telephone', 'gp_email', # Clinician 'clinician_found', 'clinician_title', 'clinician_first_name', 'clinician_last_name', 'clinician_address_1', 'clinician_address_2', 'clinician_address_3', 'clinician_address_4', 'clinician_address_5', 'clinician_address_6', 'clinician_address_7', 'clinician_telephone', 'clinician_email', 'clinician_is_consultant', 'clinician_signatory_title', # Decisions 'decisions', 'secret_decisions', # Extras 'get_test_views', ) fields = readonly_fields date_hierarchy = 'lookup_at' list_display = ('id', 'nhs_number', 'pt_first_name', 'pt_last_name', 'pt_dob') search_fields = ('nhs_number', 'pt_first_name', 'pt_last_name') def get_test_views(self, obj: PatientLookup) -> str: return ''' <a href="{}">Draft letter to patient re first traffic-light choice (as HTML)</a><br> <a href="{}">Draft letter to patient re first traffic-light choice (as PDF)</a> '''.format( reverse('draft_first_traffic_light_letter', args=[obj.id, "html"]), reverse('draft_first_traffic_light_letter', args=[obj.id, "pdf"]), ) get_test_views.short_description = "Test views" get_test_views.allow_tags = True
# ----------------------------------------------------------------------------- # Consent mode # -----------------------------------------------------------------------------
[docs]class ConsentModeInline(admin.TabularInline): model = ConsentMode
[docs]class ConsentModeAdminForm(forms.ModelForm):
[docs] def clean(self) -> Dict[str, Any]: if not self.cleaned_data.get('changed_by_clinician_override'): kwargs = {} for field in Decision.FIELDS: kwargs[field] = self.cleaned_data.get(field) decision = Decision(**kwargs) decision.validate_decision() return self.cleaned_data
[docs]class ConsentModeMgrAdmin(AddOnlyModelAdmin): # To switch off the Boolean icons: replace exclude_entirely with # exclude_entirely_col in the fieldlist, and define the function as: # # def exclude_entirely_col(self, obj): # return obj.exclude_entirely # exclude_entirely_col.boolean = False # # Can use get_fields(self, request, obj=None) and get_readonly_fields(...) # to customize icon behaviour depending on whether we're adding or # editing. form = ConsentModeAdminForm fields = [ 'nhs_number', 'exclude_entirely', 'consent_mode', # 'consent_after_discharge', # 'max_approaches_per_year', # 'other_requests', 'prefers_email', 'changed_by_clinician_override', ] + Decision.FIELDS list_display = ( 'id', 'nhs_number', 'consent_mode', # 'consent_after_discharge' 'source', ) list_display_links = ('id', 'nhs_number') search_fields = ('nhs_number', ) list_filter = ( 'consent_mode', # 'consent_after_discharge', 'exclude_entirely', 'prefers_email' ) date_hierarchy = 'created_at' fields_for_viewing = replace_in_list(fields, { 'exclude_entirely': 'exclude_entirely2', # 'consent_after_discharge': 'consent_after_discharge2', 'prefers_email': 'prefers_email2', 'changed_by_clinician_override': 'changed_by_clinician_override2', }) exclude_entirely2 = disable_bool_icon('exclude_entirely', ConsentMode) consent_after_discharge2 = disable_bool_icon( 'consent_after_discharge', ConsentMode) prefers_email2 = disable_bool_icon('prefers_email', ConsentMode) changed_by_clinician_override2 = disable_bool_icon( 'changed_by_clinician_override', ConsentMode) # Populate the created_by field automatically, with the two functions below # https://code.djangoproject.com/wiki/CookBookNewformsAdminAndUser
[docs] def save_model(self, request: HttpRequest, obj: ConsentMode, form: forms.ModelForm, change: bool) -> None: obj.current = True obj.needs_processing = True obj.created_by = request.user obj.save() transaction.on_commit( lambda: process_consent_change.delay(obj.id) ) # Asynchronous # Without transaction.on_commit, we get a RACE CONDITION: # object is received in the pre-save() state. self.message_user( request, "Consent mode will be changed. You will be e-mailed regarding " "the letter to patient (+/- re withdrawal of consent to " "researchers).")
# def save_formset(self, request, form, formset, change): # if formset.model == ConsentMode: # instances = formset.save(commit=False) # for instance in instances: # instance.created_by = request.user # instance.save() # else: # formset.save() # Restrict to current ones
[docs] def get_queryset(self, request: HttpRequest) -> QuerySet: qs = super().get_queryset(request) return qs.filter(current=True)
[docs]class ConsentModeDevAdmin(ReadOnlyModelAdmin): readonly_fields = [ 'nhs_number', 'current', 'created_at', 'created_by', 'exclude_entirely', 'consent_mode', 'consent_after_discharge', 'max_approaches_per_year', 'other_requests', 'prefers_email', 'changed_by_clinician_override', 'source', ] + Decision.FIELDS + [ 'get_test_views', ] fields = readonly_fields list_display = ( 'id', 'current', 'nhs_number', 'consent_mode', # 'consent_after_discharge' 'source', ) search_fields = ('nhs_number', ) list_filter = ( 'current', 'consent_mode', # 'consent_after_discharge', 'exclude_entirely', 'prefers_email' ) def get_test_views(self, obj: ConsentMode) -> str: return ''' <a href="{}">Draft letter to patient confirming traffic-light choice (as HTML)</a><br> <a href="{}">Draft letter to patient confirming traffic-light choice (as PDF)</a> '''.format( reverse('draft_confirm_traffic_light_letter', args=[obj.id, "html"]), reverse('draft_confirm_traffic_light_letter', args=[obj.id, "pdf"]), ) get_test_views.short_description = "Test views" get_test_views.allow_tags = True
# ----------------------------------------------------------------------------- # Team rep # -----------------------------------------------------------------------------
[docs]class TeamRepMgrAdmin(admin.ModelAdmin): fields = ('team', 'user') list_display = ('team', 'user') search_fields = ('team', )
# ----------------------------------------------------------------------------- # Charity payments # -----------------------------------------------------------------------------
[docs]class CharityPaymentRecordMgrAdmin(AddOnlyModelAdmin): fields = ('payee', 'amount') fields_for_viewing = fields list_display = ('id', 'created_at', 'payee', 'amount') list_display_links = ('id', 'created_at') search_fields = ('payee', ) date_hierarchy = 'created_at'
# ----------------------------------------------------------------------------- # Contact request # ----------------------------------------------------------------------------- class ClinicianRespondedListFilter(SimpleListFilter): title = 'clinician responded' parameter_name = 'clinician_responded' def lookups(self, request: HttpRequest, model_admin: admin.ModelAdmin) -> Iterable[Tuple[str, str]]: return ( ('y', "Clinician responded"), ('n', "Clinician asked but hasn’t responded"), ) def queryset(self, request: HttpRequest, queryset: QuerySet) -> QuerySet: if self.value() == 'y': return ( queryset .filter(decided_send_to_clinician=True) .filter(clinician_response__responded=True) ) if self.value() == 'n': return ( queryset .filter(decided_send_to_clinician=True) .filter(clinician_response__responded=False) )
[docs]class ContactRequestMgrAdmin(ReadOnlyModelAdmin): NONCONFIDENTIAL_FIELDS = ( 'id', 'created_at', 'request_by', 'get_study', 'request_direct_approach', 'lookup_nhs_number', 'lookup_rid', 'lookup_mrid', 'processed', 'get_consent_mode', 'approaches_in_past_year', 'decisions', 'decided_no_action', 'decided_send_to_researcher', 'decided_send_to_clinician', 'clinician_involvement', 'consent_withdrawn', 'consent_withdrawn_at', 'get_letters', 'get_emails', ) fields = NONCONFIDENTIAL_FIELDS + ('get_clinician_email_address', 'get_clinician_responded') readonly_fields = fields NONCONFIDENTIAL_LIST_DISPLAY = ( 'id', 'created_at', 'request_by', 'study', 'lookup_nhs_number', 'lookup_rid', 'lookup_mrid', 'decided_no_action', 'decided_send_to_researcher', 'decided_send_to_clinician', 'get_clinician_responded', ) list_display = NONCONFIDENTIAL_LIST_DISPLAY + ( 'get_clinician_email_address', ) list_filter = ('decided_no_action', 'decided_send_to_researcher', 'decided_send_to_clinician', ClinicianRespondedListFilter) list_select_related = ( 'clinician_response', 'request_by', 'study__lead_researcher', 'patient_lookup', 'consent_mode', ) date_hierarchy = 'created_at' def get_consent_mode(self, obj: ContactRequest) -> ConsentMode: consent_mode = obj.consent_mode return consent_mode.consent_mode get_consent_mode.short_description = "Consent mode" def get_study(self, obj: ContactRequest) -> str: return admin_view_fk_link(self, obj, "study") get_study.allow_tags = True get_study.short_description = "Study" def get_clinician_email_address(self, obj: ContactRequest) -> str: if obj.decided_send_to_clinician: return obj.patient_lookup.clinician_email else: return '' get_clinician_email_address.short_description = "Clinician e-mail address" def get_clinician_responded(self, obj: ContactRequest) -> bool: if not hasattr(obj, 'clinician_response'): return False return obj.clinician_response.responded get_clinician_responded.short_description = "Clinician responded" get_clinician_responded.boolean = True def get_letters(self, obj: ContactRequest) -> str: return admin_view_reverse_fk_links(self, obj, "letter_set") get_letters.short_description = "Letter(s)" get_letters.allow_tags = True def get_emails(self, obj: ContactRequest) -> str: return admin_view_reverse_fk_links(self, obj, "email_set") get_emails.short_description = "E-mail(s)" get_emails.allow_tags = True
[docs]class ContactRequestResAdmin(ContactRequestMgrAdmin): fields = ContactRequestMgrAdmin.NONCONFIDENTIAL_FIELDS readonly_fields = fields list_display = ContactRequestMgrAdmin.NONCONFIDENTIAL_LIST_DISPLAY
[docs] def get_queryset(self, request: HttpRequest) -> QuerySet: qs = super().get_queryset(request) studies = Study.filter_studies_for_researcher(Study.objects.all(), request.user) return qs.filter(study__in=studies)
[docs] def has_module_permission(self, request: HttpRequest) -> bool: return request.user.is_staff
[docs] def has_change_permission(self, request: HttpRequest, obj: ContactRequest = None) -> bool: return request.user.is_staff
[docs]class ContactRequestDevAdmin(ContactRequestMgrAdmin): fields = ContactRequestMgrAdmin.NONCONFIDENTIAL_FIELDS + ( # RDBM can also see 'get_clinician_email_address', 'get_link_clinician_email', 'get_clinician_responded', 'get_link_clinician_response', # properly secret 'nhs_number', 'get_patient_lookup', # exploratory test views 'get_test_views', ) readonly_fields = fields list_display = ( 'id', 'created_at', 'request_by', 'study', 'nhs_number', 'decided_no_action', 'decided_send_to_researcher', 'decided_send_to_clinician', 'get_clinician_responded', 'get_clinician_email_address', ) def get_link_clinician_email(self, obj: ContactRequest) -> str: return admin_view_reverse_fk_links(self, obj, "email_set") get_link_clinician_email.short_description = "E-mail to clinician" get_link_clinician_email.allow_tags = True def get_link_clinician_response(self, obj: ContactRequest) -> str: return admin_view_fk_link(self, obj, "clinician_response") get_link_clinician_response.short_description = "Clinician response" get_link_clinician_response.allow_tags = True def get_patient_lookup(self, obj: ContactRequest) -> str: return admin_view_fk_link(self, obj, "patient_lookup") get_patient_lookup.short_description = "Patient lookup" get_patient_lookup.allow_tags = True def get_consent_mode(self, obj: ContactRequest) -> str: return admin_view_fk_link(self, obj, "consent_mode") get_consent_mode.short_description = "Consent mode" get_consent_mode.allow_tags = True def get_letters(self, obj: ContactRequest) -> str: return admin_view_reverse_fk_links(self, obj, "letter_set") get_letters.allow_tags = True get_letters.short_description = "Letter(s)" def get_test_views(self, obj: ContactRequest) -> str: return ''' <a href="{}">Draft e-mail to clinician</a><br> <a href="{}">Draft letter from clinician to patient re study (HTML)</a></br> <a href="{}">Draft letter from clinician to patient re study (PDF)</a></br> <a href="{}">Decision form to patient re study (HTML)</a><br> <a href="{}">Decision form to patient re study (PDF)</a><br> <a href="{}">Draft approval letter to researcher (HTML)</a><br> <a href="{}">Draft approval letter to researcher (PDF)</a><br> <a href="{}">Draft approval covering e-mail to researcher</a><br> <a href="{}">Draft withdrawal letter to researcher (HTML)</a><br> <a href="{}">Draft withdrawal letter to researcher (PDF)</a><br> <a href="{}">Draft withdrawal covering e-mail to researcher</a> '''.format( reverse('draft_clinician_email', args=[obj.id]), reverse('draft_letter_clinician_to_pt_re_study', args=[obj.id, "html"]), reverse('draft_letter_clinician_to_pt_re_study', args=[obj.id, "pdf"]), reverse('decision_form_to_pt_re_study', args=[obj.id, "html"]), reverse('decision_form_to_pt_re_study', args=[obj.id, "pdf"]), reverse('draft_approval_letter', args=[obj.id, "html"]), reverse('draft_approval_letter', args=[obj.id, "pdf"]), reverse('draft_approval_email', args=[obj.id]), reverse('draft_withdrawal_letter', args=[obj.id, "html"]), reverse('draft_withdrawal_letter', args=[obj.id, "pdf"]), reverse('draft_withdrawal_email', args=[obj.id]), ) get_test_views.short_description = "Test views" get_test_views.allow_tags = True
# ----------------------------------------------------------------------------- # Clinician response # -----------------------------------------------------------------------------
[docs]class ClinicianResponseDevAdmin(ReadOnlyModelAdmin): fields = [ 'created_at', 'contact_request', 'token', 'responded', 'responded_at', 'response_route', 'email_choice', 'response', 'veto_reason', 'ineligible_reason', 'pt_uncontactable_reason', 'clinician_confirm_name', 'charity_amount_due', 'get_contact_request', ] readonly_fields = fields date_hierarchy = 'created_at' def get_contact_request(self, obj: ClinicianResponse) -> str: return admin_view_fk_link(self, obj, "contact_request") get_contact_request.allow_tags = True get_contact_request.short_description = "Contact request"
# ----------------------------------------------------------------------------- # Patient response # -----------------------------------------------------------------------------
[docs]class PatientResponseAdminForm(forms.ModelForm):
[docs] def clean(self) -> Dict[str, Any]: kwargs = {} for field in Decision.FIELDS: kwargs[field] = self.cleaned_data.get(field) decision = Decision(**kwargs) decision.validate_decision() return self.cleaned_data
[docs]class PatientResponseMgrAdmin(EditOnceOnlyModelAdmin): form = PatientResponseAdminForm fields = [ 'id', 'created_at', 'get_contact_request', 'response' ] + Decision.FIELDS readonly_fields = ['id', 'created_at', 'get_contact_request'] date_hierarchy = 'created_at' # Populate the created_by field automatically, with the two functions below
[docs] def save_model(self, request: HttpRequest, obj: PatientResponse, form: forms.ModelForm, change: bool) -> None: obj.recorded_by = request.user obj.save() # log.debug("PatientResponse: {}".format(modelrepr(obj))) transaction.on_commit( lambda: process_patient_response.delay(obj.id) ) # Asynchronous if obj.response == PatientResponse.YES: self.message_user( request, "Approval to researcher will be generated. You will be " "e-mailed if the system can't send it to the researcher.")
[docs] def has_change_permission(self, request: HttpRequest, obj: PatientResponse = None) -> bool: if obj and obj.response: return False # already saved return True
def get_contact_request(self, obj: PatientResponse) -> str: return admin_view_fk_link(self, obj, "contact_request") get_contact_request.short_description = "Contact request" get_contact_request.allow_tags = True
[docs] def get_queryset(self, request: HttpRequest) -> QuerySet: # Restrict to unresponded ones return super().get_queryset(request).filter(response__isnull=True)
[docs]class PatientResponseDevAdmin(ReadOnlyModelAdmin): fields = PatientResponseMgrAdmin.fields readonly_fields = fields date_hierarchy = 'created_at' def get_contact_request(self, obj: PatientResponse) -> str: return admin_view_fk_link(self, obj, "contact_request") get_contact_request.short_description = "Contact request" get_contact_request.allow_tags = True
# ----------------------------------------------------------------------------- # Letters # ----------------------------------------------------------------------------- class LetterSendingStatusFilter(SimpleListFilter): title = "sending status" parameter_name = 'sending_status' def lookups(self, request: HttpRequest, model_admin: admin.ModelAdmin) -> Iterable[Tuple[str, str]]: return ( ('sent_manually', "Sent manually"), ('not_sent_manually', "Not sent manually"), ('sent_by_email', "Sent by e-mail"), ('not_sent_by_email', "Not sent by e-mail"), ('require_sending', "REQUIRE SENDING"), ) def queryset(self, request: HttpRequest, queryset: QuerySet) -> QuerySet: if self.value() == 'sent_manually': return queryset.filter(sent_manually_at__isnull=False) if self.value() == 'not_sent_manually': return queryset.filter(sent_manually_at__isnull=True) if self.value() == 'sent_by_email': return ( queryset .filter(email__emailtransmission__sent=True) .distinct() ) if self.value() == 'not_sent_by_email': return queryset.exclude(email__emailtransmission__sent=True) if self.value() == 'require_sending': return ( queryset # Restrict to letters not sent manually .filter(sent_manually_at__isnull=True) # Exclude letters successfully sent by e-mail .exclude(email__emailtransmission__sent=True) )
[docs]class LetterDevAdmin(ReadOnlyModelAdmin): fields = ('id', 'created_at', 'pdf', 'to_clinician', 'to_researcher', 'to_patient', 'get_study', 'get_contact_request', 'sent_manually_at', 'get_emails') readonly_fields = fields list_display = ('id', 'created_at', 'to_clinician', 'to_researcher', 'to_patient', 'study', 'contact_request', 'sent_manually_at') list_filter = (LetterSendingStatusFilter, 'to_clinician', 'to_researcher', 'to_patient') list_select_related = ( 'study__lead_researcher', 'contact_request', ) date_hierarchy = 'created_at' # ... see also http://stackoverflow.com/questions/991926/custom-filter-in-django-admin-on-django-1-3-or-below # noqa actions = ['mark_sent'] def mark_sent(self, request: HttpRequest, queryset: QuerySet) -> None: ids = [] for letter in queryset: letter.mark_sent() ids.append(letter.id) self.message_user( request, "{} letter(s) were marked as sent: IDs {}.".format(len(ids), str(ids))) mark_sent.short_description = "Mark selected letters as printed/sent" def get_study(self, obj: Letter) -> str: return admin_view_fk_link(self, obj, "study") get_study.allow_tags = True get_study.short_description = "Study" def get_contact_request(self, obj: Letter) -> str: return admin_view_fk_link(self, obj, "contact_request") get_contact_request.allow_tags = True get_contact_request.short_description = "Contact request" def get_emails(self, obj: Letter) -> str: return admin_view_reverse_fk_links(self, obj, "email_set") get_emails.allow_tags = True get_emails.short_description = "E-mail(s)"
[docs]class LetterMgrAdmin(LetterDevAdmin): """Restrict to letters visible to a researcher."""
[docs] def get_queryset(self, request: HttpRequest) -> QuerySet: return ( super().get_queryset(request) .filter(Q(to_researcher=True) | Q(rdbm_may_view=True)) )
[docs]class LetterResAdmin(LetterDevAdmin): fields = ('id', 'created_at', 'get_pdf', 'to_clinician', 'to_researcher', 'to_patient', 'study', 'contact_request', 'sent_manually_at', 'email') readonly_fields = fields """Restrict to letters visible to a researcher."""
[docs] def get_queryset(self, request: HttpRequest) -> QuerySet: qs = super().get_queryset(request).filter(to_researcher=True) studies = Study.filter_studies_for_researcher(Study.objects.all(), request.user) return qs.filter(study__in=studies)
[docs] def has_module_permission(self, request: HttpRequest) -> bool: return request.user.is_staff
[docs] def has_change_permission(self, request: HttpRequest, obj: Letter = None) -> bool: return request.user.is_staff
def get_pdf(self, obj: Letter) -> str: if not obj.pdf: return "(Missing)" return '<a href="{}">PDF</a>'.format( reverse("letter", args=[obj.id])) get_pdf.short_description = "Letter PDF" get_pdf.allow_tags = True
# ============================================================================= # User profiles # =============================================================================
[docs]class UserProfileInline(admin.StackedInline): model = UserProfile max_num = 1 can_delete = False inlines = [StudyInline] fields = ('per_page', 'line_length', 'collapse_at_len', 'collapse_at_n_lines', 'is_developer', 'title', 'telephone', 'address_1', 'address_2', 'address_3', 'address_4', 'address_5', 'address_6', 'address_7', 'is_clinician', 'is_consultant', 'get_studies_as_lead', 'get_studies_as_researcher', 'enough_info_for_researcher') readonly_fields = ('get_studies_as_lead', 'get_studies_as_researcher', 'enough_info_for_researcher') def get_studies_as_lead(self, obj: settings.AUTH_USER_MODEL) -> str: studies = obj.user.studies_as_lead.all() return render_to_string('shortlist_studies.html', {'studies': studies}) get_studies_as_lead.short_description = "Studies as lead researcher" get_studies_as_lead.allow_tags = True def get_studies_as_researcher(self, obj: settings.AUTH_USER_MODEL) -> str: studies = obj.user.studies_as_researcher.all() return render_to_string('shortlist_studies.html', {'studies': studies}) get_studies_as_researcher.short_description = "Studies as researcher" get_studies_as_researcher.allow_tags = True def enough_info_for_researcher(self, obj: settings.AUTH_USER_MODEL) -> bool: return ( bool(obj.title) and bool(obj.user.first_name) and bool(obj.user.last_name) ) enough_info_for_researcher.short_description = ( "Enough info for researcher status (title, firstname, lastname)?" ) enough_info_for_researcher.boolean = True
[docs]class ExtendedUserMgrAdmin(UserAdmin): inlines = [UserProfileInline] list_display = ( 'username', 'email', 'first_name', 'last_name', 'is_staff', 'enough_info_for_researcher', ) def enough_info_for_researcher(self, obj: settings.AUTH_USER_MODEL) -> bool: return ( bool(obj.profile.title) and bool(obj.first_name) and bool(obj.last_name) ) enough_info_for_researcher.short_description = ( "Enough researcher info?" ) enough_info_for_researcher.boolean = True
# ============================================================================= # Assemble main admin site # ============================================================================= # http://stackoverflow.com/questions/4938491/django-admin-change-header-django-administration-text # noqa # http://stackoverflow.com/questions/3400641/how-do-i-inline-edit-a-django-user-profile-in-the-admin-interface # noqa
[docs]class MgrAdminSite(admin.AdminSite): # Text to put at the end of each page's <title>. site_title = ugettext_lazy(settings.RESEARCH_DB_TITLE + ' manager admin') # Text to put in each page's <h1>. site_header = ugettext_lazy(settings.RESEARCH_DB_TITLE + ": manager admin") # URL for the "View site" link at the top of each admin page. site_url = settings.FORCE_SCRIPT_NAME + "/" # Text to put at the top of the admin index page. index_title = ugettext_lazy(settings.RESEARCH_DB_TITLE + ' site administration for RDBM') index_template = 'admin/viewchange_admin_index.html' app_index_template = 'admin/viewchange_admin_app_index.html'
mgr_admin_site = MgrAdminSite(name="mgradmin") mgr_admin_site.disable_action('delete_selected') # ... particularly for e-mail where we manually specify a bulk action (resend) # https://docs.djangoproject.com/en/1.8/ref/contrib/admin/actions/ mgr_admin_site.register(CharityPaymentRecord, CharityPaymentRecordMgrAdmin) mgr_admin_site.register(ConsentMode, ConsentModeMgrAdmin) mgr_admin_site.register(ContactRequest, ContactRequestMgrAdmin) mgr_admin_site.register(Email, EmailMgrAdmin) mgr_admin_site.register(Leaflet, LeafletMgrAdmin) mgr_admin_site.register(Letter, LetterMgrAdmin) mgr_admin_site.register(PatientResponse, PatientResponseMgrAdmin) mgr_admin_site.register(QueryAudit, QueryMgrAdmin) mgr_admin_site.register(Study, StudyMgrAdmin) mgr_admin_site.register(TeamRep, TeamRepMgrAdmin) mgr_admin_site.register(User, ExtendedUserMgrAdmin) # ============================================================================= # Assemble secondary (developer) admin site # ============================================================================= # http://stackoverflow.com/questions/4938491/django-admin-change-header-django-administration-text # noqa # http://stackoverflow.com/questions/3400641/how-do-i-inline-edit-a-django-user-profile-in-the-admin-interface # noqa
[docs]class DevAdminSite(admin.AdminSite): site_title = ugettext_lazy(settings.RESEARCH_DB_TITLE + ' dev admin') site_header = ugettext_lazy(settings.RESEARCH_DB_TITLE + ": developer admin") site_url = settings.FORCE_SCRIPT_NAME + "/" index_title = ugettext_lazy(settings.RESEARCH_DB_TITLE + ' developer administration') index_template = 'admin/viewchange_admin_index.html' app_index_template = 'admin/viewchange_admin_app_index.html'
dev_admin_site = DevAdminSite(name="devadmin") dev_admin_site.disable_action('delete_selected') # Where no specific DevAdmin version exists, use the MgrAdmin dev_admin_site.register(CharityPaymentRecord, CharityPaymentRecordMgrAdmin) dev_admin_site.register(ClinicianResponse, ClinicianResponseDevAdmin) dev_admin_site.register(ConsentMode, ConsentModeDevAdmin) dev_admin_site.register(ContactRequest, ContactRequestDevAdmin) dev_admin_site.register(DummyPatientSourceInfo, DummyPatientSourceInfoDevAdmin) dev_admin_site.register(Email, EmailDevAdmin) dev_admin_site.register(Leaflet, LeafletMgrAdmin) dev_admin_site.register(Letter, LetterDevAdmin) dev_admin_site.register(PatientLookup, PatientLookupDevAdmin) dev_admin_site.register(PatientResponse, PatientResponseDevAdmin) dev_admin_site.register(QueryAudit, QueryMgrAdmin) dev_admin_site.register(Study, StudyMgrAdmin) dev_admin_site.register(TeamRep, TeamRepMgrAdmin) dev_admin_site.register(User, ExtendedUserMgrAdmin) # ============================================================================= # Assemble tertiary (researcher) admin site # =============================================================================
[docs]class ResearcherAdminSite(admin.AdminSite): site_title = ugettext_lazy(settings.RESEARCH_DB_TITLE + ' researcher admin views') site_header = ugettext_lazy(settings.RESEARCH_DB_TITLE + ": researcher admin") site_url = settings.FORCE_SCRIPT_NAME + "/" index_title = ugettext_lazy("View/manage your studies") index_template = 'admin/viewchange_admin_index.html' app_index_template = 'admin/viewchange_admin_app_index.html'
res_admin_site = ResearcherAdminSite(name="resadmin") res_admin_site.disable_action('delete_selected') res_admin_site.register(Study, StudyResAdmin) res_admin_site.register(Leaflet, LeafletResAdmin) res_admin_site.register(Email, EmailResAdmin) res_admin_site.register(Letter, LetterResAdmin) res_admin_site.register(ContactRequest, ContactRequestResAdmin) """ Problem with non-superusers not seeing any apps: - http://stackoverflow.com/questions/1929707/django-admin-not-seeing-any-app-permission-problem # noqa ... but django.contrib.auth.backends.ModelBackend won't load in INSTALLED_APPS # noqa - log.debug("registered: {}".format(res_admin_site.is_registered(Leaflet))) ... OK ... and anyway, it works for superusers - app_list is blank in the template; this is set in AdminSite.index() (in django/contrib/admin/sites.py) So the failure is either in model_admin.has_module_permission(request) return request.user.has_module_perms(self.opts.app_label) or model_admin.get_model_perms(request) They're in django/contrib/admin/options.py; ModelAdmin and BaseModelAdmin From a Werkzeug console in home view: from core.admin import LeafletResAdmin LeafletResAdmin.has_module_permission(request) # ... fails (class not instance!) but shows code, so: request.user.has_module_perms('resadmin') # ... False - so HERE'S a problem. Solution: add these to relevant ModelAdmin classes: def has_module_permission(self, request): return request.user.is_staff def has_change_permission(self, request, obj=None): return request.user.is_staff """