388 lines
12 KiB
Python
388 lines
12 KiB
Python
# 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
|
|
]
|