feat(admin): add article detail page with legal checklist

This commit is contained in:
Oliver 2026-02-18 09:50:32 +01:00
parent 2c331d683b
commit c52363f1a7
No known key found for this signature in database
4 changed files with 236 additions and 0 deletions

View file

@ -18,6 +18,7 @@ from .repositories import (
create_feed, create_feed,
create_source, create_source,
get_article_by_id, get_article_by_id,
get_feed_by_id,
list_articles, list_articles,
list_feeds, list_feeds,
list_runs, list_runs,
@ -80,6 +81,57 @@ def _parse_meta_json(raw: str | None) -> dict:
return {} return {}
def _legal_checklist(article: dict, feed: dict | None) -> list[dict[str, str]]:
meta = article.get("meta", {})
extraction = meta.get("extraction") if isinstance(meta.get("extraction"), dict) else {}
attribution = meta.get("attribution") if isinstance(meta.get("attribution"), dict) else {}
checks: list[dict[str, str]] = []
checks.append(
{
"label": "Original-Link vorhanden",
"status": "ok" if article.get("source_url") else "missing",
"value": article.get("source_url") or "-",
}
)
checks.append(
{
"label": "Autor vorhanden",
"status": "ok" if article.get("author") else "missing",
"value": article.get("author") or "-",
}
)
checks.append(
{
"label": "Bilder extrahiert",
"status": "ok" if extraction.get("images") else "missing",
"value": str(len(extraction.get("images", []))) if isinstance(extraction.get("images"), list) else "0",
}
)
checks.append(
{
"label": "Pressekontakt",
"status": "ok" if extraction.get("press_contact") else "missing",
"value": extraction.get("press_contact") or "-",
}
)
checks.append(
{
"label": "Lizenz/Terms",
"status": "ok" if attribution.get("source_license_name") and attribution.get("source_terms_url") else "missing",
"value": f"{attribution.get('source_license_name') or '-'} | {attribution.get('source_terms_url') or '-'}",
}
)
checks.append(
{
"label": "Risiko-Status Quelle",
"status": "ok" if (feed and feed.get("source_risk_level") == "green") else "missing",
"value": feed.get("source_risk_level") if feed else "-",
}
)
return checks
@router.get("/admin", response_class=HTMLResponse) @router.get("/admin", response_class=HTMLResponse)
def admin_index(request: Request): def admin_index(request: Request):
user = _admin_user(request) user = _admin_user(request)
@ -167,6 +219,38 @@ def admin_dashboard(request: Request):
) )
@router.get("/admin/articles/{article_id}", response_class=HTMLResponse)
def admin_article_detail(request: Request, article_id: int):
user = _admin_user(request)
if not user:
return RedirectResponse(url="/admin/login", status_code=303)
article = get_article_by_id(article_id)
if not article:
return _dashboard_redirect(msg=f"Artikel #{article_id} nicht gefunden", msg_type="error")
meta = _parse_meta_json(article.get("meta_json"))
article["meta"] = meta
extraction = meta.get("extraction") if isinstance(meta.get("extraction"), dict) else {}
article["extraction"] = extraction
feed = get_feed_by_id(int(article["feed_id"])) if article.get("feed_id") else None
checklist = _legal_checklist(article, feed)
return templates.TemplateResponse(
request,
"admin_article_detail.html",
{
"request": request,
"title": f"Artikel #{article_id}",
"user": user,
"article": article,
"feed": feed,
"checklist": checklist,
"allowed_transitions": ALLOWED_TRANSITIONS.get(article.get("status"), ()),
},
)
@router.post("/admin/sources/create") @router.post("/admin/sources/create")
def admin_create_source( def admin_create_source(
request: Request, request: Request,

View file

@ -0,0 +1,100 @@
<!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>Artikel-Detail #{{ article.id }}</h1>
<p>Angemeldet als <strong>{{ user }}</strong></p>
</div>
<div class="row">
<a class="linkbtn" href="/admin/dashboard">Zurück</a>
<form method="post" action="/admin/logout">
<button type="submit" class="secondary">Logout</button>
</form>
</div>
</header>
<main class="container">
<section class="card">
<h2>{{ article.title }}</h2>
<p><strong>Status:</strong> <span class="badge">{{ article.status }}</span></p>
<p><strong>Autor:</strong> {{ article.author or "-" }}</p>
<p><strong>Feed:</strong> {{ feed.name if feed else "-" }}</p>
<p><strong>Quelle:</strong> <a href="{{ article.source_url }}" target="_blank" rel="noopener">{{ article.source_url }}</a></p>
{% if article.canonical_url %}
<p><strong>Canonical:</strong> <a href="{{ article.canonical_url }}" target="_blank" rel="noopener">{{ article.canonical_url }}</a></p>
{% endif %}
{% if article.summary %}
<p><strong>Summary:</strong> {{ article.summary }}</p>
{% endif %}
</section>
<section class="card">
<h2>Rechts-Checkliste</h2>
<table>
<thead>
<tr><th>Kriterium</th><th>Status</th><th>Wert</th></tr>
</thead>
<tbody>
{% for c in checklist %}
<tr>
<td>{{ c.label }}</td>
<td>
{% if c.status == "ok" %}
<span class="badge ok">OK</span>
{% else %}
<span class="badge bad">Fehlt</span>
{% endif %}
</td>
<td>{{ c.value }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="card">
<h2>Extrahierte Daten</h2>
<p><strong>Bilder:</strong> {{ article.extraction.images|length if article.extraction.images else 0 }}</p>
{% if article.extraction.images %}
<ul>
{% for img in article.extraction.images %}
<li><a href="{{ img }}" target="_blank" rel="noopener">{{ img }}</a></li>
{% endfor %}
</ul>
{% endif %}
{% if article.extraction.press_contact %}
<p><strong>Pressekontakt</strong></p>
<div class="pre">{{ article.extraction.press_contact }}</div>
{% endif %}
{% if article.extraction.extraction_error %}
<p class="subtle">Extraktionsfehler: {{ article.extraction.extraction_error }}</p>
{% endif %}
</section>
<section class="card">
<h2>Volltext</h2>
<div class="pre">{{ article.content_raw or "-" }}</div>
</section>
<section class="card">
<h2>Status ändern</h2>
<form method="post" action="/admin/articles/{{ article.id }}/transition" class="row">
<select name="target_status">
{% for s in allowed_transitions %}
<option value="{{ s }}">{{ s }}</option>
{% endfor %}
</select>
<input name="note" placeholder="Notiz" />
<button type="submit" class="secondary">Setzen</button>
</form>
</section>
</main>
</body>
</html>

View file

@ -144,6 +144,7 @@
<strong>{{ a.title }}</strong><br /> <strong>{{ a.title }}</strong><br />
<span class="subtle">Autor: {{ a.author or "-" }}</span><br /> <span class="subtle">Autor: {{ a.author or "-" }}</span><br />
<a href="{{ a.source_url }}" target="_blank" rel="noopener">Original öffnen</a> <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 %} {% if a.canonical_url and a.canonical_url != a.source_url %}
<br /><a href="{{ a.canonical_url }}" target="_blank" rel="noopener">Canonical öffnen</a> <br /><a href="{{ a.canonical_url }}" target="_blank" rel="noopener">Canonical öffnen</a>
{% endif %} {% endif %}

View file

@ -8,6 +8,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.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.repositories import ArticleUpsert, FeedCreate, SourceCreate, create_feed, create_source, upsert_article
class TestAdminUi(unittest.TestCase): class TestAdminUi(unittest.TestCase):
@ -60,6 +61,56 @@ class TestAdminUi(unittest.TestCase):
self.assertEqual(res.status_code, 303) self.assertEqual(res.status_code, 303)
self.assertTrue(res.headers.get("location", "").startswith("/admin/dashboard")) self.assertTrue(res.headers.get("location", "").startswith("/admin/dashboard"))
def test_article_detail_page_renders(self) -> None:
source_id = create_source(
SourceCreate(
name="Test Source",
base_url="https://example.org",
terms_url="https://example.org/terms",
license_name="cc-by",
risk_level="green",
is_enabled=True,
notes=None,
last_reviewed_at="2026-02-18T00:00:00Z",
)
)
feed_id = create_feed(
FeedCreate(
name="Test Feed",
url="https://example.org/feed.xml",
source_id=source_id,
is_enabled=True,
)
)
article_id = upsert_article(
ArticleUpsert(
feed_id=feed_id,
source_article_id="id-1",
source_hash="hash-1",
title="Titel A",
source_url="https://example.org/a",
canonical_url="https://example.org/a",
published_at=None,
author="Autor A",
summary="Summary A",
content_raw="Volltext A",
content_rewritten=None,
word_count=2,
status="new",
meta_json='{"extraction":{"images":["https://example.org/img.jpg"],"press_contact":"Kontakt"}}',
)
)
self.client.post(
"/admin/login",
data={"username": "admin", "password": "secret"},
follow_redirects=True,
)
res = self.client.get(f"/admin/articles/{article_id}", follow_redirects=True)
self.assertEqual(res.status_code, 200)
self.assertIn("Artikel-Detail", res.text)
self.assertIn("Rechts-Checkliste", res.text)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()