"""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'
' f"error: {exc}
", status_code=400, ) label = { "merge_keep_left": "Merged into LEFT", "merge_keep_right": "Merged into RIGHT", "reject": "Rejected (kept both)", }[action] return HTMLResponse( f'
' f"{label}. " f'← back to list
' ) def mount_static(app) -> None: # pragma: no cover - dev convenience # APK MIME type — bez tego Android Browser nie traktuje pliku jako instalable APK # (text/plain → "Plik został pobrany" zamiast prompta install). Rejestracja jest # idempotentna na poziomie procesu — bezpiecznie wywoływać przy każdym startup. import mimetypes mimetypes.add_type("application/vnd.android.package-archive", ".apk") if _STATIC_DIR.exists(): app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")