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

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/cc_modules/cc_client_api_core.py 

5 

6=============================================================================== 

7 

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

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

10 

11 This file is part of CamCOPS. 

12 

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. 

17 

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. 

22 

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

25 

26=============================================================================== 

27 

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

29 

30""" 

31 

32from typing import ( 

33 Any, 

34 Dict, 

35 Iterable, 

36 List, 

37 NoReturn, 

38 Optional, 

39 Set, 

40 TYPE_CHECKING, 

41) 

42 

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 

48 

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) 

70 

71if TYPE_CHECKING: 

72 from camcops_server.cc_modules.cc_exportrecipient import ExportRecipient 

73 from camcops_server.cc_modules.cc_request import CamcopsRequest 

74 

75 

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

77# Constants 

78# ============================================================================= 

79 

80 

81class TabletParam(object): 

82 """ 

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

84 bidirectional). 

85 """ 

86 

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 

146 

147 # Retired (part of defunct mobileweb interface): 

148 # WHEREFIELDS = "wherefields" 

149 # WHERENOTFIELDS = "wherenotfields" 

150 # WHERENOTVALUES = "wherenotvalues" 

151 # WHEREVALUES = "wherevalues" 

152 

153 

154class ExtraStringFieldNames(object): 

155 """ 

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

157 """ 

158 

159 TASK = "task" 

160 NAME = "name" 

161 LANGUAGE = "language" 

162 VALUE = "value" 

163 

164 

165class AllowedTablesFieldNames(object): 

166 """ 

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

168 """ 

169 

170 TABLENAME = "tablename" 

171 MIN_CLIENT_VERSION = "min_client_version" 

172 

173 

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' 

184 

185 

186class UserErrorException(Exception): 

187 """ 

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

189 """ 

190 

191 pass 

192 

193 

194class ServerErrorException(Exception): 

195 """ 

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

197 """ 

198 

199 pass 

200 

201 

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

207 

208 pass 

209 

210 

211# ============================================================================= 

212# Return message functions 

213# ============================================================================= 

214 

215 

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

221 

222 

223# NO LONGER USED: 

224# def succeed_generic(operation: str) -> str: 

225# """ 

226# Generic success message to tablet. 

227# """ 

228# return "CamCOPS: {}".format(operation) 

229 

230 

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

232 """ 

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

234 

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) 

242 

243 

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

252 

253 

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

260 

261 

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

263 """ 

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

265 

266 Raises :exc:`ServerErrorException`. 

267 """ 

268 raise ServerErrorException(msg) 

269 

270 

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

277 

278 

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

285 

286 

287# ============================================================================= 

288# Information classes used during upload 

289# ============================================================================= 

290 

291 

292class BatchDetails(object): 

293 """ 

294 Represents a current upload batch. 

295 """ 

296 

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 

319 

320 def __repr__(self) -> str: 

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

322 

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) 

330 

331 

332class WhichKeyToSendInfo(object): 

333 """ 

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

335 needs to upload recordwise. 

336 """ 

337 

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 

347 

348 

349class ServerRecord(object): 

350 """ 

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

352 retrieving server records. 

353 """ 

354 

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 

392 

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 ) 

409 

410 

411class UploadRecordResult(object): 

412 """ 

413 Represents the result of uploading a record. 

414 """ 

415 

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] 

444 

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 ) 

456 

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) 

464 

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 

473 

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 

482 

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

489 

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

499 

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 ) 

508 

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

519 

520 

521class UploadTableChanges(object): 

522 """ 

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

524 """ 

525 

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] 

533 

534 # ------------------------------------------------------------------------- 

535 # Basic info 

536 # ------------------------------------------------------------------------- 

537 

538 @property 

539 def tablename(self) -> str: 

540 """ 

541 The table's name. 

542 """ 

543 return self.table.name 

544 

545 # ------------------------------------------------------------------------- 

546 # Tell us about PKs 

547 # ------------------------------------------------------------------------- 

548 

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

550 """ 

551 Records an "addition" PK. 

552 """ 

553 self._addition_pks.add(pk) 

554 

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

556 """ 

557 Records multiple "addition" PKs. 

558 """ 

559 self._addition_pks.update(pks) 

560 

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) 

566 

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) 

572 

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) 

578 

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) 

584 

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) 

590 

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) 

596 

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) 

602 

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

604 """ 

605 Records multiple "current" PKs. 

606 """ 

607 self._current_pks.update(pks) 

608 

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

616 

617 Called by 

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

619 

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) 

630 

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

635 

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) 

653 

654 # ------------------------------------------------------------------------- 

655 # Counts 

656 # ------------------------------------------------------------------------- 

657 

658 @property 

659 def n_added(self) -> int: 

660 """ 

661 Number of server records added. 

662 """ 

663 return len(self._addition_pks) 

664 

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) 

672 

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) 

680 

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 

688 

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) 

697 

698 # ------------------------------------------------------------------------- 

699 # PKs for various purposes 

700 # ------------------------------------------------------------------------- 

701 

702 @property 

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

704 """ 

705 Server PKs of records being added. 

706 """ 

707 return sorted(self._addition_pks) 

708 

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) 

715 

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) 

722 

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) 

729 

730 @property 

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

732 """ 

733 Server PKs of records being preserved. 

734 """ 

735 return sorted(self._preservation_pks) 

736 

737 @property 

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

739 return sorted(self._current_pks) 

740 

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

746 

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) 

751 

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) 

759 

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 ) 

775 

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

781 

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) 

800 

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. 

807 

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

816 

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 ) 

830 

831 # ------------------------------------------------------------------------- 

832 # Audit info 

833 # ------------------------------------------------------------------------- 

834 

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 ) 

846 

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 ) 

860 

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

888 

889 

890# ============================================================================= 

891# Value dictionaries for updating records, to reduce repetition 

892# ============================================================================= 

893 

894 

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} 

900 

901 

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 } 

915 

916 

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 } 

931 

932 

933# ============================================================================= 

934# CamCOPS table reading functions 

935# ============================================================================= 

936 

937 

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. 

948 

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? 

957 

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