feat(admin): add article detail page with legal checklist
This commit is contained in:
parent
2c331d683b
commit
c52363f1a7
4 changed files with 236 additions and 0 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
100
backend/templates/admin_article_detail.html
Normal file
100
backend/templates/admin_article_detail.html
Normal 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>
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue