#!/usr/bin/env python
# camcops_server/cc_modules/cc_policy.py
"""
===============================================================================
Copyright (C) 2012-2018 Rudolf Cardinal (rudolf@pobox.com).
This file is part of CamCOPS.
CamCOPS is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
CamCOPS is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with CamCOPS. If not, see <http://www.gnu.org/licenses/>.
===============================================================================
Note that the upload script should NOT attempt to verify patients
against the ID policy, not least because tablets are allowed to upload
task data (in a separate transaction) before uploading patients;
referential integrity would be very hard to police. So the tablet software
deals with ID compliance. (Also, the superuser can change the server's ID
policy retrospectively!)
"""
import io
import logging
import tokenize
from typing import List, Optional, Tuple, TYPE_CHECKING
from cardinal_pythonlib.logs import BraceStyleAdapter
from pendulum import Date
from .cc_simpleobjects import BarePatientInfo, IdNumReference
from .cc_unittest import ExtendedTestCase
if TYPE_CHECKING:
from .cc_request import CamcopsRequest
log = BraceStyleAdapter(logging.getLogger(__name__))
# =============================================================================
# TokenizedPolicy
# =============================================================================
TOKEN_TYPE = int
TOKENIZED_POLICY_TYPE = List[TOKEN_TYPE]
# http://stackoverflow.com/questions/36932
BAD_TOKEN = 0
TK_LPAREN = -1
TK_RPAREN = -2
TK_AND = -3
TK_OR = -4
TK_FORENAME = -5
TK_SURNAME = -6
TK_DOB = -7
TK_SEX = -8
TK_ANY_IDNUM = -9
# Tokens for ID numbers are from 1 upwards.
POLICY_TOKEN_DICT = {
"(": TK_LPAREN,
")": TK_RPAREN,
"AND": TK_AND,
"OR": TK_OR,
"FORENAME": TK_FORENAME,
"SURNAME": TK_SURNAME,
"DOB": TK_DOB,
"SEX": TK_SEX,
"ANYIDNUM": TK_ANY_IDNUM,
}
TOKEN_IDNUM_PREFIX = "IDNUM"
class TokenizedPolicy(object):
def __init__(self, policy: str) -> None:
self.tokens = self.get_tokenized_id_policy(policy)
@staticmethod
def name_to_token(name: str) -> int:
if name in POLICY_TOKEN_DICT:
return POLICY_TOKEN_DICT[name]
if name.startswith(TOKEN_IDNUM_PREFIX):
nstr = name[len(TOKEN_IDNUM_PREFIX):]
try:
return int(nstr)
except (TypeError, ValueError):
return BAD_TOKEN
return BAD_TOKEN
@classmethod
def get_tokenized_id_policy(cls, policy: str) \
-> TOKENIZED_POLICY_TYPE:
"""
Takes a string policy and returns a tokenized policy, or [].
"""
if policy is None:
return []
# http://stackoverflow.com/questions/88613
string_index = 1
try:
tokenstrings = list(
token[string_index]
for token in tokenize.generate_tokens(
io.StringIO(policy.upper()).readline)
if token[string_index]
)
except tokenize.TokenError:
# something went wrong
return []
tokens = [cls.name_to_token(k) for k in tokenstrings]
if any(t == BAD_TOKEN for t in tokens):
# There's something bad in there.
return []
return tokens
def is_syntactically_valid(self) -> bool:
return bool(self.tokens)
def is_valid_from_req(self, req: "CamcopsRequest") -> bool:
return self.is_valid(req.valid_which_idnums)
def is_valid(self, valid_idnums: List[int]) -> bool:
# First, syntax:
if not self.is_syntactically_valid():
return False
# Second, all ID numbers referred to by the policy exist:
for token in self.tokens:
if token > 0 and token not in valid_idnums:
return False
return True
def find_critical_single_numerical_id_from_req(
self, req: "CamcopsRequest") -> Optional[int]:
return self.find_critical_single_numerical_id(req.valid_which_idnums)
def find_critical_single_numerical_id(
self,
valid_which_idnums: List[int]) -> Optional[int]:
"""
If the policy involves a single mandatory ID number, return that ID
number; otherwise return None.
"""
# This method is a bit silly, but it should work.
if not self.tokens:
return None
successes = 0
critical_idnum = None
for n in valid_which_idnums:
dummyptinfo = BarePatientInfo(
forename="X",
surname="X",
dob=Date.today(), # random value
sex="X",
idnum_definitions=[IdNumReference(which_idnum=n,
idnum_value=1)]
)
# Set the idnum of interest
if self.satisfies_id_policy(dummyptinfo):
successes += 1
critical_idnum = n
if successes == 1:
return critical_idnum
else:
return None
# e.g. if no ID numbers are required, or if more than
# one ID number satisfies the requirement.
def is_idnum_mandatory_in_policy(
self,
which_idnum: int,
valid_which_idnums: List[int]) -> bool:
"""
Is the ID number mandatory in the specified policy?
"""
if which_idnum is None or which_idnum < 1:
return False
# A hacky way...
dummyptinfo = BarePatientInfo(
forename="X",
surname="X",
dob=Date.today(), # random value
sex="X",
idnum_definitions=[
IdNumReference(which_idnum=n, idnum_value=1)
for n in valid_which_idnums
if n != which_idnum
]
)
# ... so now everything but the idnum in question is set
if self.satisfies_id_policy(dummyptinfo):
return False # because that means it wasn't mandatory
return True
def satisfies_id_policy(self, ptinfo: BarePatientInfo) -> bool:
"""
Does the patient information in ptinfo satisfy the specified ID policy?
"""
return bool(self.id_policy_chunk(self.tokens, ptinfo))
# ... which is recursive
@classmethod
def id_policy_chunk(cls,
policy: TOKENIZED_POLICY_TYPE,
ptinfo: BarePatientInfo) -> Optional[bool]:
"""
Applies the policy to the patient info in ptinfo.
Can be used recursively.
Args:
policy: a tokenized policy
ptinfo: an instance of BarePatientInfo
"""
want_content = True
processing_and = False
processing_or = False
index = 0
value = None
while index < len(policy):
# log.debug("index:" + str(index) + ", want_content:"
# + str(want_content) + ", policy:" + str(policy))
if want_content:
(nextchunk, index) = cls.id_policy_content(policy, ptinfo,
index)
# log.debug("nextchunk:" + str(nextchunk))
if nextchunk is None:
return None # fail
if value is None:
value = nextchunk
elif processing_and:
value = value and nextchunk
elif processing_or:
value = value or nextchunk
else:
# Error; shouldn't get here
return None
processing_and = False
processing_or = False
else:
# Want operator
(operator, index) = cls.id_policy_op(policy, index)
# log.debug("operator:" + str(operator))
if operator is None:
return None # fail
if operator == TK_AND:
processing_and = True
elif operator == TK_OR:
processing_or = True
else:
# Error; shouldn't get here
return None
want_content = not want_content
if value is None or want_content:
# log.debug("id_policy_chunk returning None")
return None
# log.debug("id_policy_chunk returning " + str(value))
return value
@classmethod
def id_policy_content(cls,
policy: TOKENIZED_POLICY_TYPE,
ptinfo: BarePatientInfo,
start: int) -> Tuple[Optional[bool], int]:
"""
Applies part of a policy to ptinfo. Called by id_policy_chunk (q.v.).
"""
if start >= len(policy):
return None, start
token = policy[start]
if token in [TK_RPAREN, TK_AND, TK_OR]:
# Chunks mustn't start with these; bad policy
return None, start
elif token == TK_LPAREN:
subchunkstart = start + 1 # exclude the opening bracket
# Find closing parenthesis
depth = 1
searchidx = subchunkstart
while depth > 0:
if searchidx >= len(policy):
# unmatched left parenthesis; bad policy
return None, start
elif policy[searchidx] == TK_LPAREN:
depth += 1
elif policy[searchidx] == TK_RPAREN:
depth -= 1
searchidx += 1
subchunkend = searchidx - 1
# ... to exclude the closing bracket from the analysed subchunk
chunk = cls.id_policy_chunk(policy[subchunkstart:subchunkend],
ptinfo)
return chunk, subchunkend + 1 # to move past the closing bracket
else:
# meaningful token
return cls.id_policy_element(ptinfo, token), start + 1
@classmethod
def id_policy_op(cls, policy: TOKENIZED_POLICY_TYPE, start: int) \
-> Tuple[Optional[TOKEN_TYPE], int]:
"""Returns an operator from the policy, or None."""
if start >= len(policy):
return None, start
token = policy[start]
if token in [TK_AND, TK_OR]:
return token, start + 1
else:
# Not an operator
return None, start
@classmethod
def id_policy_element(cls, ptinfo: BarePatientInfo, token: TOKEN_TYPE) \
-> Optional[bool]:
"""
Returns a Boolean corresponding to whether the token's information is
present in the ptinfo.
"""
if token == TK_FORENAME:
return ptinfo.forename is not None
if token == TK_SURNAME:
return ptinfo.surname is not None
if token == TK_DOB:
return ptinfo.dob is not None
if token == TK_SEX:
return ptinfo.sex is not None
if token == TK_ANY_IDNUM:
for idnumdef in ptinfo.idnum_definitions:
if idnumdef.idnum_value is not None:
return True
return False
if token > 0: # ID token
for iddef in ptinfo.idnum_definitions:
if (iddef.which_idnum == token and
iddef.idnum_value is not None):
return True
return False
return None
# =============================================================================
# Unit tests
# =============================================================================
[docs]class PolicyTests(ExtendedTestCase):
def test_policies(self) -> None:
self.announce("test_policies")
empty = ""
bad1 = "sex AND (failure"
good1 = "sex AND idnum1"
test_idnums = [
None,
-1,
1
]
valid_which_idnums = [1, 2, 3]
bpi = BarePatientInfo(
forename="forename",
surname="surname",
dob=Date.today(), # random value
sex="sex",
idnum_definitions=[
IdNumReference(1, 1),
IdNumReference(10, 3),
],
)
for policy_string in [empty, bad1, good1]:
p = TokenizedPolicy(policy_string)
self.assertIsInstance(p.is_syntactically_valid(), bool)
self.assertIsInstance(p.is_valid(valid_idnums=valid_which_idnums),
bool)
self.assertIsInstanceOrNone(p.find_critical_single_numerical_id(
valid_which_idnums=valid_which_idnums), int)
for which_idnum in test_idnums:
self.assertIsInstance(p.is_idnum_mandatory_in_policy(
which_idnum=which_idnum,
valid_which_idnums=valid_which_idnums), bool)
self.assertIsInstance(p.satisfies_id_policy(bpi), bool)
if policy_string == good1:
self.assertEqual(p.find_critical_single_numerical_id(
valid_which_idnums=valid_which_idnums), 1)