Coverage for crateweb/consent/lookup_common.py: 23%
60 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/consent/lookup_common.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**Helper functions for consent-for-contact lookup processes.**
28"""
30import datetime
31from operator import attrgetter
32from typing import List, Optional, Union
34from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist
36from crate_anon.crateweb.consent.models import (
37 ClinicianInfoHolder,
38 PatientLookup,
39 TeamRep,
40)
41from crate_anon.crateweb.consent.utils import latest_date
44# =============================================================================
45# Constants
46# =============================================================================
49class SignatoryTitles:
50 """
51 Standard signatory titles for clinicians of various kinds.
52 """
54 CARE_COORDINATOR = "Care coordinator"
55 CLINICIAN = "Clinician"
56 CONS_PSYCHIATRIST = "Consultant psychiatrist"
57 CONSULTANT = "Consultant"
58 TEAM_MEMBER = "Clinical team member"
61# =============================================================================
62# Create a clinician object to represent a team, ideally personalized to that
63# team's known representative.
64# =============================================================================
67def get_team_details(
68 team_name: str,
69 start_date: Union[datetime.date, datetime.datetime],
70 end_date: Optional[Union[datetime.date, datetime.datetime]],
71 decisions: List[str],
72) -> ClinicianInfoHolder:
73 """
74 Modify ``team_info`` if possible to add a team representative's details.
76 Args:
77 team_name:
78 Name of the team to look up.
79 start_date:
80 Start date for the team's involvement.
81 end_date:
82 Optional end date for the team's involvement.
83 decisions:
84 Log of decisions made. Will be written to.
85 """
86 team_info = ClinicianInfoHolder(
87 clinician_type=ClinicianInfoHolder.TEAM,
88 title="",
89 first_name="",
90 surname="",
91 email="",
92 signatory_title=SignatoryTitles.TEAM_MEMBER,
93 is_consultant=False,
94 start_date=start_date,
95 end_date=end_date,
96 )
97 # We know a team - do we have a team representative?
98 team_summary = "{status} team {desc}".format(
99 status="active" if team_info.end_date is None else "previous",
100 desc=repr(team_name),
101 )
102 try:
103 teamrep = TeamRep.objects.get(team=team_name)
104 decisions.append("Clinical team representative found.")
105 profile = teamrep.user.profile
106 team_info.title = profile.title
107 team_info.first_name = teamrep.user.first_name
108 team_info.surname = teamrep.user.last_name
109 team_info.email = teamrep.user.email
110 team_info.signatory_title = profile.signatory_title
111 team_info.is_consultant = profile.is_consultant
112 except ObjectDoesNotExist:
113 decisions.append(f"No team representative found for {team_summary}.")
114 except MultipleObjectsReturned:
115 decisions.append(
116 f"Confused: >1 team representative found for {team_summary}."
117 )
118 return team_info
119 # We return it even if we can't find a representative, because it still
120 # carries information about whether the patient is discharged or not.
123# =============================================================================
124# Pick the most appropriate of several possible clinicians
125# =============================================================================
128def pick_best_clinician(
129 lookup: PatientLookup,
130 clinicians: List[ClinicianInfoHolder],
131 decisions: List[str],
132) -> None:
133 """
134 By now we know all relevant recent clinicians, including (potentially) ones
135 from which the patient has been discharged, and ones that are active.
137 Work through possible clinicians and see who's the best to pick (e.g. is
138 contactable!). Store that information back in the lookup,
140 Args:
141 lookup:
142 Patient being looked up. Will be modified.
143 clinicians:
144 Candidate clinicians.
145 decisions:
146 Log of decisions made. Will be written to.
147 """
148 decisions.append(
149 f"{len(clinicians)} total past/present "
150 f"clinician(s)/team(s) found: {clinicians!r}."
151 )
152 current_clinicians = [c for c in clinicians if c.current()]
153 if current_clinicians:
154 lookup.pt_discharged = False
155 lookup.pt_discharge_date = None
156 decisions.append("Patient not discharged.")
157 contactable_curr_clin = [
158 c for c in current_clinicians if c.contactable()
159 ]
160 # Sorting by two keys: https://stackoverflow.com/questions/11206884
161 # LOW priority: most recent clinician. (Goes first in sort.)
162 # HIGH priority: preferred type of clinician. (Goes last in sort.)
163 # Sort order is: most preferred first.
164 contactable_curr_clin.sort(key=attrgetter("start_date"), reverse=True)
165 contactable_curr_clin.sort(
166 key=attrgetter("clinician_preference_order")
167 )
168 decisions.append(
169 f"{len(contactable_curr_clin)} contactable active "
170 f"clinician(s) found."
171 )
172 if contactable_curr_clin:
173 chosen_clinician = contactable_curr_clin[0]
174 lookup.set_from_clinician_info_holder(chosen_clinician)
175 decisions.append(
176 f"Found active clinician of type: "
177 f"{chosen_clinician.clinician_type}"
178 )
179 return # All done!
180 # If we get here, the patient is not discharged, but we haven't found
181 # a contactable active clinician.
182 # We'll fall through and check older clinicians for contactability.
183 else:
184 end_dates = [c.end_date for c in clinicians]
185 lookup.pt_discharged = True
186 lookup.pt_discharge_date = latest_date(*end_dates)
187 decisions.append("Patient discharged.")
189 # We get here either if the patient is discharged, or they're current but
190 # we can't contact a current clinician.
191 contactable_old_clin = [c for c in clinicians if c.contactable()]
192 # LOW priority: preferred type of clinician. (Goes first in sort.)
193 # HIGH priority: most recent end date. (Goes last in sort.)
194 # Sort order is: most preferred first.
195 contactable_old_clin.sort(key=attrgetter("clinician_preference_order"))
196 contactable_old_clin.sort(key=attrgetter("end_date"), reverse=True)
197 decisions.append(
198 f"{len(contactable_old_clin)} contactable previous "
199 f"clinician(s) found."
200 )
201 if contactable_old_clin:
202 chosen_clinician = contactable_old_clin[0]
203 lookup.set_from_clinician_info_holder(chosen_clinician)
204 decisions.append(
205 f"Found previous clinician of type: "
206 f"{chosen_clinician.clinician_type}"
207 )
209 if not lookup.clinician_found:
210 decisions.append("Failed to establish contactable clinician.")