Coverage for cc_modules/cc_client_api_core.py: 91%

1"""

2 camcops_server/cc_modules/cc_client_api_core.py

3

4===============================================================================

5

6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.

7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).

8

9 This file is part of CamCOPS.

10

11 CamCOPS is free software: you can redistribute it and/or modify

12 it under the terms of the GNU General Public License as published by

13 the Free Software Foundation, either version 3 of the License, or

14 (at your option) any later version.

15

16 CamCOPS is distributed in the hope that it will be useful,

17 but WITHOUT ANY WARRANTY; without even the implied warranty of

18 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the

19 GNU General Public License for more details.

20

21 You should have received a copy of the GNU General Public License

22 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.

23

24===============================================================================

25

26**Core constants and functions used by the client (tablet device) API.**

27

28"""

29

30 from typing import (

31 Any,

32 Dict,

33 Iterable,

34 List,

35 NoReturn,

36 Optional,

37 Set,

38 TYPE_CHECKING,

39)

40

41 from cardinal_pythonlib.datetimefunc import format_datetime

42 from cardinal_pythonlib.reprfunc import simple_repr

43 from pendulum import DateTime as Pendulum

44 from sqlalchemy.sql.expression import literal, select

45 from sqlalchemy.sql.schema import Table

46

47 from camcops_server.cc_modules.cc_constants import (

48 CLIENT_DATE_FIELD,

49 DateFormat,

50 ERA_NOW,

51 MOVE_OFF_TABLET_FIELD,

52)

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

68

69 if TYPE_CHECKING:

70 from camcops_server.cc_modules.cc_exportrecipient import ExportRecipient

71 from camcops_server.cc_modules.cc_request import CamcopsRequest

72

73

74# =============================================================================

75# Constants

76# =============================================================================

77

78

79 class TabletParam(object):

80 """

81 Keys used by server or client (in the comments: S server, C client, B

82 bidirectional).

83 """

84

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

144

145 # Retired (part of defunct mobileweb interface):

146 # WHEREFIELDS = "wherefields"

147 # WHERENOTFIELDS = "wherenotfields"

148 # WHERENOTVALUES = "wherenotvalues"

149 # WHEREVALUES = "wherevalues"

150

151

152 class ExtraStringFieldNames(object):

153 """

154 To match ``extrastring.cpp`` on the tablet.

155 """

156

157 TASK = "task"

158 NAME = "name"

159 LANGUAGE = "language"

160 VALUE = "value"

161

162

163 class AllowedTablesFieldNames(object):

164 """

165 To match ``allowedservertable.cpp`` on the tablet

166 """

167

168 TABLENAME = "tablename"

169 MIN_CLIENT_VERSION = "min_client_version"

170

171

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'

182

183

184 class UserErrorException(Exception):

185 """

186 Exception class for when the input from the tablet is dodgy.

187 """

188

189 pass

190

191

192 class ServerErrorException(Exception):

193 """

194 Exception class for when something's broken on the server side.

195 """

196

197 pass

198

199

200# =============================================================================

201# Return message functions

202# =============================================================================

203

204

205 def exception_description(e: Exception) -> str:

206 """

207 Returns a formatted description of a Python exception.

208 """

209 return f"{type(e).__name__}: {str(e)}"

210

211

212 def fail_user_error(msg: str) -> NoReturn:

213 """

214 Function to abort the script when the input is dodgy.

215

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)

223

224

225 def 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")

233

234

235 def 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))

241

242

243 def fail_server_error(msg: str) -> NoReturn:

244 """

245 Function to abort the script when something's broken server-side.

246

247 Raises :exc:`ServerErrorException`.

248 """

249 raise ServerErrorException(msg)

250

251

252 def 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))

258

259

260 def 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")

266

267

268# =============================================================================

269# Information classes used during upload

270# =============================================================================

271

272

273 class BatchDetails(object):

274 """

275 Represents a current upload batch.

276 """

277

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

300

301 def __repr__(self) -> str:

302 return simple_repr(self, ["batchtime", "preserving", "onestep"])

303

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)

311

312

313 class WhichKeyToSendInfo(object):

314 """

315 Represents information the client has sent, asking us which records it

316 needs to upload recordwise.

317 """

318

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

328

329

330 class ServerRecord(object):

331 """

332 Class to represent whether a server record exists, and/or the results of

333 retrieving server records.

334 """

335

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

373

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 )

390

391

392 class UploadRecordResult(object):

393 """

394 Represents the result of uploading a record.

395 """

396

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]

425

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 )

437

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)

445

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

454

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

463

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 []

470

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 []

480

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 )

489

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 []

500

501

502 class UploadTableChanges(object):

503 """

504 Represents information to process and audit an upload to a table.

505 """

506

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]

514

515 # -------------------------------------------------------------------------

516 # Basic info

517 # -------------------------------------------------------------------------

518

519 @property

520 def tablename(self) -> str:

521 """

522 The table's name.

523 """

524 return self.table.name

525

526 # -------------------------------------------------------------------------

527 # Tell us about PKs

528 # -------------------------------------------------------------------------

529

530 def note_addition_pk(self, pk: int) -> None:

531 """

532 Records an "addition" PK.

533 """

534 self._addition_pks.add(pk)

535

536 def note_addition_pks(self, pks: Iterable[int]) -> None:

537 """

538 Records multiple "addition" PKs.

539 """

540 self._addition_pks.update(pks)

541

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)

547

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)

553

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)

559

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)

565

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)

571

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)

577

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)

583

584 def note_current_pks(self, pks: Iterable[int]) -> None:

585 """

586 Records multiple "current" PKs.

587 """

588 self._current_pks.update(pks)

589

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`.

597

598 Called by

599 :func:`camcops_server.cc_modules.client_api.process_table_for_onestep_upload`.

600

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)

611

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`.

616

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)

634

635 # -------------------------------------------------------------------------

636 # Counts

637 # -------------------------------------------------------------------------

638

639 @property

640 def n_added(self) -> int:

641 """

642 Number of server records added.

643 """

644 return len(self._addition_pks)

645

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)

653

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)

661

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

669

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)

678

679 # -------------------------------------------------------------------------

680 # PKs for various purposes

681 # -------------------------------------------------------------------------

682

683 @property

684 def addition_pks(self) -> List[int]:

685 """

686 Server PKs of records being added.

687 """

688 return sorted(self._addition_pks)

689

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)

696

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)

703

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)

710

711 @property

712 def preservation_pks(self) -> List[int]:

713 """

714 Server PKs of records being preserved.

715 """

716 return sorted(self._preservation_pks)

717

718 @property

719 def current_pks(self) -> List[int]:

720 return sorted(self._current_pks)

721

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.)

727

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)

732

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)

740

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 )

756

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.)

762

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)

781

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.

788

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 []

797

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 )

811

812 # -------------------------------------------------------------------------

813 # Audit info

814 # -------------------------------------------------------------------------

815

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 )

827

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 )

841

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)})"

869

870

871# =============================================================================

872# Value dictionaries for updating records, to reduce repetition

873# =============================================================================

874

875

876 def 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}

881

882

883 def 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 }

896

897

898 def 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 }

912

913

914# =============================================================================

915# CamCOPS table reading functions

916# =============================================================================

917

918

919 def 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.

929

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?

938

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