diff --git a/.dedupe/index.sqlite b/.dedupe/index.sqlite deleted file mode 100644 index d2fa618..0000000 Binary files a/.dedupe/index.sqlite and /dev/null differ diff --git a/.dedupe/report.csv b/.dedupe/report.csv deleted file mode 100644 index 8056de2..0000000 --- a/.dedupe/report.csv +++ /dev/null @@ -1 +0,0 @@ -group_id,canonical_path,dup_path,dup_size_bytes diff --git a/.env.example b/.env.example deleted file mode 100644 index fd3ded5..0000000 --- a/.env.example +++ /dev/null @@ -1,16 +0,0 @@ -# Copy to .env and fill in values - -# WordPress base URL (required) -WP_BASE_URL=https://your-site.tld - -# Authentication: prefer WP_AUTH_BASE64 OR use USERNAME+PASSWORD (Application Password) -# Example to generate: base64(username:application_password) -WP_AUTH_BASE64= - -# Alternatively provide username and application password -WP_USERNAME= -WP_PASSWORD= - -# OpenAI API key (optional, enables rewrite) -OPENAI_API_KEY= - diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index af3394f..5d55808 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -19,16 +19,9 @@ jobs: username: oliver key: ${{ secrets.HETZNER_SSH_KEY }} port: 22 - envs: APP_ADMIN_USERNAME,APP_ADMIN_PASSWORD script: | - cd /opt/rss-news + cd rss-news git pull origin main source .venv/bin/activate pip install -r requirements.txt - pip install -r backend/requirements.txt || true - sudo systemctl restart rss-news-api - sleep 3 - BASE_URL="https://news.vanityontour.de" APP_ADMIN_USERNAME="${APP_ADMIN_USERNAME}" APP_ADMIN_PASSWORD="${APP_ADMIN_PASSWORD}" bash scripts/smoke_backend.sh - env: - APP_ADMIN_USERNAME: ${{ secrets.NEWS_APP_ADMIN_USERNAME }} - APP_ADMIN_PASSWORD: ${{ secrets.NEWS_APP_ADMIN_PASSWORD }} + sudo systemctl restart rss-app diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 1d627db..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Backend Tests - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - backend-tests: - runs-on: ubuntu-latest - timeout-minutes: 15 - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r backend/requirements.txt - pip install -r backend/requirements-test.txt - - - name: Run tests with coverage - env: - APP_DB_PATH: /tmp/rss_news_test.db - run: | - pytest backend/tests --cov=backend/app --cov-report=term-missing --cov-report=xml - - - name: Upload coverage artifact - uses: actions/upload-artifact@v4 - with: - name: coverage-xml - path: coverage.xml diff --git a/.gitignore b/.gitignore index 2e1a7b7..d714f32 100644 --- a/.gitignore +++ b/.gitignore @@ -23,17 +23,6 @@ Thumbs.db # dotenv / Umgebungsvariablen .env -# SQLite-Datenbanken NIE versionieren. -# Die Produktivdatenbank liegt auf dem Server unter demselben Pfad -# (backend/data/rss_news.db, 95 MB). Solange die Datei im Repo lag, haette -# jeder Commit, der sie mitnimmt, beim naechsten Deploy die Live-Daten mit -# einem Entwicklungsstand ueberschrieben. init_db() legt die Datei beim -# ersten Start selbst an. -*.db -*.db-wal -*.db-shm -backend/data/ - # JSON-Datenbanken / Zwischenspeicherung # *.json # !feeds.json # optional entfernbar, wenn du Beispielfeeds mit versionieren willst @@ -46,5 +35,3 @@ internal/start.sh internal/copy_files.sh internal/_line.txt internal/push_commit.txt -internal/git.sh -CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 7251de6..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,40 +0,0 @@ -# Repository Guidelines - -## Project Structure & Module Organization -- `app.py`: Streamlit UI (entry point for the app). -- `main.py`: RSS fetching, rewrite, and WordPress upload logic. -- `utils/`: Helpers (image/article extraction, WP uploader, UI helpers). -- `pages/`: Streamlit pages (e.g., `01_feed_manager.py`, `log_viewer.py`). -- `data/`: JSON state (`articles.json`, `feeds.json`). -- `logs/`: Runtime logs (`rss_tool.log`). -- `docs/`: Project notes (e.g., roadmap). -- `__version__.py`: Version string written by `versioning.py`. - -## Build, Test, and Development Commands -- Create env: `python -m venv .venv && source .venv/bin/activate` -- Install deps: `pip install -r requirements.txt` -- Run app: `streamlit run app.py` -- Version bump: `python versioning.py --level patch --push` (updates `__version__.py`, prepares `CHANGELOG.md`, creates tag; see `--help`). - -## Coding Style & Naming Conventions -- Python 3.10+, PEP 8, 4-space indentation, type hints where practical. -- Modules and functions: `snake_case`; classes: `PascalCase`. -- Streamlit pages: numeric prefix for order, e.g., `pages/01_feature.py`. -- Keep functions small and pure in `utils/`; isolate I/O in app layers. -- Suggested tools (optional): Black (`black .`) and Ruff (`ruff check .`). - -## Testing Guidelines -- Framework: pytest (recommended). Place tests under `tests/` with `test_*.py`. -- Unit tests for `utils/*`; light integration checks for `main.py` with temporary files. -- Run: `pytest -q`. Add coverage if needed (e.g., `pytest --cov=utils`). -- Test data: avoid mutating files in `data/`; use temp dirs or fixtures. - -## Commit & Pull Request Guidelines -- Commits: imperative mood, concise; examples: `Add feed dedupe`, `Fix WP upload retry`, `Bump version to v1.7.0`. -- PRs: clear description, linked issue, screenshots/GIFs for UI changes, note env variables touched. -- Update `CHANGELOG.md` and bump version via `versioning.py` before release PRs. - -## Security & Configuration Tips -- Required env: `OPENAI_API_KEY`, `WP_BASE_URL`, `WP_USERNAME`, `WP_PASSWORD` or `WP_AUTH_BASE64` (see `.env`). -- Never commit secrets; `.env` is git-ignored. Avoid hardcoded credentials; prefer `os.getenv`. -- Logs and data may contain content; do not commit `logs/` or large `data/` snapshots. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cbf8c4..0e8d5df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,110 +1,3 @@ -## [1.8.1] - 2026-08-24 - -### 📋 Freigabe-Seite - - Neue Seite `/admin/freigabe`: alle Artikel im Status „Wartet auf Freigabe" auf einer Arbeitsliste - - Pro Artikel: Hauptbild, Relevanz-Score mit Begründung, Tags, Wortzahl, Alter, Link zum Originalartikel - - Der umgeschriebene Text wird **lesbar gerendert** statt als Markup — durch einen Tag-Whitelist-Filter, weil er aus Modell-Ausgabe über fremde Webseiten stammt - - Drei Aktionen direkt an jedem Artikel: ✅ Freigeben (mit Notizfeld), ✏️ Neu schreiben, ❌ Verwerfen - - Nach jeder Aktion zurück zur Liste, damit sich eine Warteschlange am Stück abarbeiten lässt - - Verlinkt aus der Navigation aller Admin-Seiten, mit Zähler im Dashboard - -### 📨 Telegram - - Freigabe-Meldung enthält jetzt zwei Links: „Diesen Artikel prüfen" (Detailseite) und „Alle wartenden Artikel" (Warteschlange) - - Pipeline-Zusammenfassung verlinkt die Warteschlange, sobald etwas darauf wartet - -### 🧹 Status-Altlasten entfernt - - Artikelliste zeigte interne Kürzel (`pending_review`, `approved`, `error`) — jetzt überall Klartext - - Status `review` (Relevanz-Warnzone 60–79) wurde als „Rewrite" angezeigt, was etwas anderes bedeutet — heißt jetzt „Niedrige Relevanz" - - Übergang `Rewrite → Freigegeben` entfernt: ein zweiter Weg an der Freigabe vorbei - - Übergang `Freigegeben → Veröffentlicht` entfernt: das setzen WordPress bzw. der WP-Sync, nicht die Hand - - API-Statusliste wird aus dem Statusmodell abgeleitet statt handgepflegt (kannte `freigabe` nicht und lehnte den Wechsel mit 422 ab) - -### 🐛 Behoben - - `list_articles` las `content_rewritten` nicht mit — die Freigabe-Seite hätte nie einen Text angezeigt - ---- - -## [1.8.0] - 2026-08-24 - -### ⚖️ Redaktionelle Freigabe vor der Veröffentlichung (Art. 50 Abs. 4 KI-VO) - - Neuer Status **„Wartet auf Freigabe"** (`pending_review`) zwischen Rewrite und Publish - - Die Pipeline endet jetzt beim Rewrite: **kein WordPress-Beitrag und kein Publish-Slot**, bevor ein Mensch den Artikel freigegeben hat - - Neuer Button „✅ Redaktionell geprüft & freigeben" auf der Artikel-Detailseite, mit optionaler Prüfnotiz - - Freigabe stempelt Prüfer und **Systemzeit** in `editorial_review_at` / `_by` / `_note` — nicht editierbar, zusätzlich im Audit-Trail (`review_events`) - - Erst die Freigabe reserviert den Slot und legt den WordPress-Beitrag an (Status `future`) - - Schlägt WordPress dabei fehl, fällt der Artikel mit freigegebenem Slot zurück in die Warteschlange — Freigabe einfach wiederholen - - Kein Schlupfloch: auch der manuelle Statuswechsel nach `publish` (Admin-UI und API) stempelt die Freigabe - - Altbestand bleibt unberührt: bereits freigegebene/veröffentlichte Artikel brauchen keinen Stempel - -### 📨 Telegram - - Neue Meldung „📝 Neuer Artikel wartet auf Freigabe" mit Deep-Link ins Portal, bewusst **ohne** Freigabe-Button - - Neue Bestätigung „✅ Freigegeben und eingeplant" mit Prüfer und Slot - - Relevanz-Warnung enthält zusätzlich den Portal-Link - - Pipeline-Zusammenfassung und `/status` zeigen die Zahl der wartenden Artikel - -### 🖥️ Admin-UI - - Dashboard-Banner mit Anzahl der Artikel, die auf Freigabe warten, inkl. Direktfilter - - Klartext-Bezeichnungen für alle Status statt interner Kürzel - - Publish-Bereitschaft weist „Redaktionelle Freigabe fehlt" aus - -### 🔧 Technisch - - Migration: Spalten `editorial_review_at/_by/_note`, Status-CHECK um `pending_review` erweitert - - Spalten-Migration wird nach den Tabellen-Neubauten erneut angewandt (der ältere `no_image`-Rebuild hat frisch angelegte Spalten sonst wieder verworfen) - - Neue Konfiguration `EDITORIAL_REVIEW_REQUIRED` (Default `true`) und `PORTAL_BASE_URL` - - Neue Doku `docs/KI-VO.md`; `docs/AUTOMATION.md` an den neuen Ablauf angepasst - - 12 neue Tests in `backend/tests/test_editorial_review.py` - ---- - -## [1.7.1] - 2025-08-24 - -### ✨ Security angepasst - - alle Credentials in die .env Datei verschoben - - beim Start der App werden die Credentials geprüft und beim fehlen entsprechende Meldungen ausgegeben - ---- - -## [1.7.0] - 2025-08-24 - -### Multi-Select & Massenoperationen: - - ✅ Checkboxes für Artikel-Auswahl im "Artikel verwalten" Bereich - - ✅ "Alle auswählen" / "Auswahl aufheben" Buttons - - ✅ Massenoperationen für ausgewählte Artikel: - - Bulk Status-Änderung für mehrere Artikel gleichzeitig - - Bulk Artikel-Umschreibung mit automatischer Status-Verwaltung - - Bulk WordPress-Upload nur für "Process"-Artikel - - Bulk Papierkorb-Funktion - -### Schnellaktionen Integration: - - ✅ Feed-Aktualisierung direkt im Artikel-Tab verfügbar - - ✅ Alle Dashboard-Schnellaktionen in Artikel-Verwaltung integriert - - ✅ Intelligente Anzeige nur relevanter Operationen (z.B. WordPress-Upload nur bei Process-Artikeln) - -### 🔧 Verbesserungen - - - UI/UX: Verbesserte Artikel-Card-Layouts mit Checkbox-Integration - - Workflow: Streamlined Artikel-Management ohne Tab-Wechsel nötig - - Feedback: Detaillierte Statusmeldungen bei Massenoperationen - - Performance: Optimierte Session-State-Verwaltung für Artikel-Auswahl - -### 🏗️ Technische Änderungen - - - Session State Erweiterung um selected_articles Set - - Neue Bulk-Operation-Funktionen in app.py:326-467 - - Überarbeitetes Artikel-Card-Layout mit 3-Spalten-Design - - Integration bestehender WordPress-Upload und Rewrite-Funktionen - ---- - -## [1.6.3] - 2025-08-18 - -### 🔧 Verbesserungen -- **SyleSheet erneut hinzugefügt - - Style wurde bei einem Release leider vergessen - - Style auf DarkMode angepasst - ---- - ## [v1.6.2] - 2025-08-16 ### 🐛 Kritische Fehlerbehebung diff --git a/README.md b/README.md index a9c493c..0f3d86c 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,76 @@ -# rss-news (Rebuild) +# 📰 RSS News Bot -`rss-news` wird als bestehendes Repository weitergefuehrt und schrittweise zu einer robusten, rechtssicheren News-Pipeline neu aufgebaut. +Ein intelligentes Tool zum Einlesen, Umschreiben und Veröffentlichen von Artikeln aus RSS-Feeds – mit automatischer Tag-Erkennung, KI-unterstütztem Rewrite via GPT-4, Bildextraktion aus Originalartikeln und optionaler DALL·E-Bildgenerierung. -Aktueller Stand: -- Alte Streamlit-App wird nicht produktiv genutzt. -- `news.vanityontour.de` wird bis zum Go-Live der neuen App auf `https://vanityontour.de` umgeleitet. -- Planung, Doku und Wiki werden als Grundlage fuer den Neuaufbau gepflegt. + + + + -## Ziele -- RSS-gestuetzte Artikelverarbeitung mit klaren Quellregeln -- Rechtssichere Nutzung (Quellen, Attribution, Lizenzinformationen) -- Zuverlaessige Automatisierung auf Hetzner -- Publikation nach WordPress (IONOS aktuell, spaeter offen) -- Zugriff nur nach Login (zunaechst User/Password) +--- -## Architektur-Richtung (MVP) -- Backend: `Python + FastAPI` -- Jobs: Queue-Worker (z. B. Redis + RQ/Celery) -- Daten: SQLite fuer MVP, spaeter optional PostgreSQL -- Auth: Session-Login mit einem Admin-User -- Publishing: WordPress REST API (Status zunaechst `pending`) +## 🚀 Features -Details: `docs/PROJECT_PLAN.md` +- 📡 **RSS-Feeds verwalten** (hinzufügen, aktualisieren) +- ✍️ **Artikel automatisch umschreiben** mit GPT-4 +- 🏷️ **Tags automatisch generieren** +- 🖼️ **Bilder aus Originalartikeln extrahieren** +- 🪄 **Optionales DALL·E-Bild generieren** +- 🔧 **Bearbeiten von Bildmetadaten** +- 🗂️ **Statusverwaltung der Artikel (New, Rewrite, Process, etc.)** +- 📜 **Log-Viewer-Seite integriert** +- 📥 **Export zur Veröffentlichung auf WordPress vorbereitet** +- 📋 Artikeltabelle mit Status-Filter +- 🔍 Artikel-Expander mit Rewrite, Tags & Bildern +- 🪄 Button für KI-Bildgenerierung -## Projektsteuerung -- GitHub Project: `https://github.com/users/OliverGiertz/projects/3/views/1` -- Dieses Board ist die zentrale Steuerung fuer ToDos, Bugs, Verbesserungen. -- Wiki-Struktur liegt unter `docs/wiki/`. -## Dokumentation -- Projektplan: `docs/PROJECT_PLAN.md` -- ToDo-Liste: `docs/TODO.md` -- Quell- und Lizenzpolicy: `docs/SOURCE_POLICY.md` -- KI-Verordnung / redaktionelle Freigabe: `docs/KI-VO.md` -- Wiki Home: `docs/wiki/Home.md` +--- -## Lokale Entwicklung (Legacy-Code) -Der vorhandene Legacy-Stand kann weiterhin lokal gestartet werden: +## 🧱 Projektstruktur + +ss-news/ +├── app.py # Haupt-UI mit Streamlit +├── main.py # Logik für Feed-Import und Verarbeitung +├── utils/ +│ └── image_extractor.py # Bilder aus Originalartikeln extrahieren +│ └── dalle_generator.py # DALL·E-Integration (KI-Bild) +├── pages/ +│ └── log_viewer.py # UI zur Anzeige der Logs +├── data/ +│ └── articles.json # Gespeicherte Artikel +│ └── feeds.json # Gespeicherte Feed-URLs +├── logs/ +│ └── rss_tool.log # Logging der Verarbeitung +├── versioning.py # CLI-Tool zur Versionierung & Release +├── TEST-CHECKLIST.md # Manuelle Prüfliste für Releases +├── version.py # Aktuelle Version +└── CHANGELOG.md # Änderungsprotokoll + + +--- + +## ⚙️ Installation ```bash +git clone https://github.com/OliverGiertz/rss-news.git +cd rss-news python -m venv .venv source .venv/bin/activate pip install -r requirements.txt -streamlit run app.py ``` -Hinweis: Diese App ist funktional historisch und wird durch die neue Architektur ersetzt. +--- -## Deployment-Zielbild -- Betrieb auf Hetzner -- Reverse Proxy via CloudPanel/Nginx -- Produktive Domain: `news.vanityontour.de` -- Bis zur Fertigstellung: Redirect auf `https://vanityontour.de` +## Update +Ein Update Script findest du hier: https://gist.github.com/OliverGiertz/ad33ae3de9aa1c1163dad5fe8affb6ca -## Sicherheit -- Keine Secrets im Repository -- `.env` lokal/auf Server, nie committen -- Auth-Pflicht fuer die neue WebApp -- spaeter optional: Passkeys/WebAuthn +```bash +bash update.sh +``` -## Rechtlicher Hinweis -Dieses Projekt verarbeitet nur Quellen mit dokumentierter Nutzungsgrundlage. Vor produktiver Nutzung ist eine finale rechtliche Pruefung der ausgewaehlten Feeds notwendig. + +## ▶️ Starten der App + +streamlit run app.py diff --git a/__version__.py b/__version__.py index 6a6d0f8..c8a19b1 100644 --- a/__version__.py +++ b/__version__.py @@ -1 +1 @@ -VERSION = "1.7.1" +VERSION = "1.6.2" diff --git a/app.py b/app.py index f161a65..0296f0b 100644 --- a/app.py +++ b/app.py @@ -13,8 +13,6 @@ from main import ( ) from utils.dalle_generator import generate_dalle_image from utils.wordpress_uploader import WordPressUploader -from utils.css_loader import load_css, apply_dark_theme -from utils.config import validate_env import os from collections import Counter import time @@ -26,22 +24,103 @@ st.set_page_config( initial_sidebar_state="collapsed" ) -# === CSS & Theme laden === -load_css() -apply_dark_theme() - -# === Environment-Validierung (.env) === -env_check = validate_env() -if not env_check.get("ok"): - st.error("🔒 Sicherheits-/Konfigurationshinweis: Bitte .env korrekt konfigurieren.") - for msg in env_check.get("errors", []): - st.markdown(f"- ❌ {msg}") - for msg in env_check.get("warnings", []): - st.markdown(f"- ⚠️ {msg}") -elif env_check.get("warnings"): - st.info("ℹ️ Hinweise zur Konfiguration:") - for msg in env_check.get("warnings", []): - st.markdown(f"- ⚠️ {msg}") +# === Custom CSS für modernes Design === +st.markdown(""" + +""", unsafe_allow_html=True) # === Initialize Session State === if 'selected_articles' not in st.session_state: @@ -261,8 +340,9 @@ with tab1:
Basic-Auth mit Base64-kodierten Zugangsdaten:Authorization: Basic <base64(username:password)>WP_AUTH_BASE64 setzen (aus username:application_password erzeugt).WP_USERNAME und WP_PASSWORD gesetzt werden; dann wird Base64 zur Laufzeit generiert.
- edit_posts - Beiträge erstellen und bearbeitenpublish_posts - Beiträge veröffentlichen (für Status-Änderungen)upload_files - Dateien hochladen (für spätere Bild-Uploads)edit_categories - Kategorien verwaltenedit_tags - Tags verwalten
- {escape(re.sub(r'<[^>]+>', ' ', text))}
" - cleaned = "".join(parser.parts).strip() - if not cleaned: - return "" - # Plain text without any block tag would collapse into one run. - if "" not in cleaned and "
{cleaned}
" - return cleaned - - -def _safe_admin_redirect(value: str | None, fallback: str) -> str: - """Only allow redirects back into the admin UI, never to another host.""" - target = (value or "").strip() - if target.startswith("/admin/") and "//" not in target[1:] and "\\" not in target: - return target - return fallback - - -def _publish_readiness(article: dict, meta: dict) -> tuple[bool, list[str]]: - reasons: list[str] = [] - status_ui = internal_to_ui_status(article.get("status")) - if status_ui not in {"publish", "published"}: - reasons.append("Status ist nicht 'publish'") - # Altbestand (bereits veroeffentlicht) hat keinen Stempel und braucht keinen. - if ( - settings.editorial_review_required - and status_ui != "published" - and not (article.get("editorial_review_at") or "") - ): - reasons.append("Redaktionelle Freigabe fehlt") - image_review = meta.get("image_review") if isinstance(meta.get("image_review"), dict) else {} - selected_image = image_review.get("selected_url") if isinstance(image_review.get("selected_url"), str) else None - if not selected_image: - reasons.append("Hauptbild nicht ausgewählt") - return len(reasons) == 0, reasons - - -def _classify_publish_error(error_message: str | None) -> tuple[str, str]: - text = (error_message or "").lower() - if not text.strip(): - return "ok", "-" - if "rechtsfreigabe fehlt" in text or "hauptbild nicht gesetzt" in text or "status ist nicht" in text: - return "policy", "Artikelvoraussetzungen im UI prüfen (Status/Hauptbild)." - if "401" in text or "403" in text or "authorization" in text or "forbidden" in text or "unauthorized" in text: - return "auth", "WordPress Nutzer/App-Passwort prüfen." - if "404" in text and ("media" in text or "posts" in text or "wp-json" in text): - return "api", "WordPress REST-Endpunkt prüfen (`/wp-json/wp/v2`)." - if "timed out" in text or "timeout" in text or "nodename nor servname provided" in text or "name or service not known" in text: - return "dns", "DNS/Netzwerk zur WordPress-Domain prüfen." - if "media-upload fehlgeschlagen" in text or "liefert kein bild" in text or "featured_media" in text: - return "media", "Bild-URL/Format prüfen oder anderes Hauptbild auswählen." - return "unknown", "Fehlerdetails prüfen und bei Bedarf Job erneut starten." - - -def _legal_checklist(article: dict, feed: dict | None) -> list[dict[str, str]]: - meta = article.get("meta", {}) - extraction = meta.get("extraction") if isinstance(meta.get("extraction"), dict) else {} - attribution = meta.get("attribution") if isinstance(meta.get("attribution"), dict) else {} - - checks: list[dict[str, str]] = [] - checks.append( - { - "label": "Original-Link vorhanden", - "status": "ok" if article.get("source_url") else "missing", - "value": article.get("source_url") or "-", - } - ) - checks.append( - { - "label": "Autor vorhanden", - "status": "ok" if article.get("author") else "missing", - "value": article.get("author") or "-", - } - ) - checks.append( - { - "label": "Bilder extrahiert", - "status": "ok" if article.get("image_urls_json") else "missing", - "value": str(len(extraction.get("images", []))) if isinstance(extraction.get("images"), list) else "0", - } - ) - checks.append( - { - "label": "Pressekontakt", - "status": "ok" if article.get("press_contact") else "missing", - "value": article.get("press_contact") or extraction.get("press_contact") or "-", - } - ) - checks.append( - { - "label": "Lizenz/Terms", - "status": "ok" if article.get("source_license_name_snapshot") and article.get("source_terms_url_snapshot") else "missing", - "value": f"{article.get('source_license_name_snapshot') or attribution.get('source_license_name') or '-'} | {article.get('source_terms_url_snapshot') or attribution.get('source_terms_url') or '-'}", - } - ) - checks.append( - { - "label": "Risiko-Status Quelle", - "status": "ok" if (feed and feed.get("source_risk_level") == "green") else "missing", - "value": feed.get("source_risk_level") if feed else "-", - } - ) - image_review = meta.get("image_review") if isinstance(meta.get("image_review"), dict) else {} - selected_image = image_review.get("selected_url") if isinstance(image_review.get("selected_url"), str) else None - checks.append( - { - "label": "Hauptbild ausgewählt", - "status": "ok" if selected_image else "missing", - "value": selected_image or "-", - } - ) - return checks - - -def _build_connectivity_targets() -> list[dict[str, str]]: - targets: list[dict[str, str]] = [] - seen: set[tuple[str, str]] = set() - - def add_target(label: str, kind: str, value: str) -> None: - normalized = (value or "").strip() - if not normalized: - return - key = (kind, normalized.lower()) - if key in seen: - return - seen.add(key) - targets.append({"label": label, "kind": kind, "value": normalized}) - - add_target("OpenAI API", "host", "api.openai.com") - if settings.wordpress_base_url: - parsed = urlparse(settings.wordpress_base_url) - if parsed.hostname: - add_target("WordPress Host", "host", parsed.hostname) - wp_api_url = f"{settings.wordpress_base_url.rstrip('/')}/wp-json/wp/v2" - add_target("WordPress REST", "url", wp_api_url) - - for feed in list_feeds(): - name = (feed.get("name") or "").strip() or f"Feed #{feed.get('id')}" - feed_url = str(feed.get("url") or "").strip() - if not feed_url: - continue - parsed = urlparse(feed_url) - if parsed.hostname: - add_target(f"{name} (Feed)", "host", parsed.hostname) - add_target(f"{name} (Feed URL)", "url", feed_url) - - return targets - - -def _run_connectivity_check(target: dict[str, str]) -> dict[str, object]: - kind = target.get("kind", "") - value = str(target.get("value") or "") - row: dict[str, object] = { - "label": target.get("label") or "-", - "kind": kind, - "target": value, - "dns_ok": False, - "dns_info": "-", - "tcp_ok": False, - "tcp_info": "-", - "http_ok": False, - "http_info": "-", - "duration_ms": 0, - "ok": False, - } - started = time.perf_counter() - try: - hostname = value if kind == "host" else (urlparse(value).hostname or "") - port = 443 - if kind == "url": - parsed = urlparse(value) - if parsed.scheme not in {"http", "https"}: - row["http_info"] = f"unsupported scheme: {parsed.scheme or '-'}" - return row - port = 443 if parsed.scheme == "https" else 80 - if not hostname: - row["dns_info"] = "host fehlt" - return row - - try: - addr_info = socket.getaddrinfo(hostname, None, proto=socket.IPPROTO_TCP) - ips = sorted({entry[4][0] for entry in addr_info if entry and len(entry) > 4 and entry[4]}) - row["dns_ok"] = True - row["dns_info"] = ", ".join(ips[:3]) if ips else "resolved" - except Exception as exc: - row["dns_info"] = str(exc) - return row - - try: - socket.create_connection((hostname, port), timeout=4).close() - row["tcp_ok"] = True - row["tcp_info"] = f"port {port} erreichbar" - except Exception as exc: - row["tcp_info"] = str(exc) - return row - - if kind == "host": - row["http_ok"] = True - row["http_info"] = "n/a (host-only)" - row["ok"] = True - return row - - try: - req = UrlRequest( - url=value, - headers={"User-Agent": IMAGE_PROXY_USER_AGENT, "Accept": "*/*"}, - ) - with urlopen(req, timeout=6, context=ssl.create_default_context()) as resp: - code = getattr(resp, "status", None) or resp.getcode() - row["http_ok"] = True - row["http_info"] = f"HTTP {code}" - except Exception as exc: - row["http_info"] = str(exc) - return row - - row["ok"] = bool(row["dns_ok"] and row["tcp_ok"] and row["http_ok"]) - return row - finally: - row["duration_ms"] = int((time.perf_counter() - started) * 1000) - - -def _upsert_article_from_existing( - article: dict, - *, - content_rewritten: str | None = None, - status: str | None = None, - wp_post_id: int | None | object = _UNSET, - wp_post_url: str | None | object = _UNSET, - publish_attempts: int | object = _UNSET, - publish_last_error: str | None | object = _UNSET, - published_to_wp_at: str | None | object = _UNSET, - meta_json: str | None | object = _UNSET, -) -> None: - rewritten = article.get("content_rewritten") if content_rewritten is None else content_rewritten - upsert_article( - ArticleUpsert( - feed_id=article.get("feed_id"), - source_article_id=article.get("source_article_id"), - source_hash=article.get("source_hash"), - title=article.get("title"), - source_url=article.get("source_url"), - canonical_url=article.get("canonical_url"), - published_at=article.get("published_at"), - author=article.get("author"), - summary=article.get("summary"), - content_raw=article.get("content_raw"), - content_rewritten=rewritten, - image_urls_json=article.get("image_urls_json"), - press_contact=article.get("press_contact"), - source_name_snapshot=article.get("source_name_snapshot"), - source_terms_url_snapshot=article.get("source_terms_url_snapshot"), - source_license_name_snapshot=article.get("source_license_name_snapshot"), - legal_checked=bool(int(article.get("legal_checked", 0))), - legal_checked_at=article.get("legal_checked_at"), - legal_note=article.get("legal_note"), - wp_post_id=article.get("wp_post_id") if wp_post_id is _UNSET else wp_post_id, - wp_post_url=article.get("wp_post_url") if wp_post_url is _UNSET else wp_post_url, - publish_attempts=int(article.get("publish_attempts", 0)) if publish_attempts is _UNSET else publish_attempts, - publish_last_error=article.get("publish_last_error") if publish_last_error is _UNSET else publish_last_error, - published_to_wp_at=article.get("published_to_wp_at") if published_to_wp_at is _UNSET else published_to_wp_at, - word_count=len(str(rewritten or "").split()), - status=article.get("status") if status is None else status, - meta_json=article.get("meta_json") if meta_json is _UNSET else meta_json, - ) - ) - - -@router.get("/admin", response_class=HTMLResponse) -def admin_index(request: Request): - user = _admin_user(request) - if not user: - return RedirectResponse(url="/admin/login", status_code=303) - return RedirectResponse(url="/admin/dashboard", status_code=303) - - -@router.get("/admin/login", response_class=HTMLResponse) -def admin_login_page(request: Request): - return templates.TemplateResponse( - request, - "admin_login.html", - {"request": request, "title": "Admin Login", "error": request.query_params.get("error")}, - ) - - -@router.post("/admin/login") -def admin_login(request: Request, username: str = Form(...), password: str = Form(...)): - if not verify_credentials(username, password): - return RedirectResponse(url="/admin/login?error=1", status_code=303) - - token = create_session_token(username) - response = RedirectResponse(url="/admin/dashboard", status_code=303) - response.set_cookie( - key=settings.session_cookie_name, - value=token, - max_age=settings.session_max_age_seconds, - httponly=True, - secure=False, - samesite="lax", - ) - return response - - -@router.post("/admin/logout") -def admin_logout(): - response = RedirectResponse(url="/admin/login", status_code=303) - response.delete_cookie(settings.session_cookie_name) - return response - - -@router.get("/admin/dashboard", response_class=HTMLResponse) -def admin_dashboard(request: Request): - user = _admin_user(request) - if not user: - return RedirectResponse(url="/admin/login", status_code=303) - - sources = list_sources() - source_policy = {s["id"]: evaluate_source_policy(s) for s in sources} - feeds = list_feeds() - runs = list_runs(limit=30) - publish_jobs = list_publish_jobs(limit=30) - for job in publish_jobs: - category, hint = _classify_publish_error(job.get("error_message")) - job["error_category"] = category - job["error_hint"] = hint - status_filter = request.query_params.get("status_filter") - internal_filter = ui_to_internal_status(status_filter) if status_filter else None - if status_filter in set(UI_STATUSES): - articles = list_articles(limit=100, status_filter=internal_filter) - else: - status_filter = "" - articles = [a for a in list_articles(limit=250) if internal_to_ui_status(a.get("status")) != "close"][:100] - for article in articles: - meta = _parse_meta_json(article.get("meta_json")) - extraction = meta.get("extraction") if isinstance(meta.get("extraction"), dict) else {} - images = _read_article_images(article, extraction) - article["meta"] = meta - ready, reasons = _publish_readiness(article, meta) - article["publish_ready"] = ready - article["publish_blockers"] = reasons - article["extracted_images"] = images - article["image_entries"] = _build_image_entries(article, extraction, meta) - image_review = meta.get("image_review") if isinstance(meta.get("image_review"), dict) else {} - article["selected_image_url"] = image_review.get("selected_url") if isinstance(image_review.get("selected_url"), str) else None - article["selected_image_proxy_url"] = ( - f"/admin/images/proxy?{urlencode({'url': article['selected_image_url']})}" if article.get("selected_image_url") else None - ) - if not article.get("press_contact") and isinstance(extraction.get("press_contact"), str): - article["press_contact"] = extraction.get("press_contact") - article["extraction_error"] = extraction.get("extraction_error") if isinstance(extraction.get("extraction_error"), str) else None - article["days_old"] = article_age_days(article.get("published_at")) - article["relevance"] = article_relevance(article.get("published_at")) - article["status_ui"] = internal_to_ui_status(article.get("status")) - tags = meta.get("generated_tags") if isinstance(meta.get("generated_tags"), list) else [] - article["generated_tags"] = [str(t) for t in tags if t] - - return templates.TemplateResponse( - request, - "admin_dashboard.html", - { - "request": request, - "title": "Admin Dashboard", - "user": user, - "sources": sources, - "source_policy": source_policy, - "feeds": feeds, - "runs": runs, - "publish_jobs": publish_jobs, - "articles": articles, - "status_options": list(UI_STATUSES), - "status_labels": UI_STATUS_LABELS, - "allowed_transitions": ALLOWED_TRANSITIONS, - "pending_review_count": len(list_articles(limit=500, status_filter="pending_review")), - "status_filter": status_filter, - "flash_msg": request.query_params.get("msg", ""), - "flash_type": request.query_params.get("type", "success"), - }, - ) - - -@router.get("/admin/connectivity", response_class=HTMLResponse) -def admin_connectivity(request: Request): - user = _admin_user(request) - if not user: - return RedirectResponse(url="/admin/login", status_code=303) - - checks = [_run_connectivity_check(target) for target in _build_connectivity_targets()] - ok_count = len([c for c in checks if c.get("ok")]) - error_count = len(checks) - ok_count - return templates.TemplateResponse( - request, - "admin_connectivity.html", - { - "request": request, - "title": "Connectivity Check", - "user": user, - "checks": checks, - "ok_count": ok_count, - "error_count": error_count, - }, - ) - - -@router.get("/admin/articles/{article_id}", response_class=HTMLResponse) -def admin_article_detail(request: Request, article_id: int): - user = _admin_user(request) - if not user: - return RedirectResponse(url="/admin/login", status_code=303) - - article = get_article_by_id(article_id) - if not article: - return _dashboard_redirect(msg=f"Artikel #{article_id} nicht gefunden", msg_type="error") - - meta = _parse_meta_json(article.get("meta_json")) - article["meta"] = meta - extraction = meta.get("extraction") if isinstance(meta.get("extraction"), dict) else {} - extraction["images"] = _read_article_images(article, extraction) - if not article.get("press_contact") and isinstance(extraction.get("press_contact"), str): - article["press_contact"] = extraction.get("press_contact") - article["extraction"] = extraction - publish_ready, publish_blockers = _publish_readiness(article, meta) - article["publish_ready"] = publish_ready - article["publish_blockers"] = publish_blockers - article["image_selection"] = extraction.get("image_selection") if isinstance(extraction.get("image_selection"), dict) else {} - article["image_entries"] = _build_image_entries(article, extraction, meta) - image_review = meta.get("image_review") if isinstance(meta.get("image_review"), dict) else {} - article["selected_image_url"] = image_review.get("selected_url") if isinstance(image_review.get("selected_url"), str) else None - article["selected_image_proxy_url"] = ( - f"/admin/images/proxy?{urlencode({'url': article['selected_image_url']})}" if article.get("selected_image_url") else None - ) - article["days_old"] = article_age_days(article.get("published_at")) - article["relevance"] = article_relevance(article.get("published_at")) - article["status_ui"] = internal_to_ui_status(article.get("status")) - feed = get_feed_by_id(int(article["feed_id"])) if article.get("feed_id") else None - checklist = _legal_checklist(article, feed) - - return templates.TemplateResponse( - request, - "admin_article_detail.html", - { - "request": request, - "title": f"Artikel #{article_id}", - "user": user, - "article": article, - "feed": feed, - "checklist": checklist, - "allowed_transitions": ALLOWED_TRANSITIONS.get(article.get("status_ui"), ()), - "status_labels": UI_STATUS_LABELS, - "editorial_review_required": settings.editorial_review_required, - "flash_msg": request.query_params.get("msg", ""), - "flash_type": request.query_params.get("type", "success"), - }, - ) - - -@router.post("/admin/articles/{article_id}/images/decision") -def admin_article_image_decision( - request: Request, - article_id: int, - image_url: str = Form(...), - action: str = Form(...), -): - user = _admin_user(request) - if not user: - return RedirectResponse(url="/admin/login", status_code=303) - - ok = set_article_image_decision(article_id=article_id, image_url=image_url, action=action, actor=user) - if not ok: - return _dashboard_redirect(msg=f"Bildaktion fehlgeschlagen fuer Artikel #{article_id}", msg_type="error") - return RedirectResponse(url=f"/admin/articles/{article_id}", status_code=303) - - -@router.post("/admin/articles/{article_id}/publish-enqueue") -def admin_enqueue_publish(request: Request, article_id: int, max_attempts: str = Form("3")): - user = _admin_user(request) - if not user: - return RedirectResponse(url="/admin/login", status_code=303) - try: - job_id = enqueue_publish(article_id=article_id, max_attempts=max(1, int(max_attempts))) - except Exception as exc: - return _dashboard_redirect(msg=f"Publish Queue Fehler fuer Artikel #{article_id}: {exc}", msg_type="error") - return RedirectResponse(url=f"/admin/articles/{article_id}?msg=Publish-Job%20#{job_id}%20erstellt&type=success", status_code=303) - - -@router.post("/admin/publisher/run") -def admin_run_publisher(request: Request, max_jobs: str = Form("10")): - user = _admin_user(request) - if not user: - return RedirectResponse(url="/admin/login", status_code=303) - try: - stats = run_publisher(max_jobs=max(1, int(max_jobs))) - except Exception as exc: - return _dashboard_redirect(msg=f"Publisher Fehler: {exc}", msg_type="error") - return _dashboard_redirect( - msg=f"Publisher: processed={stats.processed}, success={stats.success}, failed={stats.failed}, requeued={stats.requeued}" - ) - - -@router.get("/admin/images/proxy") -def admin_image_proxy(request: Request, url: str): - if not _is_http_image_url(url): - return Response(status_code=400) - - try: - referer = request.headers.get("referer", "") - req = UrlRequest( - url=url, - headers={ - "User-Agent": IMAGE_PROXY_USER_AGENT, - "Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", - "Referer": referer or url, - }, - ) - with urlopen(req, timeout=10) as resp: - body = resp.read() - content_type = resp.headers.get("Content-Type", "application/octet-stream") - except Exception: - return Response(status_code=404) - - if not content_type.lower().startswith("image/"): - return Response(status_code=415) - return Response(content=body, media_type=content_type) - - -@router.post("/admin/sources/create") -def admin_create_source( - request: Request, - name: str = Form(...), - base_url: str = Form(""), - terms_url: str = Form(""), - license_name: str = Form(""), - risk_level: str = Form("yellow"), - last_reviewed_at: str = Form(""), -): - user = _admin_user(request) - if not user: - return RedirectResponse(url="/admin/login", status_code=303) - - try: - create_source( - SourceCreate( - name=name, - base_url=base_url or None, - terms_url=terms_url or None, - license_name=license_name or None, - risk_level=risk_level, - is_enabled=True, - notes=None, - last_reviewed_at=last_reviewed_at or None, - ) - ) - except Exception as exc: - return _dashboard_redirect(msg=f"Quelle konnte nicht gespeichert werden: {exc}", msg_type="error") - return _dashboard_redirect(msg="Quelle gespeichert") - - -@router.post("/admin/sources/{source_id}/update") -def admin_update_source( - request: Request, - source_id: int, - name: str = Form(...), - base_url: str = Form(""), - terms_url: str = Form(""), - license_name: str = Form(""), - risk_level: str = Form("yellow"), - is_enabled: str = Form("1"), - notes: str = Form(""), - last_reviewed_at: str = Form(""), -): - user = _admin_user(request) - if not user: - return RedirectResponse(url="/admin/login", status_code=303) - try: - ok = update_source( - source_id, - SourceUpdate( - name=name, - base_url=base_url or None, - terms_url=terms_url or None, - license_name=license_name or None, - risk_level=risk_level, - is_enabled=is_enabled == "1", - notes=notes or None, - last_reviewed_at=last_reviewed_at or None, - ), - ) - except Exception as exc: - return _dashboard_redirect(msg=f"Quelle #{source_id} Update fehlgeschlagen: {exc}", msg_type="error") - if not ok: - return _dashboard_redirect(msg=f"Quelle #{source_id} nicht gefunden", msg_type="error") - return _dashboard_redirect(msg=f"Quelle #{source_id} aktualisiert") - - -@router.post("/admin/sources/{source_id}/delete") -def admin_delete_source(request: Request, source_id: int): - user = _admin_user(request) - if not user: - return RedirectResponse(url="/admin/login", status_code=303) - ok = delete_source(source_id) - if not ok: - return _dashboard_redirect(msg=f"Quelle #{source_id} nicht gefunden", msg_type="error") - return _dashboard_redirect(msg=f"Quelle #{source_id} gelöscht") - - -@router.post("/admin/feeds/create") -def admin_create_feed( - request: Request, - name: str = Form(...), - url: str = Form(...), - source_id: str = Form(""), -): - user = _admin_user(request) - if not user: - return RedirectResponse(url="/admin/login", status_code=303) - - try: - create_feed( - FeedCreate( - name=name, - url=url, - source_id=_to_optional_int(source_id), - is_enabled=True, - ) - ) - except Exception as exc: - return _dashboard_redirect(msg=f"Feed konnte nicht gespeichert werden: {exc}", msg_type="error") - return _dashboard_redirect(msg="Feed gespeichert") - - -@router.post("/admin/feeds/{feed_id}/update") -def admin_update_feed( - request: Request, - feed_id: int, - name: str = Form(...), - url: str = Form(...), - source_id: str = Form(""), - is_enabled: str = Form("1"), -): - user = _admin_user(request) - if not user: - return RedirectResponse(url="/admin/login", status_code=303) - try: - ok = update_feed( - feed_id, - FeedUpdate( - name=name, - url=url, - source_id=_to_optional_int(source_id), - is_enabled=is_enabled == "1", - ), - ) - except Exception as exc: - return _dashboard_redirect(msg=f"Feed #{feed_id} Update fehlgeschlagen: {exc}", msg_type="error") - if not ok: - return _dashboard_redirect(msg=f"Feed #{feed_id} nicht gefunden", msg_type="error") - return _dashboard_redirect(msg=f"Feed #{feed_id} aktualisiert") - - -@router.post("/admin/feeds/{feed_id}/delete") -def admin_delete_feed(request: Request, feed_id: int): - user = _admin_user(request) - if not user: - return RedirectResponse(url="/admin/login", status_code=303) - ok = delete_feed(feed_id) - if not ok: - return _dashboard_redirect(msg=f"Feed #{feed_id} nicht gefunden", msg_type="error") - return _dashboard_redirect(msg=f"Feed #{feed_id} gelöscht") - - -@router.post("/admin/ingestion/run") -def admin_run_ingestion(request: Request, feed_id: str = Form("")): - user = _admin_user(request) - if not user: - return RedirectResponse(url="/admin/login", status_code=303) - try: - stats = run_ingestion(feed_id=_to_optional_int(feed_id)) - except Exception as exc: - return _dashboard_redirect(msg=f"Ingestion fehlgeschlagen: {exc}", msg_type="error") - return _dashboard_redirect(msg=f"Ingestion: {stats.status}, upserts={stats.articles_upserted}") - - -@router.post("/admin/articles/{article_id}/review") -def admin_review_article(request: Request, article_id: int, decision: str = Form(...), note: str = Form("")): - user = _admin_user(request) - if not user: - return RedirectResponse(url="/admin/login", status_code=303) - - return _dashboard_redirect(msg="Review-Aktion wurde durch Rewrite ersetzt", msg_type="error") - - -@router.post("/admin/articles/{article_id}/rewrite-run") -def admin_rewrite_run(request: Request, article_id: int, redirect_to: str = Form("")): - user = _admin_user(request) - if not user: - return RedirectResponse(url="/admin/login", status_code=303) - - back = _safe_admin_redirect(redirect_to, f"/admin/articles/{article_id}") - sep = "&" if "?" in back else "?" - - def _back(msg: str, msg_type: str = "success"): - return RedirectResponse(url=f"{back}{sep}msg={quote_plus(msg)}&type={msg_type}", status_code=303) - - article = get_article_by_id(article_id) - if not article: - return _dashboard_redirect(msg=f"Artikel #{article_id} nicht gefunden", msg_type="error") - if internal_to_ui_status(article.get("status")) not in {"new", "rewrite", "freigabe", "no_image"}: - return _back(f"Rewrite fuer Artikel #{article_id} in diesem Status nicht moeglich", "error") - try: - rewritten = rewrite_article_text(article) - tags = generate_article_tags(article, rewritten_text=rewritten) - except Exception as exc: - return _back(f"Rewrite fehlgeschlagen fuer Artikel #{article_id}: {exc}", "error") - merged_meta = merge_generated_tags(article.get("meta_json"), tags) - new_status = post_rewrite_status() - _upsert_article_from_existing(article, content_rewritten=rewritten, status=new_status, meta_json=merged_meta) - if new_status == "pending_review": - clear_article_editorial_review(article_id) - target = ui_status_label(internal_to_ui_status(new_status)) - return _back(f"Artikel #{article_id} neu geschrieben -> {target}") - - -@router.post("/admin/rewrite/run") -def admin_rewrite_run_batch(request: Request, max_jobs: str = Form("10")): - user = _admin_user(request) - if not user: - return RedirectResponse(url="/admin/login", status_code=303) - try: - limit = max(1, min(int(max_jobs), 100)) - except Exception: - limit = 10 - planned = list_articles(limit=limit, status_filter="rewrite") - processed = 0 - success = 0 - failed = 0 - for article in planned: - processed += 1 - try: - rewritten = rewrite_article_text(article) - tags = generate_article_tags(article, rewritten_text=rewritten) - merged_meta = merge_generated_tags(article.get("meta_json"), tags) - new_status = post_rewrite_status() - _upsert_article_from_existing(article, content_rewritten=rewritten, status=new_status, meta_json=merged_meta) - if new_status == "pending_review": - clear_article_editorial_review(int(article["id"])) - success += 1 - except Exception: - failed += 1 - return _dashboard_redirect(msg=f"Rewrite-Run: processed={processed}, success={success}, failed={failed}") - - -@router.post("/admin/articles/{article_id}/rewrite-save") -def admin_rewrite_save(request: Request, article_id: int, content_rewritten: str = Form(...)): - user = _admin_user(request) - if not user: - return RedirectResponse(url="/admin/login", status_code=303) - article = get_article_by_id(article_id) - if not article: - return _dashboard_redirect(msg=f"Artikel #{article_id} nicht gefunden", msg_type="error") - text = (content_rewritten or "").strip() - if not text: - return RedirectResponse( - url=f"/admin/articles/{article_id}?msg=Rewrite-Text%20darf%20nicht%20leer%20sein&type=error", - status_code=303, - ) - _upsert_article_from_existing(article, content_rewritten=text) - return RedirectResponse(url=f"/admin/articles/{article_id}?msg=Rewrite-Text%20gespeichert&type=success", status_code=303) - - -@router.post("/admin/articles/{article_id}/reopen") -def admin_reopen_article(request: Request, article_id: int): - user = _admin_user(request) - if not user: - return RedirectResponse(url="/admin/login", status_code=303) - article = get_article_by_id(article_id) - if not article: - return _dashboard_redirect(msg=f"Artikel #{article_id} nicht gefunden", msg_type="error") - _upsert_article_from_existing( - article, - status="rewrite", - wp_post_id=None, - wp_post_url=None, - publish_attempts=0, - publish_last_error=None, - published_to_wp_at=None, - ) - return RedirectResponse( - url=f"/admin/articles/{article_id}?msg=Artikel%20zurueck%20in%20Rewrite-Workflow%20gesetzt&type=success", - status_code=303, - ) - - -@router.get("/admin/freigabe", response_class=HTMLResponse) -def admin_review_queue(request: Request): - """The daily work list: every article waiting for the editorial sign-off. - - Everything needed to decide is on this one page — the rewritten text is - rendered to be read, not shown as markup — and each article carries its three - actions, so a queue can be worked through without ever leaving the page. - """ - user = _admin_user(request) - if not user: - return RedirectResponse(url="/admin/login", status_code=303) - - articles = list_articles(limit=100, status_filter="pending_review") - for article in articles: - meta = _parse_meta_json(article.get("meta_json")) - image_review = meta.get("image_review") if isinstance(meta.get("image_review"), dict) else {} - selected = image_review.get("selected_url") if isinstance(image_review.get("selected_url"), str) else None - article["selected_image_url"] = selected - article["selected_image_proxy_url"] = ( - f"/admin/images/proxy?{urlencode({'url': selected})}" if selected else None - ) - relevance = meta.get("relevance") if isinstance(meta.get("relevance"), dict) else {} - article["relevance_score"] = relevance.get("score") - article["relevance_reason"] = relevance.get("reason") - tags = meta.get("generated_tags") if isinstance(meta.get("generated_tags"), list) else [] - article["generated_tags"] = [str(t) for t in tags if t] - article["preview_html"] = _sanitize_preview_html(article.get("content_rewritten")) - article["days_old"] = article_age_days(article.get("published_at")) - article["source_display"] = ( - article.get("source_name_snapshot") or article.get("feed_name") or "unbekannte Quelle" - ) - - return templates.TemplateResponse( - request, - "admin_review_queue.html", - { - "request": request, - "title": "Freigabe", - "user": user, - "articles": articles, - "flash_msg": request.query_params.get("msg", ""), - "flash_type": request.query_params.get("type", "success"), - }, - ) - - -@router.post("/admin/articles/{article_id}/approve") -def admin_approve_article( - request: Request, - article_id: int, - note: str = Form(""), - redirect_to: str = Form(""), -): - """Editorial sign-off: a person read the article and takes responsibility. - - Stamps reviewer and system time, then hands the article to WordPress and the - scheduler. Nothing about the timestamp is user-editable — that is the point. - """ - user = _admin_user(request) - if not user: - return RedirectResponse(url="/admin/login", status_code=303) - - back = _safe_admin_redirect(redirect_to, f"/admin/articles/{article_id}") - sep = "&" if "?" in back else "?" - - try: - result = approve_article(article_id, actor=user, note=note.strip() or None) - except ValueError as exc: - return RedirectResponse( - url=f"{back}{sep}msg={quote_plus(f'Artikel #{article_id}: {exc}')}&type=error", - status_code=303, - ) - except Exception as exc: - return RedirectResponse( - url=f"{back}{sep}msg={quote_plus(f'Freigabe von #{article_id} fehlgeschlagen: {exc}')}&type=error", - status_code=303, - ) - - slot = result.get("scheduled_publish_at") or "-" - return RedirectResponse( - url=f"{back}{sep}msg={quote_plus(f'Artikel #{article_id} freigegeben. Veroeffentlichung geplant fuer {slot}.')}&type=success", - status_code=303, - ) - - -@router.post("/admin/articles/{article_id}/transition") -def admin_transition_article( - request: Request, - article_id: int, - target_status: str = Form(...), - note: str = Form(""), - redirect_to: str = Form(""), -): - user = _admin_user(request) - if not user: - return RedirectResponse(url="/admin/login", status_code=303) - - def _back(msg: str, msg_type: str = "success"): - if not redirect_to: - return _dashboard_redirect(msg=msg, msg_type=msg_type) - target = _safe_admin_redirect(redirect_to, "/admin/dashboard") - sep = "&" if "?" in target else "?" - return RedirectResponse( - url=f"{target}{sep}msg={quote_plus(msg)}&type={msg_type}", status_code=303 - ) - - article = get_article_by_id(article_id) - if article: - current_ui = internal_to_ui_status(article.get("status")) - target_internal = ui_to_internal_status(target_status) - target_ui = internal_to_ui_status(target_internal) - if target_ui in ALLOWED_TRANSITIONS.get(current_ui, ()): - # Setting an article to `publish` by hand IS the editorial sign-off. - # Route it through the same door so it cannot slip into WordPress - # unstamped and unscheduled. - if target_ui == "publish" and not (article.get("editorial_review_at") or ""): - return admin_approve_article(request, article_id, note=note, redirect_to=redirect_to) - update_article_status(article_id, target_internal, actor=user, note=note or None) - return _back( - f"Artikel #{article_id}: {ui_status_label(current_ui)} -> {ui_status_label(target_ui)}" - ) - return _back(f"Ungueltiger Statuswechsel fuer Artikel #{article_id}", msg_type="error") - - -_PAGE_SIZE = 50 - - -@router.get("/admin/article-list", response_class=HTMLResponse) -def admin_article_list(request: Request): - """Paginated article list with inline WP ID editing.""" - user = _admin_user(request) - if not user: - return RedirectResponse(url="/admin/login", status_code=303) - - page = max(1, int(request.query_params.get("page", 1))) - status_filter = request.query_params.get("status_filter", "") or None - search = request.query_params.get("search", "").strip() or None - offset = (page - 1) * _PAGE_SIZE - - articles, total = list_articles_page( - limit=_PAGE_SIZE, offset=offset, - status_filter=status_filter, search=search, - ) - - # Enrich each article with thumbnail URL - for a in articles: - a["status_ui"] = internal_to_ui_status(a.get("status")) - a["status_label"] = ui_status_label(a["status_ui"]) - meta = _parse_meta_json(a.get("meta_json")) - image_review = meta.get("image_review") if isinstance(meta.get("image_review"), dict) else {} - sel = image_review.get("selected_url") if isinstance(image_review.get("selected_url"), str) else None - if not sel: - sel = (meta.get("extraction") or {}).get("image_selection", {}).get("primary") - a["thumb_url"] = sel - a["thumb_proxy"] = f"/admin/images/proxy?{urlencode({'url': sel})}" if sel else None - raw = (a.get("content_raw") or a.get("summary") or "").strip() - a["excerpt"] = raw[:120] + "…" if len(raw) > 120 else raw - - total_pages = max(1, (total + _PAGE_SIZE - 1) // _PAGE_SIZE) - - return templates.TemplateResponse( - request, - "admin_article_list.html", - { - "request": request, - "title": "Artikelliste", - "user": user, - "articles": articles, - "page": page, - "total_pages": total_pages, - "total": total, - "page_size": _PAGE_SIZE, - # Wert bleibt der interne Status (danach wird gefiltert), angezeigt - # wird die Klartext-Bezeichnung. - "status_options": [ - (s, ui_status_label(internal_to_ui_status(s))) for s in INTERNAL_STATUSES - ], - "status_filter": status_filter or "", - "search": search or "", - "flash_msg": request.query_params.get("msg", ""), - "flash_type": request.query_params.get("type", "success"), - }, - ) - - -@router.post("/admin/article-list/update") -async def admin_article_list_update(request: Request): - """Bulk update WP post IDs from the article list form.""" - user = _admin_user(request) - if not user: - return RedirectResponse(url="/admin/login", status_code=303) - - form = await request.form() - updates: list[tuple[int, int | None]] = [] - - # Form fields: wp_,