From 3619257d1352119b864c3075e12d3901181ca503 Mon Sep 17 00:00:00 2001 From: rithviknishad Date: Mon, 6 Jan 2025 17:27:10 +0530 Subject: [PATCH 01/15] API for cancelling existing booking --- care/emr/api/viewsets/scheduling/booking.py | 58 +++++++++++++++---- .../emr/resources/scheduling/schedule/spec.py | 4 -- care/emr/resources/scheduling/slot/spec.py | 7 ++- 3 files changed, 54 insertions(+), 15 deletions(-) diff --git a/care/emr/api/viewsets/scheduling/booking.py b/care/emr/api/viewsets/scheduling/booking.py index e9f89edaf2..7bd68627f5 100644 --- a/care/emr/api/viewsets/scheduling/booking.py +++ b/care/emr/api/viewsets/scheduling/booking.py @@ -1,5 +1,9 @@ +from typing import Literal + +from django.db import transaction from django_filters import CharFilter, DateFilter, FilterSet, UUIDFilter from django_filters.rest_framework import DjangoFilterBackend +from pydantic import BaseModel from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied from rest_framework.generics import get_object_or_404 @@ -7,19 +11,27 @@ from care.emr.api.viewsets.base import ( EMRBaseViewSet, - EMRDeleteMixin, EMRListMixin, EMRRetrieveMixin, EMRUpdateMixin, ) from care.emr.models.scheduling import SchedulableUserResource, TokenBooking from care.emr.resources.scheduling.slot.spec import ( + CANCELLED_STATUS_CHOICES, + BookingStatusChoices, TokenBookingReadSpec, - TokenBookingUpdateSpec, + TokenBookingWriteSpec, ) from care.emr.resources.user.spec import UserSpec from care.facility.models import Facility, FacilityOrganizationUser from care.security.authorization import AuthorizationController +from care.utils.lock import Lock + + +class CancelBookingSpec(BaseModel): + reason: Literal[ + BookingStatusChoices.cancelled, BookingStatusChoices.entered_in_error + ] class TokenBookingFilters(FilterSet): @@ -40,13 +52,20 @@ def filter_by_user(self, queryset, name, value): return queryset.filter(token_slot__resource=resource) +def lock_cancel_appointment(token_booking): + token_slot = token_booking.token_slot + with Lock(f"booking:slot:{token_slot.id}"), transaction.atomic(): + token_slot.allocated -= 1 + token_slot.save() + + class TokenBookingViewSet( - EMRRetrieveMixin, EMRUpdateMixin, EMRListMixin, EMRDeleteMixin, EMRBaseViewSet + EMRRetrieveMixin, EMRUpdateMixin, EMRListMixin, EMRBaseViewSet ): database_model = TokenBooking - pydantic_model = TokenBookingReadSpec + pydantic_model = TokenBookingWriteSpec pydantic_read_model = TokenBookingReadSpec - pydantic_update_model = TokenBookingUpdateSpec + pydantic_update_model = TokenBookingWriteSpec filterset_class = TokenBookingFilters filter_backends = [DjangoFilterBackend] @@ -57,10 +76,6 @@ def get_facility_obj(self): Facility, external_id=self.kwargs["facility_external_id"] ) - def authorize_delete(self, instance): - # TODO, need more depth to handle this case - pass - def authorize_update(self, request_obj, model_instance): if not AuthorizationController.call( "can_write_user_booking", @@ -68,7 +83,7 @@ def authorize_update(self, request_obj, model_instance): model_instance.token_slot.resource.facility, model_instance.token_slot.resource.user, ): - raise PermissionDenied("You do not have permission to view user schedule") + raise PermissionDenied("You do not have permission to update bookings") def get_queryset(self): facility = self.get_facility_obj() @@ -89,6 +104,29 @@ def get_queryset(self): .order_by("-modified_date") ) + @classmethod + def cancel_appointment_handler(cls, instance, request_data, user): + request_data = CancelBookingSpec(**request_data) + if instance.status not in CANCELLED_STATUS_CHOICES: + # Free up the slot if it is not cancelled already + lock_cancel_appointment(instance) + instance.status = request_data.reason + instance.updated_by = user + instance.save() + return Response({"status": request_data.reason}) + + @action(detail=True, methods=["POST"]) + def cancel(self, request, *args, **kwargs): + instance = self.get_object() + if not AuthorizationController.call( + "can_write_user_booking", + self.request.user, + instance.token_slot.resource.facility, + instance.token_slot.resource.user, + ): + raise PermissionDenied("You do not have permission to cancel bookings") + return self.cancel_appointment_handler(instance, request.data, request.user) + @action(detail=False, methods=["GET"]) def available_users(self, request, *args, **kwargs): facility = Facility.objects.get(external_id=self.kwargs["facility_external_id"]) diff --git a/care/emr/resources/scheduling/schedule/spec.py b/care/emr/resources/scheduling/schedule/spec.py index 556d190dac..499265b067 100644 --- a/care/emr/resources/scheduling/schedule/spec.py +++ b/care/emr/resources/scheduling/schedule/spec.py @@ -21,10 +21,6 @@ class SlotTypeOptions(str, Enum): closed = "closed" -class ResourceTypeOptions(str, Enum): - user = "user" - - class AvailabilityDateTimeSpec(EMRResource): day_of_week: int = Field(le=6) start_time: datetime.time diff --git a/care/emr/resources/scheduling/slot/spec.py b/care/emr/resources/scheduling/slot/spec.py index 9c8f2de37a..25df6b5f90 100644 --- a/care/emr/resources/scheduling/slot/spec.py +++ b/care/emr/resources/scheduling/slot/spec.py @@ -2,6 +2,7 @@ from enum import Enum from pydantic import UUID4 +from rest_framework.exceptions import ValidationError from care.emr.models import TokenBooking from care.emr.models.scheduling.booking import TokenSlot @@ -64,9 +65,13 @@ class TokenBookingBaseSpec(EMRResource): __exclude__ = ["token_slot", "patient"] -class TokenBookingUpdateSpec(TokenBookingBaseSpec): +class TokenBookingWriteSpec(TokenBookingBaseSpec): status: BookingStatusChoices + def perform_extra_deserialization(self, is_update, obj): + if self.status in CANCELLED_STATUS_CHOICES: + raise ValidationError("Cannot cancel a booking. Use the cancel endpoint") + class TokenBookingReadSpec(TokenBookingBaseSpec): id: UUID4 | None = None From 298dc0b3d5ab63499e0fd931767a737d2c2f75c0 Mon Sep 17 00:00:00 2001 From: rithviknishad Date: Mon, 6 Jan 2025 17:27:20 +0530 Subject: [PATCH 02/15] fix exception creation not working --- .../viewsets/scheduling/availability_exceptions.py | 12 ++++++------ .../scheduling/availability_exception/spec.py | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/care/emr/api/viewsets/scheduling/availability_exceptions.py b/care/emr/api/viewsets/scheduling/availability_exceptions.py index 7594cd6e31..2dddc6b724 100644 --- a/care/emr/api/viewsets/scheduling/availability_exceptions.py +++ b/care/emr/api/viewsets/scheduling/availability_exceptions.py @@ -4,13 +4,14 @@ from rest_framework.generics import get_object_or_404 from care.emr.api.viewsets.base import EMRModelViewSet -from care.emr.models import AvailabilityException, SchedulableUserResource +from care.emr.models import AvailabilityException from care.emr.resources.scheduling.availability_exception.spec import ( AvailabilityExceptionReadSpec, AvailabilityExceptionWriteSpec, ) from care.facility.models import Facility from care.security.authorization import AuthorizationController +from care.users.models import User class AvailabilityExceptionFilters(FilterSet): @@ -38,14 +39,13 @@ def authorize_delete(self, instance): self.authorize_update({}, instance) def authorize_create(self, instance): - user_resource = get_object_or_404( - SchedulableUserResource, external_id=instance.resource - ) + facility = self.get_facility_obj() + schedule_user = get_object_or_404(User, external_id=instance.user) if not AuthorizationController.call( "can_write_user_schedule", self.request.user, - user_resource.facility, - user_resource.user, + facility, + schedule_user, ): raise PermissionDenied("You do not have permission to view user schedule") diff --git a/care/emr/resources/scheduling/availability_exception/spec.py b/care/emr/resources/scheduling/availability_exception/spec.py index c54ba987ab..dbdd4d0ca9 100644 --- a/care/emr/resources/scheduling/availability_exception/spec.py +++ b/care/emr/resources/scheduling/availability_exception/spec.py @@ -30,13 +30,13 @@ class AvailabilityExceptionBaseSpec(EMRResource): class AvailabilityExceptionWriteSpec(AvailabilityExceptionBaseSpec): facility: UUID4 | None = None - resource: UUID4 + user: UUID4 def perform_extra_deserialization(self, is_update, obj): if not is_update: resource = None try: - user = User.objects.get(external_id=self.resource) + user = User.objects.get(external_id=self.user) resource = SchedulableUserResource.objects.get( user=user, facility=Facility.objects.get(external_id=self.facility), From 32adbcb946b2cf97416f4d283ee3fcbb0e531e35 Mon Sep 17 00:00:00 2001 From: rithviknishad Date: Mon, 6 Jan 2025 21:16:40 +0530 Subject: [PATCH 03/15] handle delete schedule --- care/emr/api/viewsets/scheduling/booking.py | 8 +------- care/emr/api/viewsets/scheduling/schedule.py | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/care/emr/api/viewsets/scheduling/booking.py b/care/emr/api/viewsets/scheduling/booking.py index 7bd68627f5..afa5f48b83 100644 --- a/care/emr/api/viewsets/scheduling/booking.py +++ b/care/emr/api/viewsets/scheduling/booking.py @@ -118,13 +118,7 @@ def cancel_appointment_handler(cls, instance, request_data, user): @action(detail=True, methods=["POST"]) def cancel(self, request, *args, **kwargs): instance = self.get_object() - if not AuthorizationController.call( - "can_write_user_booking", - self.request.user, - instance.token_slot.resource.facility, - instance.token_slot.resource.user, - ): - raise PermissionDenied("You do not have permission to cancel bookings") + self.authorize_update({}, instance) return self.cancel_appointment_handler(instance, request.data, request.user) @action(detail=False, methods=["GET"]) diff --git a/care/emr/api/viewsets/scheduling/schedule.py b/care/emr/api/viewsets/scheduling/schedule.py index 04f39bb44a..5aba553d7f 100644 --- a/care/emr/api/viewsets/scheduling/schedule.py +++ b/care/emr/api/viewsets/scheduling/schedule.py @@ -1,4 +1,5 @@ from django.db import transaction +from django.utils import timezone from django_filters import FilterSet, UUIDFilter from django_filters.rest_framework import DjangoFilterBackend from rest_framework.exceptions import PermissionDenied, ValidationError @@ -6,6 +7,7 @@ from care.emr.api.viewsets.base import EMRModelViewSet from care.emr.models.organization import FacilityOrganizationUser +from care.emr.models.scheduling.booking import TokenSlot from care.emr.models.scheduling.schedule import Schedule from care.emr.resources.scheduling.schedule.spec import ( ScheduleReadSpec, @@ -15,6 +17,7 @@ from care.facility.models import Facility from care.security.authorization import AuthorizationController from care.users.models import User +from care.utils.lock import Lock class ScheduleFilters(FilterSet): @@ -43,6 +46,22 @@ def perform_create(self, instance): availability_obj.schedule = instance availability_obj.save() + def perform_delete(self, instance): + with Lock(f"booking:resource:{instance.resource.id}"), transaction.atomic(): + # Check if there are any tokens allocated for this schedule in the future + availability_ids = instance.availability_set.values_list("id", flat=True) + has_future_bookings = TokenSlot.objects.filter( + resource=instance.resource, + availability_id__in=availability_ids, + start_datetime__gt=timezone.now(), + allocated__gt=0, + ).exists() + if has_future_bookings: + raise ValidationError( + "Cannot delete schedule as there are future bookings associated with it" + ) + super().perform_delete(instance) + def authorize_delete(self, instance): self.authorize_update({}, instance) From 0cc83b30e1e46e535e6a245f67a2d3271036c7bf Mon Sep 17 00:00:00 2001 From: rithviknishad Date: Mon, 6 Jan 2025 21:32:37 +0530 Subject: [PATCH 04/15] cascade delete availabilities --- care/emr/api/viewsets/scheduling/schedule.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/care/emr/api/viewsets/scheduling/schedule.py b/care/emr/api/viewsets/scheduling/schedule.py index 5aba553d7f..002d1a9fea 100644 --- a/care/emr/api/viewsets/scheduling/schedule.py +++ b/care/emr/api/viewsets/scheduling/schedule.py @@ -49,10 +49,10 @@ def perform_create(self, instance): def perform_delete(self, instance): with Lock(f"booking:resource:{instance.resource.id}"), transaction.atomic(): # Check if there are any tokens allocated for this schedule in the future - availability_ids = instance.availability_set.values_list("id", flat=True) + availabilities = instance.availability_set.all() has_future_bookings = TokenSlot.objects.filter( resource=instance.resource, - availability_id__in=availability_ids, + availability_id__in=availabilities.values_list("id", flat=True), start_datetime__gt=timezone.now(), allocated__gt=0, ).exists() @@ -60,6 +60,7 @@ def perform_delete(self, instance): raise ValidationError( "Cannot delete schedule as there are future bookings associated with it" ) + availabilities.update(deleted=True) super().perform_delete(instance) def authorize_delete(self, instance): From 6211a680cec629b1ccf0c96e01e16b8def633129 Mon Sep 17 00:00:00 2001 From: rithviknishad Date: Tue, 7 Jan 2025 11:31:46 +0530 Subject: [PATCH 05/15] review suggestions --- .../api/viewsets/scheduling/availability.py | 3 +- care/emr/api/viewsets/scheduling/booking.py | 29 ++++++++----------- care/emr/api/viewsets/scheduling/schedule.py | 8 +++-- .../0002_alter_availability_schedule.py | 23 +++++++++++++++ care/emr/models/scheduling/schedule.py | 4 ++- .../emr/resources/scheduling/schedule/spec.py | 5 ++++ 6 files changed, 50 insertions(+), 22 deletions(-) create mode 100644 care/emr/migrations/0002_alter_availability_schedule.py diff --git a/care/emr/api/viewsets/scheduling/availability.py b/care/emr/api/viewsets/scheduling/availability.py index 1f6fe340e1..5db9120353 100644 --- a/care/emr/api/viewsets/scheduling/availability.py +++ b/care/emr/api/viewsets/scheduling/availability.py @@ -179,8 +179,7 @@ def get_slots_for_day_handler(cls, facility_external_id, request_data): # Get list of all slots, create if missed # Return slots - @classmethod - def create_appointment_handler(cls, obj, request_data, user): + def create_appointment_handler(self, obj, request_data, user): request_data = AppointmentBookingSpec(**request_data) patient = Patient.objects.filter(external_id=request_data.patient).first() if not patient: diff --git a/care/emr/api/viewsets/scheduling/booking.py b/care/emr/api/viewsets/scheduling/booking.py index afa5f48b83..1cfe43d48a 100644 --- a/care/emr/api/viewsets/scheduling/booking.py +++ b/care/emr/api/viewsets/scheduling/booking.py @@ -25,7 +25,6 @@ from care.emr.resources.user.spec import UserSpec from care.facility.models import Facility, FacilityOrganizationUser from care.security.authorization import AuthorizationController -from care.utils.lock import Lock class CancelBookingSpec(BaseModel): @@ -52,13 +51,6 @@ def filter_by_user(self, queryset, name, value): return queryset.filter(token_slot__resource=resource) -def lock_cancel_appointment(token_booking): - token_slot = token_booking.token_slot - with Lock(f"booking:slot:{token_slot.id}"), transaction.atomic(): - token_slot.allocated -= 1 - token_slot.save() - - class TokenBookingViewSet( EMRRetrieveMixin, EMRUpdateMixin, EMRListMixin, EMRBaseViewSet ): @@ -104,16 +96,19 @@ def get_queryset(self): .order_by("-modified_date") ) - @classmethod - def cancel_appointment_handler(cls, instance, request_data, user): + def cancel_appointment_handler(self, instance, request_data, user): request_data = CancelBookingSpec(**request_data) - if instance.status not in CANCELLED_STATUS_CHOICES: - # Free up the slot if it is not cancelled already - lock_cancel_appointment(instance) - instance.status = request_data.reason - instance.updated_by = user - instance.save() - return Response({"status": request_data.reason}) + with transaction.atomic(): + if instance.status not in CANCELLED_STATUS_CHOICES: + # Free up the slot if it is not cancelled already + instance.token_slot.allocated -= 1 + instance.token_slot.save() + instance.status = request_data.reason + instance.updated_by = user + instance.save() + return Response( + TokenBookingReadSpec.serialize(instance).model_dump(exclude=["meta"]) + ) @action(detail=True, methods=["POST"]) def cancel(self, request, *args, **kwargs): diff --git a/care/emr/api/viewsets/scheduling/schedule.py b/care/emr/api/viewsets/scheduling/schedule.py index 002d1a9fea..cd89eab9b6 100644 --- a/care/emr/api/viewsets/scheduling/schedule.py +++ b/care/emr/api/viewsets/scheduling/schedule.py @@ -49,7 +49,7 @@ def perform_create(self, instance): def perform_delete(self, instance): with Lock(f"booking:resource:{instance.resource.id}"), transaction.atomic(): # Check if there are any tokens allocated for this schedule in the future - availabilities = instance.availability_set.all() + availabilities = instance.availabilities.all() has_future_bookings = TokenSlot.objects.filter( resource=instance.resource, availability_id__in=availabilities.values_list("id", flat=True), @@ -69,7 +69,11 @@ def authorize_delete(self, instance): def validate_data(self, instance, model_obj=None): # Validate user is part of the facility facility = self.get_facility_obj() - schedule_user = get_object_or_404(User, external_id=instance.user) + schedule_user = ( + model_obj.resource.user + if model_obj + else get_object_or_404(User, external_id=instance.user) + ) if not FacilityOrganizationUser.objects.filter( user=schedule_user, organization__facility=facility ).exists(): diff --git a/care/emr/migrations/0002_alter_availability_schedule.py b/care/emr/migrations/0002_alter_availability_schedule.py new file mode 100644 index 0000000000..71aa46b45b --- /dev/null +++ b/care/emr/migrations/0002_alter_availability_schedule.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.3 on 2025-01-07 05:22 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("emr", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="availability", + name="schedule", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="availabilities", + to="emr.schedule", + ), + ), + ] diff --git a/care/emr/models/scheduling/schedule.py b/care/emr/models/scheduling/schedule.py index 6d10ba55ff..d3fc7e9bc1 100644 --- a/care/emr/models/scheduling/schedule.py +++ b/care/emr/models/scheduling/schedule.py @@ -20,7 +20,9 @@ class Schedule(EMRBaseModel): class Availability(EMRBaseModel): - schedule = models.ForeignKey(Schedule, on_delete=models.CASCADE) + schedule = models.ForeignKey( + Schedule, on_delete=models.CASCADE, related_name="availabilities" + ) name = models.CharField(max_length=255) slot_type = models.CharField() slot_size_in_minutes = models.IntegerField(null=False, blank=False, default=0) diff --git a/care/emr/resources/scheduling/schedule/spec.py b/care/emr/resources/scheduling/schedule/spec.py index 499265b067..5572aa7339 100644 --- a/care/emr/resources/scheduling/schedule/spec.py +++ b/care/emr/resources/scheduling/schedule/spec.py @@ -57,6 +57,11 @@ class ScheduleWriteSpec(ScheduleBaseSpec): valid_to: datetime.datetime availabilities: list[AvailabilityBaseSpec] + @model_validator(mode="after") + def validate_period(self): + if self.valid_from > self.valid_to: + raise ValidationError("Valid from cannot be greater than valid to") + def perform_extra_deserialization(self, is_update, obj): if not is_update: user = get_object_or_404(User, external_id=self.user) From 1ccfe16f6d5fd6acb572d8e432129f707b95a6dc Mon Sep 17 00:00:00 2001 From: rithviknishad Date: Tue, 7 Jan 2025 11:34:29 +0530 Subject: [PATCH 06/15] validation for schedule validity update --- .../emr/resources/scheduling/schedule/spec.py | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/care/emr/resources/scheduling/schedule/spec.py b/care/emr/resources/scheduling/schedule/spec.py index 5572aa7339..c8d8d5caa5 100644 --- a/care/emr/resources/scheduling/schedule/spec.py +++ b/care/emr/resources/scheduling/schedule/spec.py @@ -1,9 +1,12 @@ import datetime from enum import Enum -from pydantic import UUID4, Field +from django.db.models import Sum +from pydantic import UUID4, Field, model_validator +from rest_framework.exceptions import ValidationError from rest_framework.generics import get_object_or_404 +from care.emr.models.scheduling.booking import TokenSlot from care.emr.models.scheduling.schedule import ( Availability, SchedulableUserResource, @@ -83,6 +86,38 @@ class ScheduleUpdateSpec(ScheduleBaseSpec): valid_from: datetime.datetime valid_to: datetime.datetime + def perform_extra_deserialization(self, is_update, obj): + old_instance = Schedule.objects.get(id=obj.id) + + # Get sum of allocated tokens in old date range + old_allocated_sum = ( + TokenSlot.objects.filter( + resource=old_instance.resource, + availability__schedule__id=obj.id, + start_datetime__gte=old_instance.valid_from, + start_datetime__lte=old_instance.valid_to, + ).aggregate(total=Sum("allocated"))["total"] + or 0 + ) + + # Get sum of allocated tokens in new validity range + new_allocated_sum = ( + TokenSlot.objects.filter( + resource=old_instance.resource, + availability__schedule__id=obj.id, + start_datetime__gte=self.valid_from, + start_datetime__lte=self.valid_to, + ).aggregate(total=Sum("allocated"))["total"] + or 0 + ) + + if old_allocated_sum != new_allocated_sum: + msg = ( + "Cannot modify schedule validity as it would exclude some allocated slots. " + f"Old range has {old_allocated_sum} allocated slots while new range has {new_allocated_sum} allocated slots." + ) + raise ValidationError(msg) + class ScheduleReadSpec(ScheduleBaseSpec): name: str From 049c3e5291cb0bbeb2ca5b18413882a02641f2b7 Mon Sep 17 00:00:00 2001 From: rithviknishad Date: Tue, 7 Jan 2025 11:45:05 +0530 Subject: [PATCH 07/15] acquire lock for resource when updating schedule --- care/emr/api/viewsets/scheduling/schedule.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/care/emr/api/viewsets/scheduling/schedule.py b/care/emr/api/viewsets/scheduling/schedule.py index cd89eab9b6..3c1003d77b 100644 --- a/care/emr/api/viewsets/scheduling/schedule.py +++ b/care/emr/api/viewsets/scheduling/schedule.py @@ -46,6 +46,10 @@ def perform_create(self, instance): availability_obj.schedule = instance availability_obj.save() + def perform_update(self, instance): + with Lock(f"booking:resource:{instance.resource.id}"): + super().perform_update(instance) + def perform_delete(self, instance): with Lock(f"booking:resource:{instance.resource.id}"), transaction.atomic(): # Check if there are any tokens allocated for this schedule in the future From 9f7f57e92d1545769e62c9807150c7376e004343 Mon Sep 17 00:00:00 2001 From: rithviknishad Date: Tue, 7 Jan 2025 13:35:07 +0530 Subject: [PATCH 08/15] CRUD APIs for managing availabilities of a schedule --- care/emr/api/viewsets/scheduling/schedule.py | 80 +++++++++++++++++-- .../emr/resources/scheduling/schedule/spec.py | 63 ++++++++++++--- config/api_router.py | 12 ++- 3 files changed, 139 insertions(+), 16 deletions(-) diff --git a/care/emr/api/viewsets/scheduling/schedule.py b/care/emr/api/viewsets/scheduling/schedule.py index 3c1003d77b..ab821b975e 100644 --- a/care/emr/api/viewsets/scheduling/schedule.py +++ b/care/emr/api/viewsets/scheduling/schedule.py @@ -8,8 +8,10 @@ from care.emr.api.viewsets.base import EMRModelViewSet from care.emr.models.organization import FacilityOrganizationUser from care.emr.models.scheduling.booking import TokenSlot -from care.emr.models.scheduling.schedule import Schedule +from care.emr.models.scheduling.schedule import Availability, Schedule from care.emr.resources.scheduling.schedule.spec import ( + AvailabilityForScheduleSpec, + AvailabilityUpdateSpec, ScheduleReadSpec, ScheduleUpdateSpec, ScheduleWriteSpec, @@ -67,9 +69,6 @@ def perform_delete(self, instance): availabilities.update(deleted=True) super().perform_delete(instance) - def authorize_delete(self, instance): - self.authorize_update({}, instance) - def validate_data(self, instance, model_obj=None): # Validate user is part of the facility facility = self.get_facility_obj() @@ -89,7 +88,7 @@ def authorize_create(self, instance): if not AuthorizationController.call( "can_write_user_schedule", self.request.user, facility, schedule_user ): - raise PermissionDenied("You do not have permission to view user schedule") + raise PermissionDenied("You do not have permission to create schedule") def authorize_update(self, request_obj, model_instance): if not AuthorizationController.call( @@ -100,6 +99,9 @@ def authorize_update(self, request_obj, model_instance): ): raise PermissionDenied("You do not have permission to view user schedule") + def authorize_delete(self, instance): + self.authorize_update({}, instance) + def clean_create_data(self, request_data): request_data["facility"] = self.kwargs["facility_external_id"] return request_data @@ -117,3 +119,71 @@ def get_queryset(self): .select_related("resource", "created_by", "updated_by") .order_by("-modified_date") ) + + +class AvailabilityViewSet(EMRModelViewSet): + database_model = Availability + pydantic_model = AvailabilityForScheduleSpec + pydantic_update_model = AvailabilityUpdateSpec + pydantic_read_model = AvailabilityForScheduleSpec + + def get_facility_obj(self): + return get_object_or_404( + Facility, external_id=self.kwargs["facility_external_id"] + ) + + def get_schedule_obj(self): + return get_object_or_404( + Schedule, external_id=self.kwargs["schedule_external_id"] + ) + + def get_queryset(self): + facility = self.get_facility_obj() + if not AuthorizationController.call( + "can_list_user_schedule", self.request.user, facility + ): + raise PermissionDenied("You do not have permission to view user schedule") + return ( + super() + .get_queryset() + .filter(schedule=self.get_schedule_obj()) + .select_related( + "schedule", + "schedule__resource", + "schedule__resource__user", + "created_by", + "updated_by", + ) + .order_by("-modified_date") + ) + + def perform_update(self, instance): + with Lock(f"booking:resource:{instance.schedule.resource.id}"): + super().perform_update(instance) + + def perform_delete(self, instance): + with Lock(f"booking:resource:{instance.schedule.resource.id}"): + has_future_bookings = TokenSlot.objects.filter( + availability_id=instance.id, + start_datetime__gt=timezone.now(), + allocated__gt=0, + ).exists() + if has_future_bookings: + raise ValidationError( + "Cannot delete availability as there are future bookings associated with it" + ) + super().perform_delete(instance) + + def authorize_create(self, instance): + facility = self.get_facility_obj() + schedule_user = self.get_schedule_obj().resource.user + if not AuthorizationController.call( + "can_write_user_schedule", self.request.user, facility, schedule_user + ): + raise PermissionDenied("You do not have permission to create schedule") + + def authorize_update(self, request_obj, model_instance): + self.authorize_create({}, model_instance) + + def authorize_delete(self, instance): + self.authorize_update({}, instance) diff --git a/care/emr/resources/scheduling/schedule/spec.py b/care/emr/resources/scheduling/schedule/spec.py index c8d8d5caa5..99b526cf76 100644 --- a/care/emr/resources/scheduling/schedule/spec.py +++ b/care/emr/resources/scheduling/schedule/spec.py @@ -1,8 +1,9 @@ import datetime from enum import Enum -from django.db.models import Sum -from pydantic import UUID4, Field, model_validator +from django.db.models import Max, Sum +from django.utils import timezone +from pydantic import UUID4, Field, field_validator, model_validator from rest_framework.exceptions import ValidationError from rest_framework.generics import get_object_or_404 @@ -32,18 +33,62 @@ class AvailabilityDateTimeSpec(EMRResource): class AvailabilityBaseSpec(EMRResource): __model__ = Availability + __exclude__ = ["schedule"] id: UUID4 | None = None + + # TODO Check if Availability Types are coinciding at any point + + @classmethod + @field_validator("availability", mode="after") + def validate_availability(cls, availabilities: list[AvailabilityDateTimeSpec]): + # Validates if availability overlaps for the same day + for i in range(len(availabilities)): + for j in range(i + 1, len(availabilities)): + if availabilities[i].day_of_week != availabilities[j].day_of_week: + continue + # Check if time ranges overlap + if ( + availabilities[i].start_time <= availabilities[j].end_time + and availabilities[j].start_time <= availabilities[i].end_time + ): + raise ValueError("Availability time ranges are overlapping") + return availabilities + + +class AvailabilityUpdateSpec(AvailabilityBaseSpec): + name: str + tokens_per_slot: int = Field(ge=1) + reason: str = "" + + def perform_extra_deserialization(self, is_update, obj): + old_instance = Availability.objects.get(id=obj.id) + + future_slots = TokenSlot.objects.filter( + availability__id=obj.id, + start_datetime__gte=timezone.now(), + ) + + # Prevents editing tokens per slot below the max allocated tokens of future slots + if old_instance.tokens_per_slot != self.tokens_per_slot: + max_allocated = future_slots.aggregate(max=Max("allocated"))["max"] or 0 + if max_allocated > self.tokens_per_slot: + msg = ( + "Cannot modify tokens per slot as it would exclude some allocated slots. " + f"Max allocated tokens is {max_allocated} while new tokens per slot is {self.tokens_per_slot}." + ) + raise ValidationError(msg) + + +class AvailabilityForScheduleSpec(AvailabilityBaseSpec): name: str slot_type: SlotTypeOptions - slot_size_in_minutes: int - tokens_per_slot: int + slot_size_in_minutes: int = Field(ge=1) + tokens_per_slot: int = Field(ge=1) create_tokens: bool = False reason: str = "" availability: list[AvailabilityDateTimeSpec] - # TODO Check if Availability Types are coinciding at any point - class ScheduleBaseSpec(EMRResource): __model__ = Schedule @@ -58,7 +103,7 @@ class ScheduleWriteSpec(ScheduleBaseSpec): name: str valid_from: datetime.datetime valid_to: datetime.datetime - availabilities: list[AvailabilityBaseSpec] + availabilities: list[AvailabilityForScheduleSpec] @model_validator(mode="after") def validate_period(self): @@ -69,8 +114,6 @@ def perform_extra_deserialization(self, is_update, obj): if not is_update: user = get_object_or_404(User, external_id=self.user) # TODO Validation that user is in given facility - if not user: - raise ValueError("User not found") obj.facility = Facility.objects.get(external_id=self.facility) resource, _ = SchedulableUserResource.objects.get_or_create( @@ -137,6 +180,6 @@ def perform_extra_serialization(cls, mapping, obj): mapping["updated_by"] = UserSpec.serialize(obj.updated_by) mapping["availabilities"] = [ - AvailabilityBaseSpec.serialize(o) + AvailabilityForScheduleSpec.serialize(o) for o in Availability.objects.filter(schedule=obj) ] diff --git a/config/api_router.py b/config/api_router.py index b5ac5e8ed1..eeda324113 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -40,7 +40,11 @@ ResourceRequestViewSet, ) from care.emr.api.viewsets.roles import RoleViewSet -from care.emr.api.viewsets.scheduling import ScheduleViewSet, SlotViewSet +from care.emr.api.viewsets.scheduling import ( + AvailabilityViewSet, + ScheduleViewSet, + SlotViewSet, +) from care.emr.api.viewsets.scheduling.availability_exceptions import ( AvailabilityExceptionsViewSet, ) @@ -168,6 +172,12 @@ ) facility_nested_router.register(r"schedule", ScheduleViewSet, basename="schedule") +schedule_nested_router = NestedSimpleRouter( + facility_nested_router, r"schedule", lookup="schedule" +) +schedule_nested_router.register( + r"availability", AvailabilityViewSet, basename="schedule-availability" +) facility_nested_router.register(r"slots", SlotViewSet, basename="slot") From e7d24cba75864651b5ccd95bfba5af44bf8bed3c Mon Sep 17 00:00:00 2001 From: rithviknishad Date: Wed, 8 Jan 2025 16:29:12 +0530 Subject: [PATCH 09/15] add date range filter for /appointments --- care/emr/api/viewsets/scheduling/booking.py | 4 ++-- care/emr/api/viewsets/scheduling/schedule.py | 2 +- config/api_router.py | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/care/emr/api/viewsets/scheduling/booking.py b/care/emr/api/viewsets/scheduling/booking.py index 1cfe43d48a..7b58d7e5ba 100644 --- a/care/emr/api/viewsets/scheduling/booking.py +++ b/care/emr/api/viewsets/scheduling/booking.py @@ -1,7 +1,7 @@ from typing import Literal from django.db import transaction -from django_filters import CharFilter, DateFilter, FilterSet, UUIDFilter +from django_filters import CharFilter, DateFromToRangeFilter, FilterSet, UUIDFilter from django_filters.rest_framework import DjangoFilterBackend from pydantic import BaseModel from rest_framework.decorators import action @@ -35,7 +35,7 @@ class CancelBookingSpec(BaseModel): class TokenBookingFilters(FilterSet): status = CharFilter(field_name="status") - date = DateFilter(field_name="token_slot__start_datetime__date") + date = DateFromToRangeFilter(field_name="token_slot__start_datetime__date") slot = UUIDFilter(field_name="token_slot__external_id") user = UUIDFilter(method="filter_by_user") patient = UUIDFilter(field_name="patient__external_id") diff --git a/care/emr/api/viewsets/scheduling/schedule.py b/care/emr/api/viewsets/scheduling/schedule.py index ab821b975e..0a07803179 100644 --- a/care/emr/api/viewsets/scheduling/schedule.py +++ b/care/emr/api/viewsets/scheduling/schedule.py @@ -183,7 +183,7 @@ def authorize_create(self, instance): raise PermissionDenied("You do not have permission to create schedule") def authorize_update(self, request_obj, model_instance): - self.authorize_create({}, model_instance) + self.authorize_create(model_instance) def authorize_delete(self, instance): self.authorize_update({}, instance) diff --git a/config/api_router.py b/config/api_router.py index eeda324113..0b5d8a3e82 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -300,6 +300,7 @@ path("", include(router.urls)), path("", include(user_nested_router.urls)), path("", include(facility_nested_router.urls)), + path("", include(schedule_nested_router.urls)), # path("", include(facility_location_nested_router.urls)), # path("", include(asset_nested_router.urls)), # path("", include(bed_nested_router.urls)), From c1122e80b40eee8d2843b050bc992015383adbdc Mon Sep 17 00:00:00 2001 From: rithviknishad Date: Wed, 8 Jan 2025 18:50:46 +0530 Subject: [PATCH 10/15] revert related name due to conflict with spec --- care/emr/api/viewsets/scheduling/schedule.py | 2 +- .../0002_alter_availability_schedule.py | 23 ------------------- care/emr/models/scheduling/schedule.py | 4 +--- 3 files changed, 2 insertions(+), 27 deletions(-) delete mode 100644 care/emr/migrations/0002_alter_availability_schedule.py diff --git a/care/emr/api/viewsets/scheduling/schedule.py b/care/emr/api/viewsets/scheduling/schedule.py index 0a07803179..caaa5c2255 100644 --- a/care/emr/api/viewsets/scheduling/schedule.py +++ b/care/emr/api/viewsets/scheduling/schedule.py @@ -55,7 +55,7 @@ def perform_update(self, instance): def perform_delete(self, instance): with Lock(f"booking:resource:{instance.resource.id}"), transaction.atomic(): # Check if there are any tokens allocated for this schedule in the future - availabilities = instance.availabilities.all() + availabilities = instance.availability_set.all() has_future_bookings = TokenSlot.objects.filter( resource=instance.resource, availability_id__in=availabilities.values_list("id", flat=True), diff --git a/care/emr/migrations/0002_alter_availability_schedule.py b/care/emr/migrations/0002_alter_availability_schedule.py deleted file mode 100644 index 71aa46b45b..0000000000 --- a/care/emr/migrations/0002_alter_availability_schedule.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.1.3 on 2025-01-07 05:22 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("emr", "0001_initial"), - ] - - operations = [ - migrations.AlterField( - model_name="availability", - name="schedule", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="availabilities", - to="emr.schedule", - ), - ), - ] diff --git a/care/emr/models/scheduling/schedule.py b/care/emr/models/scheduling/schedule.py index d3fc7e9bc1..6d10ba55ff 100644 --- a/care/emr/models/scheduling/schedule.py +++ b/care/emr/models/scheduling/schedule.py @@ -20,9 +20,7 @@ class Schedule(EMRBaseModel): class Availability(EMRBaseModel): - schedule = models.ForeignKey( - Schedule, on_delete=models.CASCADE, related_name="availabilities" - ) + schedule = models.ForeignKey(Schedule, on_delete=models.CASCADE) name = models.CharField(max_length=255) slot_type = models.CharField() slot_size_in_minutes = models.IntegerField(null=False, blank=False, default=0) From 9139b88c1c4418a8f1a2527519709127941f8899 Mon Sep 17 00:00:00 2001 From: rithviknishad Date: Wed, 8 Jan 2025 19:10:37 +0530 Subject: [PATCH 11/15] allow patients to cancel the booking --- care/emr/api/otp_viewsets/slot.py | 27 +++++++++++++++++-- .../api/viewsets/scheduling/availability.py | 3 ++- care/emr/api/viewsets/scheduling/booking.py | 3 ++- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/care/emr/api/otp_viewsets/slot.py b/care/emr/api/otp_viewsets/slot.py index c6667142c4..903d3cc951 100644 --- a/care/emr/api/otp_viewsets/slot.py +++ b/care/emr/api/otp_viewsets/slot.py @@ -1,6 +1,7 @@ -from pydantic import UUID4 +from pydantic import UUID4, BaseModel from rest_framework.decorators import action from rest_framework.exceptions import ValidationError +from rest_framework.generics import get_object_or_404 from rest_framework.response import Response from care.emr.api.viewsets.base import EMRBaseViewSet, EMRRetrieveMixin @@ -9,9 +10,11 @@ SlotsForDayRequestSpec, SlotViewSet, ) +from care.emr.api.viewsets.scheduling.booking import TokenBookingViewSet from care.emr.models.patient import Patient from care.emr.models.scheduling import TokenBooking, TokenSlot from care.emr.resources.scheduling.slot.spec import ( + BookingStatusChoices, TokenBookingReadSpec, TokenSlotBaseSpec, ) @@ -25,6 +28,11 @@ class SlotsForDayRequestSpec(SlotsForDayRequestSpec): facility: UUID4 +class CancelAppointmentSpec(BaseModel): + patient: UUID4 + appointment: UUID4 + + class OTPSlotViewSet(EMRRetrieveMixin, EMRBaseViewSet): authentication_classes = [JWTTokenPatientAuthentication] permission_classes = [OTPAuthenticatedPermission] @@ -44,11 +52,26 @@ def create_appointment(self, request, *args, **kwargs): if not Patient.objects.filter( external_id=request_data.patient, phone_number=request.user.phone_number ).exists(): - raise ValidationError("Patient not allowed ") + raise ValidationError("Patient not allowed") return SlotViewSet.create_appointment_handler( self.get_object(), request.data, None ) + @action(detail=False, methods=["POST"]) + def cancel_appointment(self, request, *args, **kwargs): + request_data = CancelAppointmentSpec(**request.data) + patient = get_object_or_404( + Patient, + external_id=request_data.patient, + phone_number=request.user.phone_number, + ) + token_booking = get_object_or_404( + TokenBooking, external_id=request_data.appointment, patient=patient + ) + return TokenBookingViewSet.cancel_appointment_handler( + token_booking, {"reason": BookingStatusChoices.cancelled}, None + ) + @action(detail=False, methods=["GET"]) def get_appointments(self, request, *args, **kwargs): appointments = TokenBooking.objects.filter( diff --git a/care/emr/api/viewsets/scheduling/availability.py b/care/emr/api/viewsets/scheduling/availability.py index 5db9120353..1f6fe340e1 100644 --- a/care/emr/api/viewsets/scheduling/availability.py +++ b/care/emr/api/viewsets/scheduling/availability.py @@ -179,7 +179,8 @@ def get_slots_for_day_handler(cls, facility_external_id, request_data): # Get list of all slots, create if missed # Return slots - def create_appointment_handler(self, obj, request_data, user): + @classmethod + def create_appointment_handler(cls, obj, request_data, user): request_data = AppointmentBookingSpec(**request_data) patient = Patient.objects.filter(external_id=request_data.patient).first() if not patient: diff --git a/care/emr/api/viewsets/scheduling/booking.py b/care/emr/api/viewsets/scheduling/booking.py index 7b58d7e5ba..e2abd82511 100644 --- a/care/emr/api/viewsets/scheduling/booking.py +++ b/care/emr/api/viewsets/scheduling/booking.py @@ -96,7 +96,8 @@ def get_queryset(self): .order_by("-modified_date") ) - def cancel_appointment_handler(self, instance, request_data, user): + @classmethod + def cancel_appointment_handler(cls, instance, request_data, user): request_data = CancelBookingSpec(**request_data) with transaction.atomic(): if instance.status not in CANCELLED_STATUS_CHOICES: From e13fb6e198c5ab31b62a56dc1f6866df47129e71 Mon Sep 17 00:00:00 2001 From: rithviknishad Date: Wed, 8 Jan 2025 21:25:21 +0530 Subject: [PATCH 12/15] fix edge case for heatmap API where it gets stuck on same day --- care/emr/api/viewsets/scheduling/availability.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/care/emr/api/viewsets/scheduling/availability.py b/care/emr/api/viewsets/scheduling/availability.py index 1f6fe340e1..c49dfb550c 100644 --- a/care/emr/api/viewsets/scheduling/availability.py +++ b/care/emr/api/viewsets/scheduling/availability.py @@ -301,8 +301,12 @@ def calculate_slots( for available_slot in availability["availability"]: if available_slot["day_of_week"] != day_of_week: continue - start_time = time.fromisoformat(available_slot["start_time"]) - end_time = time.fromisoformat(available_slot["end_time"]) + start_time = datetime.datetime.combine( + date, time.fromisoformat(available_slot["start_time"]) + ) + end_time = datetime.datetime.combine( + date, time.fromisoformat(available_slot["end_time"]) + ) while start_time <= end_time: conflicting = False for exception in exceptions: @@ -311,10 +315,9 @@ def calculate_slots( and exception["end_time"] >= start_time ): conflicting = True - start_time = ( - datetime.datetime.combine(date.today(), start_time) - + timedelta(minutes=availability["slot_size_in_minutes"]) - ).time() + start_time = start_time + timedelta( + minutes=availability["slot_size_in_minutes"] + ) if conflicting: continue slots += availability["tokens_per_slot"] From af37d1f46287fde0199b2b3a5489d648c3707de6 Mon Sep 17 00:00:00 2001 From: rithviknishad Date: Wed, 8 Jan 2025 22:15:05 +0530 Subject: [PATCH 13/15] cascade soft delete token slots --- care/emr/api/viewsets/scheduling/schedule.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/care/emr/api/viewsets/scheduling/schedule.py b/care/emr/api/viewsets/scheduling/schedule.py index caaa5c2255..147763864e 100644 --- a/care/emr/api/viewsets/scheduling/schedule.py +++ b/care/emr/api/viewsets/scheduling/schedule.py @@ -67,6 +67,10 @@ def perform_delete(self, instance): "Cannot delete schedule as there are future bookings associated with it" ) availabilities.update(deleted=True) + TokenSlot.objects.filter( + resource=instance.resource, + availability_id__in=availabilities.values_list("id", flat=True), + ).update(deleted=True) super().perform_delete(instance) def validate_data(self, instance, model_obj=None): From 0ebc52828baf46eef626337c109fffb38101a0d8 Mon Sep 17 00:00:00 2001 From: rithviknishad Date: Wed, 8 Jan 2025 22:15:37 +0530 Subject: [PATCH 14/15] fix generated slots not being tz naive and heatmap edge case for tz --- care/emr/api/viewsets/scheduling/availability.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/care/emr/api/viewsets/scheduling/availability.py b/care/emr/api/viewsets/scheduling/availability.py index c49dfb550c..a1de0c46b1 100644 --- a/care/emr/api/viewsets/scheduling/availability.py +++ b/care/emr/api/viewsets/scheduling/availability.py @@ -155,10 +155,10 @@ def get_slots_for_day_handler(cls, facility_external_id, request_data): TokenSlot.objects.create( resource=schedulable_resource_obj, start_datetime=datetime.datetime.combine( - request_data.day, slot["start_time"], tzinfo=timezone.now().tzinfo + request_data.day, slot["start_time"], tzinfo=None ), end_datetime=datetime.datetime.combine( - request_data.day, slot["end_time"], tzinfo=timezone.now().tzinfo + request_data.day, slot["end_time"], tzinfo=None ), availability_id=slot["availability_id"], ) @@ -251,12 +251,16 @@ def availability_stats(self, request, *args, **kwargs): # Calculate all matching schedules current_schedules = [] for schedule in schedules: - if schedule["valid_from"].date() <= day <= schedule["valid_to"].date(): + valid_from = timezone.make_naive(schedule["valid_from"]).date() + valid_to = timezone.make_naive(schedule["valid_to"]).date() + if valid_from <= day <= valid_to: current_schedules.append(schedule) # Calculate availability exception for that day exceptions = [] for exception in availability_exceptions: - if exception["valid_from"] <= day <= exception["valid_to"]: + valid_from = timezone.make_naive(exception["valid_from"]).date() + valid_to = timezone.make_naive(exception["valid_to"]).date() + if valid_from <= day <= valid_to: exceptions.append(exception) # Calculate slots based on these data @@ -302,10 +306,10 @@ def calculate_slots( if available_slot["day_of_week"] != day_of_week: continue start_time = datetime.datetime.combine( - date, time.fromisoformat(available_slot["start_time"]) + date, time.fromisoformat(available_slot["start_time"]), tzinfo=None ) end_time = datetime.datetime.combine( - date, time.fromisoformat(available_slot["end_time"]) + date, time.fromisoformat(available_slot["end_time"]), tzinfo=None ) while start_time <= end_time: conflicting = False From f47ee496c48dedaf0580ef58453d1292dd3dd9c4 Mon Sep 17 00:00:00 2001 From: Vignesh Hari <14056798+vigneshhari@users.noreply.github.com> Date: Wed, 8 Jan 2025 23:17:50 +0530 Subject: [PATCH 15/15] Update booking.py --- care/emr/api/viewsets/scheduling/booking.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/care/emr/api/viewsets/scheduling/booking.py b/care/emr/api/viewsets/scheduling/booking.py index e2abd82511..010962be21 100644 --- a/care/emr/api/viewsets/scheduling/booking.py +++ b/care/emr/api/viewsets/scheduling/booking.py @@ -130,7 +130,7 @@ def available_users(self, request, *args, **kwargs): return Response( { "users": [ - UserSpec.serialize(facility_user.user).model_dump(exclude=["meta"]) + UserSpec.serialize(facility_user.user).to_json() for facility_user in facility_users ] }