Skip to content

Commit

Permalink
Merge pull request #35007 from dimagi/mk/3822-add-case-external-id-lo…
Browse files Browse the repository at this point in the history
…okup-3

RelatedCaseExpression allows UCR case lookups using external_id
  • Loading branch information
kaapstorm authored Aug 27, 2024
2 parents e981a4e + 087cd32 commit 3bf8484
Show file tree
Hide file tree
Showing 8 changed files with 450 additions and 16 deletions.
62 changes: 55 additions & 7 deletions corehq/apps/change_feed/tests/test_data_sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@
from decorator import contextmanager

from casexml.apps.phone.document_store import SyncLogDocumentStore
from corehq.apps.domain.tests.test_utils import test_domain
from dimagi.utils.couch.database import get_db
from pillowtop.dao.couch import CouchDocumentStore
from pillowtop.dao.exceptions import DocumentNotFoundError

from corehq.apps.change_feed import data_sources
from corehq.apps.change_feed.data_sources import get_document_store
from corehq.apps.change_feed.exceptions import UnknownDocumentStore
from corehq.apps.domain.tests.test_utils import test_domain
from corehq.apps.locations.document_store import LocationDocumentStore
from corehq.apps.sms.document_stores import SMSDocumentStore
from corehq.form_processor.backends.sql.dbaccessors import LedgerAccessorSQL
Expand Down Expand Up @@ -117,10 +118,12 @@ def form_data():

@contextmanager
def location_data():
from corehq.apps.locations.tests.util import LocationTypeStructure
from corehq.apps.locations.tests.util import LocationStructure
from corehq.apps.locations.tests.util import setup_location_types_with_structure
from corehq.apps.locations.tests.util import setup_locations_with_structure
from corehq.apps.locations.tests.util import (
LocationStructure,
LocationTypeStructure,
setup_location_types_with_structure,
setup_locations_with_structure,
)
location_type_structure = [LocationTypeStructure('t1', [])]

location_structure = [
Expand All @@ -138,9 +141,8 @@ def location_data():

@contextmanager
def ledger_data():
from casexml.apps.stock.mock import Balance
from casexml.apps.case.mock import CaseFactory
from casexml.apps.stock.mock import Entry
from casexml.apps.stock.mock import Balance, Entry

factory = CaseFactory('domain')
with case_data() as case_ids:
Expand Down Expand Up @@ -224,3 +226,49 @@ def _test_document_store(self, doc_store_cls, doc_store_args, data_context, id_f
], DocumentStoreDbTests)
def test_documet_store(*args):
_test_document_store(*args)


class CaseDocumentStoreTests(TestCase):

@classmethod
def setUpClass(cls):
super().setUpClass()

domain = 'test-domain'
cls.store = CaseDocumentStore(domain)

cls.case_ids = [uuid.uuid4().hex, uuid.uuid4().hex, uuid.uuid4().hex]
external_ids = ['external-id', 'external-id-dup', 'external-id-dup']
now = datetime.utcnow()
for case_id, external_id in zip(cls.case_ids, external_ids):
case = CommCareCase(
domain=domain,
case_id=case_id,
external_id=external_id,
modified_on=now,
server_modified_on=now,
)
case.save()
cls.addClassCleanup(case.delete)

def test_get_doc_by_case_id(self):
case_id = self.case_ids[0]
doc = self.store.get_document(case_id)
self.assertEqual(doc['case_id'], case_id)

def test_get_doc_by_external_id(self):
external_id = 'external-id'
doc = self.store.get_document(None, external_id=external_id)
self.assertEqual(doc['external_id'], external_id)

def test_external_id_not_found(self):
with self.assertRaises(DocumentNotFoundError):
self.store.get_document(None, external_id='external-id-not-found')

def test_case_not_found(self):
with self.assertRaises(DocumentNotFoundError):
self.store.get_document('case-id-not-found')

def test_get_doc_by_duplicate_external_id(self):
with self.assertRaises(DocumentNotFoundError):
self.store.get_document(None, external_id='external-id-dup')
28 changes: 25 additions & 3 deletions corehq/apps/userreports/expressions/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,20 @@

from jsonobject.exceptions import BadValueError

from corehq.apps.userreports.specs import FactoryContext
from dimagi.utils.parsing import json_format_date, json_format_datetime
from dimagi.utils.web import json_handler

from corehq.apps.userreports.exceptions import BadSpecError
from corehq.apps.userreports.expressions.date_specs import (
AddDaysExpressionSpec,
AddHoursExpressionSpec,
AddMonthsExpressionSpec,
DiffDaysExpressionSpec,
EthiopianDateToGregorianDateSpec,
GregorianDateToEthiopianDateSpec,
MonthEndDateExpressionSpec,
MonthStartDateExpressionSpec,
AddHoursExpressionSpec,
UTCNow
UTCNow,
)
from corehq.apps.userreports.expressions.list_specs import (
FilterItemsExpressionSpec,
Expand All @@ -46,13 +45,15 @@
NestedExpressionSpec,
PropertyNameGetterSpec,
PropertyPathGetterSpec,
RelatedCaseExpressionSpec,
RelatedDocExpressionSpec,
ReportingGroupsExpressionSpec,
RootDocExpressionSpec,
SplitStringExpressionSpec,
SubcasesExpressionSpec,
SwitchExpressionSpec,
)
from corehq.apps.userreports.specs import FactoryContext


def _make_filter(spec, factory_context):
Expand Down Expand Up @@ -132,6 +133,26 @@ def _related_doc_expression(spec, factory_context):
return wrapped


def _related_case_expression(spec, factory_context):
wrapped = RelatedCaseExpressionSpec.wrap(spec)
kwargs = {'value_expression': ExpressionFactory.from_spec(
wrapped.value_expression,
factory_context
)}
if getattr(wrapped, 'case_id_expression', None):
kwargs['case_id_expression'] = ExpressionFactory.from_spec(
wrapped.case_id_expression,
factory_context
)
if getattr(wrapped, 'external_id_expression', None):
kwargs['external_id_expression'] = ExpressionFactory.from_spec(
wrapped.external_id_expression,
factory_context,
)
wrapped.configure(**kwargs)
return wrapped


def _iterator_expression(spec, factory_context):
wrapped = IteratorExpressionSpec.wrap(spec)
wrapped.configure(
Expand Down Expand Up @@ -359,6 +380,7 @@ class ExpressionFactory(object):
'property_name': _property_name_expression,
'property_path': _property_path_expression,
'reduce_items': _reduce_items_expression,
'related_case': _related_case_expression,
'related_doc': _related_doc_expression,
'root_doc': _root_doc_expression,
'sort_items': _sort_items_expression,
Expand Down
80 changes: 80 additions & 0 deletions corehq/apps/userreports/expressions/specs.py
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,86 @@ def _device_to_dict(
return {k: v for k, v in device.to_json().items() if k in whitelist}


class RelatedCaseExpressionSpec(JsonObject):
"""
Similar to ``RelatedDocExpressionSpec``, this can be used to look up
a property in a related case. Related cases can be identified either
by their case ID or by ``external_id``. This example looks up the
full name of a patient identified by their external ID:
.. code:: json
{
"type": "related_case",
"external_id_expression": {
"type": "property_name",
"property_name": "patient_external_id"
},
"value_expression": {
"type": "property_name",
"property_name": "full_name"
}
}
"""
type = TypeProperty('related_case')
case_id_expression = DictProperty()
external_id_expression = DictProperty()
value_expression = DictProperty(required=True)

def configure(
self,
*,
value_expression,
case_id_expression=None,
external_id_expression=None,
):
if bool(case_id_expression) == bool(external_id_expression):
# Both case_id_expression and external_id_expression are
# present or both are missing
raise BadSpecError(
'RelatedCaseExpression must have either case_id_expression '
'or external_id_expression'
)
self._case_id_expression = case_id_expression
self._external_id_expression = external_id_expression
self._value_expression = value_expression

def __call__(self, item, evaluation_context=None):
if self._case_id_expression:
case_id = self._case_id_expression(item, evaluation_context)
external_id = None
else:
case_id = None
external_id = self._external_id_expression(item, evaluation_context)
if case_id or external_id:
return self.get_value(case_id, external_id, evaluation_context)

def get_value(self, case_id, external_id, evaluation_context):
case = self._get_document(case_id, external_id, evaluation_context)
# Use a new evaluation context since this is a new document
return self._value_expression(case, EvaluationContext(case, 0))

@staticmethod
@ucr_context_cache(vary_on=('case_id', 'external_id',))
def _get_document(case_id, external_id, evaluation_context):
domain = evaluation_context.root_doc['domain']
assert domain

# Call get_document_store_for_doc_type() so that load counter tracks load.
case_document_store = get_document_store_for_doc_type(
domain,
'CommCareCase',
load_source='related_case_expression'
)
try:
doc = case_document_store.get_document(case_id, external_id=external_id)
except DocumentNotFoundError:
return None
if domain != doc.get('domain'):
return None
return doc


class NestedExpressionSpec(JsonObject):
"""
These can be used to nest expressions. This can be used, e.g. to pull a
Expand Down
Loading

0 comments on commit 3bf8484

Please sign in to comment.