diff --git a/care/facility/api/serializers/asset.py b/care/facility/api/serializers/asset.py index 1c7d6eb52b..410f7719eb 100644 --- a/care/facility/api/serializers/asset.py +++ b/care/facility/api/serializers/asset.py @@ -29,7 +29,10 @@ StatusChoices, UserDefaultAssetLocation, ) -from care.users.api.serializers.user import UserBaseMinimumSerializer +from care.users.api.serializers.user import ( + UserAssignedSerializer, + UserBaseMinimumSerializer, +) from care.utils.assetintegration.hl7monitor import HL7MonitorAsset from care.utils.assetintegration.onvif import OnvifAsset from care.utils.assetintegration.ventilator import VentilatorAsset @@ -42,6 +45,11 @@ class AssetLocationSerializer(ModelSerializer): facility = FacilityBareMinimumSerializer(read_only=True) id = UUIDField(source="external_id", read_only=True) location_type = ChoiceField(choices=AssetLocation.RoomTypeChoices) + users = serializers.SerializerMethodField() + + def get_users(self, obj): + users = obj.users.filter(assetlocationdutystaff__deleted=False) + return UserAssignedSerializer(users, many=True, read_only=True).data def validate_middleware_address(self, value): value = (value or "").strip() @@ -130,7 +138,10 @@ def update(self, instance, validated_data): "type": "object", "properties": { "hostname": {"type": "string"}, - "source": {"type": "string", "enum": ["asset", "location", "facility"]}, + "source": { + "type": "string", + "enum": ["asset", "location", "facility"], + }, }, "nullable": True, } @@ -214,7 +225,9 @@ def create(self, validated_data): asset_instance = super().create(validated_data) if last_serviced_on or note: asset_service = AssetService( - asset=asset_instance, serviced_on=last_serviced_on, note=note + asset=asset_instance, + serviced_on=last_serviced_on, + note=note, ) asset_service.save() asset_instance.last_service = asset_service diff --git a/care/facility/api/viewsets/asset.py b/care/facility/api/viewsets/asset.py index 0a1c423397..4b7ba0200a 100644 --- a/care/facility/api/viewsets/asset.py +++ b/care/facility/api/viewsets/asset.py @@ -42,12 +42,14 @@ Asset, AssetAvailabilityRecord, AssetLocation, + AssetLocationDutyStaff, AssetService, AssetTransaction, ConsultationBedAsset, UserDefaultAssetLocation, ) from care.facility.models.asset import AssetTypeChoices, StatusChoices +from care.users.api.serializers.user import UserBaseMinimumSerializer from care.users.models import User from care.utils.assetintegration.asset_classes import AssetClasses from care.utils.assetintegration.base import BaseAssetIntegration @@ -118,6 +120,82 @@ def get_facility(self): def perform_create(self, serializer): serializer.save(facility=self.get_facility()) + @extend_schema(tags=["asset_location"]) + @action(methods=["POST"], detail=True) + def duty_staff(self, request, facility_external_id, external_id): + """ + Endpoint for assigning staffs to asset location + """ + + asset: AssetLocation = self.get_object() + duty_staff = request.data.get("duty_staff") + + if not duty_staff: + return Response(status=status.HTTP_204_NO_CONTENT) + + query = AssetLocationDutyStaff.objects.filter( + asset_location=asset, user__id=duty_staff, deleted=False + ) + + if query.exists(): + raise ValidationError( + {"duty_staff": "Staff already assigned to the location"} + ) + + user = User.objects.filter(id=duty_staff, home_facility=asset.facility) + if not user.exists(): + raise ValidationError( + {"duty_staff": "Staff does not belong to the facility"} + ) + + AssetLocationDutyStaff.objects.create( + asset_location=asset, user=user.first(), created_by=request.user + ) + + return Response(status=status.HTTP_201_CREATED) + + @extend_schema(tags=["asset_location"]) + @duty_staff.mapping.get + def duty_staff_get(self, request, facility_external_id, external_id): + """ + Endpoint for getting staffs from asset location + """ + + asset: AssetLocation = self.get_object() + + duty_staff = User.objects.filter( + id__in=AssetLocationDutyStaff.objects.filter( + asset_location=asset, deleted=False + ).values_list("user__id", flat=True) + ) + + return Response( + UserBaseMinimumSerializer(duty_staff, many=True).data, + status=status.HTTP_200_OK, + ) + + @extend_schema(tags=["asset_location"]) + @duty_staff.mapping.delete + def duty_staff_delete(self, request, facility_external_id, external_id): + """ + Endpoint for removing staffs from asset location + """ + + asset: AssetLocation = self.get_object() + + if "duty_staff" not in request.data: + raise ValidationError({"duty_staff": "Staff is required"}) + + duty_staff = request.data.get("duty_staff") + if not duty_staff: + raise ValidationError({"duty_staff": "Staff is required"}) + + AssetLocationDutyStaff.objects.filter( + asset_location=asset, user__id=duty_staff + ).update(deleted=True) + + return Response(status=status.HTTP_204_NO_CONTENT) + class AssetFilter(filters.FilterSet): facility = filters.UUIDFilter(field_name="current_location__facility__external_id") @@ -260,7 +338,9 @@ def list(self, request, *args, **kwargs): queryset = self.filter_queryset(self.get_queryset()).values(*mapping.keys()) pretty_mapping = Asset.CSV_MAKE_PRETTY.copy() return render_to_csv_response( - queryset, field_header_map=mapping, field_serializer_map=pretty_mapping + queryset, + field_header_map=mapping, + field_serializer_map=pretty_mapping, ) return super(AssetViewSet, self).list(request, *args, **kwargs) diff --git a/care/facility/api/viewsets/facility_users.py b/care/facility/api/viewsets/facility_users.py index 578f326849..5d7213d13e 100644 --- a/care/facility/api/viewsets/facility_users.py +++ b/care/facility/api/viewsets/facility_users.py @@ -17,6 +17,9 @@ class UserFilter(filters.FilterSet): choices=[(key, key) for key in User.TYPE_VALUE_MAP], coerce=lambda role: User.TYPE_VALUE_MAP[role], ) + home_facility = filters.UUIDFilter( + field_name="home_facility__external_id", lookup_expr="exact" + ) class Meta: model = User diff --git a/care/facility/migrations/0398_assetlocationdutystaff_assetlocation_users.py b/care/facility/migrations/0398_assetlocationdutystaff_assetlocation_users.py new file mode 100644 index 0000000000..6b005e685e --- /dev/null +++ b/care/facility/migrations/0398_assetlocationdutystaff_assetlocation_users.py @@ -0,0 +1,79 @@ +# Generated by Django 4.2.5 on 2023-11-17 13:25 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("facility", "0397_truncate_discharge_time"), + ] + + operations = [ + migrations.CreateModel( + name="AssetLocationDutyStaff", + 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)), + ( + "asset_location", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="facility.assetlocation", + ), + ), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.AddField( + model_name="assetlocation", + name="users", + field=models.ManyToManyField( + blank=True, + related_name="duty_staff", + through="facility.AssetLocationDutyStaff", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/care/facility/migrations/0403_merge_20231215_0845.py b/care/facility/migrations/0403_merge_20231215_0845.py new file mode 100644 index 0000000000..81a53eed70 --- /dev/null +++ b/care/facility/migrations/0403_merge_20231215_0845.py @@ -0,0 +1,12 @@ +# Generated by Django 4.2.5 on 2023-12-15 03:15 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("facility", "0398_assetlocationdutystaff_assetlocation_users"), + ("facility", "0402_patientconsultation_new_discharge_reason"), + ] + + operations = [] diff --git a/care/facility/models/asset.py b/care/facility/models/asset.py index 81e43cb82f..27f359c4f8 100644 --- a/care/facility/models/asset.py +++ b/care/facility/models/asset.py @@ -47,11 +47,43 @@ class RoomType(enum.Enum): Facility, on_delete=models.PROTECT, null=False, blank=False ) + users = models.ManyToManyField( + User, + through="AssetLocationDutyStaff", + related_name="duty_staff", + through_fields=("asset_location", "user"), + blank=True, + ) + middleware_address = models.CharField( null=True, blank=True, default=None, max_length=200 ) +class AssetLocationDutyStaff(BaseModel): + asset_location = models.ForeignKey( + AssetLocation, on_delete=models.CASCADE, null=False, blank=False + ) + user = models.ForeignKey(User, on_delete=models.CASCADE, null=False, blank=False) + + created_by = models.ForeignKey( + User, + on_delete=models.PROTECT, + null=False, + blank=False, + related_name="+", + ) + + def __str__(self): + return f"{self.user} under {self.asset_location.name}" + + CSV_MAPPING = { + "asset_location__name": "Asset Location Name", + "user__username": "User", + "created_by__username": "Assigned By", + } + + class AssetType(enum.Enum): INTERNAL = 50 EXTERNAL = 100 @@ -80,7 +112,11 @@ class Asset(BaseModel): choices=AssetTypeChoices, default=AssetType.INTERNAL.value ) asset_class = models.CharField( - choices=AssetClassChoices, default=None, null=True, blank=True, max_length=20 + choices=AssetClassChoices, + default=None, + null=True, + blank=True, + max_length=20, ) status = models.IntegerField(choices=StatusChoices, default=Status.ACTIVE.value) current_location = models.ForeignKey( @@ -91,7 +127,9 @@ class Asset(BaseModel): serial_number = models.CharField(max_length=1024, blank=True, null=True) warranty_details = models.TextField(null=True, blank=True, default="") # Deprecated meta = JSONField( - default=dict, blank=True, validators=[JSONFieldSchemaValidator(ASSET_META)] + default=dict, + blank=True, + validators=[JSONFieldSchemaValidator(ASSET_META)], ) # Vendor Details vendor_name = models.CharField(max_length=1024, blank=True, null=True) diff --git a/care/facility/tests/test_asset_location_api.py b/care/facility/tests/test_asset_location_api.py index 2fbb244553..4a9abc4d81 100644 --- a/care/facility/tests/test_asset_location_api.py +++ b/care/facility/tests/test_asset_location_api.py @@ -4,7 +4,7 @@ from care.utils.tests.test_utils import TestUtils -class AssetLocationViewSetTestCase(TestUtils, APITestCase): +class AssetLocationViewsetTestcase(TestUtils, APITestCase): @classmethod def setUpTestData(cls) -> None: cls.state = cls.create_state() @@ -29,7 +29,8 @@ def test_retrieve_asset_location(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["id"], str(self.asset_location.external_id)) self.assertEqual( - response.data["middleware_address"], self.asset_location.middleware_address + response.data["middleware_address"], + self.asset_location.middleware_address, ) def test_create_asset_location(self): @@ -45,7 +46,8 @@ def test_create_asset_location(self): self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.data["name"], sample_data["name"]) self.assertEqual( - response.data["middleware_address"], sample_data["middleware_address"] + response.data["middleware_address"], + sample_data["middleware_address"], ) def test_update_asset_location(self): @@ -61,7 +63,8 @@ def test_update_asset_location(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["name"], sample_data["name"]) self.assertEqual( - response.data["middleware_address"], sample_data["middleware_address"] + response.data["middleware_address"], + sample_data["middleware_address"], ) def test_create_asset_location_invalid_middleware(self): @@ -78,3 +81,48 @@ def test_create_asset_location_invalid_middleware(self): self.assertEqual( response.data["middleware_address"][0].code, "invalid_domain_name" ) + + def test_assign_duty_staff(self): + # creating sample doctor and staff + + created_user = self.create_user( + "doctor1", + self.district, + home_facility=self.facility, + user_type=15, + ) + + data = {"duty_staff": created_user.id} + + response = self.client.post( + f"/api/v1/facility/{self.facility.external_id}/asset_location/{self.asset_location.external_id}/duty_staff/", + data, + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_remove_duty_staff(self): + # creating sample doctor and staff + + created_user = self.create_user( + "doctor1", + self.district, + home_facility=self.facility, + user_type=15, + ) + + data = {"duty_staff": created_user.id} + + response = self.client.post( + f"/api/v1/facility/{self.facility.external_id}/asset_location/{self.asset_location.external_id}/duty_staff/", + data, + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + response = self.client.delete( + f"/api/v1/facility/{self.facility.external_id}/asset_location/{self.asset_location.external_id}/duty_staff/", + data, + ) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)