rss-news/backend/app/admin_ui.py

265 lines
9.1 KiB
Python

from __future__ import annotations
import json
from pathlib import Path
from urllib.parse import urlencode
from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from .auth import create_session_token, verify_credentials, verify_session_token
from .config import get_settings
from .ingestion import run_ingestion
from .policy import evaluate_source_policy
from .repositories import (
FeedCreate,
SourceCreate,
create_feed,
create_source,
get_article_by_id,
list_articles,
list_feeds,
list_runs,
list_sources,
update_article_status,
)
settings = get_settings()
router = APIRouter(tags=["admin-ui"])
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates"))
ALLOWED_TRANSITIONS: dict[str, tuple[str, ...]] = {
"new": ("review", "rewrite", "error"),
"rewrite": ("review", "error"),
"review": ("approved", "rewrite", "error"),
"approved": ("published", "error"),
"published": ("error",),
"error": ("review", "rewrite"),
}
def _admin_user(request: Request) -> str | None:
token = request.cookies.get(settings.session_cookie_name)
if not token:
return None
return verify_session_token(token)
def _to_optional_int(raw: str | None) -> int | None:
if raw is None:
return None
value = raw.strip()
if value == "":
return None
return int(value)
def _dashboard_redirect(
*,
msg: str | None = None,
msg_type: str = "success",
status_filter: str | None = None,
) -> RedirectResponse:
query: dict[str, str] = {}
if msg:
query["msg"] = msg
query["type"] = msg_type
if status_filter:
query["status_filter"] = status_filter
suffix = f"?{urlencode(query)}" if query else ""
return RedirectResponse(url=f"/admin/dashboard{suffix}", status_code=303)
def _parse_meta_json(raw: str | None) -> dict:
if not raw:
return {}
try:
parsed = json.loads(raw)
return parsed if isinstance(parsed, dict) else {}
except Exception:
return {}
@router.get("/admin", response_class=HTMLResponse)
def admin_index(request: Request):
user = _admin_user(request)
if not user:
return RedirectResponse(url="/admin/login", status_code=303)
return RedirectResponse(url="/admin/dashboard", status_code=303)
@router.get("/admin/login", response_class=HTMLResponse)
def admin_login_page(request: Request):
return templates.TemplateResponse(
request,
"admin_login.html",
{"request": request, "title": "Admin Login", "error": request.query_params.get("error")},
)
@router.post("/admin/login")
def admin_login(request: Request, username: str = Form(...), password: str = Form(...)):
if not verify_credentials(username, password):
return RedirectResponse(url="/admin/login?error=1", status_code=303)
token = create_session_token(username)
response = RedirectResponse(url="/admin/dashboard", status_code=303)
response.set_cookie(
key=settings.session_cookie_name,
value=token,
max_age=settings.session_max_age_seconds,
httponly=True,
secure=False,
samesite="lax",
)
return response
@router.post("/admin/logout")
def admin_logout():
response = RedirectResponse(url="/admin/login", status_code=303)
response.delete_cookie(settings.session_cookie_name)
return response
@router.get("/admin/dashboard", response_class=HTMLResponse)
def admin_dashboard(request: Request):
user = _admin_user(request)
if not user:
return RedirectResponse(url="/admin/login", status_code=303)
sources = list_sources()
source_policy = {s["id"]: evaluate_source_policy(s) for s in sources}
feeds = list_feeds()
runs = list_runs(limit=30)
status_filter = request.query_params.get("status_filter")
if status_filter in {"new", "rewrite", "review", "approved", "published", "error"}:
articles = list_articles(limit=100, status_filter=status_filter)
else:
status_filter = ""
articles = list_articles(limit=100)
for article in articles:
meta = _parse_meta_json(article.get("meta_json"))
extraction = meta.get("extraction") if isinstance(meta.get("extraction"), dict) else {}
article["meta"] = meta
article["extracted_images"] = extraction.get("images") if isinstance(extraction.get("images"), list) else []
article["press_contact"] = extraction.get("press_contact") if isinstance(extraction.get("press_contact"), str) else None
article["extraction_error"] = extraction.get("extraction_error") if isinstance(extraction.get("extraction_error"), str) else None
return templates.TemplateResponse(
request,
"admin_dashboard.html",
{
"request": request,
"title": "Admin Dashboard",
"user": user,
"sources": sources,
"source_policy": source_policy,
"feeds": feeds,
"runs": runs,
"articles": articles,
"status_options": ["new", "rewrite", "review", "approved", "published", "error"],
"allowed_transitions": ALLOWED_TRANSITIONS,
"status_filter": status_filter,
"flash_msg": request.query_params.get("msg", ""),
"flash_type": request.query_params.get("type", "success"),
},
)
@router.post("/admin/sources/create")
def admin_create_source(
request: Request,
name: str = Form(...),
base_url: str = Form(""),
terms_url: str = Form(""),
license_name: str = Form(""),
risk_level: str = Form("yellow"),
last_reviewed_at: str = Form(""),
):
user = _admin_user(request)
if not user:
return RedirectResponse(url="/admin/login", status_code=303)
try:
create_source(
SourceCreate(
name=name,
base_url=base_url or None,
terms_url=terms_url or None,
license_name=license_name or None,
risk_level=risk_level,
is_enabled=True,
notes=None,
last_reviewed_at=last_reviewed_at or None,
)
)
except Exception as exc:
return _dashboard_redirect(msg=f"Quelle konnte nicht gespeichert werden: {exc}", msg_type="error")
return _dashboard_redirect(msg="Quelle gespeichert")
@router.post("/admin/feeds/create")
def admin_create_feed(
request: Request,
name: str = Form(...),
url: str = Form(...),
source_id: str = Form(""),
):
user = _admin_user(request)
if not user:
return RedirectResponse(url="/admin/login", status_code=303)
try:
create_feed(
FeedCreate(
name=name,
url=url,
source_id=_to_optional_int(source_id),
is_enabled=True,
)
)
except Exception as exc:
return _dashboard_redirect(msg=f"Feed konnte nicht gespeichert werden: {exc}", msg_type="error")
return _dashboard_redirect(msg="Feed gespeichert")
@router.post("/admin/ingestion/run")
def admin_run_ingestion(request: Request, feed_id: str = Form("")):
user = _admin_user(request)
if not user:
return RedirectResponse(url="/admin/login", status_code=303)
try:
stats = run_ingestion(feed_id=_to_optional_int(feed_id))
except Exception as exc:
return _dashboard_redirect(msg=f"Ingestion fehlgeschlagen: {exc}", msg_type="error")
return _dashboard_redirect(msg=f"Ingestion: {stats.status}, upserts={stats.articles_upserted}")
@router.post("/admin/articles/{article_id}/review")
def admin_review_article(request: Request, article_id: int, decision: str = Form(...), note: str = Form("")):
user = _admin_user(request)
if not user:
return RedirectResponse(url="/admin/login", status_code=303)
article = get_article_by_id(article_id)
if article and article.get("status") == "review" and decision in {"approve", "reject"}:
target = "approved" if decision == "approve" else "rewrite"
update_article_status(article_id, target, actor=user, note=note or None, decision=decision)
return _dashboard_redirect(msg=f"Artikel #{article_id}: {decision}")
return _dashboard_redirect(msg=f"Review-Aktion ungueltig fuer Artikel #{article_id}", msg_type="error")
@router.post("/admin/articles/{article_id}/transition")
def admin_transition_article(request: Request, article_id: int, target_status: str = Form(...), note: str = Form("")):
user = _admin_user(request)
if not user:
return RedirectResponse(url="/admin/login", status_code=303)
article = get_article_by_id(article_id)
if article:
current = article.get("status")
if target_status in ALLOWED_TRANSITIONS.get(current, ()):
update_article_status(article_id, target_status, actor=user, note=note or None)
return _dashboard_redirect(msg=f"Artikel #{article_id}: {current} -> {target_status}")
return _dashboard_redirect(msg=f"Ungueltiger Statuswechsel fuer Artikel #{article_id}", msg_type="error")