refactor: modular route organization (Phase 1-2 complete)

- Split monolithic build route handler into focused modules
- Extract validation, multi-copy, include/exclude, themes, and partner routes
- Add response utilities and telemetry decorators
- Create route pattern documentation
- Fix multi-copy detection bug (tag key mismatch)
- Improve code maintainability and testability

Roadmap 9 M1 Phase 1-2
This commit is contained in:
matt 2026-03-03 21:49:08 -08:00
parent 97da117ccb
commit e81b47bccf
20 changed files with 2852 additions and 1552 deletions

View file

@ -9,6 +9,12 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
## [Unreleased]
### Added
- **Backend Standardization Framework**: Improved code organization and maintainability
- Response builder utilities for consistent HTTP responses
- Telemetry decorators for route access tracking and error logging
- Route pattern documentation defining standards for all routes
- Split monolithic build route handler into focused, maintainable modules
- Foundation for integrating custom exceptions into web layer
- **Template Validation Tests**: Comprehensive test suite for HTML/Jinja2 templates
- Validates Jinja2 syntax across all templates
- Checks HTML structure (balanced tags, unique IDs, proper attributes)
@ -92,6 +98,11 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
- Optimized linting rules for development workflow
### Fixed
- **Multi-Copy Package Detection**: Fixed bug preventing multi-copy suggestions from appearing in New Deck wizard
- Corrected key mismatch between archetype definitions ('tagsAny') and detection code ('tags_any')
- Multi-copy panel now properly displays when commander and theme tags match supported archetypes (e.g., Hare Apparent for Rabbit Kindred + Tokens Matter)
- Updated panel background color to match theme (now uses CSS variable instead of hardcoded value)
- Affects all 12 multi-copy archetypes (Hare Apparent, Slime Against Humanity, Dragon's Approach, etc.)
- **Card Data Auto-Refresh**: Fixed stale data issue when new sets are released
- Auto-refresh now deletes cached raw parquet file before downloading fresh data
- Ensures new sets are included instead of reprocessing old cached data

View file

@ -3,9 +3,16 @@
## [Unreleased]
### Summary
Web UI improvements with Tailwind CSS migration, TypeScript conversion, component library, template validation tests, enhanced code quality tools, and optional card image caching for faster performance and better maintainability.
Backend standardization infrastructure, web UI improvements with Tailwind CSS migration, TypeScript conversion, component library, template validation tests, enhanced code quality tools, and optional card image caching for faster performance and better maintainability.
### Added
- **Backend Standardization Framework**: Improved code organization and maintainability
- Response builder utilities for standardized HTTP/JSON/HTMX responses
- Telemetry decorators for automatic route tracking and error logging
- Route pattern documentation with examples and migration guide
- Modular route organization with focused, maintainable modules
- Foundation for integrating custom exception hierarchy
- Benefits: Easier to maintain, extend, and test backend code
- **Template Validation Tests**: Comprehensive test suite ensuring HTML/template quality
- Validates Jinja2 syntax and structure
- Checks for common HTML issues (duplicate IDs, balanced tags)
@ -89,6 +96,11 @@ Web UI improvements with Tailwind CSS migration, TypeScript conversion, componen
_None_
### Fixed
- **Multi-Copy Package Detection**: Fixed multi-copy suggestions not appearing in New Deck wizard
- Multi-copy panel now properly displays when commander and theme tags match supported archetypes
- Example: Hare Apparent now appears when building with Rabbit Kindred + Tokens Matter themes
- Panel styling now matches current theme (dark/light mode support)
- Affects all 12 multi-copy archetypes in the system
- **Card Data Auto-Refresh**: Fixed stale data issue when new sets are released
- Auto-refresh now deletes cached raw parquet file before downloading fresh data
- Ensures new sets are included instead of reprocessing old cached data

View file

@ -1034,7 +1034,7 @@ def detect_viable_multi_copy_archetypes(builder) -> list[dict]:
continue
# Tag triggers
trig = meta.get('triggers', {}) or {}
any_tags = _normalize_tags_list(trig.get('tags_any', []) or [])
any_tags = _normalize_tags_list(trig.get('tagsAny', []) or [])
all_tags = _normalize_tags_list(trig.get('tags_all', []) or [])
score = 0
reasons: list[str] = []

View file

@ -2256,6 +2256,11 @@ async def setup_status():
# Routers
from .routes import build as build_routes # noqa: E402
from .routes import build_validation as build_validation_routes # noqa: E402
from .routes import build_multicopy as build_multicopy_routes # noqa: E402
from .routes import build_include_exclude as build_include_exclude_routes # noqa: E402
from .routes import build_themes as build_themes_routes # noqa: E402
from .routes import build_partners as build_partners_routes # noqa: E402
from .routes import configs as config_routes # noqa: E402
from .routes import decks as decks_routes # noqa: E402
from .routes import setup as setup_routes # noqa: E402
@ -2269,6 +2274,11 @@ from .routes import card_browser as card_browser_routes # noqa: E402
from .routes import compare as compare_routes # noqa: E402
from .routes import api as api_routes # noqa: E402
app.include_router(build_routes.router)
app.include_router(build_validation_routes.router, prefix="/build")
app.include_router(build_multicopy_routes.router, prefix="/build")
app.include_router(build_include_exclude_routes.router, prefix="/build")
app.include_router(build_themes_routes.router, prefix="/build")
app.include_router(build_partners_routes.router, prefix="/build")
app.include_router(config_routes.router)
app.include_router(decks_routes.router)
app.include_router(setup_routes.router)
@ -2284,7 +2294,7 @@ app.include_router(api_routes.router)
# Warm validation cache early to reduce first-call latency in tests and dev
try:
build_routes.warm_validation_name_cache()
build_validation_routes.warm_validation_name_cache()
except Exception:
pass

View file

@ -0,0 +1 @@
"""Decorators for route handlers."""

View file

@ -0,0 +1,97 @@
"""Telemetry decorators for route handlers.
Provides decorators to automatically track route access, build times, and other metrics.
"""
from functools import wraps
from typing import Callable, Any
import time
from code.logging_util import get_logger
LOGGER = get_logger(__name__)
def track_route_access(event_name: str):
"""Decorator to track route access with telemetry.
Args:
event_name: Name of the telemetry event to log
Example:
@router.get("/build/new")
@track_route_access("build_start")
async def start_build(request: Request):
...
"""
def decorator(func: Callable) -> Callable:
@wraps(func)
async def wrapper(*args, **kwargs):
start_time = time.time()
try:
result = await func(*args, **kwargs)
elapsed_ms = int((time.time() - start_time) * 1000)
LOGGER.debug(f"Route {event_name} completed in {elapsed_ms}ms")
return result
except Exception as e:
elapsed_ms = int((time.time() - start_time) * 1000)
LOGGER.error(f"Route {event_name} failed after {elapsed_ms}ms: {e}")
raise
return wrapper
return decorator
def track_build_time(operation: str):
"""Decorator to track deck building operation timing.
Args:
operation: Description of the build operation
Example:
@track_build_time("commander_selection")
async def select_commander(request: Request):
...
"""
def decorator(func: Callable) -> Callable:
@wraps(func)
async def wrapper(*args, **kwargs):
start_time = time.time()
result = await func(*args, **kwargs)
elapsed_ms = int((time.time() - start_time) * 1000)
LOGGER.info(f"Build operation '{operation}' took {elapsed_ms}ms")
return result
return wrapper
return decorator
def log_route_errors(route_name: str):
"""Decorator to log route errors with context.
Args:
route_name: Name of the route for error context
Example:
@router.post("/build/create")
@log_route_errors("build_create")
async def create_deck(request: Request):
...
"""
def decorator(func: Callable) -> Callable:
@wraps(func)
async def wrapper(*args, **kwargs):
try:
return await func(*args, **kwargs)
except Exception as e:
# Extract request if available
request = None
for arg in args:
if hasattr(arg, "url") and hasattr(arg, "state"):
request = arg
break
request_id = getattr(request.state, "request_id", "unknown") if request else "unknown"
LOGGER.error(
f"Error in route '{route_name}' [request_id={request_id}]: {e}",
exc_info=True
)
raise
return wrapper
return decorator

View file

@ -0,0 +1 @@
"""Middleware modules for the web application."""

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,216 @@
"""
Include/Exclude card list management routes.
Handles user-defined include (must-have) and exclude (forbidden) card lists
for deck building, including the card toggle endpoint and summary rendering.
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Request, Form
from fastapi.responses import HTMLResponse, JSONResponse
from ..app import ALLOW_MUST_HAVES, templates
from ..services.build_utils import step5_base_ctx
from ..services.tasks import get_session, new_sid
from ..services.telemetry import log_include_exclude_toggle
from .build import _merge_hx_trigger
router = APIRouter()
def _must_have_state(sess: dict) -> tuple[dict[str, Any], list[str], list[str]]:
"""
Extract include/exclude card lists and enforcement settings from session.
Args:
sess: Session dictionary containing user state
Returns:
Tuple of (state_dict, includes_list, excludes_list) where:
- state_dict contains enforcement mode, fuzzy matching, and list contents
- includes_list contains card names to include
- excludes_list contains card names to exclude
"""
includes = list(sess.get("include_cards") or [])
excludes = list(sess.get("exclude_cards") or [])
state = {
"includes": includes,
"excludes": excludes,
"enforcement_mode": (sess.get("enforcement_mode") or "warn"),
"allow_illegal": bool(sess.get("allow_illegal")),
"fuzzy_matching": bool(sess.get("fuzzy_matching", True)),
}
return state, includes, excludes
def _render_include_exclude_summary(
request: Request,
sess: dict,
sid: str,
*,
state: dict[str, Any] | None = None,
includes: list[str] | None = None,
excludes: list[str] | None = None,
) -> HTMLResponse:
"""
Render the include/exclude summary template.
Args:
request: FastAPI request object
sess: Session dictionary
sid: Session ID for cookie
state: Optional pre-computed state dict
includes: Optional pre-computed includes list
excludes: Optional pre-computed excludes list
Returns:
HTMLResponse with rendered include/exclude summary
"""
ctx = step5_base_ctx(request, sess, include_name=False, include_locks=False)
if state is None or includes is None or excludes is None:
state, includes, excludes = _must_have_state(sess)
ctx["must_have_state"] = state
ctx["summary"] = sess.get("step5_summary") if sess.get("step5_summary_ready") else None
ctx["include_cards"] = includes
ctx["exclude_cards"] = excludes
response = templates.TemplateResponse("partials/include_exclude_summary.html", ctx)
response.set_cookie("sid", sid, httponly=True, samesite="lax")
return response
@router.post("/must-haves/toggle", response_class=HTMLResponse)
async def toggle_must_haves(
request: Request,
card_name: str = Form(...),
list_type: str = Form(...),
enabled: str = Form("1"),
):
"""
Toggle a card's inclusion in the include or exclude list.
This endpoint handles:
- Adding/removing cards from include (must-have) lists
- Adding/removing cards from exclude (forbidden) lists
- Mutual exclusivity (card can't be in both lists)
- List size limits (10 includes, 15 excludes)
- Case-insensitive duplicate detection
Args:
request: FastAPI request object
card_name: Name of the card to toggle
list_type: Either "include" or "exclude"
enabled: "1"/"true"/"yes"/"on" to add, anything else to remove
Returns:
HTMLResponse with updated include/exclude summary, or
JSONResponse with error if validation fails
HX-Trigger Events:
must-haves:toggle: Payload with card, list, enabled status, and counts
"""
if not ALLOW_MUST_HAVES:
return JSONResponse({"error": "Must-have lists are disabled"}, status_code=403)
name = str(card_name or "").strip()
if not name:
return JSONResponse({"error": "Card name is required"}, status_code=400)
list_key = str(list_type or "").strip().lower()
if list_key not in {"include", "exclude"}:
return JSONResponse({"error": "Unsupported toggle type"}, status_code=400)
enabled_flag = str(enabled).strip().lower() in {"1", "true", "yes", "on"}
sid = request.cookies.get("sid") or request.headers.get("X-Session-ID")
if not sid:
sid = new_sid()
sess = get_session(sid)
includes = list(sess.get("include_cards") or [])
excludes = list(sess.get("exclude_cards") or [])
include_lookup = {str(v).strip().lower(): str(v) for v in includes if str(v).strip()}
exclude_lookup = {str(v).strip().lower(): str(v) for v in excludes if str(v).strip()}
key = name.lower()
display_name = include_lookup.get(key) or exclude_lookup.get(key) or name
changed = False
include_limit = 10
exclude_limit = 15
def _remove_casefold(items: list[str], item_key: str) -> list[str]:
"""Remove items matching the given key (case-insensitive)."""
return [c for c in items if str(c).strip().lower() != item_key]
if list_key == "include":
if enabled_flag:
if key not in include_lookup:
if len(include_lookup) >= include_limit:
return JSONResponse({"error": f"Include limit reached ({include_limit})."}, status_code=400)
includes.append(name)
include_lookup[key] = name
changed = True
if key in exclude_lookup:
excludes = _remove_casefold(excludes, key)
exclude_lookup.pop(key, None)
changed = True
else:
if key in include_lookup:
includes = _remove_casefold(includes, key)
include_lookup.pop(key, None)
changed = True
else: # exclude
if enabled_flag:
if key not in exclude_lookup:
if len(exclude_lookup) >= exclude_limit:
return JSONResponse({"error": f"Exclude limit reached ({exclude_limit})."}, status_code=400)
excludes.append(name)
exclude_lookup[key] = name
changed = True
if key in include_lookup:
includes = _remove_casefold(includes, key)
include_lookup.pop(key, None)
changed = True
else:
if key in exclude_lookup:
excludes = _remove_casefold(excludes, key)
exclude_lookup.pop(key, None)
changed = True
if changed:
sess["include_cards"] = includes
sess["exclude_cards"] = excludes
if "include_exclude_diagnostics" in sess:
try:
del sess["include_exclude_diagnostics"]
except Exception:
pass
response = _render_include_exclude_summary(request, sess, sid)
try:
log_include_exclude_toggle(
request,
card_name=display_name,
action=list_key,
enabled=enabled_flag,
include_count=len(includes),
exclude_count=len(excludes),
)
except Exception:
pass
trigger_payload = {
"card": display_name,
"list": list_key,
"enabled": enabled_flag,
"include_count": len(includes),
"exclude_count": len(excludes),
}
try:
_merge_hx_trigger(response, {"must-haves:toggle": trigger_payload})
except Exception:
pass
return response

View file

@ -0,0 +1,349 @@
"""Multi-copy archetype routes for deck building.
Handles multi-copy package detection, selection, and integration with the deck builder.
Multi-copy archetypes allow multiple copies of specific cards (e.g., Hare Apparent, Dragon's Approach).
Routes:
GET /multicopy/check - Check if commander/tags suggest multi-copy archetype
POST /multicopy/save - Save or skip multi-copy selection
GET /new/multicopy - Get multi-copy suggestions for New Deck modal (inline)
Created: 2026-02-20
Roadmap: R9 M1 Phase 2
"""
from __future__ import annotations
from fastapi import APIRouter, Request, Form, Query
from fastapi.responses import HTMLResponse
from html import escape as _esc
from deck_builder.builder import DeckBuilder
from deck_builder import builder_utils as bu, builder_constants as bc
from ..app import templates
from ..services.tasks import get_session, new_sid
from ..services import orchestrator as orch
from ..services.build_utils import owned_names as owned_names_helper
router = APIRouter()
def _rebuild_ctx_with_multicopy(sess: dict) -> None:
"""Rebuild the staged context so Multi-Copy runs first, avoiding overfill.
This ensures the added cards are accounted for before lands and later phases,
which keeps totals near targets and shows the multi-copy additions ahead of basics.
Args:
sess: Session dictionary containing build state
"""
try:
if not sess or not sess.get("commander"):
return
# Build fresh ctx with the same options, threading multi_copy explicitly
opts = orch.bracket_options()
default_bracket = (opts[0]["level"] if opts else 1)
bracket_val = sess.get("bracket")
try:
safe_bracket = int(bracket_val) if bracket_val is not None else default_bracket
except Exception:
safe_bracket = int(default_bracket)
ideals_val = sess.get("ideals") or orch.ideal_defaults()
use_owned = bool(sess.get("use_owned_only"))
prefer = bool(sess.get("prefer_owned"))
owned_names = owned_names_helper() if (use_owned or prefer) else None
locks = list(sess.get("locks", []))
sess["build_ctx"] = orch.start_build_ctx(
commander=sess.get("commander"),
tags=sess.get("tags", []),
bracket=safe_bracket,
ideals=ideals_val,
tag_mode=sess.get("tag_mode", "AND"),
use_owned_only=use_owned,
prefer_owned=prefer,
owned_names=owned_names,
locks=locks,
custom_export_base=sess.get("custom_export_base"),
multi_copy=sess.get("multi_copy"),
prefer_combos=bool(sess.get("prefer_combos")),
combo_target_count=int(sess.get("combo_target_count", 2)),
combo_balance=str(sess.get("combo_balance", "mix")),
swap_mdfc_basics=bool(sess.get("swap_mdfc_basics")),
)
except Exception:
# If rebuild fails (e.g., commander not found in test), fall back to injecting
# a minimal Multi-Copy stage on the existing builder so the UI can render additions.
try:
ctx = sess.get("build_ctx")
if not isinstance(ctx, dict):
return
b = ctx.get("builder")
if b is None:
return
# Thread selection onto the builder; runner will be resilient without full DFs
try:
setattr(b, "_web_multi_copy", sess.get("multi_copy") or None)
except Exception:
pass
# Ensure minimal structures exist
try:
if not isinstance(getattr(b, "card_library", None), dict):
b.card_library = {}
except Exception:
pass
try:
if not isinstance(getattr(b, "ideal_counts", None), dict):
b.ideal_counts = {}
except Exception:
pass
# Inject a single Multi-Copy stage
ctx["stages"] = [{"key": "multi_copy", "label": "Multi-Copy Package", "runner_name": "__add_multi_copy__"}]
ctx["idx"] = 0
ctx["last_visible_idx"] = 0
except Exception:
# Leave existing context untouched on unexpected failure
pass
@router.get("/multicopy/check", response_class=HTMLResponse)
async def multicopy_check(request: Request) -> HTMLResponse:
"""If current commander/tags suggest a multi-copy archetype, render a choose-one modal.
Returns empty content when not applicable to avoid flashing a modal unnecessarily.
Args:
request: FastAPI request object
Returns:
HTMLResponse with multi-copy modal or empty string
"""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
commander = str(sess.get("commander") or "").strip()
tags = list(sess.get("tags") or [])
if not commander:
return HTMLResponse("")
# Avoid re-prompting repeatedly for the same selection context
key = commander + "||" + ",".join(sorted([str(t).strip().lower() for t in tags if str(t).strip()]))
seen = set(sess.get("mc_seen_keys", []) or [])
if key in seen:
return HTMLResponse("")
# Build a light DeckBuilder seeded with commander + tags (no heavy data load required)
try:
tmp = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True)
df = tmp.load_commander_data()
row = df[df["name"].astype(str) == commander]
if row.empty:
return HTMLResponse("")
tmp._apply_commander_selection(row.iloc[0])
tmp.selected_tags = list(tags or [])
try:
tmp.primary_tag = tmp.selected_tags[0] if len(tmp.selected_tags) > 0 else None
tmp.secondary_tag = tmp.selected_tags[1] if len(tmp.selected_tags) > 1 else None
tmp.tertiary_tag = tmp.selected_tags[2] if len(tmp.selected_tags) > 2 else None
except Exception:
pass
# Establish color identity from the selected commander
try:
tmp.determine_color_identity()
except Exception:
pass
# Detect viable archetypes
results = bu.detect_viable_multi_copy_archetypes(tmp) or []
if not results:
# Remember this key to avoid re-checking until tags/commander change
try:
seen.add(key)
sess["mc_seen_keys"] = list(seen)
except Exception:
pass
return HTMLResponse("")
# Render modal template with top N (cap small for UX)
items = results[:5]
ctx = {
"request": request,
"items": items,
"commander": commander,
"tags": tags,
}
return templates.TemplateResponse("build/_multi_copy_modal.html", ctx)
except Exception:
return HTMLResponse("")
@router.post("/multicopy/save", response_class=HTMLResponse)
async def multicopy_save(
request: Request,
choice_id: str = Form(None),
count: int = Form(None),
thrumming: str | None = Form(None),
skip: str | None = Form(None),
) -> HTMLResponse:
"""Persist user selection (or skip) for multi-copy archetype in session and close modal.
Returns a tiny confirmation chip via OOB swap (optional) and removes the modal.
Args:
request: FastAPI request object
choice_id: Multi-copy archetype ID (e.g., 'hare_apparent')
count: Number of copies to include
thrumming: Whether to include Thrumming Stone
skip: Whether to skip multi-copy for this build
Returns:
HTMLResponse with confirmation chip (OOB swap)
"""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
commander = str(sess.get("commander") or "").strip()
tags = list(sess.get("tags") or [])
key = commander + "||" + ",".join(sorted([str(t).strip().lower() for t in tags if str(t).strip()]))
# Update seen set to avoid re-prompt next load
seen = set(sess.get("mc_seen_keys", []) or [])
seen.add(key)
sess["mc_seen_keys"] = list(seen)
# Handle skip explicitly
if skip and str(skip).strip() in ("1","true","on","yes"):
# Clear any prior choice for this run
try:
if sess.get("multi_copy"):
del sess["multi_copy"]
if sess.get("mc_applied_key"):
del sess["mc_applied_key"]
except Exception:
pass
# Return nothing (modal will be removed client-side)
# Also emit an OOB chip indicating skip
chip = (
'<div id="last-action" hx-swap-oob="true">'
'<span class="chip" title="Click to dismiss">Dismissed multi-copy suggestions</span>'
'</div>'
)
return HTMLResponse(chip)
# Persist selection when provided
payload = None
try:
meta = bc.MULTI_COPY_ARCHETYPES.get(str(choice_id), {})
name = meta.get("name") or str(choice_id)
printed_cap = meta.get("printed_cap")
# Coerce count with bounds: default -> rec_window[0], cap by printed_cap when present
if count is None:
count = int(meta.get("default_count", 25))
try:
count = int(count)
except Exception:
count = int(meta.get("default_count", 25))
if isinstance(printed_cap, int) and printed_cap > 0:
count = max(1, min(printed_cap, count))
payload = {
"id": str(choice_id),
"name": name,
"count": int(count),
"thrumming": True if (thrumming and str(thrumming).strip() in ("1","true","on","yes")) else False,
}
sess["multi_copy"] = payload
# Mark as not yet applied so the next build start/continue can account for it once
try:
if sess.get("mc_applied_key"):
del sess["mc_applied_key"]
except Exception:
pass
# If there's an active build context, rebuild it so Multi-Copy runs first
if sess.get("build_ctx"):
_rebuild_ctx_with_multicopy(sess)
except Exception:
payload = None
# Return OOB chip summarizing the selection
if payload:
chip = (
'<div id="last-action" hx-swap-oob="true">'
f'<span class="chip" title="Click to dismiss">Selected multi-copy: '
f"<strong>{_esc(payload.get('name',''))}</strong> x{int(payload.get('count',0))}"
f"{' + Thrumming Stone' if payload.get('thrumming') else ''}</span>"
'</div>'
)
else:
chip = (
'<div id="last-action" hx-swap-oob="true">'
'<span class="chip" title="Click to dismiss">Saved</span>'
'</div>'
)
return HTMLResponse(chip)
@router.get("/new/multicopy", response_class=HTMLResponse)
async def build_new_multicopy(
request: Request,
commander: str = Query(""),
primary_tag: str | None = Query(None),
secondary_tag: str | None = Query(None),
tertiary_tag: str | None = Query(None),
tag_mode: str | None = Query("AND"),
) -> HTMLResponse:
"""Return multi-copy suggestions for the New Deck modal based on commander + selected tags.
This does not mutate the session; it simply renders a form snippet that posts with the main modal.
Args:
request: FastAPI request object
commander: Commander name
primary_tag: Primary theme tag
secondary_tag: Secondary theme tag
tertiary_tag: Tertiary theme tag
tag_mode: Tag matching mode (AND/OR)
Returns:
HTMLResponse with multi-copy suggestions or empty string
"""
name = (commander or "").strip()
if not name:
return HTMLResponse("")
try:
tmp = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True)
df = tmp.load_commander_data()
row = df[df["name"].astype(str) == name]
if row.empty:
return HTMLResponse("")
tmp._apply_commander_selection(row.iloc[0])
tags = [t for t in [primary_tag, secondary_tag, tertiary_tag] if t]
tmp.selected_tags = list(tags or [])
try:
tmp.primary_tag = tmp.selected_tags[0] if len(tmp.selected_tags) > 0 else None
tmp.secondary_tag = tmp.selected_tags[1] if len(tmp.selected_tags) > 1 else None
tmp.tertiary_tag = tmp.selected_tags[2] if len(tmp.selected_tags) > 2 else None
except Exception:
pass
try:
tmp.determine_color_identity()
except Exception:
pass
results = bu.detect_viable_multi_copy_archetypes(tmp) or []
# For the New Deck modal, only show suggestions where the matched tags intersect
# the explicitly selected tags (ignore commander-default themes).
sel_tags = {str(t).strip().lower() for t in (tags or []) if str(t).strip()}
def _matched_reason_tags(item: dict) -> set[str]:
out = set()
try:
for r in item.get('reasons', []) or []:
if not isinstance(r, str):
continue
rl = r.strip().lower()
if rl.startswith('tags:'):
body = rl.split('tags:', 1)[1].strip()
parts = [p.strip() for p in body.split(',') if p.strip()]
out.update(parts)
except Exception:
return set()
return out
if sel_tags:
results = [it for it in results if (_matched_reason_tags(it) & sel_tags)]
else:
# If no selected tags, do not show any multi-copy suggestions in the modal
results = []
if not results:
return HTMLResponse("")
items = results[:5]
ctx = {"request": request, "items": items}
return templates.TemplateResponse("build/_new_deck_multicopy.html", ctx)
except Exception:
return HTMLResponse("")

View file

@ -0,0 +1,738 @@
"""
Partner mechanics routes and utilities for deck building.
Handles partner commanders, backgrounds, Doctor/Companion pairings,
and partner preview/validation functionality.
"""
from __future__ import annotations
from typing import Any, Iterable
from urllib.parse import quote_plus
from fastapi import APIRouter, Request, Form
from fastapi.responses import JSONResponse
from ..app import (
ENABLE_PARTNER_MECHANICS,
ENABLE_PARTNER_SUGGESTIONS,
)
from ..services.telemetry import log_partner_suggestion_selected
from ..services.partner_suggestions import get_partner_suggestions
from ..services.commander_catalog_loader import (
load_commander_catalog,
find_commander_record,
CommanderRecord,
normalized_restricted_labels,
shared_restricted_partner_label,
)
from deck_builder.background_loader import load_background_cards
from deck_builder.partner_selection import apply_partner_inputs
from deck_builder.builder import DeckBuilder
from exceptions import CommanderPartnerError
from code.logging_util import get_logger
LOGGER = get_logger(__name__)
router = APIRouter()
_PARTNER_MODE_LABELS = {
"partner": "Partner",
"partner_restricted": "Partner (Restricted)",
"partner_with": "Partner With",
"background": "Choose a Background",
"doctor_companion": "Doctor & Companion",
}
_WUBRG_ORDER = ["W", "U", "B", "R", "G"]
_COLOR_NAME_MAP = {
"W": "White",
"U": "Blue",
"B": "Black",
"R": "Red",
"G": "Green",
}
def _color_code(identity: Iterable[str]) -> str:
"""Convert color identity to standard WUBRG-ordered code."""
colors = [str(c).strip().upper() for c in identity if str(c).strip()]
if not colors:
return "C"
ordered: list[str] = [c for c in _WUBRG_ORDER if c in colors]
for color in colors:
if color not in ordered:
ordered.append(color)
return "".join(ordered) or "C"
def _format_color_label(identity: Iterable[str]) -> str:
"""Format color identity as human-readable label with code."""
code = _color_code(identity)
if code == "C":
return "Colorless (C)"
names = [_COLOR_NAME_MAP.get(ch, ch) for ch in code]
return " / ".join(names) + f" ({code})"
def _partner_mode_label(mode: str | None) -> str:
"""Convert partner mode to display label."""
if not mode:
return "Partner Mechanics"
return _PARTNER_MODE_LABELS.get(mode, mode.title())
def _scryfall_image_url(card_name: str, version: str = "normal") -> str | None:
"""Generate Scryfall image URL for card."""
name = str(card_name or "").strip()
if not name:
return None
return f"https://api.scryfall.com/cards/named?fuzzy={quote_plus(name)}&format=image&version={version}"
def _scryfall_page_url(card_name: str) -> str | None:
"""Generate Scryfall search URL for card."""
name = str(card_name or "").strip()
if not name:
return None
return f"https://scryfall.com/search?q={quote_plus(name)}"
def _secondary_role_label(mode: str | None, secondary_name: str | None) -> str | None:
"""Determine the role label for the secondary commander based on pairing mode."""
if not mode:
return None
mode_lower = mode.lower()
if mode_lower == "background":
return "Background"
if mode_lower == "partner_with":
return "Partner With"
if mode_lower == "doctor_companion":
record = find_commander_record(secondary_name or "") if secondary_name else None
if record and getattr(record, "is_doctor", False):
return "Doctor"
if record and getattr(record, "is_doctors_companion", False):
return "Doctor's Companion"
return "Doctor pairing"
return "Partner commander"
def _combined_to_payload(combined: Any) -> dict[str, Any]:
"""Convert CombinedCommander object to JSON-serializable payload."""
color_identity = tuple(getattr(combined, "color_identity", ()) or ())
warnings = list(getattr(combined, "warnings", []) or [])
mode_obj = getattr(combined, "partner_mode", None)
mode_value = getattr(mode_obj, "value", None) if mode_obj is not None else None
secondary = getattr(combined, "secondary_name", None)
secondary_image = _scryfall_image_url(secondary)
secondary_url = _scryfall_page_url(secondary)
secondary_role = _secondary_role_label(mode_value, secondary)
return {
"primary_name": getattr(combined, "primary_name", None),
"secondary_name": secondary,
"partner_mode": mode_value,
"partner_mode_label": _partner_mode_label(mode_value),
"color_identity": list(color_identity),
"color_code": _color_code(color_identity),
"color_label": _format_color_label(color_identity),
"theme_tags": list(getattr(combined, "theme_tags", []) or []),
"warnings": warnings,
"secondary_image_url": secondary_image,
"secondary_scryfall_url": secondary_url,
"secondary_role_label": secondary_role,
}
def _build_partner_options(primary: CommanderRecord | None) -> tuple[list[dict[str, Any]], str | None]:
"""
Build list of valid partner options for a given primary commander.
Returns:
Tuple of (partner_options_list, variant_type) where variant is
"partner", "doctor_companion", or None
"""
if not ENABLE_PARTNER_MECHANICS:
return [], None
try:
catalog = load_commander_catalog()
except Exception:
return [], None
if primary is None:
return [], None
primary_name = primary.display_name.casefold()
primary_partner_targets = {target.casefold() for target in (primary.partner_with or ())}
primary_is_partner = bool(primary.is_partner or primary_partner_targets)
primary_restricted_labels = normalized_restricted_labels(primary)
primary_is_doctor = bool(primary.is_doctor)
primary_is_companion = bool(primary.is_doctors_companion)
variant: str | None = None
if primary_is_doctor or primary_is_companion:
variant = "doctor_companion"
elif primary_is_partner:
variant = "partner"
options: list[dict[str, Any]] = []
if variant is None:
return [], None
for record in catalog.entries:
if record.display_name.casefold() == primary_name:
continue
pairing_mode: str | None = None
role_label: str | None = None
restriction_label: str | None = None
record_name_cf = record.display_name.casefold()
is_direct_pair = bool(primary_partner_targets and record_name_cf in primary_partner_targets)
if variant == "doctor_companion":
if is_direct_pair:
pairing_mode = "partner_with"
role_label = "Partner With"
elif primary_is_doctor and record.is_doctors_companion:
pairing_mode = "doctor_companion"
role_label = "Doctor's Companion"
elif primary_is_companion and record.is_doctor:
pairing_mode = "doctor_companion"
role_label = "Doctor"
else:
if not record.is_partner or record.is_background:
continue
if primary_partner_targets:
if not is_direct_pair:
continue
pairing_mode = "partner_with"
role_label = "Partner With"
elif primary_restricted_labels:
restriction = shared_restricted_partner_label(primary, record)
if not restriction:
continue
pairing_mode = "partner_restricted"
restriction_label = restriction
else:
if record.partner_with:
continue
if not getattr(record, "has_plain_partner", False):
continue
if record.is_doctors_companion:
continue
pairing_mode = "partner"
if not pairing_mode:
continue
options.append(
{
"name": record.display_name,
"color_code": _color_code(record.color_identity),
"color_label": _format_color_label(record.color_identity),
"partner_with": list(record.partner_with or ()),
"pairing_mode": pairing_mode,
"role_label": role_label,
"restriction_label": restriction_label,
"mode_label": _partner_mode_label(pairing_mode),
"image_url": _scryfall_image_url(record.display_name),
"scryfall_url": _scryfall_page_url(record.display_name),
}
)
options.sort(key=lambda item: item["name"].casefold())
return options, variant
def _build_background_options() -> list[dict[str, Any]]:
"""Build list of available background cards for Choose a Background commanders."""
if not ENABLE_PARTNER_MECHANICS:
return []
options: list[dict[str, Any]] = []
try:
catalog = load_background_cards()
except FileNotFoundError as exc:
LOGGER.warning("background_cards_missing fallback_to_commander_catalog", extra={"error": str(exc)})
catalog = None
except Exception as exc: # pragma: no cover - unexpected loader failure
LOGGER.warning("background_cards_failed fallback_to_commander_catalog", exc_info=exc)
catalog = None
if catalog and getattr(catalog, "entries", None):
seen: set[str] = set()
for card in catalog.entries:
name_key = card.display_name.casefold()
if name_key in seen:
continue
seen.add(name_key)
options.append(
{
"name": card.display_name,
"color_code": _color_code(card.color_identity),
"color_label": _format_color_label(card.color_identity),
"image_url": _scryfall_image_url(card.display_name),
"scryfall_url": _scryfall_page_url(card.display_name),
"role_label": "Background",
}
)
if options:
options.sort(key=lambda item: item["name"].casefold())
return options
fallback_options = _background_options_from_commander_catalog()
if fallback_options:
return fallback_options
return options
def _background_options_from_commander_catalog() -> list[dict[str, Any]]:
"""Fallback: load backgrounds from commander catalog when background_cards.json is unavailable."""
try:
catalog = load_commander_catalog()
except Exception as exc: # pragma: no cover - catalog load issues handled elsewhere
LOGGER.warning("commander_catalog_background_fallback_failed", exc_info=exc)
return []
seen: set[str] = set()
options: list[dict[str, Any]] = []
for record in getattr(catalog, "entries", ()):
if not getattr(record, "is_background", False):
continue
name = getattr(record, "display_name", None)
if not name:
continue
key = str(name).casefold()
if key in seen:
continue
seen.add(key)
color_identity = getattr(record, "color_identity", tuple())
options.append(
{
"name": name,
"color_code": _color_code(color_identity),
"color_label": _format_color_label(color_identity),
"image_url": _scryfall_image_url(name),
"scryfall_url": _scryfall_page_url(name),
"role_label": "Background",
}
)
options.sort(key=lambda item: item["name"].casefold())
return options
def _partner_ui_context(
commander_name: str,
*,
partner_enabled: bool,
secondary_selection: str | None,
background_selection: str | None,
combined_preview: dict[str, Any] | None,
warnings: Iterable[str] | None,
partner_error: str | None,
auto_note: str | None,
auto_assigned: bool | None = None,
auto_prefill_allowed: bool = True,
) -> dict[str, Any]:
"""
Build complete partner UI context for rendering partner selection components.
This includes partner options, background options, preview payload,
suggestions, warnings, and all necessary state for the partner UI.
"""
record = find_commander_record(commander_name)
partner_options, partner_variant = _build_partner_options(record)
supports_backgrounds = bool(record.supports_backgrounds) if record else False
background_options = _build_background_options() if supports_backgrounds else []
selected_secondary = (secondary_selection or "").strip()
selected_background = (background_selection or "").strip()
warnings_list = list(warnings or [])
preview_payload: dict[str, Any] | None = combined_preview if isinstance(combined_preview, dict) else None
preview_error: str | None = None
auto_prefill_applied = False
auto_default_name: str | None = None
auto_note_value = auto_note
# Auto-prefill Partner With targets
if (
ENABLE_PARTNER_MECHANICS
and partner_variant == "partner"
and record
and record.partner_with
and not selected_secondary
and not selected_background
and auto_prefill_allowed
):
target_names = [name.strip() for name in record.partner_with if str(name).strip()]
for target in target_names:
for option in partner_options:
if option["name"].casefold() == target.casefold():
selected_secondary = option["name"]
auto_default_name = option["name"]
auto_prefill_applied = True
if not auto_note_value:
auto_note_value = f"Automatically paired with {option['name']} (Partner With)."
break
if auto_prefill_applied:
break
partner_active = bool((selected_secondary or selected_background) and ENABLE_PARTNER_MECHANICS)
partner_capable = bool(ENABLE_PARTNER_MECHANICS and (partner_options or background_options))
# Dynamic labels based on variant
placeholder = "Select a partner"
select_label = "Partner commander"
role_hint: str | None = None
if partner_variant == "doctor_companion" and record:
has_partner_with_option = any(option.get("pairing_mode") == "partner_with" for option in partner_options)
if record.is_doctor:
if has_partner_with_option:
placeholder = "Select a companion or Partner With match"
select_label = "Companion or Partner"
role_hint = "Choose a Doctor's Companion or Partner With match for this Doctor."
else:
placeholder = "Select a companion"
select_label = "Companion"
role_hint = "Choose a Doctor's Companion to pair with this Doctor."
elif record.is_doctors_companion:
if has_partner_with_option:
placeholder = "Select a Doctor or Partner With match"
select_label = "Doctor or Partner"
role_hint = "Choose a Doctor or Partner With pairing for this companion."
else:
placeholder = "Select a Doctor"
select_label = "Doctor partner"
role_hint = "Choose a Doctor to accompany this companion."
# Partner suggestions
suggestions_enabled = bool(ENABLE_PARTNER_MECHANICS and ENABLE_PARTNER_SUGGESTIONS)
suggestions_visible: list[dict[str, Any]] = []
suggestions_hidden: list[dict[str, Any]] = []
suggestions_total = 0
suggestions_metadata: dict[str, Any] = {}
suggestions_error: str | None = None
suggestions_loaded = False
if suggestions_enabled and record:
try:
suggestion_result = get_partner_suggestions(record.display_name)
except Exception as exc: # pragma: no cover - defensive logging
LOGGER.warning("partner suggestions failed", exc_info=exc)
suggestion_result = None
if suggestion_result is None:
suggestions_error = "Partner suggestions dataset is unavailable."
else:
suggestions_loaded = True
partner_names = [opt.get("name") for opt in (partner_options or []) if opt.get("name")]
background_names = [opt.get("name") for opt in (background_options or []) if opt.get("name")]
try:
visible, hidden = suggestion_result.flatten(partner_names, background_names, visible_limit=3)
except Exception as exc: # pragma: no cover - defensive
LOGGER.warning("partner suggestions flatten failed", exc_info=exc)
visible = []
hidden = []
suggestions_visible = visible
suggestions_hidden = hidden
suggestions_total = suggestion_result.total
if isinstance(suggestion_result.metadata, dict):
suggestions_metadata = dict(suggestion_result.metadata)
context = {
"partner_feature_available": ENABLE_PARTNER_MECHANICS,
"partner_capable": partner_capable,
"partner_enabled": partner_active,
"selected_secondary_commander": selected_secondary,
"selected_background": selected_background if supports_backgrounds else "",
"partner_options": partner_options if partner_options else [],
"background_options": background_options if background_options else [],
"primary_partner_with": list(record.partner_with) if record else [],
"primary_supports_backgrounds": supports_backgrounds,
"primary_is_partner": bool(record.is_partner) if record else False,
"primary_commander_display": record.display_name if record else commander_name,
"partner_preview": preview_payload,
"partner_warnings": warnings_list,
"partner_error": partner_error,
"partner_auto_note": auto_note_value,
"partner_auto_assigned": bool(auto_prefill_applied or auto_assigned),
"partner_auto_default": auto_default_name,
"partner_select_variant": partner_variant,
"partner_select_label": select_label,
"partner_select_placeholder": placeholder,
"partner_role_hint": role_hint,
"partner_suggestions_enabled": suggestions_enabled,
"partner_suggestions": suggestions_visible,
"partner_suggestions_hidden": suggestions_hidden,
"partner_suggestions_total": suggestions_total,
"partner_suggestions_metadata": suggestions_metadata,
"partner_suggestions_loaded": suggestions_loaded,
"partner_suggestions_error": suggestions_error,
"partner_suggestions_available": bool(suggestions_visible or suggestions_hidden),
"partner_suggestions_has_hidden": bool(suggestions_hidden),
"partner_suggestions_endpoint": "/api/partner/suggestions",
}
context["has_partner_options"] = bool(partner_options)
context["has_background_options"] = bool(background_options)
context["partner_hidden_value"] = "1" if partner_capable else "0"
context["partner_auto_opt_out"] = not bool(auto_prefill_allowed)
context["partner_prefill_available"] = bool(partner_variant == "partner" and partner_options)
# Generate preview if not provided
if preview_payload is None and ENABLE_PARTNER_MECHANICS and (selected_secondary or selected_background):
try:
builder = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True)
combined_obj = apply_partner_inputs(
builder,
primary_name=commander_name,
secondary_name=selected_secondary or None,
background_name=selected_background or None,
feature_enabled=True,
)
except CommanderPartnerError as exc:
preview_error = str(exc) or "Invalid partner selection."
except Exception as exc:
preview_error = f"Partner preview failed: {exc}"
else:
if combined_obj is not None:
preview_payload = _combined_to_payload(combined_obj)
if combined_obj.warnings:
for warn in combined_obj.warnings:
if warn not in warnings_list:
warnings_list.append(warn)
if preview_payload:
context["partner_preview"] = preview_payload
preview_tags = preview_payload.get("theme_tags")
if preview_tags:
context["partner_theme_tags"] = list(preview_tags)
if preview_error and not partner_error:
context["partner_error"] = preview_error
partner_error = preview_error
context["partner_warnings"] = warnings_list
return context
def _resolve_partner_selection(
commander_name: str,
*,
feature_enabled: bool,
partner_enabled: bool,
secondary_candidate: str | None,
background_candidate: str | None,
auto_opt_out: bool = False,
selection_source: str | None = None,
) -> tuple[
str | None,
dict[str, Any] | None,
list[str],
str | None,
str | None,
str | None,
str | None,
bool,
]:
"""
Resolve and validate partner selection, applying auto-pairing when appropriate.
Returns:
Tuple of (error, preview_payload, warnings, auto_note, resolved_secondary,
resolved_background, partner_mode, auto_assigned_flag)
"""
if not (feature_enabled and ENABLE_PARTNER_MECHANICS):
return None, None, [], None, None, None, None, False
secondary = (secondary_candidate or "").strip()
background = (background_candidate or "").strip()
auto_note: str | None = None
auto_assigned = False
selection_source_clean = (selection_source or "").strip().lower() or None
record = find_commander_record(commander_name)
partner_options, partner_variant = _build_partner_options(record)
supports_backgrounds = bool(record and record.supports_backgrounds)
background_options = _build_background_options() if supports_backgrounds else []
if not partner_enabled and not secondary and not background:
return None, None, [], None, None, None, None, False
if not supports_backgrounds:
background = ""
if not partner_options:
secondary = ""
if secondary and background:
return "Provide either a secondary commander or a background, not both.", None, [], auto_note, secondary, background, None, False
option_lookup = {opt["name"].casefold(): opt for opt in partner_options}
if secondary:
key = secondary.casefold()
if key not in option_lookup:
return "Selected partner is not valid for this commander.", None, [], auto_note, secondary, background or None, None, False
if background:
normalized_backgrounds = {opt["name"].casefold() for opt in background_options}
if background.casefold() not in normalized_backgrounds:
return "Selected background is not available.", None, [], auto_note, secondary or None, background, None, False
# Auto-assign Partner With targets
if not secondary and not background and not auto_opt_out and partner_variant == "partner" and record and record.partner_with:
target_names = [name.strip() for name in record.partner_with if str(name).strip()]
for target in target_names:
opt = option_lookup.get(target.casefold())
if opt:
secondary = opt["name"]
auto_note = f"Automatically paired with {secondary} (Partner With)."
auto_assigned = True
break
if not secondary and not background:
return None, None, [], auto_note, None, None, None, auto_assigned
builder = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True)
try:
combined = apply_partner_inputs(
builder,
primary_name=commander_name,
secondary_name=secondary or None,
background_name=background or None,
feature_enabled=True,
selection_source=selection_source_clean,
)
except CommanderPartnerError as exc:
message = str(exc) or "Invalid partner selection."
return message, None, [], auto_note, secondary or None, background or None, None, auto_assigned
except Exception as exc:
return f"Partner selection failed: {exc}", None, [], auto_note, secondary or None, background or None, None, auto_assigned
if combined is None:
return "Unable to resolve partner selection.", None, [], auto_note, secondary or None, background or None, None, auto_assigned
payload = _combined_to_payload(combined)
warnings = payload.get("warnings", []) or []
mode = payload.get("partner_mode")
if mode == "background":
resolved_background = payload.get("secondary_name")
return None, payload, warnings, auto_note, None, resolved_background, mode, auto_assigned
return None, payload, warnings, auto_note, payload.get("secondary_name"), None, mode, auto_assigned
@router.post("/partner/preview", response_class=JSONResponse)
async def build_partner_preview(
request: Request,
commander: str = Form(...),
partner_enabled: str | None = Form(None),
secondary_commander: str | None = Form(None),
background: str | None = Form(None),
partner_auto_opt_out: str | None = Form(None),
scope: str | None = Form(None),
selection_source: str | None = Form(None),
) -> JSONResponse:
"""
Preview a partner pairing and return combined commander details.
This endpoint validates partner selections and returns:
- Combined color identity and theme tags
- Partner preview payload with images and metadata
- Warnings about legality or capability mismatches
- Auto-pairing information for Partner With targets
Args:
request: FastAPI request
commander: Primary commander name
partner_enabled: Whether partner mechanics are enabled ("1"/"true"/etc.)
secondary_commander: Secondary partner commander name
background: Background card name (for Choose a Background commanders)
partner_auto_opt_out: Opt-out of auto-pairing for Partner With
scope: Request scope identifier
selection_source: Source of selection (e.g., "suggestion", "manual")
Returns:
JSONResponse with partner preview data and validation results
"""
partner_feature_enabled = ENABLE_PARTNER_MECHANICS
raw_partner_enabled = (partner_enabled or "").strip().lower()
partner_flag = partner_feature_enabled and raw_partner_enabled in {"1", "true", "on", "yes"}
auto_opt_out_flag = (partner_auto_opt_out or "").strip().lower() in {"1", "true", "on", "yes"}
selection_source_value = (selection_source or "").strip().lower() or None
try:
(
partner_error,
combined_payload,
partner_warnings,
partner_auto_note,
resolved_secondary,
resolved_background,
partner_mode,
partner_auto_assigned_flag,
) = _resolve_partner_selection(
commander,
feature_enabled=partner_feature_enabled,
partner_enabled=partner_flag,
secondary_candidate=secondary_commander,
background_candidate=background,
auto_opt_out=auto_opt_out_flag,
selection_source=selection_source_value,
)
except Exception as exc: # pragma: no cover - defensive
return JSONResponse(
{
"ok": False,
"error": f"Partner preview failed: {exc}",
"scope": scope or "",
}
)
partner_ctx = _partner_ui_context(
commander,
partner_enabled=partner_flag,
secondary_selection=resolved_secondary or secondary_commander,
background_selection=resolved_background or background,
combined_preview=combined_payload,
warnings=partner_warnings,
partner_error=partner_error,
auto_note=partner_auto_note,
auto_assigned=partner_auto_assigned_flag,
auto_prefill_allowed=not auto_opt_out_flag,
)
preview_payload = partner_ctx.get("partner_preview")
theme_tags = partner_ctx.get("partner_theme_tags") or []
warnings_list = partner_ctx.get("partner_warnings") or partner_warnings or []
response = {
"ok": True,
"scope": scope or "",
"preview": preview_payload,
"theme_tags": theme_tags,
"warnings": warnings_list,
"auto_note": partner_auto_note,
"resolved_secondary": resolved_secondary,
"resolved_background": resolved_background,
"partner_mode": partner_mode,
"auto_assigned": bool(partner_auto_assigned_flag),
}
if partner_error:
response["error"] = partner_error
try:
log_partner_suggestion_selected(
request,
commander=commander,
scope=scope,
partner_enabled=partner_flag,
auto_opt_out=auto_opt_out_flag,
auto_assigned=bool(partner_auto_assigned_flag),
selection_source=selection_source_value,
secondary_candidate=secondary_commander,
background_candidate=background,
resolved_secondary=resolved_secondary,
resolved_background=resolved_background,
partner_mode=partner_mode,
has_preview=bool(preview_payload),
warnings=warnings_list,
error=response.get("error"),
)
except Exception: # pragma: no cover - telemetry should not break responses
pass
return JSONResponse(response)

View file

@ -0,0 +1,205 @@
"""
Custom theme management routes for deck building.
Handles user-defined custom themes including adding, removing, choosing
suggestions, and switching between permissive/strict matching modes.
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Request, Form
from fastapi.responses import HTMLResponse
from ..app import (
ENABLE_CUSTOM_THEMES,
USER_THEME_LIMIT,
DEFAULT_THEME_MATCH_MODE,
_sanitize_theme,
templates,
)
from ..services.tasks import get_session, new_sid
from ..services import custom_theme_manager as theme_mgr
router = APIRouter()
_INVALID_THEME_MESSAGE = (
"Theme names can only include letters, numbers, spaces, hyphens, apostrophes, and underscores."
)
def _custom_theme_context(
request: Request,
sess: dict,
*,
message: str | None = None,
level: str = "info",
) -> dict[str, Any]:
"""
Assemble the Additional Themes section context for the modal.
Args:
request: FastAPI request object
sess: Session dictionary
message: Optional status message to display
level: Message level ("info", "success", "warning", "error")
Returns:
Context dictionary for rendering the additional themes template
"""
if not ENABLE_CUSTOM_THEMES:
return {
"request": request,
"theme_state": None,
"theme_message": message,
"theme_message_level": level,
"theme_limit": USER_THEME_LIMIT,
"enable_custom_themes": False,
}
theme_mgr.set_limit(sess, USER_THEME_LIMIT)
state = theme_mgr.get_view_state(sess, default_mode=DEFAULT_THEME_MATCH_MODE)
return {
"request": request,
"theme_state": state,
"theme_message": message,
"theme_message_level": level,
"theme_limit": USER_THEME_LIMIT,
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
}
@router.post("/themes/add", response_class=HTMLResponse)
async def build_theme_add(request: Request, theme: str = Form("")) -> HTMLResponse:
"""
Add a custom theme to the user's theme list.
Validates theme name format and enforces theme count limits.
Args:
request: FastAPI request object
theme: Theme name to add (will be trimmed and sanitized)
Returns:
HTMLResponse with updated themes list and status message
"""
if not ENABLE_CUSTOM_THEMES:
return HTMLResponse("", status_code=204)
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
trimmed = theme.strip()
sanitized = _sanitize_theme(trimmed) if trimmed else ""
if trimmed and not sanitized:
ctx = _custom_theme_context(request, sess, message=_INVALID_THEME_MESSAGE, level="error")
else:
value = sanitized if sanitized is not None else trimmed
_, message, level = theme_mgr.add_theme(
sess,
value,
commander_tags=list(sess.get("tags", [])),
mode=sess.get("theme_match_mode", DEFAULT_THEME_MATCH_MODE),
limit=USER_THEME_LIMIT,
)
ctx = _custom_theme_context(request, sess, message=message, level=level)
resp = templates.TemplateResponse("build/_new_deck_additional_themes.html", ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.post("/themes/remove", response_class=HTMLResponse)
async def build_theme_remove(request: Request, theme: str = Form("")) -> HTMLResponse:
"""
Remove a custom theme from the user's theme list.
Args:
request: FastAPI request object
theme: Theme name to remove
Returns:
HTMLResponse with updated themes list and status message
"""
if not ENABLE_CUSTOM_THEMES:
return HTMLResponse("", status_code=204)
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
value = _sanitize_theme(theme) or theme
_, message, level = theme_mgr.remove_theme(
sess,
value,
commander_tags=list(sess.get("tags", [])),
mode=sess.get("theme_match_mode", DEFAULT_THEME_MATCH_MODE),
)
ctx = _custom_theme_context(request, sess, message=message, level=level)
resp = templates.TemplateResponse("build/_new_deck_additional_themes.html", ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.post("/themes/choose", response_class=HTMLResponse)
async def build_theme_choose(
request: Request,
original: str = Form(""),
choice: str = Form(""),
) -> HTMLResponse:
"""
Replace an invalid theme with a suggested alternative.
When a user's custom theme doesn't perfectly match commander tags,
the system suggests alternatives. This route accepts the user's
choice from those suggestions.
Args:
request: FastAPI request object
original: The original (invalid) theme name
choice: The selected suggestion to use instead
Returns:
HTMLResponse with updated themes list and status message
"""
if not ENABLE_CUSTOM_THEMES:
return HTMLResponse("", status_code=204)
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
selection = _sanitize_theme(choice) or choice
_, message, level = theme_mgr.choose_suggestion(
sess,
original,
selection,
commander_tags=list(sess.get("tags", [])),
mode=sess.get("theme_match_mode", DEFAULT_THEME_MATCH_MODE),
)
ctx = _custom_theme_context(request, sess, message=message, level=level)
resp = templates.TemplateResponse("build/_new_deck_additional_themes.html", ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.post("/themes/mode", response_class=HTMLResponse)
async def build_theme_mode(request: Request, mode: str = Form("permissive")) -> HTMLResponse:
"""
Switch theme matching mode between permissive and strict.
- Permissive: Suggests alternatives for invalid themes
- Strict: Rejects invalid themes outright
Args:
request: FastAPI request object
mode: Either "permissive" or "strict"
Returns:
HTMLResponse with updated themes list and status message
"""
if not ENABLE_CUSTOM_THEMES:
return HTMLResponse("", status_code=204)
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
_, message, level = theme_mgr.set_mode(
sess,
mode,
commander_tags=list(sess.get("tags", [])),
)
ctx = _custom_theme_context(request, sess, message=message, level=level)
resp = templates.TemplateResponse("build/_new_deck_additional_themes.html", ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp

View file

@ -0,0 +1,379 @@
"""Validation endpoints for card name validation and include/exclude lists.
This module handles validation of card names and include/exclude lists for the deck builder,
including fuzzy matching, color identity validation, and limit enforcement.
"""
import os
from fastapi import APIRouter, Form, Request
from fastapi.responses import JSONResponse
from path_util import csv_dir as _csv_dir
router = APIRouter()
# Read configuration directly to avoid circular import with app.py
def _as_bool(val: str | bool | None, default: bool = False) -> bool:
"""Convert environment variable to boolean."""
if isinstance(val, bool):
return val
if val is None:
return default
s = str(val).strip().lower()
return s in ("1", "true", "yes", "on")
ALLOW_MUST_HAVES = _as_bool(os.getenv("ALLOW_MUST_HAVES"), True)
# Cache for available card names used by validation endpoints
_AVAILABLE_CARDS_CACHE: set[str] | None = None
_AVAILABLE_CARDS_NORM_SET: set[str] | None = None
_AVAILABLE_CARDS_NORM_MAP: dict[str, str] | None = None
def _available_cards() -> set[str]:
"""Fast load of available card names using the csv module (no pandas).
Reads only once and caches results in memory.
"""
global _AVAILABLE_CARDS_CACHE
if _AVAILABLE_CARDS_CACHE is not None:
return _AVAILABLE_CARDS_CACHE
try:
import csv
path = f"{_csv_dir()}/cards.csv"
with open(path, 'r', encoding='utf-8', newline='') as f:
reader = csv.DictReader(f)
fields = reader.fieldnames or []
name_col = None
for col in ['name', 'Name', 'card_name', 'CardName']:
if col in fields:
name_col = col
break
if name_col is None and fields:
# Heuristic: pick first field containing 'name'
for col in fields:
if 'name' in col.lower():
name_col = col
break
if name_col is None:
raise ValueError(f"No name-like column found in {path}: {fields}")
names: set[str] = set()
for row in reader:
try:
v = row.get(name_col)
if v:
names.add(str(v))
except Exception:
continue
_AVAILABLE_CARDS_CACHE = names
return _AVAILABLE_CARDS_CACHE
except Exception:
_AVAILABLE_CARDS_CACHE = set()
return _AVAILABLE_CARDS_CACHE
def _available_cards_normalized() -> tuple[set[str], dict[str, str]]:
"""Return cached normalized card names and mapping to originals."""
global _AVAILABLE_CARDS_NORM_SET, _AVAILABLE_CARDS_NORM_MAP
if _AVAILABLE_CARDS_NORM_SET is not None and _AVAILABLE_CARDS_NORM_MAP is not None:
return _AVAILABLE_CARDS_NORM_SET, _AVAILABLE_CARDS_NORM_MAP
# Build from available cards set
names = _available_cards()
try:
from code.deck_builder.include_exclude_utils import normalize_punctuation
except Exception:
# Fallback: identity normalization
def normalize_punctuation(x: str) -> str:
return str(x).strip().casefold()
norm_map: dict[str, str] = {}
for name in names:
try:
n = normalize_punctuation(name)
if n not in norm_map:
norm_map[n] = name
except Exception:
continue
_AVAILABLE_CARDS_NORM_MAP = norm_map
_AVAILABLE_CARDS_NORM_SET = set(norm_map.keys())
return _AVAILABLE_CARDS_NORM_SET, _AVAILABLE_CARDS_NORM_MAP
def warm_validation_name_cache() -> None:
"""Pre-populate the available-cards caches to avoid first-call latency."""
try:
_ = _available_cards()
_ = _available_cards_normalized()
except Exception:
# Best-effort warmup; proceed silently on failure
pass
@router.post("/validate/exclude_cards")
async def validate_exclude_cards(
request: Request,
exclude_cards: str = Form(default=""),
commander: str = Form(default="")
):
"""Legacy exclude cards validation endpoint - redirect to new unified endpoint."""
if not ALLOW_MUST_HAVES:
return JSONResponse({"error": "Feature not enabled"}, status_code=404)
# Call new unified endpoint
result = await validate_include_exclude_cards(
request=request,
include_cards="",
exclude_cards=exclude_cards,
commander=commander,
enforcement_mode="warn",
allow_illegal=False,
fuzzy_matching=True
)
# Transform to legacy format for backward compatibility
if hasattr(result, 'body'):
import json
data = json.loads(result.body)
if 'excludes' in data:
excludes = data['excludes']
return JSONResponse({
"count": excludes.get("count", 0),
"limit": excludes.get("limit", 15),
"over_limit": excludes.get("over_limit", False),
"cards": excludes.get("cards", []),
"duplicates": excludes.get("duplicates", {}),
"warnings": excludes.get("warnings", [])
})
return result
@router.post("/validate/include_exclude")
async def validate_include_exclude_cards(
request: Request,
include_cards: str = Form(default=""),
exclude_cards: str = Form(default=""),
commander: str = Form(default=""),
enforcement_mode: str = Form(default="warn"),
allow_illegal: bool = Form(default=False),
fuzzy_matching: bool = Form(default=True)
):
"""Validate include/exclude card lists with comprehensive diagnostics."""
if not ALLOW_MUST_HAVES:
return JSONResponse({"error": "Feature not enabled"}, status_code=404)
try:
from code.deck_builder.include_exclude_utils import (
parse_card_list_input, collapse_duplicates,
fuzzy_match_card_name, MAX_INCLUDES, MAX_EXCLUDES
)
from code.deck_builder.builder import DeckBuilder
# Parse inputs
include_list = parse_card_list_input(include_cards) if include_cards.strip() else []
exclude_list = parse_card_list_input(exclude_cards) if exclude_cards.strip() else []
# Collapse duplicates
include_unique, include_dupes = collapse_duplicates(include_list)
exclude_unique, exclude_dupes = collapse_duplicates(exclude_list)
# Initialize result structure
result = {
"includes": {
"count": len(include_unique),
"limit": MAX_INCLUDES,
"over_limit": len(include_unique) > MAX_INCLUDES,
"duplicates": include_dupes,
"cards": include_unique[:10] if len(include_unique) <= 10 else include_unique[:7] + ["..."],
"warnings": [],
"legal": [],
"illegal": [],
"color_mismatched": [],
"fuzzy_matches": {}
},
"excludes": {
"count": len(exclude_unique),
"limit": MAX_EXCLUDES,
"over_limit": len(exclude_unique) > MAX_EXCLUDES,
"duplicates": exclude_dupes,
"cards": exclude_unique[:10] if len(exclude_unique) <= 10 else exclude_unique[:7] + ["..."],
"warnings": [],
"legal": [],
"illegal": [],
"fuzzy_matches": {}
},
"conflicts": [], # Cards that appear in both lists
"confirmation_needed": [], # Cards needing fuzzy match confirmation
"overall_warnings": []
}
# Check for conflicts (cards in both lists)
conflicts = set(include_unique) & set(exclude_unique)
if conflicts:
result["conflicts"] = list(conflicts)
result["overall_warnings"].append(f"Cards appear in both lists: {', '.join(list(conflicts)[:3])}{'...' if len(conflicts) > 3 else ''}")
# Size warnings based on actual counts
if result["includes"]["over_limit"]:
result["includes"]["warnings"].append(f"Too many includes: {len(include_unique)}/{MAX_INCLUDES}")
elif len(include_unique) > MAX_INCLUDES * 0.8: # 80% capacity warning
result["includes"]["warnings"].append(f"Approaching limit: {len(include_unique)}/{MAX_INCLUDES}")
if result["excludes"]["over_limit"]:
result["excludes"]["warnings"].append(f"Too many excludes: {len(exclude_unique)}/{MAX_EXCLUDES}")
elif len(exclude_unique) > MAX_EXCLUDES * 0.8: # 80% capacity warning
result["excludes"]["warnings"].append(f"Approaching limit: {len(exclude_unique)}/{MAX_EXCLUDES}")
# If we have a commander, do advanced validation (color identity, etc.)
if commander and commander.strip():
try:
# Create a temporary builder
builder = DeckBuilder()
# Set up commander FIRST (before setup_dataframes)
df = builder.load_commander_data()
commander_rows = df[df["name"] == commander.strip()]
if not commander_rows.empty:
# Apply commander selection (this sets commander_row properly)
builder._apply_commander_selection(commander_rows.iloc[0])
# Now setup dataframes (this will use the commander info)
builder.setup_dataframes()
# Get available card names for fuzzy matching
name_col = 'name' if 'name' in builder._full_cards_df.columns else 'Name'
available_cards = set(builder._full_cards_df[name_col].tolist())
# Validate includes with fuzzy matching
for card_name in include_unique:
if fuzzy_matching:
match_result = fuzzy_match_card_name(card_name, available_cards)
if match_result.matched_name:
if match_result.auto_accepted:
result["includes"]["fuzzy_matches"][card_name] = match_result.matched_name
result["includes"]["legal"].append(match_result.matched_name)
else:
# Needs confirmation
result["confirmation_needed"].append({
"input": card_name,
"suggestions": match_result.suggestions,
"confidence": match_result.confidence,
"type": "include"
})
else:
result["includes"]["illegal"].append(card_name)
else:
# Exact match only
if card_name in available_cards:
result["includes"]["legal"].append(card_name)
else:
result["includes"]["illegal"].append(card_name)
# Validate excludes with fuzzy matching
for card_name in exclude_unique:
if fuzzy_matching:
match_result = fuzzy_match_card_name(card_name, available_cards)
if match_result.matched_name:
if match_result.auto_accepted:
result["excludes"]["fuzzy_matches"][card_name] = match_result.matched_name
result["excludes"]["legal"].append(match_result.matched_name)
else:
# Needs confirmation
result["confirmation_needed"].append({
"input": card_name,
"suggestions": match_result.suggestions,
"confidence": match_result.confidence,
"type": "exclude"
})
else:
result["excludes"]["illegal"].append(card_name)
else:
# Exact match only
if card_name in available_cards:
result["excludes"]["legal"].append(card_name)
else:
result["excludes"]["illegal"].append(card_name)
# Color identity validation for includes (only if we have a valid commander with colors)
commander_colors = getattr(builder, 'color_identity', [])
if commander_colors:
color_validated_includes = []
for card_name in result["includes"]["legal"]:
if builder._validate_card_color_identity(card_name):
color_validated_includes.append(card_name)
else:
# Add color-mismatched cards to illegal instead of separate category
result["includes"]["illegal"].append(card_name)
# Update legal includes to only those that pass color identity
result["includes"]["legal"] = color_validated_includes
except Exception as validation_error:
# Advanced validation failed, but return basic validation
result["overall_warnings"].append(f"Advanced validation unavailable: {str(validation_error)}")
else:
# No commander provided, do basic fuzzy matching only
if fuzzy_matching and (include_unique or exclude_unique):
try:
# Use cached available cards set (1st call populates cache)
available_cards = _available_cards()
# Fast path: normalized exact matches via cached sets
norm_set, norm_map = _available_cards_normalized()
# Validate includes with fuzzy matching
for card_name in include_unique:
from code.deck_builder.include_exclude_utils import normalize_punctuation
n = normalize_punctuation(card_name)
if n in norm_set:
result["includes"]["fuzzy_matches"][card_name] = norm_map[n]
result["includes"]["legal"].append(norm_map[n])
continue
match_result = fuzzy_match_card_name(card_name, available_cards)
if match_result.matched_name and match_result.auto_accepted:
# Exact or high-confidence match
result["includes"]["fuzzy_matches"][card_name] = match_result.matched_name
result["includes"]["legal"].append(match_result.matched_name)
elif not match_result.auto_accepted and match_result.suggestions:
# Needs confirmation - has suggestions but low confidence
result["confirmation_needed"].append({
"input": card_name,
"suggestions": match_result.suggestions,
"confidence": match_result.confidence,
"type": "include"
})
else:
# No match found at all, add to illegal
result["includes"]["illegal"].append(card_name)
# Validate excludes with fuzzy matching
for card_name in exclude_unique:
from code.deck_builder.include_exclude_utils import normalize_punctuation
n = normalize_punctuation(card_name)
if n in norm_set:
result["excludes"]["fuzzy_matches"][card_name] = norm_map[n]
result["excludes"]["legal"].append(norm_map[n])
continue
match_result = fuzzy_match_card_name(card_name, available_cards)
if match_result.matched_name:
if match_result.auto_accepted:
result["excludes"]["fuzzy_matches"][card_name] = match_result.matched_name
result["excludes"]["legal"].append(match_result.matched_name)
else:
# Needs confirmation
result["confirmation_needed"].append({
"input": card_name,
"suggestions": match_result.suggestions,
"confidence": match_result.confidence,
"type": "exclude"
})
else:
# No match found, add to illegal
result["excludes"]["illegal"].append(card_name)
except Exception as fuzzy_error:
result["overall_warnings"].append(f"Fuzzy matching unavailable: {str(fuzzy_error)}")
return JSONResponse(result)
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=400)

View file

@ -1137,7 +1137,7 @@
.then(function(r){ return r.text(); })
.then(function(html){ slot.innerHTML = html; })
.catch(function(){ slot.innerHTML = ''; });
}catch(_){ }
}catch(e){ }
}
// Listen for OOB updates to the tags slot to trigger fetch
document.body.addEventListener('htmx:afterSwap', function(ev){

View file

@ -4,7 +4,7 @@
<div class="muted" style="font-size:12px; margin-bottom:.35rem;">We detected a viable multi-copy archetype for your commander/themes. Choose one or skip.</div>
<div style="display:grid; gap:.5rem;">
{% for it in items %}
<label class="mc-option" style="display:grid; grid-template-columns: auto 1fr; gap:.5rem; align-items:flex-start; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0b0d12;">
<label class="mc-option" style="display:grid; grid-template-columns: auto 1fr; gap:.5rem; align-items:flex-start; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:var(--panel);">
<input type="radio" name="multi_choice_id" value="{{ it.id }}" {% if loop.first %}checked{% endif %} />
<div>
<div><strong>{{ it.name }}</strong> {% if it.printed_cap %}<span class="muted">(Cap: {{ it.printed_cap }})</span>{% endif %}</div>

View file

@ -0,0 +1 @@
"""Utility modules for the web application."""

158
code/web/utils/responses.py Normal file
View file

@ -0,0 +1,158 @@
"""Response builder utilities for standardized HTTP responses.
Provides helper functions for creating consistent response objects across all routes.
"""
from typing import Any, Dict, Optional
from fastapi import Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
def build_error_response(
request: Request,
status_code: int,
error_type: str,
message: str,
detail: Optional[str] = None,
fields: Optional[Dict[str, list[str]]] = None
) -> JSONResponse:
"""Build a standardized error response.
Args:
request: FastAPI request object
status_code: HTTP status code
error_type: Type of error (e.g., "ValidationError", "NotFoundError")
message: User-friendly error message
detail: Additional error detail
fields: Field-level validation errors
Returns:
JSONResponse with standardized error structure
"""
import time
request_id = getattr(request.state, "request_id", "unknown")
error_data = {
"status": status_code,
"error": error_type,
"message": message,
"path": str(request.url.path),
"request_id": request_id,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
if detail:
error_data["detail"] = detail
if fields:
error_data["fields"] = fields
return JSONResponse(content=error_data, status_code=status_code)
def build_success_response(
data: Any,
status_code: int = 200,
headers: Optional[Dict[str, str]] = None
) -> JSONResponse:
"""Build a standardized success response.
Args:
data: Response data to return
status_code: HTTP status code (default 200)
headers: Optional additional headers
Returns:
JSONResponse with data
"""
response = JSONResponse(content=data, status_code=status_code)
if headers:
for key, value in headers.items():
response.headers[key] = value
return response
def build_template_response(
request: Request,
templates: Jinja2Templates,
template_name: str,
context: Dict[str, Any],
status_code: int = 200
) -> HTMLResponse:
"""Build a standardized template response.
Args:
request: FastAPI request object
templates: Jinja2Templates instance
template_name: Name of template to render
context: Template context dictionary
status_code: HTTP status code (default 200)
Returns:
HTMLResponse with rendered template
"""
# Ensure request is in context
if "request" not in context:
context["request"] = request
return templates.TemplateResponse(
request,
template_name,
context,
status_code=status_code
)
def build_htmx_response(
content: str,
trigger: Optional[Dict[str, Any]] = None,
retarget: Optional[str] = None,
reswap: Optional[str] = None
) -> HTMLResponse:
"""Build an HTMX partial response with appropriate headers.
Args:
content: HTML content to return
trigger: HTMX trigger events to fire
retarget: Optional HX-Retarget header
reswap: Optional HX-Reswap header
Returns:
HTMLResponse with HTMX headers
"""
import json
response = HTMLResponse(content=content)
if trigger:
response.headers["HX-Trigger"] = json.dumps(trigger)
if retarget:
response.headers["HX-Retarget"] = retarget
if reswap:
response.headers["HX-Reswap"] = reswap
return response
def merge_hx_trigger(response: HTMLResponse, events: Dict[str, Any]) -> None:
"""Merge additional HTMX trigger events into an existing response.
Args:
response: Existing HTMLResponse
events: Additional trigger events to merge
"""
import json
if not events:
return
existing = response.headers.get("HX-Trigger")
if existing:
try:
existing_events = json.loads(existing)
existing_events.update(events)
response.headers["HX-Trigger"] = json.dumps(existing_events)
except (json.JSONDecodeError, AttributeError):
# If existing is a simple string, convert to dict
response.headers["HX-Trigger"] = json.dumps(events)
else:
response.headers["HX-Trigger"] = json.dumps(events)

View file

@ -24359,7 +24359,7 @@
"generated_from": "merge (analytics + curated YAML + whitelist)",
"metadata_info": {
"mode": "merge",
"generated_at": "2026-02-20T11:11:25",
"generated_at": "2026-02-20T17:27:58",
"curated_yaml_files": 740,
"synergy_cap": 5,
"inference": "pmi",

View file

@ -0,0 +1,206 @@
# Build.py Splitting Strategy
**Status**: Planning (R9 M1)
**Created**: 2026-02-20
## Current State
[code/web/routes/build.py](../../code/web/routes/build.py) is **5,740 lines** with 40+ route endpoints.
## Analysis of Route Groups
Based on route path analysis, the file can be split into these logical modules:
### 1. **Validation Routes** (~200 lines)
- `/build/validate/card` - Card name validation
- `/build/validate/cards` - Bulk card validation
- `/build/validate/commander` - Commander validation
- Utility functions: `_available_cards()`, `warm_validation_name_cache()`
**New module**: `code/web/routes/build_validation.py`
### 2. **Include/Exclude Routes** (~300 lines)
- `/build/must-haves/toggle` - Toggle include/exclude feature
- Include/exclude card management
- Related utilities and form handlers
**New module**: `code/web/routes/build_include_exclude.py`
### 3. **Partner/Background Routes** (~400 lines)
- `/build/partner/preview` - Partner commander preview
- `/build/partner/*` - Partner selection flows
- Background commander handling
**New module**: `code/web/routes/build_partners.py`
### 4. **Multi-copy Routes** (~300 lines)
- `/build/multicopy/check` - Multi-copy detection
- `/build/multicopy/save` - Save multi-copy preferences
- `/build/new/multicopy` - Multi-copy wizard step
**New module**: `code/web/routes/build_multicopy.py`
### 5. **Theme Management Routes** (~400 lines)
- `/build/themes/add` - Add theme
- `/build/themes/remove` - Remove theme
- `/build/themes/choose` - Choose themes
- `/build/themes/mode` - Theme matching mode
**New module**: `code/web/routes/build_themes.py`
### 6. **Step-based Wizard Routes** (~1,500 lines)
- `/build/step1` - Commander selection (GET/POST)
- `/build/step2` - Theme selection
- `/build/step3` - Ideals configuration
- `/build/step4` - Owned cards
- `/build/step5` - Final build
- `/build/step*/*` - Related step handlers
**New module**: `code/web/routes/build_wizard.py`
### 7. **New Build Routes** (~1,200 lines)
- `/build/new` - Start new build (GET/POST)
- `/build/new/candidates` - Commander candidates
- `/build/new/inspect` - Inspect commander
- `/build/new/toggle-skip` - Skip wizard steps
- Single-page build flow (non-wizard)
**New module**: `code/web/routes/build_new.py`
### 8. **Permalink/Lock Routes** (~400 lines)
- `/build/permalink` - Generate permalink
- `/build/from` - Restore from permalink
- `/build/locks/*` - Card lock management
- State serialization/deserialization
**New module**: `code/web/routes/build_permalinks.py`
### 9. **Deck List Routes** (~300 lines)
- `/build/view/*` - View completed decks
- `/build/list` - List saved decks
- Deck export and display
**New module**: `code/web/routes/build_decks.py`
### 10. **Shared Utilities** (~300 lines)
- Common helper functions
- Response builders (migrate to `utils/responses.py`)
- Session utilities (migrate to `services/`)
**New module**: `code/web/routes/build_utils.py` (temporary, will merge into services)
## Migration Strategy
### Phase 1: Extract Validation (Low Risk)
1. Create `build_validation.py`
2. Move validation routes and utilities
3. Test validation endpoints
4. Update imports in main build.py
### Phase 2: Extract Simple Modules (Low-Medium Risk)
1. Multi-copy routes → `build_multicopy.py`
2. Include/Exclude routes → `build_include_exclude.py`
3. Theme routes → `build_themes.py`
4. Partner routes → `build_partners.py`
### Phase 3: Extract Complex Wizard (Medium Risk)
1. Step-based wizard → `build_wizard.py`
2. Preserve session management carefully
3. Extensive testing required
### Phase 4: Extract New Build Flow (Medium-High Risk)
1. Single-page build → `build_new.py`
2. Test all build flows thoroughly
### Phase 5: Extract Permalinks and Decks (Low Risk)
1. Permalink/Lock routes → `build_permalinks.py`
2. Deck list routes → `build_decks.py`
### Phase 6: Cleanup (Low Risk)
1. Move utilities to proper locations
2. Remove `build_utils.py`
3. Update all imports
4. Final testing
## Import Strategy
Each new module will have a router that gets included in the main build router:
```python
# code/web/routes/build.py (main file, reduced to ~500 lines)
from fastapi import APIRouter
from . import (
build_validation,
build_include_exclude,
build_partners,
build_multicopy,
build_themes,
build_wizard,
build_new,
build_permalinks,
build_decks,
)
router = APIRouter(prefix="/build", tags=["build"])
# Include sub-routers
router.include_router(build_validation.router)
router.include_router(build_include_exclude.router)
router.include_router(build_partners.router)
router.include_router(build_multicopy.router)
router.include_router(build_themes.router)
router.include_router(build_wizard.router)
router.include_router(build_new.router)
router.include_router(build_permalinks.router)
router.include_router(build_decks.router)
```
## Testing Plan
For each module extracted:
1. Run existing test suite
2. Manual testing of affected routes
3. Integration tests for cross-module interactions
4. Smoke test full build flow (wizard + single-page)
## Risks
**High Risk:**
- Breaking session state management across modules
- Import circular dependencies
- Lost functionality in split
**Mitigations:**
- Extract one module at a time
- Full test suite after each module
- Careful session/state handling
- Keep shared utilities accessible
**Medium Risk:**
- Performance regression from additional imports
- HTMX/template path issues
**Mitigations:**
- Profile before/after
- Update template paths carefully
- Test HTMX partials thoroughly
## Success Criteria
- [ ] All 9 modules created and tested
- [ ] Main build.py reduced to <500 lines
- [ ] All tests passing
- [ ] No functionality lost
- [ ] Documentation updated
- [ ] Import structure clean
---
**Next Steps:**
1. Start with Phase 1 (Validation routes - low risk)
2. Create `build_validation.py`
3. Test thoroughly
4. Proceed to Phase 2
**Last Updated**: 2026-02-20
**Roadmap**: R9 M1 - Route Handler Standardization

View file

@ -0,0 +1,448 @@
# Route Handler Patterns
**Status**: ✅ Active Standard (R9 M1)
**Last Updated**: 2026-02-20
This document defines the standard patterns for FastAPI route handlers in the MTG Deckbuilder web application.
## Table of Contents
- [Overview](#overview)
- [Standard Route Pattern](#standard-route-pattern)
- [Decorators](#decorators)
- [Request Handling](#request-handling)
- [Response Building](#response-building)
- [Error Handling](#error-handling)
- [Examples](#examples)
## Overview
All route handlers should follow these principles:
- **Consistency**: Use standard patterns for request/response handling
- **Clarity**: Clear separation between validation, business logic, and response building
- **Observability**: Proper logging and telemetry
- **Error Handling**: Use custom exceptions, not HTTPException directly
- **Type Safety**: Full type hints for all parameters and return types
## Standard Route Pattern
```python
from fastapi import APIRouter, Request, Query, Form
from fastapi.responses import HTMLResponse, JSONResponse
from ..decorators.telemetry import track_route_access, log_route_errors
from ..utils.responses import build_template_response, build_error_response
from exceptions import ValidationError, NotFoundError # From code/exceptions.py
router = APIRouter()
@router.get("/endpoint", response_class=HTMLResponse)
@track_route_access("event_name") # Optional: for telemetry
@log_route_errors("route_name") # Optional: for error logging
async def endpoint_handler(
request: Request,
param: str = Query(..., description="Parameter description"),
) -> HTMLResponse:
"""
Brief description of what this endpoint does.
Args:
request: FastAPI request object
param: Query parameter description
Returns:
HTMLResponse with rendered template
Raises:
ValidationError: When parameter validation fails
NotFoundError: When resource is not found
"""
try:
# 1. Validate inputs
if not param:
raise ValidationError("parameter_required", details={"param": "required"})
# 2. Call service layer (business logic)
from ..services.your_service import process_request
result = await process_request(param)
if not result:
raise NotFoundError("resource_not_found", details={"param": param})
# 3. Build and return response
from ..app import templates
context = {
"result": result,
"param": param,
}
return build_template_response(
request, templates, "path/template.html", context
)
except (ValidationError, NotFoundError):
# Let custom exception handlers in app.py handle these
raise
except Exception as e:
# Log unexpected errors and re-raise
LOGGER.error(f"Unexpected error in endpoint_handler: {e}", exc_info=True)
raise
```
## Decorators
### Telemetry Decorators
Located in [code/web/decorators/telemetry.py](../../code/web/decorators/telemetry.py):
```python
from ..decorators.telemetry import (
track_route_access, # Track route access
track_build_time, # Track operation timing
log_route_errors, # Enhanced error logging
)
@router.get("/build/step1")
@track_route_access("build_step1_access")
@log_route_errors("build_step1")
async def step1_handler(request: Request):
# Route implementation
...
```
**When to use:**
- `@track_route_access`: For all user-facing routes (telemetry)
- `@track_build_time`: For deck building operations (performance monitoring)
- `@log_route_errors`: For routes with complex error handling
### Decorator Ordering
Order matters! Apply decorators from bottom to top:
```python
@router.get("/endpoint") # 1. Router decorator (bottom)
@track_route_access("event") # 2. Telemetry (before error handler)
@log_route_errors("route") # 3. Error logging (top)
async def handler(...):
...
```
## Request Handling
### Query Parameters
```python
from fastapi import Query
@router.get("/search")
async def search_cards(
request: Request,
query: str = Query(..., min_length=1, max_length=100, description="Search query"),
limit: int = Query(20, ge=1, le=100, description="Results limit"),
) -> JSONResponse:
...
```
### Form Data
```python
from fastapi import Form
@router.post("/build/create")
async def create_deck(
request: Request,
commander: str = Form(..., description="Commander name"),
themes: list[str] = Form(default=[], description="Theme tags"),
) -> HTMLResponse:
...
```
### JSON Body (Pydantic Models)
```python
from pydantic import BaseModel, Field
class BuildRequest(BaseModel):
"""Build request validation model."""
commander: str = Field(..., min_length=1, max_length=200)
themes: list[str] = Field(default_factory=list, max_items=5)
power_bracket: int = Field(default=2, ge=1, le=4)
@router.post("/api/build")
async def api_build_deck(
request: Request,
build_req: BuildRequest,
) -> JSONResponse:
# build_req is automatically validated
...
```
### Session Data
```python
from ..services.tasks import get_session, set_session_value
@router.post("/step2")
async def step2_handler(request: Request):
sid = request.cookies.get("sid")
if not sid:
raise ValidationError("session_required")
session = get_session(sid)
commander = session.get("commander")
# Update session
set_session_value(sid, "step", "2")
...
```
## Response Building
### Template Responses
Use `build_template_response` from [code/web/utils/responses.py](../../code/web/utils/responses.py):
```python
from ..utils.responses import build_template_response
from ..app import templates
context = {
"title": "Page Title",
"data": result_data,
}
return build_template_response(
request, templates, "path/template.html", context
)
```
### JSON Responses
```python
from ..utils.responses import build_success_response
data = {
"commander": "Atraxa, Praetors' Voice",
"themes": ["Proliferate", "Superfriends"],
}
return build_success_response(data, status_code=200)
```
### HTMX Partial Responses
```python
from ..utils.responses import build_htmx_response
html_content = templates.get_template("partials/result.html").render(context)
return build_htmx_response(
content=html_content,
trigger={"deckUpdated": {"commander": "Atraxa"}},
retarget="#result-container",
)
```
### Error Responses
```python
from ..utils.responses import build_error_response
# Manual error response (prefer raising custom exceptions instead)
return build_error_response(
request,
status_code=400,
error_type="ValidationError",
message="Invalid commander name",
detail="Commander 'Foo' does not exist",
fields={"commander": ["Commander 'Foo' does not exist"]}
)
```
## Error Handling
### Use Custom Exceptions
**Always use custom exceptions** from [code/exceptions.py](../../code/exceptions.py), not `HTTPException`:
```python
from exceptions import (
ValidationError,
NotFoundError,
CommanderValidationError,
ThemeError,
)
# ❌ DON'T DO THIS
from fastapi import HTTPException
raise HTTPException(status_code=400, detail="Invalid input")
# ✅ DO THIS INSTEAD
raise ValidationError("Invalid input", code="VALIDATION_ERR", details={"field": "value"})
```
### Exception Hierarchy
See [code/exceptions.py](../../code/exceptions.py) for the full hierarchy. Common exceptions:
- `DeckBuilderError` - Base class for all custom exceptions
- `MTGSetupError` - Setup-related errors
- `CSVError` - Data loading errors
- `CommanderValidationError` - Commander validation failures
- `CommanderTypeError`, `CommanderColorError`, etc.
- `ThemeError` - Theme-related errors
- `PriceError` - Price checking errors
- `LibraryOrganizationError` - Deck organization errors
### Let Exception Handlers Handle It
The app.py exception handlers will convert custom exceptions to HTTP responses:
```python
@router.get("/commander/{name}")
async def get_commander(request: Request, name: str):
# Validate
if not name:
raise ValidationError("Commander name required", code="CMD_NAME_REQUIRED")
# Business logic
try:
commander = await load_commander(name)
except CommanderNotFoundError as e:
# Re-raise to let global handler convert to 404
raise
# Return success
return build_success_response({"commander": commander})
```
## Examples
### Example 1: Simple GET with Template Response
```python
from fastapi import Request
from fastapi.responses import HTMLResponse
from ..utils.responses import build_template_response
from ..decorators.telemetry import track_route_access
from ..app import templates
@router.get("/commanders", response_class=HTMLResponse)
@track_route_access("commanders_list_view")
async def list_commanders(request: Request) -> HTMLResponse:
"""Display the commanders catalog page."""
from ..services.commander_catalog_loader import load_commander_catalog
catalog = load_commander_catalog()
context = {"commanders": catalog.commanders}
return build_template_response(
request, templates, "commanders/list.html", context
)
```
### Example 2: POST with Form Data and Session
```python
from fastapi import Request, Form
from fastapi.responses import HTMLResponse
from ..utils.responses import build_template_response, build_htmx_response
from ..services.tasks import get_session, set_session_value
from exceptions import CommanderValidationError
@router.post("/build/select_commander", response_class=HTMLResponse)
async def select_commander(
request: Request,
commander: str = Form(..., description="Selected commander name"),
) -> HTMLResponse:
"""Handle commander selection in deck builder wizard."""
# Validate commander
if not commander or len(commander) > 200:
raise CommanderValidationError(
f"Invalid commander name: {commander}",
code="CMD_INVALID",
details={"name": commander}
)
# Store in session
sid = request.cookies.get("sid")
if sid:
set_session_value(sid, "commander", commander)
# Return HTMX partial
from ..app import templates
context = {"commander": commander, "step": "themes"}
html = templates.get_template("build/step2_themes.html").render(context)
return build_htmx_response(
content=html,
trigger={"commanderSelected": {"name": commander}},
)
```
### Example 3: API Endpoint with JSON Response
```python
from fastapi import Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from ..utils.responses import build_success_response
from exceptions import ThemeError
class ThemeSearchRequest(BaseModel):
"""Theme search request model."""
query: str = Field(..., min_length=1, max_length=100)
limit: int = Field(default=10, ge=1, le=50)
@router.post("/api/themes/search")
async def search_themes(
request: Request,
search: ThemeSearchRequest,
) -> JSONResponse:
"""API endpoint to search for themes."""
from ..services.theme_catalog_loader import search_themes as _search
results = _search(search.query, limit=search.limit)
if not results:
raise ThemeError(
f"No themes found matching '{search.query}'",
code="THEME_NOT_FOUND",
details={"query": search.query}
)
return build_success_response({
"query": search.query,
"count": len(results),
"themes": [{"id": t.id, "name": t.name} for t in results],
})
```
## Migration Guide
### For Existing Routes
When updating existing routes to follow this pattern:
1. **Add type hints** if missing
2. **Replace HTTPException** with custom exceptions
3. **Use response builders** instead of direct Response construction
4. **Add telemetry decorators** where appropriate
5. **Add docstrings** following the standard format
6. **Separate concerns**: validation → business logic → response
### Checklist
- [ ] Route has full type hints
- [ ] Uses custom exceptions (not HTTPException)
- [ ] Uses response builder utilities
- [ ] Has telemetry decorators (if applicable)
- [ ] Has complete docstring
- [ ] Separates validation, logic, and response
- [ ] Handles errors gracefully
---
**Related Documentation:**
- [Service Layer Architecture](./service_architecture.md) (M2)
- [Validation Framework](./validation.md) (M3)
- [Error Handling Guide](./error_handling.md) (M4)
- [Testing Standards](./testing.md) (M5)
**Last Updated**: 2026-02-20
**Roadmap**: R9 M1 - Route Handler Standardization