Skip to content

Commit

Permalink
Tests for Schedule & Booking related ViewSets (#2731)
Browse files Browse the repository at this point in the history
Add Tests for Schedule, Booking, Availability
  • Loading branch information
rithviknishad authored Jan 19, 2025
1 parent 9ff9036 commit a053f09
Show file tree
Hide file tree
Showing 8 changed files with 2,095 additions and 68 deletions.
105 changes: 76 additions & 29 deletions care/emr/api/viewsets/scheduling/availability.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import datetime
from datetime import time, timedelta

from dateutil.parser import parse
from django.db import transaction
from django.db.models import Sum
from django.utils import timezone
Expand All @@ -18,6 +17,7 @@
from care.emr.models.scheduling.schedule import Availability, SchedulableUserResource
from care.emr.resources.scheduling.schedule.spec import SlotTypeOptions
from care.emr.resources.scheduling.slot.spec import (
CANCELLED_STATUS_CHOICES,
TokenBookingReadSpec,
TokenSlotBaseSpec,
)
Expand Down Expand Up @@ -45,43 +45,76 @@ class AvailabilityStatsRequestSpec(BaseModel):
def validate_period(self):
max_period = 32
if self.from_date > self.to_date:
raise ValidationError("From Date cannot be greater than To Date")
if self.from_date - self.to_date > datetime.timedelta(days=max_period):
raise ValidationError("Period cannot be be greater than max days")
raise ValidationError("From Date cannot be after To Date")
if self.to_date - self.from_date > datetime.timedelta(days=max_period):
msg = f"Period cannot be be greater than {max_period} days"
raise ValidationError(msg)


def convert_availability_to_slots(availabilities):
def convert_availability_and_exceptions_to_slots(availabilities, exceptions, day):
slots = {}
for availability in availabilities:
start_time = parse(availability["availability"]["start_time"])
end_time = parse(availability["availability"]["end_time"])
start_time = datetime.datetime.combine(
day,
time.fromisoformat(availability["availability"]["start_time"]),
tzinfo=None,
)
end_time = datetime.datetime.combine(
day,
time.fromisoformat(availability["availability"]["end_time"]),
tzinfo=None,
)
slot_size_in_minutes = availability["slot_size_in_minutes"]
availability_id = availability["availability_id"]
current_time = start_time
i = 0
while current_time < end_time:
i += 1
if i == 30: # noqa PLR2004
if i == 30: # noqa PLR2004 pragma: no cover
# Failsafe to prevent infinite loop
break
slots[
f"{current_time.time()}-{(current_time + datetime.timedelta(minutes=slot_size_in_minutes)).time()}"
] = {
"start_time": current_time.time(),
"end_time": (
current_time + datetime.timedelta(minutes=slot_size_in_minutes)
).time(),
"availability_id": availability_id,
}

conflicting = False
for exception in exceptions:
exception_start_time = datetime.datetime.combine(
day, exception.start_time, tzinfo=None
)
exception_end_time = datetime.datetime.combine(
day, exception.end_time, tzinfo=None
)
if (
exception_start_time
< (current_time + datetime.timedelta(minutes=slot_size_in_minutes))
) and exception_end_time > current_time:
conflicting = True

if not conflicting:
slots[
f"{current_time.time()}-{(current_time + datetime.timedelta(minutes=slot_size_in_minutes)).time()}"
] = {
"start_time": current_time.time(),
"end_time": (
current_time + datetime.timedelta(minutes=slot_size_in_minutes)
).time(),
"availability_id": availability_id,
}

current_time += datetime.timedelta(minutes=slot_size_in_minutes)
return slots


def lock_create_appointment(token_slot, patient, created_by, reason_for_visit):
with Lock(f"booking:resource:{token_slot.resource.id}"), transaction.atomic():
if token_slot.start_datetime < timezone.now():
raise ValidationError("Slot is already past")
if token_slot.allocated >= token_slot.availability.tokens_per_slot:
raise ValidationError("Slot is already full")
if (
TokenBooking.objects.filter(token_slot=token_slot, patient=patient)
.exclude(status__in=CANCELLED_STATUS_CHOICES)
.exists()
):
raise ValidationError("Patient already has a booking for this slot")
token_slot.allocated += 1
token_slot.save()
return TokenBooking.objects.create(
Expand Down Expand Up @@ -132,9 +165,15 @@ def get_slots_for_day_handler(cls, facility_external_id, request_data):
"availability_id": schedule_availability.id,
}
)
# Remove anything that has an availability exception
# Generate all slots already created for that day
slots = convert_availability_to_slots(calculated_dow_availabilities)
exceptions = AvailabilityException.objects.filter(
resource=schedulable_resource_obj,
valid_from__lte=request_data.day,
valid_to__gte=request_data.day,
)
# Generate all slots already created for that day, exclude anything that conflicts with availability exception
slots = convert_availability_and_exceptions_to_slots(
calculated_dow_availabilities, exceptions, request_data.day
)
# Fetch all existing slots in that day
created_slots = TokenSlot.objects.filter(
start_datetime__date=request_data.day,
Expand Down Expand Up @@ -184,7 +223,7 @@ 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:
raise ValidationError({"Patient not found"})
raise ValidationError("Patient not found")
appointment = lock_create_appointment(
obj, patient, user, request_data.reason_for_visit
)
Expand Down Expand Up @@ -258,8 +297,8 @@ def availability_stats(self, request, *args, **kwargs):
# Calculate availability exception for that day
exceptions = []
for exception in availability_exceptions:
valid_from = timezone.make_naive(exception["valid_from"]).date()
valid_to = timezone.make_naive(exception["valid_to"]).date()
valid_from = exception["valid_from"]
valid_to = exception["valid_to"]
if valid_from <= day <= valid_to:
exceptions.append(exception)
# Calculate slots based on these data
Expand Down Expand Up @@ -311,17 +350,25 @@ def calculate_slots(
end_time = datetime.datetime.combine(
date, time.fromisoformat(available_slot["end_time"]), tzinfo=None
)
while start_time <= end_time:
current_start_time = start_time
while current_start_time < end_time:
conflicting = False
current_end_time = current_start_time + timedelta(
minutes=availability["slot_size_in_minutes"]
)
for exception in exceptions:
exception_start_time = datetime.datetime.combine(
date, exception["start_time"], tzinfo=None
)
exception_end_time = datetime.datetime.combine(
date, exception["end_time"], tzinfo=None
)
if (
exception["start_time"] <= end_time
and exception["end_time"] >= start_time
exception_start_time < current_end_time
and exception_end_time > current_start_time
):
conflicting = True
start_time = start_time + timedelta(
minutes=availability["slot_size_in_minutes"]
)
current_start_time = current_end_time
if conflicting:
continue
slots += availability["tokens_per_slot"]
Expand Down
2 changes: 0 additions & 2 deletions care/emr/api/viewsets/scheduling/booking.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,6 @@ class TokenBookingFilters(FilterSet):
patient = UUIDFilter(field_name="patient__external_id")

def filter_by_user(self, queryset, name, value):
if not value:
return queryset
resource = SchedulableUserResource.objects.filter(
user__external_id=value
).first()
Expand Down
6 changes: 3 additions & 3 deletions care/emr/api/viewsets/scheduling/schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@
from care.emr.models.scheduling.schedule import Availability, Schedule
from care.emr.resources.scheduling.schedule.spec import (
AvailabilityForScheduleSpec,
ScheduleCreateSpec,
ScheduleReadSpec,
ScheduleUpdateSpec,
ScheduleWriteSpec,
)
from care.facility.models import Facility
from care.security.authorization import AuthorizationController
Expand All @@ -32,7 +32,7 @@ class ScheduleFilters(FilterSet):

class ScheduleViewSet(EMRModelViewSet):
database_model = Schedule
pydantic_model = ScheduleWriteSpec
pydantic_model = ScheduleCreateSpec
pydantic_update_model = ScheduleUpdateSpec
pydantic_read_model = ScheduleReadSpec
filterset_class = ScheduleFilters
Expand Down Expand Up @@ -189,5 +189,5 @@ def authorize_create(self, instance):
):
raise PermissionDenied("You do not have permission to create schedule")

def authorize_delete(self, instance):
def authorize_destroy(self, instance):
self.authorize_create(instance)
14 changes: 12 additions & 2 deletions care/emr/resources/scheduling/availability_exception/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from pydantic import UUID4
from rest_framework.exceptions import ValidationError

from care.emr.models import AvailabilityException
from care.emr.models import AvailabilityException, TokenSlot
from care.emr.models.scheduling.schedule import SchedulableUserResource
from care.emr.resources.base import EMRResource
from care.facility.models import Facility
Expand Down Expand Up @@ -34,7 +34,6 @@ class AvailabilityExceptionWriteSpec(AvailabilityExceptionBaseSpec):

def perform_extra_deserialization(self, is_update, obj):
if not is_update:
resource = None
try:
user = User.objects.get(external_id=self.user)
resource = SchedulableUserResource.objects.get(
Expand All @@ -45,6 +44,17 @@ def perform_extra_deserialization(self, is_update, obj):
except ObjectDoesNotExist as e:
raise ValidationError("Object does not exist") from e

slots = TokenSlot.objects.filter(
resource=obj.resource,
start_datetime__date__gte=self.valid_from,
start_datetime__date__lte=self.valid_to,
start_datetime__time__gte=self.start_time,
start_datetime__time__lte=self.end_time,
)
if slots.filter(allocated__gt=0):
raise ValidationError("There are bookings during this exception")
slots.delete()


class AvailabilityExceptionReadSpec(AvailabilityExceptionBaseSpec):
@classmethod
Expand Down
45 changes: 22 additions & 23 deletions care/emr/resources/scheduling/schedule/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,18 @@ class AvailabilityBaseSpec(EMRResource):

# TODO Check if Availability Types are coinciding at any point


class AvailabilityForScheduleSpec(AvailabilityBaseSpec):
name: str
slot_type: SlotTypeOptions
slot_size_in_minutes: int | None = Field(ge=1)
tokens_per_slot: int | None = Field(ge=1)
create_tokens: bool = False
reason: str = ""
availability: list[AvailabilityDateTimeSpec]

@field_validator("availability")
@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)):
Expand All @@ -54,16 +64,6 @@ def validate_availability(cls, availabilities: list[AvailabilityDateTimeSpec]):
raise ValueError("Availability time ranges are overlapping")
return availabilities


class AvailabilityForScheduleSpec(AvailabilityBaseSpec):
name: str
slot_type: SlotTypeOptions
slot_size_in_minutes: int | None = Field(ge=1)
tokens_per_slot: int | None = Field(ge=1)
create_tokens: bool = False
reason: str = ""
availability: list[AvailabilityDateTimeSpec]

@model_validator(mode="after")
def validate_for_slot_type(self):
if self.slot_type == "appointment":
Expand All @@ -86,7 +86,7 @@ class ScheduleBaseSpec(EMRResource):
id: UUID4 | None = None


class ScheduleWriteSpec(ScheduleBaseSpec):
class ScheduleCreateSpec(ScheduleBaseSpec):
user: UUID4
facility: UUID4
name: str
Expand All @@ -101,17 +101,16 @@ def validate_period(self):
return self

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
obj.facility = Facility.objects.get(external_id=self.facility)

resource, _ = SchedulableUserResource.objects.get_or_create(
facility=obj.facility,
user=user,
)
obj.resource = resource
obj.availabilities = self.availabilities
user = get_object_or_404(User, external_id=self.user)
# TODO Validation that user is in given facility
obj.facility = Facility.objects.get(external_id=self.facility)

resource, _ = SchedulableUserResource.objects.get_or_create(
facility=obj.facility,
user=user,
)
obj.resource = resource
obj.availabilities = self.availabilities


class ScheduleUpdateSpec(ScheduleBaseSpec):
Expand Down
9 changes: 0 additions & 9 deletions care/emr/resources/scheduling/slot/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

from care.emr.models import TokenBooking
from care.emr.models.scheduling.booking import TokenSlot
from care.emr.models.scheduling.schedule import Availability
from care.emr.resources.base import EMRResource
from care.emr.resources.facility.spec import FacilityBareMinimumSpec
from care.emr.resources.patient.otp_based_flow import PatientOTPReadSpec
Expand All @@ -15,14 +14,6 @@
from care.users.models import User


class AvailabilityforTokenSpec(EMRResource):
__model__ = Availability

id: UUID4 | None = None
name: str
tokens_per_slot: int


class TokenSlotBaseSpec(EMRResource):
__model__ = TokenSlot
__exclude__ = ["resource", "availability"]
Expand Down
Loading

0 comments on commit a053f09

Please sign in to comment.