From efaf132936defbcda5004a5c44c650fe5fef2e58 Mon Sep 17 00:00:00 2001 From: Oliver G Date: Wed, 18 Feb 2026 10:11:22 +0100 Subject: [PATCH] feat(images): add thumbnail gallery with select/exclude workflow --- backend/app/admin_ui.py | 110 +++++++++++++++++--- backend/app/main.py | 13 +++ backend/app/repositories.py | 58 +++++++++++ backend/static/admin.css | 54 ++++++++++ backend/templates/admin_article_detail.html | 45 ++++++-- backend/templates/admin_dashboard.html | 4 + backend/tests/test_admin_ui.py | 22 +++- 7 files changed, 282 insertions(+), 24 deletions(-) diff --git a/backend/app/admin_ui.py b/backend/app/admin_ui.py index 44cb7c5..b8a1777 100644 --- a/backend/app/admin_ui.py +++ b/backend/app/admin_ui.py @@ -2,6 +2,7 @@ from __future__ import annotations import json from pathlib import Path +import re from urllib.parse import urlencode from fastapi import APIRouter, Form, Request @@ -24,6 +25,7 @@ from .repositories import ( list_feeds, list_runs, list_sources, + set_article_image_decision, set_article_legal_review, update_article_status, ) @@ -83,6 +85,63 @@ def _parse_meta_json(raw: str | None) -> dict: return {} +def _read_article_images(article: dict, extraction: dict) -> list[str]: + images: list[str] = [] + if article.get("image_urls_json"): + try: + parsed_images = json.loads(article["image_urls_json"]) + if isinstance(parsed_images, list): + images = [str(item) for item in parsed_images if item] + except Exception: + images = [] + if not images and isinstance(extraction.get("images"), list): + images = [str(item) for item in extraction.get("images") if item] + # deduplicate preserving order + seen: set[str] = set() + deduped: list[str] = [] + for image in images: + if image not in seen: + seen.add(image) + deduped.append(image) + return deduped + + +def _is_probably_irrelevant_image(url: str) -> bool: + lowered = url.lower() + patterns = ( + r"logo", + r"icon", + r"sprite", + r"avatar", + r"favicon", + r"/ads/", + r"tracking", + r"pixel", + r"banner", + ) + return any(re.search(pattern, lowered) for pattern in patterns) + + +def _build_image_entries(article: dict, extraction: dict, meta: dict) -> list[dict[str, object]]: + all_images = _read_article_images(article, extraction) + image_review = meta.get("image_review") if isinstance(meta.get("image_review"), dict) else {} + selected_url = image_review.get("selected_url") if isinstance(image_review.get("selected_url"), str) else None + excluded_urls = image_review.get("excluded_urls") if isinstance(image_review.get("excluded_urls"), list) else [] + excluded_set = {str(item) for item in excluded_urls if item} + + entries: list[dict[str, object]] = [] + for url in all_images: + entries.append( + { + "url": url, + "is_selected": selected_url == url, + "is_excluded": url in excluded_set, + "is_irrelevant_hint": _is_probably_irrelevant_image(url), + } + ) + return entries + + 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 {} @@ -138,6 +197,15 @@ def _legal_checklist(article: dict, feed: dict | None) -> list[dict[str, str]]: "value": article.get("legal_checked_at") or "-", } ) + image_review = meta.get("image_review") if isinstance(meta.get("image_review"), dict) else {} + selected_image = image_review.get("selected_url") if isinstance(image_review.get("selected_url"), str) else None + checks.append( + { + "label": "Hauptbild ausgewählt", + "status": "ok" if selected_image else "missing", + "value": selected_image or "-", + } + ) return checks @@ -202,18 +270,12 @@ def admin_dashboard(request: Request): for article in articles: meta = _parse_meta_json(article.get("meta_json")) extraction = meta.get("extraction") if isinstance(meta.get("extraction"), dict) else {} - images = [] - if article.get("image_urls_json"): - try: - parsed_images = json.loads(article["image_urls_json"]) - if isinstance(parsed_images, list): - images = [str(item) for item in parsed_images if item] - except Exception: - images = [] - if not images and isinstance(extraction.get("images"), list): - images = extraction.get("images") + images = _read_article_images(article, extraction) article["meta"] = meta article["extracted_images"] = images + article["image_entries"] = _build_image_entries(article, extraction, meta) + image_review = meta.get("image_review") if isinstance(meta.get("image_review"), dict) else {} + article["selected_image_url"] = image_review.get("selected_url") if isinstance(image_review.get("selected_url"), str) else None if not article.get("press_contact") and isinstance(extraction.get("press_contact"), str): article["press_contact"] = extraction.get("press_contact") article["extraction_error"] = extraction.get("extraction_error") if isinstance(extraction.get("extraction_error"), str) else None @@ -254,16 +316,13 @@ def admin_article_detail(request: Request, article_id: int): meta = _parse_meta_json(article.get("meta_json")) article["meta"] = meta extraction = meta.get("extraction") if isinstance(meta.get("extraction"), dict) else {} - if article.get("image_urls_json"): - try: - parsed_images = json.loads(article["image_urls_json"]) - if isinstance(parsed_images, list): - extraction["images"] = [str(item) for item in parsed_images if item] - except Exception: - pass + extraction["images"] = _read_article_images(article, extraction) if not article.get("press_contact") and isinstance(extraction.get("press_contact"), str): article["press_contact"] = extraction.get("press_contact") article["extraction"] = extraction + article["image_entries"] = _build_image_entries(article, extraction, meta) + image_review = meta.get("image_review") if isinstance(meta.get("image_review"), dict) else {} + article["selected_image_url"] = image_review.get("selected_url") if isinstance(image_review.get("selected_url"), str) else None article["days_old"] = article_age_days(article.get("published_at")) article["relevance"] = article_relevance(article.get("published_at")) feed = get_feed_by_id(int(article["feed_id"])) if article.get("feed_id") else None @@ -284,6 +343,23 @@ def admin_article_detail(request: Request, article_id: int): ) +@router.post("/admin/articles/{article_id}/images/decision") +def admin_article_image_decision( + request: Request, + article_id: int, + image_url: str = Form(...), + action: str = Form(...), +): + user = _admin_user(request) + if not user: + return RedirectResponse(url="/admin/login", status_code=303) + + ok = set_article_image_decision(article_id=article_id, image_url=image_url, action=action, actor=user) + if not ok: + return _dashboard_redirect(msg=f"Bildaktion fehlgeschlagen fuer Artikel #{article_id}", msg_type="error") + return RedirectResponse(url=f"/admin/articles/{article_id}", status_code=303) + + @router.post("/admin/articles/{article_id}/legal-review") def admin_article_legal_review(request: Request, article_id: int, approved: str = Form("0"), note: str = Form("")): user = _admin_user(request) diff --git a/backend/app/main.py b/backend/app/main.py index 277630b..177c312 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -336,6 +336,17 @@ def api_export_articles( articles = repo_list_articles(limit=500, status_filter=status_filter) rows = [] for article in articles: + meta: dict = {} + if article.get("meta_json"): + try: + parsed = json.loads(article["meta_json"]) + if isinstance(parsed, dict): + meta = parsed + except Exception: + meta = {} + image_review = meta.get("image_review") if isinstance(meta.get("image_review"), dict) else {} + selected_image_url = image_review.get("selected_url") if isinstance(image_review.get("selected_url"), str) else None + days_old = article_age_days(article.get("published_at")) rows.append( { @@ -353,6 +364,7 @@ def api_export_articles( "source_terms_url_snapshot": article.get("source_terms_url_snapshot"), "press_contact": article.get("press_contact"), "image_urls_json": article.get("image_urls_json"), + "selected_image_url": selected_image_url, "legal_checked": bool(int(article.get("legal_checked", 0))), "legal_checked_at": article.get("legal_checked_at"), "legal_note": article.get("legal_note"), @@ -377,6 +389,7 @@ def api_export_articles( "source_terms_url_snapshot", "press_contact", "image_urls_json", + "selected_image_url", "legal_checked", "legal_checked_at", "legal_note", diff --git a/backend/app/repositories.py b/backend/app/repositories.py index 9d9883c..164fc79 100644 --- a/backend/app/repositories.py +++ b/backend/app/repositories.py @@ -262,6 +262,16 @@ def _merge_review_event(meta_json: str | None, event: dict[str, Any]) -> str: return json.dumps(meta, ensure_ascii=False) +def _load_meta(meta_json: str | None) -> dict[str, Any]: + if not meta_json: + return {} + try: + parsed = json.loads(meta_json) + return parsed if isinstance(parsed, dict) else {} + except Exception: + return {} + + def update_article_status( article_id: int, new_status: str, @@ -317,6 +327,54 @@ def set_article_legal_review(article_id: int, approved: bool, note: str | None, return True +def set_article_image_decision(article_id: int, image_url: str, action: str, actor: str | None = None) -> bool: + article = get_article_by_id(article_id) + if not article: + return False + url = (image_url or "").strip() + if not url: + return False + if action not in {"select", "exclude", "restore"}: + return False + + meta = _load_meta(article.get("meta_json")) + image_review = meta.get("image_review") + if not isinstance(image_review, dict): + image_review = {} + + excluded = image_review.get("excluded_urls") + if not isinstance(excluded, list): + excluded = [] + excluded_set = {str(item) for item in excluded if item} + + selected_url = image_review.get("selected_url") + if not isinstance(selected_url, str): + selected_url = None + + if action == "select": + selected_url = url + excluded_set.discard(url) + elif action == "exclude": + excluded_set.add(url) + if selected_url == url: + selected_url = None + elif action == "restore": + excluded_set.discard(url) + + image_review["selected_url"] = selected_url + image_review["excluded_urls"] = sorted(excluded_set) + image_review["updated_at"] = datetime.now(timezone.utc).isoformat() + image_review["updated_by"] = actor or "system" + meta["image_review"] = image_review + + with get_conn() as conn: + conn.execute( + "UPDATE articles SET meta_json = ? WHERE id = ?", + (json.dumps(meta, ensure_ascii=False), article_id), + ) + return True + + def _resolve_existing_article_id(payload: ArticleUpsert) -> int | None: with get_conn() as conn: # 1) strongest key: source_url diff --git a/backend/static/admin.css b/backend/static/admin.css index 348264f..402c067 100644 --- a/backend/static/admin.css +++ b/backend/static/admin.css @@ -179,6 +179,60 @@ button.secondary { background: #f8fafc; } +.thumb { + width: 72px; + height: 72px; + object-fit: cover; + border-radius: 8px; + border: 1px solid #cbd5e1; + margin-top: 6px; +} + +.image-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 10px; +} + +.image-card { + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 8px; + background: #fff; +} + +.image-card img { + width: 100%; + height: 120px; + object-fit: cover; + border-radius: 6px; + border: 1px solid #e2e8f0; + background: #f8fafc; +} + +.image-meta { + margin-top: 6px; + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.image-actions { + margin-top: 8px; + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.image-selected { + border-color: #10b981; + box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.25); +} + +.image-excluded { + opacity: 0.65; +} + @media (max-width: 920px) { .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/backend/templates/admin_article_detail.html b/backend/templates/admin_article_detail.html index d2b1b67..7acb5c1 100644 --- a/backend/templates/admin_article_detail.html +++ b/backend/templates/admin_article_detail.html @@ -67,13 +67,46 @@

Extrahierte Daten

-

Bilder: {{ article.extraction.images|length if article.extraction.images else 0 }}

- {% if article.extraction.images %} - + {% endif %} {% if article.press_contact or article.extraction.press_contact %}

Pressekontakt

diff --git a/backend/templates/admin_dashboard.html b/backend/templates/admin_dashboard.html index 27bcaf5..34a0f84 100644 --- a/backend/templates/admin_dashboard.html +++ b/backend/templates/admin_dashboard.html @@ -155,6 +155,10 @@ {{ a.status }}
Legal: {{ "OK" if a.legal_checked else "offen" }}
+ {% if a.selected_image_url %} +
Hauptbild gesetzt
+ Hauptbild + {% endif %} {% if a.summary %}
Summary: {{ a.summary }}
{% endif %} diff --git a/backend/tests/test_admin_ui.py b/backend/tests/test_admin_ui.py index f52d917..ac8a615 100644 --- a/backend/tests/test_admin_ui.py +++ b/backend/tests/test_admin_ui.py @@ -8,7 +8,15 @@ from fastapi.testclient import TestClient from backend.app import config as config_module from backend.app.db import init_db from backend.app.main import app -from backend.app.repositories import ArticleUpsert, FeedCreate, SourceCreate, create_feed, create_source, upsert_article +from backend.app.repositories import ( + ArticleUpsert, + FeedCreate, + SourceCreate, + create_feed, + create_source, + get_article_by_id, + upsert_article, +) class TestAdminUi(unittest.TestCase): @@ -119,6 +127,18 @@ class TestAdminUi(unittest.TestCase): self.assertIn("Artikel-Detail", res.text) self.assertIn("Rechts-Checkliste", res.text) + decision = self.client.post( + f"/admin/articles/{article_id}/images/decision", + data={"image_url": "https://example.org/img.jpg", "action": "select"}, + follow_redirects=True, + ) + self.assertEqual(decision.status_code, 200) + self.assertIn("Ausgewähltes Hauptbild", decision.text) + + article = get_article_by_id(article_id) + self.assertIsNotNone(article) + self.assertIn("selected_url", article.get("meta_json", "")) + if __name__ == "__main__": unittest.main()