mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2026-03-18 11:16:30 +01:00
- 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
216 lines
7.4 KiB
Python
216 lines
7.4 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
|
|
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
|