Coverage for cc_modules/cc_policy.py: 29%

449 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_policy.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**Represents ID number policies.** 

29 

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

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

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

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

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

35 

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

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

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

39configuration). 

40 

41""" 

42 

43import io 

44import logging 

45import tokenize 

46from typing import Callable, Dict, List, Optional, Tuple 

47 

48from cardinal_pythonlib.dicts import reversedict 

49from cardinal_pythonlib.logs import BraceStyleAdapter 

50from cardinal_pythonlib.reprfunc import auto_repr 

51 

52from camcops_server.cc_modules.cc_simpleobjects import BarePatientInfo 

53 

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

55 

56 

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

58# Tokens 

59# ============================================================================= 

60 

61TOKEN_TYPE = int 

62TOKENIZED_POLICY_TYPE = List[TOKEN_TYPE] 

63 

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

65BAD_TOKEN = 0 

66TK_LPAREN = -1 

67TK_RPAREN = -2 

68TK_AND = -3 

69TK_OR = -4 

70TK_NOT = -5 

71TK_ANY_IDNUM = -6 

72TK_OTHER_IDNUM = -7 

73TK_FORENAME = -8 

74TK_SURNAME = -9 

75TK_SEX = -10 

76TK_DOB = -11 

77TK_ADDRESS = -12 

78TK_GP = -13 

79TK_OTHER_DETAILS = -14 

80TK_EMAIL = -15 

81 

82# Tokens for ID numbers are from 1 upwards. 

83 

84POLICY_TOKEN_DICT = { 

85 "(": TK_LPAREN, 

86 ")": TK_RPAREN, 

87 "AND": TK_AND, 

88 "OR": TK_OR, 

89 "NOT": TK_NOT, 

90 "ANYIDNUM": TK_ANY_IDNUM, 

91 "OTHERIDNUM": TK_OTHER_IDNUM, 

92 "FORENAME": TK_FORENAME, 

93 "SURNAME": TK_SURNAME, 

94 "SEX": TK_SEX, 

95 "DOB": TK_DOB, 

96 "ADDRESS": TK_ADDRESS, 

97 "GP": TK_GP, 

98 "OTHERDETAILS": TK_OTHER_DETAILS, 

99 "EMAIL": TK_EMAIL, 

100} 

101TOKEN_POLICY_DICT = reversedict(POLICY_TOKEN_DICT) 

102 

103NON_IDNUM_INFO_TOKENS = [ 

104 TK_OTHER_IDNUM, 

105 TK_ANY_IDNUM, 

106 TK_FORENAME, 

107 TK_SURNAME, 

108 TK_SEX, 

109 TK_DOB, 

110 TK_ADDRESS, 

111 TK_GP, 

112 TK_OTHER_DETAILS, 

113 TK_EMAIL, 

114] 

115 

116TOKEN_IDNUM_PREFIX = "IDNUM" 

117 

118 

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

120 """ 

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

122 operator? 

123 """ 

124 return token > 0 or token in NON_IDNUM_INFO_TOKENS 

125 

126 

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

128 """ 

129 Returns a string version of the specified token. 

130 """ 

131 if token < 0: 

132 return TOKEN_POLICY_DICT.get(token) 

133 else: 

134 return TOKEN_IDNUM_PREFIX + str(token) 

135 

136 

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

138# Quad-state logic 

139# ============================================================================= 

140 

141 

142class QuadState(object): 

143 def __str__(self) -> str: 

144 if self is Q_TRUE: 

145 return "QTrue" 

146 elif self is Q_FALSE: 

147 return "QFalse" 

148 elif self is Q_DONT_CARE: 

149 return "QDontCare" 

150 else: 

151 return "QError" 

152 

153 

154Q_TRUE = QuadState() 

155Q_FALSE = QuadState() 

156Q_ERROR = QuadState() 

157Q_DONT_CARE = QuadState() 

158 

159 

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

161 return Q_TRUE if x else Q_FALSE 

162 

163 

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

165 # Boolean logic 

166 if x is Q_TRUE: 

167 return Q_FALSE 

168 elif x is Q_FALSE: 

169 return Q_TRUE 

170 # Unusual logic 

171 elif x is Q_DONT_CARE: 

172 return Q_DONT_CARE 

173 else: 

174 return Q_ERROR 

175 

176 

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

178 either = (x, y) 

179 # Unusual logic 

180 if Q_ERROR in either: 

181 return Q_ERROR 

182 elif Q_DONT_CARE in either: 

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

184 return other 

185 # Boolean logic 

186 elif x is Q_TRUE and y is Q_TRUE: 

187 return Q_TRUE 

188 else: 

189 return Q_FALSE 

190 

191 

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

193 either = (x, y) 

194 # Unusual logic 

195 if Q_ERROR in either: 

196 return Q_ERROR 

197 elif Q_DONT_CARE in either: 

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

199 return other 

200 # Boolean logic 

201 elif x is Q_TRUE or y is Q_TRUE: 

202 return Q_TRUE 

203 else: 

204 return Q_FALSE 

205 

206 

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

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

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

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

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

212 ] 

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

214 return result 

215 

216 return wrap 

217 

218 

219DEBUG_QUAD_STATE_LOGIC = False 

220 

221if DEBUG_QUAD_STATE_LOGIC: 

222 quad_not = debug_wrapper(quad_not, "quad_not") 

223 quad_and = debug_wrapper(quad_and, "quad_and") 

224 quad_or = debug_wrapper(quad_or, "quad_or") 

225 

226 

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

228# PatientInfoPresence 

229# ============================================================================= 

230 

231 

232class PatientInfoPresence(object): 

233 """ 

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

235 about a patient. 

236 """ 

237 

238 def __init__( 

239 self, 

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

241 default: QuadState = Q_FALSE, 

242 ) -> None: 

243 """ 

244 Args: 

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

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

247 """ 

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

249 self.default = default 

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

251 assert is_info_token(t) 

252 

253 def __repr__(self) -> str: 

254 return auto_repr(self) 

255 

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

257 """ 

258 Is information represented by a particular token present? 

259 

260 Args: 

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

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

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

264 

265 Returns: 

266 a :class:`QuadState` 

267 """ 

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

269 

270 @property 

271 def forename_present(self) -> QuadState: 

272 return self.is_present(TK_FORENAME) 

273 

274 @property 

275 def surname_present(self) -> QuadState: 

276 return self.is_present(TK_SURNAME) 

277 

278 @property 

279 def sex_present(self) -> QuadState: 

280 return self.is_present(TK_SEX) 

281 

282 @property 

283 def dob_present(self) -> QuadState: 

284 return self.is_present(TK_DOB) 

285 

286 @property 

287 def address_present(self) -> QuadState: 

288 return self.is_present(TK_ADDRESS) 

289 

290 @property 

291 def email_present(self) -> QuadState: 

292 return self.is_present(TK_EMAIL) 

293 

294 @property 

295 def gp_present(self) -> QuadState: 

296 return self.is_present(TK_GP) 

297 

298 @property 

299 def otherdetails_present(self) -> QuadState: 

300 return self.is_present(TK_OTHER_DETAILS) 

301 

302 @property 

303 def otheridnum_present(self) -> QuadState: 

304 return self.is_present(TK_OTHER_IDNUM) 

305 

306 @property 

307 def special_anyidnum_present(self) -> QuadState: 

308 return self.is_present(TK_ANY_IDNUM) 

309 

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

311 """ 

312 Is the specified ID number type present? 

313 """ 

314 assert which_idnum > 0 

315 return self.is_present(which_idnum) 

316 

317 def any_idnum_present(self) -> QuadState: 

318 """ 

319 Is at least one ID number present? 

320 """ 

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

322 if k > 0 and v is Q_TRUE: 

323 return Q_TRUE 

324 return self.special_anyidnum_present 

325 

326 @classmethod 

327 def make_from_ptinfo( 

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

329 ) -> "PatientInfoPresence": 

330 """ 

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

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

333 """ 

334 presences = { 

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

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

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

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

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

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

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

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

343 TK_OTHER_IDNUM: Q_FALSE, # may change 

344 } # type: Dict[int, QuadState] 

345 for iddef in ptinfo.idnum_definitions: 

346 this_idnum_present = iddef.idnum_value is not None 

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

348 if iddef.which_idnum not in policy_mentioned_idnums: 

349 presences[TK_OTHER_IDNUM] = Q_TRUE 

350 return cls(presences, default=Q_FALSE) 

351 

352 @classmethod 

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

354 """ 

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

356 """ 

357 return cls({}, default=Q_DONT_CARE) 

358 

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

360 """ 

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

362 

363 Args: 

364 which_idnum: which ID number type 

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

366 """ 

367 self.present[which_idnum] = present 

368 

369 @classmethod 

370 def make_uncaring_except( 

371 cls, token: int, present: QuadState 

372 ) -> "PatientInfoPresence": 

373 """ 

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

375 thing, specified by token. 

376 """ 

377 assert is_info_token(token) 

378 pip = cls.make_uncaring() 

379 pip.present[token] = present 

380 return pip 

381 

382 

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

384# More constants 

385# ============================================================================= 

386 

387CONTENT_TOKEN_PROCESSOR_TYPE = Callable[[int], QuadState] 

388 

389 

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

391# TokenizedPolicy 

392# ============================================================================= 

393 

394 

395class TokenizedPolicy(object): 

396 """ 

397 Represents a tokenized ID policy. 

398 

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

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

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

402 ID number types. 

403 """ 

404 

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

406 self.tokens = self.get_tokenized_id_policy(policy) 

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

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

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

410 

411 def __str__(self) -> str: 

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

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

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

415 return policy 

416 

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

418 # ID number info 

419 # ------------------------------------------------------------------------- 

420 

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

422 """ 

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

424 Caches "valid for these ID numbers" information. 

425 

426 Args: 

427 valid_idnums: list of valid ID number types 

428 """ 

429 sorted_idnums = sorted(valid_idnums) 

430 if sorted_idnums != self.valid_idnums: 

431 self.valid_idnums = sorted_idnums 

432 self._valid_for_idnums = None # clear cache 

433 

434 def require_valid_idnum_info(self) -> None: 

435 """ 

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

437 :exc:`AssertionError`. 

438 """ 

439 assert ( 

440 self.valid_idnums is not None 

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

442 

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

444 # Tokenize 

445 # ------------------------------------------------------------------------- 

446 

447 @staticmethod 

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

449 """ 

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

451 integer token. 

452 """ 

453 if name in POLICY_TOKEN_DICT: 

454 return POLICY_TOKEN_DICT[name] 

455 if name.startswith(TOKEN_IDNUM_PREFIX): 

456 nstr = name[len(TOKEN_IDNUM_PREFIX) :] # noqa: E203 

457 try: 

458 return int(nstr) 

459 except (TypeError, ValueError): 

460 return BAD_TOKEN 

461 return BAD_TOKEN 

462 

463 @classmethod 

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

465 """ 

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

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

468 """ 

469 if policy is None: 

470 return [] 

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

472 string_index = 1 

473 # single line, upper case: 

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

475 try: 

476 tokenstrings = list( 

477 token[string_index] 

478 for token in tokenize.generate_tokens( 

479 io.StringIO(policy).readline 

480 ) 

481 if token[string_index] 

482 ) 

483 except tokenize.TokenError: 

484 # something went wrong 

485 return [] 

486 tokens = [cls.name_to_token(k) for k in tokenstrings] 

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

488 # There's something bad in there. 

489 return [] 

490 return tokens 

491 

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

493 # Validity checks 

494 # ------------------------------------------------------------------------- 

495 

496 def is_syntactically_valid(self) -> bool: 

497 """ 

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

499 """ 

500 if self._syntactically_valid is None: 

501 # Cache it 

502 if not self.tokens: 

503 self._syntactically_valid = False 

504 else: 

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

506 # it's gone wrong. 

507 pip = PatientInfoPresence() 

508 value = self._value_for_pip(pip) 

509 self._syntactically_valid = value is not Q_ERROR 

510 return self._syntactically_valid 

511 

512 def is_valid( 

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

514 ) -> bool: 

515 """ 

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

517 database? 

518 

519 Args: 

520 valid_idnums: optional list of valid ID number types 

521 verbose: report reasons to debug log 

522 """ 

523 if valid_idnums is not None: 

524 self.set_valid_idnums(valid_idnums) 

525 if self._valid_for_idnums is None: 

526 # Cache information 

527 self.require_valid_idnum_info() 

528 self._valid_for_idnums = self.is_valid_for_idnums( 

529 self.valid_idnums, verbose=verbose 

530 ) 

531 return self._valid_for_idnums 

532 

533 def is_valid_for_idnums( 

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

535 ) -> bool: 

536 """ 

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

538 

539 Checks the following: 

540 

541 - valid syntax 

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

543 - is compatible with the tablet ID policy 

544 

545 Args: 

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

547 verbose: report reasons to debug log 

548 """ 

549 # First, syntax: 

550 if not self.is_syntactically_valid(): 

551 if verbose: 

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

553 return False 

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

555 for token in self.tokens: 

556 if token > 0 and token not in valid_idnums: 

557 if verbose: 

558 log.debug( 

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

560 "{}, which does not exist", 

561 token, 

562 ) 

563 return False 

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

565 if verbose: 

566 log.debug( 

567 "is_valid_for_idnums(): Less restrictive than the " 

568 "tablet minimum ID policy; invalid" 

569 ) 

570 return False 

571 return True 

572 

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

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

575 # ------------------------------------------------------------------------- 

576 

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

578 """ 

579 Which ID numbers are relevant to this policy? 

580 

581 Args: 

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

583 

584 Returns: 

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

586 policy 

587 """ 

588 if not self.tokens: 

589 return [] 

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

591 # all are relevant 

592 return valid_idnums 

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

594 for which_idnum in valid_idnums: 

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

596 if which_idnum in self.tokens: 

597 relevant_idnums.append(which_idnum) 

598 return relevant_idnums 

599 

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

601 """ 

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

603 policy, as a list. 

604 """ 

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

606 

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

608 """ 

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

610 

611 Args: 

612 which_idnum: ID number type to test 

613 """ 

614 assert which_idnum > 0 

615 return which_idnum in self.tokens 

616 

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

618 # More complex attributes 

619 # ------------------------------------------------------------------------- 

620 

621 def find_critical_single_numerical_id( 

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

623 ) -> Optional[int]: 

624 """ 

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

626 number; otherwise return None. 

627 

628 Args: 

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

630 verbose: report reasons to debug log 

631 

632 Returns: 

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

634 """ 

635 if not self.is_valid(valid_idnums): 

636 if verbose: 

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

638 return None 

639 relevant_idnums = self.specifically_mentioned_idnums() 

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

641 for which_idnum in relevant_idnums: 

642 pip_with = PatientInfoPresence.make_uncaring_except( 

643 which_idnum, Q_TRUE 

644 ) 

645 satisfies_with_1 = self._value_for_pip(pip_with) 

646 pip_with.present[TK_OTHER_IDNUM] = Q_FALSE 

647 satisfies_with_2 = self._value_for_pip(pip_with) 

648 pip_without = PatientInfoPresence.make_uncaring_except( 

649 which_idnum, Q_FALSE 

650 ) 

651 satisfies_without_1 = self._value_for_pip(pip_without) 

652 pip_with.present[TK_OTHER_IDNUM] = Q_TRUE 

653 satisfies_without_2 = self._value_for_pip(pip_without) 

654 if verbose: 

655 log.debug( 

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

657 "satisfies_without_2={}", 

658 which_idnum, 

659 satisfies_with_1, 

660 satisfies_without_1, 

661 satisfies_without_2, 

662 ) 

663 if ( 

664 satisfies_with_1 is Q_TRUE 

665 and satisfies_with_2 is Q_TRUE 

666 and satisfies_without_1 is Q_FALSE 

667 and satisfies_without_2 is Q_FALSE 

668 ): 

669 possible_critical_idnums.append(which_idnum) 

670 if verbose: 

671 log.debug( 

672 "find_critical_single_numerical_id(): " 

673 "possible_critical_idnums = {}", 

674 possible_critical_idnums, 

675 ) 

676 if len(possible_critical_idnums) == 1: 

677 return possible_critical_idnums[0] 

678 return None 

679 

680 def is_idnum_mandatory_in_policy( 

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

682 ) -> bool: 

683 """ 

684 Is the ID number mandatory in the specified policy? 

685 

686 Args: 

687 which_idnum: ID number type to test 

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

689 verbose: report reasons to debug log 

690 """ 

691 if which_idnum is None or which_idnum < 1: 

692 if verbose: 

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

694 return False 

695 if not self.contains_specific_idnum(which_idnum): 

696 if verbose: 

697 log.debug( 

698 "is_idnum_mandatory_in_policy(): policy does not " 

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

700 which_idnum, 

701 ) 

702 return False 

703 self.set_valid_idnums(valid_idnums) 

704 if not self.is_valid(): 

705 if verbose: 

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

707 return False 

708 

709 pip_with = PatientInfoPresence.make_uncaring_except( 

710 which_idnum, Q_TRUE 

711 ) 

712 satisfies_with = self._value_for_pip(pip_with) 

713 if satisfies_with != Q_TRUE: 

714 if verbose: 

715 log.debug( 

716 "is_idnum_mandatory_in_policy(): policy not " 

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

718 which_idnum, 

719 ) 

720 return False 

721 pip_without = PatientInfoPresence.make_uncaring_except( 

722 which_idnum, Q_FALSE 

723 ) 

724 satisfies_without = self._value_for_pip(pip_without) 

725 if satisfies_without != Q_FALSE: 

726 if verbose: 

727 log.debug( 

728 "is_idnum_mandatory_in_policy(): policy satisfied " 

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

730 which_idnum, 

731 ) 

732 return False 

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

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

735 if verbose: 

736 log.debug( 

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

738 which_idnum, 

739 ) 

740 return True 

741 

742 def _requires_prohibits( 

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

744 ) -> Tuple[bool, bool]: 

745 """ 

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

747 

748 Args: 

749 token: token to check 

750 verbose: report reasons to debug log 

751 

752 Returns: 

753 tuple: requires, prohibits 

754 """ 

755 pip_with = PatientInfoPresence.make_uncaring_except(token, Q_TRUE) 

756 satisfies_with = self._value_for_pip(pip_with) 

757 pip_without = PatientInfoPresence.make_uncaring_except(token, Q_FALSE) 

758 satisfies_without = self._value_for_pip(pip_without) 

759 requires = satisfies_with is Q_TRUE and satisfies_without is Q_FALSE 

760 prohibits = satisfies_with is Q_FALSE and satisfies_without is Q_TRUE 

761 if verbose: 

762 log.debug( 

763 "_requires_prohibits({t}): " 

764 "satisfies_with={sw}, " 

765 "satisfies_without={swo}, " 

766 "requires={r}, " 

767 "prohibits={p}", 

768 t=token_to_str(token), 

769 sw=satisfies_with, 

770 swo=satisfies_without, 

771 r=requires, 

772 p=prohibits, 

773 ) 

774 return requires, prohibits 

775 

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

777 """ 

778 Does this policy require sex to be present? 

779 

780 Args: 

781 verbose: report reasons to debug log 

782 """ 

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

784 return requires 

785 

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

787 """ 

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

789 

790 Args: 

791 verbose: report reasons to debug log 

792 """ 

793 if verbose: 

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

795 for token in self.specifically_mentioned_idnums() + [ 

796 TK_ANY_IDNUM, 

797 TK_OTHER_IDNUM, 

798 ]: 

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

800 if requires: 

801 if verbose: 

802 log.debug( 

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

804 ) 

805 return True 

806 return False 

807 

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

809 # valid_idnums: List[int], 

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

811 # """ 

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

813 # 

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

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

816 # 

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

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

819 # 

820 # Args: 

821 # other: the other policy 

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

823 # verbose: report reasons to debug log 

824 # 

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

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

827 # """ 

828 # if verbose: 

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

830 # self, other) 

831 # possible_tokens = valid_idnums + NON_IDNUM_INFO_TOKENS 

832 # for token in possible_tokens: 

833 # # Self 

834 # self_requires, self_prohibits = self._requires_prohibits( 

835 # token, valid_idnums) 

836 # # Other 

837 # pip_with = PatientInfoPresence.make_uncaring_except( 

838 # token, Q_TRUE, valid_idnums) 

839 # other_satisfies_with = other._value_for_pip(pip_with) 

840 # pip_without = PatientInfoPresence.make_uncaring_except( 

841 # token, Q_FALSE, valid_idnums) 

842 # other_satisfies_without_1 = other._value_for_pip(pip_without) 

843 # pip_without.special_anyidnum_present = Q_TRUE 

844 # other_satisfies_without_2 = other._value_for_pip(pip_without) 

845 # other_requires = ( 

846 # other_satisfies_with is Q_TRUE and 

847 # other_satisfies_without_1 is Q_FALSE and 

848 # other_satisfies_without_2 is Q_FALSE 

849 # ) 

850 # other_prohibits = ( 

851 # other_satisfies_with is Q_FALSE and 

852 # other_satisfies_without_1 is Q_TRUE and 

853 # other_satisfies_without_2 is Q_TRUE 

854 # ) 

855 # if verbose: 

856 # log.debug( 

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

858 # "self_requires={sr}, " 

859 # "self_prohibits={sp}, " 

860 # "other_satisfies_with={osw}, " 

861 # "other_satisfies_without_1={oswo1}, " 

862 # "other_satisfies_without_2={oswo2}, " 

863 # "other_requires={or_}", 

864 # "other_prohibits={op}", 

865 # t=token_to_str(token), 

866 # sr=self_requires, 

867 # sp=self_prohibits, 

868 # osw=other_satisfies_with, 

869 # oswo1=other_satisfies_without_1, 

870 # oswo2=other_satisfies_without_2, 

871 # or_=other_requires, 

872 # op=other_prohibits, 

873 # ) 

874 # 

875 # if other_requires and not self_requires: 

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

877 # if verbose: 

878 # log.debug( 

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

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

881 # token) 

882 # return True 

883 # # if self_prohibits and not other_prohibits: 

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

885 # # if verbose: 

886 # # log.debug( 

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

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

889 # # token) 

890 # # return True 

891 # if verbose: 

892 # log.debug( 

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

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

895 # self, other 

896 # ) 

897 # return False 

898 

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

900 """ 

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

902 

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

904 policy, but may not be LESS restrictive. 

905 

906 Args: 

907 verbose: report reasons to debug log 

908 

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

910 """ 

911 # Method 1: abandoned. 

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

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

914 # got added. 

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

916 # yet. 

917 # 

918 # return not self._less_restrictive_than( 

919 # TABLET_ID_POLICY, valid_idnums, verbose=verbose) 

920 

921 # Method 2: manual. 

922 if verbose: 

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

924 requires_sex = self._requires_sex(verbose=verbose) 

925 if requires_sex: 

926 if verbose: 

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

928 else: 

929 if verbose: 

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

931 return False 

932 requires_an_idnum = self._requires_an_idnum(verbose=verbose) 

933 if requires_an_idnum: 

934 if verbose: 

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

936 return True 

937 if verbose: 

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

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

940 for token in other_mandatory: 

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

942 if not requires: 

943 if verbose: 

944 log.debug( 

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

946 token_to_str(token), 

947 ) 

948 return False 

949 log.debug( 

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

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

952 ) 

953 return True 

954 

955 def compatible_with_tablet_id_policy( 

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

957 ) -> bool: 

958 """ 

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

960 

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

962 policy, but may not be LESS restrictive. 

963 

964 Args: 

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

966 verbose: report reasons to debug log 

967 """ 

968 self.set_valid_idnums(valid_idnums) 

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

970 return False 

971 return self._compatible_with_tablet_id_policy(verbose=verbose) 

972 

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

974 # Check if a patient satisfies the policy 

975 # ------------------------------------------------------------------------- 

976 

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

978 """ 

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

980 

981 Args: 

982 ptinfo: 

983 a `camcops_server.cc_modules.cc_simpleobjects.BarePatientInfo` 

984 

985 Returns: 

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

987 """ 

988 pip = PatientInfoPresence.make_from_ptinfo( 

989 ptinfo, self.specifically_mentioned_idnums() 

990 ) 

991 return self._value_for_pip(pip) 

992 

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

994 """ 

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

996 object? 

997 

998 Args: 

999 pip: 

1000 a `camcops_server.cc_modules.cc_simpleobjects.PatientInfoPresence` 

1001 

1002 Returns: 

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

1004 """ # noqa 

1005 

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

1007 return self._element_value_test_pip(pip, token) 

1008 

1009 return self._chunk_value( 

1010 self.tokens, content_token_processor=content_token_processor 

1011 ) 

1012 # ... which is recursive 

1013 

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

1015 """ 

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

1017 

1018 Args: 

1019 ptinfo: 

1020 a `camcops_server.cc_modules.cc_simpleobjects.BarePatientInfo` 

1021 """ 

1022 return self._value_for_ptinfo(ptinfo) is Q_TRUE 

1023 

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

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

1026 # ------------------------------------------------------------------------- 

1027 

1028 def _chunk_value( 

1029 self, 

1030 tokens: TOKENIZED_POLICY_TYPE, 

1031 content_token_processor: CONTENT_TOKEN_PROCESSOR_TYPE, 

1032 ) -> QuadState: 

1033 """ 

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

1035 Can be used recursively. 

1036 

1037 Args: 

1038 tokens: 

1039 a tokenized policy 

1040 content_token_processor: 

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

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

1043 

1044 Returns: 

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

1046 """ 

1047 want_content = True 

1048 processing_and = False 

1049 processing_or = False 

1050 index = 0 

1051 value = None # type: Optional[QuadState] 

1052 while index < len(tokens): 

1053 if want_content: 

1054 nextchunk, index = self._content_chunk_value( 

1055 tokens, index, content_token_processor 

1056 ) 

1057 if nextchunk is Q_ERROR: 

1058 return Q_ERROR # fail 

1059 if value is None: 

1060 value = nextchunk 

1061 elif processing_and: 

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

1063 # implement logical AND 

1064 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

1065 value = quad_and(value, nextchunk) 

1066 elif processing_or: 

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

1068 # implement logical OR 

1069 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

1070 value = quad_or(value, nextchunk) 

1071 else: 

1072 # Error; shouldn't get here 

1073 return Q_ERROR 

1074 processing_and = False 

1075 processing_or = False 

1076 else: 

1077 # Want operator 

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

1079 if operator is None: 

1080 return Q_ERROR # fail 

1081 if operator == TK_AND: 

1082 processing_and = True 

1083 elif operator == TK_OR: 

1084 processing_or = True 

1085 else: 

1086 # Error; shouldn't get here 

1087 return Q_ERROR 

1088 want_content = not want_content 

1089 if want_content: 

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

1091 return Q_ERROR 

1092 return value 

1093 

1094 def _content_chunk_value( 

1095 self, 

1096 tokens: TOKENIZED_POLICY_TYPE, 

1097 start: int, 

1098 content_token_processor: CONTENT_TOKEN_PROCESSOR_TYPE, 

1099 ) -> Tuple[QuadState, int]: 

1100 """ 

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

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

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

1104 (q.v.). 

1105 

1106 Args: 

1107 tokens: 

1108 a tokenized policy (list of integers) 

1109 start: 

1110 zero-based index of the first token to check 

1111 content_token_processor: 

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

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

1114 

1115 Returns: 

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

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

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

1119 index of the next token after this chunk. 

1120 

1121 """ 

1122 if start >= len(tokens): 

1123 log.debug( 

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

1125 ) 

1126 return Q_ERROR, start 

1127 token = tokens[start] 

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

1129 log.debug( 

1130 "_content_chunk_value(): " 

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

1132 ) 

1133 return Q_ERROR, start 

1134 elif token == TK_LPAREN: 

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

1136 # implement parentheses 

1137 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

1138 subchunkstart = start + 1 # exclude the opening bracket 

1139 # Find closing parenthesis 

1140 depth = 1 

1141 searchidx = subchunkstart 

1142 while depth > 0: 

1143 if searchidx >= len(tokens): 

1144 log.debug( 

1145 "_content_chunk_value(): " 

1146 "Unmatched left parenthesis; bad policy" 

1147 ) 

1148 return Q_ERROR, start 

1149 elif tokens[searchidx] == TK_LPAREN: 

1150 depth += 1 

1151 elif tokens[searchidx] == TK_RPAREN: 

1152 depth -= 1 

1153 searchidx += 1 

1154 subchunkend = searchidx - 1 

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

1156 chunk_value = self._chunk_value( 

1157 tokens[subchunkstart:subchunkend], content_token_processor 

1158 ) 

1159 return ( 

1160 chunk_value, 

1161 subchunkend + 1, 

1162 ) # to move past the closing bracket # noqa 

1163 elif token == TK_NOT: 

1164 next_value, next_index = self._content_chunk_value( 

1165 tokens, start + 1, content_token_processor 

1166 ) 

1167 if next_value is Q_ERROR: 

1168 return next_value, start 

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

1170 # implement logical NOT 

1171 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

1172 return quad_not(next_value), next_index 

1173 else: 

1174 # meaningful token 

1175 return content_token_processor(token), start + 1 

1176 

1177 @classmethod 

1178 def _op( 

1179 cls, policy: TOKENIZED_POLICY_TYPE, start: int 

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

1181 """ 

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

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

1184 

1185 policy: 

1186 a tokenized policy (list of integers) 

1187 start: 

1188 zero-based index of the first token to check 

1189 

1190 Returns: 

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

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

1193 the policy to check at. 

1194 """ 

1195 if start >= len(policy): 

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

1197 return None, start 

1198 token = policy[start] 

1199 if token in (TK_AND, TK_OR): 

1200 return token, start + 1 

1201 else: 

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

1203 # Not an operator 

1204 return None, start 

1205 

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

1207 

1208 @staticmethod 

1209 def _element_value_test_pip( 

1210 pip: PatientInfoPresence, token: TOKEN_TYPE 

1211 ) -> QuadState: 

1212 """ 

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

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

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

1216 

1217 Args: 

1218 pip: 

1219 a `camcops_server.cc_modules.cc_simpleobjects.PatientInfoPresence` 

1220 token: 

1221 an integer token from the policy 

1222 

1223 Returns: 

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

1225 """ # noqa 

1226 assert is_info_token(token) 

1227 if token == TK_ANY_IDNUM: 

1228 return pip.any_idnum_present() 

1229 else: 

1230 return pip.is_present(token) 

1231 

1232 

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

1234# Tablet ID policy 

1235# ============================================================================= 

1236 

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

1238TABLET_ID_POLICY = TokenizedPolicy(TABLET_ID_POLICY_STR)