From 7753fb6f9ebaa2ee956869940fb50e00c8f1b4aa Mon Sep 17 00:00:00 2001 From: Shivank Kacker Date: Wed, 8 May 2024 00:20:11 +0000 Subject: [PATCH 01/16] added facility hubs --- care/facility/admin.py | 6 ++ care/facility/api/serializers/facility.py | 43 ++++++++++- care/facility/api/viewsets/facility.py | 31 +++++++- .../0429_facilityhubspoke_facility_hubs.py | 74 +++++++++++++++++++ care/facility/models/facility.py | 14 ++++ config/api_router.py | 3 +- 6 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 care/facility/migrations/0429_facilityhubspoke_facility_hubs.py diff --git a/care/facility/admin.py b/care/facility/admin.py index db0ac6a173..c323dc279c 100644 --- a/care/facility/admin.py +++ b/care/facility/admin.py @@ -6,6 +6,7 @@ from care.facility.models.ambulance import Ambulance, AmbulanceDriver from care.facility.models.asset import Asset from care.facility.models.bed import AssetBed, Bed +from care.facility.models.facility import FacilityHubSpoke from care.facility.models.patient_sample import PatientSample from care.facility.models.patient_tele_consultation import PatientTeleConsultation @@ -91,6 +92,10 @@ class FacilityAdmin(DjangoQLSearchMixin, admin.ModelAdmin): list_filter = [StateFilter, DistrictFilter] djangoql_completion_enabled_by_default = True +class FacilityHubSpokeAdmin(DjangoQLSearchMixin, admin.ModelAdmin): + search_fields = ["name"] + djangoql_completion_enabled_by_default = True + class FacilityStaffAdmin(DjangoQLSearchMixin, admin.ModelAdmin): autocomplete_fields = ["facility", "staff"] @@ -185,6 +190,7 @@ class FacilityUserAdmin(DjangoQLSearchMixin, admin.ModelAdmin, ExportCsvMixin): admin.site.register(Facility, FacilityAdmin) +admin.site.register(FacilityHubSpoke, FacilityHubSpokeAdmin) admin.site.register(FacilityStaff, FacilityStaffAdmin) admin.site.register(FacilityCapacity, FacilityCapacityAdmin) admin.site.register(FacilityVolunteer, FacilityVolunteerAdmin) diff --git a/care/facility/api/serializers/facility.py b/care/facility/api/serializers/facility.py index 73779bdfd9..25a3a6361c 100644 --- a/care/facility/api/serializers/facility.py +++ b/care/facility/api/serializers/facility.py @@ -1,10 +1,11 @@ +from uuid import UUID import boto3 from django.contrib.auth import get_user_model from rest_framework import serializers from care.facility.models import FACILITY_TYPES, Facility, FacilityLocalGovtBody from care.facility.models.bed import Bed -from care.facility.models.facility import FEATURE_CHOICES +from care.facility.models.facility import FEATURE_CHOICES, FacilityHubSpoke from care.facility.models.patient import PatientRegistration from care.users.api.serializers.lsg import ( DistrictSerializer, @@ -84,7 +85,6 @@ class Meta: "bed_count", ) - class FacilitySerializer(FacilityBasicInfoSerializer): """Serializer for facility.models.Facility.""" @@ -151,6 +151,45 @@ def create(self, validated_data): validated_data["created_by"] = self.context["request"].user return super().create(validated_data) + def validate_hubs(self, value): + # check if hubs are array of valid facility external_ids + if not all(Facility.objects.filter(external_id__in=value).exists()): + raise serializers.ValidationError("Invalid hubs") + + # check if hubs are not spoke of itself + if self.instance and self.instance.external_id in value: + raise serializers.ValidationError("Facility cannot be a spoke of itself") + + # check if hubs are not any of the facility's spokes + if self.instance and Facility.objects.filter(pk__in=value, hubs=self.instance.external_id).exists(): + raise serializers.ValidationError("Facility cannot be a spoke of its spoke") + + return Facility.objects.filter(external_id__in=value) + +class FacilityHubSerializer(serializers.ModelSerializer): + hub = FacilityBareMinimumSerializer(read_only=True) + spoke = FacilityBareMinimumSerializer(read_only=True) + hub_id = serializers.UUIDField(write_only=True) + + class Meta: + model = FacilityHubSpoke + fields = ("external_id", "hub", "hub_id", "spoke", "relationship", "created_date", "modified_date") + read_only_fields = ("external_id", "spoke", "created_date", "modified_date") + + def validate(self, data): + hub = Facility.objects.get(external_id=data["hub_id"]) + data["hub"] = hub + del data["hub_id"] + data["spoke"] = self.context["facility"] + + if data["hub"].external_id == data["spoke"].external_id: + raise serializers.ValidationError("Hub and Spoke cannot be same") + + if FacilityHubSpoke.objects.filter(hub=data["hub"], spoke=data["spoke"]).exists(): + raise serializers.ValidationError("Hub and Spoke already exists") + + return data + class FacilityImageUploadSerializer(serializers.ModelSerializer): cover_image = serializers.ImageField(required=True, write_only=True) diff --git a/care/facility/api/viewsets/facility.py b/care/facility/api/viewsets/facility.py index 93b7e4bd2e..5c79ef0d56 100644 --- a/care/facility/api/viewsets/facility.py +++ b/care/facility/api/viewsets/facility.py @@ -1,4 +1,5 @@ from django.conf import settings +from django.shortcuts import get_object_or_404 from django_filters import rest_framework as filters from djqscsv import render_to_csv_response from drf_spectacular.utils import extend_schema, extend_schema_view @@ -12,6 +13,7 @@ from care.facility.api.serializers.facility import ( FacilityBasicInfoSerializer, + FacilityHubSerializer, FacilityImageUploadSerializer, FacilitySerializer, ) @@ -21,8 +23,9 @@ FacilityPatientStatsHistory, HospitalDoctors, ) -from care.facility.models.facility import FacilityUser +from care.facility.models.facility import FacilityHubSpoke, FacilityUser from care.users.models import User +from care.utils.queryset.facility import get_facility_queryset class FacilityFilter(filters.FilterSet): @@ -183,3 +186,29 @@ class AllFacilityViewSet( filterset_class = FacilityFilter lookup_field = "external_id" search_fields = ["name", "district__name", "state__name"] + +class FacilityHubsViewSet( + mixins.RetrieveModelMixin, + mixins.ListModelMixin, + mixins.CreateModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + queryset = FacilityHubSpoke.objects.all().select_related("spoke") + serializer_class = FacilityHubSerializer + permission_classes = (IsAuthenticated, DRYPermissions) + filter_backends = (filters.DjangoFilterBackend, drf_filters.SearchFilter) + lookup_field = "external_id" + + def get_facility(self): + facilities = get_facility_queryset(self.request.user) + return get_object_or_404( + facilities.filter(external_id=self.kwargs["facility_external_id"]) + ) + + def get_serializer_context(self): + facility = self.get_facility() + context = super().get_serializer_context() + context["facility"] = facility + return context diff --git a/care/facility/migrations/0429_facilityhubspoke_facility_hubs.py b/care/facility/migrations/0429_facilityhubspoke_facility_hubs.py new file mode 100644 index 0000000000..4def4f3c95 --- /dev/null +++ b/care/facility/migrations/0429_facilityhubspoke_facility_hubs.py @@ -0,0 +1,74 @@ +# Generated by Django 4.2.10 on 2024-05-06 14:03 + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ("facility", "0428_alter_patientmetainfo_occupation"), + ] + + operations = [ + migrations.CreateModel( + name="FacilityHubSpoke", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "external_id", + models.UUIDField(db_index=True, default=uuid.uuid4, unique=True), + ), + ( + "created_date", + models.DateTimeField(auto_now_add=True, db_index=True, null=True), + ), + ( + "modified_date", + models.DateTimeField(auto_now=True, db_index=True, null=True), + ), + ("deleted", models.BooleanField(db_index=True, default=False)), + ( + "relationship", + models.IntegerField(choices=[(1, "Tele ICU Hub")], default=1), + ), + ( + "hub", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="hub_set", + to="facility.facility", + ), + ), + ( + "spoke", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="spoke_set", + to="facility.facility", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.AddField( + model_name="facility", + name="hubs", + field=models.ManyToManyField( + related_name="spokes", + through="facility.FacilityHubSpoke", + to="facility.facility", + ), + ), + ] diff --git a/care/facility/models/facility.py b/care/facility/models/facility.py index f417092853..9a610b5ec5 100644 --- a/care/facility/models/facility.py +++ b/care/facility/models/facility.py @@ -12,6 +12,7 @@ FacilityRelatedPermissionMixin, ) from care.users.models import District, LocalBody, State, Ward +from care.utils.models.base import BaseModel from care.utils.models.validators import mobile_or_landline_number_validator User = get_user_model() @@ -47,6 +48,10 @@ (6, "Blood Bank"), ] +HUB_RELATIONSHIP = [ + (1, "Tele ICU Hub") +] + ROOM_TYPES.extend(BASE_ROOM_TYPES) REVERSE_ROOM_TYPES = reverse_choices(ROOM_TYPES) @@ -140,6 +145,7 @@ class Facility(FacilityBaseModel, FacilityPermissionMixin): District, on_delete=models.SET_NULL, null=True, blank=True ) state = models.ForeignKey(State, on_delete=models.SET_NULL, null=True, blank=True) + hubs = models.ManyToManyField('self', through='FacilityHubSpoke', symmetrical=False, related_name='spokes') oxygen_capacity = models.IntegerField(default=0) type_b_cylinders = models.IntegerField(default=0) @@ -214,6 +220,14 @@ def save(self, *args, **kwargs) -> None: CSV_MAKE_PRETTY = {"facility_type": (lambda x: REVERSE_FACILITY_TYPES[x])} +class FacilityHubSpoke(BaseModel, FacilityRelatedPermissionMixin): + hub = models.ForeignKey(Facility, on_delete=models.CASCADE, related_name='hub_set') + spoke = models.ForeignKey(Facility, on_delete=models.CASCADE, related_name='spoke_set') + relationship = models.IntegerField(choices=HUB_RELATIONSHIP, default=HUB_RELATIONSHIP[0][0]) + + def __str__(self): + return f"Hub: {self.hub.name} Spoke: {self.spoke.name}" + class FacilityLocalGovtBody(models.Model): """ diff --git a/config/api_router.py b/config/api_router.py index 82b330cc1c..333ea84142 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -34,7 +34,7 @@ EventTypeViewSet, PatientConsultationEventViewSet, ) -from care.facility.api.viewsets.facility import AllFacilityViewSet, FacilityViewSet +from care.facility.api.viewsets.facility import AllFacilityViewSet, FacilityHubsViewSet, FacilityViewSet from care.facility.api.viewsets.facility_capacity import FacilityCapacityViewSet from care.facility.api.viewsets.facility_users import FacilityUserViewSet from care.facility.api.viewsets.file_upload import FileUploadViewSet @@ -189,6 +189,7 @@ facility_nested_router.register(r"inventorysummary", FacilityInventorySummaryViewSet) facility_nested_router.register(r"min_quantity", FacilityInventoryMinQuantityViewSet) facility_nested_router.register(r"asset_location", AssetLocationViewSet) +facility_nested_router.register(r"hubs", FacilityHubsViewSet) facility_location_nested_router = NestedSimpleRouter( facility_nested_router, r"asset_location", lookup="asset_location" From 5f195c6a10b4d02f48f84e6c67cba4d97e235158 Mon Sep 17 00:00:00 2001 From: Shivank Kacker Date: Wed, 8 May 2024 00:34:46 +0000 Subject: [PATCH 02/16] added tests --- care/facility/tests/test_facility_api.py | 49 ++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/care/facility/tests/test_facility_api.py b/care/facility/tests/test_facility_api.py index 60cdb08c36..753236cf2e 100644 --- a/care/facility/tests/test_facility_api.py +++ b/care/facility/tests/test_facility_api.py @@ -1,6 +1,8 @@ from rest_framework import status from rest_framework.test import APITestCase +from care import facility +from care.facility.models.facility import FacilityHubSpoke from care.utils.tests.test_utils import TestUtils @@ -91,3 +93,50 @@ def test_delete_with_active_patients(self): self.client.force_authenticate(user=state_admin) response = self.client.delete(f"/api/v1/facility/{facility.external_id}/") self.assertIs(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_add_hubs(self): + facility = self.create_facility(self.super_user, self.district, self.local_body) + facility2 = self.create_facility(self.super_user, self.district, self.local_body) + + state_admin = self.create_user("state_admin", self.district, user_type=40) + self.client.force_authenticate(user=state_admin) + response = self.client.post( + f"/api/v1/facility/{facility.external_id}/hubs/", + {"hub_id": facility2.external_id}, + ) + self.assertIs(response.status_code, status.HTTP_201_CREATED) + + def test_delete_hub(self): + facility = self.create_facility(self.super_user, self.district, self.local_body) + facility2 = self.create_facility(self.super_user, self.district, self.local_body) + + state_admin = self.create_user("state_admin", self.district, user_type=40) + + hub = FacilityHubSpoke.objects.create(hub=facility2, spoke=facility) + self.client.force_authenticate(user=state_admin) + response = self.client.delete( + f"/api/v1/facility/{facility.external_id}/hubs/{hub.external_id}/" + ) + self.assertIs(response.status_code, status.HTTP_204_NO_CONTENT) + + def test_add_hub_no_permission(self): + facility = self.create_facility(self.super_user, self.district, self.local_body) + facility2 = self.create_facility(self.super_user, self.district, self.local_body) + + self.client.force_authenticate(user=self.user) + response = self.client.post( + f"/api/v1/facility/{facility.external_id}/hubs/", + {"hub_id": facility2.external_id}, + ) + self.assertIs(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_delete_hub_no_permission(self): + facility = self.create_facility(self.super_user, self.district, self.local_body) + facility2 = self.create_facility(self.super_user, self.district, self.local_body) + + hub = FacilityHubSpoke.objects.create(hub=facility2, spoke=facility) + self.client.force_authenticate(user=self.user) + response = self.client.delete( + f"/api/v1/facility/{facility.external_id}/hubs/{hub.external_id}/" + ) + self.assertIs(response.status_code, status.HTTP_403_FORBIDDEN) From f22e28202aec70d92d8e033ca97cfe8e00b19c9a Mon Sep 17 00:00:00 2001 From: Shivank Kacker Date: Wed, 8 May 2024 00:41:15 +0000 Subject: [PATCH 03/16] remove unnecessary code --- care/facility/api/serializers/facility.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/care/facility/api/serializers/facility.py b/care/facility/api/serializers/facility.py index 25a3a6361c..208f47ed33 100644 --- a/care/facility/api/serializers/facility.py +++ b/care/facility/api/serializers/facility.py @@ -151,21 +151,6 @@ def create(self, validated_data): validated_data["created_by"] = self.context["request"].user return super().create(validated_data) - def validate_hubs(self, value): - # check if hubs are array of valid facility external_ids - if not all(Facility.objects.filter(external_id__in=value).exists()): - raise serializers.ValidationError("Invalid hubs") - - # check if hubs are not spoke of itself - if self.instance and self.instance.external_id in value: - raise serializers.ValidationError("Facility cannot be a spoke of itself") - - # check if hubs are not any of the facility's spokes - if self.instance and Facility.objects.filter(pk__in=value, hubs=self.instance.external_id).exists(): - raise serializers.ValidationError("Facility cannot be a spoke of its spoke") - - return Facility.objects.filter(external_id__in=value) - class FacilityHubSerializer(serializers.ModelSerializer): hub = FacilityBareMinimumSerializer(read_only=True) spoke = FacilityBareMinimumSerializer(read_only=True) From 51dcba70b05ebb7c6d124e84c1559db360666575 Mon Sep 17 00:00:00 2001 From: Shivank Kacker Date: Wed, 8 May 2024 00:46:04 +0000 Subject: [PATCH 04/16] lint --- care/facility/admin.py | 1 + care/facility/api/serializers/facility.py | 17 ++++++++++++++--- care/facility/api/viewsets/facility.py | 1 + .../0429_facilityhubspoke_facility_hubs.py | 6 +++--- care/facility/models/facility.py | 19 ++++++++++++------- care/facility/tests/test_facility_api.py | 17 ++++++++++++----- config/api_router.py | 6 +++++- 7 files changed, 48 insertions(+), 19 deletions(-) diff --git a/care/facility/admin.py b/care/facility/admin.py index c323dc279c..ea989f8243 100644 --- a/care/facility/admin.py +++ b/care/facility/admin.py @@ -92,6 +92,7 @@ class FacilityAdmin(DjangoQLSearchMixin, admin.ModelAdmin): list_filter = [StateFilter, DistrictFilter] djangoql_completion_enabled_by_default = True + class FacilityHubSpokeAdmin(DjangoQLSearchMixin, admin.ModelAdmin): search_fields = ["name"] djangoql_completion_enabled_by_default = True diff --git a/care/facility/api/serializers/facility.py b/care/facility/api/serializers/facility.py index 208f47ed33..b35894919d 100644 --- a/care/facility/api/serializers/facility.py +++ b/care/facility/api/serializers/facility.py @@ -1,4 +1,3 @@ -from uuid import UUID import boto3 from django.contrib.auth import get_user_model from rest_framework import serializers @@ -85,6 +84,7 @@ class Meta: "bed_count", ) + class FacilitySerializer(FacilityBasicInfoSerializer): """Serializer for facility.models.Facility.""" @@ -151,6 +151,7 @@ def create(self, validated_data): validated_data["created_by"] = self.context["request"].user return super().create(validated_data) + class FacilityHubSerializer(serializers.ModelSerializer): hub = FacilityBareMinimumSerializer(read_only=True) spoke = FacilityBareMinimumSerializer(read_only=True) @@ -158,7 +159,15 @@ class FacilityHubSerializer(serializers.ModelSerializer): class Meta: model = FacilityHubSpoke - fields = ("external_id", "hub", "hub_id", "spoke", "relationship", "created_date", "modified_date") + fields = ( + "external_id", + "hub", + "hub_id", + "spoke", + "relationship", + "created_date", + "modified_date", + ) read_only_fields = ("external_id", "spoke", "created_date", "modified_date") def validate(self, data): @@ -170,7 +179,9 @@ def validate(self, data): if data["hub"].external_id == data["spoke"].external_id: raise serializers.ValidationError("Hub and Spoke cannot be same") - if FacilityHubSpoke.objects.filter(hub=data["hub"], spoke=data["spoke"]).exists(): + if FacilityHubSpoke.objects.filter( + hub=data["hub"], spoke=data["spoke"] + ).exists(): raise serializers.ValidationError("Hub and Spoke already exists") return data diff --git a/care/facility/api/viewsets/facility.py b/care/facility/api/viewsets/facility.py index 5c79ef0d56..ac9f599b7f 100644 --- a/care/facility/api/viewsets/facility.py +++ b/care/facility/api/viewsets/facility.py @@ -187,6 +187,7 @@ class AllFacilityViewSet( lookup_field = "external_id" search_fields = ["name", "district__name", "state__name"] + class FacilityHubsViewSet( mixins.RetrieveModelMixin, mixins.ListModelMixin, diff --git a/care/facility/migrations/0429_facilityhubspoke_facility_hubs.py b/care/facility/migrations/0429_facilityhubspoke_facility_hubs.py index 4def4f3c95..21787dcd3c 100644 --- a/care/facility/migrations/0429_facilityhubspoke_facility_hubs.py +++ b/care/facility/migrations/0429_facilityhubspoke_facility_hubs.py @@ -1,12 +1,12 @@ # Generated by Django 4.2.10 on 2024-05-06 14:03 -from django.db import migrations, models -import django.db.models.deletion import uuid +import django.db.models.deletion +from django.db import migrations, models + class Migration(migrations.Migration): - dependencies = [ ("facility", "0428_alter_patientmetainfo_occupation"), ] diff --git a/care/facility/models/facility.py b/care/facility/models/facility.py index 9a610b5ec5..aef9e8209f 100644 --- a/care/facility/models/facility.py +++ b/care/facility/models/facility.py @@ -48,9 +48,7 @@ (6, "Blood Bank"), ] -HUB_RELATIONSHIP = [ - (1, "Tele ICU Hub") -] +HUB_RELATIONSHIP = [(1, "Tele ICU Hub")] ROOM_TYPES.extend(BASE_ROOM_TYPES) @@ -145,7 +143,9 @@ class Facility(FacilityBaseModel, FacilityPermissionMixin): District, on_delete=models.SET_NULL, null=True, blank=True ) state = models.ForeignKey(State, on_delete=models.SET_NULL, null=True, blank=True) - hubs = models.ManyToManyField('self', through='FacilityHubSpoke', symmetrical=False, related_name='spokes') + hubs = models.ManyToManyField( + "self", through="FacilityHubSpoke", symmetrical=False, related_name="spokes" + ) oxygen_capacity = models.IntegerField(default=0) type_b_cylinders = models.IntegerField(default=0) @@ -220,10 +220,15 @@ def save(self, *args, **kwargs) -> None: CSV_MAKE_PRETTY = {"facility_type": (lambda x: REVERSE_FACILITY_TYPES[x])} + class FacilityHubSpoke(BaseModel, FacilityRelatedPermissionMixin): - hub = models.ForeignKey(Facility, on_delete=models.CASCADE, related_name='hub_set') - spoke = models.ForeignKey(Facility, on_delete=models.CASCADE, related_name='spoke_set') - relationship = models.IntegerField(choices=HUB_RELATIONSHIP, default=HUB_RELATIONSHIP[0][0]) + hub = models.ForeignKey(Facility, on_delete=models.CASCADE, related_name="hub_set") + spoke = models.ForeignKey( + Facility, on_delete=models.CASCADE, related_name="spoke_set" + ) + relationship = models.IntegerField( + choices=HUB_RELATIONSHIP, default=HUB_RELATIONSHIP[0][0] + ) def __str__(self): return f"Hub: {self.hub.name} Spoke: {self.spoke.name}" diff --git a/care/facility/tests/test_facility_api.py b/care/facility/tests/test_facility_api.py index 753236cf2e..b6c0e22c12 100644 --- a/care/facility/tests/test_facility_api.py +++ b/care/facility/tests/test_facility_api.py @@ -1,7 +1,6 @@ from rest_framework import status from rest_framework.test import APITestCase -from care import facility from care.facility.models.facility import FacilityHubSpoke from care.utils.tests.test_utils import TestUtils @@ -96,7 +95,9 @@ def test_delete_with_active_patients(self): def test_add_hubs(self): facility = self.create_facility(self.super_user, self.district, self.local_body) - facility2 = self.create_facility(self.super_user, self.district, self.local_body) + facility2 = self.create_facility( + self.super_user, self.district, self.local_body + ) state_admin = self.create_user("state_admin", self.district, user_type=40) self.client.force_authenticate(user=state_admin) @@ -108,7 +109,9 @@ def test_add_hubs(self): def test_delete_hub(self): facility = self.create_facility(self.super_user, self.district, self.local_body) - facility2 = self.create_facility(self.super_user, self.district, self.local_body) + facility2 = self.create_facility( + self.super_user, self.district, self.local_body + ) state_admin = self.create_user("state_admin", self.district, user_type=40) @@ -121,7 +124,9 @@ def test_delete_hub(self): def test_add_hub_no_permission(self): facility = self.create_facility(self.super_user, self.district, self.local_body) - facility2 = self.create_facility(self.super_user, self.district, self.local_body) + facility2 = self.create_facility( + self.super_user, self.district, self.local_body + ) self.client.force_authenticate(user=self.user) response = self.client.post( @@ -132,7 +137,9 @@ def test_add_hub_no_permission(self): def test_delete_hub_no_permission(self): facility = self.create_facility(self.super_user, self.district, self.local_body) - facility2 = self.create_facility(self.super_user, self.district, self.local_body) + facility2 = self.create_facility( + self.super_user, self.district, self.local_body + ) hub = FacilityHubSpoke.objects.create(hub=facility2, spoke=facility) self.client.force_authenticate(user=self.user) diff --git a/config/api_router.py b/config/api_router.py index 333ea84142..59515707e5 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -34,7 +34,11 @@ EventTypeViewSet, PatientConsultationEventViewSet, ) -from care.facility.api.viewsets.facility import AllFacilityViewSet, FacilityHubsViewSet, FacilityViewSet +from care.facility.api.viewsets.facility import ( + AllFacilityViewSet, + FacilityHubsViewSet, + FacilityViewSet, +) from care.facility.api.viewsets.facility_capacity import FacilityCapacityViewSet from care.facility.api.viewsets.facility_users import FacilityUserViewSet from care.facility.api.viewsets.file_upload import FileUploadViewSet From 51951da97c4d220751d92ec35a5e1e38e1152386 Mon Sep 17 00:00:00 2001 From: Shivank Kacker Date: Fri, 10 May 2024 22:05:51 +0000 Subject: [PATCH 05/16] Changes made --- care/facility/api/serializers/facility.py | 30 +++++++++-------------- care/facility/api/viewsets/facility.py | 12 +++------ care/facility/models/facility.py | 23 +++++++++++++++-- care/facility/tests/test_facility_api.py | 4 +-- 4 files changed, 38 insertions(+), 31 deletions(-) diff --git a/care/facility/api/serializers/facility.py b/care/facility/api/serializers/facility.py index b35894919d..c54f7bef6c 100644 --- a/care/facility/api/serializers/facility.py +++ b/care/facility/api/serializers/facility.py @@ -13,6 +13,7 @@ WardSerializer, ) from care.utils.csp.config import BucketType, get_client_config +from care.utils.serializer.external_id_field import ExternalIdSerializerField from config.serializers import ChoiceField from config.validators import MiddlewareDomainAddressValidator @@ -153,37 +154,28 @@ def create(self, validated_data): class FacilityHubSerializer(serializers.ModelSerializer): - hub = FacilityBareMinimumSerializer(read_only=True) - spoke = FacilityBareMinimumSerializer(read_only=True) - hub_id = serializers.UUIDField(write_only=True) + id = serializers.UUIDField(source="external_id", read_only=True) + hub = ExternalIdSerializerField( + queryset=Facility.objects.all(), required=True, write_only=True + ) + hub_object = FacilityBareMinimumSerializer(read_only=True, source="hub") + spoke_object = FacilityBareMinimumSerializer(read_only=True, source="spoke") class Meta: model = FacilityHubSpoke fields = ( - "external_id", + "id", "hub", - "hub_id", - "spoke", + "hub_object", + "spoke_object", "relationship", "created_date", "modified_date", ) - read_only_fields = ("external_id", "spoke", "created_date", "modified_date") + read_only_fields = ("id", "spoke", "created_date", "modified_date") def validate(self, data): - hub = Facility.objects.get(external_id=data["hub_id"]) - data["hub"] = hub - del data["hub_id"] data["spoke"] = self.context["facility"] - - if data["hub"].external_id == data["spoke"].external_id: - raise serializers.ValidationError("Hub and Spoke cannot be same") - - if FacilityHubSpoke.objects.filter( - hub=data["hub"], spoke=data["spoke"] - ).exists(): - raise serializers.ValidationError("Hub and Spoke already exists") - return data diff --git a/care/facility/api/viewsets/facility.py b/care/facility/api/viewsets/facility.py index ac9f599b7f..4ee3c7034f 100644 --- a/care/facility/api/viewsets/facility.py +++ b/care/facility/api/viewsets/facility.py @@ -64,14 +64,7 @@ def filter_queryset(self, request, queryset, view): return queryset -class FacilityViewSet( - mixins.CreateModelMixin, - mixins.ListModelMixin, - mixins.RetrieveModelMixin, - mixins.UpdateModelMixin, - mixins.DestroyModelMixin, - viewsets.GenericViewSet, -): +class FacilityViewSet(viewsets.ModelViewSet): """Viewset for facility CRUD operations.""" queryset = Facility.objects.all().select_related( @@ -202,6 +195,9 @@ class FacilityHubsViewSet( filter_backends = (filters.DjangoFilterBackend, drf_filters.SearchFilter) lookup_field = "external_id" + def get_queryset(self): + return self.queryset.filter(spoke=self.get_facility()) + def get_facility(self): facilities = get_facility_queryset(self.request.user) return get_object_or_404( diff --git a/care/facility/models/facility.py b/care/facility/models/facility.py index aef9e8209f..0f74e9a98f 100644 --- a/care/facility/models/facility.py +++ b/care/facility/models/facility.py @@ -2,6 +2,8 @@ from django.contrib.auth import get_user_model from django.core.validators import MinValueValidator from django.db import models +from django.db.models import IntegerChoices +from django.utils.translation import gettext_lazy as _ from multiselectfield import MultiSelectField from multiselectfield.utils import get_max_length from simple_history.models import HistoricalRecords @@ -48,7 +50,10 @@ (6, "Blood Bank"), ] -HUB_RELATIONSHIP = [(1, "Tele ICU Hub")] + +class HubRelationship(IntegerChoices): + TELE_ICU_HUB = 1, _("Tele ICU Hub") + ROOM_TYPES.extend(BASE_ROOM_TYPES) @@ -227,9 +232,23 @@ class FacilityHubSpoke(BaseModel, FacilityRelatedPermissionMixin): Facility, on_delete=models.CASCADE, related_name="spoke_set" ) relationship = models.IntegerField( - choices=HUB_RELATIONSHIP, default=HUB_RELATIONSHIP[0][0] + choices=HubRelationship.choices, default=HubRelationship.TELE_ICU_HUB ) + def save(self, *args, **kwargs): + if self.hub == self.spoke: + raise ValueError("Hub and Spoke cannot be the same") + + if ( + not self.pk + and FacilityHubSpoke.objects.filter( + hub=self.spoke, spoke=self.hub, deleted=False + ).exists() + ): + raise ValueError("Hub and Spoke already exists") + + super().save(*args, **kwargs) + def __str__(self): return f"Hub: {self.hub.name} Spoke: {self.spoke.name}" diff --git a/care/facility/tests/test_facility_api.py b/care/facility/tests/test_facility_api.py index b6c0e22c12..516ef48383 100644 --- a/care/facility/tests/test_facility_api.py +++ b/care/facility/tests/test_facility_api.py @@ -103,7 +103,7 @@ def test_add_hubs(self): self.client.force_authenticate(user=state_admin) response = self.client.post( f"/api/v1/facility/{facility.external_id}/hubs/", - {"hub_id": facility2.external_id}, + {"hub": facility2.external_id}, ) self.assertIs(response.status_code, status.HTTP_201_CREATED) @@ -131,7 +131,7 @@ def test_add_hub_no_permission(self): self.client.force_authenticate(user=self.user) response = self.client.post( f"/api/v1/facility/{facility.external_id}/hubs/", - {"hub_id": facility2.external_id}, + {"hub": facility2.external_id}, ) self.assertIs(response.status_code, status.HTTP_403_FORBIDDEN) From a91e5f924ab9fc6b844cd0f041528ab65031e0fe Mon Sep 17 00:00:00 2001 From: Shivank Kacker Date: Fri, 10 May 2024 23:42:16 +0000 Subject: [PATCH 06/16] . --- .../0429_facilityhubspoke_facility_hubs.py | 74 ------------------- 1 file changed, 74 deletions(-) delete mode 100644 care/facility/migrations/0429_facilityhubspoke_facility_hubs.py diff --git a/care/facility/migrations/0429_facilityhubspoke_facility_hubs.py b/care/facility/migrations/0429_facilityhubspoke_facility_hubs.py deleted file mode 100644 index 21787dcd3c..0000000000 --- a/care/facility/migrations/0429_facilityhubspoke_facility_hubs.py +++ /dev/null @@ -1,74 +0,0 @@ -# Generated by Django 4.2.10 on 2024-05-06 14:03 - -import uuid - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("facility", "0428_alter_patientmetainfo_occupation"), - ] - - operations = [ - migrations.CreateModel( - name="FacilityHubSpoke", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "external_id", - models.UUIDField(db_index=True, default=uuid.uuid4, unique=True), - ), - ( - "created_date", - models.DateTimeField(auto_now_add=True, db_index=True, null=True), - ), - ( - "modified_date", - models.DateTimeField(auto_now=True, db_index=True, null=True), - ), - ("deleted", models.BooleanField(db_index=True, default=False)), - ( - "relationship", - models.IntegerField(choices=[(1, "Tele ICU Hub")], default=1), - ), - ( - "hub", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="hub_set", - to="facility.facility", - ), - ), - ( - "spoke", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="spoke_set", - to="facility.facility", - ), - ), - ], - options={ - "abstract": False, - }, - ), - migrations.AddField( - model_name="facility", - name="hubs", - field=models.ManyToManyField( - related_name="spokes", - through="facility.FacilityHubSpoke", - to="facility.facility", - ), - ), - ] From 51540d596a899641ba48b37b4a60d36eaaf65193 Mon Sep 17 00:00:00 2001 From: Shivank Kacker Date: Fri, 10 May 2024 23:43:13 +0000 Subject: [PATCH 07/16] fixed migrations --- .../0430_facilityhubspoke_facility_hubs.py | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 care/facility/migrations/0430_facilityhubspoke_facility_hubs.py diff --git a/care/facility/migrations/0430_facilityhubspoke_facility_hubs.py b/care/facility/migrations/0430_facilityhubspoke_facility_hubs.py new file mode 100644 index 0000000000..06671d06c5 --- /dev/null +++ b/care/facility/migrations/0430_facilityhubspoke_facility_hubs.py @@ -0,0 +1,80 @@ +# Generated by Django 4.2.10 on 2024-05-10 23:42 + +import uuid + +import django.db.models.deletion +from django.db import migrations, models + +import care.facility.models.mixins.permissions.facility + + +class Migration(migrations.Migration): + dependencies = [ + ("facility", "0429_double_pain_scale"), + ] + + operations = [ + migrations.CreateModel( + name="FacilityHubSpoke", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "external_id", + models.UUIDField(db_index=True, default=uuid.uuid4, unique=True), + ), + ( + "created_date", + models.DateTimeField(auto_now_add=True, db_index=True, null=True), + ), + ( + "modified_date", + models.DateTimeField(auto_now=True, db_index=True, null=True), + ), + ("deleted", models.BooleanField(db_index=True, default=False)), + ( + "relationship", + models.IntegerField(choices=[(1, "Tele ICU Hub")], default=1), + ), + ( + "hub", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="hub_set", + to="facility.facility", + ), + ), + ( + "spoke", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="spoke_set", + to="facility.facility", + ), + ), + ], + options={ + "abstract": False, + }, + bases=( + models.Model, + care.facility.models.mixins.permissions.facility.FacilityRelatedPermissionMixin, + ), + ), + migrations.AddField( + model_name="facility", + name="hubs", + field=models.ManyToManyField( + related_name="spokes", + through="facility.FacilityHubSpoke", + to="facility.facility", + ), + ), + ] From 61c9f5d4d51e51f27eaec6e24dc6030fcfe281e7 Mon Sep 17 00:00:00 2001 From: Aakash Singh Date: Wed, 15 May 2024 00:40:26 +0530 Subject: [PATCH 08/16] Update care/facility/api/viewsets/facility.py --- care/facility/api/viewsets/facility.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/care/facility/api/viewsets/facility.py b/care/facility/api/viewsets/facility.py index 4ee3c7034f..aceaa0cdeb 100644 --- a/care/facility/api/viewsets/facility.py +++ b/care/facility/api/viewsets/facility.py @@ -181,14 +181,7 @@ class AllFacilityViewSet( search_fields = ["name", "district__name", "state__name"] -class FacilityHubsViewSet( - mixins.RetrieveModelMixin, - mixins.ListModelMixin, - mixins.CreateModelMixin, - mixins.UpdateModelMixin, - mixins.DestroyModelMixin, - viewsets.GenericViewSet, -): +class FacilityHubsViewSet(viewsets.ModelViewSet): queryset = FacilityHubSpoke.objects.all().select_related("spoke") serializer_class = FacilityHubSerializer permission_classes = (IsAuthenticated, DRYPermissions) From e89629e509e73ffa62e94d4950b7fc76083910bc Mon Sep 17 00:00:00 2001 From: Shivank Kacker Date: Tue, 21 May 2024 22:31:16 +0530 Subject: [PATCH 09/16] fixed migrations --- ...acility_hubs.py => 0438_facilityhubspoke_facility_hubs.py} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename care/facility/migrations/{0430_facilityhubspoke_facility_hubs.py => 0438_facilityhubspoke_facility_hubs.py} (95%) diff --git a/care/facility/migrations/0430_facilityhubspoke_facility_hubs.py b/care/facility/migrations/0438_facilityhubspoke_facility_hubs.py similarity index 95% rename from care/facility/migrations/0430_facilityhubspoke_facility_hubs.py rename to care/facility/migrations/0438_facilityhubspoke_facility_hubs.py index 06671d06c5..ca354aebbf 100644 --- a/care/facility/migrations/0430_facilityhubspoke_facility_hubs.py +++ b/care/facility/migrations/0438_facilityhubspoke_facility_hubs.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.10 on 2024-05-10 23:42 +# Generated by Django 4.2.10 on 2024-05-21 17:00 import uuid @@ -10,7 +10,7 @@ class Migration(migrations.Migration): dependencies = [ - ("facility", "0429_double_pain_scale"), + ("facility", "0437_alter_dailyround_rounds_type"), ] operations = [ From 356f489ea86f9cd9a9823f1139a78c021b47be42 Mon Sep 17 00:00:00 2001 From: Aakash Singh Date: Sat, 24 Aug 2024 20:59:32 +0530 Subject: [PATCH 10/16] update migrations --- ...facility_hubs.py => 0454_facilityhubspoke_facility_hubs.py} | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) rename care/facility/migrations/{0450_facilityhubspoke_facility_hubs.py => 0454_facilityhubspoke_facility_hubs.py} (98%) diff --git a/care/facility/migrations/0450_facilityhubspoke_facility_hubs.py b/care/facility/migrations/0454_facilityhubspoke_facility_hubs.py similarity index 98% rename from care/facility/migrations/0450_facilityhubspoke_facility_hubs.py rename to care/facility/migrations/0454_facilityhubspoke_facility_hubs.py index 3e252c00b0..4a88cf6530 100644 --- a/care/facility/migrations/0450_facilityhubspoke_facility_hubs.py +++ b/care/facility/migrations/0454_facilityhubspoke_facility_hubs.py @@ -9,9 +9,8 @@ class Migration(migrations.Migration): - dependencies = [ - ("facility", "0449_merge_20240822_1343"), + ("facility", "0453_merge_20240824_2040"), ] operations = [ From c1fcf893232197d5da23a255fe087e9cd65772f0 Mon Sep 17 00:00:00 2001 From: Shivank Kacker Date: Sat, 31 Aug 2024 22:35:26 +0530 Subject: [PATCH 11/16] Shifted to spokes routing --- care/facility/api/serializers/facility.py | 59 ++++++++++++++++--- care/facility/api/viewsets/facility.py | 13 ++-- ...s.py => 0454_facilityhubspoke_and_more.py} | 42 +++++++------ care/facility/models/facility.py | 43 ++++++++------ config/api_router.py | 6 +- 5 files changed, 111 insertions(+), 52 deletions(-) rename care/facility/migrations/{0454_facilityhubspoke_facility_hubs.py => 0454_facilityhubspoke_and_more.py} (65%) diff --git a/care/facility/api/serializers/facility.py b/care/facility/api/serializers/facility.py index 7cfcb69e48..d4a8e8d663 100644 --- a/care/facility/api/serializers/facility.py +++ b/care/facility/api/serializers/facility.py @@ -1,6 +1,7 @@ import boto3 from django.conf import settings from django.contrib.auth import get_user_model +from django.db.models import Q from rest_framework import serializers from care.facility.models import FACILITY_TYPES, Facility, FacilityLocalGovtBody @@ -167,31 +168,75 @@ def create(self, validated_data): return super().create(validated_data) -class FacilityHubSerializer(serializers.ModelSerializer): +class FacilitySpokeSerializer(serializers.ModelSerializer): id = serializers.UUIDField(source="external_id", read_only=True) - hub = ExternalIdSerializerField( + spoke = ExternalIdSerializerField( queryset=Facility.objects.all(), required=True, write_only=True ) - hub_object = FacilityBareMinimumSerializer(read_only=True, source="hub") - spoke_object = FacilityBareMinimumSerializer(read_only=True, source="spoke") + hub_object = FacilityBasicInfoSerializer(read_only=True, source="hub") + spoke_object = FacilityBasicInfoSerializer(read_only=True, source="spoke") class Meta: model = FacilityHubSpoke fields = ( "id", - "hub", + "spoke", "hub_object", "spoke_object", "relationship", "created_date", "modified_date", ) - read_only_fields = ("id", "spoke", "created_date", "modified_date") + read_only_fields = ( + "id", + "spoke_object", + "hub_object", + "created_date", + "modified_date", + ) def validate(self, data): - data["spoke"] = self.context["facility"] + data["hub"] = self.context["facility"] return data + def validate_spoke(self, spoke: Facility): + hub: Facility = self.context["facility"] + user = self.context["request"].user + + if hub == spoke: + raise serializers.ValidationError("Cannot set a facility as it's own spoke") + + if not ( + user.is_superuser + or ( + user.user_type <= User.TYPE_VALUE_MAP["LocalBodyAdmin"] + and spoke.state == user.state + and spoke.district == user.district + and spoke.local_body == user.local_body + ) + or ( + user.user_type > User.TYPE_VALUE_MAP["LocalBodyAdmin"] + and user.user_type <= User.TYPE_VALUE_MAP["DistrictAdmin"] + and spoke.state == user.state + and spoke.district == user.district + ) + or ( + user.user_type > User.TYPE_VALUE_MAP["DistrictAdmin"] + and user.user_type <= User.TYPE_VALUE_MAP["StateAdmin"] + and spoke.state == user.state + ) + ): + raise serializers.ValidationError( + "You do not have permission to set this spoke" + ) + + if FacilityHubSpoke.objects.filter( + Q(hub=hub, spoke=spoke) | Q(hub=spoke, spoke=hub) + ).first(): + raise serializers.ValidationError("Facility is already a spoke/hub") + + return spoke + class FacilityImageUploadSerializer(serializers.ModelSerializer): cover_image = serializers.ImageField(required=True, write_only=True) diff --git a/care/facility/api/viewsets/facility.py b/care/facility/api/viewsets/facility.py index aceaa0cdeb..fa96ae36b9 100644 --- a/care/facility/api/viewsets/facility.py +++ b/care/facility/api/viewsets/facility.py @@ -13,9 +13,9 @@ from care.facility.api.serializers.facility import ( FacilityBasicInfoSerializer, - FacilityHubSerializer, FacilityImageUploadSerializer, FacilitySerializer, + FacilitySpokeSerializer, ) from care.facility.models import ( Facility, @@ -181,15 +181,14 @@ class AllFacilityViewSet( search_fields = ["name", "district__name", "state__name"] -class FacilityHubsViewSet(viewsets.ModelViewSet): - queryset = FacilityHubSpoke.objects.all().select_related("spoke") - serializer_class = FacilityHubSerializer - permission_classes = (IsAuthenticated, DRYPermissions) - filter_backends = (filters.DjangoFilterBackend, drf_filters.SearchFilter) +class FacilitySpokesViewSet(viewsets.ModelViewSet): + queryset = FacilityHubSpoke.objects.all().select_related("spoke", "hub") + serializer_class = FacilitySpokeSerializer + permission_classes = (IsAuthenticated,) lookup_field = "external_id" def get_queryset(self): - return self.queryset.filter(spoke=self.get_facility()) + return self.queryset.filter(hub=self.get_facility()) def get_facility(self): facilities = get_facility_queryset(self.request.user) diff --git a/care/facility/migrations/0454_facilityhubspoke_facility_hubs.py b/care/facility/migrations/0454_facilityhubspoke_and_more.py similarity index 65% rename from care/facility/migrations/0454_facilityhubspoke_facility_hubs.py rename to care/facility/migrations/0454_facilityhubspoke_and_more.py index 4a88cf6530..0c62d344b4 100644 --- a/care/facility/migrations/0454_facilityhubspoke_facility_hubs.py +++ b/care/facility/migrations/0454_facilityhubspoke_and_more.py @@ -1,14 +1,13 @@ -# Generated by Django 4.2.10 on 2024-08-23 07:55 +# Generated by Django 4.2.10 on 2024-08-29 21:13 import uuid import django.db.models.deletion from django.db import migrations, models -import care.facility.models.mixins.permissions.facility - class Migration(migrations.Migration): + dependencies = [ ("facility", "0453_merge_20240824_2040"), ] @@ -41,7 +40,9 @@ class Migration(migrations.Migration): ("deleted", models.BooleanField(db_index=True, default=False)), ( "relationship", - models.IntegerField(choices=[(1, "Tele ICU Hub")], default=1), + models.IntegerField( + choices=[(1, "Regular Hub"), (2, "Tele ICU Hub")], default=1 + ), ), ( "hub", @@ -60,21 +61,28 @@ class Migration(migrations.Migration): ), ), ], - options={ - "abstract": False, - }, - bases=( - models.Model, - care.facility.models.mixins.permissions.facility.FacilityRelatedPermissionMixin, + ), + migrations.AddConstraint( + model_name="facilityhubspoke", + constraint=models.CheckConstraint( + check=models.Q(("hub", models.F("spoke")), _negated=True), + name="hub_and_spoke_not_same", + ), + ), + migrations.AddConstraint( + model_name="facilityhubspoke", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted", False)), + fields=("hub", "spoke"), + name="unique_hub_spoke", ), ), - migrations.AddField( - model_name="facility", - name="hubs", - field=models.ManyToManyField( - related_name="spokes", - through="facility.FacilityHubSpoke", - to="facility.facility", + migrations.AddConstraint( + model_name="facilityhubspoke", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted", False)), + fields=("spoke", "hub"), + name="unique_spoke_hub", ), ), ] diff --git a/care/facility/models/facility.py b/care/facility/models/facility.py index 39714d09b7..0f6a922c5f 100644 --- a/care/facility/models/facility.py +++ b/care/facility/models/facility.py @@ -4,6 +4,7 @@ from django.core.validators import MinValueValidator from django.db import models from django.db.models import IntegerChoices +from django.db.models.constraints import CheckConstraint, UniqueConstraint from django.utils.translation import gettext_lazy as _ from multiselectfield import MultiSelectField from multiselectfield.utils import get_max_length @@ -54,7 +55,8 @@ class HubRelationship(IntegerChoices): - TELE_ICU_HUB = 1, _("Tele ICU Hub") + REGULAR_HUB = 1, _("Regular Hub") + TELE_ICU_HUB = 2, _("Tele ICU Hub") class FacilityFeature(models.IntegerChoices): @@ -223,9 +225,6 @@ class Facility(FacilityBaseModel, FacilityPermissionMixin): District, on_delete=models.SET_NULL, null=True, blank=True ) state = models.ForeignKey(State, on_delete=models.SET_NULL, null=True, blank=True) - hubs = models.ManyToManyField( - "self", through="FacilityHubSpoke", symmetrical=False, related_name="spokes" - ) oxygen_capacity = models.IntegerField(default=0) type_b_cylinders = models.IntegerField(default=0) @@ -307,28 +306,34 @@ def get_features_display(self): CSV_MAKE_PRETTY = {"facility_type": (lambda x: REVERSE_FACILITY_TYPES[x])} -class FacilityHubSpoke(BaseModel, FacilityRelatedPermissionMixin): +class FacilityHubSpoke(BaseModel): hub = models.ForeignKey(Facility, on_delete=models.CASCADE, related_name="hub_set") spoke = models.ForeignKey( Facility, on_delete=models.CASCADE, related_name="spoke_set" ) relationship = models.IntegerField( - choices=HubRelationship.choices, default=HubRelationship.TELE_ICU_HUB + choices=HubRelationship.choices, default=HubRelationship.REGULAR_HUB ) - def save(self, *args, **kwargs): - if self.hub == self.spoke: - raise ValueError("Hub and Spoke cannot be the same") - - if ( - not self.pk - and FacilityHubSpoke.objects.filter( - hub=self.spoke, spoke=self.hub, deleted=False - ).exists() - ): - raise ValueError("Hub and Spoke already exists") - - super().save(*args, **kwargs) + class Meta: + constraints = [ + # Ensure hub and spoke are not the same + CheckConstraint( + check=~models.Q(hub=models.F("spoke")), + name="hub_and_spoke_not_same", + ), + # bidirectional uniqueness + UniqueConstraint( + fields=["hub", "spoke"], + name="unique_hub_spoke", + condition=models.Q(deleted=False), + ), + UniqueConstraint( + fields=["spoke", "hub"], + name="unique_spoke_hub", + condition=models.Q(deleted=False), + ), + ] def __str__(self): return f"Hub: {self.hub.name} Spoke: {self.spoke.name}" diff --git a/config/api_router.py b/config/api_router.py index 7f71970d96..42a8b26a08 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -37,7 +37,7 @@ ) from care.facility.api.viewsets.facility import ( AllFacilityViewSet, - FacilityHubsViewSet, + FacilitySpokesViewSet, FacilityViewSet, ) from care.facility.api.viewsets.facility_capacity import FacilityCapacityViewSet @@ -222,7 +222,9 @@ FacilityDischargedPatientViewSet, basename="facility-discharged-patients", ) -facility_nested_router.register(r"hubs", FacilityHubsViewSet, basename="facility-hubs") +facility_nested_router.register( + r"spokes", FacilitySpokesViewSet, basename="facility-spokes" +) router.register("asset", AssetViewSet, basename="asset") asset_nested_router = NestedSimpleRouter(router, r"asset", lookup="asset") From 5ae8602f2b4863ce5d942115357d32db8c93882e Mon Sep 17 00:00:00 2001 From: Shivank Kacker Date: Sun, 1 Sep 2024 03:42:46 +0530 Subject: [PATCH 12/16] fixed tesst --- care/facility/api/viewsets/facility.py | 9 +++++++- care/facility/tests/test_facility_api.py | 26 ++++++++++++------------ 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/care/facility/api/viewsets/facility.py b/care/facility/api/viewsets/facility.py index fa96ae36b9..57415a475e 100644 --- a/care/facility/api/viewsets/facility.py +++ b/care/facility/api/viewsets/facility.py @@ -64,7 +64,14 @@ def filter_queryset(self, request, queryset, view): return queryset -class FacilityViewSet(viewsets.ModelViewSet): +class FacilityViewSet( + mixins.CreateModelMixin, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): """Viewset for facility CRUD operations.""" queryset = Facility.objects.all().select_related( diff --git a/care/facility/tests/test_facility_api.py b/care/facility/tests/test_facility_api.py index 023bf63c51..1c77da68ea 100644 --- a/care/facility/tests/test_facility_api.py +++ b/care/facility/tests/test_facility_api.py @@ -149,7 +149,7 @@ def test_delete_with_active_patients(self): response = self.client.delete(f"/api/v1/facility/{facility.external_id}/") self.assertIs(response.status_code, status.HTTP_400_BAD_REQUEST) - def test_add_hubs(self): + def test_add_spoke(self): facility = self.create_facility(self.super_user, self.district, self.local_body) facility2 = self.create_facility( self.super_user, self.district, self.local_body @@ -158,12 +158,12 @@ def test_add_hubs(self): state_admin = self.create_user("state_admin", self.district, user_type=40) self.client.force_authenticate(user=state_admin) response = self.client.post( - f"/api/v1/facility/{facility.external_id}/hubs/", - {"hub": facility2.external_id}, + f"/api/v1/facility/{facility.external_id}/spokes/", + {"spoke": facility2.external_id}, ) self.assertIs(response.status_code, status.HTTP_201_CREATED) - def test_delete_hub(self): + def test_delete_spoke(self): facility = self.create_facility(self.super_user, self.district, self.local_body) facility2 = self.create_facility( self.super_user, self.district, self.local_body @@ -171,14 +171,14 @@ def test_delete_hub(self): state_admin = self.create_user("state_admin", self.district, user_type=40) - hub = FacilityHubSpoke.objects.create(hub=facility2, spoke=facility) + spoke = FacilityHubSpoke.objects.create(hub=facility, spoke=facility2) self.client.force_authenticate(user=state_admin) response = self.client.delete( - f"/api/v1/facility/{facility.external_id}/hubs/{hub.external_id}/" + f"/api/v1/facility/{facility.external_id}/spokes/{spoke.external_id}/" ) self.assertIs(response.status_code, status.HTTP_204_NO_CONTENT) - def test_add_hub_no_permission(self): + def test_add_spoke_no_permission(self): facility = self.create_facility(self.super_user, self.district, self.local_body) facility2 = self.create_facility( self.super_user, self.district, self.local_body @@ -186,20 +186,20 @@ def test_add_hub_no_permission(self): self.client.force_authenticate(user=self.user) response = self.client.post( - f"/api/v1/facility/{facility.external_id}/hubs/", + f"/api/v1/facility/{facility.external_id}/spokes/", {"hub": facility2.external_id}, ) - self.assertIs(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertIs(response.status_code, status.HTTP_404_NOT_FOUND) - def test_delete_hub_no_permission(self): + def test_delete_spoke_no_permission(self): facility = self.create_facility(self.super_user, self.district, self.local_body) facility2 = self.create_facility( self.super_user, self.district, self.local_body ) - hub = FacilityHubSpoke.objects.create(hub=facility2, spoke=facility) + spoke = FacilityHubSpoke.objects.create(hub=facility, spoke=facility2) self.client.force_authenticate(user=self.user) response = self.client.delete( - f"/api/v1/facility/{facility.external_id}/hubs/{hub.external_id}/" + f"/api/v1/facility/{facility.external_id}/spokes/{spoke.external_id}/" ) - self.assertIs(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertIs(response.status_code, status.HTTP_404_NOT_FOUND) From c69567be6120aa451009a5a9fce27701a59392d8 Mon Sep 17 00:00:00 2001 From: Shivank Kacker Date: Tue, 17 Sep 2024 13:16:27 +0530 Subject: [PATCH 13/16] added cyclical check in model --- ...and_more.py => 0456_facilityhubspoke_and_more.py} | 4 ++-- care/facility/models/facility.py | 12 +++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) rename care/facility/migrations/{0454_facilityhubspoke_and_more.py => 0456_facilityhubspoke_and_more.py} (96%) diff --git a/care/facility/migrations/0454_facilityhubspoke_and_more.py b/care/facility/migrations/0456_facilityhubspoke_and_more.py similarity index 96% rename from care/facility/migrations/0454_facilityhubspoke_and_more.py rename to care/facility/migrations/0456_facilityhubspoke_and_more.py index 0c62d344b4..b016352971 100644 --- a/care/facility/migrations/0454_facilityhubspoke_and_more.py +++ b/care/facility/migrations/0456_facilityhubspoke_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.10 on 2024-08-29 21:13 +# Generated by Django 4.2.10 on 2024-09-17 07:31 import uuid @@ -9,7 +9,7 @@ class Migration(migrations.Migration): dependencies = [ - ("facility", "0453_merge_20240824_2040"), + ("facility", "0455_remove_facility_old_features"), ] operations = [ diff --git a/care/facility/models/facility.py b/care/facility/models/facility.py index c86ea7baaf..9dc5982364 100644 --- a/care/facility/models/facility.py +++ b/care/facility/models/facility.py @@ -3,7 +3,7 @@ from django.contrib.postgres.fields import ArrayField from django.core.validators import MinValueValidator from django.db import models -from django.db.models import IntegerChoices +from django.db.models import IntegerChoices, Q from django.db.models.constraints import CheckConstraint, UniqueConstraint from django.utils.translation import gettext_lazy as _ from simple_history.models import HistoricalRecords @@ -327,6 +327,16 @@ class Meta: ), ] + def save(self, *args, **kwargs): + if ( + not self.deleted + and FacilityHubSpoke.objects.filter( + Q(hub=self.hub, spoke=self.spoke) | Q(hub=self.spoke, spoke=self.hub) + ).first() + ): + raise ValueError("Facility is already a spoke/hub") + return super().save(*args, **kwargs) + def __str__(self): return f"Hub: {self.hub.name} Spoke: {self.spoke.name}" From 71a792b8141acefbb54cbcdc739813b04ae3318a Mon Sep 17 00:00:00 2001 From: Shivank Kacker Date: Fri, 20 Sep 2024 10:38:03 +0530 Subject: [PATCH 14/16] merge conflict fix --- care/facility/admin.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/care/facility/admin.py b/care/facility/admin.py index a50a4540f9..11a210d0ed 100644 --- a/care/facility/admin.py +++ b/care/facility/admin.py @@ -228,17 +228,6 @@ class Meta: form = FacilityFeatureFlagForm -admin.site.register(Facility, FacilityAdmin) -admin.site.register(FacilityHubSpoke, FacilityHubSpokeAdmin) -admin.site.register(FacilityStaff, FacilityStaffAdmin) -admin.site.register(FacilityCapacity, FacilityCapacityAdmin) -admin.site.register(FacilityVolunteer, FacilityVolunteerAdmin) -admin.site.register(FacilityUser, FacilityUserAdmin) -admin.site.register(Building, BuildingAdmin) -admin.site.register(Room, RoomAdmin) -admin.site.register(StaffRoomAllocation, StaffRoomAllocationAdmin) -admin.site.register(InventoryItem, InventoryItemAdmin) -admin.site.register(Inventory, InventoryAdmin) admin.site.register(InventoryLog) admin.site.register(Disease) admin.site.register(FacilityInventoryUnit) From a90da6fca6d7389608d8268443f8cf3673f6ebf1 Mon Sep 17 00:00:00 2001 From: Shivank Kacker Date: Sat, 21 Sep 2024 01:24:29 +0530 Subject: [PATCH 15/16] remade migrations --- ...e_and_more.py => 0460_facilityhubspoke.py} | 45 +++++++++---------- 1 file changed, 20 insertions(+), 25 deletions(-) rename care/facility/migrations/{0456_facilityhubspoke_and_more.py => 0460_facilityhubspoke.py} (67%) diff --git a/care/facility/migrations/0456_facilityhubspoke_and_more.py b/care/facility/migrations/0460_facilityhubspoke.py similarity index 67% rename from care/facility/migrations/0456_facilityhubspoke_and_more.py rename to care/facility/migrations/0460_facilityhubspoke.py index b016352971..4d1ec095a1 100644 --- a/care/facility/migrations/0456_facilityhubspoke_and_more.py +++ b/care/facility/migrations/0460_facilityhubspoke.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.10 on 2024-09-17 07:31 +# Generated by Django 5.1.1 on 2024-09-20 19:54 import uuid @@ -9,7 +9,7 @@ class Migration(migrations.Migration): dependencies = [ - ("facility", "0455_remove_facility_old_features"), + ("facility", "0459_remove_bed_unique_bed_name_per_location_and_more"), ] operations = [ @@ -61,28 +61,23 @@ class Migration(migrations.Migration): ), ), ], - ), - migrations.AddConstraint( - model_name="facilityhubspoke", - constraint=models.CheckConstraint( - check=models.Q(("hub", models.F("spoke")), _negated=True), - name="hub_and_spoke_not_same", - ), - ), - migrations.AddConstraint( - model_name="facilityhubspoke", - constraint=models.UniqueConstraint( - condition=models.Q(("deleted", False)), - fields=("hub", "spoke"), - name="unique_hub_spoke", - ), - ), - migrations.AddConstraint( - model_name="facilityhubspoke", - constraint=models.UniqueConstraint( - condition=models.Q(("deleted", False)), - fields=("spoke", "hub"), - name="unique_spoke_hub", - ), + options={ + "constraints": [ + models.CheckConstraint( + condition=models.Q(("hub", models.F("spoke")), _negated=True), + name="hub_and_spoke_not_same", + ), + models.UniqueConstraint( + condition=models.Q(("deleted", False)), + fields=("hub", "spoke"), + name="unique_hub_spoke", + ), + models.UniqueConstraint( + condition=models.Q(("deleted", False)), + fields=("spoke", "hub"), + name="unique_spoke_hub", + ), + ], + }, ), ] From 063b197084fa18704cae0e35556db6a6741b5add Mon Sep 17 00:00:00 2001 From: Shivank Kacker Date: Sat, 21 Sep 2024 18:49:38 +0530 Subject: [PATCH 16/16] Added Cyclical check, updated permissions logic --- care/facility/api/serializers/facility.py | 25 ---------------- care/facility/api/viewsets/facility.py | 2 +- ...tyhubspoke.py => 0462_facilityhubspoke.py} | 8 ++--- care/facility/models/facility.py | 30 +++++++++++-------- care/facility/tests/test_facility_api.py | 27 +++++++++++++++-- 5 files changed, 46 insertions(+), 46 deletions(-) rename care/facility/migrations/{0460_facilityhubspoke.py => 0462_facilityhubspoke.py} (92%) diff --git a/care/facility/api/serializers/facility.py b/care/facility/api/serializers/facility.py index 71abf6fd57..b75bf06897 100644 --- a/care/facility/api/serializers/facility.py +++ b/care/facility/api/serializers/facility.py @@ -213,35 +213,10 @@ def validate(self, data): def validate_spoke(self, spoke: Facility): hub: Facility = self.context["facility"] - user = self.context["request"].user if hub == spoke: raise serializers.ValidationError("Cannot set a facility as it's own spoke") - if not ( - user.is_superuser - or ( - user.user_type <= User.TYPE_VALUE_MAP["LocalBodyAdmin"] - and spoke.state == user.state - and spoke.district == user.district - and spoke.local_body == user.local_body - ) - or ( - user.user_type > User.TYPE_VALUE_MAP["LocalBodyAdmin"] - and user.user_type <= User.TYPE_VALUE_MAP["DistrictAdmin"] - and spoke.state == user.state - and spoke.district == user.district - ) - or ( - user.user_type > User.TYPE_VALUE_MAP["DistrictAdmin"] - and user.user_type <= User.TYPE_VALUE_MAP["StateAdmin"] - and spoke.state == user.state - ) - ): - raise serializers.ValidationError( - "You do not have permission to set this spoke" - ) - if FacilityHubSpoke.objects.filter( Q(hub=hub, spoke=spoke) | Q(hub=spoke, spoke=hub) ).first(): diff --git a/care/facility/api/viewsets/facility.py b/care/facility/api/viewsets/facility.py index 57415a475e..427be23f94 100644 --- a/care/facility/api/viewsets/facility.py +++ b/care/facility/api/viewsets/facility.py @@ -191,7 +191,7 @@ class AllFacilityViewSet( class FacilitySpokesViewSet(viewsets.ModelViewSet): queryset = FacilityHubSpoke.objects.all().select_related("spoke", "hub") serializer_class = FacilitySpokeSerializer - permission_classes = (IsAuthenticated,) + permission_classes = (IsAuthenticated, DRYPermissions) lookup_field = "external_id" def get_queryset(self): diff --git a/care/facility/migrations/0460_facilityhubspoke.py b/care/facility/migrations/0462_facilityhubspoke.py similarity index 92% rename from care/facility/migrations/0460_facilityhubspoke.py rename to care/facility/migrations/0462_facilityhubspoke.py index 4d1ec095a1..f7fb6ca81c 100644 --- a/care/facility/migrations/0460_facilityhubspoke.py +++ b/care/facility/migrations/0462_facilityhubspoke.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.1 on 2024-09-20 19:54 +# Generated by Django 5.1.1 on 2024-09-21 12:26 import uuid @@ -9,7 +9,7 @@ class Migration(migrations.Migration): dependencies = [ - ("facility", "0459_remove_bed_unique_bed_name_per_location_and_more"), + ("facility", "0461_remove_patientconsultation_prescriptions_and_more"), ] operations = [ @@ -48,7 +48,7 @@ class Migration(migrations.Migration): "hub", models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, - related_name="hub_set", + related_name="spokes", to="facility.facility", ), ), @@ -56,7 +56,7 @@ class Migration(migrations.Migration): "spoke", models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, - related_name="spoke_set", + related_name="hubs", to="facility.facility", ), ), diff --git a/care/facility/models/facility.py b/care/facility/models/facility.py index 0fa3342093..209dbc3869 100644 --- a/care/facility/models/facility.py +++ b/care/facility/models/facility.py @@ -3,9 +3,10 @@ from django.contrib.postgres.fields import ArrayField from django.core.validators import MinValueValidator from django.db import models -from django.db.models import IntegerChoices, Q +from django.db.models import IntegerChoices from django.db.models.constraints import CheckConstraint, UniqueConstraint from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers from simple_history.models import HistoricalRecords from care.facility.models import FacilityBaseModel, reverse_choices @@ -191,6 +192,17 @@ class FacilityFeature(models.IntegerChoices): REVERSE_FEATURE_CHOICES = reverse_choices(FEATURE_CHOICES) +# making sure A -> B -> C -> A does not happen +def check_if_spoke_is_not_ancestor(base_id: int, spoke_id: int): + ancestors_of_base = FacilityHubSpoke.objects.filter(spoke_id=base_id).values_list( + "hub_id", flat=True + ) + if spoke_id in ancestors_of_base: + raise serializers.ValidationError("This facility is already an ancestor hub") + for ancestor in ancestors_of_base: + check_if_spoke_is_not_ancestor(ancestor, spoke_id) + + class Facility(FacilityBaseModel, FacilityPermissionMixin): name = models.CharField(max_length=1000, blank=False, null=False) is_active = models.BooleanField(default=True) @@ -302,11 +314,9 @@ def get_facility_flags(self): CSV_MAKE_PRETTY = {"facility_type": (lambda x: REVERSE_FACILITY_TYPES[x])} -class FacilityHubSpoke(BaseModel): - hub = models.ForeignKey(Facility, on_delete=models.CASCADE, related_name="hub_set") - spoke = models.ForeignKey( - Facility, on_delete=models.CASCADE, related_name="spoke_set" - ) +class FacilityHubSpoke(BaseModel, FacilityRelatedPermissionMixin): + hub = models.ForeignKey(Facility, on_delete=models.CASCADE, related_name="spokes") + spoke = models.ForeignKey(Facility, on_delete=models.CASCADE, related_name="hubs") relationship = models.IntegerField( choices=HubRelationship.choices, default=HubRelationship.REGULAR_HUB ) @@ -332,13 +342,7 @@ class Meta: ] def save(self, *args, **kwargs): - if ( - not self.deleted - and FacilityHubSpoke.objects.filter( - Q(hub=self.hub, spoke=self.spoke) | Q(hub=self.spoke, spoke=self.hub) - ).first() - ): - raise ValueError("Facility is already a spoke/hub") + check_if_spoke_is_not_ancestor(self.hub.id, self.spoke.id) return super().save(*args, **kwargs) def __str__(self): diff --git a/care/facility/tests/test_facility_api.py b/care/facility/tests/test_facility_api.py index 1c77da68ea..a8dffba834 100644 --- a/care/facility/tests/test_facility_api.py +++ b/care/facility/tests/test_facility_api.py @@ -187,9 +187,9 @@ def test_add_spoke_no_permission(self): self.client.force_authenticate(user=self.user) response = self.client.post( f"/api/v1/facility/{facility.external_id}/spokes/", - {"hub": facility2.external_id}, + {"spoke": facility2.external_id}, ) - self.assertIs(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertIs(response.status_code, status.HTTP_403_FORBIDDEN) def test_delete_spoke_no_permission(self): facility = self.create_facility(self.super_user, self.district, self.local_body) @@ -202,4 +202,25 @@ def test_delete_spoke_no_permission(self): response = self.client.delete( f"/api/v1/facility/{facility.external_id}/spokes/{spoke.external_id}/" ) - self.assertIs(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertIs(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_spoke_is_not_ancestor(self): + facility_a = self.create_facility( + self.super_user, self.district, self.local_body + ) + facility_b = self.create_facility( + self.super_user, self.district, self.local_body + ) + facility_c = self.create_facility( + self.super_user, self.district, self.local_body + ) + + FacilityHubSpoke.objects.create(hub=facility_a, spoke=facility_b) + FacilityHubSpoke.objects.create(hub=facility_b, spoke=facility_c) + + self.client.force_authenticate(user=self.super_user) + response = self.client.post( + f"/api/v1/facility/{facility_c.external_id}/spokes/", + {"spoke": facility_a.external_id}, + ) + self.assertIs(response.status_code, status.HTTP_400_BAD_REQUEST)