goon/app/api/admin_html.py
goon-foss a4486980a4 style: myslniki em/en z komentarzy i docstringow (calosc repo)
1745 wystapien w 291 plikach (backend, scripts, mobile). Dominowal jeden ksztalt,
' — ' laczace zdania, wiec poszedl przecinek; myslnik otwierajacy mysl zdjety;
zakresy 0–5 i A–Z na dywiz.

Podzial komentarz/kod robil skaner stanow, nie regex, zeby nie ruszyc myslnika
stojacego w stringu albo w wyrazeniu regularnym. Potwierdzone maszynowo: po wycieciu
komentarzy z wersji HEAD i z dysku obie sa identyczne we wszystkich 291 plikach,
czyli poza komentarzami nie zmienilo sie nic. 233 pliki .py parsuja sie bez bledu,
tsc ma te same 11 bledow w tych samych 3 plikach co przed zmiana.

Dwie rzeczy wymagaly poprawki po automacie: wyrownane listy definicji, gdzie myslnik
byl separatorem kolumny (37 miejsc, kolumna przywrocona), i trzy przeniesienia mysli
do nowej linii, gdzie zostawal wiszacy przecinek na poczatku (poprawione recznie).

Bundla nie republikuje: Hermes i tak nie niesie komentarzy, wiec dla uzytkownika
to zero zmiany.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:51:57 +02:00

206 lines
6.5 KiB
Python

"""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'<div class="card" id="actions" style="border-color: var(--bad);">'
f"<strong>error:</strong> {exc}</div>",
status_code=400,
)
label = {
"merge_keep_left": "Merged into LEFT",
"merge_keep_right": "Merged into RIGHT",
"reject": "Rejected (kept both)",
}[action]
return HTMLResponse(
f'<div class="card" id="actions" style="border-color: var(--good);">'
f"<strong>{label}.</strong> "
f'<a href="/ui/">← back to list</a></div>'
)
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")