From d2588c5326093677d6d7559ad6cd78c1753c1eda Mon Sep 17 00:00:00 2001 From: Wei Lu Date: Thu, 5 Sep 2024 05:56:23 -0400 Subject: [PATCH] Ability to query and filter by if group beneficiaries meet enrollment criteria for a given status (#93) * Add group beneficiary graphql tests * Add isEligible field to group beneficiary query results Also make group beneficiaries searchable by isEligible * Test eligibility filter for group beneficiaries * Refactor group beneficiary service test to reuse helper method * Fix custom filter query relation with added regression test setup * Test fixing MSSQL module CI failure --- social_protection/custom_filters.py | 2 +- social_protection/gql_queries.py | 23 +- social_protection/schema.py | 80 ++++- .../tests/group_beneficiary_gql_test.py | 328 ++++++++++++++++++ .../tests/group_beneficiary_service_test.py | 14 +- social_protection/tests/test_helpers.py | 46 ++- 6 files changed, 461 insertions(+), 32 deletions(-) create mode 100644 social_protection/tests/group_beneficiary_gql_test.py diff --git a/social_protection/custom_filters.py b/social_protection/custom_filters.py index ae23c29..0db8246 100644 --- a/social_protection/custom_filters.py +++ b/social_protection/custom_filters.py @@ -76,7 +76,7 @@ def apply_filter_to_queryset(self, custom_filters: List[namedtuple], query: Quer field, value_type = field.rsplit('__', 1) value = self.__cast_value(value, value_type) filter_kwargs = {f"{relation}__json_ext__{field}" if relation else f"json_ext__{field}": value} - query = query.filter(**filter_kwargs) + query = query.filter(**filter_kwargs).distinct() return query def __process_schema_and_build_tuple( diff --git a/social_protection/gql_queries.py b/social_protection/gql_queries.py index cd7ed42..803808f 100644 --- a/social_protection/gql_queries.py +++ b/social_protection/gql_queries.py @@ -97,13 +97,12 @@ def resolve_is_eligible(self, info): return self.is_eligible -class GroupBeneficiaryGQLType(DjangoObjectType, JsonExtMixin): - uuid = graphene.String(source='uuid') +class GroupBeneficiaryFilter(django_filters.FilterSet): + is_eligible = django_filters.BooleanFilter(method='filter_is_eligible') class Meta: model = GroupBeneficiary - interfaces = (graphene.relay.Node,) - filter_fields = { + fields = { "id": ["exact"], "status": ["exact", "iexact", "startswith", "istartswith", "contains", "icontains"], "date_valid_from": ["exact", "lt", "lte", "gt", "gte"], @@ -115,8 +114,24 @@ class Meta: "is_deleted": ["exact"], "version": ["exact"], } + + def filter_is_eligible(self, queryset, name, value): + return queryset.filter(is_eligible=value) + + +class GroupBeneficiaryGQLType(DjangoObjectType, JsonExtMixin): + uuid = graphene.String(source='uuid') + is_eligible = graphene.Boolean() + + class Meta: + model = GroupBeneficiary + interfaces = (graphene.relay.Node,) + filterset_class = GroupBeneficiaryFilter connection_class = ExtendedConnection + def resolve_is_eligible(self, info): + return self.is_eligible + class BenefitPlanDataUploadQGLType(DjangoObjectType, JsonExtMixin): uuid = graphene.String(source='uuid') diff --git a/social_protection/schema.py b/social_protection/schema.py index dd0d488..5771726 100644 --- a/social_protection/schema.py +++ b/social_protection/schema.py @@ -277,29 +277,77 @@ def _annotate_is_eligible(query, eligible_uuids, eligibility_check_performed): return gql_optimizer.query(query, info) def resolve_group_beneficiary(self, info, **kwargs): - filters = append_validity_filter(**kwargs) + def _build_filters(info, **kwargs): + filters = append_validity_filter(**kwargs) - client_mutation_id = kwargs.get("client_mutation_id", None) - if client_mutation_id: - filters.append(Q(mutations__mutation__client_mutation_id=client_mutation_id)) + client_mutation_id = kwargs.get("client_mutation_id") + if client_mutation_id: + filters.append(Q(mutations__mutation__client_mutation_id=client_mutation_id)) - Query._check_permissions( - info.context.user, - SocialProtectionConfig.gql_beneficiary_search_perms - ) - query = GroupBeneficiary.objects.filter(*filters) + Query._check_permissions( + info.context.user, + SocialProtectionConfig.gql_beneficiary_search_perms + ) + return filters + + def _apply_custom_filters(query, **kwargs): + custom_filters = kwargs.get("customFilters") + if custom_filters: + query = CustomFilterWizardStorage.build_custom_filters_queryset( + Query.module_name, + Query.object_type, + custom_filters, + query, + "group__groupindividual__individual", + ) + return query + + def _get_eligible_group_uuids(query, info, **kwargs): + status = kwargs.get("status") + benefit_plan_id = kwargs.get("benefit_plan__id") + default_results = (set(), False) # No eligibility check was performed + + if not status or not benefit_plan_id: + return default_results - custom_filters = kwargs.get("customFilters", None) - if custom_filters: - query = CustomFilterWizardStorage.build_custom_filters_queryset( - "individual", - "GroupIndividual", - custom_filters, + benefit_plan = BenefitPlan.objects.filter(id=benefit_plan_id).first() + if not benefit_plan: + return default_results + + eligibility_filters = (benefit_plan.json_ext or {}).get('advanced_criteria', {}).get(status) + if not eligibility_filters: + return default_results + + query_eligible = CustomFilterWizardStorage.build_custom_filters_queryset( + Query.module_name, + Query.object_type, + eligibility_filters, query, - "group", + "group__groupindividual__individual", ) + eligible_group_beneficiaries = gql_optimizer.query(query_eligible, info) + eligible_group_uuids = set(eligible_group_beneficiaries.values_list('uuid', flat=True)) + return eligible_group_uuids, True # Eligibility check was performed + + def _annotate_is_eligible(query, eligible_group_uuids, eligibility_check_performed): + return query.annotate( + is_eligible=Case( + When(uuid__in=eligible_group_uuids, then=Value(True)), + When(~Q(uuid__in=eligible_group_uuids) & Value(eligibility_check_performed), then=Value(False)), + default=Value(None), + output_field=BooleanField() + ) + ) + + filters = _build_filters(info, **kwargs) + query = _apply_custom_filters(GroupBeneficiary.objects.filter(*filters), **kwargs) + + eligible_group_uuids, eligibility_check_performed = _get_eligible_group_uuids(query, info, **kwargs) + query = _annotate_is_eligible(query, eligible_group_uuids, eligibility_check_performed) + return gql_optimizer.query(query, info) + def resolve_awaiting_beneficiary(self, info, **kwargs): filters = append_validity_filter(**kwargs) diff --git a/social_protection/tests/group_beneficiary_gql_test.py b/social_protection/tests/group_beneficiary_gql_test.py new file mode 100644 index 0000000..eec1c0d --- /dev/null +++ b/social_protection/tests/group_beneficiary_gql_test.py @@ -0,0 +1,328 @@ +from unittest import mock +import graphene +from core.models import User +from core.models.openimis_graphql_test_case import openIMISGraphQLTestCase +from core.test_helpers import create_test_interactive_user +from social_protection import schema as sp_schema +from graphene import Schema +from graphene.test import Client +from graphene_django.utils.testing import GraphQLTestCase +from django.conf import settings +from graphql_jwt.shortcuts import get_token +from social_protection.tests.test_helpers import create_benefit_plan,\ + create_group_with_individual, add_group_to_benefit_plan, create_individual, add_individual_to_group +from social_protection.services import GroupBeneficiaryService +import json + +class GroupBeneficiaryGQLTest(openIMISGraphQLTestCase): + schema = Schema(query=sp_schema.Query) + + class BaseTestContext: + def __init__(self, user): + self.user = user + + class AnonymousUserContext: + user = mock.Mock(is_anonymous=True) + + @classmethod + def setUpClass(cls): + super(GroupBeneficiaryGQLTest, cls).setUpClass() + cls.user = User.objects.filter(username='admin', i_user__isnull=False).first() + if not cls.user: + cls.user = create_test_interactive_user(username='admin') + cls.user_token = get_token(cls.user, cls.BaseTestContext(user=cls.user)) + cls.benefit_plan = create_benefit_plan(cls.user.username, payload_override={ + 'code': 'GGQLTest', + 'type': 'GROUP' + }) + cls.individual_2child, cls.group_2child, gi = create_group_with_individual(cls.user.username) + child1 = create_individual(cls.user.username, payload_override={ + 'first_name': 'Child1', + 'json_ext': { + 'number_of_children': 0 + } + }) + child2 = create_individual(cls.user.username, payload_override={ + 'first_name': 'Child2', + 'json_ext': { + 'number_of_children': 0 + } + }) + add_individual_to_group(cls.user.username, child1, cls.group_2child) + add_individual_to_group(cls.user.username, child2, cls.group_2child) + + cls.individual_1child, cls.group_1child, _ = create_group_with_individual( + cls.user.username, + individual_override={ + 'first_name': 'OneChild', + 'json_ext': { + 'number_of_children': 1 + } + } + ) + cls.individual, cls.group_0child, _ = create_group_with_individual( + cls.user.username, + individual_override={ + 'first_name': 'NoChild', + 'json_ext': { + 'number_of_children': 0 + } + } + ) + cls.individual_not_enrolled, cls.group_not_enrolled, _ = create_group_with_individual( + cls.user.username, + individual_override={ + 'first_name': 'Not enrolled', + 'json_ext': { + 'number_of_children': 0, + 'able_bodied': True + } + } + ) + cls.service = GroupBeneficiaryService(cls.user) + add_group_to_benefit_plan(cls.service, cls.group_2child, cls.benefit_plan) + add_group_to_benefit_plan(cls.service, cls.group_1child, cls.benefit_plan) + add_group_to_benefit_plan(cls.service, cls.group_0child, cls.benefit_plan, + payload_override={'status': 'ACTIVE'}) + + def test_query_beneficiary_basic(self): + response = self.query( + f""" + query {{ + groupBeneficiary(benefitPlan_Id: "{self.benefit_plan.uuid}", isDeleted: false, first: 10) {{ + totalCount + pageInfo {{ + hasNextPage + hasPreviousPage + startCursor + endCursor + }} + edges {{ + node {{ + id + jsonExt + benefitPlan {{ + id + }} + group {{ + id + code + }} + status + isEligible + }} + }} + }} + }} + """ + , headers={"HTTP_AUTHORIZATION": f"Bearer {self.user_token}"}) + self.assertResponseNoErrors(response) + response_data = json.loads(response.content) + + # Asserting the response has one beneficiary record + beneficiary_data = response_data['data']['groupBeneficiary'] + self.assertEqual(beneficiary_data['totalCount'], 3) + + enrolled_group_codes = list( + e['node']['group']['code'] for e in beneficiary_data['edges'] + ) + self.assertTrue(self.group_0child.code in enrolled_group_codes) + self.assertTrue(self.group_1child.code in enrolled_group_codes) + self.assertTrue(self.group_2child.code in enrolled_group_codes) + self.assertFalse(self.group_not_enrolled.code in enrolled_group_codes) + + # eligibility is status specific, so None is expected for all records without status filter + eligible_none = list( + e['node']['isEligible'] is None for e in beneficiary_data['edges'] + ) + self.assertTrue(all(eligible_none)) + + + def test_query_beneficiary_custom_filter(self): + query_str = f""" + query {{ + groupBeneficiary( + benefitPlan_Id: "{self.benefit_plan.uuid}", + customFilters: ["number_of_children__lt__integer=2"], + isDeleted: false, + first: 10 + ) {{ + totalCount + pageInfo {{ + hasNextPage + hasPreviousPage + startCursor + endCursor + }} + edges {{ + node {{ + id + jsonExt + benefitPlan {{ + id + }} + group {{ + id + code + }} + status + }} + }} + }} + }} + """ + response = self.query(query_str, + headers={"HTTP_AUTHORIZATION": f"Bearer {self.user_token}"}) + self.assertResponseNoErrors(response) + response_data = json.loads(response.content) + + beneficiary_data = response_data['data']['groupBeneficiary'] + self.assertEqual(beneficiary_data['totalCount'], 3) + + returned_group_codes = list( + e['node']['group']['code'] for e in beneficiary_data['edges'] + ) + self.assertTrue(self.group_0child.code in returned_group_codes) + self.assertTrue(self.group_1child.code in returned_group_codes) + # group_2child also included because it contains individuals with < 2 children + self.assertTrue(self.group_2child.code in returned_group_codes) + + query_str = query_str.replace('__lt__', '__gte__') + + response = self.query(query_str, + headers={"HTTP_AUTHORIZATION": f"Bearer {self.user_token}"}) + self.assertResponseNoErrors(response) + response_data = json.loads(response.content) + + beneficiary_data = response_data['data']['groupBeneficiary'] + self.assertEqual(beneficiary_data['totalCount'], 1) + + beneficiary_node = beneficiary_data['edges'][0]['node'] + group_data = beneficiary_node['group'] + self.assertEqual(group_data['code'], self.group_2child.code) + + + def test_query_beneficiary_status_filter(self): + query_str = f""" + query {{ + groupBeneficiary( + benefitPlan_Id: "{self.benefit_plan.uuid}", + status: POTENTIAL, + isDeleted: false, + first: 10 + ) {{ + totalCount + pageInfo {{ + hasNextPage + hasPreviousPage + startCursor + endCursor + }} + edges {{ + node {{ + id + jsonExt + benefitPlan {{ + id + }} + group {{ + id + code + }} + status + isEligible + }} + }} + }} + }} + """ + response = self.query(query_str, + headers={"HTTP_AUTHORIZATION": f"Bearer {self.user_token}"}) + self.assertResponseNoErrors(response) + response_data = json.loads(response.content) + + beneficiary_data = response_data['data']['groupBeneficiary'] + self.assertEqual(beneficiary_data['totalCount'], 2) + + enrolled_group_codes = list( + e['node']['group']['code'] for e in beneficiary_data['edges'] + ) + self.assertFalse(self.group_0child.code in enrolled_group_codes) + self.assertTrue(self.group_1child.code in enrolled_group_codes) + self.assertTrue(self.group_2child.code in enrolled_group_codes) + self.assertFalse(self.group_not_enrolled.code in enrolled_group_codes) + + def find_beneficiary_by_code(code): + for edge in beneficiary_data['edges']: + if edge['node']['group']['code'] == code: + return edge['node'] + return None + + beneficiary_1child = find_beneficiary_by_code(self.group_1child.code) + self.assertFalse(beneficiary_1child['isEligible']) + + beneficiary_2child = find_beneficiary_by_code(self.group_2child.code) + self.assertTrue(beneficiary_2child['isEligible']) + + + def test_query_beneficiary_eligible_filter(self): + query_str = f""" + query {{ + groupBeneficiary( + benefitPlan_Id: "{self.benefit_plan.uuid}", + status: POTENTIAL, + isEligible: true, + isDeleted: false, + first: 10 + ) {{ + totalCount + pageInfo {{ + hasNextPage + hasPreviousPage + startCursor + endCursor + }} + edges {{ + node {{ + id + jsonExt + benefitPlan {{ + id + }} + group {{ + id + code + }} + status + isEligible + }} + }} + }} + }} + """ + response = self.query(query_str, + headers={"HTTP_AUTHORIZATION": f"Bearer {self.user_token}"}) + self.assertResponseNoErrors(response) + response_data = json.loads(response.content) + + beneficiary_data = response_data['data']['groupBeneficiary'] + self.assertEqual(beneficiary_data['totalCount'], 1) + + eligible_beneficiary = beneficiary_data['edges'][0]['node'] + self.assertTrue(eligible_beneficiary['isEligible']) + self.assertEqual(self.group_2child.code, eligible_beneficiary['group']['code']) + + # flip search criteria and result should only return ineligible records + query_str = query_str.replace('isEligible: true', 'isEligible: false') + + response = self.query(query_str, + headers={"HTTP_AUTHORIZATION": f"Bearer {self.user_token}"}) + self.assertResponseNoErrors(response) + response_data = json.loads(response.content) + + beneficiary_data = response_data['data']['groupBeneficiary'] + self.assertEqual(beneficiary_data['totalCount'], 1) + + eligible_beneficiary = beneficiary_data['edges'][0]['node'] + self.assertFalse(eligible_beneficiary['isEligible']) + self.assertEqual(self.group_1child.code, eligible_beneficiary['group']['code']) diff --git a/social_protection/tests/group_beneficiary_service_test.py b/social_protection/tests/group_beneficiary_service_test.py index 2c05315..e9ff6db 100644 --- a/social_protection/tests/group_beneficiary_service_test.py +++ b/social_protection/tests/group_beneficiary_service_test.py @@ -10,7 +10,9 @@ service_beneficiary_add_payload, service_beneficiary_update_payload, ) from core.test_helpers import LogInHelper -from social_protection.tests.test_helpers import create_benefit_plan +from social_protection.tests.test_helpers import ( + create_benefit_plan, create_group +) from datetime import datetime @@ -29,7 +31,7 @@ def setUpClass(cls): cls.benefit_plan = create_benefit_plan(cls.user.username, payload_override={ 'type': "GROUP" }) - cls.group = cls.__create_group(object_data={'code': str(datetime.now())}) + cls.group = create_group(cls.user.username) cls.payload = { **service_beneficiary_add_payload, "group_id": cls.group.id, @@ -66,11 +68,3 @@ def test_delete_group_beneficiary(self): self.assertTrue(result.get('success', False), result.get('detail', "No details provided")) query = self.query_all.filter(uuid=uuid) self.assertEqual(query.count(), 0) - - @classmethod - def __create_group(cls, object_data={}): - - group = Group(**object_data) - group.save(username=cls.user.username) - - return group diff --git a/social_protection/tests/test_helpers.py b/social_protection/tests/test_helpers.py index 00d18e8..32f88ef 100644 --- a/social_protection/tests/test_helpers.py +++ b/social_protection/tests/test_helpers.py @@ -1,5 +1,7 @@ +import random +import string import copy -from individual.models import Individual +from individual.models import Individual, Group, GroupIndividual from social_protection.models import BenefitPlan from social_protection.tests.data import ( service_add_payload_valid_schema, @@ -7,6 +9,11 @@ service_add_individual_payload_with_ext, ) + +def generate_random_string(length=6): + letters = string.ascii_uppercase + return ''.join(random.choice(letters) for i in range(length)) + def merge_dicts(original, override): updated = copy.deepcopy(original) for key, value in override.items(): @@ -30,6 +37,29 @@ def create_individual(username, payload_override={}): return individual +def create_group(username, payload_override={}): + updated_payload = merge_dicts({'code': generate_random_string()}, payload_override) + group = Group(**updated_payload) + group.save(username=username) + return group + +def add_individual_to_group(username, individual, group, is_head=True): + object_data = { + "individual_id": individual.id, + "group_id": group.id, + } + if is_head: + object_data["role"] = "HEAD" + group_individual = GroupIndividual(**object_data) + group_individual.save(username=username) + return group_individual + +def create_group_with_individual(username, group_override={}, individual_override={}): + individual = create_individual(username, individual_override) + group = create_group(username, group_override) + group_individual = add_individual_to_group(username, individual, group) + return individual, group, group_individual + def add_individual_to_benefit_plan(service, individual, benefit_plan, payload_override={}): payload = { **service_beneficiary_add_payload, @@ -43,3 +73,17 @@ def add_individual_to_benefit_plan(service, individual, benefit_plan, payload_ov assert result.get('success', False), result.get('detail', "No details provided") uuid = result.get('data', {}).get('uuid', None) return uuid + +def add_group_to_benefit_plan(service, group, benefit_plan, payload_override={}): + payload = { + **service_beneficiary_add_payload, + "group_id": group.id, + "benefit_plan_id": benefit_plan.id, + "json_ext": group.json_ext, + } + benefit_plan.type = BenefitPlan.BenefitPlanType.GROUP_TYPE + updated_payload = merge_dicts(payload, payload_override) + result = service.create(updated_payload) + assert result.get('success', False), result.get('detail', "No details provided") + uuid = result.get('data', {}).get('uuid', None) + return uuid