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
|
||||
|
||||
### ⚖️ Redaktionelle Freigabe vor der Veröffentlichung (Art. 50 Abs. 4 KI-VO)
|
||||
|
|
|
|||
|
|
@ -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", ""),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
</div>
|
||||
<div class="row">
|
||||
<a class="linkbtn" href="/admin/dashboard">Zurück</a>
|
||||
<a class="linkbtn" href="/admin/freigabe">Freigabe</a>
|
||||
<form method="post" action="/admin/logout">
|
||||
<button type="submit" class="secondary">Logout</button>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@
|
|||
</div>
|
||||
<div class="row">
|
||||
<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>
|
||||
<form method="post" action="/admin/logout">
|
||||
<button type="submit" class="secondary">Logout</button>
|
||||
|
|
@ -72,8 +73,8 @@
|
|||
<label>Status</label>
|
||||
<select name="status_filter">
|
||||
<option value="">Alle</option>
|
||||
{% for s in ["new","review","pending_review","approved","published","error","no_image"] %}
|
||||
<option value="{{ s }}" {% if status_filter == s %}selected{% endif %}>{{ s }}</option>
|
||||
{% for value, label in status_options %}
|
||||
<option value="{{ value }}" {% if status_filter == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
|
@ -139,7 +140,7 @@
|
|||
{% endif %}
|
||||
</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 style="font-size:0.82em;">
|
||||
{% if a.scheduled_publish_at %}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
<p>Angemeldet als <strong>{{ user }}</strong></p>
|
||||
</div>
|
||||
<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/schedule">Veröffentlichungsplan</a>
|
||||
<a class="linkbtn" href="/admin/connectivity">Connectivity Check</a>
|
||||
|
|
@ -239,7 +240,7 @@
|
|||
{% if pending_review_count %}
|
||||
<p class="flash flash-success" style="padding:8px 12px;">
|
||||
📝 <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.
|
||||
</p>
|
||||
{% 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 class="row">
|
||||
<a class="linkbtn" href="/admin/dashboard">Dashboard</a>
|
||||
<a class="linkbtn" href="/admin/freigabe">Freigabe</a>
|
||||
<a class="linkbtn" href="/admin/connectivity">Connectivity</a>
|
||||
<form method="post" action="/admin/logout">
|
||||
<button type="submit" class="secondary">Logout</button>
|
||||
|
|
|
|||
|
|
@ -65,24 +65,33 @@ class TestArticleWorkflow(unittest.TestCase):
|
|||
return article.json()["id"]
|
||||
|
||||
def test_valid_transition_chain(self) -> None:
|
||||
"""The chain now runs through the editorial gate: rewrite -> freigabe -> publish."""
|
||||
article_id = self._create_article()
|
||||
|
||||
t1 = self.client.post(f"/api/articles/{article_id}/transition", json={"target_status": "rewrite"})
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
final = self.client.get(f"/api/articles/{article_id}")
|
||||
self.assertEqual(final.status_code, 200)
|
||||
self.assertEqual(final.json()["item"]["status"], "rewrite")
|
||||
self.assertEqual(final.json()["item"]["status_ui"], "rewrite")
|
||||
self.assertEqual(final.json()["item"]["status"], "error")
|
||||
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:
|
||||
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 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.main import app
|
||||
from backend.app.publisher import _can_publish
|
||||
|
|
@ -315,7 +316,113 @@ class TestDashboardSurfacesQueue(EditorialReviewTestBase):
|
|||
page = self.client.get("/admin/dashboard")
|
||||
self.assertEqual(page.status_code, 200)
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ N8N (2× täglich, 08:00 + 16:00 Uhr)
|
|||
│ └── Score < 60 → Abgelehnt + tägliche Telegram-Liste
|
||||
└── Pipeline-Zusammenfassung via Telegram
|
||||
|
||||
Danach, manuell im Portal (news.vanityontour.de):
|
||||
Artikel öffnen → lesen → [✅ Redaktionell geprüft & freigeben]
|
||||
Danach, manuell im Portal (news.vanityontour.de/admin/freigabe):
|
||||
Artikel lesen → [✅ Freigeben] / [✏️ Neu schreiben] / [❌ Verwerfen]
|
||||
├── Stempel: Prüfer + Systemzeit (nicht editierbar)
|
||||
├── Publish-Slot reservieren
|
||||
└── 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
|
||||
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) (Status close)
|
||||
„✅ Freigeben" „✏️ Neu schreiben" „❌ Verwerfen"
|
||||
│
|
||||
▼
|
||||
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
|
||||
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
|
||||
|
||||
| Ort | Inhalt |
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue