324 lines
14 KiB
Python
324 lines
14 KiB
Python
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.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
|
|
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)
|
|
class RepairNotificationTemplate:
|
|
event_type: str
|
|
status: RepairStatus | None
|
|
subject: str
|
|
text: str
|
|
html: str | None = None
|
|
|
|
|
|
TEMPLATES: tuple[RepairNotificationTemplate, ...] = (
|
|
RepairNotificationTemplate(
|
|
event_type="repair_created",
|
|
status="new",
|
|
subject="Reparatur {repair_number} wurde erfasst",
|
|
text="Guten Tag {customer_name},\n\nIhre Reparatur {repair_number} für {device} wurde erfasst. Den aktuellen Status finden Sie später unter {public_status_url}.\n\n{company_name}",
|
|
),
|
|
RepairNotificationTemplate(
|
|
event_type="device_accepted",
|
|
status="accepted",
|
|
subject="Gerät zu Reparatur {repair_number} angenommen",
|
|
text="Guten Tag {customer_name},\n\nIhr Gerät {device} wurde angenommen. Aktueller Status: {status_label}.\n\n{company_name}",
|
|
),
|
|
RepairNotificationTemplate(
|
|
event_type="diagnosis_started",
|
|
status="diagnosis",
|
|
subject="Diagnose für Reparatur {repair_number} läuft",
|
|
text="Guten Tag {customer_name},\n\nwir prüfen Ihr Gerät {device}. Aktueller Status: {status_label}.\n\n{company_name}",
|
|
),
|
|
RepairNotificationTemplate(
|
|
event_type="estimate_created",
|
|
status="estimate",
|
|
subject="Kostenvoranschlag zu Reparatur {repair_number}",
|
|
text="Guten Tag {customer_name},\n\nfür Ihr Gerät {device} wurde ein Kostenvoranschlag vorbereitet. Aktueller Status: {status_label}.\n\n{company_name}",
|
|
),
|
|
RepairNotificationTemplate(
|
|
event_type="waiting_for_customer",
|
|
status="waiting_for_customer",
|
|
subject="Freigabe für Reparatur {repair_number} erforderlich",
|
|
text="Guten Tag {customer_name},\n\nfür Ihre Reparatur {repair_number} warten wir auf Ihre Rückmeldung. Aktueller Status: {status_label}.\n\n{company_name}",
|
|
),
|
|
RepairNotificationTemplate(
|
|
event_type="repair_started",
|
|
status="repair",
|
|
subject="Reparatur {repair_number} läuft",
|
|
text="Guten Tag {customer_name},\n\nwir bearbeiten Ihr Gerät {device}. Aktueller Status: {status_label}.\n\n{company_name}",
|
|
),
|
|
RepairNotificationTemplate(
|
|
event_type="final_test",
|
|
status="final_test",
|
|
subject="Endprüfung für Reparatur {repair_number}",
|
|
text="Guten Tag {customer_name},\n\nIhr Gerät {device} befindet sich in der Endprüfung. Aktueller Status: {status_label}.\n\n{company_name}",
|
|
),
|
|
RepairNotificationTemplate(
|
|
event_type="ready_for_pickup",
|
|
status="ready_for_pickup",
|
|
subject="Reparatur {repair_number} ist abholbereit",
|
|
text="Guten Tag {customer_name},\n\nIhr Gerät {device} ist abholbereit. Aktueller Status: {status_label}.\n\n{company_name}",
|
|
),
|
|
RepairNotificationTemplate(
|
|
event_type="shipped",
|
|
status="shipped",
|
|
subject="Reparatur {repair_number} wurde versendet",
|
|
text="Guten Tag {customer_name},\n\nIhr Gerät {device} wurde versendet. Aktueller Status: {status_label}.\n\n{company_name}",
|
|
),
|
|
RepairNotificationTemplate(
|
|
event_type="completed",
|
|
status="completed",
|
|
subject="Reparatur {repair_number} abgeschlossen",
|
|
text="Guten Tag {customer_name},\n\nIhre Reparatur {repair_number} wurde abgeschlossen. Vielen Dank.\n\n{company_name}",
|
|
),
|
|
)
|
|
|
|
|
|
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": _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",
|
|
}
|
|
return [
|
|
RepairNotificationTemplateResponse(
|
|
event_type=template.event_type,
|
|
status=template.status,
|
|
subject=template.subject.format(**placeholders),
|
|
text=template.text.format(**placeholders),
|
|
html=template.html.format(**placeholders) if template.html else None,
|
|
)
|
|
for template in TEMPLATES
|
|
]
|
|
|
|
@staticmethod
|
|
def overview(db: Session, repair: Repair, *, public_status_url: str = "") -> RepairNotificationOverviewResponse:
|
|
return RepairNotificationOverviewResponse(
|
|
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},
|
|
)
|