mirror of
https://github.com/complexcaresolutions/dak.c2s.git
synced 2026-03-17 14:53:41 +00:00
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>
This commit is contained in:
parent
83bc1f5865
commit
3afe03b0a9
8 changed files with 340 additions and 5 deletions
|
|
@ -1,5 +1,6 @@
|
|||
"""Admin-only API endpoints: user CRUD, invitation management, audit log."""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
|
@ -17,13 +18,77 @@ from app.schemas.disclosure import (
|
|||
DisclosureCountResponse, DisclosureRequestResponse, DisclosureRequestUpdate,
|
||||
)
|
||||
from app.schemas.user import UserCreate, UserResponse, UserUpdate
|
||||
from app.config import get_settings
|
||||
from app.services.audit_service import log_action
|
||||
from app.services.disclosure_service import (
|
||||
admin_delete_disclosure, get_pending_count, reactivate_disclosure,
|
||||
review_disclosure_request, revoke_disclosure,
|
||||
)
|
||||
from app.services.notification_service import send_email
|
||||
from app.services.pdf_guide import generate_guide_pdf
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_settings = get_settings()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Invitation email helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _send_invitation_email(
|
||||
email: str, token: str, expires_at: datetime
|
||||
) -> None:
|
||||
"""Send an invitation email with a PDF onboarding guide attached."""
|
||||
try:
|
||||
invite_url = f"{_settings.FRONTEND_BASE_URL}/register?token={token}"
|
||||
pdf_bytes = generate_guide_pdf(invite_url)
|
||||
|
||||
expires_str = expires_at.strftime("%d.%m.%Y")
|
||||
|
||||
plain_body = (
|
||||
"Guten Tag,\n\n"
|
||||
"Sie wurden zum DAK Zweitmeinungs-Portal eingeladen.\n\n"
|
||||
"Bitte registrieren Sie sich unter folgendem Link:\n"
|
||||
f"{invite_url}\n\n"
|
||||
f"Der Link ist gueltig bis zum {expires_str}.\n\n"
|
||||
"Im Anhang finden Sie eine Anleitung (PDF) mit allen "
|
||||
"wichtigen Informationen zur Registrierung und Nutzung "
|
||||
"des Portals.\n\n"
|
||||
"Mit freundlichen Gruessen\n"
|
||||
"Complex Care Solutions GmbH"
|
||||
)
|
||||
|
||||
html_body = (
|
||||
"<div style='font-family: Arial, sans-serif; max-width: 600px;'>"
|
||||
"<h2 style='color: #005293;'>DAK Zweitmeinungs-Portal</h2>"
|
||||
"<p>Guten Tag,</p>"
|
||||
"<p>Sie wurden zum <strong>DAK Zweitmeinungs-Portal</strong> eingeladen.</p>"
|
||||
"<p>Bitte registrieren Sie sich unter folgendem Link:</p>"
|
||||
f"<p><a href='{invite_url}' style='color: #005293; "
|
||||
f"font-weight: bold;'>{invite_url}</a></p>"
|
||||
f"<p>Der Link ist gültig bis zum <strong>{expires_str}</strong>.</p>"
|
||||
"<p>Im Anhang finden Sie eine <strong>Anleitung (PDF)</strong> mit allen "
|
||||
"wichtigen Informationen zur Registrierung und Nutzung des Portals.</p>"
|
||||
"<hr style='border: none; border-top: 1px solid #dee2e6; margin: 20px 0;'>"
|
||||
"<p style='color: #6c757d; font-size: 12px;'>"
|
||||
"Complex Care Solutions GmbH<br>"
|
||||
"https://dak.complexcaresolutions.de</p>"
|
||||
"</div>"
|
||||
)
|
||||
|
||||
send_email(
|
||||
to=email,
|
||||
subject="[DAK Portal] Ihre Einladung zum Zweitmeinungs-Portal",
|
||||
body=plain_body,
|
||||
html_body=html_body,
|
||||
attachments=[
|
||||
("DAK-Portal-Anleitung.pdf", pdf_bytes, "application/pdf")
|
||||
],
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to send invitation email to %s", email)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -250,6 +315,10 @@ def create_invitation(
|
|||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
|
||||
# Send invitation email with PDF guide (fire-and-forget)
|
||||
if payload.email:
|
||||
_send_invitation_email(payload.email, token, expires_at)
|
||||
|
||||
return invitation
|
||||
|
||||
|
||||
|
|
|
|||
BIN
backend/app/assets/LiberationSans-Bold.ttf
Normal file
BIN
backend/app/assets/LiberationSans-Bold.ttf
Normal file
Binary file not shown.
BIN
backend/app/assets/LiberationSans-Italic.ttf
Normal file
BIN
backend/app/assets/LiberationSans-Italic.ttf
Normal file
Binary file not shown.
BIN
backend/app/assets/LiberationSans-Regular.ttf
Normal file
BIN
backend/app/assets/LiberationSans-Regular.ttf
Normal file
Binary file not shown.
BIN
backend/app/assets/dak_logo.png
Normal file
BIN
backend/app/assets/dak_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
|
|
@ -5,8 +5,10 @@ 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
|
||||
|
|
@ -28,21 +30,45 @@ def send_email(
|
|||
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:
|
||||
msg = MIMEMultipart("alternative")
|
||||
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
|
||||
|
||||
msg.attach(MIMEText(body, "plain"))
|
||||
if html_body:
|
||||
msg.attach(MIMEText(html_body, "html"))
|
||||
|
||||
with smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT) as server:
|
||||
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||
server.send_message(msg)
|
||||
|
|
|
|||
239
backend/app/services/pdf_guide.py
Normal file
239
backend/app/services/pdf_guide.py
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
"""PDF guide generator for DAK onboarding invitations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fpdf import FPDF
|
||||
|
||||
ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets"
|
||||
LOGO_PATH = ASSETS_DIR / "dak_logo.png"
|
||||
FONT_DIR = ASSETS_DIR
|
||||
|
||||
# Font family name used throughout the PDF
|
||||
_FONT = "LiberationSans"
|
||||
|
||||
# Colours (DAK blue tones)
|
||||
DAK_BLUE = (0, 82, 147)
|
||||
DAK_DARK = (33, 37, 41)
|
||||
DAK_GREY = (108, 117, 125)
|
||||
DAK_LIGHT_BG = (240, 244, 248)
|
||||
|
||||
|
||||
class _GuidePDF(FPDF):
|
||||
"""Custom FPDF subclass with shared header/footer styling."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
# Register Liberation Sans (Unicode TTF) in Regular, Bold, Italic
|
||||
self.add_font(_FONT, "", str(FONT_DIR / "LiberationSans-Regular.ttf"))
|
||||
self.add_font(_FONT, "B", str(FONT_DIR / "LiberationSans-Bold.ttf"))
|
||||
self.add_font(_FONT, "I", str(FONT_DIR / "LiberationSans-Italic.ttf"))
|
||||
|
||||
def header(self):
|
||||
if self.page_no() == 1:
|
||||
return # Page 1 has its own header with logo
|
||||
self.set_font(_FONT, "I", 8)
|
||||
self.set_text_color(*DAK_GREY)
|
||||
self.cell(0, 8, "DAK Zweitmeinungs-Portal \u2014 Anleitung", align="R")
|
||||
self.ln(12)
|
||||
|
||||
def footer(self):
|
||||
self.set_y(-15)
|
||||
self.set_font(_FONT, "I", 8)
|
||||
self.set_text_color(*DAK_GREY)
|
||||
self.cell(0, 10, f"Seite {self.page_no()}/{{nb}}", align="C")
|
||||
|
||||
# -- helpers ---------------------------------------------------------------
|
||||
|
||||
def section_title(self, text: str) -> None:
|
||||
self.set_font(_FONT, "B", 14)
|
||||
self.set_text_color(*DAK_BLUE)
|
||||
self.cell(0, 10, text)
|
||||
self.ln(8)
|
||||
# blue underline
|
||||
self.set_draw_color(*DAK_BLUE)
|
||||
self.set_line_width(0.5)
|
||||
self.line(self.l_margin, self.get_y(), self.w - self.r_margin, self.get_y())
|
||||
self.ln(6)
|
||||
|
||||
def body_text(self, text: str) -> None:
|
||||
self.set_font(_FONT, "", 10)
|
||||
self.set_text_color(*DAK_DARK)
|
||||
self.multi_cell(0, 6, text)
|
||||
self.ln(2)
|
||||
|
||||
def numbered_step(self, number: int, text: str) -> None:
|
||||
self.set_font(_FONT, "B", 10)
|
||||
self.set_text_color(*DAK_BLUE)
|
||||
self.cell(8, 6, f"{number}.")
|
||||
self.set_font(_FONT, "", 10)
|
||||
self.set_text_color(*DAK_DARK)
|
||||
self.multi_cell(0, 6, text)
|
||||
self.ln(1)
|
||||
|
||||
def bullet_item(self, title: str, description: str) -> None:
|
||||
self.set_font(_FONT, "B", 10)
|
||||
self.set_text_color(*DAK_DARK)
|
||||
self.cell(4, 6, "\u2022 ")
|
||||
self.cell(0, 6, title)
|
||||
self.ln(6)
|
||||
if description:
|
||||
self.set_font(_FONT, "", 9)
|
||||
self.set_text_color(*DAK_GREY)
|
||||
self.set_x(self.l_margin + 6)
|
||||
self.multi_cell(0, 5, description)
|
||||
self.ln(2)
|
||||
|
||||
def info_box(self, text: str) -> None:
|
||||
self.set_fill_color(*DAK_LIGHT_BG)
|
||||
self.set_draw_color(*DAK_BLUE)
|
||||
x = self.l_margin
|
||||
y = self.get_y()
|
||||
w = self.w - self.l_margin - self.r_margin
|
||||
self.set_font(_FONT, "", 9)
|
||||
self.set_text_color(*DAK_DARK)
|
||||
# calculate height
|
||||
self.set_xy(x + 4, y + 4)
|
||||
self.multi_cell(w - 8, 5, text)
|
||||
h = self.get_y() - y + 4
|
||||
# draw box behind
|
||||
self.rect(x, y, w, h, style="DF")
|
||||
# re-render text on top
|
||||
self.set_xy(x + 4, y + 4)
|
||||
self.multi_cell(w - 8, 5, text)
|
||||
self.ln(4)
|
||||
|
||||
|
||||
def generate_guide_pdf(invite_url: str) -> bytes:
|
||||
"""Generate the DAK onboarding guide as PDF bytes.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
invite_url:
|
||||
The personalised invitation/registration URL to embed in the document.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bytes
|
||||
The raw PDF content (no disk I/O).
|
||||
"""
|
||||
pdf = _GuidePDF(orientation="P", unit="mm", format="A4")
|
||||
pdf.alias_nb_pages()
|
||||
pdf.set_auto_page_break(auto=True, margin=20)
|
||||
pdf.set_left_margin(20)
|
||||
pdf.set_right_margin(20)
|
||||
|
||||
# ── Page 1: Welcome & Registration ────────────────────────────────────
|
||||
pdf.add_page()
|
||||
|
||||
# Logo
|
||||
if LOGO_PATH.exists():
|
||||
pdf.image(str(LOGO_PATH), x=75, y=15, w=60)
|
||||
pdf.ln(50)
|
||||
else:
|
||||
pdf.ln(15)
|
||||
|
||||
# Title
|
||||
pdf.set_font(_FONT, "B", 20)
|
||||
pdf.set_text_color(*DAK_BLUE)
|
||||
pdf.cell(0, 12, "Willkommen im", align="C")
|
||||
pdf.ln(12)
|
||||
pdf.cell(0, 12, "DAK Zweitmeinungs-Portal", align="C")
|
||||
pdf.ln(20)
|
||||
|
||||
# Registration section
|
||||
pdf.section_title("Konto erstellen")
|
||||
pdf.body_text(
|
||||
"Um Ihr Konto zu erstellen, folgen Sie bitte diesen Schritten:"
|
||||
)
|
||||
|
||||
pdf.numbered_step(1, "Registrierungslink im Browser öffnen (siehe unten)")
|
||||
pdf.numbered_step(2, "Benutzername festlegen (mindestens 3 Zeichen)")
|
||||
pdf.numbered_step(3, "E-Mail-Adresse eingeben")
|
||||
pdf.numbered_step(4, "Passwort setzen (mindestens 8 Zeichen)")
|
||||
pdf.numbered_step(5, "\u201eRegistrieren\u201c anklicken")
|
||||
pdf.ln(2)
|
||||
|
||||
# Invite URL box
|
||||
pdf.set_font(_FONT, "B", 10)
|
||||
pdf.set_text_color(*DAK_BLUE)
|
||||
pdf.cell(0, 6, "Ihr Registrierungslink:")
|
||||
pdf.ln(7)
|
||||
pdf.info_box(invite_url)
|
||||
|
||||
# First login
|
||||
pdf.section_title("Erste Anmeldung")
|
||||
pdf.body_text(
|
||||
"Melden Sie sich mit Ihrem Benutzernamen (oder E-Mail-Adresse) "
|
||||
"und dem gewählten Passwort an."
|
||||
)
|
||||
pdf.body_text(
|
||||
"Empfehlung: Aktivieren Sie die Zwei-Faktor-Authentifizierung "
|
||||
"unter Kontoverwaltung für zusätzliche Sicherheit."
|
||||
)
|
||||
|
||||
# ── Page 2: Feature overview ──────────────────────────────────────────
|
||||
pdf.add_page()
|
||||
pdf.section_title("Funktionsübersicht")
|
||||
|
||||
pdf.body_text(
|
||||
"Das DAK Zweitmeinungs-Portal bietet Ihnen folgende Funktionen:"
|
||||
)
|
||||
pdf.ln(2)
|
||||
|
||||
features = [
|
||||
("Dashboard", "Übersicht über KPIs, Diagramme und Jahresvergleich."),
|
||||
("Fälle", "Fallübersicht mit Such- und Filterfunktionen."),
|
||||
("ICD-Eingabe", "ICD-Codes für Fälle erfassen und bearbeiten."),
|
||||
("Berichte", "Wochenberichte als Excel-Datei herunterladen."),
|
||||
(
|
||||
"Wochenübersicht",
|
||||
"Datenübersicht pro Kalenderwoche und ICD-Upload per Excel.",
|
||||
),
|
||||
(
|
||||
"Freigaben",
|
||||
"Personenbezogene Daten bei Bedarf anfordern (24 Stunden Gültigkeit).",
|
||||
),
|
||||
(
|
||||
"Kontoverwaltung",
|
||||
"Profil bearbeiten, Passwort ändern, Zwei-Faktor-Authentifizierung.",
|
||||
),
|
||||
]
|
||||
for title, desc in features:
|
||||
pdf.bullet_item(title, desc)
|
||||
|
||||
# ── Page 3: Closing notes ─────────────────────────────────────────────
|
||||
pdf.add_page()
|
||||
pdf.section_title("Weitere Informationen")
|
||||
|
||||
pdf.body_text(
|
||||
"Ausführliche Anleitungen zu allen Funktionen finden Sie direkt "
|
||||
"im Portal unter dem Menüpunkt \u201eAnleitung\u201c."
|
||||
)
|
||||
pdf.ln(4)
|
||||
|
||||
pdf.body_text(
|
||||
"Bei Fragen oder Problemen wenden Sie sich bitte an Ihren "
|
||||
"Ansprechpartner bei Complex Care Solutions:"
|
||||
)
|
||||
pdf.ln(2)
|
||||
|
||||
pdf.set_font(_FONT, "B", 10)
|
||||
pdf.set_text_color(*DAK_DARK)
|
||||
pdf.cell(0, 6, "Complex Care Solutions GmbH")
|
||||
pdf.ln(6)
|
||||
pdf.set_font(_FONT, "", 10)
|
||||
pdf.cell(0, 6, "E-Mail: info@complexcaresolutions.de")
|
||||
pdf.ln(6)
|
||||
pdf.cell(0, 6, "Portal: https://dak.complexcaresolutions.de")
|
||||
pdf.ln(16)
|
||||
|
||||
# Final info box
|
||||
pdf.info_box(
|
||||
"Dieses Dokument wurde automatisch generiert und enthält "
|
||||
"Ihren persönlichen Registrierungslink. "
|
||||
"Bitte geben Sie diesen Link nicht an Dritte weiter."
|
||||
)
|
||||
|
||||
return pdf.output()
|
||||
|
|
@ -16,6 +16,7 @@ pydantic==2.10.4
|
|||
pydantic-settings==2.7.1
|
||||
python-dotenv==1.0.1
|
||||
email-validator==2.2.0
|
||||
fpdf2==2.8.3
|
||||
httpx==0.28.1
|
||||
|
||||
# Dev/Test
|
||||
|
|
|
|||
Loading…
Reference in a new issue