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
|
||||
]
|
||||
Reference in New Issue
Block a user