Hide keyboard shortcuts

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 

2 

3""" 

4camcops_server/cc_modules/cc_patient.py 

5 

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

7 

8 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com). 

9 

10 This file is part of CamCOPS. 

11 

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. 

16 

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. 

21 

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/>. 

24 

25=============================================================================== 

26 

27**Patients.** 

28 

29""" 

30 

31import logging 

32from typing import ( 

33 Any, Dict, Generator, List, Optional, Tuple, TYPE_CHECKING, Union, 

34) 

35import uuid 

36 

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 

50 

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 

62 

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) 

108 

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 

115 

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

117 

118 

119# ============================================================================= 

120# Patient class 

121# ============================================================================= 

122 

123class Patient(GenericTabletRecordMixin, Base): 

124 """ 

125 Class representing a patient. 

126 """ 

127 __tablename__ = "patient" 

128 

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] 

209 

210 task_schedules = relationship( 

211 "PatientTaskSchedule", 

212 back_populates="patient", 

213 cascade="all, delete" 

214 ) # type: List[PatientTaskSchedule] 

215 

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 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

248 

249 # ------------------------------------------------------------------------- 

250 # Relationships 

251 # ------------------------------------------------------------------------- 

252 

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 ) 

279 

280 # ------------------------------------------------------------------------- 

281 # Patient-fetching classmethods 

282 # ------------------------------------------------------------------------- 

283 

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. 

293 

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? 

300 

301 Returns: 

302 list of all matching patients 

303 

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 

320 

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() 

328 

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 ) 

344 

345 # ------------------------------------------------------------------------- 

346 # String representations 

347 # ------------------------------------------------------------------------- 

348 

349 def __str__(self) -> str: 

350 """ 

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

352 

353 Example: 

354 

355 .. code-block:: none 

356 

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 ) 

365 

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

367 """ 

368 A prettified string version. 

369 

370 Args: 

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

372 

373 Example: 

374 

375 .. code-block:: none 

376 

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 ) 

385 

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". 

390 

391 Example: 

392 

393 .. code-block:: none 

394 

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 ) 

403 

404 # ------------------------------------------------------------------------- 

405 # Equality 

406 # ------------------------------------------------------------------------- 

407 

408 def __eq__(self, other: "Patient") -> bool: 

409 """ 

410 Is this patient the same as another? 

411 

412 .. code-block:: python 

413 

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 

421 

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

423 

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). 

428 

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 

452 

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" 

462 

463 # ------------------------------------------------------------------------- 

464 # ID numbers 

465 # ------------------------------------------------------------------------- 

466 

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

468 """ 

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

470 

471 These are SQLAlchemy ORM objects. 

472 """ 

473 return self.idnums 

474 

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. 

480 

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()] 

486 

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()] 

494 

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 

504 

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 

510 

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 

517 

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) 

542 

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 

550 

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 

558 

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. 

563 

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 

571 

572 # ------------------------------------------------------------------------- 

573 # Group 

574 # ------------------------------------------------------------------------- 

575 

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 

583 

584 # ------------------------------------------------------------------------- 

585 # Policies 

586 # ------------------------------------------------------------------------- 

587 

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()) 

596 

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()) 

605 

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()) 

611 

612 # ------------------------------------------------------------------------- 

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

614 # ------------------------------------------------------------------------- 

615 

616 def get_surname(self) -> str: 

617 """ 

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

619 """ 

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

621 

622 def get_forename(self) -> str: 

623 """ 

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

625 """ 

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

627 

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}" 

636 

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}" 

644 

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)) 

653 

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}." 

660 

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) 

668 

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) 

677 

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) 

686 

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) 

694 

695 def is_female(self) -> bool: 

696 """ 

697 Is sex 'F'? 

698 """ 

699 return self.sex == SEX_FEMALE 

700 

701 def is_male(self) -> bool: 

702 """ 

703 Is sex 'M'? 

704 """ 

705 return self.sex == SEX_MALE 

706 

707 def get_sex(self) -> str: 

708 """ 

709 Return sex or "". 

710 """ 

711 return self.sex or "" 

712 

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) 

718 

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 "" 

725 

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

727 """ 

728 Returns email address 

729 """ 

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

731 return email or "" 

732 

733 # ------------------------------------------------------------------------- 

734 # Other representations 

735 # ------------------------------------------------------------------------- 

736 

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`. 

742 

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) 

766 

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 

798 

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 ) 

816 

817 def get_hl7_pid_segment(self, 

818 req: "CamcopsRequest", 

819 recipient: "ExportRecipient") -> hl7.Segment: 

820 """ 

821 Get HL7 patient identifier (PID) segment. 

822 

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` 

828 

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 ) 

868 

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) 

876 

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 }) 

883 

884 gender_lookup = { 

885 "F": "female", 

886 "M": "male", 

887 "X": "other", 

888 } 

889 

890 fhir_patient = FhirPatient(jsondict={ 

891 "identifier": [identifier.as_json()], 

892 "name": [name.as_json()], 

893 "gender": gender_lookup.get(self.sex, "unknown") 

894 }) 

895 

896 bundle_request = BundleEntryRequest(jsondict={ 

897 "method": "POST", 

898 "url": "Patient", 

899 "ifNoneExist": f"identifier={identifier.system}|{identifier.value}", 

900 }) 

901 

902 return BundleEntry(jsondict={ 

903 "resource": fhir_patient.as_json(), 

904 "request": bundle_request.as_json() 

905 }).as_json() 

906 

907 def get_fhir_identifier(self, 

908 req: "CamcopsRequest", 

909 recipient: "ExportRecipient") -> Identifier: 

910 which_idnum = recipient.primary_idnum 

911 

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 ) 

918 

919 return Identifier(jsondict={ 

920 "system": idnum_url, 

921 "value": str(idnum_value), 

922 }) 

923 

924 # ------------------------------------------------------------------------- 

925 # Database status 

926 # ------------------------------------------------------------------------- 

927 

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 

933 

934 # ------------------------------------------------------------------------- 

935 # Audit 

936 # ------------------------------------------------------------------------- 

937 

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) 

949 

950 # ------------------------------------------------------------------------- 

951 # Special notes 

952 # ------------------------------------------------------------------------- 

953 

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 

975 

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

977 # Deletion 

978 # ------------------------------------------------------------------------- 

979 

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 

988 

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) 

998 

999 # ------------------------------------------------------------------------- 

1000 # Editing 

1001 # ------------------------------------------------------------------------- 

1002 

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 

1012 

1013 def created_on_server(self, req: "CamcopsRequest") -> bool: 

1014 server_device = Device.get_server_device(req.dbsession) 

1015 

1016 return (self._era == ERA_NOW and 

1017 self._device_id == server_device.id) 

1018 

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) 

1024 

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 

1034 

1035 return proquint_from_uuid(self.uuid) 

1036 

1037 

1038# ============================================================================= 

1039# Validate candidate patient info for upload 

1040# ============================================================================= 

1041 

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? 

1047 

1048 Checks: 

1049 

1050 - group upload or finalize policy 

1051 

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

1053 the group wants 

1054 

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? 

1065 

1066 Returns: 

1067 tuple: valid, reason 

1068 

1069 """ 

1070 if not group: 

1071 return False, "Nonexistent group" 

1072 

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" 

1079 

1080 # todo: add checks against prevalidated patients here 

1081 

1082 return True, "" 

1083 

1084 

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. 

1092 

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 

1100 

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, "" 

1108 

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 ) 

1115 

1116 server_ptinfo = server_patient.get_bare_ptinfo() 

1117 if ptinfo != server_ptinfo: 

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

1119 

1120 return True, "" 

1121 

1122 

1123# ============================================================================= 

1124# Reports 

1125# ============================================================================= 

1126 

1127class DistinctPatientReport(Report): 

1128 """ 

1129 Report to show distinct patients. 

1130 """ 

1131 

1132 # noinspection PyMethodParameters 

1133 @classproperty 

1134 def report_id(cls) -> str: 

1135 return "patient_distinct" 

1136 

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") 

1142 

1143 # noinspection PyMethodParameters 

1144 @classproperty 

1145 def superuser_only(cls) -> bool: 

1146 return False 

1147 

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