Coverage for cc_modules/cc_patient.py: 34%

386 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-11-08 23:14 +0000

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/cc_modules/cc_patient.py 

5 

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

7 

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

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

10 

11 This file is part of CamCOPS. 

12 

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

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

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

16 (at your option) any later version. 

17 

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

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

20 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

21 GNU General Public License for more details. 

22 

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

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

25 

26=============================================================================== 

27 

28**Patients.** 

29 

30""" 

31 

32import logging 

33from typing import ( 

34 Any, 

35 Dict, 

36 Generator, 

37 List, 

38 Optional, 

39 Set, 

40 Tuple, 

41 TYPE_CHECKING, 

42 Union, 

43) 

44import uuid 

45 

46from cardinal_pythonlib.classes import classproperty 

47from cardinal_pythonlib.datetimefunc import ( 

48 coerce_to_pendulum_date, 

49 format_datetime, 

50 get_age, 

51 PotentialDatetimeType, 

52) 

53from cardinal_pythonlib.json.typing_helpers import JsonObjectType 

54from cardinal_pythonlib.logs import BraceStyleAdapter 

55import cardinal_pythonlib.rnc_web as ws 

56from fhirclient.models.address import Address 

57from fhirclient.models.contactpoint import ContactPoint 

58from fhirclient.models.humanname import HumanName 

59from fhirclient.models.fhirreference import FHIRReference 

60from fhirclient.models.identifier import Identifier 

61from fhirclient.models.patient import Patient as FhirPatient 

62import hl7 

63import pendulum 

64from sqlalchemy.ext.declarative import declared_attr 

65from sqlalchemy.orm import relationship 

66from sqlalchemy.orm import Session as SqlASession 

67from sqlalchemy.orm.relationships import RelationshipProperty 

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

69from sqlalchemy.sql.schema import Column 

70from sqlalchemy.sql.selectable import SelectBase 

71from sqlalchemy.sql import sqltypes 

72from sqlalchemy.sql.sqltypes import Integer, UnicodeText 

73 

74from camcops_server.cc_modules.cc_audit import audit 

75from camcops_server.cc_modules.cc_constants import ( 

76 DateFormat, 

77 ERA_NOW, 

78 FHIRConst as Fc, 

79 FP_ID_DESC, 

80 FP_ID_SHORT_DESC, 

81 FP_ID_NUM, 

82 SEX_FEMALE, 

83 SEX_MALE, 

84 SEX_OTHER_UNSPECIFIED, 

85 SPREADSHEET_PATIENT_FIELD_PREFIX, 

86) 

87from camcops_server.cc_modules.cc_dataclasses import SummarySchemaInfo 

88from camcops_server.cc_modules.cc_db import ( 

89 GenericTabletRecordMixin, 

90 PFN_UUID, 

91 TABLET_ID_FIELD, 

92) 

93from camcops_server.cc_modules.cc_fhir import ( 

94 fhir_pk_identifier, 

95 make_fhir_bundle_entry, 

96) 

97from camcops_server.cc_modules.cc_hl7 import make_pid_segment 

98from camcops_server.cc_modules.cc_html import answer 

99from camcops_server.cc_modules.cc_idnumdef import IdNumDefinition 

100from camcops_server.cc_modules.cc_simpleobjects import ( 

101 BarePatientInfo, 

102 HL7PatientIdentifier, 

103) 

104from camcops_server.cc_modules.cc_patientidnum import ( 

105 extra_id_colname, 

106 PatientIdNum, 

107) 

108from camcops_server.cc_modules.cc_proquint import proquint_from_uuid 

109from camcops_server.cc_modules.cc_report import Report 

110from camcops_server.cc_modules.cc_simpleobjects import ( 

111 IdNumReference, 

112 TaskExportOptions, 

113) 

114from camcops_server.cc_modules.cc_specialnote import SpecialNote 

115from camcops_server.cc_modules.cc_sqla_coltypes import ( 

116 CamcopsColumn, 

117 EmailAddressColType, 

118 PatientNameColType, 

119 SexColType, 

120 UuidColType, 

121) 

122from camcops_server.cc_modules.cc_sqlalchemy import Base 

123from camcops_server.cc_modules.cc_spreadsheet import SpreadsheetPage 

124from camcops_server.cc_modules.cc_version import CAMCOPS_SERVER_VERSION_STRING 

125from camcops_server.cc_modules.cc_xml import ( 

126 XML_COMMENT_SPECIAL_NOTES, 

127 XmlElement, 

128) 

129 

130if TYPE_CHECKING: 

131 from camcops_server.cc_modules.cc_exportrecipient import ExportRecipient 

132 from camcops_server.cc_modules.cc_group import Group 

133 from camcops_server.cc_modules.cc_policy import TokenizedPolicy 

134 from camcops_server.cc_modules.cc_request import CamcopsRequest 

135 from camcops_server.cc_modules.cc_taskschedule import PatientTaskSchedule 

136 from camcops_server.cc_modules.cc_user import User 

137 

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

139 

140 

141# ============================================================================= 

142# Patient class 

143# ============================================================================= 

144 

145 

146class Patient(GenericTabletRecordMixin, Base): 

147 """ 

148 Class representing a patient. 

149 """ 

150 

151 __tablename__ = "patient" 

152 

153 id = Column( 

154 TABLET_ID_FIELD, 

155 Integer, 

156 nullable=False, 

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

158 # client PK 

159 ) 

160 uuid = CamcopsColumn( 

161 PFN_UUID, 

162 UuidColType, 

163 comment="UUID", 

164 default=uuid.uuid4, # generates a random UUID 

165 ) # type: Optional[uuid.UUID] 

166 forename = CamcopsColumn( 

167 "forename", 

168 PatientNameColType, 

169 index=True, 

170 identifies_patient=True, 

171 include_in_anon_staging_db=True, 

172 comment="Forename", 

173 ) # type: Optional[str] 

174 surname = CamcopsColumn( 

175 "surname", 

176 PatientNameColType, 

177 index=True, 

178 identifies_patient=True, 

179 include_in_anon_staging_db=True, 

180 comment="Surname", 

181 ) # type: Optional[str] 

182 dob = CamcopsColumn( 

183 "dob", 

184 sqltypes.Date, # verified: merge_db handles this correctly 

185 index=True, 

186 identifies_patient=True, 

187 include_in_anon_staging_db=True, 

188 comment="Date of birth" 

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

190 ) 

191 sex = CamcopsColumn( 

192 "sex", 

193 SexColType, 

194 index=True, 

195 include_in_anon_staging_db=True, 

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

197 ) 

198 address = CamcopsColumn( 

199 "address", UnicodeText, identifies_patient=True, comment="Address" 

200 ) 

201 email = CamcopsColumn( 

202 "email", 

203 EmailAddressColType, 

204 identifies_patient=True, 

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

206 ) 

207 gp = CamcopsColumn( 

208 "gp", 

209 UnicodeText, 

210 identifies_patient=True, 

211 comment="General practitioner (GP)", 

212 ) 

213 other = CamcopsColumn( 

214 "other", UnicodeText, identifies_patient=True, comment="Other details" 

215 ) 

216 idnums = relationship( 

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

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

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

220 "PatientIdNum", 

221 primaryjoin=( 

222 "and_(" 

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

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

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

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

227 ")" 

228 ), 

229 uselist=True, 

230 viewonly=True, 

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

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

233 # lazy="select" : 35.3s 

234 # lazy="joined" : 27.3s 

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

236 # lazy="selectin": 26.4s 

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

238 lazy="subquery", 

239 ) # type: List[PatientIdNum] 

240 

241 task_schedules = relationship( 

242 "PatientTaskSchedule", back_populates="patient", cascade="all, delete" 

243 ) # type: List[PatientTaskSchedule] 

244 

245 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

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

247 # THE UPLOAD API. 

248 # 

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

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

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

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

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

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

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

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

257 # 

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

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

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

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

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

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

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

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

266 # 

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

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

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

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

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

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

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

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

275 # 

276 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

277 

278 # ------------------------------------------------------------------------- 

279 # Relationships 

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

281 

282 # noinspection PyMethodParameters 

283 @declared_attr 

284 def special_notes(cls) -> RelationshipProperty: 

285 """ 

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

287 patient. 

288 """ 

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

290 # like this: 

291 return relationship( 

292 SpecialNote, 

293 primaryjoin=( 

294 "and_(" 

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

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

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

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

299 " not_(SpecialNote.hidden)" 

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

301 ), 

302 uselist=True, 

303 order_by="SpecialNote.note_at", 

304 viewonly=True, # for now! 

305 ) 

306 

307 # ------------------------------------------------------------------------- 

308 # Patient-fetching classmethods 

309 # ------------------------------------------------------------------------- 

310 

311 @classmethod 

312 def get_patients_by_idnum( 

313 cls, 

314 dbsession: SqlASession, 

315 which_idnum: int, 

316 idnum_value: int, 

317 group_id: int = None, 

318 current_only: bool = True, 

319 ) -> List["Patient"]: 

320 """ 

321 Get all patients matching the specified ID number. 

322 

323 Args: 

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

325 which_idnum: which ID number type? 

326 idnum_value: actual value of the ID number 

327 group_id: optional group ID to restrict to 

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

329 

330 Returns: 

331 list of all matching patients 

332 

333 """ 

334 if not which_idnum or which_idnum < 1: 

335 return [] 

336 if idnum_value is None: 

337 return [] 

338 q = dbsession.query(cls).join(cls.idnums) 

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

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

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

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

343 if group_id is not None: 

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

345 if current_only: 

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

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

348 return patients 

349 

350 @classmethod 

351 def get_patient_by_pk( 

352 cls, dbsession: SqlASession, server_pk: int 

353 ) -> Optional["Patient"]: 

354 """ 

355 Fetch a patient by the server PK. 

356 """ 

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

358 

359 @classmethod 

360 def get_patient_by_id_device_era( 

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

362 ) -> Optional["Patient"]: 

363 """ 

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

365 """ 

366 return ( 

367 dbsession.query(cls) 

368 .filter(cls.id == client_id) 

369 .filter(cls._device_id == device_id) 

370 .filter(cls._era == era) 

371 .first() 

372 ) 

373 

374 # ------------------------------------------------------------------------- 

375 # String representations 

376 # ------------------------------------------------------------------------- 

377 

378 def __str__(self) -> str: 

379 """ 

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

381 

382 Example: 

383 

384 .. code-block:: none 

385 

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

387 """ 

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

389 sf=self.get_surname_forename_upper(), 

390 sex=self.sex, 

391 dob=self.get_dob_str(), 

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

393 ) 

394 

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

396 """ 

397 A prettified string version. 

398 

399 Args: 

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

401 

402 Example: 

403 

404 .. code-block:: none 

405 

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

407 """ 

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

409 sf=self.get_surname_forename_upper(), 

410 sex=self.sex, 

411 dob=self.get_dob_str(), 

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

413 ) 

414 

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

416 """ 

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

418 say "it's about this patient". 

419 

420 Example: 

421 

422 .. code-block:: none 

423 

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

425 """ 

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

427 fs=self.get_forename_surname(), 

428 dob=self.get_dob_str(), 

429 ids=", ".join( 

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

431 ), 

432 ) 

433 

434 # ------------------------------------------------------------------------- 

435 # Equality 

436 # ------------------------------------------------------------------------- 

437 

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

439 """ 

440 Is this patient the same as another? 

441 

442 .. code-block:: python 

443 

444 from camcops_server.cc_modules.cc_patient import Patient 

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

446 print(p1 == p1) # True 

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

448 print(p1 == p2) # True 

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

450 print(p1 == p3) # False 

451 

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

453 

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

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

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

457 equal). 

458 

459 """ 

460 # Same object? 

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

462 if self is other: 

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

464 return True 

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

466 if ( 

467 self.id == other.id 

468 and self._device_id == other._device_id 

469 and self._era == other._era 

470 and self.id is not None 

471 and self._device_id is not None 

472 and self._era is not None 

473 ): 

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

475 return True 

476 # Shared ID number? 

477 for sid in self.idnums: 

478 if sid in other.idnums: 

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

480 return True 

481 # Otherwise... 

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

483 return False 

484 

485 def __hash__(self) -> int: 

486 """ 

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

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

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

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

491 equal). 

492 """ 

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

494 

495 # ------------------------------------------------------------------------- 

496 # ID numbers 

497 # ------------------------------------------------------------------------- 

498 

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

500 """ 

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

502 

503 These are SQLAlchemy ORM objects. 

504 """ 

505 return self.idnums 

506 

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

508 """ 

509 Returns all 

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

511 objects for the patient. 

512 

513 These are simple which_idnum/idnum_value pairs. 

514 """ 

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

516 return [ 

517 x.get_idnum_reference() 

518 for x in idnums 

519 if x.is_superficially_valid() 

520 ] 

521 

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

523 """ 

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

525 represent) for the patient. 

526 """ 

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

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

529 

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

531 """ 

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

533 """ 

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

535 for x in idnums: 

536 if x.which_idnum == which_idnum: 

537 return x 

538 return None 

539 

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

541 """ 

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

543 """ 

544 return self.get_idnum_object(which_idnum) is not None 

545 

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

547 """ 

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

549 """ 

550 idobj = self.get_idnum_object(which_idnum) 

551 return idobj.idnum_value if idobj else None 

552 

553 def set_idnum_value( 

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

555 ) -> None: 

556 """ 

557 Sets an ID number value. 

558 """ 

559 dbsession = req.dbsession 

560 ccsession = req.camcops_session 

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

562 for idobj in idnums: 

563 if idobj.which_idnum == which_idnum: 

564 idobj.idnum_value = idnum_value 

565 return 

566 # Otherwise, make a new one: 

567 newid = PatientIdNum() 

568 newid.patient_id = self.id 

569 newid._device_id = self._device_id 

570 newid._era = self._era 

571 newid._current = True 

572 newid._when_added_exact = req.now_era_format 

573 newid._when_added_batch_utc = req.now_utc 

574 newid._adding_user_id = ccsession.user_id 

575 newid._camcops_version = CAMCOPS_SERVER_VERSION_STRING 

576 dbsession.add(newid) 

577 self.idnums.append(newid) 

578 

579 def get_iddesc( 

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

581 ) -> Optional[str]: 

582 """ 

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

584 """ 

585 idobj = self.get_idnum_object(which_idnum) 

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

587 

588 def get_idshortdesc( 

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

590 ) -> Optional[str]: 

591 """ 

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

593 """ 

594 idobj = self.get_idnum_object(which_idnum) 

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

596 

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

598 """ 

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

600 number info to a row. 

601 

602 Args: 

603 row: future database row, as a dictionary 

604 """ 

605 for idobj in self.idnums: 

606 which_idnum = idobj.which_idnum 

607 fieldname = extra_id_colname(which_idnum) 

608 row[fieldname] = idobj.idnum_value 

609 

610 # ------------------------------------------------------------------------- 

611 # Group 

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

613 

614 @property 

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

616 """ 

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

618 this patient's record belongs. 

619 """ 

620 return self._group 

621 

622 # ------------------------------------------------------------------------- 

623 # Policies 

624 # ------------------------------------------------------------------------- 

625 

626 def satisfies_upload_id_policy(self) -> bool: 

627 """ 

628 Does the patient satisfy the uploading ID policy? 

629 """ 

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

631 if not group: 

632 return False 

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

634 

635 def satisfies_finalize_id_policy(self) -> bool: 

636 """ 

637 Does the patient satisfy the finalizing ID policy? 

638 """ 

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

640 if not group: 

641 return False 

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

643 

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

645 """ 

646 Does the patient satisfy a particular ID policy? 

647 """ 

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

649 

650 # ------------------------------------------------------------------------- 

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

652 # ------------------------------------------------------------------------- 

653 

654 def get_surname(self) -> str: 

655 """ 

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

657 """ 

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

659 

660 def get_forename(self) -> str: 

661 """ 

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

663 """ 

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

665 

666 def get_forename_surname(self) -> str: 

667 """ 

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

669 details. 

670 """ 

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

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

673 return f"{f} {s}" 

674 

675 def get_surname_forename_upper(self) -> str: 

676 """ 

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

678 """ 

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

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

681 return f"{s}, {f}" 

682 

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

684 """ 

685 HTML fragment for date of birth. 

686 """ 

687 _ = req.gettext 

688 if longform: 

689 dob = answer( 

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

691 ) 

692 

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

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

695 else: 

696 dobtext = _("DOB:") 

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

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

699 

700 def get_age( 

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

702 ) -> Union[int, str]: 

703 """ 

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

705 """ 

706 now = req.now 

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

708 

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

710 """ 

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

712 """ 

713 dob = self.dob 

714 if not dob: 

715 return None 

716 return coerce_to_pendulum_date(dob) 

717 

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

719 """ 

720 Date of birth, as a string. 

721 """ 

722 dob_dt = self.get_dob() 

723 if dob_dt is None: 

724 return None 

725 return format_datetime(dob_dt, DateFormat.SHORT_DATE) 

726 

727 def get_age_at( 

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

729 ) -> Union[int, str]: 

730 """ 

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

732 """ 

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

734 

735 def is_female(self) -> bool: 

736 """ 

737 Is sex 'F'? 

738 """ 

739 return self.sex == SEX_FEMALE 

740 

741 def is_male(self) -> bool: 

742 """ 

743 Is sex 'M'? 

744 """ 

745 return self.sex == SEX_MALE 

746 

747 def get_sex(self) -> str: 

748 """ 

749 Return sex or "". 

750 """ 

751 return self.sex or "" 

752 

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

754 """ 

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

756 """ 

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

758 

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

760 """ 

761 Returns address (NOT necessarily web-safe). 

762 """ 

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

764 return address or "" 

765 

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

767 """ 

768 Returns email address 

769 """ 

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

771 return email or "" 

772 

773 # ------------------------------------------------------------------------- 

774 # Other representations 

775 # ------------------------------------------------------------------------- 

776 

777 def get_xml_root( 

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

779 ) -> XmlElement: 

780 """ 

781 Get root of XML tree, as an 

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

783 

784 Args: 

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

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

787 """ # noqa 

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

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

790 # Now add new-style IDs: 

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

792 pidnum_options = TaskExportOptions( 

793 xml_include_plain_columns=True, xml_with_header_comments=False 

794 ) 

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

796 pidnum_branches.append( 

797 pidnum._get_xml_root(req, options=pidnum_options) 

798 ) 

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

800 # Special notes 

801 branches.append(XML_COMMENT_SPECIAL_NOTES) 

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

803 for sn in special_notes: 

804 branches.append(sn.get_xml_root()) 

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

806 

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

808 """ 

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

810 for the patient. 

811 """ 

812 # 1. Our core fields. 

813 page = self._get_core_spreadsheet_page( 

814 req, heading_prefix=SPREADSHEET_PATIENT_FIELD_PREFIX 

815 ) 

816 # 2. ID number details 

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

818 # through all possible ID numbers. 

819 for iddef in req.idnum_definitions: 

820 n = iddef.which_idnum 

821 nstr = str(n) 

822 shortdesc = iddef.short_description 

823 longdesc = iddef.description 

824 idnum_value = next( 

825 ( 

826 idnum.idnum_value 

827 for idnum in self.idnums 

828 if idnum.which_idnum == n 

829 and idnum.is_superficially_valid() 

830 ), 

831 None, 

832 ) 

833 page.add_or_set_value( 

834 heading=SPREADSHEET_PATIENT_FIELD_PREFIX + FP_ID_NUM + nstr, 

835 value=idnum_value, 

836 ) 

837 page.add_or_set_value( 

838 heading=SPREADSHEET_PATIENT_FIELD_PREFIX + FP_ID_DESC + nstr, 

839 value=longdesc, 

840 ) 

841 page.add_or_set_value( 

842 heading=( 

843 SPREADSHEET_PATIENT_FIELD_PREFIX + FP_ID_SHORT_DESC + nstr 

844 ), 

845 value=shortdesc, 

846 ) 

847 return page 

848 

849 def get_spreadsheet_schema_elements( 

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

851 ) -> Set[SummarySchemaInfo]: 

852 """ 

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

854 information. 

855 """ 

856 # 1. Core fields 

857 items = self._get_core_spreadsheet_schema( 

858 table_name=table_name, 

859 column_name_prefix=SPREADSHEET_PATIENT_FIELD_PREFIX, 

860 ) 

861 # 2. ID number details 

862 table_name = table_name or self.__tablename__ 

863 for iddef in req.idnum_definitions: 

864 n = iddef.which_idnum 

865 nstr = str(n) 

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

867 items.add( 

868 SummarySchemaInfo( 

869 table_name=table_name, 

870 source=SummarySchemaInfo.SSV_DB, 

871 column_name=( 

872 SPREADSHEET_PATIENT_FIELD_PREFIX + FP_ID_NUM + nstr 

873 ), 

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

875 comment=PatientIdNum.idnum_value.comment + comment_suffix, 

876 ) 

877 ) 

878 items.add( 

879 SummarySchemaInfo( 

880 table_name=table_name, 

881 source=SummarySchemaInfo.SSV_DB, 

882 column_name=( 

883 SPREADSHEET_PATIENT_FIELD_PREFIX + FP_ID_DESC + nstr 

884 ), 

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

886 comment=IdNumDefinition.description.comment 

887 + comment_suffix, 

888 ) 

889 ) 

890 items.add( 

891 SummarySchemaInfo( 

892 table_name=table_name, 

893 source=SummarySchemaInfo.SSV_DB, 

894 column_name=( 

895 SPREADSHEET_PATIENT_FIELD_PREFIX 

896 + FP_ID_SHORT_DESC 

897 + nstr 

898 ), 

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

900 comment=( 

901 IdNumDefinition.short_description.comment 

902 + comment_suffix 

903 ), 

904 ) 

905 ) 

906 return items 

907 

908 def get_bare_ptinfo(self) -> BarePatientInfo: 

909 """ 

910 Get basic identifying information, as a 

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

912 object. 

913 """ 

914 return BarePatientInfo( 

915 forename=self.forename, 

916 surname=self.surname, 

917 sex=self.sex, 

918 dob=self.dob, 

919 address=self.address, 

920 email=self.email, 

921 gp=self.gp, 

922 otherdetails=self.other, 

923 idnum_definitions=self.get_idnum_references(), 

924 ) 

925 

926 def get_hl7_pid_segment( 

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

928 ) -> hl7.Segment: 

929 """ 

930 Get HL7 patient identifier (PID) segment. 

931 

932 Args: 

933 req: 

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

935 recipient: 

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

937 

938 Returns: 

939 a :class:`hl7.Segment` object 

940 """ # noqa 

941 # Put the primary one first: 

942 patient_id_tuple_list = [ 

943 HL7PatientIdentifier( 

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

945 id_type=recipient.get_hl7_id_type( 

946 req, recipient.primary_idnum 

947 ), 

948 assigning_authority=recipient.get_hl7_id_aa( 

949 req, recipient.primary_idnum 

950 ), 

951 ) 

952 ] 

953 # Then the rest: 

954 for idobj in self.idnums: 

955 which_idnum = idobj.which_idnum 

956 if which_idnum == recipient.primary_idnum: 

957 continue 

958 idnum_value = idobj.idnum_value 

959 if idnum_value is None: 

960 continue 

961 patient_id_tuple_list.append( 

962 HL7PatientIdentifier( 

963 pid=str(idnum_value), 

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

965 assigning_authority=recipient.get_hl7_id_aa( 

966 req, which_idnum 

967 ), 

968 ) 

969 ) 

970 return make_pid_segment( 

971 forename=self.get_surname(), 

972 surname=self.get_forename(), 

973 dob=self.get_dob(), 

974 sex=self.get_sex(), 

975 address=self.get_address(), 

976 patient_id_list=patient_id_tuple_list, 

977 ) 

978 

979 # ------------------------------------------------------------------------- 

980 # FHIR 

981 # ------------------------------------------------------------------------- 

982 

983 def get_fhir_bundle_entry( 

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

985 ) -> Dict[str, Any]: 

986 """ 

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

988 encapsulates patient identity information in a FHIR bundle. 

989 

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

991 """ 

992 # The JSON objects we will build up: 

993 patient_dict = {} # type: JsonObjectType 

994 

995 # Name 

996 if self.forename or self.surname: 

997 name_dict = {} # type: JsonObjectType 

998 if self.forename: 

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

1000 if self.surname: 

1001 name_dict[Fc.NAME_FAMILY] = self.surname 

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

1003 

1004 # DOB 

1005 if self.dob: 

1006 patient_dict[Fc.BIRTHDATE] = format_datetime( 

1007 self.dob, DateFormat.FILENAME_DATE_ONLY 

1008 ) 

1009 

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

1011 if self.sex: 

1012 gender_lookup = { 

1013 SEX_FEMALE: Fc.GENDER_FEMALE, 

1014 SEX_MALE: Fc.GENDER_MALE, 

1015 SEX_OTHER_UNSPECIFIED: Fc.GENDER_OTHER, 

1016 } 

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

1018 self.sex, Fc.GENDER_UNKNOWN 

1019 ) 

1020 

1021 # Address 

1022 if self.address: 

1023 patient_dict[Fc.ADDRESS] = [ 

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

1025 ] 

1026 

1027 # Email 

1028 if self.email: 

1029 patient_dict[Fc.TELECOM] = [ 

1030 ContactPoint( 

1031 jsondict={ 

1032 Fc.SYSTEM: Fc.TELECOM_SYSTEM_EMAIL, 

1033 Fc.VALUE: self.email, 

1034 } 

1035 ).as_json() 

1036 ] 

1037 

1038 # General practitioner (GP): via 

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

1040 

1041 # ID numbers go here: 

1042 return make_fhir_bundle_entry( 

1043 resource_type_url=Fc.RESOURCE_TYPE_PATIENT, 

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

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

1046 ) 

1047 

1048 def get_fhir_identifier( 

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

1050 ) -> Identifier: 

1051 """ 

1052 Returns a FHIR identifier for this patient, as a 

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

1054 

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

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

1057 

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

1059 the PK on our CamCOPS server). 

1060 """ 

1061 which_idnum = recipient.primary_idnum 

1062 try: 

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

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

1065 if which_idnum is None: 

1066 raise AttributeError 

1067 idnum_object = self.get_idnum_object(which_idnum) 

1068 idnum_value = idnum_object.idnum_value # may raise AttributeError 

1069 iddef = req.get_idnum_definition(which_idnum) 

1070 idnum_url = iddef.effective_fhir_id_system(req) 

1071 return Identifier( 

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

1073 ) 

1074 except AttributeError: 

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

1076 # a default identifier. 

1077 return fhir_pk_identifier( 

1078 req, 

1079 self.__tablename__, 

1080 self.pk, 

1081 Fc.CAMCOPS_VALUE_PATIENT_WITHIN_TASK, 

1082 ) 

1083 

1084 def get_fhir_subject_ref( 

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

1086 ) -> Dict: 

1087 """ 

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

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

1090 """ 

1091 return FHIRReference( 

1092 jsondict={ 

1093 Fc.TYPE: Fc.RESOURCE_TYPE_PATIENT, 

1094 Fc.IDENTIFIER: self.get_fhir_identifier( 

1095 req, recipient 

1096 ).as_json(), 

1097 } 

1098 ).as_json() 

1099 

1100 # ------------------------------------------------------------------------- 

1101 # Database status 

1102 # ------------------------------------------------------------------------- 

1103 

1104 def is_preserved(self) -> bool: 

1105 """ 

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

1107 """ 

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

1109 

1110 # ------------------------------------------------------------------------- 

1111 # Audit 

1112 # ------------------------------------------------------------------------- 

1113 

1114 def audit( 

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

1116 ) -> None: 

1117 """ 

1118 Audits an action to this patient. 

1119 """ 

1120 audit( 

1121 req, 

1122 details, 

1123 patient_server_pk=self._pk, 

1124 table=Patient.__tablename__, 

1125 server_pk=self._pk, 

1126 from_console=from_console, 

1127 ) 

1128 

1129 # ------------------------------------------------------------------------- 

1130 # Special notes 

1131 # ------------------------------------------------------------------------- 

1132 

1133 def apply_special_note( 

1134 self, 

1135 req: "CamcopsRequest", 

1136 note: str, 

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

1138 ) -> None: 

1139 """ 

1140 Manually applies a special note to a patient. 

1141 WRITES TO DATABASE. 

1142 """ 

1143 sn = SpecialNote() 

1144 sn.basetable = self.__tablename__ 

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

1146 sn.device_id = self._device_id 

1147 sn.era = self._era 

1148 sn.note_at = req.now 

1149 sn.user_id = req.user_id 

1150 sn.note = note 

1151 req.dbsession.add(sn) 

1152 self.special_notes.append(sn) 

1153 self.audit(req, audit_msg) 

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

1155 

1156 # ------------------------------------------------------------------------- 

1157 # Deletion 

1158 # ------------------------------------------------------------------------- 

1159 

1160 def gen_patient_idnums_even_noncurrent( 

1161 self, 

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

1163 """ 

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

1165 ones. 

1166 """ 

1167 for lineage_member in self._gen_unique_lineage_objects( 

1168 self.idnums 

1169 ): # type: PatientIdNum # noqa 

1170 yield lineage_member 

1171 

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

1173 """ 

1174 Delete the patient with all its dependent objects. 

1175 """ 

1176 if self._pk is None: 

1177 return 

1178 for pidnum in self.gen_patient_idnums_even_noncurrent(): 

1179 req.dbsession.delete(pidnum) 

1180 super().delete_with_dependants(req) 

1181 

1182 # ------------------------------------------------------------------------- 

1183 # Permissions 

1184 # ------------------------------------------------------------------------- 

1185 

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

1187 """ 

1188 May this user inspect patient details directly? 

1189 """ 

1190 return self._group_id in user.ids_of_groups_user_may_see 

1191 

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

1193 """ 

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

1195 """ 

1196 if self.created_on_server(req): 

1197 # Anyone in the group with the right permission 

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

1199 

1200 # Finalized patient: Need to be group administrator 

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

1202 

1203 # -------------------------------------------------------------------------- 

1204 # UUID 

1205 # -------------------------------------------------------------------------- 

1206 @property 

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

1208 # Convert integer into pronounceable quintuplets (proquint) 

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

1210 if self.uuid is None: 

1211 return None 

1212 

1213 return proquint_from_uuid(self.uuid) 

1214 

1215 

1216# ============================================================================= 

1217# Validate candidate patient info for upload 

1218# ============================================================================= 

1219 

1220 

1221def is_candidate_patient_valid_for_group( 

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

1223) -> Tuple[bool, str]: 

1224 """ 

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

1226 

1227 Checks: 

1228 

1229 - group upload or finalize policy 

1230 

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

1232 the group wants 

1233 

1234 Args: 

1235 ptinfo: 

1236 a 

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

1238 representing the patient info to check 

1239 group: 

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

1241 this patient will be uploaded, if allowed 

1242 finalizing: 

1243 finalizing, rather than uploading? 

1244 

1245 Returns: 

1246 tuple: valid, reason 

1247 

1248 """ 

1249 if not group: 

1250 return False, "Nonexistent group" 

1251 

1252 if finalizing: 

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

1254 return False, "Fails finalizing ID policy" 

1255 else: 

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

1257 return False, "Fails upload ID policy" 

1258 

1259 # todo: add checks against prevalidated patients here 

1260 

1261 return True, "" 

1262 

1263 

1264def is_candidate_patient_valid_for_restricted_user( 

1265 req: "CamcopsRequest", ptinfo: BarePatientInfo 

1266) -> Tuple[bool, str]: 

1267 """ 

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

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

1270 identifiers all match the expected patient. 

1271 

1272 Args: 

1273 req: 

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

1275 ptinfo: 

1276 a 

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

1278 representing the patient info to check 

1279 

1280 Returns: 

1281 tuple: valid, reason 

1282 """ 

1283 user = req.user 

1284 if not user.auto_generated: 

1285 # Not a restricted user; no problem. 

1286 return True, "" 

1287 

1288 server_patient = user.single_patient 

1289 if not server_patient: 

1290 return ( 

1291 False, 

1292 ( 

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

1294 f"patient details" 

1295 ), 

1296 ) 

1297 

1298 server_ptinfo = server_patient.get_bare_ptinfo() 

1299 if ptinfo != server_ptinfo: 

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

1301 

1302 return True, "" 

1303 

1304 

1305# ============================================================================= 

1306# Reports 

1307# ============================================================================= 

1308 

1309 

1310class DistinctPatientReport(Report): 

1311 """ 

1312 Report to show distinct patients. 

1313 """ 

1314 

1315 # noinspection PyMethodParameters 

1316 @classproperty 

1317 def report_id(cls) -> str: 

1318 return "patient_distinct" 

1319 

1320 @classmethod 

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

1322 _ = req.gettext 

1323 return _( 

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

1325 ) 

1326 

1327 # noinspection PyMethodParameters 

1328 @classproperty 

1329 def superuser_only(cls) -> bool: 

1330 return False 

1331 

1332 # noinspection PyProtectedMember 

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

1334 select_fields = [ 

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

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

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

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

1339 ] 

1340 # noinspection PyUnresolvedReferences 

1341 select_from = Patient.__table__ 

1342 wheres = [ 

1343 Patient._current == True # noqa: E712 

1344 ] # type: List[ClauseElement] 

1345 if not req.user.superuser: 

1346 # Restrict to accessible groups 

1347 group_ids = req.user.ids_of_groups_user_may_report_on 

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

1349 for iddef in req.idnum_definitions: 

1350 n = iddef.which_idnum 

1351 desc = iddef.short_description 

1352 # noinspection PyUnresolvedReferences 

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

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

1355 select_from = select_from.outerjoin( 

1356 aliased_table, 

1357 and_( 

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

1359 aliased_table.c._device_id == Patient._device_id, 

1360 aliased_table.c._era == Patient._era, 

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

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

1363 # the OUTER JOIN): 

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

1365 aliased_table.c.which_idnum == n, 

1366 ), 

1367 ) # nopep8 

1368 order_by = [ 

1369 Patient.surname, 

1370 Patient.forename, 

1371 Patient.dob, 

1372 Patient.sex, 

1373 ] 

1374 query = ( 

1375 select(select_fields) 

1376 .select_from(select_from) 

1377 .where(and_(*wheres)) 

1378 .order_by(*order_by) 

1379 .distinct() 

1380 ) 

1381 return query