Files
bakery-ia/services/external/app/api/calendar_operations.py

388 lines
12 KiB
Python
Raw Normal View History

2025-11-02 20:24:44 +01:00
# services/external/app/api/calendar_operations.py
"""
Calendar Operations API - School calendars and tenant location context endpoints
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Path, Body
from typing import List, Optional
from uuid import UUID
import structlog
from app.schemas.calendar import (
SchoolCalendarResponse,
SchoolCalendarListResponse,
TenantLocationContextResponse,
TenantLocationContextCreateRequest,
CalendarCheckResponse
)
from app.registry.calendar_registry import CalendarRegistry, SchoolType
from app.repositories.calendar_repository import CalendarRepository
from app.cache.redis_wrapper import ExternalDataCache
from shared.routing.route_builder import RouteBuilder
from shared.auth.decorators import get_current_user_dep
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from datetime import datetime, date
route_builder = RouteBuilder('external')
router = APIRouter(tags=["calendar-operations"])
logger = structlog.get_logger()
# Initialize cache
cache = ExternalDataCache()
# ===== School Calendar Endpoints =====
@router.get(
route_builder.build_operations_route("cities/{city_id}/school-calendars"),
response_model=SchoolCalendarListResponse
)
async def list_school_calendars_for_city(
city_id: str = Path(..., description="City ID (e.g., 'madrid')"),
school_type: Optional[str] = Query(None, description="Filter by school type"),
academic_year: Optional[str] = Query(None, description="Filter by academic year"),
db: AsyncSession = Depends(get_db)
):
"""List all available school calendars for a city"""
try:
repo = CalendarRepository(db)
calendars = await repo.get_calendars_by_city(city_id, enabled_only=True)
# Apply filters if provided
if school_type:
calendars = [c for c in calendars if c.school_type == school_type]
if academic_year:
calendars = [c for c in calendars if c.academic_year == academic_year]
calendar_responses = [
SchoolCalendarResponse(
calendar_id=str(c.id),
calendar_name=c.calendar_name,
city_id=c.city_id,
school_type=c.school_type,
academic_year=c.academic_year,
holiday_periods=c.holiday_periods,
school_hours=c.school_hours,
source=c.source,
enabled=c.enabled
)
for c in calendars
]
return SchoolCalendarListResponse(
city_id=city_id,
calendars=calendar_responses,
total=len(calendar_responses)
)
except Exception as e:
logger.error(
"Error listing school calendars",
city_id=city_id,
error=str(e)
)
raise HTTPException(
status_code=500,
detail=f"Error retrieving school calendars: {str(e)}"
)
@router.get(
route_builder.build_operations_route("school-calendars/{calendar_id}"),
response_model=SchoolCalendarResponse
)
async def get_school_calendar(
calendar_id: UUID = Path(..., description="School calendar ID"),
db: AsyncSession = Depends(get_db)
):
"""Get detailed information about a specific school calendar (cached)"""
try:
calendar_id_str = str(calendar_id)
# Check cache first
cached = await cache.get_cached_calendar(calendar_id_str)
if cached:
logger.debug("Returning cached calendar", calendar_id=calendar_id_str)
return SchoolCalendarResponse(**cached)
# Cache miss - fetch from database
repo = CalendarRepository(db)
calendar = await repo.get_calendar_by_id(calendar_id)
if not calendar:
raise HTTPException(status_code=404, detail="School calendar not found")
response_data = {
"calendar_id": str(calendar.id),
"calendar_name": calendar.calendar_name,
"city_id": calendar.city_id,
"school_type": calendar.school_type,
"academic_year": calendar.academic_year,
"holiday_periods": calendar.holiday_periods,
"school_hours": calendar.school_hours,
"source": calendar.source,
"enabled": calendar.enabled
}
# Cache the result
await cache.set_cached_calendar(calendar_id_str, response_data)
return SchoolCalendarResponse(**response_data)
except HTTPException:
raise
except Exception as e:
logger.error(
"Error retrieving school calendar",
calendar_id=str(calendar_id),
error=str(e)
)
raise HTTPException(
status_code=500,
detail=f"Error retrieving school calendar: {str(e)}"
)
@router.get(
route_builder.build_operations_route("school-calendars/{calendar_id}/is-holiday"),
response_model=CalendarCheckResponse
)
async def check_is_school_holiday(
calendar_id: UUID = Path(..., description="School calendar ID"),
check_date: str = Query(..., description="Date to check (ISO format: YYYY-MM-DD)"),
db: AsyncSession = Depends(get_db)
):
"""Check if a specific date is a school holiday"""
try:
repo = CalendarRepository(db)
calendar = await repo.get_calendar_by_id(calendar_id)
if not calendar:
raise HTTPException(status_code=404, detail="School calendar not found")
# Parse the date
try:
date_obj = datetime.strptime(check_date, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(
status_code=400,
detail="Invalid date format. Use YYYY-MM-DD"
)
# Check if date falls within any holiday period
is_holiday = False
holiday_name = None
for period in calendar.holiday_periods:
start = datetime.strptime(period["start_date"], "%Y-%m-%d").date()
end = datetime.strptime(period["end_date"], "%Y-%m-%d").date()
if start <= date_obj <= end:
is_holiday = True
holiday_name = period["name"]
break
return CalendarCheckResponse(
date=check_date,
is_holiday=is_holiday,
holiday_name=holiday_name,
calendar_id=str(calendar_id),
calendar_name=calendar.calendar_name
)
except HTTPException:
raise
except Exception as e:
logger.error(
"Error checking holiday status",
calendar_id=str(calendar_id),
date=check_date,
error=str(e)
)
raise HTTPException(
status_code=500,
detail=f"Error checking holiday status: {str(e)}"
)
# ===== Tenant Location Context Endpoints =====
@router.get(
route_builder.build_base_route("location-context"),
response_model=TenantLocationContextResponse
)
async def get_tenant_location_context(
tenant_id: UUID = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Get location context for a tenant including school calendar assignment (cached)"""
try:
tenant_id_str = str(tenant_id)
# Check cache first
cached = await cache.get_cached_tenant_context(tenant_id_str)
if cached:
logger.debug("Returning cached tenant context", tenant_id=tenant_id_str)
return TenantLocationContextResponse(**cached)
# Cache miss - fetch from database
repo = CalendarRepository(db)
context = await repo.get_tenant_with_calendar(tenant_id)
if not context:
raise HTTPException(
status_code=404,
detail="Location context not found for this tenant"
)
# Cache the result
await cache.set_cached_tenant_context(tenant_id_str, context)
return TenantLocationContextResponse(**context)
except HTTPException:
raise
except Exception as e:
logger.error(
"Error retrieving tenant location context",
tenant_id=str(tenant_id),
error=str(e)
)
raise HTTPException(
status_code=500,
detail=f"Error retrieving location context: {str(e)}"
)
@router.post(
route_builder.build_base_route("location-context"),
response_model=TenantLocationContextResponse
)
async def create_or_update_tenant_location_context(
request: TenantLocationContextCreateRequest,
tenant_id: UUID = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Create or update tenant location context"""
try:
repo = CalendarRepository(db)
# Validate calendar_id if provided
if request.school_calendar_id:
calendar = await repo.get_calendar_by_id(request.school_calendar_id)
if not calendar:
raise HTTPException(
status_code=400,
detail="Invalid school_calendar_id"
)
# Create or update context
context_obj = await repo.create_or_update_tenant_location_context(
tenant_id=tenant_id,
city_id=request.city_id,
school_calendar_id=request.school_calendar_id,
neighborhood=request.neighborhood,
local_events=request.local_events,
notes=request.notes
)
# Invalidate cache since context was updated
await cache.invalidate_tenant_context(str(tenant_id))
# Get full context with calendar details
context = await repo.get_tenant_with_calendar(tenant_id)
# Cache the new context
await cache.set_cached_tenant_context(str(tenant_id), context)
return TenantLocationContextResponse(**context)
except HTTPException:
raise
except Exception as e:
logger.error(
"Error creating/updating tenant location context",
tenant_id=str(tenant_id),
error=str(e)
)
raise HTTPException(
status_code=500,
detail=f"Error creating/updating location context: {str(e)}"
)
@router.delete(
route_builder.build_base_route("location-context"),
status_code=204
)
async def delete_tenant_location_context(
tenant_id: UUID = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Delete tenant location context"""
try:
repo = CalendarRepository(db)
deleted = await repo.delete_tenant_location_context(tenant_id)
if not deleted:
raise HTTPException(
status_code=404,
detail="Location context not found"
)
return None
except HTTPException:
raise
except Exception as e:
logger.error(
"Error deleting tenant location context",
tenant_id=str(tenant_id),
error=str(e)
)
raise HTTPException(
status_code=500,
detail=f"Error deleting location context: {str(e)}"
)
# ===== Helper Endpoints =====
@router.get(
route_builder.build_operations_route("calendars/registry"),
response_model=List[SchoolCalendarResponse]
)
async def list_registry_calendars():
"""List all calendars from the CalendarRegistry (static configuration)"""
calendars = CalendarRegistry.get_enabled_calendars()
return [
SchoolCalendarResponse(
calendar_id=cal.calendar_id,
calendar_name=cal.calendar_name,
city_id=cal.city_id,
school_type=cal.school_type.value,
academic_year=cal.academic_year,
holiday_periods=[
{
"name": hp.name,
"start_date": hp.start_date,
"end_date": hp.end_date,
"description": hp.description
}
for hp in cal.holiday_periods
],
school_hours={
"morning_start": cal.school_hours.morning_start,
"morning_end": cal.school_hours.morning_end,
"has_afternoon_session": cal.school_hours.has_afternoon_session,
"afternoon_start": cal.school_hours.afternoon_start,
"afternoon_end": cal.school_hours.afternoon_end
},
source=cal.source,
enabled=cal.enabled
)
for cal in calendars
]