Update configuration and enhance newsletter generation features

- Change WIKI_API_URL in .env.example and config to point to the new API endpoint.
- Set COOKIE_SECURE to false for local development in .env.example and config.
- Improve the newsletter generation logic to handle new and edited articles separately.
- Add display options for showing date, user, and category in the newsletter output.
- Enhance error handling for Wiki API requests and update templates for better user feedback.
- Update CSS for new UI elements and improve overall layout in dashboard and login pages.
This commit is contained in:
smueller
2026-07-03 12:47:35 +02:00
parent 0d7729287f
commit e5e4d812c7
13 changed files with 629 additions and 91 deletions

View File

@@ -7,8 +7,9 @@ HOST_PORT=8080
DATABASE_URL=sqlite:///./data/newsletter.db DATABASE_URL=sqlite:///./data/newsletter.db
# Beispiel PostgreSQL: # Beispiel PostgreSQL:
# DATABASE_URL=postgresql+psycopg://newsletter:newsletter@postgres:5432/newsletter # DATABASE_URL=postgresql+psycopg://newsletter:newsletter@postgres:5432/newsletter
WIKI_API_URL=https://www.thomas-krenn.com/de/wiki/api.php WIKI_API_URL=https://www.thomas-krenn.com/de/wikiDE/api.php
ALLOWED_HOSTS=* ALLOWED_HOSTS=*
COOKIE_SECURE=true COOKIE_SECURE=false
# Auf true setzen, wenn hinter HTTPS/TLS-Terminierung (Reverse Proxy)
ADMIN_BOOTSTRAP_EMAIL=admin@internal.local ADMIN_BOOTSTRAP_EMAIL=admin@internal.local
ADMIN_BOOTSTRAP_PASSWORD=ChangeMe123! ADMIN_BOOTSTRAP_PASSWORD=ChangeMe123!

View File

@@ -10,7 +10,7 @@ Produktionsnahes internes Tool zur Erstellung von Thomas-Krenn.AG Newslettern au
- Zeitraum in Tagen - Zeitraum in Tagen
- Nur bearbeitete Artikel - Nur bearbeitete Artikel
- Kategorie - Kategorie
- Datenquelle: MediaWiki API (`recentchanges`) - Datenquelle: MediaWiki API (`recentchanges` über `wikiDE/api.php`)
- Export: - Export:
- Plain Text (für Outlook) - Plain Text (für Outlook)
- Raw HTML - Raw HTML

View File

@@ -8,9 +8,9 @@ class Settings(BaseSettings):
algorithm: str = "HS256" algorithm: str = "HS256"
access_token_expire_minutes: int = 120 access_token_expire_minutes: int = 120
database_url: str = "sqlite:///./data/newsletter.db" database_url: str = "sqlite:///./data/newsletter.db"
wiki_api_url: str = "https://www.thomas-krenn.com/de/wiki/api.php" wiki_api_url: str = "https://www.thomas-krenn.com/de/wikiDE/api.php"
allowed_hosts: str = "*" allowed_hosts: str = "*"
cookie_secure: bool = True cookie_secure: bool = False
admin_bootstrap_email: str = "admin@internal.local" admin_bootstrap_email: str = "admin@internal.local"
admin_bootstrap_password: str = "ChangeMe123!" admin_bootstrap_password: str = "ChangeMe123!"

13
app/core/cookies.py Normal file
View File

@@ -0,0 +1,13 @@
from fastapi import Request
from app.core.config import settings
def cookie_secure(request: Request) -> bool:
"""Secure-Flag nur setzen, wenn aktiviert und die Anfrage wirklich per HTTPS läuft."""
if not settings.cookie_secure:
return False
forwarded = request.headers.get("x-forwarded-proto", "")
if forwarded:
return forwarded.split(",")[0].strip().lower() == "https"
return request.url.scheme == "https"

View File

@@ -9,6 +9,7 @@ from sqlalchemy import inspect, text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import settings from app.core.config import settings
from app.core.cookies import cookie_secure
from app.core.security import create_access_token, hash_password, verify_password from app.core.security import create_access_token, hash_password, verify_password
from app.db.session import Base, engine, get_db from app.db.session import Base, engine, get_db
from app.models.system import SendLog from app.models.system import SendLog
@@ -17,9 +18,18 @@ from app.schemas.user import UserCreate
from app.services.auth import get_admin_user, get_current_user, get_optional_user, require_editor_or_admin from app.services.auth import get_admin_user, get_current_user, get_optional_user, require_editor_or_admin
from app.services.config_store import get_config, set_config from app.services.config_store import get_config, set_config
from app.services.csrf import CSRF_COOKIE_NAME, ensure_csrf_cookie, validate_csrf from app.services.csrf import CSRF_COOKIE_NAME, ensure_csrf_cookie, validate_csrf
from app.services.newsletter import create_html, create_plain_text, filter_articles from app.services.newsletter import (
DEFAULT_DISPLAY,
article_url,
create_html,
create_plain_text,
create_subject,
filter_articles,
parse_display_options,
split_articles_by_type,
)
from app.services.smtp_sender import get_smtp_settings, run_scheduled_send_if_due, send_newsletter from app.services.smtp_sender import get_smtp_settings, run_scheduled_send_if_due, send_newsletter
from app.services.wiki import WikiService from app.services.wiki import WikiFetchError, WikiService
app = FastAPI(title=settings.app_name) app = FastAPI(title=settings.app_name)
allowed_hosts = [h.strip() for h in settings.allowed_hosts.split(",") if h.strip()] allowed_hosts = [h.strip() for h in settings.allowed_hosts.split(",") if h.strip()]
@@ -27,6 +37,7 @@ if allowed_hosts:
app.add_middleware(TrustedHostMiddleware, allowed_hosts=allowed_hosts) app.add_middleware(TrustedHostMiddleware, allowed_hosts=allowed_hosts)
app.mount("/static", StaticFiles(directory="app/static"), name="static") app.mount("/static", StaticFiles(directory="app/static"), name="static")
templates = Jinja2Templates(directory="app/templates") templates = Jinja2Templates(directory="app/templates")
templates.env.globals["wiki_article_url"] = article_url
wiki_service = WikiService() wiki_service = WikiService()
@@ -54,9 +65,9 @@ async def add_security_headers(request: Request, call_next):
response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY" response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "same-origin" response.headers["Referrer-Policy"] = "same-origin"
response.headers["Content-Security-Policy"] = "default-src 'self'; style-src 'self';" response.headers["Content-Security-Policy"] = "default-src 'self'; style-src 'self'; script-src 'self';"
if not request.cookies.get(CSRF_COOKIE_NAME): if not request.cookies.get(CSRF_COOKIE_NAME):
response.set_cookie(CSRF_COOKIE_NAME, ensure_csrf_cookie(request), httponly=True, secure=settings.cookie_secure, samesite="strict") response.set_cookie(CSRF_COOKIE_NAME, ensure_csrf_cookie(request), httponly=True, secure=cookie_secure(request), samesite="strict")
return response return response
@@ -120,7 +131,10 @@ def login_page(request: Request, db: Session = Depends(get_db)):
return _render( return _render(
request, request,
"login.html", "login.html",
{"csrf_token": request.cookies.get(CSRF_COOKIE_NAME) or ensure_csrf_cookie(request)}, {
"csrf_token": request.cookies.get(CSRF_COOKIE_NAME) or ensure_csrf_cookie(request),
"error_message": None,
},
) )
@@ -132,13 +146,30 @@ def login(
password: str = Form(...), password: str = Form(...),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
validate_csrf(request, csrf_token) try:
validate_csrf(request, csrf_token)
except HTTPException:
return _render(
request,
"login.html",
{
"csrf_token": request.cookies.get(CSRF_COOKIE_NAME) or ensure_csrf_cookie(request),
"error_message": "Sitzung abgelaufen. Bitte erneut anmelden.",
},
)
user = db.query(User).filter(User.email == email, User.is_active.is_(True)).first() user = db.query(User).filter(User.email == email, User.is_active.is_(True)).first()
if not user or not verify_password(password, user.password_hash): if not user or not verify_password(password, user.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Ungültige Zugangsdaten.") return _render(
request,
"login.html",
{
"csrf_token": request.cookies.get(CSRF_COOKIE_NAME) or ensure_csrf_cookie(request),
"error_message": "Ungültige Zugangsdaten.",
},
)
token = create_access_token(user.id) token = create_access_token(user.id)
response = RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND) response = RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND)
response.set_cookie("access_token", token, httponly=True, secure=settings.cookie_secure, samesite="strict") response.set_cookie("access_token", token, httponly=True, secure=cookie_secure(request), samesite="strict")
return response return response
@@ -153,7 +184,20 @@ def logout(request: Request, csrf_token: str = Form(...)):
@app.get("/dashboard", response_class=HTMLResponse) @app.get("/dashboard", response_class=HTMLResponse)
async def dashboard(request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): async def dashboard(request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
context = _base_context(request, current_user, db) context = _base_context(request, current_user, db)
context.update({"articles": [], "raw_html": "", "plain_text": "", "filters": {"days": 30, "only_edited": False, "category": ""}}) context.update({
"articles": [],
"articles_new": [],
"articles_edited": [],
"raw_html": "",
"plain_text": "",
"filters": {
"days": 30,
"only_edited": False,
"category": "",
"display": DEFAULT_DISPLAY,
},
"wiki_error": None,
})
logs = db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all() logs = db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all()
context["send_logs"] = logs context["send_logs"] = logs
return _render(request, "dashboard.html", context) return _render(request, "dashboard.html", context)
@@ -164,27 +208,50 @@ async def dashboard_generate(
request: Request, request: Request,
csrf_token: str = Form(...), csrf_token: str = Form(...),
days: int = Form(30), days: int = Form(30),
only_edited: bool = Form(False), only_edited: str | None = Form(None),
category: str = Form(""), category: str = Form(""),
show_date: str | None = Form(None),
show_user: str | None = Form(None),
show_category: str | None = Form(None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_editor_or_admin), current_user: User = Depends(require_editor_or_admin),
): ):
validate_csrf(request, csrf_token) validate_csrf(request, csrf_token)
days = max(1, min(days, 90)) days = max(1, min(days, 90))
articles = await wiki_service.get_recent_changes(days=days, only_edited=only_edited) only_edited_flag = only_edited == "true"
filtered = filter_articles(articles, category_filter=category or None) display = parse_display_options(show_date, show_user, show_category)
title = f"Thomas-Krenn Wiki Newsletter ({days} Tage)" wiki_error = None
plain_text = create_plain_text(filtered, title) filtered: list = []
raw_html = create_html(filtered, title) articles_new: list = []
run_scheduled_send_if_due(db, title, raw_html, plain_text) articles_edited: list = []
plain_text = ""
raw_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)
raw_html = create_html(filtered, days, display, category)
run_scheduled_send_if_due(db, title, raw_html, plain_text)
except WikiFetchError as exc:
wiki_error = exc.message
context = _base_context(request, current_user, db) context = _base_context(request, current_user, db)
context.update( context.update(
{ {
"articles": filtered, "articles": filtered,
"articles_new": articles_new,
"articles_edited": articles_edited,
"raw_html": raw_html, "raw_html": raw_html,
"plain_text": plain_text, "plain_text": plain_text,
"filters": {"days": days, "only_edited": only_edited, "category": category}, "filters": {
"days": days,
"only_edited": only_edited_flag,
"category": category,
"display": display,
},
"send_logs": db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all(), "send_logs": db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all(),
"wiki_error": wiki_error,
} }
) )
return _render(request, "dashboard.html", context) return _render(request, "dashboard.html", context)
@@ -195,17 +262,25 @@ async def send_now(
request: Request, request: Request,
csrf_token: str = Form(...), csrf_token: str = Form(...),
days: int = Form(30), days: int = Form(30),
only_edited: bool = Form(False), only_edited: str | None = Form(None),
category: str = Form(""), category: str = Form(""),
show_date: str | None = Form(None),
show_user: str | None = Form(None),
show_category: str | None = Form(None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_editor_or_admin), current_user: User = Depends(require_editor_or_admin),
): ):
validate_csrf(request, csrf_token) validate_csrf(request, csrf_token)
articles = await wiki_service.get_recent_changes(days=max(1, min(days, 90)), only_edited=only_edited) display = parse_display_options(show_date, show_user, show_category)
only_edited_flag = only_edited == "true"
try:
articles = await wiki_service.get_recent_changes(days=max(1, min(days, 90)), 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) filtered = filter_articles(articles, category_filter=category or None)
title = f"Thomas-Krenn Wiki Newsletter ({days} Tage)" title = create_subject(max(1, min(days, 90)), category)
plain_text = create_plain_text(filtered, title) plain_text = create_plain_text(filtered, max(1, min(days, 90)), display, category)
raw_html = create_html(filtered, title) raw_html = create_html(filtered, max(1, min(days, 90)), display, category)
recipients = [r.strip() for r in get_config(db, "smtp.recipients", "").split(",") if r.strip()] 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) send_newsletter(db, title, raw_html, plain_text, recipients, scheduled=False)
return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND) return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND)

View File

@@ -1,6 +1,38 @@
from datetime import datetime from datetime import datetime, timedelta
from html import escape from html import escape
from typing import Any from typing import Any, TypedDict
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"
LINE = "=" * 78
SUBLINE = "-" * 78
class DisplayOptions(TypedDict):
show_date: bool
show_user: bool
show_category: bool
DEFAULT_DISPLAY: DisplayOptions = {
"show_date": True,
"show_user": True,
"show_category": True,
}
def parse_display_options(
show_date: str | None = None,
show_user: str | None = None,
show_category: str | None = None,
) -> DisplayOptions:
return {
"show_date": show_date == "true",
"show_user": show_user == "true",
"show_category": show_category == "true",
}
def filter_articles( def filter_articles(
@@ -17,60 +49,280 @@ def filter_articles(
] ]
def create_plain_text(articles: list[dict[str, Any]], title: str) -> str: def split_articles_by_type(articles: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
lines = [title, "=" * len(title), ""] sorted_articles = sorted(articles, key=lambda a: a.get("timestamp", ""), reverse=True)
if not articles: new_articles: list[dict[str, Any]] = []
lines.append("Keine Artikel im gewählten Zeitraum gefunden.") edited_articles: list[dict[str, Any]] = []
return "\n".join(lines) new_titles: set[str] = set()
for idx, item in enumerate(articles, start=1): for item in sorted_articles:
stamp = _format_ts(item.get("timestamp")) title = item.get("title", "")
cat = ", ".join(item.get("categories", [])) or "Keine Kategorie" if not title or item.get("type") != "new":
comment = item.get("comment") or "-" continue
if title not in new_titles:
new_articles.append(item)
new_titles.add(title)
seen_edited: set[str] = set()
for item in sorted_articles:
title = item.get("title", "")
if not title or title in new_titles or title in seen_edited:
continue
edited_articles.append(item)
seen_edited.add(title)
return new_articles, edited_articles
def create_subject(days: int, category: str = "") -> str:
base = f"Interner Wiki-Newsletter {ORG_NAME} ({days} Tage)"
if category.strip():
return f"{base} | Kategorie: {category.strip()}"
return base
def create_plain_text(
articles: list[dict[str, Any]],
days: int,
display: DisplayOptions | None = None,
category: str = "",
) -> 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")
lines = [
LINE,
f"{ORG_NAME.upper()} INTERNER WIKI-NEWSLETTER",
LINE,
f"Zeitraum: {period_start} bis {period_end} ({days} Tage)",
f"Erstellt am: {created_at}",
]
if category.strip():
lines.append(f"Kategorie: {category.strip()}")
lines.extend(
[
"",
"Liebe Kolleginnen und Kollegen,",
"",
"im Thomas-Krenn-Wiki wurden im ausgewählten Zeitraum folgende Artikel",
"neu veröffentlicht bzw. aktualisiert:",
"",
]
)
if not new_articles and not edited_articles:
lines.extend( lines.extend(
[ [
f"{idx}. {item.get('title', 'Ohne Titel')}", SUBLINE,
f" Zeit: {stamp}", "HINWEIS",
f" Benutzer: {item.get('user', '-')}", SUBLINE,
f" Kategorien: {cat}", "Im gewählten Zeitraum wurden keine passenden Wiki-Änderungen gefunden.",
f" Kommentar: {comment}",
"", "",
_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.",
)
)
lines.append("")
lines.append(_plain_footer(new_articles, edited_articles))
return "\n".join(lines).strip() return "\n".join(lines).strip()
def create_html(articles: list[dict[str, Any]], title: str) -> str: def create_html(
if not articles: articles: list[dict[str, Any]],
return f"<h2>{escape(title)}</h2><p>Keine Artikel im gewählten Zeitraum gefunden.</p>" days: int,
display: DisplayOptions | None = None,
category: str = "",
) -> 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")
subject = escape(create_subject(days, category))
blocks = [f"<h2>{escape(title)}</h2>", "<ul>"] category_row = ""
for item in articles: if category.strip():
article_title = escape(item.get("title", "Ohne Titel")) category_row = f'<p style="margin:4px 0;color:#334155;"><strong>Kategorie:</strong> {escape(category.strip())}</p>'
stamp = escape(_format_ts(item.get("timestamp")))
user = escape(item.get("user", "-")) if not new_articles and not edited_articles:
cat = escape(", ".join(item.get("categories", [])) or "Keine Kategorie") body = '<p style="margin:16px 0;">Im gewählten Zeitraum wurden keine passenden Wiki-Änderungen gefunden.</p>'
comment = escape(item.get("comment") or "-") else:
blocks.append( body = "\n".join(
( [
"<li>" _html_section("Neue Artikel", new_articles, display, "Keine neuen Artikel im Zeitraum."),
f"<strong>{article_title}</strong><br>" _html_section("Bearbeitete Artikel", edited_articles, display, "Keine bearbeiteten Artikel im Zeitraum."),
f"Zeit: {stamp}<br>" ]
f"Benutzer: {user}<br>"
f"Kategorien: {cat}<br>"
f"Kommentar: {comment}"
"</li>"
)
) )
blocks.append("</ul>")
summary = _html_summary(new_articles, edited_articles)
return f"""<!DOCTYPE html>
<html lang="de">
<head><meta charset="utf-8"><title>{subject}</title></head>
<body style="margin:0;padding:0;background:#f4f6fa;font-family:Arial,Helvetica,sans-serif;color:#1f2430;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f6fa;padding:24px 0;">
<tr><td align="center">
<table role="presentation" width="640" cellpadding="0" cellspacing="0" style="background:#ffffff;border:1px solid #dde3ef;border-radius:8px;overflow:hidden;">
<tr><td style="background:#0c2c5a;color:#ffffff;padding:20px 24px;">
<h1 style="margin:0;font-size:20px;">Interner Wiki-Newsletter</h1>
<p style="margin:8px 0 0;font-size:14px;">{escape(ORG_NAME)}</p>
</td></tr>
<tr><td style="padding:24px;">
<p style="margin:0 0 8px;color:#334155;"><strong>Zeitraum:</strong> {period_start} bis {period_end} ({days} Tage)</p>
<p style="margin:0 0 8px;color:#334155;"><strong>Erstellt am:</strong> {created_at}</p>
{category_row}
<p style="margin:20px 0 8px;line-height:1.5;">Liebe Kolleginnen und Kollegen,</p>
<p style="margin:0 0 20px;line-height:1.5;">im Thomas-Krenn-Wiki wurden im ausgewählten Zeitraum folgende Artikel neu veröffentlicht bzw. aktualisiert:</p>
{body}
{summary}
<p style="margin:24px 0 0;"><a href="{WIKI_HOME_URL}" style="color:#1b5dbf;">Zum Thomas-Krenn-Wiki</a></p>
</td></tr>
<tr><td style="background:#f8fafc;padding:16px 24px;border-top:1px solid #e6ebf4;font-size:12px;color:#64748b;line-height:1.5;">
Dieser Newsletter wurde automatisch erstellt und ist nur für den internen Gebrauch bei {escape(ORG_NAME)} bestimmt.
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>"""
def _plain_section(
heading: str,
items: list[dict[str, Any]],
display: DisplayOptions,
empty_text: str,
) -> list[str]:
lines = [SUBLINE, f"{heading} ({len(items)})", SUBLINE, ""]
if not items:
lines.append(f" {empty_text}")
lines.append("")
return lines
for idx, item in enumerate(items, start=1):
lines.extend(_plain_item(idx, item, display))
return lines
def _plain_item(idx: int, item: dict[str, Any], display: DisplayOptions) -> list[str]:
title = item.get("title", "Ohne Titel")
url = _article_url(item.get("title", ""))
meta_parts: list[str] = []
if display["show_date"]:
meta_parts.append(_format_ts(item.get("timestamp")))
if display["show_user"]:
meta_parts.append(str(item.get("user", "-")))
if display["show_category"]:
cat = ", ".join(item.get("categories", [])) or "Keine Kategorie"
meta_parts.append(cat)
lines = [f" {idx}. {title}", f" {url}"]
if meta_parts:
lines.append(f" ({' | '.join(meta_parts)})")
lines.append("")
return lines
def _plain_footer(new_articles: list[dict[str, Any]], edited_articles: list[dict[str, Any]]) -> str:
total = len(new_articles) + len(edited_articles)
return "\n".join(
[
SUBLINE,
"ZUSAMMENFASSUNG",
SUBLINE,
f" Neue Artikel: {len(new_articles)}",
f" Bearbeitete Artikel: {len(edited_articles)}",
f" Gesamt: {total}",
"",
f"Wiki: {WIKI_HOME_URL}",
"",
SUBLINE,
f"Dieser Newsletter wurde automatisch erstellt und ist nur für den internen",
f"Gebrauch bei {ORG_NAME} bestimmt.",
LINE,
]
)
def _html_section(heading: str, items: list[dict[str, Any]], display: DisplayOptions, empty_text: str) -> str:
blocks = [
f'<h2 style="margin:24px 0 8px;font-size:16px;color:#0c2c5a;border-bottom:2px solid #dde3ef;padding-bottom:6px;">{escape(heading)} ({len(items)})</h2>'
]
if not items:
blocks.append(f'<p style="margin:8px 0 0;color:#64748b;">{escape(empty_text)}</p>')
return "\n".join(blocks)
blocks.append('<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:8px;">')
for idx, item in enumerate(items, start=1):
blocks.append(_html_item_row(idx, item, display))
blocks.append("</table>")
return "\n".join(blocks) return "\n".join(blocks)
def _html_item_row(idx: int, item: dict[str, Any], display: DisplayOptions) -> str:
title = escape(item.get("title", "Ohne Titel"))
url = escape(_article_url(item.get("title", "")))
meta_parts: list[str] = []
if display["show_date"]:
meta_parts.append(f"<strong>Datum:</strong> {escape(_format_ts(item.get('timestamp')))}")
if display["show_user"]:
meta_parts.append(f"<strong>Benutzer:</strong> {escape(str(item.get('user', '-')))}")
if display["show_category"]:
cat = escape(", ".join(item.get("categories", [])) or "Keine Kategorie")
meta_parts.append(f"<strong>Kategorie:</strong> {cat}")
meta_html = f'<div style="font-size:12px;color:#64748b;margin-top:4px;">{" &nbsp;|&nbsp; ".join(meta_parts)}</div>' if meta_parts else ""
return f"""<tr>
<td style="padding:10px 0;border-bottom:1px solid #e6ebf4;vertical-align:top;width:28px;color:#64748b;">{idx}.</td>
<td style="padding:10px 0;border-bottom:1px solid #e6ebf4;vertical-align:top;">
<a href="{url}" style="color:#1b5dbf;font-weight:bold;text-decoration:none;">{title}</a>
{meta_html}
</td>
</tr>"""
def _html_summary(new_articles: list[dict[str, Any]], edited_articles: list[dict[str, Any]]) -> str:
total = len(new_articles) + len(edited_articles)
return f"""<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:24px;background:#f8fafc;border:1px solid #e6ebf4;border-radius:6px;">
<tr><td style="padding:14px 16px;font-size:14px;line-height:1.6;">
<strong>Zusammenfassung</strong><br>
Neue Artikel: {len(new_articles)}<br>
Bearbeitete Artikel: {len(edited_articles)}<br>
Gesamt: {total}
</td></tr>
</table>"""
def _article_url(title: str) -> str:
return f"{WIKI_ARTICLE_BASE}{quote(title.replace(' ', '_'))}"
def article_url(title: str) -> str:
return _article_url(title)
def _period_range(days: int) -> tuple[str, str]:
end = datetime.now()
start = end - timedelta(days=days)
return start.strftime("%d.%m.%Y"), end.strftime("%d.%m.%Y")
def _format_ts(value: str | None) -> str: def _format_ts(value: str | None) -> str:
if not value: if not value:
return "-" return "-"
try: try:
dt = datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ") dt = datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ")
return dt.strftime("%d.%m.%Y %H:%M UTC") return dt.strftime("%d.%m.%Y %H:%M")
except ValueError: except ValueError:
return value return value

View File

@@ -1,5 +1,6 @@
import smtplib import smtplib
from datetime import datetime from datetime import datetime
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from typing import Sequence from typing import Sequence
@@ -40,7 +41,9 @@ def send_newsletter(
_log(db, subject, recipients, "failed", "SMTP unvollständig konfiguriert.", scheduled) _log(db, subject, recipients, "failed", "SMTP unvollständig konfiguriert.", scheduled)
return return
msg = MIMEText(html_content, "html", "utf-8") msg = MIMEMultipart("alternative")
msg.attach(MIMEText(text_content, "plain", "utf-8"))
msg.attach(MIMEText(html_content, "html", "utf-8"))
msg["Subject"] = subject msg["Subject"] = subject
msg["From"] = settings["from_email"] msg["From"] = settings["from_email"]
msg["To"] = ", ".join(recipients) msg["To"] = ", ".join(recipients)

View File

@@ -6,6 +6,14 @@ import httpx
from app.core.config import settings from app.core.config import settings
USER_AGENT = "TK-Newsletter-Admin/1.0 (internal; contact@thomas-krenn.com)"
class WikiFetchError(Exception):
def __init__(self, message: str) -> None:
self.message = message
super().__init__(message)
class WikiService: class WikiService:
def __init__(self) -> None: def __init__(self) -> None:
@@ -17,7 +25,7 @@ class WikiService:
rcstart = now.strftime("%Y-%m-%dT%H:%M:%SZ") rcstart = now.strftime("%Y-%m-%dT%H:%M:%SZ")
rcend = start.strftime("%Y-%m-%dT%H:%M:%SZ") rcend = start.strftime("%Y-%m-%dT%H:%M:%SZ")
params = { params: dict[str, str] = {
"action": "query", "action": "query",
"format": "json", "format": "json",
"list": "recentchanges", "list": "recentchanges",
@@ -34,18 +42,31 @@ class WikiService:
rows: list[dict[str, Any]] = [] rows: list[dict[str, Any]] = []
cont: dict[str, Any] = {} cont: dict[str, Any] = {}
async with httpx.AsyncClient(timeout=20.0) as client: headers = {"User-Agent": USER_AGENT}
while True: try:
response = await client.get(self.api_url, params={**params, **cont}) async with httpx.AsyncClient(timeout=30.0, headers=headers, follow_redirects=True) as client:
response.raise_for_status() while True:
payload = response.json() response = await client.get(self.api_url, params={**params, **cont})
rows.extend(payload.get("query", {}).get("recentchanges", [])) if response.status_code >= 400:
if "continue" not in payload: raise WikiFetchError(
break f"Wiki-API nicht erreichbar (HTTP {response.status_code}). "
cont = payload["continue"] f"Prüfe WIKI_API_URL in der Konfiguration."
)
payload = response.json()
if "error" in payload:
code = payload["error"].get("code", "unknown")
info = payload["error"].get("info", "Unbekannter API-Fehler")
raise WikiFetchError(f"Wiki-API-Fehler ({code}): {info}")
titles = sorted({item["title"] for item in rows if item.get("title")}) rows.extend(payload.get("query", {}).get("recentchanges", []))
categories = await self._get_categories_for_titles(client, titles) if "continue" not in payload:
break
cont = payload["continue"]
titles = sorted({item["title"] for item in rows if item.get("title")})
categories = await self._get_categories_for_titles(client, titles)
except httpx.RequestError as exc:
raise WikiFetchError(f"Wiki-API-Anfrage fehlgeschlagen: {exc}") from exc
for row in rows: for row in rows:
row["categories"] = categories.get(row.get("title", ""), []) row["categories"] = categories.get(row.get("title", ""), [])
@@ -67,12 +88,15 @@ class WikiService:
"titles": "|".join(chunk), "titles": "|".join(chunk),
} }
response = await client.get(self.api_url, params=params) response = await client.get(self.api_url, params=params)
response.raise_for_status() if response.status_code >= 400:
continue
pages = response.json().get("query", {}).get("pages", {}) pages = response.json().get("query", {}).get("pages", {})
for page in pages.values(): for page in pages.values():
title = page.get("title") title = page.get("title")
if not title: if not title:
continue continue
raw_categories = page.get("categories", []) raw_categories = page.get("categories", [])
result[title] = [c.get("title", "").replace("Kategorie:", "") for c in raw_categories if c.get("title")] result[title] = [
c.get("title", "").replace("Kategorie:", "") for c in raw_categories if c.get("title")
]
return result return result

View File

@@ -9,6 +9,17 @@ label { display: grid; gap: 0.3rem; font-weight: 600; }
input, button, textarea { font: inherit; padding: 0.55rem; } input, button, textarea { font: inherit; padding: 0.55rem; }
textarea { width: 100%; resize: vertical; } textarea { width: 100%; resize: vertical; }
button { width: fit-content; background: #1b5dbf; color: #fff; border: 0; border-radius: 4px; cursor: pointer; } button { width: fit-content; background: #1b5dbf; color: #fff; border: 0; border-radius: 4px; cursor: pointer; }
.btn-secondary { background: #334155; }
.copy-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem; margin-bottom: 0.75rem; }
.copy-status { font-size: 0.92rem; color: #64748b; }
.copy-status.success { color: #027a48; }
.copy-status.error { color: #b42318; }
.newsletter-preview-frame { width: 100%; min-height: 520px; border: 1px solid #dde3ef; border-radius: 6px; background: #fff; }
.hidden-source { display: none; }
table { width: 100%; border-collapse: collapse; } table { width: 100%; border-collapse: collapse; }
th, td { border-bottom: 1px solid #e6ebf4; text-align: left; padding: 0.55rem; vertical-align: top; } th, td { border-bottom: 1px solid #e6ebf4; text-align: left; padding: 0.55rem; vertical-align: top; }
.checkbox { display: flex; align-items: center; gap: 0.45rem; font-weight: 500; } .checkbox { display: flex; align-items: center; gap: 0.45rem; font-weight: 500; }
.display-options { border: 1px solid #dde3ef; border-radius: 6px; padding: 0.75rem; display: grid; gap: 0.45rem; }
.display-options legend { font-weight: 600; padding: 0 0.25rem; }
.error { color: #b42318; background: #fef3f2; border: 1px solid #fecdca; padding: 0.6rem; border-radius: 4px; margin: 0 0 0.75rem; }
.hint { color: #64748b; font-size: 0.92rem; margin: 0 0 0.6rem; }

View File

@@ -0,0 +1,83 @@
function setCopyStatus(message, isError) {
const status = document.getElementById("copy-status");
if (!status) return;
status.textContent = message;
status.className = isError ? "copy-status error" : "copy-status success";
}
async function copyForOutlook() {
const htmlSource = document.getElementById("newsletter-html-source");
const plainSource = document.getElementById("newsletter-plain-source");
if (!htmlSource || !htmlSource.value.trim()) {
setCopyStatus("Bitte zuerst einen Newsletter generieren.", true);
return;
}
const html = htmlSource.value;
const plain = plainSource ? plainSource.value : "";
try {
if (navigator.clipboard && window.ClipboardItem) {
await navigator.clipboard.write([
new ClipboardItem({
"text/html": new Blob([html], { type: "text/html" }),
"text/plain": new Blob([plain], { type: "text/plain" }),
}),
]);
setCopyStatus("Newsletter mit Hyperlinks kopiert. In Outlook mit Strg+V einfügen.");
return;
}
} catch (err) {
console.warn("Clipboard API failed, using fallback.", err);
}
const container = document.createElement("div");
container.innerHTML = html;
container.style.position = "fixed";
container.style.left = "-9999px";
document.body.appendChild(container);
const range = document.createRange();
range.selectNodeContents(container);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
let ok = false;
try {
ok = document.execCommand("copy");
} finally {
selection.removeAllRanges();
document.body.removeChild(container);
}
setCopyStatus(
ok ? "Newsletter mit Hyperlinks kopiert. In Outlook mit Strg+V einfügen." : "Kopieren fehlgeschlagen.",
!ok
);
}
async function copyHtmlSource() {
const htmlSource = document.getElementById("newsletter-html-source");
if (!htmlSource || !htmlSource.value.trim()) {
setCopyStatus("Bitte zuerst einen Newsletter generieren.", true);
return;
}
try {
await navigator.clipboard.writeText(htmlSource.value);
setCopyStatus("HTML-Quelltext kopiert.");
} catch (err) {
setCopyStatus("Kopieren fehlgeschlagen.", true);
}
}
document.addEventListener("DOMContentLoaded", () => {
const htmlSource = document.getElementById("newsletter-html-source");
const frame = document.getElementById("newsletter-preview-frame");
if (htmlSource && frame && htmlSource.value.trim()) {
frame.srcdoc = htmlSource.value;
}
document.getElementById("copy-outlook-btn")?.addEventListener("click", copyForOutlook);
document.getElementById("copy-html-source-btn")?.addEventListener("click", copyHtmlSource);
});

View File

@@ -19,5 +19,6 @@
<main class="container"> <main class="container">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
{% block scripts %}{% endblock %}
</body> </body>
</html> </html>

View File

@@ -14,6 +14,21 @@
<input type="checkbox" name="only_edited" value="true" {% if filters.only_edited %}checked{% endif %}> <input type="checkbox" name="only_edited" value="true" {% if filters.only_edited %}checked{% endif %}>
Nur bearbeitete Artikel (keine neu erstellten) Nur bearbeitete Artikel (keine neu erstellten)
</label> </label>
<fieldset class="display-options">
<legend>Anzeige im Output</legend>
<label class="checkbox">
<input type="checkbox" name="show_date" value="true" {% if filters.display.show_date %}checked{% endif %}>
Datum anzeigen
</label>
<label class="checkbox">
<input type="checkbox" name="show_user" value="true" {% if filters.display.show_user %}checked{% endif %}>
Benutzer anzeigen
</label>
<label class="checkbox">
<input type="checkbox" name="show_category" value="true" {% if filters.display.show_category %}checked{% endif %}>
Kategorie anzeigen
</label>
</fieldset>
{% if user.role in ["admin", "editor"] %} {% if user.role in ["admin", "editor"] %}
<button type="submit">Newsletter generieren</button> <button type="submit">Newsletter generieren</button>
{% endif %} {% endif %}
@@ -21,6 +36,9 @@
{% if user.role == "admin" %} {% if user.role == "admin" %}
<p><a href="/admin/users">Zur Benutzerverwaltung</a></p> <p><a href="/admin/users">Zur Benutzerverwaltung</a></p>
{% endif %} {% endif %}
{% if wiki_error %}
<p class="error">{{ wiki_error }}</p>
{% endif %}
</section> </section>
{% if user.role in ["admin", "editor"] %} {% if user.role in ["admin", "editor"] %}
@@ -31,6 +49,9 @@
<input type="hidden" name="days" value="{{ filters.days }}"> <input type="hidden" name="days" value="{{ filters.days }}">
<input type="hidden" name="category" value="{{ filters.category }}"> <input type="hidden" name="category" value="{{ filters.category }}">
<input type="hidden" name="only_edited" value="{{ 'true' if filters.only_edited else 'false' }}"> <input type="hidden" name="only_edited" value="{{ 'true' if filters.only_edited else 'false' }}">
<input type="hidden" name="show_date" value="{{ 'true' if filters.display.show_date else 'false' }}">
<input type="hidden" name="show_user" value="{{ 'true' if filters.display.show_user else 'false' }}">
<input type="hidden" name="show_category" value="{{ 'true' if filters.display.show_category else 'false' }}">
<button type="submit">Newsletter jetzt senden</button> <button type="submit">Newsletter jetzt senden</button>
</form> </form>
</section> </section>
@@ -76,29 +97,76 @@
{% endif %} {% endif %}
<section class="card"> <section class="card">
<h3>Output: Outlook Text</h3> <h3>Newsletter-Vorschau (mit Hyperlinks)</h3>
<textarea rows="14" readonly>{{ plain_text }}</textarea> <p class="hint">Für Outlook den Button „Für Outlook kopieren“ nutzen dann bleiben Links als klickbare Hyperlinks erhalten.</p>
<div class="copy-actions">
<button type="button" id="copy-outlook-btn" class="btn-secondary">Für Outlook kopieren</button>
<button type="button" id="copy-html-source-btn" class="btn-secondary">HTML-Quelltext kopieren</button>
<span id="copy-status" class="copy-status" aria-live="polite"></span>
</div>
<iframe id="newsletter-preview-frame" class="newsletter-preview-frame" title="Newsletter Vorschau"></iframe>
<textarea id="newsletter-html-source" class="hidden-source" hidden readonly>{{ raw_html }}</textarea>
<textarea id="newsletter-plain-source" class="hidden-source" hidden readonly>{{ plain_text }}</textarea>
</section> </section>
<section class="card"> <section class="card">
<h3>Output: Raw HTML</h3> <h3>Output: Nur-Text (Fallback)</h3>
<textarea rows="14" readonly>{{ raw_html }}</textarea> <p class="hint">Enthält URLs als Text. Für klickbare Links bitte die Vorschau bzw. „Für Outlook kopieren“ verwenden.</p>
<textarea rows="16" readonly>{{ plain_text }}</textarea>
</section> </section>
<section class="card"> <section class="card">
<h3>Gefundene Artikel ({{ articles|length }})</h3> <h3>Output: HTML-Quelltext</h3>
<textarea rows="12" readonly>{{ raw_html }}</textarea>
</section>
<section class="card">
<h3>Neue Artikel ({{ articles_new|length }})</h3>
<table> <table>
<thead> <thead>
<tr><th>Titel</th><th>Zeit</th><th>Benutzer</th><th>Kategorien</th></tr> <tr>
<th>Titel</th>
{% if filters.display.show_date %}<th>Zeit</th>{% endif %}
{% if filters.display.show_user %}<th>Benutzer</th>{% endif %}
{% if filters.display.show_category %}<th>Kategorien</th>{% endif %}
</tr>
</thead> </thead>
<tbody> <tbody>
{% for item in articles %} {% for item in articles_new %}
<tr> <tr>
<td>{{ item.title }}</td> <td><a href="{{ wiki_article_url(item.title) }}" target="_blank" rel="noopener">{{ item.title }}</a></td>
<td>{{ item.timestamp }}</td> {% if filters.display.show_date %}<td>{{ item.timestamp }}</td>{% endif %}
<td>{{ item.user }}</td> {% if filters.display.show_user %}<td>{{ item.user }}</td>{% endif %}
<td>{{ item.categories|join(", ") }}</td> {% if filters.display.show_category %}<td>{{ item.categories|join(", ") }}</td>{% endif %}
</tr> </tr>
{% else %}
<tr><td colspan="4">Keine neuen Artikel im gewählten Zeitraum.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="card">
<h3>Bearbeitete Artikel ({{ articles_edited|length }})</h3>
<table>
<thead>
<tr>
<th>Titel</th>
{% if filters.display.show_date %}<th>Zeit</th>{% endif %}
{% if filters.display.show_user %}<th>Benutzer</th>{% endif %}
{% if filters.display.show_category %}<th>Kategorien</th>{% endif %}
</tr>
</thead>
<tbody>
{% for item in articles_edited %}
<tr>
<td><a href="{{ wiki_article_url(item.title) }}" target="_blank" rel="noopener">{{ item.title }}</a></td>
{% if filters.display.show_date %}<td>{{ item.timestamp }}</td>{% endif %}
{% if filters.display.show_user %}<td>{{ item.user }}</td>{% endif %}
{% if filters.display.show_category %}<td>{{ item.categories|join(", ") }}</td>{% endif %}
</tr>
{% else %}
<tr><td colspan="4">Keine bearbeiteten Artikel im gewählten Zeitraum.</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
@@ -122,3 +190,7 @@
</table> </table>
</section> </section>
{% endblock %} {% endblock %}
{% block scripts %}
<script src="/static/js/newsletter.js"></script>
{% endblock %}

View File

@@ -2,6 +2,9 @@
{% block content %} {% block content %}
<section class="card narrow"> <section class="card narrow">
<h2>Login</h2> <h2>Login</h2>
{% if error_message %}
<p class="error">{{ error_message }}</p>
{% endif %}
<form method="post" action="/login" class="form-grid"> <form method="post" action="/login" class="form-grid">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>E-Mail <label>E-Mail