feat(17-02): SNMP profile CRUD API and SNMP metrics query endpoint

- Add Pydantic schemas for SNMP profile CRUD (list excludes profile_data JSONB)
- Add 5-route SNMP profiles router with system profile protection (403)
- Add device deletion protection for referenced profiles (409)
- Add time-bucketed SNMP metrics query endpoint with metric_name/group filters
- Add distinct metric names endpoint for frontend dropdowns
- Register snmp_profiles_router in main.py

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jason Staack
2026-03-21 18:52:58 -05:00
parent 3d149b674f
commit eb3ea0def3
4 changed files with 498 additions and 0 deletions

View File

@@ -462,6 +462,7 @@ def create_app() -> FastAPI:
from app.routers.settings import router as settings_router
from app.routers.remote_access import router as remote_access_router
from app.routers.winbox_remote import router as winbox_remote_router
from app.routers.snmp_profiles import router as snmp_profiles_router
from app.routers.sites import router as sites_router
from app.routers.links import router as links_router
from app.routers.sectors import router as sectors_router
@@ -496,6 +497,7 @@ def create_app() -> FastAPI:
app.include_router(settings_router, prefix="/api")
app.include_router(remote_access_router, prefix="/api")
app.include_router(winbox_remote_router, prefix="/api")
app.include_router(snmp_profiles_router, prefix="/api")
app.include_router(sites_router, prefix="/api")
app.include_router(links_router, prefix="/api")
app.include_router(sectors_router, prefix="/api")

View File

@@ -312,6 +312,104 @@ async def device_wireless_latest(
return [dict(row) for row in rows]
# ---------------------------------------------------------------------------
# SNMP custom metrics
# ---------------------------------------------------------------------------
@router.get(
"/tenants/{tenant_id}/devices/{device_id}/metrics/snmp",
summary="Time-bucketed custom SNMP metrics",
)
async def device_snmp_metrics(
tenant_id: uuid.UUID,
device_id: uuid.UUID,
start: datetime = Query(..., description="Start of time range (ISO format)"),
end: datetime = Query(..., description="End of time range (ISO format)"),
metric_name: Optional[str] = Query(None, description="Filter to specific metric name"),
metric_group: Optional[str] = Query(None, description="Filter to specific metric group"),
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> list[dict[str, Any]]:
"""Return time-bucketed custom SNMP metrics for a device.
Supports optional filtering by metric_name and metric_group.
Numeric values are aggregated (avg, min, max); text values return
the most recent value per bucket.
"""
await _check_tenant_access(current_user, tenant_id, db)
bucket = _bucket_for_range(start, end)
# Build optional filters
filters = ""
if metric_name:
filters += " AND metric_name = :metric_name"
if metric_group:
filters += " AND metric_group = :metric_group"
sql = f"""
SELECT
time_bucket(:bucket, time) AS bucket,
metric_name,
metric_group,
oid,
index_value,
avg(value_numeric) AS avg_value,
max(value_numeric) AS max_value,
min(value_numeric) AS min_value,
(array_agg(value_text ORDER BY time DESC))[1] AS last_text_value
FROM snmp_metrics
WHERE device_id = :device_id
AND time >= :start AND time < :end
{filters}
GROUP BY bucket, metric_name, metric_group, oid, index_value
ORDER BY metric_name, index_value, bucket ASC
"""
params: dict[str, Any] = {
"bucket": bucket,
"device_id": str(device_id),
"start": start,
"end": end,
}
if metric_name:
params["metric_name"] = metric_name
if metric_group:
params["metric_group"] = metric_group
result = await db.execute(text(sql), params)
rows = result.mappings().all()
return [dict(row) for row in rows]
@router.get(
"/tenants/{tenant_id}/devices/{device_id}/metrics/snmp/names",
summary="List distinct SNMP metric names for a device",
)
async def device_snmp_metric_names(
tenant_id: uuid.UUID,
device_id: uuid.UUID,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> list[dict[str, str]]:
"""Return distinct (metric_name, metric_group) pairs for a device.
Used by the frontend to populate metric selection dropdowns.
"""
await _check_tenant_access(current_user, tenant_id, db)
result = await db.execute(
text("""
SELECT DISTINCT metric_name, metric_group
FROM snmp_metrics
WHERE device_id = :device_id
ORDER BY metric_group, metric_name
"""),
{"device_id": str(device_id)},
)
rows = result.mappings().all()
return [dict(row) for row in rows]
# ---------------------------------------------------------------------------
# Sparkline
# ---------------------------------------------------------------------------

View File

@@ -0,0 +1,305 @@
"""
SNMP Profile CRUD API endpoints.
Routes: /api/tenants/{tenant_id}/snmp-profiles
Provides listing, creation, update, and deletion of SNMP device profiles.
System-shipped profiles (is_system=True, tenant_id IS NULL) are visible to
all tenants but cannot be modified or deleted.
RBAC:
- devices:read scope: GET (list, detail)
- operator+: POST, PUT (create, update tenant profiles)
- tenant_admin+: DELETE (delete tenant profiles)
"""
import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.middleware.rbac import require_operator_or_above, require_scope, require_tenant_admin_or_above
from app.middleware.tenant_context import CurrentUser, get_current_user
from app.routers.devices import _check_tenant_access
from app.schemas.snmp_profile import (
SNMPProfileCreate,
SNMPProfileDetailResponse,
SNMPProfileListResponse,
SNMPProfileResponse,
SNMPProfileUpdate,
)
router = APIRouter(tags=["snmp-profiles"])
# ---------------------------------------------------------------------------
# List profiles (system + tenant)
# ---------------------------------------------------------------------------
@router.get(
"/tenants/{tenant_id}/snmp-profiles",
response_model=SNMPProfileListResponse,
summary="List SNMP profiles (system + tenant)",
dependencies=[require_scope("devices:read")],
)
async def list_profiles(
tenant_id: uuid.UUID,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> SNMPProfileListResponse:
"""List all SNMP profiles visible to a tenant.
Returns both system-shipped profiles (tenant_id IS NULL) and
tenant-specific custom profiles. System profiles appear first.
"""
await _check_tenant_access(current_user, tenant_id, db)
result = await db.execute(
text("""
SELECT id, tenant_id, name, description, sys_object_id, vendor,
category, is_system, created_at, updated_at
FROM snmp_profiles
WHERE tenant_id = :tenant_id OR tenant_id IS NULL
ORDER BY is_system DESC, name ASC
"""),
{"tenant_id": str(tenant_id)},
)
rows = result.mappings().all()
return SNMPProfileListResponse(profiles=[dict(row) for row in rows])
# ---------------------------------------------------------------------------
# Get profile detail (includes profile_data)
# ---------------------------------------------------------------------------
@router.get(
"/tenants/{tenant_id}/snmp-profiles/{profile_id}",
response_model=SNMPProfileDetailResponse,
summary="Get SNMP profile detail",
dependencies=[require_scope("devices:read")],
)
async def get_profile(
tenant_id: uuid.UUID,
profile_id: uuid.UUID,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> dict[str, Any]:
"""Get a single SNMP profile with full profile_data JSONB."""
await _check_tenant_access(current_user, tenant_id, db)
result = await db.execute(
text("""
SELECT id, tenant_id, name, description, sys_object_id, vendor,
category, profile_data, is_system, created_at, updated_at
FROM snmp_profiles
WHERE id = :profile_id
AND (tenant_id = :tenant_id OR tenant_id IS NULL)
"""),
{"profile_id": str(profile_id), "tenant_id": str(tenant_id)},
)
row = result.mappings().first()
if not row:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="SNMP profile not found")
return dict(row)
# ---------------------------------------------------------------------------
# Create profile
# ---------------------------------------------------------------------------
@router.post(
"/tenants/{tenant_id}/snmp-profiles",
response_model=SNMPProfileResponse,
status_code=status.HTTP_201_CREATED,
summary="Create a tenant SNMP profile",
dependencies=[Depends(require_operator_or_above)],
)
async def create_profile(
tenant_id: uuid.UUID,
data: SNMPProfileCreate,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> dict[str, Any]:
"""Create a new tenant-scoped SNMP profile (is_system=False)."""
await _check_tenant_access(current_user, tenant_id, db)
import json
result = await db.execute(
text("""
INSERT INTO snmp_profiles
(tenant_id, name, description, sys_object_id, vendor,
category, profile_data, is_system)
VALUES
(:tenant_id, :name, :description, :sys_object_id, :vendor,
:category, :profile_data::jsonb, FALSE)
RETURNING id, tenant_id, name, description, sys_object_id, vendor,
category, is_system, created_at, updated_at
"""),
{
"tenant_id": str(tenant_id),
"name": data.name,
"description": data.description,
"sys_object_id": data.sys_object_id,
"vendor": data.vendor,
"category": data.category,
"profile_data": json.dumps(data.profile_data),
},
)
await db.commit()
row = result.mappings().first()
return dict(row)
# ---------------------------------------------------------------------------
# Update profile
# ---------------------------------------------------------------------------
@router.put(
"/tenants/{tenant_id}/snmp-profiles/{profile_id}",
response_model=SNMPProfileResponse,
summary="Update a tenant SNMP profile",
dependencies=[Depends(require_operator_or_above)],
)
async def update_profile(
tenant_id: uuid.UUID,
profile_id: uuid.UUID,
data: SNMPProfileUpdate,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> dict[str, Any]:
"""Update an existing tenant-scoped SNMP profile.
System profiles (is_system=True) cannot be modified -- returns 403.
"""
await _check_tenant_access(current_user, tenant_id, db)
# Verify profile exists and is tenant-owned
existing = await db.execute(
text("""
SELECT id, is_system
FROM snmp_profiles
WHERE id = :profile_id
AND (tenant_id = :tenant_id OR tenant_id IS NULL)
"""),
{"profile_id": str(profile_id), "tenant_id": str(tenant_id)},
)
row = existing.mappings().first()
if not row:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="SNMP profile not found")
if row["is_system"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="System profiles cannot be modified",
)
# Build dynamic SET clause from provided fields
import json
updates = {}
set_clauses = []
fields = data.model_dump(exclude_unset=True)
for field, value in fields.items():
if field == "profile_data" and value is not None:
set_clauses.append(f"{field} = :{field}::jsonb")
updates[field] = json.dumps(value)
else:
set_clauses.append(f"{field} = :{field}")
updates[field] = value
if not set_clauses:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No fields to update",
)
set_clauses.append("updated_at = NOW()")
updates["profile_id"] = str(profile_id)
updates["tenant_id"] = str(tenant_id)
sql = f"""
UPDATE snmp_profiles
SET {', '.join(set_clauses)}
WHERE id = :profile_id AND tenant_id = :tenant_id
RETURNING id, tenant_id, name, description, sys_object_id, vendor,
category, is_system, created_at, updated_at
"""
result = await db.execute(text(sql), updates)
await db.commit()
row = result.mappings().first()
return dict(row)
# ---------------------------------------------------------------------------
# Delete profile
# ---------------------------------------------------------------------------
@router.delete(
"/tenants/{tenant_id}/snmp-profiles/{profile_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a tenant SNMP profile",
dependencies=[Depends(require_tenant_admin_or_above)],
)
async def delete_profile(
tenant_id: uuid.UUID,
profile_id: uuid.UUID,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> None:
"""Delete a tenant-scoped SNMP profile.
System profiles (is_system=True) cannot be deleted -- returns 403.
Profiles referenced by devices cannot be deleted -- returns 409.
"""
await _check_tenant_access(current_user, tenant_id, db)
# Verify profile exists and is tenant-owned
existing = await db.execute(
text("""
SELECT id, is_system
FROM snmp_profiles
WHERE id = :profile_id
AND (tenant_id = :tenant_id OR tenant_id IS NULL)
"""),
{"profile_id": str(profile_id), "tenant_id": str(tenant_id)},
)
row = existing.mappings().first()
if not row:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="SNMP profile not found")
if row["is_system"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="System profiles cannot be deleted",
)
# Check if any devices reference this profile
ref_check = await db.execute(
text("""
SELECT COUNT(*) AS cnt
FROM devices
WHERE snmp_profile_id = :profile_id
"""),
{"profile_id": str(profile_id)},
)
count = ref_check.scalar()
if count and count > 0:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Cannot delete profile: {count} device(s) still reference it",
)
await db.execute(
text("DELETE FROM snmp_profiles WHERE id = :profile_id AND tenant_id = :tenant_id"),
{"profile_id": str(profile_id), "tenant_id": str(tenant_id)},
)
await db.commit()

View File

@@ -0,0 +1,93 @@
"""Pydantic schemas for SNMP Profile CRUD endpoints."""
import uuid
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, field_validator
VALID_CATEGORIES = {"switch", "router", "access_point", "ups", "printer", "server", "generic"}
class SNMPProfileCreate(BaseModel):
"""Schema for creating a tenant-scoped SNMP profile."""
name: str
description: Optional[str] = None
sys_object_id: Optional[str] = None
vendor: Optional[str] = None
category: Optional[str] = None
profile_data: dict
@field_validator("name")
@classmethod
def validate_name(cls, v: str) -> str:
v = v.strip()
if len(v) < 1 or len(v) > 255:
raise ValueError("Profile name must be 1-255 characters")
return v
@field_validator("category")
@classmethod
def validate_category(cls, v: Optional[str]) -> Optional[str]:
if v is not None and v not in VALID_CATEGORIES:
raise ValueError(f"Category must be one of: {', '.join(sorted(VALID_CATEGORIES))}")
return v
class SNMPProfileUpdate(BaseModel):
"""Schema for updating an SNMP profile. All fields optional."""
name: Optional[str] = None
description: Optional[str] = None
sys_object_id: Optional[str] = None
vendor: Optional[str] = None
category: Optional[str] = None
profile_data: Optional[dict] = None
@field_validator("name")
@classmethod
def validate_name(cls, v: Optional[str]) -> Optional[str]:
if v is None:
return v
v = v.strip()
if len(v) < 1 or len(v) > 255:
raise ValueError("Profile name must be 1-255 characters")
return v
@field_validator("category")
@classmethod
def validate_category(cls, v: Optional[str]) -> Optional[str]:
if v is not None and v not in VALID_CATEGORIES:
raise ValueError(f"Category must be one of: {', '.join(sorted(VALID_CATEGORIES))}")
return v
class SNMPProfileResponse(BaseModel):
"""SNMP profile response (list view -- excludes large profile_data JSONB)."""
id: uuid.UUID
tenant_id: Optional[uuid.UUID] = None
name: str
description: Optional[str] = None
sys_object_id: Optional[str] = None
vendor: Optional[str] = None
category: Optional[str] = None
is_system: bool
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class SNMPProfileDetailResponse(SNMPProfileResponse):
"""SNMP profile detail response (includes full profile_data)."""
profile_data: dict
class SNMPProfileListResponse(BaseModel):
"""List of SNMP profiles."""
profiles: list[SNMPProfileResponse]