From e5e4d812c7a4adf2ee9f2f31d2e2ab38f698f2db Mon Sep 17 00:00:00 2001 From: smueller Date: Fri, 3 Jul 2026 12:47:35 +0200 Subject: [PATCH] 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. --- .env.example | 5 +- README.md | 2 +- app/core/config.py | 4 +- app/core/cookies.py | 13 ++ app/main.py | 119 ++++++++++--- app/services/newsletter.py | 328 +++++++++++++++++++++++++++++++---- app/services/smtp_sender.py | 5 +- app/services/wiki.py | 52 ++++-- app/static/css/main.css | 11 ++ app/static/js/newsletter.js | 83 +++++++++ app/templates/base.html | 1 + app/templates/dashboard.html | 94 ++++++++-- app/templates/login.html | 3 + 13 files changed, 629 insertions(+), 91 deletions(-) create mode 100644 app/core/cookies.py create mode 100644 app/static/js/newsletter.js diff --git a/.env.example b/.env.example index a9da6c9..ca55781 100644 --- a/.env.example +++ b/.env.example @@ -7,8 +7,9 @@ HOST_PORT=8080 DATABASE_URL=sqlite:///./data/newsletter.db # Beispiel PostgreSQL: # 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=* -COOKIE_SECURE=true +COOKIE_SECURE=false +# Auf true setzen, wenn hinter HTTPS/TLS-Terminierung (Reverse Proxy) ADMIN_BOOTSTRAP_EMAIL=admin@internal.local ADMIN_BOOTSTRAP_PASSWORD=ChangeMe123! diff --git a/README.md b/README.md index 0c7f458..0382256 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Produktionsnahes internes Tool zur Erstellung von Thomas-Krenn.AG Newslettern au - Zeitraum in Tagen - Nur bearbeitete Artikel - Kategorie -- Datenquelle: MediaWiki API (`recentchanges`) +- Datenquelle: MediaWiki API (`recentchanges` über `wikiDE/api.php`) - Export: - Plain Text (für Outlook) - Raw HTML diff --git a/app/core/config.py b/app/core/config.py index d4e4b55..815bb5c 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -8,9 +8,9 @@ class Settings(BaseSettings): algorithm: str = "HS256" access_token_expire_minutes: int = 120 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 = "*" - cookie_secure: bool = True + cookie_secure: bool = False admin_bootstrap_email: str = "admin@internal.local" admin_bootstrap_password: str = "ChangeMe123!" diff --git a/app/core/cookies.py b/app/core/cookies.py new file mode 100644 index 0000000..604ecc6 --- /dev/null +++ b/app/core/cookies.py @@ -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" diff --git a/app/main.py b/app/main.py index b263746..47bc233 100644 --- a/app/main.py +++ b/app/main.py @@ -9,6 +9,7 @@ from sqlalchemy import inspect, text from sqlalchemy.orm import Session 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.db.session import Base, engine, get_db 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.config_store import get_config, set_config 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.wiki import WikiService +from app.services.wiki import WikiFetchError, WikiService app = FastAPI(title=settings.app_name) 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.mount("/static", StaticFiles(directory="app/static"), name="static") templates = Jinja2Templates(directory="app/templates") +templates.env.globals["wiki_article_url"] = article_url 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-Frame-Options"] = "DENY" 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): - 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 @@ -120,7 +131,10 @@ def login_page(request: Request, db: Session = Depends(get_db)): return _render( request, "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(...), 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() 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) 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 @@ -153,7 +184,20 @@ def logout(request: Request, csrf_token: str = Form(...)): @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) - 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() context["send_logs"] = logs return _render(request, "dashboard.html", context) @@ -164,27 +208,50 @@ async def dashboard_generate( request: Request, csrf_token: str = Form(...), days: int = Form(30), - only_edited: bool = Form(False), + only_edited: str | None = Form(None), 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), current_user: User = Depends(require_editor_or_admin), ): validate_csrf(request, csrf_token) days = max(1, min(days, 90)) - articles = await wiki_service.get_recent_changes(days=days, only_edited=only_edited) - filtered = filter_articles(articles, category_filter=category or None) - title = f"Thomas-Krenn Wiki Newsletter ({days} Tage)" - plain_text = create_plain_text(filtered, title) - raw_html = create_html(filtered, title) - run_scheduled_send_if_due(db, title, raw_html, plain_text) + only_edited_flag = only_edited == "true" + display = parse_display_options(show_date, show_user, show_category) + wiki_error = None + filtered: list = [] + articles_new: list = [] + 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.update( { "articles": filtered, + "articles_new": articles_new, + "articles_edited": articles_edited, "raw_html": raw_html, "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(), + "wiki_error": wiki_error, } ) return _render(request, "dashboard.html", context) @@ -195,17 +262,25 @@ async def send_now( request: Request, csrf_token: str = Form(...), days: int = Form(30), - only_edited: bool = Form(False), + only_edited: str | None = Form(None), 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), current_user: User = Depends(require_editor_or_admin), ): 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) - title = f"Thomas-Krenn Wiki Newsletter ({days} Tage)" - plain_text = create_plain_text(filtered, title) - raw_html = create_html(filtered, title) + 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) 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 533b4c5..464e0b4 100644 --- a/app/services/newsletter.py +++ b/app/services/newsletter.py @@ -1,6 +1,38 @@ -from datetime import datetime +from datetime import datetime, timedelta 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( @@ -17,60 +49,280 @@ def filter_articles( ] -def create_plain_text(articles: list[dict[str, Any]], title: str) -> str: - lines = [title, "=" * len(title), ""] - if not articles: - lines.append("Keine Artikel im gewählten Zeitraum gefunden.") - return "\n".join(lines) +def split_articles_by_type(articles: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + sorted_articles = sorted(articles, key=lambda a: a.get("timestamp", ""), reverse=True) + new_articles: list[dict[str, Any]] = [] + edited_articles: list[dict[str, Any]] = [] + new_titles: set[str] = set() - for idx, item in enumerate(articles, start=1): - stamp = _format_ts(item.get("timestamp")) - cat = ", ".join(item.get("categories", [])) or "Keine Kategorie" - comment = item.get("comment") or "-" + for item in sorted_articles: + title = item.get("title", "") + if not title or item.get("type") != "new": + 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( [ - f"{idx}. {item.get('title', 'Ohne Titel')}", - f" Zeit: {stamp}", - f" Benutzer: {item.get('user', '-')}", - f" Kategorien: {cat}", - f" Kommentar: {comment}", + SUBLINE, + "HINWEIS", + SUBLINE, + "Im gewählten Zeitraum wurden keine passenden Wiki-Änderungen gefunden.", "", + _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() -def create_html(articles: list[dict[str, Any]], title: str) -> str: - if not articles: - return f"

{escape(title)}

Keine Artikel im gewählten Zeitraum gefunden.

" +def create_html( + 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") + subject = escape(create_subject(days, category)) - blocks = [f"

{escape(title)}

", "") + + summary = _html_summary(new_articles, edited_articles) + return f""" + +{subject} + + + +
+ + + + +
+

Interner Wiki-Newsletter

+

{escape(ORG_NAME)}

+
+

Zeitraum: {period_start} bis {period_end} ({days} Tage)

+

Erstellt am: {created_at}

+ {category_row} +

Liebe Kolleginnen und Kollegen,

+

im Thomas-Krenn-Wiki wurden im ausgewählten Zeitraum folgende Artikel neu veröffentlicht bzw. aktualisiert:

+ {body} + {summary} +

Zum Thomas-Krenn-Wiki

+
+ Dieser Newsletter wurde automatisch erstellt und ist nur für den internen Gebrauch bei {escape(ORG_NAME)} bestimmt. +
+
+ +""" + + +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'

{escape(heading)} ({len(items)})

' + ] + if not items: + blocks.append(f'

{escape(empty_text)}

') + return "\n".join(blocks) + + blocks.append('') + for idx, item in enumerate(items, start=1): + blocks.append(_html_item_row(idx, item, display)) + blocks.append("
") 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"Datum: {escape(_format_ts(item.get('timestamp')))}") + if display["show_user"]: + meta_parts.append(f"Benutzer: {escape(str(item.get('user', '-')))}") + if display["show_category"]: + cat = escape(", ".join(item.get("categories", [])) or "Keine Kategorie") + meta_parts.append(f"Kategorie: {cat}") + meta_html = f'
{"  |  ".join(meta_parts)}
' if meta_parts else "" + + return f""" + {idx}. + + {title} + {meta_html} + +""" + + +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""" + +
+ Zusammenfassung
+ Neue Artikel: {len(new_articles)}
+ Bearbeitete Artikel: {len(edited_articles)}
+ Gesamt: {total} +
""" + + +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: if not value: return "-" try: 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: return value diff --git a/app/services/smtp_sender.py b/app/services/smtp_sender.py index 7642c32..0773aeb 100644 --- a/app/services/smtp_sender.py +++ b/app/services/smtp_sender.py @@ -1,5 +1,6 @@ import smtplib from datetime import datetime +from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from typing import Sequence @@ -40,7 +41,9 @@ def send_newsletter( _log(db, subject, recipients, "failed", "SMTP unvollständig konfiguriert.", scheduled) 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["From"] = settings["from_email"] msg["To"] = ", ".join(recipients) diff --git a/app/services/wiki.py b/app/services/wiki.py index 28bd866..c7f42c2 100644 --- a/app/services/wiki.py +++ b/app/services/wiki.py @@ -6,6 +6,14 @@ import httpx 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: def __init__(self) -> None: @@ -17,7 +25,7 @@ class WikiService: rcstart = now.strftime("%Y-%m-%dT%H:%M:%SZ") rcend = start.strftime("%Y-%m-%dT%H:%M:%SZ") - params = { + params: dict[str, str] = { "action": "query", "format": "json", "list": "recentchanges", @@ -34,18 +42,31 @@ class WikiService: rows: list[dict[str, Any]] = [] cont: dict[str, Any] = {} - async with httpx.AsyncClient(timeout=20.0) as client: - while True: - response = await client.get(self.api_url, params={**params, **cont}) - response.raise_for_status() - payload = response.json() - rows.extend(payload.get("query", {}).get("recentchanges", [])) - if "continue" not in payload: - break - cont = payload["continue"] + headers = {"User-Agent": USER_AGENT} + try: + async with httpx.AsyncClient(timeout=30.0, headers=headers, follow_redirects=True) as client: + while True: + response = await client.get(self.api_url, params={**params, **cont}) + if response.status_code >= 400: + raise WikiFetchError( + f"Wiki-API nicht erreichbar (HTTP {response.status_code}). " + 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")}) - categories = await self._get_categories_for_titles(client, titles) + rows.extend(payload.get("query", {}).get("recentchanges", [])) + 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: row["categories"] = categories.get(row.get("title", ""), []) @@ -67,12 +88,15 @@ class WikiService: "titles": "|".join(chunk), } 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", {}) for page in pages.values(): title = page.get("title") if not title: continue 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 diff --git a/app/static/css/main.css b/app/static/css/main.css index cef2a18..63c37a5 100644 --- a/app/static/css/main.css +++ b/app/static/css/main.css @@ -9,6 +9,17 @@ label { display: grid; gap: 0.3rem; font-weight: 600; } input, button, textarea { font: inherit; padding: 0.55rem; } textarea { width: 100%; resize: vertical; } 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; } 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; } +.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; } diff --git a/app/static/js/newsletter.js b/app/static/js/newsletter.js new file mode 100644 index 0000000..9ccc909 --- /dev/null +++ b/app/static/js/newsletter.js @@ -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); +}); diff --git a/app/templates/base.html b/app/templates/base.html index dc69878..e8be257 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -19,5 +19,6 @@
{% block content %}{% endblock %}
+{% block scripts %}{% endblock %} diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 28b37fa..da151ea 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -14,6 +14,21 @@ Nur bearbeitete Artikel (keine neu erstellten) +
+ Anzeige im Output + + + +
{% if user.role in ["admin", "editor"] %} {% endif %} @@ -21,6 +36,9 @@ {% if user.role == "admin" %}

Zur Benutzerverwaltung

{% endif %} + {% if wiki_error %} +

{{ wiki_error }}

+ {% endif %} {% if user.role in ["admin", "editor"] %} @@ -31,6 +49,9 @@ + + + @@ -76,29 +97,76 @@ {% endif %}
-

Output: Outlook Text

- +

Newsletter-Vorschau (mit Hyperlinks)

+

Für Outlook den Button „Für Outlook kopieren“ nutzen – dann bleiben Links als klickbare Hyperlinks erhalten.

+
+ + + +
+ + +
-

Output: Raw HTML

- +

Output: Nur-Text (Fallback)

+

Enthält URLs als Text. Für klickbare Links bitte die Vorschau bzw. „Für Outlook kopieren“ verwenden.

+
-

Gefundene Artikel ({{ articles|length }})

+

Output: HTML-Quelltext

+ +
+ +
+

Neue Artikel ({{ articles_new|length }})

- + + + {% if filters.display.show_date %}{% endif %} + {% if filters.display.show_user %}{% endif %} + {% if filters.display.show_category %}{% endif %} + - {% for item in articles %} + {% for item in articles_new %} - - - - + + {% if filters.display.show_date %}{% endif %} + {% if filters.display.show_user %}{% endif %} + {% if filters.display.show_category %}{% endif %} + {% else %} + + {% endfor %} + +
TitelZeitBenutzerKategorien
TitelZeitBenutzerKategorien
{{ item.title }}{{ item.timestamp }}{{ item.user }}{{ item.categories|join(", ") }}{{ item.title }}{{ item.timestamp }}{{ item.user }}{{ item.categories|join(", ") }}
Keine neuen Artikel im gewählten Zeitraum.
+
+ +
+

Bearbeitete Artikel ({{ articles_edited|length }})

+ + + + + {% if filters.display.show_date %}{% endif %} + {% if filters.display.show_user %}{% endif %} + {% if filters.display.show_category %}{% endif %} + + + + {% for item in articles_edited %} + + + {% if filters.display.show_date %}{% endif %} + {% if filters.display.show_user %}{% endif %} + {% if filters.display.show_category %}{% endif %} + + {% else %} + {% endfor %}
TitelZeitBenutzerKategorien
{{ item.title }}{{ item.timestamp }}{{ item.user }}{{ item.categories|join(", ") }}
Keine bearbeiteten Artikel im gewählten Zeitraum.
@@ -122,3 +190,7 @@
{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html index 0f441cd..07dd991 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -2,6 +2,9 @@ {% block content %}

Login

+ {% if error_message %} +

{{ error_message }}

+ {% endif %}