from __future__ import annotations import json from pathlib import Path import re import socket import ssl import time from urllib.parse import urlparse from urllib.parse import urlencode, quote_plus from urllib.request import Request as UrlRequest, urlopen from fastapi import APIRouter, Form, Request from fastapi.responses import HTMLResponse, RedirectResponse, Response from fastapi.templating import Jinja2Templates from .auth import create_session_token, verify_credentials, verify_session_token from .config import get_settings from .ingestion import run_ingestion from .pipeline import approve_article, post_rewrite_status from .policy import evaluate_source_policy from .publisher import enqueue_publish, run_publisher from .relevance import article_age_days, article_relevance from .rewrite import generate_article_tags, merge_generated_tags, rewrite_article_text from .repositories import ( FeedCreate, FeedUpdate, SourceCreate, SourceUpdate, delete_feed, delete_source, clear_article_editorial_review, create_feed, create_source, get_article_by_id, get_feed_by_id, list_articles, list_articles_page, bulk_update_wp_post_ids, list_feeds, list_publish_jobs, list_runs, list_sources, set_article_image_decision, upsert_article, update_feed, update_source, update_article_status, ArticleUpsert, ) from .workflow import ( ALLOWED_UI_TRANSITIONS, UI_STATUS_LABELS, UI_STATUSES, internal_to_ui_status, ui_to_internal_status, ) settings = get_settings() router = APIRouter(tags=["admin-ui"]) templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates")) ALLOWED_TRANSITIONS: dict[str, tuple[str, ...]] = { "new": ("rewrite", "close"), "rewrite": ("freigabe", "publish", "close"), "freigabe": ("publish", "rewrite", "close"), "publish": ("published", "close"), "published": ("rewrite", "close"), "close": ("rewrite",), "no_image": ("rewrite", "close"), } IMAGE_PROXY_USER_AGENT = "rss-news-admin/1.0" _UNSET = object() def _admin_user(request: Request) -> str | None: token = request.cookies.get(settings.session_cookie_name) if not token: return None return verify_session_token(token) def _to_optional_int(raw: str | None) -> int | None: if raw is None: return None value = raw.strip() if value == "": return None return int(value) def _dashboard_redirect( *, msg: str | None = None, msg_type: str = "success", status_filter: str | None = None, ) -> RedirectResponse: query: dict[str, str] = {} if msg: query["msg"] = msg query["type"] = msg_type if status_filter: query["status_filter"] = status_filter suffix = f"?{urlencode(query)}" if query else "" return RedirectResponse(url=f"/admin/dashboard{suffix}", status_code=303) def _parse_meta_json(raw: str | None) -> dict: if not raw: return {} try: parsed = json.loads(raw) return parsed if isinstance(parsed, dict) else {} except Exception: return {} def _read_article_images(article: dict, extraction: dict) -> list[str]: images: list[str] = [] if article.get("image_urls_json"): try: parsed_images = json.loads(article["image_urls_json"]) if isinstance(parsed_images, list): images = [str(item) for item in parsed_images if item] except Exception: images = [] if not images and isinstance(extraction.get("images"), list): images = [str(item) for item in extraction.get("images") if item] # deduplicate preserving order seen: set[str] = set() deduped: list[str] = [] for image in images: if image not in seen: seen.add(image) deduped.append(image) return deduped def _is_probably_irrelevant_image(url: str) -> bool: lowered = url.lower() patterns = ( r"logo", r"icon", r"sprite", r"avatar", r"favicon", r"/ads/", r"tracking", r"pixel", r"banner", ) return any(re.search(pattern, lowered) for pattern in patterns) def _is_http_image_url(url: str) -> bool: try: parsed = urlparse(url) except Exception: return False return parsed.scheme in {"http", "https"} and bool(parsed.netloc) def _build_image_entries(article: dict, extraction: dict, meta: dict) -> list[dict[str, object]]: all_images = _read_article_images(article, extraction) image_review = meta.get("image_review") if isinstance(meta.get("image_review"), dict) else {} selected_url = image_review.get("selected_url") if isinstance(image_review.get("selected_url"), str) else None excluded_urls = image_review.get("excluded_urls") if isinstance(image_review.get("excluded_urls"), list) else [] excluded_set = {str(item) for item in excluded_urls if item} entries: list[dict[str, object]] = [] for url in all_images: entries.append( { "url": url, "proxy_url": f"/admin/images/proxy?{urlencode({'url': url})}", "is_selected": selected_url == url, "is_excluded": url in excluded_set, "is_irrelevant_hint": _is_probably_irrelevant_image(url), } ) return entries 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): 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") if internal_to_ui_status(article.get("status")) not in {"new", "rewrite", "freigabe", "no_image"}: return _dashboard_redirect(msg=f"Rewrite nur aus new/rewrite/freigabe fuer Artikel #{article_id}", msg_type="error") try: rewritten = rewrite_article_text(article) tags = generate_article_tags(article, rewritten_text=rewritten) except Exception as exc: return _dashboard_redirect(msg=f"Rewrite fehlgeschlagen fuer Artikel #{article_id}: {exc}", msg_type="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 = "Freigabe" if new_status == "pending_review" else "publish" return RedirectResponse( url=f"/admin/articles/{article_id}?msg={quote_plus(f'Rewrite fertig -> {target}')}&type=success", status_code=303, ) @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.post("/admin/articles/{article_id}/approve") def admin_approve_article(request: Request, article_id: int, note: 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) try: result = approve_article(article_id, actor=user, note=note.strip() or None) except ValueError as exc: return RedirectResponse( url=f"/admin/articles/{article_id}?msg={quote_plus(str(exc))}&type=error", status_code=303, ) except Exception as exc: return RedirectResponse( url=f"/admin/articles/{article_id}?msg={quote_plus(f'Freigabe fehlgeschlagen: {exc}')}&type=error", status_code=303, ) slot = result.get("scheduled_publish_at") or "-" return RedirectResponse( url=f"/admin/articles/{article_id}?msg={quote_plus(f'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("")): user = _admin_user(request) if not user: return RedirectResponse(url="/admin/login", 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) update_article_status(article_id, target_internal, actor=user, note=note or None) return _dashboard_redirect(msg=f"Artikel #{article_id}: {current_ui} -> {target_ui}") return _dashboard_redirect(msg=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: 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, "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_ = new value, orig_ = original value for key, new_val in form.items(): if not key.startswith("wp_"): continue try: article_id = int(key[3:]) except ValueError: continue orig_val = str(form.get(f"orig_{article_id}", "")).strip() new_val_s = str(new_val).strip() if new_val_s == orig_val: continue # unchanged new_wp_id = int(new_val_s) if new_val_s else None updates.append((article_id, new_wp_id)) if updates: count = bulk_update_wp_post_ids(updates) msg = f"{count} WP-ID(s) aktualisiert. Bitte jetzt WP-Sync ausführen um Slots & URLs zu aktualisieren." msg_type = "success" else: msg = "Keine Änderungen erkannt." msg_type = "success" # Preserve pagination/filter params from referer page = form.get("page", "1") status_filter = form.get("status_filter", "") search = form.get("search", "") qs: dict[str, str] = {"msg": msg, "type": msg_type, "page": page} if status_filter: qs["status_filter"] = status_filter if search: qs["search"] = search return RedirectResponse(url=f"/admin/article-list?{urlencode(qs)}", status_code=303) @router.post("/admin/wp-sync") def admin_wp_sync(request: Request): """Sync scheduled_publish_at and WP references in the DB from WordPress.""" user = _admin_user(request) if not user: return RedirectResponse(url="/admin/login", status_code=303) try: from .wordpress import sync_db_from_wordpress stats = sync_db_from_wordpress() msg = ( f"WP-Sync abgeschlossen: " f"{stats['slot_updated']} Slots aktualisiert, " f"{stats['slot_cleared_draft']} Slots geleert (Draft), " f"{stats['marked_published']} als veröffentlicht markiert, " f"{stats['wp_reference_cleared']} WP-Referenzen gelöscht (Papierkorb), " f"{stats['already_in_sync']} bereits synchron." ) return RedirectResponse(url=f"/admin/schedule?msg={msg}&type=success", status_code=303) except Exception as exc: return RedirectResponse(url=f"/admin/schedule?msg=Sync fehlgeschlagen: {exc}&type=error", status_code=303) @router.post("/admin/articles/{article_id}/retry") def admin_retry_article(request: Request, article_id: int): """Reset a failed article to 'new' so the pipeline picks it up on next run.""" 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") from .scheduler import release_publish_slot release_publish_slot(article_id) update_article_status(article_id, "new", actor=user, note="Manuell zurückgesetzt für erneuten Pipeline-Versuch") return _dashboard_redirect( msg=f"Artikel #{article_id} wurde auf 'neu' zurückgesetzt und wird beim nächsten Pipeline-Lauf verarbeitet", status_filter="close", ) @router.get("/admin/schedule", response_class=HTMLResponse) def admin_schedule(request: Request): """Schedule overview: all booked slots from DB and WordPress.""" user = _admin_user(request) if not user: return RedirectResponse(url="/admin/login", status_code=303) from .scheduler import get_schedule_overview, _preferred_hours, _today_cet from datetime import timedelta slots = get_schedule_overview(lookahead_days=60) today = _today_cet() hours = _preferred_hours() # Build a calendar grid: for each day in the next 60 days, show each preferred hour slot booked: dict[tuple[str, int], dict] = {(s["date"], s["hour"]): s for s in slots} calendar_days = [] for offset in range(0, 61): d = today + timedelta(days=offset) d_str = d.isoformat() day_slots = [] for h in hours: key = (d_str, h) day_slots.append({ "hour": h, "booked": key in booked, "slot": booked.get(key), }) calendar_days.append({ "date": d_str, "date_fmt": d.strftime("%d.%m.%Y"), "weekday": ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"][d.weekday()], "slots": day_slots, "any_booked": any(s["booked"] for s in day_slots), }) return templates.TemplateResponse( request, "admin_schedule.html", { "request": request, "title": "Veröffentlichungsplan", "user": user, "slots": slots, "calendar_days": calendar_days, "hours": hours, "flash_msg": request.query_params.get("msg", ""), "flash_type": request.query_params.get("type", "success"), }, )