/corehq/motech/dhis2/finders.py

https://github.com/dimagi/commcare-hq · Python · 128 lines · 94 code · 22 blank · 12 comment · 18 complexity · 31d853b5bfc05728ec30eb6e6f3ebdce MD5 · raw file

  1. from collections import namedtuple
  2. from functools import partial
  3. from django.utils.translation import gettext as _
  4. from memoized import memoized_property
  5. from requests import HTTPError
  6. from corehq.motech.finders import MATCH_FUNCTIONS, MATCH_TYPE_EXACT
  7. from corehq.motech.value_source import deserialize, get_value
  8. CandidateScore = namedtuple('CandidateScore', 'candidate score')
  9. class TrackedEntityInstanceFinder:
  10. def __init__(self, requests, case_config):
  11. self.requests = requests
  12. self.case_config = case_config
  13. @property
  14. def property_weights(self):
  15. return self.case_config.finder_config.property_weights
  16. @property
  17. def confidence_margin(self):
  18. return self.case_config.finder_config.confidence_margin
  19. @memoized_property
  20. def attr_type_id_value_source_by_case_property(self):
  21. return {
  22. value_source["case_property"]: (attr_type_id, value_source)
  23. for attr_type_id, value_source in self.case_config.attributes.items()
  24. }
  25. def find_tracked_entity_instances(self, case_trigger_info):
  26. """
  27. Search DHIS2 for potential matches of the CommCare case. Score
  28. search results and keep those with score > 1. If more than one
  29. result has a score > 1, select the best candidate if it exceeds
  30. a confidence margin. Otherwise return all results with score > 1.
  31. """
  32. # For new cases, this is the first API query. If something is
  33. # wrong with the configuration or the API, this is the first
  34. # chance to find out about it.
  35. try:
  36. results = self.fetch_query_results(case_trigger_info)
  37. except HTTPError as err:
  38. err.args += (_(
  39. "An error occurred on the first API request to DHIS2. Please "
  40. "refer to Remote API Logs to confirm that the URL and the "
  41. "request parameters are correct."
  42. ),)
  43. raise
  44. candidate_scores = []
  45. for instance in results:
  46. score = self.get_score(instance, case_trigger_info)
  47. if score >= 1:
  48. candidate_scores.append(CandidateScore(instance, score))
  49. if len(candidate_scores) > 1:
  50. candidate_scores = sorted(candidate_scores, key=lambda cs: cs.score, reverse=True)
  51. if candidate_scores[0].score / candidate_scores[1].score > 1 + self.confidence_margin:
  52. return [candidate_scores[0].candidate]
  53. return [cs.candidate for cs in candidate_scores]
  54. def fetch_query_results(self, case_trigger_info):
  55. endpoint = "/api/trackedEntityInstances"
  56. query_filters = self.get_query_filters(case_trigger_info)
  57. if not query_filters:
  58. return []
  59. params = {
  60. "ou": get_value(self.case_config.org_unit_id, case_trigger_info),
  61. "filter": query_filters,
  62. "ouMode": "DESCENDANTS",
  63. "skipPaging": "true",
  64. }
  65. response = self.requests.get(endpoint, params=params, raise_for_status=True)
  66. return response.json()["trackedEntityInstances"]
  67. def get_query_filters(self, case_trigger_info):
  68. filters = []
  69. for property_weight in self.property_weights:
  70. case_property = property_weight['case_property']
  71. value = case_trigger_info.extra_fields[case_property]
  72. if property_weight["match_type"] == MATCH_TYPE_EXACT and is_a_value(value):
  73. attr_type_id = self.attr_type_id_value_source_by_case_property[case_property][0]
  74. filters.append(f"{attr_type_id}:EQ:{value}")
  75. return filters
  76. def get_score(self, candidate, case_trigger_info):
  77. return sum(self.get_weights(candidate, case_trigger_info))
  78. def get_weights(self, candidate, case_trigger_info):
  79. for property_weight in self.property_weights:
  80. case_property = property_weight['case_property']
  81. (attr_type_id, value_source_config) = self.attr_type_id_value_source_by_case_property[case_property]
  82. candidate_value = get_tei_attr(candidate, attr_type_id)
  83. case_value = case_trigger_info.extra_fields[case_property]
  84. weight = property_weight['weight']
  85. match_type = property_weight['match_type']
  86. match_params = property_weight['match_params']
  87. match_function = partial(MATCH_FUNCTIONS[match_type], *match_params)
  88. is_equivalent = match_function(deserialize(value_source_config, candidate_value), case_value)
  89. yield weight if is_equivalent else 0
  90. def get_tei_attr(instance, attr_type_id):
  91. for attr in instance["attributes"]:
  92. if attr["attribute"] == attr_type_id:
  93. return attr["value"]
  94. def is_a_value(value):
  95. """
  96. Returns True if `value` is truthy or 0
  97. >>> is_a_value("yes")
  98. True
  99. >>> is_a_value(0)
  100. True
  101. >>> is_a_value("")
  102. False
  103. """
  104. return bool(value or value == 0)