Files
breakpilot-compliance/backend-compliance/compliance/api/email_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

824 lines
28 KiB
Python

"""
E-Mail-Template Routes — Benachrichtigungsvorlagen fuer DSGVO-Compliance.
Verwaltet Templates fuer DSR, Consent, Breach, Vendor und Training E-Mails.
Inklusive Versionierung, Approval-Workflow, Vorschau und Send-Logging.
"""
import uuid
from datetime import datetime, timezone
from typing import Optional, Dict
from fastapi import APIRouter, Depends, HTTPException, Query, Header
from pydantic import BaseModel
from sqlalchemy.orm import Session
from classroom_engine.database import get_db
from ..db.email_template_models import (
EmailTemplateDB, EmailTemplateVersionDB, EmailTemplateApprovalDB,
EmailSendLogDB, EmailTemplateSettingsDB,
)
router = APIRouter(prefix="/email-templates", tags=["compliance-email-templates"])
DEFAULT_TENANT = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"
# Template-Typen und zugehoerige Variablen
TEMPLATE_TYPES = {
"welcome": {"name": "Willkommen", "category": "general", "variables": ["user_name", "company_name", "login_url"]},
"verification": {"name": "E-Mail-Verifizierung", "category": "general", "variables": ["user_name", "verification_url", "expiry_hours"]},
"password_reset": {"name": "Passwort zuruecksetzen", "category": "general", "variables": ["user_name", "reset_url", "expiry_hours"]},
"dsr_receipt": {"name": "DSR Eingangsbestaetigung", "category": "dsr", "variables": ["requester_name", "reference_number", "request_type", "deadline"]},
"dsr_identity_request": {"name": "DSR Identitaetsanfrage", "category": "dsr", "variables": ["requester_name", "reference_number"]},
"dsr_completion": {"name": "DSR Abschluss", "category": "dsr", "variables": ["requester_name", "reference_number", "request_type", "completion_date"]},
"dsr_rejection": {"name": "DSR Ablehnung", "category": "dsr", "variables": ["requester_name", "reference_number", "rejection_reason", "legal_basis"]},
"dsr_extension": {"name": "DSR Fristverlaengerung", "category": "dsr", "variables": ["requester_name", "reference_number", "new_deadline", "extension_reason"]},
"consent_request": {"name": "Einwilligungsanfrage", "category": "consent", "variables": ["user_name", "purpose", "consent_url"]},
"consent_confirmation": {"name": "Einwilligungsbestaetigung", "category": "consent", "variables": ["user_name", "purpose", "consent_date"]},
"consent_withdrawal": {"name": "Widerruf bestaetigt", "category": "consent", "variables": ["user_name", "purpose", "withdrawal_date"]},
"consent_reminder": {"name": "Einwilligungs-Erinnerung", "category": "consent", "variables": ["user_name", "purpose", "expiry_date"]},
"breach_notification_authority": {"name": "Datenpanne Aufsichtsbehoerde", "category": "breach", "variables": ["incident_date", "incident_description", "affected_count", "measures_taken", "authority_name"]},
"breach_notification_affected": {"name": "Datenpanne Betroffene", "category": "breach", "variables": ["user_name", "incident_date", "incident_description", "measures_taken", "contact_info"]},
"breach_internal": {"name": "Datenpanne intern", "category": "breach", "variables": ["reporter_name", "incident_date", "incident_description", "severity"]},
"vendor_dpa_request": {"name": "AVV-Anfrage", "category": "vendor", "variables": ["vendor_name", "contact_name", "deadline", "requirements"]},
"vendor_review_reminder": {"name": "Vendor-Pruefung Erinnerung", "category": "vendor", "variables": ["vendor_name", "review_due_date", "last_review_date"]},
"training_invitation": {"name": "Schulungseinladung", "category": "training", "variables": ["user_name", "training_title", "training_date", "training_url"]},
"training_reminder": {"name": "Schulungs-Erinnerung", "category": "training", "variables": ["user_name", "training_title", "deadline"]},
"training_completion": {"name": "Schulung abgeschlossen", "category": "training", "variables": ["user_name", "training_title", "completion_date", "certificate_url"]},
}
VALID_STATUSES = ["draft", "review", "approved", "published"]
VALID_CATEGORIES = ["general", "dsr", "consent", "breach", "vendor", "training"]
# =============================================================================
# Pydantic Schemas
# =============================================================================
class TemplateCreate(BaseModel):
template_type: str
name: Optional[str] = None
description: Optional[str] = None
category: Optional[str] = None
is_active: bool = True
class VersionCreate(BaseModel):
version: str = "1.0"
language: str = "de"
subject: str
body_html: str
body_text: Optional[str] = None
class VersionUpdate(BaseModel):
subject: Optional[str] = None
body_html: Optional[str] = None
body_text: Optional[str] = None
class PreviewRequest(BaseModel):
variables: Optional[Dict[str, str]] = None
class SendTestRequest(BaseModel):
recipient: str
variables: Optional[Dict[str, str]] = None
class SettingsUpdate(BaseModel):
sender_name: Optional[str] = None
sender_email: Optional[str] = None
reply_to: Optional[str] = None
logo_url: Optional[str] = None
primary_color: Optional[str] = None
secondary_color: Optional[str] = None
footer_text: Optional[str] = None
company_name: Optional[str] = None
company_address: Optional[str] = None
# =============================================================================
# Helpers
# =============================================================================
def _get_tenant(x_tenant_id: Optional[str] = Header(None, alias='X-Tenant-ID')) -> str:
return x_tenant_id or DEFAULT_TENANT
def _template_to_dict(t: EmailTemplateDB, latest_version=None) -> dict:
result = {
"id": str(t.id),
"tenant_id": str(t.tenant_id),
"template_type": t.template_type,
"name": t.name,
"description": t.description,
"category": t.category,
"is_active": t.is_active,
"sort_order": t.sort_order,
"variables": t.variables or [],
"created_at": t.created_at.isoformat() if t.created_at else None,
"updated_at": t.updated_at.isoformat() if t.updated_at else None,
}
if latest_version:
result["latest_version"] = _version_to_dict(latest_version)
return result
def _version_to_dict(v: EmailTemplateVersionDB) -> dict:
return {
"id": str(v.id),
"template_id": str(v.template_id),
"version": v.version,
"language": v.language,
"subject": v.subject,
"body_html": v.body_html,
"body_text": v.body_text,
"status": v.status,
"submitted_at": v.submitted_at.isoformat() if v.submitted_at else None,
"submitted_by": v.submitted_by,
"published_at": v.published_at.isoformat() if v.published_at else None,
"published_by": v.published_by,
"created_at": v.created_at.isoformat() if v.created_at else None,
"created_by": v.created_by,
}
def _render_template(html: str, variables: Dict[str, str]) -> str:
"""Replace {{variable}} placeholders with values."""
result = html
for key, value in variables.items():
result = result.replace(f"{{{{{key}}}}}", str(value))
return result
# =============================================================================
# Template Type Info (MUST be before parameterized routes)
# =============================================================================
@router.get("/types")
async def get_template_types():
"""Gibt alle verfuegbaren Template-Typen mit Variablen zurueck."""
return [
{
"type": ttype,
"name": info["name"],
"category": info["category"],
"variables": info["variables"],
}
for ttype, info in TEMPLATE_TYPES.items()
]
@router.get("/stats")
async def get_stats(
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Statistiken ueber E-Mail-Templates."""
tid = uuid.UUID(tenant_id)
base = db.query(EmailTemplateDB).filter(EmailTemplateDB.tenant_id == tid)
total = base.count()
active = base.filter(EmailTemplateDB.is_active).count()
# Count templates with published versions
published_count = 0
templates = base.all()
for t in templates:
has_published = db.query(EmailTemplateVersionDB).filter(
EmailTemplateVersionDB.template_id == t.id,
EmailTemplateVersionDB.status == "published",
).count() > 0
if has_published:
published_count += 1
# By category
by_category = {}
for cat in VALID_CATEGORIES:
by_category[cat] = base.filter(EmailTemplateDB.category == cat).count()
# Send logs stats
total_sent = db.query(EmailSendLogDB).filter(EmailSendLogDB.tenant_id == tid).count()
return {
"total": total,
"active": active,
"published": published_count,
"draft": total - published_count,
"by_category": by_category,
"total_sent": total_sent,
}
@router.get("/settings")
async def get_settings(
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Globale E-Mail-Einstellungen laden."""
tid = uuid.UUID(tenant_id)
settings = db.query(EmailTemplateSettingsDB).filter(
EmailTemplateSettingsDB.tenant_id == tid,
).first()
if not settings:
return {
"sender_name": "Datenschutzbeauftragter",
"sender_email": "datenschutz@example.de",
"reply_to": None,
"logo_url": None,
"primary_color": "#4F46E5",
"secondary_color": "#7C3AED",
"footer_text": "Datenschutzhinweis: Diese E-Mail enthaelt vertrauliche Informationen.",
"company_name": None,
"company_address": None,
}
return {
"sender_name": settings.sender_name,
"sender_email": settings.sender_email,
"reply_to": settings.reply_to,
"logo_url": settings.logo_url,
"primary_color": settings.primary_color,
"secondary_color": settings.secondary_color,
"footer_text": settings.footer_text,
"company_name": settings.company_name,
"company_address": settings.company_address,
}
@router.put("/settings")
async def update_settings(
body: SettingsUpdate,
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Globale E-Mail-Einstellungen speichern."""
tid = uuid.UUID(tenant_id)
settings = db.query(EmailTemplateSettingsDB).filter(
EmailTemplateSettingsDB.tenant_id == tid,
).first()
if not settings:
settings = EmailTemplateSettingsDB(tenant_id=tid)
db.add(settings)
for field in ["sender_name", "sender_email", "reply_to", "logo_url",
"primary_color", "secondary_color", "footer_text",
"company_name", "company_address"]:
val = getattr(body, field, None)
if val is not None:
setattr(settings, field, val)
settings.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(settings)
return {
"sender_name": settings.sender_name,
"sender_email": settings.sender_email,
"reply_to": settings.reply_to,
"logo_url": settings.logo_url,
"primary_color": settings.primary_color,
"secondary_color": settings.secondary_color,
"footer_text": settings.footer_text,
"company_name": settings.company_name,
"company_address": settings.company_address,
}
@router.get("/logs")
async def get_send_logs(
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
template_type: Optional[str] = Query(None),
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Send-Logs (paginiert)."""
tid = uuid.UUID(tenant_id)
query = db.query(EmailSendLogDB).filter(EmailSendLogDB.tenant_id == tid)
if template_type:
query = query.filter(EmailSendLogDB.template_type == template_type)
total = query.count()
logs = query.order_by(EmailSendLogDB.sent_at.desc()).offset(offset).limit(limit).all()
return {
"logs": [
{
"id": str(l.id),
"template_type": l.template_type,
"recipient": l.recipient,
"subject": l.subject,
"status": l.status,
"variables": l.variables or {},
"error_message": l.error_message,
"sent_at": l.sent_at.isoformat() if l.sent_at else None,
}
for l in logs
],
"total": total,
"limit": limit,
"offset": offset,
}
@router.post("/initialize")
async def initialize_defaults(
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Default-Templates fuer einen Tenant initialisieren."""
tid = uuid.UUID(tenant_id)
existing = db.query(EmailTemplateDB).filter(EmailTemplateDB.tenant_id == tid).count()
if existing > 0:
return {"message": "Templates already initialized", "count": existing}
created = 0
for idx, (ttype, info) in enumerate(TEMPLATE_TYPES.items()):
t = EmailTemplateDB(
tenant_id=tid,
template_type=ttype,
name=info["name"],
category=info["category"],
sort_order=idx * 10,
variables=info["variables"],
)
db.add(t)
created += 1
db.commit()
return {"message": f"{created} templates created", "count": created}
@router.get("/default/{template_type}")
async def get_default_content(template_type: str):
"""Default-Content fuer einen Template-Typ."""
if template_type not in TEMPLATE_TYPES:
raise HTTPException(status_code=404, detail=f"Unknown template type: {template_type}")
info = TEMPLATE_TYPES[template_type]
vars_html = " ".join([f'<span style="color:#4F46E5">{{{{{v}}}}}</span>' for v in info["variables"]])
return {
"template_type": template_type,
"name": info["name"],
"category": info["category"],
"variables": info["variables"],
"default_subject": f"{info['name']} - {{{{company_name}}}}",
"default_body_html": f"<p>Sehr geehrte(r) {{{{user_name}}}},</p>\n<p>[Inhalt hier einfuegen]</p>\n<p>Verfuegbare Variablen: {vars_html}</p>\n<p>Mit freundlichen Gruessen<br/>{{{{sender_name}}}}</p>",
}
# =============================================================================
# Template CRUD (MUST be before /{id} parameterized routes)
# =============================================================================
@router.get("")
async def list_templates(
category: Optional[str] = Query(None),
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Alle Templates mit letzter publizierter Version."""
tid = uuid.UUID(tenant_id)
query = db.query(EmailTemplateDB).filter(EmailTemplateDB.tenant_id == tid)
if category:
query = query.filter(EmailTemplateDB.category == category)
templates = query.order_by(EmailTemplateDB.sort_order).all()
result = []
for t in templates:
latest = db.query(EmailTemplateVersionDB).filter(
EmailTemplateVersionDB.template_id == t.id,
).order_by(EmailTemplateVersionDB.created_at.desc()).first()
result.append(_template_to_dict(t, latest))
return result
@router.post("")
async def create_template(
body: TemplateCreate,
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Template erstellen."""
if body.template_type not in TEMPLATE_TYPES:
raise HTTPException(status_code=400, detail=f"Unknown template type: {body.template_type}")
tid = uuid.UUID(tenant_id)
existing = db.query(EmailTemplateDB).filter(
EmailTemplateDB.tenant_id == tid,
EmailTemplateDB.template_type == body.template_type,
).first()
if existing:
raise HTTPException(status_code=409, detail=f"Template type '{body.template_type}' already exists")
info = TEMPLATE_TYPES[body.template_type]
t = EmailTemplateDB(
tenant_id=tid,
template_type=body.template_type,
name=body.name or info["name"],
description=body.description,
category=body.category or info["category"],
is_active=body.is_active,
variables=info["variables"],
)
db.add(t)
db.commit()
db.refresh(t)
return _template_to_dict(t)
# =============================================================================
# Version Management (static paths before parameterized)
# =============================================================================
@router.post("/versions")
async def create_version(
body: VersionCreate,
template_id: str = Query(..., alias="template_id"),
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Neue Version erstellen (via query param template_id)."""
try:
tid = uuid.UUID(template_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid template ID")
template = db.query(EmailTemplateDB).filter(
EmailTemplateDB.id == tid,
EmailTemplateDB.tenant_id == uuid.UUID(tenant_id),
).first()
if not template:
raise HTTPException(status_code=404, detail="Template not found")
v = EmailTemplateVersionDB(
template_id=tid,
version=body.version,
language=body.language,
subject=body.subject,
body_html=body.body_html,
body_text=body.body_text,
status="draft",
)
db.add(v)
db.commit()
db.refresh(v)
return _version_to_dict(v)
# =============================================================================
# Single Template (parameterized — after all static paths)
# =============================================================================
@router.get("/{template_id}")
async def get_template(
template_id: str,
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Template-Detail."""
try:
tid = uuid.UUID(template_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid template ID")
t = db.query(EmailTemplateDB).filter(
EmailTemplateDB.id == tid,
EmailTemplateDB.tenant_id == uuid.UUID(tenant_id),
).first()
if not t:
raise HTTPException(status_code=404, detail="Template not found")
latest = db.query(EmailTemplateVersionDB).filter(
EmailTemplateVersionDB.template_id == t.id,
).order_by(EmailTemplateVersionDB.created_at.desc()).first()
return _template_to_dict(t, latest)
@router.get("/{template_id}/versions")
async def get_versions(
template_id: str,
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Versionen eines Templates."""
try:
tid = uuid.UUID(template_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid template ID")
template = db.query(EmailTemplateDB).filter(
EmailTemplateDB.id == tid,
EmailTemplateDB.tenant_id == uuid.UUID(tenant_id),
).first()
if not template:
raise HTTPException(status_code=404, detail="Template not found")
versions = db.query(EmailTemplateVersionDB).filter(
EmailTemplateVersionDB.template_id == tid,
).order_by(EmailTemplateVersionDB.created_at.desc()).all()
return [_version_to_dict(v) for v in versions]
@router.post("/{template_id}/versions")
async def create_version_for_template(
template_id: str,
body: VersionCreate,
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Neue Version fuer ein Template erstellen."""
try:
tid = uuid.UUID(template_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid template ID")
template = db.query(EmailTemplateDB).filter(
EmailTemplateDB.id == tid,
EmailTemplateDB.tenant_id == uuid.UUID(tenant_id),
).first()
if not template:
raise HTTPException(status_code=404, detail="Template not found")
v = EmailTemplateVersionDB(
template_id=tid,
version=body.version,
language=body.language,
subject=body.subject,
body_html=body.body_html,
body_text=body.body_text,
status="draft",
)
db.add(v)
db.commit()
db.refresh(v)
return _version_to_dict(v)
# =============================================================================
# Version Workflow (parameterized by version_id)
# =============================================================================
@router.get("/versions/{version_id}")
async def get_version(
version_id: str,
db: Session = Depends(get_db),
):
"""Version-Detail."""
try:
vid = uuid.UUID(version_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid version ID")
v = db.query(EmailTemplateVersionDB).filter(
EmailTemplateVersionDB.id == vid,
).first()
if not v:
raise HTTPException(status_code=404, detail="Version not found")
return _version_to_dict(v)
@router.put("/versions/{version_id}")
async def update_version(
version_id: str,
body: VersionUpdate,
db: Session = Depends(get_db),
):
"""Draft aktualisieren."""
try:
vid = uuid.UUID(version_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid version ID")
v = db.query(EmailTemplateVersionDB).filter(
EmailTemplateVersionDB.id == vid,
).first()
if not v:
raise HTTPException(status_code=404, detail="Version not found")
if v.status != "draft":
raise HTTPException(status_code=400, detail="Only draft versions can be edited")
if body.subject is not None:
v.subject = body.subject
if body.body_html is not None:
v.body_html = body.body_html
if body.body_text is not None:
v.body_text = body.body_text
db.commit()
db.refresh(v)
return _version_to_dict(v)
@router.post("/versions/{version_id}/submit")
async def submit_version(
version_id: str,
db: Session = Depends(get_db),
):
"""Zur Pruefung einreichen."""
try:
vid = uuid.UUID(version_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid version ID")
v = db.query(EmailTemplateVersionDB).filter(
EmailTemplateVersionDB.id == vid,
).first()
if not v:
raise HTTPException(status_code=404, detail="Version not found")
if v.status != "draft":
raise HTTPException(status_code=400, detail="Only draft versions can be submitted")
v.status = "review"
v.submitted_at = datetime.now(timezone.utc)
v.submitted_by = "admin"
db.commit()
db.refresh(v)
return _version_to_dict(v)
@router.post("/versions/{version_id}/approve")
async def approve_version(
version_id: str,
comment: Optional[str] = None,
db: Session = Depends(get_db),
):
"""Genehmigen."""
try:
vid = uuid.UUID(version_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid version ID")
v = db.query(EmailTemplateVersionDB).filter(
EmailTemplateVersionDB.id == vid,
).first()
if not v:
raise HTTPException(status_code=404, detail="Version not found")
if v.status != "review":
raise HTTPException(status_code=400, detail="Only review versions can be approved")
v.status = "approved"
approval = EmailTemplateApprovalDB(
version_id=vid,
action="approve",
comment=comment,
approved_by="admin",
)
db.add(approval)
db.commit()
db.refresh(v)
return _version_to_dict(v)
@router.post("/versions/{version_id}/reject")
async def reject_version(
version_id: str,
comment: Optional[str] = None,
db: Session = Depends(get_db),
):
"""Ablehnen."""
try:
vid = uuid.UUID(version_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid version ID")
v = db.query(EmailTemplateVersionDB).filter(
EmailTemplateVersionDB.id == vid,
).first()
if not v:
raise HTTPException(status_code=404, detail="Version not found")
if v.status != "review":
raise HTTPException(status_code=400, detail="Only review versions can be rejected")
v.status = "draft" # Back to draft
approval = EmailTemplateApprovalDB(
version_id=vid,
action="reject",
comment=comment,
approved_by="admin",
)
db.add(approval)
db.commit()
db.refresh(v)
return _version_to_dict(v)
@router.post("/versions/{version_id}/publish")
async def publish_version(
version_id: str,
db: Session = Depends(get_db),
):
"""Publizieren."""
try:
vid = uuid.UUID(version_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid version ID")
v = db.query(EmailTemplateVersionDB).filter(
EmailTemplateVersionDB.id == vid,
).first()
if not v:
raise HTTPException(status_code=404, detail="Version not found")
if v.status not in ("approved", "review", "draft"):
raise HTTPException(status_code=400, detail="Version cannot be published")
now = datetime.now(timezone.utc)
v.status = "published"
v.published_at = now
v.published_by = "admin"
db.commit()
db.refresh(v)
return _version_to_dict(v)
@router.post("/versions/{version_id}/preview")
async def preview_version(
version_id: str,
body: PreviewRequest,
db: Session = Depends(get_db),
):
"""Vorschau mit Test-Variablen."""
try:
vid = uuid.UUID(version_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid version ID")
v = db.query(EmailTemplateVersionDB).filter(
EmailTemplateVersionDB.id == vid,
).first()
if not v:
raise HTTPException(status_code=404, detail="Version not found")
variables = body.variables or {}
# Fill in defaults for missing variables
template = db.query(EmailTemplateDB).filter(
EmailTemplateDB.id == v.template_id,
).first()
if template and template.variables:
for var in template.variables:
if var not in variables:
variables[var] = f"[{var}]"
rendered_subject = _render_template(v.subject, variables)
rendered_html = _render_template(v.body_html, variables)
return {
"subject": rendered_subject,
"body_html": rendered_html,
"variables_used": variables,
}
@router.post("/versions/{version_id}/send-test")
async def send_test_email(
version_id: str,
body: SendTestRequest,
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Test-E-Mail senden (Simulation — loggt nur)."""
try:
vid = uuid.UUID(version_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid version ID")
v = db.query(EmailTemplateVersionDB).filter(
EmailTemplateVersionDB.id == vid,
).first()
if not v:
raise HTTPException(status_code=404, detail="Version not found")
template = db.query(EmailTemplateDB).filter(
EmailTemplateDB.id == v.template_id,
).first()
variables = body.variables or {}
rendered_subject = _render_template(v.subject, variables)
# Log the send attempt
log = EmailSendLogDB(
tenant_id=uuid.UUID(tenant_id),
template_type=template.template_type if template else "unknown",
version_id=vid,
recipient=body.recipient,
subject=rendered_subject,
status="test_sent",
variables=variables,
)
db.add(log)
db.commit()
return {
"success": True,
"message": f"Test-E-Mail an {body.recipient} gesendet (Simulation)",
"subject": rendered_subject,
}