Coverage for cc_modules/cc_client_api_core.py: 56%
304 statements
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
1#!/usr/bin/env python
3"""
4camcops_server/cc_modules/cc_client_api_core.py
6===============================================================================
8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
11 This file is part of CamCOPS.
13 CamCOPS is free software: you can redistribute it and/or modify
14 it under the terms of the GNU General Public License as published by
15 the Free Software Foundation, either version 3 of the License, or
16 (at your option) any later version.
18 CamCOPS is distributed in the hope that it will be useful,
19 but WITHOUT ANY WARRANTY; without even the implied warranty of
20 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21 GNU General Public License for more details.
23 You should have received a copy of the GNU General Public License
24 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.
26===============================================================================
28**Core constants and functions used by the client (tablet device) API.**
30"""
32from typing import (
33 Any,
34 Dict,
35 Iterable,
36 List,
37 NoReturn,
38 Optional,
39 Set,
40 TYPE_CHECKING,
41)
43from cardinal_pythonlib.datetimefunc import format_datetime
44from cardinal_pythonlib.reprfunc import simple_repr
45from pendulum import DateTime as Pendulum
46from sqlalchemy.sql.expression import literal, select
47from sqlalchemy.sql.schema import Table
49from camcops_server.cc_modules.cc_constants import (
50 CLIENT_DATE_FIELD,
51 DateFormat,
52 ERA_NOW,
53 MOVE_OFF_TABLET_FIELD,
54)
55from camcops_server.cc_modules.cc_db import (
56 FN_ADDITION_PENDING,
57 FN_CURRENT,
58 FN_DEVICE_ID,
59 FN_ERA,
60 FN_FORCIBLY_PRESERVED,
61 FN_PK,
62 FN_PREDECESSOR_PK,
63 FN_PRESERVING_USER_ID,
64 FN_REMOVAL_PENDING,
65 FN_REMOVING_USER_ID,
66 FN_SUCCESSOR_PK,
67 FN_WHEN_REMOVED_BATCH_UTC,
68 FN_WHEN_REMOVED_EXACT,
69)
71if TYPE_CHECKING:
72 from camcops_server.cc_modules.cc_exportrecipient import ExportRecipient
73 from camcops_server.cc_modules.cc_request import CamcopsRequest
76# =============================================================================
77# Constants
78# =============================================================================
81class TabletParam(object):
82 """
83 Keys used by server or client (in the comments: S server, C client, B
84 bidirectional).
85 """
87 ADDRESS = "address" # C->S, in JSON, v2.3.0
88 ANONYMOUS = "anonymous" # S->C; new in v2.4.0
89 CAMCOPS_VERSION = "camcops_version" # C->S
90 COMPLETE = "complete" # S->C; new in v2.4.0
91 DATABASE_TITLE = "databaseTitle" # S->C
92 DATEVALUES = "datevalues" # C->S
93 DBDATA = "dbdata" # C->S, v2.3.0
94 DEVICE = "device" # C->S
95 DEVICE_FRIENDLY_NAME = "devicefriendlyname" # C->S
96 DOB = "dob" # C->S, in JSON, v2.3.0
97 DUE_BY = "due_by" # C->S; new in v2.4.0
98 DUE_FROM = "due_from" # C->S; new in v2.4.0
99 EMAIL = "email" # C->S; new in v2.4.0
100 ERROR = "error" # S->C
101 FIELDS = "fields" # B
102 FINALIZING = "finalizing"
103 # ... C->S, in JSON and upload_entire_database, v2.3.0; synonym for
104 # preserving
105 FORENAME = "forename" # C->S, in JSON, v2.3.0
106 GP = "gp" # C->S, in JSON, v2.3.0
107 ID_DESCRIPTION_PREFIX = "idDescription" # S->C
108 ID_POLICY_FINALIZE = "idPolicyFinalize" # S->C
109 ID_POLICY_UPLOAD = "idPolicyUpload" # S->C
110 ID_SHORT_DESCRIPTION_PREFIX = "idShortDescription" # S->C
111 ID_VALIDATION_METHOD_PREFIX = "idValidationMethod" # S->C; new in v2.2.8
112 IDNUM_PREFIX = "idnum" # C->S, in JSON, v2.3.0
113 IP_USE_INFO = "ip_use_info" # S->C; new in v2.4.0
114 IP_USE_COMMERCIAL = "ip_use_commercial" # S->C; new in v2.4.0
115 IP_USE_CLINICAL = "ip_use_clinical" # S->C; new in v2.4.0
116 IP_USE_EDUCATIONAL = "ip_use_educational" # S->C; new in v2.4.0
117 IP_USE_RESEARCH = "ip_use_research" # S->C; new in v2.4.0
118 MOVE_OFF_TABLET_VALUES = "move_off_tablet_values" # C->S, v2.3.0
119 NFIELDS = "nfields" # B
120 NRECORDS = "nrecords" # B
121 OPERATION = "operation" # C->S
122 OTHER = "other" # C->S, in JSON, v2.3.0
123 PASSWORD = "password" # C->S
124 PATIENT_INFO = "patient_info" # C->S; new in v2.3.0, S->C new in v2.4.0
125 PATIENT_PROQUINT = "patient_proquint" # C->S; new in v2.4.0
126 PKNAME = "pkname" # C->S
127 PKNAMEINFO = "pknameinfo" # C->S, new in v2.3.0
128 PKVALUES = "pkvalues" # C->S
129 RECORD_PREFIX = "record" # B
130 RESULT = "result" # S->C
131 SERVER_CAMCOPS_VERSION = "serverCamcopsVersion" # S->C
132 SESSION_ID = "session_id" # B
133 SESSION_TOKEN = "session_token" # B
134 SETTINGS = "settings" # S->C; new in v2.4.0
135 SEX = "sex" # C->S, in JSON, v2.3.0
136 SUCCESS = "success" # S->C
137 SURNAME = "surname" # C->S, in JSON, v2.3.0
138 TABLE = "table" # C->S
139 TABLES = "tables" # C->S
140 TASK_SCHEDULES = "task_schedules" # S->C; new in v2.4.0
141 TASK_SCHEDULE_ITEMS = "task_schedule_items" # S->C; new in v2.4.0
142 TASK_SCHEDULE_NAME = "task_schedule_name" # S->C; new in v2.4.0
143 USER = "user" # C->S
144 VALUES = "values" # C->S
145 WHEN_COMPLETED = "when_completed" # S->C; new in v2.4.0
147 # Retired (part of defunct mobileweb interface):
148 # WHEREFIELDS = "wherefields"
149 # WHERENOTFIELDS = "wherenotfields"
150 # WHERENOTVALUES = "wherenotvalues"
151 # WHEREVALUES = "wherevalues"
154class ExtraStringFieldNames(object):
155 """
156 To match ``extrastring.cpp`` on the tablet.
157 """
159 TASK = "task"
160 NAME = "name"
161 LANGUAGE = "language"
162 VALUE = "value"
165class AllowedTablesFieldNames(object):
166 """
167 To match ``allowedservertable.cpp`` on the tablet
168 """
170 TABLENAME = "tablename"
171 MIN_CLIENT_VERSION = "min_client_version"
174# =============================================================================
175# Exceptions used by client API
176# =============================================================================
177# Note the following about exception strings:
178#
179# class Blah(Exception):
180# pass
181#
182# x = Blah("hello")
183# str(x) # 'hello'
186class UserErrorException(Exception):
187 """
188 Exception class for when the input from the tablet is dodgy.
189 """
191 pass
194class ServerErrorException(Exception):
195 """
196 Exception class for when something's broken on the server side.
197 """
199 pass
202class IgnoringAntiqueTableException(Exception):
203 """
204 Special exception to return success when we're ignoring an old tablet's
205 request to upload the "storedvars" table.
206 """
208 pass
211# =============================================================================
212# Return message functions
213# =============================================================================
216def exception_description(e: Exception) -> str:
217 """
218 Returns a formatted description of a Python exception.
219 """
220 return f"{type(e).__name__}: {str(e)}"
223# NO LONGER USED:
224# def succeed_generic(operation: str) -> str:
225# """
226# Generic success message to tablet.
227# """
228# return "CamCOPS: {}".format(operation)
231def fail_user_error(msg: str) -> NoReturn:
232 """
233 Function to abort the script when the input is dodgy.
235 Raises :exc:`UserErrorException`.
236 """
237 # While Titanium-Android can extract error messages from e.g.
238 # finish("400 Bad Request: @_"), Titanium-iOS can't, and we need the error
239 # messages. Therefore, we will return an HTTP success code but "Success: 0"
240 # in the reply details.
241 raise UserErrorException(msg)
244def require_keys(dictionary: Dict[Any, Any], keys: List[Any]) -> None:
245 """
246 Ensure that all listed keys are present in the specified dictionary, or
247 raise a :exc:`UserErrorException`.
248 """
249 for k in keys:
250 if k not in dictionary:
251 fail_user_error(f"Field {repr(k)} missing in client input")
254def fail_user_error_from_exception(e: Exception) -> NoReturn:
255 """
256 Raise :exc:`UserErrorException` with a description that comes from
257 the specified exception.
258 """
259 fail_user_error(exception_description(e))
262def fail_server_error(msg: str) -> NoReturn:
263 """
264 Function to abort the script when something's broken server-side.
266 Raises :exc:`ServerErrorException`.
267 """
268 raise ServerErrorException(msg)
271def fail_server_error_from_exception(e: Exception) -> NoReturn:
272 """
273 Raise :exc:`ServerErrorException` with a description that comes from
274 the specified exception.
275 """
276 fail_server_error(exception_description(e))
279def fail_unsupported_operation(operation: str) -> NoReturn:
280 """
281 Abort the script (with a :exc:`UserErrorException`) when the
282 operation is invalid.
283 """
284 fail_user_error(f"operation={operation}: not supported")
287# =============================================================================
288# Information classes used during upload
289# =============================================================================
292class BatchDetails(object):
293 """
294 Represents a current upload batch.
295 """
297 def __init__(
298 self,
299 batchtime: Optional[Pendulum] = None,
300 preserving: bool = False,
301 onestep: bool = False,
302 ) -> None:
303 """
304 Args:
305 batchtime:
306 the batchtime; UTC time this upload batch started; will be
307 applied to all changes
308 preserving:
309 are we preserving (finalizing) the records -- that is, moving
310 them from the current era (``NOW``) to the ``batchtime`` era,
311 so they can be deleted from the tablet without apparent loss on
312 the server?
313 onestep:
314 is this a one-step whole-database upload?
315 """
316 self.batchtime = batchtime
317 self.preserving = preserving
318 self.onestep = onestep
320 def __repr__(self) -> str:
321 return simple_repr(self, ["batchtime", "preserving", "onestep"])
323 @property
324 def new_era(self) -> str:
325 """
326 Returns the string used for the new era for this batch, in case we
327 are preserving records.
328 """
329 return format_datetime(self.batchtime, DateFormat.ERA)
332class WhichKeyToSendInfo(object):
333 """
334 Represents information the client has sent, asking us which records it
335 needs to upload recordwise.
336 """
338 def __init__(
339 self,
340 client_pk: int,
341 client_when: Pendulum,
342 client_move_off_tablet: bool,
343 ) -> None:
344 self.client_pk = client_pk
345 self.client_when = client_when
346 self.client_move_off_tablet = client_move_off_tablet
349class ServerRecord(object):
350 """
351 Class to represent whether a server record exists, and/or the results of
352 retrieving server records.
353 """
355 def __init__(
356 self,
357 client_pk: int = None,
358 exists_on_server: bool = False,
359 server_pk: int = None,
360 server_when: Pendulum = None,
361 move_off_tablet: bool = False,
362 current: bool = False,
363 addition_pending: bool = False,
364 removal_pending: bool = False,
365 predecessor_pk: int = None,
366 successor_pk: int = None,
367 ) -> None:
368 """
369 Args:
370 client_pk: client's PK
371 exists_on_server: does the record exist on the server?
372 server_pk: if it exists, what's the server PK?
373 server_when: if it exists, what's the server's "when"
374 (``when_last_modified``) field?
375 move_off_tablet: is the ``__move_off_tablet`` flag set?
376 current: is the record current (``_current`` flag set)?
377 addition_pending: is the ``_addition_pending`` flag set?
378 removal_pending: is the ``_removal_pending`` flag set?
379 predecessor_pk: predecessor server PK, or ``None``
380 successor_pk: successor server PK, or ``None``
381 """
382 self.client_pk = client_pk
383 self.exists = exists_on_server
384 self.server_pk = server_pk
385 self.server_when = server_when
386 self.move_off_tablet = move_off_tablet
387 self.current = current
388 self.addition_pending = addition_pending
389 self.removal_pending = removal_pending
390 self.predecessor_pk = predecessor_pk
391 self.successor_pk = successor_pk
393 def __repr__(self) -> str:
394 return simple_repr(
395 self,
396 [
397 "client_pk",
398 "exists",
399 "server_pk",
400 "server_when",
401 "move_off_tablet",
402 "current",
403 "addition_pending",
404 "removal_pending",
405 "predecessor_pk",
406 "successor_pk",
407 ],
408 )
411class UploadRecordResult(object):
412 """
413 Represents the result of uploading a record.
414 """
416 def __init__(
417 self,
418 oldserverpk: Optional[int] = None,
419 newserverpk: Optional[int] = None,
420 dirty: bool = False,
421 specifically_marked_for_preservation: bool = False,
422 ):
423 """
424 Args:
425 oldserverpk:
426 the server's PK of the old version of the record; ``None`` if
427 the record is new
428 newserverpk:
429 the server's PK of the new version of the record; ``None`` if
430 the record was unmodified
431 dirty:
432 was the database table modified? (May be true even if
433 ``newserverpk`` is ``None``, if ``_move_off_tablet`` was set.
434 specifically_marked_for_preservation:
435 should the record(s) be preserved?
436 """
437 self.oldserverpk = oldserverpk
438 self.newserverpk = newserverpk
439 self.dirty = dirty
440 self.specifically_marked_for_preservation = (
441 specifically_marked_for_preservation
442 )
443 self._specifically_marked_preservation_pks = [] # type: List[int]
445 def __repr__(self) -> str:
446 return simple_repr(
447 self,
448 [
449 "oldserverpk",
450 "newserverpk",
451 "dirty",
452 "to_be_preserved",
453 "specifically_marked_preservation_pks",
454 ],
455 )
457 def note_specifically_marked_preservation_pks(
458 self, pks: List[int]
459 ) -> None:
460 """
461 Notes that some PKs are marked specifically for preservation.
462 """
463 self._specifically_marked_preservation_pks.extend(pks)
465 @property
466 def latest_pk(self) -> Optional[int]:
467 """
468 Returns the latest of the two PKs.
469 """
470 if self.newserverpk is not None:
471 return self.newserverpk
472 return self.oldserverpk
474 @property
475 def specifically_marked_preservation_pks(self) -> List[int]:
476 """
477 Returns a list of server PKs of records specifically marked to be
478 preserved. This may include older versions (the predecessor chain) of
479 records being uploaded.
480 """
481 return self._specifically_marked_preservation_pks
483 @property
484 def addition_pks(self) -> List[int]:
485 """
486 Returns a list of PKs representing new records being added.
487 """
488 return [self.newserverpk] if self.newserverpk is not None else []
490 @property
491 def removal_modified_pks(self) -> List[int]:
492 """
493 Returns a list of PKs representing records removed because they have
494 been "modified out".
495 """
496 if self.oldserverpk is not None and self.newserverpk is not None:
497 return [self.oldserverpk]
498 return []
500 @property
501 def all_pks(self) -> List[int]:
502 """
503 Returns all PKs (old, new, or both).
504 """
505 return list(
506 x for x in (self.oldserverpk, self.newserverpk) if x is not None
507 )
509 @property
510 def current_pks(self) -> List[int]:
511 """
512 Returns PKs that represent current records on the server.
513 """
514 if self.newserverpk is not None:
515 return [self.newserverpk] # record was replaced; new one's current
516 if self.oldserverpk is not None:
517 return [self.oldserverpk] # not replaced; old one's current
518 return []
521class UploadTableChanges(object):
522 """
523 Represents information to process and audit an upload to a table.
524 """
526 def __init__(self, table: Table) -> None:
527 self.table = table
528 self._addition_pks = set() # type: Set[int]
529 self._removal_modified_pks = set() # type: Set[int]
530 self._removal_deleted_pks = set() # type: Set[int]
531 self._preservation_pks = set() # type: Set[int]
532 self._current_pks = set() # type: Set[int]
534 # -------------------------------------------------------------------------
535 # Basic info
536 # -------------------------------------------------------------------------
538 @property
539 def tablename(self) -> str:
540 """
541 The table's name.
542 """
543 return self.table.name
545 # -------------------------------------------------------------------------
546 # Tell us about PKs
547 # -------------------------------------------------------------------------
549 def note_addition_pk(self, pk: int) -> None:
550 """
551 Records an "addition" PK.
552 """
553 self._addition_pks.add(pk)
555 def note_addition_pks(self, pks: Iterable[int]) -> None:
556 """
557 Records multiple "addition" PKs.
558 """
559 self._addition_pks.update(pks)
561 def note_removal_modified_pk(self, pk: int) -> None:
562 """
563 Records a "removal because modified" PK (replaced by successor).
564 """
565 self._removal_modified_pks.add(pk)
567 def note_removal_modified_pks(self, pks: Iterable[int]) -> None:
568 """
569 Records multiple "removal because modified" PKs.
570 """
571 self._removal_modified_pks.update(pks)
573 def note_removal_deleted_pk(self, pk: int) -> None:
574 """
575 Records a "deleted" PK (removed with no successor).
576 """
577 self._removal_deleted_pks.add(pk)
579 def note_removal_deleted_pks(self, pks: Iterable[int]) -> None:
580 """
581 Records multiple "deleted" PKs (see :func:`note_removal_deleted_pk`).
582 """
583 self._removal_deleted_pks.update(pks)
585 def note_preservation_pk(self, pk: int) -> None:
586 """
587 Records a "preservation" PK (a record that's being finalized).
588 """
589 self._preservation_pks.add(pk)
591 def note_preservation_pks(self, pks: Iterable[int]) -> None:
592 """
593 Records multiple "preservation" PKs (records that are being finalized).
594 """
595 self._preservation_pks.update(pks)
597 def note_current_pk(self, pk: int) -> None:
598 """
599 Records that a record is current on the server. For indexing.
600 """
601 self._current_pks.add(pk)
603 def note_current_pks(self, pks: Iterable[int]) -> None:
604 """
605 Records multiple "current" PKs.
606 """
607 self._current_pks.update(pks)
609 def note_urr(
610 self, urr: UploadRecordResult, preserving_new_records: bool
611 ) -> None:
612 """
613 Records information from a :class:`UploadRecordResult`, which is itself
614 the result of calling
615 :func:`camcops_server.cc_modules.client_api.upload_record_core`.
617 Called by
618 :func:`camcops_server.cc_modules.client_api.process_table_for_onestep_upload`.
620 Args:
621 urr: a :class:`UploadRecordResult`
622 preserving_new_records: are new records being preserved?
623 """ # noqa
624 self.note_addition_pks(urr.addition_pks)
625 self.note_removal_modified_pks(urr.removal_modified_pks)
626 if preserving_new_records:
627 self.note_preservation_pks(urr.addition_pks)
628 self.note_preservation_pks(urr.specifically_marked_preservation_pks)
629 self.note_current_pks(urr.current_pks)
631 def note_serverrec(self, sr: ServerRecord, preserving: bool) -> None:
632 """
633 Records information from a :class:`ServerRecord`. Called by
634 :func:`camcops_server.cc_modules.client_api.commit_table`.
636 Args:
637 sr: a :class:`ServerRecord`
638 preserving: are we preserving uploaded records?
639 """
640 pk = sr.server_pk
641 if sr.addition_pending:
642 self.note_addition_pk(pk)
643 self.note_current_pk(pk)
644 elif sr.removal_pending:
645 if sr.successor_pk is None:
646 self.note_removal_deleted_pk(pk)
647 else:
648 self.note_removal_modified_pk(pk)
649 elif sr.current:
650 self.note_current_pk(pk)
651 if preserving or sr.move_off_tablet:
652 self.note_preservation_pk(pk)
654 # -------------------------------------------------------------------------
655 # Counts
656 # -------------------------------------------------------------------------
658 @property
659 def n_added(self) -> int:
660 """
661 Number of server records added.
662 """
663 return len(self._addition_pks)
665 @property
666 def n_removed_modified(self) -> int:
667 """
668 Number of server records "modified out" -- replaced by a modified
669 version and marked as removed.
670 """
671 return len(self._removal_modified_pks)
673 @property
674 def n_removed_deleted(self) -> int:
675 """
676 Number of server records "deleted" -- marked as removed with no
677 successor.
678 """
679 return len(self._removal_deleted_pks)
681 @property
682 def n_removed(self) -> int:
683 """
684 Number of server records "removed" -- marked as removed (either with or
685 without a successor).
686 """
687 return self.n_removed_modified + self.n_removed_deleted
689 @property
690 def n_preserved(self) -> int:
691 """
692 Number of server records "preserved" (finalized) -- moved from the
693 ``NOW`` era to the batch era (and no longer modifiable by the client
694 device).
695 """
696 return len(self._preservation_pks)
698 # -------------------------------------------------------------------------
699 # PKs for various purposes
700 # -------------------------------------------------------------------------
702 @property
703 def addition_pks(self) -> List[int]:
704 """
705 Server PKs of records being added.
706 """
707 return sorted(self._addition_pks)
709 @property
710 def removal_modified_pks(self) -> List[int]:
711 """
712 Server PKs of records being modified out.
713 """
714 return sorted(self._removal_modified_pks)
716 @property
717 def removal_deleted_pks(self) -> List[int]:
718 """
719 Server PKs of records being deleted.
720 """
721 return sorted(self._removal_deleted_pks)
723 @property
724 def removal_pks(self) -> List[int]:
725 """
726 Server PKs of records being removed (modified out, or deleted).
727 """
728 return sorted(self._removal_modified_pks | self._removal_deleted_pks)
730 @property
731 def preservation_pks(self) -> List[int]:
732 """
733 Server PKs of records being preserved.
734 """
735 return sorted(self._preservation_pks)
737 @property
738 def current_pks(self) -> List[int]:
739 return sorted(self._current_pks)
741 @property
742 def idnum_delete_index_pks(self) -> List[int]:
743 """
744 Server PKs of records to delete old index entries for, if this is the
745 ID number table. (Includes records that need re-indexing.)
747 We don't care about preservation PKs here, as the ID number index
748 doesn't incorporate that.
749 """
750 return sorted(self._removal_modified_pks | self._removal_deleted_pks)
752 @property
753 def idnum_add_index_pks(self) -> List[int]:
754 """
755 Server PKs of records to add index entries for, if this is the ID
756 number table.
757 """
758 return sorted(self._addition_pks)
760 @property
761 def task_delete_index_pks(self) -> List[int]:
762 """
763 Server PKs of records to delete old index entries for, if this is a
764 task table. (Includes records that need re-indexing.)
765 """
766 return sorted(
767 (
768 self._removal_modified_pks
769 | self._removal_deleted_pks # needs reindexing
770 | self._preservation_pks # gone
771 ) # ... these need reindexing
772 - self._addition_pks
773 # ... _addition_pks won't be indexed, so no need to delete index
774 )
776 @property
777 def task_reindex_pks(self) -> List[int]:
778 """
779 Server PKs of records to rebuild index entries for, if this is a task
780 table. (Includes records that need re-indexing.)
782 We include records being preserved, because their era has changed,
783 and the index includes era. Unless they are being removed!
784 """
785 return sorted(
786 (
787 (self._addition_pks | self._preservation_pks) # new; index
788 - ( # reindex (but only if current)
789 self._removal_modified_pks
790 | self._removal_deleted_pks # modified out; don't index
791 ) # deleted; don't index
792 )
793 & self._current_pks # only reindex current PKs
794 )
795 # A quick reminder, since I got this wrong:
796 # | union (A or B)
797 # & intersection (A and B)
798 # ^ xor (A or B but not both)
799 # - difference (A - B)
801 def get_task_push_export_pks(
802 self, recipient: "ExportRecipient", uploading_group_id: int
803 ) -> List[int]:
804 """
805 Returns PKs for tasks matching the requirements of a particular
806 export recipient.
808 (In practice, only "push" recipients will come our way, so we can
809 ignore this.)
810 """
811 if not recipient.is_upload_suitable_for_push(
812 tablename=self.tablename, uploading_group_id=uploading_group_id
813 ):
814 # Not suitable
815 return []
817 if recipient.finalized_only:
818 return sorted(
819 self._preservation_pks # finalized
820 & self._current_pks # only send current tasks
821 )
822 else:
823 return sorted(
824 (
825 self._addition_pks # new (may be unfinalized)
826 | self._preservation_pks # finalized
827 )
828 & self._current_pks # only send current tasks
829 )
831 # -------------------------------------------------------------------------
832 # Audit info
833 # -------------------------------------------------------------------------
835 @property
836 def any_changes(self) -> bool:
837 """
838 Has anything changed that we're aware of?
839 """
840 return (
841 self.n_added > 0
842 or self.n_removed_modified > 0
843 or self.n_removed_deleted > 0
844 or self.n_preserved > 0
845 )
847 def __str__(self) -> str:
848 return (
849 f"{self.tablename}: "
850 f"({self.n_added} added, "
851 f"PKs {self.addition_pks}; "
852 f"{self.n_removed_modified} modified out, "
853 f"PKs {self.removal_modified_pks}; "
854 f"{self.n_removed_deleted} deleted, "
855 f"PKs {self.removal_deleted_pks}; "
856 f"{self.n_preserved} preserved, "
857 f"PKs {self.preservation_pks}; "
858 f"current PKs {self.current_pks})"
859 )
861 def description(self, always_show_current_pks: bool = True) -> str:
862 """
863 Short description, only including bits that have changed.
864 """
865 parts = [] # type: List[str]
866 if self._addition_pks:
867 parts.append(f"{self.n_added} added, PKs {self.addition_pks}")
868 if self._removal_modified_pks:
869 parts.append(
870 f"{self.n_removed_modified} modified out, "
871 f"PKs {self.removal_modified_pks}"
872 )
873 if self._removal_deleted_pks:
874 parts.append(
875 f"{self.n_removed_deleted} deleted, "
876 f"PKs {self.removal_deleted_pks}"
877 )
878 if self._preservation_pks:
879 parts.append(
880 f"{self.n_preserved} preserved, "
881 f"PKs {self.preservation_pks}"
882 )
883 if not parts:
884 parts.append("no changes")
885 if always_show_current_pks or self.any_changes:
886 parts.append(f"current PKs {self.current_pks}")
887 return f"{self.tablename} ({'; '.join(parts)})"
890# =============================================================================
891# Value dictionaries for updating records, to reduce repetition
892# =============================================================================
895def values_delete_later() -> Dict[str, Any]:
896 """
897 Field/value pairs to mark a record as "to be deleted later".
898 """
899 return {FN_REMOVAL_PENDING: 1, FN_SUCCESSOR_PK: None}
902def values_delete_now(
903 req: "CamcopsRequest", batchdetails: BatchDetails
904) -> Dict[str, Any]:
905 """
906 Field/value pairs to mark a record as deleted now.
907 """
908 return {
909 FN_CURRENT: 0,
910 FN_REMOVAL_PENDING: 0,
911 FN_REMOVING_USER_ID: req.user_id,
912 FN_WHEN_REMOVED_EXACT: req.now,
913 FN_WHEN_REMOVED_BATCH_UTC: batchdetails.batchtime,
914 }
917def values_preserve_now(
918 req: "CamcopsRequest",
919 batchdetails: BatchDetails,
920 forcibly_preserved: bool = False,
921) -> Dict[str, Any]:
922 """
923 Field/value pairs to mark a record as preserved now.
924 """
925 return {
926 FN_ERA: batchdetails.new_era,
927 FN_PRESERVING_USER_ID: req.user_id,
928 MOVE_OFF_TABLET_FIELD: 0,
929 FN_FORCIBLY_PRESERVED: forcibly_preserved,
930 }
933# =============================================================================
934# CamCOPS table reading functions
935# =============================================================================
938def get_server_live_records(
939 req: "CamcopsRequest",
940 device_id: int,
941 table: Table,
942 clientpk_name: str = None,
943 current_only: bool = True,
944) -> List[ServerRecord]:
945 """
946 Gets details of all records on the server, for the specified table,
947 that are live on this client device.
949 Args:
950 req: the :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
951 device_id: ID of the
952 :class:`camcops_server.cc_modules.cc_device.Device`
953 table: an SQLAlchemy :class:`Table`
954 clientpk_name: the column name of the client's PK; if none is supplied,
955 the client_pk fields of the results will be ``None``
956 current_only: restrict to "current" (``_current``) records only?
958 Returns:
959 :class:`ServerRecord` objects for active records (``_current`` and in
960 the 'NOW' era) for the specified device/table.
961 """
962 recs = [] # type: List[ServerRecord]
963 client_pk_clause = (
964 table.c[clientpk_name] if clientpk_name else literal(None)
965 )
966 query = (
967 select(
968 [
969 client_pk_clause, # 0: client PK (or None)
970 table.c[FN_PK], # 1: server PK
971 table.c[
972 CLIENT_DATE_FIELD
973 ], # 2: when last modified (on the server)
974 table.c[MOVE_OFF_TABLET_FIELD], # 3: move_off_tablet
975 table.c[FN_CURRENT], # 4: current
976 table.c[FN_ADDITION_PENDING], # 5
977 table.c[FN_REMOVAL_PENDING], # 6
978 table.c[FN_PREDECESSOR_PK], # 7
979 table.c[FN_SUCCESSOR_PK], # 8
980 ]
981 )
982 .where(table.c[FN_DEVICE_ID] == device_id)
983 .where(table.c[FN_ERA] == ERA_NOW)
984 )
985 if current_only:
986 query = query.where(table.c[FN_CURRENT])
987 rows = req.dbsession.execute(query)
988 for row in rows:
989 recs.append(
990 ServerRecord(
991 client_pk=row[0],
992 exists_on_server=True,
993 server_pk=row[1],
994 server_when=row[2],
995 move_off_tablet=row[3],
996 current=row[4],
997 addition_pending=row[5],
998 removal_pending=row[6],
999 predecessor_pk=row[7],
1000 successor_pk=row[8],
1001 )
1002 )
1003 return recs