Coverage for cc_modules/cc_patient.py : 40%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1#!/usr/bin/env python
3"""
4camcops_server/cc_modules/cc_patient.py
6===============================================================================
8 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com).
10 This file is part of CamCOPS.
12 CamCOPS is free software: you can redistribute it and/or modify
13 it under the terms of the GNU General Public License as published by
14 the Free Software Foundation, either version 3 of the License, or
15 (at your option) any later version.
17 CamCOPS is distributed in the hope that it will be useful,
18 but WITHOUT ANY WARRANTY; without even the implied warranty of
19 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20 GNU General Public License for more details.
22 You should have received a copy of the GNU General Public License
23 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.
25===============================================================================
27**Patients.**
29"""
31import logging
32from typing import (
33 Any, Dict, Generator, List, Optional, Tuple, TYPE_CHECKING, Union,
34)
35import uuid
37from cardinal_pythonlib.classes import classproperty
38from cardinal_pythonlib.datetimefunc import (
39 coerce_to_pendulum_date,
40 format_datetime,
41 get_age,
42 PotentialDatetimeType,
43)
44from cardinal_pythonlib.logs import BraceStyleAdapter
45import cardinal_pythonlib.rnc_web as ws
46from fhirclient.models.bundle import BundleEntry, BundleEntryRequest
47from fhirclient.models.humanname import HumanName
48from fhirclient.models.identifier import Identifier
49from fhirclient.models.patient import Patient as FhirPatient
51import hl7
52import pendulum
53from sqlalchemy.ext.declarative import declared_attr
54from sqlalchemy.orm import relationship
55from sqlalchemy.orm import Session as SqlASession
56from sqlalchemy.orm.relationships import RelationshipProperty
57from sqlalchemy.sql.expression import and_, ClauseElement, select
58from sqlalchemy.sql.schema import Column
59from sqlalchemy.sql.selectable import SelectBase
60from sqlalchemy.sql import sqltypes
61from sqlalchemy.sql.sqltypes import Integer, UnicodeText
63from camcops_server.cc_modules.cc_audit import audit
64from camcops_server.cc_modules.cc_constants import (
65 DateFormat,
66 ERA_NOW,
67 FP_ID_DESC,
68 FP_ID_SHORT_DESC,
69 FP_ID_NUM,
70 SEX_FEMALE,
71 SEX_MALE,
72 TSV_PATIENT_FIELD_PREFIX,
73)
74from camcops_server.cc_modules.cc_db import GenericTabletRecordMixin
75from camcops_server.cc_modules.cc_device import Device
76from camcops_server.cc_modules.cc_hl7 import make_pid_segment
77from camcops_server.cc_modules.cc_html import answer
78from camcops_server.cc_modules.cc_pyramid import Routes
79from camcops_server.cc_modules.cc_simpleobjects import (
80 BarePatientInfo,
81 HL7PatientIdentifier,
82)
83from camcops_server.cc_modules.cc_patientidnum import (
84 extra_id_colname,
85 PatientIdNum,
86)
87from camcops_server.cc_modules.cc_proquint import proquint_from_uuid
88from camcops_server.cc_modules.cc_report import Report
89from camcops_server.cc_modules.cc_simpleobjects import (
90 IdNumReference,
91 TaskExportOptions,
92)
93from camcops_server.cc_modules.cc_specialnote import SpecialNote
94from camcops_server.cc_modules.cc_sqla_coltypes import (
95 CamcopsColumn,
96 EmailAddressColType,
97 PatientNameColType,
98 SexColType,
99 UuidColType,
100)
101from camcops_server.cc_modules.cc_sqlalchemy import Base
102from camcops_server.cc_modules.cc_tsv import TsvPage
103from camcops_server.cc_modules.cc_version import CAMCOPS_SERVER_VERSION_STRING
104from camcops_server.cc_modules.cc_xml import (
105 XML_COMMENT_SPECIAL_NOTES,
106 XmlElement,
107)
109if TYPE_CHECKING:
110 from camcops_server.cc_modules.cc_exportrecipient import ExportRecipient
111 from camcops_server.cc_modules.cc_group import Group
112 from camcops_server.cc_modules.cc_policy import TokenizedPolicy
113 from camcops_server.cc_modules.cc_request import CamcopsRequest
114 from camcops_server.cc_modules.cc_taskschedule import PatientTaskSchedule
116log = BraceStyleAdapter(logging.getLogger(__name__))
119# =============================================================================
120# Patient class
121# =============================================================================
123class Patient(GenericTabletRecordMixin, Base):
124 """
125 Class representing a patient.
126 """
127 __tablename__ = "patient"
129 id = Column(
130 "id", Integer,
131 nullable=False,
132 comment="Primary key (patient ID) on the source tablet device"
133 # client PK
134 )
135 uuid = CamcopsColumn(
136 "uuid", UuidColType,
137 comment="UUID",
138 default=uuid.uuid4 # generates a random UUID
139 ) # type: Optional[uuid.UUID]
140 forename = CamcopsColumn(
141 "forename", PatientNameColType,
142 index=True,
143 identifies_patient=True, include_in_anon_staging_db=True,
144 comment="Forename"
145 ) # type: Optional[str]
146 surname = CamcopsColumn(
147 "surname", PatientNameColType,
148 index=True,
149 identifies_patient=True, include_in_anon_staging_db=True,
150 comment="Surname"
151 ) # type: Optional[str]
152 dob = CamcopsColumn(
153 "dob", sqltypes.Date, # verified: merge_db handles this correctly
154 index=True,
155 identifies_patient=True, include_in_anon_staging_db=True,
156 comment="Date of birth"
157 # ... e.g. "2013-02-04"
158 )
159 sex = CamcopsColumn(
160 "sex", SexColType,
161 index=True,
162 include_in_anon_staging_db=True,
163 comment="Sex (M, F, X)"
164 )
165 address = CamcopsColumn(
166 "address", UnicodeText,
167 identifies_patient=True,
168 comment="Address"
169 )
170 email = CamcopsColumn(
171 "email", EmailAddressColType,
172 identifies_patient=True,
173 comment="Patient's e-mail address"
174 )
175 gp = CamcopsColumn(
176 "gp", UnicodeText,
177 identifies_patient=True,
178 comment="General practitioner (GP)"
179 )
180 other = CamcopsColumn(
181 "other", UnicodeText,
182 identifies_patient=True,
183 comment="Other details"
184 )
185 idnums = relationship(
186 # http://docs.sqlalchemy.org/en/latest/orm/join_conditions.html#relationship-custom-foreign
187 # http://docs.sqlalchemy.org/en/latest/orm/relationship_api.html#sqlalchemy.orm.relationship # noqa
188 # http://docs.sqlalchemy.org/en/latest/orm/join_conditions.html#relationship-primaryjoin # noqa
189 "PatientIdNum",
190 primaryjoin=(
191 "and_("
192 " remote(PatientIdNum.patient_id) == foreign(Patient.id), "
193 " remote(PatientIdNum._device_id) == foreign(Patient._device_id), "
194 " remote(PatientIdNum._era) == foreign(Patient._era), "
195 " remote(PatientIdNum._current) == True "
196 ")"
197 ),
198 uselist=True,
199 viewonly=True,
200 # Profiling results 2019-10-14 exporting 4185 phq9 records with
201 # unique patients to xlsx (task-patient relationship "selectin")
202 # lazy="select" : 35.3s
203 # lazy="joined" : 27.3s
204 # lazy="subquery": 15.2s (31.0s when task-patient also subquery)
205 # lazy="selectin": 26.4s
206 # See also patient relationship on Task class (cc_task.py)
207 lazy="subquery"
208 ) # type: List[PatientIdNum]
210 task_schedules = relationship(
211 "PatientTaskSchedule",
212 back_populates="patient",
213 cascade="all, delete"
214 ) # type: List[PatientTaskSchedule]
216 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
217 # THE FOLLOWING ARE DEFUNCT, AND THE SERVER WORKS AROUND OLD TABLETS IN
218 # THE UPLOAD API.
219 #
220 # idnum1 = Column("idnum1", BigInteger, comment="ID number 1")
221 # idnum2 = Column("idnum2", BigInteger, comment="ID number 2")
222 # idnum3 = Column("idnum3", BigInteger, comment="ID number 3")
223 # idnum4 = Column("idnum4", BigInteger, comment="ID number 4")
224 # idnum5 = Column("idnum5", BigInteger, comment="ID number 5")
225 # idnum6 = Column("idnum6", BigInteger, comment="ID number 6")
226 # idnum7 = Column("idnum7", BigInteger, comment="ID number 7")
227 # idnum8 = Column("idnum8", BigInteger, comment="ID number 8")
228 #
229 # iddesc1 = Column("iddesc1", IdDescriptorColType, comment="ID description 1") # noqa
230 # iddesc2 = Column("iddesc2", IdDescriptorColType, comment="ID description 2") # noqa
231 # iddesc3 = Column("iddesc3", IdDescriptorColType, comment="ID description 3") # noqa
232 # iddesc4 = Column("iddesc4", IdDescriptorColType, comment="ID description 4") # noqa
233 # iddesc5 = Column("iddesc5", IdDescriptorColType, comment="ID description 5") # noqa
234 # iddesc6 = Column("iddesc6", IdDescriptorColType, comment="ID description 6") # noqa
235 # iddesc7 = Column("iddesc7", IdDescriptorColType, comment="ID description 7") # noqa
236 # iddesc8 = Column("iddesc8", IdDescriptorColType, comment="ID description 8") # noqa
237 #
238 # idshortdesc1 = Column("idshortdesc1", IdDescriptorColType, comment="ID short description 1") # noqa
239 # idshortdesc2 = Column("idshortdesc2", IdDescriptorColType, comment="ID short description 2") # noqa
240 # idshortdesc3 = Column("idshortdesc3", IdDescriptorColType, comment="ID short description 3") # noqa
241 # idshortdesc4 = Column("idshortdesc4", IdDescriptorColType, comment="ID short description 4") # noqa
242 # idshortdesc5 = Column("idshortdesc5", IdDescriptorColType, comment="ID short description 5") # noqa
243 # idshortdesc6 = Column("idshortdesc6", IdDescriptorColType, comment="ID short description 6") # noqa
244 # idshortdesc7 = Column("idshortdesc7", IdDescriptorColType, comment="ID short description 7") # noqa
245 # idshortdesc8 = Column("idshortdesc8", IdDescriptorColType, comment="ID short description 8") # noqa
246 #
247 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
249 # -------------------------------------------------------------------------
250 # Relationships
251 # -------------------------------------------------------------------------
253 # noinspection PyMethodParameters
254 @declared_attr
255 def special_notes(cls) -> RelationshipProperty:
256 """
257 Relationship to all :class:`SpecialNote` objects associated with this
258 patient.
259 """
260 # The SpecialNote also allows a link to patients, not just tasks,
261 # like this:
262 return relationship(
263 SpecialNote,
264 primaryjoin=(
265 "and_("
266 " remote(SpecialNote.basetable) == literal({repr_patient_tablename}), " # noqa
267 " remote(SpecialNote.task_id) == foreign(Patient.id), "
268 " remote(SpecialNote.device_id) == foreign(Patient._device_id), " # noqa
269 " remote(SpecialNote.era) == foreign(Patient._era), "
270 " not_(SpecialNote.hidden)"
271 ")".format(
272 repr_patient_tablename=repr(cls.__tablename__),
273 )
274 ),
275 uselist=True,
276 order_by="SpecialNote.note_at",
277 viewonly=True, # for now!
278 )
280 # -------------------------------------------------------------------------
281 # Patient-fetching classmethods
282 # -------------------------------------------------------------------------
284 @classmethod
285 def get_patients_by_idnum(cls,
286 dbsession: SqlASession,
287 which_idnum: int,
288 idnum_value: int,
289 group_id: int = None,
290 current_only: bool = True) -> List['Patient']:
291 """
292 Get all patients matching the specified ID number.
294 Args:
295 dbsession: a :class:`sqlalchemy.orm.session.Session`
296 which_idnum: which ID number type?
297 idnum_value: actual value of the ID number
298 group_id: optional group ID to restrict to
299 current_only: restrict to ``_current`` patients?
301 Returns:
302 list of all matching patients
304 """
305 if not which_idnum or which_idnum < 1:
306 return []
307 if idnum_value is None:
308 return []
309 q = dbsession.query(cls).join(cls.idnums)
310 # ... the join pre-restricts to current ID numbers
311 # http://docs.sqlalchemy.org/en/latest/orm/join_conditions.html#using-custom-operators-in-join-conditions # noqa
312 q = q.filter(PatientIdNum.which_idnum == which_idnum)
313 q = q.filter(PatientIdNum.idnum_value == idnum_value)
314 if group_id is not None:
315 q = q.filter(Patient._group_id == group_id)
316 if current_only:
317 q = q.filter(cls._current == True) # noqa: E712
318 patients = q.all() # type: List[Patient]
319 return patients
321 @classmethod
322 def get_patient_by_pk(cls, dbsession: SqlASession,
323 server_pk: int) -> Optional["Patient"]:
324 """
325 Fetch a patient by the server PK.
326 """
327 return dbsession.query(cls).filter(cls._pk == server_pk).first()
329 @classmethod
330 def get_patient_by_id_device_era(cls, dbsession: SqlASession,
331 client_id: int,
332 device_id: int,
333 era: str) -> Optional["Patient"]:
334 """
335 Fetch a patient by the client ID, device ID, and era.
336 """
337 return (
338 dbsession.query(cls)
339 .filter(cls.id == client_id)
340 .filter(cls._device_id == device_id)
341 .filter(cls._era == era)
342 .first()
343 )
345 # -------------------------------------------------------------------------
346 # String representations
347 # -------------------------------------------------------------------------
349 def __str__(self) -> str:
350 """
351 A plain string version, without the need for a request object.
353 Example:
355 .. code-block:: none
357 SMITH, BOB (M, 1 Jan 1950, idnum1=123, idnum2=456)
358 """
359 return "{sf} ({sex}, {dob}, {ids})".format(
360 sf=self.get_surname_forename_upper(),
361 sex=self.sex,
362 dob=self.get_dob_str(),
363 ids=", ".join(str(i) for i in self.get_idnum_objects()),
364 )
366 def prettystr(self, req: "CamcopsRequest") -> str:
367 """
368 A prettified string version.
370 Args:
371 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
373 Example:
375 .. code-block:: none
377 SMITH, BOB (M, 1 Jan 1950, RiO# 123, NHS# 456)
378 """
379 return "{sf} ({sex}, {dob}, {ids})".format(
380 sf=self.get_surname_forename_upper(),
381 sex=self.sex,
382 dob=self.get_dob_str(),
383 ids=", ".join(i.prettystr(req) for i in self.get_idnum_objects()),
384 )
386 def get_letter_style_identifiers(self, req: "CamcopsRequest") -> str:
387 """
388 Our best guess at the kind of text you'd put in a clinical letter to
389 say "it's about this patient".
391 Example:
393 .. code-block:: none
395 Bob Smith (1 Jan 1950, RiO number 123, NHS number 456)
396 """
397 return "{fs} ({dob}, {ids})".format(
398 fs=self.get_forename_surname(),
399 dob=self.get_dob_str(),
400 ids=", ".join(i.full_prettystr(req)
401 for i in self.get_idnum_objects()),
402 )
404 # -------------------------------------------------------------------------
405 # Equality
406 # -------------------------------------------------------------------------
408 def __eq__(self, other: "Patient") -> bool:
409 """
410 Is this patient the same as another?
412 .. code-block:: python
414 from camcops_server.cc_modules.cc_patient import Patient
415 p1 = Patient(id=1, _device_id=1, _era="NOW")
416 print(p1 == p1) # True
417 p2 = Patient(id=1, _device_id=1, _era="NOW")
418 print(p1 == p2) # True
419 p3 = Patient(id=1, _device_id=2, _era="NOW")
420 print(p1 == p3) # False
422 s = set([p1, p2, p3]) # contains two patients
424 IMPERFECT in that it doesn't use intermediate patients to link
425 identity (e.g. P1 has RiO#=3, P2 has RiO#=3, NHS#=5, P3 has NHS#=5;
426 they are all the same by inference but P1 and P3 will not compare
427 equal).
429 """
430 # Same object?
431 # log.warning("self={}, other={}", self, other)
432 if self is other:
433 # log.warning("... same object; equal")
434 return True
435 # Same device/era/patient ID (client PK)? Test int before str for speed
436 if (self.id == other.id and
437 self._device_id == other._device_id and
438 self._era == other._era and
439 self.id is not None and
440 self._device_id is not None and
441 self._era is not None):
442 # log.warning("... same device/era/id; equal")
443 return True
444 # Shared ID number?
445 for sid in self.idnums:
446 if sid in other.idnums:
447 # log.warning("... share idnum {}; equal", sid)
448 return True
449 # Otherwise...
450 # log.warning("... unequal")
451 return False
453 def __hash__(self) -> int:
454 """
455 To put objects into a set, they must be hashable.
456 See https://docs.python.org/3/glossary.html#term-hashable.
457 If two objects are equal (via :func:`__eq__`) they must provide the
458 same hash value (but two objects with the same hash are not necessarily
459 equal).
460 """
461 return 0 # all objects have the same hash; "use __eq__() instead"
463 # -------------------------------------------------------------------------
464 # ID numbers
465 # -------------------------------------------------------------------------
467 def get_idnum_objects(self) -> List[PatientIdNum]:
468 """
469 Returns all :class:`PatientIdNum` objects for the patient.
471 These are SQLAlchemy ORM objects.
472 """
473 return self.idnums
475 def get_idnum_references(self) -> List[IdNumReference]:
476 """
477 Returns all
478 :class:`camcops_server.cc_modules.cc_simpleobjects.IdNumReference`
479 objects for the patient.
481 These are simple which_idnum/idnum_value pairs.
482 """
483 idnums = self.idnums # type: List[PatientIdNum]
484 return [x.get_idnum_reference() for x in idnums
485 if x.is_superficially_valid()]
487 def get_idnum_raw_values_only(self) -> List[int]:
488 """
489 Get all plain ID number values (ignoring which ID number type they
490 represent) for the patient.
491 """
492 idnums = self.idnums # type: List[PatientIdNum]
493 return [x.idnum_value for x in idnums if x.is_superficially_valid()]
495 def get_idnum_object(self, which_idnum: int) -> Optional[PatientIdNum]:
496 """
497 Gets the PatientIdNum object for a specified which_idnum, or None.
498 """
499 idnums = self.idnums # type: List[PatientIdNum]
500 for x in idnums:
501 if x.which_idnum == which_idnum:
502 return x
503 return None
505 def has_idnum_type(self, which_idnum: int) -> bool:
506 """
507 Does the patient have an ID number of the specified type?
508 """
509 return self.get_idnum_object(which_idnum) is not None
511 def get_idnum_value(self, which_idnum: int) -> Optional[int]:
512 """
513 Get value of a specific ID number, if present.
514 """
515 idobj = self.get_idnum_object(which_idnum)
516 return idobj.idnum_value if idobj else None
518 def set_idnum_value(self, req: "CamcopsRequest",
519 which_idnum: int, idnum_value: int) -> None:
520 """
521 Sets an ID number value.
522 """
523 dbsession = req.dbsession
524 ccsession = req.camcops_session
525 idnums = self.idnums # type: List[PatientIdNum]
526 for idobj in idnums:
527 if idobj.which_idnum == which_idnum:
528 idobj.idnum_value = idnum_value
529 return
530 # Otherwise, make a new one:
531 newid = PatientIdNum()
532 newid.patient_id = self.id
533 newid._device_id = self._device_id
534 newid._era = self._era
535 newid._current = True
536 newid._when_added_exact = req.now_era_format
537 newid._when_added_batch_utc = req.now_utc
538 newid._adding_user_id = ccsession.user_id
539 newid._camcops_version = CAMCOPS_SERVER_VERSION_STRING
540 dbsession.add(newid)
541 self.idnums.append(newid)
543 def get_iddesc(self, req: "CamcopsRequest",
544 which_idnum: int) -> Optional[str]:
545 """
546 Get value of a specific ID description, if present.
547 """
548 idobj = self.get_idnum_object(which_idnum)
549 return idobj.description(req) if idobj else None
551 def get_idshortdesc(self, req: "CamcopsRequest",
552 which_idnum: int) -> Optional[str]:
553 """
554 Get value of a specific ID short description, if present.
555 """
556 idobj = self.get_idnum_object(which_idnum)
557 return idobj.short_description(req) if idobj else None
559 def add_extra_idnum_info_to_row(self, row: Dict[str, Any]) -> None:
560 """
561 For the ``DB_PATIENT_ID_PER_ROW`` export option. Adds additional ID
562 number info to a row.
564 Args:
565 row: future database row, as a dictionary
566 """
567 for idobj in self.idnums:
568 which_idnum = idobj.which_idnum
569 fieldname = extra_id_colname(which_idnum)
570 row[fieldname] = idobj.idnum_value
572 # -------------------------------------------------------------------------
573 # Group
574 # -------------------------------------------------------------------------
576 @property
577 def group(self) -> Optional["Group"]:
578 """
579 Returns the :class:`camcops_server.cc_modules.cc_group.Group` to which
580 this patient's record belongs.
581 """
582 return self._group
584 # -------------------------------------------------------------------------
585 # Policies
586 # -------------------------------------------------------------------------
588 def satisfies_upload_id_policy(self) -> bool:
589 """
590 Does the patient satisfy the uploading ID policy?
591 """
592 group = self._group # type: Optional[Group]
593 if not group:
594 return False
595 return self.satisfies_id_policy(group.tokenized_upload_policy())
597 def satisfies_finalize_id_policy(self) -> bool:
598 """
599 Does the patient satisfy the finalizing ID policy?
600 """
601 group = self._group # type: Optional[Group]
602 if not group:
603 return False
604 return self.satisfies_id_policy(group.tokenized_finalize_policy())
606 def satisfies_id_policy(self, policy: "TokenizedPolicy") -> bool:
607 """
608 Does the patient satisfy a particular ID policy?
609 """
610 return policy.satisfies_id_policy(self.get_bare_ptinfo())
612 # -------------------------------------------------------------------------
613 # Name, DOB/age, sex, address, etc.
614 # -------------------------------------------------------------------------
616 def get_surname(self) -> str:
617 """
618 Get surname (in upper case) or "".
619 """
620 return self.surname.upper() if self.surname else ""
622 def get_forename(self) -> str:
623 """
624 Get forename (in upper case) or "".
625 """
626 return self.forename.upper() if self.forename else ""
628 def get_forename_surname(self) -> str:
629 """
630 Get "Forename Surname" as a string, using "(UNKNOWN)" for missing
631 details.
632 """
633 f = self.forename or "(UNKNOWN)"
634 s = self.surname or "(UNKNOWN)"
635 return f"{f} {s}"
637 def get_surname_forename_upper(self) -> str:
638 """
639 Get "SURNAME, FORENAME", using "(UNKNOWN)" for missing details.
640 """
641 s = self.surname.upper() if self.surname else "(UNKNOWN)"
642 f = self.forename.upper() if self.forename else "(UNKNOWN)"
643 return f"{s}, {f}"
645 def get_dob_html(self, req: "CamcopsRequest", longform: bool) -> str:
646 """
647 HTML fragment for date of birth.
648 """
649 _ = req.gettext
650 if longform:
651 dob = answer(format_datetime(
652 self.dob, DateFormat.LONG_DATE, default=None))
654 dobtext = _("Date of birth:")
655 return f"<br>{dobtext} {dob}"
656 else:
657 dobtext = _("DOB:")
658 dob = format_datetime(self.dob, DateFormat.SHORT_DATE)
659 return f"{dobtext} {dob}."
661 def get_age(self, req: "CamcopsRequest",
662 default: str = "") -> Union[int, str]:
663 """
664 Age (in whole years) today, or default.
665 """
666 now = req.now
667 return self.get_age_at(now, default=default)
669 def get_dob(self) -> Optional[pendulum.Date]:
670 """
671 Date of birth, as a a timezone-naive date.
672 """
673 dob = self.dob
674 if not dob:
675 return None
676 return coerce_to_pendulum_date(dob)
678 def get_dob_str(self) -> Optional[str]:
679 """
680 Date of birth, as a string.
681 """
682 dob_dt = self.get_dob()
683 if dob_dt is None:
684 return None
685 return format_datetime(dob_dt, DateFormat.SHORT_DATE)
687 def get_age_at(self,
688 when: PotentialDatetimeType,
689 default: str = "") -> Union[int, str]:
690 """
691 Age (in whole years) at a particular date, or default.
692 """
693 return get_age(self.dob, when, default=default)
695 def is_female(self) -> bool:
696 """
697 Is sex 'F'?
698 """
699 return self.sex == SEX_FEMALE
701 def is_male(self) -> bool:
702 """
703 Is sex 'M'?
704 """
705 return self.sex == SEX_MALE
707 def get_sex(self) -> str:
708 """
709 Return sex or "".
710 """
711 return self.sex or ""
713 def get_sex_verbose(self, default: str = "sex unknown") -> str:
714 """
715 Returns HTML-safe version of sex, or default.
716 """
717 return default if not self.sex else ws.webify(self.sex)
719 def get_address(self) -> Optional[str]:
720 """
721 Returns address (NOT necessarily web-safe).
722 """
723 address = self.address # type: Optional[str]
724 return address or ""
726 def get_email(self) -> Optional[str]:
727 """
728 Returns email address
729 """
730 email = self.email # type: Optional[str]
731 return email or ""
733 # -------------------------------------------------------------------------
734 # Other representations
735 # -------------------------------------------------------------------------
737 def get_xml_root(self, req: "CamcopsRequest",
738 options: TaskExportOptions = None) -> XmlElement:
739 """
740 Get root of XML tree, as an
741 :class:`camcops_server.cc_modules.cc_xml.XmlElement`.
743 Args:
744 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
745 options: a :class:`camcops_server.cc_modules.cc_simpleobjects.TaskExportOptions`
746 """ # noqa
747 # No point in skipping old ID columns (1-8) now; they're gone.
748 branches = self._get_xml_branches(req, options=options)
749 # Now add new-style IDs:
750 pidnum_branches = [] # type: List[XmlElement]
751 pidnum_options = TaskExportOptions(xml_include_plain_columns=True,
752 xml_with_header_comments=False)
753 for pidnum in self.idnums: # type: PatientIdNum
754 pidnum_branches.append(pidnum._get_xml_root(
755 req, options=pidnum_options))
756 branches.append(XmlElement(
757 name="idnums",
758 value=pidnum_branches
759 ))
760 # Special notes
761 branches.append(XML_COMMENT_SPECIAL_NOTES)
762 special_notes = self.special_notes # type: List[SpecialNote]
763 for sn in special_notes:
764 branches.append(sn.get_xml_root())
765 return XmlElement(name=self.__tablename__, value=branches)
767 def get_tsv_page(self, req: "CamcopsRequest") -> TsvPage:
768 """
769 Get a :class:`camcops_server.cc_modules.cc_tsv.TsvPage` for the
770 patient.
771 """
772 # 1. Our core fields.
773 page = self._get_core_tsv_page(
774 req, heading_prefix=TSV_PATIENT_FIELD_PREFIX)
775 # 2. ID number details
776 # We can't just iterate through the ID numbers; we have to iterate
777 # through all possible ID numbers.
778 for iddef in req.idnum_definitions:
779 n = iddef.which_idnum
780 nstr = str(n)
781 shortdesc = iddef.short_description
782 longdesc = iddef.description
783 idnum_value = next(
784 (idnum.idnum_value for idnum in self.idnums
785 if idnum.which_idnum == n and idnum.is_superficially_valid()),
786 None)
787 page.add_or_set_value(
788 heading=TSV_PATIENT_FIELD_PREFIX + FP_ID_NUM + nstr,
789 value=idnum_value)
790 page.add_or_set_value(
791 heading=TSV_PATIENT_FIELD_PREFIX + FP_ID_DESC + nstr,
792 value=longdesc)
793 page.add_or_set_value(
794 heading=(TSV_PATIENT_FIELD_PREFIX + FP_ID_SHORT_DESC +
795 nstr),
796 value=shortdesc)
797 return page
799 def get_bare_ptinfo(self) -> BarePatientInfo:
800 """
801 Get basic identifying information, as a
802 :class:`camcops_server.cc_modules.cc_simpleobjects.BarePatientInfo`
803 object.
804 """
805 return BarePatientInfo(
806 forename=self.forename,
807 surname=self.surname,
808 sex=self.sex,
809 dob=self.dob,
810 address=self.address,
811 email=self.email,
812 gp=self.gp,
813 otherdetails=self.other,
814 idnum_definitions=self.get_idnum_references()
815 )
817 def get_hl7_pid_segment(self,
818 req: "CamcopsRequest",
819 recipient: "ExportRecipient") -> hl7.Segment:
820 """
821 Get HL7 patient identifier (PID) segment.
823 Args:
824 req:
825 a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
826 recipient:
827 a :class:`camcops_server.cc_modules.cc_exportrecipient.ExportRecipient`
829 Returns:
830 a :class:`hl7.Segment` object
831 """ # noqa
832 # Put the primary one first:
833 patient_id_tuple_list = [
834 HL7PatientIdentifier(
835 pid=str(self.get_idnum_value(recipient.primary_idnum)),
836 id_type=recipient.get_hl7_id_type(
837 req,
838 recipient.primary_idnum),
839 assigning_authority=recipient.get_hl7_id_aa(
840 req,
841 recipient.primary_idnum)
842 )
843 ]
844 # Then the rest:
845 for idobj in self.idnums:
846 which_idnum = idobj.which_idnum
847 if which_idnum == recipient.primary_idnum:
848 continue
849 idnum_value = idobj.idnum_value
850 if idnum_value is None:
851 continue
852 patient_id_tuple_list.append(
853 HL7PatientIdentifier(
854 pid=str(idnum_value),
855 id_type=recipient.get_hl7_id_type(req, which_idnum),
856 assigning_authority=recipient.get_hl7_id_aa(
857 req, which_idnum)
858 )
859 )
860 return make_pid_segment(
861 forename=self.get_surname(),
862 surname=self.get_forename(),
863 dob=self.get_dob(),
864 sex=self.get_sex(),
865 address=self.get_address(),
866 patient_id_list=patient_id_tuple_list,
867 )
869 # -------------------------------------------------------------------------
870 # FHIR
871 # -------------------------------------------------------------------------
872 def get_fhir_bundle_entry(self,
873 req: "CamcopsRequest",
874 recipient: "ExportRecipient") -> Dict:
875 identifier = self.get_fhir_identifier(req, recipient)
877 # TODO: Other fields we could add here
878 # address, GP, DOB, email
879 name = HumanName(jsondict={
880 "family": self.surname,
881 "given": [self.forename],
882 })
884 gender_lookup = {
885 "F": "female",
886 "M": "male",
887 "X": "other",
888 }
890 fhir_patient = FhirPatient(jsondict={
891 "identifier": [identifier.as_json()],
892 "name": [name.as_json()],
893 "gender": gender_lookup.get(self.sex, "unknown")
894 })
896 bundle_request = BundleEntryRequest(jsondict={
897 "method": "POST",
898 "url": "Patient",
899 "ifNoneExist": f"identifier={identifier.system}|{identifier.value}",
900 })
902 return BundleEntry(jsondict={
903 "resource": fhir_patient.as_json(),
904 "request": bundle_request.as_json()
905 }).as_json()
907 def get_fhir_identifier(self,
908 req: "CamcopsRequest",
909 recipient: "ExportRecipient") -> Identifier:
910 which_idnum = recipient.primary_idnum
912 idnum_object = self.get_idnum_object(which_idnum)
913 idnum_value = idnum_object.idnum_value
914 idnum_url = req.route_url(
915 Routes.FHIR_PATIENT_ID,
916 which_idnum=which_idnum
917 )
919 return Identifier(jsondict={
920 "system": idnum_url,
921 "value": str(idnum_value),
922 })
924 # -------------------------------------------------------------------------
925 # Database status
926 # -------------------------------------------------------------------------
928 def is_preserved(self) -> bool:
929 """
930 Is the patient record preserved and erased from the tablet?
931 """
932 return self._pk is not None and self._era != ERA_NOW
934 # -------------------------------------------------------------------------
935 # Audit
936 # -------------------------------------------------------------------------
938 def audit(self, req: "CamcopsRequest",
939 details: str, from_console: bool = False) -> None:
940 """
941 Audits an action to this patient.
942 """
943 audit(req,
944 details,
945 patient_server_pk=self._pk,
946 table=Patient.__tablename__,
947 server_pk=self._pk,
948 from_console=from_console)
950 # -------------------------------------------------------------------------
951 # Special notes
952 # -------------------------------------------------------------------------
954 def apply_special_note(
955 self,
956 req: "CamcopsRequest",
957 note: str,
958 audit_msg: str = "Special note applied manually") -> None:
959 """
960 Manually applies a special note to a patient.
961 WRITES TO DATABASE.
962 """
963 sn = SpecialNote()
964 sn.basetable = self.__tablename__
965 sn.task_id = self.id # patient ID, in this case
966 sn.device_id = self._device_id
967 sn.era = self._era
968 sn.note_at = req.now
969 sn.user_id = req.user_id
970 sn.note = note
971 req.dbsession.add(sn)
972 self.special_notes.append(sn)
973 self.audit(req, audit_msg)
974 # HL7 deletion of corresponding tasks is done in camcops_server.py
976 # -------------------------------------------------------------------------
977 # Deletion
978 # -------------------------------------------------------------------------
980 def gen_patient_idnums_even_noncurrent(self) -> \
981 Generator[PatientIdNum, None, None]:
982 """
983 Generates all :class:`PatientIdNum` objects, including non-current
984 ones.
985 """
986 for lineage_member in self._gen_unique_lineage_objects(self.idnums): # type: PatientIdNum # noqa
987 yield lineage_member
989 def delete_with_dependants(self, req: "CamcopsRequest") -> None:
990 """
991 Delete the patient with all its dependent objects.
992 """
993 if self._pk is None:
994 return
995 for pidnum in self.gen_patient_idnums_even_noncurrent():
996 req.dbsession.delete(pidnum)
997 super().delete_with_dependants(req)
999 # -------------------------------------------------------------------------
1000 # Editing
1001 # -------------------------------------------------------------------------
1003 def is_finalized(self) -> bool:
1004 """
1005 Is the patient finalized (no longer available to be edited on the
1006 client device), and therefore editable on the server?
1007 """
1008 if self._era == ERA_NOW:
1009 # Not finalized; no editing on server
1010 return False
1011 return True
1013 def created_on_server(self, req: "CamcopsRequest") -> bool:
1014 server_device = Device.get_server_device(req.dbsession)
1016 return (self._era == ERA_NOW and
1017 self._device_id == server_device.id)
1019 def user_may_edit(self, req: "CamcopsRequest") -> bool:
1020 """
1021 Does the current user have permission to edit this patient?
1022 """
1023 return req.user.may_administer_group(self._group_id)
1025 # --------------------------------------------------------------------------
1026 # UUID
1027 # --------------------------------------------------------------------------
1028 @property
1029 def uuid_as_proquint(self) -> Optional[str]:
1030 # Convert integer into pronounceable quintuplets (proquint)
1031 # https://arxiv.org/html/0901.4016
1032 if self.uuid is None:
1033 return None
1035 return proquint_from_uuid(self.uuid)
1038# =============================================================================
1039# Validate candidate patient info for upload
1040# =============================================================================
1042def is_candidate_patient_valid_for_group(ptinfo: BarePatientInfo,
1043 group: "Group",
1044 finalizing: bool) -> Tuple[bool, str]:
1045 """
1046 Is the specified patient acceptable to upload into this group?
1048 Checks:
1050 - group upload or finalize policy
1052 .. todo:: is_candidate_patient_valid: check against predefined patients, if
1053 the group wants
1055 Args:
1056 ptinfo:
1057 a
1058 :class:`camcops_server.cc_modules.cc_simpleobjects.BarePatientInfo`
1059 representing the patient info to check
1060 group:
1061 the :class:`camcops_server.cc_modules.cc_group.Group` into which
1062 this patient will be uploaded, if allowed
1063 finalizing:
1064 finalizing, rather than uploading?
1066 Returns:
1067 tuple: valid, reason
1069 """
1070 if not group:
1071 return False, "Nonexistent group"
1073 if finalizing:
1074 if not group.tokenized_finalize_policy().satisfies_id_policy(ptinfo):
1075 return False, "Fails finalizing ID policy"
1076 else:
1077 if not group.tokenized_upload_policy().satisfies_id_policy(ptinfo):
1078 return False, "Fails upload ID policy"
1080 # todo: add checks against prevalidated patients here
1082 return True, ""
1085def is_candidate_patient_valid_for_restricted_user(
1086 req: "CamcopsRequest",
1087 ptinfo: BarePatientInfo) -> Tuple[bool, str]:
1088 """
1089 Is the specified patient OK to be uploaded by this user? Performs a check
1090 for restricted (single-patient) users; if true, ensures that the
1091 identifiers all match the expected patient.
1093 Args:
1094 req:
1095 the :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
1096 ptinfo:
1097 a
1098 :class:`camcops_server.cc_modules.cc_simpleobjects.BarePatientInfo`
1099 representing the patient info to check
1101 Returns:
1102 tuple: valid, reason
1103 """
1104 user = req.user
1105 if not user.auto_generated:
1106 # Not a restricted user; no problem.
1107 return True, ""
1109 server_patient = user.single_patient
1110 if not server_patient:
1111 return False, (
1112 f"Restricted user {user.username} does not have associated "
1113 f"patient details"
1114 )
1116 server_ptinfo = server_patient.get_bare_ptinfo()
1117 if ptinfo != server_ptinfo:
1118 return False, f"Should be {server_ptinfo}"
1120 return True, ""
1123# =============================================================================
1124# Reports
1125# =============================================================================
1127class DistinctPatientReport(Report):
1128 """
1129 Report to show distinct patients.
1130 """
1132 # noinspection PyMethodParameters
1133 @classproperty
1134 def report_id(cls) -> str:
1135 return "patient_distinct"
1137 @classmethod
1138 def title(cls, req: "CamcopsRequest") -> str:
1139 _ = req.gettext
1140 return _("(Server) Patients, distinct by name, sex, DOB, all ID "
1141 "numbers")
1143 # noinspection PyMethodParameters
1144 @classproperty
1145 def superuser_only(cls) -> bool:
1146 return False
1148 # noinspection PyProtectedMember
1149 def get_query(self, req: "CamcopsRequest") -> SelectBase:
1150 select_fields = [
1151 Patient.surname.label("surname"),
1152 Patient.forename.label("forename"),
1153 Patient.dob.label("dob"),
1154 Patient.sex.label("sex"),
1155 ]
1156 # noinspection PyUnresolvedReferences
1157 select_from = Patient.__table__
1158 wheres = [Patient._current == True] # type: List[ClauseElement] # noqa: E501,E712
1159 if not req.user.superuser:
1160 # Restrict to accessible groups
1161 group_ids = req.user.ids_of_groups_user_may_report_on
1162 wheres.append(Patient._group_id.in_(group_ids))
1163 for iddef in req.idnum_definitions:
1164 n = iddef.which_idnum
1165 desc = iddef.short_description
1166 # noinspection PyUnresolvedReferences
1167 aliased_table = PatientIdNum.__table__.alias(f"i{n}")
1168 select_fields.append(aliased_table.c.idnum_value.label(desc))
1169 select_from = select_from.outerjoin(aliased_table, and_(
1170 aliased_table.c.patient_id == Patient.id,
1171 aliased_table.c._device_id == Patient._device_id,
1172 aliased_table.c._era == Patient._era,
1173 # Note: the following are part of the JOIN, not the WHERE:
1174 # (or failure to match a row will wipe out the Patient from the
1175 # OUTER JOIN):
1176 aliased_table.c._current == True, # noqa: E712
1177 aliased_table.c.which_idnum == n,
1178 )) # nopep8
1179 order_by = [
1180 Patient.surname,
1181 Patient.forename,
1182 Patient.dob,
1183 Patient.sex,
1184 ]
1185 query = (
1186 select(select_fields)
1187 .select_from(select_from)
1188 .where(and_(*wheres))
1189 .order_by(*order_by)
1190 .distinct()
1191 )
1192 return query