Files
breakpilot-compliance/backend-compliance/compliance/api/legal_template_routes.py
Sharang Parnerkar 3320ef94fc refactor: phase 0 guardrails + phase 1 step 2 (models.py split)
Squash of branch refactor/phase0-guardrails-and-models-split — 4 commits,
81 files, 173/173 pytest green, OpenAPI contract preserved (360 paths /
484 operations).

## Phase 0 — Architecture guardrails

Three defense-in-depth layers to keep the architecture rules enforced
regardless of who opens Claude Code in this repo:

  1. .claude/settings.json PreToolUse hook on Write/Edit blocks any file
     that would exceed the 500-line hard cap. Auto-loads in every Claude
     session in this repo.
  2. scripts/githooks/pre-commit (install via scripts/install-hooks.sh)
     enforces the LOC cap locally, freezes migrations/ without
     [migration-approved], and protects guardrail files without
     [guardrail-change].
  3. .gitea/workflows/ci.yaml gains loc-budget + guardrail-integrity +
     sbom-scan (syft+grype) jobs, adds mypy --strict for the new Python
     packages (compliance/{services,repositories,domain,schemas}), and
     tsc --noEmit for admin-compliance + developer-portal.

Per-language conventions documented in AGENTS.python.md, AGENTS.go.md,
AGENTS.typescript.md at the repo root — layering, tooling, and explicit
"what you may NOT do" lists. Root CLAUDE.md is prepended with the six
non-negotiable rules. Each of the 10 services gets a README.md.

scripts/check-loc.sh enforces soft 300 / hard 500 and surfaces the
current baseline of 205 hard + 161 soft violations so Phases 1-4 can
drain it incrementally. CI gates only CHANGED files in PRs so the
legacy baseline does not block unrelated work.

## Deprecation sweep

47 files. Pydantic V1 regex= -> pattern= (2 sites), class Config ->
ConfigDict in source_policy_router.py (schemas.py intentionally skipped;
it is the Phase 1 Step 3 split target). datetime.utcnow() ->
datetime.now(timezone.utc) everywhere including SQLAlchemy default=
callables. All DB columns already declare timezone=True, so this is a
latent-bug fix at the Python side, not a schema change.

DeprecationWarning count dropped from 158 to 35.

## Phase 1 Step 1 — Contract test harness

tests/contracts/test_openapi_baseline.py diffs the live FastAPI /openapi.json
against tests/contracts/openapi.baseline.json on every test run. Fails on
removed paths, removed status codes, or new required request body fields.
Regenerate only via tests/contracts/regenerate_baseline.py after a
consumer-updated contract change. This is the safety harness for all
subsequent refactor commits.

## Phase 1 Step 2 — models.py split (1466 -> 85 LOC shim)

compliance/db/models.py is decomposed into seven sibling aggregate modules
following the existing repo pattern (dsr_models.py, vvt_models.py, ...):

  regulation_models.py       (134) — Regulation, Requirement
  control_models.py          (279) — Control, Mapping, Evidence, Risk
  ai_system_models.py        (141) — AISystem, AuditExport
  service_module_models.py   (176) — ServiceModule, ModuleRegulation, ModuleRisk
  audit_session_models.py    (177) — AuditSession, AuditSignOff
  isms_governance_models.py  (323) — ISMSScope, Context, Policy, Objective, SoA
  isms_audit_models.py       (468) — Finding, CAPA, MgmtReview, InternalAudit,
                                     AuditTrail, Readiness

models.py becomes an 85-line re-export shim in dependency order so
existing imports continue to work unchanged. Schema is byte-identical:
__tablename__, column definitions, relationship strings, back_populates,
cascade directives all preserved.

All new sibling files are under the 500-line hard cap; largest is
isms_audit_models.py at 468. No file in compliance/db/ now exceeds
the hard cap.

## Phase 1 Step 3 — infrastructure only

backend-compliance/compliance/{schemas,domain,repositories}/ packages
are created as landing zones with docstrings. compliance/domain/
exports DomainError / NotFoundError / ConflictError / ValidationError /
PermissionError — the base classes services will use to raise
domain-level errors instead of HTTPException.

PHASE1_RUNBOOK.md at backend-compliance/PHASE1_RUNBOOK.md documents
the nine-step execution plan for Phase 1: snapshot baseline,
characterization tests, split models.py (this commit), split schemas.py
(next), extract services, extract repositories, mypy --strict, coverage.

## Verification

  backend-compliance/.venv-phase1: uv python install 3.12 + pip -r requirements.txt
  PYTHONPATH=. pytest compliance/tests/ tests/contracts/
  -> 173 passed, 0 failed, 35 warnings, OpenAPI 360/484 unchanged

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

367 lines
12 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, timezone
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",
}
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.now(timezone.utc),
}
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")