Files
breakpilot-compliance/backend-compliance/compliance/api/agent_history_routes.py
T
Benjamin Admin 8336c01c5c feat: Phase 6-8 — PDF export, recurring scans, multi-website compare
Phase 6: PDF export via WeasyPrint — POST /agent/scans/pdf generates
printable compliance report with findings table, service comparison,
risk badge, and legal disclaimer.

Phase 7: Recurring scans — POST /agent/monitored-urls to add URLs,
POST /agent/run-scheduled triggers all enabled scans (cron/ZeroClaw).
In-memory storage with DB upgrade path.

Phase 8: Multi-website compare — POST /agent/compare with 2-5 URLs,
parallel scanning, comparison table (risk, findings, services, compliance
features per site).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 15:27:51 +02:00

221 lines
7.2 KiB
Python

"""
Agent History Routes — persist and retrieve scan results.
GET /api/compliance/agent/scans — list recent scans
GET /api/compliance/agent/scans/{id} — get single scan
POST /api/compliance/agent/scans — save a scan result
"""
import json
import logging
import os
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Query
from fastapi.responses import Response
from pydantic import BaseModel
from compliance.services.agent_pdf_export import generate_scan_pdf
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/compliance/agent", tags=["agent"])
DATABASE_URL = os.environ.get(
"COMPLIANCE_DATABASE_URL",
os.environ.get("DATABASE_URL", ""),
)
class SaveScanRequest(BaseModel):
url: str
scan_type: str = "scan"
analysis_mode: str = "post_launch"
result: dict # Full scan result JSON
class ScanHistoryItem(BaseModel):
id: str
url: str
scan_type: str
analysis_mode: str
risk_level: str | None = None
risk_score: float = 0
findings_count: int = 0
pages_scanned: int = 0
email_sent: bool = False
created_at: str
class ScanDetail(BaseModel):
id: str
url: str
scan_type: str
analysis_mode: str
result: dict
created_at: str
async def _get_pool():
"""Get or create database connection pool."""
import asyncpg
if not DATABASE_URL:
return None
try:
return await asyncpg.create_pool(DATABASE_URL, min_size=1, max_size=3)
except Exception as e:
logger.warning("DB connection failed: %s", e)
return None
@router.post("/scans")
async def save_scan(req: SaveScanRequest):
"""Save a scan result to the database."""
pool = await _get_pool()
if not pool:
return {"status": "skipped", "reason": "no database"}
scan_id = str(uuid.uuid4())
result = req.result
try:
async with pool.acquire() as conn:
await conn.execute("""
INSERT INTO compliance_agent_scans
(id, url, scan_type, analysis_mode, classification, risk_level,
risk_score, escalation_level, responsible_role, services,
findings, summary_html, pages_scanned, pages_list, email_sent,
created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
""",
uuid.UUID(scan_id),
req.url,
req.scan_type,
req.analysis_mode,
result.get("classification", ""),
result.get("risk_level", ""),
result.get("risk_score", 0),
result.get("escalation_level", ""),
result.get("responsible_role", ""),
json.dumps(result.get("services", [])),
json.dumps(result.get("findings", [])),
result.get("summary", result.get("summary_html", "")),
result.get("pages_scanned", 0),
json.dumps(result.get("pages_list", [])),
result.get("email_status") == "sent",
datetime.now(timezone.utc),
)
return {"status": "saved", "id": scan_id}
except Exception as e:
logger.error("Failed to save scan: %s", e)
return {"status": "error", "error": str(e)}
finally:
await pool.close()
@router.get("/scans", response_model=list[ScanHistoryItem])
async def list_scans(
limit: int = Query(20, le=100),
scan_type: str | None = None,
):
"""List recent scans."""
pool = await _get_pool()
if not pool:
return []
try:
async with pool.acquire() as conn:
query = """
SELECT id, url, scan_type, analysis_mode, risk_level, risk_score,
findings, pages_scanned, email_sent, created_at
FROM compliance_agent_scans
"""
params = []
if scan_type:
query += " WHERE scan_type = $1"
params.append(scan_type)
query += " ORDER BY created_at DESC LIMIT " + str(limit)
rows = await conn.fetch(query, *params)
return [
ScanHistoryItem(
id=str(r["id"]),
url=r["url"],
scan_type=r["scan_type"],
analysis_mode=r["analysis_mode"],
risk_level=r["risk_level"],
risk_score=r["risk_score"] or 0,
findings_count=len(json.loads(r["findings"] or "[]")),
pages_scanned=r["pages_scanned"] or 0,
email_sent=r["email_sent"] or False,
created_at=r["created_at"].isoformat() if r["created_at"] else "",
)
for r in rows
]
except Exception as e:
logger.error("Failed to list scans: %s", e)
return []
finally:
await pool.close()
@router.get("/scans/{scan_id}", response_model=ScanDetail)
async def get_scan(scan_id: str):
"""Get a single scan result."""
pool = await _get_pool()
if not pool:
return ScanDetail(id=scan_id, url="", scan_type="", analysis_mode="", result={}, created_at="")
try:
async with pool.acquire() as conn:
row = await conn.fetchrow("""
SELECT * FROM compliance_agent_scans WHERE id = $1
""", uuid.UUID(scan_id))
if not row:
return ScanDetail(id=scan_id, url="", scan_type="", analysis_mode="", result={}, created_at="")
return ScanDetail(
id=str(row["id"]),
url=row["url"],
scan_type=row["scan_type"],
analysis_mode=row["analysis_mode"],
result={
"classification": row["classification"],
"risk_level": row["risk_level"],
"risk_score": row["risk_score"],
"services": json.loads(row["services"] or "[]"),
"findings": json.loads(row["findings"] or "[]"),
"summary": row["summary_html"],
"pages_scanned": row["pages_scanned"],
"pages_list": json.loads(row["pages_list"] or "[]"),
},
created_at=row["created_at"].isoformat() if row["created_at"] else "",
)
except Exception as e:
logger.error("Failed to get scan: %s", e)
return ScanDetail(id=scan_id, url="", scan_type="", analysis_mode="", result={}, created_at="")
finally:
await pool.close()
@router.post("/scans/pdf")
async def export_scan_pdf(req: SaveScanRequest):
"""Generate a PDF report from scan results (no DB required)."""
try:
pdf_bytes = generate_scan_pdf({
"url": req.url,
"scan_type": req.scan_type,
"analysis_mode": req.analysis_mode,
**req.result,
})
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="compliance-report-{req.url.split("/")[2][:30]}.pdf"'},
)
except Exception as e:
logger.error("PDF generation failed: %s", e)
return {"error": str(e)}