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
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 15:51 +0100
1"""
2camcops_server/cc_modules/cc_db.py
4===============================================================================
6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
9 This file is part of CamCOPS.
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.
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.
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/>.
24===============================================================================
26**Common database code, e.g. mixins for tables that are uploaded from the
27client.**
29"""
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)
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
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)
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
115log = BraceStyleAdapter(logging.getLogger(__name__))
118# =============================================================================
119# Hacks for specific database drivers
120# =============================================================================
122CRASH_ON_BAD_CONVERSIONS = False # for debugging only!
124if CRASH_ON_BAD_CONVERSIONS:
125 log.error("DANGER: CRASH_ON_BAD_CONVERSIONS set in cc_db.py")
127try:
128 import MySQLdb
129 import MySQLdb.converters
130except ImportError:
131 MySQLdb = None
133try:
134 import pymysql
135 import pymysql.converters
136except ImportError:
137 pymysql = None
139_SQL_LITERAL_TYPE = Union[int, float, str]
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
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]
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)!
160 **Conversions by mysqlclient (MySQLdb)**
162 As per the help docstring for ``MySQLdb/converters.py``,
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.
168 - The database-to-Python conversion function has the argument ``f(s)``
169 where ``s`` is a string; it returns a Python object.
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.
175 **Conversions by pymysql**
177 Similar (for back compatibility), but not the same.
179 - ``pymysql.converters.conversions`` is ``pymysql.converters.decoders`` and
180 contains database-to-Python converters.
182 - ``pymysql.converters.encoders`` contains Python-to-database converters.
184 Args:
185 o: Python object
186 d: MySQLdb conversion dictionary
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)
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)
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# -----------------------------------------------------------------------------
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 )
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)
244# =============================================================================
245# Constants
246# =============================================================================
248T = TypeVar("T")
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"
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"
280# Fieldnames for the task patient mixin. Do not change.
281TFN_PATIENT_ID = "patient_id"
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"
291# Fieldnames for the task respondent mixin. Do not change.
292TFN_RESPONDENT_NAME = "respondent_name"
293TFN_RESPONDENT_RELATIONSHIP = "respondent_relationship"
295# Selected field/column names for patients. Do not change.
296PFN_UUID = "uuid"
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"
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)
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)
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}
420# =============================================================================
421# GenericTabletRecordMixin
422# =============================================================================
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.
431 From the server's perspective, ``_pk`` is the unique primary key.
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 """
438 __tablename__ = None # type: str # sorts out some mixin type checking
440 # -------------------------------------------------------------------------
441 # On the server side:
442 # -------------------------------------------------------------------------
444 # Plain columns
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 )
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 )
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
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
583 # noinspection PyMethodParameters
584 _addition_pending: Mapped[bool] = mapped_column(
585 FN_ADDITION_PENDING,
586 default=False,
587 comment="(SERVER) Addition pending?",
588 )
590 # noinspection PyMethodParameters
591 _removal_pending: Mapped[Optional[bool]] = mapped_column(
592 FN_REMOVAL_PENDING,
593 default=False,
594 comment="(SERVER) Removal pending?",
595 )
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 )
605 # -------------------------------------------------------------------------
606 # Fields that *all* client tables have:
607 # -------------------------------------------------------------------------
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 )
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 )
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 )
632 # -------------------------------------------------------------------------
633 # Relationships
634 # -------------------------------------------------------------------------
636 # noinspection PyMethodParameters
637 @declared_attr
638 def _device(cls) -> Mapped["Device"]:
639 return relationship("Device")
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
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
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
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 )
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
668 # -------------------------------------------------------------------------
669 # Fetching attributes
670 # -------------------------------------------------------------------------
672 @property
673 def pk(self) -> Optional[int]:
674 """
675 Returns the (server) primary key of this record.
676 """
677 return self._pk
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
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
695 @property
696 def group_id(self) -> Optional[int]:
697 """
698 Returns the group ID of this record.
699 """
700 return self._group_id
702 # -------------------------------------------------------------------------
703 # Other universal properties
704 # -------------------------------------------------------------------------
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
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()
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
727 server_device = Device.get_server_device(req.dbsession)
728 return self._era == ERA_NOW and self._device_id == server_device.id
730 # -------------------------------------------------------------------------
731 # Autoscanning objects and their relationships
732 # -------------------------------------------------------------------------
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.
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 )
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.
762 Used by :func:`_get_xml_root` above, but also by Tasks themselves.
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
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])
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 )
837 # -------------------------------------------------------------------------
838 # Erasing (overwriting data, not deleting the database records)
839 # -------------------------------------------------------------------------
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.
846 The object remains ``_current`` (if it was), as a placeholder, but its
847 contents are wiped.
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
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)
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).
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
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
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
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
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
960 def get_lineage(self) -> List["GenericTabletRecordMixin"]:
961 """
962 Returns all records that are part of the same "lineage", that is:
964 - of the same class;
965 - matching on id/device_id/era;
966 - including both current and any historical non-current versions.
968 Will include the "self" object.
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)
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
1001 # -------------------------------------------------------------------------
1002 # Retrieving a linked record by client ID
1003 # -------------------------------------------------------------------------
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()
1027 # -------------------------------------------------------------------------
1028 # History functions for server-side editing
1029 # -------------------------------------------------------------------------
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).
1038 Amends this object so the "self" object replaces the predecessor, so:
1040 - "self" becomes current and refers back to "predecessor";
1041 - "predecessor" becomes non-current and refers forward to "self".
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)
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
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
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
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__
1112 saved_ok = False
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
1129 next_id = last_id + 1
1131 while not saved_ok:
1132 self.id = next_id
1134 req.dbsession.add(self)
1136 try:
1137 req.dbsession.flush()
1138 saved_ok = True
1139 except IntegrityError:
1140 req.dbsession.rollback()
1141 next_id += 1
1143 # -------------------------------------------------------------------------
1144 # Override this if you provide summaries
1145 # -------------------------------------------------------------------------
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).
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 []
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)]
1166# =============================================================================
1167# Relationships
1168# =============================================================================
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 )
1209# =============================================================================
1210# Field creation assistance
1211# =============================================================================
1213# TypeEngineBase = TypeVar('TypeEngineBase', bound=TypeEngine)
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.
1236 Called via __init_subclass__() on the base class.
1237 Used to make task creation a bit easier.
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:
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.
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))
1299# =============================================================================
1300# TaskDescendant
1301# =============================================================================
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.
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 """
1314 @classmethod
1315 def task_ancestor_class(cls) -> Optional[Type["Task"]]:
1316 """
1317 Returns the class of the ancestral task.
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
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
1336 def task_ancestor_server_pk(self) -> Optional[int]:
1337 """
1338 Returns the server PK of the ancestral task.
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
1349 def task_ancestor(self) -> Optional["Task"]:
1350 """
1351 Returns the specific ancestor task of this object.
1352 """
1353 raise NotImplementedError
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
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 ]
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.
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