Coverage for cc_modules/cc_patient.py: 36%

394 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-15 15:51 +0100

1""" 

2camcops_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 

30import datetime 

31import logging 

32from typing import ( 

33 Any, 

34 Dict, 

35 Generator, 

36 List, 

37 Optional, 

38 Set, 

39 Tuple, 

40 TYPE_CHECKING, 

41 Union, 

42) 

43import uuid as python_uuid 

44 

45from cardinal_pythonlib.classes import classproperty 

46from cardinal_pythonlib.datetimefunc import ( 

47 coerce_to_pendulum_date, 

48 format_datetime, 

49 get_age, 

50 PotentialDatetimeType, 

51) 

52from cardinal_pythonlib.json.typing_helpers import JsonObjectType 

53from cardinal_pythonlib.logs import BraceStyleAdapter 

54import cardinal_pythonlib.rnc_web as ws 

55from fhirclient.models.address import Address 

56from fhirclient.models.contactpoint import ContactPoint 

57from fhirclient.models.humanname import HumanName 

58from fhirclient.models.fhirreference import FHIRReference 

59from fhirclient.models.identifier import Identifier 

60from fhirclient.models.patient import Patient as FhirPatient 

61import hl7 

62import pendulum 

63from sqlalchemy.ext.declarative import declared_attr 

64from sqlalchemy.orm import mapped_column, Mapped, relationship 

65from sqlalchemy.orm import Session as SqlASession 

66from sqlalchemy.orm.relationships import RelationshipProperty 

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

68from sqlalchemy.sql.selectable import SelectBase 

69from sqlalchemy.sql.sqltypes import UnicodeText 

70 

71from camcops_server.cc_modules.cc_audit import audit 

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

84from camcops_server.cc_modules.cc_dataclasses import SummarySchemaInfo 

85from camcops_server.cc_modules.cc_db import GenericTabletRecordMixin 

86from camcops_server.cc_modules.cc_fhir import ( 

87 fhir_pk_identifier, 

88 make_fhir_bundle_entry, 

89) 

90from camcops_server.cc_modules.cc_hl7 import make_pid_segment 

91from camcops_server.cc_modules.cc_html import answer 

92from camcops_server.cc_modules.cc_idnumdef import IdNumDefinition 

93from camcops_server.cc_modules.cc_simpleobjects import ( 

94 BarePatientInfo, 

95 HL7PatientIdentifier, 

96) 

97from camcops_server.cc_modules.cc_patientidnum import ( 

98 extra_id_colname, 

99 PatientIdNum, 

100) 

101from camcops_server.cc_modules.cc_proquint import proquint_from_uuid 

102from camcops_server.cc_modules.cc_report import Report 

103from camcops_server.cc_modules.cc_simpleobjects import ( 

104 IdNumReference, 

105 TaskExportOptions, 

106) 

107from camcops_server.cc_modules.cc_specialnote import SpecialNote 

108from camcops_server.cc_modules.cc_sqla_coltypes import ( 

109 EmailAddressColType, 

110 mapped_camcops_column, 

111 PatientNameColType, 

112 SexColType, 

113 UuidColType, 

114) 

115from camcops_server.cc_modules.cc_sqlalchemy import Base 

116from camcops_server.cc_modules.cc_spreadsheet import SpreadsheetPage 

117from camcops_server.cc_modules.cc_version import CAMCOPS_SERVER_VERSION_STRING 

118from camcops_server.cc_modules.cc_xml import ( 

119 XML_COMMENT_SPECIAL_NOTES, 

120 XmlElement, 

121) 

122 

123if 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 

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

134 

135 

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

137# Patient class 

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

139 

140 

141class 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 

1239def 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 

1282def 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 

1328class 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