Improve the frontend 5

This commit is contained in:
Urtzi Alfaro
2025-11-02 20:24:44 +01:00
parent 0220da1725
commit 5adb0e39c0
90 changed files with 10658 additions and 2548 deletions

237
services/external/app/api/audit.py vendored Normal file
View 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)}"
)

View 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
]

View File

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

View File

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

View File

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

View File

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

View File

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

View 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'),
)

View 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 []

View 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())

View 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"
}
}