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:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
305
backend/app/routers/snmp_profiles.py
Normal file
305
backend/app/routers/snmp_profiles.py
Normal 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()
|
||||
93
backend/app/schemas/snmp_profile.py
Normal file
93
backend/app/schemas/snmp_profile.py
Normal 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]
|
||||
Reference in New Issue
Block a user