mtg_python_deckbuilder/code/web/routes/build_include_exclude.py
mwisnowski 1aa8e4d7e8
Some checks failed
CI / build (push) Has been cancelled
feat: revamp multicopy flow with include/exclude conflict dialogs (#60)
* feat: revamp multicopy flow with include/exclude conflict dialogs

* feat: revamp multicopy flow with include/exclude conflict dialogs
2026-03-21 19:39:51 -07:00

368 lines
14 KiB
Python

"""
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, Query
from fastapi.responses import HTMLResponse, JSONResponse
from deck_builder import builder_constants as bc
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 _is_multi_copy_archetype(card_name: str) -> dict | None:
"""Return archetype dict if card_name exactly matches a known multi-copy archetype, else None."""
normalized = card_name.strip().lower()
for archetype in bc.MULTI_COPY_ARCHETYPES.values():
if str(archetype.get("name", "")).strip().lower() == normalized:
return archetype
return None
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)
# R13: Multi-copy archetype conflict detection
if enabled_flag and ALLOW_MUST_HAVES:
archetype = _is_multi_copy_archetype(name)
if list_key == "include" and archetype:
ctx = {"request": request, "archetype": archetype, "card_name": name}
resp = templates.TemplateResponse("partials/multicopy_include_dialog.html", ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
if list_key == "exclude":
active_mc = sess.get("multi_copy") or {}
if active_mc and str(active_mc.get("name", "")).strip().lower() == name.lower():
ctx = {"request": request, "archetype": active_mc, "card_name": name}
resp = templates.TemplateResponse("partials/multicopy_exclude_warning.html", ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
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
@router.get("/must-haves/summary", response_class=HTMLResponse)
async def must_haves_summary(request: Request) -> HTMLResponse:
"""Return the current include/exclude summary fragment (used by dialog cancel actions)."""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
return _render_include_exclude_summary(request, sess, sid)
@router.get("/must-haves/multicopy-dialog", response_class=HTMLResponse)
async def multicopy_include_dialog_view(
request: Request,
card_name: str = Query(...),
) -> HTMLResponse:
"""Return the count-picker dialog fragment for a multi-copy archetype card."""
archetype = _is_multi_copy_archetype(card_name)
if not archetype:
return HTMLResponse("", status_code=200)
ctx = {"request": request, "archetype": archetype, "card_name": card_name}
return templates.TemplateResponse("partials/multicopy_include_dialog.html", ctx)
@router.get("/must-haves/exclude-archetype-warning", response_class=HTMLResponse)
async def exclude_archetype_warning_view(
request: Request,
card_name: str = Query(...),
) -> HTMLResponse:
"""Return the warning dialog fragment for excluding the currently-active archetype."""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
active_mc = sess.get("multi_copy") or {}
ctx = {"request": request, "archetype": active_mc, "card_name": card_name}
return templates.TemplateResponse("partials/multicopy_exclude_warning.html", ctx)
@router.post("/must-haves/save-archetype-include", response_class=HTMLResponse)
async def save_archetype_include(
request: Request,
card_name: str = Form(...),
choice_id: str = Form(...),
count: int = Form(None),
thrumming: str | None = Form(None),
) -> HTMLResponse:
"""Save multi-copy archetype selection and add card to the must-include list.
Combines the archetype save (multicopy/save) and include toggle into a single
action, called from the multicopy_include_dialog when the user confirms.
"""
if not ALLOW_MUST_HAVES:
return JSONResponse({"error": "Must-have lists are disabled"}, status_code=403)
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
# Persist archetype selection (mirrors multicopy/save logic)
meta = bc.MULTI_COPY_ARCHETYPES.get(str(choice_id), {})
archetype_name = meta.get("name") or card_name.strip()
if count is None:
count = int(meta.get("default_count", 25))
try:
count = int(count)
except Exception:
count = int(meta.get("default_count", 25))
printed_cap = meta.get("printed_cap")
if isinstance(printed_cap, int) and printed_cap > 0:
count = max(1, min(printed_cap, count))
sess["multi_copy"] = {
"id": str(choice_id),
"name": archetype_name,
"count": count,
"thrumming": str(thrumming or "").strip().lower() in {"1", "true", "on", "yes"},
}
try:
if "mc_applied_key" in sess:
del sess["mc_applied_key"]
except Exception:
pass
# Add card to include list
name = card_name.strip()
key = name.lower()
includes = list(sess.get("include_cards") or [])
include_lookup = {str(v).strip().lower(): str(v) for v in includes}
if key not in include_lookup:
includes.append(name)
# Remove from excludes if present
excludes = [c for c in (sess.get("exclude_cards") or []) if str(c).strip().lower() != key]
sess["include_cards"] = includes
sess["exclude_cards"] = excludes
return _render_include_exclude_summary(request, sess, sid)
@router.post("/must-haves/clear-archetype", response_class=HTMLResponse)
async def clear_archetype(
request: Request,
card_name: str | None = Form(None),
) -> HTMLResponse:
"""Clear the active multi-copy archetype and optionally add card to the exclude list.
Called when user confirms the exclude-archetype-warning dialog.
"""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
for k in ("multi_copy", "mc_applied_key", "mc_seen_keys"):
try:
if k in sess:
del sess[k]
except Exception:
pass
if card_name:
name = str(card_name).strip()
key = name.lower()
excludes = list(sess.get("exclude_cards") or [])
exclude_lookup = {str(v).strip().lower(): str(v) for v in excludes}
if key not in exclude_lookup:
excludes.append(name)
# Remove from includes if present
includes = [c for c in (sess.get("include_cards") or []) if str(c).strip().lower() != key]
sess["include_cards"] = includes
sess["exclude_cards"] = excludes
return _render_include_exclude_summary(request, sess, sid)