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>
314 lines
10 KiB
Python
314 lines
10 KiB
Python
"""
|
|
FastAPI routes for Consent Email Templates + DSGVO Processes.
|
|
|
|
Endpoints:
|
|
GET /consent-templates — List email templates (filtered by tenant)
|
|
POST /consent-templates — Create a new email template
|
|
PUT /consent-templates/{id} — Update an email template
|
|
DELETE /consent-templates/{id} — Delete an email template
|
|
GET /gdpr-processes — List GDPR processes
|
|
PUT /gdpr-processes/{id} — Update a GDPR process
|
|
"""
|
|
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Header
|
|
from pydantic import BaseModel
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import text
|
|
|
|
from classroom_engine.database import get_db
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(tags=["consent-templates"])
|
|
|
|
|
|
# ============================================================================
|
|
# Pydantic Schemas
|
|
# ============================================================================
|
|
|
|
class ConsentTemplateCreate(BaseModel):
|
|
template_key: str
|
|
subject: str
|
|
body: str
|
|
language: str = 'de'
|
|
is_active: bool = True
|
|
|
|
|
|
class ConsentTemplateUpdate(BaseModel):
|
|
subject: Optional[str] = None
|
|
body: Optional[str] = None
|
|
is_active: Optional[bool] = None
|
|
|
|
|
|
class GDPRProcessUpdate(BaseModel):
|
|
title: Optional[str] = None
|
|
description: Optional[str] = None
|
|
legal_basis: Optional[str] = None
|
|
retention_days: Optional[int] = None
|
|
is_active: Optional[bool] = None
|
|
|
|
|
|
# ============================================================================
|
|
# Helpers
|
|
# ============================================================================
|
|
|
|
def _get_tenant(x_tenant_id: Optional[str] = Header(None, alias='X-Tenant-ID')) -> str:
|
|
return x_tenant_id or 'default'
|
|
|
|
|
|
# ============================================================================
|
|
# Email Templates
|
|
# ============================================================================
|
|
|
|
@router.get("/consent-templates")
|
|
async def list_consent_templates(
|
|
db: Session = Depends(get_db),
|
|
tenant_id: str = Depends(_get_tenant),
|
|
):
|
|
"""List all email templates for a tenant."""
|
|
rows = db.execute(
|
|
text("""
|
|
SELECT id, tenant_id, template_key, subject, body, language, is_active, created_at, updated_at
|
|
FROM compliance_consent_email_templates
|
|
WHERE tenant_id = :tenant_id
|
|
ORDER BY template_key, language
|
|
"""),
|
|
{"tenant_id": tenant_id},
|
|
).fetchall()
|
|
|
|
return [
|
|
{
|
|
"id": str(r.id),
|
|
"tenant_id": r.tenant_id,
|
|
"template_key": r.template_key,
|
|
"subject": r.subject,
|
|
"body": r.body,
|
|
"language": r.language,
|
|
"is_active": r.is_active,
|
|
"created_at": r.created_at.isoformat() if r.created_at else None,
|
|
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
|
|
}
|
|
for r in rows
|
|
]
|
|
|
|
|
|
@router.post("/consent-templates", status_code=201)
|
|
async def create_consent_template(
|
|
request: ConsentTemplateCreate,
|
|
db: Session = Depends(get_db),
|
|
tenant_id: str = Depends(_get_tenant),
|
|
):
|
|
"""Create a new email template."""
|
|
existing = db.execute(
|
|
text("""
|
|
SELECT id FROM compliance_consent_email_templates
|
|
WHERE tenant_id = :tenant_id AND template_key = :template_key AND language = :language
|
|
"""),
|
|
{"tenant_id": tenant_id, "template_key": request.template_key, "language": request.language},
|
|
).fetchone()
|
|
|
|
if existing:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=f"Template '{request.template_key}' for language '{request.language}' already exists for this tenant",
|
|
)
|
|
|
|
row = db.execute(
|
|
text("""
|
|
INSERT INTO compliance_consent_email_templates
|
|
(tenant_id, template_key, subject, body, language, is_active)
|
|
VALUES (:tenant_id, :template_key, :subject, :body, :language, :is_active)
|
|
RETURNING id, tenant_id, template_key, subject, body, language, is_active, created_at, updated_at
|
|
"""),
|
|
{
|
|
"tenant_id": tenant_id,
|
|
"template_key": request.template_key,
|
|
"subject": request.subject,
|
|
"body": request.body,
|
|
"language": request.language,
|
|
"is_active": request.is_active,
|
|
},
|
|
).fetchone()
|
|
db.commit()
|
|
|
|
return {
|
|
"id": str(row.id),
|
|
"tenant_id": row.tenant_id,
|
|
"template_key": row.template_key,
|
|
"subject": row.subject,
|
|
"body": row.body,
|
|
"language": row.language,
|
|
"is_active": row.is_active,
|
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
|
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
|
}
|
|
|
|
|
|
@router.put("/consent-templates/{template_id}")
|
|
async def update_consent_template(
|
|
template_id: str,
|
|
request: ConsentTemplateUpdate,
|
|
db: Session = Depends(get_db),
|
|
tenant_id: str = Depends(_get_tenant),
|
|
):
|
|
"""Update an existing email template."""
|
|
existing = db.execute(
|
|
text("""
|
|
SELECT id FROM compliance_consent_email_templates
|
|
WHERE id = :id AND tenant_id = :tenant_id
|
|
"""),
|
|
{"id": template_id, "tenant_id": tenant_id},
|
|
).fetchone()
|
|
|
|
if not existing:
|
|
raise HTTPException(status_code=404, detail=f"Template {template_id} not found")
|
|
|
|
updates = request.dict(exclude_none=True)
|
|
if not updates:
|
|
raise HTTPException(status_code=400, detail="No fields to update")
|
|
|
|
set_clauses = ", ".join(f"{k} = :{k}" for k in updates)
|
|
updates["id"] = template_id
|
|
updates["tenant_id"] = tenant_id
|
|
updates["now"] = datetime.now(timezone.utc)
|
|
|
|
row = db.execute(
|
|
text(f"""
|
|
UPDATE compliance_consent_email_templates
|
|
SET {set_clauses}, updated_at = :now
|
|
WHERE id = :id AND tenant_id = :tenant_id
|
|
RETURNING id, tenant_id, template_key, subject, body, language, is_active, created_at, updated_at
|
|
"""),
|
|
updates,
|
|
).fetchone()
|
|
db.commit()
|
|
|
|
return {
|
|
"id": str(row.id),
|
|
"tenant_id": row.tenant_id,
|
|
"template_key": row.template_key,
|
|
"subject": row.subject,
|
|
"body": row.body,
|
|
"language": row.language,
|
|
"is_active": row.is_active,
|
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
|
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
|
}
|
|
|
|
|
|
@router.delete("/consent-templates/{template_id}")
|
|
async def delete_consent_template(
|
|
template_id: str,
|
|
db: Session = Depends(get_db),
|
|
tenant_id: str = Depends(_get_tenant),
|
|
):
|
|
"""Delete an email template."""
|
|
existing = db.execute(
|
|
text("""
|
|
SELECT id FROM compliance_consent_email_templates
|
|
WHERE id = :id AND tenant_id = :tenant_id
|
|
"""),
|
|
{"id": template_id, "tenant_id": tenant_id},
|
|
).fetchone()
|
|
|
|
if not existing:
|
|
raise HTTPException(status_code=404, detail=f"Template {template_id} not found")
|
|
|
|
db.execute(
|
|
text("DELETE FROM compliance_consent_email_templates WHERE id = :id AND tenant_id = :tenant_id"),
|
|
{"id": template_id, "tenant_id": tenant_id},
|
|
)
|
|
db.commit()
|
|
|
|
return {"success": True, "message": f"Template {template_id} deleted"}
|
|
|
|
|
|
# ============================================================================
|
|
# GDPR Processes
|
|
# ============================================================================
|
|
|
|
@router.get("/gdpr-processes")
|
|
async def list_gdpr_processes(
|
|
db: Session = Depends(get_db),
|
|
tenant_id: str = Depends(_get_tenant),
|
|
):
|
|
"""List all GDPR processes for a tenant."""
|
|
rows = db.execute(
|
|
text("""
|
|
SELECT id, tenant_id, process_key, title, description, legal_basis, retention_days, is_active, created_at
|
|
FROM compliance_consent_gdpr_processes
|
|
WHERE tenant_id = :tenant_id
|
|
ORDER BY process_key
|
|
"""),
|
|
{"tenant_id": tenant_id},
|
|
).fetchall()
|
|
|
|
return [
|
|
{
|
|
"id": str(r.id),
|
|
"tenant_id": r.tenant_id,
|
|
"process_key": r.process_key,
|
|
"title": r.title,
|
|
"description": r.description,
|
|
"legal_basis": r.legal_basis,
|
|
"retention_days": r.retention_days,
|
|
"is_active": r.is_active,
|
|
"created_at": r.created_at.isoformat() if r.created_at else None,
|
|
}
|
|
for r in rows
|
|
]
|
|
|
|
|
|
@router.put("/gdpr-processes/{process_id}")
|
|
async def update_gdpr_process(
|
|
process_id: str,
|
|
request: GDPRProcessUpdate,
|
|
db: Session = Depends(get_db),
|
|
tenant_id: str = Depends(_get_tenant),
|
|
):
|
|
"""Update an existing GDPR process."""
|
|
existing = db.execute(
|
|
text("""
|
|
SELECT id FROM compliance_consent_gdpr_processes
|
|
WHERE id = :id AND tenant_id = :tenant_id
|
|
"""),
|
|
{"id": process_id, "tenant_id": tenant_id},
|
|
).fetchone()
|
|
|
|
if not existing:
|
|
raise HTTPException(status_code=404, detail=f"GDPR process {process_id} not found")
|
|
|
|
updates = request.dict(exclude_none=True)
|
|
if not updates:
|
|
raise HTTPException(status_code=400, detail="No fields to update")
|
|
|
|
set_clauses = ", ".join(f"{k} = :{k}" for k in updates)
|
|
updates["id"] = process_id
|
|
updates["tenant_id"] = tenant_id
|
|
|
|
row = db.execute(
|
|
text(f"""
|
|
UPDATE compliance_consent_gdpr_processes
|
|
SET {set_clauses}
|
|
WHERE id = :id AND tenant_id = :tenant_id
|
|
RETURNING id, tenant_id, process_key, title, description, legal_basis, retention_days, is_active, created_at
|
|
"""),
|
|
updates,
|
|
).fetchone()
|
|
db.commit()
|
|
|
|
return {
|
|
"id": str(row.id),
|
|
"tenant_id": row.tenant_id,
|
|
"process_key": row.process_key,
|
|
"title": row.title,
|
|
"description": row.description,
|
|
"legal_basis": row.legal_basis,
|
|
"retention_days": row.retention_days,
|
|
"is_active": row.is_active,
|
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
|
}
|