Coverage for tasks/diagnosis.py: 46%
409 statements
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
1#!/usr/bin/env python
3"""
4camcops_server/tasks/diagnosis.py
6===============================================================================
8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
11 This file is part of CamCOPS.
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.
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.
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/>.
26===============================================================================
28"""
30from abc import ABC
31import logging
32from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING
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
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)
104if TYPE_CHECKING:
105 from sqlalchemy.sql.elements import ColumnElement
107log = BraceStyleAdapter(logging.getLogger(__name__))
109# =============================================================================
110# Helpers
111# =============================================================================
113FK_COMMENT = "FK to parent table"
116# =============================================================================
117# DiagnosisBase
118# =============================================================================
121class DiagnosisItemBase(GenericTabletRecordMixin, Base):
122 __abstract__ = True
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 )
134 # noinspection PyMethodParameters
135 @declared_attr
136 def code(cls) -> Column:
137 return Column("code", DiagnosticCodeColType, comment="Diagnostic code")
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 )
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 )
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 )
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()
170 def get_text_for_hl7(self) -> str:
171 return self.description or ""
173 def is_empty(self) -> bool:
174 return not bool(self.code)
176 def human(self) -> str:
177 suffix = f" [{self.comment}]" if self.comment else ""
178 return f"{self.code}: {self.description}{suffix}"
181class DiagnosisBase(
182 TaskHasClinicianMixin,
183 TaskHasPatientMixin,
184 Task,
185 ABC,
186 metaclass=DeclarativeAndABCMeta,
187):
188 __abstract__ = True
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 )
197 items = None # type: List[DiagnosisItemBase]
198 # ... must be overridden by a relationship
200 hl7_coding_system = "?"
202 def get_num_items(self) -> int:
203 return len(self.items)
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
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
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
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
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
321# =============================================================================
322# DiagnosisIcd10
323# =============================================================================
326class DiagnosisIcd10Item(DiagnosisItemBase, TaskDescendant):
327 __tablename__ = "diagnosis_icd10_item"
329 diagnosis_icd10_id = Column(
330 "diagnosis_icd10_id", Integer, nullable=False, comment=FK_COMMENT
331 )
333 # -------------------------------------------------------------------------
334 # TaskDescendant overrides
335 # -------------------------------------------------------------------------
337 @classmethod
338 def task_ancestor_class(cls) -> Optional[Type["Task"]]:
339 return DiagnosisIcd10
341 def task_ancestor(self) -> Optional["DiagnosisIcd10"]:
342 return DiagnosisIcd10.get_linked(self.diagnosis_icd10_id, self)
345class DiagnosisIcd10(DiagnosisBase):
346 """
347 Server implementation of the Diagnosis/ICD-10 task.
348 """
350 __tablename__ = "diagnosis_icd10"
351 info_filename_stem = "icd"
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]
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
365 @staticmethod
366 def longname(req: "CamcopsRequest") -> str:
367 _ = req.gettext
368 return _("Diagnostic codes, ICD-10")
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.
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?
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
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.
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?
413 Returns:
414 list: of :class:`SnomedConcept` objects
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
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 )
438# =============================================================================
439# DiagnosisIcd9CM
440# =============================================================================
443class DiagnosisIcd9CMItem(DiagnosisItemBase, TaskDescendant):
444 __tablename__ = "diagnosis_icd9cm_item"
446 diagnosis_icd9cm_id = Column(
447 "diagnosis_icd9cm_id", Integer, nullable=False, comment=FK_COMMENT
448 )
450 # -------------------------------------------------------------------------
451 # TaskDescendant overrides
452 # -------------------------------------------------------------------------
454 @classmethod
455 def task_ancestor_class(cls) -> Optional[Type["Task"]]:
456 return DiagnosisIcd9CM
458 def task_ancestor(self) -> Optional["DiagnosisIcd9CM"]:
459 return DiagnosisIcd9CM.get_linked(self.diagnosis_icd9cm_id, self)
462class DiagnosisIcd9CM(DiagnosisBase):
463 """
464 Server implementation of the Diagnosis/ICD-9-CM task.
465 """
467 __tablename__ = "diagnosis_icd9cm"
468 info_filename_stem = "icd"
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]
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
482 @staticmethod
483 def longname(req: "CamcopsRequest") -> str:
484 _ = req.gettext
485 return _("Diagnostic codes, ICD-9-CM (DSM-IV-TR)")
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
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 )
511# =============================================================================
512# Reports
513# =============================================================================
515# -----------------------------------------------------------------------------
516# Helpers
517# -----------------------------------------------------------------------------
519ORDER_BY = [
520 "surname",
521 "forename",
522 "dob",
523 "sex",
524 "when_created",
525 "system",
526 "code",
527]
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
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
623# -----------------------------------------------------------------------------
624# Plain "all diagnoses" reports
625# -----------------------------------------------------------------------------
628class DiagnosisICD9CMReport(Report):
629 """Report to show ICD-9-CM (DSM-IV-TR) diagnoses."""
631 # noinspection PyMethodParameters
632 @classproperty
633 def report_id(cls) -> str:
634 return "diagnoses_icd9cm"
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 )
643 # noinspection PyMethodParameters
644 @classproperty
645 def superuser_only(cls) -> bool:
646 return False
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 )
658class DiagnosisICD10Report(Report):
659 """Report to show ICD-10 diagnoses."""
661 # noinspection PyMethodParameters
662 @classproperty
663 def report_id(cls) -> str:
664 return "diagnoses_icd10"
666 @classmethod
667 def title(cls, req: "CamcopsRequest") -> str:
668 _ = req.gettext
669 return _("Diagnosis – ICD-10 diagnoses for all patients")
671 # noinspection PyMethodParameters
672 @classproperty
673 def superuser_only(cls) -> bool:
674 return False
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 )
686class DiagnosisAllReport(Report):
687 """Report to show all diagnoses."""
689 # noinspection PyMethodParameters
690 @classproperty
691 def report_id(cls) -> str:
692 return "diagnoses_all"
694 @classmethod
695 def title(cls, req: "CamcopsRequest") -> str:
696 _ = req.gettext
697 return _("Diagnosis – All diagnoses for all patients")
699 # noinspection PyMethodParameters
700 @classproperty
701 def superuser_only(cls) -> bool:
702 return False
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
724# -----------------------------------------------------------------------------
725# "Find me patients matching certain diagnostic criteria"
726# -----------------------------------------------------------------------------
729class DiagnosisNode(SchemaNode, RequestAwareMixin):
730 schema_type = String
732 def __init__(self, *args, **kwargs) -> None:
733 self.title = "" # for type checker
734 self.description = "" # for type checker
735 super().__init__(*args, **kwargs)
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 )
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))
753class DiagnosesSequence(SequenceSchema, RequestAwareMixin):
754 diagnoses = DiagnosisNode()
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)
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 )
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"))
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
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)")
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.
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
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)
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))
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)))
984 query = select(select_fields).select_from(select_from).where(and_(*wheres))
985 return query
988# noinspection PyAbstractClass
989class DiagnosisFinderReportBase(Report):
990 """Report to show all diagnoses."""
992 # noinspection PyMethodParameters
993 @classproperty
994 def superuser_only(cls) -> bool:
995 return False
997 @staticmethod
998 def get_paramform_schema_class() -> Type["ReportParamSchema"]:
999 return DiagnosisFinderReportSchema
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 ]
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)
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 )
1047class DiagnosisICD10FinderReport(DiagnosisFinderReportBase):
1048 # noinspection PyMethodParameters
1049 @classproperty
1050 def report_id(cls) -> str:
1051 return "diagnoses_finder_icd10"
1053 @classmethod
1054 def title(cls, req: "CamcopsRequest") -> str:
1055 _ = req.gettext
1056 return _("Diagnosis – Find patients by ICD-10 diagnosis ± age")
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)
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
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 }
1098class DiagnosisICD9CMFinderReport(DiagnosisFinderReportBase):
1099 # noinspection PyMethodParameters
1100 @classproperty
1101 def report_id(cls) -> str:
1102 return "diagnoses_finder_icd9cm"
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 )
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)
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
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 }