Coverage for crateweb/consent/models.py: 41%

1200 statements  

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

1""" 

2crate_anon/crateweb/consent/models.py 

3 

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

5 

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

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

8 

9 This file is part of CRATE. 

10 

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

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

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

14 (at your option) any later version. 

15 

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

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

18 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

19 GNU General Public License for more details. 

20 

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

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

23 

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

25 

26**Django ORM models for the consent-to-contact system.** 

27 

28""" 

29 

30import datetime 

31from dateutil.relativedelta import relativedelta 

32import logging 

33import os 

34from typing import Any, List, Optional, Tuple, Type, Union 

35 

36# from audit_log.models import AuthStampedModel # django-audit-log 

37from cardinal_pythonlib.django.admin import admin_view_url 

38from cardinal_pythonlib.django.fields.helpers import choice_explanation 

39from cardinal_pythonlib.django.fields.restrictedcontentfile import ( 

40 ContentTypeRestrictedFileField, 

41) 

42from cardinal_pythonlib.django.files import ( 

43 auto_delete_files_on_instance_change, 

44 auto_delete_files_on_instance_delete, 

45) 

46from cardinal_pythonlib.django.reprfunc import modelrepr 

47from cardinal_pythonlib.httpconst import ContentType 

48from cardinal_pythonlib.logs import BraceStyleAdapter 

49from cardinal_pythonlib.pdf import get_concatenated_pdf_in_memory 

50from cardinal_pythonlib.reprfunc import simple_repr 

51from django import forms 

52from django.conf import settings 

53from django.contrib.auth import get_user_model 

54from django.core.exceptions import ObjectDoesNotExist, ValidationError 

55from django.core.mail import EmailMessage, EmailMultiAlternatives 

56from django.core.validators import validate_email 

57from django.db import models, transaction 

58from django.db.models import Q, QuerySet 

59from django.dispatch import receiver 

60from django.http import QueryDict, Http404 

61from django.http.request import HttpRequest 

62from django.urls import reverse 

63from django.utils import timezone 

64from django.utils.crypto import get_random_string 

65from django.utils.html import escape 

66 

67from crate_anon.crateweb.config.constants import ( 

68 ClinicalDatabaseType, 

69 SOURCE_DB_NAME_MAX_LENGTH, 

70 UrlNames, 

71) 

72from crate_anon.crateweb.consent.constants import EthicsInfo 

73from crate_anon.crateweb.core.constants import ( 

74 LEN_ADDRESS, 

75 LEN_FIELD_DESCRIPTION, 

76 LEN_NAME, 

77 LEN_PHONE, 

78 LEN_TITLE, 

79 MAX_HASH_LENGTH, 

80) 

81from crate_anon.crateweb.core.utils import ( 

82 site_absolute_url, 

83 string_time_now, 

84 url_with_querystring, 

85) 

86from crate_anon.crateweb.extra.pdf import ( 

87 make_pdf_on_disk_from_html_with_django_settings, 

88 CratePdfPlan, 

89) 

90from crate_anon.crateweb.extra.salutation import ( 

91 forename_surname, 

92 get_initial_surname_tuple_from_string, 

93 salutation, 

94 title_forename_surname, 

95) 

96from crate_anon.crateweb.consent.storage import privatestorage 

97from crate_anon.crateweb.consent.tasks import ( 

98 email_rdbm_task, 

99 process_consent_change, 

100 process_contact_request, 

101 finalize_clinician_response, 

102) 

103from crate_anon.crateweb.consent.teamlookup import get_teams 

104from crate_anon.crateweb.consent.utils import ( 

105 days_to_years, 

106 make_cpft_email_address, 

107 render_email_html_to_string, 

108 render_pdf_html_to_string, 

109 to_date, 

110 validate_researcher_email_domain, 

111) 

112from crate_anon.crateweb.research.models import get_mpid 

113from crate_anon.crateweb.research.research_db_info import ( 

114 get_research_db_info, 

115) 

116from crate_anon.crateweb.userprofile.models import UserProfile 

117 

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

119 

120CLINICIAN_RESPONSE_FWD_REF = "ClinicianResponse" 

121CONSENT_MODE_FWD_REF = "ConsentMode" 

122CONTACT_REQUEST_FWD_REF = "ContactRequest" 

123EMAIL_FWD_REF = "Email" 

124EMAIL_TRANSMISSION_FWD_REF = "EmailTransmission" 

125LEAFLET_FWD_REF = "Leaflet" 

126LETTER_FWD_REF = "Letter" 

127STUDY_FWD_REF = "Study" 

128 

129TEST_ID = -1 

130TEST_ID_STR = str(TEST_ID) 

131TEST_ID_TWO = -2 

132TEST_ID_TWO_STR = str(TEST_ID_TWO) 

133TEST_ID_STRINGS = (TEST_ID_STR, TEST_ID_TWO_STR) 

134 

135 

136# ============================================================================= 

137# Study 

138# ============================================================================= 

139 

140 

141def study_details_upload_to(instance: STUDY_FWD_REF, filename: str) -> str: 

142 """ 

143 Determines the filename used for study information PDF uploads. 

144 

145 Args: 

146 instance: instance of :class:`Study` (potentially unsaved; 

147 and you can't call :func:`save`; it goes into infinite recursion) 

148 filename: uploaded filename 

149 

150 Returns: 

151 filename with extension but without path, to be used on the server 

152 filesystem 

153 """ 

154 extension = os.path.splitext(filename)[1] # includes the '.' if present 

155 return os.path.join( 

156 "study", 

157 f"{instance.institutional_id}_details_{string_time_now()}{extension}", 

158 ) 

159 # ... as id may not exist yet 

160 

161 

162def study_form_upload_to(instance: STUDY_FWD_REF, filename: str) -> str: 

163 """ 

164 Determines the filename used for study clinician-form PDF uploads. 

165 

166 Args: 

167 instance: instance of :class:`Study` (potentially unsaved 

168 and you can't call :func:`save`; it goes into infinite recursion) 

169 filename: uploaded filename 

170 

171 Returns: 

172 filename with extension but without path, to be used on the server 

173 filesystem 

174 """ 

175 extension = os.path.splitext(filename)[1] 

176 return os.path.join( 

177 "study", 

178 f"{instance.institutional_id}_form_{string_time_now()}{extension}", 

179 ) 

180 

181 

182class Study(models.Model): 

183 """ 

184 Represents a research study. 

185 """ 

186 

187 # implicit 'id' field 

188 institutional_id = models.PositiveIntegerField( 

189 verbose_name="Institutional (e.g. NHS Trust) study number", unique=True 

190 ) 

191 title = models.CharField(max_length=255, verbose_name="Study title") 

192 lead_researcher = models.ForeignKey( 

193 settings.AUTH_USER_MODEL, 

194 on_delete=models.PROTECT, 

195 related_name="studies_as_lead", 

196 ) 

197 researchers = models.ManyToManyField( 

198 settings.AUTH_USER_MODEL, 

199 related_name="studies_as_researcher", 

200 blank=True, 

201 ) 

202 registered_at = models.DateTimeField( 

203 null=True, blank=True, verbose_name="When was the study registered?" 

204 ) 

205 summary = models.TextField(verbose_name="Summary of study") 

206 summary_is_html = models.BooleanField( 

207 default=False, 

208 verbose_name="Is the summary in HTML (not plain text) format?", 

209 ) 

210 search_methods_planned = models.TextField( 

211 blank=True, verbose_name="Search methods planned" 

212 ) 

213 patient_contact = models.BooleanField( 

214 verbose_name="Involves patient contact?" 

215 ) 

216 include_under_16s = models.BooleanField( 

217 verbose_name="Include patients under 16?" 

218 ) 

219 include_lack_capacity = models.BooleanField( 

220 verbose_name="Include patients lacking capacity?" 

221 ) 

222 clinical_trial = models.BooleanField( 

223 verbose_name="Clinical trial (CTIMP)?" 

224 ) 

225 include_discharged = models.BooleanField( 

226 verbose_name="Include discharged patients?" 

227 ) 

228 request_direct_approach = models.BooleanField( 

229 verbose_name="Researchers request direct approach to patients?" 

230 ) 

231 approved_by_rec = models.BooleanField(verbose_name="Approved by REC?") 

232 rec_reference = models.CharField( 

233 max_length=50, 

234 blank=True, 

235 verbose_name="Research Ethics Committee reference", 

236 ) 

237 approved_locally = models.BooleanField( 

238 verbose_name="Approved by local institution?" 

239 ) 

240 local_approval_at = models.DateTimeField( 

241 null=True, 

242 blank=True, 

243 verbose_name="When approved by local institution?", 

244 ) 

245 study_details_pdf = ContentTypeRestrictedFileField( 

246 blank=True, 

247 storage=privatestorage, 

248 content_types=[ContentType.PDF], 

249 max_upload_size=settings.MAX_UPLOAD_SIZE_BYTES, 

250 upload_to=study_details_upload_to, 

251 ) 

252 subject_form_template_pdf = ContentTypeRestrictedFileField( 

253 blank=True, 

254 storage=privatestorage, 

255 content_types=[ContentType.PDF], 

256 max_upload_size=settings.MAX_UPLOAD_SIZE_BYTES, 

257 upload_to=study_form_upload_to, 

258 ) 

259 # http://nemesisdesign.net/blog/coding/django-private-file-upload-and-serving/ # noqa: E501 

260 # https://stackoverflow.com/questions/8609192/differentiate-null-true-blank-true-in-django # noqa: E501 

261 AUTODELETE_OLD_FILE_FIELDS = [ 

262 "study_details_pdf", 

263 "subject_form_template_pdf", 

264 ] 

265 

266 class Meta: 

267 verbose_name_plural = "studies" 

268 

269 def __str__(self) -> str: 

270 # noinspection PyUnresolvedReferences 

271 return ( 

272 f"[Study {self.id}] {self.institutional_id}: " 

273 f"{self.lead_researcher.get_full_name()} / {self.title}" 

274 ) 

275 

276 def __repr__(self) -> str: 

277 return modelrepr(self) 

278 

279 def get_lead_researcher_name_address(self) -> List[str]: 

280 """ 

281 Returns name/address components (as lines you might use on a letter or 

282 envelope) for the study's lead researcher. 

283 """ 

284 # noinspection PyUnresolvedReferences 

285 return [ 

286 self.lead_researcher.profile.get_title_forename_surname() 

287 ] + self.lead_researcher.profile.get_address_components() 

288 

289 def get_lead_researcher_salutation(self) -> str: 

290 """ 

291 Returns the salutation for the study's lead researcher (e.g. 

292 "Prof. Jones"). 

293 """ 

294 # noinspection PyUnresolvedReferences 

295 return self.lead_researcher.profile.get_salutation() 

296 

297 def get_involves_lack_of_capacity(self) -> str: 

298 """ 

299 Returns a human-readable string indicating whether or not the study 

300 involves patients lacking capacity (and if so, whether it's a clinical 

301 trial [CTIMP]). 

302 """ 

303 if not self.include_lack_capacity: 

304 return "No" 

305 if self.clinical_trial: 

306 return "Yes (and it is a clinical trial)" 

307 return "Yes (and it is not a clinical trial)" 

308 

309 @staticmethod 

310 def get_queryset_possible_contact_studies() -> QuerySet: 

311 """ 

312 Returns all approved studies involving direct patient contact that 

313 have a properly identifiable lead researcher. 

314 """ 

315 return ( 

316 Study.objects.filter(patient_contact=True) 

317 .filter(approved_by_rec=True) 

318 .filter(approved_locally=True) 

319 .exclude(study_details_pdf="") 

320 .exclude(lead_researcher__profile__title="") 

321 .exclude(lead_researcher__first_name="") 

322 .exclude(lead_researcher__last_name="") 

323 ) 

324 

325 @staticmethod 

326 def filter_studies_for_researcher( 

327 queryset: QuerySet, user: settings.AUTH_USER_MODEL 

328 ) -> QuerySet: 

329 """ 

330 Filters the supplied query set down to those studies for which the 

331 given user is a researcher on. 

332 """ 

333 return queryset.filter( 

334 Q(lead_researcher=user) | Q(researchers__in=[user]) 

335 ).distinct() 

336 

337 @property 

338 def html_summary(self) -> str: 

339 """ 

340 Returns a version of the study's summary with HTML tags marking up 

341 paragraphs. If the summary is already in HTML format, just return 

342 that. 

343 """ 

344 # Check if summary exists and if not return the empty string 

345 summary = self.summary 

346 if not summary: 

347 return "" 

348 

349 # If the summary is already HTML, return it as it is. 

350 if self.summary_is_html: 

351 return summary 

352 

353 # Split lines and ensure each line is HTML-escaped (e.g. if there's a 

354 # "<" or similar in the raw text). 

355 paragraphs = [escape(x) for x in summary.splitlines() if x] 

356 

357 # NB an equivalent to 

358 # [x for x in something if x] 

359 # is 

360 # list(filter(None, something)) 

361 

362 if len(paragraphs) <= 1: 

363 # 0 or 1 paragraphs; no point using <p> 

364 return "".join(paragraphs) 

365 # Otherwise: 

366 

367 # Method 1: with <p> 

368 # Visually better once CSS fixed. 

369 return "".join(f"<p>{x}</p>" for x in paragraphs) 

370 

371 # Method 2: with <br> 

372 # Wider gaps. 

373 # return "<br><br>".join(paragraphs) 

374 

375 

376# noinspection PyUnusedLocal 

377@receiver(models.signals.post_delete, sender=Study) 

378def auto_delete_study_files_on_delete( 

379 sender: Type[Study], instance: Study, **kwargs: Any 

380) -> None: 

381 """ 

382 Django signal receiver. 

383 

384 Deletes files from filesystem when :class:`Study` object is deleted. 

385 """ 

386 auto_delete_files_on_instance_delete( 

387 instance, Study.AUTODELETE_OLD_FILE_FIELDS 

388 ) 

389 

390 

391# noinspection PyUnusedLocal 

392@receiver(models.signals.pre_save, sender=Study) 

393def auto_delete_study_files_on_change( 

394 sender: Type[Study], instance: Study, **kwargs: Any 

395) -> None: 

396 """ 

397 Django signal receiver. 

398 

399 Deletes files from filesystem when :class:`Study` object is changed. 

400 """ 

401 auto_delete_files_on_instance_change( 

402 instance, Study.AUTODELETE_OLD_FILE_FIELDS, Study 

403 ) 

404 

405 

406# ============================================================================= 

407# Generic leaflets 

408# ============================================================================= 

409 

410 

411def leaflet_upload_to(instance: LEAFLET_FWD_REF, filename: str) -> str: 

412 """ 

413 Determines the filename used for leaflet uploads. 

414 

415 Args: 

416 instance: instance of :class:`Leaflet` (potentially unsaved; 

417 and you can't call :func:`save`; it goes into infinite recursion) 

418 filename: uploaded filename 

419 

420 Returns: 

421 filename with extension but without path, to be used on the server 

422 filesystem 

423 """ 

424 extension = os.path.splitext(filename)[1] # includes the '.' if present 

425 return os.path.join( 

426 "leaflet", f"{instance.name}_{string_time_now()}{extension}" 

427 ) 

428 # ... as id may not exist yet 

429 

430 

431class Leaflet(models.Model): 

432 """ 

433 Represents a system-wide patient information leaflet. 

434 """ 

435 

436 CPFT_TPIR = "cpft_tpir" # mandatory 

437 NIHR_YHRSL = "nihr_yhrsl" # not used automatically 

438 CPFT_TRAFFICLIGHT_CHOICE = "cpft_trafficlight_choice" 

439 CPFT_CLINRES = "cpft_clinres" 

440 

441 LEAFLET_CHOICES = ( 

442 (CPFT_TPIR, "CPFT: Taking part in research [MANDATORY]"), 

443 ( 

444 NIHR_YHRSL, 

445 "NIHR: Your health records save lives [not currently used]", 

446 ), 

447 ( 

448 CPFT_TRAFFICLIGHT_CHOICE, 

449 "CPFT: traffic-light choice decision form [not currently used: " 

450 "personalized version created instead]", 

451 ), 

452 (CPFT_CLINRES, "CPFT: clinical research [not currently used]"), 

453 ) 

454 # https://docs.djangoproject.com/en/dev/ref/models/fields/#django.db.models.Field.choices # noqa: E501 

455 

456 name = models.CharField( 

457 max_length=50, 

458 unique=True, 

459 choices=LEAFLET_CHOICES, 

460 verbose_name="leaflet name", 

461 ) 

462 pdf = ContentTypeRestrictedFileField( 

463 blank=True, 

464 storage=privatestorage, 

465 content_types=[ContentType.PDF], 

466 max_upload_size=settings.MAX_UPLOAD_SIZE_BYTES, 

467 upload_to=leaflet_upload_to, 

468 ) 

469 

470 def __str__(self) -> str: 

471 for x in Leaflet.LEAFLET_CHOICES: 

472 if x[0] == self.name: 

473 name = x[1] 

474 if not self.pdf: 

475 name += " (MISSING)" 

476 return name 

477 return f"? (bad name: {self.name})" 

478 

479 @staticmethod 

480 def populate() -> None: 

481 """ 

482 Pre-create records for all the system-wide leaflets we use. 

483 """ 

484 keys = [x[0] for x in Leaflet.LEAFLET_CHOICES] 

485 for x in keys: 

486 if not Leaflet.objects.filter(name=x).exists(): 

487 obj = Leaflet(name=x) 

488 obj.save() 

489 

490 

491# noinspection PyUnusedLocal 

492@receiver(models.signals.post_delete, sender=Leaflet) 

493def auto_delete_leaflet_files_on_delete( 

494 sender: Type[Leaflet], instance: Leaflet, **kwargs: Any 

495) -> None: 

496 """ 

497 Django signal receiver. 

498 

499 Deletes files from filesystem when :class:`Leaflet` object is deleted. 

500 """ 

501 auto_delete_files_on_instance_delete(instance, ["pdf"]) 

502 

503 

504# noinspection PyUnusedLocal 

505@receiver(models.signals.pre_save, sender=Leaflet) 

506def auto_delete_leaflet_files_on_change( 

507 sender: Type[Leaflet], instance: Leaflet, **kwargs: Any 

508) -> None: 

509 """ 

510 Django signal receiver. 

511 Deletes files from filesystem when Leaflet object is changed. 

512 """ 

513 auto_delete_files_on_instance_change(instance, ["pdf"], Leaflet) 

514 

515 

516# ============================================================================= 

517# Generic fields for decisions 

518# ============================================================================= 

519 

520 

521class Decision(models.Model): 

522 """ 

523 Abstract class to represent how a decision has been made (e.g. by a patient 

524 or their surrogate decision-maker or clinician). 

525 """ 

526 

527 # Note that Decision._meta.get_fields() doesn't care about the 

528 # ordering of its fields (and, I think, they can change). So: 

529 FIELDS = [ 

530 "decision_signed_by_patient", 

531 "decision_otherwise_directly_authorized_by_patient", 

532 "decision_under16_signed_by_parent", 

533 "decision_under16_signed_by_clinician", 

534 "decision_lack_capacity_signed_by_representative", 

535 "decision_lack_capacity_signed_by_clinician", 

536 ] 

537 decision_signed_by_patient = models.BooleanField( 

538 default=False, verbose_name="Request signed by patient?" 

539 ) 

540 decision_otherwise_directly_authorized_by_patient = models.BooleanField( 

541 default=False, 

542 verbose_name="Request otherwise directly authorized by patient?", 

543 ) 

544 decision_under16_signed_by_parent = models.BooleanField( 

545 default=False, 

546 verbose_name="Patient under 16 and request countersigned by parent?", 

547 ) 

548 decision_under16_signed_by_clinician = models.BooleanField( 

549 default=False, 

550 verbose_name="Patient under 16 and request countersigned by " 

551 "clinician?", 

552 ) 

553 decision_lack_capacity_signed_by_representative = models.BooleanField( 

554 default=False, 

555 verbose_name="Patient lacked capacity and request signed by " 

556 "authorized representative?", 

557 ) 

558 decision_lack_capacity_signed_by_clinician = models.BooleanField( 

559 default=False, 

560 verbose_name="Patient lacked capacity and request countersigned by " 

561 "clinician?", 

562 ) 

563 

564 class Meta: 

565 abstract = True 

566 

567 def decision_valid(self) -> bool: 

568 """ 

569 Does the decision meet our rules about who can make decisions? 

570 """ 

571 # We can never electronically validate being under 16 (time may have 

572 # passed since the lookup) or, especially, lacking capacity, so let's 

573 # just trust the user 

574 return ( 

575 ( 

576 self.decision_signed_by_patient 

577 or self.decision_otherwise_directly_authorized_by_patient 

578 ) 

579 or ( 

580 # Lacks capacity 

581 self.decision_lack_capacity_signed_by_representative 

582 and self.decision_lack_capacity_signed_by_clinician 

583 ) 

584 or ( 

585 # Under 16: 2/3 rule 

586 int( 

587 self.decision_signed_by_patient 

588 or self.decision_otherwise_directly_authorized_by_patient 

589 ) 

590 + int(self.decision_under16_signed_by_parent) 

591 + int(self.decision_under16_signed_by_clinician) 

592 >= 2 

593 # I know the logic overlaps. But there you go. 

594 ) 

595 ) 

596 

597 def validate_decision(self) -> None: 

598 """ 

599 Ensure that the decision is valid according to our rules, or raise 

600 :exc:`django.forms.ValidationError`. 

601 """ 

602 if not self.decision_valid(): 

603 raise forms.ValidationError( 

604 "Invalid decision. Options are: " 

605 "(*) Signed/authorized by patient. " 

606 "(*) Lacks capacity - signed by rep + clinician. " 

607 "(*) Under 16 - signed by 2/3 of (patient, clinician, " 

608 "parent); see special rules" 

609 ) 

610 

611 

612# ============================================================================= 

613# Information about patient captured from clinical database 

614# ============================================================================= 

615 

616 

617class ClinicianInfoHolder: 

618 """ 

619 Represents information about a clinician (relating to a patient, whose 

620 details are not held here). Also embodies information about which sort of 

621 clinician we prefer to ask about patient contact requests (via 

622 :attr:`clinician_preference_order`). 

623 

624 Python object only; not stored in the database. 

625 """ 

626 

627 CARE_COORDINATOR = "care_coordinator" 

628 CONSULTANT = "consultant" 

629 HCP = "HCP" 

630 TEAM = "team" 

631 

632 def __init__( 

633 self, 

634 clinician_type: str, 

635 title: str, 

636 first_name: str, 

637 surname: str, 

638 email: str, 

639 signatory_title: str, 

640 is_consultant: bool, 

641 start_date: Union[datetime.date, datetime.datetime] = None, 

642 end_date: Union[datetime.date, datetime.datetime] = None, 

643 address_components: List[str] = None, 

644 ) -> None: 

645 """ 

646 Args: 

647 clinician_type: one of our special strings indicating what type 

648 of clinician (e.g. care coordinator, consultant, other 

649 healthcare professional, authorized clinical team 

650 representative). 

651 title: clinician's title 

652 first_name: clinician's first name 

653 surname: clinician's surname 

654 email: clinician's e-mail address 

655 signatory_title: when the clinician signs a letter, what should 

656 go under their name to say what job they do? (For example: 

657 "Consultant psychiatrist"; "Care coordinator"). 

658 is_consultant: is the clinician an NHS consultant? (Relates to 

659 special legal rules regarding CTIMPs.) 

660 start_date: 

661 when did this clinician's involvement start? 

662 end_date: 

663 when did this clinician's involvement end? 

664 address_components: 

665 address lines for the clinician 

666 """ 

667 self.clinician_type = clinician_type 

668 self.title = title 

669 self.first_name = first_name 

670 self.surname = surname 

671 self.email = email or make_cpft_email_address(first_name, surname) 

672 self.signatory_title = signatory_title 

673 self.is_consultant = is_consultant 

674 self.start_date = to_date(start_date) 

675 self.end_date = to_date(end_date) 

676 self.address_components = address_components or [] # type: List[str] 

677 

678 if clinician_type == self.CARE_COORDINATOR: 

679 self.clinician_preference_order = 1 # best 

680 elif clinician_type == self.CONSULTANT: 

681 self.clinician_preference_order = 2 

682 elif clinician_type == self.HCP: 

683 self.clinician_preference_order = 3 

684 elif clinician_type == self.TEAM: 

685 self.clinician_preference_order = 4 

686 else: 

687 self.clinician_preference_order = 99999 # worst 

688 

689 def __repr__(self) -> str: 

690 return simple_repr( 

691 self, 

692 [ 

693 "clinician_type", 

694 "title", 

695 "first_name", 

696 "surname", 

697 "email", 

698 "signatory_title", 

699 "is_consultant", 

700 "start_date", 

701 "end_date", 

702 "address_components", 

703 ], 

704 ) 

705 

706 def current(self) -> bool: 

707 """ 

708 Is the clinician currently involved (with this patient's care)? 

709 """ 

710 return self.end_date is None or self.end_date >= datetime.date.today() 

711 

712 def contactable(self) -> bool: 

713 """ 

714 Do we have enough information to contact the clinician 

715 (electronically)? 

716 """ 

717 return bool(self.surname and self.email) 

718 

719 

720class PatientLookupBase(models.Model): 

721 """ 

722 Base class for :class:`PatientLookup` and :class:`DummyPatientSourceInfo`. 

723 

724 Must be able to be instantiate with defaults, for the "not found" 

725 situation. 

726 

727 Note that derived classes must implement ``nhs_number`` as a column. 

728 """ 

729 

730 MALE = "M" 

731 FEMALE = "F" 

732 INTERSEX = "X" 

733 UNKNOWNSEX = "?" 

734 SEX_CHOICES = ( 

735 (MALE, "Male"), 

736 (FEMALE, "Female"), 

737 (INTERSEX, "Inderminate/intersex"), 

738 (UNKNOWNSEX, "Unknown"), 

739 ) 

740 

741 # Details of lookup 

742 pt_local_id_description = models.CharField( 

743 blank=True, 

744 max_length=LEN_FIELD_DESCRIPTION, 

745 verbose_name="Description of database-specific ID", 

746 ) 

747 pt_local_id_number = models.BigIntegerField( 

748 null=True, blank=True, verbose_name="Database-specific ID" 

749 ) 

750 # Information coming out: patient 

751 pt_dob = models.DateField( 

752 null=True, blank=True, verbose_name="Patient date of birth" 

753 ) 

754 pt_dod = models.DateField( 

755 null=True, 

756 blank=True, 

757 verbose_name="Patient date of death (NULL if alive)", 

758 ) 

759 pt_dead = models.BooleanField( 

760 default=False, verbose_name="Patient is dead" 

761 ) 

762 pt_discharged = models.BooleanField( 

763 null=True, verbose_name="Patient discharged" 

764 ) 

765 pt_discharge_date = models.DateField( 

766 null=True, blank=True, verbose_name="Patient date of discharge" 

767 ) 

768 pt_sex = models.CharField( 

769 max_length=1, 

770 blank=True, 

771 choices=SEX_CHOICES, 

772 verbose_name="Patient sex", 

773 ) 

774 pt_title = models.CharField( 

775 max_length=LEN_TITLE, blank=True, verbose_name="Patient title" 

776 ) 

777 pt_first_name = models.CharField( 

778 max_length=LEN_NAME, blank=True, verbose_name="Patient first name" 

779 ) 

780 pt_last_name = models.CharField( 

781 max_length=LEN_NAME, blank=True, verbose_name="Patient last name" 

782 ) 

783 pt_address_1 = models.CharField( 

784 max_length=LEN_ADDRESS, 

785 blank=True, 

786 verbose_name="Patient address line 1", 

787 ) 

788 pt_address_2 = models.CharField( 

789 max_length=LEN_ADDRESS, 

790 blank=True, 

791 verbose_name="Patient address line 2", 

792 ) 

793 pt_address_3 = models.CharField( 

794 max_length=LEN_ADDRESS, 

795 blank=True, 

796 verbose_name="Patient address line 3", 

797 ) 

798 pt_address_4 = models.CharField( 

799 max_length=LEN_ADDRESS, 

800 blank=True, 

801 verbose_name="Patient address line 4", 

802 ) 

803 pt_address_5 = models.CharField( 

804 max_length=LEN_ADDRESS, 

805 blank=True, 

806 verbose_name="Patient address line 5 (county)", 

807 ) 

808 pt_address_6 = models.CharField( 

809 max_length=LEN_ADDRESS, 

810 blank=True, 

811 verbose_name="Patient address line 6 (postcode)", 

812 ) 

813 pt_address_7 = models.CharField( 

814 max_length=LEN_ADDRESS, 

815 blank=True, 

816 verbose_name="Patient address line 7 (country)", 

817 ) 

818 pt_telephone = models.CharField( 

819 max_length=LEN_PHONE, blank=True, verbose_name="Patient telephone" 

820 ) 

821 pt_email = models.EmailField(blank=True, verbose_name="Patient email") 

822 

823 # Information coming out: GP 

824 gp_title = models.CharField( 

825 max_length=LEN_TITLE, blank=True, verbose_name="GP title" 

826 ) 

827 gp_first_name = models.CharField( 

828 max_length=LEN_NAME, blank=True, verbose_name="GP first name" 

829 ) 

830 gp_last_name = models.CharField( 

831 max_length=LEN_NAME, blank=True, verbose_name="GP last name" 

832 ) 

833 gp_address_1 = models.CharField( 

834 max_length=LEN_ADDRESS, blank=True, verbose_name="GP address line 1" 

835 ) 

836 gp_address_2 = models.CharField( 

837 max_length=LEN_ADDRESS, blank=True, verbose_name="GP address line 2" 

838 ) 

839 gp_address_3 = models.CharField( 

840 max_length=LEN_ADDRESS, blank=True, verbose_name="GP address line 3" 

841 ) 

842 gp_address_4 = models.CharField( 

843 max_length=LEN_ADDRESS, blank=True, verbose_name="GP address line 4" 

844 ) 

845 gp_address_5 = models.CharField( 

846 max_length=LEN_ADDRESS, 

847 blank=True, 

848 verbose_name="GP address line 5 (county)", 

849 ) 

850 gp_address_6 = models.CharField( 

851 max_length=LEN_ADDRESS, 

852 blank=True, 

853 verbose_name="GP address line 6 (postcode)", 

854 ) 

855 gp_address_7 = models.CharField( 

856 max_length=LEN_ADDRESS, 

857 blank=True, 

858 verbose_name="GP address line 7 (country)", 

859 ) 

860 gp_telephone = models.CharField( 

861 max_length=LEN_PHONE, blank=True, verbose_name="GP telephone" 

862 ) 

863 gp_email = models.EmailField(blank=True, verbose_name="GP email") 

864 

865 # Information coming out: clinician 

866 clinician_title = models.CharField( 

867 max_length=LEN_TITLE, blank=True, verbose_name="Clinician title" 

868 ) 

869 clinician_first_name = models.CharField( 

870 max_length=LEN_NAME, blank=True, verbose_name="Clinician first name" 

871 ) 

872 clinician_last_name = models.CharField( 

873 max_length=LEN_NAME, blank=True, verbose_name="Clinician last name" 

874 ) 

875 clinician_address_1 = models.CharField( 

876 max_length=LEN_ADDRESS, 

877 blank=True, 

878 verbose_name="Clinician address line 1", 

879 ) 

880 clinician_address_2 = models.CharField( 

881 max_length=LEN_ADDRESS, 

882 blank=True, 

883 verbose_name="Clinician address line 2", 

884 ) 

885 clinician_address_3 = models.CharField( 

886 max_length=LEN_ADDRESS, 

887 blank=True, 

888 verbose_name="Clinician address line 3", 

889 ) 

890 clinician_address_4 = models.CharField( 

891 max_length=LEN_ADDRESS, 

892 blank=True, 

893 verbose_name="Clinician address line 4", 

894 ) 

895 clinician_address_5 = models.CharField( 

896 max_length=LEN_ADDRESS, 

897 blank=True, 

898 verbose_name="Clinician address line 5 (county)", 

899 ) 

900 clinician_address_6 = models.CharField( 

901 max_length=LEN_ADDRESS, 

902 blank=True, 

903 verbose_name="Clinician address line 6 (postcode)", 

904 ) 

905 clinician_address_7 = models.CharField( 

906 max_length=LEN_ADDRESS, 

907 blank=True, 

908 verbose_name="Clinician address line 7 (country)", 

909 ) 

910 clinician_telephone = models.CharField( 

911 max_length=LEN_PHONE, blank=True, verbose_name="Clinician telephone" 

912 ) 

913 clinician_email = models.EmailField( 

914 blank=True, verbose_name="Clinician email" 

915 ) 

916 clinician_is_consultant = models.BooleanField( 

917 default=False, verbose_name="Clinician is a consultant" 

918 ) 

919 clinician_signatory_title = models.CharField( 

920 max_length=LEN_NAME, 

921 blank=True, 

922 verbose_name="Clinician's title for signature " 

923 "(e.g. 'Consultant psychiatrist')", 

924 ) 

925 

926 class Meta: 

927 abstract = True 

928 

929 # Generic title stuff: 

930 

931 # ------------------------------------------------------------------------- 

932 # Patient 

933 # ------------------------------------------------------------------------- 

934 

935 def pt_salutation(self) -> str: 

936 """ 

937 Returns a salutation for the patient, like "Mrs Smith". 

938 """ 

939 # noinspection PyTypeChecker 

940 return salutation( 

941 self.pt_title, 

942 self.pt_first_name, 

943 self.pt_last_name, 

944 sex=self.pt_sex, 

945 ) 

946 

947 def pt_title_forename_surname(self) -> str: 

948 """ 

949 Returns the patient's title/forename/surname, like "Mrs Ann Smith". 

950 """ 

951 # noinspection PyTypeChecker 

952 return title_forename_surname( 

953 self.pt_title, self.pt_first_name, self.pt_last_name 

954 ) 

955 

956 def pt_forename_surname(self) -> str: 

957 """ 

958 Returns the patient's forename/surname, like "Ann Smith". 

959 """ 

960 # noinspection PyTypeChecker 

961 return forename_surname(self.pt_first_name, self.pt_last_name) 

962 

963 def pt_address_components(self) -> List[str]: 

964 """ 

965 Returns lines of the patient's address (e.g. for letter headings or 

966 envelopes). 

967 """ 

968 return list( 

969 filter( 

970 None, 

971 [ 

972 self.pt_address_1, 

973 self.pt_address_2, 

974 self.pt_address_3, 

975 self.pt_address_4, 

976 self.pt_address_5, 

977 self.pt_address_6, 

978 self.pt_address_7, 

979 ], 

980 ) 

981 ) 

982 

983 def pt_address_components_str(self) -> str: 

984 """ 

985 Returns the patient's address as a single (one-line) string. 

986 """ 

987 return ", ".join(filter(None, self.pt_address_components())) 

988 

989 def pt_name_address_components(self) -> List[str]: 

990 """ 

991 Returns the patient's name and address, as lines (e.g. for an 

992 envelope). 

993 """ 

994 return [ 

995 self.pt_title_forename_surname() 

996 ] + self.pt_address_components() 

997 

998 def get_id_numbers_as_str(self) -> str: 

999 """ 

1000 Returns ID numbers, in a format like "NHS#: 123. RiO# 456." 

1001 """ 

1002 # Note that self.nhs_number must be implemented by derived classes: 

1003 # noinspection PyUnresolvedReferences 

1004 idnums = [f"NHS#: {self.nhs_number}"] 

1005 if self.pt_local_id_description: 

1006 idnums.append( 

1007 f"{self.pt_local_id_description}: {self.pt_local_id_number}" 

1008 ) 

1009 return ". ".join(idnums) 

1010 

1011 def get_pt_age_years(self) -> Optional[int]: 

1012 """ 

1013 Returns the patient's current age in years, or ``None`` if unknown. 

1014 """ 

1015 if self.pt_dob is None: 

1016 return None 

1017 now = datetime.datetime.now() # timezone-naive 

1018 # now = timezone.now() # timezone-aware 

1019 return relativedelta(now, self.pt_dob).years 

1020 

1021 def is_under_16(self) -> bool: 

1022 """ 

1023 Is the patient under 16? 

1024 """ 

1025 age = self.get_pt_age_years() 

1026 return age is not None and age < 16 

1027 

1028 def is_under_15(self) -> bool: 

1029 """ 

1030 Is the patient under 15? 

1031 """ 

1032 age = self.get_pt_age_years() 

1033 return age is not None and age < 15 

1034 

1035 def days_since_discharge(self) -> Optional[int]: 

1036 """ 

1037 Returns the number of days since discharge, or ``None`` if the patient 

1038 is not discharged (or if we don't know). 

1039 """ 

1040 if not self.pt_discharged or not self.pt_discharge_date: 

1041 return None 

1042 try: 

1043 today = datetime.date.today() 

1044 discharged = self.pt_discharge_date # type: datetime.date 

1045 diff = today - discharged 

1046 return diff.days 

1047 except (AttributeError, TypeError, ValueError): 

1048 return None 

1049 

1050 # @property 

1051 # def nhs_number(self) -> int: 

1052 # raise NotImplementedError() 

1053 # 

1054 # ... NO; do not do this; it makes nhs_number a read-only attribute, so 

1055 # derived class creation fails with 

1056 # 

1057 # AttributeError: can't set attribute 

1058 # 

1059 # when trying to write nhs_number 

1060 

1061 # ------------------------------------------------------------------------- 

1062 # GP 

1063 # ------------------------------------------------------------------------- 

1064 

1065 def gp_title_forename_surname(self) -> str: 

1066 """ 

1067 Returns the title/forename/surname for the patient's GP, like 

1068 "Dr Joe Bloggs". 

1069 """ 

1070 return title_forename_surname( 

1071 self.gp_title, 

1072 self.gp_first_name, 

1073 self.gp_last_name, 

1074 always_title=True, 

1075 assume_dr=True, 

1076 ) 

1077 

1078 def gp_address_components(self) -> List[str]: 

1079 """ 

1080 Returns address lines for the GP (e.g. for an envelope). 

1081 """ 

1082 return list( 

1083 filter( 

1084 None, 

1085 [ 

1086 self.gp_address_1, 

1087 self.gp_address_2, 

1088 self.gp_address_3, 

1089 self.gp_address_4, 

1090 self.gp_address_5, 

1091 self.gp_address_6, 

1092 self.gp_address_7, 

1093 ], 

1094 ) 

1095 ) 

1096 

1097 def gp_address_components_str(self) -> str: 

1098 """ 

1099 Returns the GP's address as a single line. 

1100 """ 

1101 return ", ".join(self.gp_address_components()) 

1102 

1103 def gp_name_address_str(self) -> str: 

1104 """ 

1105 Returns the GP's name and address as a single line. 

1106 """ 

1107 return ", ".join( 

1108 filter( 

1109 None, 

1110 [ 

1111 self.gp_title_forename_surname(), 

1112 self.gp_address_components_str(), 

1113 ], 

1114 ) 

1115 ) 

1116 

1117 # noinspection PyUnusedLocal 

1118 def set_gp_name_components( 

1119 self, name: str, decisions: List[str], secret_decisions: List[str] 

1120 ) -> None: 

1121 """ 

1122 Takes a name, splits it into components as best it can, and stores it 

1123 in the ``gp_title``, ``gp_first_name``, and ``gp_last_name`` fields. 

1124 

1125 Args: 

1126 name: GP name, e.g. "Dr Joe Bloggs" 

1127 decisions: list of human-readable decisions; will be modified 

1128 secret_decisions: list of human-readable decisions containing 

1129 secret (identifiable) information; will be modified 

1130 """ 

1131 secret_decisions.append(f"Setting GP name components from: {name}.") 

1132 self.gp_title = "" 

1133 self.gp_first_name = "" 

1134 self.gp_last_name = "" 

1135 if name == "No Registered GP" or not name: 

1136 self.gp_last_name = "[No registered GP]" 

1137 return 

1138 if "(" in name: 

1139 # A very odd thing like "LINTON H C (PL)" 

1140 self.gp_last_name = name 

1141 return 

1142 initial, surname = get_initial_surname_tuple_from_string(name) 

1143 initial = initial.title() # title case 

1144 surname = surname.title() # title case 

1145 self.gp_title = "Dr" 

1146 self.gp_first_name = initial + ("." if initial else "") 

1147 self.gp_last_name = surname 

1148 

1149 # ------------------------------------------------------------------------- 

1150 # Clinician 

1151 # ------------------------------------------------------------------------- 

1152 

1153 def clinician_salutation(self) -> str: 

1154 """ 

1155 Returns the salutation for the patient's clinician (e.g. "Dr 

1156 Paroxetine"). 

1157 """ 

1158 # noinspection PyTypeChecker 

1159 return salutation( 

1160 self.clinician_title, 

1161 self.clinician_first_name, 

1162 self.clinician_last_name, 

1163 assume_dr=True, 

1164 ) 

1165 

1166 def clinician_title_forename_surname(self) -> str: 

1167 """ 

1168 Returns the title/forename/surname for the patient's clinician (e.g. 

1169 "Dr Petra Paroxetine"). 

1170 """ 

1171 # noinspection PyTypeChecker 

1172 return title_forename_surname( 

1173 self.clinician_title, 

1174 self.clinician_first_name, 

1175 self.clinician_last_name, 

1176 ) 

1177 

1178 def clinician_address_components(self) -> List[str]: 

1179 """ 

1180 Returns the clinician's address -- or the Research Database Manager's 

1181 (with "c/o") if we don't know the clinician's. 

1182 

1183 (We're going to put the clinician's postal address into letters to 

1184 patients. Therefore, we need a sensible fallback, i.e. the RDBM's.) 

1185 """ 

1186 address_components = [ 

1187 self.clinician_address_1, 

1188 self.clinician_address_2, 

1189 self.clinician_address_3, 

1190 self.clinician_address_4, 

1191 self.clinician_address_5, 

1192 self.clinician_address_6, 

1193 self.clinician_address_7, 

1194 ] 

1195 if not any(x for x in address_components): 

1196 address_components = settings.RDBM_ADDRESS.copy() 

1197 if address_components: 

1198 address_components[0] = "c/o " + address_components[0] 

1199 return list(filter(None, address_components)) 

1200 

1201 def clinician_address_components_str(self) -> str: 

1202 """ 

1203 Returns the clinician's address in single-line format. 

1204 """ 

1205 return ", ".join(self.clinician_address_components()) 

1206 

1207 def clinician_name_address_str(self) -> str: 

1208 """ 

1209 Returns the clinician's name and address in single-line format. 

1210 """ 

1211 return ", ".join( 

1212 filter( 

1213 None, 

1214 [ 

1215 self.clinician_title_forename_surname(), 

1216 self.clinician_address_components_str(), 

1217 ], 

1218 ) 

1219 ) 

1220 

1221 # ------------------------------------------------------------------------- 

1222 # Paperwork 

1223 # ------------------------------------------------------------------------- 

1224 

1225 def get_traffic_light_decision_form(self, generic: bool = False) -> str: 

1226 """ 

1227 Returns HTML for a traffic-light decision form, customized to this 

1228 patient. 

1229 """ 

1230 context = { 

1231 "patient_lookup": self, 

1232 "generic": generic, 

1233 "settings": settings, 

1234 } 

1235 return render_pdf_html_to_string( 

1236 "traffic_light_decision_form.html", context, patient=True 

1237 ) 

1238 

1239 

1240class DummyPatientSourceInfo(PatientLookupBase): 

1241 """ 

1242 A patient lookup class that is a dummy one, for testing. 

1243 """ 

1244 

1245 # Key 

1246 nhs_number = models.BigIntegerField(verbose_name="NHS number", unique=True) 

1247 

1248 class Meta: 

1249 verbose_name_plural = "Dummy patient source information" 

1250 

1251 def __str__(self) -> str: 

1252 return ( 

1253 f"[DummyPatientSourceInfo {self.id}] " 

1254 f"Dummy patient lookup for NHS# {self.nhs_number}" 

1255 ) 

1256 

1257 

1258class PatientLookup(PatientLookupBase): 

1259 """ 

1260 Represents a moment of lookup up identifiable data about patient, GP, 

1261 and clinician from the relevant clinical database. 

1262 

1263 Inherits from :class:`PatientLookupBase` so it has the same fields, and 

1264 more. 

1265 """ 

1266 

1267 nhs_number = models.BigIntegerField( 

1268 verbose_name="NHS number used for lookup" 

1269 ) 

1270 lookup_at = models.DateTimeField( 

1271 verbose_name="When fetched from clinical database", auto_now_add=True 

1272 ) 

1273 

1274 # Information going in 

1275 source_db = models.CharField( 

1276 max_length=SOURCE_DB_NAME_MAX_LENGTH, 

1277 choices=ClinicalDatabaseType.DATABASE_CHOICES, 

1278 verbose_name="Source database used for lookup", 

1279 ) 

1280 

1281 # Information coming out: general 

1282 decisions = models.TextField( 

1283 blank=True, verbose_name="Decisions made during lookup" 

1284 ) 

1285 secret_decisions = models.TextField( 

1286 blank=True, 

1287 verbose_name="Secret (identifying) decisions made during lookup", 

1288 ) 

1289 

1290 # Information coming out: patient 

1291 pt_found = models.BooleanField(default=False, verbose_name="Patient found") 

1292 

1293 # Information coming out: GP 

1294 gp_found = models.BooleanField(default=False, verbose_name="GP found") 

1295 

1296 # Information coming out: clinician 

1297 clinician_found = models.BooleanField( 

1298 default=False, verbose_name="Clinician found" 

1299 ) 

1300 

1301 def __repr__(self) -> str: 

1302 return modelrepr(self) 

1303 

1304 def __str__(self) -> str: 

1305 return f"[PatientLookup {self.id}] NHS# {self.nhs_number}" 

1306 

1307 def get_first_traffic_light_letter_html(self) -> str: 

1308 """ 

1309 Covering letter to patient for first enquiry about research preference. 

1310 

1311 Returns HTML for this document, customized to the patient. 

1312 """ 

1313 context = { 

1314 # Letter bits 

1315 "address_from": self.clinician_address_components(), 

1316 "address_to": self.pt_name_address_components(), 

1317 "salutation": self.pt_salutation(), 

1318 "signatory_name": self.clinician_title_forename_surname(), 

1319 "signatory_title": self.clinician_signatory_title, 

1320 # Specific bits 

1321 "settings": settings, 

1322 "patient_lookup": self, 

1323 } 

1324 return render_pdf_html_to_string( 

1325 "letter_patient_first_traffic_light.html", context, patient=True 

1326 ) 

1327 

1328 def set_from_clinician_info_holder( 

1329 self, info: ClinicianInfoHolder 

1330 ) -> None: 

1331 """ 

1332 Sets the clinician information fields from the supplied 

1333 :class:`ClinicianInfoHolder`. 

1334 """ 

1335 self.clinician_found = True 

1336 self.clinician_title = info.title 

1337 self.clinician_first_name = info.first_name 

1338 self.clinician_last_name = info.surname 

1339 self.clinician_email = info.email 

1340 self.clinician_is_consultant = info.is_consultant 

1341 self.clinician_signatory_title = info.signatory_title 

1342 # Slice notation returns an empty list, rather than an exception, 

1343 # if the index is out of range 

1344 self.clinician_address_1 = info.address_components[0:1] or "" 

1345 self.clinician_address_2 = info.address_components[1:2] or "" 

1346 self.clinician_address_3 = info.address_components[2:3] or "" 

1347 self.clinician_address_4 = info.address_components[3:4] or "" 

1348 self.clinician_address_5 = info.address_components[4:5] or "" 

1349 self.clinician_address_6 = info.address_components[5:6] or "" 

1350 self.clinician_address_7 = info.address_components[6:7] or "" 

1351 

1352 

1353# ============================================================================= 

1354# Clinical team representative 

1355# ============================================================================= 

1356 

1357 

1358class TeamInfo: 

1359 """ 

1360 Represents information about all clinical teams, fetched from a clinical 

1361 source database. 

1362 

1363 Provides some simple views on 

1364 :func:`crate_anon.crateweb.consent.teamlookup.get_teams`. 

1365 """ 

1366 

1367 @staticmethod 

1368 def teams() -> List[str]: 

1369 """ 

1370 Returns all clinical team names. 

1371 """ 

1372 return get_teams() # cached function 

1373 

1374 @classmethod 

1375 def team_choices(cls) -> List[Tuple[str, str]]: 

1376 """ 

1377 Returns a Django choice list, i.e. a list of tuples like ``value, 

1378 description``. 

1379 """ 

1380 teams = cls.teams() 

1381 return [(team, team) for team in teams] 

1382 

1383 

1384class TeamRep(models.Model): 

1385 """ 

1386 Represents a clinical team representative, which is recorded in CRATE. 

1387 """ 

1388 

1389 team = models.CharField( 

1390 max_length=LEN_NAME, unique=True, verbose_name="Team description" 

1391 ) 

1392 user = models.ForeignKey( 

1393 settings.AUTH_USER_MODEL, on_delete=models.CASCADE 

1394 ) 

1395 

1396 class Meta: 

1397 verbose_name = "clinical team representative" 

1398 verbose_name_plural = "clinical team representatives" 

1399 

1400 

1401# ============================================================================= 

1402# Record of payments to charity 

1403# ============================================================================= 

1404# In passing - singleton objects: 

1405# http://goodcode.io/articles/django-singleton-models/ 

1406 

1407 

1408class CharityPaymentRecord(models.Model): 

1409 """ 

1410 A record of a payment made to charity. 

1411 """ 

1412 

1413 created_at = models.DateTimeField( 

1414 verbose_name="When created", auto_now_add=True 

1415 ) 

1416 payee = models.CharField(max_length=255) 

1417 amount = models.DecimalField(max_digits=8, decimal_places=2) 

1418 

1419 

1420# ============================================================================= 

1421# Record of consent mode for a patient 

1422# ============================================================================= 

1423 

1424 

1425class ConsentMode(Decision): 

1426 """ 

1427 Represents a consent-to-contact consent mode for a patient. 

1428 """ 

1429 

1430 RED = "red" 

1431 YELLOW = "yellow" 

1432 GREEN = "green" 

1433 

1434 VALID_CONSENT_MODES = [RED, YELLOW, GREEN] 

1435 CONSENT_MODE_CHOICES = ( 

1436 (RED, "red"), 

1437 (YELLOW, "yellow"), 

1438 (GREEN, "green"), 

1439 ) 

1440 # ... https://stackoverflow.com/questions/12822847/best-practice-for-python-django-constants # noqa: E501 

1441 

1442 SOURCE_USER_ENTRY = "crate_user_entry" 

1443 SOURCE_AUTOCREATED = "crate_auto_created" 

1444 SOURCE_LEGACY = "legacy" # default, for old versions 

1445 

1446 nhs_number = models.BigIntegerField(verbose_name="NHS number") 

1447 current = models.BooleanField(default=False) 

1448 # see save() and process_change() below 

1449 created_at = models.DateTimeField( 

1450 verbose_name="When was this record created?", auto_now_add=True 

1451 ) 

1452 created_by = models.ForeignKey( 

1453 settings.AUTH_USER_MODEL, on_delete=models.PROTECT 

1454 ) 

1455 

1456 exclude_entirely = models.BooleanField( 

1457 default=False, 

1458 verbose_name="Exclude patient from Research Database entirely?", 

1459 ) 

1460 consent_mode = models.CharField( 

1461 max_length=10, 

1462 default="", 

1463 choices=CONSENT_MODE_CHOICES, 

1464 verbose_name="Consent mode (red/yellow/green)", 

1465 ) 

1466 consent_after_discharge = models.BooleanField( 

1467 default=False, 

1468 verbose_name="Consent given to contact patient after discharge?", 

1469 ) 

1470 max_approaches_per_year = models.PositiveSmallIntegerField( 

1471 verbose_name="Maximum number of approaches permissible per year " 

1472 "(0 = no limit)", 

1473 default=0, 

1474 ) 

1475 other_requests = models.TextField( 

1476 blank=True, verbose_name="Other special requests by patient" 

1477 ) 

1478 prefers_email = models.BooleanField( 

1479 default=False, verbose_name="Patient prefers e-mail contact?" 

1480 ) 

1481 changed_by_clinician_override = models.BooleanField( 

1482 default=False, 

1483 verbose_name="Consent mode changed by clinician's override?", 

1484 ) 

1485 

1486 source = models.CharField( 

1487 max_length=SOURCE_DB_NAME_MAX_LENGTH, 

1488 default=SOURCE_USER_ENTRY, 

1489 verbose_name="Source of information", 

1490 ) 

1491 

1492 skip_letter_to_patient = models.BooleanField( 

1493 default=False 

1494 ) # added 2018-06-29 

1495 needs_processing = models.BooleanField(default=False) # added 2018-06-29 

1496 processed = models.BooleanField(default=False) # added 2018-06-29 

1497 processed_at = models.DateTimeField(null=True) # added 2018-06-29 

1498 

1499 # class Meta: 

1500 # get_latest_by = "created_at" 

1501 

1502 def save(self, *args, **kwargs) -> None: 

1503 """ 

1504 Custom save method. Ensures that only one :class:`ConsentMode` has 

1505 ``current == True`` for a given patient. 

1506 

1507 This is better than a ``get_latest_by`` clause, because with a flag 

1508 like this, we can have a simple query that says "get the current 

1509 records for all patients" -- which is harder if done by date (group by 

1510 patient, order by patient/date, pick last one for each patient...). 

1511 

1512 See 

1513 https://stackoverflow.com/questions/1455126/unique-booleanfield-value-in-django 

1514 """ 

1515 if self.current: 

1516 ConsentMode.objects.filter( 

1517 nhs_number=self.nhs_number, current=True 

1518 ).update(current=False) 

1519 super().save(*args, **kwargs) 

1520 

1521 def __str__(self) -> str: 

1522 return ( 

1523 f"[ConsentMode {self.id}] " 

1524 f"NHS# {self.nhs_number}, {self.consent_mode}" 

1525 ) 

1526 

1527 @classmethod 

1528 def get_or_create( 

1529 cls, nhs_number: int, created_by: settings.AUTH_USER_MODEL 

1530 ) -> CONSENT_MODE_FWD_REF: 

1531 """ 

1532 Fetches the current :class:`ConsentMode` for this patient. 

1533 If there isn't one, creates a default one and returns that. 

1534 """ 

1535 try: 

1536 consent_mode = cls.objects.get(nhs_number=nhs_number, current=True) 

1537 except cls.DoesNotExist: 

1538 consent_mode = cls( 

1539 nhs_number=nhs_number, 

1540 created_by=created_by, 

1541 source=cls.SOURCE_AUTOCREATED, 

1542 needs_processing=False, 

1543 current=True, 

1544 ) 

1545 consent_mode.save() 

1546 except cls.MultipleObjectsReturned: 

1547 log.warning( 

1548 "bug: ConsentMode.get_or_create() received " 

1549 "exception ConsentMode.MultipleObjectsReturned" 

1550 ) 

1551 consent_mode = cls( 

1552 nhs_number=nhs_number, 

1553 created_by=created_by, 

1554 source=cls.SOURCE_AUTOCREATED, 

1555 needs_processing=False, 

1556 current=True, 

1557 ) 

1558 consent_mode.save() 

1559 return consent_mode 

1560 

1561 @classmethod 

1562 def get_or_none(cls, nhs_number: int) -> Optional[CONSENT_MODE_FWD_REF]: 

1563 """ 

1564 Fetches the current :class:`ConsentMode` for this patient. 

1565 If there isn't one, returns ``None``. 

1566 """ 

1567 try: 

1568 return cls.objects.get(nhs_number=nhs_number, current=True) 

1569 except cls.DoesNotExist: 

1570 return None 

1571 

1572 @classmethod 

1573 def refresh_from_primary_clinical_record( 

1574 cls, 

1575 nhs_number: int, 

1576 created_by: settings.AUTH_USER_MODEL, 

1577 source_db: str = None, 

1578 ) -> List[str]: 

1579 """ 

1580 Checks the primary clinical record and CRATE's own records for consent 

1581 modes for this patient. If the most recent one is in the external 

1582 database, copies it to CRATE's database and marks that one as current. 

1583 

1584 This has the effect that external primary clinical records (e.g. RiO) 

1585 take priority, but if there's no record in RiO, we can still proceed. 

1586 

1587 Returns a list of human-readable decisions. 

1588 

1589 Internally, we do this: 

1590 

1591 - Fetch the most recent record. 

1592 - If its date is later than the most recent CRATE record: 

1593 

1594 - create a new ConsentMode with (..., source=source_db) 

1595 - save it 

1596 - trigger 

1597 :func:`crate_anon.crateweb.consent.tasks.process_consent_change` on 

1598 commit, which might take further action 

1599 

1600 """ 

1601 from crate_anon.crateweb.consent.lookup import ( 

1602 lookup_consent, 

1603 ) # delayed import 

1604 

1605 decisions = [] # type: List[str] 

1606 source_db = source_db or settings.CLINICAL_LOOKUP_CONSENT_DB 

1607 decisions.append(f"source_db = {source_db}") 

1608 

1609 latest = lookup_consent( 

1610 nhs_number=nhs_number, source_db=source_db, decisions=decisions 

1611 ) 

1612 if latest is None: 

1613 decisions.append( 

1614 "No consent decision found in primary clinical " "record" 

1615 ) 

1616 return decisions 

1617 

1618 crate_version = cls.get_or_none(nhs_number=nhs_number) 

1619 

1620 if crate_version and crate_version.created_at >= latest.created_at: 

1621 decisions.append( 

1622 f"CRATE stored version is at least as recent " 

1623 f"({crate_version.created_at}) as the version from the " 

1624 f"clinical record ({latest.created_at}); ignoring" 

1625 ) 

1626 return decisions 

1627 

1628 # If we get here, we've found a newer version in the clinical record. 

1629 latest.created_by = created_by 

1630 latest.source = source_db 

1631 latest.current = True 

1632 latest.needs_processing = True 

1633 latest.skip_letter_to_patient = True # the patient already knows; 

1634 # they made the decision with the clinician who entered this into the 

1635 # primary clinical record. 

1636 latest.save() # This now becomes the current CRATE consent mode. 

1637 transaction.on_commit( 

1638 lambda: process_consent_change.delay(latest.id) 

1639 ) # Asynchronous 

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

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

1642 return decisions 

1643 

1644 def consider_withdrawal(self) -> None: 

1645 """ 

1646 If required, withdraw consent for other studies. 

1647 

1648 Note that as per Major Amendment 1 to 12/EE/0407, this happens 

1649 automatically, rather than having a special flag to control it. 

1650 """ 

1651 try: 

1652 previous = ConsentMode.objects.filter( 

1653 nhs_number=self.nhs_number, 

1654 current=False, 

1655 created_at__isnull=False, 

1656 ).latest("created_at") 

1657 # ... https://docs.djangoproject.com/en/dev/ref/models/querysets/#latest # noqa: E501 

1658 if not previous: 

1659 return # no previous ConsentMode; nothing to do 

1660 if ( 

1661 previous.consent_mode == ConsentMode.GREEN 

1662 and self.consent_mode != ConsentMode.GREEN 

1663 ): 

1664 contact_requests = ( 

1665 ContactRequest.objects.filter(nhs_number=self.nhs_number) 

1666 .filter(consent_mode__consent_mode=ConsentMode.GREEN) 

1667 .filter(decided_send_to_researcher=True) 

1668 .filter(consent_withdrawn=False) 

1669 ) 

1670 for contact_request in contact_requests: 

1671 ( 

1672 letter, 

1673 email_succeeded, 

1674 ) = contact_request.withdraw_consent() 

1675 if not email_succeeded: 

1676 self.notify_rdbm_of_work(letter, to_researcher=True) 

1677 except ConsentMode.DoesNotExist: 

1678 pass # no previous ConsentMode; nothing to do. 

1679 except ConsentMode.MultipleObjectsReturned: 

1680 log.warning( 

1681 "bug: ConsentMode.consider_withdrawal() received " 

1682 "exception ConsentMode.MultipleObjectsReturned" 

1683 ) 

1684 # do nothing else 

1685 

1686 def get_latest_patient_lookup(self) -> PatientLookup: 

1687 """ 

1688 Returns the latest :class:`PatientLookup` information (from the CRATE 

1689 admin database) for this patient. 

1690 """ 

1691 from crate_anon.crateweb.consent.lookup import ( 

1692 lookup_patient, 

1693 ) # delayed import 

1694 

1695 # noinspection PyTypeChecker 

1696 return lookup_patient(self.nhs_number, existing_ok=True) 

1697 

1698 def get_confirm_traffic_to_patient_letter_html( 

1699 self, patient_lookup_override: PatientLookup = None 

1700 ) -> str: 

1701 """ 

1702 Letter to patient, confirming traffic-light choice. 

1703 

1704 Returns HTML for this letter, customized to the patient. 

1705 """ 

1706 patient_lookup = ( 

1707 patient_lookup_override or self.get_latest_patient_lookup() 

1708 ) 

1709 context = { 

1710 # Letter bits 

1711 "address_from": settings.RDBM_ADDRESS + [settings.RDBM_EMAIL], 

1712 "address_to": patient_lookup.pt_name_address_components(), 

1713 "salutation": patient_lookup.pt_salutation(), 

1714 "signatory_name": settings.RDBM_NAME, 

1715 "signatory_title": settings.RDBM_TITLE, 

1716 # Specific bits 

1717 "consent_mode": self, 

1718 "patient_lookup": patient_lookup, 

1719 "settings": settings, 

1720 # URLs 

1721 # 'red_img_url': site_absolute_url(static('red.png')), 

1722 # 'yellow_img_url': site_absolute_url(static('yellow.png')), 

1723 # 'green_img_url': site_absolute_url(static('green.png')), 

1724 } 

1725 # 1. Building a static URL in code: 

1726 # https://stackoverflow.com/questions/11721818/django-get-the-static-files-url-in-view # noqa: E501 

1727 # 2. Making it an absolute URL means that wkhtmltopdf will also see it 

1728 # (by fetching it from this web server). 

1729 # 3. Works with Django testing server. 

1730 # 4. Works with Apache, + proxying to backend, + SSL 

1731 return render_pdf_html_to_string( 

1732 "letter_patient_confirm_traffic.html", context, patient=True 

1733 ) 

1734 

1735 def notify_rdbm_of_work( 

1736 self, letter: LETTER_FWD_REF, to_researcher: bool = False 

1737 ) -> None: 

1738 """ 

1739 E-mail the RDBM saying that there's new work to do: a letter to be 

1740 sent. 

1741 

1742 Args: 

1743 letter: :class:`Letter` 

1744 to_researcher: is it a letter that needs to go to a researcher, 

1745 rather than to a patient? 

1746 """ 

1747 subject = ( 

1748 f"WORK FROM RESEARCH DATABASE COMPUTER - consent mode {self.id}" 

1749 ) 

1750 if to_researcher: 

1751 template = "email_rdbm_new_work_researcher.html" 

1752 else: 

1753 template = "email_rdbm_new_work_pt_from_rdbm.html" 

1754 html = render_email_html_to_string(template, {"letter": letter}) 

1755 email = Email.create_rdbm_email(subject, html) 

1756 email.send() 

1757 

1758 @staticmethod 

1759 def get_unprocessed() -> QuerySet: 

1760 """ 

1761 Return all :class:`ConsentMode` objects that need processing. 

1762 

1763 See :func:`crate_anon.crateweb.consent.tasks.process_consent_change` 

1764 and :func:`process_change`, which does the work. 

1765 """ 

1766 return ConsentMode.objects.filter( 

1767 needs_processing=True, 

1768 current=True, 

1769 processed=False, 

1770 ) 

1771 

1772 def process_change(self) -> None: 

1773 """ 

1774 Called upon saving. 

1775 

1776 - Will create a letter to patient. 

1777 - May create a withdrawal-of-consent letter to researcher. 

1778 - Marks the :class:`ConsentMode` as having been processed. 

1779 

1780 **Major Amendment 1 (Oct 2014) to 12/EE/0407:** always withdraw consent 

1781 and tell researchers, i.e. "active cancellation" of ongoing permission, 

1782 where the researchers have not yet made contact. 

1783 """ 

1784 if self.processed: 

1785 log.warning( 

1786 f"ConsentMode #{self.id}: already processed; " 

1787 f"not processing again" 

1788 ) 

1789 return 

1790 if not self.needs_processing: 

1791 return 

1792 if not self.current: 

1793 # No point processing non-current things. 

1794 return 

1795 

1796 if not self.skip_letter_to_patient: 

1797 # noinspection PyTypeChecker 

1798 letter = Letter.create_consent_confirmation_to_patient(self) 

1799 # ... will save 

1800 self.notify_rdbm_of_work(letter, to_researcher=False) 

1801 

1802 self.consider_withdrawal() 

1803 

1804 self.processed = True 

1805 self.needs_processing = False 

1806 self.processed_at = timezone.now() 

1807 self.save() 

1808 

1809 

1810# ============================================================================= 

1811# Request for patient contact 

1812# ============================================================================= 

1813 

1814 

1815class ContactRequest(models.Model): 

1816 """ 

1817 Represents a contact request to a patient (directly or indirectly) about a 

1818 study. 

1819 """ 

1820 

1821 CLINICIAN_INVOLVEMENT_NONE = 0 

1822 CLINICIAN_INVOLVEMENT_REQUESTED = 1 

1823 CLINICIAN_INVOLVEMENT_REQUIRED_YELLOW = 2 

1824 CLINICIAN_INVOLVEMENT_REQUIRED_UNKNOWN = 3 

1825 

1826 CLINICIAN_CONTACT_MODE_CHOICES = ( 

1827 ( 

1828 CLINICIAN_INVOLVEMENT_NONE, 

1829 "No clinician involvement required or requested", 

1830 ), 

1831 ( 

1832 CLINICIAN_INVOLVEMENT_REQUESTED, 

1833 "Clinician involvement requested by researchers", 

1834 ), 

1835 ( 

1836 CLINICIAN_INVOLVEMENT_REQUIRED_YELLOW, 

1837 "Clinician involvement required by YELLOW consent mode", 

1838 ), 

1839 ( 

1840 CLINICIAN_INVOLVEMENT_REQUIRED_UNKNOWN, 

1841 "Clinician involvement required by UNKNOWN consent mode", 

1842 ), 

1843 ) 

1844 

1845 # Created initially: 

1846 created_at = models.DateTimeField( 

1847 verbose_name="When created", auto_now_add=True 

1848 ) 

1849 request_by = models.ForeignKey( 

1850 settings.AUTH_USER_MODEL, on_delete=models.PROTECT 

1851 ) 

1852 study = models.ForeignKey(Study, on_delete=models.PROTECT) # type: Study 

1853 request_direct_approach = models.BooleanField( 

1854 verbose_name="Request direct contact with patient if available" 

1855 " (not contact with clinician first)" 

1856 ) 

1857 # One of these will be non-NULL 

1858 lookup_nhs_number = models.BigIntegerField( 

1859 null=True, verbose_name="NHS number used for lookup" 

1860 ) 

1861 lookup_rid = models.CharField( 

1862 max_length=MAX_HASH_LENGTH, 

1863 null=True, 

1864 verbose_name="Research ID used for lookup", 

1865 ) 

1866 lookup_mrid = models.CharField( 

1867 max_length=MAX_HASH_LENGTH, 

1868 null=True, 

1869 verbose_name="Master research ID used for lookup", 

1870 ) 

1871 

1872 processed = models.BooleanField(default=False) 

1873 processed_at = models.DateTimeField(null=True) # added 2018-06-29 

1874 # Below: created during processing. 

1875 

1876 # Those numbers translate to this: 

1877 nhs_number = models.BigIntegerField(null=True, verbose_name="NHS number") 

1878 # ... from which: 

1879 patient_lookup = models.ForeignKey( 

1880 PatientLookup, on_delete=models.SET_NULL, null=True 

1881 ) 

1882 consent_mode = models.ForeignKey( 

1883 ConsentMode, on_delete=models.SET_NULL, null=True 

1884 ) 

1885 # Now decisions: 

1886 approaches_in_past_year = models.PositiveIntegerField(null=True) 

1887 decisions = models.TextField(blank=True, verbose_name="Decisions made") 

1888 decided_no_action = models.BooleanField(default=False) 

1889 decided_send_to_researcher = models.BooleanField(default=False) 

1890 decided_send_to_clinician = models.BooleanField(default=False) 

1891 clinician_involvement = models.PositiveSmallIntegerField( 

1892 choices=CLINICIAN_CONTACT_MODE_CHOICES, null=True 

1893 ) 

1894 consent_withdrawn = models.BooleanField(default=False) 

1895 consent_withdrawn_at = models.DateTimeField( 

1896 verbose_name="When consent withdrawn", null=True 

1897 ) 

1898 clinician_initiated = models.BooleanField(default=False) 

1899 clinician_email = models.TextField(null=True, default=None) 

1900 # Specifically for clinician-initiated case: 

1901 rdbm_to_contact_pt = models.BooleanField(default=False) 

1902 # Should be in form 'title firstname lastname' 

1903 clinician_signatory_name = models.TextField(null=True, default=None) 

1904 clinician_signatory_title = models.TextField(null=True, default=None) 

1905 

1906 def __init__(self, *args: Any, **kwargs: Any) -> None: 

1907 super().__init__(*args, **kwargs) 

1908 self.decisionlist = [] # type: List[str] 

1909 

1910 def __str__(self) -> str: 

1911 return f"[ContactRequest {self.id}] Study {self.study_id}" 

1912 

1913 def __repr__(self) -> str: 

1914 return modelrepr(self) 

1915 

1916 @classmethod 

1917 def create( 

1918 cls, 

1919 request: HttpRequest, 

1920 study: Study, 

1921 request_direct_approach: bool, 

1922 lookup_nhs_number: int = None, 

1923 lookup_rid: str = None, 

1924 lookup_mrid: str = None, 

1925 clinician_initiated: bool = False, 

1926 clinician_email: str = None, 

1927 rdbm_to_contact_pt: bool = False, 

1928 clinician_signatory_name: Optional[str] = None, 

1929 clinician_signatory_title: Optional[str] = None, 

1930 ) -> CONTACT_REQUEST_FWD_REF: 

1931 """ 

1932 Create a contact request and act on it. 

1933 

1934 Args: 

1935 request: the :class:`django.http.request.HttpRequest` 

1936 study: the :class:`Study` 

1937 request_direct_approach: would the researchers prefer to approach 

1938 the patient directly, if permitted? 

1939 lookup_nhs_number: NHS number to look up patient from 

1940 lookup_rid: research ID (RID) to look up patient from 

1941 lookup_mrid: master research ID (MRID) to look up patient from 

1942 clinician_initiated: contact request initiated by the clinician? 

1943 clinician_email: override the clinician email in patient_lookup 

1944 rdbm_to_contact_pt: should the rbdm contact the patient - for cases 

1945 where the request was initiated by clinician 

1946 clinician_signatory_name: name of clinician for letter - if None 

1947 will use PatientLookup 

1948 clinician_signatory_title: signatory title of clinician - if None 

1949 will use PatientLookup 

1950 

1951 Returns: 

1952 a :class:`ContactRequest` 

1953 """ 

1954 # https://docs.djangoproject.com/en/1.9/ref/request-response/ 

1955 # noinspection PyTypeChecker 

1956 cr = cls( 

1957 request_by=request.user, 

1958 study=study, 

1959 request_direct_approach=request_direct_approach, 

1960 lookup_nhs_number=lookup_nhs_number, 

1961 lookup_rid=lookup_rid, 

1962 lookup_mrid=lookup_mrid, 

1963 clinician_initiated=clinician_initiated, 

1964 clinician_email=clinician_email, 

1965 rdbm_to_contact_pt=rdbm_to_contact_pt, 

1966 clinician_signatory_name=clinician_signatory_name, 

1967 clinician_signatory_title=clinician_signatory_title, 

1968 ) 

1969 cr.save() 

1970 transaction.on_commit( 

1971 lambda: process_contact_request.delay(cr.id) 

1972 ) # Asynchronous 

1973 return cr 

1974 

1975 @staticmethod 

1976 def get_unprocessed() -> QuerySet: 

1977 """ 

1978 Return all :class:`ContactRequest` objects that need processing. 

1979 

1980 See :func:`crate_anon.crateweb.consent.tasks.process_contact_request` 

1981 and :func:`process_request`, which does the work. 

1982 """ 

1983 return ContactRequest.objects.filter(processed=False) 

1984 

1985 def process_request(self) -> None: 

1986 """ 

1987 Processes the :class:`ContactRequest` and marks it as processed. The 

1988 main work is done by :func:`process_request_main`. 

1989 """ 

1990 if self.processed: 

1991 log.warning( 

1992 f"ContactRequest #{self.id}: already processed; " 

1993 f"not processing again" 

1994 ) 

1995 return 

1996 self.decisionlist = [] # type: List[str] 

1997 self.process_request_main() 

1998 self.decisions = " ".join(self.decisionlist) 

1999 self.processed = True 

2000 self.processed_at = timezone.now() 

2001 self.save() 

2002 

2003 def mockup(self): 

2004 """ 

2005 Used to ensure test objects are OK. 

2006 """ 

2007 self.store_clinician_details() 

2008 

2009 def store_clinician_details(self) -> None: 

2010 """ 

2011 Ensure that if we have not got "override" details for the clinician, 

2012 that we copy them from the patient lookup. 

2013 """ 

2014 # We may need to input clinician email manually, otherwise use default 

2015 if not self.patient_lookup: 

2016 return 

2017 if not self.clinician_email: 

2018 self.clinician_email = self.patient_lookup.clinician_email 

2019 if not self.clinician_signatory_name: 

2020 self.clinician_signatory_name = ( 

2021 self.patient_lookup.clinician_title_forename_surname() 

2022 ) 

2023 if not self.clinician_signatory_title: 

2024 self.clinician_signatory_title = ( 

2025 self.patient_lookup.clinician_signatory_title 

2026 ) 

2027 

2028 def process_request_main(self) -> None: 

2029 """ 

2030 Act on a contact request and store the decisions made. 

2031 

2032 **CORE DECISION-MAKING FUNCTION FOR THE CONSENT-TO-CONTACT PROCESS.** 

2033 

2034 Writes to :attr:`decisionlist`. 

2035 """ 

2036 from crate_anon.crateweb.consent.lookup import ( 

2037 lookup_patient, 

2038 ) # delayed import 

2039 

2040 # Translate to an NHS number 

2041 research_database_info = get_research_db_info() 

2042 dbinfo = research_database_info.dbinfo_for_contact_lookup 

2043 if self.lookup_nhs_number is not None: 

2044 self.nhs_number = self.lookup_nhs_number 

2045 elif self.lookup_rid is not None: 

2046 self.nhs_number = get_mpid(dbinfo=dbinfo, rid=self.lookup_rid) 

2047 elif self.lookup_mrid is not None: 

2048 self.nhs_number = get_mpid(dbinfo=dbinfo, mrid=self.lookup_mrid) 

2049 else: 

2050 raise ValueError("No NHS number, RID, or MRID supplied.") 

2051 # Look up patient details (afresh) 

2052 self.patient_lookup = lookup_patient(self.nhs_number, save=True) 

2053 # Ensure clinician details are OK 

2054 self.store_clinician_details() 

2055 # Establish consent mode (always do this to avoid NULL problem) 

2056 ConsentMode.refresh_from_primary_clinical_record( 

2057 nhs_number=self.nhs_number, created_by=self.request_by 

2058 ) 

2059 self.consent_mode = ConsentMode.get_or_create( 

2060 nhs_number=self.nhs_number, created_by=self.request_by 

2061 ) 

2062 # Rest of processing 

2063 self.calc_approaches_in_past_year() 

2064 

2065 # --------------------------------------------------------------------- 

2066 # Main decision process 

2067 # --------------------------------------------------------------------- 

2068 

2069 # Simple failures 

2070 if not self.patient_lookup.pt_found: 

2071 self.stop("no patient found") 

2072 return 

2073 if self.consent_mode.exclude_entirely: 

2074 self.stop( 

2075 "patient has exclude_entirely flag set; " 

2076 " POTENTIALLY SERIOUS ERROR in that this patient shouldn't" 

2077 " have been in the anonymised database." 

2078 ) 

2079 return 

2080 if self.patient_lookup.pt_dead: 

2081 self.stop("patient is dead") 

2082 return 

2083 if self.consent_mode.consent_mode == ConsentMode.RED: 

2084 self.stop("patient's consent mode is RED") 

2085 return 

2086 

2087 # Age? 

2088 if self.patient_lookup.pt_dob is None: 

2089 self.stop("patient DOB unknown") 

2090 return 

2091 if ( 

2092 not self.study.include_under_16s 

2093 and self.patient_lookup.is_under_16() 

2094 ): 

2095 self.stop("patient is under 16 and study not approved for that") 

2096 return 

2097 

2098 # Discharged/outside discharge criteria? 

2099 if self.patient_lookup.pt_discharged: 

2100 if not self.study.include_discharged: 

2101 self.stop( 

2102 "patient is discharged and study not approved for that" 

2103 ) 

2104 return 

2105 # if self.consent_mode.consent_mode not in (ConsentMode.GREEN, 

2106 # ConsentMode.YELLOW): 

2107 # self.stop("patient is discharged and consent mode is not " 

2108 # "GREEN or YELLOW") 

2109 # return 

2110 days_since_discharge = self.patient_lookup.days_since_discharge() 

2111 permitted_n_days = ( 

2112 settings.PERMITTED_TO_CONTACT_DISCHARGED_PATIENTS_FOR_N_DAYS 

2113 ) 

2114 if not self.consent_mode.consent_after_discharge: 

2115 if days_since_discharge is None: 

2116 self.stop( 

2117 "patient is discharged; patient did not consent " 

2118 "to contact after discharge; unable to " 

2119 "determine days since discharge" 

2120 ) 

2121 return 

2122 if days_since_discharge > permitted_n_days: 

2123 self.stop( 

2124 f"patient was discharged {days_since_discharge} days " 

2125 f"ago; permission exists only for up to " 

2126 f"{permitted_n_days} days; patient did not consent to " 

2127 f"contact after discharge" 

2128 ) 

2129 return 

2130 

2131 # Maximum number of approaches exceeded? 

2132 if self.consent_mode.max_approaches_per_year > 0: 

2133 if ( 

2134 self.approaches_in_past_year 

2135 >= self.consent_mode.max_approaches_per_year 

2136 ): 

2137 self.stop( 

2138 f"patient has had {self.approaches_in_past_year} " 

2139 f"approaches in the past year and has set a cap of " 

2140 f"{self.consent_mode.max_approaches_per_year} per year" 

2141 ) 

2142 return 

2143 

2144 # --------------------------------------------------------------------- 

2145 # OK. If we get here, we're going to try to contact someone! 

2146 # --------------------------------------------------------------------- 

2147 

2148 # Direct? 

2149 self.save() # makes self.id, needed for FKs 

2150 if ( 

2151 self.consent_mode.consent_mode == ConsentMode.GREEN 

2152 and self.request_direct_approach 

2153 ): 

2154 # noinspection PyTypeChecker 

2155 letter = Letter.create_researcher_approval(self) # will save 

2156 self.decided_send_to_researcher = True 

2157 self.clinician_involvement = ( 

2158 ContactRequest.CLINICIAN_INVOLVEMENT_NONE 

2159 ) 

2160 self.decide( 

2161 "GREEN: Researchers prefer direct approach and patient" 

2162 " has chosen green mode: send approval to researcher." 

2163 ) 

2164 

2165 # CLARIFICATION, CPFT Research Database Oversight Committee 

2166 # 2018-11-12: even for CTIMPs, if patient is GREEN, researchers can 

2167 # contact directly -- but will need consultant involvement at the 

2168 # later (consent) stage. 

2169 

2170 # noinspection PyUnresolvedReferences 

2171 researcher_emailaddr = self.study.lead_researcher.email 

2172 try: 

2173 validate_email(researcher_emailaddr) 

2174 # noinspection PyTypeChecker 

2175 email = Email.create_researcher_approval_email(self, letter) 

2176 emailtransmission = email.send() 

2177 if emailtransmission.sent: 

2178 self.decide( 

2179 f"Sent approval to researcher at " 

2180 f"{researcher_emailaddr}" 

2181 ) 

2182 return 

2183 self.decide( 

2184 f"Failed to e-mail approval to researcher at " 

2185 f"{researcher_emailaddr}." 

2186 ) 

2187 # noinspection PyTypeChecker 

2188 self.decide(emailtransmission.failure_reason) 

2189 except ValidationError: 

2190 pass 

2191 self.decide( 

2192 "Approval letter to researcher created and needs " "printing" 

2193 ) 

2194 self.notify_rdbm_of_work(letter, to_researcher=True) 

2195 return 

2196 

2197 # All other routes are via clinician. 

2198 

2199 # noinspection PyTypeChecker 

2200 self.clinician_involvement = self.get_clinician_involvement( 

2201 consent_mode_str=self.consent_mode.consent_mode, 

2202 request_direct_approach=self.request_direct_approach, 

2203 ) 

2204 

2205 # Do we have a clinician? 

2206 if not self.patient_lookup.clinician_found: 

2207 self.stop("don't know clinician; can't proceed") 

2208 return 

2209 clinician_emailaddr = self.clinician_email 

2210 try: 

2211 validate_email(clinician_emailaddr) 

2212 except ValidationError: 

2213 self.stop(f"clinician e-mail ({clinician_emailaddr}) is invalid") 

2214 return 

2215 try: 

2216 # noinspection PyTypeChecker 

2217 validate_researcher_email_domain(clinician_emailaddr) 

2218 except ValidationError: 

2219 self.stop( 

2220 f"clinician e-mail ({clinician_emailaddr}) " 

2221 f"is not in a permitted domain" 

2222 ) 

2223 return 

2224 

2225 # Warnings 

2226 if ( 

2227 ContactRequest.objects.filter(nhs_number=self.nhs_number) 

2228 .filter(study=self.study) 

2229 .filter(decided_send_to_clinician=True) 

2230 .filter(clinician_response__responded=False) 

2231 .exists() 

2232 ): 

2233 self.decide( 

2234 "WARNING: outstanding request to clinician for same " 

2235 "patient/study." 

2236 ) 

2237 if ( 

2238 ContactRequest.objects.filter(nhs_number=self.nhs_number) 

2239 .filter(study=self.study) 

2240 .filter(decided_send_to_clinician=True) 

2241 .filter(clinician_response__responded=True) 

2242 .filter( 

2243 clinician_response__response__in=[ 

2244 ClinicianResponse.RESPONSE_B, 

2245 ClinicianResponse.RESPONSE_C, 

2246 ClinicianResponse.RESPONSE_D, 

2247 ] 

2248 ) 

2249 .exists() 

2250 ): 

2251 self.decide( 

2252 "WARNING: clinician has already rejected a request " 

2253 "about this patient/study." 

2254 ) 

2255 

2256 # If the request is clinician initiated, we need to send a different 

2257 # email. This will also create a clinician response and set the 

2258 # clinician's response to either 'yes I will contact the patient' or 

2259 # 'yes but let the RDBM contact them for me' 

2260 if self.clinician_initiated: 

2261 email = Email.create_clinician_initiated_cr_email(self) 

2262 emailtransmission = email.send() 

2263 if not emailtransmission.sent: 

2264 # noinspection PyTypeChecker 

2265 self.decide(emailtransmission.failure_reason) 

2266 self.stop( 

2267 f"Failed to send e-mail to clinician at " 

2268 f"{clinician_emailaddr}" 

2269 ) 

2270 self.decided_send_to_clinician = True 

2271 self.decide(f"Sent request to clinician at {clinician_emailaddr}") 

2272 return 

2273 

2274 # Send e-mail to clinician 

2275 # noinspection PyTypeChecker 

2276 email = Email.create_clinician_email(self) 

2277 # ... will also create a ClinicianResponse 

2278 emailtransmission = email.send() 

2279 if not emailtransmission.sent: 

2280 # noinspection PyTypeChecker 

2281 self.decide(emailtransmission.failure_reason) 

2282 self.stop( 

2283 f"Failed to send e-mail to clinician at " 

2284 f"{clinician_emailaddr}" 

2285 ) 

2286 # We don't set decided_send_to_clinician because this attempt has 

2287 # failed, and we don't want to put anyone off trying again 

2288 # immediately. 

2289 self.decided_send_to_clinician = True 

2290 self.decide(f"Sent request to clinician at {clinician_emailaddr}") 

2291 

2292 @staticmethod 

2293 def get_clinician_involvement( 

2294 consent_mode_str: str, request_direct_approach: bool 

2295 ) -> int: 

2296 """ 

2297 Returns a number indicating why a clinician is involved. 

2298 

2299 Args: 

2300 consent_mode_str: consent mode in use (see :class:`ConsentMode`) 

2301 request_direct_approach: do the researchers request direct 

2302 approach to the patient, if permitted? 

2303 

2304 Returns: 

2305 an integer constant; see :class:`ContactRequest` 

2306 

2307 """ 

2308 # Let's be precise about why the clinician is involved. 

2309 if not request_direct_approach: 

2310 return ContactRequest.CLINICIAN_INVOLVEMENT_REQUESTED 

2311 elif consent_mode_str == ConsentMode.YELLOW: 

2312 return ContactRequest.CLINICIAN_INVOLVEMENT_REQUIRED_YELLOW 

2313 else: 

2314 # Only other possibility 

2315 return ContactRequest.CLINICIAN_INVOLVEMENT_REQUIRED_UNKNOWN 

2316 

2317 def decide(self, msg: str) -> None: 

2318 """ 

2319 Make a note of a decision. 

2320 """ 

2321 self.decisionlist.append(msg) 

2322 

2323 def stop(self, msg: str) -> None: 

2324 """ 

2325 Make a note of a decision and that we have finished processing this 

2326 contact request, taking no further action. 

2327 """ 

2328 self.decide("Stopping: " + msg) 

2329 self.decided_no_action = True 

2330 

2331 def calc_approaches_in_past_year(self) -> None: 

2332 """ 

2333 Sets :attr:`approaches_in_past_year` to indicate the number of 

2334 approaches in the past year to this patient via CRATE. 

2335 

2336 How best to count this? Not by e.g. calendar year, with a flag that 

2337 gets reset to zero annually, because you might have a limit of 5, and 

2338 get 4 requests in Dec 2020 and then another 4 in Jan 2021 just after 

2339 the flag resets. Instead, we count the number of requests to that 

2340 patient in the past year. 

2341 

2342 """ 

2343 one_year_ago = timezone.now() - datetime.timedelta(days=365) 

2344 

2345 self.approaches_in_past_year = ContactRequest.objects.filter( 

2346 Q(decided_send_to_researcher=True) 

2347 | ( 

2348 Q(decided_send_to_clinician=True) 

2349 & ( 

2350 Q( 

2351 clinician_response__response=ClinicianResponse.RESPONSE_A # noqa: E501 

2352 ) 

2353 | Q( 

2354 clinician_response__response=ClinicianResponse.RESPONSE_R # noqa: E501 

2355 ) 

2356 ) 

2357 ), 

2358 nhs_number=self.nhs_number, 

2359 created_at__gte=one_year_ago, 

2360 ).count() 

2361 

2362 def withdraw_consent(self) -> Tuple[LETTER_FWD_REF, bool]: 

2363 """ 

2364 Withdraws consent that had previously been given. Will e-mail the 

2365 researcher to let them know, if it can. 

2366 

2367 Returns: 

2368 tuple: ``letter, email_succeeded`` where ``letter`` is a 

2369 :class:`Letter` to the researcher and ``email_succeeded`` indicates 

2370 whether we managed to e-mail the researcher. 

2371 

2372 """ 

2373 self.consent_withdrawn = True 

2374 self.consent_withdrawn_at = timezone.now() 

2375 self.save() 

2376 # noinspection PyTypeChecker 

2377 letter = Letter.create_researcher_withdrawal(self) # will save 

2378 # noinspection PyUnresolvedReferences 

2379 researcher_emailaddr = self.study.lead_researcher.email 

2380 email_succeeded = False 

2381 try: 

2382 validate_email(researcher_emailaddr) 

2383 # noinspection PyTypeChecker 

2384 email = Email.create_researcher_withdrawal_email(self, letter) 

2385 emailtransmission = email.send() 

2386 email_succeeded = emailtransmission.sent 

2387 except ValidationError: 

2388 pass 

2389 return letter, email_succeeded 

2390 

2391 def get_permission_date(self) -> Optional[datetime.datetime]: 

2392 """ 

2393 When was the researcher given permission? Used for the letter 

2394 withdrawing permission. 

2395 """ 

2396 if self.decided_no_action: 

2397 return None 

2398 if self.decided_send_to_researcher: 

2399 # Green route 

2400 # noinspection PyTypeChecker 

2401 return self.created_at 

2402 if self.decided_send_to_clinician: 

2403 # Yellow route -> patient -> said yes 

2404 if hasattr(self, "patient_response"): 

2405 if self.patient_response.response == PatientResponse.YES: 

2406 return self.patient_response.created_at 

2407 return None 

2408 

2409 def notify_rdbm_of_work( 

2410 self, letter: LETTER_FWD_REF, to_researcher: bool = False 

2411 ) -> None: 

2412 """ 

2413 E-mail the RDBM to say that there's work to do. 

2414 

2415 Args: 

2416 letter: a :class:`Letter` 

2417 to_researcher: is it a letter that needs to go to a researcher 

2418 manually, rather than a letter that a clinician wants the 

2419 RDBM to send on their behalf? 

2420 """ 

2421 subject = ( 

2422 f"CHEERFUL WORK FROM RESEARCH DATABASE COMPUTER - " 

2423 f"contact request {self.id}" 

2424 ) 

2425 if to_researcher: 

2426 template = "email_rdbm_new_work_researcher.html" 

2427 else: 

2428 template = "email_rdbm_new_work_pt_from_clinician.html" 

2429 html = render_email_html_to_string(template, {"letter": letter}) 

2430 email = Email.create_rdbm_email(subject, html) 

2431 email.send() 

2432 

2433 def notify_rdbm_of_bad_progress(self) -> None: 

2434 """ 

2435 Lets the RDBM know that a clinician refused (vetoed) a request. 

2436 """ 

2437 subject = ( 

2438 f"INFO ONLY - clinician refused Research Database request " 

2439 f"- contact request {self.id}" 

2440 ) 

2441 html = render_email_html_to_string( 

2442 "email_rdbm_bad_progress.html", 

2443 { 

2444 "id": self.id, 

2445 "response": self.clinician_response.response, 

2446 "explanation": self.clinician_response.get_response_explanation(), # noqa: E501 

2447 }, 

2448 ) 

2449 email = Email.create_rdbm_email(subject, html) 

2450 email.send() 

2451 

2452 def notify_rdbm_of_good_progress(self) -> None: 

2453 """ 

2454 Lets the RDBM know that a clinician said yes to a request and wishes to 

2455 do the work themselves. 

2456 """ 

2457 subject = ( 

2458 f"INFO ONLY - clinician agreed to Research Database request" 

2459 f" - contact request {self.id}" 

2460 ) 

2461 html = render_email_html_to_string( 

2462 "email_rdbm_good_progress.html", 

2463 { 

2464 "id": self.id, 

2465 "response": self.clinician_response.response, 

2466 "explanation": self.clinician_response.get_response_explanation(), # noqa: E501 

2467 }, 

2468 ) 

2469 email = Email.create_rdbm_email(subject, html) 

2470 email.send() 

2471 

2472 def get_clinician_email_html(self, save: bool = True) -> str: 

2473 """ 

2474 **REC DOCUMENTS 09, 11, 13 (A): E-mail to clinician asking them to pass 

2475 on contact request.** 

2476 

2477 Args: 

2478 save: save the e-mail to the database? (Only false for testing.) 

2479 

2480 Returns: 

2481 HTML for this e-mail 

2482 

2483 - When we create a URL, should we put parameters in the path, 

2484 querystring, or both? 

2485 

2486 - see notes in ``core/utils.py`` 

2487 - In this case, we decide as follows: since we are creating a 

2488 :class:`ClinicianResponse`, we should use its ModelForm. 

2489 - URL path for PK 

2490 - querystring for other parameters, with form-based validation 

2491 

2492 """ 

2493 clinician_response = ClinicianResponse.create(self, save=save) 

2494 if not save: 

2495 clinician_response.id = -1 # dummy PK, guaranteed to fail 

2496 context = { 

2497 "contact_request": self, 

2498 "study": self.study, 

2499 "patient_lookup": self.patient_lookup, 

2500 "consent_mode": self.consent_mode, 

2501 "settings": settings, 

2502 "url_yes": clinician_response.get_abs_url_yes(), 

2503 "url_no": clinician_response.get_abs_url_no(), 

2504 "url_maybe": clinician_response.get_abs_url_maybe(), 

2505 "permitted_to_contact_discharged_patients_for_n_days": settings.PERMITTED_TO_CONTACT_DISCHARGED_PATIENTS_FOR_N_DAYS, # noqa: E501 

2506 "permitted_to_contact_discharged_patients_for_n_years": days_to_years( # noqa: E501 

2507 settings.PERMITTED_TO_CONTACT_DISCHARGED_PATIENTS_FOR_N_DAYS 

2508 ), 

2509 } 

2510 return render_email_html_to_string("email_clinician.html", context) 

2511 

2512 def get_clinician_initiated_email_html(self, save: bool = True) -> str: 

2513 """ 

2514 Email to clinician confirming a clinician-initiated contact request. 

2515 Will inlcude a link to the clinician pack if they do not want the RDBM 

2516 to contact the patient for them. Also sets the clinician's response. 

2517 

2518 Args: 

2519 save: save the e-mail to the database? (Only false for testing.) 

2520 

2521 Returns: 

2522 HTML for this e-mail 

2523 

2524 """ 

2525 clinician_response = ClinicianResponse.create(self, save=save) 

2526 if not save: 

2527 clinician_response.id = -1 # dummy PK, guaranteed to fail 

2528 if self.rdbm_to_contact_pt: 

2529 clinician_response.response = ClinicianResponse.RESPONSE_R 

2530 else: 

2531 clinician_response.response = ClinicianResponse.RESPONSE_A 

2532 clinician_response.finalize_a() # first part of processing 

2533 transaction.on_commit( 

2534 lambda: finalize_clinician_response.delay(clinician_response.id) 

2535 ) 

2536 rev = reverse( 

2537 "clinician_pack", 

2538 args=[clinician_response.id, clinician_response.token], 

2539 ) 

2540 url_pack = site_absolute_url(rev) 

2541 context = { 

2542 "contact_request": self, 

2543 "study": self.study, 

2544 "patient_lookup": self.patient_lookup, 

2545 "consent_mode": self.consent_mode, 

2546 "clinician_response": clinician_response, 

2547 "settings": settings, 

2548 "url_pack": url_pack, 

2549 } 

2550 return render_email_html_to_string( 

2551 "email_clinician_initiated_cr.html", context 

2552 ) 

2553 

2554 def get_approval_letter_html(self) -> str: 

2555 """ 

2556 **REC DOCUMENT 15. Letter to researcher approving contact.** 

2557 

2558 Returns the HTML for this letter. 

2559 """ 

2560 context = { 

2561 # Letter bits 

2562 "address_from": ( 

2563 settings.RDBM_ADDRESS 

2564 + [settings.RDBM_TELEPHONE, settings.RDBM_EMAIL] 

2565 ), 

2566 "address_to": self.study.get_lead_researcher_name_address(), 

2567 "salutation": self.study.get_lead_researcher_salutation(), 

2568 "signatory_name": settings.RDBM_NAME, 

2569 "signatory_title": settings.RDBM_TITLE, 

2570 # Specific bits 

2571 "contact_request": self, 

2572 "study": self.study, 

2573 "patient_lookup": self.patient_lookup, 

2574 "consent_mode": self.consent_mode, 

2575 "permitted_to_contact_discharged_patients_for_n_days": settings.PERMITTED_TO_CONTACT_DISCHARGED_PATIENTS_FOR_N_DAYS, # noqa: E501 

2576 "permitted_to_contact_discharged_patients_for_n_years": days_to_years( # noqa: E501 

2577 settings.PERMITTED_TO_CONTACT_DISCHARGED_PATIENTS_FOR_N_DAYS 

2578 ), 

2579 "RDBM_ADDRESS": settings.RDBM_ADDRESS, 

2580 } 

2581 return render_pdf_html_to_string( 

2582 "letter_researcher_approve.html", context, patient=False 

2583 ) 

2584 

2585 def get_withdrawal_letter_html(self) -> str: 

2586 """ 

2587 **REC DOCUMENT 16. Letter to researcher notifying them of withdrawal of 

2588 consent.** 

2589 

2590 Returns the HTML for this letter. 

2591 """ 

2592 context = { 

2593 # Letter bits 

2594 "address_from": ( 

2595 settings.RDBM_ADDRESS 

2596 + [settings.RDBM_TELEPHONE, settings.RDBM_EMAIL] 

2597 ), 

2598 "address_to": self.study.get_lead_researcher_name_address(), 

2599 "salutation": self.study.get_lead_researcher_salutation(), 

2600 "signatory_name": settings.RDBM_NAME, 

2601 "signatory_title": settings.RDBM_TITLE, 

2602 # Specific bits 

2603 "contact_request": self, 

2604 "study": self.study, 

2605 "patient_lookup": self.patient_lookup, 

2606 "consent_mode": self.consent_mode, 

2607 } 

2608 return render_pdf_html_to_string( 

2609 "letter_researcher_withdraw.html", context, patient=False 

2610 ) 

2611 

2612 def get_approval_email_html(self) -> str: 

2613 """ 

2614 Returns HTML for a simple e-mail to the researcher attaching an 

2615 approval letter. 

2616 """ 

2617 context = { 

2618 "contact_request": self, 

2619 "study": self.study, 

2620 "patient_lookup": self.patient_lookup, 

2621 "consent_mode": self.consent_mode, 

2622 } 

2623 return render_email_html_to_string( 

2624 "email_researcher_approval.html", context 

2625 ) 

2626 

2627 def get_withdrawal_email_html(self) -> str: 

2628 """ 

2629 Returns HTML for a simple e-mail to the researcher attaching an 

2630 withdrawal-of-previous-consent letter. 

2631 """ 

2632 context = { 

2633 "contact_request": self, 

2634 "study": self.study, 

2635 "patient_lookup": self.patient_lookup, 

2636 "consent_mode": self.consent_mode, 

2637 } 

2638 return render_email_html_to_string( 

2639 "email_researcher_withdrawal.html", context 

2640 ) 

2641 

2642 def get_letter_clinician_to_pt_re_study(self) -> str: 

2643 """ 

2644 Letters from clinician to patient, with decision form. 

2645 

2646 Returns the HTML for this letter. 

2647 """ 

2648 patient_lookup = self.patient_lookup 

2649 if not patient_lookup: 

2650 raise Http404( 

2651 "No patient_lookup: is the back-end message queue " 

2652 "(e.g. Celery + RabbitMQ) running?" 

2653 ) 

2654 yellow = ( 

2655 self.clinician_involvement 

2656 == ContactRequest.CLINICIAN_INVOLVEMENT_REQUIRED_YELLOW 

2657 ) 

2658 if self.clinician_initiated: 

2659 clinician_address_components = self.request_by_address_components() 

2660 else: 

2661 clinician_address_components = ( 

2662 patient_lookup.clinician_address_components() 

2663 ) 

2664 context = { 

2665 # Letter bits 

2666 "address_from": clinician_address_components, 

2667 "address_to": patient_lookup.pt_name_address_components(), 

2668 "salutation": patient_lookup.pt_salutation(), 

2669 "signatory_name": self.clinician_signatory_name, 

2670 "signatory_title": self.clinician_signatory_title, 

2671 # Specific bits 

2672 "contact_request": self, 

2673 "study": self.study, 

2674 "patient_lookup": patient_lookup, 

2675 "settings": settings, 

2676 "extra_form": self.is_extra_form(), 

2677 "yellow": yellow, 

2678 "unknown_consent_mode": self.is_consent_mode_unknown(), 

2679 } 

2680 return render_pdf_html_to_string( 

2681 "letter_patient_from_clinician_re_study.html", 

2682 context, 

2683 patient=True, 

2684 ) 

2685 

2686 def is_extra_form(self) -> bool: 

2687 """ 

2688 Is there an extra form from the researchers that they wish passed on to 

2689 the patient? 

2690 """ 

2691 study = self.study 

2692 clinician_requested = not self.request_direct_approach 

2693 extra_form = ( 

2694 clinician_requested and study.subject_form_template_pdf.name 

2695 ) 

2696 # log.debug(f"clinician_requested: {clinician_requested}") 

2697 # log.debug(f"extra_form: {extra_form}") 

2698 return extra_form 

2699 

2700 def is_consent_mode_unknown(self) -> bool: 

2701 """ 

2702 Is the consent mode "unknown" (NULL in the database)? 

2703 """ 

2704 return not self.consent_mode.consent_mode 

2705 

2706 def get_decision_form_to_pt_re_study(self) -> str: 

2707 """ 

2708 Returns HTML for the form for the patient to decide about this 

2709 study. 

2710 """ 

2711 n_forms = 1 

2712 extra_form = self.is_extra_form() 

2713 if extra_form: 

2714 n_forms += 1 

2715 yellow = ( 

2716 self.clinician_involvement 

2717 == ContactRequest.CLINICIAN_INVOLVEMENT_REQUIRED_YELLOW 

2718 ) 

2719 unknown = ( 

2720 self.clinician_involvement 

2721 == ContactRequest.CLINICIAN_INVOLVEMENT_REQUIRED_UNKNOWN 

2722 ) 

2723 if unknown: 

2724 n_forms += 1 

2725 context = { 

2726 "contact_request": self, 

2727 "study": self.study, 

2728 "patient_lookup": self.patient_lookup, 

2729 "settings": settings, 

2730 "extra_form": extra_form, 

2731 "n_forms": n_forms, 

2732 "yellow": yellow, 

2733 } 

2734 return render_pdf_html_to_string( 

2735 "decision_form_to_patient_re_study.html", context, patient=True 

2736 ) 

2737 

2738 def get_clinician_pack_pdf(self) -> bytes: 

2739 """ 

2740 Returns a PDF of the "clinician pack": a cover letter, decision forms, 

2741 and any other information required, customized for this request. 

2742 """ 

2743 # Order should match letter... 

2744 

2745 # Letter to patient from clinician 

2746 pdf_plans = [ 

2747 CratePdfPlan( 

2748 is_html=True, 

2749 html=self.get_letter_clinician_to_pt_re_study(), 

2750 ethics_doccode=EthicsInfo.LTR_C_P_STUDY, 

2751 ) 

2752 ] 

2753 # Study details 

2754 if self.study.study_details_pdf: 

2755 # noinspection PyUnresolvedReferences 

2756 pdf_plans.append( 

2757 CratePdfPlan( 

2758 is_filename=True, 

2759 filename=self.study.study_details_pdf.path, 

2760 ) 

2761 ) 

2762 # Decision form about this study 

2763 pdf_plans.append( 

2764 CratePdfPlan( 

2765 is_html=True, 

2766 html=self.get_decision_form_to_pt_re_study(), 

2767 ethics_doccode=EthicsInfo.FORM_STUDY, 

2768 ) 

2769 ) 

2770 # Additional form for this study 

2771 if self.is_extra_form(): 

2772 if self.study.subject_form_template_pdf: 

2773 # noinspection PyUnresolvedReferences 

2774 pdf_plans.append( 

2775 CratePdfPlan( 

2776 is_filename=True, 

2777 filename=self.study.subject_form_template_pdf.path, 

2778 ) 

2779 ) 

2780 # Traffic-light decision form, if consent mode unknown 

2781 if self.is_consent_mode_unknown(): 

2782 # 2017-03-03: changed to a personalized version 

2783 

2784 # try: 

2785 # leaflet = Leaflet.objects.get( 

2786 # name=Leaflet.CPFT_TRAFFICLIGHT_CHOICE) 

2787 # pdf_plans.append(PdfPlan(is_filename=True, 

2788 # filename=leaflet.pdf.path)) 

2789 # except ObjectDoesNotExist: 

2790 # log.warning("Missing traffic-light leaflet!") 

2791 # email_rdbm_task.delay( 

2792 # subject="ERROR FROM RESEARCH DATABASE COMPUTER", 

2793 # text=( 

2794 # "Missing traffic-light leaflet! Incomplete clinician " # noqa: E501 

2795 # "pack accessed for contact request {}.".format( 

2796 # self.id) 

2797 # ) 

2798 # ) 

2799 

2800 pdf_plans.append( 

2801 CratePdfPlan( 

2802 is_html=True, 

2803 html=self.patient_lookup.get_traffic_light_decision_form(), 

2804 ethics_doccode=EthicsInfo.FORM_TRAFFIC_PERSONALIZED, 

2805 ) 

2806 ) 

2807 # General info leaflet 

2808 try: 

2809 leaflet = Leaflet.objects.get(name=Leaflet.CPFT_TPIR) 

2810 pdf_plans.append( 

2811 CratePdfPlan(is_filename=True, filename=leaflet.pdf.path) 

2812 ) 

2813 except ObjectDoesNotExist: 

2814 log.warning("Missing taking-part-in-research leaflet!") 

2815 email_rdbm_task.delay( 

2816 subject="ERROR FROM RESEARCH DATABASE COMPUTER", 

2817 text=( 

2818 f"Missing taking-part-in-research leaflet! Incomplete " 

2819 f"clinician pack accessed for contact request {self.id}." 

2820 ), 

2821 ) 

2822 return get_concatenated_pdf_in_memory(pdf_plans, start_recto=True) 

2823 

2824 def get_mgr_admin_url(self) -> str: 

2825 """ 

2826 Returns the URL for the admin site to view this 

2827 :class:`ContactRequest`. 

2828 """ 

2829 from crate_anon.crateweb.core.admin import ( 

2830 mgr_admin_site, 

2831 ) # delayed import 

2832 

2833 return admin_view_url(mgr_admin_site, self) 

2834 

2835 def request_by_address_components(self) -> List[str]: 

2836 """ 

2837 Returns the address of the person who made the contact request -- or 

2838 the Research Database Manager's (with "c/o") if we don't know the 

2839 requester's. 

2840 

2841 This will be used in cases of a clinician-iniated request, for use in 

2842 letters to the patient. 

2843 """ 

2844 try: 

2845 userprofile = UserProfile.objects.get(user=self.request_by) 

2846 except UserProfile.DoesNotExist: 

2847 log.warning( 

2848 "ContactRequest object needs 'request_by' to be " 

2849 "a valid user for 'request_by_address_components'." 

2850 ) 

2851 address_components = [] # type: List[str] 

2852 else: 

2853 address_components = [ 

2854 userprofile.address_1, 

2855 userprofile.address_2, 

2856 userprofile.address_3, 

2857 userprofile.address_4, 

2858 userprofile.address_5, 

2859 userprofile.address_6, 

2860 userprofile.address_7, 

2861 ] 

2862 if not any(x for x in address_components): 

2863 address_components = settings.RDBM_ADDRESS.copy() 

2864 if address_components: 

2865 address_components[0] = "c/o " + address_components[0] 

2866 return list(filter(None, address_components)) 

2867 

2868 

2869# ============================================================================= 

2870# Clinician response 

2871# ============================================================================= 

2872 

2873 

2874class ClinicianResponse(models.Model): 

2875 """ 

2876 Represents the response of a clinician to a :class:`ContactRequest` that 

2877 was routed to them. 

2878 """ 

2879 

2880 TOKEN_LENGTH_CHARS = 20 

2881 # info_bits = math.log(math.pow(26 + 26 + 10, TOKEN_LENGTH_CHARS), 2) 

2882 # p_guess = math.pow(0.5, info_bits) 

2883 

2884 RESPONSE_A = "A" 

2885 RESPONSE_B = "B" 

2886 RESPONSE_C = "C" 

2887 RESPONSE_D = "D" 

2888 RESPONSE_R = "R" 

2889 RESPONSES = ( 

2890 (RESPONSE_R, "R: Clinician asks RDBM to pass request to patient"), 

2891 (RESPONSE_A, "A: Clinician will pass the request to the patient"), 

2892 (RESPONSE_B, "B: Clinician vetoes on clinical grounds"), 

2893 (RESPONSE_C, "C: Patient is definitely ineligible"), 

2894 ( 

2895 RESPONSE_D, 

2896 "D: Patient is deceased, discharged, or details are defunct", 

2897 ), 

2898 ) 

2899 

2900 ROUTE_EMAIL = "e" 

2901 ROUTE_WEB = "w" 

2902 RESPONSE_ROUTES = ( 

2903 (ROUTE_EMAIL, "E-mail"), 

2904 (ROUTE_WEB, "Web"), 

2905 ) 

2906 

2907 EMAIL_CHOICE_Y = "y" 

2908 EMAIL_CHOICE_N = "n" 

2909 EMAIL_CHOICE_TELL_ME_MORE = "more" 

2910 EMAIL_CHOICES = ( 

2911 (EMAIL_CHOICE_Y, "Yes"), 

2912 (EMAIL_CHOICE_N, "No"), 

2913 (EMAIL_CHOICE_TELL_ME_MORE, "Tell me more"), 

2914 ) 

2915 

2916 created_at = models.DateTimeField( 

2917 verbose_name="When created", auto_now_add=True 

2918 ) 

2919 contact_request = models.OneToOneField( 

2920 ContactRequest, 

2921 on_delete=models.PROTECT, 

2922 related_name="clinician_response", 

2923 ) 

2924 token = models.CharField(max_length=TOKEN_LENGTH_CHARS) 

2925 responded = models.BooleanField(default=False, verbose_name="Responded?") 

2926 responded_at = models.DateTimeField( 

2927 verbose_name="When responded", null=True 

2928 ) 

2929 response_route = models.CharField(max_length=1, choices=RESPONSE_ROUTES) 

2930 email_choice = models.CharField(max_length=4, choices=EMAIL_CHOICES) 

2931 response = models.CharField(max_length=1, choices=RESPONSES) 

2932 veto_reason = models.TextField( 

2933 blank=True, verbose_name="Reason for clinical veto" 

2934 ) 

2935 ineligible_reason = models.TextField( 

2936 blank=True, verbose_name="Reason patient is ineligible" 

2937 ) 

2938 pt_uncontactable_reason = models.TextField( 

2939 blank=True, verbose_name="Reason patient is not contactable" 

2940 ) 

2941 clinician_confirm_name = models.CharField( 

2942 max_length=255, verbose_name="Type your name to confirm" 

2943 ) 

2944 charity_amount_due = models.DecimalField( 

2945 max_digits=8, decimal_places=2, default=0 

2946 ) 

2947 # ... set to settings.CHARITY_AMOUNT_CLINICIAN_RESPONSE upon response 

2948 

2949 processed = models.BooleanField(default=False) # added 2018-06-29 

2950 processed_at = models.DateTimeField(null=True) # added 2018-06-29 

2951 

2952 def get_response_explanation(self) -> str: 

2953 """ 

2954 Returns the human-readable description of the clinician's response. 

2955 """ 

2956 # log.debug(f"get_response_explanation: {self.response}") 

2957 # noinspection PyTypeChecker 

2958 return choice_explanation(self.response, ClinicianResponse.RESPONSES) 

2959 

2960 @classmethod 

2961 def create( 

2962 cls, contact_request: ContactRequest, save: bool = True 

2963 ) -> CLINICIAN_RESPONSE_FWD_REF: 

2964 """ 

2965 Creates a new clinician response object. 

2966 

2967 Args: 

2968 contact_request: a :class:`ContactRequest` 

2969 save: save to the database? (Only false for debugging.) 

2970 

2971 Returns: 

2972 a :class:`ClinicianResponse` 

2973 

2974 """ 

2975 newtoken = get_random_string(ClinicianResponse.TOKEN_LENGTH_CHARS) 

2976 # https://github.com/django/django/blob/master/django/utils/crypto.py#L51 # noqa: E501 

2977 clinician_response = cls( 

2978 contact_request=contact_request, 

2979 token=newtoken, 

2980 ) 

2981 if save: 

2982 clinician_response.save() 

2983 return clinician_response 

2984 

2985 def get_abs_url_path(self) -> str: 

2986 """ 

2987 Returns an absolute URL path to the page that lets the clinician 

2988 respond for this :class:`ClinicianResponse`. 

2989 

2990 This is used in the e-mail to the clinician. 

2991 """ 

2992 rev = reverse(UrlNames.CLINICIAN_RESPONSE, args=[self.id]) 

2993 url = site_absolute_url(rev) 

2994 return url 

2995 

2996 def get_common_querydict(self, email_choice: str) -> QueryDict: 

2997 """ 

2998 Returns a query dictionary that will contribute to our final URLs. That 

2999 is, information about the clinician's choice (and also a security 

3000 token) that will be added to the base "response" URL path. 

3001 

3002 Args: 

3003 email_choice: code for the clinician's choice 

3004 

3005 Returns: 

3006 a :class:`django.http.request.QueryDict` 

3007 

3008 """ 

3009 querydict = QueryDict(mutable=True) 

3010 querydict["token"] = self.token 

3011 querydict["email_choice"] = email_choice 

3012 return querydict 

3013 

3014 def get_abs_url(self, email_choice: str) -> str: 

3015 """ 

3016 Returns an absolute URL representing a specific choice for the 

3017 clinician. 

3018 

3019 Args: 

3020 email_choice: code for the clinician's choice 

3021 

3022 Returns: 

3023 a URL 

3024 

3025 """ 

3026 path = self.get_abs_url_path() 

3027 querydict = self.get_common_querydict(email_choice) 

3028 return url_with_querystring(path, querydict) 

3029 

3030 def get_abs_url_yes(self) -> str: 

3031 """ 

3032 Returns an absolute URL for "clinician says yes". 

3033 """ 

3034 return self.get_abs_url(ClinicianResponse.EMAIL_CHOICE_Y) 

3035 

3036 def get_abs_url_no(self) -> str: 

3037 """ 

3038 Returns an absolute URL for "clinician says no". 

3039 """ 

3040 return self.get_abs_url(ClinicianResponse.EMAIL_CHOICE_N) 

3041 

3042 def get_abs_url_maybe(self) -> str: 

3043 """ 

3044 Returns an absolute URL for "clinician says tell me more". 

3045 """ 

3046 return self.get_abs_url(ClinicianResponse.EMAIL_CHOICE_TELL_ME_MORE) 

3047 

3048 def __str__(self) -> str: 

3049 return ( 

3050 f"[ClinicianResponse {self.id}] " 

3051 f"ContactRequest {self.contact_request_id}" 

3052 ) 

3053 

3054 def finalize_a(self) -> None: 

3055 """ 

3056 Call this when the clinician completes their response. 

3057 

3058 Part A: immediate, called from the web front end, for acknowledgement. 

3059 """ 

3060 self.responded = True 

3061 self.responded_at = timezone.now() 

3062 self.charity_amount_due = settings.CHARITY_AMOUNT_CLINICIAN_RESPONSE 

3063 self.save() 

3064 

3065 @staticmethod 

3066 def get_unprocessed() -> QuerySet: 

3067 return ClinicianResponse.objects.filter(processed=False) 

3068 

3069 def finalize_b(self) -> None: 

3070 """ 

3071 Call this when the clinician completes their response. 

3072 

3073 Part B: called by the background task processor, for the slower 

3074 aspects. 

3075 """ 

3076 if self.processed: 

3077 log.warning( 

3078 f"ClinicianResponse #{self.id}: already processed; " 

3079 f"not processing again" 

3080 ) 

3081 return 

3082 if self.response == ClinicianResponse.RESPONSE_R: 

3083 # noinspection PyTypeChecker 

3084 letter = Letter.create_request_to_patient( 

3085 self.contact_request, rdbm_may_view=True 

3086 ) 

3087 # ... will save 

3088 # noinspection PyTypeChecker 

3089 PatientResponse.create(self.contact_request) 

3090 # ... will save 

3091 self.contact_request.notify_rdbm_of_work(letter) 

3092 elif self.response == ClinicianResponse.RESPONSE_A: 

3093 # noinspection PyTypeChecker 

3094 Letter.create_request_to_patient( 

3095 self.contact_request, rdbm_may_view=False 

3096 ) 

3097 # ... return value not used 

3098 # noinspection PyTypeChecker 

3099 PatientResponse.create(self.contact_request) 

3100 self.contact_request.notify_rdbm_of_good_progress() 

3101 elif self.response in ( 

3102 ClinicianResponse.RESPONSE_B, 

3103 ClinicianResponse.RESPONSE_C, 

3104 ClinicianResponse.RESPONSE_D, 

3105 ): 

3106 self.contact_request.notify_rdbm_of_bad_progress() 

3107 self.processed = True 

3108 self.processed_at = timezone.now() 

3109 self.save() 

3110 

3111 

3112# ============================================================================= 

3113# Patient response 

3114# ============================================================================= 

3115 

3116PATIENT_RESPONSE_FWD_REF = "PatientResponse" 

3117 

3118 

3119class PatientResponse(Decision): 

3120 """ 

3121 Represents the patient's decision about a specific study. (We get one of 

3122 these if the clinician passed details to the patient and the patient has 

3123 responded.) 

3124 """ 

3125 

3126 YES = 1 

3127 NO = 2 

3128 RESPONSES = ( 

3129 (YES, "1: Yes"), 

3130 (NO, "2: No"), 

3131 ) 

3132 created_at = models.DateTimeField( 

3133 verbose_name="When created", auto_now_add=True 

3134 ) 

3135 contact_request = models.OneToOneField( 

3136 ContactRequest, 

3137 on_delete=models.PROTECT, 

3138 related_name="patient_response", 

3139 ) 

3140 recorded_by = models.ForeignKey( 

3141 settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True 

3142 ) 

3143 response = models.PositiveSmallIntegerField( 

3144 null=True, choices=RESPONSES, verbose_name="Patient's response" 

3145 ) 

3146 processed = models.BooleanField(default=False) # added 2018-06-29 

3147 processed_at = models.DateTimeField(null=True) # added 2018-06-29 

3148 

3149 def __str__(self) -> str: 

3150 if self.response: 

3151 # noinspection PyTypeChecker 

3152 suffix = "response was {}".format( 

3153 choice_explanation(self.response, PatientResponse.RESPONSES) 

3154 ) 

3155 else: 

3156 suffix = "AWAITING RESPONSE" 

3157 return ( 

3158 f"Patient response {self.id} " 

3159 f"(contact request {self.contact_request.id}, " 

3160 f"study {self.contact_request.study.id}): {suffix}" 

3161 ) 

3162 

3163 @classmethod 

3164 def create( 

3165 cls, contact_request: ContactRequest 

3166 ) -> PATIENT_RESPONSE_FWD_REF: 

3167 """ 

3168 Creates a patient response object for a given contact request. 

3169 

3170 Args: 

3171 contact_request: a :class:`ContactRequest` 

3172 

3173 Returns: 

3174 :class:`PatientResponse` 

3175 

3176 """ 

3177 patient_response = cls(contact_request=contact_request) 

3178 patient_response.save() 

3179 return patient_response 

3180 

3181 @staticmethod 

3182 def get_unprocessed() -> QuerySet: 

3183 """ 

3184 Return all :class:`PatientResponse` objects that need processing. 

3185 

3186 See :func:`crate_anon.crateweb.consent.tasks.process_patient_response` 

3187 and :func:`process_response`, which does the work. 

3188 """ 

3189 return PatientResponse.objects.filter(processed=False) 

3190 

3191 def process_response(self) -> None: 

3192 """ 

3193 Processes the :class:`PatientResponse` and marks it as processed. 

3194 

3195 If the patient said yes, this triggers a letter to the researcher. 

3196 """ 

3197 # log.debug(f"process_response: PatientResponse: {modelrepr(self)}") 

3198 if self.processed: 

3199 log.warning( 

3200 f"PatientResponse #{self.id}: already processed; " 

3201 f"not processing again" 

3202 ) 

3203 return 

3204 if self.response == PatientResponse.YES: 

3205 contact_request = self.contact_request 

3206 # noinspection PyTypeChecker 

3207 letter = Letter.create_researcher_approval(contact_request) 

3208 # ... will save 

3209 # noinspection PyTypeChecker 

3210 email = Email.create_researcher_approval_email( 

3211 contact_request, letter 

3212 ) 

3213 emailtransmission = email.send() 

3214 emailed = emailtransmission.sent 

3215 if not emailed: 

3216 contact_request.notify_rdbm_of_work(letter, to_researcher=True) 

3217 self.processed = True 

3218 self.processed_at = timezone.now() 

3219 self.save() 

3220 

3221 

3222# ============================================================================= 

3223# Letter, and record of letter being printed 

3224# ============================================================================= 

3225 

3226 

3227class Letter(models.Model): 

3228 """ 

3229 Represents a letter (e.g. to a patient, clinician, or researcher). 

3230 """ 

3231 

3232 created_at = models.DateTimeField( 

3233 verbose_name="When created", auto_now_add=True 

3234 ) 

3235 pdf = models.FileField(storage=privatestorage) 

3236 # Other flags: 

3237 to_clinician = models.BooleanField(default=False) 

3238 to_researcher = models.BooleanField(default=False) 

3239 to_patient = models.BooleanField(default=False) 

3240 rdbm_may_view = models.BooleanField(default=False) 

3241 study = models.ForeignKey(Study, on_delete=models.PROTECT, null=True) 

3242 contact_request = models.ForeignKey( 

3243 ContactRequest, on_delete=models.PROTECT, null=True 

3244 ) 

3245 sent_manually_at = models.DateTimeField(null=True) 

3246 

3247 def __str__(self) -> str: 

3248 return f"Letter {self.id}" 

3249 

3250 @classmethod 

3251 def create( 

3252 cls, 

3253 basefilename: str, 

3254 html: str = None, 

3255 pdf: bytes = None, 

3256 to_clinician: bool = False, 

3257 to_researcher: bool = False, 

3258 to_patient: bool = False, 

3259 rdbm_may_view: bool = False, 

3260 study: Study = None, 

3261 contact_request: ContactRequest = None, 

3262 debug_store_html: bool = False, 

3263 ) -> LETTER_FWD_REF: 

3264 """ 

3265 Creates a letter. 

3266 

3267 Args: 

3268 basefilename: filename to be used to store a PDF copy of the letter 

3269 on disk (without a path) 

3270 html: for letters supplied as HTML, the HTML 

3271 pdf: for letters supplied as PDF, the PDF 

3272 to_clinician: is the letter to a clinician? 

3273 to_researcher: is the letter to a researcher? 

3274 to_patient: is the letter to a patient? 

3275 rdbm_may_view: may the RDBM view this letter? 

3276 study: which :class:`Study` does it relate to, if any? 

3277 contact_request: which :class:`ContactRequest` does it relate to, 

3278 if any? 

3279 debug_store_html: should we store the HTML of the letter, as well 

3280 as the PDF (for letters originating in HTML only)? 

3281 

3282 Returns: 

3283 a :class:`Letter` 

3284 

3285 """ 

3286 # Writing to a FileField directly: you can use field.save(), but then 

3287 # you having to write one file and copy to another, etc. 

3288 # Here we use the method of assigning to field.name (you can't assign 

3289 # to field.path). Also, note that you should never read 

3290 # the path attribute if name is blank; it raises an exception. 

3291 if bool(html) == bool(pdf): 

3292 # One or the other! 

3293 raise ValueError("Invalid html/pdf options to Letter.create") 

3294 filename_in_storage = os.path.join("letter", basefilename) 

3295 abs_filename = os.path.join( 

3296 settings.PRIVATE_FILE_STORAGE_ROOT, filename_in_storage 

3297 ) 

3298 os.makedirs(os.path.dirname(abs_filename), exist_ok=True) 

3299 if html: 

3300 # HTML supplied 

3301 if debug_store_html: 

3302 with open(abs_filename + ".html", "w") as f: 

3303 f.write(html) 

3304 make_pdf_on_disk_from_html_with_django_settings( 

3305 html, 

3306 output_path=abs_filename, 

3307 header_html=None, 

3308 footer_html=None, 

3309 ) 

3310 else: 

3311 # PDF supplied in memory 

3312 with open(abs_filename, "wb") as f: 

3313 f.write(pdf) 

3314 letter = cls( 

3315 to_clinician=to_clinician, 

3316 to_researcher=to_researcher, 

3317 to_patient=to_patient, 

3318 rdbm_may_view=rdbm_may_view, 

3319 study=study, 

3320 contact_request=contact_request, 

3321 ) 

3322 letter.pdf.name = filename_in_storage 

3323 letter.save() 

3324 return letter 

3325 

3326 @classmethod 

3327 def create_researcher_approval( 

3328 cls, contact_request: ContactRequest 

3329 ) -> LETTER_FWD_REF: 

3330 """ 

3331 Creates a letter to a researcher giving approval to contact a patient. 

3332 

3333 Args: 

3334 contact_request: a :class:`ContactRequest` 

3335 

3336 Returns: 

3337 a :class:`Letter` 

3338 

3339 """ 

3340 basefilename = ( 

3341 f"cr{contact_request.id}_res_approve_{string_time_now()}.pdf" 

3342 ) 

3343 html = contact_request.get_approval_letter_html() 

3344 # noinspection PyTypeChecker 

3345 return cls.create( 

3346 basefilename, 

3347 html=html, 

3348 to_researcher=True, 

3349 study=contact_request.study, 

3350 contact_request=contact_request, 

3351 rdbm_may_view=True, 

3352 ) 

3353 

3354 @classmethod 

3355 def create_researcher_withdrawal( 

3356 cls, contact_request: ContactRequest 

3357 ) -> LETTER_FWD_REF: 

3358 """ 

3359 Creates a letter to a researcher withdrawing previous approval to 

3360 contact a patient. 

3361 

3362 Args: 

3363 contact_request: a :class:`ContactRequest` 

3364 

3365 Returns: 

3366 a :class:`Letter` 

3367 

3368 """ 

3369 basefilename = ( 

3370 f"cr{contact_request.id}_res_withdraw_{string_time_now()}.pdf" 

3371 ) 

3372 html = contact_request.get_withdrawal_letter_html() 

3373 # noinspection PyTypeChecker 

3374 return cls.create( 

3375 basefilename, 

3376 html=html, 

3377 to_researcher=True, 

3378 study=contact_request.study, 

3379 contact_request=contact_request, 

3380 rdbm_may_view=True, 

3381 ) 

3382 

3383 @classmethod 

3384 def create_request_to_patient( 

3385 cls, contact_request: ContactRequest, rdbm_may_view: bool = False 

3386 ) -> LETTER_FWD_REF: 

3387 """ 

3388 Creates a letter to a patient asking them about a specific study. 

3389 

3390 Args: 

3391 contact_request: a :class:`ContactRequest` 

3392 rdbm_may_view: is this a request that the Research Database 

3393 Manager (RDBM) is allowed to see under our information 

3394 governance rules? 

3395 

3396 Returns: 

3397 a :class:`Letter` 

3398 

3399 """ 

3400 basefilename = f"cr{contact_request.id}_to_pt_{string_time_now()}.pdf" 

3401 pdf = contact_request.get_clinician_pack_pdf() 

3402 # noinspection PyTypeChecker 

3403 letter = cls.create( 

3404 basefilename, 

3405 pdf=pdf, 

3406 to_patient=True, 

3407 study=contact_request.study, 

3408 contact_request=contact_request, 

3409 rdbm_may_view=rdbm_may_view, 

3410 ) 

3411 if not rdbm_may_view: 

3412 # Letter is from clinician directly; clinician will print 

3413 letter.mark_sent() 

3414 return letter 

3415 

3416 @classmethod 

3417 def create_consent_confirmation_to_patient( 

3418 cls, consent_mode: ConsentMode 

3419 ) -> LETTER_FWD_REF: 

3420 """ 

3421 Creates a letter to a patient confirming their traffic-light 

3422 consent-mode choice. 

3423 

3424 Args: 

3425 consent_mode: a :class:`ConsentMode` 

3426 

3427 Returns: 

3428 a :class:`Letter` 

3429 

3430 """ 

3431 basefilename = f"cm{consent_mode.id}_to_pt_{string_time_now()}.pdf" 

3432 html = consent_mode.get_confirm_traffic_to_patient_letter_html() 

3433 return cls.create( 

3434 basefilename, html=html, to_patient=True, rdbm_may_view=True 

3435 ) 

3436 

3437 def mark_sent(self) -> None: 

3438 """ 

3439 Mark the letter as having been sent now. 

3440 """ 

3441 self.sent_manually_at = timezone.now() 

3442 self.save() 

3443 

3444 

3445# noinspection PyUnusedLocal 

3446@receiver(models.signals.post_delete, sender=Letter) 

3447def auto_delete_letter_files_on_delete( 

3448 sender: Type[Letter], instance: Letter, **kwargs: Any 

3449) -> None: 

3450 """ 

3451 Django signal receiver. 

3452 

3453 Deletes files from filesystem when a :class:`Letter` object is deleted. 

3454 """ 

3455 auto_delete_files_on_instance_delete(instance, ["pdf"]) 

3456 

3457 

3458# noinspection PyUnusedLocal 

3459@receiver(models.signals.pre_save, sender=Letter) 

3460def auto_delete_letter_files_on_change( 

3461 sender: Type[Letter], instance: Letter, **kwargs: Any 

3462) -> None: 

3463 """ 

3464 Django signal receiver. 

3465 

3466 Deletes files from filesystem when a :class:`Letter` object is changed. 

3467 """ 

3468 auto_delete_files_on_instance_change(instance, ["pdf"], Letter) 

3469 

3470 

3471# ============================================================================= 

3472# Record of sent e-mails 

3473# ============================================================================= 

3474 

3475 

3476def _get_default_email_sender() -> str: 

3477 """ 

3478 Returns the default e-mail sender. 

3479 

3480 Using a callable, ``default=_get_default_email_sender``, rather than a 

3481 value, ``default=settings.EMAIL_SENDER``, makes the Django migration system 

3482 stop implementing pointless changes when local settings change. 

3483 

3484 See 

3485 https://docs.djangoproject.com/en/2.1/ref/models/fields/#django.db.models.Field.default 

3486 """ 

3487 return settings.EMAIL_SENDER 

3488 

3489 

3490class Email(models.Model): 

3491 """ 

3492 Represents an e-mail sent (or to be sent) from CRATE. 

3493 """ 

3494 

3495 # Let's not record host/port/user. It's configured into the settings. 

3496 created_at = models.DateTimeField( 

3497 verbose_name="When created", auto_now_add=True 

3498 ) 

3499 sender = models.CharField( 

3500 max_length=255, default=_get_default_email_sender 

3501 ) 

3502 recipient = models.CharField(max_length=255) 

3503 subject = models.CharField(max_length=255) 

3504 msg_text = models.TextField() 

3505 msg_html = models.TextField() 

3506 # Other flags and links: 

3507 to_clinician = models.BooleanField(default=False) 

3508 to_researcher = models.BooleanField(default=False) 

3509 to_patient = models.BooleanField(default=False) 

3510 study = models.ForeignKey(Study, on_delete=models.PROTECT, null=True) 

3511 contact_request = models.ForeignKey( 

3512 ContactRequest, on_delete=models.PROTECT, null=True 

3513 ) 

3514 letter = models.ForeignKey(Letter, on_delete=models.PROTECT, null=True) 

3515 # Transmission attempts are in EmailTransmission. 

3516 # Except that filtering in the admin 

3517 

3518 def __str__(self) -> str: 

3519 return f"Email {self.id} to {self.recipient}" 

3520 

3521 @classmethod 

3522 def create_clinician_email( 

3523 cls, contact_request: ContactRequest 

3524 ) -> EMAIL_FWD_REF: 

3525 """ 

3526 Creates an e-mail to a clinician, asking them to consider a request 

3527 from a study about a patient. 

3528 

3529 Args: 

3530 contact_request: a :class:`ContactRequest` 

3531 

3532 Returns: 

3533 an :class:`Email` 

3534 

3535 """ 

3536 recipient = contact_request.clinician_email 

3537 # noinspection PyUnresolvedReferences 

3538 subject = ( 

3539 "RESEARCH REQUEST on behalf of {researcher}, contact request " 

3540 "code {contact_req_code}".format( 

3541 researcher=contact_request.study.lead_researcher.profile.get_title_forename_surname(), # noqa: E501 

3542 contact_req_code=contact_request.id, 

3543 ) 

3544 ) 

3545 html = contact_request.get_clinician_email_html() 

3546 email = cls( 

3547 recipient=recipient, 

3548 subject=subject, 

3549 msg_html=html, 

3550 study=contact_request.study, 

3551 contact_request=contact_request, 

3552 to_clinician=True, 

3553 ) 

3554 email.save() 

3555 return email 

3556 

3557 @classmethod 

3558 def create_clinician_initiated_cr_email( 

3559 cls, contact_request: ContactRequest 

3560 ) -> EMAIL_FWD_REF: 

3561 """ 

3562 Creates an e-mail to a clinician when they have initiated a contact 

3563 request. This email will give them a link to the clinician pack if 

3564 they said they'd contact the patient. 

3565 

3566 Args: 

3567 contact_request: a :class:`ContactRequest` 

3568 

3569 Returns: 

3570 an :class:`Email` 

3571 

3572 """ 

3573 recipient = contact_request.clinician_email 

3574 # noinspection PyUnresolvedReferences 

3575 subject = ( 

3576 f"Confirmation of request for patient to be included in study. " 

3577 f"Contact request code {contact_request.id}" 

3578 ) 

3579 html = contact_request.get_clinician_initiated_email_html() 

3580 email = cls( 

3581 recipient=recipient, 

3582 subject=subject, 

3583 msg_html=html, 

3584 study=contact_request.study, 

3585 contact_request=contact_request, 

3586 to_clinician=True, 

3587 ) 

3588 email.save() 

3589 return email 

3590 

3591 @classmethod 

3592 def create_researcher_approval_email( 

3593 cls, contact_request: ContactRequest, letter: Letter 

3594 ) -> EMAIL_FWD_REF: 

3595 """ 

3596 Creates an e-mail to a researcher, enclosing a letter giving them 

3597 permission to contact a patient. 

3598 

3599 Args: 

3600 contact_request: a :class:`ContactRequest` 

3601 letter: a :class:`Letter` 

3602 

3603 Returns: 

3604 an :class:`Email` 

3605 

3606 """ 

3607 # noinspection PyUnresolvedReferences 

3608 recipient = contact_request.study.lead_researcher.email 

3609 subject = ( 

3610 f"APPROVAL TO CONTACT PATIENT: contact request code " 

3611 f"{contact_request.id}" 

3612 ) 

3613 html = contact_request.get_approval_email_html() 

3614 email = cls( 

3615 recipient=recipient, 

3616 subject=subject, 

3617 msg_html=html, 

3618 study=contact_request.study, 

3619 contact_request=contact_request, 

3620 letter=letter, 

3621 to_researcher=True, 

3622 ) 

3623 email.save() 

3624 # noinspection PyTypeChecker 

3625 EmailAttachment.create( 

3626 email=email, fileobj=letter.pdf, content_type=ContentType.PDF 

3627 ) # will save 

3628 return email 

3629 

3630 @classmethod 

3631 def create_researcher_withdrawal_email( 

3632 cls, contact_request: ContactRequest, letter: Letter 

3633 ) -> EMAIL_FWD_REF: 

3634 """ 

3635 Creates an e-mail to a researcher, enclosing a letter withdrawing their 

3636 permission to contact a patient. 

3637 

3638 Args: 

3639 contact_request: a :class:`ContactRequest` 

3640 letter: a :class:`Letter` 

3641 

3642 Returns: 

3643 an :class:`Email` 

3644 

3645 """ 

3646 # noinspection PyUnresolvedReferences 

3647 recipient = contact_request.study.lead_researcher.email 

3648 subject = ( 

3649 f"WITHDRAWAL OF APPROVAL TO CONTACT PATIENT: contact request code " 

3650 f"{contact_request.id}" 

3651 ) 

3652 html = contact_request.get_withdrawal_email_html() 

3653 email = cls( 

3654 recipient=recipient, 

3655 subject=subject, 

3656 msg_html=html, 

3657 study=contact_request.study, 

3658 contact_request=contact_request, 

3659 letter=letter, 

3660 to_researcher=True, 

3661 ) 

3662 email.save() 

3663 # noinspection PyTypeChecker 

3664 EmailAttachment.create( 

3665 email=email, fileobj=letter.pdf, content_type=ContentType.PDF 

3666 ) # will save 

3667 return email 

3668 

3669 @classmethod 

3670 def create_rdbm_email(cls, subject: str, html: str) -> EMAIL_FWD_REF: 

3671 """ 

3672 Create an HTML-based e-mail to the RDBM. 

3673 

3674 Args: 

3675 subject: subject line 

3676 html: HTML body 

3677 

3678 Returns: 

3679 an :class:`Email` 

3680 

3681 """ 

3682 email = cls( 

3683 recipient=settings.RDBM_EMAIL, subject=subject, msg_html=html 

3684 ) 

3685 email.save() 

3686 return email 

3687 

3688 @classmethod 

3689 def create_rdbm_text_email(cls, subject: str, text: str) -> EMAIL_FWD_REF: 

3690 """ 

3691 Create an text-based e-mail to the RDBM. 

3692 

3693 Args: 

3694 subject: subject line 

3695 text: message body 

3696 

3697 Returns: 

3698 an :class:`Email` 

3699 

3700 """ 

3701 email = cls( 

3702 recipient=settings.RDBM_EMAIL, subject=subject, msg_text=text 

3703 ) 

3704 email.save() 

3705 return email 

3706 

3707 def has_been_sent(self) -> bool: 

3708 """ 

3709 Has this e-mail been sent? 

3710 

3711 (Internally: does an :class:`EmailTransmission` for this e-mail 

3712 exist with its ``sent`` flag set?) 

3713 """ 

3714 return self.emailtransmission_set.filter(sent=True).exists() 

3715 

3716 def send( 

3717 self, user: settings.AUTH_USER_MODEL = None, resend: bool = False 

3718 ) -> Optional[EMAIL_TRANSMISSION_FWD_REF]: 

3719 """ 

3720 Sends the e-mail. Makes a record. 

3721 

3722 Args: 

3723 user: the sender. 

3724 resend: say that it's OK to resend one that's already been sent. 

3725 

3726 Returns: 

3727 an :class:`EmailTransmission` object. 

3728 """ 

3729 if self.has_been_sent() and not resend: 

3730 log.error(f"Trying to send e-mail twice: ID={self.id}") 

3731 return None 

3732 if settings.SAFETY_CATCH_ON: 

3733 self.recipient = settings.DEVELOPER_EMAIL 

3734 try: 

3735 if self.msg_html and not self.msg_text: 

3736 # HTML-only email 

3737 # http://www.masnun.com/2014/01/09/django-sending-html-only-email.html # noqa: E501 

3738 msg = EmailMessage( 

3739 subject=self.subject, 

3740 body=self.msg_html, 

3741 from_email=self.sender, 

3742 to=[self.recipient], 

3743 ) 

3744 msg.content_subtype = "html" # Main content is now text/html 

3745 else: 

3746 # Text only, or separate text/HTML 

3747 msg = EmailMultiAlternatives( 

3748 subject=self.subject, 

3749 body=self.msg_text, 

3750 from_email=self.sender, 

3751 to=[self.recipient], 

3752 ) 

3753 if self.msg_html: 

3754 msg.attach_alternative(self.msg_html, "text/html") 

3755 for attachment in self.emailattachment_set.all(): 

3756 # don't use msg.attach_file() if you want to control 

3757 # the outbound filename; use msg.attach() 

3758 if not attachment.file: 

3759 continue 

3760 path = attachment.file.path 

3761 if not attachment.sent_filename: 

3762 attachment.sent_filename = os.path.basename(path) 

3763 attachment.save() 

3764 with open(path, "rb") as f: 

3765 content = f.read() 

3766 msg.attach( 

3767 attachment.sent_filename, 

3768 content, 

3769 attachment.content_type or None, 

3770 ) 

3771 msg.send() 

3772 sent = True 

3773 failure_reason = "" 

3774 except Exception as e: 

3775 sent = False 

3776 failure_reason = str(e) 

3777 self.save() 

3778 emailtransmission = EmailTransmission( 

3779 email=self, by=user, sent=sent, failure_reason=failure_reason 

3780 ) 

3781 emailtransmission.save() 

3782 return emailtransmission 

3783 

3784 def resend(self, user: settings.AUTH_USER_MODEL) -> None: 

3785 """ 

3786 Resend this e-mail. 

3787 """ 

3788 return self.send(user=user, resend=True) 

3789 

3790 

3791EMAIL_ATTACHMENT_FWD_REF = "EmailAttachment" 

3792 

3793 

3794class EmailAttachment(models.Model): 

3795 """ 

3796 E-mail attachment class. 

3797 

3798 Typically, this does NOT manage its own files (i.e. if the attachment 

3799 object is deleted, the files won't be). Use this method for referencing 

3800 files already stored elsewhere in the database. 

3801 

3802 If the :attr:`owns_file` attribute is set, however, the associated file 

3803 *is* "owned" by this object, and the file will be deleted when the database 

3804 object is. 

3805 """ 

3806 

3807 email = models.ForeignKey(Email, on_delete=models.PROTECT) 

3808 file = models.FileField(storage=privatestorage) 

3809 sent_filename = models.CharField(null=True, max_length=255) 

3810 content_type = models.CharField(null=True, max_length=255) 

3811 owns_file = models.BooleanField(default=False) 

3812 

3813 def exists(self) -> bool: 

3814 """ 

3815 Does the attached file exist on disk? 

3816 """ 

3817 if not self.file: 

3818 return False 

3819 return os.path.isfile(self.file.path) 

3820 

3821 def size(self) -> int: 

3822 """ 

3823 Returns the size of the attachment in bytes, if it exists on disk 

3824 (otherwise 0). 

3825 """ 

3826 if not self.file: 

3827 return 0 

3828 return os.path.getsize(self.file.path) 

3829 

3830 @classmethod 

3831 def create( 

3832 cls, 

3833 email: Email, 

3834 fileobj: models.FileField, 

3835 content_type: str, 

3836 sent_filename: str = None, 

3837 owns_file=False, 

3838 ) -> EMAIL_ATTACHMENT_FWD_REF: 

3839 """ 

3840 Creates an e-mail attachment object and attaches it to an e-mail. 

3841 When the e-mail is sent, the file thus referenced will be sent along 

3842 with the e-mail; see :meth:`Email.send`. 

3843 

3844 Args: 

3845 email: an :class:`Email`, to which this attachment is attached 

3846 fileobj: a :class:`django.db.models.FileField` representing the 

3847 file (on disk) to be attached 

3848 content_type: HTTP content type string 

3849 sent_filename: name of the filename as seen within the e-mail 

3850 owns_file: (see class help) Should the file on disk be deleted 

3851 if/when this database object is deleted? 

3852 

3853 Returns: 

3854 a :class:`EmailAttachment` 

3855 

3856 """ 

3857 if sent_filename is None: 

3858 sent_filename = os.path.basename(fileobj.name) 

3859 attachment = cls( 

3860 email=email, 

3861 file=fileobj, 

3862 sent_filename=sent_filename, 

3863 content_type=content_type, 

3864 owns_file=owns_file, 

3865 ) 

3866 attachment.save() 

3867 return attachment 

3868 

3869 

3870# noinspection PyUnusedLocal 

3871@receiver(models.signals.post_delete, sender=EmailAttachment) 

3872def auto_delete_emailattachment_files_on_delete( 

3873 sender: Type[EmailAttachment], instance: EmailAttachment, **kwargs: Any 

3874) -> None: 

3875 """ 

3876 Django signal receiver. 

3877 

3878 Deletes files from filesystem when :class:`EmailAttachment` object is 

3879 deleted, if its :attr:`owns_file` flag is set. 

3880 """ 

3881 if instance.owns_file: 

3882 auto_delete_files_on_instance_delete(instance, ["file"]) 

3883 

3884 

3885# noinspection PyUnusedLocal 

3886@receiver(models.signals.pre_save, sender=EmailAttachment) 

3887def auto_delete_emailattachment_files_on_change( 

3888 sender: Type[EmailAttachment], instance: EmailAttachment, **kwargs: Any 

3889) -> None: 

3890 """ 

3891 Django signal receiver. 

3892 

3893 Deletes files from filesystem when :class:`EmailAttachment` object is 

3894 changed, if its :attr:`owns_file` flag is set. 

3895 """ 

3896 if instance.owns_file: 

3897 auto_delete_files_on_instance_change( 

3898 instance, ["file"], EmailAttachment 

3899 ) 

3900 

3901 

3902class EmailTransmission(models.Model): 

3903 """ 

3904 Represents an e-mail transmission attempt. 

3905 """ 

3906 

3907 email = models.ForeignKey(Email, on_delete=models.PROTECT) 

3908 at = models.DateTimeField(verbose_name="When sent", auto_now_add=True) 

3909 by = models.ForeignKey( 

3910 settings.AUTH_USER_MODEL, 

3911 on_delete=models.PROTECT, 

3912 null=True, 

3913 related_name="emailtransmissions", 

3914 ) 

3915 sent = models.BooleanField(default=False) 

3916 failure_reason = models.TextField(verbose_name="Reason sending failed") 

3917 

3918 def __str__(self) -> str: 

3919 return "Email transmission at {} by {}: {}".format( 

3920 self.at, 

3921 self.by or "(system)", 

3922 "success" if self.sent else f"failure: {self.failure_reason}", 

3923 ) 

3924 

3925 

3926# ============================================================================= 

3927# A dummy set of objects, for template testing. 

3928# Linked, so cross-references work. 

3929# Don't save() them! 

3930# ============================================================================= 

3931 

3932 

3933class DummyObjectCollection: 

3934 """ 

3935 A collection of dummy objects within the consent-to-contact system, for 

3936 testing templates. 

3937 """ 

3938 

3939 def __init__( 

3940 self, 

3941 contact_request: ContactRequest, 

3942 consent_mode: ConsentMode, 

3943 patient_lookup: PatientLookup, 

3944 study: Study, 

3945 clinician_response: ClinicianResponse, 

3946 ): 

3947 self.contact_request = contact_request 

3948 self.consent_mode = consent_mode 

3949 self.patient_lookup = patient_lookup 

3950 self.study = study 

3951 self.clinician_response = clinician_response 

3952 

3953 

3954def make_dummy_objects( 

3955 request: HttpRequest, test_id: str = TEST_ID_STR 

3956) -> DummyObjectCollection: 

3957 """ 

3958 Returns a collection of dummy objects, for testing consent-to-contact 

3959 templates without using live patient data. 

3960 

3961 Args: 

3962 request: the :class:`django.http.request.HttpRequest` 

3963 

3964 Returns: 

3965 a :class:`DummyObjectCollection` 

3966 

3967 We want to create these objects in memory, without saving to the DB. 

3968 However, Django is less good at SQLAlchemy for this, and saves. 

3969 

3970 - https://stackoverflow.com/questions/7908349/django-making-relationships-in-memory-without-saving-to-db # noqa: E501 

3971 - https://code.djangoproject.com/ticket/17253 

3972 - https://stackoverflow.com/questions/23372786/django-models-assigning-foreignkey-object-without-saving-to-database # noqa: E501 

3973 - https://stackoverflow.com/questions/7121341/django-adding-objects-to-a-related-set-without-saving-to-db # noqa: E501 

3974 

3975 A simple method works for an SQLite backend database but fails with 

3976 an IntegrityError for MySQL/SQL Server. For example: 

3977 

3978 .. code-block:: none 

3979 

3980 IntegrityError at /draft_traffic_light_decision_form/-1/html/ 

3981 (1452, 'Cannot add or update a child row: a foreign key constraint 

3982 fails (`crate_django`.`consent_study_researchers`, CONSTRAINT 

3983 `consent_study_researchers_study_id_19bb255f_fk_consent_study_id` 

3984 FOREIGN KEY (`study_id`) REFERENCES `consent_study` (`id`))') 

3985 

3986 This occurs in the first creation, of a :class:`Study`, and only if you 

3987 specify ``researchers``. 

3988 

3989 The reason for the crash is that ``researchers`` is a ManyToManyField, and 

3990 Django is trying to set the ``user.studies_as_researcher`` back-reference, 

3991 but can't do so because the :class:`Study` doesn't have a PK yet. 

3992 

3993 Since this is a minor thing, and templates are unaffected, and this is only 

3994 for debugging, let's ignore it. 

3995 """ 

3996 using_alt = test_id == TEST_ID_TWO_STR 

3997 

3998 def get_int(query_param_name: str, default: Optional[int]) -> int: 

3999 try: 

4000 # noinspection PyCallByClass,PyTypeChecker 

4001 return int(request.GET.get(query_param_name, default)) 

4002 except (TypeError, ValueError): 

4003 return default 

4004 

4005 def get_str(query_param_name: str, default: Optional[str]) -> str: 

4006 # noinspection PyCallByClass,PyTypeChecker 

4007 return request.GET.get(query_param_name, default) 

4008 

4009 age = get_int("age", 13 if using_alt else 40) 

4010 age_months = get_int("age_months", 2) 

4011 today = datetime.date.today() 

4012 dob = today - relativedelta(years=age, months=age_months) 

4013 

4014 consent_mode_str = get_str( 

4015 "consent_mode", ConsentMode.YELLOW if using_alt else None 

4016 ) 

4017 if consent_mode_str not in ( 

4018 None, 

4019 ConsentMode.RED, 

4020 ConsentMode.YELLOW, 

4021 ConsentMode.GREEN, 

4022 ): 

4023 consent_mode_str = None 

4024 

4025 request_direct_approach = bool(get_int("request_direct_approach", 1)) 

4026 clinician_involvement = ContactRequest.get_clinician_involvement( 

4027 consent_mode_str=consent_mode_str, 

4028 request_direct_approach=request_direct_approach, 

4029 ) 

4030 

4031 consent_after_discharge = bool(get_int("consent_after_discharge", 0)) 

4032 

4033 nhs_number = 2345678901 if using_alt else 1234567890 

4034 study_summary_plaintext = ( 

4035 "An investigation of the change in blood-oxygen-level-" 

4036 "dependent (BOLD) functional magnetic resonance imaging " 

4037 "(fMRI) signals during the experience of quaint and " 

4038 "fanciful humorous activity. " 

4039 "(Incorrectly marked as a CTIMP for illustration only.)" 

4040 # "\n" 

4041 # "\n" 

4042 # "This is paragraph 2.\n" 

4043 # "\n" 

4044 # "For patients aged >18 and <65." 

4045 ) 

4046 study_summary_html = """ 

4047 <p>An investigation of the change in <b>blood-oxygen-level-dependent 

4048 (BOLD)</b> <i>functional magnetic resonance imaging (fMRI)</i> signals 

4049 during the experience of quaint and fanciful humourous activity.</p> 

4050 """ 

4051 # """ 

4052 # 

4053 # <p>Now with extra HTML.</p> 

4054 # 

4055 # <p>For patients aged &gt;18 and &lt;65.</p> 

4056 # """ 

4057 use_html = False 

4058 User = get_user_model() 

4059 lead_researcher_profile = UserProfile() 

4060 lead_researcher_profile.title = "Prof." 

4061 lead_researcher_user = User() 

4062 lead_researcher_user.first_name = "Gabrielle" 

4063 lead_researcher_user.last_name = "Gnosis" 

4064 lead_researcher_user.profile = lead_researcher_profile 

4065 study = Study( 

4066 id=TEST_ID, 

4067 institutional_id=9999999999999, 

4068 title="Functional neuroimaging of whimsy", 

4069 lead_researcher=lead_researcher_user, 

4070 # lead_researcher=request.user, 

4071 # researchers=[request.user], # THIS BREAKS IT. 

4072 # ... actual crash is in 

4073 # django/db/models/fields/related_descriptors.py:500, in 

4074 # ReverseManyToOneDescriptor.__set__(), calling 

4075 # manager.set(value) 

4076 registered_at=datetime.datetime.now(), 

4077 summary=study_summary_html if use_html else study_summary_plaintext, 

4078 summary_is_html=use_html, 

4079 search_methods_planned="Generalized trawl", 

4080 patient_contact=True, 

4081 include_under_16s=True, 

4082 include_lack_capacity=True, 

4083 clinical_trial=True, 

4084 request_direct_approach=clinician_involvement, 

4085 approved_by_rec=True, 

4086 rec_reference="blah/999", 

4087 approved_locally=True, 

4088 local_approval_at=True, 

4089 study_details_pdf=None, 

4090 subject_form_template_pdf=None, 

4091 ) 

4092 consent_mode = ConsentMode( 

4093 id=TEST_ID, 

4094 nhs_number=nhs_number, 

4095 current=True, 

4096 created_by=request.user, 

4097 exclude_entirely=False, 

4098 consent_mode=consent_mode_str, 

4099 consent_after_discharge=consent_after_discharge, 

4100 max_approaches_per_year=0, 

4101 other_requests="", 

4102 prefers_email=False, 

4103 changed_by_clinician_override=False, 

4104 source="Fictional", 

4105 ) 

4106 patient_lookup = PatientLookup( 

4107 id=TEST_ID, 

4108 # PatientLookupBase 

4109 pt_local_id_description="CPFT#", 

4110 pt_local_id_number=987654 if using_alt else 876543, 

4111 pt_dob=dob, 

4112 pt_dod=None, 

4113 pt_dead=False, 

4114 pt_discharged=False, 

4115 pt_discharge_date=None, 

4116 pt_sex=( 

4117 PatientLookupBase.FEMALE if using_alt else PatientLookupBase.MALE 

4118 ), 

4119 pt_title="Miss" if using_alt else "Mr", 

4120 pt_first_name="Jane" if using_alt else "John", 

4121 pt_last_name="Smith", 

4122 pt_address_1="The Farthings", 

4123 pt_address_2="1 Penny Lane", 

4124 pt_address_3="Mordenville", 

4125 pt_address_4="Slowtown", 

4126 pt_address_5="Cambridgeshire", 

4127 pt_address_6="CB1 0ZZ", 

4128 pt_address_7="UK", 

4129 pt_telephone="01223 000000", 

4130 pt_email="jane@smith.com" if using_alt else "john@smith.com", 

4131 gp_title="Dr", 

4132 gp_first_name="Gordon", 

4133 gp_last_name="Generalist", 

4134 gp_address_1="Honeysuckle Medical Practice", 

4135 gp_address_2="99 Bloom Street", 

4136 gp_address_3="Mordenville", 

4137 gp_address_4="Slowtown", 

4138 gp_address_5="Cambridgeshire", 

4139 gp_address_6="CB1 9QQ", 

4140 gp_address_7="UK", 

4141 gp_telephone="01223 111111", 

4142 gp_email="g.generalist@honeysuckle.nhs.uk", 

4143 clinician_title="Dr", 

4144 clinician_first_name="Petra", 

4145 clinician_last_name="Paroxetine", 

4146 clinician_address_1="Union House", 

4147 clinician_address_2="37 Union Lane", 

4148 clinician_address_3="Chesterton", 

4149 clinician_address_4="Cambridge", 

4150 clinician_address_5="Cambridgeshire", 

4151 clinician_address_6="CB4 1PR", 

4152 clinician_address_7="UK", 

4153 clinician_telephone="01223 222222", 

4154 clinician_email="p.paroxetine@cpft_or_similar.nhs.uk", 

4155 clinician_is_consultant=True, 

4156 clinician_signatory_title="Consultant psychiatrist", 

4157 # PatientLookup 

4158 nhs_number=nhs_number, 

4159 source_db="Fictional database", 

4160 decisions="No real decisions", 

4161 secret_decisions="No real secret decisions", 

4162 pt_found=True, 

4163 gp_found=True, 

4164 clinician_found=True, 

4165 ) 

4166 contact_request = ContactRequest( 

4167 id=TEST_ID, 

4168 request_by=request.user, 

4169 study=study, 

4170 lookup_rid=9999999, 

4171 created_at=timezone.now(), 

4172 processed=True, 

4173 nhs_number=nhs_number, 

4174 patient_lookup=patient_lookup, 

4175 consent_mode=consent_mode, 

4176 approaches_in_past_year=0, 

4177 decisions="No decisions required", 

4178 decided_no_action=False, 

4179 # decided_send_to_researcher=False, 

4180 decided_send_to_researcher=True, 

4181 decided_send_to_clinician=True, 

4182 clinician_involvement=clinician_involvement, 

4183 consent_withdrawn=False, 

4184 consent_withdrawn_at=None, 

4185 ) 

4186 clinician_response = ClinicianResponse( 

4187 id=TEST_ID, 

4188 contact_request=contact_request, 

4189 token="dummytoken", 

4190 responded=False, 

4191 ) 

4192 

4193 return DummyObjectCollection( 

4194 contact_request=contact_request, 

4195 consent_mode=consent_mode, 

4196 patient_lookup=patient_lookup, 

4197 study=study, 

4198 clinician_response=clinician_response, 

4199 )