Files
breakpilot-compliance/backend-compliance/compliance/api/legal_template_routes.py
Benjamin Admin 0171d611f6
All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 34s
CI/CD / test-python-backend-compliance (push) Successful in 35s
CI/CD / test-python-document-crawler (push) Successful in 26s
CI/CD / test-python-dsms-gateway (push) Successful in 19s
CI/CD / validate-canonical-controls (push) Successful in 10s
CI/CD / Deploy (push) Successful in 2s
feat: add policy library with 29 German policy templates
Add 29 new document types (IT security, data, personnel, vendor, BCM
policies) to VALID_DOCUMENT_TYPES and 5 category pills to the document
generator UI. Include seed script for production DB population.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 22:37:33 +01:00

409 lines
14 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
from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.orm import Session
from classroom_engine.database import get_db
from .tenant_utils import get_tenant_id as _get_tenant_id
from .db_utils import row_to_dict as _row_to_dict
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/legal-templates", tags=["legal-templates"])
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",
# Security document templates (Migration 051)
"it_security_concept",
"data_protection_concept",
"backup_recovery_concept",
"logging_concept",
"incident_response_plan",
"access_control_concept",
"risk_management_concept",
# Policy templates — IT Security (Migration 054)
"information_security_policy",
"access_control_policy",
"password_policy",
"encryption_policy",
"logging_policy",
"backup_policy",
"incident_response_policy",
"change_management_policy",
"patch_management_policy",
"asset_management_policy",
"cloud_security_policy",
"devsecops_policy",
"secrets_management_policy",
"vulnerability_management_policy",
# Policy templates — Data (Migration 054)
"data_protection_policy",
"data_classification_policy",
"data_retention_policy",
"data_transfer_policy",
"privacy_incident_policy",
# Policy templates — Personnel (Migration 054)
"employee_security_policy",
"security_awareness_policy",
"remote_work_policy",
"offboarding_policy",
# Policy templates — Vendor/Supply Chain (Migration 054)
"vendor_risk_management_policy",
"third_party_security_policy",
"supplier_security_policy",
# Policy templates — BCM (Migration 054)
"business_continuity_policy",
"disaster_recovery_policy",
"crisis_management_policy",
}
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
# =============================================================================
# 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),
tenant_id: str = Depends(_get_tenant_id),
):
"""List legal templates with optional filters."""
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),
tenant_id: str = Depends(_get_tenant_id),
):
"""Return template counts by document_type."""
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),
tenant_id: str = Depends(_get_tenant_id),
):
"""Return distinct source_name values."""
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),
tenant_id: str = Depends(_get_tenant_id),
):
"""Fetch a single template by 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),
tenant_id: str = Depends(_get_tenant_id),
):
"""Create a new legal template."""
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),
tenant_id: str = Depends(_get_tenant_id),
):
"""Update an existing legal template."""
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="Invalid document_type")
if "status" in updates and updates["status"] not in VALID_STATUSES:
raise HTTPException(status_code=400, detail="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),
tenant_id: str = Depends(_get_tenant_id),
):
"""Delete a legal template."""
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")