Coverage for cc_modules/cc_patient.py: 34%
386 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/cc_modules/cc_patient.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**Patients.**
30"""
32import logging
33from typing import (
34 Any,
35 Dict,
36 Generator,
37 List,
38 Optional,
39 Set,
40 Tuple,
41 TYPE_CHECKING,
42 Union,
43)
44import uuid
46from cardinal_pythonlib.classes import classproperty
47from cardinal_pythonlib.datetimefunc import (
48 coerce_to_pendulum_date,
49 format_datetime,
50 get_age,
51 PotentialDatetimeType,
52)
53from cardinal_pythonlib.json.typing_helpers import JsonObjectType
54from cardinal_pythonlib.logs import BraceStyleAdapter
55import cardinal_pythonlib.rnc_web as ws
56from fhirclient.models.address import Address
57from fhirclient.models.contactpoint import ContactPoint
58from fhirclient.models.humanname import HumanName
59from fhirclient.models.fhirreference import FHIRReference
60from fhirclient.models.identifier import Identifier
61from fhirclient.models.patient import Patient as FhirPatient
62import hl7
63import pendulum
64from sqlalchemy.ext.declarative import declared_attr
65from sqlalchemy.orm import relationship
66from sqlalchemy.orm import Session as SqlASession
67from sqlalchemy.orm.relationships import RelationshipProperty
68from sqlalchemy.sql.expression import and_, ClauseElement, select
69from sqlalchemy.sql.schema import Column
70from sqlalchemy.sql.selectable import SelectBase
71from sqlalchemy.sql import sqltypes
72from sqlalchemy.sql.sqltypes import Integer, UnicodeText
74from camcops_server.cc_modules.cc_audit import audit
75from camcops_server.cc_modules.cc_constants import (
76 DateFormat,
77 ERA_NOW,
78 FHIRConst as Fc,
79 FP_ID_DESC,
80 FP_ID_SHORT_DESC,
81 FP_ID_NUM,
82 SEX_FEMALE,
83 SEX_MALE,
84 SEX_OTHER_UNSPECIFIED,
85 SPREADSHEET_PATIENT_FIELD_PREFIX,
86)
87from camcops_server.cc_modules.cc_dataclasses import SummarySchemaInfo
88from camcops_server.cc_modules.cc_db import (
89 GenericTabletRecordMixin,
90 PFN_UUID,
91 TABLET_ID_FIELD,
92)
93from camcops_server.cc_modules.cc_fhir import (
94 fhir_pk_identifier,
95 make_fhir_bundle_entry,
96)
97from camcops_server.cc_modules.cc_hl7 import make_pid_segment
98from camcops_server.cc_modules.cc_html import answer
99from camcops_server.cc_modules.cc_idnumdef import IdNumDefinition
100from camcops_server.cc_modules.cc_simpleobjects import (
101 BarePatientInfo,
102 HL7PatientIdentifier,
103)
104from camcops_server.cc_modules.cc_patientidnum import (
105 extra_id_colname,
106 PatientIdNum,
107)
108from camcops_server.cc_modules.cc_proquint import proquint_from_uuid
109from camcops_server.cc_modules.cc_report import Report
110from camcops_server.cc_modules.cc_simpleobjects import (
111 IdNumReference,
112 TaskExportOptions,
113)
114from camcops_server.cc_modules.cc_specialnote import SpecialNote
115from camcops_server.cc_modules.cc_sqla_coltypes import (
116 CamcopsColumn,
117 EmailAddressColType,
118 PatientNameColType,
119 SexColType,
120 UuidColType,
121)
122from camcops_server.cc_modules.cc_sqlalchemy import Base
123from camcops_server.cc_modules.cc_spreadsheet import SpreadsheetPage
124from camcops_server.cc_modules.cc_version import CAMCOPS_SERVER_VERSION_STRING
125from camcops_server.cc_modules.cc_xml import (
126 XML_COMMENT_SPECIAL_NOTES,
127 XmlElement,
128)
130if TYPE_CHECKING:
131 from camcops_server.cc_modules.cc_exportrecipient import ExportRecipient
132 from camcops_server.cc_modules.cc_group import Group
133 from camcops_server.cc_modules.cc_policy import TokenizedPolicy
134 from camcops_server.cc_modules.cc_request import CamcopsRequest
135 from camcops_server.cc_modules.cc_taskschedule import PatientTaskSchedule
136 from camcops_server.cc_modules.cc_user import User
138log = BraceStyleAdapter(logging.getLogger(__name__))
141# =============================================================================
142# Patient class
143# =============================================================================
146class Patient(GenericTabletRecordMixin, Base):
147 """
148 Class representing a patient.
149 """
151 __tablename__ = "patient"
153 id = Column(
154 TABLET_ID_FIELD,
155 Integer,
156 nullable=False,
157 comment="Primary key (patient ID) on the source tablet device"
158 # client PK
159 )
160 uuid = CamcopsColumn(
161 PFN_UUID,
162 UuidColType,
163 comment="UUID",
164 default=uuid.uuid4, # generates a random UUID
165 ) # type: Optional[uuid.UUID]
166 forename = CamcopsColumn(
167 "forename",
168 PatientNameColType,
169 index=True,
170 identifies_patient=True,
171 include_in_anon_staging_db=True,
172 comment="Forename",
173 ) # type: Optional[str]
174 surname = CamcopsColumn(
175 "surname",
176 PatientNameColType,
177 index=True,
178 identifies_patient=True,
179 include_in_anon_staging_db=True,
180 comment="Surname",
181 ) # type: Optional[str]
182 dob = CamcopsColumn(
183 "dob",
184 sqltypes.Date, # verified: merge_db handles this correctly
185 index=True,
186 identifies_patient=True,
187 include_in_anon_staging_db=True,
188 comment="Date of birth"
189 # ... e.g. "2013-02-04"
190 )
191 sex = CamcopsColumn(
192 "sex",
193 SexColType,
194 index=True,
195 include_in_anon_staging_db=True,
196 comment="Sex (M, F, X)",
197 )
198 address = CamcopsColumn(
199 "address", UnicodeText, identifies_patient=True, comment="Address"
200 )
201 email = CamcopsColumn(
202 "email",
203 EmailAddressColType,
204 identifies_patient=True,
205 comment="Patient's e-mail address",
206 )
207 gp = CamcopsColumn(
208 "gp",
209 UnicodeText,
210 identifies_patient=True,
211 comment="General practitioner (GP)",
212 )
213 other = CamcopsColumn(
214 "other", UnicodeText, identifies_patient=True, comment="Other details"
215 )
216 idnums = relationship(
217 # https://docs.sqlalchemy.org/en/latest/orm/join_conditions.html#relationship-custom-foreign
218 # https://docs.sqlalchemy.org/en/latest/orm/relationship_api.html#sqlalchemy.orm.relationship # noqa
219 # https://docs.sqlalchemy.org/en/latest/orm/join_conditions.html#relationship-primaryjoin # noqa
220 "PatientIdNum",
221 primaryjoin=(
222 "and_("
223 " remote(PatientIdNum.patient_id) == foreign(Patient.id), "
224 " remote(PatientIdNum._device_id) == foreign(Patient._device_id), "
225 " remote(PatientIdNum._era) == foreign(Patient._era), "
226 " remote(PatientIdNum._current) == True "
227 ")"
228 ),
229 uselist=True,
230 viewonly=True,
231 # Profiling results 2019-10-14 exporting 4185 phq9 records with
232 # unique patients to xlsx (task-patient relationship "selectin")
233 # lazy="select" : 35.3s
234 # lazy="joined" : 27.3s
235 # lazy="subquery": 15.2s (31.0s when task-patient also subquery)
236 # lazy="selectin": 26.4s
237 # See also patient relationship on Task class (cc_task.py)
238 lazy="subquery",
239 ) # type: List[PatientIdNum]
241 task_schedules = relationship(
242 "PatientTaskSchedule", back_populates="patient", cascade="all, delete"
243 ) # type: List[PatientTaskSchedule]
245 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
246 # THE FOLLOWING ARE DEFUNCT, AND THE SERVER WORKS AROUND OLD TABLETS IN
247 # THE UPLOAD API.
248 #
249 # idnum1 = Column("idnum1", BigInteger, comment="ID number 1")
250 # idnum2 = Column("idnum2", BigInteger, comment="ID number 2")
251 # idnum3 = Column("idnum3", BigInteger, comment="ID number 3")
252 # idnum4 = Column("idnum4", BigInteger, comment="ID number 4")
253 # idnum5 = Column("idnum5", BigInteger, comment="ID number 5")
254 # idnum6 = Column("idnum6", BigInteger, comment="ID number 6")
255 # idnum7 = Column("idnum7", BigInteger, comment="ID number 7")
256 # idnum8 = Column("idnum8", BigInteger, comment="ID number 8")
257 #
258 # iddesc1 = Column("iddesc1", IdDescriptorColType, comment="ID description 1") # noqa
259 # iddesc2 = Column("iddesc2", IdDescriptorColType, comment="ID description 2") # noqa
260 # iddesc3 = Column("iddesc3", IdDescriptorColType, comment="ID description 3") # noqa
261 # iddesc4 = Column("iddesc4", IdDescriptorColType, comment="ID description 4") # noqa
262 # iddesc5 = Column("iddesc5", IdDescriptorColType, comment="ID description 5") # noqa
263 # iddesc6 = Column("iddesc6", IdDescriptorColType, comment="ID description 6") # noqa
264 # iddesc7 = Column("iddesc7", IdDescriptorColType, comment="ID description 7") # noqa
265 # iddesc8 = Column("iddesc8", IdDescriptorColType, comment="ID description 8") # noqa
266 #
267 # idshortdesc1 = Column("idshortdesc1", IdDescriptorColType, comment="ID short description 1") # noqa
268 # idshortdesc2 = Column("idshortdesc2", IdDescriptorColType, comment="ID short description 2") # noqa
269 # idshortdesc3 = Column("idshortdesc3", IdDescriptorColType, comment="ID short description 3") # noqa
270 # idshortdesc4 = Column("idshortdesc4", IdDescriptorColType, comment="ID short description 4") # noqa
271 # idshortdesc5 = Column("idshortdesc5", IdDescriptorColType, comment="ID short description 5") # noqa
272 # idshortdesc6 = Column("idshortdesc6", IdDescriptorColType, comment="ID short description 6") # noqa
273 # idshortdesc7 = Column("idshortdesc7", IdDescriptorColType, comment="ID short description 7") # noqa
274 # idshortdesc8 = Column("idshortdesc8", IdDescriptorColType, comment="ID short description 8") # noqa
275 #
276 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
278 # -------------------------------------------------------------------------
279 # Relationships
280 # -------------------------------------------------------------------------
282 # noinspection PyMethodParameters
283 @declared_attr
284 def special_notes(cls) -> RelationshipProperty:
285 """
286 Relationship to all :class:`SpecialNote` objects associated with this
287 patient.
288 """
289 # The SpecialNote also allows a link to patients, not just tasks,
290 # like this:
291 return relationship(
292 SpecialNote,
293 primaryjoin=(
294 "and_("
295 " remote(SpecialNote.basetable) == literal({repr_patient_tablename}), " # noqa
296 " remote(SpecialNote.task_id) == foreign(Patient.id), "
297 " remote(SpecialNote.device_id) == foreign(Patient._device_id), " # noqa
298 " remote(SpecialNote.era) == foreign(Patient._era), "
299 " not_(SpecialNote.hidden)"
300 ")".format(repr_patient_tablename=repr(cls.__tablename__))
301 ),
302 uselist=True,
303 order_by="SpecialNote.note_at",
304 viewonly=True, # for now!
305 )
307 # -------------------------------------------------------------------------
308 # Patient-fetching classmethods
309 # -------------------------------------------------------------------------
311 @classmethod
312 def get_patients_by_idnum(
313 cls,
314 dbsession: SqlASession,
315 which_idnum: int,
316 idnum_value: int,
317 group_id: int = None,
318 current_only: bool = True,
319 ) -> List["Patient"]:
320 """
321 Get all patients matching the specified ID number.
323 Args:
324 dbsession: a :class:`sqlalchemy.orm.session.Session`
325 which_idnum: which ID number type?
326 idnum_value: actual value of the ID number
327 group_id: optional group ID to restrict to
328 current_only: restrict to ``_current`` patients?
330 Returns:
331 list of all matching patients
333 """
334 if not which_idnum or which_idnum < 1:
335 return []
336 if idnum_value is None:
337 return []
338 q = dbsession.query(cls).join(cls.idnums)
339 # ... the join pre-restricts to current ID numbers
340 # https://docs.sqlalchemy.org/en/latest/orm/join_conditions.html#using-custom-operators-in-join-conditions # noqa
341 q = q.filter(PatientIdNum.which_idnum == which_idnum)
342 q = q.filter(PatientIdNum.idnum_value == idnum_value)
343 if group_id is not None:
344 q = q.filter(Patient._group_id == group_id)
345 if current_only:
346 q = q.filter(cls._current == True) # noqa: E712
347 patients = q.all() # type: List[Patient]
348 return patients
350 @classmethod
351 def get_patient_by_pk(
352 cls, dbsession: SqlASession, server_pk: int
353 ) -> Optional["Patient"]:
354 """
355 Fetch a patient by the server PK.
356 """
357 return dbsession.query(cls).filter(cls._pk == server_pk).first()
359 @classmethod
360 def get_patient_by_id_device_era(
361 cls, dbsession: SqlASession, client_id: int, device_id: int, era: str
362 ) -> Optional["Patient"]:
363 """
364 Fetch a patient by the client ID, device ID, and era.
365 """
366 return (
367 dbsession.query(cls)
368 .filter(cls.id == client_id)
369 .filter(cls._device_id == device_id)
370 .filter(cls._era == era)
371 .first()
372 )
374 # -------------------------------------------------------------------------
375 # String representations
376 # -------------------------------------------------------------------------
378 def __str__(self) -> str:
379 """
380 A plain string version, without the need for a request object.
382 Example:
384 .. code-block:: none
386 SMITH, BOB (M, 1 Jan 1950, idnum1=123, idnum2=456)
387 """
388 return "{sf} ({sex}, {dob}, {ids})".format(
389 sf=self.get_surname_forename_upper(),
390 sex=self.sex,
391 dob=self.get_dob_str(),
392 ids=", ".join(str(i) for i in self.get_idnum_objects()),
393 )
395 def prettystr(self, req: "CamcopsRequest") -> str:
396 """
397 A prettified string version.
399 Args:
400 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
402 Example:
404 .. code-block:: none
406 SMITH, BOB (M, 1 Jan 1950, RiO# 123, NHS# 456)
407 """
408 return "{sf} ({sex}, {dob}, {ids})".format(
409 sf=self.get_surname_forename_upper(),
410 sex=self.sex,
411 dob=self.get_dob_str(),
412 ids=", ".join(i.prettystr(req) for i in self.get_idnum_objects()),
413 )
415 def get_letter_style_identifiers(self, req: "CamcopsRequest") -> str:
416 """
417 Our best guess at the kind of text you'd put in a clinical letter to
418 say "it's about this patient".
420 Example:
422 .. code-block:: none
424 Bob Smith (1 Jan 1950, RiO number 123, NHS number 456)
425 """
426 return "{fs} ({dob}, {ids})".format(
427 fs=self.get_forename_surname(),
428 dob=self.get_dob_str(),
429 ids=", ".join(
430 i.full_prettystr(req) for i in self.get_idnum_objects()
431 ),
432 )
434 # -------------------------------------------------------------------------
435 # Equality
436 # -------------------------------------------------------------------------
438 def __eq__(self, other: "Patient") -> bool:
439 """
440 Is this patient the same as another?
442 .. code-block:: python
444 from camcops_server.cc_modules.cc_patient import Patient
445 p1 = Patient(id=1, _device_id=1, _era="NOW")
446 print(p1 == p1) # True
447 p2 = Patient(id=1, _device_id=1, _era="NOW")
448 print(p1 == p2) # True
449 p3 = Patient(id=1, _device_id=2, _era="NOW")
450 print(p1 == p3) # False
452 s = set([p1, p2, p3]) # contains two patients
454 IMPERFECT in that it doesn't use intermediate patients to link
455 identity (e.g. P1 has RiO#=3, P2 has RiO#=3, NHS#=5, P3 has NHS#=5;
456 they are all the same by inference but P1 and P3 will not compare
457 equal).
459 """
460 # Same object?
461 # log.debug("self={}, other={}", self, other)
462 if self is other:
463 # log.debug("... same object; equal")
464 return True
465 # Same device/era/patient ID (client PK)? Test int before str for speed
466 if (
467 self.id == other.id
468 and self._device_id == other._device_id
469 and self._era == other._era
470 and self.id is not None
471 and self._device_id is not None
472 and self._era is not None
473 ):
474 # log.debug("... same device/era/id; equal")
475 return True
476 # Shared ID number?
477 for sid in self.idnums:
478 if sid in other.idnums:
479 # log.debug("... share idnum {}; equal", sid)
480 return True
481 # Otherwise...
482 # log.debug("... unequal")
483 return False
485 def __hash__(self) -> int:
486 """
487 To put objects into a set, they must be hashable.
488 See https://docs.python.org/3/glossary.html#term-hashable.
489 If two objects are equal (via :func:`__eq__`) they must provide the
490 same hash value (but two objects with the same hash are not necessarily
491 equal).
492 """
493 return 0 # all objects have the same hash; "use __eq__() instead"
495 # -------------------------------------------------------------------------
496 # ID numbers
497 # -------------------------------------------------------------------------
499 def get_idnum_objects(self) -> List[PatientIdNum]:
500 """
501 Returns all :class:`PatientIdNum` objects for the patient.
503 These are SQLAlchemy ORM objects.
504 """
505 return self.idnums
507 def get_idnum_references(self) -> List[IdNumReference]:
508 """
509 Returns all
510 :class:`camcops_server.cc_modules.cc_simpleobjects.IdNumReference`
511 objects for the patient.
513 These are simple which_idnum/idnum_value pairs.
514 """
515 idnums = self.idnums # type: List[PatientIdNum]
516 return [
517 x.get_idnum_reference()
518 for x in idnums
519 if x.is_superficially_valid()
520 ]
522 def get_idnum_raw_values_only(self) -> List[int]:
523 """
524 Get all plain ID number values (ignoring which ID number type they
525 represent) for the patient.
526 """
527 idnums = self.idnums # type: List[PatientIdNum]
528 return [x.idnum_value for x in idnums if x.is_superficially_valid()]
530 def get_idnum_object(self, which_idnum: int) -> Optional[PatientIdNum]:
531 """
532 Gets the PatientIdNum object for a specified which_idnum, or None.
533 """
534 idnums = self.idnums # type: List[PatientIdNum]
535 for x in idnums:
536 if x.which_idnum == which_idnum:
537 return x
538 return None
540 def has_idnum_type(self, which_idnum: int) -> bool:
541 """
542 Does the patient have an ID number of the specified type?
543 """
544 return self.get_idnum_object(which_idnum) is not None
546 def get_idnum_value(self, which_idnum: int) -> Optional[int]:
547 """
548 Get value of a specific ID number, if present.
549 """
550 idobj = self.get_idnum_object(which_idnum)
551 return idobj.idnum_value if idobj else None
553 def set_idnum_value(
554 self, req: "CamcopsRequest", which_idnum: int, idnum_value: int
555 ) -> None:
556 """
557 Sets an ID number value.
558 """
559 dbsession = req.dbsession
560 ccsession = req.camcops_session
561 idnums = self.idnums # type: List[PatientIdNum]
562 for idobj in idnums:
563 if idobj.which_idnum == which_idnum:
564 idobj.idnum_value = idnum_value
565 return
566 # Otherwise, make a new one:
567 newid = PatientIdNum()
568 newid.patient_id = self.id
569 newid._device_id = self._device_id
570 newid._era = self._era
571 newid._current = True
572 newid._when_added_exact = req.now_era_format
573 newid._when_added_batch_utc = req.now_utc
574 newid._adding_user_id = ccsession.user_id
575 newid._camcops_version = CAMCOPS_SERVER_VERSION_STRING
576 dbsession.add(newid)
577 self.idnums.append(newid)
579 def get_iddesc(
580 self, req: "CamcopsRequest", which_idnum: int
581 ) -> Optional[str]:
582 """
583 Get value of a specific ID description, if present.
584 """
585 idobj = self.get_idnum_object(which_idnum)
586 return idobj.description(req) if idobj else None
588 def get_idshortdesc(
589 self, req: "CamcopsRequest", which_idnum: int
590 ) -> Optional[str]:
591 """
592 Get value of a specific ID short description, if present.
593 """
594 idobj = self.get_idnum_object(which_idnum)
595 return idobj.short_description(req) if idobj else None
597 def add_extra_idnum_info_to_row(self, row: Dict[str, Any]) -> None:
598 """
599 For the ``DB_PATIENT_ID_PER_ROW`` export option. Adds additional ID
600 number info to a row.
602 Args:
603 row: future database row, as a dictionary
604 """
605 for idobj in self.idnums:
606 which_idnum = idobj.which_idnum
607 fieldname = extra_id_colname(which_idnum)
608 row[fieldname] = idobj.idnum_value
610 # -------------------------------------------------------------------------
611 # Group
612 # -------------------------------------------------------------------------
614 @property
615 def group(self) -> Optional["Group"]:
616 """
617 Returns the :class:`camcops_server.cc_modules.cc_group.Group` to which
618 this patient's record belongs.
619 """
620 return self._group
622 # -------------------------------------------------------------------------
623 # Policies
624 # -------------------------------------------------------------------------
626 def satisfies_upload_id_policy(self) -> bool:
627 """
628 Does the patient satisfy the uploading ID policy?
629 """
630 group = self._group # type: Optional[Group]
631 if not group:
632 return False
633 return self.satisfies_id_policy(group.tokenized_upload_policy())
635 def satisfies_finalize_id_policy(self) -> bool:
636 """
637 Does the patient satisfy the finalizing ID policy?
638 """
639 group = self._group # type: Optional[Group]
640 if not group:
641 return False
642 return self.satisfies_id_policy(group.tokenized_finalize_policy())
644 def satisfies_id_policy(self, policy: "TokenizedPolicy") -> bool:
645 """
646 Does the patient satisfy a particular ID policy?
647 """
648 return policy.satisfies_id_policy(self.get_bare_ptinfo())
650 # -------------------------------------------------------------------------
651 # Name, DOB/age, sex, address, etc.
652 # -------------------------------------------------------------------------
654 def get_surname(self) -> str:
655 """
656 Get surname (in upper case) or "".
657 """
658 return self.surname.upper() if self.surname else ""
660 def get_forename(self) -> str:
661 """
662 Get forename (in upper case) or "".
663 """
664 return self.forename.upper() if self.forename else ""
666 def get_forename_surname(self) -> str:
667 """
668 Get "Forename Surname" as a string, using "(UNKNOWN)" for missing
669 details.
670 """
671 f = self.forename or "(UNKNOWN)"
672 s = self.surname or "(UNKNOWN)"
673 return f"{f} {s}"
675 def get_surname_forename_upper(self) -> str:
676 """
677 Get "SURNAME, FORENAME", using "(UNKNOWN)" for missing details.
678 """
679 s = self.surname.upper() if self.surname else "(UNKNOWN)"
680 f = self.forename.upper() if self.forename else "(UNKNOWN)"
681 return f"{s}, {f}"
683 def get_dob_html(self, req: "CamcopsRequest", longform: bool) -> str:
684 """
685 HTML fragment for date of birth.
686 """
687 _ = req.gettext
688 if longform:
689 dob = answer(
690 format_datetime(self.dob, DateFormat.LONG_DATE, default=None)
691 )
693 dobtext = _("Date of birth:")
694 return f"<br>{dobtext} {dob}"
695 else:
696 dobtext = _("DOB:")
697 dob = format_datetime(self.dob, DateFormat.SHORT_DATE)
698 return f"{dobtext} {dob}."
700 def get_age(
701 self, req: "CamcopsRequest", default: str = ""
702 ) -> Union[int, str]:
703 """
704 Age (in whole years) today, or default.
705 """
706 now = req.now
707 return self.get_age_at(now, default=default)
709 def get_dob(self) -> Optional[pendulum.Date]:
710 """
711 Date of birth, as a a timezone-naive date.
712 """
713 dob = self.dob
714 if not dob:
715 return None
716 return coerce_to_pendulum_date(dob)
718 def get_dob_str(self) -> Optional[str]:
719 """
720 Date of birth, as a string.
721 """
722 dob_dt = self.get_dob()
723 if dob_dt is None:
724 return None
725 return format_datetime(dob_dt, DateFormat.SHORT_DATE)
727 def get_age_at(
728 self, when: PotentialDatetimeType, default: str = ""
729 ) -> Union[int, str]:
730 """
731 Age (in whole years) at a particular date, or default.
732 """
733 return get_age(self.dob, when, default=default)
735 def is_female(self) -> bool:
736 """
737 Is sex 'F'?
738 """
739 return self.sex == SEX_FEMALE
741 def is_male(self) -> bool:
742 """
743 Is sex 'M'?
744 """
745 return self.sex == SEX_MALE
747 def get_sex(self) -> str:
748 """
749 Return sex or "".
750 """
751 return self.sex or ""
753 def get_sex_verbose(self, default: str = "sex unknown") -> str:
754 """
755 Returns HTML-safe version of sex, or default.
756 """
757 return default if not self.sex else ws.webify(self.sex)
759 def get_address(self) -> Optional[str]:
760 """
761 Returns address (NOT necessarily web-safe).
762 """
763 address = self.address # type: Optional[str]
764 return address or ""
766 def get_email(self) -> Optional[str]:
767 """
768 Returns email address
769 """
770 email = self.email # type: Optional[str]
771 return email or ""
773 # -------------------------------------------------------------------------
774 # Other representations
775 # -------------------------------------------------------------------------
777 def get_xml_root(
778 self, req: "CamcopsRequest", options: TaskExportOptions = None
779 ) -> XmlElement:
780 """
781 Get root of XML tree, as an
782 :class:`camcops_server.cc_modules.cc_xml.XmlElement`.
784 Args:
785 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
786 options: a :class:`camcops_server.cc_modules.cc_simpleobjects.TaskExportOptions`
787 """ # noqa
788 # No point in skipping old ID columns (1-8) now; they're gone.
789 branches = self._get_xml_branches(req, options=options)
790 # Now add new-style IDs:
791 pidnum_branches = [] # type: List[XmlElement]
792 pidnum_options = TaskExportOptions(
793 xml_include_plain_columns=True, xml_with_header_comments=False
794 )
795 for pidnum in self.idnums: # type: PatientIdNum
796 pidnum_branches.append(
797 pidnum._get_xml_root(req, options=pidnum_options)
798 )
799 branches.append(XmlElement(name="idnums", value=pidnum_branches))
800 # Special notes
801 branches.append(XML_COMMENT_SPECIAL_NOTES)
802 special_notes = self.special_notes # type: List[SpecialNote]
803 for sn in special_notes:
804 branches.append(sn.get_xml_root())
805 return XmlElement(name=self.__tablename__, value=branches)
807 def get_spreadsheet_page(self, req: "CamcopsRequest") -> SpreadsheetPage:
808 """
809 Get a :class:`camcops_server.cc_modules.cc_spreadsheet.SpreadsheetPage`
810 for the patient.
811 """
812 # 1. Our core fields.
813 page = self._get_core_spreadsheet_page(
814 req, heading_prefix=SPREADSHEET_PATIENT_FIELD_PREFIX
815 )
816 # 2. ID number details
817 # We can't just iterate through the ID numbers; we have to iterate
818 # through all possible ID numbers.
819 for iddef in req.idnum_definitions:
820 n = iddef.which_idnum
821 nstr = str(n)
822 shortdesc = iddef.short_description
823 longdesc = iddef.description
824 idnum_value = next(
825 (
826 idnum.idnum_value
827 for idnum in self.idnums
828 if idnum.which_idnum == n
829 and idnum.is_superficially_valid()
830 ),
831 None,
832 )
833 page.add_or_set_value(
834 heading=SPREADSHEET_PATIENT_FIELD_PREFIX + FP_ID_NUM + nstr,
835 value=idnum_value,
836 )
837 page.add_or_set_value(
838 heading=SPREADSHEET_PATIENT_FIELD_PREFIX + FP_ID_DESC + nstr,
839 value=longdesc,
840 )
841 page.add_or_set_value(
842 heading=(
843 SPREADSHEET_PATIENT_FIELD_PREFIX + FP_ID_SHORT_DESC + nstr
844 ),
845 value=shortdesc,
846 )
847 return page
849 def get_spreadsheet_schema_elements(
850 self, req: "CamcopsRequest", table_name: str = ""
851 ) -> Set[SummarySchemaInfo]:
852 """
853 Follows :func:`get_spreadsheet_page`, but retrieving schema
854 information.
855 """
856 # 1. Core fields
857 items = self._get_core_spreadsheet_schema(
858 table_name=table_name,
859 column_name_prefix=SPREADSHEET_PATIENT_FIELD_PREFIX,
860 )
861 # 2. ID number details
862 table_name = table_name or self.__tablename__
863 for iddef in req.idnum_definitions:
864 n = iddef.which_idnum
865 nstr = str(n)
866 comment_suffix = f" [ID#{n}]"
867 items.add(
868 SummarySchemaInfo(
869 table_name=table_name,
870 source=SummarySchemaInfo.SSV_DB,
871 column_name=(
872 SPREADSHEET_PATIENT_FIELD_PREFIX + FP_ID_NUM + nstr
873 ),
874 data_type=str(PatientIdNum.idnum_value.type),
875 comment=PatientIdNum.idnum_value.comment + comment_suffix,
876 )
877 )
878 items.add(
879 SummarySchemaInfo(
880 table_name=table_name,
881 source=SummarySchemaInfo.SSV_DB,
882 column_name=(
883 SPREADSHEET_PATIENT_FIELD_PREFIX + FP_ID_DESC + nstr
884 ),
885 data_type=str(IdNumDefinition.description.type),
886 comment=IdNumDefinition.description.comment
887 + comment_suffix,
888 )
889 )
890 items.add(
891 SummarySchemaInfo(
892 table_name=table_name,
893 source=SummarySchemaInfo.SSV_DB,
894 column_name=(
895 SPREADSHEET_PATIENT_FIELD_PREFIX
896 + FP_ID_SHORT_DESC
897 + nstr
898 ),
899 data_type=str(IdNumDefinition.short_description.type),
900 comment=(
901 IdNumDefinition.short_description.comment
902 + comment_suffix
903 ),
904 )
905 )
906 return items
908 def get_bare_ptinfo(self) -> BarePatientInfo:
909 """
910 Get basic identifying information, as a
911 :class:`camcops_server.cc_modules.cc_simpleobjects.BarePatientInfo`
912 object.
913 """
914 return BarePatientInfo(
915 forename=self.forename,
916 surname=self.surname,
917 sex=self.sex,
918 dob=self.dob,
919 address=self.address,
920 email=self.email,
921 gp=self.gp,
922 otherdetails=self.other,
923 idnum_definitions=self.get_idnum_references(),
924 )
926 def get_hl7_pid_segment(
927 self, req: "CamcopsRequest", recipient: "ExportRecipient"
928 ) -> hl7.Segment:
929 """
930 Get HL7 patient identifier (PID) segment.
932 Args:
933 req:
934 a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
935 recipient:
936 a :class:`camcops_server.cc_modules.cc_exportrecipient.ExportRecipient`
938 Returns:
939 a :class:`hl7.Segment` object
940 """ # noqa
941 # Put the primary one first:
942 patient_id_tuple_list = [
943 HL7PatientIdentifier(
944 pid=str(self.get_idnum_value(recipient.primary_idnum)),
945 id_type=recipient.get_hl7_id_type(
946 req, recipient.primary_idnum
947 ),
948 assigning_authority=recipient.get_hl7_id_aa(
949 req, recipient.primary_idnum
950 ),
951 )
952 ]
953 # Then the rest:
954 for idobj in self.idnums:
955 which_idnum = idobj.which_idnum
956 if which_idnum == recipient.primary_idnum:
957 continue
958 idnum_value = idobj.idnum_value
959 if idnum_value is None:
960 continue
961 patient_id_tuple_list.append(
962 HL7PatientIdentifier(
963 pid=str(idnum_value),
964 id_type=recipient.get_hl7_id_type(req, which_idnum),
965 assigning_authority=recipient.get_hl7_id_aa(
966 req, which_idnum
967 ),
968 )
969 )
970 return make_pid_segment(
971 forename=self.get_surname(),
972 surname=self.get_forename(),
973 dob=self.get_dob(),
974 sex=self.get_sex(),
975 address=self.get_address(),
976 patient_id_list=patient_id_tuple_list,
977 )
979 # -------------------------------------------------------------------------
980 # FHIR
981 # -------------------------------------------------------------------------
983 def get_fhir_bundle_entry(
984 self, req: "CamcopsRequest", recipient: "ExportRecipient"
985 ) -> Dict[str, Any]:
986 """
987 Returns a dictionary, suitable for serializing to JSON, that
988 encapsulates patient identity information in a FHIR bundle.
990 See https://www.hl7.org/fhir/patient.html.
991 """
992 # The JSON objects we will build up:
993 patient_dict = {} # type: JsonObjectType
995 # Name
996 if self.forename or self.surname:
997 name_dict = {} # type: JsonObjectType
998 if self.forename:
999 name_dict[Fc.NAME_GIVEN] = [self.forename]
1000 if self.surname:
1001 name_dict[Fc.NAME_FAMILY] = self.surname
1002 patient_dict[Fc.NAME] = [HumanName(jsondict=name_dict).as_json()]
1004 # DOB
1005 if self.dob:
1006 patient_dict[Fc.BIRTHDATE] = format_datetime(
1007 self.dob, DateFormat.FILENAME_DATE_ONLY
1008 )
1010 # Sex/gender (should always be present, per client minimum ID policy)
1011 if self.sex:
1012 gender_lookup = {
1013 SEX_FEMALE: Fc.GENDER_FEMALE,
1014 SEX_MALE: Fc.GENDER_MALE,
1015 SEX_OTHER_UNSPECIFIED: Fc.GENDER_OTHER,
1016 }
1017 patient_dict[Fc.GENDER] = gender_lookup.get(
1018 self.sex, Fc.GENDER_UNKNOWN
1019 )
1021 # Address
1022 if self.address:
1023 patient_dict[Fc.ADDRESS] = [
1024 Address(jsondict={Fc.ADDRESS_TEXT: self.address}).as_json()
1025 ]
1027 # Email
1028 if self.email:
1029 patient_dict[Fc.TELECOM] = [
1030 ContactPoint(
1031 jsondict={
1032 Fc.SYSTEM: Fc.TELECOM_SYSTEM_EMAIL,
1033 Fc.VALUE: self.email,
1034 }
1035 ).as_json()
1036 ]
1038 # General practitioner (GP): via
1039 # fhirclient.models.fhirreference.FHIRReference; too structured.
1041 # ID numbers go here:
1042 return make_fhir_bundle_entry(
1043 resource_type_url=Fc.RESOURCE_TYPE_PATIENT,
1044 identifier=self.get_fhir_identifier(req, recipient),
1045 resource=FhirPatient(jsondict=patient_dict).as_json(),
1046 )
1048 def get_fhir_identifier(
1049 self, req: "CamcopsRequest", recipient: "ExportRecipient"
1050 ) -> Identifier:
1051 """
1052 Returns a FHIR identifier for this patient, as a
1053 :class:`fhirclient.models.identifier.Identifier` object.
1055 This pairs a URL to our CamCOPS server indicating the ID number type
1056 (as the "system") with the actual ID number (as the "value").
1058 For debugging situations, it falls back to a default identifier (using
1059 the PK on our CamCOPS server).
1060 """
1061 which_idnum = recipient.primary_idnum
1062 try:
1063 # For real exports, the fact that the patient does have an ID
1064 # number of the right type will have been pre-verified.
1065 if which_idnum is None:
1066 raise AttributeError
1067 idnum_object = self.get_idnum_object(which_idnum)
1068 idnum_value = idnum_object.idnum_value # may raise AttributeError
1069 iddef = req.get_idnum_definition(which_idnum)
1070 idnum_url = iddef.effective_fhir_id_system(req)
1071 return Identifier(
1072 jsondict={Fc.SYSTEM: idnum_url, Fc.VALUE: str(idnum_value)}
1073 )
1074 except AttributeError:
1075 # We are probably in a debugging/drafting situation. Fall back to
1076 # a default identifier.
1077 return fhir_pk_identifier(
1078 req,
1079 self.__tablename__,
1080 self.pk,
1081 Fc.CAMCOPS_VALUE_PATIENT_WITHIN_TASK,
1082 )
1084 def get_fhir_subject_ref(
1085 self, req: "CamcopsRequest", recipient: "ExportRecipient"
1086 ) -> Dict:
1087 """
1088 Returns a FHIRReference (in JSON dict format) used to refer to this
1089 patient as a "subject" of some other entry (like a questionnaire).
1090 """
1091 return FHIRReference(
1092 jsondict={
1093 Fc.TYPE: Fc.RESOURCE_TYPE_PATIENT,
1094 Fc.IDENTIFIER: self.get_fhir_identifier(
1095 req, recipient
1096 ).as_json(),
1097 }
1098 ).as_json()
1100 # -------------------------------------------------------------------------
1101 # Database status
1102 # -------------------------------------------------------------------------
1104 def is_preserved(self) -> bool:
1105 """
1106 Is the patient record preserved and erased from the tablet?
1107 """
1108 return self._pk is not None and self._era != ERA_NOW
1110 # -------------------------------------------------------------------------
1111 # Audit
1112 # -------------------------------------------------------------------------
1114 def audit(
1115 self, req: "CamcopsRequest", details: str, from_console: bool = False
1116 ) -> None:
1117 """
1118 Audits an action to this patient.
1119 """
1120 audit(
1121 req,
1122 details,
1123 patient_server_pk=self._pk,
1124 table=Patient.__tablename__,
1125 server_pk=self._pk,
1126 from_console=from_console,
1127 )
1129 # -------------------------------------------------------------------------
1130 # Special notes
1131 # -------------------------------------------------------------------------
1133 def apply_special_note(
1134 self,
1135 req: "CamcopsRequest",
1136 note: str,
1137 audit_msg: str = "Special note applied manually",
1138 ) -> None:
1139 """
1140 Manually applies a special note to a patient.
1141 WRITES TO DATABASE.
1142 """
1143 sn = SpecialNote()
1144 sn.basetable = self.__tablename__
1145 sn.task_id = self.id # patient ID, in this case
1146 sn.device_id = self._device_id
1147 sn.era = self._era
1148 sn.note_at = req.now
1149 sn.user_id = req.user_id
1150 sn.note = note
1151 req.dbsession.add(sn)
1152 self.special_notes.append(sn)
1153 self.audit(req, audit_msg)
1154 # HL7 deletion of corresponding tasks is done in camcops_server.py
1156 # -------------------------------------------------------------------------
1157 # Deletion
1158 # -------------------------------------------------------------------------
1160 def gen_patient_idnums_even_noncurrent(
1161 self,
1162 ) -> Generator[PatientIdNum, None, None]:
1163 """
1164 Generates all :class:`PatientIdNum` objects, including non-current
1165 ones.
1166 """
1167 for lineage_member in self._gen_unique_lineage_objects(
1168 self.idnums
1169 ): # type: PatientIdNum # noqa
1170 yield lineage_member
1172 def delete_with_dependants(self, req: "CamcopsRequest") -> None:
1173 """
1174 Delete the patient with all its dependent objects.
1175 """
1176 if self._pk is None:
1177 return
1178 for pidnum in self.gen_patient_idnums_even_noncurrent():
1179 req.dbsession.delete(pidnum)
1180 super().delete_with_dependants(req)
1182 # -------------------------------------------------------------------------
1183 # Permissions
1184 # -------------------------------------------------------------------------
1186 def user_may_view(self, user: "User") -> bool:
1187 """
1188 May this user inspect patient details directly?
1189 """
1190 return self._group_id in user.ids_of_groups_user_may_see
1192 def user_may_edit(self, req: "CamcopsRequest") -> bool:
1193 """
1194 Does the current user have permission to edit this patient?
1195 """
1196 if self.created_on_server(req):
1197 # Anyone in the group with the right permission
1198 return req.user.may_manage_patients_in_group(self._group_id)
1200 # Finalized patient: Need to be group administrator
1201 return req.user.may_administer_group(self._group_id)
1203 # --------------------------------------------------------------------------
1204 # UUID
1205 # --------------------------------------------------------------------------
1206 @property
1207 def uuid_as_proquint(self) -> Optional[str]:
1208 # Convert integer into pronounceable quintuplets (proquint)
1209 # https://arxiv.org/html/0901.4016
1210 if self.uuid is None:
1211 return None
1213 return proquint_from_uuid(self.uuid)
1216# =============================================================================
1217# Validate candidate patient info for upload
1218# =============================================================================
1221def is_candidate_patient_valid_for_group(
1222 ptinfo: BarePatientInfo, group: "Group", finalizing: bool
1223) -> Tuple[bool, str]:
1224 """
1225 Is the specified patient acceptable to upload into this group?
1227 Checks:
1229 - group upload or finalize policy
1231 .. todo:: is_candidate_patient_valid: check against predefined patients, if
1232 the group wants
1234 Args:
1235 ptinfo:
1236 a
1237 :class:`camcops_server.cc_modules.cc_simpleobjects.BarePatientInfo`
1238 representing the patient info to check
1239 group:
1240 the :class:`camcops_server.cc_modules.cc_group.Group` into which
1241 this patient will be uploaded, if allowed
1242 finalizing:
1243 finalizing, rather than uploading?
1245 Returns:
1246 tuple: valid, reason
1248 """
1249 if not group:
1250 return False, "Nonexistent group"
1252 if finalizing:
1253 if not group.tokenized_finalize_policy().satisfies_id_policy(ptinfo):
1254 return False, "Fails finalizing ID policy"
1255 else:
1256 if not group.tokenized_upload_policy().satisfies_id_policy(ptinfo):
1257 return False, "Fails upload ID policy"
1259 # todo: add checks against prevalidated patients here
1261 return True, ""
1264def is_candidate_patient_valid_for_restricted_user(
1265 req: "CamcopsRequest", ptinfo: BarePatientInfo
1266) -> Tuple[bool, str]:
1267 """
1268 Is the specified patient OK to be uploaded by this user? Performs a check
1269 for restricted (single-patient) users; if true, ensures that the
1270 identifiers all match the expected patient.
1272 Args:
1273 req:
1274 the :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
1275 ptinfo:
1276 a
1277 :class:`camcops_server.cc_modules.cc_simpleobjects.BarePatientInfo`
1278 representing the patient info to check
1280 Returns:
1281 tuple: valid, reason
1282 """
1283 user = req.user
1284 if not user.auto_generated:
1285 # Not a restricted user; no problem.
1286 return True, ""
1288 server_patient = user.single_patient
1289 if not server_patient:
1290 return (
1291 False,
1292 (
1293 f"Restricted user {user.username} does not have associated "
1294 f"patient details"
1295 ),
1296 )
1298 server_ptinfo = server_patient.get_bare_ptinfo()
1299 if ptinfo != server_ptinfo:
1300 return False, f"Should be {server_ptinfo}"
1302 return True, ""
1305# =============================================================================
1306# Reports
1307# =============================================================================
1310class DistinctPatientReport(Report):
1311 """
1312 Report to show distinct patients.
1313 """
1315 # noinspection PyMethodParameters
1316 @classproperty
1317 def report_id(cls) -> str:
1318 return "patient_distinct"
1320 @classmethod
1321 def title(cls, req: "CamcopsRequest") -> str:
1322 _ = req.gettext
1323 return _(
1324 "(Server) Patients, distinct by name, sex, DOB, all ID " "numbers"
1325 )
1327 # noinspection PyMethodParameters
1328 @classproperty
1329 def superuser_only(cls) -> bool:
1330 return False
1332 # noinspection PyProtectedMember
1333 def get_query(self, req: "CamcopsRequest") -> SelectBase:
1334 select_fields = [
1335 Patient.surname.label("surname"),
1336 Patient.forename.label("forename"),
1337 Patient.dob.label("dob"),
1338 Patient.sex.label("sex"),
1339 ]
1340 # noinspection PyUnresolvedReferences
1341 select_from = Patient.__table__
1342 wheres = [
1343 Patient._current == True # noqa: E712
1344 ] # type: List[ClauseElement]
1345 if not req.user.superuser:
1346 # Restrict to accessible groups
1347 group_ids = req.user.ids_of_groups_user_may_report_on
1348 wheres.append(Patient._group_id.in_(group_ids))
1349 for iddef in req.idnum_definitions:
1350 n = iddef.which_idnum
1351 desc = iddef.short_description
1352 # noinspection PyUnresolvedReferences
1353 aliased_table = PatientIdNum.__table__.alias(f"i{n}")
1354 select_fields.append(aliased_table.c.idnum_value.label(desc))
1355 select_from = select_from.outerjoin(
1356 aliased_table,
1357 and_(
1358 aliased_table.c.patient_id == Patient.id,
1359 aliased_table.c._device_id == Patient._device_id,
1360 aliased_table.c._era == Patient._era,
1361 # Note: the following are part of the JOIN, not the WHERE:
1362 # (or failure to match a row will wipe out the Patient from
1363 # the OUTER JOIN):
1364 aliased_table.c._current == True, # noqa: E712
1365 aliased_table.c.which_idnum == n,
1366 ),
1367 ) # nopep8
1368 order_by = [
1369 Patient.surname,
1370 Patient.forename,
1371 Patient.dob,
1372 Patient.sex,
1373 ]
1374 query = (
1375 select(select_fields)
1376 .select_from(select_from)
1377 .where(and_(*wheres))
1378 .order_by(*order_by)
1379 .distinct()
1380 )
1381 return query