mirror of
https://github.com/complexcaresolutions/dak.c2s.git
synced 2026-03-17 21:53:41 +00:00
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>
225 lines
7.1 KiB
Python
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.",
|
|
)
|