8336c01c5c
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>
221 lines
7.2 KiB
Python
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)}
|