Coverage for cc_modules/cc_db.py: 52%

1"""

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

27 client.**

28

29"""

30

31 from collections import OrderedDict

32 import datetime

33 import logging

34 from 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

52 from cardinal_pythonlib.logs import BraceStyleAdapter

53 from cardinal_pythonlib.sqlalchemy.orm_inspect import gen_columns

54 from pendulum import DateTime as Pendulum

55 from semantic_version import Version

56 from sqlalchemy.exc import IntegrityError

57 from sqlalchemy.ext.declarative import declared_attr

58 from sqlalchemy.orm import Mapped, mapped_column, relationship

59 from sqlalchemy.orm.relationships import RelationshipProperty

60 from sqlalchemy.orm import Session as SqlASession

61 from sqlalchemy.sql.functions import func

62 from sqlalchemy.sql.schema import Column, ForeignKey

63 from sqlalchemy.sql.sqltypes import Integer

64

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

75 from camcops_server.cc_modules.cc_dataclasses import SummarySchemaInfo

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

89 from camcops_server.cc_modules.cc_simpleobjects import TaskExportOptions

90 from camcops_server.cc_modules.cc_spreadsheet import SpreadsheetPage

91 from camcops_server.cc_modules.cc_version import CAMCOPS_SERVER_VERSION

92 from 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

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

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

116

117

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

119# Hacks for specific database drivers

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

121

122 CRASH_ON_BAD_CONVERSIONS = False # for debugging only!

123

124 if CRASH_ON_BAD_CONVERSIONS:

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

126

127 try:

128 import MySQLdb

129 import MySQLdb.converters

130 except ImportError:

131 MySQLdb = None

132

133 try:

134 import pymysql

135 import pymysql.converters

136 except 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

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

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

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

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

248 T = TypeVar("T")

249

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

251 FN_PK = "_pk"

252 FN_DEVICE_ID = "_device_id"

253 FN_ERA = "_era"

254 FN_CURRENT = "_current"

255 FN_WHEN_ADDED_EXACT = "_when_added_exact"

256 FN_WHEN_ADDED_BATCH_UTC = "_when_added_batch_utc"

257 FN_ADDING_USER_ID = "_adding_user_id"

258 FN_WHEN_REMOVED_EXACT = "_when_removed_exact"

259 FN_WHEN_REMOVED_BATCH_UTC = "_when_removed_batch_utc"

260 FN_REMOVING_USER_ID = "_removing_user_id"

261 FN_PRESERVING_USER_ID = "_preserving_user_id"

262 FN_FORCIBLY_PRESERVED = "_forcibly_preserved"

263 FN_PREDECESSOR_PK = "_predecessor_pk"

264 FN_SUCCESSOR_PK = "_successor_pk"

265 FN_MANUALLY_ERASED = "_manually_erased"

266 FN_MANUALLY_ERASED_AT = "_manually_erased_at"

267 FN_MANUALLY_ERASING_USER_ID = "_manually_erasing_user_id"

268 FN_CAMCOPS_VERSION = "_camcops_version"

269 FN_ADDITION_PENDING = "_addition_pending"

270 FN_REMOVAL_PENDING = "_removal_pending"

271 FN_GROUP_ID = "_group_id"

272

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

274 TFN_WHEN_CREATED = "when_created"

275 TFN_WHEN_FIRSTEXIT = "when_firstexit"

276 TFN_FIRSTEXIT_IS_FINISH = "firstexit_is_finish"

277 TFN_FIRSTEXIT_IS_ABORT = "firstexit_is_abort"

278 TFN_EDITING_TIME_S = "editing_time_s"

279

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

281 TFN_PATIENT_ID = "patient_id"

282

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

284 TFN_CLINICIAN_SPECIALTY = "clinician_specialty"

285 TFN_CLINICIAN_NAME = "clinician_name"

286 TFN_CLINICIAN_PROFESSIONAL_REGISTRATION = "clinician_professional_registration"

287 TFN_CLINICIAN_POST = "clinician_post"

288 TFN_CLINICIAN_SERVICE = "clinician_service"

289 TFN_CLINICIAN_CONTACT_DETAILS = "clinician_contact_details"

290

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

292 TFN_RESPONDENT_NAME = "respondent_name"

293 TFN_RESPONDENT_RELATIONSHIP = "respondent_relationship"

294

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

296 PFN_UUID = "uuid"

297

298# Column names for task summaries.

299 SFN_IS_COMPLETE = "is_complete"

300 SFN_SECONDS_CREATION_TO_FIRST_FINISH = "seconds_from_creation_to_first_finish"

301 SFN_CAMCOPS_SERVER_VERSION = "camcops_server_version"

302

303 RESERVED_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 "_"...

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

327

328 TABLET_STANDARD_FIELDS = RESERVED_FIELDS + (

329 TABLET_ID_FIELD,

330 CLIENT_DATE_FIELD, # when_last_modified

331 MOVE_OFF_TABLET_FIELD,

332)

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

341 TASK_FREQUENT_AND_FK_FIELDS = TASK_STANDARD_FIELDS + (

342 # Tasks with a patient:

343 TFN_PATIENT_ID,

344)

345 TASK_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

358 REMOVE_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

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

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

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

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