Phase 1: Add Availability model and API

- Create Availability model with date, start_time, end_time
- Add availability schemas with 15-minute boundary validation
- Add admin endpoints:
  - GET /api/admin/availability - query by date range
  - PUT /api/admin/availability - set slots for a date
  - POST /api/admin/availability/copy - copy to multiple days
- Add 26 tests covering permissions, CRUD, and validation
This commit is contained in:
counterweight 2025-12-20 23:36:11 +01:00
parent 6c1a05d93d
commit 64d2e99d73
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
5 changed files with 788 additions and 4 deletions

View file

@ -12,6 +12,7 @@ from routes import profile as profile_routes
from routes import invites as invites_routes from routes import invites as invites_routes
from routes import auth as auth_routes from routes import auth as auth_routes
from routes import meta as meta_routes from routes import meta as meta_routes
from routes import availability as availability_routes
from validate_constants import validate_shared_constants 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(profile_routes.router)
app.include_router(invites_routes.router) app.include_router(invites_routes.router)
app.include_router(invites_routes.admin_router) app.include_router(invites_routes.admin_router)
app.include_router(availability_routes.router)
app.include_router(meta_routes.router) app.include_router(meta_routes.router)

View file

@ -1,7 +1,7 @@
from datetime import datetime, UTC from datetime import datetime, date, time, UTC
from enum import Enum as PyEnum from enum import Enum as PyEnum
from typing import TypedDict 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.orm import Mapped, mapped_column, relationship
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database import Base from database import Base
@ -248,3 +248,24 @@ class Invite(Base):
) )
spent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) spent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
revoked_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)
)

View file

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

View file

@ -1,8 +1,8 @@
"""Pydantic schemas for API request/response models.""" """Pydantic schemas for API request/response models."""
from datetime import datetime from datetime import datetime, date, time
from typing import Generic, TypeVar from typing import Generic, TypeVar
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr, field_validator
class UserCredentials(BaseModel): class UserCredentials(BaseModel):
@ -140,6 +140,49 @@ class AdminUserResponse(BaseModel):
email: str 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 # Meta/Constants Schemas
# ============================================================================= # =============================================================================

View file

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