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.
This commit is contained in:
smueller
2026-07-07 14:20:21 +02:00
parent f0a014a077
commit 654276f6b5
7 changed files with 487 additions and 125 deletions

View File

@@ -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'<tr><td style="padding:24px 32px 0 32px;" class="mobile-padding">{_html_summary(new_articles, edited_articles)}</td></tr>'
@@ -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(
</td>
</tr>"""
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():

View File

@@ -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:

View File

@@ -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"