diff --git a/care/emr/api/viewsets/resource_category.py b/care/emr/api/viewsets/resource_category.py index 563d9e84ff..6689245caa 100644 --- a/care/emr/api/viewsets/resource_category.py +++ b/care/emr/api/viewsets/resource_category.py @@ -1,3 +1,4 @@ +from django.db import transaction from django_filters import rest_framework as filters from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied, ValidationError @@ -7,11 +8,15 @@ from care.emr.api.viewsets.base import ( EMRBaseViewSet, EMRCreateMixin, + EMRDestroyMixin, EMRListMixin, EMRRetrieveMixin, EMRUpdateMixin, EMRUpsertMixin, ) +from care.emr.models.activity_definition import ActivityDefinition +from care.emr.models.charge_item_definition import ChargeItemDefinition +from care.emr.models.product_knowledge import ProductKnowledge from care.emr.models.resource_category import ( ResourceCategory, summarise_monetary_components, @@ -48,6 +53,7 @@ class ResourceCategoryViewSet( EMRListMixin, EMRUpsertMixin, EMRBaseViewSet, + EMRDestroyMixin, ): lookup_field = "slug" database_model = ResourceCategory @@ -107,6 +113,46 @@ def perform_update(self, instance): ) super().perform_update(instance) + def validate_destroy(self, instance): + if instance.children.exists(): + raise ValidationError("Cannot delete a resource category with children") + resource_type = instance.resource_type + if ( + ( + resource_type + == ResourceCategoryResourceTypeOptions.activity_definition.value + and ActivityDefinition.objects.filter(category=instance).exists() + ) + or ( + resource_type + == ResourceCategoryResourceTypeOptions.charge_item_definition.value + and ChargeItemDefinition.objects.filter(category=instance).exists() + ) + or ( + resource_type + == ResourceCategoryResourceTypeOptions.product_knowledge.value + and ProductKnowledge.objects.filter(category=instance).exists() + ) + ): + raise ValidationError( + "Cannot delete a resource category associated with a resource" + ) + + def perform_destroy(self, instance): + parent = instance.parent + with transaction.atomic(): + instance.deleted = True + instance.updated_by = self.request.user + instance.save(update_fields=["deleted", "updated_by", "modified_date"]) + if parent: + parent.has_children = ResourceCategory.objects.filter( + parent=parent + ).exists() + parent.updated_by = self.request.user + parent.save( + update_fields=["has_children", "updated_by", "modified_date"] + ) + def authorize_create(self, instance): if not AuthorizationController.call( "can_write_facility_resource_category", @@ -123,6 +169,9 @@ def authorize_update(self, request_obj, model_instance): ): raise PermissionDenied("Access denied to resource category") + def authorize_destroy(self, instance): + self.authorize_update({}, instance) + def get_queryset(self): queryset = super().get_queryset() facility_obj = self.get_facility_obj() diff --git a/care/emr/migrations/0080_alter_activitydefinition_category_and_more.py b/care/emr/migrations/0080_alter_activitydefinition_category_and_more.py new file mode 100644 index 0000000000..7a1a3a4092 --- /dev/null +++ b/care/emr/migrations/0080_alter_activitydefinition_category_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 6.0 on 2026-07-06 07:53 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('emr', '0079_resourcerequest_extensions'), + ] + + operations = [ + migrations.AlterField( + model_name='activitydefinition', + name='category', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='emr.resourcecategory'), + ), + migrations.AlterField( + model_name='chargeitemdefinition', + name='category', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='emr.resourcecategory'), + ), + migrations.AlterField( + model_name='productknowledge', + name='category', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='emr.resourcecategory'), + ), + ] diff --git a/care/emr/models/activity_definition.py b/care/emr/models/activity_definition.py index 429f7b905c..1ae4a5ae00 100644 --- a/care/emr/models/activity_definition.py +++ b/care/emr/models/activity_definition.py @@ -33,7 +33,7 @@ class ActivityDefinition(SlugBaseModel): tags = ArrayField(models.IntegerField(), default=list) category = models.ForeignKey( "emr.ResourceCategory", - on_delete=models.CASCADE, + on_delete=models.PROTECT, null=True, blank=True, ) diff --git a/care/emr/models/charge_item_definition.py b/care/emr/models/charge_item_definition.py index b6daa0f014..719d556010 100644 --- a/care/emr/models/charge_item_definition.py +++ b/care/emr/models/charge_item_definition.py @@ -21,7 +21,7 @@ class ChargeItemDefinition(SlugBaseModel): can_edit_charge_item = models.BooleanField(default=True) category = models.ForeignKey( "emr.ResourceCategory", - on_delete=models.CASCADE, + on_delete=models.PROTECT, null=True, blank=True, ) diff --git a/care/emr/models/product_knowledge.py b/care/emr/models/product_knowledge.py index 6bb4b27cd0..7a175449b9 100644 --- a/care/emr/models/product_knowledge.py +++ b/care/emr/models/product_knowledge.py @@ -20,7 +20,7 @@ class ProductKnowledge(SlugBaseModel): base_unit = models.JSONField(default=dict, null=True, blank=True) category = models.ForeignKey( "emr.ResourceCategory", - on_delete=models.CASCADE, + on_delete=models.PROTECT, null=True, blank=True, ) diff --git a/care/emr/tests/test_resource_category_api.py b/care/emr/tests/test_resource_category_api.py index cc8d53d875..c4339246a0 100644 --- a/care/emr/tests/test_resource_category_api.py +++ b/care/emr/tests/test_resource_category_api.py @@ -1,6 +1,9 @@ from django.urls import reverse from model_bakery import baker +from care.emr.models.activity_definition import ActivityDefinition +from care.emr.models.charge_item_definition import ChargeItemDefinition +from care.emr.models.product_knowledge import ProductKnowledge from care.emr.models.resource_category import ResourceCategory from care.security.permissions.resource_category import ResourceCategoryPermissions from care.utils.tests.base import CareAPITestBase @@ -265,6 +268,28 @@ def test_list_resource_categories_with_parent_filter(self): ) self.assertEqual(response.data["results"][0]["title"], "Child Category") + def test_list_resource_categories_with_parent_filter_no_results(self): + self.client.force_authenticate(user=self.super_user) + parent_category = self.create_resource_category( + slug_value="parent-category", + facility=self.facility, + title="Parent Category", + ) + self.create_resource_category( + slug_value="child-category", + facility=self.facility, + title="Child Category", + parent=parent_category, + ) + response = self.client.get( + self.get_url(self.facility) + "?parent=", format="json" + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["results"]), 1) + self.assertEqual( + response.data["results"][0]["id"], str(parent_category.external_id) + ) + # Test cases for retrieve resource category api def test_retrieve_resource_category_as_super_user(self): @@ -537,3 +562,132 @@ def test_create_resource_category_with_duplicate_slug(self): "Resource category with this slug already exists.", status_code=400, ) + + def test_delete_resource_category_as_super_user(self): + self.client.force_authenticate(user=self.super_user) + resource_category = self.create_resource_category( + slug_value="test-category", facility=self.facility + ) + url = self.get_detail_url(self.facility, resource_category) + response = self.client.delete(url, format="json") + self.assertEqual(response.status_code, 204) + self.assertFalse( + ResourceCategory.objects.filter(id=resource_category.id).exists() + ) + + def test_delete_resource_category_as_regular_user_with_permission(self): + self.attach_role_facility_organization_user( + self.facility_organization, self.user, self.role + ) + self.client.force_authenticate(user=self.user) + resource_category = self.create_resource_category( + slug_value="test-category", facility=self.facility + ) + url = self.get_detail_url(self.facility, resource_category) + response = self.client.delete(url, format="json") + self.assertEqual(response.status_code, 204) + self.assertFalse( + ResourceCategory.objects.filter(id=resource_category.id).exists() + ) + + def test_delete_resource_category_as_regular_user_without_permission(self): + self.client.force_authenticate(user=self.user) + resource_category = self.create_resource_category( + slug_value="test-category", facility=self.facility + ) + url = self.get_detail_url(self.facility, resource_category) + response = self.client.delete(url, format="json") + self.assertEqual(response.status_code, 403) + self.assertEqual(response.data["detail"], "Access denied to resource category") + self.assertTrue( + ResourceCategory.objects.filter(id=resource_category.id).exists() + ) + + def test_delete_resource_category_with_associated_product_knowledge(self): + self.client.force_authenticate(user=self.super_user) + resource_category = self.create_resource_category( + slug_value="test-category", + facility=self.facility, + resource_type="product_knowledge", + ) + # Create a ProductKnowledge associated with the resource category + baker.make( + ProductKnowledge, + facility=self.facility, + name="Test Product", + slug=self.calculate_slug("test-product", self.facility), + category=resource_category, + ) + url = self.get_detail_url(self.facility, resource_category) + response = self.client.delete(url, format="json") + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.data["errors"][0]["msg"], + "Cannot delete a resource category associated with a resource", + ) + + def test_delete_resource_category_with_associated_activity_definition(self): + self.client.force_authenticate(user=self.super_user) + resource_category = self.create_resource_category( + slug_value="test-category", + facility=self.facility, + resource_type="activity_definition", + ) + # Create an ActivityDefinition associated with the resource category + baker.make( + ActivityDefinition, + title="Test Activity", + facility=self.facility, + slug=self.calculate_slug("test-activity", self.facility), + category=resource_category, + ) + url = self.get_detail_url(self.facility, resource_category) + response = self.client.delete(url, format="json") + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.data["errors"][0]["msg"], + "Cannot delete a resource category associated with a resource", + ) + + def test_delete_resource_category_with_associated_charge_item_definition(self): + self.client.force_authenticate(user=self.super_user) + resource_category = self.create_resource_category( + slug_value="test-category", + facility=self.facility, + resource_type="charge_item_definition", + ) + baker.make( + ChargeItemDefinition, + facility=self.facility, + title="Test Charge Item", + slug=self.calculate_slug("test-charge-item", self.facility), + category=resource_category, + ) + url = self.get_detail_url(self.facility, resource_category) + response = self.client.delete(url, format="json") + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.data["errors"][0]["msg"], + "Cannot delete a resource category associated with a resource", + ) + + def test_delete_resource_category_with_children(self): + self.client.force_authenticate(user=self.super_user) + parent_category = self.create_resource_category( + slug_value="parent-category", + facility=self.facility, + title="Parent Category", + ) + self.create_resource_category( + slug_value="child-category", + facility=self.facility, + title="Child Category", + parent=parent_category, + ) + url = self.get_detail_url(self.facility, parent_category) + response = self.client.delete(url, format="json") + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.data["errors"][0]["msg"], + "Cannot delete a resource category with children", + )