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
@@ -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