scholarr/app/web/routers/runs.py
2026-02-17 14:51:25 +01:00

274 lines
8.5 KiB
Python

from __future__ import annotations
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db_session
from app.services import runs as run_service
from app.theme import resolve_theme
from app.web import common
router = APIRouter()
def _as_bool(value: str | None) -> bool:
if value is None:
return False
return value.strip().lower() in {"1", "true", "yes", "on"}
def _status_badge(status_value: str) -> str:
if status_value == "success":
return "ok"
if status_value == "failed":
return "danger"
return "warn"
def _queue_status_badge(status_value: str) -> str:
if status_value == "dropped":
return "danger"
if status_value == "retrying":
return "ok"
return "warn"
def _format_dt(value: datetime | None) -> str:
if value is None:
return "-"
return value.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
def _summary_dict(error_log: object) -> dict[str, object]:
if not isinstance(error_log, dict):
return {}
summary = error_log.get("summary")
if not isinstance(summary, dict):
return {}
return summary
@router.get("/runs", response_class=HTMLResponse)
async def runs_page(
request: Request,
theme: str | None = None,
failed_only: str | None = None,
db_session: AsyncSession = Depends(get_db_session),
) -> HTMLResponse:
current_user = await common.get_authenticated_user(request, db_session)
if current_user is None:
return common.redirect_to_login()
failed_only_enabled = _as_bool(failed_only)
runs = await run_service.list_runs_for_user(
db_session,
user_id=current_user.id,
limit=200,
failed_only=failed_only_enabled,
)
run_items = []
for run in runs:
summary = _summary_dict(run.error_log)
failed_count = summary.get("failed_count", 0)
partial_count = summary.get("partial_count", 0)
run_items.append(
{
"id": run.id,
"started_at": _format_dt(run.start_dt),
"finished_at": _format_dt(run.end_dt),
"status": run.status.value,
"status_badge": _status_badge(run.status.value),
"trigger_type": run.trigger_type.value,
"scholar_count": run.scholar_count,
"new_publication_count": run.new_pub_count,
"failed_count": failed_count,
"partial_count": partial_count,
}
)
queue_entries = await run_service.list_queue_items_for_user(
db_session,
user_id=current_user.id,
limit=200,
)
queue_items = []
for item in queue_entries:
queue_items.append(
{
"id": item.id,
"scholar_profile_id": item.scholar_profile_id,
"scholar_label": item.scholar_label,
"status": item.status,
"status_badge": _queue_status_badge(item.status),
"reason": item.reason,
"dropped_reason": item.dropped_reason,
"attempt_count": item.attempt_count,
"resume_cstart": item.resume_cstart,
"next_attempt_at": _format_dt(item.next_attempt_dt),
"updated_at": _format_dt(item.updated_at),
"last_error": item.last_error,
"last_run_id": item.last_run_id,
}
)
context = common.build_template_context(
request,
page_title="Runs",
active_nav="runs",
theme_name=resolve_theme(theme),
session_user=common.to_session_user(current_user),
notice=request.query_params.get("notice"),
page_error=request.query_params.get("error"),
)
context["runs"] = run_items
context["failed_only"] = failed_only_enabled
context["queue_items"] = queue_items
return common.templates.TemplateResponse(
request=request,
name="runs.html",
context=context,
)
@router.get("/runs/{run_id}", response_class=HTMLResponse)
async def run_detail_page(
request: Request,
run_id: int,
theme: str | None = None,
db_session: AsyncSession = Depends(get_db_session),
) -> HTMLResponse:
current_user = await common.get_authenticated_user(request, db_session)
if current_user is None:
return common.redirect_to_login()
run = await run_service.get_run_for_user(
db_session,
user_id=current_user.id,
run_id=run_id,
)
if run is None:
raise HTTPException(status_code=404, detail="Run not found.")
error_log = run.error_log if isinstance(run.error_log, dict) else {}
scholar_results = error_log.get("scholar_results", [])
if not isinstance(scholar_results, list):
scholar_results = []
context = common.build_template_context(
request,
page_title=f"Run #{run.id}",
active_nav="runs",
theme_name=resolve_theme(theme),
session_user=common.to_session_user(current_user),
)
context["run"] = {
"id": run.id,
"started_at": _format_dt(run.start_dt),
"finished_at": _format_dt(run.end_dt),
"status": run.status.value,
"status_badge": _status_badge(run.status.value),
"trigger_type": run.trigger_type.value,
"scholar_count": run.scholar_count,
"new_publication_count": run.new_pub_count,
}
context["run_summary"] = _summary_dict(error_log)
context["scholar_results"] = scholar_results
return common.templates.TemplateResponse(
request=request,
name="run_detail.html",
context=context,
)
@router.post("/runs/queue/{queue_item_id}/drop")
async def drop_queue_item(
request: Request,
queue_item_id: int,
db_session: AsyncSession = Depends(get_db_session),
):
current_user = await common.get_authenticated_user(request, db_session)
if current_user is None:
return common.redirect_to_login()
try:
dropped = await run_service.drop_queue_item_for_user(
db_session,
user_id=current_user.id,
queue_item_id=queue_item_id,
)
except run_service.QueueTransitionError as exc:
return common.redirect_with_message(
"/runs",
error=f"Queue item #{queue_item_id}: {exc.message}",
)
if dropped is None:
raise HTTPException(status_code=404, detail="Queue item not found.")
return common.redirect_with_message(
"/runs",
notice=f"Queue item #{queue_item_id} marked as dropped.",
)
@router.post("/runs/queue/{queue_item_id}/clear")
async def clear_queue_item(
request: Request,
queue_item_id: int,
db_session: AsyncSession = Depends(get_db_session),
):
current_user = await common.get_authenticated_user(request, db_session)
if current_user is None:
return common.redirect_to_login()
try:
cleared = await run_service.clear_queue_item_for_user(
db_session,
user_id=current_user.id,
queue_item_id=queue_item_id,
)
except run_service.QueueTransitionError as exc:
return common.redirect_with_message(
"/runs",
error=f"Queue item #{queue_item_id}: {exc.message}",
)
if cleared is None:
raise HTTPException(status_code=404, detail="Queue item not found.")
return common.redirect_with_message(
"/runs",
notice=f"Queue item #{queue_item_id} cleared.",
)
@router.post("/runs/queue/{queue_item_id}/retry")
async def retry_queue_item(
request: Request,
queue_item_id: int,
db_session: AsyncSession = Depends(get_db_session),
):
current_user = await common.get_authenticated_user(request, db_session)
if current_user is None:
return common.redirect_to_login()
try:
queue_item = await run_service.retry_queue_item_for_user(
db_session,
user_id=current_user.id,
queue_item_id=queue_item_id,
)
except run_service.QueueTransitionError as exc:
return common.redirect_with_message(
"/runs",
error=f"Queue item #{queue_item_id}: {exc.message}",
)
if queue_item is None:
raise HTTPException(status_code=404, detail="Queue item not found.")
return common.redirect_with_message(
"/runs",
notice=(
f"Queue item #{queue_item_id} queued for retry "
f"(scholar: {queue_item.scholar_label})."
),
)