Files
breakpilot-compliance/backend-compliance/compliance/api/legal_template_routes.py
Benjamin Admin 7e5047290c
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 36s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 23s
CI / test-python-dsms-gateway (push) Successful in 18s
feat: Dokumentengenerator — Vollständige Vorlage-Bibliothek + Frontend-Redesign
- Migration 020: Typ-Renames (data_processing_agreement→dpa, withdrawal_policy→widerruf) + 11 neue MIT-Templates (NDA DE/EN, SLA, AUP, Community Guidelines, Copyright Policy, Cloud Service Agreement, Data Usage Clause, Cookie Banner, AGB, Liability Clause)
- Backend: VALID_DOCUMENT_TYPES auf 16 Typen erweitert; /legal-templates/status nutzt jetzt dynamisches GROUP BY statt Hard-coded Felder
- searchTemplates.ts: loadAllTemplates() für Library-First UX
- page.tsx: Vollständig-Rewrite — Template-Bibliothek (immer sichtbar) mit Kategorie-Pills, Sprache-Toggle, optionaler Suche, Inline-Preview-Expand und Kachel-Grid; Generator-Section erscheint per Scroll wenn Vorlage gewählt
- Tests: 52/52 bestanden, TestLegalTemplateNewTypes (19 neue Tests) + aktualisierte Typ-Checks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 10:47:38 +01:00

399 lines
13 KiB
Python

"""
FastAPI routes for Legal Templates (Document Generator).
Self-authored templates (MIT License) stored in compliance_legal_templates.
Endpoints:
GET /legal-templates — list (query, document_type, language, status, limit, offset)
GET /legal-templates/status — counts by type
GET /legal-templates/sources — distinct source_name list
GET /legal-templates/{id} — single template by id
POST /legal-templates — create (admin)
PUT /legal-templates/{id} — update
DELETE /legal-templates/{id} — delete (204)
"""
import json
import logging
from datetime import datetime
from typing import Optional, List, Any, Dict
from fastapi import APIRouter, Depends, HTTPException, Query, Header
from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.orm import Session
from uuid import UUID
from classroom_engine.database import get_db
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/legal-templates", tags=["legal-templates"])
DEFAULT_TENANT_ID = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"
VALID_DOCUMENT_TYPES = {
# Original types
"privacy_policy",
"terms_of_service",
"impressum",
"cookie_policy",
# Renamed from data_processing_agreement / withdrawal_policy (Migration 020)
"dpa",
"widerruf",
# New types (Migration 020)
"nda",
"sla",
"acceptable_use",
"community_guidelines",
"copyright_policy",
"cloud_service_agreement",
"data_usage_clause",
"cookie_banner",
"agb",
"clause",
}
VALID_STATUSES = {"published", "draft", "archived"}
# =============================================================================
# Pydantic Schemas
# =============================================================================
class LegalTemplateCreate(BaseModel):
document_type: str
title: str
description: Optional[str] = None
content: str
placeholders: Optional[List[str]] = None
language: str = "de"
jurisdiction: str = "DE"
license_id: str = "mit"
license_name: str = "MIT License"
source_name: str = "BreakPilot Compliance"
attribution_required: bool = False
is_complete_document: bool = True
version: str = "1.0.0"
status: str = "published"
# Attribution / source traceability
source_url: Optional[str] = None
source_repo: Optional[str] = None
source_file_path: Optional[str] = None
attribution_text: Optional[str] = None
inspiration_sources: Optional[List[Any]] = None
class LegalTemplateUpdate(BaseModel):
document_type: Optional[str] = None
title: Optional[str] = None
description: Optional[str] = None
content: Optional[str] = None
placeholders: Optional[List[str]] = None
language: Optional[str] = None
jurisdiction: Optional[str] = None
license_id: Optional[str] = None
license_name: Optional[str] = None
source_name: Optional[str] = None
attribution_required: Optional[bool] = None
is_complete_document: Optional[bool] = None
version: Optional[str] = None
status: Optional[str] = None
# Attribution / source traceability
source_url: Optional[str] = None
source_repo: Optional[str] = None
source_file_path: Optional[str] = None
attribution_text: Optional[str] = None
inspiration_sources: Optional[List[Any]] = None
# =============================================================================
# Helpers
# =============================================================================
def _row_to_dict(row) -> Dict[str, Any]:
result = dict(row._mapping)
for key, val in result.items():
if isinstance(val, datetime):
result[key] = val.isoformat()
elif hasattr(val, '__str__') and not isinstance(val, (str, int, float, bool, list, dict, type(None))):
result[key] = str(val)
return result
def _get_tenant_id(x_tenant_id: Optional[str] = Header(None)) -> str:
if x_tenant_id:
try:
UUID(x_tenant_id)
return x_tenant_id
except ValueError:
pass
return DEFAULT_TENANT_ID
# =============================================================================
# Routes
# =============================================================================
@router.get("")
async def list_legal_templates(
query: Optional[str] = Query(None, description="Full-text ILIKE search on title/description/content"),
document_type: Optional[str] = Query(None),
language: Optional[str] = Query(None),
status: Optional[str] = Query("published"),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
x_tenant_id: Optional[str] = Header(None),
):
"""List legal templates with optional filters."""
tenant_id = _get_tenant_id(x_tenant_id)
where_clauses = ["tenant_id = :tenant_id"]
params: Dict[str, Any] = {"tenant_id": tenant_id, "limit": limit, "offset": offset}
if status:
where_clauses.append("status = :status")
params["status"] = status
if document_type:
where_clauses.append("document_type = :document_type")
params["document_type"] = document_type
if language:
where_clauses.append("language = :language")
params["language"] = language
if query:
where_clauses.append(
"(title ILIKE :query OR description ILIKE :query OR content ILIKE :query)"
)
params["query"] = f"%{query}%"
where_sql = " AND ".join(where_clauses)
total_row = db.execute(
text(f"SELECT COUNT(*) FROM compliance_legal_templates WHERE {where_sql}"),
params,
).fetchone()
total = total_row[0] if total_row else 0
rows = db.execute(
text(f"""
SELECT * FROM compliance_legal_templates
WHERE {where_sql}
ORDER BY document_type, title
LIMIT :limit OFFSET :offset
"""),
params,
).fetchall()
return {
"templates": [_row_to_dict(r) for r in rows],
"total": total,
}
@router.get("/status")
async def get_templates_status(
db: Session = Depends(get_db),
x_tenant_id: Optional[str] = Header(None),
):
"""Return template counts by document_type."""
tenant_id = _get_tenant_id(x_tenant_id)
total_row = db.execute(
text("SELECT COUNT(*) FROM compliance_legal_templates WHERE tenant_id = :tenant_id"),
{"tenant_id": tenant_id},
).fetchone()
total = int(total_row[0] or 0) if total_row else 0
status_rows = db.execute(text("""
SELECT status, COUNT(*) AS cnt
FROM compliance_legal_templates
WHERE tenant_id = :tenant_id
GROUP BY status
"""), {"tenant_id": tenant_id}).fetchall()
by_status: Dict[str, int] = {r[0]: int(r[1] or 0) for r in status_rows}
type_rows = db.execute(text("""
SELECT document_type, COUNT(*) AS cnt
FROM compliance_legal_templates
WHERE tenant_id = :tenant_id
GROUP BY document_type
ORDER BY document_type
"""), {"tenant_id": tenant_id}).fetchall()
by_type: Dict[str, int] = {r[0]: int(r[1] or 0) for r in type_rows}
return {
"total": total,
"by_status": {
"published": by_status.get("published", 0),
"draft": by_status.get("draft", 0),
"archived": by_status.get("archived", 0),
},
"by_type": by_type,
}
@router.get("/sources")
async def get_template_sources(
db: Session = Depends(get_db),
x_tenant_id: Optional[str] = Header(None),
):
"""Return distinct source_name values."""
tenant_id = _get_tenant_id(x_tenant_id)
rows = db.execute(
text("SELECT DISTINCT source_name FROM compliance_legal_templates WHERE tenant_id = :tenant_id ORDER BY source_name"),
{"tenant_id": tenant_id},
).fetchall()
return {"sources": [r[0] for r in rows]}
@router.get("/{template_id}")
async def get_legal_template(
template_id: str,
db: Session = Depends(get_db),
x_tenant_id: Optional[str] = Header(None),
):
"""Fetch a single template by ID."""
tenant_id = _get_tenant_id(x_tenant_id)
row = db.execute(
text("SELECT * FROM compliance_legal_templates WHERE id = :id AND tenant_id = :tenant_id"),
{"id": template_id, "tenant_id": tenant_id},
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Template not found")
return _row_to_dict(row)
@router.post("", status_code=201)
async def create_legal_template(
payload: LegalTemplateCreate,
db: Session = Depends(get_db),
x_tenant_id: Optional[str] = Header(None),
):
"""Create a new legal template."""
tenant_id = _get_tenant_id(x_tenant_id)
if payload.document_type not in VALID_DOCUMENT_TYPES:
raise HTTPException(
status_code=400,
detail=f"Invalid document_type. Must be one of: {', '.join(VALID_DOCUMENT_TYPES)}"
)
if payload.status not in VALID_STATUSES:
raise HTTPException(
status_code=400,
detail=f"Invalid status. Must be one of: {', '.join(VALID_STATUSES)}"
)
placeholders_json = json.dumps(payload.placeholders or [])
inspiration_json = json.dumps(payload.inspiration_sources or [])
row = db.execute(text("""
INSERT INTO compliance_legal_templates (
tenant_id, document_type, title, description, content,
placeholders, language, jurisdiction,
license_id, license_name, source_name,
attribution_required, is_complete_document, version, status,
source_url, source_repo, source_file_path, source_retrieved_at,
attribution_text, inspiration_sources
) VALUES (
:tenant_id, :document_type, :title, :description, :content,
CAST(:placeholders AS jsonb), :language, :jurisdiction,
:license_id, :license_name, :source_name,
:attribution_required, :is_complete_document, :version, :status,
:source_url, :source_repo, :source_file_path, :source_retrieved_at,
:attribution_text, CAST(:inspiration_sources AS jsonb)
) RETURNING *
"""), {
"tenant_id": tenant_id,
"document_type": payload.document_type,
"title": payload.title,
"description": payload.description,
"content": payload.content,
"placeholders": placeholders_json,
"language": payload.language,
"jurisdiction": payload.jurisdiction,
"license_id": payload.license_id,
"license_name": payload.license_name,
"source_name": payload.source_name,
"attribution_required": payload.attribution_required,
"is_complete_document": payload.is_complete_document,
"version": payload.version,
"status": payload.status,
"source_url": payload.source_url,
"source_repo": payload.source_repo,
"source_file_path": payload.source_file_path,
"source_retrieved_at": None,
"attribution_text": payload.attribution_text,
"inspiration_sources": inspiration_json,
}).fetchone()
db.commit()
return _row_to_dict(row)
@router.put("/{template_id}")
async def update_legal_template(
template_id: str,
payload: LegalTemplateUpdate,
db: Session = Depends(get_db),
x_tenant_id: Optional[str] = Header(None),
):
"""Update an existing legal template."""
tenant_id = _get_tenant_id(x_tenant_id)
updates = payload.model_dump(exclude_unset=True)
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
if "document_type" in updates and updates["document_type"] not in VALID_DOCUMENT_TYPES:
raise HTTPException(status_code=400, detail=f"Invalid document_type")
if "status" in updates and updates["status"] not in VALID_STATUSES:
raise HTTPException(status_code=400, detail=f"Invalid status")
set_clauses = ["updated_at = :updated_at"]
params: Dict[str, Any] = {
"id": template_id,
"tenant_id": tenant_id,
"updated_at": datetime.utcnow(),
}
jsonb_fields = {"placeholders", "inspiration_sources"}
for field, value in updates.items():
if field in jsonb_fields:
params[field] = json.dumps(value if value is not None else [])
set_clauses.append(f"{field} = CAST(:{field} AS jsonb)")
else:
params[field] = value
set_clauses.append(f"{field} = :{field}")
row = db.execute(
text(f"""
UPDATE compliance_legal_templates
SET {', '.join(set_clauses)}
WHERE id = :id AND tenant_id = :tenant_id
RETURNING *
"""),
params,
).fetchone()
db.commit()
if not row:
raise HTTPException(status_code=404, detail="Template not found")
return _row_to_dict(row)
@router.delete("/{template_id}", status_code=204)
async def delete_legal_template(
template_id: str,
db: Session = Depends(get_db),
x_tenant_id: Optional[str] = Header(None),
):
"""Delete a legal template."""
tenant_id = _get_tenant_id(x_tenant_id)
result = db.execute(
text("DELETE FROM compliance_legal_templates WHERE id = :id AND tenant_id = :tenant_id"),
{"id": template_id, "tenant_id": tenant_id},
)
db.commit()
if result.rowcount == 0:
raise HTTPException(status_code=404, detail="Template not found")