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
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
1"""
2camcops_server/cc_modules/cc_policy.py
4===============================================================================
6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
9 This file is part of CamCOPS.
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.
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.
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/>.
24===============================================================================
26**Represents ID number policies.**
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!)
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).
39"""
41import io
42import logging
43import tokenize
44from typing import Any, Callable, Dict, List, Optional, Tuple
46from cardinal_pythonlib.dicts import reversedict
47from cardinal_pythonlib.logs import BraceStyleAdapter
48from cardinal_pythonlib.reprfunc import auto_repr
50from camcops_server.cc_modules.cc_simpleobjects import BarePatientInfo
52log = BraceStyleAdapter(logging.getLogger(__name__))
55# =============================================================================
56# Tokens
57# =============================================================================
59TOKEN_TYPE = int
60TOKENIZED_POLICY_TYPE = List[TOKEN_TYPE]
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
80# Tokens for ID numbers are from 1 upwards.
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)
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]
114TOKEN_IDNUM_PREFIX = "IDNUM"
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
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)
135# =============================================================================
136# Quad-state logic
137# =============================================================================
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"
152Q_TRUE = QuadState()
153Q_FALSE = QuadState()
154Q_ERROR = QuadState()
155Q_DONT_CARE = QuadState()
158def bool_to_quad(x: bool) -> QuadState:
159 return Q_TRUE if x else Q_FALSE
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
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
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
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
214 return wrap
217DEBUG_QUAD_STATE_LOGIC = False
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")
225# =============================================================================
226# PatientInfoPresence
227# =============================================================================
230class PatientInfoPresence(object):
231 """
232 Represents simply the presence/absence of different kinds of information
233 about a patient.
234 """
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)
251 def __repr__(self) -> str:
252 return auto_repr(self)
254 def is_present(self, token: int, default: QuadState = None) -> QuadState:
255 """
256 Is information represented by a particular token present?
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.
263 Returns:
264 a :class:`QuadState`
265 """
266 return self.present.get(token, default or self.default)
268 @property
269 def forename_present(self) -> QuadState:
270 return self.is_present(TK_FORENAME)
272 @property
273 def surname_present(self) -> QuadState:
274 return self.is_present(TK_SURNAME)
276 @property
277 def sex_present(self) -> QuadState:
278 return self.is_present(TK_SEX)
280 @property
281 def dob_present(self) -> QuadState:
282 return self.is_present(TK_DOB)
284 @property
285 def address_present(self) -> QuadState:
286 return self.is_present(TK_ADDRESS)
288 @property
289 def email_present(self) -> QuadState:
290 return self.is_present(TK_EMAIL)
292 @property
293 def gp_present(self) -> QuadState:
294 return self.is_present(TK_GP)
296 @property
297 def otherdetails_present(self) -> QuadState:
298 return self.is_present(TK_OTHER_DETAILS)
300 @property
301 def otheridnum_present(self) -> QuadState:
302 return self.is_present(TK_OTHER_IDNUM)
304 @property
305 def special_anyidnum_present(self) -> QuadState:
306 return self.is_present(TK_ANY_IDNUM)
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)
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
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)
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)
357 def set_idnum_presence(self, which_idnum: int, present: QuadState) -> None:
358 """
359 Set the "presence" state for one ID number type.
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
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
381# =============================================================================
382# More constants
383# =============================================================================
385CONTENT_TOKEN_PROCESSOR_TYPE = Callable[[int], QuadState]
388# =============================================================================
389# TokenizedPolicy
390# =============================================================================
393class TokenizedPolicy(object):
394 """
395 Represents a tokenized ID policy.
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 """
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]
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
415 # -------------------------------------------------------------------------
416 # ID number info
417 # -------------------------------------------------------------------------
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.
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
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}"
441 # -------------------------------------------------------------------------
442 # Tokenize
443 # -------------------------------------------------------------------------
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
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
490 # -------------------------------------------------------------------------
491 # Validity checks
492 # -------------------------------------------------------------------------
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
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?
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
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?
537 Checks the following:
539 - valid syntax
540 - refers only to ID number types defined on the server
541 - is compatible with the tablet ID policy
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
571 # -------------------------------------------------------------------------
572 # Information about the ID number types the policy refers to
573 # -------------------------------------------------------------------------
575 def relevant_idnums(self, valid_idnums: List[int]) -> List[int]:
576 """
577 Which ID numbers are relevant to this policy?
579 Args:
580 valid_idnums: ID number types that are valid on the server
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
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]
605 def contains_specific_idnum(self, which_idnum: int) -> bool:
606 """
607 Does the policy refer specifically to the given ID number type?
609 Args:
610 which_idnum: ID number type to test
611 """
612 assert which_idnum > 0
613 return which_idnum in self.tokens
615 # -------------------------------------------------------------------------
616 # More complex attributes
617 # -------------------------------------------------------------------------
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.
626 Args:
627 valid_idnums: ID number types that are valid on the server
628 verbose: report reasons to debug log
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
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?
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
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
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?
746 Args:
747 token: token to check
748 verbose: report reasons to debug log
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
774 def _requires_sex(self, verbose: bool = False) -> bool:
775 """
776 Does this policy require sex to be present?
778 Args:
779 verbose: report reasons to debug log
780 """
781 requires, _ = self._requires_prohibits(TK_SEX, verbose=verbose)
782 return requires
784 def _requires_an_idnum(self, verbose: bool = False) -> bool:
785 """
786 Does this policy require an ID number to be present?
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
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
897 def _compatible_with_tablet_id_policy(self, verbose: bool = False) -> bool:
898 """
899 Is this policy compatible with :data:`TABLET_ID_POLICY`?
901 The "self" policy may be MORE restrictive than the tablet minimum ID
902 policy, but may not be LESS restrictive.
904 Args:
905 verbose: report reasons to debug log
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)
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
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`?
959 The "self" policy may be MORE restrictive than the tablet minimum ID
960 policy, but may not be LESS restrictive.
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)
971 # -------------------------------------------------------------------------
972 # Check if a patient satisfies the policy
973 # -------------------------------------------------------------------------
975 def _value_for_ptinfo(self, ptinfo: BarePatientInfo) -> QuadState:
976 """
977 What does the policy evaluate to for a given patient info object?
979 Args:
980 ptinfo:
981 a `camcops_server.cc_modules.cc_simpleobjects.BarePatientInfo`
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)
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?
996 Args:
997 pip:
998 a `camcops_server.cc_modules.cc_simpleobjects.PatientInfoPresence`
1000 Returns:
1001 a :class:`QuadState` quad-state value
1002 """ # noqa
1004 def content_token_processor(token: int) -> QuadState:
1005 return self._element_value_test_pip(pip, token)
1007 return self._chunk_value(
1008 self.tokens, content_token_processor=content_token_processor
1009 )
1010 # ... which is recursive
1012 def satisfies_id_policy(self, ptinfo: BarePatientInfo) -> bool:
1013 """
1014 Does the patient information in ptinfo satisfy the specified ID policy?
1016 Args:
1017 ptinfo:
1018 a `camcops_server.cc_modules.cc_simpleobjects.BarePatientInfo`
1019 """
1020 return self._value_for_ptinfo(ptinfo) is Q_TRUE
1022 # -------------------------------------------------------------------------
1023 # Functions for the policy to parse itself and compare itself to a patient
1024 # -------------------------------------------------------------------------
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.
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
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
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.).
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
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.
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
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.
1183 policy:
1184 a tokenized policy (list of integers)
1185 start:
1186 zero-based index of the first token to check
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
1204 # Things to do with content tokens 1: are they present in patient info?
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``.
1215 Args:
1216 pip:
1217 a `camcops_server.cc_modules.cc_simpleobjects.PatientInfoPresence`
1218 token:
1219 an integer token from the policy
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)
1231# =============================================================================
1232# Tablet ID policy
1233# =============================================================================
1235TABLET_ID_POLICY_STR = "sex AND ((forename AND surname AND dob) OR anyidnum)"
1236TABLET_ID_POLICY = TokenizedPolicy(TABLET_ID_POLICY_STR)