Coverage for cc_modules/cc_policy.py: 28%

448 statements  

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

1""" 

2camcops_server/cc_modules/cc_policy.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**Represents ID number policies.** 

27 

28Note that the upload script should NOT attempt to verify patients against the 

29ID policy, not least because tablets are allowed to upload task data (in a 

30separate transaction) before uploading patients; referential integrity would be 

31very hard to police. So the tablet software deals with ID compliance. (Also, 

32the superuser can change the server's ID policy retrospectively!) 

33 

34Both the client and the server do policy tokenizing and can check patient info 

35against policies. The server has additional code to answer questions like "is 

36this policy valid?" (in general and in the context of the server's 

37configuration). 

38 

39""" 

40 

41import io 

42import logging 

43import tokenize 

44from typing import Any, Callable, Dict, List, Optional, Tuple 

45 

46from cardinal_pythonlib.dicts import reversedict 

47from cardinal_pythonlib.logs import BraceStyleAdapter 

48from cardinal_pythonlib.reprfunc import auto_repr 

49 

50from camcops_server.cc_modules.cc_simpleobjects import BarePatientInfo 

51 

52log = BraceStyleAdapter(logging.getLogger(__name__)) 

53 

54 

55# ============================================================================= 

56# Tokens 

57# ============================================================================= 

58 

59TOKEN_TYPE = int 

60TOKENIZED_POLICY_TYPE = List[TOKEN_TYPE] 

61 

62# http://stackoverflow.com/questions/36932 

63BAD_TOKEN = 0 

64TK_LPAREN = -1 

65TK_RPAREN = -2 

66TK_AND = -3 

67TK_OR = -4 

68TK_NOT = -5 

69TK_ANY_IDNUM = -6 

70TK_OTHER_IDNUM = -7 

71TK_FORENAME = -8 

72TK_SURNAME = -9 

73TK_SEX = -10 

74TK_DOB = -11 

75TK_ADDRESS = -12 

76TK_GP = -13 

77TK_OTHER_DETAILS = -14 

78TK_EMAIL = -15 

79 

80# Tokens for ID numbers are from 1 upwards. 

81 

82POLICY_TOKEN_DICT = { 

83 "(": TK_LPAREN, 

84 ")": TK_RPAREN, 

85 "AND": TK_AND, 

86 "OR": TK_OR, 

87 "NOT": TK_NOT, 

88 "ANYIDNUM": TK_ANY_IDNUM, 

89 "OTHERIDNUM": TK_OTHER_IDNUM, 

90 "FORENAME": TK_FORENAME, 

91 "SURNAME": TK_SURNAME, 

92 "SEX": TK_SEX, 

93 "DOB": TK_DOB, 

94 "ADDRESS": TK_ADDRESS, 

95 "GP": TK_GP, 

96 "OTHERDETAILS": TK_OTHER_DETAILS, 

97 "EMAIL": TK_EMAIL, 

98} 

99TOKEN_POLICY_DICT = reversedict(POLICY_TOKEN_DICT) 

100 

101NON_IDNUM_INFO_TOKENS = [ 

102 TK_OTHER_IDNUM, 

103 TK_ANY_IDNUM, 

104 TK_FORENAME, 

105 TK_SURNAME, 

106 TK_SEX, 

107 TK_DOB, 

108 TK_ADDRESS, 

109 TK_GP, 

110 TK_OTHER_DETAILS, 

111 TK_EMAIL, 

112] 

113 

114TOKEN_IDNUM_PREFIX = "IDNUM" 

115 

116 

117def is_info_token(token: int) -> bool: 

118 """ 

119 Is the token a kind that represents information, not (for example) an 

120 operator? 

121 """ 

122 return token > 0 or token in NON_IDNUM_INFO_TOKENS 

123 

124 

125def token_to_str(token: int) -> str: 

126 """ 

127 Returns a string version of the specified token. 

128 """ 

129 if token < 0: 

130 return TOKEN_POLICY_DICT.get(token) 

131 else: 

132 return TOKEN_IDNUM_PREFIX + str(token) 

133 

134 

135# ============================================================================= 

136# Quad-state logic 

137# ============================================================================= 

138 

139 

140class QuadState(object): 

141 def __str__(self) -> str: 

142 if self is Q_TRUE: 

143 return "QTrue" 

144 elif self is Q_FALSE: 

145 return "QFalse" 

146 elif self is Q_DONT_CARE: 

147 return "QDontCare" 

148 else: 

149 return "QError" 

150 

151 

152Q_TRUE = QuadState() 

153Q_FALSE = QuadState() 

154Q_ERROR = QuadState() 

155Q_DONT_CARE = QuadState() 

156 

157 

158def bool_to_quad(x: bool) -> QuadState: 

159 return Q_TRUE if x else Q_FALSE 

160 

161 

162def quad_not(x: QuadState) -> QuadState: 

163 # Boolean logic 

164 if x is Q_TRUE: 

165 return Q_FALSE 

166 elif x is Q_FALSE: 

167 return Q_TRUE 

168 # Unusual logic 

169 elif x is Q_DONT_CARE: 

170 return Q_DONT_CARE 

171 else: 

172 return Q_ERROR 

173 

174 

175def quad_and(x: QuadState, y: QuadState) -> QuadState: 

176 either = (x, y) 

177 # Unusual logic 

178 if Q_ERROR in either: 

179 return Q_ERROR 

180 elif Q_DONT_CARE in either: 

181 other = either[1] if either[0] == Q_DONT_CARE else either[0] 

182 return other 

183 # Boolean logic 

184 elif x is Q_TRUE and y is Q_TRUE: 

185 return Q_TRUE 

186 else: 

187 return Q_FALSE 

188 

189 

190def quad_or(x: QuadState, y: QuadState) -> QuadState: 

191 either = (x, y) 

192 # Unusual logic 

193 if Q_ERROR in either: 

194 return Q_ERROR 

195 elif Q_DONT_CARE in either: 

196 other = either[1] if either[0] == Q_DONT_CARE else either[0] 

197 return other 

198 # Boolean logic 

199 elif x is Q_TRUE or y is Q_TRUE: 

200 return Q_TRUE 

201 else: 

202 return Q_FALSE 

203 

204 

205def debug_wrapper(fn: Callable, name: str) -> Callable: 

206 def wrap(*args: Any, **kwargs: Any) -> QuadState: 

207 result = fn(*args, **kwargs) 

208 arglist = [str(x) for x in args] + [ 

209 f"{k}={v}" for k, v in kwargs.items() 

210 ] 

211 log.critical("{}({}) -> {}".format(name, ", ".join(arglist), result)) 

212 return result 

213 

214 return wrap 

215 

216 

217DEBUG_QUAD_STATE_LOGIC = False 

218 

219if DEBUG_QUAD_STATE_LOGIC: 

220 quad_not = debug_wrapper(quad_not, "quad_not") 

221 quad_and = debug_wrapper(quad_and, "quad_and") 

222 quad_or = debug_wrapper(quad_or, "quad_or") 

223 

224 

225# ============================================================================= 

226# PatientInfoPresence 

227# ============================================================================= 

228 

229 

230class PatientInfoPresence(object): 

231 """ 

232 Represents simply the presence/absence of different kinds of information 

233 about a patient. 

234 """ 

235 

236 def __init__( 

237 self, 

238 present: Dict[int, QuadState] = None, 

239 default: QuadState = Q_FALSE, 

240 ) -> None: 

241 """ 

242 Args: 

243 present: map from token to :class:`QuadState` 

244 default: default :class:`QuadState` to return if unspecified 

245 """ 

246 self.present = present or {} # type: Dict[int, QuadState] 

247 self.default = default 

248 for t in self.present.keys(): 

249 assert is_info_token(t) 

250 

251 def __repr__(self) -> str: 

252 return auto_repr(self) 

253 

254 def is_present(self, token: int, default: QuadState = None) -> QuadState: 

255 """ 

256 Is information represented by a particular token present? 

257 

258 Args: 

259 token: token to check for; e.g. :data:`TK_FORENAME` 

260 default: default :class:`QuadState` to return if unspecified; if 

261 this is None, ``self.default`` is used. 

262 

263 Returns: 

264 a :class:`QuadState` 

265 """ 

266 return self.present.get(token, default or self.default) 

267 

268 @property 

269 def forename_present(self) -> QuadState: 

270 return self.is_present(TK_FORENAME) 

271 

272 @property 

273 def surname_present(self) -> QuadState: 

274 return self.is_present(TK_SURNAME) 

275 

276 @property 

277 def sex_present(self) -> QuadState: 

278 return self.is_present(TK_SEX) 

279 

280 @property 

281 def dob_present(self) -> QuadState: 

282 return self.is_present(TK_DOB) 

283 

284 @property 

285 def address_present(self) -> QuadState: 

286 return self.is_present(TK_ADDRESS) 

287 

288 @property 

289 def email_present(self) -> QuadState: 

290 return self.is_present(TK_EMAIL) 

291 

292 @property 

293 def gp_present(self) -> QuadState: 

294 return self.is_present(TK_GP) 

295 

296 @property 

297 def otherdetails_present(self) -> QuadState: 

298 return self.is_present(TK_OTHER_DETAILS) 

299 

300 @property 

301 def otheridnum_present(self) -> QuadState: 

302 return self.is_present(TK_OTHER_IDNUM) 

303 

304 @property 

305 def special_anyidnum_present(self) -> QuadState: 

306 return self.is_present(TK_ANY_IDNUM) 

307 

308 def idnum_present(self, which_idnum: int) -> QuadState: 

309 """ 

310 Is the specified ID number type present? 

311 """ 

312 assert which_idnum > 0 

313 return self.is_present(which_idnum) 

314 

315 def any_idnum_present(self) -> QuadState: 

316 """ 

317 Is at least one ID number present? 

318 """ 

319 for k, v in self.present.items(): 

320 if k > 0 and v is Q_TRUE: 

321 return Q_TRUE 

322 return self.special_anyidnum_present 

323 

324 @classmethod 

325 def make_from_ptinfo( 

326 cls, ptinfo: BarePatientInfo, policy_mentioned_idnums: List[int] 

327 ) -> "PatientInfoPresence": 

328 """ 

329 Returns a :class:`PatientInfoPresence` representing whether different 

330 kinds of information about the patient are present or not. 

331 """ 

332 presences = { 

333 TK_FORENAME: bool_to_quad(bool(ptinfo.forename)), 

334 TK_SURNAME: bool_to_quad(bool(ptinfo.surname)), 

335 TK_SEX: bool_to_quad(bool(ptinfo.sex)), 

336 TK_DOB: bool_to_quad(ptinfo.dob is not None), 

337 TK_ADDRESS: bool_to_quad(bool(ptinfo.address)), 

338 TK_EMAIL: bool_to_quad(bool(ptinfo.email)), 

339 TK_GP: bool_to_quad(bool(ptinfo.gp)), 

340 TK_OTHER_DETAILS: bool_to_quad(bool(ptinfo.otherdetails)), 

341 TK_OTHER_IDNUM: Q_FALSE, # may change 

342 } # type: Dict[int, QuadState] 

343 for iddef in ptinfo.idnum_definitions: 

344 this_idnum_present = iddef.idnum_value is not None 

345 presences[iddef.which_idnum] = bool_to_quad(this_idnum_present) 

346 if iddef.which_idnum not in policy_mentioned_idnums: 

347 presences[TK_OTHER_IDNUM] = Q_TRUE 

348 return cls(presences, default=Q_FALSE) 

349 

350 @classmethod 

351 def make_uncaring(cls) -> "PatientInfoPresence": 

352 """ 

353 Makes a :class:`PatientInfoPresence` that doesn't care about anything. 

354 """ 

355 return cls({}, default=Q_DONT_CARE) 

356 

357 def set_idnum_presence(self, which_idnum: int, present: QuadState) -> None: 

358 """ 

359 Set the "presence" state for one ID number type. 

360 

361 Args: 

362 which_idnum: which ID number type 

363 present: its state of being present (or not, or other states) 

364 """ 

365 self.present[which_idnum] = present 

366 

367 @classmethod 

368 def make_uncaring_except( 

369 cls, token: int, present: QuadState 

370 ) -> "PatientInfoPresence": 

371 """ 

372 Make a :class:`PatientInfoPresence` that is uncaring except for one 

373 thing, specified by token. 

374 """ 

375 assert is_info_token(token) 

376 pip = cls.make_uncaring() 

377 pip.present[token] = present 

378 return pip 

379 

380 

381# ============================================================================= 

382# More constants 

383# ============================================================================= 

384 

385CONTENT_TOKEN_PROCESSOR_TYPE = Callable[[int], QuadState] 

386 

387 

388# ============================================================================= 

389# TokenizedPolicy 

390# ============================================================================= 

391 

392 

393class TokenizedPolicy(object): 

394 """ 

395 Represents a tokenized ID policy. 

396 

397 A tokenized policy is a policy represented by a sequence of integers; 

398 0 means "bad token"; negative numbers represent fixed things like 

399 "forename" or "left parenthesis" or "and"; positive numbers represent 

400 ID number types. 

401 """ 

402 

403 def __init__(self, policy: str) -> None: 

404 self.tokens = self.get_tokenized_id_policy(policy) 

405 self._syntactically_valid = None # type: Optional[bool] 

406 self.valid_idnums = None # type: Optional[List[int]] 

407 self._valid_for_idnums = None # type: Optional[bool] 

408 

409 def __str__(self) -> str: 

410 policy = " ".join(token_to_str(t) for t in self.tokens) 

411 policy = policy.replace("( ", "(") 

412 policy = policy.replace(" )", ")") 

413 return policy 

414 

415 # ------------------------------------------------------------------------- 

416 # ID number info 

417 # ------------------------------------------------------------------------- 

418 

419 def set_valid_idnums(self, valid_idnums: List[int]) -> None: 

420 """ 

421 Make a note of which ID number types are currently valid. 

422 Caches "valid for these ID numbers" information. 

423 

424 Args: 

425 valid_idnums: list of valid ID number types 

426 """ 

427 sorted_idnums = sorted(valid_idnums) 

428 if sorted_idnums != self.valid_idnums: 

429 self.valid_idnums = sorted_idnums 

430 self._valid_for_idnums = None # clear cache 

431 

432 def require_valid_idnum_info(self) -> None: 

433 """ 

434 Checks that set_valid_idnums() has been called properly, or raises 

435 :exc:`AssertionError`. 

436 """ 

437 assert ( 

438 self.valid_idnums is not None 

439 ), "Must call set_valid_idnums() first! Currently: {!r}" 

440 

441 # ------------------------------------------------------------------------- 

442 # Tokenize 

443 # ------------------------------------------------------------------------- 

444 

445 @staticmethod 

446 def name_to_token(name: str) -> int: 

447 """ 

448 Converts an upper-case string token name (such as ``DOB``) to an 

449 integer token. 

450 """ 

451 if name in POLICY_TOKEN_DICT: 

452 return POLICY_TOKEN_DICT[name] 

453 if name.startswith(TOKEN_IDNUM_PREFIX): 

454 nstr = name[len(TOKEN_IDNUM_PREFIX) :] 

455 try: 

456 return int(nstr) 

457 except (TypeError, ValueError): 

458 return BAD_TOKEN 

459 return BAD_TOKEN 

460 

461 @classmethod 

462 def get_tokenized_id_policy(cls, policy: str) -> TOKENIZED_POLICY_TYPE: 

463 """ 

464 Takes a string policy and returns a tokenized policy, meaning a list of 

465 integer tokens, or ``[]``. 

466 """ 

467 if policy is None: 

468 return [] 

469 # http://stackoverflow.com/questions/88613 

470 string_index = 1 

471 # single line, upper case: 

472 policy = " ".join(policy.strip().upper().splitlines()) 

473 try: 

474 tokenstrings = list( 

475 token[string_index] 

476 for token in tokenize.generate_tokens( 

477 io.StringIO(policy).readline 

478 ) 

479 if token[string_index] 

480 ) 

481 except tokenize.TokenError: 

482 # something went wrong 

483 return [] 

484 tokens = [cls.name_to_token(k) for k in tokenstrings] # type: ignore[arg-type] # noqa: E501 

485 if any(t == BAD_TOKEN for t in tokens): 

486 # There's something bad in there. 

487 return [] 

488 return tokens 

489 

490 # ------------------------------------------------------------------------- 

491 # Validity checks 

492 # ------------------------------------------------------------------------- 

493 

494 def is_syntactically_valid(self) -> bool: 

495 """ 

496 Is the policy syntactically valid? This is a basic check. 

497 """ 

498 if self._syntactically_valid is None: 

499 # Cache it 

500 if not self.tokens: 

501 self._syntactically_valid = False 

502 else: 

503 # Evaluate against a dummy patient info object. If we get None, 

504 # it's gone wrong. 

505 pip = PatientInfoPresence() 

506 value = self._value_for_pip(pip) 

507 self._syntactically_valid = value is not Q_ERROR 

508 return self._syntactically_valid 

509 

510 def is_valid( 

511 self, valid_idnums: List[int] = None, verbose: bool = False 

512 ) -> bool: 

513 """ 

514 Is the policy valid in the context of the ID types available in our 

515 database? 

516 

517 Args: 

518 valid_idnums: optional list of valid ID number types 

519 verbose: report reasons to debug log 

520 """ 

521 if valid_idnums is not None: 

522 self.set_valid_idnums(valid_idnums) 

523 if self._valid_for_idnums is None: 

524 # Cache information 

525 self.require_valid_idnum_info() 

526 self._valid_for_idnums = self.is_valid_for_idnums( 

527 self.valid_idnums, verbose=verbose 

528 ) 

529 return self._valid_for_idnums 

530 

531 def is_valid_for_idnums( 

532 self, valid_idnums: List[int], verbose: bool = False 

533 ) -> bool: 

534 """ 

535 Is the policy valid, given a list of valid ID number types? 

536 

537 Checks the following: 

538 

539 - valid syntax 

540 - refers only to ID number types defined on the server 

541 - is compatible with the tablet ID policy 

542 

543 Args: 

544 valid_idnums: ID number types that are valid on the server 

545 verbose: report reasons to debug log 

546 """ 

547 # First, syntax: 

548 if not self.is_syntactically_valid(): 

549 if verbose: 

550 log.debug("is_valid_for_idnums(): Not syntactically valid") 

551 return False 

552 # Second, all ID numbers referred to by the policy exist: 

553 for token in self.tokens: 

554 if token > 0 and token not in valid_idnums: 

555 if verbose: 

556 log.debug( 

557 "is_valid_for_idnums(): Refers to ID number type " 

558 "{}, which does not exist", 

559 token, 

560 ) 

561 return False 

562 if not self._compatible_with_tablet_id_policy(verbose=verbose): 

563 if verbose: 

564 log.debug( 

565 "is_valid_for_idnums(): Less restrictive than the " 

566 "tablet minimum ID policy; invalid" 

567 ) 

568 return False 

569 return True 

570 

571 # ------------------------------------------------------------------------- 

572 # Information about the ID number types the policy refers to 

573 # ------------------------------------------------------------------------- 

574 

575 def relevant_idnums(self, valid_idnums: List[int]) -> List[int]: 

576 """ 

577 Which ID numbers are relevant to this policy? 

578 

579 Args: 

580 valid_idnums: ID number types that are valid on the server 

581 

582 Returns: 

583 the subset of ``valid_idnums`` that is mentioned somehow in the 

584 policy 

585 """ 

586 if not self.tokens: 

587 return [] 

588 if TK_ANY_IDNUM in self.tokens or TK_OTHER_IDNUM in self.tokens: 

589 # all are relevant 

590 return valid_idnums 

591 relevant_idnums = [] # type: List[int] 

592 for which_idnum in valid_idnums: 

593 assert which_idnum > 0, "Silly ID number types" 

594 if which_idnum in self.tokens: 

595 relevant_idnums.append(which_idnum) 

596 return relevant_idnums 

597 

598 def specifically_mentioned_idnums(self) -> List[int]: 

599 """ 

600 Returns the ID number tokens for all ID numbers mentioned in the 

601 policy, as a list. 

602 """ 

603 return [x for x in self.tokens if x > 0] 

604 

605 def contains_specific_idnum(self, which_idnum: int) -> bool: 

606 """ 

607 Does the policy refer specifically to the given ID number type? 

608 

609 Args: 

610 which_idnum: ID number type to test 

611 """ 

612 assert which_idnum > 0 

613 return which_idnum in self.tokens 

614 

615 # ------------------------------------------------------------------------- 

616 # More complex attributes 

617 # ------------------------------------------------------------------------- 

618 

619 def find_critical_single_numerical_id( 

620 self, valid_idnums: List[int] = None, verbose: bool = False 

621 ) -> Optional[int]: 

622 """ 

623 If the policy involves a single mandatory ID number, return that ID 

624 number; otherwise return None. 

625 

626 Args: 

627 valid_idnums: ID number types that are valid on the server 

628 verbose: report reasons to debug log 

629 

630 Returns: 

631 int: the single critical ID number type, or ``None`` 

632 """ 

633 if not self.is_valid(valid_idnums): 

634 if verbose: 

635 log.debug("find_critical_single_numerical_id(): invalid") 

636 return None 

637 relevant_idnums = self.specifically_mentioned_idnums() 

638 possible_critical_idnums = [] # type: List[int] 

639 for which_idnum in relevant_idnums: 

640 pip_with = PatientInfoPresence.make_uncaring_except( 

641 which_idnum, Q_TRUE 

642 ) 

643 satisfies_with_1 = self._value_for_pip(pip_with) 

644 pip_with.present[TK_OTHER_IDNUM] = Q_FALSE 

645 satisfies_with_2 = self._value_for_pip(pip_with) 

646 pip_without = PatientInfoPresence.make_uncaring_except( 

647 which_idnum, Q_FALSE 

648 ) 

649 satisfies_without_1 = self._value_for_pip(pip_without) 

650 pip_with.present[TK_OTHER_IDNUM] = Q_TRUE 

651 satisfies_without_2 = self._value_for_pip(pip_without) 

652 if verbose: 

653 log.debug( 

654 "... {}: satisfies_with={}, satisfies_without_1={}, " 

655 "satisfies_without_2={}", 

656 which_idnum, 

657 satisfies_with_1, 

658 satisfies_without_1, 

659 satisfies_without_2, 

660 ) 

661 if ( 

662 satisfies_with_1 is Q_TRUE 

663 and satisfies_with_2 is Q_TRUE 

664 and satisfies_without_1 is Q_FALSE 

665 and satisfies_without_2 is Q_FALSE 

666 ): 

667 possible_critical_idnums.append(which_idnum) 

668 if verbose: 

669 log.debug( 

670 "find_critical_single_numerical_id(): " 

671 "possible_critical_idnums = {}", 

672 possible_critical_idnums, 

673 ) 

674 if len(possible_critical_idnums) == 1: 

675 return possible_critical_idnums[0] 

676 return None 

677 

678 def is_idnum_mandatory_in_policy( 

679 self, which_idnum: int, valid_idnums: List[int], verbose: bool = False 

680 ) -> bool: 

681 """ 

682 Is the ID number mandatory in the specified policy? 

683 

684 Args: 

685 which_idnum: ID number type to test 

686 valid_idnums: ID number types that are valid on the server 

687 verbose: report reasons to debug log 

688 """ 

689 if which_idnum is None or which_idnum < 1: 

690 if verbose: 

691 log.debug("is_idnum_mandatory_in_policy(): bad ID type") 

692 return False 

693 if not self.contains_specific_idnum(which_idnum): 

694 if verbose: 

695 log.debug( 

696 "is_idnum_mandatory_in_policy(): policy does not " 

697 "contain ID {}, so not mandatory", 

698 which_idnum, 

699 ) 

700 return False 

701 self.set_valid_idnums(valid_idnums) 

702 if not self.is_valid(): 

703 if verbose: 

704 log.debug("is_idnum_mandatory_in_policy(): policy invalid") 

705 return False 

706 

707 pip_with = PatientInfoPresence.make_uncaring_except( 

708 which_idnum, Q_TRUE 

709 ) 

710 satisfies_with = self._value_for_pip(pip_with) 

711 if satisfies_with != Q_TRUE: 

712 if verbose: 

713 log.debug( 

714 "is_idnum_mandatory_in_policy(): policy not " 

715 "satisfied by presence of ID {}, so not mandatory", 

716 which_idnum, 

717 ) 

718 return False 

719 pip_without = PatientInfoPresence.make_uncaring_except( 

720 which_idnum, Q_FALSE 

721 ) 

722 satisfies_without = self._value_for_pip(pip_without) 

723 if satisfies_without != Q_FALSE: 

724 if verbose: 

725 log.debug( 

726 "is_idnum_mandatory_in_policy(): policy satisfied " 

727 "without presence of ID {}, so not mandatory", 

728 which_idnum, 

729 ) 

730 return False 

731 # Thus, if we get here, the policy is unhappy with the absence of our 

732 # ID number type, but happy with it; therefore it is mandatory. 

733 if verbose: 

734 log.debug( 

735 "is_idnum_mandatory_in_policy(): ID {} is mandatory", 

736 which_idnum, 

737 ) 

738 return True 

739 

740 def _requires_prohibits( 

741 self, token: int, verbose: bool = False 

742 ) -> Tuple[bool, bool]: 

743 """ 

744 Does this policy require, and/or prohibit, a particular token? 

745 

746 Args: 

747 token: token to check 

748 verbose: report reasons to debug log 

749 

750 Returns: 

751 tuple: requires, prohibits 

752 """ 

753 pip_with = PatientInfoPresence.make_uncaring_except(token, Q_TRUE) 

754 satisfies_with = self._value_for_pip(pip_with) 

755 pip_without = PatientInfoPresence.make_uncaring_except(token, Q_FALSE) 

756 satisfies_without = self._value_for_pip(pip_without) 

757 requires = satisfies_with is Q_TRUE and satisfies_without is Q_FALSE 

758 prohibits = satisfies_with is Q_FALSE and satisfies_without is Q_TRUE 

759 if verbose: 

760 log.debug( 

761 "_requires_prohibits({t}): " 

762 "satisfies_with={sw}, " 

763 "satisfies_without={swo}, " 

764 "requires={r}, " 

765 "prohibits={p}", 

766 t=token_to_str(token), 

767 sw=satisfies_with, 

768 swo=satisfies_without, 

769 r=requires, 

770 p=prohibits, 

771 ) 

772 return requires, prohibits 

773 

774 def _requires_sex(self, verbose: bool = False) -> bool: 

775 """ 

776 Does this policy require sex to be present? 

777 

778 Args: 

779 verbose: report reasons to debug log 

780 """ 

781 requires, _ = self._requires_prohibits(TK_SEX, verbose=verbose) 

782 return requires 

783 

784 def _requires_an_idnum(self, verbose: bool = False) -> bool: 

785 """ 

786 Does this policy require an ID number to be present? 

787 

788 Args: 

789 verbose: report reasons to debug log 

790 """ 

791 if verbose: 

792 log.debug("_requires_an_idnum():") 

793 for token in self.specifically_mentioned_idnums() + [ 

794 TK_ANY_IDNUM, 

795 TK_OTHER_IDNUM, 

796 ]: 

797 requires, _ = self._requires_prohibits(token, verbose=verbose) 

798 if requires: 

799 if verbose: 

800 log.debug( 

801 "... requires ID number '{}'", token_to_str(token) 

802 ) 

803 return True 

804 return False 

805 

806 # def _less_restrictive_than(self, other: "TokenizedPolicy", 

807 # valid_idnums: List[int], 

808 # verbose: bool = False) -> bool: 

809 # """ 

810 # Is this ("self") policy less restrictive than the "other" policy? 

811 # 

812 # "More restrictive" means "requires more information". 

813 # "Less restrictive" means "requires or enforces less information". 

814 # 

815 # Therefore, we must return True if we can find a situation where 

816 # "self" is satisfied but "other" is not. 

817 # 

818 # Args: 

819 # other: the other policy 

820 # valid_idnums: ID number types that are valid on the server 

821 # verbose: report reasons to debug log 

822 # 

823 # This is very difficult. Abandoned this generic attempt in favour of a 

824 # specific hard-coded check for the tablet policy. 

825 # """ 

826 # if verbose: 

827 # log.debug("_less_restrictive_than(): self={}, other={}", 

828 # self, other) 

829 # possible_tokens = valid_idnums + NON_IDNUM_INFO_TOKENS 

830 # for token in possible_tokens: 

831 # # Self 

832 # self_requires, self_prohibits = self._requires_prohibits( 

833 # token, valid_idnums) 

834 # # Other 

835 # pip_with = PatientInfoPresence.make_uncaring_except( 

836 # token, Q_TRUE, valid_idnums) 

837 # other_satisfies_with = other._value_for_pip(pip_with) 

838 # pip_without = PatientInfoPresence.make_uncaring_except( 

839 # token, Q_FALSE, valid_idnums) 

840 # other_satisfies_without_1 = other._value_for_pip(pip_without) 

841 # pip_without.special_anyidnum_present = Q_TRUE 

842 # other_satisfies_without_2 = other._value_for_pip(pip_without) 

843 # other_requires = ( 

844 # other_satisfies_with is Q_TRUE and 

845 # other_satisfies_without_1 is Q_FALSE and 

846 # other_satisfies_without_2 is Q_FALSE 

847 # ) 

848 # other_prohibits = ( 

849 # other_satisfies_with is Q_FALSE and 

850 # other_satisfies_without_1 is Q_TRUE and 

851 # other_satisfies_without_2 is Q_TRUE 

852 # ) 

853 # if verbose: 

854 # log.debug( 

855 # "... for {t}: " 

856 # "self_requires={sr}, " 

857 # "self_prohibits={sp}, " 

858 # "other_satisfies_with={osw}, " 

859 # "other_satisfies_without_1={oswo1}, " 

860 # "other_satisfies_without_2={oswo2}, " 

861 # "other_requires={or_}", 

862 # "other_prohibits={op}", 

863 # t=token_to_str(token), 

864 # sr=self_requires, 

865 # sp=self_prohibits, 

866 # osw=other_satisfies_with, 

867 # oswo1=other_satisfies_without_1, 

868 # oswo2=other_satisfies_without_2, 

869 # or_=other_requires, 

870 # op=other_prohibits, 

871 # ) 

872 # 

873 # if other_requires and not self_requires: 

874 # # The "self" policy is LESS RESTRICTIVE (requires less info). 

875 # if verbose: 

876 # log.debug( 

877 # "... self does not require ID type {}, but other does " # noqa 

878 # "require it; therefore self is less restrictive", 

879 # token) 

880 # return True 

881 # # if self_prohibits and not other_prohibits: 

882 # # # The "self" policy is LESS RESTRICTIVE (enforces less info). # noqa 

883 # # if verbose: 

884 # # log.debug( 

885 # # "... self prohibits ID type {}, but other does not " # noqa 

886 # # "prohibit it; therefore self is less restrictive", 

887 # # token) 

888 # # return True 

889 # if verbose: 

890 # log.debug( 

891 # "... by elimination, self [{}] not less " 

892 # "restrictive than other [{}]", 

893 # self, other 

894 # ) 

895 # return False 

896 

897 def _compatible_with_tablet_id_policy(self, verbose: bool = False) -> bool: 

898 """ 

899 Is this policy compatible with :data:`TABLET_ID_POLICY`? 

900 

901 The "self" policy may be MORE restrictive than the tablet minimum ID 

902 policy, but may not be LESS restrictive. 

903 

904 Args: 

905 verbose: report reasons to debug log 

906 

907 Internal function -- doesn't used cached information. 

908 """ 

909 # Method 1: abandoned. 

910 # We previously used a version of _less_restrictive_than() that 

911 # did a brute-force attempt, but that became prohibitive as ID numbers 

912 # got added. 

913 # A generic method is very hard (see above) -- not properly succeeded 

914 # yet. 

915 # 

916 # return not self._less_restrictive_than( 

917 # TABLET_ID_POLICY, valid_idnums, verbose=verbose) 

918 

919 # Method 2: manual. 

920 if verbose: 

921 log.debug("_compatible_with_tablet_id_policy():") 

922 requires_sex = self._requires_sex(verbose=verbose) 

923 if requires_sex: 

924 if verbose: 

925 log.debug("... requires sex") 

926 else: 

927 if verbose: 

928 log.debug("... doesn't require sex; returning False") 

929 return False 

930 requires_an_idnum = self._requires_an_idnum(verbose=verbose) 

931 if requires_an_idnum: 

932 if verbose: 

933 log.debug("... requires an ID number; returning True") 

934 return True 

935 if verbose: 

936 log.debug("... does not require an ID number; trying alternatives") 

937 other_mandatory = [TK_FORENAME, TK_SURNAME, TK_DOB, TK_EMAIL] 

938 for token in other_mandatory: 

939 requires, _ = self._requires_prohibits(token, verbose=verbose) 

940 if not requires: 

941 if verbose: 

942 log.debug( 

943 "... does not require '{}'; returning False", 

944 token_to_str(token), 

945 ) 

946 return False 

947 log.debug( 

948 "... requires all of {!r}; returning True", 

949 [token_to_str(t) for t in other_mandatory], 

950 ) 

951 return True 

952 

953 def compatible_with_tablet_id_policy( 

954 self, valid_idnums: List[int], verbose: bool = False 

955 ) -> bool: 

956 """ 

957 Is this policy compatible with :data:`TABLET_ID_POLICY`? 

958 

959 The "self" policy may be MORE restrictive than the tablet minimum ID 

960 policy, but may not be LESS restrictive. 

961 

962 Args: 

963 valid_idnums: ID number types that are valid on the server 

964 verbose: report reasons to debug log 

965 """ 

966 self.set_valid_idnums(valid_idnums) 

967 if not self.is_valid(verbose=verbose): 

968 return False 

969 return self._compatible_with_tablet_id_policy(verbose=verbose) 

970 

971 # ------------------------------------------------------------------------- 

972 # Check if a patient satisfies the policy 

973 # ------------------------------------------------------------------------- 

974 

975 def _value_for_ptinfo(self, ptinfo: BarePatientInfo) -> QuadState: 

976 """ 

977 What does the policy evaluate to for a given patient info object? 

978 

979 Args: 

980 ptinfo: 

981 a `camcops_server.cc_modules.cc_simpleobjects.BarePatientInfo` 

982 

983 Returns: 

984 a :class:`QuadState` quad-state value 

985 """ 

986 pip = PatientInfoPresence.make_from_ptinfo( 

987 ptinfo, self.specifically_mentioned_idnums() 

988 ) 

989 return self._value_for_pip(pip) 

990 

991 def _value_for_pip(self, pip: PatientInfoPresence) -> QuadState: 

992 """ 

993 What does the policy evaluate to for a given patient info presence 

994 object? 

995 

996 Args: 

997 pip: 

998 a `camcops_server.cc_modules.cc_simpleobjects.PatientInfoPresence` 

999 

1000 Returns: 

1001 a :class:`QuadState` quad-state value 

1002 """ # noqa 

1003 

1004 def content_token_processor(token: int) -> QuadState: 

1005 return self._element_value_test_pip(pip, token) 

1006 

1007 return self._chunk_value( 

1008 self.tokens, content_token_processor=content_token_processor 

1009 ) 

1010 # ... which is recursive 

1011 

1012 def satisfies_id_policy(self, ptinfo: BarePatientInfo) -> bool: 

1013 """ 

1014 Does the patient information in ptinfo satisfy the specified ID policy? 

1015 

1016 Args: 

1017 ptinfo: 

1018 a `camcops_server.cc_modules.cc_simpleobjects.BarePatientInfo` 

1019 """ 

1020 return self._value_for_ptinfo(ptinfo) is Q_TRUE 

1021 

1022 # ------------------------------------------------------------------------- 

1023 # Functions for the policy to parse itself and compare itself to a patient 

1024 # ------------------------------------------------------------------------- 

1025 

1026 def _chunk_value( 

1027 self, 

1028 tokens: TOKENIZED_POLICY_TYPE, 

1029 content_token_processor: CONTENT_TOKEN_PROCESSOR_TYPE, 

1030 ) -> QuadState: 

1031 """ 

1032 Applies the policy to the patient info in ``ptinfo``. 

1033 Can be used recursively. 

1034 

1035 Args: 

1036 tokens: 

1037 a tokenized policy 

1038 content_token_processor: 

1039 a function to be called for each "content" token, which returns 

1040 its Boolean value, or ``None`` in case of failure 

1041 

1042 Returns: 

1043 a :class:`QuadState` quad-state value 

1044 """ 

1045 want_content = True 

1046 processing_and = False 

1047 processing_or = False 

1048 index = 0 

1049 value = None # type: Optional[QuadState] 

1050 while index < len(tokens): 

1051 if want_content: 

1052 nextchunk, index = self._content_chunk_value( 

1053 tokens, index, content_token_processor 

1054 ) 

1055 if nextchunk is Q_ERROR: 

1056 return Q_ERROR # fail 

1057 if value is None: 

1058 value = nextchunk 

1059 elif processing_and: 

1060 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

1061 # implement logical AND 

1062 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

1063 value = quad_and(value, nextchunk) 

1064 elif processing_or: 

1065 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

1066 # implement logical OR 

1067 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

1068 value = quad_or(value, nextchunk) 

1069 else: 

1070 # Error; shouldn't get here 

1071 return Q_ERROR 

1072 processing_and = False 

1073 processing_or = False 

1074 else: 

1075 # Want operator 

1076 operator, index = self._op(tokens, index) 

1077 if operator is None: 

1078 return Q_ERROR # fail 

1079 if operator == TK_AND: 

1080 processing_and = True 

1081 elif operator == TK_OR: 

1082 processing_or = True 

1083 else: 

1084 # Error; shouldn't get here 

1085 return Q_ERROR 

1086 want_content = not want_content 

1087 if want_content: 

1088 log.debug("_chunk_value(): ended wanting content; bad policy") 

1089 return Q_ERROR 

1090 return value 

1091 

1092 def _content_chunk_value( 

1093 self, 

1094 tokens: TOKENIZED_POLICY_TYPE, 

1095 start: int, 

1096 content_token_processor: CONTENT_TOKEN_PROCESSOR_TYPE, 

1097 ) -> Tuple[QuadState, int]: 

1098 """ 

1099 Applies part of a policy to ``ptinfo``. The part of policy pointed to 

1100 by ``start`` represents something -- "content" -- that should return a 

1101 value (not an operator, for example). Called by :func:`id_policy_chunk` 

1102 (q.v.). 

1103 

1104 Args: 

1105 tokens: 

1106 a tokenized policy (list of integers) 

1107 start: 

1108 zero-based index of the first token to check 

1109 content_token_processor: 

1110 a function to be called for each "content" token, which returns 

1111 its Boolean value, or ``None`` in case of failure 

1112 

1113 Returns: 

1114 tuple: chunk_value, next_index. ``chunk_value`` is ``True`` if the 

1115 specified chunk is satisfied by the ``ptinfo``, ``False`` if it 

1116 isn't, and ``None`` if there was an error. ``next_index`` is the 

1117 index of the next token after this chunk. 

1118 

1119 """ 

1120 if start >= len(tokens): 

1121 log.debug( 

1122 "_content_chunk_value(): " "beyond end of policy; bad policy" 

1123 ) 

1124 return Q_ERROR, start 

1125 token = tokens[start] 

1126 if token in (TK_RPAREN, TK_AND, TK_OR): 

1127 log.debug( 

1128 "_content_chunk_value(): " 

1129 "chunk starts with ), AND, or OR; bad policy" 

1130 ) 

1131 return Q_ERROR, start 

1132 elif token == TK_LPAREN: 

1133 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

1134 # implement parentheses 

1135 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

1136 subchunkstart = start + 1 # exclude the opening bracket 

1137 # Find closing parenthesis 

1138 depth = 1 

1139 searchidx = subchunkstart 

1140 while depth > 0: 

1141 if searchidx >= len(tokens): 

1142 log.debug( 

1143 "_content_chunk_value(): " 

1144 "Unmatched left parenthesis; bad policy" 

1145 ) 

1146 return Q_ERROR, start 

1147 elif tokens[searchidx] == TK_LPAREN: 

1148 depth += 1 

1149 elif tokens[searchidx] == TK_RPAREN: 

1150 depth -= 1 

1151 searchidx += 1 

1152 subchunkend = searchidx - 1 

1153 # ... to exclude the closing bracket from the analysed subchunk 

1154 chunk_value = self._chunk_value( 

1155 tokens[subchunkstart:subchunkend], content_token_processor 

1156 ) 

1157 return ( 

1158 chunk_value, 

1159 subchunkend + 1, 

1160 ) # to move past the closing bracket 

1161 elif token == TK_NOT: 

1162 next_value, next_index = self._content_chunk_value( 

1163 tokens, start + 1, content_token_processor 

1164 ) 

1165 if next_value is Q_ERROR: 

1166 return next_value, start 

1167 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

1168 # implement logical NOT 

1169 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

1170 return quad_not(next_value), next_index 

1171 else: 

1172 # meaningful token 

1173 return content_token_processor(token), start + 1 

1174 

1175 @classmethod 

1176 def _op( 

1177 cls, policy: TOKENIZED_POLICY_TYPE, start: int 

1178 ) -> Tuple[Optional[TOKEN_TYPE], int]: 

1179 """ 

1180 Returns an operator from the policy, beginning at index ``start``, or 

1181 ``None`` if there wasn't an operator there. 

1182 

1183 policy: 

1184 a tokenized policy (list of integers) 

1185 start: 

1186 zero-based index of the first token to check 

1187 

1188 Returns: 

1189 tuple: ``operator, next_index``. ``operator`` is the operator's 

1190 integer token or ``None``. ``next_index`` gives the next index of 

1191 the policy to check at. 

1192 """ 

1193 if start >= len(policy): 

1194 log.debug("_op(): beyond end of policy") 

1195 return None, start 

1196 token = policy[start] 

1197 if token in (TK_AND, TK_OR): 

1198 return token, start + 1 

1199 else: 

1200 log.debug("_op(): not an operator; bad policy") 

1201 # Not an operator 

1202 return None, start 

1203 

1204 # Things to do with content tokens 1: are they present in patient info? 

1205 

1206 @staticmethod 

1207 def _element_value_test_pip( 

1208 pip: PatientInfoPresence, token: TOKEN_TYPE 

1209 ) -> QuadState: 

1210 """ 

1211 Returns the "value" of a content token as judged against the patient 

1212 information. For example, if the patient information contains a date of 

1213 birth, a ``TK_DOB`` token will evaluate to ``True``. 

1214 

1215 Args: 

1216 pip: 

1217 a `camcops_server.cc_modules.cc_simpleobjects.PatientInfoPresence` 

1218 token: 

1219 an integer token from the policy 

1220 

1221 Returns: 

1222 a :class:`QuadState` quad-state value 

1223 """ # noqa 

1224 assert is_info_token(token) 

1225 if token == TK_ANY_IDNUM: 

1226 return pip.any_idnum_present() 

1227 else: 

1228 return pip.is_present(token) 

1229 

1230 

1231# ============================================================================= 

1232# Tablet ID policy 

1233# ============================================================================= 

1234 

1235TABLET_ID_POLICY_STR = "sex AND ((forename AND surname AND dob) OR anyidnum)" 

1236TABLET_ID_POLICY = TokenizedPolicy(TABLET_ID_POLICY_STR)