Coverage for cc_modules/cc_client_api_core.py: 91%
302 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_client_api_core.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**Core constants and functions used by the client (tablet device) API.**
28"""
30from typing import (
31 Any,
32 Dict,
33 Iterable,
34 List,
35 NoReturn,
36 Optional,
37 Set,
38 TYPE_CHECKING,
39)
41from cardinal_pythonlib.datetimefunc import format_datetime
42from cardinal_pythonlib.reprfunc import simple_repr
43from pendulum import DateTime as Pendulum
44from sqlalchemy.sql.expression import literal, select
45from sqlalchemy.sql.schema import Table
47from camcops_server.cc_modules.cc_constants import (
48 CLIENT_DATE_FIELD,
49 DateFormat,
50 ERA_NOW,
51 MOVE_OFF_TABLET_FIELD,
52)
53from camcops_server.cc_modules.cc_db import (
54 FN_ADDITION_PENDING,
55 FN_CURRENT,
56 FN_DEVICE_ID,
57 FN_ERA,
58 FN_FORCIBLY_PRESERVED,
59 FN_PK,
60 FN_PREDECESSOR_PK,
61 FN_PRESERVING_USER_ID,
62 FN_REMOVAL_PENDING,
63 FN_REMOVING_USER_ID,
64 FN_SUCCESSOR_PK,
65 FN_WHEN_REMOVED_BATCH_UTC,
66 FN_WHEN_REMOVED_EXACT,
67)
69if TYPE_CHECKING:
70 from camcops_server.cc_modules.cc_exportrecipient import ExportRecipient
71 from camcops_server.cc_modules.cc_request import CamcopsRequest
74# =============================================================================
75# Constants
76# =============================================================================
79class TabletParam(object):
80 """
81 Keys used by server or client (in the comments: S server, C client, B
82 bidirectional).
83 """
85 ADDRESS = "address" # C->S, in JSON, v2.3.0
86 ANONYMOUS = "anonymous" # S->C; new in v2.4.0
87 CAMCOPS_VERSION = "camcops_version" # C->S
88 COMPLETE = "complete" # S->C; new in v2.4.0
89 DATABASE_TITLE = "databaseTitle" # S->C
90 DATEVALUES = "datevalues" # C->S
91 DBDATA = "dbdata" # C->S, v2.3.0
92 DEVICE = "device" # C->S
93 DEVICE_FRIENDLY_NAME = "devicefriendlyname" # C->S
94 DOB = "dob" # C->S, in JSON, v2.3.0
95 DUE_BY = "due_by" # C->S; new in v2.4.0
96 DUE_FROM = "due_from" # C->S; new in v2.4.0
97 EMAIL = "email" # C->S; new in v2.4.0
98 ERROR = "error" # S->C
99 FIELDS = "fields" # B
100 FINALIZING = "finalizing"
101 # ... C->S, in JSON and upload_entire_database, v2.3.0; synonym for
102 # preserving
103 FORENAME = "forename" # C->S, in JSON, v2.3.0
104 GP = "gp" # C->S, in JSON, v2.3.0
105 ID_DESCRIPTION_PREFIX = "idDescription" # S->C
106 ID_POLICY_FINALIZE = "idPolicyFinalize" # S->C
107 ID_POLICY_UPLOAD = "idPolicyUpload" # S->C
108 ID_SHORT_DESCRIPTION_PREFIX = "idShortDescription" # S->C
109 ID_VALIDATION_METHOD_PREFIX = "idValidationMethod" # S->C; new in v2.2.8
110 IDNUM_PREFIX = "idnum" # C->S, in JSON, v2.3.0
111 IP_USE_INFO = "ip_use_info" # S->C; new in v2.4.0
112 IP_USE_COMMERCIAL = "ip_use_commercial" # S->C; new in v2.4.0
113 IP_USE_CLINICAL = "ip_use_clinical" # S->C; new in v2.4.0
114 IP_USE_EDUCATIONAL = "ip_use_educational" # S->C; new in v2.4.0
115 IP_USE_RESEARCH = "ip_use_research" # S->C; new in v2.4.0
116 MOVE_OFF_TABLET_VALUES = "move_off_tablet_values" # C->S, v2.3.0
117 NFIELDS = "nfields" # B
118 NRECORDS = "nrecords" # B
119 OPERATION = "operation" # C->S
120 OTHER = "other" # C->S, in JSON, v2.3.0
121 PASSWORD = "password" # C->S
122 PATIENT_INFO = "patient_info" # C->S; new in v2.3.0, S->C new in v2.4.0
123 PATIENT_PROQUINT = "patient_proquint" # C->S; new in v2.4.0
124 PKNAME = "pkname" # C->S
125 PKNAMEINFO = "pknameinfo" # C->S, new in v2.3.0
126 PKVALUES = "pkvalues" # C->S
127 RECORD_PREFIX = "record" # B
128 RESULT = "result" # S->C
129 SERVER_CAMCOPS_VERSION = "serverCamcopsVersion" # S->C
130 SESSION_ID = "session_id" # B
131 SESSION_TOKEN = "session_token" # B
132 SETTINGS = "settings" # S->C; new in v2.4.0
133 SEX = "sex" # C->S, in JSON, v2.3.0
134 SUCCESS = "success" # S->C
135 SURNAME = "surname" # C->S, in JSON, v2.3.0
136 TABLE = "table" # C->S
137 TABLES = "tables" # C->S
138 TASK_SCHEDULES = "task_schedules" # S->C; new in v2.4.0
139 TASK_SCHEDULE_ITEMS = "task_schedule_items" # S->C; new in v2.4.0
140 TASK_SCHEDULE_NAME = "task_schedule_name" # S->C; new in v2.4.0
141 USER = "user" # C->S
142 VALUES = "values" # C->S
143 WHEN_COMPLETED = "when_completed" # S->C; new in v2.4.0
145 # Retired (part of defunct mobileweb interface):
146 # WHEREFIELDS = "wherefields"
147 # WHERENOTFIELDS = "wherenotfields"
148 # WHERENOTVALUES = "wherenotvalues"
149 # WHEREVALUES = "wherevalues"
152class ExtraStringFieldNames(object):
153 """
154 To match ``extrastring.cpp`` on the tablet.
155 """
157 TASK = "task"
158 NAME = "name"
159 LANGUAGE = "language"
160 VALUE = "value"
163class AllowedTablesFieldNames(object):
164 """
165 To match ``allowedservertable.cpp`` on the tablet
166 """
168 TABLENAME = "tablename"
169 MIN_CLIENT_VERSION = "min_client_version"
172# =============================================================================
173# Exceptions used by client API
174# =============================================================================
175# Note the following about exception strings:
176#
177# class Blah(Exception):
178# pass
179#
180# x = Blah("hello")
181# str(x) # 'hello'
184class UserErrorException(Exception):
185 """
186 Exception class for when the input from the tablet is dodgy.
187 """
189 pass
192class ServerErrorException(Exception):
193 """
194 Exception class for when something's broken on the server side.
195 """
197 pass
200# =============================================================================
201# Return message functions
202# =============================================================================
205def exception_description(e: Exception) -> str:
206 """
207 Returns a formatted description of a Python exception.
208 """
209 return f"{type(e).__name__}: {str(e)}"
212def fail_user_error(msg: str) -> NoReturn:
213 """
214 Function to abort the script when the input is dodgy.
216 Raises :exc:`UserErrorException`.
217 """
218 # While Titanium-Android can extract error messages from e.g.
219 # finish("400 Bad Request: @_"), Titanium-iOS can't, and we need the error
220 # messages. Therefore, we will return an HTTP success code but "Success: 0"
221 # in the reply details.
222 raise UserErrorException(msg)
225def require_keys(dictionary: Dict[Any, Any], keys: List[Any]) -> None:
226 """
227 Ensure that all listed keys are present in the specified dictionary, or
228 raise a :exc:`UserErrorException`.
229 """
230 for k in keys:
231 if k not in dictionary:
232 fail_user_error(f"Field {repr(k)} missing in client input")
235def fail_user_error_from_exception(e: Exception) -> NoReturn:
236 """
237 Raise :exc:`UserErrorException` with a description that comes from
238 the specified exception.
239 """
240 fail_user_error(exception_description(e))
243def fail_server_error(msg: str) -> NoReturn:
244 """
245 Function to abort the script when something's broken server-side.
247 Raises :exc:`ServerErrorException`.
248 """
249 raise ServerErrorException(msg)
252def fail_server_error_from_exception(e: Exception) -> NoReturn:
253 """
254 Raise :exc:`ServerErrorException` with a description that comes from
255 the specified exception.
256 """
257 fail_server_error(exception_description(e))
260def fail_unsupported_operation(operation: str) -> NoReturn:
261 """
262 Abort the script (with a :exc:`UserErrorException`) when the
263 operation is invalid.
264 """
265 fail_user_error(f"operation={operation}: not supported")
268# =============================================================================
269# Information classes used during upload
270# =============================================================================
273class BatchDetails(object):
274 """
275 Represents a current upload batch.
276 """
278 def __init__(
279 self,
280 batchtime: Optional[Pendulum] = None,
281 preserving: bool = False,
282 onestep: bool = False,
283 ) -> None:
284 """
285 Args:
286 batchtime:
287 the batchtime; UTC time this upload batch started; will be
288 applied to all changes
289 preserving:
290 are we preserving (finalizing) the records -- that is, moving
291 them from the current era (``NOW``) to the ``batchtime`` era,
292 so they can be deleted from the tablet without apparent loss on
293 the server?
294 onestep:
295 is this a one-step whole-database upload?
296 """
297 self.batchtime = batchtime
298 self.preserving = preserving
299 self.onestep = onestep
301 def __repr__(self) -> str:
302 return simple_repr(self, ["batchtime", "preserving", "onestep"])
304 @property
305 def new_era(self) -> str:
306 """
307 Returns the string used for the new era for this batch, in case we
308 are preserving records.
309 """
310 return format_datetime(self.batchtime, DateFormat.ERA)
313class WhichKeyToSendInfo(object):
314 """
315 Represents information the client has sent, asking us which records it
316 needs to upload recordwise.
317 """
319 def __init__(
320 self,
321 client_pk: int,
322 client_when: Pendulum,
323 client_move_off_tablet: bool,
324 ) -> None:
325 self.client_pk = client_pk
326 self.client_when = client_when
327 self.client_move_off_tablet = client_move_off_tablet
330class ServerRecord(object):
331 """
332 Class to represent whether a server record exists, and/or the results of
333 retrieving server records.
334 """
336 def __init__(
337 self,
338 client_pk: int = None,
339 exists_on_server: bool = False,
340 server_pk: int = None,
341 server_when: Pendulum = None,
342 move_off_tablet: bool = False,
343 current: bool = False,
344 addition_pending: bool = False,
345 removal_pending: bool = False,
346 predecessor_pk: int = None,
347 successor_pk: int = None,
348 ) -> None:
349 """
350 Args:
351 client_pk: client's PK
352 exists_on_server: does the record exist on the server?
353 server_pk: if it exists, what's the server PK?
354 server_when: if it exists, what's the server's "when"
355 (``when_last_modified``) field?
356 move_off_tablet: is the ``__move_off_tablet`` flag set?
357 current: is the record current (``_current`` flag set)?
358 addition_pending: is the ``_addition_pending`` flag set?
359 removal_pending: is the ``_removal_pending`` flag set?
360 predecessor_pk: predecessor server PK, or ``None``
361 successor_pk: successor server PK, or ``None``
362 """
363 self.client_pk = client_pk
364 self.exists = exists_on_server
365 self.server_pk = server_pk
366 self.server_when = server_when
367 self.move_off_tablet = move_off_tablet
368 self.current = current
369 self.addition_pending = addition_pending
370 self.removal_pending = removal_pending
371 self.predecessor_pk = predecessor_pk
372 self.successor_pk = successor_pk
374 def __repr__(self) -> str:
375 return simple_repr(
376 self,
377 [
378 "client_pk",
379 "exists",
380 "server_pk",
381 "server_when",
382 "move_off_tablet",
383 "current",
384 "addition_pending",
385 "removal_pending",
386 "predecessor_pk",
387 "successor_pk",
388 ],
389 )
392class UploadRecordResult(object):
393 """
394 Represents the result of uploading a record.
395 """
397 def __init__(
398 self,
399 oldserverpk: Optional[int] = None,
400 newserverpk: Optional[int] = None,
401 dirty: bool = False,
402 specifically_marked_for_preservation: bool = False,
403 ):
404 """
405 Args:
406 oldserverpk:
407 the server's PK of the old version of the record; ``None`` if
408 the record is new
409 newserverpk:
410 the server's PK of the new version of the record; ``None`` if
411 the record was unmodified
412 dirty:
413 was the database table modified? (May be true even if
414 ``newserverpk`` is ``None``, if ``_move_off_tablet`` was set.
415 specifically_marked_for_preservation:
416 should the record(s) be preserved?
417 """
418 self.oldserverpk = oldserverpk
419 self.newserverpk = newserverpk
420 self.dirty = dirty
421 self.specifically_marked_for_preservation = (
422 specifically_marked_for_preservation
423 )
424 self._specifically_marked_preservation_pks = [] # type: List[int]
426 def __repr__(self) -> str:
427 return simple_repr(
428 self,
429 [
430 "oldserverpk",
431 "newserverpk",
432 "dirty",
433 "to_be_preserved",
434 "specifically_marked_preservation_pks",
435 ],
436 )
438 def note_specifically_marked_preservation_pks(
439 self, pks: List[int]
440 ) -> None:
441 """
442 Notes that some PKs are marked specifically for preservation.
443 """
444 self._specifically_marked_preservation_pks.extend(pks)
446 @property
447 def latest_pk(self) -> Optional[int]:
448 """
449 Returns the latest of the two PKs.
450 """
451 if self.newserverpk is not None:
452 return self.newserverpk
453 return self.oldserverpk
455 @property
456 def specifically_marked_preservation_pks(self) -> List[int]:
457 """
458 Returns a list of server PKs of records specifically marked to be
459 preserved. This may include older versions (the predecessor chain) of
460 records being uploaded.
461 """
462 return self._specifically_marked_preservation_pks
464 @property
465 def addition_pks(self) -> List[int]:
466 """
467 Returns a list of PKs representing new records being added.
468 """
469 return [self.newserverpk] if self.newserverpk is not None else []
471 @property
472 def removal_modified_pks(self) -> List[int]:
473 """
474 Returns a list of PKs representing records removed because they have
475 been "modified out".
476 """
477 if self.oldserverpk is not None and self.newserverpk is not None:
478 return [self.oldserverpk]
479 return []
481 @property
482 def all_pks(self) -> List[int]:
483 """
484 Returns all PKs (old, new, or both).
485 """
486 return list(
487 x for x in (self.oldserverpk, self.newserverpk) if x is not None
488 )
490 @property
491 def current_pks(self) -> List[int]:
492 """
493 Returns PKs that represent current records on the server.
494 """
495 if self.newserverpk is not None:
496 return [self.newserverpk] # record was replaced; new one's current
497 if self.oldserverpk is not None:
498 return [self.oldserverpk] # not replaced; old one's current
499 return []
502class UploadTableChanges(object):
503 """
504 Represents information to process and audit an upload to a table.
505 """
507 def __init__(self, table: Table) -> None:
508 self.table = table
509 self._addition_pks = set() # type: Set[int]
510 self._removal_modified_pks = set() # type: Set[int]
511 self._removal_deleted_pks = set() # type: Set[int]
512 self._preservation_pks = set() # type: Set[int]
513 self._current_pks = set() # type: Set[int]
515 # -------------------------------------------------------------------------
516 # Basic info
517 # -------------------------------------------------------------------------
519 @property
520 def tablename(self) -> str:
521 """
522 The table's name.
523 """
524 return self.table.name
526 # -------------------------------------------------------------------------
527 # Tell us about PKs
528 # -------------------------------------------------------------------------
530 def note_addition_pk(self, pk: int) -> None:
531 """
532 Records an "addition" PK.
533 """
534 self._addition_pks.add(pk)
536 def note_addition_pks(self, pks: Iterable[int]) -> None:
537 """
538 Records multiple "addition" PKs.
539 """
540 self._addition_pks.update(pks)
542 def note_removal_modified_pk(self, pk: int) -> None:
543 """
544 Records a "removal because modified" PK (replaced by successor).
545 """
546 self._removal_modified_pks.add(pk)
548 def note_removal_modified_pks(self, pks: Iterable[int]) -> None:
549 """
550 Records multiple "removal because modified" PKs.
551 """
552 self._removal_modified_pks.update(pks)
554 def note_removal_deleted_pk(self, pk: int) -> None:
555 """
556 Records a "deleted" PK (removed with no successor).
557 """
558 self._removal_deleted_pks.add(pk)
560 def note_removal_deleted_pks(self, pks: Iterable[int]) -> None:
561 """
562 Records multiple "deleted" PKs (see :func:`note_removal_deleted_pk`).
563 """
564 self._removal_deleted_pks.update(pks)
566 def note_preservation_pk(self, pk: int) -> None:
567 """
568 Records a "preservation" PK (a record that's being finalized).
569 """
570 self._preservation_pks.add(pk)
572 def note_preservation_pks(self, pks: Iterable[int]) -> None:
573 """
574 Records multiple "preservation" PKs (records that are being finalized).
575 """
576 self._preservation_pks.update(pks)
578 def note_current_pk(self, pk: int) -> None:
579 """
580 Records that a record is current on the server. For indexing.
581 """
582 self._current_pks.add(pk)
584 def note_current_pks(self, pks: Iterable[int]) -> None:
585 """
586 Records multiple "current" PKs.
587 """
588 self._current_pks.update(pks)
590 def note_urr(
591 self, urr: UploadRecordResult, preserving_new_records: bool
592 ) -> None:
593 """
594 Records information from a :class:`UploadRecordResult`, which is itself
595 the result of calling
596 :func:`camcops_server.cc_modules.client_api.upload_record_core`.
598 Called by
599 :func:`camcops_server.cc_modules.client_api.process_table_for_onestep_upload`.
601 Args:
602 urr: a :class:`UploadRecordResult`
603 preserving_new_records: are new records being preserved?
604 """
605 self.note_addition_pks(urr.addition_pks)
606 self.note_removal_modified_pks(urr.removal_modified_pks)
607 if preserving_new_records:
608 self.note_preservation_pks(urr.addition_pks)
609 self.note_preservation_pks(urr.specifically_marked_preservation_pks)
610 self.note_current_pks(urr.current_pks)
612 def note_serverrec(self, sr: ServerRecord, preserving: bool) -> None:
613 """
614 Records information from a :class:`ServerRecord`. Called by
615 :func:`camcops_server.cc_modules.client_api.commit_table`.
617 Args:
618 sr: a :class:`ServerRecord`
619 preserving: are we preserving uploaded records?
620 """
621 pk = sr.server_pk
622 if sr.addition_pending:
623 self.note_addition_pk(pk)
624 self.note_current_pk(pk)
625 elif sr.removal_pending:
626 if sr.successor_pk is None:
627 self.note_removal_deleted_pk(pk)
628 else:
629 self.note_removal_modified_pk(pk)
630 elif sr.current:
631 self.note_current_pk(pk)
632 if preserving or sr.move_off_tablet:
633 self.note_preservation_pk(pk)
635 # -------------------------------------------------------------------------
636 # Counts
637 # -------------------------------------------------------------------------
639 @property
640 def n_added(self) -> int:
641 """
642 Number of server records added.
643 """
644 return len(self._addition_pks)
646 @property
647 def n_removed_modified(self) -> int:
648 """
649 Number of server records "modified out" -- replaced by a modified
650 version and marked as removed.
651 """
652 return len(self._removal_modified_pks)
654 @property
655 def n_removed_deleted(self) -> int:
656 """
657 Number of server records "deleted" -- marked as removed with no
658 successor.
659 """
660 return len(self._removal_deleted_pks)
662 @property
663 def n_removed(self) -> int:
664 """
665 Number of server records "removed" -- marked as removed (either with or
666 without a successor).
667 """
668 return self.n_removed_modified + self.n_removed_deleted
670 @property
671 def n_preserved(self) -> int:
672 """
673 Number of server records "preserved" (finalized) -- moved from the
674 ``NOW`` era to the batch era (and no longer modifiable by the client
675 device).
676 """
677 return len(self._preservation_pks)
679 # -------------------------------------------------------------------------
680 # PKs for various purposes
681 # -------------------------------------------------------------------------
683 @property
684 def addition_pks(self) -> List[int]:
685 """
686 Server PKs of records being added.
687 """
688 return sorted(self._addition_pks)
690 @property
691 def removal_modified_pks(self) -> List[int]:
692 """
693 Server PKs of records being modified out.
694 """
695 return sorted(self._removal_modified_pks)
697 @property
698 def removal_deleted_pks(self) -> List[int]:
699 """
700 Server PKs of records being deleted.
701 """
702 return sorted(self._removal_deleted_pks)
704 @property
705 def removal_pks(self) -> List[int]:
706 """
707 Server PKs of records being removed (modified out, or deleted).
708 """
709 return sorted(self._removal_modified_pks | self._removal_deleted_pks)
711 @property
712 def preservation_pks(self) -> List[int]:
713 """
714 Server PKs of records being preserved.
715 """
716 return sorted(self._preservation_pks)
718 @property
719 def current_pks(self) -> List[int]:
720 return sorted(self._current_pks)
722 @property
723 def idnum_delete_index_pks(self) -> List[int]:
724 """
725 Server PKs of records to delete old index entries for, if this is the
726 ID number table. (Includes records that need re-indexing.)
728 We don't care about preservation PKs here, as the ID number index
729 doesn't incorporate that.
730 """
731 return sorted(self._removal_modified_pks | self._removal_deleted_pks)
733 @property
734 def idnum_add_index_pks(self) -> List[int]:
735 """
736 Server PKs of records to add index entries for, if this is the ID
737 number table.
738 """
739 return sorted(self._addition_pks)
741 @property
742 def task_delete_index_pks(self) -> List[int]:
743 """
744 Server PKs of records to delete old index entries for, if this is a
745 task table. (Includes records that need re-indexing.)
746 """
747 return sorted(
748 (
749 self._removal_modified_pks
750 | self._removal_deleted_pks # needs reindexing
751 | self._preservation_pks # gone
752 ) # ... these need reindexing
753 - self._addition_pks
754 # ... _addition_pks won't be indexed, so no need to delete index
755 )
757 @property
758 def task_reindex_pks(self) -> List[int]:
759 """
760 Server PKs of records to rebuild index entries for, if this is a task
761 table. (Includes records that need re-indexing.)
763 We include records being preserved, because their era has changed,
764 and the index includes era. Unless they are being removed!
765 """
766 return sorted(
767 (
768 (self._addition_pks | self._preservation_pks) # new; index
769 - ( # reindex (but only if current)
770 self._removal_modified_pks
771 | self._removal_deleted_pks # modified out; don't index
772 ) # deleted; don't index
773 )
774 & self._current_pks # only reindex current PKs
775 )
776 # A quick reminder, since I got this wrong:
777 # | union (A or B)
778 # & intersection (A and B)
779 # ^ xor (A or B but not both)
780 # - difference (A - B)
782 def get_task_push_export_pks(
783 self, recipient: "ExportRecipient", uploading_group_id: int
784 ) -> List[int]:
785 """
786 Returns PKs for tasks matching the requirements of a particular
787 export recipient.
789 (In practice, only "push" recipients will come our way, so we can
790 ignore this.)
791 """
792 if not recipient.is_upload_suitable_for_push(
793 tablename=self.tablename, uploading_group_id=uploading_group_id
794 ):
795 # Not suitable
796 return []
798 if recipient.finalized_only:
799 return sorted(
800 self._preservation_pks # finalized
801 & self._current_pks # only send current tasks
802 )
803 else:
804 return sorted(
805 (
806 self._addition_pks # new (may be unfinalized)
807 | self._preservation_pks # finalized
808 )
809 & self._current_pks # only send current tasks
810 )
812 # -------------------------------------------------------------------------
813 # Audit info
814 # -------------------------------------------------------------------------
816 @property
817 def any_changes(self) -> bool:
818 """
819 Has anything changed that we're aware of?
820 """
821 return (
822 self.n_added > 0
823 or self.n_removed_modified > 0
824 or self.n_removed_deleted > 0
825 or self.n_preserved > 0
826 )
828 def __str__(self) -> str:
829 return (
830 f"{self.tablename}: "
831 f"({self.n_added} added, "
832 f"PKs {self.addition_pks}; "
833 f"{self.n_removed_modified} modified out, "
834 f"PKs {self.removal_modified_pks}; "
835 f"{self.n_removed_deleted} deleted, "
836 f"PKs {self.removal_deleted_pks}; "
837 f"{self.n_preserved} preserved, "
838 f"PKs {self.preservation_pks}; "
839 f"current PKs {self.current_pks})"
840 )
842 def description(self, always_show_current_pks: bool = True) -> str:
843 """
844 Short description, only including bits that have changed.
845 """
846 parts = [] # type: List[str]
847 if self._addition_pks:
848 parts.append(f"{self.n_added} added, PKs {self.addition_pks}")
849 if self._removal_modified_pks:
850 parts.append(
851 f"{self.n_removed_modified} modified out, "
852 f"PKs {self.removal_modified_pks}"
853 )
854 if self._removal_deleted_pks:
855 parts.append(
856 f"{self.n_removed_deleted} deleted, "
857 f"PKs {self.removal_deleted_pks}"
858 )
859 if self._preservation_pks:
860 parts.append(
861 f"{self.n_preserved} preserved, "
862 f"PKs {self.preservation_pks}"
863 )
864 if not parts:
865 parts.append("no changes")
866 if always_show_current_pks or self.any_changes:
867 parts.append(f"current PKs {self.current_pks}")
868 return f"{self.tablename} ({'; '.join(parts)})"
871# =============================================================================
872# Value dictionaries for updating records, to reduce repetition
873# =============================================================================
876def values_delete_later() -> Dict[str, Any]:
877 """
878 Field/value pairs to mark a record as "to be deleted later".
879 """
880 return {FN_REMOVAL_PENDING: 1, FN_SUCCESSOR_PK: None}
883def values_delete_now(
884 req: "CamcopsRequest", batchdetails: BatchDetails
885) -> Dict[str, Any]:
886 """
887 Field/value pairs to mark a record as deleted now.
888 """
889 return {
890 FN_CURRENT: 0,
891 FN_REMOVAL_PENDING: 0,
892 FN_REMOVING_USER_ID: req.user_id,
893 FN_WHEN_REMOVED_EXACT: req.now,
894 FN_WHEN_REMOVED_BATCH_UTC: batchdetails.batchtime,
895 }
898def values_preserve_now(
899 req: "CamcopsRequest",
900 batchdetails: BatchDetails,
901 forcibly_preserved: bool = False,
902) -> Dict[str, Any]:
903 """
904 Field/value pairs to mark a record as preserved now.
905 """
906 return {
907 FN_ERA: batchdetails.new_era,
908 FN_PRESERVING_USER_ID: req.user_id,
909 MOVE_OFF_TABLET_FIELD: 0,
910 FN_FORCIBLY_PRESERVED: forcibly_preserved,
911 }
914# =============================================================================
915# CamCOPS table reading functions
916# =============================================================================
919def get_server_live_records(
920 req: "CamcopsRequest",
921 device_id: int,
922 table: Table,
923 clientpk_name: str = None,
924 current_only: bool = True,
925) -> List[ServerRecord]:
926 """
927 Gets details of all records on the server, for the specified table,
928 that are live on this client device.
930 Args:
931 req: the :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
932 device_id: ID of the
933 :class:`camcops_server.cc_modules.cc_device.Device`
934 table: an SQLAlchemy :class:`Table`
935 clientpk_name: the column name of the client's PK; if none is supplied,
936 the client_pk fields of the results will be ``None``
937 current_only: restrict to "current" (``_current``) records only?
939 Returns:
940 :class:`ServerRecord` objects for active records (``_current`` and in
941 the 'NOW' era) for the specified device/table.
942 """
943 recs = [] # type: List[ServerRecord]
944 c = table.c
945 client_pk_clause = c[clientpk_name] if clientpk_name else literal(None)
946 query = (
947 select(
948 client_pk_clause, # 0: client PK (or None)
949 c[FN_PK], # 1: server PK
950 c[CLIENT_DATE_FIELD], # 2: when last modified (on the server)
951 c[MOVE_OFF_TABLET_FIELD], # 3: move_off_tablet
952 c[FN_CURRENT], # 4: current
953 c[FN_ADDITION_PENDING], # 5
954 c[FN_REMOVAL_PENDING], # 6
955 c[FN_PREDECESSOR_PK], # 7
956 c[FN_SUCCESSOR_PK], # 8
957 )
958 .where(c[FN_DEVICE_ID] == device_id)
959 .where(c[FN_ERA] == ERA_NOW) # type: ignore[arg-type]
960 )
961 if current_only:
962 query = query.where(c[FN_CURRENT])
963 rows = req.dbsession.execute(query)
964 for row in rows:
965 recs.append(
966 ServerRecord(
967 client_pk=row[0],
968 exists_on_server=True,
969 server_pk=row[1],
970 server_when=row[2],
971 move_off_tablet=row[3],
972 current=row[4],
973 addition_pending=row[5],
974 removal_pending=row[6],
975 predecessor_pk=row[7],
976 successor_pk=row[8],
977 )
978 )
979 return recs