Coverage for cc_modules/cc_client_api_core.py: 91%

302 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-15 15:51 +0100

1""" 

2camcops_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 

30from typing import ( 

31 Any, 

32 Dict, 

33 Iterable, 

34 List, 

35 NoReturn, 

36 Optional, 

37 Set, 

38 TYPE_CHECKING, 

39) 

40 

41from cardinal_pythonlib.datetimefunc import format_datetime 

42from cardinal_pythonlib.reprfunc import simple_repr 

43from pendulum import DateTime as Pendulum 

44from sqlalchemy.sql.expression import literal, select 

45from sqlalchemy.sql.schema import Table 

46 

47from camcops_server.cc_modules.cc_constants import ( 

48 CLIENT_DATE_FIELD, 

49 DateFormat, 

50 ERA_NOW, 

51 MOVE_OFF_TABLET_FIELD, 

52) 

53from camcops_server.cc_modules.cc_db import ( 

54 FN_ADDITION_PENDING, 

55 FN_CURRENT, 

56 FN_DEVICE_ID, 

57 FN_ERA, 

58 FN_FORCIBLY_PRESERVED, 

59 FN_PK, 

60 FN_PREDECESSOR_PK, 

61 FN_PRESERVING_USER_ID, 

62 FN_REMOVAL_PENDING, 

63 FN_REMOVING_USER_ID, 

64 FN_SUCCESSOR_PK, 

65 FN_WHEN_REMOVED_BATCH_UTC, 

66 FN_WHEN_REMOVED_EXACT, 

67) 

68 

69if 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 

79class 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 

152class 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 

163class 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 

184class UserErrorException(Exception): 

185 """ 

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

187 """ 

188 

189 pass 

190 

191 

192class 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 

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

206 """ 

207 Returns a formatted description of a Python exception. 

208 """ 

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

210 

211 

212def 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 

225def require_keys(dictionary: Dict[Any, Any], keys: List[Any]) -> None: 

226 """ 

227 Ensure that all listed keys are present in the specified dictionary, or 

228 raise a :exc:`UserErrorException`. 

229 """ 

230 for k in keys: 

231 if k not in dictionary: 

232 fail_user_error(f"Field {repr(k)} missing in client input") 

233 

234 

235def fail_user_error_from_exception(e: Exception) -> NoReturn: 

236 """ 

237 Raise :exc:`UserErrorException` with a description that comes from 

238 the specified exception. 

239 """ 

240 fail_user_error(exception_description(e)) 

241 

242 

243def 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 

252def fail_server_error_from_exception(e: Exception) -> NoReturn: 

253 """ 

254 Raise :exc:`ServerErrorException` with a description that comes from 

255 the specified exception. 

256 """ 

257 fail_server_error(exception_description(e)) 

258 

259 

260def fail_unsupported_operation(operation: str) -> NoReturn: 

261 """ 

262 Abort the script (with a :exc:`UserErrorException`) when the 

263 operation is invalid. 

264 """ 

265 fail_user_error(f"operation={operation}: not supported") 

266 

267 

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

269# Information classes used during upload 

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

271 

272 

273class 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 

313class 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 

330class 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 

392class 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 

502class 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 

876def values_delete_later() -> Dict[str, Any]: 

877 """ 

878 Field/value pairs to mark a record as "to be deleted later". 

879 """ 

880 return {FN_REMOVAL_PENDING: 1, FN_SUCCESSOR_PK: None} 

881 

882 

883def values_delete_now( 

884 req: "CamcopsRequest", batchdetails: BatchDetails 

885) -> Dict[str, Any]: 

886 """ 

887 Field/value pairs to mark a record as deleted now. 

888 """ 

889 return { 

890 FN_CURRENT: 0, 

891 FN_REMOVAL_PENDING: 0, 

892 FN_REMOVING_USER_ID: req.user_id, 

893 FN_WHEN_REMOVED_EXACT: req.now, 

894 FN_WHEN_REMOVED_BATCH_UTC: batchdetails.batchtime, 

895 } 

896 

897 

898def values_preserve_now( 

899 req: "CamcopsRequest", 

900 batchdetails: BatchDetails, 

901 forcibly_preserved: bool = False, 

902) -> Dict[str, Any]: 

903 """ 

904 Field/value pairs to mark a record as preserved now. 

905 """ 

906 return { 

907 FN_ERA: batchdetails.new_era, 

908 FN_PRESERVING_USER_ID: req.user_id, 

909 MOVE_OFF_TABLET_FIELD: 0, 

910 FN_FORCIBLY_PRESERVED: forcibly_preserved, 

911 } 

912 

913 

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

915# CamCOPS table reading functions 

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

917 

918 

919def get_server_live_records( 

920 req: "CamcopsRequest", 

921 device_id: int, 

922 table: Table, 

923 clientpk_name: str = None, 

924 current_only: bool = True, 

925) -> List[ServerRecord]: 

926 """ 

927 Gets details of all records on the server, for the specified table, 

928 that are live on this client device. 

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