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.settings import router as settings_router
|
||||||
from app.routers.remote_access import router as remote_access_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.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.sites import router as sites_router
|
||||||
from app.routers.links import router as links_router
|
from app.routers.links import router as links_router
|
||||||
from app.routers.sectors import router as sectors_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(settings_router, prefix="/api")
|
||||||
app.include_router(remote_access_router, prefix="/api")
|
app.include_router(remote_access_router, prefix="/api")
|
||||||
app.include_router(winbox_remote_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(sites_router, prefix="/api")
|
||||||
app.include_router(links_router, prefix="/api")
|
app.include_router(links_router, prefix="/api")
|
||||||
app.include_router(sectors_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]
|
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
|
# 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