"""htmx + Jinja2 admin UI dla MergeCandidate triage. Endpointy: GET /ui/ — lista pending (filter status) GET /ui/candidate/{id} — side-by-side scen POST /ui/candidate/{id}/resolve — htmx form submit (action=merge_keep_left|merge_keep_right|reject) zwraca fragment HTML z potwierdzeniem """ from __future__ import annotations import uuid from pathlib import Path from typing import Annotated from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from sqlalchemy import func, select from sqlalchemy.orm import Session from app.api.scenes import _build_scene_out from app.auth import require_api_key from app.db import get_session from app.models.merge_candidate import MergeCandidate, MergeKind, MergeStatus from app.models.scene import Scene from app.resolve.scene_merge import MergeError, resolve_candidate _TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates" _STATIC_DIR = Path(__file__).resolve().parent.parent / "static" templates = Jinja2Templates(directory=str(_TEMPLATES_DIR)) def _score_class(score: float) -> str: if score >= 0.92: return "high" if score >= 0.75: return "mid" return "low" templates.env.globals["score_class"] = _score_class router = APIRouter( prefix="/ui", tags=["ui"], dependencies=[Depends(require_api_key)], ) @router.get("/", response_class=HTMLResponse) def list_view( request: Request, session: Annotated[Session, Depends(get_session)], status: Annotated[str, Query(pattern="^(pending|auto_merged|merged|rejected|all)$")] = "pending", page: Annotated[int, Query(ge=1)] = 1, ) -> HTMLResponse: per_page = 50 base = select(MergeCandidate).where(MergeCandidate.kind == MergeKind.scene) if status != "all": base = base.where(MergeCandidate.status == MergeStatus(status)) total = session.execute(select(func.count()).select_from(base.subquery())).scalar_one() rows = ( session.execute( base.order_by(MergeCandidate.score.desc(), MergeCandidate.created_at.desc()) .offset((page - 1) * per_page) .limit(per_page) ) .scalars() .all() ) titles: dict[uuid.UUID, str] = {} scene_ids = {r.left_id for r in rows} | {r.right_id for r in rows} if scene_ids: for sid, title in session.execute( select(Scene.id, Scene.title).where(Scene.id.in_(scene_ids)) ): titles[sid] = title items = [ { "id": r.id, "kind": r.kind.value, "left_id": r.left_id, "right_id": r.right_id, "score": r.score, "status": r.status.value, "left_title": titles.get(r.left_id), "right_title": titles.get(r.right_id), } for r in rows ] label_map = { "pending": "Pending", "auto_merged": "Auto-merged", "merged": "Merged", "rejected": "Rejected", "all": "All", } return templates.TemplateResponse( request, "candidates_list.html", { "items": items, "total": total, "page": page, "per_page": per_page, "status": status, "status_label": label_map[status], }, ) @router.get("/candidate/{candidate_id}", response_class=HTMLResponse) def detail_view( candidate_id: uuid.UUID, request: Request, session: Annotated[Session, Depends(get_session)], ) -> HTMLResponse: cand = session.get(MergeCandidate, candidate_id) if cand is None: raise HTTPException(status_code=404, detail="merge candidate not found") left_out = right_out = None if cand.kind == MergeKind.scene: left_scene = session.get(Scene, cand.left_id) right_scene = session.get(Scene, cand.right_id) if left_scene is not None: left_out = _build_scene_out(session, left_scene) if right_scene is not None and right_scene.id != cand.left_id: right_out = _build_scene_out(session, right_scene) return templates.TemplateResponse( request, "candidate_detail.html", { "cand": { "id": cand.id, "kind": cand.kind.value, "score": cand.score, "status": cand.status.value, "reasons": cand.reasons or {}, "left": left_out, "right": right_out, "left_id": cand.left_id, "right_id": cand.right_id, }, }, ) @router.post("/candidate/{candidate_id}/resolve", response_class=HTMLResponse) def resolve_form( candidate_id: uuid.UUID, session: Annotated[Session, Depends(get_session)], action: Annotated[str, Form()], ) -> HTMLResponse: if action not in {"merge_keep_left", "merge_keep_right", "reject"}: raise HTTPException(status_code=400, detail=f"invalid action: {action}") api_action = "reject" if action == "reject" else "merge" keep_left = action != "merge_keep_right" try: resolve_candidate( session, candidate_id=candidate_id, action=api_action, keep_left=keep_left, resolved_by="ui", ) except MergeError as exc: return HTMLResponse( f'