Coverage for cc_modules/cc_policy.py: 28%

1"""

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

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

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

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

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

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

33

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

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

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

37 configuration).

38

39"""

40

41 import io

42 import logging

43 import tokenize

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

45

46 from cardinal_pythonlib.dicts import reversedict

47 from cardinal_pythonlib.logs import BraceStyleAdapter

48 from cardinal_pythonlib.reprfunc import auto_repr

49

50 from camcops_server.cc_modules.cc_simpleobjects import BarePatientInfo

51

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

53

54

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

56# Tokens

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

58

59 TOKEN_TYPE = int

60 TOKENIZED_POLICY_TYPE = List[TOKEN_TYPE]

61

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

63 BAD_TOKEN = 0

64 TK_LPAREN = -1

65 TK_RPAREN = -2

66 TK_AND = -3

67 TK_OR = -4

68 TK_NOT = -5

69 TK_ANY_IDNUM = -6

70 TK_OTHER_IDNUM = -7

71 TK_FORENAME = -8

72 TK_SURNAME = -9

73 TK_SEX = -10

74 TK_DOB = -11

75 TK_ADDRESS = -12

76 TK_GP = -13

77 TK_OTHER_DETAILS = -14

78 TK_EMAIL = -15

79

80# Tokens for ID numbers are from 1 upwards.

81

82 POLICY_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}

99 TOKEN_POLICY_DICT = reversedict(POLICY_TOKEN_DICT)

100

101 NON_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

114 TOKEN_IDNUM_PREFIX = "IDNUM"

115

116

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

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

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

152 Q_TRUE = QuadState()

153 Q_FALSE = QuadState()

154 Q_ERROR = QuadState()

155 Q_DONT_CARE = QuadState()

156

157

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

159 return Q_TRUE if x else Q_FALSE

160

161

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

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

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

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

217 DEBUG_QUAD_STATE_LOGIC = False

218

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

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

385 CONTENT_TOKEN_PROCESSOR_TYPE = Callable[[int], QuadState]

386

387

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

389# TokenizedPolicy

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

391

392

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

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

1236 TABLET_ID_POLICY = TokenizedPolicy(TABLET_ID_POLICY_STR)