Die Freigabe ist jetzt der taegliche Arbeitsschritt und bekommt eine eigene Seite: /admin/freigabe listet alle wartenden Artikel mit Bild, Score, Tags und dem umgeschriebenen Text lesbar gerendert, dazu die drei Aktionen Freigeben, Neu schreiben, Verwerfen. Nach jeder Aktion geht es zurueck in die Liste, damit sich eine Warteschlange am Stueck abarbeiten laesst. Der Vorschautext ist Modell-Ausgabe ueber fremde Webseiten und laeuft deshalb durch einen Tag-Whitelist-Filter statt roh ins Template. Aufgeraeumt: - Artikelliste zeigte interne Kuerzel statt Klartext - Status `review` (Relevanz-Warnzone) wurde als "Rewrite" ausgegeben - `Rewrite -> Freigegeben` entfernt: zweiter Weg an der Freigabe vorbei - `Freigegeben -> Veroeffentlicht` entfernt: das macht der WP-Sync - API-Statusliste wird abgeleitet statt handgepflegt, sie kannte den neuen Status nicht und lehnte den Wechsel mit 422 ab Behoben: list_articles las content_rewritten nicht mit, die neue Seite haette nie einen Text angezeigt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1360 lines
52 KiB
Python
1360 lines
52 KiB
Python
from __future__ import annotations
|
|
|
|
from html import escape
|
|
from html.parser import HTMLParser
|
|
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,
|
|
INTERNAL_STATUSES,
|
|
UI_STATUS_LABELS,
|
|
UI_STATUSES,
|
|
internal_to_ui_status,
|
|
ui_status_label,
|
|
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"),
|
|
"relevanz": ("rewrite", "close"),
|
|
"rewrite": ("freigabe", "close"),
|
|
"freigabe": ("publish", "rewrite", "close"),
|
|
"publish": ("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
|
|
|
|
|
|
_PREVIEW_ALLOWED_TAGS = {
|
|
"p", "br", "h2", "h3", "h4", "ul", "ol", "li",
|
|
"strong", "b", "em", "i", "blockquote", "a",
|
|
}
|
|
_PREVIEW_DROPPED_CONTENT_TAGS = {"script", "style"}
|
|
|
|
|
|
class _PreviewSanitizer(HTMLParser):
|
|
"""Rebuild article HTML with a small whitelist of tags.
|
|
|
|
The rewrite text comes from a language model fed with scraped source pages,
|
|
and the Freigabe page renders it instead of showing raw markup — so it gets
|
|
filtered rather than trusted. Disallowed tags are dropped, their text kept.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__(convert_charrefs=True)
|
|
self.parts: list[str] = []
|
|
self._suppress_depth = 0
|
|
|
|
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
if tag in _PREVIEW_DROPPED_CONTENT_TAGS:
|
|
self._suppress_depth += 1
|
|
return
|
|
if self._suppress_depth or tag not in _PREVIEW_ALLOWED_TAGS:
|
|
return
|
|
if tag == "a":
|
|
href = next((v or "" for k, v in attrs if k == "href"), "")
|
|
if href.lower().startswith(("http://", "https://")):
|
|
self.parts.append(f'<a href="{escape(href, quote=True)}" target="_blank" rel="noopener">')
|
|
else:
|
|
self.parts.append("<a>")
|
|
return
|
|
self.parts.append(f"<{tag}>")
|
|
|
|
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
if not self._suppress_depth and tag == "br":
|
|
self.parts.append("<br />")
|
|
|
|
def handle_endtag(self, tag: str) -> None:
|
|
if tag in _PREVIEW_DROPPED_CONTENT_TAGS:
|
|
self._suppress_depth = max(0, self._suppress_depth - 1)
|
|
return
|
|
if self._suppress_depth or tag not in _PREVIEW_ALLOWED_TAGS or tag == "br":
|
|
return
|
|
self.parts.append(f"</{tag}>")
|
|
|
|
def handle_data(self, data: str) -> None:
|
|
if not self._suppress_depth:
|
|
self.parts.append(escape(data))
|
|
|
|
|
|
def _sanitize_preview_html(raw: str | None) -> str:
|
|
text = (raw or "").strip()
|
|
if not text:
|
|
return ""
|
|
parser = _PreviewSanitizer()
|
|
try:
|
|
parser.feed(text)
|
|
parser.close()
|
|
except Exception:
|
|
return f"<p>{escape(re.sub(r'<[^>]+>', ' ', text))}</p>"
|
|
cleaned = "".join(parser.parts).strip()
|
|
if not cleaned:
|
|
return ""
|
|
# Plain text without any block tag would collapse into one run.
|
|
if "<p>" not in cleaned and "<h2>" not in cleaned and "<ul>" not in cleaned:
|
|
cleaned = f"<p>{cleaned}</p>"
|
|
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_<article_id> = new value, orig_<article_id> = 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"),
|
|
},
|
|
)
|