Improve the frontend 5
This commit is contained in:
237
services/external/app/api/audit.py
vendored
Normal file
237
services/external/app/api/audit.py
vendored
Normal file
@@ -0,0 +1,237 @@
|
||||
# services/external/app/api/audit.py
|
||||
"""
|
||||
Audit Logs API - Retrieve audit trail for external service
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
|
||||
from typing import Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
import structlog
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import AuditLog
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from shared.auth.access_control import require_user_role
|
||||
from shared.routing import RouteBuilder
|
||||
from shared.models.audit_log_schemas import (
|
||||
AuditLogResponse,
|
||||
AuditLogListResponse,
|
||||
AuditLogStatsResponse
|
||||
)
|
||||
from app.core.database import database_manager
|
||||
|
||||
route_builder = RouteBuilder('external')
|
||||
router = APIRouter(tags=["audit-logs"])
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
async def get_db():
|
||||
"""Database session dependency"""
|
||||
async with database_manager.get_session() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_base_route("audit-logs"),
|
||||
response_model=AuditLogListResponse
|
||||
)
|
||||
@require_user_role(['admin', 'owner'])
|
||||
async def get_audit_logs(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
start_date: Optional[datetime] = Query(None, description="Filter logs from this date"),
|
||||
end_date: Optional[datetime] = Query(None, description="Filter logs until this date"),
|
||||
user_id: Optional[UUID] = Query(None, description="Filter by user ID"),
|
||||
action: Optional[str] = Query(None, description="Filter by action type"),
|
||||
resource_type: Optional[str] = Query(None, description="Filter by resource type"),
|
||||
severity: Optional[str] = Query(None, description="Filter by severity level"),
|
||||
search: Optional[str] = Query(None, description="Search in description field"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="Number of records to return"),
|
||||
offset: int = Query(0, ge=0, description="Number of records to skip"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get audit logs for external service.
|
||||
Requires admin or owner role.
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
"Retrieving audit logs",
|
||||
tenant_id=tenant_id,
|
||||
user_id=current_user.get("user_id"),
|
||||
filters={
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"action": action,
|
||||
"resource_type": resource_type,
|
||||
"severity": severity
|
||||
}
|
||||
)
|
||||
|
||||
# Build query filters
|
||||
filters = [AuditLog.tenant_id == tenant_id]
|
||||
|
||||
if start_date:
|
||||
filters.append(AuditLog.created_at >= start_date)
|
||||
if end_date:
|
||||
filters.append(AuditLog.created_at <= end_date)
|
||||
if user_id:
|
||||
filters.append(AuditLog.user_id == user_id)
|
||||
if action:
|
||||
filters.append(AuditLog.action == action)
|
||||
if resource_type:
|
||||
filters.append(AuditLog.resource_type == resource_type)
|
||||
if severity:
|
||||
filters.append(AuditLog.severity == severity)
|
||||
if search:
|
||||
filters.append(AuditLog.description.ilike(f"%{search}%"))
|
||||
|
||||
# Count total matching records
|
||||
count_query = select(func.count()).select_from(AuditLog).where(and_(*filters))
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Fetch paginated results
|
||||
query = (
|
||||
select(AuditLog)
|
||||
.where(and_(*filters))
|
||||
.order_by(AuditLog.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
audit_logs = result.scalars().all()
|
||||
|
||||
# Convert to response models
|
||||
items = [AuditLogResponse.from_orm(log) for log in audit_logs]
|
||||
|
||||
logger.info(
|
||||
"Successfully retrieved audit logs",
|
||||
tenant_id=tenant_id,
|
||||
total=total,
|
||||
returned=len(items)
|
||||
)
|
||||
|
||||
return AuditLogListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
has_more=(offset + len(items)) < total
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to retrieve audit logs",
|
||||
error=str(e),
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to retrieve audit logs: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_base_route("audit-logs/stats"),
|
||||
response_model=AuditLogStatsResponse
|
||||
)
|
||||
@require_user_role(['admin', 'owner'])
|
||||
async def get_audit_log_stats(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
start_date: Optional[datetime] = Query(None, description="Filter logs from this date"),
|
||||
end_date: Optional[datetime] = Query(None, description="Filter logs until this date"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get audit log statistics for external service.
|
||||
Requires admin or owner role.
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
"Retrieving audit log statistics",
|
||||
tenant_id=tenant_id,
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
|
||||
# Build base filters
|
||||
filters = [AuditLog.tenant_id == tenant_id]
|
||||
if start_date:
|
||||
filters.append(AuditLog.created_at >= start_date)
|
||||
if end_date:
|
||||
filters.append(AuditLog.created_at <= end_date)
|
||||
|
||||
# Total events
|
||||
count_query = select(func.count()).select_from(AuditLog).where(and_(*filters))
|
||||
total_result = await db.execute(count_query)
|
||||
total_events = total_result.scalar() or 0
|
||||
|
||||
# Events by action
|
||||
action_query = (
|
||||
select(AuditLog.action, func.count().label('count'))
|
||||
.where(and_(*filters))
|
||||
.group_by(AuditLog.action)
|
||||
)
|
||||
action_result = await db.execute(action_query)
|
||||
events_by_action = {row.action: row.count for row in action_result}
|
||||
|
||||
# Events by severity
|
||||
severity_query = (
|
||||
select(AuditLog.severity, func.count().label('count'))
|
||||
.where(and_(*filters))
|
||||
.group_by(AuditLog.severity)
|
||||
)
|
||||
severity_result = await db.execute(severity_query)
|
||||
events_by_severity = {row.severity: row.count for row in severity_result}
|
||||
|
||||
# Events by resource type
|
||||
resource_query = (
|
||||
select(AuditLog.resource_type, func.count().label('count'))
|
||||
.where(and_(*filters))
|
||||
.group_by(AuditLog.resource_type)
|
||||
)
|
||||
resource_result = await db.execute(resource_query)
|
||||
events_by_resource_type = {row.resource_type: row.count for row in resource_result}
|
||||
|
||||
# Date range
|
||||
date_range_query = (
|
||||
select(
|
||||
func.min(AuditLog.created_at).label('min_date'),
|
||||
func.max(AuditLog.created_at).label('max_date')
|
||||
)
|
||||
.where(and_(*filters))
|
||||
)
|
||||
date_result = await db.execute(date_range_query)
|
||||
date_row = date_result.one()
|
||||
|
||||
logger.info(
|
||||
"Successfully retrieved audit log statistics",
|
||||
tenant_id=tenant_id,
|
||||
total_events=total_events
|
||||
)
|
||||
|
||||
return AuditLogStatsResponse(
|
||||
total_events=total_events,
|
||||
events_by_action=events_by_action,
|
||||
events_by_severity=events_by_severity,
|
||||
events_by_resource_type=events_by_resource_type,
|
||||
date_range={
|
||||
"min": date_row.min_date,
|
||||
"max": date_row.max_date
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to retrieve audit log statistics",
|
||||
error=str(e),
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to retrieve audit log statistics: {str(e)}"
|
||||
)
|
||||
387
services/external/app/api/calendar_operations.py
vendored
Normal file
387
services/external/app/api/calendar_operations.py
vendored
Normal file
@@ -0,0 +1,387 @@
|
||||
# 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
|
||||
]
|
||||
112
services/external/app/cache/redis_wrapper.py
vendored
112
services/external/app/cache/redis_wrapper.py
vendored
@@ -184,3 +184,115 @@ class ExternalDataCache:
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error invalidating cache", error=str(e))
|
||||
|
||||
# ===== Calendar Caching Methods =====
|
||||
|
||||
def _calendar_cache_key(self, calendar_id: str) -> str:
|
||||
"""Generate cache key for school calendar"""
|
||||
return f"calendar:{calendar_id}"
|
||||
|
||||
def _tenant_context_cache_key(self, tenant_id: str) -> str:
|
||||
"""Generate cache key for tenant location context"""
|
||||
return f"tenant_context:{tenant_id}"
|
||||
|
||||
async def get_cached_calendar(
|
||||
self,
|
||||
calendar_id: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Get cached school calendar by ID"""
|
||||
try:
|
||||
key = self._calendar_cache_key(calendar_id)
|
||||
client = await self._get_client()
|
||||
cached = await client.get(key)
|
||||
|
||||
if cached:
|
||||
logger.debug("Calendar cache hit", calendar_id=calendar_id)
|
||||
return json.loads(cached)
|
||||
|
||||
logger.debug("Calendar cache miss", calendar_id=calendar_id)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error reading calendar cache", error=str(e))
|
||||
return None
|
||||
|
||||
async def set_cached_calendar(
|
||||
self,
|
||||
calendar_id: str,
|
||||
calendar_data: Dict[str, Any]
|
||||
):
|
||||
"""Cache school calendar data (7 days TTL)"""
|
||||
try:
|
||||
key = self._calendar_cache_key(calendar_id)
|
||||
client = await self._get_client()
|
||||
|
||||
# Calendars change rarely, use 7-day TTL
|
||||
ttl = 86400 * 7
|
||||
|
||||
await client.setex(
|
||||
key,
|
||||
ttl,
|
||||
json.dumps(calendar_data)
|
||||
)
|
||||
|
||||
logger.debug("Calendar cached", calendar_id=calendar_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error caching calendar", error=str(e))
|
||||
|
||||
async def get_cached_tenant_context(
|
||||
self,
|
||||
tenant_id: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Get cached tenant location context"""
|
||||
try:
|
||||
key = self._tenant_context_cache_key(tenant_id)
|
||||
client = await self._get_client()
|
||||
cached = await client.get(key)
|
||||
|
||||
if cached:
|
||||
logger.debug("Tenant context cache hit", tenant_id=tenant_id)
|
||||
return json.loads(cached)
|
||||
|
||||
logger.debug("Tenant context cache miss", tenant_id=tenant_id)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error reading tenant context cache", error=str(e))
|
||||
return None
|
||||
|
||||
async def set_cached_tenant_context(
|
||||
self,
|
||||
tenant_id: str,
|
||||
context_data: Dict[str, Any]
|
||||
):
|
||||
"""Cache tenant location context (24 hours TTL)"""
|
||||
try:
|
||||
key = self._tenant_context_cache_key(tenant_id)
|
||||
client = await self._get_client()
|
||||
|
||||
# Tenant context changes less frequently, 24-hour TTL
|
||||
ttl = 86400
|
||||
|
||||
await client.setex(
|
||||
key,
|
||||
ttl,
|
||||
json.dumps(context_data)
|
||||
)
|
||||
|
||||
logger.debug("Tenant context cached", tenant_id=tenant_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error caching tenant context", error=str(e))
|
||||
|
||||
async def invalidate_tenant_context(self, tenant_id: str):
|
||||
"""Invalidate tenant context cache (called when context is updated)"""
|
||||
try:
|
||||
key = self._tenant_context_cache_key(tenant_id)
|
||||
client = await self._get_client()
|
||||
await client.delete(key)
|
||||
|
||||
logger.info("Tenant context cache invalidated", tenant_id=tenant_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error invalidating tenant context cache", error=str(e))
|
||||
|
||||
@@ -9,8 +9,10 @@ import structlog
|
||||
import asyncio
|
||||
|
||||
from app.registry.city_registry import CityRegistry
|
||||
from app.registry.calendar_registry import CalendarRegistry
|
||||
from .adapters import get_adapter
|
||||
from app.repositories.city_data_repository import CityDataRepository
|
||||
from app.repositories.calendar_repository import CalendarRepository
|
||||
from app.core.database import database_manager
|
||||
|
||||
logger = structlog.get_logger()
|
||||
@@ -266,3 +268,99 @@ class DataIngestionManager:
|
||||
error=str(e)
|
||||
)
|
||||
return False
|
||||
|
||||
async def seed_school_calendars(self) -> bool:
|
||||
"""
|
||||
Seed school calendars from CalendarRegistry into database
|
||||
Called during initialization - idempotent
|
||||
"""
|
||||
try:
|
||||
logger.info("Starting school calendar seeding...")
|
||||
|
||||
# Get all calendars from registry
|
||||
calendars = CalendarRegistry.get_all_calendars()
|
||||
logger.info(f"Found {len(calendars)} calendars in registry")
|
||||
|
||||
async with self.database_manager.get_session() as session:
|
||||
repo = CalendarRepository(session)
|
||||
|
||||
seeded_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for cal_def in calendars:
|
||||
logger.info(
|
||||
"Processing calendar",
|
||||
calendar_id=cal_def.calendar_id,
|
||||
city=cal_def.city_id,
|
||||
type=cal_def.school_type.value,
|
||||
year=cal_def.academic_year
|
||||
)
|
||||
|
||||
# Check if calendar already exists (idempotency)
|
||||
existing = await repo.get_calendar_by_city_type_year(
|
||||
city_id=cal_def.city_id,
|
||||
school_type=cal_def.school_type.value,
|
||||
academic_year=cal_def.academic_year
|
||||
)
|
||||
|
||||
if existing:
|
||||
logger.info(
|
||||
"Calendar already exists, skipping",
|
||||
calendar_id=cal_def.calendar_id
|
||||
)
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Convert holiday periods to dict format
|
||||
holiday_periods = [
|
||||
{
|
||||
"name": hp.name,
|
||||
"start_date": hp.start_date,
|
||||
"end_date": hp.end_date,
|
||||
"description": hp.description
|
||||
}
|
||||
for hp in cal_def.holiday_periods
|
||||
]
|
||||
|
||||
# Convert school hours to dict format
|
||||
school_hours = {
|
||||
"morning_start": cal_def.school_hours.morning_start,
|
||||
"morning_end": cal_def.school_hours.morning_end,
|
||||
"has_afternoon_session": cal_def.school_hours.has_afternoon_session,
|
||||
"afternoon_start": cal_def.school_hours.afternoon_start,
|
||||
"afternoon_end": cal_def.school_hours.afternoon_end
|
||||
}
|
||||
|
||||
# Create calendar in database
|
||||
created_calendar = await repo.create_school_calendar(
|
||||
city_id=cal_def.city_id,
|
||||
calendar_name=cal_def.calendar_name,
|
||||
school_type=cal_def.school_type.value,
|
||||
academic_year=cal_def.academic_year,
|
||||
holiday_periods=holiday_periods,
|
||||
school_hours=school_hours,
|
||||
source=cal_def.source,
|
||||
enabled=cal_def.enabled
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Calendar seeded successfully",
|
||||
calendar_id=str(created_calendar.id),
|
||||
city=cal_def.city_id,
|
||||
type=cal_def.school_type.value,
|
||||
year=cal_def.academic_year
|
||||
)
|
||||
seeded_count += 1
|
||||
|
||||
logger.info(
|
||||
"School calendar seeding completed",
|
||||
seeded=seeded_count,
|
||||
skipped=skipped_count,
|
||||
total=len(calendars)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error seeding school calendars", error=str(e))
|
||||
return False
|
||||
|
||||
22
services/external/app/jobs/initialize_data.py
vendored
22
services/external/app/jobs/initialize_data.py
vendored
@@ -16,18 +16,30 @@ logger = structlog.get_logger()
|
||||
|
||||
|
||||
async def main(months: int = 24):
|
||||
"""Initialize historical data for all enabled cities"""
|
||||
"""Initialize historical data for all enabled cities and seed calendars"""
|
||||
logger.info("Starting data initialization job", months=months)
|
||||
|
||||
try:
|
||||
manager = DataIngestionManager()
|
||||
success = await manager.initialize_all_cities(months=months)
|
||||
|
||||
if success:
|
||||
logger.info("✅ Data initialization completed successfully")
|
||||
# Initialize weather and traffic data
|
||||
weather_traffic_success = await manager.initialize_all_cities(months=months)
|
||||
|
||||
# Seed school calendars
|
||||
logger.info("Proceeding to seed school calendars...")
|
||||
calendar_success = await manager.seed_school_calendars()
|
||||
|
||||
# Both must succeed
|
||||
overall_success = weather_traffic_success and calendar_success
|
||||
|
||||
if overall_success:
|
||||
logger.info("✅ Data initialization completed successfully (weather, traffic, calendars)")
|
||||
sys.exit(0)
|
||||
else:
|
||||
logger.error("❌ Data initialization failed")
|
||||
if not weather_traffic_success:
|
||||
logger.error("❌ Weather/traffic initialization failed")
|
||||
if not calendar_success:
|
||||
logger.error("❌ Calendar seeding failed")
|
||||
sys.exit(1)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
7
services/external/app/main.py
vendored
7
services/external/app/main.py
vendored
@@ -10,7 +10,7 @@ from app.core.database import database_manager
|
||||
from app.services.messaging import setup_messaging, cleanup_messaging
|
||||
from shared.service_base import StandardFastAPIService
|
||||
# Include routers
|
||||
from app.api import weather_data, traffic_data, city_operations
|
||||
from app.api import weather_data, traffic_data, city_operations, calendar_operations, audit
|
||||
|
||||
|
||||
class ExternalService(StandardFastAPIService):
|
||||
@@ -177,6 +177,9 @@ app = service.create_app()
|
||||
service.setup_standard_endpoints()
|
||||
|
||||
# Include routers
|
||||
# IMPORTANT: Register audit router FIRST to avoid route matching conflicts
|
||||
service.add_router(audit.router)
|
||||
service.add_router(weather_data.router)
|
||||
service.add_router(traffic_data.router)
|
||||
service.add_router(city_operations.router) # New v2.0 city-based optimized endpoints
|
||||
service.add_router(city_operations.router) # New v2.0 city-based optimized endpoints
|
||||
service.add_router(calendar_operations.router) # School calendars and hyperlocal data
|
||||
4
services/external/app/models/__init__.py
vendored
4
services/external/app/models/__init__.py
vendored
@@ -25,6 +25,7 @@ from .weather import (
|
||||
|
||||
from .city_weather import CityWeatherData
|
||||
from .city_traffic import CityTrafficData
|
||||
from .calendar import SchoolCalendar, TenantLocationContext
|
||||
|
||||
# List all models for easier access
|
||||
__all__ = [
|
||||
@@ -38,5 +39,8 @@ __all__ = [
|
||||
# City-based models (new)
|
||||
"CityWeatherData",
|
||||
"CityTrafficData",
|
||||
# Calendar models (hyperlocal)
|
||||
"SchoolCalendar",
|
||||
"TenantLocationContext",
|
||||
"AuditLog",
|
||||
]
|
||||
|
||||
86
services/external/app/models/calendar.py
vendored
Normal file
86
services/external/app/models/calendar.py
vendored
Normal file
@@ -0,0 +1,86 @@
|
||||
# services/external/app/models/calendar.py
|
||||
"""
|
||||
School Calendar and Tenant Location Context Models
|
||||
Hyperlocal data for demand forecasting
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, String, DateTime, Index, Boolean
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class SchoolCalendar(Base):
|
||||
"""City-based school calendar data for forecasting"""
|
||||
|
||||
__tablename__ = "school_calendars"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
city_id = Column(String(50), nullable=False, index=True)
|
||||
calendar_name = Column(String(100), nullable=False)
|
||||
school_type = Column(String(20), nullable=False) # primary, secondary, university
|
||||
academic_year = Column(String(10), nullable=False) # e.g., "2024-2025"
|
||||
|
||||
# Holiday periods as array of date ranges
|
||||
# Example: [
|
||||
# {"name": "Christmas", "start": "2024-12-20", "end": "2025-01-08"},
|
||||
# {"name": "Easter", "start": "2025-04-10", "end": "2025-04-21"},
|
||||
# {"name": "Summer", "start": "2025-06-23", "end": "2025-09-09"}
|
||||
# ]
|
||||
holiday_periods = Column(JSONB, nullable=False, default=list)
|
||||
|
||||
# School hours configuration
|
||||
# Example: {
|
||||
# "morning_start": "09:00",
|
||||
# "morning_end": "14:00",
|
||||
# "afternoon_start": "15:00", # if applicable
|
||||
# "afternoon_end": "17:00",
|
||||
# "has_afternoon_session": false
|
||||
# }
|
||||
school_hours = Column(JSONB, nullable=False, default=dict)
|
||||
|
||||
# Metadata
|
||||
source = Column(String(100), nullable=True) # e.g., "madrid_education_dept"
|
||||
enabled = Column(Boolean, default=True, nullable=False)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
updated_at = Column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_school_calendar_city_year', 'city_id', 'academic_year'),
|
||||
Index('idx_school_calendar_city_type', 'city_id', 'school_type'),
|
||||
)
|
||||
|
||||
|
||||
class TenantLocationContext(Base):
|
||||
"""Tenant-specific location context for hyperlocal forecasting"""
|
||||
|
||||
__tablename__ = "tenant_location_contexts"
|
||||
|
||||
tenant_id = Column(UUID(as_uuid=True), primary_key=True)
|
||||
city_id = Column(String(50), nullable=False, index=True)
|
||||
|
||||
# School calendar assignment
|
||||
school_calendar_id = Column(UUID(as_uuid=True), nullable=True, index=True)
|
||||
|
||||
# Hyperlocal context
|
||||
neighborhood = Column(String(100), nullable=True)
|
||||
|
||||
# Custom local events specific to this tenant's location
|
||||
# Example: [
|
||||
# {"name": "Neighborhood Festival", "date": "2025-06-15", "impact": "high"},
|
||||
# {"name": "Local Market Day", "date": "2025-05-20", "impact": "medium"}
|
||||
# ]
|
||||
local_events = Column(JSONB, nullable=True, default=list)
|
||||
|
||||
# Additional metadata
|
||||
notes = Column(String(500), nullable=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
updated_at = Column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_tenant_location_calendar', 'school_calendar_id'),
|
||||
)
|
||||
377
services/external/app/registry/calendar_registry.py
vendored
Normal file
377
services/external/app/registry/calendar_registry.py
vendored
Normal file
@@ -0,0 +1,377 @@
|
||||
# services/external/app/registry/calendar_registry.py
|
||||
"""
|
||||
Calendar Registry - Pre-configured school calendars and local events
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import date
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class SchoolType(str, Enum):
|
||||
PRIMARY = "primary"
|
||||
SECONDARY = "secondary"
|
||||
UNIVERSITY = "university"
|
||||
|
||||
|
||||
@dataclass
|
||||
class HolidayPeriod:
|
||||
"""School holiday period definition"""
|
||||
name: str
|
||||
start_date: str # ISO format: "2024-12-20"
|
||||
end_date: str # ISO format: "2025-01-08"
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SchoolHours:
|
||||
"""School operating hours configuration"""
|
||||
morning_start: str # "09:00"
|
||||
morning_end: str # "14:00"
|
||||
has_afternoon_session: bool # True/False
|
||||
afternoon_start: Optional[str] = None # "15:00" if has_afternoon_session
|
||||
afternoon_end: Optional[str] = None # "17:00" if has_afternoon_session
|
||||
|
||||
|
||||
@dataclass
|
||||
class CalendarDefinition:
|
||||
"""School calendar configuration for a specific city and school type"""
|
||||
calendar_id: str
|
||||
calendar_name: str
|
||||
city_id: str
|
||||
school_type: SchoolType
|
||||
academic_year: str # "2024-2025"
|
||||
holiday_periods: List[HolidayPeriod]
|
||||
school_hours: SchoolHours
|
||||
source: str
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class CalendarRegistry:
|
||||
"""Central registry of school calendars for forecasting"""
|
||||
|
||||
# Madrid Primary School Calendar 2024-2025 (Official Comunidad de Madrid - ORDEN 1177/2024)
|
||||
MADRID_PRIMARY_2024_2025 = CalendarDefinition(
|
||||
calendar_id="madrid_primary_2024_2025",
|
||||
calendar_name="Madrid Primary School Calendar 2024-2025",
|
||||
city_id="madrid",
|
||||
school_type=SchoolType.PRIMARY,
|
||||
academic_year="2024-2025",
|
||||
holiday_periods=[
|
||||
HolidayPeriod(
|
||||
name="Christmas Holiday",
|
||||
start_date="2024-12-21",
|
||||
end_date="2025-01-07",
|
||||
description="Official Christmas break - Comunidad de Madrid (Dec 21 - Jan 7)"
|
||||
),
|
||||
HolidayPeriod(
|
||||
name="Easter Holiday (Semana Santa)",
|
||||
start_date="2025-04-11",
|
||||
end_date="2025-04-21",
|
||||
description="Official Easter break - Comunidad de Madrid (Apr 11-21)"
|
||||
),
|
||||
HolidayPeriod(
|
||||
name="Summer Holiday",
|
||||
start_date="2025-06-21",
|
||||
end_date="2025-09-08",
|
||||
description="Summer vacation (Last day Jun 20, classes resume Sep 9)"
|
||||
),
|
||||
HolidayPeriod(
|
||||
name="All Saints Long Weekend",
|
||||
start_date="2024-10-31",
|
||||
end_date="2024-11-03",
|
||||
description="October 31 - November 3 non-working days"
|
||||
),
|
||||
HolidayPeriod(
|
||||
name="February Long Weekend",
|
||||
start_date="2025-02-28",
|
||||
end_date="2025-03-03",
|
||||
description="February 28 - March 3 non-working days"
|
||||
),
|
||||
],
|
||||
school_hours=SchoolHours(
|
||||
morning_start="09:00",
|
||||
morning_end="14:00",
|
||||
has_afternoon_session=False
|
||||
),
|
||||
source="comunidad_madrid_orden_1177_2024",
|
||||
enabled=True
|
||||
)
|
||||
|
||||
# Madrid Secondary School Calendar 2024-2025 (Official Comunidad de Madrid - ORDEN 1177/2024)
|
||||
MADRID_SECONDARY_2024_2025 = CalendarDefinition(
|
||||
calendar_id="madrid_secondary_2024_2025",
|
||||
calendar_name="Madrid Secondary School Calendar 2024-2025",
|
||||
city_id="madrid",
|
||||
school_type=SchoolType.SECONDARY,
|
||||
academic_year="2024-2025",
|
||||
holiday_periods=[
|
||||
HolidayPeriod(
|
||||
name="Christmas Holiday",
|
||||
start_date="2024-12-21",
|
||||
end_date="2025-01-07",
|
||||
description="Official Christmas break - Comunidad de Madrid (Dec 21 - Jan 7)"
|
||||
),
|
||||
HolidayPeriod(
|
||||
name="Easter Holiday (Semana Santa)",
|
||||
start_date="2025-04-11",
|
||||
end_date="2025-04-21",
|
||||
description="Official Easter break - Comunidad de Madrid (Apr 11-21)"
|
||||
),
|
||||
HolidayPeriod(
|
||||
name="Summer Holiday",
|
||||
start_date="2025-06-21",
|
||||
end_date="2025-09-09",
|
||||
description="Summer vacation (Last day Jun 20, classes resume Sep 10)"
|
||||
),
|
||||
HolidayPeriod(
|
||||
name="All Saints Long Weekend",
|
||||
start_date="2024-10-31",
|
||||
end_date="2024-11-03",
|
||||
description="October 31 - November 3 non-working days"
|
||||
),
|
||||
HolidayPeriod(
|
||||
name="February Long Weekend",
|
||||
start_date="2025-02-28",
|
||||
end_date="2025-03-03",
|
||||
description="February 28 - March 3 non-working days"
|
||||
),
|
||||
],
|
||||
school_hours=SchoolHours(
|
||||
morning_start="09:00",
|
||||
morning_end="14:00",
|
||||
has_afternoon_session=False
|
||||
),
|
||||
source="comunidad_madrid_orden_1177_2024",
|
||||
enabled=True
|
||||
)
|
||||
|
||||
# Madrid Primary School Calendar 2025-2026 (Official Comunidad de Madrid - ORDEN 1476/2025)
|
||||
MADRID_PRIMARY_2025_2026 = CalendarDefinition(
|
||||
calendar_id="madrid_primary_2025_2026",
|
||||
calendar_name="Madrid Primary School Calendar 2025-2026",
|
||||
city_id="madrid",
|
||||
school_type=SchoolType.PRIMARY,
|
||||
academic_year="2025-2026",
|
||||
holiday_periods=[
|
||||
HolidayPeriod(
|
||||
name="Christmas Holiday",
|
||||
start_date="2025-12-20",
|
||||
end_date="2026-01-07",
|
||||
description="Official Christmas break - Comunidad de Madrid (Dec 20 - Jan 7)"
|
||||
),
|
||||
HolidayPeriod(
|
||||
name="Easter Holiday (Semana Santa)",
|
||||
start_date="2026-03-27",
|
||||
end_date="2026-04-06",
|
||||
description="Official Easter break - Comunidad de Madrid (Mar 27 - Apr 6)"
|
||||
),
|
||||
HolidayPeriod(
|
||||
name="Summer Holiday",
|
||||
start_date="2026-06-21",
|
||||
end_date="2026-09-08",
|
||||
description="Summer vacation (classes resume Sep 9)"
|
||||
),
|
||||
HolidayPeriod(
|
||||
name="October Long Weekend",
|
||||
start_date="2025-10-13",
|
||||
end_date="2025-10-13",
|
||||
description="October 13 non-working day (after Día de la Hispanidad)"
|
||||
),
|
||||
HolidayPeriod(
|
||||
name="All Saints Long Weekend",
|
||||
start_date="2025-11-03",
|
||||
end_date="2025-11-03",
|
||||
description="November 3 non-working day (after All Saints)"
|
||||
),
|
||||
],
|
||||
school_hours=SchoolHours(
|
||||
morning_start="09:00",
|
||||
morning_end="14:00",
|
||||
has_afternoon_session=False
|
||||
),
|
||||
source="comunidad_madrid_orden_1476_2025",
|
||||
enabled=True
|
||||
)
|
||||
|
||||
# Madrid Secondary School Calendar 2025-2026 (Official Comunidad de Madrid - ORDEN 1476/2025)
|
||||
MADRID_SECONDARY_2025_2026 = CalendarDefinition(
|
||||
calendar_id="madrid_secondary_2025_2026",
|
||||
calendar_name="Madrid Secondary School Calendar 2025-2026",
|
||||
city_id="madrid",
|
||||
school_type=SchoolType.SECONDARY,
|
||||
academic_year="2025-2026",
|
||||
holiday_periods=[
|
||||
HolidayPeriod(
|
||||
name="Christmas Holiday",
|
||||
start_date="2025-12-20",
|
||||
end_date="2026-01-07",
|
||||
description="Official Christmas break - Comunidad de Madrid (Dec 20 - Jan 7)"
|
||||
),
|
||||
HolidayPeriod(
|
||||
name="Easter Holiday (Semana Santa)",
|
||||
start_date="2026-03-27",
|
||||
end_date="2026-04-06",
|
||||
description="Official Easter break - Comunidad de Madrid (Mar 27 - Apr 6)"
|
||||
),
|
||||
HolidayPeriod(
|
||||
name="Summer Holiday",
|
||||
start_date="2026-06-21",
|
||||
end_date="2026-09-09",
|
||||
description="Summer vacation (classes resume Sep 10)"
|
||||
),
|
||||
HolidayPeriod(
|
||||
name="October Long Weekend",
|
||||
start_date="2025-10-13",
|
||||
end_date="2025-10-13",
|
||||
description="October 13 non-working day (after Día de la Hispanidad)"
|
||||
),
|
||||
HolidayPeriod(
|
||||
name="All Saints Long Weekend",
|
||||
start_date="2025-11-03",
|
||||
end_date="2025-11-03",
|
||||
description="November 3 non-working day (after All Saints)"
|
||||
),
|
||||
],
|
||||
school_hours=SchoolHours(
|
||||
morning_start="09:00",
|
||||
morning_end="14:00",
|
||||
has_afternoon_session=False
|
||||
),
|
||||
source="comunidad_madrid_orden_1476_2025",
|
||||
enabled=True
|
||||
)
|
||||
|
||||
# Registry of all calendars
|
||||
CALENDARS: List[CalendarDefinition] = [
|
||||
MADRID_PRIMARY_2024_2025,
|
||||
MADRID_SECONDARY_2024_2025,
|
||||
MADRID_PRIMARY_2025_2026,
|
||||
MADRID_SECONDARY_2025_2026,
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def get_all_calendars(cls) -> List[CalendarDefinition]:
|
||||
"""Get all calendars"""
|
||||
return cls.CALENDARS
|
||||
|
||||
@classmethod
|
||||
def get_enabled_calendars(cls) -> List[CalendarDefinition]:
|
||||
"""Get all enabled calendars"""
|
||||
return [cal for cal in cls.CALENDARS if cal.enabled]
|
||||
|
||||
@classmethod
|
||||
def get_calendar(cls, calendar_id: str) -> Optional[CalendarDefinition]:
|
||||
"""Get calendar by ID"""
|
||||
for cal in cls.CALENDARS:
|
||||
if cal.calendar_id == calendar_id:
|
||||
return cal
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_calendars_for_city(cls, city_id: str) -> List[CalendarDefinition]:
|
||||
"""Get all enabled calendars for a specific city"""
|
||||
return [
|
||||
cal for cal in cls.CALENDARS
|
||||
if cal.city_id == city_id and cal.enabled
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def get_calendar_for_city_and_type(
|
||||
cls,
|
||||
city_id: str,
|
||||
school_type: SchoolType,
|
||||
academic_year: Optional[str] = None
|
||||
) -> Optional[CalendarDefinition]:
|
||||
"""Get specific calendar for city, type, and optionally year"""
|
||||
for cal in cls.CALENDARS:
|
||||
if (cal.city_id == city_id and
|
||||
cal.school_type == school_type and
|
||||
cal.enabled and
|
||||
(academic_year is None or cal.academic_year == academic_year)):
|
||||
return cal
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def to_dict(cls, calendar: CalendarDefinition) -> Dict[str, Any]:
|
||||
"""Convert calendar definition to dictionary for JSON serialization"""
|
||||
return {
|
||||
"calendar_id": calendar.calendar_id,
|
||||
"calendar_name": calendar.calendar_name,
|
||||
"city_id": calendar.city_id,
|
||||
"school_type": calendar.school_type.value,
|
||||
"academic_year": calendar.academic_year,
|
||||
"holiday_periods": [
|
||||
{
|
||||
"name": hp.name,
|
||||
"start_date": hp.start_date,
|
||||
"end_date": hp.end_date,
|
||||
"description": hp.description
|
||||
}
|
||||
for hp in calendar.holiday_periods
|
||||
],
|
||||
"school_hours": {
|
||||
"morning_start": calendar.school_hours.morning_start,
|
||||
"morning_end": calendar.school_hours.morning_end,
|
||||
"has_afternoon_session": calendar.school_hours.has_afternoon_session,
|
||||
"afternoon_start": calendar.school_hours.afternoon_start,
|
||||
"afternoon_end": calendar.school_hours.afternoon_end,
|
||||
},
|
||||
"source": calendar.source,
|
||||
"enabled": calendar.enabled
|
||||
}
|
||||
|
||||
|
||||
# Local Events Registry for Madrid
|
||||
@dataclass
|
||||
class LocalEventDefinition:
|
||||
"""Local event that impacts demand"""
|
||||
event_id: str
|
||||
name: str
|
||||
city_id: str
|
||||
date: str # ISO format or "annual-MM-DD" for recurring
|
||||
impact_level: str # "low", "medium", "high"
|
||||
description: Optional[str] = None
|
||||
recurring: bool = False # True for annual events
|
||||
|
||||
|
||||
class LocalEventsRegistry:
|
||||
"""Registry of local events and festivals"""
|
||||
|
||||
MADRID_EVENTS = [
|
||||
LocalEventDefinition(
|
||||
event_id="madrid_san_isidro",
|
||||
name="San Isidro Festival",
|
||||
city_id="madrid",
|
||||
date="annual-05-15",
|
||||
impact_level="high",
|
||||
description="Madrid's patron saint festival - major citywide celebration",
|
||||
recurring=True
|
||||
),
|
||||
LocalEventDefinition(
|
||||
event_id="madrid_dos_de_mayo",
|
||||
name="Dos de Mayo",
|
||||
city_id="madrid",
|
||||
date="annual-05-02",
|
||||
impact_level="medium",
|
||||
description="Madrid regional holiday",
|
||||
recurring=True
|
||||
),
|
||||
LocalEventDefinition(
|
||||
event_id="madrid_almudena",
|
||||
name="Virgen de la Almudena",
|
||||
city_id="madrid",
|
||||
date="annual-11-09",
|
||||
impact_level="medium",
|
||||
description="Madrid patron saint day",
|
||||
recurring=True
|
||||
),
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def get_events_for_city(cls, city_id: str) -> List[LocalEventDefinition]:
|
||||
"""Get all local events for a city"""
|
||||
if city_id == "madrid":
|
||||
return cls.MADRID_EVENTS
|
||||
return []
|
||||
329
services/external/app/repositories/calendar_repository.py
vendored
Normal file
329
services/external/app/repositories/calendar_repository.py
vendored
Normal file
@@ -0,0 +1,329 @@
|
||||
# services/external/app/repositories/calendar_repository.py
|
||||
"""
|
||||
Calendar Repository - Manages school calendars and tenant location contexts
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
from sqlalchemy import select, and_, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import structlog
|
||||
import uuid
|
||||
|
||||
from app.models.calendar import SchoolCalendar, TenantLocationContext
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class CalendarRepository:
|
||||
"""Repository for school calendar and tenant location data"""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
# ===== School Calendar Operations =====
|
||||
|
||||
async def create_school_calendar(
|
||||
self,
|
||||
city_id: str,
|
||||
calendar_name: str,
|
||||
school_type: str,
|
||||
academic_year: str,
|
||||
holiday_periods: List[Dict[str, Any]],
|
||||
school_hours: Dict[str, Any],
|
||||
source: Optional[str] = None,
|
||||
enabled: bool = True
|
||||
) -> SchoolCalendar:
|
||||
"""Create a new school calendar"""
|
||||
try:
|
||||
calendar = SchoolCalendar(
|
||||
id=uuid.uuid4(),
|
||||
city_id=city_id,
|
||||
calendar_name=calendar_name,
|
||||
school_type=school_type,
|
||||
academic_year=academic_year,
|
||||
holiday_periods=holiday_periods,
|
||||
school_hours=school_hours,
|
||||
source=source,
|
||||
enabled=enabled
|
||||
)
|
||||
|
||||
self.session.add(calendar)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(calendar)
|
||||
|
||||
logger.info(
|
||||
"School calendar created",
|
||||
calendar_id=str(calendar.id),
|
||||
city_id=city_id,
|
||||
school_type=school_type
|
||||
)
|
||||
|
||||
return calendar
|
||||
|
||||
except Exception as e:
|
||||
await self.session.rollback()
|
||||
logger.error(
|
||||
"Error creating school calendar",
|
||||
city_id=city_id,
|
||||
error=str(e)
|
||||
)
|
||||
raise
|
||||
|
||||
async def get_calendar_by_id(
|
||||
self,
|
||||
calendar_id: uuid.UUID
|
||||
) -> Optional[SchoolCalendar]:
|
||||
"""Get school calendar by ID"""
|
||||
stmt = select(SchoolCalendar).where(SchoolCalendar.id == calendar_id)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_calendars_by_city(
|
||||
self,
|
||||
city_id: str,
|
||||
enabled_only: bool = True
|
||||
) -> List[SchoolCalendar]:
|
||||
"""Get all school calendars for a city"""
|
||||
stmt = select(SchoolCalendar).where(SchoolCalendar.city_id == city_id)
|
||||
|
||||
if enabled_only:
|
||||
stmt = stmt.where(SchoolCalendar.enabled == True)
|
||||
|
||||
stmt = stmt.order_by(SchoolCalendar.academic_year.desc(), SchoolCalendar.school_type)
|
||||
|
||||
result = await self.session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_calendar_by_city_type_year(
|
||||
self,
|
||||
city_id: str,
|
||||
school_type: str,
|
||||
academic_year: str
|
||||
) -> Optional[SchoolCalendar]:
|
||||
"""Get specific calendar by city, type, and year"""
|
||||
stmt = select(SchoolCalendar).where(
|
||||
and_(
|
||||
SchoolCalendar.city_id == city_id,
|
||||
SchoolCalendar.school_type == school_type,
|
||||
SchoolCalendar.academic_year == academic_year,
|
||||
SchoolCalendar.enabled == True
|
||||
)
|
||||
)
|
||||
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def update_calendar(
|
||||
self,
|
||||
calendar_id: uuid.UUID,
|
||||
**kwargs
|
||||
) -> Optional[SchoolCalendar]:
|
||||
"""Update school calendar"""
|
||||
try:
|
||||
calendar = await self.get_calendar_by_id(calendar_id)
|
||||
if not calendar:
|
||||
return None
|
||||
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(calendar, key):
|
||||
setattr(calendar, key, value)
|
||||
|
||||
calendar.updated_at = datetime.utcnow()
|
||||
|
||||
await self.session.commit()
|
||||
await self.session.refresh(calendar)
|
||||
|
||||
logger.info(
|
||||
"School calendar updated",
|
||||
calendar_id=str(calendar_id),
|
||||
fields=list(kwargs.keys())
|
||||
)
|
||||
|
||||
return calendar
|
||||
|
||||
except Exception as e:
|
||||
await self.session.rollback()
|
||||
logger.error(
|
||||
"Error updating school calendar",
|
||||
calendar_id=str(calendar_id),
|
||||
error=str(e)
|
||||
)
|
||||
raise
|
||||
|
||||
async def delete_calendar(self, calendar_id: uuid.UUID) -> bool:
|
||||
"""Delete school calendar"""
|
||||
try:
|
||||
calendar = await self.get_calendar_by_id(calendar_id)
|
||||
if not calendar:
|
||||
return False
|
||||
|
||||
await self.session.delete(calendar)
|
||||
await self.session.commit()
|
||||
|
||||
logger.info("School calendar deleted", calendar_id=str(calendar_id))
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
await self.session.rollback()
|
||||
logger.error(
|
||||
"Error deleting school calendar",
|
||||
calendar_id=str(calendar_id),
|
||||
error=str(e)
|
||||
)
|
||||
raise
|
||||
|
||||
# ===== Tenant Location Context Operations =====
|
||||
|
||||
async def create_or_update_tenant_location_context(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
city_id: str,
|
||||
school_calendar_id: Optional[uuid.UUID] = None,
|
||||
neighborhood: Optional[str] = None,
|
||||
local_events: Optional[List[Dict[str, Any]]] = None,
|
||||
notes: Optional[str] = None
|
||||
) -> TenantLocationContext:
|
||||
"""Create or update tenant location context"""
|
||||
try:
|
||||
# Check if context exists
|
||||
existing = await self.get_tenant_location_context(tenant_id)
|
||||
|
||||
if existing:
|
||||
# Update existing
|
||||
existing.city_id = city_id
|
||||
if school_calendar_id is not None:
|
||||
existing.school_calendar_id = school_calendar_id
|
||||
if neighborhood is not None:
|
||||
existing.neighborhood = neighborhood
|
||||
if local_events is not None:
|
||||
existing.local_events = local_events
|
||||
if notes is not None:
|
||||
existing.notes = notes
|
||||
existing.updated_at = datetime.utcnow()
|
||||
|
||||
await self.session.commit()
|
||||
await self.session.refresh(existing)
|
||||
|
||||
logger.info(
|
||||
"Tenant location context updated",
|
||||
tenant_id=str(tenant_id)
|
||||
)
|
||||
|
||||
return existing
|
||||
else:
|
||||
# Create new
|
||||
context = TenantLocationContext(
|
||||
tenant_id=tenant_id,
|
||||
city_id=city_id,
|
||||
school_calendar_id=school_calendar_id,
|
||||
neighborhood=neighborhood,
|
||||
local_events=local_events or [],
|
||||
notes=notes
|
||||
)
|
||||
|
||||
self.session.add(context)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(context)
|
||||
|
||||
logger.info(
|
||||
"Tenant location context created",
|
||||
tenant_id=str(tenant_id),
|
||||
city_id=city_id
|
||||
)
|
||||
|
||||
return context
|
||||
|
||||
except Exception as e:
|
||||
await self.session.rollback()
|
||||
logger.error(
|
||||
"Error creating/updating tenant location context",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e)
|
||||
)
|
||||
raise
|
||||
|
||||
async def get_tenant_location_context(
|
||||
self,
|
||||
tenant_id: uuid.UUID
|
||||
) -> Optional[TenantLocationContext]:
|
||||
"""Get tenant location context"""
|
||||
stmt = select(TenantLocationContext).where(
|
||||
TenantLocationContext.tenant_id == tenant_id
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_tenant_with_calendar(
|
||||
self,
|
||||
tenant_id: uuid.UUID
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Get tenant location context with full calendar details"""
|
||||
context = await self.get_tenant_location_context(tenant_id)
|
||||
if not context:
|
||||
return None
|
||||
|
||||
result = {
|
||||
"tenant_id": str(context.tenant_id),
|
||||
"city_id": context.city_id,
|
||||
"neighborhood": context.neighborhood,
|
||||
"local_events": context.local_events,
|
||||
"notes": context.notes,
|
||||
"calendar": None
|
||||
}
|
||||
|
||||
if context.school_calendar_id:
|
||||
calendar = await self.get_calendar_by_id(context.school_calendar_id)
|
||||
if calendar:
|
||||
result["calendar"] = {
|
||||
"calendar_id": str(calendar.id),
|
||||
"calendar_name": calendar.calendar_name,
|
||||
"school_type": calendar.school_type,
|
||||
"academic_year": calendar.academic_year,
|
||||
"holiday_periods": calendar.holiday_periods,
|
||||
"school_hours": calendar.school_hours,
|
||||
"source": calendar.source
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
async def delete_tenant_location_context(
|
||||
self,
|
||||
tenant_id: uuid.UUID
|
||||
) -> bool:
|
||||
"""Delete tenant location context"""
|
||||
try:
|
||||
context = await self.get_tenant_location_context(tenant_id)
|
||||
if not context:
|
||||
return False
|
||||
|
||||
await self.session.delete(context)
|
||||
await self.session.commit()
|
||||
|
||||
logger.info(
|
||||
"Tenant location context deleted",
|
||||
tenant_id=str(tenant_id)
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
await self.session.rollback()
|
||||
logger.error(
|
||||
"Error deleting tenant location context",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e)
|
||||
)
|
||||
raise
|
||||
|
||||
# ===== Helper Methods =====
|
||||
|
||||
async def get_all_tenants_for_calendar(
|
||||
self,
|
||||
calendar_id: uuid.UUID
|
||||
) -> List[TenantLocationContext]:
|
||||
"""Get all tenants using a specific calendar"""
|
||||
stmt = select(TenantLocationContext).where(
|
||||
TenantLocationContext.school_calendar_id == calendar_id
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
134
services/external/app/schemas/calendar.py
vendored
Normal file
134
services/external/app/schemas/calendar.py
vendored
Normal file
@@ -0,0 +1,134 @@
|
||||
# services/external/app/schemas/calendar.py
|
||||
"""
|
||||
Calendar Schemas - Request/Response types for school calendars and location context
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Dict, Any
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
class SchoolCalendarResponse(BaseModel):
|
||||
"""School calendar information"""
|
||||
calendar_id: str
|
||||
calendar_name: str
|
||||
city_id: str
|
||||
school_type: str
|
||||
academic_year: str
|
||||
holiday_periods: List[Dict[str, Any]]
|
||||
school_hours: Dict[str, Any]
|
||||
source: Optional[str] = None
|
||||
enabled: bool = True
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"calendar_id": "madrid_primary_2024_2025",
|
||||
"calendar_name": "Madrid Primary School Calendar 2024-2025",
|
||||
"city_id": "madrid",
|
||||
"school_type": "primary",
|
||||
"academic_year": "2024-2025",
|
||||
"holiday_periods": [
|
||||
{
|
||||
"name": "Christmas Holiday",
|
||||
"start_date": "2024-12-23",
|
||||
"end_date": "2025-01-07",
|
||||
"description": "Christmas and New Year break"
|
||||
}
|
||||
],
|
||||
"school_hours": {
|
||||
"morning_start": "09:00",
|
||||
"morning_end": "14:00",
|
||||
"has_afternoon_session": False
|
||||
},
|
||||
"source": "madrid_education_dept_2024",
|
||||
"enabled": True
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class SchoolCalendarListResponse(BaseModel):
|
||||
"""List of school calendars for a city"""
|
||||
city_id: str
|
||||
calendars: List[SchoolCalendarResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class CalendarCheckResponse(BaseModel):
|
||||
"""Response for holiday check"""
|
||||
date: str = Field(..., description="Date checked (ISO format)")
|
||||
is_holiday: bool = Field(..., description="Whether the date is a school holiday")
|
||||
holiday_name: Optional[str] = Field(None, description="Name of the holiday if applicable")
|
||||
calendar_id: str
|
||||
calendar_name: str
|
||||
|
||||
|
||||
class TenantLocationContextResponse(BaseModel):
|
||||
"""Tenant location context with calendar details"""
|
||||
tenant_id: str
|
||||
city_id: str
|
||||
neighborhood: Optional[str] = None
|
||||
local_events: Optional[List[Dict[str, Any]]] = None
|
||||
notes: Optional[str] = None
|
||||
calendar: Optional[Dict[str, Any]] = Field(
|
||||
None,
|
||||
description="Full calendar details if assigned"
|
||||
)
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"tenant_id": "fbffcf18-d02a-4104-b6e3-0b32006e3e47",
|
||||
"city_id": "madrid",
|
||||
"neighborhood": "Chamberí",
|
||||
"local_events": [
|
||||
{
|
||||
"name": "Neighborhood Festival",
|
||||
"date": "2025-06-15",
|
||||
"impact": "high"
|
||||
}
|
||||
],
|
||||
"notes": "Bakery near primary school",
|
||||
"calendar": {
|
||||
"calendar_id": "uuid",
|
||||
"calendar_name": "Madrid Primary School Calendar 2024-2025",
|
||||
"school_type": "primary",
|
||||
"academic_year": "2024-2025",
|
||||
"holiday_periods": [],
|
||||
"school_hours": {},
|
||||
"source": "madrid_education_dept_2024"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TenantLocationContextCreateRequest(BaseModel):
|
||||
"""Request to create/update tenant location context"""
|
||||
city_id: str = Field(..., description="City ID (e.g., 'madrid')")
|
||||
school_calendar_id: Optional[UUID] = Field(
|
||||
None,
|
||||
description="School calendar ID to assign"
|
||||
)
|
||||
neighborhood: Optional[str] = Field(None, description="Neighborhood name")
|
||||
local_events: Optional[List[Dict[str, Any]]] = Field(
|
||||
None,
|
||||
description="Local events specific to this location"
|
||||
)
|
||||
notes: Optional[str] = Field(None, description="Additional notes")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"city_id": "madrid",
|
||||
"school_calendar_id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"neighborhood": "Chamberí",
|
||||
"local_events": [
|
||||
{
|
||||
"name": "Local Market Day",
|
||||
"date": "2025-05-20",
|
||||
"impact": "medium"
|
||||
}
|
||||
],
|
||||
"notes": "Bakery located near primary school entrance"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user