"""Monitors API endpoints with tier enforcement. When creating a monitor for an organization, tier enforcement checks: - monitors_per_service: max number of monitors per service - check_interval_min: minimum allowed check interval """ from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.dependencies import get_db, verify_api_key from app.models.models import Monitor, MonitorResult, Service from app.models.saas_models import Organization from app.services.tier_enforcement import ( enforce_monitor_limit, validate_check_interval, ) from app.services.tier_limits import TierLimitExceeded, get_tier_limits router = APIRouter() class MonitorCreate(BaseModel): service_id: UUID url: str = Field(..., max_length=500) method: str = Field("GET", pattern=r"^(GET|POST|HEAD)$") expected_status: int = 200 timeout_seconds: int = Field(10, ge=1, le=60) interval_seconds: int = Field(60, ge=30, le=3600) organization_id: str | None = None # Optional: for tier enforcement class MonitorUpdate(BaseModel): url: str | None = Field(None, max_length=500) method: str | None = Field(None, pattern=r"^(GET|POST|HEAD)$") expected_status: int | None = None timeout_seconds: int | None = Field(None, ge=1, le=60) interval_seconds: int | None = Field(None, ge=30, le=3600) is_active: bool | None = None def serialize_monitor(m: Monitor) -> dict: return { "id": m.id, "service_id": m.service_id, "url": m.url, "method": m.method, "expected_status": m.expected_status, "timeout_seconds": m.timeout_seconds, "interval_seconds": m.interval_seconds, "is_active": m.is_active, "organization_id": m.organization_id, "created_at": m.created_at.isoformat() if m.created_at else None, "updated_at": m.updated_at.isoformat() if m.updated_at else None, } @router.get("/") async def list_monitors(db: AsyncSession = Depends(get_db)): """List all monitors.""" result = await db.execute(select(Monitor)) monitors = result.scalars().all() return [serialize_monitor(m) for m in monitors] @router.post("/", status_code=status.HTTP_201_CREATED) async def create_monitor( data: MonitorCreate, db: AsyncSession = Depends(get_db), api_key: str = Depends(verify_api_key), ): """Create a new monitor. If organization_id is provided, tier enforcement is applied: - check monitors_per_service limit - validate check_interval against minimum for the tier """ # Look up org if provided org = None if data.organization_id: result = await db.execute( select(Organization).where(Organization.id == data.organization_id) ) org = result.scalar_one_or_none() if org is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Organization '{data.organization_id}' not found", ) # Tier enforcement when org context is provided if org is not None: # Check monitors_per_service limit await enforce_monitor_limit(db, org, str(data.service_id)) # Validate check interval validate_check_interval(org, data.interval_seconds) # Verify the service exists service_result = await db.execute( select(Service).where(Service.id == str(data.service_id)) ) service = service_result.scalar_one_or_none() if not service: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Service '{data.service_id}' not found", ) monitor = Monitor( service_id=str(data.service_id), url=data.url, method=data.method, expected_status=data.expected_status, timeout_seconds=data.timeout_seconds, interval_seconds=data.interval_seconds, organization_id=data.organization_id or (service.organization_id if service else None), ) db.add(monitor) await db.flush() await db.refresh(monitor) return serialize_monitor(monitor) @router.get("/{monitor_id}") async def get_monitor(monitor_id: UUID, db: AsyncSession = Depends(get_db)): """Get a monitor with recent results.""" result = await db.execute(select(Monitor).where(Monitor.id == str(monitor_id))) monitor = result.scalar_one_or_none() if not monitor: raise HTTPException(status_code=404, detail="Monitor not found") # Query recent results separately results_query = ( select(MonitorResult) .where(MonitorResult.monitor_id == str(monitor_id)) .order_by(MonitorResult.checked_at.desc()) .limit(10) ) results_result = await db.execute(results_query) recent_results = results_result.scalars().all() data = serialize_monitor(monitor) data["recent_results"] = [ { "id": r.id, "status": r.status, "response_time_ms": r.response_time_ms, "status_code": r.status_code, "error_message": r.error_message, "checked_at": r.checked_at.isoformat() if r.checked_at else None, } for r in recent_results ] return data @router.patch("/{monitor_id}") async def update_monitor( monitor_id: UUID, data: MonitorUpdate, db: AsyncSession = Depends(get_db), api_key: str = Depends(verify_api_key), ): """Update a monitor. If interval_seconds is being changed, validate against the org's tier minimum. """ result = await db.execute(select(Monitor).where(Monitor.id == str(monitor_id))) monitor = result.scalar_one_or_none() if not monitor: raise HTTPException(status_code=404, detail="Monitor not found") update_data = data.model_dump(exclude_unset=True) # If updating interval_seconds and org context exists, validate if data.interval_seconds is not None and monitor.organization_id: org_result = await db.execute( select(Organization).where(Organization.id == monitor.organization_id) ) org = org_result.scalar_one_or_none() if org: validate_check_interval(org, data.interval_seconds) for field, value in update_data.items(): setattr(monitor, field, value) await db.flush() await db.refresh(monitor) return serialize_monitor(monitor) @router.delete("/{monitor_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_monitor( monitor_id: UUID, db: AsyncSession = Depends(get_db), api_key: str = Depends(verify_api_key), ): """Delete a monitor.""" result = await db.execute(select(Monitor).where(Monitor.id == str(monitor_id))) monitor = result.scalar_one_or_none() if not monitor: raise HTTPException(status_code=404, detail="Monitor not found") await db.delete(monitor) @router.post("/{monitor_id}/check") async def trigger_check( monitor_id: UUID, db: AsyncSession = Depends(get_db), api_key: str = Depends(verify_api_key), ): """Trigger a manual uptime check for this monitor.""" from app.services.uptime import check_monitor result = await db.execute(select(Monitor).where(Monitor.id == str(monitor_id))) monitor = result.scalar_one_or_none() if not monitor: raise HTTPException(status_code=404, detail="Monitor not found") monitor_result = await check_monitor(monitor, db) return { "status": monitor_result.status, "response_time_ms": monitor_result.response_time_ms, "status_code": monitor_result.status_code, }