mtg_python_deckbuilder/code/web/routes/partner_suggestions.py

160 lines
6 KiB
Python

from __future__ import annotations
from typing import Iterable, List, Optional
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import JSONResponse
from deck_builder.combined_commander import PartnerMode
from ..app import ENABLE_PARTNER_MECHANICS, ENABLE_PARTNER_SUGGESTIONS
from ..services.partner_suggestions import get_partner_suggestions
from ..services.telemetry import log_partner_suggestions_generated
router = APIRouter(prefix="/api/partner", tags=["partner suggestions"])
def _parse_modes(values: Optional[Iterable[str]]) -> list[PartnerMode]:
if not values:
return []
modes: list[PartnerMode] = []
seen: set[str] = set()
for value in values:
if not value:
continue
normalized = str(value).strip().replace("-", "_").lower()
if not normalized or normalized in seen:
continue
seen.add(normalized)
for mode in PartnerMode:
if mode.value == normalized:
modes.append(mode)
break
return modes
def _coerce_name_list(values: Optional[Iterable[str]]) -> list[str]:
if not values:
return []
out: list[str] = []
seen: set[str] = set()
for value in values:
if value is None:
continue
text = str(value).strip()
if not text:
continue
key = text.casefold()
if key in seen:
continue
seen.add(key)
out.append(text)
return out
@router.get("/suggestions")
async def partner_suggestions_api(
request: Request,
commander: str = Query(..., min_length=1, description="Primary commander display name"),
limit: int = Query(5, ge=1, le=20, description="Maximum suggestions per partner mode"),
visible_limit: int = Query(3, ge=0, le=10, description="Number of suggestions to mark as visible"),
include_hidden: bool = Query(False, description="When true, include hidden suggestions in the response"),
partner: Optional[List[str]] = Query(None, description="Available partner commander names"),
background: Optional[List[str]] = Query(None, description="Available background names"),
mode: Optional[List[str]] = Query(None, description="Restrict results to specific partner modes"),
refresh: bool = Query(False, description="When true, force a dataset refresh before scoring"),
):
if not (ENABLE_PARTNER_MECHANICS and ENABLE_PARTNER_SUGGESTIONS):
raise HTTPException(status_code=404, detail="Partner suggestions are disabled")
commander_name = (commander or "").strip()
if not commander_name:
raise HTTPException(status_code=400, detail="Commander name is required")
include_modes = _parse_modes(mode)
result = get_partner_suggestions(
commander_name,
limit_per_mode=limit,
include_modes=include_modes or None,
refresh_dataset=refresh,
)
if result is None:
raise HTTPException(status_code=503, detail="Partner suggestion dataset is unavailable")
partner_names = _coerce_name_list(partner)
background_names = _coerce_name_list(background)
# If the client didn't provide select options, fall back to the suggestions themselves.
if not partner_names:
for key, entries in result.by_mode.items():
if key == PartnerMode.BACKGROUND.value:
continue
for entry in entries:
if not isinstance(entry, dict):
continue
name_value = entry.get("name")
if isinstance(name_value, str) and name_value.strip():
partner_names.append(name_value)
if not background_names:
background_entries = result.by_mode.get(PartnerMode.BACKGROUND.value, [])
for entry in background_entries:
if not isinstance(entry, dict):
continue
name_value = entry.get("name")
if isinstance(name_value, str) and name_value.strip():
background_names.append(name_value)
partner_names = _coerce_name_list(partner_names)
background_names = _coerce_name_list(background_names)
visible, hidden = result.flatten(partner_names, background_names, visible_limit=visible_limit)
visible_count = len(visible)
hidden_count = len(hidden)
if include_hidden:
combined_visible = visible + hidden
remaining = []
else:
combined_visible = visible
remaining = hidden
payload = {
"commander": {
"display_name": result.display_name,
"canonical": result.canonical,
},
"metadata": result.metadata,
"modes": result.by_mode,
"visible": combined_visible,
"hidden": remaining,
"total": result.total,
"limit": {
"per_mode": limit,
"visible": visible_limit,
},
"available_modes": [mode_key for mode_key, entries in result.by_mode.items() if entries],
"has_hidden": bool(remaining),
}
headers = {"Cache-Control": "no-store"}
try:
mode_counts = {mode_key: len(entries) for mode_key, entries in result.by_mode.items()}
available_modes = [mode_key for mode_key, count in mode_counts.items() if count]
log_partner_suggestions_generated(
request,
commander_display=result.display_name,
commander_canonical=result.canonical,
include_modes=[mode.value for mode in include_modes] if include_modes else [],
available_modes=available_modes,
total=result.total,
mode_counts=mode_counts,
visible_count=visible_count,
hidden_count=hidden_count,
limit_per_mode=limit,
visible_limit=visible_limit,
include_hidden=include_hidden,
refresh_requested=refresh,
dataset_metadata=result.metadata,
)
except Exception: # pragma: no cover - telemetry should not break responses
pass
return JSONResponse(payload, headers=headers)