diff --git a/README.md b/README.md index 0382256..2c82177 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,13 @@ Produktionsnahes internes Tool zur Erstellung von Thomas-Krenn.AG Newslettern au - Zeitraum in Tagen - Nur bearbeitete Artikel - Kategorie +- Redaktioneller Feinschliff pro Ausgabe: + - **Redaktionsnotiz** – optionaler Hinweistext, der als eigene Box im Newsletter erscheint + - **Top-Highlights** – bis zu 3 Artikel per Titel hervorheben (erscheinen oben als Highlight-Box) - Datenquelle: MediaWiki API (`recentchanges` über `wikiDE/api.php`) +- E-Mail-Layout im Thomas-Krenn Corporate Design: + - Hero-Header mit Logo, Artikel-Karten mit „Zum Beitrag“-Button, Kontakt- und Adress-Footer + - Neu-/Bearbeitet-Trennung, Zusammenfassungs-Kacheln, Outlook-taugliche Kopiervorschau - Export: - Plain Text (für Outlook) - Raw HTML diff --git a/app/main.py b/app/main.py index 9759cb1..92d0456 100644 --- a/app/main.py +++ b/app/main.py @@ -29,6 +29,7 @@ from app.services.newsletter import ( create_subject, filter_articles, parse_display_options, + parse_highlights, split_articles_by_type, ) from app.services.smtp_sender import get_smtp_settings, run_scheduled_send_if_due, send_newsletter @@ -213,6 +214,8 @@ async def dashboard(request: Request, db: Session = Depends(get_db), current_use "only_edited": False, "category": "", "display": DEFAULT_DISPLAY, + "editor_tip": "", + "highlights": "", }, "wiki_error": None, }) @@ -231,6 +234,8 @@ async def dashboard_generate( show_date: str | None = Form(None), show_user: str | None = Form(None), show_category: str | None = Form(None), + editor_tip: str = Form(""), + highlights: str = Form(""), db: Session = Depends(get_db), current_user: User = Depends(require_editor_or_admin), ): @@ -238,6 +243,7 @@ async def dashboard_generate( days = max(1, min(days, 90)) only_edited_flag = only_edited == "true" display = parse_display_options(show_date, show_user, show_category) + highlight_list = parse_highlights(highlights) wiki_error = None filtered: list = [] articles_new: list = [] @@ -250,9 +256,9 @@ async def dashboard_generate( 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) - raw_html = create_html(filtered, days, display, category) - outlook_html = create_outlook_html(filtered, days, display, 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 @@ -270,6 +276,8 @@ async def dashboard_generate( "only_edited": only_edited_flag, "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, @@ -288,20 +296,24 @@ async def send_now( show_date: str | None = Form(None), show_user: str | None = Form(None), show_category: str | None = Form(None), + editor_tip: str = Form(""), + highlights: str = Form(""), db: Session = Depends(get_db), current_user: User = Depends(require_editor_or_admin), ): validate_csrf(request, csrf_token) display = parse_display_options(show_date, show_user, show_category) only_edited_flag = only_edited == "true" + highlight_list = parse_highlights(highlights) + days = max(1, min(days, 90)) try: - articles = await wiki_service.get_recent_changes(days=max(1, min(days, 90)), only_edited=only_edited_flag) + 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(max(1, min(days, 90)), category) - plain_text = create_plain_text(filtered, max(1, min(days, 90)), display, category) - raw_html = create_html(filtered, max(1, min(days, 90)), display, category) + 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) diff --git a/app/services/newsletter.py b/app/services/newsletter.py index dabf124..fbdc58b 100644 --- a/app/services/newsletter.py +++ b/app/services/newsletter.py @@ -6,6 +6,7 @@ from urllib.parse import quote WIKI_ARTICLE_BASE = "https://www.thomas-krenn.com/de/wiki/" WIKI_HOME_URL = "https://www.thomas-krenn.com/de/wiki/Hauptseite" ORG_NAME = "Thomas-Krenn.AG" +TK_LOGO_URL = "https://www.thomas-krenn.com/res/pics/tk_logo_340px.png" LINE = "=" * 78 SUBLINE = "-" * 78 @@ -14,6 +15,7 @@ FONT = "Arial, Helvetica, sans-serif" COLOR_TK_ORANGE = "#ED6B06" COLOR_TK_ORANGE_DARK = "#C95605" COLOR_TK_ORANGE_LIGHT = "#FFF4ED" +COLOR_TK_ORANGE_BORDER = "#F5C9A6" COLOR_TK_GRAY_DARK = "#4A4A4A" COLOR_TK_GRAY = "#6B6B6B" COLOR_TK_GRAY_LIGHT = "#F5F5F5" @@ -23,6 +25,7 @@ COLOR_HEADING = COLOR_TK_GRAY_DARK COLOR_LINK = COLOR_TK_ORANGE COLOR_BORDER = "#E0E0E0" COLOR_BG = COLOR_TK_GRAY_LIGHT +COLOR_PAGE_BG = "#ECEEF1" COLOR_WHITE = "#FFFFFF" COLOR_ACCENT_NEW = COLOR_TK_ORANGE COLOR_ACCENT_NEW_BG = COLOR_TK_ORANGE_LIGHT @@ -105,11 +108,53 @@ def create_subject(days: int, category: str = "") -> str: return base +def parse_highlights(raw: str | None) -> list[str]: + """Zerlegt das Highlight-Eingabefeld (Titel pro Zeile) in eine Liste.""" + if not raw: + return [] + return [line.strip() for line in raw.splitlines() if line.strip()] + + +def _match_highlights( + articles: list[dict[str, Any]], + highlights: list[str] | None, +) -> list[dict[str, Any]]: + """Ordnet Highlight-Titel den geladenen Artikeln zu (max. 3).""" + if not highlights: + return [] + result: list[dict[str, Any]] = [] + for wanted in highlights: + key = wanted.strip().lower() + if not key: + continue + for article in articles: + if article.get("title", "").strip().lower() == key: + result.append(article) + break + if len(result) >= 3: + break + return result + + +def _summary_sentence(total: int) -> str: + if total == 0: + return "Aktuell wurden keine neuen Wiki-Artikel gefunden." + if total == 1: + return "Es gibt einen neuen bzw. aktualisierten Wiki-Artikel." + return f"Es gibt {total} neue bzw. aktualisierte Wiki-Artikel." + + +# --------------------------------------------------------------------------- +# Plain-Text +# --------------------------------------------------------------------------- + def create_plain_text( articles: list[dict[str, Any]], days: int, display: DisplayOptions | None = None, category: str = "", + editor_tip: str = "", + highlights: list[str] | None = None, ) -> str: display = display or DEFAULT_DISPLAY new_articles, edited_articles = split_articles_by_type(articles) @@ -135,6 +180,14 @@ def create_plain_text( ] ) + highlight_articles = _match_highlights(new_articles + edited_articles, highlights) + if highlight_articles: + lines.extend([SUBLINE, "TOP HIGHLIGHTS", SUBLINE, ""]) + for idx, item in enumerate(highlight_articles, start=1): + lines.append(f" {idx}. {item.get('title', 'Ohne Titel')}") + lines.append(f" {_article_url(item.get('title', ''))}") + lines.append("") + if not new_articles and not edited_articles: lines.extend( [ @@ -143,9 +196,10 @@ def create_plain_text( SUBLINE, "Im gewählten Zeitraum wurden keine passenden Wiki-Änderungen gefunden.", "", - _plain_footer(new_articles, edited_articles), ] ) + lines.extend(_plain_editor_tip(editor_tip)) + 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.")) @@ -159,24 +213,47 @@ def create_plain_text( ) ) lines.append("") + lines.extend(_plain_editor_tip(editor_tip)) lines.append(_plain_footer(new_articles, edited_articles)) return "\n".join(lines).strip() +def _plain_editor_tip(editor_tip: str) -> list[str]: + text = (editor_tip or "").strip() + if not text: + return [] + lines = [SUBLINE, "REDAKTIONSNOTIZ", SUBLINE, ""] + lines.extend(f" {line}" for line in text.splitlines()) + lines.append("") + return lines + + +# --------------------------------------------------------------------------- +# HTML (Standard, für SMTP-Versand & Quelltext) +# --------------------------------------------------------------------------- + def create_html( articles: list[dict[str, Any]], days: int, display: DisplayOptions | None = None, category: str = "", + editor_tip: str = "", + highlights: list[str] | None = None, ) -> str: display = display or DEFAULT_DISPLAY new_articles, edited_articles = split_articles_by_type(articles) period_start, period_end = _period_range(days) - created_at = datetime.now().strftime("%d.%m.%Y %H:%M") + total = len(new_articles) + len(edited_articles) subject = escape(create_subject(days, category)) + preheader = escape(_summary_sentence(total)) + period_label = escape(f"{period_start} – {period_end}") + summary_text = escape(_summary_sentence(total)) + + highlight_articles = _match_highlights(new_articles + edited_articles, highlights) + highlights_section = _html_highlights(highlight_articles) if not new_articles and not edited_articles: - body = f'
Im gewählten Zeitraum wurden keine passenden Wiki-Änderungen gefunden.
' + body = _html_empty_card("Für diesen Zeitraum wurden keine passenden Wiki-Änderungen gefunden.") else: body = "\n".join( [ @@ -185,85 +262,280 @@ def create_html( ] ) - summary = _html_summary(new_articles, edited_articles) - intro = escape(_intro_sentence(period_start, period_end)) - category_hint = ( - f'Filter: Kategorie {escape(category.strip())}
' + f'Filter: Kategorie {escape(category.strip())}
-
|
-
+
|
+
| ' + f'{escape(label)}' + f' |
| ' + f'{escape(text)} |
| Top Highlights |
| Redaktionsnotiz |
| {body} |
| {idx}. {eyebrow} |
| ' + f'{title} |
| {_cta_button(url, "Zum Beitrag →")} |
| Zusammenfassung | +|||||
+
|
+
| ' + f'{_outlook_link(label, url, color="#FFFFFF")}' + f' |
|
- {_outlook_font("Interner Newsletter", color=OUTLOOK_ORANGE, size="1")} - {_outlook_font("Thomas-Krenn Wiki Update", color=OUTLOOK_WHITE, size="5", bold=True)} - {_outlook_font(ORG_NAME, color="#CCCCCC", size="2")} + |
+ |
+ ||||||||||||||||||||||||||||||||||
|
+ {_outlook_font("Thomas-Krenn Wiki Update", color=OUTLOOK_ORANGE, size="1", bold=True)} + {_outlook_font("Die neuesten Wiki-Artikel", color=OUTLOOK_WHITE, size="5", bold=True)} + {_outlook_font(f"{period_label} | {summary_text}", color="#DDDDDD", size="2")} |
|||||||||||||||||||||||||||||||||||
|
- {_outlook_font("Liebe Kolleginnen und Kollegen,")} -{_outlook_font(intro)} -{_outlook_font(f"Erstellt am: {created_at}", color=OUTLOOK_MUTED, size="1")} +{_outlook_font("Servus,")} +{_outlook_font(f"hier ist deine Übersicht mit den neuesten Beiträgen aus dem Thomas-Krenn-Wiki vom {period_start} bis {period_end}.")} {category_block} + {highlights_rows}
".join(_outlook_font(line) for line in text.splitlines()) + return ( + f' '
+ f'{_outlook_font("Redaktionsnotiz", color=OUTLOOK_HEADING, size="1", bold=True)} | ' + f'{body} {_outlook_font(meta, color=OUTLOOK_MUTED, size='1')}" - rows.append( - f"""
- {_outlook_font(f"{idx}.")}
- |
-
- {_outlook_link(title, url)}{meta_html}
- |
-
+ |
{_outlook_font(f"{heading} ({len(items)})", color=accent, size="3", bold=True)}
|
+ |
{_outlook_font(empty_text, color=OUTLOOK_MUTED)}
|
{_outlook_font(meta, color=OUTLOOK_MUTED, size="1")}' if meta else "" + return f"""
+ |
+
{escape(empty_text)} ' - ) - return "\n".join(blocks) - - blocks.append( - f'
- | """
-
-
-def _html_item_row(idx: int, item: dict[str, Any], display: DisplayOptions, accent: str) -> str:
- title = escape(item.get("title", "Ohne Titel"))
- url = escape(_article_url(item.get("title", "")))
- meta_html = _html_meta_tags(item, display)
-
- return f"""
- |
-
Hervorgehobene Artikel erscheinen als „Top Highlights“-Box oben im Newsletter. + +Wird nur angezeigt, wenn Text hinterlegt ist – erscheint als eigene Box im Newsletter. {% if wiki_error %}{{ wiki_error }} {% endif %} @@ -184,6 +192,8 @@ + + |