diff --git a/backend-compliance/compliance/api/email_template_routes.py b/backend-compliance/compliance/api/email_template_routes.py index 0592784..210c01b 100644 --- a/backend-compliance/compliance/api/email_template_routes.py +++ b/backend-compliance/compliance/api/email_template_routes.py @@ -3,153 +3,53 @@ 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. + +Phase 1 Step 4 refactor: handlers delegate to EmailTemplateService +(templates/settings/logs/stats/initialize) and EmailTemplateVersionService +(version workflow + preview + test-send). Template types catalog is +re-exported for any legacy callers. """ -import uuid -from datetime import datetime, timezone -from typing import Optional, Dict +from typing import Any, Optional -from fastapi import APIRouter, Depends, HTTPException, Query, Header -from pydantic import BaseModel +from fastapi import APIRouter, Depends, Header, Query from sqlalchemy.orm import Session from classroom_engine.database import get_db -from ..db.email_template_models import ( - EmailTemplateDB, EmailTemplateVersionDB, EmailTemplateApprovalDB, - EmailSendLogDB, EmailTemplateSettingsDB, +from compliance.api._http_errors import translate_domain_errors +from compliance.schemas.email_template import ( + PreviewRequest, + SendTestRequest, + SettingsUpdate, + TemplateCreate, + VersionCreate, + VersionUpdate, +) +from compliance.services.email_template_service import ( + TEMPLATE_TYPES, + VALID_CATEGORIES, + VALID_STATUSES, + EmailTemplateService, +) +from compliance.services.email_template_version_service import ( + EmailTemplateVersionService, ) 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: +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 get_template_service(db: Session = Depends(get_db)) -> EmailTemplateService: + return EmailTemplateService(db) -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 +def get_version_service(db: Session = Depends(get_db)) -> EmailTemplateVersionService: + return EmailTemplateVersionService(db) # ============================================================================= @@ -157,135 +57,40 @@ def _render_template(html: str, variables: Dict[str, str]) -> str: # ============================================================================= @router.get("/types") -async def get_template_types(): +async def get_template_types() -> list[dict[str, Any]]: """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() - ] + return EmailTemplateService.list_types() @router.get("/stats") async def get_stats( tenant_id: str = Depends(_get_tenant), - db: Session = Depends(get_db), -): + service: EmailTemplateService = Depends(get_template_service), +) -> dict[str, Any]: """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, - } + with translate_domain_errors(): + return service.stats(tenant_id) @router.get("/settings") async def get_settings( tenant_id: str = Depends(_get_tenant), - db: Session = Depends(get_db), -): + service: EmailTemplateService = Depends(get_template_service), +) -> dict[str, Any]: """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, - } + with translate_domain_errors(): + return service.get_settings(tenant_id) @router.put("/settings") async def update_settings( body: SettingsUpdate, tenant_id: str = Depends(_get_tenant), - db: Session = Depends(get_db), -): + service: EmailTemplateService = Depends(get_template_service), +) -> dict[str, Any]: """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, - } + with translate_domain_errors(): + return service.update_settings(tenant_id, body) @router.get("/logs") @@ -294,147 +99,58 @@ async def get_send_logs( offset: int = Query(0, ge=0), template_type: Optional[str] = Query(None), tenant_id: str = Depends(_get_tenant), - db: Session = Depends(get_db), -): + service: EmailTemplateService = Depends(get_template_service), +) -> dict[str, Any]: """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, - } + with translate_domain_errors(): + return service.send_logs(tenant_id, limit, offset, template_type) @router.post("/initialize") async def initialize_defaults( tenant_id: str = Depends(_get_tenant), - db: Session = Depends(get_db), -): + service: EmailTemplateService = Depends(get_template_service), +) -> dict[str, Any]: """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} + with translate_domain_errors(): + return service.initialize_defaults(tenant_id) @router.get("/default/{template_type}") -async def get_default_content(template_type: str): +async def get_default_content(template_type: str) -> dict[str, Any]: """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'{{{{{v}}}}}' 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"

Sehr geehrte(r) {{{{user_name}}}},

\n

[Inhalt hier einfuegen]

\n

Verfuegbare Variablen: {vars_html}

\n

Mit freundlichen Gruessen
{{{{sender_name}}}}

", - } + with translate_domain_errors(): + return EmailTemplateService.default_content(template_type) # ============================================================================= -# Template CRUD (MUST be before /{id} parameterized routes) +# Template CRUD # ============================================================================= @router.get("") async def list_templates( category: Optional[str] = Query(None), tenant_id: str = Depends(_get_tenant), - db: Session = Depends(get_db), -): + service: EmailTemplateService = Depends(get_template_service), +) -> list[dict[str, Any]]: """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 + with translate_domain_errors(): + return service.list_templates(tenant_id, category) @router.post("") async def create_template( body: TemplateCreate, tenant_id: str = Depends(_get_tenant), - db: Session = Depends(get_db), -): + service: EmailTemplateService = Depends(get_template_service), +) -> dict[str, Any]: """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) + with translate_domain_errors(): + return service.create_template(tenant_id, body) # ============================================================================= -# Version Management (static paths before parameterized) +# Version Management (static path before parameterized) # ============================================================================= @router.post("/versions") @@ -442,34 +158,11 @@ async def create_version( body: VersionCreate, template_id: str = Query(..., alias="template_id"), tenant_id: str = Depends(_get_tenant), - db: Session = Depends(get_db), -): + service: EmailTemplateVersionService = Depends(get_version_service), +) -> dict[str, Any]: """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) + with translate_domain_errors(): + return service.create_version(tenant_id, template_id, body) # ============================================================================= @@ -480,52 +173,22 @@ async def create_version( async def get_template( template_id: str, tenant_id: str = Depends(_get_tenant), - db: Session = Depends(get_db), -): + service: EmailTemplateService = Depends(get_template_service), +) -> dict[str, Any]: """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) + with translate_domain_errors(): + return service.get_template(tenant_id, template_id) @router.get("/{template_id}/versions") async def get_versions( template_id: str, tenant_id: str = Depends(_get_tenant), - db: Session = Depends(get_db), -): + service: EmailTemplateVersionService = Depends(get_version_service), +) -> list[dict[str, Any]]: """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] + with translate_domain_errors(): + return service.list_versions(tenant_id, template_id) @router.post("/{template_id}/versions") @@ -533,34 +196,11 @@ async def create_version_for_template( template_id: str, body: VersionCreate, tenant_id: str = Depends(_get_tenant), - db: Session = Depends(get_db), -): + service: EmailTemplateVersionService = Depends(get_version_service), +) -> dict[str, Any]: """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) + with translate_domain_errors(): + return service.create_version(tenant_id, template_id, body) # ============================================================================= @@ -570,211 +210,75 @@ async def create_version_for_template( @router.get("/versions/{version_id}") async def get_version( version_id: str, - db: Session = Depends(get_db), -): + service: EmailTemplateVersionService = Depends(get_version_service), +) -> dict[str, Any]: """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) + with translate_domain_errors(): + return service.get_version(version_id) @router.put("/versions/{version_id}") async def update_version( version_id: str, body: VersionUpdate, - db: Session = Depends(get_db), -): + service: EmailTemplateVersionService = Depends(get_version_service), +) -> dict[str, Any]: """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) + with translate_domain_errors(): + return service.update_version(version_id, body) @router.post("/versions/{version_id}/submit") async def submit_version( version_id: str, - db: Session = Depends(get_db), -): + service: EmailTemplateVersionService = Depends(get_version_service), +) -> dict[str, Any]: """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) + with translate_domain_errors(): + return service.submit(version_id) @router.post("/versions/{version_id}/approve") async def approve_version( version_id: str, comment: Optional[str] = None, - db: Session = Depends(get_db), -): + service: EmailTemplateVersionService = Depends(get_version_service), +) -> dict[str, Any]: """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) + with translate_domain_errors(): + return service.approve(version_id, comment) @router.post("/versions/{version_id}/reject") async def reject_version( version_id: str, comment: Optional[str] = None, - db: Session = Depends(get_db), -): + service: EmailTemplateVersionService = Depends(get_version_service), +) -> dict[str, Any]: """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) + with translate_domain_errors(): + return service.reject(version_id, comment) @router.post("/versions/{version_id}/publish") async def publish_version( version_id: str, - db: Session = Depends(get_db), -): + service: EmailTemplateVersionService = Depends(get_version_service), +) -> dict[str, Any]: """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) + with translate_domain_errors(): + return service.publish(version_id) @router.post("/versions/{version_id}/preview") async def preview_version( version_id: str, body: PreviewRequest, - db: Session = Depends(get_db), -): + service: EmailTemplateVersionService = Depends(get_version_service), +) -> dict[str, Any]: """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, - } + with translate_domain_errors(): + return service.preview(version_id, body) @router.post("/versions/{version_id}/send-test") @@ -782,42 +286,17 @@ async def send_test_email( version_id: str, body: SendTestRequest, tenant_id: str = Depends(_get_tenant), - db: Session = Depends(get_db), -): + service: EmailTemplateVersionService = Depends(get_version_service), +) -> dict[str, Any]: """Test-E-Mail senden (Simulation — loggt nur).""" - try: - vid = uuid.UUID(version_id) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid version ID") + with translate_domain_errors(): + return service.send_test(tenant_id, version_id, body) - 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, - } +# Legacy re-exports +__all__ = [ + "router", + "TEMPLATE_TYPES", + "VALID_CATEGORIES", + "VALID_STATUSES", +] diff --git a/backend-compliance/compliance/schemas/email_template.py b/backend-compliance/compliance/schemas/email_template.py new file mode 100644 index 0000000..18d247c --- /dev/null +++ b/backend-compliance/compliance/schemas/email_template.py @@ -0,0 +1,62 @@ +""" +Email template request schemas. + +Phase 1 Step 4: extracted from ``compliance.api.email_template_routes``. +""" + +from typing import Optional + +from pydantic import BaseModel + + +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 + + +__all__ = [ + "TemplateCreate", + "VersionCreate", + "VersionUpdate", + "PreviewRequest", + "SendTestRequest", + "SettingsUpdate", +] diff --git a/backend-compliance/compliance/services/email_template_service.py b/backend-compliance/compliance/services/email_template_service.py new file mode 100644 index 0000000..dadf221 --- /dev/null +++ b/backend-compliance/compliance/services/email_template_service.py @@ -0,0 +1,397 @@ +# 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'{{{{{v}}}}}' 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"

Sehr geehrte(r) {{{{user_name}}}},

\n" + f"

[Inhalt hier einfuegen]

\n" + f"

Verfuegbare Variablen: {vars_html}

\n" + f"

Mit freundlichen Gruessen
{{{{sender_name}}}}

" + ), + } + + # ------------------------------------------------------------------ + # 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) diff --git a/backend-compliance/compliance/services/email_template_version_service.py b/backend-compliance/compliance/services/email_template_version_service.py new file mode 100644 index 0000000..87c120a --- /dev/null +++ b/backend-compliance/compliance/services/email_template_version_service.py @@ -0,0 +1,260 @@ +# mypy: disable-error-code="arg-type,assignment,union-attr" +""" +Email Template version service — version workflow (draft/review/approve/ +reject/publish/preview/send-test). + +Phase 1 Step 4: extracted from ``compliance.api.email_template_routes``. +Template-level CRUD + settings + stats live in +``compliance.services.email_template_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, + EmailTemplateApprovalDB, + EmailTemplateDB, + EmailTemplateVersionDB, +) +from compliance.domain import ConflictError, NotFoundError, ValidationError +from compliance.schemas.email_template import ( + PreviewRequest, + SendTestRequest, + VersionCreate, + VersionUpdate, +) +from compliance.services.email_template_service import ( + _render_template, + _version_to_dict, +) + + +class EmailTemplateVersionService: + """Business logic for email-template version workflow + preview + test-send.""" + + def __init__(self, db: Session) -> None: + self.db = db + + # ------------------------------------------------------------------ + # Internal lookups + # ------------------------------------------------------------------ + + @staticmethod + def _parse_template_uuid(template_id: str) -> uuid.UUID: + try: + return uuid.UUID(template_id) + except ValueError as exc: + raise ValidationError("Invalid template ID") from exc + + @staticmethod + def _parse_version_uuid(version_id: str) -> uuid.UUID: + try: + return uuid.UUID(version_id) + except ValueError as exc: + raise ValidationError("Invalid version ID") from exc + + def _template_or_raise( + self, tenant_id: str, tid: uuid.UUID + ) -> EmailTemplateDB: + template = ( + self.db.query(EmailTemplateDB) + .filter( + EmailTemplateDB.id == tid, + EmailTemplateDB.tenant_id == uuid.UUID(tenant_id), + ) + .first() + ) + if not template: + raise NotFoundError("Template not found") + return template + + def _version_or_raise(self, vid: uuid.UUID) -> EmailTemplateVersionDB: + v = ( + self.db.query(EmailTemplateVersionDB) + .filter(EmailTemplateVersionDB.id == vid) + .first() + ) + if not v: + raise NotFoundError("Version not found") + return v + + # ------------------------------------------------------------------ + # Create / list / get versions + # ------------------------------------------------------------------ + + def create_version( + self, tenant_id: str, template_id: str, body: VersionCreate + ) -> dict[str, Any]: + tid = self._parse_template_uuid(template_id) + self._template_or_raise(tenant_id, tid) + 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", + ) + self.db.add(v) + self.db.commit() + self.db.refresh(v) + return _version_to_dict(v) + + def list_versions( + self, tenant_id: str, template_id: str + ) -> list[dict[str, Any]]: + tid = self._parse_template_uuid(template_id) + self._template_or_raise(tenant_id, tid) + versions = ( + self.db.query(EmailTemplateVersionDB) + .filter(EmailTemplateVersionDB.template_id == tid) + .order_by(EmailTemplateVersionDB.created_at.desc()) + .all() + ) + return [_version_to_dict(v) for v in versions] + + def get_version(self, version_id: str) -> dict[str, Any]: + vid = self._parse_version_uuid(version_id) + return _version_to_dict(self._version_or_raise(vid)) + + def update_version( + self, version_id: str, body: VersionUpdate + ) -> dict[str, Any]: + vid = self._parse_version_uuid(version_id) + v = self._version_or_raise(vid) + if v.status != "draft": + raise ValidationError("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 + self.db.commit() + self.db.refresh(v) + return _version_to_dict(v) + + # ------------------------------------------------------------------ + # Workflow transitions + # ------------------------------------------------------------------ + + def submit(self, version_id: str) -> dict[str, Any]: + vid = self._parse_version_uuid(version_id) + v = self._version_or_raise(vid) + if v.status != "draft": + raise ValidationError("Only draft versions can be submitted") + v.status = "review" + v.submitted_at = datetime.now(timezone.utc) + v.submitted_by = "admin" + self.db.commit() + self.db.refresh(v) + return _version_to_dict(v) + + def approve(self, version_id: str, comment: Optional[str]) -> dict[str, Any]: + vid = self._parse_version_uuid(version_id) + v = self._version_or_raise(vid) + if v.status != "review": + raise ValidationError("Only review versions can be approved") + v.status = "approved" + self.db.add( + EmailTemplateApprovalDB( + version_id=vid, + action="approve", + comment=comment, + approved_by="admin", + ) + ) + self.db.commit() + self.db.refresh(v) + return _version_to_dict(v) + + def reject(self, version_id: str, comment: Optional[str]) -> dict[str, Any]: + vid = self._parse_version_uuid(version_id) + v = self._version_or_raise(vid) + if v.status != "review": + raise ValidationError("Only review versions can be rejected") + v.status = "draft" + self.db.add( + EmailTemplateApprovalDB( + version_id=vid, + action="reject", + comment=comment, + approved_by="admin", + ) + ) + self.db.commit() + self.db.refresh(v) + return _version_to_dict(v) + + def publish(self, version_id: str) -> dict[str, Any]: + vid = self._parse_version_uuid(version_id) + v = self._version_or_raise(vid) + if v.status not in ("approved", "review", "draft"): + raise ValidationError("Version cannot be published") + v.status = "published" + v.published_at = datetime.now(timezone.utc) + v.published_by = "admin" + self.db.commit() + self.db.refresh(v) + return _version_to_dict(v) + + # ------------------------------------------------------------------ + # Preview + test send + # ------------------------------------------------------------------ + + def preview(self, version_id: str, body: PreviewRequest) -> dict[str, Any]: + vid = self._parse_version_uuid(version_id) + v = self._version_or_raise(vid) + + variables = dict(body.variables or {}) + template = ( + self.db.query(EmailTemplateDB) + .filter(EmailTemplateDB.id == v.template_id) + .first() + ) + if template and template.variables: + for var in list(template.variables): + if var not in variables: + variables[var] = f"[{var}]" + + return { + "subject": _render_template(v.subject, variables), + "body_html": _render_template(v.body_html, variables), + "variables_used": variables, + } + + def send_test( + self, tenant_id: str, version_id: str, body: SendTestRequest + ) -> dict[str, Any]: + vid = self._parse_version_uuid(version_id) + v = self._version_or_raise(vid) + template = ( + self.db.query(EmailTemplateDB) + .filter(EmailTemplateDB.id == v.template_id) + .first() + ) + variables = body.variables or {} + rendered_subject = _render_template(v.subject, variables) + + self.db.add( + 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, + ) + ) + self.db.commit() + return { + "success": True, + "message": f"Test-E-Mail an {body.recipient} gesendet (Simulation)", + "subject": rendered_subject, + } diff --git a/backend-compliance/mypy.ini b/backend-compliance/mypy.ini index c097876..0be1fb6 100644 --- a/backend-compliance/mypy.ini +++ b/backend-compliance/mypy.ini @@ -89,5 +89,7 @@ ignore_errors = False ignore_errors = False [mypy-compliance.api.evidence_routes] ignore_errors = False +[mypy-compliance.api.email_template_routes] +ignore_errors = False [mypy-compliance.api._http_errors] ignore_errors = False diff --git a/backend-compliance/tests/contracts/openapi.baseline.json b/backend-compliance/tests/contracts/openapi.baseline.json index 979b037..5818f80 100644 --- a/backend-compliance/tests/contracts/openapi.baseline.json +++ b/backend-compliance/tests/contracts/openapi.baseline.json @@ -19563,135 +19563,6 @@ "title": "ConsentCreate", "type": "object" }, - "compliance__api__email_template_routes__TemplateCreate": { - "properties": { - "category": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Category" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Description" - }, - "is_active": { - "default": true, - "title": "Is Active", - "type": "boolean" - }, - "name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Name" - }, - "template_type": { - "title": "Template Type", - "type": "string" - } - }, - "required": [ - "template_type" - ], - "title": "TemplateCreate", - "type": "object" - }, - "compliance__api__email_template_routes__VersionCreate": { - "properties": { - "body_html": { - "title": "Body Html", - "type": "string" - }, - "body_text": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Body Text" - }, - "language": { - "default": "de", - "title": "Language", - "type": "string" - }, - "subject": { - "title": "Subject", - "type": "string" - }, - "version": { - "default": "1.0", - "title": "Version", - "type": "string" - } - }, - "required": [ - "subject", - "body_html" - ], - "title": "VersionCreate", - "type": "object" - }, - "compliance__api__email_template_routes__VersionUpdate": { - "properties": { - "body_html": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Body Html" - }, - "body_text": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Body Text" - }, - "subject": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Subject" - } - }, - "title": "VersionUpdate", - "type": "object" - }, "compliance__api__incident_routes__IncidentCreate": { "properties": { "affected_data_categories": { @@ -20361,6 +20232,135 @@ ], "title": "ConsentCreate", "type": "object" + }, + "compliance__schemas__email_template__TemplateCreate": { + "properties": { + "category": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Category" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "is_active": { + "default": true, + "title": "Is Active", + "type": "boolean" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "template_type": { + "title": "Template Type", + "type": "string" + } + }, + "required": [ + "template_type" + ], + "title": "TemplateCreate", + "type": "object" + }, + "compliance__schemas__email_template__VersionCreate": { + "properties": { + "body_html": { + "title": "Body Html", + "type": "string" + }, + "body_text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Body Text" + }, + "language": { + "default": "de", + "title": "Language", + "type": "string" + }, + "subject": { + "title": "Subject", + "type": "string" + }, + "version": { + "default": "1.0", + "title": "Version", + "type": "string" + } + }, + "required": [ + "subject", + "body_html" + ], + "title": "VersionCreate", + "type": "object" + }, + "compliance__schemas__email_template__VersionUpdate": { + "properties": { + "body_html": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Body Html" + }, + "body_text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Body Text" + }, + "subject": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Subject" + } + }, + "title": "VersionUpdate", + "type": "object" } } }, @@ -27260,7 +27260,14 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Response List Templates Api Compliance Email Templates Get", + "type": "array" + } } }, "description": "Successful Response" @@ -27307,7 +27314,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/compliance__api__email_template_routes__TemplateCreate" + "$ref": "#/components/schemas/compliance__schemas__email_template__TemplateCreate" } } }, @@ -27317,7 +27324,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Create Template Api Compliance Email Templates Post", + "type": "object" + } } }, "description": "Successful Response" @@ -27359,7 +27370,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Get Default Content Api Compliance Email Templates Default Template Type Get", + "type": "object" + } } }, "description": "Successful Response" @@ -27408,7 +27423,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Initialize Defaults Api Compliance Email Templates Initialize Post", + "type": "object" + } } }, "description": "Successful Response" @@ -27496,7 +27515,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Get Send Logs Api Compliance Email Templates Logs Get", + "type": "object" + } } }, "description": "Successful Response" @@ -27545,7 +27568,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Get Settings Api Compliance Email Templates Settings Get", + "type": "object" + } } }, "description": "Successful Response" @@ -27602,7 +27629,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Update Settings Api Compliance Email Templates Settings Put", + "type": "object" + } } }, "description": "Successful Response" @@ -27651,7 +27682,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Get Stats Api Compliance Email Templates Stats Get", + "type": "object" + } } }, "description": "Successful Response" @@ -27682,7 +27717,14 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Response Get Template Types Api Compliance Email Templates Types Get", + "type": "array" + } } }, "description": "Successful Response" @@ -27730,7 +27772,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/compliance__api__email_template_routes__VersionCreate" + "$ref": "#/components/schemas/compliance__schemas__email_template__VersionCreate" } } }, @@ -27740,7 +27782,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Create Version Api Compliance Email Templates Versions Post", + "type": "object" + } } }, "description": "Successful Response" @@ -27782,7 +27828,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Get Version Api Compliance Email Templates Versions Version Id Get", + "type": "object" + } } }, "description": "Successful Response" @@ -27822,7 +27872,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/compliance__api__email_template_routes__VersionUpdate" + "$ref": "#/components/schemas/compliance__schemas__email_template__VersionUpdate" } } }, @@ -27832,7 +27882,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Update Version Api Compliance Email Templates Versions Version Id Put", + "type": "object" + } } }, "description": "Successful Response" @@ -27890,7 +27944,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Approve Version Api Compliance Email Templates Versions Version Id Approve Post", + "type": "object" + } } }, "description": "Successful Response" @@ -27942,7 +28000,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Preview Version Api Compliance Email Templates Versions Version Id Preview Post", + "type": "object" + } } }, "description": "Successful Response" @@ -27984,7 +28046,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Publish Version Api Compliance Email Templates Versions Version Id Publish Post", + "type": "object" + } } }, "description": "Successful Response" @@ -28042,7 +28108,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Reject Version Api Compliance Email Templates Versions Version Id Reject Post", + "type": "object" + } } }, "description": "Successful Response" @@ -28110,7 +28180,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Send Test Email Api Compliance Email Templates Versions Version Id Send Test Post", + "type": "object" + } } }, "description": "Successful Response" @@ -28152,7 +28226,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Submit Version Api Compliance Email Templates Versions Version Id Submit Post", + "type": "object" + } } }, "description": "Successful Response" @@ -28210,7 +28288,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Get Template Api Compliance Email Templates Template Id Get", + "type": "object" + } } }, "description": "Successful Response" @@ -28268,7 +28350,14 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Response Get Versions Api Compliance Email Templates Template Id Versions Get", + "type": "array" + } } }, "description": "Successful Response" @@ -28324,7 +28413,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/compliance__api__email_template_routes__VersionCreate" + "$ref": "#/components/schemas/compliance__schemas__email_template__VersionCreate" } } }, @@ -28334,7 +28423,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Create Version For Template Api Compliance Email Templates Template Id Versions Post", + "type": "object" + } } }, "description": "Successful Response"