Files
the-other-dude/backend/app/routers/templates.py
Jason Staack 06a41ca9bf fix(lint): resolve all ruff lint errors
Add ruff config to exclude alembic E402, SQLAlchemy F821, and pre-existing
E501 line-length issues. Auto-fix 69 unused imports and 2 f-strings without
placeholders. Manually fix 8 unused variables. Apply ruff format to 127 files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 22:17:50 -05:00

619 lines
20 KiB
Python

"""
Config template CRUD, preview, and push API endpoints.
All routes are tenant-scoped under:
/api/tenants/{tenant_id}/templates/
Provides:
- GET /templates -- list templates (optional tag filter)
- POST /templates -- create a template
- GET /templates/{id} -- get single template
- PUT /templates/{id} -- update a template
- DELETE /templates/{id} -- delete a template
- POST /templates/{id}/preview -- preview rendered template for a device
- POST /templates/{id}/push -- push template to devices (sequential rollout)
- GET /templates/push-status/{rollout_id} -- poll push progress
RLS is enforced via get_db() (app_user engine with tenant context).
RBAC: viewer = read (GET/preview); operator and above = write (POST/PUT/DELETE/push).
"""
import asyncio
import logging
import uuid
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import BaseModel, ConfigDict
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.middleware.rate_limit import limiter
from app.middleware.rbac import require_min_role, require_scope
from app.middleware.tenant_context import CurrentUser, get_current_user
from app.models.config_template import ConfigTemplate, ConfigTemplateTag, TemplatePushJob
from app.models.device import Device
from app.services import template_service
logger = logging.getLogger(__name__)
router = APIRouter(tags=["templates"])
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _check_tenant_access(
current_user: CurrentUser, tenant_id: uuid.UUID, db: AsyncSession
) -> None:
"""Verify the current user is allowed to access the given tenant."""
if current_user.is_super_admin:
from app.database import set_tenant_context
await set_tenant_context(db, str(tenant_id))
return
if current_user.tenant_id != tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied: you do not belong to this tenant.",
)
def _serialize_template(template: ConfigTemplate, include_content: bool = False) -> dict:
"""Serialize a ConfigTemplate to a response dict."""
result: dict[str, Any] = {
"id": str(template.id),
"name": template.name,
"description": template.description,
"tags": [tag.name for tag in template.tags],
"variable_count": len(template.variables) if template.variables else 0,
"created_at": template.created_at.isoformat(),
"updated_at": template.updated_at.isoformat(),
}
if include_content:
result["content"] = template.content
result["variables"] = template.variables or []
return result
# ---------------------------------------------------------------------------
# Request/Response schemas
# ---------------------------------------------------------------------------
class VariableDef(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
type: str = "string" # string | ip | integer | boolean | subnet
default: Optional[str] = None
description: Optional[str] = None
class TemplateCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
description: Optional[str] = None
content: str
variables: list[VariableDef] = []
tags: list[str] = []
class TemplateUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
description: Optional[str] = None
content: str
variables: list[VariableDef] = []
tags: list[str] = []
class PreviewRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
device_id: str
variables: dict[str, str] = {}
class PushRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
device_ids: list[str]
variables: dict[str, str] = {}
# ---------------------------------------------------------------------------
# CRUD endpoints
# ---------------------------------------------------------------------------
@router.get(
"/tenants/{tenant_id}/templates",
summary="List config templates",
dependencies=[require_scope("config:read")],
)
async def list_templates(
tenant_id: uuid.UUID,
tag: Optional[str] = Query(None, description="Filter by tag name"),
current_user: CurrentUser = Depends(get_current_user),
_role: CurrentUser = Depends(require_min_role("viewer")),
db: AsyncSession = Depends(get_db),
) -> list[dict]:
"""List all config templates for a tenant with optional tag filtering."""
await _check_tenant_access(current_user, tenant_id, db)
query = (
select(ConfigTemplate)
.options(selectinload(ConfigTemplate.tags))
.where(ConfigTemplate.tenant_id == tenant_id) # type: ignore[arg-type]
.order_by(ConfigTemplate.updated_at.desc())
)
if tag:
query = query.where(
ConfigTemplate.id.in_( # type: ignore[attr-defined]
select(ConfigTemplateTag.template_id).where(
ConfigTemplateTag.name == tag,
ConfigTemplateTag.tenant_id == tenant_id, # type: ignore[arg-type]
)
)
)
result = await db.execute(query)
templates = result.scalars().all()
return [_serialize_template(t) for t in templates]
@router.post(
"/tenants/{tenant_id}/templates",
summary="Create a config template",
status_code=status.HTTP_201_CREATED,
dependencies=[require_scope("config:write")],
)
@limiter.limit("20/minute")
async def create_template(
request: Request,
tenant_id: uuid.UUID,
body: TemplateCreateRequest,
current_user: CurrentUser = Depends(get_current_user),
_role: CurrentUser = Depends(require_min_role("operator")),
db: AsyncSession = Depends(get_db),
) -> dict:
"""Create a new config template with Jinja2 content and variable definitions."""
await _check_tenant_access(current_user, tenant_id, db)
# Auto-extract variables from content for comparison
detected = template_service.extract_variables(body.content)
provided_names = {v.name for v in body.variables}
unmatched = set(detected) - provided_names
if unmatched:
logger.warning(
"Template '%s' has undeclared variables: %s (auto-adding as string type)",
body.name,
unmatched,
)
# Create template
template = ConfigTemplate(
tenant_id=tenant_id,
name=body.name,
description=body.description,
content=body.content,
variables=[v.model_dump() for v in body.variables],
)
db.add(template)
await db.flush() # Get the generated ID
# Create tags
for tag_name in body.tags:
tag = ConfigTemplateTag(
tenant_id=tenant_id,
name=tag_name,
template_id=template.id,
)
db.add(tag)
await db.flush()
# Re-query with tags loaded
result = await db.execute(
select(ConfigTemplate)
.options(selectinload(ConfigTemplate.tags))
.where(ConfigTemplate.id == template.id) # type: ignore[arg-type]
)
template = result.scalar_one()
return _serialize_template(template, include_content=True)
@router.get(
"/tenants/{tenant_id}/templates/{template_id}",
summary="Get a single config template",
dependencies=[require_scope("config:read")],
)
async def get_template(
tenant_id: uuid.UUID,
template_id: uuid.UUID,
current_user: CurrentUser = Depends(get_current_user),
_role: CurrentUser = Depends(require_min_role("viewer")),
db: AsyncSession = Depends(get_db),
) -> dict:
"""Get a config template with full content, variables, and tags."""
await _check_tenant_access(current_user, tenant_id, db)
result = await db.execute(
select(ConfigTemplate)
.options(selectinload(ConfigTemplate.tags))
.where(
ConfigTemplate.id == template_id, # type: ignore[arg-type]
ConfigTemplate.tenant_id == tenant_id, # type: ignore[arg-type]
)
)
template = result.scalar_one_or_none()
if template is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Template {template_id} not found",
)
return _serialize_template(template, include_content=True)
@router.put(
"/tenants/{tenant_id}/templates/{template_id}",
summary="Update a config template",
dependencies=[require_scope("config:write")],
)
@limiter.limit("20/minute")
async def update_template(
request: Request,
tenant_id: uuid.UUID,
template_id: uuid.UUID,
body: TemplateUpdateRequest,
current_user: CurrentUser = Depends(get_current_user),
_role: CurrentUser = Depends(require_min_role("operator")),
db: AsyncSession = Depends(get_db),
) -> dict:
"""Update an existing config template."""
await _check_tenant_access(current_user, tenant_id, db)
result = await db.execute(
select(ConfigTemplate)
.options(selectinload(ConfigTemplate.tags))
.where(
ConfigTemplate.id == template_id, # type: ignore[arg-type]
ConfigTemplate.tenant_id == tenant_id, # type: ignore[arg-type]
)
)
template = result.scalar_one_or_none()
if template is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Template {template_id} not found",
)
# Update fields
template.name = body.name
template.description = body.description
template.content = body.content
template.variables = [v.model_dump() for v in body.variables]
# Replace tags: delete old, create new
await db.execute(
delete(ConfigTemplateTag).where(
ConfigTemplateTag.template_id == template_id # type: ignore[arg-type]
)
)
for tag_name in body.tags:
tag = ConfigTemplateTag(
tenant_id=tenant_id,
name=tag_name,
template_id=template.id,
)
db.add(tag)
await db.flush()
# Re-query with fresh tags
result = await db.execute(
select(ConfigTemplate)
.options(selectinload(ConfigTemplate.tags))
.where(ConfigTemplate.id == template.id) # type: ignore[arg-type]
)
template = result.scalar_one()
return _serialize_template(template, include_content=True)
@router.delete(
"/tenants/{tenant_id}/templates/{template_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a config template",
dependencies=[require_scope("config:write")],
)
@limiter.limit("5/minute")
async def delete_template(
request: Request,
tenant_id: uuid.UUID,
template_id: uuid.UUID,
current_user: CurrentUser = Depends(get_current_user),
_role: CurrentUser = Depends(require_min_role("operator")),
db: AsyncSession = Depends(get_db),
) -> None:
"""Delete a config template. Tags are cascade-deleted. Push jobs are SET NULL."""
await _check_tenant_access(current_user, tenant_id, db)
result = await db.execute(
select(ConfigTemplate).where(
ConfigTemplate.id == template_id, # type: ignore[arg-type]
ConfigTemplate.tenant_id == tenant_id, # type: ignore[arg-type]
)
)
template = result.scalar_one_or_none()
if template is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Template {template_id} not found",
)
await db.delete(template)
# ---------------------------------------------------------------------------
# Preview & Push endpoints
# ---------------------------------------------------------------------------
@router.post(
"/tenants/{tenant_id}/templates/{template_id}/preview",
summary="Preview template rendered for a specific device",
dependencies=[require_scope("config:read")],
)
async def preview_template(
tenant_id: uuid.UUID,
template_id: uuid.UUID,
body: PreviewRequest,
current_user: CurrentUser = Depends(get_current_user),
_role: CurrentUser = Depends(require_min_role("viewer")),
db: AsyncSession = Depends(get_db),
) -> dict:
"""Render a template with device context and custom variables for preview."""
await _check_tenant_access(current_user, tenant_id, db)
# Load template
result = await db.execute(
select(ConfigTemplate).where(
ConfigTemplate.id == template_id, # type: ignore[arg-type]
ConfigTemplate.tenant_id == tenant_id, # type: ignore[arg-type]
)
)
template = result.scalar_one_or_none()
if template is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Template {template_id} not found",
)
# Load device
result = await db.execute(
select(Device).where(Device.id == body.device_id) # type: ignore[arg-type]
)
device = result.scalar_one_or_none()
if device is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Device {body.device_id} not found",
)
# Validate variables against type definitions
if template.variables:
for var_def in template.variables:
var_name = var_def.get("name", "")
var_type = var_def.get("type", "string")
value = body.variables.get(var_name)
if value is None:
# Use default if available
default = var_def.get("default")
if default is not None:
body.variables[var_name] = default
continue
error = template_service.validate_variable(var_name, value, var_type)
if error:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=error,
)
# Render
try:
rendered = template_service.render_template(
template.content,
{
"hostname": device.hostname,
"ip_address": device.ip_address,
"model": device.model,
},
body.variables,
)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Template rendering failed: {exc}",
)
return {
"rendered": rendered,
"device_hostname": device.hostname,
}
@router.post(
"/tenants/{tenant_id}/templates/{template_id}/push",
summary="Push template to devices (sequential rollout with panic-revert)",
dependencies=[require_scope("config:write")],
)
@limiter.limit("5/minute")
async def push_template(
request: Request,
tenant_id: uuid.UUID,
template_id: uuid.UUID,
body: PushRequest,
current_user: CurrentUser = Depends(get_current_user),
_role: CurrentUser = Depends(require_min_role("operator")),
db: AsyncSession = Depends(get_db),
) -> dict:
"""Start a template push to one or more devices.
Creates push jobs for each device and starts a background sequential rollout.
Returns the rollout_id for status polling.
"""
await _check_tenant_access(current_user, tenant_id, db)
# Load template
result = await db.execute(
select(ConfigTemplate).where(
ConfigTemplate.id == template_id, # type: ignore[arg-type]
ConfigTemplate.tenant_id == tenant_id, # type: ignore[arg-type]
)
)
template = result.scalar_one_or_none()
if template is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Template {template_id} not found",
)
if not body.device_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="At least one device_id is required",
)
# Validate variables
if template.variables:
for var_def in template.variables:
var_name = var_def.get("name", "")
var_type = var_def.get("type", "string")
value = body.variables.get(var_name)
if value is None:
default = var_def.get("default")
if default is not None:
body.variables[var_name] = default
continue
error = template_service.validate_variable(var_name, value, var_type)
if error:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=error,
)
rollout_id = uuid.uuid4()
jobs_created = []
for device_id_str in body.device_ids:
# Load device to render template per-device
result = await db.execute(
select(Device).where(Device.id == device_id_str) # type: ignore[arg-type]
)
device = result.scalar_one_or_none()
if device is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Device {device_id_str} not found",
)
# Render template with this device's context
try:
rendered = template_service.render_template(
template.content,
{
"hostname": device.hostname,
"ip_address": device.ip_address,
"model": device.model,
},
body.variables,
)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Template rendering failed for device {device.hostname}: {exc}",
)
# Create push job
job = TemplatePushJob(
tenant_id=tenant_id,
template_id=template_id,
device_id=device.id,
rollout_id=rollout_id,
rendered_content=rendered,
status="pending",
)
db.add(job)
jobs_created.append(
{
"job_id": str(job.id),
"device_id": str(device.id),
"device_hostname": device.hostname,
}
)
await db.flush()
# Start background push task
asyncio.create_task(template_service.push_to_devices(str(rollout_id)))
return {
"rollout_id": str(rollout_id),
"jobs": jobs_created,
}
@router.get(
"/tenants/{tenant_id}/templates/push-status/{rollout_id}",
summary="Poll push progress for a rollout",
dependencies=[require_scope("config:read")],
)
async def push_status(
tenant_id: uuid.UUID,
rollout_id: uuid.UUID,
current_user: CurrentUser = Depends(get_current_user),
_role: CurrentUser = Depends(require_min_role("viewer")),
db: AsyncSession = Depends(get_db),
) -> dict:
"""Return all push job statuses for a rollout with device hostnames."""
await _check_tenant_access(current_user, tenant_id, db)
result = await db.execute(
select(TemplatePushJob, Device.hostname)
.join(Device, TemplatePushJob.device_id == Device.id) # type: ignore[arg-type]
.where(
TemplatePushJob.rollout_id == rollout_id, # type: ignore[arg-type]
TemplatePushJob.tenant_id == tenant_id, # type: ignore[arg-type]
)
.order_by(TemplatePushJob.created_at.asc())
)
rows = result.all()
jobs = []
for job, hostname in rows:
jobs.append(
{
"device_id": str(job.device_id),
"hostname": hostname,
"status": job.status,
"error_message": job.error_message,
"started_at": job.started_at.isoformat() if job.started_at else None,
"completed_at": job.completed_at.isoformat() if job.completed_at else None,
}
)
return {
"rollout_id": str(rollout_id),
"jobs": jobs,
}