feat(admin): Freigabe-Seite und Aufraeumen der Status-Altlasten

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>
This commit is contained in:
Oliver 2026-08-24 16:32:48 +02:00
parent ea5260716e
commit de1d62c6f7
No known key found for this signature in database
14 changed files with 565 additions and 47 deletions

View file

@ -1,5 +1,7 @@
from __future__ import annotations
from html import escape
from html.parser import HTMLParser
import json
from pathlib import Path
import re
@ -50,9 +52,11 @@ from .repositories import (
)
from .workflow import (
ALLOWED_UI_TRANSITIONS,
INTERNAL_STATUSES,
UI_STATUS_LABELS,
UI_STATUSES,
internal_to_ui_status,
ui_status_label,
ui_to_internal_status,
)
@ -61,9 +65,10 @@ 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"),
"relevanz": ("rewrite", "close"),
"rewrite": ("freigabe", "close"),
"freigabe": ("publish", "rewrite", "close"),
"publish": ("published", "close"),
"publish": ("close",),
"published": ("rewrite", "close"),
"close": ("rewrite",),
"no_image": ("rewrite", "close"),
@ -180,6 +185,85 @@ def _build_image_entries(article: dict, extraction: dict, meta: dict) -> list[di
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"))
@ -852,30 +936,34 @@ def admin_review_article(request: Request, article_id: int, decision: str = Form
@router.post("/admin/articles/{article_id}/rewrite-run")
def admin_rewrite_run(request: Request, article_id: int):
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 _dashboard_redirect(msg=f"Rewrite nur aus new/rewrite/freigabe fuer Artikel #{article_id}", msg_type="error")
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 _dashboard_redirect(msg=f"Rewrite fehlgeschlagen fuer Artikel #{article_id}: {exc}", msg_type="error")
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 = "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,
)
target = ui_status_label(internal_to_ui_status(new_status))
return _back(f"Artikel #{article_id} neu geschrieben -> {target}")
@router.post("/admin/rewrite/run")
@ -948,8 +1036,59 @@ def admin_reopen_article(request: Request, article_id: int):
)
@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("")):
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
@ -959,32 +1098,50 @@ def admin_approve_article(request: Request, article_id: int, note: str = Form(""
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"/admin/articles/{article_id}?msg={quote_plus(str(exc))}&type=error",
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"/admin/articles/{article_id}?msg={quote_plus(f'Freigabe fehlgeschlagen: {exc}')}&type=error",
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"/admin/articles/{article_id}?msg={quote_plus(f'Freigegeben. Veroeffentlichung geplant fuer {slot}.')}&type=success",
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("")):
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"))
@ -995,10 +1152,12 @@ def admin_transition_article(request: Request, article_id: int, target_status: s
# 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)
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 _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")
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
@ -1023,6 +1182,8 @@ def admin_article_list(request: Request):
# 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
@ -1047,6 +1208,11 @@ def admin_article_list(request: Request):
"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", ""),

View file

@ -47,7 +47,13 @@ from .repositories import (
update_article_status,
upsert_article as repo_upsert_article,
)
from .workflow import ALLOWED_UI_TRANSITIONS, UI_STATUSES, internal_to_ui_status, ui_to_internal_status
from .workflow import (
ALLOWED_UI_TRANSITIONS,
INTERNAL_STATUSES,
UI_STATUSES,
internal_to_ui_status,
ui_to_internal_status,
)
settings = get_settings()
@ -135,8 +141,13 @@ class IngestionRunRequest(BaseModel):
feed_id: int | None = None
# Abgeleitet statt handgepflegt: die Liste ist frueher beim Einfuehren eines
# neuen Status stillschweigend veraltet und hat den Wechsel mit 422 abgelehnt.
_TRANSITION_TARGET_PATTERN = "^(" + "|".join(sorted(set(UI_STATUSES) | set(INTERNAL_STATUSES))) + ")$"
class ArticleTransitionRequest(BaseModel):
target_status: str = Field(pattern="^(new|rewrite|publish|published|close|review|approved|error|no_image)$")
target_status: str = Field(pattern=_TRANSITION_TARGET_PATTERN)
note: str | None = None

View file

@ -876,7 +876,8 @@ def list_articles(limit: int = 100, status_filter: str | None = None) -> list[di
rows = conn.execute(
"""
SELECT a.id, a.feed_id, a.source_article_id, a.source_hash, a.title, a.source_url, a.canonical_url, a.published_at, a.author,
a.summary, a.content_raw, a.word_count, a.status, a.meta_json, a.created_at, a.updated_at, f.name AS feed_name,
a.summary, a.content_raw, a.content_rewritten,
a.word_count, a.status, a.meta_json, a.created_at, a.updated_at, f.name AS feed_name,
a.image_urls_json, a.press_contact, a.source_name_snapshot, a.source_terms_url_snapshot,
a.source_license_name_snapshot, a.legal_checked, a.legal_checked_at, a.legal_note,
a.wp_post_id, a.wp_post_url, a.publish_attempts, a.publish_last_error, a.published_to_wp_at,
@ -893,7 +894,8 @@ def list_articles(limit: int = 100, status_filter: str | None = None) -> list[di
rows = conn.execute(
"""
SELECT a.id, a.feed_id, a.source_article_id, a.source_hash, a.title, a.source_url, a.canonical_url, a.published_at, a.author,
a.summary, a.content_raw, a.word_count, a.status, a.meta_json, a.created_at, a.updated_at, f.name AS feed_name,
a.summary, a.content_raw, a.content_rewritten,
a.word_count, a.status, a.meta_json, a.created_at, a.updated_at, f.name AS feed_name,
a.image_urls_json, a.press_contact, a.source_name_snapshot, a.source_terms_url_snapshot,
a.source_license_name_snapshot, a.legal_checked, a.legal_checked_at, a.legal_note,
a.wp_post_id, a.wp_post_url, a.publish_attempts, a.publish_last_error, a.published_to_wp_at,

View file

@ -1,18 +1,32 @@
from __future__ import annotations
UI_STATUSES = ("new", "rewrite", "freigabe", "publish", "published", "close", "no_image")
UI_STATUSES = ("new", "relevanz", "rewrite", "freigabe", "publish", "published", "close", "no_image")
# Menschenlesbare Beschriftungen fuer die Admin-UI.
# Menschenlesbare Beschriftungen fuer die Admin-UI. Interne Kuerzel tauchen in
# der Oberflaeche nirgends mehr auf.
UI_STATUS_LABELS: dict[str, str] = {
"new": "Neu",
"rewrite": "Rewrite",
"relevanz": "Niedrige Relevanz",
"rewrite": "Rewrite nötig",
"freigabe": "Wartet auf Freigabe",
"publish": "Freigegeben / eingeplant",
"published": "Veroeffentlicht",
"published": "Veröffentlicht",
"close": "Verworfen",
"no_image": "Kein Bild",
}
# Reihenfolge fuer Status-Filter: entlang des Ablaufs, nicht alphabetisch.
INTERNAL_STATUSES = (
"new",
"review",
"rewrite",
"pending_review",
"approved",
"published",
"error",
"no_image",
)
def internal_to_ui_status(status: str | None) -> str:
value = (status or "").strip()
@ -23,7 +37,10 @@ def internal_to_ui_status(status: str | None) -> str:
if value == "pending_review":
return "freigabe"
if value == "review":
return "rewrite"
# Relevanz-Warnzone (Score zwischen WARN und AUTO): wartet auf die
# Entscheidung, ob der Artikel ueberhaupt verarbeitet wird. Wurde frueher
# als "rewrite" angezeigt, was etwas anderes bedeutet.
return "relevanz"
if value in {"new", "rewrite", "published", "no_image"}:
return value
return value or "new"
@ -37,6 +54,8 @@ def ui_to_internal_status(status: str | None) -> str:
return "error"
if value == "freigabe":
return "pending_review"
if value == "relevanz":
return "review"
if value in {"new", "rewrite", "published", "no_image"}:
return value
if value in {"approved", "error", "review", "pending_review"}:
@ -49,14 +68,17 @@ def ui_status_label(status: str | None) -> str:
return UI_STATUS_LABELS.get(value, value or "new")
# `freigabe` ist das Tor zur Veroeffentlichung: nur ueber diesen Zustand (oder
# einen bewussten manuellen Wechsel) erreicht ein Artikel `publish`, und beides
# stempelt die redaktionelle Freigabe (Art. 50 Abs. 4 KI-VO).
# `freigabe` ist das einzige Tor zur Veroeffentlichung: der Wechsel nach
# `publish` stempelt die redaktionelle Freigabe (Art. 50 Abs. 4 KI-VO).
# Der frueher moegliche Sprung `rewrite -> publish` ist bewusst entfernt - er war
# ein zweiter Weg an der Freigabe vorbei und im Menue nur verwirrend.
# `published` setzt WordPress bzw. der Publisher, nicht die Hand.
ALLOWED_UI_TRANSITIONS: dict[str, set[str]] = {
"new": {"rewrite", "close"},
"rewrite": {"freigabe", "publish", "close"},
"relevanz": {"rewrite", "close"},
"rewrite": {"freigabe", "close"},
"freigabe": {"publish", "rewrite", "close"},
"publish": {"published", "close"},
"publish": {"close"},
"published": {"rewrite", "close"},
"close": {"rewrite"},
"no_image": {"rewrite", "close"},