Coverage for cc_modules/cc_patient.py: 36%

1"""

2 camcops_server/cc_modules/cc_patient.py

3

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

5

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

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

8

9 This file is part of CamCOPS.

10

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

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

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

14 (at your option) any later version.

15

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

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

18 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the

19 GNU General Public License for more details.

20

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

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

23

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

25

26**Patients.**

27

28"""

29

30 import datetime

31 import logging

32 from typing import (

33 Any,

34 Dict,

35 Generator,

36 List,

37 Optional,

38 Set,

39 Tuple,

40 TYPE_CHECKING,

41 Union,

42)

43 import uuid as python_uuid

44

45 from cardinal_pythonlib.classes import classproperty

46 from cardinal_pythonlib.datetimefunc import (

47 coerce_to_pendulum_date,

48 format_datetime,

49 get_age,

50 PotentialDatetimeType,

51)

52 from cardinal_pythonlib.json.typing_helpers import JsonObjectType

53 from cardinal_pythonlib.logs import BraceStyleAdapter

54 import cardinal_pythonlib.rnc_web as ws

55 from fhirclient.models.address import Address

56 from fhirclient.models.contactpoint import ContactPoint

57 from fhirclient.models.humanname import HumanName

58 from fhirclient.models.fhirreference import FHIRReference

59 from fhirclient.models.identifier import Identifier

60 from fhirclient.models.patient import Patient as FhirPatient

61 import hl7

62 import pendulum

63 from sqlalchemy.ext.declarative import declared_attr

64 from sqlalchemy.orm import mapped_column, Mapped, relationship

65 from sqlalchemy.orm import Session as SqlASession

66 from sqlalchemy.orm.relationships import RelationshipProperty

67 from sqlalchemy.sql.expression import and_, ClauseElement, select

68 from sqlalchemy.sql.selectable import SelectBase

69 from sqlalchemy.sql.sqltypes import UnicodeText

70

71 from camcops_server.cc_modules.cc_audit import audit

72 from camcops_server.cc_modules.cc_constants import (

73 DateFormat,

74 ERA_NOW,

75 FHIRConst as Fc,

76 FP_ID_DESC,

77 FP_ID_SHORT_DESC,

78 FP_ID_NUM,

79 SEX_FEMALE,

80 SEX_MALE,

81 SEX_OTHER_UNSPECIFIED,

82 SPREADSHEET_PATIENT_FIELD_PREFIX,

83)

84 from camcops_server.cc_modules.cc_dataclasses import SummarySchemaInfo

85 from camcops_server.cc_modules.cc_db import GenericTabletRecordMixin

86 from camcops_server.cc_modules.cc_fhir import (

87 fhir_pk_identifier,

88 make_fhir_bundle_entry,

89)

90 from camcops_server.cc_modules.cc_hl7 import make_pid_segment

91 from camcops_server.cc_modules.cc_html import answer

92 from camcops_server.cc_modules.cc_idnumdef import IdNumDefinition

93 from camcops_server.cc_modules.cc_simpleobjects import (

94 BarePatientInfo,

95 HL7PatientIdentifier,

96)

97 from camcops_server.cc_modules.cc_patientidnum import (

98 extra_id_colname,

99 PatientIdNum,

100)

101 from camcops_server.cc_modules.cc_proquint import proquint_from_uuid

102 from camcops_server.cc_modules.cc_report import Report

103 from camcops_server.cc_modules.cc_simpleobjects import (

104 IdNumReference,

105 TaskExportOptions,

106)

107 from camcops_server.cc_modules.cc_specialnote import SpecialNote

108 from camcops_server.cc_modules.cc_sqla_coltypes import (

109 EmailAddressColType,

110 mapped_camcops_column,

111 PatientNameColType,

112 SexColType,

113 UuidColType,

114)

115 from camcops_server.cc_modules.cc_sqlalchemy import Base

116 from camcops_server.cc_modules.cc_spreadsheet import SpreadsheetPage

117 from camcops_server.cc_modules.cc_version import CAMCOPS_SERVER_VERSION_STRING

118 from camcops_server.cc_modules.cc_xml import (

119 XML_COMMENT_SPECIAL_NOTES,

120 XmlElement,

121)

122

123 if TYPE_CHECKING:

124 from sqlalchemy.sql.elements import ColumnElement

125

126 from camcops_server.cc_modules.cc_exportrecipient import ExportRecipient

127 from camcops_server.cc_modules.cc_group import Group

128 from camcops_server.cc_modules.cc_policy import TokenizedPolicy

129 from camcops_server.cc_modules.cc_request import CamcopsRequest

130 from camcops_server.cc_modules.cc_taskschedule import PatientTaskSchedule

131 from camcops_server.cc_modules.cc_user import User

132

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

134

135

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

137# Patient class

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

139

140

141 class Patient(GenericTabletRecordMixin, Base):

142 """

143 Class representing a patient.

144 """

145

146 __tablename__ = "patient"

147

148 id: Mapped[int] = mapped_column(

149 comment="Primary key (patient ID) on the source tablet device",

150 # client PK

151 )

152 uuid: Mapped[Optional[python_uuid.UUID]] = mapped_camcops_column(

153 UuidColType,

154 comment="UUID",

155 default=python_uuid.uuid4, # generates a random UUID

156 )

157 forename: Mapped[Optional[str]] = mapped_camcops_column(

158 PatientNameColType,

159 index=True,

160 identifies_patient=True,

161 include_in_anon_staging_db=True,

162 comment="Forename",

163 )

164 surname: Mapped[Optional[str]] = mapped_camcops_column(

165 PatientNameColType,

166 index=True,

167 identifies_patient=True,

168 include_in_anon_staging_db=True,

169 comment="Surname",

170 )

171 dob: Mapped[Optional[datetime.date]] = mapped_camcops_column(

172 index=True,

173 identifies_patient=True,

174 include_in_anon_staging_db=True,

175 comment="Date of birth",

176 # ... e.g. "2013-02-04"

177 )

178 sex: Mapped[Optional[str]] = mapped_camcops_column(

179 SexColType,

180 index=True,

181 include_in_anon_staging_db=True,

182 comment="Sex (M, F, X)",

183 )

184 address: Mapped[Optional[str]] = mapped_camcops_column(

185 UnicodeText, identifies_patient=True, comment="Address"

186 )

187 email: Mapped[Optional[str]] = mapped_camcops_column(

188 EmailAddressColType,

189 identifies_patient=True,

190 comment="Patient's e-mail address",

191 )

192 gp: Mapped[Optional[str]] = mapped_camcops_column(

193 UnicodeText,

194 identifies_patient=True,

195 comment="General practitioner (GP)",

196 )

197 other: Mapped[Optional[str]] = mapped_camcops_column(

198 UnicodeText, identifies_patient=True, comment="Other details"

199 )

200 idnums: Mapped[list["PatientIdNum"]] = relationship(

201 # https://docs.sqlalchemy.org/en/latest/orm/join_conditions.html#relationship-custom-foreign

202 # https://docs.sqlalchemy.org/en/latest/orm/relationship_api.html#sqlalchemy.orm.relationship # noqa

203 # https://docs.sqlalchemy.org/en/latest/orm/join_conditions.html#relationship-primaryjoin # noqa

204 primaryjoin=(

205 "and_("

206 " remote(PatientIdNum.patient_id) == foreign(Patient.id), "

207 " remote(PatientIdNum._device_id) == foreign(Patient._device_id), "

208 " remote(PatientIdNum._era) == foreign(Patient._era), "

209 " remote(PatientIdNum._current) == True "

210 ")"

211 ),

212 uselist=True,

213 viewonly=True,

214 # Profiling results 2019-10-14 exporting 4185 phq9 records with

215 # unique patients to xlsx (task-patient relationship "selectin")

216 # lazy="select" : 35.3s

217 # lazy="joined" : 27.3s

218 # lazy="subquery": 15.2s (31.0s when task-patient also subquery)

219 # lazy="selectin": 26.4s

220 # See also patient relationship on Task class (cc_task.py)

221 lazy="subquery",

222 )

223

224 task_schedules: Mapped[list["PatientTaskSchedule"]] = relationship(

225 back_populates="patient",

226 cascade="all, delete",

227 cascade_backrefs=False,

228 )

229

230 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

231 # THE FOLLOWING ARE DEFUNCT, AND THE SERVER WORKS AROUND OLD TABLETS IN

232 # THE UPLOAD API.

233 #

234 # idnum1 = Column("idnum1", BigInteger, comment="ID number 1")

235 # idnum2 = Column("idnum2", BigInteger, comment="ID number 2")

236 # idnum3 = Column("idnum3", BigInteger, comment="ID number 3")

237 # idnum4 = Column("idnum4", BigInteger, comment="ID number 4")

238 # idnum5 = Column("idnum5", BigInteger, comment="ID number 5")

239 # idnum6 = Column("idnum6", BigInteger, comment="ID number 6")

240 # idnum7 = Column("idnum7", BigInteger, comment="ID number 7")

241 # idnum8 = Column("idnum8", BigInteger, comment="ID number 8")

242 #

243 # iddesc1 = Column("iddesc1", IdDescriptorColType, comment="ID description 1") # noqa

244 # iddesc2 = Column("iddesc2", IdDescriptorColType, comment="ID description 2") # noqa

245 # iddesc3 = Column("iddesc3", IdDescriptorColType, comment="ID description 3") # noqa

246 # iddesc4 = Column("iddesc4", IdDescriptorColType, comment="ID description 4") # noqa

247 # iddesc5 = Column("iddesc5", IdDescriptorColType, comment="ID description 5") # noqa

248 # iddesc6 = Column("iddesc6", IdDescriptorColType, comment="ID description 6") # noqa

249 # iddesc7 = Column("iddesc7", IdDescriptorColType, comment="ID description 7") # noqa

250 # iddesc8 = Column("iddesc8", IdDescriptorColType, comment="ID description 8") # noqa

251 #

252 # idshortdesc1 = Column("idshortdesc1", IdDescriptorColType, comment="ID short description 1") # noqa

253 # idshortdesc2 = Column("idshortdesc2", IdDescriptorColType, comment="ID short description 2") # noqa

254 # idshortdesc3 = Column("idshortdesc3", IdDescriptorColType, comment="ID short description 3") # noqa

255 # idshortdesc4 = Column("idshortdesc4", IdDescriptorColType, comment="ID short description 4") # noqa

256 # idshortdesc5 = Column("idshortdesc5", IdDescriptorColType, comment="ID short description 5") # noqa

257 # idshortdesc6 = Column("idshortdesc6", IdDescriptorColType, comment="ID short description 6") # noqa

258 # idshortdesc7 = Column("idshortdesc7", IdDescriptorColType, comment="ID short description 7") # noqa

259 # idshortdesc8 = Column("idshortdesc8", IdDescriptorColType, comment="ID short description 8") # noqa

260 #

261 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

262

263 # -------------------------------------------------------------------------

264 # Relationships

265 # -------------------------------------------------------------------------

266

267 # noinspection PyMethodParameters

268 @declared_attr # type: ignore[arg-type]

269 def special_notes(cls) -> RelationshipProperty:

270 """

271 Relationship to all :class:`SpecialNote` objects associated with this

272 patient.

273 """

274 # The SpecialNote also allows a link to patients, not just tasks,

275 # like this:

276 return relationship(

277 SpecialNote,

278 primaryjoin=(

279 "and_("

280 " remote(SpecialNote.basetable) == literal({repr_patient_tablename}), " # noqa

281 " remote(SpecialNote.task_id) == foreign(Patient.id), "

282 " remote(SpecialNote.device_id) == foreign(Patient._device_id), " # noqa

283 " remote(SpecialNote.era) == foreign(Patient._era), "

284 " not_(SpecialNote.hidden)"

285 ")".format(repr_patient_tablename=repr(cls.__tablename__))

286 ),

287 uselist=True,

288 order_by="SpecialNote.note_at",

289 viewonly=True, # for now!

290 )

291

292 # -------------------------------------------------------------------------

293 # Patient-fetching classmethods

294 # -------------------------------------------------------------------------

295

296 @classmethod

297 def get_patients_by_idnum(

298 cls,

299 dbsession: SqlASession,

300 which_idnum: int,

301 idnum_value: int,

302 group_id: int = None,

303 current_only: bool = True,

304 ) -> List["Patient"]:

305 """

306 Get all patients matching the specified ID number.

307

308 Args:

309 dbsession: a :class:`sqlalchemy.orm.session.Session`

310 which_idnum: which ID number type?

311 idnum_value: actual value of the ID number

312 group_id: optional group ID to restrict to

313 current_only: restrict to ``_current`` patients?

314

315 Returns:

316 list of all matching patients

317

318 """

319 if not which_idnum or which_idnum < 1:

320 return []

321 if idnum_value is None:

322 return []

323 q = dbsession.query(cls).join(cls.idnums) # type: ignore[arg-type]

324 # ... the join pre-restricts to current ID numbers

325 # https://docs.sqlalchemy.org/en/latest/orm/join_conditions.html#using-custom-operators-in-join-conditions # noqa

326 q = q.filter(PatientIdNum.which_idnum == which_idnum)

327 q = q.filter(PatientIdNum.idnum_value == idnum_value)

328 if group_id is not None:

329 q = q.filter(Patient._group_id == group_id)

330 if current_only:

331 q = q.filter(cls._current == True) # noqa: E712

332 patients = q.all() # type: List[Patient]

333 return patients

334

335 @classmethod

336 def get_patient_by_pk(

337 cls, dbsession: SqlASession, server_pk: int

338 ) -> Optional["Patient"]:

339 """

340 Fetch a patient by the server PK.

341 """

342 return dbsession.query(cls).filter(cls._pk == server_pk).first()

343

344 @classmethod

345 def get_patient_by_id_device_era(

346 cls, dbsession: SqlASession, client_id: int, device_id: int, era: str

347 ) -> Optional["Patient"]:

348 """

349 Fetch a patient by the client ID, device ID, and era.

350 """

351 return (

352 dbsession.query(cls)

353 .filter(cls.id == client_id)

354 .filter(cls._device_id == device_id)

355 .filter(cls._era == era)

356 .first()

357 )

358

359 # -------------------------------------------------------------------------

360 # String representations

361 # -------------------------------------------------------------------------

362

363 def __str__(self) -> str:

364 """

365 A plain string version, without the need for a request object.

366

367 Example:

368

369 .. code-block:: none

370

371 SMITH, BOB (M, 1 Jan 1950, idnum1=123, idnum2=456)

372 """

373 return "{sf} ({sex}, {dob}, {ids})".format(

374 sf=self.get_surname_forename_upper(),

375 sex=self.sex,

376 dob=self.get_dob_str(),

377 ids=", ".join(str(i) for i in self.get_idnum_objects()),

378 )

379

380 def prettystr(self, req: "CamcopsRequest") -> str:

381 """

382 A prettified string version.

383

384 Args:

385 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`

386

387 Example:

388

389 .. code-block:: none

390

391 SMITH, BOB (M, 1 Jan 1950, RiO# 123, NHS# 456)

392 """

393 return "{sf} ({sex}, {dob}, {ids})".format(

394 sf=self.get_surname_forename_upper(),

395 sex=self.sex,

396 dob=self.get_dob_str(),

397 ids=", ".join(i.prettystr(req) for i in self.get_idnum_objects()),

398 )

399

400 def get_letter_style_identifiers(self, req: "CamcopsRequest") -> str:

401 """

402 Our best guess at the kind of text you'd put in a clinical letter to

403 say "it's about this patient".

404

405 Example:

406

407 .. code-block:: none

408

409 Bob Smith (1 Jan 1950, RiO number 123, NHS number 456)

410 """

411 return "{fs} ({dob}, {ids})".format(

412 fs=self.get_forename_surname(),

413 dob=self.get_dob_str(),

414 ids=", ".join(

415 i.full_prettystr(req) for i in self.get_idnum_objects()

416 ),

417 )

418

419 # -------------------------------------------------------------------------

420 # Equality

421 # -------------------------------------------------------------------------

422

423 def __eq__(self, other: object) -> bool:

424 """

425 Is this patient the same as another?

426

427 .. code-block:: python

428

429 from camcops_server.cc_modules.cc_patient import Patient

430 p1 = Patient(id=1, _device_id=1, _era="NOW")

431 print(p1 == p1) # True

432 p2 = Patient(id=1, _device_id=1, _era="NOW")

433 print(p1 == p2) # True

434 p3 = Patient(id=1, _device_id=2, _era="NOW")

435 print(p1 == p3) # False

436

437 s = set([p1, p2, p3]) # contains two patients

438

439 IMPERFECT in that it doesn't use intermediate patients to link

440 identity (e.g. P1 has RiO#=3, P2 has RiO#=3, NHS#=5, P3 has NHS#=5;

441 they are all the same by inference but P1 and P3 will not compare

442 equal).

443

444 """

445 # Same object?

446 # log.debug("self={}, other={}", self, other)

447 if self is other:

448 # log.debug("... same object; equal")

449 return True

450

451 if not isinstance(other, Patient):

452 # Since SQLAlchemy 2.0, when lazy-loading from related objects

453 # (e.g. Task.patient) the patient will be compared with

454 # non-patient SQLA internal status codes so we need to cater for

455 # this. It is probably good practice anyway.

456

457 # MyPy does not recognise try... except AttributeError

458 return NotImplemented

459

460 # Same device/era/patient ID (client PK)? Test int before str for speed

461 if (

462 self.id == other.id

463 and self._device_id == other._device_id

464 and self._era == other._era

465 and self.id is not None

466 and self._device_id is not None

467 and self._era is not None

468 ):

469 # log.debug("... same device/era/id; equal")

470 return True

471 # Shared ID number?

472 for sid in self.idnums:

473 if sid in other.idnums:

474 # log.debug("... share idnum {}; equal", sid)

475 return True

476 # Otherwise...

477 # log.debug("... unequal")

478 return False

479

480 def __hash__(self) -> int:

481 """

482 To put objects into a set, they must be hashable.

483 See https://docs.python.org/3/glossary.html#term-hashable.

484 If two objects are equal (via :func:`__eq__`) they must provide the

485 same hash value (but two objects with the same hash are not necessarily

486 equal).

487 """

488 return 0 # all objects have the same hash; "use __eq__() instead"

489

490 # -------------------------------------------------------------------------

491 # ID numbers

492 # -------------------------------------------------------------------------

493

494 def get_idnum_objects(self) -> List[PatientIdNum]:

495 """

496 Returns all :class:`PatientIdNum` objects for the patient.

497

498 These are SQLAlchemy ORM objects.

499 """

500 return self.idnums

501

502 def get_idnum_references(self) -> List[IdNumReference]:

503 """

504 Returns all

505 :class:`camcops_server.cc_modules.cc_simpleobjects.IdNumReference`

506 objects for the patient.

507

508 These are simple which_idnum/idnum_value pairs.

509 """

510 idnums = self.idnums # type: List[PatientIdNum]

511 return [

512 x.get_idnum_reference()

513 for x in idnums

514 if x.is_superficially_valid()

515 ]

516

517 def get_idnum_raw_values_only(self) -> List[int]:

518 """

519 Get all plain ID number values (ignoring which ID number type they

520 represent) for the patient.

521 """

522 idnums = self.idnums # type: List[PatientIdNum]

523 return [x.idnum_value for x in idnums if x.is_superficially_valid()]

524

525 def get_idnum_object(self, which_idnum: int) -> Optional[PatientIdNum]:

526 """

527 Gets the PatientIdNum object for a specified which_idnum, or None.

528 """

529 idnums = self.idnums # type: List[PatientIdNum]

530 for x in idnums:

531 if x.which_idnum == which_idnum:

532 return x

533 return None

534

535 def has_idnum_type(self, which_idnum: int) -> bool:

536 """

537 Does the patient have an ID number of the specified type?

538 """

539 return self.get_idnum_object(which_idnum) is not None

540

541 def get_idnum_value(self, which_idnum: int) -> Optional[int]:

542 """

543 Get value of a specific ID number, if present.

544 """

545 idobj = self.get_idnum_object(which_idnum)

546 return idobj.idnum_value if idobj else None

547

548 def set_idnum_value(

549 self, req: "CamcopsRequest", which_idnum: int, idnum_value: int

550 ) -> None:

551 """

552 Sets an ID number value.

553 """

554 dbsession = req.dbsession

555 ccsession = req.camcops_session

556 idnums = self.idnums # type: List[PatientIdNum]

557 for idobj in idnums:

558 if idobj.which_idnum == which_idnum:

559 idobj.idnum_value = idnum_value

560 return

561 # Otherwise, make a new one:

562 newid = PatientIdNum()

563 newid.patient_id = self.id

564 newid._device_id = self._device_id

565 newid._era = self._era

566 newid._current = True

567 newid._when_added_exact = req.now_era_format

568 newid._when_added_batch_utc = req.now_utc

569 newid._adding_user_id = ccsession.user_id

570 newid._camcops_version = CAMCOPS_SERVER_VERSION_STRING

571 dbsession.add(newid)

572 self.idnums.append(newid)

573

574 def get_iddesc(

575 self, req: "CamcopsRequest", which_idnum: int

576 ) -> Optional[str]:

577 """

578 Get value of a specific ID description, if present.

579 """

580 idobj = self.get_idnum_object(which_idnum)

581 return idobj.description(req) if idobj else None

582

583 def get_idshortdesc(

584 self, req: "CamcopsRequest", which_idnum: int

585 ) -> Optional[str]:

586 """

587 Get value of a specific ID short description, if present.

588 """

589 idobj = self.get_idnum_object(which_idnum)

590 return idobj.short_description(req) if idobj else None

591

592 def add_extra_idnum_info_to_row(self, row: Dict[str, Any]) -> None:

593 """

594 For the ``DB_PATIENT_ID_PER_ROW`` export option. Adds additional ID

595 number info to a row.

596

597 Args:

598 row: future database row, as a dictionary

599 """

600 for idobj in self.idnums:

601 which_idnum = idobj.which_idnum

602 fieldname = extra_id_colname(which_idnum)

603 row[fieldname] = idobj.idnum_value

604

605 # -------------------------------------------------------------------------

606 # Group

607 # -------------------------------------------------------------------------

608

609 @property

610 def group(self) -> Optional["Group"]:

611 """

612 Returns the :class:`camcops_server.cc_modules.cc_group.Group` to which

613 this patient's record belongs.

614 """

615 return self._group

616

617 # -------------------------------------------------------------------------

618 # Policies

619 # -------------------------------------------------------------------------

620

621 def satisfies_upload_id_policy(self) -> bool:

622 """

623 Does the patient satisfy the uploading ID policy?

624 """

625 group = self._group # type: Optional[Group]

626 if not group:

627 return False

628 return self.satisfies_id_policy(group.tokenized_upload_policy())

629

630 def satisfies_finalize_id_policy(self) -> bool:

631 """

632 Does the patient satisfy the finalizing ID policy?

633 """

634 group = self._group # type: Optional[Group]

635 if not group:

636 return False

637 return self.satisfies_id_policy(group.tokenized_finalize_policy())

638

639 def satisfies_id_policy(self, policy: "TokenizedPolicy") -> bool:

640 """

641 Does the patient satisfy a particular ID policy?

642 """

643 return policy.satisfies_id_policy(self.get_bare_ptinfo())

644

645 # -------------------------------------------------------------------------

646 # Name, DOB/age, sex, address, etc.

647 # -------------------------------------------------------------------------

648

649 def get_surname(self) -> str:

650 """

651 Get surname (in upper case) or "".

652 """

653 return self.surname.upper() if self.surname else ""

654

655 def get_forename(self) -> str:

656 """

657 Get forename (in upper case) or "".

658 """

659 return self.forename.upper() if self.forename else ""

660

661 def get_forename_surname(self) -> str:

662 """

663 Get "Forename Surname" as a string, using "(UNKNOWN)" for missing

664 details.

665 """

666 f = self.forename or "(UNKNOWN)"

667 s = self.surname or "(UNKNOWN)"

668 return f"{f} {s}"

669

670 def get_surname_forename_upper(self) -> str:

671 """

672 Get "SURNAME, FORENAME", using "(UNKNOWN)" for missing details.

673 """

674 s = self.surname.upper() if self.surname else "(UNKNOWN)"

675 f = self.forename.upper() if self.forename else "(UNKNOWN)"

676 return f"{s}, {f}"

677

678 def get_dob_html(self, req: "CamcopsRequest", longform: bool) -> str:

679 """

680 HTML fragment for date of birth.

681 """

682 _ = req.gettext

683 if longform:

684 dob = answer(

685 format_datetime(self.dob, DateFormat.LONG_DATE, default=None)

686 )

687

688 dobtext = _("Date of birth:")

689 return f"<br>{dobtext} {dob}"

690 else:

691 dobtext = _("DOB:")

692 dob = format_datetime(self.dob, DateFormat.SHORT_DATE)

693 return f"{dobtext} {dob}."

694

695 def get_age(

696 self, req: "CamcopsRequest", default: str = ""

697 ) -> Union[int, str]:

698 """

699 Age (in whole years) today, or default.

700 """

701 now = req.now

702 return self.get_age_at(now, default=default)

703

704 def get_dob(self) -> Optional[pendulum.Date]:

705 """

706 Date of birth, as a a timezone-naive date.

707 """

708 dob = self.dob

709 if not dob:

710 return None

711 return coerce_to_pendulum_date(dob)

712

713 def get_dob_str(self) -> Optional[str]:

714 """

715 Date of birth, as a string.

716 """

717 dob_dt = self.get_dob()

718 if dob_dt is None:

719 return None

720 return format_datetime(dob_dt, DateFormat.SHORT_DATE)

721

722 def get_age_at(

723 self, when: PotentialDatetimeType, default: str = ""

724 ) -> Union[int, str]:

725 """

726 Age (in whole years) at a particular date, or default.

727 """

728 return get_age(self.dob, when, default=default)

729

730 def is_female(self) -> bool:

731 """

732 Is sex 'F'?

733 """

734 return self.sex == SEX_FEMALE

735

736 def is_male(self) -> bool:

737 """

738 Is sex 'M'?

739 """

740 return self.sex == SEX_MALE

741

742 def get_sex(self) -> str:

743 """

744 Return sex or "".

745 """

746 return self.sex or ""

747

748 def get_sex_verbose(self, default: str = "sex unknown") -> str:

749 """

750 Returns HTML-safe version of sex, or default.

751 """

752 return default if not self.sex else ws.webify(self.sex)

753

754 def get_address(self) -> Optional[str]:

755 """

756 Returns address (NOT necessarily web-safe).

757 """

758 address = self.address # type: Optional[str]

759 return address or ""

760

761 def get_email(self) -> Optional[str]:

762 """

763 Returns email address

764 """

765 email = self.email # type: Optional[str]

766 return email or ""

767

768 # -------------------------------------------------------------------------

769 # Other representations

770 # -------------------------------------------------------------------------

771

772 def get_xml_root(

773 self, req: "CamcopsRequest", options: TaskExportOptions = None

774 ) -> XmlElement:

775 """

776 Get root of XML tree, as an

777 :class:`camcops_server.cc_modules.cc_xml.XmlElement`.

778

779 Args:

780 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`

781 options: a :class:`camcops_server.cc_modules.cc_simpleobjects.TaskExportOptions`

782 """ # noqa

783 # No point in skipping old ID columns (1-8) now; they're gone.

784 branches = self._get_xml_branches(req, options=options)

785 # Now add new-style IDs:

786 pidnum_branches = [] # type: List[XmlElement]

787 pidnum_options = TaskExportOptions(

788 xml_include_plain_columns=True, xml_with_header_comments=False

789 )

790 for pidnum in self.idnums: # type: PatientIdNum

791 pidnum_branches.append(

792 pidnum._get_xml_root(req, options=pidnum_options)

793 )

794 branches.append(XmlElement(name="idnums", value=pidnum_branches))

795 # Special notes

796 branches.append(XML_COMMENT_SPECIAL_NOTES)

797 special_notes = self.special_notes # type: List[SpecialNote]

798 for sn in special_notes:

799 branches.append(sn.get_xml_root())

800 return XmlElement(name=self.__tablename__, value=branches)

801

802 def get_spreadsheet_page(self, req: "CamcopsRequest") -> SpreadsheetPage:

803 """

804 Get a :class:`camcops_server.cc_modules.cc_spreadsheet.SpreadsheetPage`

805 for the patient.

806 """

807 # 1. Our core fields.

808 page = self._get_core_spreadsheet_page(

809 req, heading_prefix=SPREADSHEET_PATIENT_FIELD_PREFIX

810 )

811 # 2. ID number details

812 # We can't just iterate through the ID numbers; we have to iterate

813 # through all possible ID numbers.

814 for iddef in req.idnum_definitions:

815 n = iddef.which_idnum

816 nstr = str(n)

817 shortdesc = iddef.short_description

818 longdesc = iddef.description

819 idnum_value = next(

820 (

821 idnum.idnum_value

822 for idnum in self.idnums

823 if idnum.which_idnum == n

824 and idnum.is_superficially_valid()

825 ),

826 None,

827 )

828 page.add_or_set_value(

829 heading=SPREADSHEET_PATIENT_FIELD_PREFIX + FP_ID_NUM + nstr,

830 value=idnum_value,

831 )

832 page.add_or_set_value(

833 heading=SPREADSHEET_PATIENT_FIELD_PREFIX + FP_ID_DESC + nstr,

834 value=longdesc,

835 )

836 page.add_or_set_value(

837 heading=(

838 SPREADSHEET_PATIENT_FIELD_PREFIX + FP_ID_SHORT_DESC + nstr

839 ),

840 value=shortdesc,

841 )

842 return page

843

844 def get_spreadsheet_schema_elements(

845 self, req: "CamcopsRequest", table_name: str = ""

846 ) -> Set[SummarySchemaInfo]:

847 """

848 Follows :func:`get_spreadsheet_page`, but retrieving schema

849 information.

850 """

851 # 1. Core fields

852 items = self._get_core_spreadsheet_schema(

853 table_name=table_name,

854 column_name_prefix=SPREADSHEET_PATIENT_FIELD_PREFIX,

855 )

856 # 2. ID number details

857 table_name = table_name or self.__tablename__

858 for iddef in req.idnum_definitions:

859 n = iddef.which_idnum

860 nstr = str(n)

861 comment_suffix = f" [ID#{n}]"

862 items.add(

863 SummarySchemaInfo(

864 table_name=table_name,

865 source=SummarySchemaInfo.SSV_DB,

866 column_name=(

867 SPREADSHEET_PATIENT_FIELD_PREFIX + FP_ID_NUM + nstr

868 ),

869 data_type=str(PatientIdNum.idnum_value.type),

870 comment=PatientIdNum.idnum_value.comment + comment_suffix,

871 )

872 )

873 items.add(

874 SummarySchemaInfo(

875 table_name=table_name,

876 source=SummarySchemaInfo.SSV_DB,

877 column_name=(

878 SPREADSHEET_PATIENT_FIELD_PREFIX + FP_ID_DESC + nstr

879 ),

880 data_type=str(IdNumDefinition.description.type),

881 comment=IdNumDefinition.description.comment

882 + comment_suffix,

883 )

884 )

885 items.add(

886 SummarySchemaInfo(

887 table_name=table_name,

888 source=SummarySchemaInfo.SSV_DB,

889 column_name=(

890 SPREADSHEET_PATIENT_FIELD_PREFIX

891 + FP_ID_SHORT_DESC

892 + nstr

893 ),

894 data_type=str(IdNumDefinition.short_description.type),

895 comment=(

896 IdNumDefinition.short_description.comment

897 + comment_suffix

898 ),

899 )

900 )

901 return items

902

903 def get_bare_ptinfo(self) -> BarePatientInfo:

904 """

905 Get basic identifying information, as a

906 :class:`camcops_server.cc_modules.cc_simpleobjects.BarePatientInfo`

907 object.

908 """

909 return BarePatientInfo(

910 forename=self.forename,

911 surname=self.surname,

912 sex=self.sex,

913 dob=self.dob, # type: ignore[arg-type]

914 address=self.address,

915 email=self.email,

916 gp=self.gp,

917 otherdetails=self.other,

918 idnum_definitions=self.get_idnum_references(),

919 )

920

921 def get_hl7_pid_segment(

922 self, req: "CamcopsRequest", recipient: "ExportRecipient"

923 ) -> hl7.Segment:

924 """

925 Get HL7 patient identifier (PID) segment.

926

927 Args:

928 req:

929 a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`

930 recipient:

931 a :class:`camcops_server.cc_modules.cc_exportrecipient.ExportRecipient`

932

933 Returns:

934 a :class:`hl7.Segment` object

935 """ # noqa

936 # Put the primary one first:

937 patient_id_tuple_list = [

938 HL7PatientIdentifier(

939 pid=str(self.get_idnum_value(recipient.primary_idnum)),

940 id_type=recipient.get_hl7_id_type(

941 req, recipient.primary_idnum

942 ),

943 assigning_authority=recipient.get_hl7_id_aa(

944 req, recipient.primary_idnum

945 ),

946 )

947 ]

948 # Then the rest:

949 for idobj in self.idnums:

950 which_idnum = idobj.which_idnum

951 if which_idnum == recipient.primary_idnum:

952 continue

953 idnum_value = idobj.idnum_value

954 if idnum_value is None:

955 continue

956 patient_id_tuple_list.append(

957 HL7PatientIdentifier(

958 pid=str(idnum_value),

959 id_type=recipient.get_hl7_id_type(req, which_idnum),

960 assigning_authority=recipient.get_hl7_id_aa(

961 req, which_idnum

962 ),

963 )

964 )

965 return make_pid_segment(

966 forename=self.get_surname(),

967 surname=self.get_forename(),

968 dob=self.get_dob(),

969 sex=self.get_sex(),

970 address=self.get_address(),

971 patient_id_list=patient_id_tuple_list,

972 )

973

974 # -------------------------------------------------------------------------

975 # FHIR

976 # -------------------------------------------------------------------------

977

978 def get_fhir_bundle_entry(

979 self, req: "CamcopsRequest", recipient: "ExportRecipient"

980 ) -> Dict[str, Any]:

981 """

982 Returns a dictionary, suitable for serializing to JSON, that

983 encapsulates patient identity information in a FHIR bundle.

984

985 See https://www.hl7.org/fhir/patient.html.

986 """

987 # The JSON objects we will build up:

988 patient_dict = {} # type: JsonObjectType

989

990 # Name

991 if self.forename or self.surname:

992 name_dict = {} # type: JsonObjectType

993 if self.forename:

994 name_dict[Fc.NAME_GIVEN] = [self.forename]

995 if self.surname:

996 name_dict[Fc.NAME_FAMILY] = self.surname

997 patient_dict[Fc.NAME] = [HumanName(jsondict=name_dict).as_json()]

998

999 # DOB

1000 if self.dob:

1001 patient_dict[Fc.BIRTHDATE] = format_datetime(

1002 self.dob, DateFormat.FILENAME_DATE_ONLY

1003 )

1004

1005 # Sex/gender (should always be present, per client minimum ID policy)

1006 if self.sex:

1007 gender_lookup = {

1008 SEX_FEMALE: Fc.GENDER_FEMALE,

1009 SEX_MALE: Fc.GENDER_MALE,

1010 SEX_OTHER_UNSPECIFIED: Fc.GENDER_OTHER,

1011 }

1012 patient_dict[Fc.GENDER] = gender_lookup.get(

1013 self.sex, Fc.GENDER_UNKNOWN

1014 )

1015

1016 # Address

1017 if self.address:

1018 patient_dict[Fc.ADDRESS] = [

1019 Address(jsondict={Fc.ADDRESS_TEXT: self.address}).as_json()

1020 ]

1021

1022 # Email

1023 if self.email:

1024 patient_dict[Fc.TELECOM] = [

1025 ContactPoint(

1026 jsondict={

1027 Fc.SYSTEM: Fc.TELECOM_SYSTEM_EMAIL,

1028 Fc.VALUE: self.email,

1029 }

1030 ).as_json()

1031 ]

1032

1033 # General practitioner (GP): via

1034 # fhirclient.models.fhirreference.FHIRReference; too structured.

1035

1036 # ID numbers go here:

1037 return make_fhir_bundle_entry(

1038 resource_type_url=Fc.RESOURCE_TYPE_PATIENT,

1039 identifier=self.get_fhir_identifier(req, recipient),

1040 resource=FhirPatient(jsondict=patient_dict).as_json(),

1041 )

1042

1043 def get_fhir_identifier(

1044 self, req: "CamcopsRequest", recipient: "ExportRecipient"

1045 ) -> Identifier:

1046 """

1047 Returns a FHIR identifier for this patient, as a

1048 :class:`fhirclient.models.identifier.Identifier` object.

1049

1050 This pairs a URL to our CamCOPS server indicating the ID number type

1051 (as the "system") with the actual ID number (as the "value").

1052

1053 For debugging situations, it falls back to a default identifier (using

1054 the PK on our CamCOPS server).

1055 """

1056 which_idnum = recipient.primary_idnum

1057 try:

1058 # For real exports, the fact that the patient does have an ID

1059 # number of the right type will have been pre-verified.

1060 if which_idnum is None:

1061 raise AttributeError

1062 idnum_object = self.get_idnum_object(which_idnum)

1063 idnum_value = idnum_object.idnum_value # may raise AttributeError

1064 iddef = req.get_idnum_definition(which_idnum)

1065 idnum_url = iddef.effective_fhir_id_system(req)

1066 return Identifier(

1067 jsondict={Fc.SYSTEM: idnum_url, Fc.VALUE: str(idnum_value)}

1068 )

1069 except AttributeError:

1070 # We are probably in a debugging/drafting situation. Fall back to

1071 # a default identifier.

1072 return fhir_pk_identifier(

1073 req,

1074 self.__tablename__,

1075 self.pk,

1076 Fc.CAMCOPS_VALUE_PATIENT_WITHIN_TASK,

1077 )

1078

1079 def get_fhir_subject_ref(

1080 self, req: "CamcopsRequest", recipient: "ExportRecipient"

1081 ) -> Dict:

1082 """

1083 Returns a FHIRReference (in JSON dict format) used to refer to this

1084 patient as a "subject" of some other entry (like a questionnaire).

1085 """

1086 return FHIRReference(

1087 jsondict={

1088 Fc.TYPE: Fc.RESOURCE_TYPE_PATIENT,

1089 Fc.IDENTIFIER: self.get_fhir_identifier(

1090 req, recipient

1091 ).as_json(),

1092 }

1093 ).as_json()

1094

1095 # -------------------------------------------------------------------------

1096 # Database status

1097 # -------------------------------------------------------------------------

1098

1099 def is_preserved(self) -> bool:

1100 """

1101 Is the patient record preserved and erased from the tablet?

1102 """

1103 return self._pk is not None and self._era != ERA_NOW

1104

1105 # -------------------------------------------------------------------------

1106 # Audit

1107 # -------------------------------------------------------------------------

1108

1109 def audit(

1110 self, req: "CamcopsRequest", details: str, from_console: bool = False

1111 ) -> None:

1112 """

1113 Audits an action to this patient.

1114 """

1115 audit(

1116 req,

1117 details,

1118 patient_server_pk=self._pk,

1119 table=Patient.__tablename__,

1120 server_pk=self._pk,

1121 from_console=from_console,

1122 )

1123

1124 # -------------------------------------------------------------------------

1125 # Special notes

1126 # -------------------------------------------------------------------------

1127

1128 def apply_special_note(

1129 self,

1130 req: "CamcopsRequest",

1131 note: str,

1132 audit_msg: str = "Special note applied manually",

1133 ) -> None:

1134 """

1135 Manually applies a special note to a patient.

1136 WRITES TO DATABASE.

1137 """

1138 sn = SpecialNote()

1139 sn.basetable = self.__tablename__

1140 sn.task_id = self.id # patient ID, in this case

1141 sn.device_id = self._device_id

1142 sn.era = self._era

1143 sn.note_at = req.now

1144 sn.user_id = req.user_id

1145 sn.note = note

1146 req.dbsession.add(sn)

1147 self.special_notes.append(sn) # type: ignore[attr-defined]

1148 self.audit(req, audit_msg)

1149 # HL7 deletion of corresponding tasks is done in camcops_server.py

1150

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

1152 # Deletion

1153 # -------------------------------------------------------------------------

1154

1155 def gen_patient_idnums_even_noncurrent(

1156 self,

1157 ) -> Generator[PatientIdNum, None, None]:

1158 """

1159 Generates all :class:`PatientIdNum` objects, including non-current

1160 ones.

1161 """

1162 for lineage_member in self._gen_unique_lineage_objects( # type: ignore[assignment] # noqa: E501

1163 self.idnums

1164 ): # type: PatientIdNum

1165 yield lineage_member

1166

1167 def delete_with_dependants(self, req: "CamcopsRequest") -> None:

1168 """

1169 Delete the patient with all its dependent objects.

1170 """

1171 if self._pk is None:

1172 return

1173 for pidnum in self.gen_patient_idnums_even_noncurrent():

1174 req.dbsession.delete(pidnum)

1175 super().delete_with_dependants(req)

1176

1177 # -------------------------------------------------------------------------

1178 # Permissions

1179 # -------------------------------------------------------------------------

1180

1181 def user_may_view(self, user: "User") -> bool:

1182 """

1183 May this user inspect patient details directly?

1184 """

1185 return self._group_id in user.ids_of_groups_user_may_see

1186

1187 def user_may_edit(self, req: "CamcopsRequest") -> bool:

1188 """

1189 Does the current user have permission to edit this patient?

1190 """

1191 if self.created_on_server(req):

1192 # Anyone in the group with the right permission

1193 return req.user.may_manage_patients_in_group(self._group_id)

1194

1195 # Finalized patient: Need to be group administrator

1196 return req.user.may_administer_group(self._group_id)

1197

1198 # --------------------------------------------------------------------------

1199 # UUID

1200 # --------------------------------------------------------------------------

1201 @property

1202 def uuid_as_proquint(self) -> Optional[str]:

1203 # Convert integer into pronounceable quintuplets (proquint)

1204 # https://arxiv.org/html/0901.4016

1205 if self.uuid is None:

1206 return None

1207

1208 return proquint_from_uuid(self.uuid)

1209

1210 @property

1211 def duplicates(self) -> set["Patient"]:

1212 """

1213 Returns a set of patients that have an ID Number that matches this one.

1214 For a patient to be considered a duplicate, the ID number needs to

1215 match on:

1216

1217 * Group

1218 * Type of ID Number (e.g. NHS Number)

1219 * Value of ID number

1220 * Device where patient was created

1221

1222 and have the "current" flag set to True

1223 """

1224

1225 dups = set()

1226

1227 for idnum in self.idnums:

1228 for dup in idnum.duplicates:

1229 dups.add(dup.patient)

1230

1231 return dups

1232

1233

1234# =============================================================================

1235# Validate candidate patient info for upload

1236# =============================================================================

1237

1238

1239 def is_candidate_patient_valid_for_group(

1240 ptinfo: BarePatientInfo, group: "Group", finalizing: bool

1241) -> Tuple[bool, str]:

1242 """

1243 Is the specified patient acceptable to upload into this group?

1244

1245 Checks:

1246

1247 - group upload or finalize policy

1248

1249 .. todo:: is_candidate_patient_valid: check against predefined patients, if

1250 the group wants

1251

1252 Args:

1253 ptinfo:

1254 a

1255 :class:`camcops_server.cc_modules.cc_simpleobjects.BarePatientInfo`

1256 representing the patient info to check

1257 group:

1258 the :class:`camcops_server.cc_modules.cc_group.Group` into which

1259 this patient will be uploaded, if allowed

1260 finalizing:

1261 finalizing, rather than uploading?

1262

1263 Returns:

1264 tuple: valid, reason

1265

1266 """

1267 if not group:

1268 return False, "Nonexistent group"

1269

1270 if finalizing:

1271 if not group.tokenized_finalize_policy().satisfies_id_policy(ptinfo):

1272 return False, "Fails finalizing ID policy"

1273 else:

1274 if not group.tokenized_upload_policy().satisfies_id_policy(ptinfo):

1275 return False, "Fails upload ID policy"

1276

1277 # todo: add checks against prevalidated patients here

1278

1279 return True, ""

1280

1281

1282 def is_candidate_patient_valid_for_restricted_user(

1283 req: "CamcopsRequest", ptinfo: BarePatientInfo

1284) -> Tuple[bool, str]:

1285 """

1286 Is the specified patient OK to be uploaded by this user? Performs a check

1287 for restricted (single-patient) users; if true, ensures that the

1288 identifiers all match the expected patient.

1289

1290 Args:

1291 req:

1292 the :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`

1293 ptinfo:

1294 a

1295 :class:`camcops_server.cc_modules.cc_simpleobjects.BarePatientInfo`

1296 representing the patient info to check

1297

1298 Returns:

1299 tuple: valid, reason

1300 """

1301 user = req.user

1302 if not user.auto_generated:

1303 # Not a restricted user; no problem.

1304 return True, ""

1305

1306 server_patient = user.single_patient

1307 if not server_patient:

1308 return (

1309 False,

1310 (

1311 f"Restricted user {user.username} does not have associated "

1312 f"patient details"

1313 ),

1314 )

1315

1316 server_ptinfo = server_patient.get_bare_ptinfo()

1317 if ptinfo != server_ptinfo:

1318 return False, f"Should be {server_ptinfo}"

1319

1320 return True, ""

1321

1322

1323# =============================================================================

1324# Reports

1325# =============================================================================

1326

1327

1328 class DistinctPatientReport(Report):

1329 """

1330 Report to show distinct patients.

1331 """

1332

1333 # noinspection PyMethodParameters

1334 @classproperty

1335 def report_id(cls) -> str:

1336 return "patient_distinct"

1337

1338 @classmethod

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

1340 _ = req.gettext

1341 return _(

1342 "(Server) Patients, distinct by name, sex, DOB, all ID " "numbers"

1343 )

1344

1345 # noinspection PyMethodParameters

1346 @classproperty

1347 def superuser_only(cls) -> bool:

1348 return False

1349

1350 # noinspection PyProtectedMember

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

1352 select_fields: list[ColumnElement[Any]] = [

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

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

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

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

1357 ]

1358 # noinspection PyUnresolvedReferences

1359 select_from = Patient.__table__

1360 wheres = [

1361 Patient._current == True # noqa: E712

1362 ] # type: List[ClauseElement]

1363 if not req.user.superuser:

1364 # Restrict to accessible groups

1365 group_ids = req.user.ids_of_groups_user_may_report_on

1366 wheres.append(Patient._group_id.in_(group_ids))

1367 for iddef in req.idnum_definitions:

1368 n = iddef.which_idnum

1369 desc = iddef.short_description

1370 # noinspection PyUnresolvedReferences

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

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

1373 select_from = select_from.outerjoin(

1374 aliased_table,

1375 and_(

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

1377 aliased_table.c._device_id == Patient._device_id,

1378 aliased_table.c._era == Patient._era,

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

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

1381 # the OUTER JOIN):

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

1383 aliased_table.c.which_idnum == n,

1384 ),

1385 ) # nopep8

1386 order_by = [

1387 Patient.surname,

1388 Patient.forename,

1389 Patient.dob,

1390 Patient.sex,

1391 ]

1392 query = (

1393 select(*select_fields)

1394 .select_from(select_from)

1395 .where(and_(*wheres)) # type: ignore[arg-type]

1396 .order_by(*order_by)

1397 .distinct()

1398 )

1399 return query