from __future__ import annotations from fastapi import APIRouter, Request, Query from fastapi.responses import HTMLResponse from typing import Any import json from urllib.parse import urlparse from html import escape as _esc from ..app import templates from ..services.tasks import get_session, new_sid from ..services.telemetry import log_commander_create_deck def _merge_hx_trigger(response: Any, payload: dict[str, Any]) -> None: if not payload or response is None: return try: existing = response.headers.get("HX-Trigger") if hasattr(response, "headers") else None except Exception: existing = None try: if existing: try: data = json.loads(existing) except Exception: data = {} if isinstance(data, dict): data.update(payload) response.headers["HX-Trigger"] = json.dumps(data) return response.headers["HX-Trigger"] = json.dumps(payload) except Exception: try: response.headers["HX-Trigger"] = json.dumps(payload) except Exception: pass def _step5_summary_placeholder_html(token: int, *, message: str | None = None) -> str: text = message or "Deck summary will appear after the build completes." return ( f'
' f'
{_esc(text)}
' '
' ) def _current_builder_summary(sess: dict) -> Any | None: try: ctx = sess.get("build_ctx") or {} builder = ctx.get("builder") if isinstance(ctx, dict) else None if builder is None: return None summary_fn = getattr(builder, "build_deck_summary", None) if callable(summary_fn): summary_data = summary_fn() # Also save to session for consistency if summary_data: sess["summary"] = summary_data return summary_data except Exception: return None return None router = APIRouter(prefix="/build") @router.get("/", response_class=HTMLResponse) async def build_index(request: Request) -> HTMLResponse: sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) # Seed commander from query string when arriving from commander browser q_commander = None try: q_commander = request.query_params.get("commander") if q_commander: # Persist a human-friendly commander name into session for the wizard sess["commander"] = str(q_commander) # Set flag to indicate this is a quick-build scenario sess["quick_build"] = True except Exception: pass return_url = None try: raw_return = request.query_params.get("return") if raw_return: parsed = urlparse(raw_return) if not parsed.scheme and not parsed.netloc and parsed.path: safe_path = parsed.path if parsed.path.startswith("/") else f"/{parsed.path}" safe_return = safe_path if parsed.query: safe_return += f"?{parsed.query}" if parsed.fragment: safe_return += f"#{parsed.fragment}" return_url = safe_return except Exception: return_url = None if q_commander: try: log_commander_create_deck( request, commander=str(q_commander), return_url=return_url, ) except Exception: pass # Determine last step (fallback heuristics if not set) last_step = sess.get("last_step") if not last_step: if sess.get("build_ctx"): last_step = 5 elif sess.get("ideals"): last_step = 4 elif sess.get("bracket"): last_step = 3 elif sess.get("commander"): last_step = 2 else: last_step = 1 # Only pass commander to template if coming from commander browser (?commander= query param) # This prevents stale commander from being pre-filled on subsequent builds # The query param only exists on initial navigation from commander browser should_auto_fill = q_commander is not None resp = templates.TemplateResponse( request, "build/index.html", { "sid": sid, "commander": sess.get("commander") if should_auto_fill else None, "tags": sess.get("tags", []), "name": sess.get("custom_export_base"), "last_step": last_step, "return_url": return_url, }, ) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp # Support /build without trailing slash @router.get("", response_class=HTMLResponse) async def build_index_alias(request: Request) -> HTMLResponse: return await build_index(request) @router.get("/batch-progress") def batch_build_progress(request: Request, batch_id: str = Query(...)): """Poll endpoint for Batch Build progress. Returns either progress indicator or redirect to comparison.""" import logging logger = logging.getLogger(__name__) sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) from ..services.build_cache import BuildCache batch_status = BuildCache.get_batch_status(sess, batch_id) logger.info(f"[Batch Progress Poll] batch_id={batch_id}, status={batch_status}") if not batch_status: return HTMLResponse('
Batch not found. Please refresh.
') if batch_status["status"] == "completed": # All builds complete - redirect to comparison page response = HTMLResponse(f'') response.set_cookie("sid", sid, httponly=True, samesite="lax") return response # Get config to determine color count for time estimate config = BuildCache.get_batch_config(sess, batch_id) commander_name = config.get("commander", "") if config else "" # Estimate time based on color count (from testing data) time_estimate = "1-3 minutes" if commander_name and config: # Try to get commander's color identity try: from ..services import orchestrator as orch cmd_data = orch.load_commander(commander_name) if cmd_data and "colorIdentity" in cmd_data: color_count = len(cmd_data.get("colorIdentity", [])) if color_count <= 2: time_estimate = "1-3 minutes" elif color_count == 3: time_estimate = "2-4 minutes" else: # 4-5 colors time_estimate = "3-5 minutes" except Exception: pass # Default to 1-3 if we can't determine # Build still running - return progress content partial only ctx = { "request": request, "batch_id": batch_id, "build_count": batch_status["count"], "completed": batch_status["completed"], "progress_pct": batch_status["progress_pct"], "status": f"Building deck {batch_status['completed'] + 1} of {batch_status['count']}..." if batch_status['completed'] < batch_status['count'] else "Finalizing...", "has_errors": batch_status["has_errors"], "error_count": batch_status["error_count"], "time_estimate": time_estimate } response = templates.TemplateResponse("build/_batch_progress_content.html", ctx) response.set_cookie("sid", sid, httponly=True, samesite="lax") return response # ============================================================================== # Phase 5 Routes Moved to Focused Modules (Roadmap 9 M1) # ============================================================================== # Permalinks and Lock Management → build_permalinks.py: # - POST /build/lock - Card lock toggle # - GET /build/permalink - State serialization # - GET /build/from - State restoration # # Alternatives → build_alternatives.py: # - GET /build/alternatives - Role-based card suggestions # # Compliance and Replacement → build_compliance.py: # - POST /build/replace - Inline card replacement # - POST /build/replace/undo - Undo replacement # - GET /build/compare - Batch comparison stub # - GET /build/compliance - Compliance panel # - POST /build/enforce/apply - Apply enforcement # - GET /build/enforcement - Full-page enforcement # ==============================================================================