From 654276f6b570ab48a40e852829b7237b0b9d2a44 Mon Sep 17 00:00:00 2001 From: smueller Date: Tue, 7 Jul 2026 14:20:21 +0200 Subject: [PATCH] Enhance newsletter generation and dashboard functionality - Introduce a new period selection feature in the dashboard for generating newsletters, allowing users to choose between "last month" and "last X days." - Refactor newsletter generation logic to support filtering by article type (new, edited, or all) and improve the handling of filters in the dashboard. - Update the dashboard template to include new input fields for period and article type, enhancing user experience. - Improve CSS styles for send notifications and sections in the dashboard, providing clearer feedback on newsletter sending status. --- app/main.py | 199 ++++++++++++++++++++++++++--------- app/services/newsletter.py | 130 ++++++++++++++++------- app/services/smtp_sender.py | 20 ++-- app/services/wiki.py | 21 ++-- app/static/css/main.css | 36 +++++++ app/templates/dashboard.html | 77 +++++++++----- preview_send.html | 129 +++++++++++++++++++++++ 7 files changed, 487 insertions(+), 125 deletions(-) create mode 100644 preview_send.html diff --git a/app/main.py b/app/main.py index 92d0456..c33356b 100644 --- a/app/main.py +++ b/app/main.py @@ -30,6 +30,7 @@ from app.services.newsletter import ( filter_articles, parse_display_options, parse_highlights, + resolve_period, split_articles_by_type, ) from app.services.smtp_sender import get_smtp_settings, run_scheduled_send_if_due, send_newsletter @@ -72,7 +73,14 @@ async def add_security_headers(request: Request, call_next): response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "DENY" response.headers["Referrer-Policy"] = "same-origin" - response.headers["Content-Security-Policy"] = "default-src 'self'; style-src 'self'; script-src 'self';" + # Die Newsletter-Vorschau läuft in einem srcdoc-iframe und erbt diese CSP. + # E-Mail-HTML benötigt zwingend Inline-Styles; das Logo liegt auf einem externen https-Host. + response.headers["Content-Security-Policy"] = ( + "default-src 'self'; " + "img-src 'self' https: data:; " + "style-src 'self' 'unsafe-inline'; " + "script-src 'self';" + ) if not cookie_token: response.set_cookie( CSRF_COOKIE_NAME, @@ -199,6 +207,63 @@ def logout(request: Request, csrf_token: str = Form(...)): return response +def _empty_filters() -> dict: + return { + "days": 30, + "period": "days", + "article_type": "all", + "category": "", + "display": DEFAULT_DISPLAY, + "editor_tip": "", + "highlights": "", + } + + +async def _generate_newsletter( + *, + period_mode: str, + days: int, + article_type: str, + category: str, + display, + editor_tip: str, + highlight_list: list[str], +) -> dict: + p = resolve_period(period_mode, days) + result = { + "period": p, + "wiki_error": None, + "articles": [], + "articles_new": [], + "articles_edited": [], + "plain_text": "", + "raw_html": "", + "outlook_html": "", + "subject": "", + } + try: + articles = await wiki_service.get_recent_changes( + days=p["days"], article_type=article_type, start=p["start"], end=p["end"] + ) + filtered = filter_articles(articles, category_filter=category or None) + new_articles, edited_articles = split_articles_by_type(filtered) + prange = (p["start_str"], p["end_str"]) + result.update( + { + "articles": filtered, + "articles_new": new_articles, + "articles_edited": edited_articles, + "subject": create_subject(p["days"], category, p["label"]), + "plain_text": create_plain_text(filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type), + "raw_html": create_html(filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type), + "outlook_html": create_outlook_html(filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type), + } + ) + except WikiFetchError as exc: + result["wiki_error"] = exc.message + return result + + @app.get("/dashboard", response_class=HTMLResponse) async def dashboard(request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): context = _base_context(request, current_user, db) @@ -209,15 +274,10 @@ async def dashboard(request: Request, db: Session = Depends(get_db), current_use "raw_html": "", "outlook_html": "", "plain_text": "", - "filters": { - "days": 30, - "only_edited": False, - "category": "", - "display": DEFAULT_DISPLAY, - "editor_tip": "", - "highlights": "", - }, + "subject": "", + "filters": _empty_filters(), "wiki_error": None, + "send_result": None, }) logs = db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all() context["send_logs"] = logs @@ -228,8 +288,9 @@ async def dashboard(request: Request, db: Session = Depends(get_db), current_use async def dashboard_generate( request: Request, csrf_token: str = Form(...), + period: str = Form("days"), days: int = Form(30), - only_edited: str | None = Form(None), + article_type: str = Form("all"), category: str = Form(""), show_date: str | None = Form(None), show_user: str | None = Form(None), @@ -241,57 +302,57 @@ async def dashboard_generate( ): validate_csrf(request, csrf_token) days = max(1, min(days, 90)) - only_edited_flag = only_edited == "true" + if article_type not in ("all", "new", "edited"): + article_type = "all" display = parse_display_options(show_date, show_user, show_category) highlight_list = parse_highlights(highlights) - wiki_error = None - filtered: list = [] - articles_new: list = [] - articles_edited: list = [] - plain_text = "" - raw_html = "" - outlook_html = "" - try: - articles = await wiki_service.get_recent_changes(days=days, only_edited=only_edited_flag) - filtered = filter_articles(articles, category_filter=category or None) - articles_new, articles_edited = split_articles_by_type(filtered) - title = create_subject(days, category) - plain_text = create_plain_text(filtered, days, display, category, editor_tip, highlight_list) - raw_html = create_html(filtered, days, display, category, editor_tip, highlight_list) - outlook_html = create_outlook_html(filtered, days, display, category, editor_tip, highlight_list) - run_scheduled_send_if_due(db, title, raw_html, plain_text) - except WikiFetchError as exc: - wiki_error = exc.message + + gen = await _generate_newsletter( + period_mode=period, + days=days, + article_type=article_type, + category=category, + display=display, + editor_tip=editor_tip, + highlight_list=highlight_list, + ) + if not gen["wiki_error"]: + run_scheduled_send_if_due(db, gen["subject"], gen["raw_html"], gen["plain_text"]) + context = _base_context(request, current_user, db) context.update( { - "articles": filtered, - "articles_new": articles_new, - "articles_edited": articles_edited, - "raw_html": raw_html, - "outlook_html": outlook_html, - "plain_text": plain_text, + "articles": gen["articles"], + "articles_new": gen["articles_new"], + "articles_edited": gen["articles_edited"], + "raw_html": gen["raw_html"], + "outlook_html": gen["outlook_html"], + "plain_text": gen["plain_text"], + "subject": gen["subject"], "filters": { "days": days, - "only_edited": only_edited_flag, + "period": period, + "article_type": article_type, "category": category, "display": display, "editor_tip": editor_tip, "highlights": highlights, }, "send_logs": db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all(), - "wiki_error": wiki_error, + "wiki_error": gen["wiki_error"], + "send_result": None, } ) return _render(request, "dashboard.html", context) -@app.post("/newsletter/send-now") +@app.post("/newsletter/send-now", response_class=HTMLResponse) async def send_now( request: Request, csrf_token: str = Form(...), + period: str = Form("days"), days: int = Form(30), - only_edited: str | None = Form(None), + article_type: str = Form("all"), category: str = Form(""), show_date: str | None = Form(None), show_user: str | None = Form(None), @@ -303,20 +364,56 @@ async def send_now( ): validate_csrf(request, csrf_token) display = parse_display_options(show_date, show_user, show_category) - only_edited_flag = only_edited == "true" + if article_type not in ("all", "new", "edited"): + article_type = "all" highlight_list = parse_highlights(highlights) days = max(1, min(days, 90)) - try: - articles = await wiki_service.get_recent_changes(days=days, only_edited=only_edited_flag) - except WikiFetchError as exc: - raise HTTPException(status_code=502, detail=exc.message) from exc - filtered = filter_articles(articles, category_filter=category or None) - title = create_subject(days, category) - plain_text = create_plain_text(filtered, days, display, category, editor_tip, highlight_list) - raw_html = create_html(filtered, days, display, category, editor_tip, highlight_list) - recipients = [r.strip() for r in get_config(db, "smtp.recipients", "").split(",") if r.strip()] - send_newsletter(db, title, raw_html, plain_text, recipients, scheduled=False) - return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND) + + gen = await _generate_newsletter( + period_mode=period, + days=days, + article_type=article_type, + category=category, + display=display, + editor_tip=editor_tip, + highlight_list=highlight_list, + ) + + send_result: dict[str, str] + if gen["wiki_error"]: + send_result = {"status": "failed", "message": f"Versand abgebrochen – Wiki-Fehler: {gen['wiki_error']}"} + elif not gen["articles"]: + send_result = {"status": "failed", "message": "Versand abgebrochen – im Zeitraum wurden keine Artikel gefunden."} + else: + recipients = [r.strip() for r in get_config(db, "smtp.recipients", "").split(",") if r.strip()] + status_code, detail = send_newsletter(db, gen["subject"], gen["raw_html"], gen["plain_text"], recipients, scheduled=False) + send_result = {"status": status_code, "message": detail} + + context = _base_context(request, current_user, db) + context.update( + { + "articles": gen["articles"], + "articles_new": gen["articles_new"], + "articles_edited": gen["articles_edited"], + "raw_html": gen["raw_html"], + "outlook_html": gen["outlook_html"], + "plain_text": gen["plain_text"], + "subject": gen["subject"], + "filters": { + "days": days, + "period": period, + "article_type": article_type, + "category": category, + "display": display, + "editor_tip": editor_tip, + "highlights": highlights, + }, + "send_logs": db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all(), + "wiki_error": gen["wiki_error"], + "send_result": send_result, + } + ) + return _render(request, "dashboard.html", context) @app.post("/admin/smtp") diff --git a/app/services/newsletter.py b/app/services/newsletter.py index abcdb49..5bb9873 100644 --- a/app/services/newsletter.py +++ b/app/services/newsletter.py @@ -101,13 +101,59 @@ def split_articles_by_type(articles: list[dict[str, Any]]) -> tuple[list[dict[st return new_articles, edited_articles -def create_subject(days: int, category: str = "") -> str: - base = f"Interner Wiki-Newsletter – {ORG_NAME} ({days} Tage)" +def create_subject(days: int, category: str = "", period_label: str | None = None) -> str: + label = period_label or f"{days} Tage" + base = f"Interner Wiki-Newsletter – {ORG_NAME} ({label})" if category.strip(): return f"{base} | Kategorie: {category.strip()}" return base +_MONTHS_DE = [ + "Januar", "Februar", "März", "April", "Mai", "Juni", + "Juli", "August", "September", "Oktober", "November", "Dezember", +] + + +def _month_label(dt: datetime) -> str: + return f"{_MONTHS_DE[dt.month - 1]} {dt.year}" + + +def resolve_period(mode: str, days: int) -> dict[str, Any]: + """Berechnet Start/Ende und Beschriftung für einen Zeitraum-Modus. + + - "last_month": voriger Kalendermonat (z. B. 01.06.–01.07.) + - sonst: die letzten N Tage + """ + now = datetime.now() + if mode == "last_month": + first_this_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + last_day_prev = first_this_month - timedelta(days=1) + start = last_day_prev.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + end = first_this_month + return { + "mode": "last_month", + "start": start, + "end": end, + "start_str": start.strftime("%d.%m.%Y"), + "end_str": end.strftime("%d.%m.%Y"), + "label": _month_label(start), + "days": max(1, (end - start).days), + } + days = max(1, min(days, 90)) + end = now + start = now - timedelta(days=days) + return { + "mode": "days", + "start": start, + "end": end, + "start_str": start.strftime("%d.%m.%Y"), + "end_str": end.strftime("%d.%m.%Y"), + "label": f"{days} Tage", + "days": days, + } + + def parse_highlights(raw: str | None) -> list[str]: """Zerlegt das Highlight-Eingabefeld (Titel pro Zeile) in eine Liste.""" if not raw: @@ -155,17 +201,23 @@ def create_plain_text( category: str = "", editor_tip: str = "", highlights: list[str] | None = None, + period_label: str | None = None, + period: tuple[str, str] | None = None, + article_type: str = "all", ) -> str: display = display or DEFAULT_DISPLAY new_articles, edited_articles = split_articles_by_type(articles) - period_start, period_end = _period_range(days) + show_new = article_type in ("all", "new") + show_edited = article_type in ("all", "edited") + period_start, period_end = period if period else _period_range(days) + period_desc = period_label or f"{days} Tage" created_at = datetime.now().strftime("%d.%m.%Y %H:%M") lines = [ LINE, f"{ORG_NAME.upper()} – INTERNER WIKI-NEWSLETTER", LINE, - f"Zeitraum: {period_start} bis {period_end} ({days} Tage)", + f"Zeitraum: {period_start} bis {period_end} ({period_desc})", f"Erstellt am: {created_at}", ] if category.strip(): @@ -202,17 +254,19 @@ def create_plain_text( lines.append(_plain_footer(new_articles, edited_articles)) return "\n".join(lines).strip() - lines.extend(_plain_section("NEUE ARTIKEL", new_articles, display, empty_text="Keine neuen Artikel im Zeitraum.")) - lines.append("") - lines.extend( - _plain_section( - "BEARBEITETE ARTIKEL", - edited_articles, - display, - empty_text="Keine bearbeiteten Artikel im Zeitraum.", + if show_new: + lines.extend(_plain_section("NEUE ARTIKEL", new_articles, display, empty_text="Keine neuen Artikel im Zeitraum.")) + lines.append("") + if show_edited: + lines.extend( + _plain_section( + "BEARBEITETE ARTIKEL", + edited_articles, + display, + empty_text="Keine bearbeiteten Artikel im Zeitraum.", + ) ) - ) - lines.append("") + lines.append("") lines.extend(_plain_editor_tip(editor_tip)) lines.append(_plain_footer(new_articles, edited_articles)) return "\n".join(lines).strip() @@ -239,12 +293,17 @@ def create_html( category: str = "", editor_tip: str = "", highlights: list[str] | None = None, + period_label: str | None = None, + period: tuple[str, str] | None = None, + article_type: str = "all", ) -> str: display = display or DEFAULT_DISPLAY new_articles, edited_articles = split_articles_by_type(articles) - period_start, period_end = _period_range(days) + show_new = article_type in ("all", "new") + show_edited = article_type in ("all", "edited") + period_start, period_end = period if period else _period_range(days) total = len(new_articles) + len(edited_articles) - subject = escape(create_subject(days, category)) + subject = escape(create_subject(days, category, period_label)) preheader = escape(_summary_sentence(total)) period_label = escape(f"{period_start} – {period_end}") summary_text = escape(_summary_sentence(total)) @@ -255,12 +314,12 @@ def create_html( if not new_articles and not edited_articles: body = _html_empty_card("Für diesen Zeitraum wurden keine passenden Wiki-Änderungen gefunden.") else: - body = "\n".join( - [ - _html_section("Neue Artikel", new_articles, display, "Keine neuen Artikel im Zeitraum.", kind="new"), - _html_section("Bearbeitete Artikel", edited_articles, display, "Keine bearbeiteten Artikel im Zeitraum.", kind="edited"), - ] - ) + sections = [] + if show_new: + sections.append(_html_section("Neue Artikel", new_articles, display, "Keine neuen Artikel im Zeitraum.", kind="new")) + if show_edited: + sections.append(_html_section("Bearbeitete Artikel", edited_articles, display, "Keine bearbeiteten Artikel im Zeitraum.", kind="edited")) + body = "\n".join(sections) summary_section = ( f'{_html_summary(new_articles, edited_articles)}' @@ -616,13 +675,18 @@ def create_outlook_html( category: str = "", editor_tip: str = "", highlights: list[str] | None = None, + period_label: str | None = None, + period: tuple[str, str] | None = None, + article_type: str = "all", ) -> str: """Word/Outlook-kompatibles HTML mit font-Tags und bgcolor (überlebt Einfügen in Outlook).""" display = display or DEFAULT_DISPLAY new_articles, edited_articles = split_articles_by_type(articles) - period_start, period_end = _period_range(days) + show_new = article_type in ("all", "new") + show_edited = article_type in ("all", "edited") + period_start, period_end = period if period else _period_range(days) total = len(new_articles) + len(edited_articles) - subject = escape(create_subject(days, category)) + subject = escape(create_subject(days, category, period_label)) period_label = f"{period_start} – {period_end}" summary_text = _summary_sentence(total) @@ -636,18 +700,12 @@ def create_outlook_html( """ else: - body_rows = "\n".join( - [ - _outlook_section("Neue Artikel", new_articles, display, "Keine neuen Artikel im Zeitraum.", kind="new"), - _outlook_section( - "Bearbeitete Artikel", - edited_articles, - display, - "Keine bearbeiteten Artikel im Zeitraum.", - kind="edited", - ), - ] - ) + sections = [] + if show_new: + sections.append(_outlook_section("Neue Artikel", new_articles, display, "Keine neuen Artikel im Zeitraum.", kind="new")) + if show_edited: + sections.append(_outlook_section("Bearbeitete Artikel", edited_articles, display, "Keine bearbeiteten Artikel im Zeitraum.", kind="edited")) + body_rows = "\n".join(sections) category_block = "" if category.strip(): diff --git a/app/services/smtp_sender.py b/app/services/smtp_sender.py index 0773aeb..6f88341 100644 --- a/app/services/smtp_sender.py +++ b/app/services/smtp_sender.py @@ -32,14 +32,16 @@ def send_newsletter( text_content: str, recipients: Sequence[str], scheduled: bool = False, -) -> None: +) -> tuple[str, str]: settings = get_smtp_settings(db) if settings["enabled"].lower() != "true": - _log(db, subject, recipients, "skipped", "SMTP ist deaktiviert.", scheduled) - return + detail = "SMTP ist deaktiviert. Bitte in der SMTP-Konfiguration aktivieren." + _log(db, subject, recipients, "skipped", detail, scheduled) + return "skipped", detail if not settings["host"] or not settings["from_email"] or not recipients: - _log(db, subject, recipients, "failed", "SMTP unvollständig konfiguriert.", scheduled) - return + detail = "SMTP unvollständig konfiguriert (Host, Absender oder Empfänger fehlen)." + _log(db, subject, recipients, "failed", detail, scheduled) + return "failed", detail msg = MIMEMultipart("alternative") msg.attach(MIMEText(text_content, "plain", "utf-8")) @@ -56,9 +58,13 @@ def send_newsletter( if settings["username"]: server.login(settings["username"], settings["password"]) server.sendmail(settings["from_email"], list(recipients), msg.as_string()) - _log(db, subject, recipients, "sent", "Versand erfolgreich.", scheduled) + detail = f"Versand erfolgreich an {len(recipients)} Empfänger." + _log(db, subject, recipients, "sent", detail, scheduled) + return "sent", detail except Exception as exc: - _log(db, subject, recipients, "failed", f"Versandfehler: {exc}", scheduled) + detail = f"Versandfehler: {exc}" + _log(db, subject, recipients, "failed", detail, scheduled) + return "failed", detail def run_scheduled_send_if_due(db: Session, subject: str, html_content: str, text_content: str) -> None: diff --git a/app/services/wiki.py b/app/services/wiki.py index c7f42c2..79f74d2 100644 --- a/app/services/wiki.py +++ b/app/services/wiki.py @@ -19,11 +19,18 @@ class WikiService: def __init__(self) -> None: self.api_url = settings.wiki_api_url - async def get_recent_changes(self, days: int = 30, only_edited: bool = False) -> list[dict[str, Any]]: - now = datetime.now(timezone.utc) - start = now - timedelta(days=days) - rcstart = now.strftime("%Y-%m-%dT%H:%M:%SZ") - rcend = start.strftime("%Y-%m-%dT%H:%M:%SZ") + async def get_recent_changes( + self, + days: int = 30, + article_type: str = "all", + start: datetime | None = None, + end: datetime | None = None, + ) -> list[dict[str, Any]]: + # MediaWiki listet neueste Änderungen zuerst: rcstart = obere (neuere) Grenze, rcend = untere (ältere). + upper = end or datetime.now(timezone.utc) + lower = start or (upper - timedelta(days=days)) + rcstart = upper.strftime("%Y-%m-%dT%H:%M:%SZ") + rcend = lower.strftime("%Y-%m-%dT%H:%M:%SZ") params: dict[str, str] = { "action": "query", @@ -35,7 +42,9 @@ class WikiService: "rcend": rcend, "rcnamespace": "0", } - if only_edited: + if article_type == "new": + params["rctype"] = "new" + elif article_type == "edited": params["rctype"] = "edit" else: params["rctype"] = "new|edit" diff --git a/app/static/css/main.css b/app/static/css/main.css index a2715a1..77cca53 100644 --- a/app/static/css/main.css +++ b/app/static/css/main.css @@ -510,6 +510,42 @@ tbody tr:hover { background: #FAFAFA; } display: block; } +.send-section { + margin-top: 1.25rem; + padding: 1rem 1.1rem; + border: 1px solid var(--tk-border); + border-left: 4px solid var(--tk-orange); + border-radius: var(--radius); + background: var(--tk-gray-light); +} + +.send-banner { + margin-top: 1rem; + padding: 0.75rem 1rem; + border-radius: var(--radius); + font-size: 0.9rem; + font-weight: 600; + border: 1px solid transparent; +} + +.send-banner-sent { + background: #E7F5EC; + color: #1E7A43; + border-color: #B7E0C6; +} + +.send-banner-failed { + background: #FDEAEA; + color: #B42318; + border-color: #F5C2C0; +} + +.send-banner-skipped { + background: var(--tk-orange-light); + color: var(--tk-orange-dark); + border-color: #F5C9A6; +} + .textarea-actions { display: flex; gap: 0.5rem; diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 76f9929..2236ee1 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -35,16 +35,27 @@
-
+
-