feat(repairs): automatic repair status emails

This commit is contained in:
Schubert Ferenc 2026-07-04 22:58:47 +02:00
parent 3ed6596bf0
commit c58e212e3a
21 changed files with 525 additions and 36 deletions

View file

@ -20,3 +20,10 @@ KNOWLEDGE_STORAGE_PATH=/data/knowledge
KNOWLEDGE_MAX_UPLOAD_MB=50
OLYMPUS_REPAIR_INTAKE_TOKEN=
PUBLIC_REPAIR_STATUS_BASE_URL=
SMTP_HOST=
SMTP_PORT=587
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_EMAIL=
SMTP_FROM_NAME=Funktechnik Schubert
SMTP_USE_TLS=true

View file

@ -0,0 +1,29 @@
"""extend repair notifications
Revision ID: e9a7c3d5b812
Revises: d4f6a2b8c901
Create Date: 2026-07-04 13:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "e9a7c3d5b812"
down_revision: Union[str, Sequence[str], None] = "d4f6a2b8c901"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("repair_notification_events", sa.Column("template", sa.Text(), server_default="", nullable=False))
op.add_column("repair_notification_events", sa.Column("success", sa.Boolean(), server_default="false", nullable=False))
op.create_index(op.f("ix_repair_notification_events_success"), "repair_notification_events", ["success"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_repair_notification_events_success"), table_name="repair_notification_events")
op.drop_column("repair_notification_events", "success")
op.drop_column("repair_notification_events", "template")

View file

@ -64,6 +64,9 @@ def get_dashboard_summary(
MetricCard(label="In Reparatur", value=RepairRepository.count_by_status(db, "repair")),
MetricCard(label="Endprüfung", value=RepairRepository.count_by_status(db, "final_test")),
MetricCard(label="Abgeschlossen", value=RepairRepository.count_by_status(db, "completed")),
MetricCard(label="Statusmails heute", value=RepairRepository.count_status_mails_sent_today(db)),
MetricCard(label="Fehlgeschlagene Mails", value=RepairRepository.count_failed_status_mails(db)),
MetricCard(label="Offen ohne Kundenmail", value=RepairRepository.count_open_repairs_without_customer_email(db)),
]
logger.info("dashboard.summary", extra={"actor_user_id": current_user.id})

View file

@ -13,6 +13,7 @@ from app.schemas.repair import (
RepairCreate,
RepairIntakePayload,
RepairIntakeResponse,
RepairNotificationEventResponse,
RepairListResponse,
RepairNotificationOverviewResponse,
RepairPublicLinkCreateResponse,
@ -171,6 +172,17 @@ def get_repair_notifications(
return RepairNotificationService.overview(db, db_repair)
@router.post("/repairs/{repair_id}/send-status-mail", response_model=RepairNotificationEventResponse)
def send_repair_status_mail(
repair_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.update")),
):
db_repair = get_repair_or_404(db, repair_id)
return RepairNotificationService.send_status_mail(db, db_repair, actor=current_user, request=request, manual=True)
@router.post("/public/repair-intake", response_model=RepairIntakeResponse, status_code=status.HTTP_201_CREATED)
def create_repair_intake(
payload: RepairIntakePayload,

View file

@ -25,6 +25,13 @@ class Settings(BaseSettings):
knowledge_max_upload_mb: int = 50
olympus_repair_intake_token: str | None = None
public_repair_status_base_url: str | None = None
smtp_host: str | None = None
smtp_port: int = 587
smtp_username: str | None = None
smtp_password: str | None = None
smtp_from_email: str | None = None
smtp_from_name: str = "Funktechnik Schubert"
smtp_use_tls: bool = True
model_config = SettingsConfigDict(
env_file=".env",

View file

@ -120,7 +120,9 @@ class RepairNotificationEvent(Base):
channel: Mapped[str] = mapped_column(String(40), index=True)
recipient: Mapped[str] = mapped_column(String(255), default="", server_default="")
subject: Mapped[str] = mapped_column(String(255))
template: Mapped[str] = mapped_column(Text, default="", server_default="")
status: Mapped[str] = mapped_column(String(40), index=True)
success: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false", index=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)

View file

@ -301,7 +301,9 @@ class RepairRepository:
channel: str,
recipient: str,
subject: str,
template: str,
status: str,
success: bool,
error_message: str | None = None,
sent_at: datetime | None = None,
) -> RepairNotificationEvent:
@ -311,7 +313,9 @@ class RepairRepository:
channel=channel,
recipient=recipient,
subject=subject,
template=template,
status=status,
success=success,
error_message=error_message,
sent_at=sent_at,
)
@ -320,6 +324,33 @@ class RepairRepository:
db.refresh(event)
return event
@staticmethod
def count_status_mails_sent_today(db: Session) -> int:
today = datetime.now(UTC).date()
return db.scalar(
select(func.count(RepairNotificationEvent.id))
.where(RepairNotificationEvent.event_type == "repair_status_mail")
.where(RepairNotificationEvent.success.is_(True))
.where(func.date(RepairNotificationEvent.created_at) == today)
) or 0
@staticmethod
def count_failed_status_mails(db: Session) -> int:
return db.scalar(
select(func.count(RepairNotificationEvent.id))
.where(RepairNotificationEvent.event_type == "repair_status_mail")
.where(RepairNotificationEvent.success.is_(False))
.where(RepairNotificationEvent.status.in_(["failed", "skipped"]))
) or 0
@staticmethod
def count_open_repairs_without_customer_email(db: Session) -> int:
return db.scalar(
select(func.count(Repair.id))
.where(~Repair.status.in_(["completed", "cancelled"]))
.where((Repair.customer_email == "") | Repair.customer_email.is_(None))
) or 0
@staticmethod
def _payload_data(payload: RepairCreate | RepairUpdate) -> dict:
data = payload.model_dump()

View file

@ -209,7 +209,9 @@ class RepairNotificationEventResponse(BaseModel):
channel: str
recipient: str
subject: str
template: str
status: str
success: bool
error_message: str | None
created_at: datetime
sent_at: datetime | None

View file

@ -13,7 +13,7 @@ from app.repositories.audit_repository import AuditRepository
logger = logging.getLogger(__name__)
SENSITIVE_KEYS = {"password", "password_hash", "token", "access_token", "secret", "secret_key"}
SENSITIVE_KEYS = {"password", "password_hash", "smtp_password", "token", "access_token", "secret", "secret_key"}
def to_audit_data(value: Any, seen: set[int] | None = None) -> Any:
@ -165,6 +165,10 @@ def action_title(action: str) -> str:
"repairs.intake_failed": "Reparatur-Intake fehlgeschlagen",
"repairs.public_link.create": "Reparatur-Statuslink erstellt",
"repairs.public_link.revoke": "Reparatur-Statuslink deaktiviert",
"repairs.public_link.auto_create": "Reparatur-Statuslink automatisch erstellt",
"repairs.public_link.regenerate": "Reparatur-Statuslink erneut erstellt",
"repairs.status_mail.sent": "Statusmail versendet",
"repairs.status_mail.failed": "Statusmail fehlgeschlagen",
}
return labels.get(action, action)

View file

@ -1,26 +1,20 @@
import smtplib
from dataclasses import dataclass
from datetime import UTC, datetime
from email.message import EmailMessage
from html import escape
from sqlalchemy.orm import Session
from starlette.requests import Request
from app.models.repair import Repair
from app.core.config import settings
from app.models.repair import Repair, RepairNotificationEvent
from app.models.user import User
from app.repositories.repair_repository import RepairRepository
from app.schemas.repair import RepairNotificationOverviewResponse, RepairNotificationTemplateResponse, RepairStatus
STATUS_LABELS: dict[str, str] = {
"new": "Neu",
"accepted": "Angenommen",
"diagnosis": "Diagnose",
"estimate": "Kostenvoranschlag",
"waiting_for_customer": "Wartet auf Kunde",
"approved": "Freigegeben",
"repair": "Reparatur",
"final_test": "Endprüfung",
"ready_for_pickup": "Abholbereit",
"shipped": "Versand",
"completed": "Abgeschlossen",
"cancelled": "Storniert",
}
from app.services.audit_service import write_audit_log
from app.services.repair_public_link_service import RepairPublicLinkService
from app.services.repair_status_labels import MAIL_STATUS_LABELS, STATUS_LABELS
@dataclass(frozen=True)
@ -96,13 +90,93 @@ TEMPLATES: tuple[RepairNotificationTemplate, ...] = (
)
def _device_label(repair: Repair) -> str:
return f"{repair.device_manufacturer} {repair.device_model}".strip()
def _repair_label(repair: Repair) -> str:
return f"{repair.repair_number} · {repair.customer_name}"
def _smtp_configured() -> bool:
return bool(settings.smtp_host and settings.smtp_from_email)
def _from_header() -> str:
if settings.smtp_from_name:
return f"{settings.smtp_from_name} <{settings.smtp_from_email}>"
return str(settings.smtp_from_email)
def _plain_status_mail(repair: Repair, *, status_label: str, public_status_url: str) -> str:
return (
f"Hallo {repair.customer_name},\n\n"
"der Status Ihrer Reparatur wurde aktualisiert.\n\n"
f"Reparaturnummer:\n{repair.repair_number}\n\n"
f"Gerät:\n{_device_label(repair)}\n\n"
f"Neuer Status:\n{status_label}\n\n"
f"Status online ansehen:\n{public_status_url}\n\n"
"Hinweis:\nBitte antworten Sie nicht direkt auf diese automatisch erzeugte E-Mail.\n\n"
"Funktechnik Schubert"
)
def _html_status_mail(repair: Repair, *, status_label: str, public_status_url: str) -> str:
return f"""<!doctype html>
<html lang="de">
<body style="margin:0;background:#f4f7fb;font-family:Arial,Helvetica,sans-serif;color:#172033;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f4f7fb;padding:24px 0;">
<tr>
<td align="center">
<table role="presentation" width="640" cellspacing="0" cellpadding="0" style="max-width:640px;width:100%;background:#ffffff;border:1px solid #dce5ef;border-radius:8px;overflow:hidden;">
<tr>
<td style="background:#082a60;color:#ffffff;padding:24px 28px;">
<div style="font-size:13px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;">Olympus CRM</div>
<div style="font-size:24px;font-weight:800;margin-top:6px;">Funktechnik Schubert</div>
</td>
</tr>
<tr>
<td style="padding:28px;">
<p style="margin:0 0 18px;font-size:16px;">Hallo {escape(repair.customer_name)},</p>
<p style="margin:0 0 22px;font-size:16px;line-height:1.55;">der Status Ihrer Reparatur wurde aktualisiert.</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse:collapse;margin:0 0 24px;">
<tr>
<td style="padding:10px 0;color:#657184;font-size:13px;font-weight:700;text-transform:uppercase;">Reparaturnummer</td>
<td style="padding:10px 0;text-align:right;font-size:16px;font-weight:800;">{escape(repair.repair_number)}</td>
</tr>
<tr>
<td style="padding:10px 0;color:#657184;font-size:13px;font-weight:700;text-transform:uppercase;border-top:1px solid #e7edf5;">Gerät</td>
<td style="padding:10px 0;text-align:right;font-size:16px;font-weight:800;border-top:1px solid #e7edf5;">{escape(_device_label(repair))}</td>
</tr>
<tr>
<td style="padding:10px 0;color:#657184;font-size:13px;font-weight:700;text-transform:uppercase;border-top:1px solid #e7edf5;">Neuer Status</td>
<td style="padding:10px 0;text-align:right;font-size:16px;font-weight:800;border-top:1px solid #e7edf5;">{escape(status_label)}</td>
</tr>
</table>
<p style="margin:0 0 24px;">
<a href="{escape(public_status_url)}" style="display:inline-block;background:#082a60;color:#ffffff;text-decoration:none;font-weight:800;border-radius:8px;padding:13px 18px;">Reparaturstatus ansehen</a>
</p>
<p style="margin:0;color:#657184;font-size:14px;line-height:1.5;">Bitte antworten Sie nicht direkt auf diese automatisch erzeugte E-Mail.</p>
</td>
</tr>
<tr>
<td style="padding:18px 28px;background:#f8fafc;color:#657184;font-size:13px;">Funktechnik Schubert</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>"""
class RepairNotificationService:
@staticmethod
def render_templates(repair: Repair, *, public_status_url: str = "") -> list[RepairNotificationTemplateResponse]:
placeholders = {
"repair_number": repair.repair_number,
"customer_name": repair.customer_name,
"device": f"{repair.device_manufacturer} {repair.device_model}".strip(),
"device": _device_label(repair),
"status_label": STATUS_LABELS.get(repair.status, repair.status),
"public_status_url": public_status_url or "wird später bereitgestellt",
"company_name": "Olympus CRM",
@ -124,3 +198,127 @@ class RepairNotificationService:
templates=RepairNotificationService.render_templates(repair, public_status_url=public_status_url),
events=RepairRepository.list_notification_events(db, repair.id),
)
@staticmethod
def send_status_mail(
db: Session,
repair: Repair,
*,
actor: User | None,
request: Request | None,
manual: bool = False,
) -> RepairNotificationEvent:
recipient = repair.customer_email.strip()
subject = f"Reparaturstatus {repair.repair_number} wurde aktualisiert"
status_label = MAIL_STATUS_LABELS.get(repair.status, repair.status)
template = "repair_status_update"
if not recipient:
event = RepairRepository.create_notification_event(
db,
repair_id=repair.id,
event_type="repair_status_mail",
channel="email",
recipient="",
subject=subject,
template=template,
status="skipped",
success=False,
error_message="Keine Kunden-E-Mail hinterlegt",
)
RepairNotificationService._audit_failure(db, repair, actor=actor, request=request, reason="missing_recipient")
return event
link_action = "repairs.public_link.regenerate" if manual else "repairs.public_link.auto_create"
public_link = RepairPublicLinkService.create_with_audit(db, repair, actor=actor, request=request, audit_action=link_action)
public_status_url = public_link.public_status_path
text = _plain_status_mail(repair, status_label=status_label, public_status_url=public_status_url)
html = _html_status_mail(repair, status_label=status_label, public_status_url=public_status_url)
if not _smtp_configured():
event = RepairRepository.create_notification_event(
db,
repair_id=repair.id,
event_type="repair_status_mail",
channel="email",
recipient=recipient,
subject=subject,
template=template,
status="skipped",
success=False,
error_message="SMTP nicht konfiguriert",
)
RepairNotificationService._audit_failure(db, repair, actor=actor, request=request, reason="smtp_not_configured")
return event
try:
RepairNotificationService._send_email(recipient=recipient, subject=subject, text=text, html=html)
except Exception:
event = RepairRepository.create_notification_event(
db,
repair_id=repair.id,
event_type="repair_status_mail",
channel="email",
recipient=recipient,
subject=subject,
template=template,
status="failed",
success=False,
error_message="Statusmail konnte nicht versendet werden",
)
RepairNotificationService._audit_failure(db, repair, actor=actor, request=request, reason="smtp_send_failed")
return event
event = RepairRepository.create_notification_event(
db,
repair_id=repair.id,
event_type="repair_status_mail",
channel="email",
recipient=recipient,
subject=subject,
template=template,
status="sent",
success=True,
sent_at=datetime.now(UTC),
)
write_audit_log(
db,
action="repairs.status_mail.sent",
entity_type="repairs",
entity_id=repair.id,
entity_label=_repair_label(repair),
actor=actor,
request=request,
metadata={"notification_event_id": event.id},
)
return event
@staticmethod
def _send_email(*, recipient: str, subject: str, text: str, html: str) -> None:
message = EmailMessage()
message["Subject"] = subject
message["From"] = _from_header()
message["To"] = recipient
message.set_content(text)
message.add_alternative(html, subtype="html")
with smtplib.SMTP(str(settings.smtp_host), settings.smtp_port, timeout=15) as smtp:
if settings.smtp_use_tls:
smtp.starttls()
if settings.smtp_username and settings.smtp_password:
smtp.login(settings.smtp_username, settings.smtp_password)
smtp.send_message(message)
@staticmethod
def _audit_failure(db: Session, repair: Repair, *, actor: User | None, request: Request | None, reason: str) -> None:
write_audit_log(
db,
action="repairs.status_mail.failed",
entity_type="repairs",
entity_id=repair.id,
entity_label=_repair_label(repair),
actor=actor,
request=request,
metadata={"reason": reason},
)

View file

@ -18,14 +18,17 @@ from app.schemas.repair import (
RepairPublicStatusResponse,
)
from app.services.audit_service import write_audit_log
from app.services.repair_notification_service import STATUS_LABELS
from app.services.repair_service import repair_label
from app.services.repair_status_labels import STATUS_LABELS
def normalize_token(token: str) -> str:
return token.strip()
def repair_label(repair: Repair) -> str:
return f"{repair.repair_number} · {repair.customer_name}"
def public_status_path(token: str) -> str:
normalized_token = normalize_token(token)
path = f"/status/{normalized_token}"
@ -64,6 +67,23 @@ class RepairPublicLinkService:
@staticmethod
def create(db: Session, repair: Repair, *, actor: User, request: Request) -> RepairPublicLinkCreateResponse:
return RepairPublicLinkService.create_with_audit(
db,
repair,
actor=actor,
request=request,
audit_action="repairs.public_link.create",
)
@staticmethod
def create_with_audit(
db: Session,
repair: Repair,
*,
actor: User | None,
request: Request | None,
audit_action: str,
) -> RepairPublicLinkCreateResponse:
token = RepairPublicLinkService.create_token()
public_link = RepairRepository.create_public_link(
db,
@ -73,7 +93,7 @@ class RepairPublicLinkService:
)
write_audit_log(
db,
action="repairs.public_link.create",
action=audit_action,
entity_type="repairs",
entity_id=repair.id,
entity_label=repair_label(repair),

View file

@ -11,6 +11,7 @@ from app.repositories.customer_repository import CustomerRepository
from app.repositories.repair_repository import RepairRepository
from app.schemas.repair import RepairCreate, RepairIntakePayload, RepairStatusUpdate, RepairUpdate
from app.services.audit_service import sanitize, write_audit_log
from app.services.repair_notification_service import RepairNotificationService
def repair_label(repair: Repair) -> str:
@ -74,6 +75,19 @@ class RepairService:
after_data=updated,
metadata={"old_status": before_data.get("status") if isinstance(before_data, dict) else None, "new_status": updated.status},
)
try:
RepairNotificationService.send_status_mail(db, updated, actor=actor, request=request)
except Exception:
write_audit_log(
db,
action="repairs.status_mail.failed",
entity_type="repairs",
entity_id=updated.id,
entity_label=repair_label(updated),
actor=actor,
request=request,
metadata={"reason": "unexpected_notification_error"},
)
return updated
@staticmethod

View file

@ -0,0 +1,29 @@
STATUS_LABELS: dict[str, str] = {
"new": "Neu",
"accepted": "Angenommen",
"diagnosis": "Diagnose",
"estimate": "Kostenvoranschlag",
"waiting_for_customer": "Wartet auf Kunde",
"approved": "Freigegeben",
"repair": "Reparatur",
"final_test": "Endprüfung",
"ready_for_pickup": "Abholbereit",
"shipped": "Versand",
"completed": "Abgeschlossen",
"cancelled": "Storniert",
}
MAIL_STATUS_LABELS: dict[str, str] = {
"new": "Neu eingegangen",
"accepted": "Reparatur angenommen",
"diagnosis": "Gerät in Diagnose",
"estimate": "Kostenvoranschlag erstellt",
"waiting_for_customer": "Wartet auf Ihre Rückmeldung",
"approved": "Reparatur freigegeben",
"repair": "Reparatur wird durchgeführt",
"final_test": "Gerät wird geprüft",
"ready_for_pickup": "Gerät ist abholbereit",
"shipped": "Gerät wurde versendet",
"completed": "Reparatur abgeschlossen",
"cancelled": "Reparatur wurde storniert",
}