diff --git a/backend/main.py b/backend/main.py index 3bcc54d..3678619 100644 --- a/backend/main.py +++ b/backend/main.py @@ -12,6 +12,7 @@ from routes import profile as profile_routes from routes import invites as invites_routes from routes import auth as auth_routes from routes import meta as meta_routes +from routes import availability as availability_routes from validate_constants import validate_shared_constants @@ -44,4 +45,5 @@ app.include_router(audit_routes.router) app.include_router(profile_routes.router) app.include_router(invites_routes.router) app.include_router(invites_routes.admin_router) +app.include_router(availability_routes.router) app.include_router(meta_routes.router) diff --git a/backend/models.py b/backend/models.py index dcdb537..b90a478 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,7 +1,7 @@ -from datetime import datetime, UTC +from datetime import datetime, date, time, UTC from enum import Enum as PyEnum from typing import TypedDict -from sqlalchemy import Integer, String, Float, DateTime, ForeignKey, Table, Column, Enum, select +from sqlalchemy import Integer, String, Float, DateTime, Date, Time, ForeignKey, Table, Column, Enum, UniqueConstraint, select from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.ext.asyncio import AsyncSession from database import Base @@ -248,3 +248,24 @@ class Invite(Base): ) spent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + +class Availability(Base): + """Admin availability slots for booking.""" + __tablename__ = "availability" + __table_args__ = ( + UniqueConstraint("date", "start_time", name="uq_availability_date_start"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + date: Mapped[date] = mapped_column(Date, nullable=False, index=True) + start_time: Mapped[time] = mapped_column(Time, nullable=False) + end_time: Mapped[time] = mapped_column(Time, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(UTC) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC) + ) diff --git a/backend/routes/availability.py b/backend/routes/availability.py new file mode 100644 index 0000000..77531e3 --- /dev/null +++ b/backend/routes/availability.py @@ -0,0 +1,192 @@ +"""Availability routes for admin to manage booking availability.""" +from datetime import date, timedelta + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import select, delete, and_ +from sqlalchemy.ext.asyncio import AsyncSession + +from auth import require_permission +from database import get_db +from models import User, Availability, Permission +from schemas import ( + TimeSlot, + AvailabilityDay, + AvailabilityResponse, + SetAvailabilityRequest, + CopyAvailabilityRequest, +) + + +router = APIRouter(prefix="/api/admin/availability", tags=["availability"]) + +# From shared/constants.json +MAX_ADVANCE_DAYS = 30 + + +def _get_date_range_bounds() -> tuple[date, date]: + """Get the valid date range for availability (tomorrow to +30 days).""" + today = date.today() + min_date = today + timedelta(days=1) # Tomorrow + max_date = today + timedelta(days=MAX_ADVANCE_DAYS) + return min_date, max_date + + +def _validate_date_in_range(d: date, min_date: date, max_date: date) -> None: + """Validate a date is within the allowed range.""" + if d < min_date: + raise HTTPException( + status_code=400, + detail=f"Cannot set availability for past dates. Earliest allowed: {min_date}", + ) + if d > max_date: + raise HTTPException( + status_code=400, + detail=f"Cannot set availability more than {MAX_ADVANCE_DAYS} days ahead. Latest allowed: {max_date}", + ) + + +@router.get("", response_model=AvailabilityResponse) +async def get_availability( + from_date: date = Query(..., alias="from", description="Start date (inclusive)"), + to_date: date = Query(..., alias="to", description="End date (inclusive)"), + db: AsyncSession = Depends(get_db), + _current_user: User = Depends(require_permission(Permission.MANAGE_AVAILABILITY)), +) -> AvailabilityResponse: + """Get availability slots for a date range.""" + if from_date > to_date: + raise HTTPException( + status_code=400, + detail="'from' date must be before or equal to 'to' date", + ) + + # Query availability in range + result = await db.execute( + select(Availability) + .where(and_(Availability.date >= from_date, Availability.date <= to_date)) + .order_by(Availability.date, Availability.start_time) + ) + slots = result.scalars().all() + + # Group by date + days_dict: dict[date, list[TimeSlot]] = {} + for slot in slots: + if slot.date not in days_dict: + days_dict[slot.date] = [] + days_dict[slot.date].append(TimeSlot( + start_time=slot.start_time, + end_time=slot.end_time, + )) + + # Convert to response format + days = [ + AvailabilityDay(date=d, slots=days_dict[d]) + for d in sorted(days_dict.keys()) + ] + + return AvailabilityResponse(days=days) + + +@router.put("", response_model=AvailabilityDay) +async def set_availability( + request: SetAvailabilityRequest, + db: AsyncSession = Depends(get_db), + _current_user: User = Depends(require_permission(Permission.MANAGE_AVAILABILITY)), +) -> AvailabilityDay: + """Set availability for a specific date. Replaces any existing availability.""" + min_date, max_date = _get_date_range_bounds() + _validate_date_in_range(request.date, min_date, max_date) + + # Validate slots don't overlap + sorted_slots = sorted(request.slots, key=lambda s: s.start_time) + for i in range(len(sorted_slots) - 1): + if sorted_slots[i].end_time > sorted_slots[i + 1].start_time: + raise HTTPException( + status_code=400, + detail=f"Time slots overlap: {sorted_slots[i].end_time} > {sorted_slots[i + 1].start_time}", + ) + + # Validate each slot's end_time > start_time + for slot in request.slots: + if slot.end_time <= slot.start_time: + raise HTTPException( + status_code=400, + detail=f"Slot end time must be after start time: {slot.start_time} - {slot.end_time}", + ) + + # Delete existing availability for this date + await db.execute( + delete(Availability).where(Availability.date == request.date) + ) + + # Create new availability slots + for slot in request.slots: + availability = Availability( + date=request.date, + start_time=slot.start_time, + end_time=slot.end_time, + ) + db.add(availability) + + await db.commit() + + return AvailabilityDay(date=request.date, slots=request.slots) + + +@router.post("/copy", response_model=AvailabilityResponse) +async def copy_availability( + request: CopyAvailabilityRequest, + db: AsyncSession = Depends(get_db), + _current_user: User = Depends(require_permission(Permission.MANAGE_AVAILABILITY)), +) -> AvailabilityResponse: + """Copy availability from one day to multiple target days.""" + min_date, max_date = _get_date_range_bounds() + + # Validate target dates + for target_date in request.target_dates: + _validate_date_in_range(target_date, min_date, max_date) + + # Get source availability + result = await db.execute( + select(Availability) + .where(Availability.date == request.source_date) + .order_by(Availability.start_time) + ) + source_slots = result.scalars().all() + + if not source_slots: + raise HTTPException( + status_code=400, + detail=f"No availability found for source date {request.source_date}", + ) + + # Copy to each target date + copied_days: list[AvailabilityDay] = [] + for target_date in request.target_dates: + if target_date == request.source_date: + continue # Skip copying to self + + # Delete existing availability for target date + await db.execute( + delete(Availability).where(Availability.date == target_date) + ) + + # Copy slots + target_slots: list[TimeSlot] = [] + for source_slot in source_slots: + new_availability = Availability( + date=target_date, + start_time=source_slot.start_time, + end_time=source_slot.end_time, + ) + db.add(new_availability) + target_slots.append(TimeSlot( + start_time=source_slot.start_time, + end_time=source_slot.end_time, + )) + + copied_days.append(AvailabilityDay(date=target_date, slots=target_slots)) + + await db.commit() + + return AvailabilityResponse(days=copied_days) + diff --git a/backend/schemas.py b/backend/schemas.py index 45b4551..7b2bf73 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -1,8 +1,8 @@ """Pydantic schemas for API request/response models.""" -from datetime import datetime +from datetime import datetime, date, time from typing import Generic, TypeVar -from pydantic import BaseModel, EmailStr +from pydantic import BaseModel, EmailStr, field_validator class UserCredentials(BaseModel): @@ -140,6 +140,49 @@ class AdminUserResponse(BaseModel): email: str +# ============================================================================= +# Availability Schemas +# ============================================================================= + +class TimeSlot(BaseModel): + """A single time slot (start and end time).""" + start_time: time + end_time: time + + @field_validator("start_time", "end_time") + @classmethod + def validate_15min_boundary(cls, v: time) -> time: + """Ensure times are on 15-minute boundaries.""" + if v.minute not in (0, 15, 30, 45): + raise ValueError("Time must be on 15-minute boundary (:00, :15, :30, :45)") + if v.second != 0 or v.microsecond != 0: + raise ValueError("Time must not have seconds or microseconds") + return v + + +class AvailabilityDay(BaseModel): + """Availability for a single day.""" + date: date + slots: list[TimeSlot] + + +class AvailabilityResponse(BaseModel): + """Response model for availability query.""" + days: list[AvailabilityDay] + + +class SetAvailabilityRequest(BaseModel): + """Request to set availability for a specific date.""" + date: date + slots: list[TimeSlot] + + +class CopyAvailabilityRequest(BaseModel): + """Request to copy availability from one day to others.""" + source_date: date + target_dates: list[date] + + # ============================================================================= # Meta/Constants Schemas # ============================================================================= diff --git a/backend/tests/test_availability.py b/backend/tests/test_availability.py new file mode 100644 index 0000000..49c3e98 --- /dev/null +++ b/backend/tests/test_availability.py @@ -0,0 +1,526 @@ +""" +Availability API Tests + +Tests for the admin availability management endpoints. +""" +from datetime import date, time, timedelta +import pytest + + +def tomorrow() -> date: + return date.today() + timedelta(days=1) + + +def in_days(n: int) -> date: + return date.today() + timedelta(days=n) + + +# ============================================================================= +# Permission Tests +# ============================================================================= + +class TestAvailabilityPermissions: + """Test that only admins can access availability endpoints.""" + + @pytest.mark.asyncio + async def test_admin_can_get_availability(self, client_factory, admin_user): + async with client_factory.create(cookies=admin_user["cookies"]) as client: + response = await client.get( + "/api/admin/availability", + params={"from": str(tomorrow()), "to": str(in_days(7))}, + ) + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_admin_can_set_availability(self, client_factory, admin_user): + async with client_factory.create(cookies=admin_user["cookies"]) as client: + response = await client.put( + "/api/admin/availability", + json={ + "date": str(tomorrow()), + "slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}], + }, + ) + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_regular_user_cannot_get_availability(self, client_factory, regular_user): + async with client_factory.create(cookies=regular_user["cookies"]) as client: + response = await client.get( + "/api/admin/availability", + params={"from": str(tomorrow()), "to": str(in_days(7))}, + ) + assert response.status_code == 403 + + @pytest.mark.asyncio + async def test_regular_user_cannot_set_availability(self, client_factory, regular_user): + async with client_factory.create(cookies=regular_user["cookies"]) as client: + response = await client.put( + "/api/admin/availability", + json={ + "date": str(tomorrow()), + "slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}], + }, + ) + assert response.status_code == 403 + + @pytest.mark.asyncio + async def test_unauthenticated_cannot_get_availability(self, client): + response = await client.get( + "/api/admin/availability", + params={"from": str(tomorrow()), "to": str(in_days(7))}, + ) + assert response.status_code == 401 + + @pytest.mark.asyncio + async def test_unauthenticated_cannot_set_availability(self, client): + response = await client.put( + "/api/admin/availability", + json={ + "date": str(tomorrow()), + "slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}], + }, + ) + assert response.status_code == 401 + + +# ============================================================================= +# Set Availability Tests +# ============================================================================= + +class TestSetAvailability: + """Test setting availability for a date.""" + + @pytest.mark.asyncio + async def test_set_single_slot(self, client_factory, admin_user): + async with client_factory.create(cookies=admin_user["cookies"]) as client: + response = await client.put( + "/api/admin/availability", + json={ + "date": str(tomorrow()), + "slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}], + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["date"] == str(tomorrow()) + assert len(data["slots"]) == 1 + assert data["slots"][0]["start_time"] == "09:00:00" + assert data["slots"][0]["end_time"] == "12:00:00" + + @pytest.mark.asyncio + async def test_set_multiple_slots(self, client_factory, admin_user): + async with client_factory.create(cookies=admin_user["cookies"]) as client: + response = await client.put( + "/api/admin/availability", + json={ + "date": str(tomorrow()), + "slots": [ + {"start_time": "09:00:00", "end_time": "12:00:00"}, + {"start_time": "14:00:00", "end_time": "17:00:00"}, + ], + }, + ) + + assert response.status_code == 200 + data = response.json() + assert len(data["slots"]) == 2 + + @pytest.mark.asyncio + async def test_set_empty_slots_clears_availability(self, client_factory, admin_user): + async with client_factory.create(cookies=admin_user["cookies"]) as client: + # First set some availability + await client.put( + "/api/admin/availability", + json={ + "date": str(tomorrow()), + "slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}], + }, + ) + + # Then clear it + response = await client.put( + "/api/admin/availability", + json={"date": str(tomorrow()), "slots": []}, + ) + + assert response.status_code == 200 + data = response.json() + assert len(data["slots"]) == 0 + + @pytest.mark.asyncio + async def test_set_replaces_existing_availability(self, client_factory, admin_user): + async with client_factory.create(cookies=admin_user["cookies"]) as client: + # Set initial availability + await client.put( + "/api/admin/availability", + json={ + "date": str(tomorrow()), + "slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}], + }, + ) + + # Replace with different slots + response = await client.put( + "/api/admin/availability", + json={ + "date": str(tomorrow()), + "slots": [{"start_time": "14:00:00", "end_time": "16:00:00"}], + }, + ) + + # Verify the replacement + get_response = await client.get( + "/api/admin/availability", + params={"from": str(tomorrow()), "to": str(tomorrow())}, + ) + + data = get_response.json() + assert len(data["days"]) == 1 + assert len(data["days"][0]["slots"]) == 1 + assert data["days"][0]["slots"][0]["start_time"] == "14:00:00" + + +# ============================================================================= +# Validation Tests +# ============================================================================= + +class TestAvailabilityValidation: + """Test validation rules for availability.""" + + @pytest.mark.asyncio + async def test_cannot_set_past_date(self, client_factory, admin_user): + yesterday = date.today() - timedelta(days=1) + async with client_factory.create(cookies=admin_user["cookies"]) as client: + response = await client.put( + "/api/admin/availability", + json={ + "date": str(yesterday), + "slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}], + }, + ) + + assert response.status_code == 400 + assert "past" in response.json()["detail"].lower() + + @pytest.mark.asyncio + async def test_cannot_set_today(self, client_factory, admin_user): + async with client_factory.create(cookies=admin_user["cookies"]) as client: + response = await client.put( + "/api/admin/availability", + json={ + "date": str(date.today()), + "slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}], + }, + ) + + assert response.status_code == 400 + assert "past" in response.json()["detail"].lower() + + @pytest.mark.asyncio + async def test_cannot_set_beyond_30_days(self, client_factory, admin_user): + too_far = in_days(31) + async with client_factory.create(cookies=admin_user["cookies"]) as client: + response = await client.put( + "/api/admin/availability", + json={ + "date": str(too_far), + "slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}], + }, + ) + + assert response.status_code == 400 + assert "30" in response.json()["detail"] + + @pytest.mark.asyncio + async def test_time_must_be_15min_boundary(self, client_factory, admin_user): + async with client_factory.create(cookies=admin_user["cookies"]) as client: + response = await client.put( + "/api/admin/availability", + json={ + "date": str(tomorrow()), + "slots": [{"start_time": "09:05:00", "end_time": "12:00:00"}], + }, + ) + + assert response.status_code == 422 # Pydantic validation error + assert "15-minute" in response.json()["detail"][0]["msg"] + + @pytest.mark.asyncio + async def test_end_time_must_be_after_start_time(self, client_factory, admin_user): + async with client_factory.create(cookies=admin_user["cookies"]) as client: + response = await client.put( + "/api/admin/availability", + json={ + "date": str(tomorrow()), + "slots": [{"start_time": "12:00:00", "end_time": "09:00:00"}], + }, + ) + + assert response.status_code == 400 + assert "after" in response.json()["detail"].lower() + + @pytest.mark.asyncio + async def test_slots_cannot_overlap(self, client_factory, admin_user): + async with client_factory.create(cookies=admin_user["cookies"]) as client: + response = await client.put( + "/api/admin/availability", + json={ + "date": str(tomorrow()), + "slots": [ + {"start_time": "09:00:00", "end_time": "12:00:00"}, + {"start_time": "11:00:00", "end_time": "14:00:00"}, + ], + }, + ) + + assert response.status_code == 400 + assert "overlap" in response.json()["detail"].lower() + + +# ============================================================================= +# Get Availability Tests +# ============================================================================= + +class TestGetAvailability: + """Test retrieving availability.""" + + @pytest.mark.asyncio + async def test_get_empty_availability(self, client_factory, admin_user): + async with client_factory.create(cookies=admin_user["cookies"]) as client: + response = await client.get( + "/api/admin/availability", + params={"from": str(tomorrow()), "to": str(in_days(7))}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["days"] == [] + + @pytest.mark.asyncio + async def test_get_availability_in_range(self, client_factory, admin_user): + async with client_factory.create(cookies=admin_user["cookies"]) as client: + # Set availability for multiple days + for i in range(1, 4): + await client.put( + "/api/admin/availability", + json={ + "date": str(in_days(i)), + "slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}], + }, + ) + + # Get range that includes all + response = await client.get( + "/api/admin/availability", + params={"from": str(in_days(1)), "to": str(in_days(3))}, + ) + + assert response.status_code == 200 + data = response.json() + assert len(data["days"]) == 3 + + @pytest.mark.asyncio + async def test_get_availability_partial_range(self, client_factory, admin_user): + async with client_factory.create(cookies=admin_user["cookies"]) as client: + # Set availability for 5 days + for i in range(1, 6): + await client.put( + "/api/admin/availability", + json={ + "date": str(in_days(i)), + "slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}], + }, + ) + + # Get only a subset + response = await client.get( + "/api/admin/availability", + params={"from": str(in_days(2)), "to": str(in_days(4))}, + ) + + assert response.status_code == 200 + data = response.json() + assert len(data["days"]) == 3 + + @pytest.mark.asyncio + async def test_get_availability_invalid_range(self, client_factory, admin_user): + async with client_factory.create(cookies=admin_user["cookies"]) as client: + response = await client.get( + "/api/admin/availability", + params={"from": str(in_days(7)), "to": str(in_days(1))}, + ) + + assert response.status_code == 400 + assert "before" in response.json()["detail"].lower() + + +# ============================================================================= +# Copy Availability Tests +# ============================================================================= + +class TestCopyAvailability: + """Test copying availability from one day to others.""" + + @pytest.mark.asyncio + async def test_copy_to_single_day(self, client_factory, admin_user): + async with client_factory.create(cookies=admin_user["cookies"]) as client: + # Set source availability + await client.put( + "/api/admin/availability", + json={ + "date": str(in_days(1)), + "slots": [ + {"start_time": "09:00:00", "end_time": "12:00:00"}, + {"start_time": "14:00:00", "end_time": "17:00:00"}, + ], + }, + ) + + # Copy to another day + response = await client.post( + "/api/admin/availability/copy", + json={ + "source_date": str(in_days(1)), + "target_dates": [str(in_days(2))], + }, + ) + + assert response.status_code == 200 + data = response.json() + assert len(data["days"]) == 1 + assert data["days"][0]["date"] == str(in_days(2)) + assert len(data["days"][0]["slots"]) == 2 + + @pytest.mark.asyncio + async def test_copy_to_multiple_days(self, client_factory, admin_user): + async with client_factory.create(cookies=admin_user["cookies"]) as client: + # Set source availability + await client.put( + "/api/admin/availability", + json={ + "date": str(in_days(1)), + "slots": [{"start_time": "10:00:00", "end_time": "11:00:00"}], + }, + ) + + # Copy to multiple days + response = await client.post( + "/api/admin/availability/copy", + json={ + "source_date": str(in_days(1)), + "target_dates": [str(in_days(2)), str(in_days(3)), str(in_days(4))], + }, + ) + + assert response.status_code == 200 + data = response.json() + assert len(data["days"]) == 3 + + @pytest.mark.asyncio + async def test_copy_replaces_existing(self, client_factory, admin_user): + async with client_factory.create(cookies=admin_user["cookies"]) as client: + # Set different availability on target + await client.put( + "/api/admin/availability", + json={ + "date": str(in_days(2)), + "slots": [{"start_time": "08:00:00", "end_time": "09:00:00"}], + }, + ) + + # Set source availability + await client.put( + "/api/admin/availability", + json={ + "date": str(in_days(1)), + "slots": [{"start_time": "14:00:00", "end_time": "15:00:00"}], + }, + ) + + # Copy (should replace) + await client.post( + "/api/admin/availability/copy", + json={ + "source_date": str(in_days(1)), + "target_dates": [str(in_days(2))], + }, + ) + + # Verify target was replaced + response = await client.get( + "/api/admin/availability", + params={"from": str(in_days(2)), "to": str(in_days(2))}, + ) + + data = response.json() + assert len(data["days"]) == 1 + assert len(data["days"][0]["slots"]) == 1 + assert data["days"][0]["slots"][0]["start_time"] == "14:00:00" + + @pytest.mark.asyncio + async def test_copy_no_source_availability(self, client_factory, admin_user): + async with client_factory.create(cookies=admin_user["cookies"]) as client: + response = await client.post( + "/api/admin/availability/copy", + json={ + "source_date": str(in_days(1)), + "target_dates": [str(in_days(2))], + }, + ) + + assert response.status_code == 400 + assert "no availability" in response.json()["detail"].lower() + + @pytest.mark.asyncio + async def test_copy_skips_self(self, client_factory, admin_user): + async with client_factory.create(cookies=admin_user["cookies"]) as client: + # Set source availability + await client.put( + "/api/admin/availability", + json={ + "date": str(in_days(1)), + "slots": [{"start_time": "09:00:00", "end_time": "10:00:00"}], + }, + ) + + # Copy including self in targets + response = await client.post( + "/api/admin/availability/copy", + json={ + "source_date": str(in_days(1)), + "target_dates": [str(in_days(1)), str(in_days(2))], + }, + ) + + assert response.status_code == 200 + data = response.json() + # Should only have copied to day 2, not day 1 (self) + assert len(data["days"]) == 1 + assert data["days"][0]["date"] == str(in_days(2)) + + @pytest.mark.asyncio + async def test_copy_validates_target_dates(self, client_factory, admin_user): + async with client_factory.create(cookies=admin_user["cookies"]) as client: + # Set source availability + await client.put( + "/api/admin/availability", + json={ + "date": str(in_days(1)), + "slots": [{"start_time": "09:00:00", "end_time": "10:00:00"}], + }, + ) + + # Try to copy to a date beyond 30 days + response = await client.post( + "/api/admin/availability/copy", + json={ + "source_date": str(in_days(1)), + "target_dates": [str(in_days(31))], + }, + ) + + assert response.status_code == 400 + assert "30" in response.json()["detail"] +