Skip to content

Commit

Permalink
Merge pull request #137 from weilu/feature/location-filters
Browse files Browse the repository at this point in the history
Enable location filters for individuals and groups
  • Loading branch information
delcroip authored Nov 27, 2024
2 parents 97851f1 + fba2ff2 commit 2fe0c87
Show file tree
Hide file tree
Showing 26 changed files with 1,725 additions and 178 deletions.
6 changes: 4 additions & 2 deletions individual/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
"validation_calculation_uuid": "4362f958-5894-435b-9bda-df6cadf88352",
"validation_import_valid_items": "individual_validation.import_valid_items",
"validation_import_group_valid_items": "individual_validation.import_group_valid_items",
"unique_class_validation": "DeduplicationIndividualValidationStrategy",
"validation_upload_valid_items": "individual_validation.upload_valid_items",
"validation_upload_valid_items_workflow": "individual-upload-valid-items.individual-upload-valid-items",
"enable_python_workflows": True,
Expand All @@ -40,6 +39,9 @@
"individual_mask_fields": [
'json_ext.beneficiary_data_source',
'json_ext.educated_level'
],
"individual_base_fields": [
'first_name', 'last_name', 'dob', 'location_name', 'location_code', 'id'
]
}

Expand Down Expand Up @@ -69,7 +71,6 @@ class IndividualConfig(AppConfig):
validation_import_valid_items_workflow = None
validation_import_valid_items = None
validation_import_group_valid_items = None
unique_class_validation = None

enable_python_workflows = None
enable_maker_checker_logic_import = None
Expand All @@ -83,6 +84,7 @@ class IndividualConfig(AppConfig):
enable_maker_checker_for_group_update = None
individual_mask_fields = None
individual_masking_enabled = None
individual_base_fields = None

def ready(self):
from core.models import ModuleConfiguration
Expand Down
32 changes: 25 additions & 7 deletions individual/gql_mutations.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from django.db.models import Subquery, Q
from django.utils.translation import gettext as _

from core import filter_validity
from core.gql.gql_mutations.base_mutation import BaseHistoryModelDeleteMutationMixin, BaseMutation, \
BaseHistoryModelUpdateMutationMixin, BaseHistoryModelCreateMutationMixin
from core.schema import OpenIMISMutation
Expand Down Expand Up @@ -167,7 +168,12 @@ def _validate_mutation(cls, user, **data):
IndividualConfig.gql_individual_delete_perms):
raise PermissionDenied(_("unauthorized"))

locations_id = list(Location.objects.filter(individuals__id__in=data['ids']).values_list('id', flat=True))
locations_id = list(
Location.objects.filter(
individuals__id__in=data['ids'],
*filter_validity()
).values_list('id', flat=True)
)
if len(locations_id)>0 and not LocationManager().is_allowed(
user,
locations_id
Expand Down Expand Up @@ -209,7 +215,12 @@ def _validate_mutation(cls, user, **data):
IndividualConfig.gql_individual_undo_delete_perms):
raise PermissionDenied(_("unauthorized"))

locations_id = list(Location.objects.filter(individuals__id__in=data['ids']).values_list('id', flat=True))
locations_id = list(
Location.objects.filter(
individuals__id__in=data['ids'],
*filter_validity()
).values_list('id', flat=True)
)
if len(locations_id)>0 and not LocationManager().is_allowed(
user,
locations_id
Expand Down Expand Up @@ -318,7 +329,12 @@ def _validate_mutation(cls, user, **data):
IndividualConfig.gql_group_delete_perms):
raise PermissionDenied(_("unauthorized"))

locations_id = list(Location.objects.filter(groups__id__in=data['ids']).values_list('id', flat=True))
locations_id = list(
Location.objects.filter(
groups__id__in=data['ids'],
*filter_validity()
).values_list('id', flat=True)
)
if len(locations_id)>0 and not LocationManager().is_allowed(
user,
locations_id
Expand Down Expand Up @@ -442,10 +458,12 @@ def _validate_mutation(cls, user, **data):
if not user.has_perms(
IndividualConfig.gql_group_delete_perms):
raise PermissionDenied(_("unauthorized"))
locations_qs = list(Location.objects.filter(
Q(groups__groupindividuals__id__in=data['ids'])|
Q(individuals__groupindividuals__id__in=data['ids'])
).values_list('id', flat=True))
locations_qs = list(
Location.objects.filter(
Q(groups__groupindividuals__id__in=data['ids'])|
Q(individuals__groupindividuals__id__in=data['ids'])
).filter(*filter_validity()).values_list('id', flat=True)
)
# must first check if locations_qs exists in case none of the groups or individuals has location
if len(locations_qs)>0 and not LocationManager().is_allowed(
user,
Expand Down
7 changes: 2 additions & 5 deletions individual/gql_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,11 @@ class Meta:
"first_name": ["iexact", "istartswith", "icontains"],
"last_name": ["iexact", "istartswith", "icontains"],
"dob": ["exact", "lt", "lte", "gt", "gte"],

"date_created": ["exact", "lt", "lte", "gt", "gte"],
"date_updated": ["exact", "lt", "lte", "gt", "gte"],
"is_deleted": ["exact"],
"version": ["exact"],
"location": ["isnull"],
}
connection_class = ExtendedConnection

Expand All @@ -64,7 +64,6 @@ class Meta:
"first_name": ["iexact", "istartswith", "icontains"],
"last_name": ["iexact", "istartswith", "icontains"],
"dob": ["exact", "lt", "lte", "gt", "gte"],

"date_created": ["exact", "lt", "lte", "gt", "gte"],
"date_updated": ["exact", "lt", "lte", "gt", "gte"],
"is_deleted": ["exact"],
Expand Down Expand Up @@ -92,7 +91,6 @@ class Meta:
"status": ["iexact", "istartswith", "icontains"],
"source_type": ["iexact", "istartswith", "icontains"],
"source_name": ["iexact", "istartswith", "icontains"],

"date_created": ["exact", "lt", "lte", "gt", "gte"],
"date_updated": ["exact", "lt", "lte", "gt", "gte"],
"is_deleted": ["exact"],
Expand All @@ -109,7 +107,6 @@ class Meta:
interfaces = (graphene.relay.Node,)
filter_fields = {
"id": ["exact", "isnull"],

"date_created": ["exact", "lt", "lte", "gt", "gte"],
"date_updated": ["exact", "lt", "lte", "gt", "gte"],
"is_deleted": ["exact"],
Expand Down Expand Up @@ -141,6 +138,7 @@ class Meta:
"date_updated": ["exact", "lt", "lte", "gt", "gte"],
"is_deleted": ["exact"],
"version": ["exact"],
"location": ["isnull"],
}
connection_class = ExtendedConnection

Expand Down Expand Up @@ -266,7 +264,6 @@ class Meta:
interfaces = (graphene.relay.Node,)
filter_fields = {
"id": ["exact", "isnull"],

"date_created": ["exact", "lt", "lte", "gt", "gte"],
"date_updated": ["exact", "lt", "lte", "gt", "gte"],
"is_deleted": ["exact"],
Expand Down
34 changes: 29 additions & 5 deletions individual/management/commands/fake_individuals.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
import tempfile

from django.core.management.base import BaseCommand
from individual.models import GroupIndividual
from individual.tests.test_helpers import generate_random_string
from location.models import Location
from core import filter_validity
from core.models import User


fake = Faker()

Expand All @@ -21,7 +27,7 @@
"beneficiary_data_source": {"type": "string"}
}

def generate_fake_individual(group_code, recipient_info, individual_role):
def generate_fake_individual(group_code, recipient_info, individual_role, location=None):
return {
"first_name": fake.first_name(),
"last_name": fake.last_name(),
Expand All @@ -37,16 +43,30 @@ def generate_fake_individual(group_code, recipient_info, individual_role):
"chronic_illness": fake.boolean(),
"number_of_elderly": fake.random_int(min=0, max=5),
"number_of_children": fake.random_int(min=0, max=10),
"beneficiary_data_source": fake.company()
"beneficiary_data_source": fake.company(),
"location_name": location.name if location else "",
"location_code": location.code if location else "",
}



class Command(BaseCommand):
help = "Create test individual csv for uploading"

def add_arguments(self, parser):
parser.add_argument(
'--username',
type=str,
help="Specify the username such that their permitted locations are assigned to individuals"
)

def handle(self, *args, **options):
from individual.models import GroupIndividual
username = options.get('username')
user = User.objects.filter(username=username).first()
location_qs = Location.objects
if user:
location_qs = Location.get_queryset(location_qs, user)
permitted_locations = list(location_qs.filter(type='V', *filter_validity()))

individuals = []
num_individuals = 100
Expand All @@ -55,11 +75,15 @@ def handle(self, *args, **options):
# Exclude the head from role choices so that one group only has one head in randomnes
available_role_choices = [choice for choice in GroupIndividual.Role if choice != GroupIndividual.Role.HEAD]

for group_code in range(1, num_households+1):
for group_index in range(0, num_households):
group_code = generate_random_string()
assign_location = random.choice([True, False])
location = random.choice(permitted_locations) if assign_location else None

for i in range(num_individuals // num_households):
recipient_info = 1 if i == 0 else 0
individual_role = GroupIndividual.Role.HEAD if i == 0 else random.choice(available_role_choices)
individual = generate_fake_individual(group_code, recipient_info, individual_role)
individual = generate_fake_individual(group_code, recipient_info, individual_role, location)
individuals.append(individual)

with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.csv', newline='') as tmp_file:
Expand Down
16 changes: 15 additions & 1 deletion individual/models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from django.conf import settings
from django.db import models
from django.db import models, transaction
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _

import core
Expand All @@ -8,6 +10,7 @@
from location.models import Location, LocationManager



class Individual(HistoryModel):
first_name = models.CharField(max_length=255, null=False)
last_name = models.CharField(max_length=255, null=False)
Expand Down Expand Up @@ -119,6 +122,16 @@ def get_queryset(cls, queryset, user):
)
return queryset

@receiver(post_save, sender=Group)
def update_member_individuals_location(sender, instance, **kwargs):
with transaction.atomic():
# has to save one-by-one instead of bulk update due to track history
for individual in Individual.objects.filter(groupindividuals__group=instance):
# only update individual location if group location is present,
# because individuals import would create a group with empty locaiton which then takes on the location of the head
if instance.location_id and individual.location_id != instance.location_id:
individual.location_id=instance.location_id
individual.save(user=instance.user_updated)

class GroupDataSource(HistoryModel):
group = models.ForeignKey(Group, models.DO_NOTHING, blank=True, null=True)
Expand Down Expand Up @@ -167,6 +180,7 @@ def save(self, *args, **kwargs):
service.handle_head_change(self.id, self.role, self.group_id)
service.handle_primary_recipient_change(self.id, self.recipient_type, self.group_id)
service.handle_assure_primary_recipient_in_group(self.group, self.recipient_type)
service.ensure_location_consistent(self.group, self.individual, self.role)
service.update_json_ext_for_group(self.group)

def delete(self, *args, **kwargs):
Expand Down
27 changes: 26 additions & 1 deletion individual/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
GroupSummaryEnrollmentGQLType, GroupDataSourceGQLType
from individual.models import Individual, IndividualDataSource, Group, \
GroupIndividual, IndividualDataSourceUpload, IndividualDataUploadRecords, GroupDataSource
from location.apps import LocationConfig


def patch_details(data_df: pd.DataFrame):
Expand Down Expand Up @@ -66,6 +67,8 @@ class Query(ExportableQueryMixin, graphene.ObjectType):
benefitPlanToEnroll=graphene.String(),
benefitPlanId=graphene.String(),
filterNotAttachedToGroup=graphene.Boolean(),
parent_location=graphene.String(),
parent_location_level=graphene.Int(),
)

individual_history = OrderedDjangoFilterConnectionField(
Expand Down Expand Up @@ -105,7 +108,11 @@ class Query(ExportableQueryMixin, graphene.ObjectType):
applyDefaultValidityFilter=graphene.Boolean(),
client_mutation_id=graphene.String(),
first_name=graphene.String(),

last_name=graphene.String(),
customFilters=graphene.List(of_type=graphene.String),
benefitPlanToEnroll=graphene.String(),
parent_location=graphene.String(),
parent_location_level=graphene.Int(),
)

group_history = OrderedDjangoFilterConnectionField(
Expand Down Expand Up @@ -188,6 +195,11 @@ def resolve_individual(self, info, **kwargs):
subquery = GroupIndividual.objects.filter(individual=OuterRef('pk')).values('individual')
filters.append(~Q(pk__in=Subquery(subquery)))

parent_location = kwargs.get('parent_location')
parent_location_level = kwargs.get('parent_location_level')
if parent_location is not None and parent_location_level is not None:
filters.append(Query._get_location_filters(parent_location, parent_location_level))

query = IndividualGQLType.get_queryset(None, info)
query = query.filter(*filters)

Expand Down Expand Up @@ -320,6 +332,11 @@ def resolve_group(self, info, **kwargs):
~Q(groupbeneficiary__benefit_plan_id=benefit_plan_to_enroll)
)

parent_location = kwargs.get('parent_location')
parent_location_level = kwargs.get('parent_location_level')
if parent_location is not None and parent_location_level is not None:
filters.append(Query._get_location_filters(parent_location, parent_location_level))

query = GroupGQLType.get_queryset(None, info)
query = query.filter(*filters).distinct()

Expand Down Expand Up @@ -442,6 +459,14 @@ def _check_permissions(user, perms):
if type(user) is AnonymousUser or not user.id or not user.has_perms(perms):
raise PermissionError("Unauthorized")

@staticmethod
def _get_location_filters(parent_location, parent_location_level):
query_key = "uuid"
for i in range(len(LocationConfig.location_types) - parent_location_level - 1):
query_key = "parent__" + query_key
query_key = "location__" + query_key
return Q(**{query_key: parent_location})


class Mutation(graphene.ObjectType):
create_individual = CreateIndividualMutation.Field()
Expand Down
Loading

0 comments on commit 2fe0c87

Please sign in to comment.