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

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