Created and activated the booking integrity ExecPlan, then implemented staff availability, overlap prevention, and duration validation with backend tests.

Added a staff availability model and migration, a booking validation service, and serializer enforcement.
This commit is contained in:
2026-02-28 12:05:57 +03:00
parent d40bb10876
commit 411180e312
9 changed files with 467 additions and 7 deletions
+3 -4
View File
@@ -1,7 +1,6 @@
from rest_framework import serializers
from django.utils.translation import gettext_lazy as _
from apps.bookings.models import Booking
from apps.bookings.services import validate_booking_request
from apps.salons.models import Service, StaffProfile
@@ -42,12 +41,12 @@ class BookingCreateSerializer(serializers.ModelSerializer):
class Meta:
model = Booking
fields = ["service", "staff", "start_time", "end_time", "notes"]
extra_kwargs = {"staff": {"required": True}}
def validate(self, attrs):
service: Service = attrs["service"]
staff = attrs.get("staff")
if staff and staff.salon_id != service.salon_id:
raise serializers.ValidationError(_("Selected staff does not belong to this salon"))
validate_booking_request(service, staff, attrs["start_time"], attrs["end_time"])
return attrs
def create(self, validated_data):
+45
View File
@@ -0,0 +1,45 @@
from datetime import timedelta
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from apps.bookings.models import Booking, BookingStatus
from apps.salons.models import StaffAvailability, StaffProfile
def validate_booking_request(service, staff, start_time, end_time):
if staff is None:
raise serializers.ValidationError({"staff": _("Staff is required for booking")})
if isinstance(staff, StaffProfile) and staff.salon_id != service.salon_id:
raise serializers.ValidationError({"staff": _("Selected staff does not belong to this salon")})
if start_time >= end_time:
raise serializers.ValidationError({"end_time": _("End time must be after start time")})
expected_end = start_time + timedelta(minutes=service.duration_minutes)
if expected_end != end_time:
raise serializers.ValidationError({"end_time": _("End time must match service duration")})
availability_qs = StaffAvailability.objects.filter(
staff=staff,
day_of_week=start_time.weekday(),
is_active=True,
)
if availability_qs.exists():
within_window = availability_qs.filter(
start_time__lte=start_time.time(),
end_time__gte=end_time.time(),
).exists()
if not within_window:
raise serializers.ValidationError({"start_time": _("Booking is outside staff availability")})
overlap_exists = Booking.objects.filter(
staff=staff,
status__in=[BookingStatus.PENDING, BookingStatus.CONFIRMED],
start_time__lt=end_time,
end_time__gt=start_time,
).exists()
if overlap_exists:
raise serializers.ValidationError({"start_time": _("Booking overlaps an existing appointment")})
@@ -0,0 +1,221 @@
from datetime import timedelta
import pytest
from django.urls import reverse
from django.utils import timezone
from apps.accounts.models import User, UserRole
from apps.bookings.models import Booking, BookingStatus
from apps.salons.models import Salon, Service, StaffAvailability, StaffProfile
@pytest.fixture
def base_entities():
owner = User.objects.create_user(email="owner@example.com", password="pass", role=UserRole.MANAGER)
customer = User.objects.create_user(email="customer@example.com", password="pass")
staff_user = User.objects.create_user(email="staff@example.com", password="pass", role=UserRole.STAFF)
salon = Salon.objects.create(
owner=owner,
name="Main Salon",
description="",
address="123 King Rd",
city="Riyadh",
phone_number="0512345678",
)
service = Service.objects.create(
salon=salon,
name="Haircut",
description="",
duration_minutes=60,
price_amount=120,
currency="SAR",
)
staff = StaffProfile.objects.create(user=staff_user, salon=salon)
return customer, staff, service
def booking_payload(service, staff, start_time, end_time, notes=""):
return {
"service": service.id,
"staff": staff.id if staff else None,
"start_time": start_time.isoformat(),
"end_time": end_time.isoformat(),
"notes": notes,
}
def next_day_at(hour, minute=0):
now = timezone.now()
target = now + timedelta(days=1)
return target.replace(hour=hour, minute=minute, second=0, microsecond=0)
@pytest.mark.django_db
def test_requires_staff_assignment(client, base_entities):
customer, staff, service = base_entities
client.force_login(customer)
start_time = next_day_at(9)
end_time = start_time + timedelta(minutes=service.duration_minutes)
payload = booking_payload(service, None, start_time, end_time)
response = client.post(
reverse("booking-list"),
payload,
content_type="application/json",
)
assert response.status_code == 400
assert "staff" in response.json()
@pytest.mark.django_db
def test_rejects_end_before_start(client, base_entities):
customer, staff, service = base_entities
client.force_login(customer)
start_time = next_day_at(9)
end_time = start_time - timedelta(minutes=service.duration_minutes)
payload = booking_payload(service, staff, start_time, end_time)
response = client.post(
reverse("booking-list"),
payload,
content_type="application/json",
)
assert response.status_code == 400
assert "end_time" in response.json()
@pytest.mark.django_db
def test_rejects_duration_mismatch(client, base_entities):
customer, staff, service = base_entities
client.force_login(customer)
start_time = next_day_at(9)
end_time = start_time + timedelta(minutes=30)
payload = booking_payload(service, staff, start_time, end_time)
response = client.post(
reverse("booking-list"),
payload,
content_type="application/json",
)
assert response.status_code == 400
assert "end_time" in response.json()
@pytest.mark.django_db
def test_rejects_outside_availability_when_defined(client, base_entities):
customer, staff, service = base_entities
client.force_login(customer)
start_time = next_day_at(8)
end_time = start_time + timedelta(minutes=service.duration_minutes)
StaffAvailability.objects.create(
staff=staff,
day_of_week=start_time.weekday(),
start_time=timezone.datetime(2000, 1, 1, 9, 0).time(),
end_time=timezone.datetime(2000, 1, 1, 17, 0).time(),
)
payload = booking_payload(service, staff, start_time, end_time)
response = client.post(
reverse("booking-list"),
payload,
content_type="application/json",
)
assert response.status_code == 400
assert "start_time" in response.json()
@pytest.mark.django_db
def test_allows_booking_without_availability_records(client, base_entities):
customer, staff, service = base_entities
client.force_login(customer)
start_time = next_day_at(10)
end_time = start_time + timedelta(minutes=service.duration_minutes)
payload = booking_payload(service, staff, start_time, end_time)
response = client.post(
reverse("booking-list"),
payload,
content_type="application/json",
)
assert response.status_code == 201
@pytest.mark.django_db
def test_rejects_overlapping_pending_or_confirmed_bookings(client, base_entities):
customer, staff, service = base_entities
client.force_login(customer)
start_time = next_day_at(9)
end_time = start_time + timedelta(minutes=service.duration_minutes)
Booking.objects.create(
salon=service.salon,
customer=customer,
service=service,
staff=staff,
start_time=start_time,
end_time=end_time,
status=BookingStatus.CONFIRMED,
price_amount=service.price_amount,
currency=service.currency,
notes="",
)
overlap_start = start_time + timedelta(minutes=30)
overlap_end = overlap_start + timedelta(minutes=service.duration_minutes)
payload = booking_payload(service, staff, overlap_start, overlap_end)
response = client.post(
reverse("booking-list"),
payload,
content_type="application/json",
)
assert response.status_code == 400
assert "start_time" in response.json()
@pytest.mark.django_db
def test_allows_overlap_with_cancelled_or_completed(client, base_entities):
customer, staff, service = base_entities
client.force_login(customer)
start_time = next_day_at(9)
end_time = start_time + timedelta(minutes=service.duration_minutes)
Booking.objects.create(
salon=service.salon,
customer=customer,
service=service,
staff=staff,
start_time=start_time,
end_time=end_time,
status=BookingStatus.CANCELLED,
price_amount=service.price_amount,
currency=service.currency,
notes="",
)
overlap_start = start_time + timedelta(minutes=30)
overlap_end = overlap_start + timedelta(minutes=service.duration_minutes)
payload = booking_payload(service, staff, overlap_start, overlap_end)
response = client.post(
reverse("booking-list"),
payload,
content_type="application/json",
)
assert response.status_code == 201