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:
parent
ea5260716e
commit
de1d62c6f7
14 changed files with 565 additions and 47 deletions
22
CHANGELOG.md
22
CHANGELOG.md
|
|
@ -1,3 +1,25 @@
|
||||||
|
## [1.8.1] - 2026-08-24
|
||||||
|
|
||||||
|
### 📋 Freigabe-Seite
|
||||||
|
- Neue Seite `/admin/freigabe`: alle Artikel im Status „Wartet auf Freigabe" auf einer Arbeitsliste
|
||||||
|
- Pro Artikel: Hauptbild, Relevanz-Score mit Begründung, Tags, Wortzahl, Alter, Link zum Originalartikel
|
||||||
|
- Der umgeschriebene Text wird **lesbar gerendert** statt als Markup — durch einen Tag-Whitelist-Filter, weil er aus Modell-Ausgabe über fremde Webseiten stammt
|
||||||
|
- Drei Aktionen direkt an jedem Artikel: ✅ Freigeben (mit Notizfeld), ✏️ Neu schreiben, ❌ Verwerfen
|
||||||
|
- Nach jeder Aktion zurück zur Liste, damit sich eine Warteschlange am Stück abarbeiten lässt
|
||||||
|
- Verlinkt aus der Navigation aller Admin-Seiten, mit Zähler im Dashboard
|
||||||
|
|
||||||
|
### 🧹 Status-Altlasten entfernt
|
||||||
|
- Artikelliste zeigte interne Kürzel (`pending_review`, `approved`, `error`) — jetzt überall Klartext
|
||||||
|
- Status `review` (Relevanz-Warnzone 60–79) wurde als „Rewrite" angezeigt, was etwas anderes bedeutet — heißt jetzt „Niedrige Relevanz"
|
||||||
|
- Übergang `Rewrite → Freigegeben` entfernt: ein zweiter Weg an der Freigabe vorbei
|
||||||
|
- Übergang `Freigegeben → Veröffentlicht` entfernt: das setzen WordPress bzw. der WP-Sync, nicht die Hand
|
||||||
|
- API-Statusliste wird aus dem Statusmodell abgeleitet statt handgepflegt (kannte `freigabe` nicht und lehnte den Wechsel mit 422 ab)
|
||||||
|
|
||||||
|
### 🐛 Behoben
|
||||||
|
- `list_articles` las `content_rewritten` nicht mit — die Freigabe-Seite hätte nie einen Text angezeigt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.8.0] - 2026-08-24
|
## [1.8.0] - 2026-08-24
|
||||||
|
|
||||||
### ⚖️ Redaktionelle Freigabe vor der Veröffentlichung (Art. 50 Abs. 4 KI-VO)
|
### ⚖️ Redaktionelle Freigabe vor der Veröffentlichung (Art. 50 Abs. 4 KI-VO)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from html import escape
|
||||||
|
from html.parser import HTMLParser
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
|
|
@ -50,9 +52,11 @@ from .repositories import (
|
||||||
)
|
)
|
||||||
from .workflow import (
|
from .workflow import (
|
||||||
ALLOWED_UI_TRANSITIONS,
|
ALLOWED_UI_TRANSITIONS,
|
||||||
|
INTERNAL_STATUSES,
|
||||||
UI_STATUS_LABELS,
|
UI_STATUS_LABELS,
|
||||||
UI_STATUSES,
|
UI_STATUSES,
|
||||||
internal_to_ui_status,
|
internal_to_ui_status,
|
||||||
|
ui_status_label,
|
||||||
ui_to_internal_status,
|
ui_to_internal_status,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -61,9 +65,10 @@ router = APIRouter(tags=["admin-ui"])
|
||||||
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates"))
|
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates"))
|
||||||
ALLOWED_TRANSITIONS: dict[str, tuple[str, ...]] = {
|
ALLOWED_TRANSITIONS: dict[str, tuple[str, ...]] = {
|
||||||
"new": ("rewrite", "close"),
|
"new": ("rewrite", "close"),
|
||||||
"rewrite": ("freigabe", "publish", "close"),
|
"relevanz": ("rewrite", "close"),
|
||||||
|
"rewrite": ("freigabe", "close"),
|
||||||
"freigabe": ("publish", "rewrite", "close"),
|
"freigabe": ("publish", "rewrite", "close"),
|
||||||
"publish": ("published", "close"),
|
"publish": ("close",),
|
||||||
"published": ("rewrite", "close"),
|
"published": ("rewrite", "close"),
|
||||||
"close": ("rewrite",),
|
"close": ("rewrite",),
|
||||||
"no_image": ("rewrite", "close"),
|
"no_image": ("rewrite", "close"),
|
||||||
|
|
@ -180,6 +185,85 @@ def _build_image_entries(article: dict, extraction: dict, meta: dict) -> list[di
|
||||||
return entries
|
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]]:
|
def _publish_readiness(article: dict, meta: dict) -> tuple[bool, list[str]]:
|
||||||
reasons: list[str] = []
|
reasons: list[str] = []
|
||||||
status_ui = internal_to_ui_status(article.get("status"))
|
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")
|
@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)
|
user = _admin_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/admin/login", status_code=303)
|
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)
|
article = get_article_by_id(article_id)
|
||||||
if not article:
|
if not article:
|
||||||
return _dashboard_redirect(msg=f"Artikel #{article_id} nicht gefunden", msg_type="error")
|
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"}:
|
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:
|
try:
|
||||||
rewritten = rewrite_article_text(article)
|
rewritten = rewrite_article_text(article)
|
||||||
tags = generate_article_tags(article, rewritten_text=rewritten)
|
tags = generate_article_tags(article, rewritten_text=rewritten)
|
||||||
except Exception as exc:
|
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)
|
merged_meta = merge_generated_tags(article.get("meta_json"), tags)
|
||||||
new_status = post_rewrite_status()
|
new_status = post_rewrite_status()
|
||||||
_upsert_article_from_existing(article, content_rewritten=rewritten, status=new_status, meta_json=merged_meta)
|
_upsert_article_from_existing(article, content_rewritten=rewritten, status=new_status, meta_json=merged_meta)
|
||||||
if new_status == "pending_review":
|
if new_status == "pending_review":
|
||||||
clear_article_editorial_review(article_id)
|
clear_article_editorial_review(article_id)
|
||||||
target = "Freigabe" if new_status == "pending_review" else "publish"
|
target = ui_status_label(internal_to_ui_status(new_status))
|
||||||
return RedirectResponse(
|
return _back(f"Artikel #{article_id} neu geschrieben -> {target}")
|
||||||
url=f"/admin/articles/{article_id}?msg={quote_plus(f'Rewrite fertig -> {target}')}&type=success",
|
|
||||||
status_code=303,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/admin/rewrite/run")
|
@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")
|
@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.
|
"""Editorial sign-off: a person read the article and takes responsibility.
|
||||||
|
|
||||||
Stamps reviewer and system time, then hands the article to WordPress and the
|
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:
|
if not user:
|
||||||
return RedirectResponse(url="/admin/login", status_code=303)
|
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:
|
try:
|
||||||
result = approve_article(article_id, actor=user, note=note.strip() or None)
|
result = approve_article(article_id, actor=user, note=note.strip() or None)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return RedirectResponse(
|
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,
|
status_code=303,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return RedirectResponse(
|
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,
|
status_code=303,
|
||||||
)
|
)
|
||||||
|
|
||||||
slot = result.get("scheduled_publish_at") or "-"
|
slot = result.get("scheduled_publish_at") or "-"
|
||||||
return RedirectResponse(
|
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,
|
status_code=303,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/admin/articles/{article_id}/transition")
|
@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)
|
user = _admin_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/admin/login", status_code=303)
|
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)
|
article = get_article_by_id(article_id)
|
||||||
if article:
|
if article:
|
||||||
current_ui = internal_to_ui_status(article.get("status"))
|
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
|
# Route it through the same door so it cannot slip into WordPress
|
||||||
# unstamped and unscheduled.
|
# unstamped and unscheduled.
|
||||||
if target_ui == "publish" and not (article.get("editorial_review_at") or ""):
|
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)
|
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 _back(
|
||||||
return _dashboard_redirect(msg=f"Ungueltiger Statuswechsel fuer Artikel #{article_id}", msg_type="error")
|
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
|
_PAGE_SIZE = 50
|
||||||
|
|
@ -1023,6 +1182,8 @@ def admin_article_list(request: Request):
|
||||||
|
|
||||||
# Enrich each article with thumbnail URL
|
# Enrich each article with thumbnail URL
|
||||||
for a in articles:
|
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"))
|
meta = _parse_meta_json(a.get("meta_json"))
|
||||||
image_review = meta.get("image_review") if isinstance(meta.get("image_review"), dict) else {}
|
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
|
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_pages": total_pages,
|
||||||
"total": total,
|
"total": total,
|
||||||
"page_size": _PAGE_SIZE,
|
"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 "",
|
"status_filter": status_filter or "",
|
||||||
"search": search or "",
|
"search": search or "",
|
||||||
"flash_msg": request.query_params.get("msg", ""),
|
"flash_msg": request.query_params.get("msg", ""),
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,13 @@ from .repositories import (
|
||||||
update_article_status,
|
update_article_status,
|
||||||
upsert_article as repo_upsert_article,
|
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()
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
@ -135,8 +141,13 @@ class IngestionRunRequest(BaseModel):
|
||||||
feed_id: int | None = None
|
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):
|
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
|
note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -876,7 +876,8 @@ def list_articles(limit: int = 100, status_filter: str | None = None) -> list[di
|
||||||
rows = conn.execute(
|
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,
|
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.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.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,
|
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(
|
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,
|
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.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.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,
|
a.wp_post_id, a.wp_post_url, a.publish_attempts, a.publish_last_error, a.published_to_wp_at,
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,32 @@
|
||||||
from __future__ import annotations
|
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] = {
|
UI_STATUS_LABELS: dict[str, str] = {
|
||||||
"new": "Neu",
|
"new": "Neu",
|
||||||
"rewrite": "Rewrite",
|
"relevanz": "Niedrige Relevanz",
|
||||||
|
"rewrite": "Rewrite nötig",
|
||||||
"freigabe": "Wartet auf Freigabe",
|
"freigabe": "Wartet auf Freigabe",
|
||||||
"publish": "Freigegeben / eingeplant",
|
"publish": "Freigegeben / eingeplant",
|
||||||
"published": "Veroeffentlicht",
|
"published": "Veröffentlicht",
|
||||||
"close": "Verworfen",
|
"close": "Verworfen",
|
||||||
"no_image": "Kein Bild",
|
"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:
|
def internal_to_ui_status(status: str | None) -> str:
|
||||||
value = (status or "").strip()
|
value = (status or "").strip()
|
||||||
|
|
@ -23,7 +37,10 @@ def internal_to_ui_status(status: str | None) -> str:
|
||||||
if value == "pending_review":
|
if value == "pending_review":
|
||||||
return "freigabe"
|
return "freigabe"
|
||||||
if value == "review":
|
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"}:
|
if value in {"new", "rewrite", "published", "no_image"}:
|
||||||
return value
|
return value
|
||||||
return value or "new"
|
return value or "new"
|
||||||
|
|
@ -37,6 +54,8 @@ def ui_to_internal_status(status: str | None) -> str:
|
||||||
return "error"
|
return "error"
|
||||||
if value == "freigabe":
|
if value == "freigabe":
|
||||||
return "pending_review"
|
return "pending_review"
|
||||||
|
if value == "relevanz":
|
||||||
|
return "review"
|
||||||
if value in {"new", "rewrite", "published", "no_image"}:
|
if value in {"new", "rewrite", "published", "no_image"}:
|
||||||
return value
|
return value
|
||||||
if value in {"approved", "error", "review", "pending_review"}:
|
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")
|
return UI_STATUS_LABELS.get(value, value or "new")
|
||||||
|
|
||||||
|
|
||||||
# `freigabe` ist das Tor zur Veroeffentlichung: nur ueber diesen Zustand (oder
|
# `freigabe` ist das einzige Tor zur Veroeffentlichung: der Wechsel nach
|
||||||
# einen bewussten manuellen Wechsel) erreicht ein Artikel `publish`, und beides
|
# `publish` stempelt die redaktionelle Freigabe (Art. 50 Abs. 4 KI-VO).
|
||||||
# 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]] = {
|
ALLOWED_UI_TRANSITIONS: dict[str, set[str]] = {
|
||||||
"new": {"rewrite", "close"},
|
"new": {"rewrite", "close"},
|
||||||
"rewrite": {"freigabe", "publish", "close"},
|
"relevanz": {"rewrite", "close"},
|
||||||
|
"rewrite": {"freigabe", "close"},
|
||||||
"freigabe": {"publish", "rewrite", "close"},
|
"freigabe": {"publish", "rewrite", "close"},
|
||||||
"publish": {"published", "close"},
|
"publish": {"close"},
|
||||||
"published": {"rewrite", "close"},
|
"published": {"rewrite", "close"},
|
||||||
"close": {"rewrite"},
|
"close": {"rewrite"},
|
||||||
"no_image": {"rewrite", "close"},
|
"no_image": {"rewrite", "close"},
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<a class="linkbtn" href="/admin/dashboard">Zurück</a>
|
<a class="linkbtn" href="/admin/dashboard">Zurück</a>
|
||||||
|
<a class="linkbtn" href="/admin/freigabe">Freigabe</a>
|
||||||
<form method="post" action="/admin/logout">
|
<form method="post" action="/admin/logout">
|
||||||
<button type="submit" class="secondary">Logout</button>
|
<button type="submit" class="secondary">Logout</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<a class="linkbtn" href="/admin/dashboard">Dashboard</a>
|
<a class="linkbtn" href="/admin/dashboard">Dashboard</a>
|
||||||
|
<a class="linkbtn" href="/admin/freigabe">Freigabe</a>
|
||||||
<a class="linkbtn" href="/admin/schedule">Veröffentlichungsplan</a>
|
<a class="linkbtn" href="/admin/schedule">Veröffentlichungsplan</a>
|
||||||
<form method="post" action="/admin/logout">
|
<form method="post" action="/admin/logout">
|
||||||
<button type="submit" class="secondary">Logout</button>
|
<button type="submit" class="secondary">Logout</button>
|
||||||
|
|
@ -72,8 +73,8 @@
|
||||||
<label>Status</label>
|
<label>Status</label>
|
||||||
<select name="status_filter">
|
<select name="status_filter">
|
||||||
<option value="">Alle</option>
|
<option value="">Alle</option>
|
||||||
{% for s in ["new","review","pending_review","approved","published","error","no_image"] %}
|
{% for value, label in status_options %}
|
||||||
<option value="{{ s }}" {% if status_filter == s %}selected{% endif %}>{{ s }}</option>
|
<option value="{{ value }}" {% if status_filter == value %}selected{% endif %}>{{ label }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -139,7 +140,7 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge-sm badge-{{ a.status }}">{{ a.status }}</span>
|
<span class="badge-sm badge-{{ a.status }}">{{ a.status_label }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td style="font-size:0.82em;">
|
<td style="font-size:0.82em;">
|
||||||
{% if a.scheduled_publish_at %}
|
{% if a.scheduled_publish_at %}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
<p>Angemeldet als <strong>{{ user }}</strong></p>
|
<p>Angemeldet als <strong>{{ user }}</strong></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
<a class="linkbtn" href="/admin/freigabe">Freigabe{% if pending_review_count %} ({{ pending_review_count }}){% endif %}</a>
|
||||||
<a class="linkbtn" href="/admin/article-list">Artikelliste</a>
|
<a class="linkbtn" href="/admin/article-list">Artikelliste</a>
|
||||||
<a class="linkbtn" href="/admin/schedule">Veröffentlichungsplan</a>
|
<a class="linkbtn" href="/admin/schedule">Veröffentlichungsplan</a>
|
||||||
<a class="linkbtn" href="/admin/connectivity">Connectivity Check</a>
|
<a class="linkbtn" href="/admin/connectivity">Connectivity Check</a>
|
||||||
|
|
@ -239,7 +240,7 @@
|
||||||
{% if pending_review_count %}
|
{% if pending_review_count %}
|
||||||
<p class="flash flash-success" style="padding:8px 12px;">
|
<p class="flash flash-success" style="padding:8px 12px;">
|
||||||
📝 <strong>{{ pending_review_count }}</strong> Artikel warten auf deine redaktionelle Freigabe —
|
📝 <strong>{{ pending_review_count }}</strong> Artikel warten auf deine redaktionelle Freigabe —
|
||||||
<a href="/admin/dashboard?status_filter=freigabe">jetzt prüfen</a>.
|
<a href="/admin/freigabe">jetzt prüfen</a>.
|
||||||
Erst nach der Freigabe gehen sie nach WordPress.
|
Erst nach der Freigabe gehen sie nach WordPress.
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
163
backend/templates/admin_review_queue.html
Normal file
163
backend/templates/admin_review_queue.html
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>{{ title }}</title>
|
||||||
|
<link rel="stylesheet" href="/admin/static/admin.css" />
|
||||||
|
<style>
|
||||||
|
.queue-intro { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
|
||||||
|
.queue-count { font-size: 1.6em; font-weight: 700; }
|
||||||
|
.queue-index { margin: 0; padding-left: 1.2em; }
|
||||||
|
.queue-index li { margin: 2px 0; }
|
||||||
|
.review-item { border-left: 4px solid #f59e0b; }
|
||||||
|
.review-head { display: flex; gap: 16px; align-items: flex-start; flex-wrap: wrap; }
|
||||||
|
.review-head h2 { margin: 0 0 6px 0; }
|
||||||
|
.review-facts { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-bottom: 6px; }
|
||||||
|
.review-thumb { width: 180px; flex: 0 0 180px; }
|
||||||
|
.review-thumb img { width: 100%; border-radius: 6px; display: block; }
|
||||||
|
.review-headtext { flex: 1 1 320px; min-width: 260px; }
|
||||||
|
.review-text {
|
||||||
|
max-width: 72ch; margin: 14px 0; padding: 14px 18px;
|
||||||
|
background: #fcfcfd; border: 1px solid #e5e7eb; border-radius: 8px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
.review-text h2 { font-size: 1.15em; margin: 1.1em 0 0.3em; }
|
||||||
|
.review-text h3 { font-size: 1.05em; margin: 1em 0 0.3em; }
|
||||||
|
.review-text p { margin: 0.6em 0; }
|
||||||
|
.review-text ul, .review-text ol { margin: 0.6em 0; padding-left: 1.4em; }
|
||||||
|
.review-actions {
|
||||||
|
display: flex; gap: 10px; flex-wrap: wrap; align-items: center;
|
||||||
|
border-top: 1px solid #e5e7eb; padding-top: 12px; margin-top: 4px;
|
||||||
|
}
|
||||||
|
.review-actions .approve { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.review-actions .approve input[name="note"] { min-width: 220px; }
|
||||||
|
.review-actions .spacer { flex: 1 1 auto; }
|
||||||
|
.btn-approve { background: #047857; }
|
||||||
|
.score-hi { background: #d1fae5; color: #065f46; }
|
||||||
|
.score-mid { background: #fef3c7; color: #92400e; }
|
||||||
|
.queue-empty { text-align: center; padding: 36px 12px; }
|
||||||
|
.queue-empty .big { font-size: 2.2em; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<h1>Freigabe</h1>
|
||||||
|
<p>Angemeldet als <strong>{{ user }}</strong></p>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<a class="linkbtn" href="/admin/dashboard">Dashboard</a>
|
||||||
|
<a class="linkbtn" href="/admin/article-list">Artikelliste</a>
|
||||||
|
<a class="linkbtn" href="/admin/schedule">Veröffentlichungsplan</a>
|
||||||
|
<form method="post" action="/admin/logout">
|
||||||
|
<button type="submit" class="secondary">Logout</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
{% if flash_msg %}
|
||||||
|
<section class="card flash {{ 'flash-error' if flash_type == 'error' else 'flash-success' }}">
|
||||||
|
{{ flash_msg }}
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not articles %}
|
||||||
|
<section class="card queue-empty">
|
||||||
|
<div class="big">✅</div>
|
||||||
|
<h2>Nichts zu prüfen</h2>
|
||||||
|
<p class="subtle">
|
||||||
|
Kein Artikel wartet auf die redaktionelle Freigabe. Neue Artikel erscheinen
|
||||||
|
hier, sobald die Pipeline gelaufen ist.
|
||||||
|
</p>
|
||||||
|
<p><a class="linkbtn" href="/admin/dashboard">Zum Dashboard</a></p>
|
||||||
|
</section>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="queue-intro">
|
||||||
|
<span class="queue-count">{{ articles|length }}</span>
|
||||||
|
<span>{{ "Artikel wartet" if articles|length == 1 else "Artikel warten" }} auf deine Freigabe</span>
|
||||||
|
</div>
|
||||||
|
<p class="subtle">
|
||||||
|
Lies den Text, dann entscheide: freigeben, neu schreiben oder verwerfen.
|
||||||
|
Erst die Freigabe legt den WordPress-Beitrag an und bucht den
|
||||||
|
Veröffentlichungs-Slot. Prüfer und Uhrzeit werden dabei automatisch
|
||||||
|
festgehalten (Art. 50 Abs. 4 KI-VO).
|
||||||
|
</p>
|
||||||
|
{% if articles|length > 1 %}
|
||||||
|
<ol class="queue-index">
|
||||||
|
{% for a in articles %}
|
||||||
|
<li><a href="#artikel-{{ a.id }}">{{ a.title }}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% for a in articles %}
|
||||||
|
<section class="card review-item" id="artikel-{{ a.id }}">
|
||||||
|
<div class="review-head">
|
||||||
|
{% if a.selected_image_proxy_url %}
|
||||||
|
<div class="review-thumb">
|
||||||
|
<a href="{{ a.selected_image_url }}" target="_blank" rel="noopener">
|
||||||
|
<img src="{{ a.selected_image_proxy_url }}" data-fallback-src="{{ a.selected_image_url }}"
|
||||||
|
alt="Hauptbild" loading="lazy"
|
||||||
|
onerror="if(!this.dataset.fallbackUsed){this.dataset.fallbackUsed='1';this.src=this.dataset.fallbackSrc;}else{this.classList.add('img-failed');}" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="review-headtext">
|
||||||
|
<h2>{{ a.title }}</h2>
|
||||||
|
<div class="review-facts">
|
||||||
|
{% if a.relevance_score is not none %}
|
||||||
|
<span class="badge {{ 'score-hi' if a.relevance_score >= 85 else 'score-mid' }}">Relevanz {{ a.relevance_score }}/100</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="badge">{{ a.word_count or 0 }} Wörter</span>
|
||||||
|
{% if a.days_old is not none %}<span class="badge">{{ a.days_old }} Tage alt</span>{% endif %}
|
||||||
|
<span class="subtle">Quelle: {{ a.source_display }}</span>
|
||||||
|
</div>
|
||||||
|
{% if a.relevance_reason %}
|
||||||
|
<p class="subtle">Einschätzung: {{ a.relevance_reason }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if a.generated_tags %}
|
||||||
|
<p class="subtle">Tags: {{ a.generated_tags|join(", ") }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<p class="subtle">
|
||||||
|
<a href="{{ a.source_url }}" target="_blank" rel="noopener">Originalartikel öffnen</a>
|
||||||
|
|
|
||||||
|
<a href="/admin/articles/{{ a.id }}">Detailseite (Bilder, Rechtliches, Text bearbeiten)</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if a.preview_html %}
|
||||||
|
<div class="review-text">{{ a.preview_html|safe }}</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="subtle">Kein Rewrite-Text vorhanden — bitte neu schreiben lassen.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="review-actions">
|
||||||
|
<form method="post" action="/admin/articles/{{ a.id }}/approve" class="approve">
|
||||||
|
<input type="hidden" name="redirect_to" value="/admin/freigabe" />
|
||||||
|
<input name="note" placeholder="Notiz zur Prüfung (optional)" />
|
||||||
|
<button type="submit" class="btn-approve">✅ Freigeben</button>
|
||||||
|
</form>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<form method="post" action="/admin/articles/{{ a.id }}/rewrite-run" class="inline">
|
||||||
|
<input type="hidden" name="redirect_to" value="/admin/freigabe" />
|
||||||
|
<button type="submit" class="secondary" title="Text mit OpenAI neu schreiben lassen">✏️ Neu schreiben</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/admin/articles/{{ a.id }}/transition" class="inline">
|
||||||
|
<input type="hidden" name="target_status" value="close" />
|
||||||
|
<input type="hidden" name="note" value="Bei der redaktionellen Prüfung verworfen" />
|
||||||
|
<input type="hidden" name="redirect_to" value="/admin/freigabe" />
|
||||||
|
<button type="submit" class="secondary">❌ Verwerfen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<a class="linkbtn" href="/admin/dashboard">Dashboard</a>
|
<a class="linkbtn" href="/admin/dashboard">Dashboard</a>
|
||||||
|
<a class="linkbtn" href="/admin/freigabe">Freigabe</a>
|
||||||
<a class="linkbtn" href="/admin/connectivity">Connectivity</a>
|
<a class="linkbtn" href="/admin/connectivity">Connectivity</a>
|
||||||
<form method="post" action="/admin/logout">
|
<form method="post" action="/admin/logout">
|
||||||
<button type="submit" class="secondary">Logout</button>
|
<button type="submit" class="secondary">Logout</button>
|
||||||
|
|
|
||||||
|
|
@ -65,24 +65,33 @@ class TestArticleWorkflow(unittest.TestCase):
|
||||||
return article.json()["id"]
|
return article.json()["id"]
|
||||||
|
|
||||||
def test_valid_transition_chain(self) -> None:
|
def test_valid_transition_chain(self) -> None:
|
||||||
|
"""The chain now runs through the editorial gate: rewrite -> freigabe -> publish."""
|
||||||
article_id = self._create_article()
|
article_id = self._create_article()
|
||||||
|
|
||||||
t1 = self.client.post(f"/api/articles/{article_id}/transition", json={"target_status": "rewrite"})
|
t1 = self.client.post(f"/api/articles/{article_id}/transition", json={"target_status": "rewrite"})
|
||||||
self.assertEqual(t1.status_code, 200)
|
self.assertEqual(t1.status_code, 200)
|
||||||
|
|
||||||
t2 = self.client.post(f"/api/articles/{article_id}/transition", json={"target_status": "publish"})
|
t2 = self.client.post(f"/api/articles/{article_id}/transition", json={"target_status": "freigabe"})
|
||||||
self.assertEqual(t2.status_code, 200)
|
self.assertEqual(t2.status_code, 200)
|
||||||
|
|
||||||
t3 = self.client.post(f"/api/articles/{article_id}/transition", json={"target_status": "published"})
|
t3 = self.client.post(f"/api/articles/{article_id}/transition", json={"target_status": "publish"})
|
||||||
self.assertEqual(t3.status_code, 200)
|
self.assertEqual(t3.status_code, 200)
|
||||||
|
|
||||||
t4 = self.client.post(f"/api/articles/{article_id}/transition", json={"target_status": "rewrite"})
|
t4 = self.client.post(f"/api/articles/{article_id}/transition", json={"target_status": "close"})
|
||||||
self.assertEqual(t4.status_code, 200)
|
self.assertEqual(t4.status_code, 200)
|
||||||
|
|
||||||
final = self.client.get(f"/api/articles/{article_id}")
|
final = self.client.get(f"/api/articles/{article_id}")
|
||||||
self.assertEqual(final.status_code, 200)
|
self.assertEqual(final.status_code, 200)
|
||||||
self.assertEqual(final.json()["item"]["status"], "rewrite")
|
self.assertEqual(final.json()["item"]["status"], "error")
|
||||||
self.assertEqual(final.json()["item"]["status_ui"], "rewrite")
|
self.assertEqual(final.json()["item"]["status_ui"], "close")
|
||||||
|
|
||||||
|
def test_rewrite_cannot_jump_straight_to_publish(self) -> None:
|
||||||
|
"""The old shortcut past the sign-off is gone."""
|
||||||
|
article_id = self._create_article()
|
||||||
|
self.client.post(f"/api/articles/{article_id}/transition", json={"target_status": "rewrite"})
|
||||||
|
|
||||||
|
jump = self.client.post(f"/api/articles/{article_id}/transition", json={"target_status": "publish"})
|
||||||
|
self.assertEqual(jump.status_code, 400)
|
||||||
|
|
||||||
def test_invalid_transition_rejected(self) -> None:
|
def test_invalid_transition_rejected(self) -> None:
|
||||||
article_id = self._create_article()
|
article_id = self._create_article()
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from backend.app import config as config_module
|
from backend.app import config as config_module
|
||||||
from backend.app import pipeline as pipeline_module
|
from backend.app import pipeline as pipeline_module
|
||||||
|
from backend.app.admin_ui import _safe_admin_redirect, _sanitize_preview_html
|
||||||
from backend.app.db import init_db
|
from backend.app.db import init_db
|
||||||
from backend.app.main import app
|
from backend.app.main import app
|
||||||
from backend.app.publisher import _can_publish
|
from backend.app.publisher import _can_publish
|
||||||
|
|
@ -315,7 +316,113 @@ class TestDashboardSurfacesQueue(EditorialReviewTestBase):
|
||||||
page = self.client.get("/admin/dashboard")
|
page = self.client.get("/admin/dashboard")
|
||||||
self.assertEqual(page.status_code, 200)
|
self.assertEqual(page.status_code, 200)
|
||||||
self.assertIn("warten auf deine redaktionelle Freigabe", page.text)
|
self.assertIn("warten auf deine redaktionelle Freigabe", page.text)
|
||||||
self.assertIn("status_filter=freigabe", page.text)
|
self.assertIn('href="/admin/freigabe"', page.text)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPreviewSanitizer(unittest.TestCase):
|
||||||
|
"""The rewrite text is model output over scraped pages — render it filtered."""
|
||||||
|
|
||||||
|
def test_keeps_article_markup(self) -> None:
|
||||||
|
html = _sanitize_preview_html("<h2>Titel</h2><p>Text mit <strong>Betonung</strong></p><ul><li>Punkt</li></ul>")
|
||||||
|
self.assertIn("<h2>Titel</h2>", html)
|
||||||
|
self.assertIn("<strong>Betonung</strong>", html)
|
||||||
|
self.assertIn("<li>Punkt</li>", html)
|
||||||
|
|
||||||
|
def test_drops_scripts_and_their_content(self) -> None:
|
||||||
|
html = _sanitize_preview_html('<p>Text</p><script>alert("x")</script>')
|
||||||
|
self.assertIn("<p>Text</p>", html)
|
||||||
|
self.assertNotIn("<script", html)
|
||||||
|
self.assertNotIn("alert", html)
|
||||||
|
|
||||||
|
def test_strips_event_handlers_and_unknown_tags(self) -> None:
|
||||||
|
html = _sanitize_preview_html('<p onclick="evil()">Hallo</p><iframe src="x"></iframe>')
|
||||||
|
self.assertNotIn("onclick", html)
|
||||||
|
self.assertNotIn("<iframe", html)
|
||||||
|
self.assertIn("Hallo", html)
|
||||||
|
|
||||||
|
def test_keeps_only_http_links(self) -> None:
|
||||||
|
html = _sanitize_preview_html('<p><a href="javascript:evil()">bad</a> <a href="https://ok.de">gut</a></p>')
|
||||||
|
self.assertNotIn("javascript:", html)
|
||||||
|
self.assertIn('href="https://ok.de"', html)
|
||||||
|
|
||||||
|
def test_plain_text_gets_a_paragraph(self) -> None:
|
||||||
|
self.assertEqual(_sanitize_preview_html("Nur Text"), "<p>Nur Text</p>")
|
||||||
|
|
||||||
|
def test_empty_input(self) -> None:
|
||||||
|
self.assertEqual(_sanitize_preview_html(None), "")
|
||||||
|
self.assertEqual(_sanitize_preview_html(" "), "")
|
||||||
|
|
||||||
|
|
||||||
|
class TestSafeRedirect(unittest.TestCase):
|
||||||
|
def test_accepts_admin_paths(self) -> None:
|
||||||
|
self.assertEqual(_safe_admin_redirect("/admin/freigabe", "/fallback"), "/admin/freigabe")
|
||||||
|
|
||||||
|
def test_rejects_foreign_targets(self) -> None:
|
||||||
|
for bad in ("https://evil.example/x", "//evil.example", "/admin//evil.example", "", None):
|
||||||
|
self.assertEqual(_safe_admin_redirect(bad, "/fallback"), "/fallback")
|
||||||
|
|
||||||
|
|
||||||
|
class TestReviewQueuePage(EditorialReviewTestBase):
|
||||||
|
def _login(self) -> None:
|
||||||
|
self.client.post(
|
||||||
|
"/admin/login",
|
||||||
|
data={"username": "admin", "password": "secret"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_queue_lists_pending_articles_with_all_three_actions(self) -> None:
|
||||||
|
self._login()
|
||||||
|
article_id = self._create_article(status="pending_review", rewritten="<h2>Kapitel</h2><p>Inhalt hier</p>")
|
||||||
|
|
||||||
|
page = self.client.get("/admin/freigabe")
|
||||||
|
self.assertEqual(page.status_code, 200)
|
||||||
|
self.assertIn("Artikel zur Freigabe", page.text) # Titel
|
||||||
|
self.assertIn("Inhalt hier", page.text) # lesbarer Text
|
||||||
|
self.assertIn(f"/admin/articles/{article_id}/approve", page.text)
|
||||||
|
self.assertIn(f"/admin/articles/{article_id}/rewrite-run", page.text)
|
||||||
|
self.assertIn(f"/admin/articles/{article_id}/transition", page.text)
|
||||||
|
|
||||||
|
def test_queue_hides_articles_in_other_states(self) -> None:
|
||||||
|
self._login()
|
||||||
|
self._create_article(status="published", rewritten=LONG_TEXT)
|
||||||
|
|
||||||
|
page = self.client.get("/admin/freigabe")
|
||||||
|
self.assertIn("Nichts zu prüfen", page.text)
|
||||||
|
|
||||||
|
def test_approving_from_queue_returns_to_the_queue(self) -> None:
|
||||||
|
self._login()
|
||||||
|
article_id = self._create_article(status="pending_review", rewritten=LONG_TEXT)
|
||||||
|
|
||||||
|
with patch.object(pipeline_module, "reserve_publish_slot", return_value="2026-08-25 09:00:00"), \
|
||||||
|
patch.object(pipeline_module, "publish_article_draft", return_value=(7, "https://blog/p/7")):
|
||||||
|
response = self.client.post(
|
||||||
|
f"/admin/articles/{article_id}/approve",
|
||||||
|
data={"note": "", "redirect_to": "/admin/freigabe"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 303)
|
||||||
|
self.assertTrue(response.headers["location"].startswith("/admin/freigabe?"))
|
||||||
|
self.assertEqual(get_article_by_id(article_id)["status"], "approved")
|
||||||
|
|
||||||
|
def test_discarding_from_queue_returns_to_the_queue(self) -> None:
|
||||||
|
self._login()
|
||||||
|
article_id = self._create_article(status="pending_review", rewritten=LONG_TEXT)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"/admin/articles/{article_id}/transition",
|
||||||
|
data={"target_status": "close", "note": "", "redirect_to": "/admin/freigabe"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 303)
|
||||||
|
self.assertTrue(response.headers["location"].startswith("/admin/freigabe?"))
|
||||||
|
self.assertEqual(get_article_by_id(article_id)["status"], "error")
|
||||||
|
|
||||||
|
def test_queue_requires_login(self) -> None:
|
||||||
|
response = self.client.get("/admin/freigabe", follow_redirects=False)
|
||||||
|
self.assertEqual(response.status_code, 303)
|
||||||
|
self.assertIn("/admin/login", response.headers["location"])
|
||||||
|
|
||||||
|
|
||||||
class TestLegacyArticlesKeepPublishing(EditorialReviewTestBase):
|
class TestLegacyArticlesKeepPublishing(EditorialReviewTestBase):
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ N8N (2× täglich, 08:00 + 16:00 Uhr)
|
||||||
│ └── Score < 60 → Abgelehnt + tägliche Telegram-Liste
|
│ └── Score < 60 → Abgelehnt + tägliche Telegram-Liste
|
||||||
└── Pipeline-Zusammenfassung via Telegram
|
└── Pipeline-Zusammenfassung via Telegram
|
||||||
|
|
||||||
Danach, manuell im Portal (news.vanityontour.de):
|
Danach, manuell im Portal (news.vanityontour.de/admin/freigabe):
|
||||||
Artikel öffnen → lesen → [✅ Redaktionell geprüft & freigeben]
|
Artikel lesen → [✅ Freigeben] / [✏️ Neu schreiben] / [❌ Verwerfen]
|
||||||
├── Stempel: Prüfer + Systemzeit (nicht editierbar)
|
├── Stempel: Prüfer + Systemzeit (nicht editierbar)
|
||||||
├── Publish-Slot reservieren
|
├── Publish-Slot reservieren
|
||||||
└── WordPress-Beitrag anlegen (Status "future" zum Slot)
|
└── WordPress-Beitrag anlegen (Status "future" zum Slot)
|
||||||
|
|
|
||||||
|
|
@ -41,12 +41,11 @@ N8N (2× täglich) → POST /api/n8n/pipeline
|
||||||
└── Status: pending_review ──► KEIN WordPress-Beitrag, KEIN Slot
|
└── Status: pending_review ──► KEIN WordPress-Beitrag, KEIN Slot
|
||||||
Telegram: Info + Link ins Portal
|
Telegram: Info + Link ins Portal
|
||||||
│
|
│
|
||||||
Mensch öffnet den Artikel im Portal, liest ihn
|
Mensch öffnet /admin/freigabe und liest den Artikel
|
||||||
│
|
│
|
||||||
┌─────────────────────────┼──────────────────────────┐
|
┌─────────────────────────┼──────────────────────────┐
|
||||||
▼ ▼ ▼
|
▼ ▼ ▼
|
||||||
„Redaktionell geprüft „Rewrite ausführen" „Verwerfen"
|
„✅ Freigeben" „✏️ Neu schreiben" „❌ Verwerfen"
|
||||||
& freigeben" (neu schreiben) (Status close)
|
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
Stempel: editorial_review_at / _by / _note (Systemzeit, nicht editierbar)
|
Stempel: editorial_review_at / _by / _note (Systemzeit, nicht editierbar)
|
||||||
|
|
@ -62,6 +61,19 @@ Wichtig an der Reihenfolge:
|
||||||
der stundenlang auf die Prüfung wartet, darf keinen davon blockieren. Der Slot
|
der stundenlang auf die Prüfung wartet, darf keinen davon blockieren. Der Slot
|
||||||
wird im Moment der Freigabe reserviert, der Scheduler füllt Lücken auf.
|
wird im Moment der Freigabe reserviert, der Scheduler füllt Lücken auf.
|
||||||
|
|
||||||
|
## Die Freigabe-Seite
|
||||||
|
|
||||||
|
`/admin/freigabe` ist die Arbeitsliste: alle Artikel im Status „Wartet auf
|
||||||
|
Freigabe", jeweils mit Hauptbild, Relevanz-Score samt Begründung, Tags, Link zum
|
||||||
|
Originalartikel — und dem **umgeschriebenen Text lesbar gerendert**, nicht als
|
||||||
|
Markup. Darunter die drei Aktionen. Nach jeder Aktion landet man wieder in der
|
||||||
|
Liste, sodass eine Warteschlange von oben nach unten abgearbeitet werden kann.
|
||||||
|
|
||||||
|
Der Text stammt aus einem Sprachmodell, das mit fremden Webseiten gefüttert
|
||||||
|
wurde. Für die Anzeige wird er deshalb durch einen Tag-Whitelist-Filter geschickt
|
||||||
|
(`_sanitize_preview_html`): Absätze, Überschriften, Listen und http(s)-Links
|
||||||
|
bleiben, alles andere fällt weg.
|
||||||
|
|
||||||
## Wo der Nachweis liegt
|
## Wo der Nachweis liegt
|
||||||
|
|
||||||
| Ort | Inhalt |
|
| Ort | Inhalt |
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue