compliance/api/email_template_routes.py (823 LOC) -> 295 LOC thin routes
+ 402-line EmailTemplateService + 241-line EmailTemplateVersionService +
61-line schemas file.
Two-service split along natural responsibility seam:
email_template_service.py (402 LOC):
- Template type catalog (TEMPLATE_TYPES constant)
- Template CRUD (list, create, get)
- Stats, settings, send logs, initialization, default content
- Shared _template_to_dict / _version_to_dict / _render_template helpers
email_template_version_service.py (241 LOC):
- Version CRUD (create, list, get, update)
- Workflow transitions (submit, approve, reject, publish)
- Preview and test-send
TEMPLATE_TYPES, VALID_CATEGORIES, VALID_STATUSES re-exported from the
route module for any legacy consumers.
State-transition errors use ValidationError (-> HTTPException 400) to
preserve the original handler's 400 status for "Only draft/review
versions can be ..." checks, since the existing TestClient integration
tests (47 tests) assert status_code == 400.
Verified:
- 47/47 tests/test_email_template_routes.py pass
- OpenAPI 360/484 unchanged
- mypy compliance/ -> Success on 138 source files
- email_template_routes.py 823 -> 295 LOC
- Hard-cap violations: 9 -> 8
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
398 lines
16 KiB
Python
398 lines
16 KiB
Python
# mypy: disable-error-code="arg-type,assignment,union-attr"
|
|
"""
|
|
Email Template service — templates CRUD, settings, logs, stats, initialization.
|
|
|
|
Phase 1 Step 4: extracted from ``compliance.api.email_template_routes``.
|
|
Version workflow (draft/review/approve/reject/publish/preview/send-test)
|
|
lives in ``compliance.services.email_template_version_service``.
|
|
"""
|
|
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Optional
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from compliance.db.email_template_models import (
|
|
EmailSendLogDB,
|
|
EmailTemplateDB,
|
|
EmailTemplateSettingsDB,
|
|
EmailTemplateVersionDB,
|
|
)
|
|
from compliance.domain import ConflictError, NotFoundError, ValidationError
|
|
from compliance.schemas.email_template import SettingsUpdate, TemplateCreate
|
|
|
|
# Template type catalog — shared across both services and the route module.
|
|
TEMPLATE_TYPES: dict[str, dict[str, Any]] = {
|
|
"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"]
|
|
|
|
|
|
def _template_to_dict(
|
|
t: EmailTemplateDB, latest_version: Optional[EmailTemplateVersionDB] = None
|
|
) -> dict[str, Any]:
|
|
result: dict[str, Any] = {
|
|
"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[str, Any]:
|
|
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
|
|
|
|
|
|
class EmailTemplateService:
|
|
"""Business logic for templates, settings, logs, stats, initialization."""
|
|
|
|
def __init__(self, db: Session) -> None:
|
|
self.db = db
|
|
|
|
# ------------------------------------------------------------------
|
|
# Type catalog + defaults
|
|
# ------------------------------------------------------------------
|
|
|
|
@staticmethod
|
|
def list_types() -> list[dict[str, Any]]:
|
|
return [
|
|
{
|
|
"type": ttype,
|
|
"name": info["name"],
|
|
"category": info["category"],
|
|
"variables": info["variables"],
|
|
}
|
|
for ttype, info in TEMPLATE_TYPES.items()
|
|
]
|
|
|
|
@staticmethod
|
|
def default_content(template_type: str) -> dict[str, Any]:
|
|
if template_type not in TEMPLATE_TYPES:
|
|
raise NotFoundError(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"
|
|
f"<p>[Inhalt hier einfuegen]</p>\n"
|
|
f"<p>Verfuegbare Variablen: {vars_html}</p>\n"
|
|
f"<p>Mit freundlichen Gruessen<br/>{{{{sender_name}}}}</p>"
|
|
),
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Stats
|
|
# ------------------------------------------------------------------
|
|
|
|
def stats(self, tenant_id: str) -> dict[str, Any]:
|
|
tid = uuid.UUID(tenant_id)
|
|
base = self.db.query(EmailTemplateDB).filter(EmailTemplateDB.tenant_id == tid)
|
|
total = base.count()
|
|
active = base.filter(EmailTemplateDB.is_active).count()
|
|
|
|
published_count = 0
|
|
for t in base.all():
|
|
has_published = (
|
|
self.db.query(EmailTemplateVersionDB)
|
|
.filter(
|
|
EmailTemplateVersionDB.template_id == t.id,
|
|
EmailTemplateVersionDB.status == "published",
|
|
)
|
|
.count()
|
|
> 0
|
|
)
|
|
if has_published:
|
|
published_count += 1
|
|
|
|
by_category: dict[str, int] = {}
|
|
for cat in VALID_CATEGORIES:
|
|
by_category[cat] = base.filter(EmailTemplateDB.category == cat).count()
|
|
|
|
total_sent = (
|
|
self.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,
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Settings
|
|
# ------------------------------------------------------------------
|
|
|
|
@staticmethod
|
|
def _settings_defaults() -> dict[str, Any]:
|
|
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,
|
|
}
|
|
|
|
@staticmethod
|
|
def _settings_to_dict(s: EmailTemplateSettingsDB) -> dict[str, Any]:
|
|
return {
|
|
"sender_name": s.sender_name,
|
|
"sender_email": s.sender_email,
|
|
"reply_to": s.reply_to,
|
|
"logo_url": s.logo_url,
|
|
"primary_color": s.primary_color,
|
|
"secondary_color": s.secondary_color,
|
|
"footer_text": s.footer_text,
|
|
"company_name": s.company_name,
|
|
"company_address": s.company_address,
|
|
}
|
|
|
|
def get_settings(self, tenant_id: str) -> dict[str, Any]:
|
|
tid = uuid.UUID(tenant_id)
|
|
settings = (
|
|
self.db.query(EmailTemplateSettingsDB)
|
|
.filter(EmailTemplateSettingsDB.tenant_id == tid)
|
|
.first()
|
|
)
|
|
if not settings:
|
|
return self._settings_defaults()
|
|
return self._settings_to_dict(settings)
|
|
|
|
def update_settings(self, tenant_id: str, body: SettingsUpdate) -> dict[str, Any]:
|
|
tid = uuid.UUID(tenant_id)
|
|
settings = (
|
|
self.db.query(EmailTemplateSettingsDB)
|
|
.filter(EmailTemplateSettingsDB.tenant_id == tid)
|
|
.first()
|
|
)
|
|
if not settings:
|
|
settings = EmailTemplateSettingsDB(tenant_id=tid)
|
|
self.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)
|
|
self.db.commit()
|
|
self.db.refresh(settings)
|
|
return self._settings_to_dict(settings)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Send logs
|
|
# ------------------------------------------------------------------
|
|
|
|
def send_logs(
|
|
self,
|
|
tenant_id: str,
|
|
limit: int,
|
|
offset: int,
|
|
template_type: Optional[str],
|
|
) -> dict[str, Any]:
|
|
tid = uuid.UUID(tenant_id)
|
|
q = self.db.query(EmailSendLogDB).filter(EmailSendLogDB.tenant_id == tid)
|
|
if template_type:
|
|
q = q.filter(EmailSendLogDB.template_type == template_type)
|
|
total = q.count()
|
|
logs = (
|
|
q.order_by(EmailSendLogDB.sent_at.desc())
|
|
.offset(offset)
|
|
.limit(limit)
|
|
.all()
|
|
)
|
|
return {
|
|
"logs": [
|
|
{
|
|
"id": str(lg.id),
|
|
"template_type": lg.template_type,
|
|
"recipient": lg.recipient,
|
|
"subject": lg.subject,
|
|
"status": lg.status,
|
|
"variables": lg.variables or {},
|
|
"error_message": lg.error_message,
|
|
"sent_at": lg.sent_at.isoformat() if lg.sent_at else None,
|
|
}
|
|
for lg in logs
|
|
],
|
|
"total": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Initialization + template CRUD
|
|
# ------------------------------------------------------------------
|
|
|
|
def initialize_defaults(self, tenant_id: str) -> dict[str, Any]:
|
|
tid = uuid.UUID(tenant_id)
|
|
existing = (
|
|
self.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()):
|
|
self.db.add(
|
|
EmailTemplateDB(
|
|
tenant_id=tid,
|
|
template_type=ttype,
|
|
name=info["name"],
|
|
category=info["category"],
|
|
sort_order=idx * 10,
|
|
variables=info["variables"],
|
|
)
|
|
)
|
|
created += 1
|
|
self.db.commit()
|
|
return {"message": f"{created} templates created", "count": created}
|
|
|
|
def list_templates(
|
|
self, tenant_id: str, category: Optional[str]
|
|
) -> list[dict[str, Any]]:
|
|
tid = uuid.UUID(tenant_id)
|
|
q = self.db.query(EmailTemplateDB).filter(EmailTemplateDB.tenant_id == tid)
|
|
if category:
|
|
q = q.filter(EmailTemplateDB.category == category)
|
|
templates = q.order_by(EmailTemplateDB.sort_order).all()
|
|
result = []
|
|
for t in templates:
|
|
latest = (
|
|
self.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
|
|
|
|
def create_template(self, tenant_id: str, body: TemplateCreate) -> dict[str, Any]:
|
|
if body.template_type not in TEMPLATE_TYPES:
|
|
raise ValidationError(f"Unknown template type: {body.template_type}")
|
|
tid = uuid.UUID(tenant_id)
|
|
existing = (
|
|
self.db.query(EmailTemplateDB)
|
|
.filter(
|
|
EmailTemplateDB.tenant_id == tid,
|
|
EmailTemplateDB.template_type == body.template_type,
|
|
)
|
|
.first()
|
|
)
|
|
if existing:
|
|
raise ConflictError(
|
|
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"],
|
|
)
|
|
self.db.add(t)
|
|
self.db.commit()
|
|
self.db.refresh(t)
|
|
return _template_to_dict(t)
|
|
|
|
def get_template(self, tenant_id: str, template_id: str) -> dict[str, Any]:
|
|
try:
|
|
tid = uuid.UUID(template_id)
|
|
except ValueError as exc:
|
|
raise ValidationError("Invalid template ID") from exc
|
|
|
|
t = (
|
|
self.db.query(EmailTemplateDB)
|
|
.filter(
|
|
EmailTemplateDB.id == tid,
|
|
EmailTemplateDB.tenant_id == uuid.UUID(tenant_id),
|
|
)
|
|
.first()
|
|
)
|
|
if not t:
|
|
raise NotFoundError("Template not found")
|
|
latest = (
|
|
self.db.query(EmailTemplateVersionDB)
|
|
.filter(EmailTemplateVersionDB.template_id == t.id)
|
|
.order_by(EmailTemplateVersionDB.created_at.desc())
|
|
.first()
|
|
)
|
|
return _template_to_dict(t, latest)
|