rss-news/backend/templates/admin_dashboard.html
Oliver G a233012887
feat(pipeline): redaktionelle Freigabe vor der Veroeffentlichung
Die Pipeline liess GPT Artikel umschreiben und legte sie direkt als
geplanten WordPress-Beitrag an - ohne dass ein Mensch sie gesehen hat.
Der KI-Hinweis auf dem Blog sagt aber redaktionelle Pruefung zu, und
genau daran haengt die Ausnahme in Art. 50 Abs. 4 KI-VO.

Neuer Status `pending_review` zwischen Rewrite und Publish: Die Pipeline
endet beim Rewrite, ohne WordPress-Beitrag und ohne Publish-Slot. Erst
die Freigabe im Portal stempelt Pruefer und Systemzeit, reserviert den
Slot und legt den Beitrag an.

- Migration: editorial_review_at/_by/_note, Status-CHECK erweitert
- Spalten-Migration laeuft nach den Tabellen-Neubauten erneut, sonst
  verwirft der aeltere no_image-Rebuild die frisch angelegten Spalten
- Jeder Weg nach `approved` stempelt (Button, Statuswechsel, API)
- Jeder maschinelle Rewrite loescht einen alten Stempel
- Telegram: Info mit Portal-Link statt Draft-Meldung, kein Freigabe-Button
- Altbestand bleibt unberuehrt und veroeffentlicht weiter
- EDITORIAL_REVIEW_REQUIRED=false stellt den alten Ablauf wieder her
- 14 neue Tests, docs/KI-VO.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 10:35:49 +02:00

412 lines
17 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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" />
</head>
<body>
<header class="topbar">
<div>
<h1>rss-news Admin Dashboard</h1>
<p>Angemeldet als <strong>{{ user }}</strong></p>
</div>
<div class="row">
<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>
<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 %}
<section class="stats">
<article class="stat">
<div class="label">Quellen</div>
<div class="value">{{ sources|length }}</div>
</article>
<article class="stat">
<div class="label">Feeds</div>
<div class="value">{{ feeds|length }}</div>
</article>
<article class="stat">
<div class="label">Artikel</div>
<div class="value">{{ articles|length }}</div>
</article>
<article class="stat">
<div class="label">Runs</div>
<div class="value">{{ runs|length }}</div>
</article>
</section>
<section class="grid two">
<article class="card">
<h2>Quelle anlegen</h2>
<form method="post" action="/admin/sources/create" class="stack">
<input name="name" placeholder="Name" required />
<input name="base_url" placeholder="Base URL" />
<input name="terms_url" placeholder="Terms URL" />
<input name="license_name" placeholder="Lizenzname" />
<select name="risk_level">
<option value="green">green</option>
<option value="yellow" selected>yellow</option>
<option value="red">red</option>
</select>
<input name="last_reviewed_at" placeholder="last_reviewed_at (ISO)" />
<button type="submit">Quelle speichern</button>
</form>
</article>
<article class="card">
<h2>Feed anlegen</h2>
<form method="post" action="/admin/feeds/create" class="stack">
<input name="name" placeholder="Feed Name" required />
<input name="url" placeholder="https://..." required />
<label>Quelle</label>
<select name="source_id">
<option value="">-- keine --</option>
{% for s in sources %}
<option value="{{ s.id }}">{{ s.name }} (#{{ s.id }})</option>
{% endfor %}
</select>
<button type="submit">Feed speichern</button>
</form>
</article>
</section>
<section class="card">
<h2>Ingestion starten</h2>
<form method="post" action="/admin/ingestion/run" class="row">
<select name="feed_id">
<option value="">Alle aktivierten Feeds</option>
{% for f in feeds %}
<option value="{{ f.id }}">{{ f.name }} (#{{ f.id }})</option>
{% endfor %}
</select>
<button type="submit">Ingestion starten</button>
</form>
</section>
<section class="card">
<h2>Publisher ausführen</h2>
<form method="post" action="/admin/publisher/run" class="row">
<input name="max_jobs" value="10" />
<button type="submit">Publisher Run starten</button>
</form>
</section>
<section class="card">
<h2>Rewrite Run (geplante Artikel)</h2>
<p class="subtle">Verarbeitet alle Artikel im Status <code>rewrite</code> und setzt sie auf <code>publish</code>.</p>
<form method="post" action="/admin/rewrite/run" class="row">
<input name="max_jobs" value="10" />
<button type="submit">Rewrite Run starten</button>
</form>
</section>
<section class="card">
<h2>Quellen + Policy</h2>
<table>
<thead>
<tr><th>ID</th><th>Name</th><th>Risk</th><th>Lizenz</th><th>Terms</th><th>Policy</th></tr>
</thead>
<tbody>
{% for s in sources %}
<tr>
<td>{{ s.id }}</td>
<td>{{ s.name }}</td>
<td>{{ s.risk_level }}</td>
<td>{{ s.license_name or "-" }}</td>
<td>{{ s.terms_url or "-" }}</td>
<td>
{% if source_policy[s.id] %}
<span class="badge bad">BLOCKED ({{ source_policy[s.id]|length }})</span>
<div class="subtle">{{ source_policy[s.id]|join(", ") }}</div>
{% else %}
<span class="badge ok">OK</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="card">
<h2>Quellen verwalten</h2>
<table>
<thead>
<tr><th>ID</th><th>Name</th><th>URLs</th><th>Meta</th><th>Aktionen</th></tr>
</thead>
<tbody>
{% for s in sources %}
{% set source_form_id = 'source-update-' ~ s.id %}
<tr>
<td>#{{ s.id }}</td>
<td>
<input form="{{ source_form_id }}" name="name" value="{{ s.name }}" required />
</td>
<td>
<input form="{{ source_form_id }}" name="base_url" value="{{ s.base_url or '' }}" placeholder="Base URL" />
<input form="{{ source_form_id }}" name="terms_url" value="{{ s.terms_url or '' }}" placeholder="Terms URL" />
<input form="{{ source_form_id }}" name="license_name" value="{{ s.license_name or '' }}" placeholder="Lizenz" />
</td>
<td>
<select form="{{ source_form_id }}" name="risk_level">
<option value="green" {% if s.risk_level == 'green' %}selected{% endif %}>green</option>
<option value="yellow" {% if s.risk_level == 'yellow' %}selected{% endif %}>yellow</option>
<option value="red" {% if s.risk_level == 'red' %}selected{% endif %}>red</option>
</select>
<select form="{{ source_form_id }}" name="is_enabled">
<option value="1" {% if s.is_enabled %}selected{% endif %}>aktiv</option>
<option value="0" {% if not s.is_enabled %}selected{% endif %}>inaktiv</option>
</select>
<input form="{{ source_form_id }}" name="last_reviewed_at" value="{{ s.last_reviewed_at or '' }}" placeholder="last_reviewed_at" />
<input form="{{ source_form_id }}" name="notes" value="{{ s.notes or '' }}" placeholder="Notiz" />
</td>
<td>
<div class="inline">
<form method="post" action="/admin/sources/{{ s.id }}/update" id="{{ source_form_id }}" class="inline">
<button type="submit" class="secondary">Speichern</button>
</form>
<form method="post" action="/admin/sources/{{ s.id }}/delete" class="inline" onsubmit="return confirm('Quelle wirklich löschen?');">
<button type="submit">Löschen</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="card">
<h2>Feeds verwalten</h2>
<table>
<thead>
<tr><th>ID</th><th>Name</th><th>URL</th><th>Quelle</th><th>Status</th><th>Aktionen</th></tr>
</thead>
<tbody>
{% for f in feeds %}
{% set feed_form_id = 'feed-update-' ~ f.id %}
<tr>
<td>#{{ f.id }}</td>
<td>
<input form="{{ feed_form_id }}" name="name" value="{{ f.name }}" required />
</td>
<td><input form="{{ feed_form_id }}" name="url" value="{{ f.url }}" required /></td>
<td>
<select form="{{ feed_form_id }}" name="source_id">
<option value="">-- keine --</option>
{% for s in sources %}
<option value="{{ s.id }}" {% if f.source_id == s.id %}selected{% endif %}>{{ s.name }} (#{{ s.id }})</option>
{% endfor %}
</select>
</td>
<td>
<select form="{{ feed_form_id }}" name="is_enabled">
<option value="1" {% if f.is_enabled %}selected{% endif %}>aktiv</option>
<option value="0" {% if not f.is_enabled %}selected{% endif %}>inaktiv</option>
</select>
</td>
<td>
<div class="inline">
<form method="post" action="/admin/feeds/{{ f.id }}/update" id="{{ feed_form_id }}" class="inline">
<button type="submit" class="secondary">Speichern</button>
</form>
<form method="post" action="/admin/feeds/{{ f.id }}/delete" class="inline" onsubmit="return confirm('Feed wirklich löschen?');">
<button type="submit">Löschen</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="card">
<h2>Artikel (Review)</h2>
{% 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>.
Erst nach der Freigabe gehen sie nach WordPress.
</p>
{% endif %}
<form method="get" action="/admin/dashboard" class="row filter-row">
<label>Status-Filter</label>
<select name="status_filter">
<option value="" {% if not status_filter %}selected{% endif %}>alle</option>
{% for s in status_options %}
<option value="{{ s }}" {% if status_filter == s %}selected{% endif %}>{{ status_labels.get(s, s) }}</option>
{% endfor %}
</select>
<button type="submit" class="secondary">Filtern</button>
<a href="/admin/dashboard" class="linkbtn">Reset</a>
<a href="/api/articles/export?format=json{% if status_filter %}&status_filter={{ status_filter }}{% endif %}" class="linkbtn">Export JSON</a>
<a href="/api/articles/export?format=csv{% if status_filter %}&status_filter={{ status_filter }}{% endif %}" class="linkbtn">Export CSV</a>
</form>
<table>
<thead>
<tr><th>ID</th><th>Artikel</th><th>Status</th><th>Details</th><th>Rewrite</th><th>Transition</th></tr>
</thead>
<tbody>
{% for a in articles %}
<tr>
<td>{{ a.id }}</td>
<td>
<strong>{{ a.title }}</strong><br />
<span class="subtle">Autor: {{ a.author or "-" }}</span><br />
<span class="subtle">Datum: {{ a.published_at or "-" }} | Alter: {{ a.days_old if a.days_old is not none else "-" }} Tage | Relevanz: {{ a.relevance }}</span><br />
<a href="{{ a.source_url }}" target="_blank" rel="noopener">Original öffnen</a>
<br /><a href="/admin/articles/{{ a.id }}">Details anzeigen</a>
{% if a.canonical_url and a.canonical_url != a.source_url %}
<br /><a href="{{ a.canonical_url }}" target="_blank" rel="noopener">Canonical öffnen</a>
{% endif %}
</td>
<td><span class="badge">{{ status_labels.get(a.status_ui, a.status_ui) }}</span></td>
<td>
<div class="subtle">Publish: {{ "bereit" if a.publish_ready else "blockiert" }}</div>
{% if not a.publish_ready and a.publish_blockers %}
<div class="subtle">{{ a.publish_blockers|join(", ") }}</div>
{% endif %}
{% if a.selected_image_url %}
<div class="subtle">Hauptbild gesetzt</div>
<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" class="thumb" loading="lazy" onerror="if(!this.dataset.fallbackUsed){this.dataset.fallbackUsed='1';this.src=this.dataset.fallbackSrc;}else{this.classList.add('img-failed');}" /></a>
{% endif %}
{% if a.summary %}
<div><strong>Summary:</strong> {{ a.summary }}</div>
{% endif %}
{% if a.generated_tags %}
<div><strong>Tags:</strong> {{ a.generated_tags|join("; ") }}</div>
{% endif %}
{% if a.content_raw %}
<details>
<summary>Volltext anzeigen</summary>
<div class="pre">{{ a.content_raw }}</div>
</details>
{% endif %}
<div class="subtle">Bilder: {{ a.extracted_images|length }}</div>
{% if a.extracted_images %}
<details>
<summary>Bild-URLs</summary>
<ul>
{% for img in a.extracted_images %}
<li><a href="{{ img }}" target="_blank" rel="noopener">{{ img }}</a></li>
{% endfor %}
</ul>
</details>
{% endif %}
{% if a.press_contact %}
<details>
<summary>Pressekontakt</summary>
<div class="pre">{{ a.press_contact }}</div>
</details>
{% endif %}
{% if a.extraction_error %}
<div class="subtle">Extraktionsfehler: {{ a.extraction_error }}</div>
{% endif %}
</td>
<td>
{% if a.status_ui in ["new", "rewrite"] %}
<form method="post" action="/admin/articles/{{ a.id }}/rewrite-run" class="inline">
<button type="submit">Rewrite ausführen</button>
</form>
{% else %}
-
{% endif %}
</td>
<td>
<form method="post" action="/admin/articles/{{ a.id }}/transition" class="inline">
<select name="target_status">
{% for s in allowed_transitions.get(a.status_ui, []) %}
<option value="{{ s }}">{{ status_labels.get(s, s) }}</option>
{% endfor %}
</select>
{% if allowed_transitions.get(a.status_ui, []) %}
<button type="submit" class="secondary">Setzen</button>
{% else %}
<span class="subtle">keine Aktion</span>
{% endif %}
</form>
{% if a.status_ui == 'close' %}
<form method="post" action="/admin/articles/{{ a.id }}/retry" class="inline" style="margin-top:4px;">
<button type="submit" title="Artikel auf 'neu' zurücksetzen wird beim nächsten Pipeline-Lauf erneut verarbeitet">🔄 Wiederholen</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="card">
<h2>Runs</h2>
<table>
<thead>
<tr><th>ID</th><th>Typ</th><th>Status</th><th>Start</th><th>Ende</th></tr>
</thead>
<tbody>
{% for r in runs %}
<tr>
<td>{{ r.id }}</td>
<td>{{ r.run_type }}</td>
<td>{{ r.status }}</td>
<td>{{ r.started_at }}</td>
<td>{{ r.finished_at or "-" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="card">
<h2>Publish Jobs</h2>
<table>
<thead>
<tr><th>ID</th><th>Artikel</th><th>Status</th><th>Attempts</th><th>WP Post</th><th>Fehler</th><th>Hinweis</th></tr>
</thead>
<tbody>
{% for j in publish_jobs %}
<tr>
<td>{{ j.id }}</td>
<td>#{{ j.article_id }} {{ j.article_title or "-" }}</td>
<td>{{ j.status }}</td>
<td>{{ j.attempts }}/{{ j.max_attempts }}</td>
<td>
{% if j.wp_post_url %}
<a href="{{ j.wp_post_url }}" target="_blank" rel="noopener">#{{ j.wp_post_id }}</a>
{% elif j.wp_post_id %}
#{{ j.wp_post_id }}
{% else %}
-
{% endif %}
</td>
<td>
{% if j.error_message %}
<span class="badge errcat errcat-{{ j.error_category }}">{{ j.error_category }}</span>
<div>{{ j.error_message }}</div>
{% else %}
-
{% endif %}
</td>
<td>{{ j.error_hint or "-" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
</main>
</body>
</html>