feat: The Other Dude v9.0.1 — full-featured email system

ci: add GitHub Pages deployment workflow for docs site

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jason Staack
2026-03-08 17:46:37 -05:00
commit b840047e19
511 changed files with 106948 additions and 0 deletions

View File

@@ -0,0 +1,146 @@
"""Report generation API endpoint.
POST /api/tenants/{tenant_id}/reports/generate
Generates PDF or CSV reports for device inventory, metrics summary,
alert history, and change log.
RLS enforced via get_db() (app_user engine with tenant context).
RBAC: require at least operator role.
"""
import uuid
from datetime import datetime
from enum import Enum
from typing import Optional
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, ConfigDict
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db, set_tenant_context
from app.middleware.tenant_context import CurrentUser, get_current_user
from app.services.report_service import generate_report
logger = structlog.get_logger(__name__)
router = APIRouter(tags=["reports"])
# ---------------------------------------------------------------------------
# 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:
await set_tenant_context(db, str(tenant_id))
elif current_user.tenant_id != tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this tenant",
)
def _require_operator(current_user: CurrentUser) -> None:
"""Raise 403 if user is a viewer (reports require operator+)."""
if current_user.role == "viewer":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Reports require at least operator role.",
)
# ---------------------------------------------------------------------------
# Request schema
# ---------------------------------------------------------------------------
class ReportType(str, Enum):
device_inventory = "device_inventory"
metrics_summary = "metrics_summary"
alert_history = "alert_history"
change_log = "change_log"
class ReportFormat(str, Enum):
pdf = "pdf"
csv = "csv"
class ReportRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
type: ReportType
date_from: Optional[datetime] = None
date_to: Optional[datetime] = None
format: ReportFormat = ReportFormat.pdf
# ---------------------------------------------------------------------------
# Endpoint
# ---------------------------------------------------------------------------
@router.post(
"/tenants/{tenant_id}/reports/generate",
summary="Generate a report (PDF or CSV)",
response_class=StreamingResponse,
)
async def generate_report_endpoint(
tenant_id: uuid.UUID,
body: ReportRequest,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> StreamingResponse:
"""Generate and download a report as PDF or CSV.
- device_inventory: no date range required
- metrics_summary, alert_history, change_log: date_from and date_to required
"""
await _check_tenant_access(current_user, tenant_id, db)
_require_operator(current_user)
# Validate date range for time-based reports
if body.type != ReportType.device_inventory:
if not body.date_from or not body.date_to:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"date_from and date_to are required for {body.type.value} reports.",
)
if body.date_from > body.date_to:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="date_from must be before date_to.",
)
try:
file_bytes, content_type, filename = await generate_report(
db=db,
tenant_id=tenant_id,
report_type=body.type.value,
date_from=body.date_from,
date_to=body.date_to,
fmt=body.format.value,
)
except Exception as exc:
logger.error("report_generation_failed", error=str(exc), report_type=body.type.value)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Report generation failed: {str(exc)}",
)
import io
return StreamingResponse(
io.BytesIO(file_bytes),
media_type=content_type,
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Content-Length": str(len(file_bytes)),
},
)