Coverage for tasks/diagnosis.py: 46%

409 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-11-08 23:14 +0000

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/tasks/diagnosis.py 

5 

6=============================================================================== 

7 

8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry. 

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

10 

11 This file is part of CamCOPS. 

12 

13 CamCOPS is free software: you can redistribute it and/or modify 

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

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

16 (at your option) any later version. 

17 

18 CamCOPS is distributed in the hope that it will be useful, 

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

20 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

21 GNU General Public License for more details. 

22 

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

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

25 

26=============================================================================== 

27 

28""" 

29 

30from abc import ABC 

31import logging 

32from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING 

33 

34from cardinal_pythonlib.classes import classproperty 

35from cardinal_pythonlib.colander_utils import get_child_node, OptionalIntNode 

36from cardinal_pythonlib.datetimefunc import pendulum_date_to_datetime_date 

37from cardinal_pythonlib.logs import BraceStyleAdapter 

38import cardinal_pythonlib.rnc_web as ws 

39from cardinal_pythonlib.sqlalchemy.dump import get_literal_query 

40from colander import Invalid, SchemaNode, SequenceSchema, String 

41from fhirclient.models.annotation import Annotation 

42from fhirclient.models.codeableconcept import CodeableConcept 

43from fhirclient.models.coding import Coding 

44from fhirclient.models.condition import Condition 

45import hl7 

46from pyramid.renderers import render_to_response 

47from pyramid.response import Response 

48from sqlalchemy.ext.declarative import declared_attr 

49from sqlalchemy.sql.expression import ( 

50 and_, 

51 exists, 

52 literal, 

53 not_, 

54 or_, 

55 select, 

56 union, 

57) 

58from sqlalchemy.sql.selectable import SelectBase 

59from sqlalchemy.sql.schema import Column 

60from sqlalchemy.sql.sqltypes import Date, Integer, UnicodeText 

61 

62from camcops_server.cc_modules.cc_constants import CssClass, FHIRConst as Fc 

63from camcops_server.cc_modules.cc_ctvinfo import CtvInfo 

64from camcops_server.cc_modules.cc_db import ( 

65 ancillary_relationship, 

66 GenericTabletRecordMixin, 

67 TaskDescendant, 

68) 

69from camcops_server.cc_modules.cc_fhir import make_fhir_bundle_entry 

70from camcops_server.cc_modules.cc_forms import ( 

71 LinkingIdNumSelector, 

72 or_join_description, 

73 ReportParamSchema, 

74 RequestAwareMixin, 

75) 

76from camcops_server.cc_modules.cc_hl7 import make_dg1_segment 

77from camcops_server.cc_modules.cc_html import answer, tr 

78from camcops_server.cc_modules.cc_nlp import guess_name_components 

79from camcops_server.cc_modules.cc_patient import Patient 

80from camcops_server.cc_modules.cc_patientidnum import PatientIdNum 

81from camcops_server.cc_modules.cc_pyramid import CamcopsPage, ViewParam 

82from camcops_server.cc_modules.cc_task import ( 

83 Task, 

84 TaskHasClinicianMixin, 

85 TaskHasPatientMixin, 

86) 

87from camcops_server.cc_modules.cc_exportrecipient import ExportRecipient 

88from camcops_server.cc_modules.cc_request import CamcopsRequest 

89from camcops_server.cc_modules.cc_report import Report 

90from camcops_server.cc_modules.cc_snomed import ( 

91 SnomedConcept, 

92 SnomedExpression, 

93 SnomedFocusConcept, 

94) 

95from camcops_server.cc_modules.cc_sqlalchemy import Base, DeclarativeAndABCMeta 

96from camcops_server.cc_modules.cc_sqla_coltypes import ( 

97 CamcopsColumn, 

98 DiagnosticCodeColType, 

99) 

100from camcops_server.cc_modules.cc_validators import ( 

101 validate_restricted_sql_search_literal, 

102) 

103 

104if TYPE_CHECKING: 

105 from sqlalchemy.sql.elements import ColumnElement 

106 

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

108 

109# ============================================================================= 

110# Helpers 

111# ============================================================================= 

112 

113FK_COMMENT = "FK to parent table" 

114 

115 

116# ============================================================================= 

117# DiagnosisBase 

118# ============================================================================= 

119 

120 

121class DiagnosisItemBase(GenericTabletRecordMixin, Base): 

122 __abstract__ = True 

123 

124 # noinspection PyMethodParameters 

125 @declared_attr 

126 def seqnum(cls) -> Column: 

127 return Column( 

128 "seqnum", 

129 Integer, 

130 nullable=False, 

131 comment="Sequence number (consistently 1-based as of 2018-12-01)", 

132 ) 

133 

134 # noinspection PyMethodParameters 

135 @declared_attr 

136 def code(cls) -> Column: 

137 return Column("code", DiagnosticCodeColType, comment="Diagnostic code") 

138 

139 # noinspection PyMethodParameters 

140 @declared_attr 

141 def description(cls) -> Column: 

142 return CamcopsColumn( 

143 "description", 

144 UnicodeText, 

145 exempt_from_anonymisation=True, 

146 comment="Description of the diagnostic code", 

147 ) 

148 

149 # noinspection PyMethodParameters 

150 @declared_attr 

151 def comment(cls) -> Column: 

152 return Column( # new in v2.0.0 

153 "comment", UnicodeText, comment="Clinician's comment" 

154 ) 

155 

156 def get_html_table_row(self) -> str: 

157 return tr( 

158 self.seqnum, 

159 answer(ws.webify(self.code)), 

160 answer(ws.webify(self.description)), 

161 answer(ws.webify(self.comment)), 

162 ) 

163 

164 def get_code_for_hl7(self) -> str: 

165 # Normal format is to strip out periods, e.g. "F20.0" becomes "F200" 

166 if not self.code: 

167 return "" 

168 return self.code.replace(".", "").upper() 

169 

170 def get_text_for_hl7(self) -> str: 

171 return self.description or "" 

172 

173 def is_empty(self) -> bool: 

174 return not bool(self.code) 

175 

176 def human(self) -> str: 

177 suffix = f" [{self.comment}]" if self.comment else "" 

178 return f"{self.code}: {self.description}{suffix}" 

179 

180 

181class DiagnosisBase( 

182 TaskHasClinicianMixin, 

183 TaskHasPatientMixin, 

184 Task, 

185 ABC, 

186 metaclass=DeclarativeAndABCMeta, 

187): 

188 __abstract__ = True 

189 

190 # noinspection PyMethodParameters 

191 @declared_attr 

192 def relates_to_date(cls) -> Column: 

193 return Column( # new in v2.0.0 

194 "relates_to_date", Date, comment="Date that diagnoses relate to" 

195 ) 

196 

197 items = None # type: List[DiagnosisItemBase] 

198 # ... must be overridden by a relationship 

199 

200 hl7_coding_system = "?" 

201 

202 def get_num_items(self) -> int: 

203 return len(self.items) 

204 

205 def is_complete(self) -> bool: 

206 if self.relates_to_date is None: 

207 return False 

208 if self.get_num_items() == 0: 

209 return False 

210 for item in self.items: # type: DiagnosisItemBase 

211 if item.is_empty(): 

212 return False 

213 return True 

214 

215 def get_task_html(self, req: CamcopsRequest) -> str: 

216 html = f""" 

217 <div class="{CssClass.SUMMARY}"> 

218 <table class="{CssClass.SUMMARY}"> 

219 {self.get_is_complete_tr(req)} 

220 </table> 

221 </div> 

222 <table class="{CssClass.TASKDETAIL}"> 

223 <tr> 

224 <th width="10%">Diagnosis #</th> 

225 <th width="10%">Code</th> 

226 <th width="40%">Description</th> 

227 <th width="40%">Comment</th> 

228 </tr> 

229 """ 

230 for item in self.items: 

231 html += item.get_html_table_row() 

232 html += """ 

233 </table> 

234 """ 

235 return html 

236 

237 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]: 

238 infolist = [] 

239 for item in self.items: 

240 infolist.append( 

241 CtvInfo( 

242 content=( 

243 f"<b>{ws.webify(item.code)}</b>: " 

244 f"{ws.webify(item.description)}" 

245 ) 

246 ) 

247 ) 

248 return infolist 

249 

250 # noinspection PyUnusedLocal 

251 def get_hl7_extra_data_segments( 

252 self, recipient_def: ExportRecipient 

253 ) -> List[hl7.Segment]: 

254 segments = [] 

255 clinician = guess_name_components(self.clinician_name) 

256 for i in range(len(self.items)): 

257 set_id = i + 1 # make it 1-based, not 0-based 

258 item = self.items[i] 

259 segments.append( 

260 make_dg1_segment( 

261 set_id=set_id, 

262 diagnosis_datetime=self.get_creation_datetime(), 

263 coding_system=self.hl7_coding_system, 

264 diagnosis_identifier=item.get_code_for_hl7(), 

265 diagnosis_text=item.get_text_for_hl7(), 

266 clinician_surname=clinician.get("surname") or "", 

267 clinician_forename=clinician.get("forename") or "", 

268 clinician_prefix=clinician.get("prefix") or "", 

269 attestation_datetime=self.get_creation_datetime(), 

270 ) 

271 ) 

272 return segments 

273 

274 def _get_fhir_extra_bundle_entries_for_system( 

275 self, req: CamcopsRequest, recipient: ExportRecipient, system: str 

276 ) -> List[Dict]: 

277 bundle_entries = [] # type: List[Dict] 

278 for item in self.items: 

279 display = item.human() 

280 condition_dict = { 

281 Fc.CODE: CodeableConcept( 

282 jsondict={ 

283 Fc.CODING: [ 

284 Coding( 

285 jsondict={ 

286 Fc.SYSTEM: system, 

287 Fc.CODE: item.code, 

288 Fc.DISPLAY: display, 

289 Fc.USER_SELECTED: True, 

290 } 

291 ).as_json() 

292 ], 

293 Fc.TEXT: display, 

294 } 

295 ).as_json(), 

296 Fc.SUBJECT: self._get_fhir_subject_ref(req, recipient), 

297 Fc.RECORDER: self._get_fhir_practitioner_ref(req), 

298 } 

299 if item.comment: 

300 condition_dict[Fc.NOTE] = [ 

301 Annotation( 

302 jsondict={ 

303 Fc.AUTHOR_REFERENCE: self._get_fhir_practitioner_ref( # noqa 

304 req 

305 ), 

306 Fc.AUTHOR_STRING: self.get_clinician_name(), 

307 Fc.TEXT: item.comment, 

308 Fc.TIME: self.fhir_when_task_created, 

309 } 

310 ).as_json() 

311 ] 

312 bundle_entry = make_fhir_bundle_entry( 

313 resource_type_url=Fc.RESOURCE_TYPE_CONDITION, 

314 identifier=self._get_fhir_condition_id(req, item.seqnum), 

315 resource=Condition(jsondict=condition_dict).as_json(), 

316 ) 

317 bundle_entries.append(bundle_entry) 

318 return bundle_entries 

319 

320 

321# ============================================================================= 

322# DiagnosisIcd10 

323# ============================================================================= 

324 

325 

326class DiagnosisIcd10Item(DiagnosisItemBase, TaskDescendant): 

327 __tablename__ = "diagnosis_icd10_item" 

328 

329 diagnosis_icd10_id = Column( 

330 "diagnosis_icd10_id", Integer, nullable=False, comment=FK_COMMENT 

331 ) 

332 

333 # ------------------------------------------------------------------------- 

334 # TaskDescendant overrides 

335 # ------------------------------------------------------------------------- 

336 

337 @classmethod 

338 def task_ancestor_class(cls) -> Optional[Type["Task"]]: 

339 return DiagnosisIcd10 

340 

341 def task_ancestor(self) -> Optional["DiagnosisIcd10"]: 

342 return DiagnosisIcd10.get_linked(self.diagnosis_icd10_id, self) 

343 

344 

345class DiagnosisIcd10(DiagnosisBase): 

346 """ 

347 Server implementation of the Diagnosis/ICD-10 task. 

348 """ 

349 

350 __tablename__ = "diagnosis_icd10" 

351 info_filename_stem = "icd" 

352 

353 items = ancillary_relationship( 

354 parent_class_name="DiagnosisIcd10", 

355 ancillary_class_name="DiagnosisIcd10Item", 

356 ancillary_fk_to_parent_attr_name="diagnosis_icd10_id", 

357 ancillary_order_by_attr_name="seqnum", 

358 ) # type: List[DiagnosisIcd10Item] 

359 

360 shortname = "Diagnosis_ICD10" 

361 dependent_classes = [DiagnosisIcd10Item] 

362 hl7_coding_system = "I10" 

363 # Page A-129 of https://www.hl7.org/special/committees/vocab/V26_Appendix_A.pdf # noqa: E501 

364 

365 @staticmethod 

366 def longname(req: "CamcopsRequest") -> str: 

367 _ = req.gettext 

368 return _("Diagnostic codes, ICD-10") 

369 

370 def get_snomed_codes( 

371 self, req: CamcopsRequest, fallback: bool = True 

372 ) -> List[SnomedExpression]: 

373 """ 

374 Returns all SNOMED-CT codes for this task. 

375 

376 Args: 

377 req: the 

378 :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

379 fallback: for example, if F32.10 is unknown, should we fall back to 

380 F32.1? 

381 

382 Returns: 

383 a list of 

384 :class:`camcops_server.cc_modules.cc_snomed.SnomedExpression` 

385 objects 

386 """ 

387 if not req.icd10_snomed_supported: 

388 return [] 

389 snomed_codes = [] # type: List[SnomedExpression] 

390 for item in self.items: 

391 concepts = self._get_snomed_concepts(item.code, req, fallback) 

392 if not concepts: 

393 continue 

394 focusconcept = SnomedFocusConcept(concepts) 

395 snomed_codes.append(SnomedExpression(focusconcept)) 

396 return snomed_codes 

397 

398 @staticmethod 

399 def _get_snomed_concepts( 

400 icd10_code: str, req: CamcopsRequest, fallback: bool = True 

401 ) -> List[SnomedConcept]: 

402 """ 

403 Internal function to return :class:`SnomedConcept` objects for an 

404 ICD-10 code. 

405 

406 Args: 

407 icd10_code: the ICD-10 code 

408 req: the 

409 :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

410 fallback: for example, if F32.10 is unknown, should we fall back to 

411 F32.1? 

412 

413 Returns: 

414 list: of :class:`SnomedConcept` objects 

415 

416 """ 

417 concepts = [] # type: List[SnomedConcept] 

418 while icd10_code: 

419 try: 

420 concepts = req.icd10_snomed(icd10_code) 

421 except KeyError: # no known code 

422 pass 

423 if concepts or not fallback: 

424 return concepts 

425 # Now fall back 

426 icd10_code = icd10_code[:-1] 

427 # Run out of code 

428 return concepts 

429 

430 def get_fhir_extra_bundle_entries( 

431 self, req: CamcopsRequest, recipient: ExportRecipient 

432 ) -> List[Dict]: 

433 return self._get_fhir_extra_bundle_entries_for_system( 

434 req, recipient, Fc.CODE_SYSTEM_ICD10 

435 ) 

436 

437 

438# ============================================================================= 

439# DiagnosisIcd9CM 

440# ============================================================================= 

441 

442 

443class DiagnosisIcd9CMItem(DiagnosisItemBase, TaskDescendant): 

444 __tablename__ = "diagnosis_icd9cm_item" 

445 

446 diagnosis_icd9cm_id = Column( 

447 "diagnosis_icd9cm_id", Integer, nullable=False, comment=FK_COMMENT 

448 ) 

449 

450 # ------------------------------------------------------------------------- 

451 # TaskDescendant overrides 

452 # ------------------------------------------------------------------------- 

453 

454 @classmethod 

455 def task_ancestor_class(cls) -> Optional[Type["Task"]]: 

456 return DiagnosisIcd9CM 

457 

458 def task_ancestor(self) -> Optional["DiagnosisIcd9CM"]: 

459 return DiagnosisIcd9CM.get_linked(self.diagnosis_icd9cm_id, self) 

460 

461 

462class DiagnosisIcd9CM(DiagnosisBase): 

463 """ 

464 Server implementation of the Diagnosis/ICD-9-CM task. 

465 """ 

466 

467 __tablename__ = "diagnosis_icd9cm" 

468 info_filename_stem = "icd" 

469 

470 items = ancillary_relationship( 

471 parent_class_name="DiagnosisIcd9CM", 

472 ancillary_class_name="DiagnosisIcd9CMItem", 

473 ancillary_fk_to_parent_attr_name="diagnosis_icd9cm_id", 

474 ancillary_order_by_attr_name="seqnum", 

475 ) # type: List[DiagnosisIcd9CMItem] 

476 

477 shortname = "Diagnosis_ICD9CM" 

478 dependent_classes = [DiagnosisIcd9CMItem] 

479 hl7_coding_system = "I9CM" 

480 # Page A-129 of https://www.hl7.org/special/committees/vocab/V26_Appendix_A.pdf # noqa: E501 

481 

482 @staticmethod 

483 def longname(req: "CamcopsRequest") -> str: 

484 _ = req.gettext 

485 return _("Diagnostic codes, ICD-9-CM (DSM-IV-TR)") 

486 

487 def get_snomed_codes(self, req: CamcopsRequest) -> List[SnomedExpression]: 

488 if not req.icd9cm_snomed_supported: 

489 return [] 

490 snomed_codes = [] # type: List[SnomedExpression] 

491 # noinspection PyTypeChecker 

492 for item in self.items: 

493 try: 

494 concepts = req.icd9cm_snomed(item.code) 

495 except KeyError: # no known code 

496 continue 

497 if not concepts: 

498 continue 

499 focusconcept = SnomedFocusConcept(concepts) 

500 snomed_codes.append(SnomedExpression(focusconcept)) 

501 return snomed_codes 

502 

503 def get_fhir_extra_bundle_entries( 

504 self, req: CamcopsRequest, recipient: ExportRecipient 

505 ) -> List[Dict]: 

506 return self._get_fhir_extra_bundle_entries_for_system( 

507 req, recipient, Fc.CODE_SYSTEM_ICD9_CM 

508 ) 

509 

510 

511# ============================================================================= 

512# Reports 

513# ============================================================================= 

514 

515# ----------------------------------------------------------------------------- 

516# Helpers 

517# ----------------------------------------------------------------------------- 

518 

519ORDER_BY = [ 

520 "surname", 

521 "forename", 

522 "dob", 

523 "sex", 

524 "when_created", 

525 "system", 

526 "code", 

527] 

528 

529 

530# noinspection PyProtectedMember,PyUnresolvedReferences 

531def get_diagnosis_report_query( 

532 req: CamcopsRequest, 

533 diagnosis_class: Type[DiagnosisBase], 

534 item_class: Type[DiagnosisItemBase], 

535 item_fk_fieldname: str, 

536 system: str, 

537) -> SelectBase: 

538 # SELECT surname, forename, dob, sex, ... 

539 select_fields = [ 

540 Patient.surname.label("surname"), 

541 Patient.forename.label("forename"), 

542 Patient.dob.label("dob"), 

543 Patient.sex.label("sex"), 

544 ] 

545 from_clause = ( 

546 # FROM patient 

547 Patient.__table__ 

548 # INNER JOIN dxset ON (dxtable.patient_id == patient.id AND ...) 

549 .join( 

550 diagnosis_class.__table__, 

551 and_( 

552 diagnosis_class.patient_id == Patient.id, 

553 diagnosis_class._device_id == Patient._device_id, 

554 diagnosis_class._era == Patient._era, 

555 ), 

556 ) 

557 # INNER JOIN dxrow ON (dxrow.fk_dxset = dxset.pk AND ...) 

558 .join( 

559 item_class.__table__, 

560 and_( 

561 getattr(item_class, item_fk_fieldname) == diagnosis_class.id, 

562 item_class._device_id == diagnosis_class._device_id, 

563 item_class._era == diagnosis_class._era, 

564 ), 

565 ) 

566 ) 

567 for iddef in req.idnum_definitions: 

568 n = iddef.which_idnum 

569 desc = iddef.short_description 

570 aliased_table = PatientIdNum.__table__.alias(f"i{n}") 

571 # ... [also] SELECT i1.idnum_value AS 'NHS' (etc.) 

572 select_fields.append(aliased_table.c.idnum_value.label(desc)) 

573 # ... [from] OUTER JOIN patientidnum AS i1 ON (...) 

574 from_clause = from_clause.outerjoin( 

575 aliased_table, 

576 and_( 

577 aliased_table.c.patient_id == Patient.id, 

578 aliased_table.c._device_id == Patient._device_id, 

579 aliased_table.c._era == Patient._era, 

580 # Note: the following are part of the JOIN, not the WHERE: 

581 # (or failure to match a row will wipe out the Patient from the 

582 # OUTER JOIN): 

583 aliased_table.c._current == True, # noqa: E712 

584 aliased_table.c.which_idnum == n, # noqa: E712 

585 ), 

586 ) # noqa: E712 

587 select_fields += [ 

588 diagnosis_class.when_created.label("when_created"), 

589 literal(system).label("system"), 

590 item_class.code.label("code"), 

591 item_class.description.label("description"), 

592 ] 

593 # WHERE... 

594 wheres = [ 

595 Patient._current == True, # noqa: E712 

596 diagnosis_class._current == True, # noqa: E712 

597 item_class._current == True, # noqa: E712 

598 ] 

599 if not req.user.superuser: 

600 # Restrict to accessible groups 

601 group_ids = req.user.ids_of_groups_user_may_report_on 

602 wheres.append(diagnosis_class._group_id.in_(group_ids)) 

603 # Helpfully, SQLAlchemy will render this as "... AND 1 != 1" if we 

604 # pass an empty list to in_(). 

605 query = select(select_fields).select_from(from_clause).where(and_(*wheres)) 

606 return query 

607 

608 

609def get_diagnosis_report( 

610 req: CamcopsRequest, 

611 diagnosis_class: Type[DiagnosisBase], 

612 item_class: Type[DiagnosisItemBase], 

613 item_fk_fieldname: str, 

614 system: str, 

615) -> SelectBase: 

616 query = get_diagnosis_report_query( 

617 req, diagnosis_class, item_class, item_fk_fieldname, system 

618 ) 

619 query = query.order_by(*ORDER_BY) 

620 return query 

621 

622 

623# ----------------------------------------------------------------------------- 

624# Plain "all diagnoses" reports 

625# ----------------------------------------------------------------------------- 

626 

627 

628class DiagnosisICD9CMReport(Report): 

629 """Report to show ICD-9-CM (DSM-IV-TR) diagnoses.""" 

630 

631 # noinspection PyMethodParameters 

632 @classproperty 

633 def report_id(cls) -> str: 

634 return "diagnoses_icd9cm" 

635 

636 @classmethod 

637 def title(cls, req: "CamcopsRequest") -> str: 

638 _ = req.gettext 

639 return _( 

640 "Diagnosis – ICD-9-CM (DSM-IV-TR) diagnoses for all " "patients" 

641 ) 

642 

643 # noinspection PyMethodParameters 

644 @classproperty 

645 def superuser_only(cls) -> bool: 

646 return False 

647 

648 def get_query(self, req: CamcopsRequest) -> SelectBase: 

649 return get_diagnosis_report( 

650 req, 

651 diagnosis_class=DiagnosisIcd9CM, 

652 item_class=DiagnosisIcd9CMItem, 

653 item_fk_fieldname="diagnosis_icd9cm_id", 

654 system="ICD-9-CM", 

655 ) 

656 

657 

658class DiagnosisICD10Report(Report): 

659 """Report to show ICD-10 diagnoses.""" 

660 

661 # noinspection PyMethodParameters 

662 @classproperty 

663 def report_id(cls) -> str: 

664 return "diagnoses_icd10" 

665 

666 @classmethod 

667 def title(cls, req: "CamcopsRequest") -> str: 

668 _ = req.gettext 

669 return _("Diagnosis – ICD-10 diagnoses for all patients") 

670 

671 # noinspection PyMethodParameters 

672 @classproperty 

673 def superuser_only(cls) -> bool: 

674 return False 

675 

676 def get_query(self, req: CamcopsRequest) -> SelectBase: 

677 return get_diagnosis_report( 

678 req, 

679 diagnosis_class=DiagnosisIcd10, 

680 item_class=DiagnosisIcd10Item, 

681 item_fk_fieldname="diagnosis_icd10_id", 

682 system="ICD-10", 

683 ) 

684 

685 

686class DiagnosisAllReport(Report): 

687 """Report to show all diagnoses.""" 

688 

689 # noinspection PyMethodParameters 

690 @classproperty 

691 def report_id(cls) -> str: 

692 return "diagnoses_all" 

693 

694 @classmethod 

695 def title(cls, req: "CamcopsRequest") -> str: 

696 _ = req.gettext 

697 return _("Diagnosis – All diagnoses for all patients") 

698 

699 # noinspection PyMethodParameters 

700 @classproperty 

701 def superuser_only(cls) -> bool: 

702 return False 

703 

704 def get_query(self, req: CamcopsRequest) -> SelectBase: 

705 sql_icd9cm = get_diagnosis_report_query( 

706 req, 

707 diagnosis_class=DiagnosisIcd9CM, 

708 item_class=DiagnosisIcd9CMItem, 

709 item_fk_fieldname="diagnosis_icd9cm_id", 

710 system="ICD-9-CM", 

711 ) 

712 sql_icd10 = get_diagnosis_report_query( 

713 req, 

714 diagnosis_class=DiagnosisIcd10, 

715 item_class=DiagnosisIcd10Item, 

716 item_fk_fieldname="diagnosis_icd10_id", 

717 system="ICD-10", 

718 ) 

719 query = union(sql_icd9cm, sql_icd10) 

720 query = query.order_by(*ORDER_BY) 

721 return query 

722 

723 

724# ----------------------------------------------------------------------------- 

725# "Find me patients matching certain diagnostic criteria" 

726# ----------------------------------------------------------------------------- 

727 

728 

729class DiagnosisNode(SchemaNode, RequestAwareMixin): 

730 schema_type = String 

731 

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

733 self.title = "" # for type checker 

734 self.description = "" # for type checker 

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

736 

737 # noinspection PyUnusedLocal 

738 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

739 _ = self.gettext 

740 self.title = _("Diagnostic code") 

741 self.description = _( 

742 "Type in a diagnostic code; you may use SQL 'LIKE' syntax for " 

743 "wildcards, i.e. _ for one character and % for zero/one/lots" 

744 ) 

745 

746 def validator(self, node: SchemaNode, value: str) -> None: 

747 try: 

748 validate_restricted_sql_search_literal(value, self.request) 

749 except ValueError as e: 

750 raise Invalid(node, str(e)) 

751 

752 

753class DiagnosesSequence(SequenceSchema, RequestAwareMixin): 

754 diagnoses = DiagnosisNode() 

755 

756 def __init__(self, *args, minimum_number: int = 0, **kwargs) -> None: 

757 self.minimum_number = minimum_number 

758 self.title = "" # for type checker 

759 self.description = "" # for type checker 

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

761 

762 # noinspection PyUnusedLocal 

763 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

764 request = self.request 

765 _ = request.gettext 

766 self.title = _("Diagnostic codes") 

767 self.description = ( 

768 _( 

769 "Use % as a wildcard (e.g. F32 matches only F32, but F32% " 

770 "matches F32, F32.1, F32.2...)." 

771 ) 

772 + " " 

773 + or_join_description(request) 

774 ) 

775 

776 def validator(self, node: SchemaNode, value: List[str]) -> None: 

777 assert isinstance(value, list) 

778 _ = self.gettext 

779 if len(value) < self.minimum_number: 

780 raise Invalid( 

781 node, 

782 _("You must specify at least") + f" {self.minimum_number}", 

783 ) 

784 if len(value) != len(set(value)): 

785 raise Invalid(node, _("You have specified duplicate diagnoses")) 

786 

787 

788class DiagnosisFinderReportSchema(ReportParamSchema): 

789 which_idnum = LinkingIdNumSelector() # must match ViewParam.WHICH_IDNUM 

790 diagnoses_inclusion = DiagnosesSequence( 

791 minimum_number=1 

792 ) # must match ViewParam.DIAGNOSES_INCLUSION 

793 diagnoses_exclusion = ( 

794 DiagnosesSequence() 

795 ) # must match ViewParam.DIAGNOSES_EXCLUSION 

796 age_minimum = OptionalIntNode() # must match ViewParam.AGE_MINIMUM 

797 age_maximum = OptionalIntNode() # must match ViewParam.AGE_MAXIMUM 

798 

799 # noinspection PyUnusedLocal 

800 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

801 _ = self.gettext 

802 diagnoses_inclusion = get_child_node(self, "diagnoses_inclusion") 

803 diagnoses_inclusion.title = _("Inclusion diagnoses (lifetime)") 

804 diagnoses_exclusion = get_child_node(self, "diagnoses_exclusion") 

805 diagnoses_exclusion.title = _("Exclusion diagnoses (lifetime)") 

806 age_minimum = get_child_node(self, "age_minimum") 

807 age_minimum.title = _("Minimum age (years) (optional)") 

808 age_maximum = get_child_node(self, "age_maximum") 

809 age_maximum.title = _("Maximum age (years) (optional)") 

810 

811 

812# noinspection PyProtectedMember 

813def get_diagnosis_inc_exc_report_query( 

814 req: CamcopsRequest, 

815 diagnosis_class: Type[DiagnosisBase], 

816 item_class: Type[DiagnosisItemBase], 

817 item_fk_fieldname: str, 

818 system: str, 

819 which_idnum: int, 

820 inclusion_dx: List[str], 

821 exclusion_dx: List[str], 

822 age_minimum_y: int, 

823 age_maximum_y: int, 

824) -> SelectBase: 

825 """ 

826 As for get_diagnosis_report_query, but this makes some modifications to 

827 do inclusion and exclusion criteria. 

828 

829 - We need a linking number to perform exclusion criteria. 

830 - Therefore, we use a single ID number, which must not be NULL. 

831 """ 

832 # The basics: 

833 desc = req.get_id_desc(which_idnum) or "BAD_IDNUM" 

834 # noinspection PyUnresolvedReferences 

835 select_fields = [ 

836 Patient.surname.label("surname"), 

837 Patient.forename.label("forename"), 

838 Patient.dob.label("dob"), 

839 Patient.sex.label("sex"), 

840 PatientIdNum.idnum_value.label(desc), 

841 diagnosis_class.when_created.label("when_created"), 

842 literal(system).label("system"), 

843 item_class.code.label("code"), 

844 item_class.description.label("description"), 

845 ] 

846 # noinspection PyUnresolvedReferences 

847 select_from = ( 

848 Patient.__table__.join( 

849 diagnosis_class.__table__, 

850 and_( 

851 diagnosis_class.patient_id == Patient.id, 

852 diagnosis_class._device_id == Patient._device_id, 

853 diagnosis_class._era == Patient._era, 

854 diagnosis_class._current == True, # noqa: E712 

855 ), 

856 ) 

857 .join( 

858 item_class.__table__, 

859 and_( 

860 getattr(item_class, item_fk_fieldname) == diagnosis_class.id, 

861 item_class._device_id == diagnosis_class._device_id, 

862 item_class._era == diagnosis_class._era, 

863 item_class._current == True, # noqa: E712 

864 ), 

865 ) 

866 .join( 

867 PatientIdNum.__table__, 

868 and_( 

869 PatientIdNum.patient_id == Patient.id, 

870 PatientIdNum._device_id == Patient._device_id, 

871 PatientIdNum._era == Patient._era, 

872 PatientIdNum._current == True, # noqa: E712 

873 PatientIdNum.which_idnum == which_idnum, 

874 PatientIdNum.idnum_value.isnot(None), # NOT NULL 

875 ), 

876 ) 

877 ) 

878 wheres = [Patient._current == True] # noqa: E712 

879 if not req.user.superuser: 

880 # Restrict to accessible groups 

881 group_ids = req.user.ids_of_groups_user_may_report_on 

882 wheres.append(diagnosis_class._group_id.in_(group_ids)) 

883 else: 

884 group_ids = [] # type: List[int] # to stop type-checker moaning below 

885 

886 # Age limits are simple, as the same patient has the same age for 

887 # all diagnosis rows. 

888 today = req.today 

889 if age_maximum_y is not None: 

890 # Example: max age is 40; earliest (oldest) DOB is therefore 41 

891 # years ago plus one day (e.g. if it's 15 June 2010, then earliest 

892 # DOB is 16 June 1969; a person born then will be 41 tomorrow). 

893 earliest_dob = pendulum_date_to_datetime_date( 

894 today.subtract(years=age_maximum_y + 1).add(days=1) 

895 ) 

896 wheres.append(Patient.dob >= earliest_dob) 

897 if age_minimum_y is not None: 

898 # Example: min age is 20; latest (youngest) DOB is therefore 20 

899 # years ago (e.g. if it's 15 June 2010, latest DOB is 15 June 1990; 

900 # if you're born after that, you're not 20 yet). 

901 latest_dob = pendulum_date_to_datetime_date( 

902 today.subtract(years=age_minimum_y) 

903 ) 

904 wheres.append(Patient.dob <= latest_dob) 

905 

906 # Diagnosis criteria are a little bit more complex. 

907 # 

908 # We can reasonably do inclusion criteria as "show the diagnoses 

909 # matching the inclusion criteria" (not the more complex "show all 

910 # diagnoses for patients having at least one inclusion diagnosis", 

911 # which is likely to be too verbose for patient finding). 

912 inclusion_criteria = [] # type: List[ColumnElement] 

913 for idx in inclusion_dx: 

914 inclusion_criteria.append(item_class.code.like(idx)) 

915 wheres.append(or_(*inclusion_criteria)) 

916 

917 # Exclusion criteria are the trickier: we need to be able to link 

918 # multiple diagnoses for the same patient, so we need to use a linking 

919 # ID number. 

920 if exclusion_dx: 

921 # noinspection PyUnresolvedReferences 

922 edx_items = item_class.__table__.alias("edx_items") 

923 # noinspection PyUnresolvedReferences 

924 edx_sets = diagnosis_class.__table__.alias("edx_sets") 

925 # noinspection PyUnresolvedReferences 

926 edx_patient = Patient.__table__.alias("edx_patient") 

927 # noinspection PyUnresolvedReferences 

928 edx_idnum = PatientIdNum.__table__.alias("edx_idnum") 

929 edx_joined = ( 

930 edx_items.join( 

931 edx_sets, 

932 and_( 

933 getattr(edx_items.c, item_fk_fieldname) == edx_sets.c.id, 

934 edx_items.c._device_id == edx_sets.c._device_id, 

935 edx_items.c._era == edx_sets.c._era, 

936 edx_items.c._current == True, # noqa: E712 

937 ), 

938 ) 

939 .join( 

940 edx_patient, 

941 and_( 

942 edx_sets.c.patient_id == edx_patient.c.id, 

943 edx_sets.c._device_id == edx_patient.c._device_id, 

944 edx_sets.c._era == edx_patient.c._era, 

945 edx_sets.c._current == True, # noqa: E712 

946 ), 

947 ) 

948 .join( 

949 edx_idnum, 

950 and_( 

951 edx_idnum.c.patient_id == edx_patient.c.id, 

952 edx_idnum.c._device_id == edx_patient.c._device_id, 

953 edx_idnum.c._era == edx_patient.c._era, 

954 edx_idnum.c._current == True, # noqa: E712 

955 edx_idnum.c.which_idnum == which_idnum, 

956 ), 

957 ) 

958 ) 

959 exclusion_criteria = [] # type: List[ColumnElement] 

960 for edx in exclusion_dx: 

961 exclusion_criteria.append(edx_items.c.code.like(edx)) 

962 edx_wheres = [ 

963 edx_items.c._current == True, # noqa: E712 

964 edx_idnum.c.idnum_value == PatientIdNum.idnum_value, 

965 or_(*exclusion_criteria), 

966 ] 

967 # Note the join above between the main and the EXISTS clauses. 

968 # We don't use an alias for the main copy of the PatientIdNum table, 

969 # and we do for the EXISTS version. This is fine; e.g. 

970 # https://msdn.microsoft.com/en-us/library/ethytz2x.aspx example: 

971 # SELECT boss.name, employee.name 

972 # FROM employee 

973 # INNER JOIN employee boss ON employee.manager_id = boss.emp_id; 

974 if not req.user.superuser: 

975 # Restrict to accessible groups 

976 # group_ids already defined from above 

977 edx_wheres.append(edx_sets.c._group_id.in_(group_ids)) 

978 # ... bugfix 2018-06-19: "wheres" -> "edx_wheres" 

979 exclusion_select = ( 

980 select(["*"]).select_from(edx_joined).where(and_(*edx_wheres)) 

981 ) 

982 wheres.append(not_(exists(exclusion_select))) 

983 

984 query = select(select_fields).select_from(select_from).where(and_(*wheres)) 

985 return query 

986 

987 

988# noinspection PyAbstractClass 

989class DiagnosisFinderReportBase(Report): 

990 """Report to show all diagnoses.""" 

991 

992 # noinspection PyMethodParameters 

993 @classproperty 

994 def superuser_only(cls) -> bool: 

995 return False 

996 

997 @staticmethod 

998 def get_paramform_schema_class() -> Type["ReportParamSchema"]: 

999 return DiagnosisFinderReportSchema 

1000 

1001 @classmethod 

1002 def get_specific_http_query_keys(cls) -> List[str]: 

1003 return [ 

1004 ViewParam.WHICH_IDNUM, 

1005 ViewParam.DIAGNOSES_INCLUSION, 

1006 ViewParam.DIAGNOSES_EXCLUSION, 

1007 ViewParam.AGE_MINIMUM, 

1008 ViewParam.AGE_MAXIMUM, 

1009 ] 

1010 

1011 def render_single_page_html( 

1012 self, req: "CamcopsRequest", column_names: List[str], page: CamcopsPage 

1013 ) -> Response: 

1014 which_idnum = req.get_int_param(ViewParam.WHICH_IDNUM) 

1015 inclusion_dx = req.get_str_list_param( 

1016 ViewParam.DIAGNOSES_INCLUSION, 

1017 validator=validate_restricted_sql_search_literal, 

1018 ) 

1019 exclusion_dx = req.get_str_list_param( 

1020 ViewParam.DIAGNOSES_EXCLUSION, 

1021 validator=validate_restricted_sql_search_literal, 

1022 ) 

1023 age_minimum = req.get_int_param(ViewParam.AGE_MINIMUM) 

1024 age_maximum = req.get_int_param(ViewParam.AGE_MAXIMUM) 

1025 idnum_desc = req.get_id_desc(which_idnum) or "BAD_IDNUM" 

1026 query = self.get_query(req) 

1027 sql = get_literal_query(query, bind=req.engine) 

1028 

1029 return render_to_response( 

1030 "diagnosis_finder_report.mako", 

1031 dict( 

1032 title=self.title(req), 

1033 page=page, 

1034 column_names=column_names, 

1035 report_id=self.report_id, 

1036 idnum_desc=idnum_desc, 

1037 inclusion_dx=inclusion_dx, 

1038 exclusion_dx=exclusion_dx, 

1039 age_minimum=age_minimum, 

1040 age_maximum=age_maximum, 

1041 sql=sql, 

1042 ), 

1043 request=req, 

1044 ) 

1045 

1046 

1047class DiagnosisICD10FinderReport(DiagnosisFinderReportBase): 

1048 # noinspection PyMethodParameters 

1049 @classproperty 

1050 def report_id(cls) -> str: 

1051 return "diagnoses_finder_icd10" 

1052 

1053 @classmethod 

1054 def title(cls, req: "CamcopsRequest") -> str: 

1055 _ = req.gettext 

1056 return _("Diagnosis – Find patients by ICD-10 diagnosis ± age") 

1057 

1058 def get_query(self, req: CamcopsRequest) -> SelectBase: 

1059 which_idnum = req.get_int_param(ViewParam.WHICH_IDNUM) 

1060 inclusion_dx = req.get_str_list_param( 

1061 ViewParam.DIAGNOSES_INCLUSION, 

1062 validator=validate_restricted_sql_search_literal, 

1063 ) 

1064 exclusion_dx = req.get_str_list_param( 

1065 ViewParam.DIAGNOSES_EXCLUSION, 

1066 validator=validate_restricted_sql_search_literal, 

1067 ) 

1068 age_minimum = req.get_int_param(ViewParam.AGE_MINIMUM) 

1069 age_maximum = req.get_int_param(ViewParam.AGE_MAXIMUM) 

1070 

1071 q = get_diagnosis_inc_exc_report_query( 

1072 req, 

1073 diagnosis_class=DiagnosisIcd10, 

1074 item_class=DiagnosisIcd10Item, 

1075 item_fk_fieldname="diagnosis_icd10_id", 

1076 system="ICD-10", 

1077 which_idnum=which_idnum, 

1078 inclusion_dx=inclusion_dx, 

1079 exclusion_dx=exclusion_dx, 

1080 age_minimum_y=age_minimum, 

1081 age_maximum_y=age_maximum, 

1082 ) 

1083 q = q.order_by(*ORDER_BY) 

1084 # log.debug("Final query:\n{}", get_literal_query(q, bind=req.engine)) 

1085 return q 

1086 

1087 @staticmethod 

1088 def get_test_querydict() -> Dict[str, Any]: 

1089 return { 

1090 ViewParam.WHICH_IDNUM: 1, 

1091 ViewParam.DIAGNOSES_INCLUSION: ["F32%"], 

1092 ViewParam.DIAGNOSES_EXCLUSION: [], 

1093 ViewParam.AGE_MINIMUM: None, 

1094 ViewParam.AGE_MAXIMUM: None, 

1095 } 

1096 

1097 

1098class DiagnosisICD9CMFinderReport(DiagnosisFinderReportBase): 

1099 # noinspection PyMethodParameters 

1100 @classproperty 

1101 def report_id(cls) -> str: 

1102 return "diagnoses_finder_icd9cm" 

1103 

1104 @classmethod 

1105 def title(cls, req: "CamcopsRequest") -> str: 

1106 _ = req.gettext 

1107 return _( 

1108 "Diagnosis – Find patients by ICD-9-CM (DSM-IV-TR) diagnosis ± age" 

1109 ) 

1110 

1111 def get_query(self, req: CamcopsRequest) -> SelectBase: 

1112 which_idnum = req.get_int_param(ViewParam.WHICH_IDNUM) 

1113 inclusion_dx = req.get_str_list_param( 

1114 ViewParam.DIAGNOSES_INCLUSION, 

1115 validator=validate_restricted_sql_search_literal, 

1116 ) 

1117 exclusion_dx = req.get_str_list_param( 

1118 ViewParam.DIAGNOSES_EXCLUSION, 

1119 validator=validate_restricted_sql_search_literal, 

1120 ) 

1121 age_minimum = req.get_int_param(ViewParam.AGE_MINIMUM) 

1122 age_maximum = req.get_int_param(ViewParam.AGE_MAXIMUM) 

1123 

1124 q = get_diagnosis_inc_exc_report_query( 

1125 req, 

1126 diagnosis_class=DiagnosisIcd9CM, 

1127 item_class=DiagnosisIcd9CMItem, 

1128 item_fk_fieldname="diagnosis_icd9cm_id", 

1129 system="ICD-9-CM", 

1130 which_idnum=which_idnum, 

1131 inclusion_dx=inclusion_dx, 

1132 exclusion_dx=exclusion_dx, 

1133 age_minimum_y=age_minimum, 

1134 age_maximum_y=age_maximum, 

1135 ) 

1136 q = q.order_by(*ORDER_BY) 

1137 # log.debug("Final query:\n{}", get_literal_query(q, bind=req.engine)) 

1138 return q 

1139 

1140 @staticmethod 

1141 def get_test_querydict() -> Dict[str, Any]: 

1142 return { 

1143 ViewParam.WHICH_IDNUM: 1, 

1144 ViewParam.DIAGNOSES_INCLUSION: ["296%"], 

1145 ViewParam.DIAGNOSES_EXCLUSION: [], 

1146 ViewParam.AGE_MINIMUM: None, 

1147 ViewParam.AGE_MAXIMUM: None, 

1148 }