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
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>
409 lines
14 KiB
Python
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")
|