Skip to content

Commit 4ca0dd8

Browse files
Add location-based row level security to BenefitPlans (#105)
* added support for location field for beneficiary upload and update, for programs of type individuals * added beneficiary base fields in base service * added support for location queries for both type of benefits plans and only show beneficiaries based on row level security * added tests for location cases for update and upload workflows * added unique_class_validation back * updated get_queryset of beneficiaries logic to reuse Individual and Group models get_queryset method functionality * debug logs removed
1 parent b7cd37f commit 4ca0dd8

13 files changed

+677
-35
lines changed

social_protection/apps.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@
4848
'json_ext.beneficiary_data_source',
4949
'json_ext.educated_level'
5050
],
51+
"beneficiary_base_fields": [
52+
'first_name', 'last_name', 'dob', 'location_name', 'location_code', 'id'
53+
],
5154
"social_protection_masking_enabled": True,
5255
"enable_python_workflows": True,
5356
"default_beneficiary_status": "POTENTIAL",
@@ -93,6 +96,7 @@ class SocialProtectionConfig(AppConfig):
9396
enable_maker_checker_logic_enrollment = None
9497
beneficiary_mask_fields = None
9598
group_beneficiary_mask_fields = None
99+
beneficiary_base_fields = None
96100
social_protection_masking_enabled = None
97101

98102
default_beneficiary_status = None

social_protection/models.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from django.conf import settings
12
from django.contrib.auth.models import AnonymousUser
23
from django.db import models
34
from django.db.models import Func
@@ -57,6 +58,18 @@ def clean(self):
5758

5859
def __str__(self):
5960
return f'{self.individual.first_name} {self.individual.last_name}'
61+
62+
@classmethod
63+
def get_queryset(cls, queryset, user):
64+
if queryset is None:
65+
queryset = cls.objects.all()
66+
67+
individuals = Individual.objects.filter(
68+
id__in=queryset.values('individual_id')
69+
).distinct()
70+
71+
individual_queryset = Individual.get_queryset(individuals, user)
72+
return queryset.filter(individual__in=individual_queryset)
6073

6174

6275
class BenefitPlanDataUploadRecords(core_models.HistoryModel):
@@ -80,6 +93,18 @@ def clean(self):
8093
raise ValidationError(_("Group beneficiary must be associated with a benefit plan type = GROUP."))
8194

8295
super().clean()
96+
97+
@classmethod
98+
def get_queryset(cls, queryset, user):
99+
if queryset is None:
100+
queryset = cls.objects.all()
101+
102+
groups = Group.objects.filter(
103+
id__in=queryset.values('group_id')
104+
).distinct()
105+
106+
group_queryset = Group.get_queryset(groups, user)
107+
return queryset.filter(group__in=group_queryset)
83108

84109

85110
class JSONUpdate(Func):

social_protection/schema.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
)
3636
from social_protection.validation import validate_bf_unique_code, validate_bf_unique_name
3737
import graphene_django_optimizer as gql_optimizer
38+
from location.apps import LocationConfig
3839

3940

4041
def patch_details(beneficiary_df: pd.DataFrame):
@@ -82,6 +83,8 @@ class Query(ExportableSocialProtectionQueryMixin, graphene.ObjectType):
8283
orderBy=graphene.List(of_type=graphene.String),
8384
dateValidFrom__Gte=graphene.DateTime(),
8485
dateValidTo__Lte=graphene.DateTime(),
86+
parent_location=graphene.String(),
87+
parent_location_level=graphene.Int(),
8588
applyDefaultValidityFilter=graphene.Boolean(),
8689
client_mutation_id=graphene.String(),
8790
customFilters=graphene.List(of_type=graphene.String),
@@ -91,6 +94,8 @@ class Query(ExportableSocialProtectionQueryMixin, graphene.ObjectType):
9194
orderBy=graphene.List(of_type=graphene.String),
9295
dateValidFrom__Gte=graphene.DateTime(),
9396
dateValidTo__Lte=graphene.DateTime(),
97+
parent_location=graphene.String(),
98+
parent_location_level=graphene.Int(),
9499
applyDefaultValidityFilter=graphene.Boolean(),
95100
client_mutation_id=graphene.String(),
96101
customFilters=graphene.List(of_type=graphene.String),
@@ -271,7 +276,14 @@ def _annotate_is_eligible(query, eligible_uuids, eligibility_check_performed):
271276
)
272277

273278
filters = _build_filters(info, **kwargs)
274-
query = _apply_custom_filters(Beneficiary.objects.filter(*filters), **kwargs)
279+
280+
parent_location = kwargs.get('parent_location')
281+
parent_location_level = kwargs.get('parent_location_level')
282+
if parent_location is not None and parent_location_level is not None:
283+
filters.append(Query._get_location_filters(parent_location, parent_location_level, prefix='individual__'))
284+
285+
query = Beneficiary.get_queryset(None, info.context.user)
286+
query = _apply_custom_filters(query.filter(*filters), **kwargs)
275287

276288
eligible_uuids, eligibility_check_performed = _get_eligible_uuids(query, info, **kwargs)
277289
query = _annotate_is_eligible(query, eligible_uuids, eligibility_check_performed)
@@ -342,7 +354,14 @@ def _annotate_is_eligible(query, eligible_group_uuids, eligibility_check_perform
342354
)
343355

344356
filters = _build_filters(info, **kwargs)
345-
query = _apply_custom_filters(GroupBeneficiary.objects.filter(*filters), **kwargs)
357+
358+
parent_location = kwargs.get('parent_location')
359+
parent_location_level = kwargs.get('parent_location_level')
360+
if parent_location is not None and parent_location_level is not None:
361+
filters.append(Query._get_location_filters(parent_location, parent_location_level, prefix='group__'))
362+
363+
query = GroupBeneficiary.get_queryset(None, info.context.user)
364+
query = _apply_custom_filters(query.filter(*filters), **kwargs)
346365

347366
eligible_group_uuids, eligibility_check_performed = _get_eligible_group_uuids(query, info, **kwargs)
348367
query = _annotate_is_eligible(query, eligible_group_uuids, eligibility_check_performed)
@@ -439,6 +458,14 @@ def resolve_benefit_plan_history(self, info, **kwargs):
439458
if sort_alphabetically:
440459
query = query.order_by('code')
441460
return gql_optimizer.query(query, info)
461+
462+
@staticmethod
463+
def _get_location_filters(parent_location, parent_location_level, prefix=""):
464+
query_key = "uuid"
465+
for i in range(len(LocationConfig.location_types) - parent_location_level - 1):
466+
query_key = "parent__" + query_key
467+
query_key = prefix + "location__" + query_key
468+
return Q(**{query_key: parent_location})
442469

443470

444471
class Mutation(graphene.ObjectType):

social_protection/tests/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,7 @@
33
from .group_beneficiary_service_test import GroupBeneficiaryServiceTest
44
from .beneficiary_import_service_test import BeneficiaryImportServiceTest
55
from .beneficiary_gql_test import BeneficiaryGQLTest
6+
from .test_workflows_beneficiaries_upload import ProcessImportBeneficiariesWorkflowTest
7+
from .test_workflows_beneficiaries_update import ProcessUpdateBeneficiariesWorkflowTest
68
# TODO: implement group upload workflow
7-
# from .group_import_gql_test import GroupBeneficiaryImportGQLTest
9+
# from .group_import_gql_test import GroupBeneficiaryImportGQLTest
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
from django.db import connection
2+
from django.test import TestCase
3+
from core.test_helpers import create_test_interactive_user
4+
from individual.models import (
5+
Individual,
6+
IndividualDataSource,
7+
IndividualDataSourceUpload,
8+
)
9+
from social_protection.models import BenefitPlanDataUploadRecords, Beneficiary, BeneficiaryStatus
10+
from social_protection.services import BeneficiaryService
11+
from social_protection.workflows.base_beneficiary_update import process_update_beneficiaries_workflow
12+
from social_protection.tests.test_helpers import add_individual_to_benefit_plan, create_benefit_plan, create_individual
13+
from location.test_helpers import create_test_village
14+
from unittest.mock import patch
15+
import uuid
16+
from unittest import skipIf
17+
18+
19+
@skipIf(
20+
connection.vendor != "postgresql",
21+
"Skipping tests due to implementation usage of validate_json_schema, which is a postgres specific extension."
22+
)
23+
class ProcessUpdateBeneficiariesWorkflowTest(TestCase):
24+
25+
@classmethod
26+
def setUpClass(cls):
27+
super().setUpClass()
28+
# Patch validate_dataframe_headers as it is already tested separately
29+
cls.validate_headers_patcher = patch(
30+
"social_protection.workflows.utils.BasePythonWorkflowExecutor.validate_dataframe_headers",
31+
lambda self, is_update: None
32+
)
33+
cls.validate_headers_patcher.start()
34+
35+
cls.schema_patcher = patch("individual.apps.IndividualConfig.individual_schema", "{}")
36+
cls.schema_patcher.start()
37+
38+
@classmethod
39+
def tearDownClass(cls):
40+
cls.validate_headers_patcher.stop()
41+
super().tearDownClass()
42+
43+
def setUp(self):
44+
self.user = create_test_interactive_user(username="admin")
45+
self.user_uuid = self.user.id
46+
47+
self.plan_schema = {"properties": {}}
48+
self.benefit_plan = create_benefit_plan(self.user.username, {
49+
"name": "Test Benefit Plan",
50+
"description": "A test benefit plan",
51+
"code": "TESTPlan",
52+
"max_beneficiaries": 1000,
53+
"beneficiary_data_schema": {}
54+
})
55+
56+
individual1_dict = {
57+
'first_name': 'Foo 1',
58+
'last_name': 'Bar',
59+
"dob": "1998-01-01",
60+
'json_ext': {},
61+
}
62+
self.individual1 = create_individual(self.user.username, individual1_dict)
63+
64+
individual2_dict = {
65+
'first_name': 'Foo 2',
66+
'last_name': 'Baz',
67+
"dob": "1980-01-01",
68+
'json_ext': {},
69+
}
70+
self.individual2 = create_individual(self.user.username, individual2_dict)
71+
72+
self.service = BeneficiaryService(self.user)
73+
74+
# Add individuals to the benefit plan and create beneficiaries
75+
self.beneficiary1_uuid = add_individual_to_benefit_plan(
76+
self.service,
77+
self.individual1,
78+
self.benefit_plan,
79+
payload_override={'status': 'ACTIVE'}
80+
)
81+
self.beneficiary2_uuid = add_individual_to_benefit_plan(
82+
self.service,
83+
self.individual2,
84+
self.benefit_plan,
85+
payload_override={'status': 'ACTIVE'}
86+
)
87+
88+
self.upload = IndividualDataSourceUpload(
89+
source_name='csv',
90+
source_type='update',
91+
status="PENDING",
92+
)
93+
self.upload.save(user=self.user)
94+
self.upload_uuid = self.upload.id
95+
96+
upload_record = BenefitPlanDataUploadRecords(
97+
data_upload=self.upload,
98+
workflow='Python Beneficiaries Update',
99+
benefit_plan=self.benefit_plan,
100+
json_ext={}
101+
)
102+
upload_record.save(user=self.user.user)
103+
104+
self.village = create_test_village({
105+
'name': 'McLean',
106+
'code': 'VwA',
107+
})
108+
self.individual1_updated_first_name = "John"
109+
self.beneficiary1_updated_status = BeneficiaryStatus.POTENTIAL
110+
self.valid_data_source = IndividualDataSource(
111+
upload_id=self.upload_uuid,
112+
json_ext={
113+
"ID": str(self.beneficiary1_uuid),
114+
"first_name": self.individual1_updated_first_name,
115+
"last_name": "Doe",
116+
"dob": "1980-01-01",
117+
"status": self.beneficiary1_updated_status,
118+
"location_name": self.village.name,
119+
"location_code": self.village.code,
120+
}
121+
)
122+
self.valid_data_source.save(user=self.user)
123+
124+
self.individual2_updated_first_name = "Jane"
125+
self.invalid_data_source = IndividualDataSource(
126+
upload_id=self.upload_uuid,
127+
json_ext={
128+
"ID": str(uuid.uuid4()),
129+
"first_name": self.individual2_updated_first_name,
130+
}
131+
)
132+
self.invalid_data_source.save(user=self.user)
133+
134+
@patch('individual.apps.IndividualConfig.enable_maker_checker_for_individual_update', False)
135+
@patch('social_protection.apps.SocialProtectionConfig.enable_maker_checker_for_beneficiary_update', False)
136+
@skipIf(
137+
connection.vendor != "postgresql",
138+
"Skipping tests due to implementation usage of validate_json_schema, which is a postgres specific extension."
139+
)
140+
def test_process_update_beneficiaries_workflow_successful_execution(self):
141+
process_update_beneficiaries_workflow(self.user_uuid, self.benefit_plan.uuid, self.upload_uuid)
142+
143+
upload = IndividualDataSourceUpload.objects.get(id=self.upload_uuid)
144+
145+
# Check that the status is 'FAIL' due to the entry with invalid ID
146+
self.assertEqual(upload.status, "FAIL")
147+
self.assertIsNotNone(upload.error)
148+
errors = upload.error['errors']
149+
self.assertIn("Invalid entries", errors['error'])
150+
151+
# Check that the correct failing entries are logged in the error field
152+
error_key = "failing_entries_invalid_id"
153+
self.assertIn(error_key, errors)
154+
self.assertIn(str(self.invalid_data_source.id), errors[error_key]['uuids'])
155+
self.assertNotIn(str(self.valid_data_source.id), errors[error_key]['uuids'])
156+
157+
# individual_id should not be assigned for any data sources
158+
data_entries = IndividualDataSource.objects.filter(upload_id=self.upload_uuid)
159+
for entry in data_entries:
160+
self.assertIsNone(entry.individual_id)
161+
162+
# individual data should not be updated
163+
individual1_from_db = Individual.objects.get(id=self.individual1.id)
164+
self.assertNotEqual(individual1_from_db.first_name, self.individual1_updated_first_name)
165+
166+
beneficiary1_from_db = Beneficiary.objects.get(id=self.beneficiary1_uuid)
167+
self.assertNotEqual(beneficiary1_from_db.status, self.beneficiary1_updated_status)
168+
169+
individual2_from_db = Individual.objects.get(id=self.individual2.id)
170+
self.assertNotEqual(individual2_from_db.first_name, self.individual2_updated_first_name)
171+
172+
@patch('individual.apps.IndividualConfig.enable_maker_checker_for_individual_update', False)
173+
@patch('social_protection.apps.SocialProtectionConfig.enable_maker_checker_for_beneficiary_update', False)
174+
@skipIf(
175+
connection.vendor != "postgresql",
176+
"Skipping tests due to implementation usage of validate_json_schema, which is a postgres specific extension."
177+
)
178+
def test_process_update_beneficiaries_workflow_with_all_valid_entries(self):
179+
# Update invalid entry in IndividualDataSource to valid data
180+
self.invalid_data_source.json_ext = {
181+
"ID": str(self.beneficiary2_uuid),
182+
"first_name": self.individual2_updated_first_name,
183+
"location_name": None,
184+
"location_code": None,
185+
}
186+
self.invalid_data_source.save(user=self.user)
187+
188+
process_update_beneficiaries_workflow(self.user_uuid, self.benefit_plan.uuid, self.upload_uuid)
189+
190+
upload = IndividualDataSourceUpload.objects.get(id=self.upload_uuid)
191+
192+
self.assertEqual(upload.status, "SUCCESS", upload.error)
193+
self.assertEqual(upload.error, {})
194+
195+
# Verify that individual IDs have been assigned to data entries in IndividualDataSource
196+
data_entries = IndividualDataSource.objects.filter(upload_id=self.upload_uuid)
197+
for entry in data_entries:
198+
self.assertIsNotNone(entry.individual_id)
199+
200+
# individual data should be updated
201+
individual1_from_db = Individual.objects.get(id=self.individual1.id)
202+
self.assertEqual(individual1_from_db.first_name, self.individual1_updated_first_name)
203+
self.assertEqual(individual1_from_db.location.name, self.village.name)
204+
205+
# Beneficiary status update shouldn't make any effect as it is not supported
206+
beneficiary1_from_db = Beneficiary.objects.get(id=self.beneficiary1_uuid)
207+
self.assertNotEqual(beneficiary1_from_db.status, self.beneficiary1_updated_status)
208+
209+
individual2_from_db = Individual.objects.get(id=self.individual2.id)
210+
self.assertEqual(individual2_from_db.first_name, self.individual2_updated_first_name)
211+
self.assertIsNone(individual2_from_db.location)
212+
213+
@patch('individual.apps.IndividualConfig.enable_maker_checker_for_individual_update', True)
214+
@patch('social_protection.apps.SocialProtectionConfig.enable_maker_checker_for_beneficiary_update', True)
215+
def test_process_update_beneficiaries_workflow_with_maker_checker_enabled(self):
216+
# Update invalid entry in IndividualDataSource to valid data
217+
self.invalid_data_source.json_ext = {
218+
"ID": str(self.beneficiary2_uuid),
219+
"first_name": "Jane",
220+
"location_name": None,
221+
"location_code": None,
222+
}
223+
self.invalid_data_source.save(user=self.user)
224+
225+
process_update_beneficiaries_workflow(self.user_uuid, self.benefit_plan.uuid, self.upload_uuid)
226+
227+
upload = IndividualDataSourceUpload.objects.get(id=self.upload_uuid)
228+
229+
self.assertEqual(upload.status, "WAITING_FOR_VERIFICATION")
230+
self.assertEqual(upload.error, {})
231+
232+
# Verify that individual IDs not yet assigned to data entries in IndividualDataSource
233+
data_entries = IndividualDataSource.objects.filter(upload_id=self.upload_uuid)
234+
for entry in data_entries:
235+
self.assertIsNone(entry.individual_id)
236+
237+
# individual data should not be updated
238+
individual1_from_db = Individual.objects.get(id=self.individual1.id)
239+
self.assertNotEqual(individual1_from_db.first_name, self.individual1_updated_first_name)
240+
241+
individual2_from_db = Individual.objects.get(id=self.individual2.id)
242+
self.assertNotEqual(individual2_from_db.first_name, self.individual2_updated_first_name)

0 commit comments

Comments
 (0)