dak.c2s/backend/app/services/notification_service.py
CCS Admin 3afe03b0a9 feat: auto-send invitation email with PDF onboarding guide
When an admin creates an invitation with an email address, an email is
now automatically sent containing the registration link and an attached
PDF guide (3 pages: registration steps, feature overview, contact info).

- Add fpdf2 for PDF generation with Unicode font support
- Add PDF guide generator (backend/app/services/pdf_guide.py)
- Extend send_email() to support file attachments
- Fire-and-forget email in create_invitation endpoint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:05:19 +00:00

225 lines
7.1 KiB
Python

"""Notification service -- in-app notifications and SMTP email delivery."""
from __future__ import annotations
import logging
import smtplib
from datetime import datetime, timezone
from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email import encoders
from typing import Optional
from sqlalchemy.orm import Session
from app.config import get_settings
from app.models.audit import Notification
from app.models.user import User
logger = logging.getLogger(__name__)
settings = get_settings()
# ---------------------------------------------------------------------------
# Low-level email helper
# ---------------------------------------------------------------------------
def send_email(
to: str,
subject: str,
body: str,
html_body: str | None = None,
attachments: list[tuple[str, bytes, str]] | None = None,
) -> bool:
"""Send an email via SMTP SSL.
Parameters
----------
attachments:
Optional list of ``(filename, data, mime_type)`` tuples.
Returns *True* on success, *False* on failure (the error is logged).
"""
try:
if attachments:
msg = MIMEMultipart("mixed")
text_part = MIMEMultipart("alternative")
text_part.attach(MIMEText(body, "plain"))
if html_body:
text_part.attach(MIMEText(html_body, "html"))
msg.attach(text_part)
for filename, data, mime_type in attachments:
maintype, subtype = mime_type.split("/", 1)
part = MIMEBase(maintype, subtype)
part.set_payload(data)
encoders.encode_base64(part)
part.add_header(
"Content-Disposition", "attachment", filename=filename
)
msg.attach(part)
else:
msg = MIMEMultipart("alternative")
msg.attach(MIMEText(body, "plain"))
if html_body:
msg.attach(MIMEText(html_body, "html"))
msg["From"] = settings.SMTP_FROM
msg["To"] = to
msg["Subject"] = subject
with smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT) as server:
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
server.send_message(msg)
logger.info("Email sent to %s: %s", to, subject)
return True
except Exception:
logger.exception("Failed to send email to %s", to)
return False
# ---------------------------------------------------------------------------
# Core notification creation
# ---------------------------------------------------------------------------
def create_notification(
db: Session,
recipient_id: int,
notification_type: str,
title: str,
message: Optional[str] = None,
related_entity_type: Optional[str] = None,
related_entity_id: Optional[int] = None,
send_email_notification: bool = True,
) -> Notification:
"""Create an in-app notification and optionally send an email."""
notif = Notification(
recipient_id=recipient_id,
notification_type=notification_type,
title=title,
message=message,
related_entity_type=related_entity_type,
related_entity_id=related_entity_id,
)
db.add(notif)
if send_email_notification:
user = db.query(User).filter(User.id == recipient_id).first()
if user and user.email:
success = send_email(
to=user.email,
subject=f"[DAK Portal] {title}",
body=message or title,
)
if success:
notif.email_sent = True
notif.email_sent_at = datetime.now(timezone.utc)
db.commit()
db.refresh(notif)
return notif
# ---------------------------------------------------------------------------
# Bulk / role-based helpers
# ---------------------------------------------------------------------------
def notify_all_users_with_role(
db: Session,
role: str,
notification_type: str,
title: str,
message: Optional[str] = None,
related_entity_type: Optional[str] = None,
related_entity_id: Optional[int] = None,
) -> list[Notification]:
"""Send a notification to every active user that has *role*."""
users = (
db.query(User)
.filter(User.role == role, User.is_active == True) # noqa: E712
.all()
)
notifications: list[Notification] = []
for user in users:
notif = create_notification(
db=db,
recipient_id=user.id,
notification_type=notification_type,
title=title,
message=message,
related_entity_type=related_entity_type,
related_entity_id=related_entity_id,
)
notifications.append(notif)
return notifications
# ---------------------------------------------------------------------------
# Convenience functions for specific business events
# ---------------------------------------------------------------------------
def notify_new_cases_uploaded(db: Session, count: int, filename: str) -> None:
"""Notify DAK users when the admin uploads new cases."""
notify_all_users_with_role(
db,
"dak_mitarbeiter",
"new_cases_uploaded",
title=f"{count} neue Fälle importiert",
message=(
f"Aus {filename} wurden {count} neue Fälle importiert. "
"Bitte ICD-Codes ergänzen."
),
)
def notify_icd_entered(db: Session, case_id: int, case_name: str) -> None:
"""Notify admins when a DAK user enters an ICD code for a case."""
notify_all_users_with_role(
db,
"admin",
"icd_entered",
title=f"ICD eingetragen: {case_name}",
message=f"Für Fall {case_name} wurde der ICD-Code eingetragen.",
related_entity_type="case",
related_entity_id=case_id,
)
def notify_icd_uploaded(db: Session, count: int) -> None:
"""Notify admins when DAK uploads an ICD Excel file."""
notify_all_users_with_role(
db,
"admin",
"icd_uploaded",
title=f"ICD-Codes hochgeladen ({count} Fälle)",
message=f"Es wurden ICD-Codes für {count} Fälle per Excel hochgeladen.",
)
def notify_report_ready(db: Session, report_id: int, jahr: int, kw: int) -> None:
"""Notify all users when a weekly report is generated."""
for role in ("admin", "dak_mitarbeiter"):
notify_all_users_with_role(
db,
role,
"report_ready",
title=f"Bericht KW {kw}/{jahr} erstellt",
message=(
f"Der Wochenbericht für KW {kw}/{jahr} steht zum Download bereit."
),
related_entity_type="report",
related_entity_id=report_id,
)
def notify_coding_completed(db: Session, count: int) -> None:
"""Notify DAK users when coding is completed for a batch of cases."""
notify_all_users_with_role(
db,
"dak_mitarbeiter",
"coding_completed",
title=f"Coding abgeschlossen ({count} Fälle)",
message=f"Für {count} Fälle wurde das Gutachten-Coding abgeschlossen.",
)