Coverage for cc_modules/cc_db.py: 52%

399 statements  

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

1""" 

2camcops_server/cc_modules/cc_db.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**Common database code, e.g. mixins for tables that are uploaded from the 

27client.** 

28 

29""" 

30 

31from collections import OrderedDict 

32import datetime 

33import logging 

34from typing import ( 

35 Any, 

36 Callable, 

37 Dict, 

38 Generator, 

39 Iterable, 

40 List, 

41 NoReturn, 

42 Optional, 

43 Sequence, 

44 Set, 

45 Tuple, 

46 Type, 

47 TYPE_CHECKING, 

48 TypeVar, 

49 Union, 

50) 

51 

52from cardinal_pythonlib.logs import BraceStyleAdapter 

53from cardinal_pythonlib.sqlalchemy.orm_inspect import gen_columns 

54from pendulum import DateTime as Pendulum 

55from semantic_version import Version 

56from sqlalchemy.exc import IntegrityError 

57from sqlalchemy.ext.declarative import declared_attr 

58from sqlalchemy.orm import Mapped, mapped_column, relationship 

59from sqlalchemy.orm.relationships import RelationshipProperty 

60from sqlalchemy.orm import Session as SqlASession 

61from sqlalchemy.sql.functions import func 

62from sqlalchemy.sql.schema import Column, ForeignKey 

63from sqlalchemy.sql.sqltypes import Integer 

64 

65from camcops_server.cc_modules.cc_constants import ( 

66 CLIENT_DATE_FIELD, 

67 ERA_NOW, 

68 EXTRA_COMMENT_PREFIX, 

69 EXTRA_TASK_SERVER_PK_FIELD, 

70 EXTRA_TASK_TABLENAME_FIELD, 

71 MOVE_OFF_TABLET_FIELD, 

72 SPREADSHEET_PATIENT_FIELD_PREFIX, 

73 TABLET_ID_FIELD, 

74) 

75from camcops_server.cc_modules.cc_dataclasses import SummarySchemaInfo 

76from camcops_server.cc_modules.cc_sqla_coltypes import ( 

77 camcops_column, 

78 COLATTR_BLOB_RELATIONSHIP_ATTR_NAME, 

79 COLATTR_PERMITTED_VALUE_CHECKER, 

80 EraColType, 

81 gen_ancillary_relationships, 

82 gen_camcops_blob_columns, 

83 PendulumDateTimeAsIsoTextColType, 

84 PermittedValueChecker, 

85 RelationshipInfo, 

86 SemanticVersionColType, 

87 TableNameColType, 

88) 

89from camcops_server.cc_modules.cc_simpleobjects import TaskExportOptions 

90from camcops_server.cc_modules.cc_spreadsheet import SpreadsheetPage 

91from camcops_server.cc_modules.cc_version import CAMCOPS_SERVER_VERSION 

92from camcops_server.cc_modules.cc_xml import ( 

93 make_xml_branches_from_blobs, 

94 make_xml_branches_from_columns, 

95 make_xml_branches_from_summaries, 

96 XML_COMMENT_STORED, 

97 XML_COMMENT_CALCULATED, 

98 XmlElement, 

99) 

100 

101if TYPE_CHECKING: 

102 from camcops_server.cc_modules.cc_blob import Blob 

103 from camcops_server.cc_modules.cc_device import Device 

104 from camcops_server.cc_modules.cc_group import Group 

105 from camcops_server.cc_modules.cc_patient import Patient 

106 from camcops_server.cc_modules.cc_request import ( 

107 CamcopsRequest, 

108 ) 

109 from camcops_server.cc_modules.cc_summaryelement import ( 

110 SummaryElement, 

111 ) 

112 from camcops_server.cc_modules.cc_task import Task 

113 from camcops_server.cc_modules.cc_user import User 

114 

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

116 

117 

118# ============================================================================= 

119# Hacks for specific database drivers 

120# ============================================================================= 

121 

122CRASH_ON_BAD_CONVERSIONS = False # for debugging only! 

123 

124if CRASH_ON_BAD_CONVERSIONS: 

125 log.error("DANGER: CRASH_ON_BAD_CONVERSIONS set in cc_db.py") 

126 

127try: 

128 import MySQLdb 

129 import MySQLdb.converters 

130except ImportError: 

131 MySQLdb = None 

132 

133try: 

134 import pymysql 

135 import pymysql.converters 

136except ImportError: 

137 pymysql = None 

138 

139_SQL_LITERAL_TYPE = Union[int, float, str] 

140 

141_MYSQL_CONVERSION_DICT_TYPE = Dict[Any, Callable] 

142_MYSQLDB_PYTHON_TO_DB_TYPE = Callable[ 

143 [Any, _MYSQL_CONVERSION_DICT_TYPE], _SQL_LITERAL_TYPE 

144] # f(o, d) -> s 

145_MYSQLDB_DB_TO_PYTHON_TYPE = Callable[[_SQL_LITERAL_TYPE], Any] # f(s) -> o 

146 

147_PYMYSQL_ENCODER_DICT_TYPE = Dict[Type, Callable] 

148_PYMYSQL_PYTHON_TO_DB_TYPE = Callable[ 

149 [Any, Optional[_PYMYSQL_ENCODER_DICT_TYPE]], _SQL_LITERAL_TYPE 

150] # f(o, mapping) -> s 

151_PYMYSQL_DB_TO_PYTHON_TYPE = Callable[[_SQL_LITERAL_TYPE], Any] 

152 

153 

154def mysqldb_crash_on_bad_conversion( 

155 o: Any, d: _MYSQL_CONVERSION_DICT_TYPE 

156) -> NoReturn: 

157 """ 

158 Reports a bad conversion and crashes. For debugging only (obviously)! 

159 

160 **Conversions by mysqlclient (MySQLdb)** 

161 

162 As per the help docstring for ``MySQLdb/converters.py``, 

163 

164 - the Python-to-database conversion function has the signature ``f(o, d)`` 

165 where ``o`` is the thing to be converted (such as a datetime.datetime) 

166 and ``d`` is the conversion dictionary; it returns an SQL literal value. 

167 

168 - The database-to-Python conversion function has the argument ``f(s)`` 

169 where ``s`` is a string; it returns a Python object. 

170 

171 Both types of functions are stored in ``MySQLdb.converters``, which is a 

172 ``dict``. The keys named ``FIELD_TYPE.*`` are the database-to-Python 

173 converters; the others are the Python-to-database converters. 

174 

175 **Conversions by pymysql** 

176 

177 Similar (for back compatibility), but not the same. 

178 

179 - ``pymysql.converters.conversions`` is ``pymysql.converters.decoders`` and 

180 contains database-to-Python converters. 

181 

182 - ``pymysql.converters.encoders`` contains Python-to-database converters. 

183 

184 Args: 

185 o: Python object 

186 d: MySQLdb conversion dictionary 

187 

188 Returns: 

189 SQL literal 

190 """ 

191 failmsg = ( 

192 f"mysqldb_crash_on_bad_conversion: attempting to convert bad Python " 

193 f"object to database: {o!r}. Conversion dict is {d!r}." 

194 ) 

195 log.critical(failmsg) 

196 raise RuntimeError(failmsg) 

197 

198 

199def pymysql_crash_on_bad_conversion( 

200 obj: Any, mapping: _PYMYSQL_ENCODER_DICT_TYPE 

201) -> NoReturn: 

202 """ 

203 See :func:`mysqldb_crash_on_bad_conversion`. 

204 """ 

205 failmsg = ( 

206 f"pymysql_crash_on_bad_conversion: attempting to convert bad Python " 

207 f"object to database: {obj!r}. Mapping dict is {mapping!r}." 

208 ) 

209 log.critical(failmsg) 

210 raise RuntimeError(failmsg) 

211 

212 

213# ----------------------------------------------------------------------------- 

214# Pendulum; see https://pypi.org/project/pendulum/ -- but note that it says 

215# "pymysql.converters.conversions" but should say 

216# "pymysql.converters.encoders". 

217# ----------------------------------------------------------------------------- 

218 

219if MySQLdb: 

220 log.debug("Hacking MySQLdb to support pendulum.DateTime") 

221 if CRASH_ON_BAD_CONVERSIONS: 

222 MySQLdb.converters.conversions[Pendulum] = ( 

223 mysqldb_crash_on_bad_conversion 

224 ) 

225 else: 

226 MySQLdb.converters.conversions[Pendulum] = ( 

227 MySQLdb.converters.DateTime2literal 

228 ) 

229 

230if pymysql: 

231 log.debug("Hacking pymysql to support pendulum.DateTime") 

232 if CRASH_ON_BAD_CONVERSIONS: 

233 pymysql.converters.encoders[Pendulum] = pymysql_crash_on_bad_conversion 

234 else: 

235 pymysql.converters.encoders[Pendulum] = ( 

236 pymysql.converters.escape_datetime 

237 ) 

238 # And also, as per the source code and 

239 # https://stackoverflow.com/questions/59871904/convert-pymysql-query-result-with-mysql-decimal-type-to-python-float # noqa 

240 pymysql.converters.conversions = pymysql.converters.encoders.copy() 

241 pymysql.converters.conversions.update(pymysql.converters.decoders) 

242 

243 

244# ============================================================================= 

245# Constants 

246# ============================================================================= 

247 

248T = TypeVar("T") 

249 

250# Database fieldname constants. Do not change. Used here and in client_api.py 

251FN_PK = "_pk" 

252FN_DEVICE_ID = "_device_id" 

253FN_ERA = "_era" 

254FN_CURRENT = "_current" 

255FN_WHEN_ADDED_EXACT = "_when_added_exact" 

256FN_WHEN_ADDED_BATCH_UTC = "_when_added_batch_utc" 

257FN_ADDING_USER_ID = "_adding_user_id" 

258FN_WHEN_REMOVED_EXACT = "_when_removed_exact" 

259FN_WHEN_REMOVED_BATCH_UTC = "_when_removed_batch_utc" 

260FN_REMOVING_USER_ID = "_removing_user_id" 

261FN_PRESERVING_USER_ID = "_preserving_user_id" 

262FN_FORCIBLY_PRESERVED = "_forcibly_preserved" 

263FN_PREDECESSOR_PK = "_predecessor_pk" 

264FN_SUCCESSOR_PK = "_successor_pk" 

265FN_MANUALLY_ERASED = "_manually_erased" 

266FN_MANUALLY_ERASED_AT = "_manually_erased_at" 

267FN_MANUALLY_ERASING_USER_ID = "_manually_erasing_user_id" 

268FN_CAMCOPS_VERSION = "_camcops_version" 

269FN_ADDITION_PENDING = "_addition_pending" 

270FN_REMOVAL_PENDING = "_removal_pending" 

271FN_GROUP_ID = "_group_id" 

272 

273# Common fieldnames used by all tasks. Do not change. 

274TFN_WHEN_CREATED = "when_created" 

275TFN_WHEN_FIRSTEXIT = "when_firstexit" 

276TFN_FIRSTEXIT_IS_FINISH = "firstexit_is_finish" 

277TFN_FIRSTEXIT_IS_ABORT = "firstexit_is_abort" 

278TFN_EDITING_TIME_S = "editing_time_s" 

279 

280# Fieldnames for the task patient mixin. Do not change. 

281TFN_PATIENT_ID = "patient_id" 

282 

283# Fieldnames for the task clinician mixin. Do not change. 

284TFN_CLINICIAN_SPECIALTY = "clinician_specialty" 

285TFN_CLINICIAN_NAME = "clinician_name" 

286TFN_CLINICIAN_PROFESSIONAL_REGISTRATION = "clinician_professional_registration" 

287TFN_CLINICIAN_POST = "clinician_post" 

288TFN_CLINICIAN_SERVICE = "clinician_service" 

289TFN_CLINICIAN_CONTACT_DETAILS = "clinician_contact_details" 

290 

291# Fieldnames for the task respondent mixin. Do not change. 

292TFN_RESPONDENT_NAME = "respondent_name" 

293TFN_RESPONDENT_RELATIONSHIP = "respondent_relationship" 

294 

295# Selected field/column names for patients. Do not change. 

296PFN_UUID = "uuid" 

297 

298# Column names for task summaries. 

299SFN_IS_COMPLETE = "is_complete" 

300SFN_SECONDS_CREATION_TO_FIRST_FINISH = "seconds_from_creation_to_first_finish" 

301SFN_CAMCOPS_SERVER_VERSION = "camcops_server_version" 

302 

303RESERVED_FIELDS = ( # fields that tablets can't upload 

304 FN_PK, 

305 FN_DEVICE_ID, 

306 FN_ERA, 

307 FN_CURRENT, 

308 FN_WHEN_ADDED_EXACT, 

309 FN_WHEN_ADDED_BATCH_UTC, 

310 FN_ADDING_USER_ID, 

311 FN_WHEN_REMOVED_EXACT, 

312 FN_WHEN_REMOVED_BATCH_UTC, 

313 FN_REMOVING_USER_ID, 

314 FN_PRESERVING_USER_ID, 

315 FN_FORCIBLY_PRESERVED, 

316 FN_PREDECESSOR_PK, 

317 FN_SUCCESSOR_PK, 

318 FN_MANUALLY_ERASED, 

319 FN_MANUALLY_ERASED_AT, 

320 FN_MANUALLY_ERASING_USER_ID, 

321 FN_CAMCOPS_VERSION, 

322 FN_ADDITION_PENDING, 

323 FN_REMOVAL_PENDING, 

324 FN_GROUP_ID, 

325) # but more generally: they start with "_"... 

326assert all(x.startswith("_") for x in RESERVED_FIELDS) 

327 

328TABLET_STANDARD_FIELDS = RESERVED_FIELDS + ( 

329 TABLET_ID_FIELD, 

330 CLIENT_DATE_FIELD, # when_last_modified 

331 MOVE_OFF_TABLET_FIELD, 

332) 

333TASK_STANDARD_FIELDS = TABLET_STANDARD_FIELDS + ( 

334 # All tasks: 

335 TFN_WHEN_CREATED, 

336 TFN_WHEN_FIRSTEXIT, 

337 TFN_FIRSTEXIT_IS_FINISH, 

338 TFN_FIRSTEXIT_IS_ABORT, 

339 TFN_EDITING_TIME_S, 

340) 

341TASK_FREQUENT_AND_FK_FIELDS = TASK_STANDARD_FIELDS + ( 

342 # Tasks with a patient: 

343 TFN_PATIENT_ID, 

344) 

345TASK_FREQUENT_FIELDS = TASK_FREQUENT_AND_FK_FIELDS + ( 

346 # Tasks with a clinician: 

347 TFN_CLINICIAN_SPECIALTY, 

348 TFN_CLINICIAN_NAME, 

349 TFN_CLINICIAN_PROFESSIONAL_REGISTRATION, 

350 TFN_CLINICIAN_POST, 

351 TFN_CLINICIAN_SERVICE, 

352 TFN_CLINICIAN_CONTACT_DETAILS, 

353 # Tasks with a respondent: 

354 TFN_RESPONDENT_NAME, 

355 TFN_RESPONDENT_RELATIONSHIP, 

356) 

357 

358REMOVE_COLUMNS_FOR_SIMPLIFIED_SPREADSHEETS = { 

359 # keep this: CLIENT_DATE_FIELD = when_last_modified 

360 # keep this: FN_PK = task PK 

361 # keep this: SFN_IS_COMPLETE = is the task complete 

362 # keep this: SPREADSHEET_PATIENT_FIELD_PREFIX + FN_PK = patient PK 

363 # keep this: TFN_WHEN_CREATED = main creation time 

364 FN_ADDING_USER_ID, 

365 FN_ADDITION_PENDING, 

366 FN_CAMCOPS_VERSION, # debatable; version that captured the original data 

367 FN_CURRENT, 

368 FN_DEVICE_ID, 

369 FN_ERA, 

370 FN_FORCIBLY_PRESERVED, 

371 FN_GROUP_ID, 

372 FN_MANUALLY_ERASED, 

373 FN_MANUALLY_ERASED_AT, 

374 FN_MANUALLY_ERASING_USER_ID, 

375 FN_PREDECESSOR_PK, 

376 FN_PRESERVING_USER_ID, 

377 FN_REMOVAL_PENDING, 

378 FN_REMOVING_USER_ID, 

379 FN_SUCCESSOR_PK, 

380 FN_WHEN_ADDED_BATCH_UTC, 

381 FN_WHEN_ADDED_EXACT, 

382 FN_WHEN_REMOVED_BATCH_UTC, 

383 FN_WHEN_REMOVED_EXACT, 

384 MOVE_OFF_TABLET_FIELD, 

385 SFN_CAMCOPS_SERVER_VERSION, # debatable; version that generated summary information # noqa 

386 SFN_SECONDS_CREATION_TO_FIRST_FINISH, 

387 SPREADSHEET_PATIENT_FIELD_PREFIX + CLIENT_DATE_FIELD, 

388 SPREADSHEET_PATIENT_FIELD_PREFIX + FN_ADDING_USER_ID, 

389 SPREADSHEET_PATIENT_FIELD_PREFIX + FN_ADDITION_PENDING, 

390 SPREADSHEET_PATIENT_FIELD_PREFIX + FN_CAMCOPS_VERSION, 

391 SPREADSHEET_PATIENT_FIELD_PREFIX + FN_CURRENT, 

392 SPREADSHEET_PATIENT_FIELD_PREFIX + FN_DEVICE_ID, 

393 SPREADSHEET_PATIENT_FIELD_PREFIX + FN_ERA, 

394 SPREADSHEET_PATIENT_FIELD_PREFIX + FN_FORCIBLY_PRESERVED, 

395 SPREADSHEET_PATIENT_FIELD_PREFIX + FN_GROUP_ID, 

396 SPREADSHEET_PATIENT_FIELD_PREFIX + FN_MANUALLY_ERASED, 

397 SPREADSHEET_PATIENT_FIELD_PREFIX + FN_MANUALLY_ERASED_AT, 

398 SPREADSHEET_PATIENT_FIELD_PREFIX + FN_MANUALLY_ERASING_USER_ID, 

399 SPREADSHEET_PATIENT_FIELD_PREFIX + FN_PREDECESSOR_PK, 

400 SPREADSHEET_PATIENT_FIELD_PREFIX + FN_PRESERVING_USER_ID, 

401 SPREADSHEET_PATIENT_FIELD_PREFIX + FN_REMOVAL_PENDING, 

402 SPREADSHEET_PATIENT_FIELD_PREFIX + FN_REMOVING_USER_ID, 

403 SPREADSHEET_PATIENT_FIELD_PREFIX + FN_SUCCESSOR_PK, 

404 SPREADSHEET_PATIENT_FIELD_PREFIX + FN_WHEN_ADDED_BATCH_UTC, 

405 SPREADSHEET_PATIENT_FIELD_PREFIX + FN_WHEN_ADDED_EXACT, 

406 SPREADSHEET_PATIENT_FIELD_PREFIX + FN_WHEN_REMOVED_BATCH_UTC, 

407 SPREADSHEET_PATIENT_FIELD_PREFIX + FN_WHEN_REMOVED_EXACT, 

408 SPREADSHEET_PATIENT_FIELD_PREFIX + MOVE_OFF_TABLET_FIELD, 

409 SPREADSHEET_PATIENT_FIELD_PREFIX + PFN_UUID, 

410 SPREADSHEET_PATIENT_FIELD_PREFIX + TABLET_ID_FIELD, 

411 TABLET_ID_FIELD, 

412 TFN_EDITING_TIME_S, 

413 TFN_FIRSTEXIT_IS_ABORT, 

414 TFN_FIRSTEXIT_IS_FINISH, 

415 TFN_PATIENT_ID, 

416 TFN_WHEN_FIRSTEXIT, 

417} 

418 

419 

420# ============================================================================= 

421# GenericTabletRecordMixin 

422# ============================================================================= 

423 

424 

425# noinspection PyAttributeOutsideInit 

426class GenericTabletRecordMixin(object): 

427 """ 

428 Mixin for all tables that are uploaded from the client, representing the 

429 fields that the server adds at the point of upload. 

430 

431 From the server's perspective, ``_pk`` is the unique primary key. 

432 

433 However, records are defined also in their tablet context, for which an 

434 individual tablet (defined by the combination of ``_device_id`` and 

435 ``_era``) sees its own PK, ``id``. 

436 """ 

437 

438 __tablename__ = None # type: str # sorts out some mixin type checking 

439 

440 # ------------------------------------------------------------------------- 

441 # On the server side: 

442 # ------------------------------------------------------------------------- 

443 

444 # Plain columns 

445 

446 # noinspection PyMethodParameters 

447 _pk: Mapped[int] = mapped_column( 

448 FN_PK, 

449 primary_key=True, 

450 autoincrement=True, 

451 index=True, 

452 comment="(SERVER) Primary key (on the server)", 

453 ) 

454 

455 # noinspection PyMethodParameters 

456 _device_id: Mapped[int] = mapped_column( 

457 FN_DEVICE_ID, 

458 ForeignKey("_security_devices.id", use_alter=True), 

459 index=True, 

460 comment="(SERVER) ID of the source tablet device", 

461 ) 

462 

463 # noinspection PyMethodParameters 

464 _era: Mapped[str] = mapped_column( 

465 FN_ERA, 

466 EraColType, 

467 index=True, 

468 comment="(SERVER) 'NOW', or when this row was preserved and " 

469 "removed from the source device (UTC ISO 8601)", 

470 ) 

471 # ... note that _era is textual so that plain comparison 

472 # with "=" always works, i.e. no NULLs -- for USER comparison too, not 

473 # just in CamCOPS code 

474 

475 # noinspection PyMethodParameters 

476 _current: Mapped[bool] = mapped_column( 

477 FN_CURRENT, 

478 index=True, 

479 comment="(SERVER) Is the row current (1) or not (0)?", 

480 ) 

481 

482 # noinspection PyMethodParameters 

483 _when_added_exact: Mapped[Optional[Pendulum]] = mapped_column( 

484 FN_WHEN_ADDED_EXACT, 

485 PendulumDateTimeAsIsoTextColType, 

486 comment="(SERVER) Date/time this row was added (ISO 8601)", 

487 ) 

488 

489 # noinspection PyMethodParameters 

490 _when_added_batch_utc: Mapped[Optional[datetime.datetime]] = mapped_column( 

491 FN_WHEN_ADDED_BATCH_UTC, 

492 comment="(SERVER) Date/time of the upload batch that added this " 

493 "row (DATETIME in UTC)", 

494 ) 

495 

496 # noinspection PyMethodParameters 

497 _adding_user_id: Mapped[Optional[int]] = mapped_column( 

498 FN_ADDING_USER_ID, 

499 ForeignKey("_security_users.id"), 

500 comment="(SERVER) ID of user that added this row", 

501 ) 

502 

503 # noinspection PyMethodParameters 

504 _when_removed_exact: Mapped[Optional[Pendulum]] = mapped_column( 

505 FN_WHEN_REMOVED_EXACT, 

506 PendulumDateTimeAsIsoTextColType, 

507 comment="(SERVER) Date/time this row was removed, i.e. made " 

508 "not current (ISO 8601)", 

509 ) 

510 

511 # noinspection PyMethodParameters 

512 _when_removed_batch_utc: Mapped[Optional[datetime.datetime]] = ( 

513 mapped_column( 

514 FN_WHEN_REMOVED_BATCH_UTC, 

515 comment="(SERVER) Date/time of the upload batch that removed " 

516 "this row (DATETIME in UTC)", 

517 ) 

518 ) 

519 

520 _removing_user_id: Mapped[Optional[int]] = mapped_column( 

521 FN_REMOVING_USER_ID, 

522 ForeignKey("_security_users.id"), 

523 comment="(SERVER) ID of user that removed this row", 

524 ) 

525 

526 # noinspection PyMethodParameters 

527 _preserving_user_id: Mapped[Optional[int]] = mapped_column( 

528 FN_PRESERVING_USER_ID, 

529 ForeignKey("_security_users.id"), 

530 comment="(SERVER) ID of user that preserved this row", 

531 ) 

532 

533 # noinspection PyMethodParameters 

534 _forcibly_preserved: Mapped[Optional[bool]] = mapped_column( 

535 FN_FORCIBLY_PRESERVED, 

536 default=False, 

537 comment="(SERVER) Forcibly preserved by superuser (rather than " 

538 "normally preserved by tablet)?", 

539 ) 

540 

541 # noinspection PyMethodParameters 

542 _predecessor_pk: Mapped[Optional[int]] = mapped_column( 

543 FN_PREDECESSOR_PK, 

544 comment="(SERVER) PK of predecessor record, prior to modification", 

545 ) 

546 

547 # noinspection PyMethodParameters 

548 _successor_pk: Mapped[Optional[int]] = mapped_column( 

549 FN_SUCCESSOR_PK, 

550 comment="(SERVER) PK of successor record (after modification) " 

551 "or NULL (whilst live, or after deletion)", 

552 ) 

553 

554 # noinspection PyMethodParameters 

555 _manually_erased: Mapped[Optional[bool]] = mapped_column( 

556 FN_MANUALLY_ERASED, 

557 default=False, 

558 comment="(SERVER) Record manually erased (content destroyed)?", 

559 ) 

560 

561 # noinspection PyMethodParameters 

562 _manually_erased_at: Mapped[Optional[Pendulum]] = mapped_column( 

563 FN_MANUALLY_ERASED_AT, 

564 PendulumDateTimeAsIsoTextColType, 

565 comment="(SERVER) Date/time of manual erasure (ISO 8601)", 

566 ) 

567 

568 # noinspection PyMethodParameters 

569 _manually_erasing_user_id: Mapped[Optional[int]] = mapped_column( 

570 FN_MANUALLY_ERASING_USER_ID, 

571 ForeignKey("_security_users.id"), 

572 comment="(SERVER) ID of user that erased this row manually", 

573 ) 

574 

575 # noinspection PyMethodParameters 

576 _camcops_version: Mapped[Optional[Version]] = mapped_column( 

577 FN_CAMCOPS_VERSION, 

578 SemanticVersionColType, 

579 default=CAMCOPS_SERVER_VERSION, 

580 comment="(SERVER) CamCOPS version number of the uploading device", 

581 ) 

582 

583 # noinspection PyMethodParameters 

584 _addition_pending: Mapped[bool] = mapped_column( 

585 FN_ADDITION_PENDING, 

586 default=False, 

587 comment="(SERVER) Addition pending?", 

588 ) 

589 

590 # noinspection PyMethodParameters 

591 _removal_pending: Mapped[Optional[bool]] = mapped_column( 

592 FN_REMOVAL_PENDING, 

593 default=False, 

594 comment="(SERVER) Removal pending?", 

595 ) 

596 

597 # noinspection PyMethodParameters 

598 _group_id: Mapped[int] = mapped_column( 

599 FN_GROUP_ID, 

600 ForeignKey("_security_groups.id"), 

601 index=True, 

602 comment="(SERVER) ID of group to which this record belongs", 

603 ) 

604 

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

606 # Fields that *all* client tables have: 

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

608 

609 # noinspection PyMethodParameters 

610 id: Mapped[int] = mapped_column( 

611 TABLET_ID_FIELD, 

612 index=True, 

613 comment="(TASK) Primary key (task ID) on the tablet device", 

614 ) 

615 

616 # noinspection PyMethodParameters 

617 when_last_modified: Mapped[Optional[Pendulum]] = mapped_column( 

618 CLIENT_DATE_FIELD, 

619 PendulumDateTimeAsIsoTextColType, 

620 index=True, # ... as used by database upload script 

621 comment="(STANDARD) Date/time this row was last modified on the " 

622 "source tablet device (ISO 8601)", 

623 ) 

624 

625 # noinspection PyMethodParameters 

626 _move_off_tablet: Mapped[Optional[bool]] = mapped_column( 

627 MOVE_OFF_TABLET_FIELD, 

628 default=False, 

629 comment="(SERVER/TABLET) Record-specific preservation pending?", 

630 ) 

631 

632 # ------------------------------------------------------------------------- 

633 # Relationships 

634 # ------------------------------------------------------------------------- 

635 

636 # noinspection PyMethodParameters 

637 @declared_attr 

638 def _device(cls) -> Mapped["Device"]: 

639 return relationship("Device") 

640 

641 # noinspection PyMethodParameters 

642 @declared_attr 

643 def _adding_user(cls) -> Mapped["User"]: 

644 return relationship("User", foreign_keys=[cls._adding_user_id]) # type: ignore[list-item] # noqa: E501 

645 

646 # noinspection PyMethodParameters 

647 @declared_attr 

648 def _removing_user(cls) -> Mapped["User"]: 

649 return relationship("User", foreign_keys=[cls._removing_user_id]) # type: ignore[list-item] # noqa: E501 

650 

651 # noinspection PyMethodParameters 

652 @declared_attr 

653 def _preserving_user(cls) -> Mapped["User"]: 

654 return relationship("User", foreign_keys=[cls._preserving_user_id]) # type: ignore[list-item] # noqa: E501 

655 

656 # noinspection PyMethodParameters 

657 @declared_attr 

658 def _manually_erasing_user(cls) -> Mapped["User"]: 

659 return relationship( 

660 "User", foreign_keys=[cls._manually_erasing_user_id] # type: ignore[list-item] # noqa: E501 

661 ) 

662 

663 # noinspection PyMethodParameters 

664 @declared_attr 

665 def _group(cls) -> Mapped["Group"]: 

666 return relationship("Group", foreign_keys=[cls._group_id]) # type: ignore[list-item] # noqa: E501 

667 

668 # ------------------------------------------------------------------------- 

669 # Fetching attributes 

670 # ------------------------------------------------------------------------- 

671 

672 @property 

673 def pk(self) -> Optional[int]: 

674 """ 

675 Returns the (server) primary key of this record. 

676 """ 

677 return self._pk 

678 

679 @property 

680 def era(self) -> Optional[str]: 

681 """ 

682 Returns the era of this record (a text representation of the date/time 

683 of the point of record finalization, or ``NOW`` if the record is still 

684 present on the client device). 

685 """ 

686 return self._era 

687 

688 @property 

689 def device_id(self) -> Optional[int]: 

690 """ 

691 Returns the client device ID of this record. 

692 """ 

693 return self._device_id 

694 

695 @property 

696 def group_id(self) -> Optional[int]: 

697 """ 

698 Returns the group ID of this record. 

699 """ 

700 return self._group_id 

701 

702 # ------------------------------------------------------------------------- 

703 # Other universal properties 

704 # ------------------------------------------------------------------------- 

705 

706 def is_live_on_tablet(self) -> bool: 

707 """ 

708 Is the record live on a tablet (not finalized)? 

709 """ 

710 return self._era == ERA_NOW 

711 

712 def is_finalized(self) -> bool: 

713 """ 

714 Is the record finalized (no longer available to be edited on the 

715 client device), and therefore (if required) editable on the server? 

716 """ 

717 return not self.is_live_on_tablet() 

718 

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

720 """ 

721 Was this record created on the server? 

722 """ 

723 from camcops_server.cc_modules.cc_device import ( 

724 Device, 

725 ) # delayed import 

726 

727 server_device = Device.get_server_device(req.dbsession) 

728 return self._era == ERA_NOW and self._device_id == server_device.id 

729 

730 # ------------------------------------------------------------------------- 

731 # Autoscanning objects and their relationships 

732 # ------------------------------------------------------------------------- 

733 

734 def _get_xml_root( 

735 self, req: "CamcopsRequest", options: TaskExportOptions 

736 ) -> XmlElement: 

737 """ 

738 Called to create an XML root object for records ancillary to Task 

739 objects. Tasks themselves use a more complex mechanism. 

740 

741 Args: 

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

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

744 """ # noqa 

745 # "__tablename__" will make the type checker complain, as we're 

746 # defining a function for a mixin that assumes it's mixed in to a 

747 # SQLAlchemy Base-derived class 

748 # noinspection PyUnresolvedReferences 

749 return XmlElement( 

750 name=self.__tablename__, 

751 value=self._get_xml_branches(req=req, options=options), 

752 ) 

753 

754 def _get_xml_branches( 

755 self, req: "CamcopsRequest", options: TaskExportOptions 

756 ) -> List[XmlElement]: 

757 """ 

758 Gets the values of SQLAlchemy columns as XmlElement objects. 

759 Optionally, find any SQLAlchemy relationships that are relationships 

760 to Blob objects, and include them too. 

761 

762 Used by :func:`_get_xml_root` above, but also by Tasks themselves. 

763 

764 Args: 

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

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

767 """ # noqa 

768 # log.debug("_get_xml_branches for {!r}", self) 

769 options = options or TaskExportOptions( 

770 xml_include_plain_columns=True, 

771 xml_include_calculated=True, 

772 xml_sort_by_name=True, 

773 ) 

774 branches = [] # type: List[XmlElement] 

775 if options.xml_with_header_comments: 

776 branches.append(XML_COMMENT_STORED) 

777 if options.xml_include_plain_columns: 

778 new_branches = make_xml_branches_from_columns( 

779 self, skip_fields=options.xml_skip_fields 

780 ) 

781 if options.xml_sort_by_name: 

782 new_branches.sort(key=lambda el: el.name) 

783 branches += new_branches 

784 if options.include_blobs: 

785 new_branches = make_xml_branches_from_blobs( 

786 req, self, skip_fields=options.xml_skip_fields 

787 ) 

788 if options.xml_sort_by_name: 

789 new_branches.sort(key=lambda el: el.name) 

790 branches += new_branches 

791 # Calculated 

792 if options.xml_include_calculated: 

793 if options.xml_with_header_comments: 

794 branches.append(XML_COMMENT_CALCULATED) 

795 branches.extend( 

796 make_xml_branches_from_summaries( 

797 self.get_summaries(req), 

798 skip_fields=options.xml_skip_fields, 

799 sort_by_name=options.xml_sort_by_name, 

800 ) 

801 ) 

802 # log.debug("... branches for {!r}: {!r}", self, branches) 

803 return branches 

804 

805 def _get_core_spreadsheet_page( 

806 self, req: "CamcopsRequest", heading_prefix: str = "" 

807 ) -> SpreadsheetPage: 

808 """ 

809 Returns a single-row 

810 :class:`camcops_server.cc_modules.cc_spreadsheet.SpreadsheetPage`, like 

811 an Excel "sheet", representing this record. (It may be combined with 

812 others later to produce a multi-row spreadsheet.) 

813 """ 

814 row = OrderedDict() 

815 for attrname, column in gen_columns(self): 

816 row[heading_prefix + attrname] = getattr(self, attrname) 

817 for s in self.get_summaries(req): 

818 row[heading_prefix + s.name] = s.value 

819 return SpreadsheetPage(name=self.__tablename__, rows=[row]) 

820 

821 def _get_core_spreadsheet_schema( 

822 self, table_name: str = "", column_name_prefix: str = "" 

823 ) -> Set[SummarySchemaInfo]: 

824 """ 

825 Returns schema information compatible with 

826 :func:`_get_core_spreadsheet_page`. 

827 """ 

828 return set( 

829 SummarySchemaInfo.from_column( 

830 column, 

831 table_name=table_name, 

832 column_name_prefix=column_name_prefix, 

833 ) 

834 for _, column in gen_columns(self) 

835 ) 

836 

837 # ------------------------------------------------------------------------- 

838 # Erasing (overwriting data, not deleting the database records) 

839 # ------------------------------------------------------------------------- 

840 

841 def manually_erase_with_dependants(self, req: "CamcopsRequest") -> None: 

842 """ 

843 Manually erases a standard record and marks it so erased. Iterates 

844 through any dependants and does likewise to them. 

845 

846 The object remains ``_current`` (if it was), as a placeholder, but its 

847 contents are wiped. 

848 

849 WRITES TO THE DATABASE. 

850 """ 

851 if self._manually_erased or self._pk is None or self._era == ERA_NOW: 

852 # ... _manually_erased: don't do it twice 

853 # ... _pk: basic sanity check 

854 # ... _era: don't erase things that are current on the tablet 

855 return 

856 # 1. "Erase my dependants" 

857 for ancillary in self.gen_ancillary_instances_even_noncurrent(): 

858 ancillary.manually_erase_with_dependants(req) 

859 for blob in self.gen_blobs_even_noncurrent(): 

860 blob.manually_erase_with_dependants(req) 

861 # 2. "Erase me" 

862 erasure_attrs = [] # type: List[str] 

863 for attrname, column in gen_columns(self): 

864 if attrname.startswith("_"): # system field 

865 continue 

866 if not column.nullable: # this should cover FKs 

867 continue 

868 if column.foreign_keys: # ... but to be sure... 

869 continue 

870 erasure_attrs.append(attrname) 

871 for attrname in erasure_attrs: 

872 setattr(self, attrname, None) 

873 self._current = False 

874 self._manually_erased = True 

875 self._manually_erased_at = req.now 

876 self._manually_erasing_user_id = req.user_id 

877 

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

879 """ 

880 Deletes (completely from the database) this record and any 

881 dependant records. 

882 """ 

883 if self._pk is None: 

884 return 

885 # 1. "Delete my dependants" 

886 for ancillary in self.gen_ancillary_instances_even_noncurrent(): 

887 ancillary.delete_with_dependants(req) 

888 for blob in self.gen_blobs_even_noncurrent(): 

889 blob.delete_with_dependants(req) 

890 # 2. "Delete me" 

891 dbsession = SqlASession.object_session(self) 

892 dbsession.delete(self) 

893 

894 def gen_attrname_ancillary_pairs( 

895 self, 

896 ) -> Generator[Tuple[str, "GenericTabletRecordMixin"], None, None]: 

897 """ 

898 Iterates through and yields all ``_current`` "ancillary" objects 

899 (typically: records of subtables). 

900 

901 Yields tuples of ``(attrname, related_record)``. 

902 """ 

903 for attrname, rel_prop, rel_cls in gen_ancillary_relationships(self): 

904 if rel_prop.uselist: 

905 ancillaries = getattr( 

906 self, attrname 

907 ) # type: List[GenericTabletRecordMixin] 

908 else: 

909 ancillaries = [ # type: ignore[no-redef] 

910 getattr(self, attrname) 

911 ] # type: List[GenericTabletRecordMixin] 

912 for ancillary in ancillaries: 

913 if ancillary is None: 

914 continue 

915 yield attrname, ancillary 

916 

917 def gen_ancillary_instances( 

918 self, 

919 ) -> Generator["GenericTabletRecordMixin", None, None]: 

920 """ 

921 Generates all ``_current`` ancillary objects of this object. 

922 """ 

923 for attrname, ancillary in self.gen_attrname_ancillary_pairs(): 

924 yield ancillary 

925 

926 def gen_ancillary_instances_even_noncurrent( 

927 self, 

928 ) -> Generator["GenericTabletRecordMixin", None, None]: 

929 """ 

930 Generates all ancillary objects of this object, even non-current 

931 ones. 

932 """ 

933 for lineage_member in self._gen_unique_lineage_objects( 

934 self.gen_ancillary_instances() 

935 ): 

936 yield lineage_member 

937 

938 def gen_blobs(self) -> Generator["Blob", None, None]: 

939 """ 

940 Generate all ``_current`` BLOBs owned by this object. 

941 """ 

942 for id_attrname, column in gen_camcops_blob_columns(self): 

943 relationship_attr = column.info.get( 

944 COLATTR_BLOB_RELATIONSHIP_ATTR_NAME 

945 ) 

946 blob = getattr(self, relationship_attr) 

947 if blob is None: 

948 continue 

949 yield blob 

950 

951 def gen_blobs_even_noncurrent(self) -> Generator["Blob", None, None]: 

952 """ 

953 Generates all BLOBs owned by this object, even non-current ones. 

954 """ 

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

956 self.gen_blobs() 

957 ): # type: "Blob" 

958 yield lineage_member 

959 

960 def get_lineage(self) -> List["GenericTabletRecordMixin"]: 

961 """ 

962 Returns all records that are part of the same "lineage", that is: 

963 

964 - of the same class; 

965 - matching on id/device_id/era; 

966 - including both current and any historical non-current versions. 

967 

968 Will include the "self" object. 

969 

970 """ 

971 dbsession = SqlASession.object_session(self) 

972 cls = self.__class__ 

973 q = ( 

974 dbsession.query(cls) 

975 .filter(cls.id == self.id) 

976 .filter(cls._device_id == self._device_id) 

977 .filter(cls._era == self._era) 

978 ) 

979 return list(q) 

980 

981 @staticmethod 

982 def _gen_unique_lineage_objects( 

983 collection: Iterable["GenericTabletRecordMixin"], 

984 ) -> Generator["GenericTabletRecordMixin", None, None]: 

985 """ 

986 Given an iterable of database records, generate all related lineage 

987 objects for each of them (via :meth:`get_lineage`) that are unique by 

988 PK. 

989 """ 

990 seen_pks = set() # type: Set[int] 

991 for item in collection: 

992 if item is None: 

993 continue 

994 for lineage_member in item.get_lineage(): 

995 pk = lineage_member.pk 

996 if pk in seen_pks: 

997 continue 

998 seen_pks.add(pk) 

999 yield lineage_member 

1000 

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

1002 # Retrieving a linked record by client ID 

1003 # ------------------------------------------------------------------------- 

1004 

1005 @classmethod 

1006 def get_linked( 

1007 cls, client_id: Optional[int], other: "GenericTabletRecordMixin" 

1008 ) -> Optional["GenericTabletRecordMixin"]: 

1009 """ 

1010 Returns a specific linked record, of the class of ``self``, whose 

1011 client-side ID is ``client_id``, and which matches ``other`` in terms 

1012 of device/era. 

1013 """ 

1014 if client_id is None: 

1015 return None 

1016 dbsession = SqlASession.object_session(other) 

1017 # noinspection PyPep8 

1018 q = ( 

1019 dbsession.query(cls) 

1020 .filter(cls.id == client_id) 

1021 .filter(cls._device_id == other._device_id) 

1022 .filter(cls._era == other._era) 

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

1024 ) 

1025 return q.first() 

1026 

1027 # ------------------------------------------------------------------------- 

1028 # History functions for server-side editing 

1029 # ------------------------------------------------------------------------- 

1030 

1031 def set_predecessor( 

1032 self, req: "CamcopsRequest", predecessor: "GenericTabletRecordMixin" 

1033 ) -> None: 

1034 """ 

1035 Used for some unusual server-side manipulations (e.g. editing patient 

1036 details). 

1037 

1038 Amends this object so the "self" object replaces the predecessor, so: 

1039 

1040 - "self" becomes current and refers back to "predecessor"; 

1041 - "predecessor" becomes non-current and refers forward to "self". 

1042 

1043 """ 

1044 assert predecessor._current 

1045 # We become new and current, and refer to our predecessor 

1046 self._device_id = predecessor._device_id 

1047 self._era = predecessor._era 

1048 self._current = True 

1049 self._when_added_exact = req.now 

1050 self._when_added_batch_utc = req.now_utc 

1051 self._adding_user_id = req.user_id 

1052 if self._era != ERA_NOW: 

1053 self._preserving_user_id = req.user_id 

1054 self._forcibly_preserved = True 

1055 self._predecessor_pk = predecessor._pk 

1056 self._camcops_version = predecessor._camcops_version 

1057 self._group_id = predecessor._group_id 

1058 # Make our predecessor refer to us 

1059 if self._pk is None: 

1060 req.dbsession.add(self) # ensure we have a PK, part 1 

1061 req.dbsession.flush() # ensure we have a PK, part 2 

1062 predecessor._set_successor(req, self) 

1063 

1064 def _set_successor( 

1065 self, req: "CamcopsRequest", successor: "GenericTabletRecordMixin" 

1066 ) -> None: 

1067 """ 

1068 See :func:`set_predecessor` above. 

1069 """ 

1070 assert successor._pk is not None 

1071 self._current = False 

1072 self._when_removed_exact = req.now 

1073 self._when_removed_batch_utc = req.now_utc 

1074 self._removing_user_id = req.user_id 

1075 self._successor_pk = successor._pk 

1076 

1077 def mark_as_deleted(self, req: "CamcopsRequest") -> None: 

1078 """ 

1079 Ends the history chain and marks this record as non-current. 

1080 """ 

1081 if self._current: 

1082 self._when_removed_exact = req.now 

1083 self._when_removed_batch_utc = req.now_utc 

1084 self._removing_user_id = req.user_id 

1085 self._current = False 

1086 

1087 def create_fresh( 

1088 self, req: "CamcopsRequest", device_id: int, era: str, group_id: int 

1089 ) -> None: 

1090 """ 

1091 Used to create a record from scratch. 

1092 """ 

1093 self._device_id = device_id 

1094 self._era = era 

1095 self._group_id = group_id 

1096 self._current = True 

1097 self._when_added_exact = req.now 

1098 self._when_added_batch_utc = req.now_utc 

1099 self._adding_user_id = req.user_id 

1100 

1101 def save_with_next_available_id( 

1102 self, req: "CamcopsRequest", device_id: int, era: str = ERA_NOW 

1103 ) -> None: 

1104 """ 

1105 Save a record with the next available client pk in sequence. 

1106 This is of use when creating patients and ID numbers on the server 

1107 to ensure uniqueness, or when fixing up a missing ID number for 

1108 a patient created on a device. 

1109 """ 

1110 cls = self.__class__ 

1111 

1112 saved_ok = False 

1113 

1114 # MySql doesn't support "select for update" so we have to keep 

1115 # trying the next available ID and checking for an integrity 

1116 # error in case another user has grabbed it by the time we have 

1117 # committed 

1118 # noinspection PyProtectedMember 

1119 last_id = ( 

1120 req.dbsession 

1121 # func.max(cls.id) + 1 here will do the right thing for 

1122 # backends that support select for update (maybe not for no rows) 

1123 .query(func.max(cls.id)) 

1124 .filter(cls._device_id == device_id) 

1125 .filter(cls._era == era) 

1126 .scalar() 

1127 ) or 0 

1128 

1129 next_id = last_id + 1 

1130 

1131 while not saved_ok: 

1132 self.id = next_id 

1133 

1134 req.dbsession.add(self) 

1135 

1136 try: 

1137 req.dbsession.flush() 

1138 saved_ok = True 

1139 except IntegrityError: 

1140 req.dbsession.rollback() 

1141 next_id += 1 

1142 

1143 # ------------------------------------------------------------------------- 

1144 # Override this if you provide summaries 

1145 # ------------------------------------------------------------------------- 

1146 

1147 # noinspection PyMethodMayBeStatic 

1148 def get_summaries(self, req: "CamcopsRequest") -> List["SummaryElement"]: 

1149 """ 

1150 Return a list of :class:`SummaryElement` objects, for this database 

1151 object (not any dependent classes/tables). 

1152 

1153 Note that this is implemented on :class:`GenericTabletRecordMixin`, 

1154 not :class:`camcops_server.cc_modules.cc_task.Task`, so that ancillary 

1155 objects can also provide summaries. 

1156 """ 

1157 return [] 

1158 

1159 def get_summary_names(self, req: "CamcopsRequest") -> List[str]: 

1160 """ 

1161 Returns a list of summary field names. 

1162 """ 

1163 return [x.name for x in self.get_summaries(req)] 

1164 

1165 

1166# ============================================================================= 

1167# Relationships 

1168# ============================================================================= 

1169 

1170 

1171def ancillary_relationship( 

1172 parent_class_name: str, 

1173 ancillary_class_name: str, 

1174 ancillary_fk_to_parent_attr_name: str, 

1175 ancillary_order_by_attr_name: str = None, 

1176 read_only: bool = True, 

1177) -> RelationshipProperty: 

1178 """ 

1179 Implements a one-to-many relationship, i.e. one parent to many ancillaries. 

1180 """ 

1181 parent_pk_attr_name = "id" # always 

1182 return relationship( 

1183 ancillary_class_name, 

1184 primaryjoin=( 

1185 "and_(" 

1186 " remote({a}.{fk}) == foreign({p}.{pk}), " 

1187 " remote({a}._device_id) == foreign({p}._device_id), " 

1188 " remote({a}._era) == foreign({p}._era), " 

1189 " remote({a}._current) == True " 

1190 ")".format( 

1191 a=ancillary_class_name, 

1192 fk=ancillary_fk_to_parent_attr_name, 

1193 p=parent_class_name, 

1194 pk=parent_pk_attr_name, 

1195 ) 

1196 ), 

1197 uselist=True, 

1198 order_by="{a}.{f}".format( 

1199 a=ancillary_class_name, f=ancillary_order_by_attr_name 

1200 ), 

1201 viewonly=read_only, 

1202 info={RelationshipInfo.IS_ANCILLARY: True}, 

1203 # ... "info" is a user-defined dictionary; see 

1204 # https://docs.sqlalchemy.org/en/latest/orm/relationship_api.html#sqlalchemy.orm.relationship.params.info # noqa 

1205 # https://docs.sqlalchemy.org/en/latest/orm/internals.html#MapperProperty.info # noqa 

1206 ) 

1207 

1208 

1209# ============================================================================= 

1210# Field creation assistance 

1211# ============================================================================= 

1212 

1213# TypeEngineBase = TypeVar('TypeEngineBase', bound=TypeEngine) 

1214 

1215 

1216def add_multiple_columns( # type: ignore[no-untyped-def] 

1217 cls: Type, 

1218 prefix: str, 

1219 start: int, 

1220 end: int, 

1221 coltype=Integer, 

1222 # this type fails: Union[Type[TypeEngineBase], TypeEngine] 

1223 # ... https://stackoverflow.com/questions/38106227 

1224 # ... https://github.com/python/typing/issues/266 

1225 colkwargs: Dict[str, Any] = None, 

1226 comment_fmt: str = None, 

1227 comment_strings: List[str] = None, 

1228 minimum: Union[int, float] = None, 

1229 maximum: Union[int, float] = None, 

1230 pv: Sequence[Any] = None, 

1231 suffix: str = "", 

1232) -> None: 

1233 """ 

1234 Add a sequence of SQLAlchemy columns to a class. 

1235 

1236 Called via __init_subclass__() on the base class. 

1237 Used to make task creation a bit easier. 

1238 

1239 Args: 

1240 cls: 

1241 class to which to add columns 

1242 prefix: 

1243 Fieldname will be ``prefix + str(n) + suffix``, where ``n`` is 

1244 defined as below. 

1245 suffix: 

1246 Optional. See ``prefix``. 

1247 start: 

1248 Start of range. 

1249 end: 

1250 End of range. Thus: ``i`` will range from ``0`` to ``(end - 

1251 start)`` inclusive; ``n`` will range from ``start`` to ``end`` 

1252 inclusive. 

1253 coltype: 

1254 SQLAlchemy column type, in either of these formats: (a) 

1255 ``Integer`` (of general type ``Type[TypeEngine]``?); (b) 

1256 ``Integer()`` (of general type ``TypeEngine``). 

1257 colkwargs: 

1258 SQLAlchemy column arguments, as in 

1259 ``Column(name, coltype, **colkwargs)`` 

1260 comment_fmt: 

1261 Format string defining field comments. Substitutable 

1262 values are: 

1263 

1264 - ``{n}``: field number (from range). 

1265 - ``{s}``: ``comment_strings[i]``, where ``i`` is a zero-based 

1266 index as defined as above, or "" if out of range. 

1267 

1268 comment_strings: 

1269 see ``comment_fmt`` 

1270 minimum: 

1271 minimum permitted value, or ``None`` 

1272 maximum: 

1273 maximum permitted value, or ``None`` 

1274 pv: 

1275 list of permitted values, or ``None`` 

1276 """ 

1277 colkwargs = {} if colkwargs is None else colkwargs # type: Dict[str, Any] 

1278 comment_strings = comment_strings or [] 

1279 for n in range(start, end + 1): 

1280 nstr = str(n) 

1281 i = n - start 

1282 colname = prefix + nstr + suffix 

1283 if comment_fmt: 

1284 s = "" 

1285 if 0 <= i < len(comment_strings): 

1286 s = comment_strings[i] or "" 

1287 colkwargs["comment"] = comment_fmt.format(n=n, s=s) 

1288 if minimum is not None or maximum is not None or pv is not None: 

1289 colkwargs[COLATTR_PERMITTED_VALUE_CHECKER] = PermittedValueChecker( 

1290 minimum=minimum, maximum=maximum, permitted_values=pv 

1291 ) 

1292 setattr( 

1293 cls, colname, camcops_column(colname, coltype, **colkwargs) 

1294 ) 

1295 else: 

1296 setattr(cls, colname, Column(colname, coltype, **colkwargs)) 

1297 

1298 

1299# ============================================================================= 

1300# TaskDescendant 

1301# ============================================================================= 

1302 

1303 

1304class TaskDescendant(object): 

1305 """ 

1306 Information mixin for sub-tables that can be traced back to a class. Used 

1307 to denormalize the database for export in some circumstances. 

1308 

1309 Not used for the Blob class, which has no reasonable way of tracing itself 

1310 back to a given task if it is used by a task's ancillary tables rather than 

1311 a primary task row. 

1312 """ 

1313 

1314 @classmethod 

1315 def task_ancestor_class(cls) -> Optional[Type["Task"]]: 

1316 """ 

1317 Returns the class of the ancestral task. 

1318 

1319 If the descendant can descend from lots of types of task (rare; only 

1320 applies to :class:`camcops_server.cc_modules.cc_blob.Blob` and 

1321 :class:`camcops_server.cc_modules.cc_summaryelement.ExtraSummaryTable`), 

1322 returns ``None``. 

1323 """ 

1324 raise NotImplementedError 

1325 

1326 @classmethod 

1327 def task_ancestor_might_have_patient(cls) -> bool: 

1328 """ 

1329 Does this object have a single task ancestor, that is not anonymous? 

1330 """ 

1331 taskcls = cls.task_ancestor_class() 

1332 if not taskcls: 

1333 return True # e.g. Blob, ExtraSummaryTable 

1334 return not taskcls.is_anonymous 

1335 

1336 def task_ancestor_server_pk(self) -> Optional[int]: 

1337 """ 

1338 Returns the server PK of the ancestral task. 

1339 

1340 Note that this is an export-time calculation; the client may update its 

1341 task rows without updating its descendant rows (so server PKs change 

1342 whilst client IDs don't). 

1343 """ 

1344 task = self.task_ancestor() 

1345 if not task: 

1346 return None 

1347 return task.pk 

1348 

1349 def task_ancestor(self) -> Optional["Task"]: 

1350 """ 

1351 Returns the specific ancestor task of this object. 

1352 """ 

1353 raise NotImplementedError 

1354 

1355 def task_ancestor_patient(self) -> Optional["Patient"]: 

1356 """ 

1357 Returns the associated patient, if there is one. 

1358 """ 

1359 task = self.task_ancestor() 

1360 return task.patient if task else None 

1361 

1362 @classmethod 

1363 def extra_task_xref_columns(cls) -> List[Column]: 

1364 """ 

1365 Returns extra columns used to cross-reference this 

1366 :class:`TaskDescendant` to its ancestor task, in certain export 

1367 formats (``DB_PATIENT_ID_PER_ROW``). 

1368 """ 

1369 return [ 

1370 Column( 

1371 EXTRA_TASK_TABLENAME_FIELD, 

1372 TableNameColType, 

1373 comment=EXTRA_COMMENT_PREFIX + "Table name of ancestor task", 

1374 ), 

1375 Column( 

1376 EXTRA_TASK_SERVER_PK_FIELD, 

1377 Integer, 

1378 comment=EXTRA_COMMENT_PREFIX + "Server PK of ancestor task", 

1379 ), 

1380 ] 

1381 

1382 def add_extra_task_xref_info_to_row(self, row: Dict[str, Any]) -> None: 

1383 """ 

1384 For the ``DB_PATIENT_ID_PER_ROW`` export option. Adds additional 

1385 cross-referencing info to a row. 

1386 

1387 Args: 

1388 row: future database row, as a dictionary 

1389 """ 

1390 ancestor = self.task_ancestor() 

1391 if ancestor: 

1392 row[EXTRA_TASK_TABLENAME_FIELD] = ancestor.tablename 

1393 row[EXTRA_TASK_SERVER_PK_FIELD] = ancestor.pk