mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 23:50:12 +01:00
feat: optimize must-have controls and commander catalog
This commit is contained in:
parent
b7bfc4ca09
commit
3877890889
23 changed files with 1150 additions and 87 deletions
|
|
@ -3,9 +3,11 @@ from __future__ import annotations
|
|||
from fastapi import APIRouter, Request, Form, Query
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from typing import Any, Iterable
|
||||
import json
|
||||
from ..app import (
|
||||
ALLOW_MUST_HAVES,
|
||||
ENABLE_CUSTOM_THEMES,
|
||||
SHOW_MUST_HAVE_BUTTONS,
|
||||
USER_THEME_LIMIT,
|
||||
DEFAULT_THEME_MATCH_MODE,
|
||||
_sanitize_theme,
|
||||
|
|
@ -13,6 +15,7 @@ from ..app import (
|
|||
ENABLE_PARTNER_SUGGESTIONS,
|
||||
)
|
||||
from ..services.build_utils import (
|
||||
step5_base_ctx,
|
||||
step5_ctx_from_result,
|
||||
step5_error_ctx,
|
||||
step5_empty_ctx,
|
||||
|
|
@ -37,6 +40,7 @@ from ..services.alts_utils import get_cached as _alts_get_cached, set_cached as
|
|||
from ..services.telemetry import (
|
||||
log_commander_create_deck,
|
||||
log_partner_suggestion_selected,
|
||||
log_include_exclude_toggle,
|
||||
)
|
||||
from ..services.partner_suggestions import get_partner_suggestions
|
||||
from urllib.parse import urlparse, quote_plus
|
||||
|
|
@ -684,6 +688,129 @@ def _resolve_partner_selection(
|
|||
|
||||
router = APIRouter(prefix="/build")
|
||||
|
||||
|
||||
@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"),
|
||||
):
|
||||
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]:
|
||||
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
|
||||
|
||||
must_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)),
|
||||
}
|
||||
|
||||
ctx = step5_base_ctx(request, sess, include_name=False, include_locks=False)
|
||||
ctx["must_have_state"] = must_state
|
||||
ctx["summary"] = None
|
||||
response = templates.TemplateResponse("partials/include_exclude_summary.html", ctx)
|
||||
response.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
|
||||
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:
|
||||
response.headers["HX-Trigger"] = json.dumps({"must-haves:toggle": trigger_payload})
|
||||
except Exception:
|
||||
pass
|
||||
return response
|
||||
|
||||
# Alternatives cache moved to services/alts_utils
|
||||
|
||||
|
||||
|
|
@ -1132,6 +1259,7 @@ async def build_new_modal(request: Request) -> HTMLResponse:
|
|||
"labels": orch.ideal_labels(),
|
||||
"defaults": orch.ideal_defaults(),
|
||||
"allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag
|
||||
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
|
||||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||
"form": {
|
||||
"prefer_combos": bool(sess.get("prefer_combos")),
|
||||
|
|
@ -1531,6 +1659,7 @@ async def build_new_submit(
|
|||
"labels": orch.ideal_labels(),
|
||||
"defaults": orch.ideal_defaults(),
|
||||
"allow_must_haves": ALLOW_MUST_HAVES,
|
||||
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
|
||||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||
"form": _form_state(suggested),
|
||||
"tag_slot_html": None,
|
||||
|
|
@ -1554,6 +1683,7 @@ async def build_new_submit(
|
|||
"labels": orch.ideal_labels(),
|
||||
"defaults": orch.ideal_defaults(),
|
||||
"allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag
|
||||
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
|
||||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||
"form": _form_state(commander),
|
||||
"tag_slot_html": None,
|
||||
|
|
@ -1657,6 +1787,7 @@ async def build_new_submit(
|
|||
"labels": orch.ideal_labels(),
|
||||
"defaults": orch.ideal_defaults(),
|
||||
"allow_must_haves": ALLOW_MUST_HAVES,
|
||||
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
|
||||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||
"form": _form_state(primary_commander_name),
|
||||
"tag_slot_html": tag_slot_html,
|
||||
|
|
@ -1794,6 +1925,7 @@ async def build_new_submit(
|
|||
"labels": orch.ideal_labels(),
|
||||
"defaults": orch.ideal_defaults(),
|
||||
"allow_must_haves": ALLOW_MUST_HAVES,
|
||||
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
|
||||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||
"form": _form_state(sess.get("commander", "")),
|
||||
"tag_slot_html": None,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass
|
||||
from difflib import SequenceMatcher
|
||||
from math import ceil
|
||||
from typing import Iterable, Mapping, Sequence
|
||||
from typing import Dict, Iterable, Mapping, Sequence, Tuple
|
||||
from urllib.parse import urlencode
|
||||
import re
|
||||
|
||||
|
|
@ -11,7 +12,7 @@ from fastapi import APIRouter, Query, Request
|
|||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from ..app import templates
|
||||
from ..services.commander_catalog_loader import CommanderRecord, load_commander_catalog
|
||||
from ..services.commander_catalog_loader import CommanderCatalog, CommanderRecord, load_commander_catalog
|
||||
from ..services.theme_catalog_loader import load_index, slugify
|
||||
from ..services.telemetry import log_commander_page_view
|
||||
|
||||
|
|
@ -89,6 +90,20 @@ class ThemeRecommendation:
|
|||
score: float
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CommanderFilterCacheEntry:
|
||||
records: Tuple[CommanderRecord, ...]
|
||||
theme_recommendations: Tuple[ThemeRecommendation, ...]
|
||||
page_views: Dict[int, Tuple[CommanderView, ...]]
|
||||
|
||||
|
||||
_FILTER_CACHE_MAX = 48
|
||||
_FILTER_CACHE: "OrderedDict[tuple[str, str, str, str], CommanderFilterCacheEntry]" = OrderedDict()
|
||||
_THEME_OPTIONS_CACHE: Dict[str, Tuple[str, ...]] = {}
|
||||
_COLOR_OPTIONS_CACHE: Dict[str, Tuple[Tuple[str, str], ...]] = {}
|
||||
_LAST_SEEN_ETAG: str | None = None
|
||||
|
||||
|
||||
def _is_htmx(request: Request) -> bool:
|
||||
return request.headers.get("HX-Request", "").lower() == "true"
|
||||
|
||||
|
|
@ -142,6 +157,74 @@ def _color_label_from_code(code: str) -> str:
|
|||
return f"{pretty} ({code})"
|
||||
|
||||
|
||||
def _cache_key_for_filters(etag: str, query: str | None, theme_query: str | None, color: str | None) -> tuple[str, str, str, str]:
|
||||
def _normalize(text: str | None) -> str:
|
||||
return (text or "").strip().lower()
|
||||
|
||||
return (
|
||||
etag,
|
||||
_normalize(query),
|
||||
_normalize(theme_query),
|
||||
(color or "").strip().upper(),
|
||||
)
|
||||
|
||||
|
||||
def _ensure_catalog_caches(etag: str) -> None:
|
||||
global _LAST_SEEN_ETAG
|
||||
if _LAST_SEEN_ETAG == etag:
|
||||
return
|
||||
_LAST_SEEN_ETAG = etag
|
||||
_FILTER_CACHE.clear()
|
||||
_THEME_OPTIONS_CACHE.clear()
|
||||
_COLOR_OPTIONS_CACHE.clear()
|
||||
|
||||
|
||||
def _theme_options_for_catalog(entries: Sequence[CommanderRecord], *, etag: str) -> Tuple[str, ...]:
|
||||
cached = _THEME_OPTIONS_CACHE.get(etag)
|
||||
if cached is not None:
|
||||
return cached
|
||||
options = _collect_theme_names(entries)
|
||||
result = tuple(options)
|
||||
_THEME_OPTIONS_CACHE[etag] = result
|
||||
return result
|
||||
|
||||
|
||||
def _color_options_for_catalog(entries: Sequence[CommanderRecord], *, etag: str) -> Tuple[Tuple[str, str], ...]:
|
||||
cached = _COLOR_OPTIONS_CACHE.get(etag)
|
||||
if cached is not None:
|
||||
return cached
|
||||
options = tuple(_build_color_options(entries))
|
||||
_COLOR_OPTIONS_CACHE[etag] = options
|
||||
return options
|
||||
|
||||
|
||||
def _get_cached_filter_entry(
|
||||
catalog: CommanderCatalog,
|
||||
query: str | None,
|
||||
theme_query: str | None,
|
||||
canon_color: str | None,
|
||||
theme_options: Sequence[str],
|
||||
) -> CommanderFilterCacheEntry:
|
||||
key = _cache_key_for_filters(catalog.etag, query, theme_query, canon_color)
|
||||
cached = _FILTER_CACHE.get(key)
|
||||
if cached is not None:
|
||||
_FILTER_CACHE.move_to_end(key)
|
||||
return cached
|
||||
|
||||
filtered = tuple(_filter_commanders(catalog.entries, query, canon_color, theme_query))
|
||||
recommendations = tuple(_build_theme_recommendations(theme_query, theme_options))
|
||||
entry = CommanderFilterCacheEntry(
|
||||
records=filtered,
|
||||
theme_recommendations=recommendations,
|
||||
page_views={},
|
||||
)
|
||||
_FILTER_CACHE[key] = entry
|
||||
_FILTER_CACHE.move_to_end(key)
|
||||
if len(_FILTER_CACHE) > _FILTER_CACHE_MAX:
|
||||
_FILTER_CACHE.popitem(last=False)
|
||||
return entry
|
||||
|
||||
|
||||
def _color_aria_label(record: CommanderRecord) -> str:
|
||||
if record.color_identity:
|
||||
names = [_COLOR_NAMES.get(ch, ch) for ch in record.color_identity]
|
||||
|
|
@ -351,13 +434,19 @@ def _build_theme_recommendations(theme_query: str | None, theme_names: Sequence[
|
|||
return tuple(filtered[:_THEME_RECOMMENDATION_LIMIT])
|
||||
|
||||
|
||||
def _filter_commanders(records: Iterable[CommanderRecord], q: str | None, color: str | None, theme: str | None) -> list[CommanderRecord]:
|
||||
items = list(records)
|
||||
def _filter_commanders(records: Iterable[CommanderRecord], q: str | None, color: str | None, theme: str | None) -> Sequence[CommanderRecord]:
|
||||
items: Sequence[CommanderRecord]
|
||||
if isinstance(records, Sequence):
|
||||
items = records
|
||||
else:
|
||||
items = tuple(records)
|
||||
|
||||
color_code = _canon_color_code(color)
|
||||
if color_code:
|
||||
items = [rec for rec in items if _record_color_code(rec) == color_code]
|
||||
|
||||
normalized_query = _normalize_search_text(q)
|
||||
if normalized_query:
|
||||
if normalized_query and items:
|
||||
filtered: list[tuple[float, CommanderRecord]] = []
|
||||
for rec in items:
|
||||
score = _commander_name_match_score(normalized_query, rec)
|
||||
|
|
@ -368,6 +457,7 @@ def _filter_commanders(records: Iterable[CommanderRecord], q: str | None, color:
|
|||
items = [rec for _, rec in filtered]
|
||||
else:
|
||||
items = []
|
||||
|
||||
normalized_theme_query = _normalize_search_text(theme)
|
||||
if normalized_theme_query and items:
|
||||
theme_tokens = tuple(normalized_theme_query.split())
|
||||
|
|
@ -381,7 +471,10 @@ def _filter_commanders(records: Iterable[CommanderRecord], q: str | None, color:
|
|||
items = [rec for _, rec in filtered_by_theme]
|
||||
else:
|
||||
items = []
|
||||
return items
|
||||
|
||||
if isinstance(items, list):
|
||||
return items
|
||||
return tuple(items)
|
||||
|
||||
|
||||
def _build_color_options(records: Sequence[CommanderRecord]) -> list[tuple[str, str]]:
|
||||
|
|
@ -441,27 +534,69 @@ async def commanders_index(
|
|||
color: str | None = Query(default=None, alias="color"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
) -> HTMLResponse:
|
||||
catalog: CommanderCatalog | None = None
|
||||
entries: Sequence[CommanderRecord] = ()
|
||||
error: str | None = None
|
||||
try:
|
||||
catalog = load_commander_catalog()
|
||||
entries = catalog.entries
|
||||
_ensure_catalog_caches(catalog.etag)
|
||||
except FileNotFoundError:
|
||||
error = "Commander catalog is unavailable. Ensure csv_files/commander_cards.csv exists."
|
||||
theme_names = _collect_theme_names(entries)
|
||||
except Exception:
|
||||
error = "Commander catalog failed to load. Check server logs."
|
||||
|
||||
theme_query = (theme or "").strip()
|
||||
filtered = _filter_commanders(entries, q, color, theme_query)
|
||||
theme_recommendations = _build_theme_recommendations(theme_query, theme_names)
|
||||
total_filtered = len(filtered)
|
||||
page_count = max(1, ceil(total_filtered / PAGE_SIZE)) if total_filtered else 1
|
||||
if page > page_count:
|
||||
page = page_count
|
||||
start_index = (page - 1) * PAGE_SIZE
|
||||
end_index = start_index + PAGE_SIZE
|
||||
page_records = filtered[start_index:end_index]
|
||||
theme_info = _build_theme_info(page_records)
|
||||
views = [_record_to_view(rec, theme_info) for rec in page_records]
|
||||
color_options = _build_color_options(entries) if entries else []
|
||||
query_value = (q or "").strip()
|
||||
canon_color = _canon_color_code(color)
|
||||
|
||||
theme_names: Tuple[str, ...] = ()
|
||||
color_options: Tuple[Tuple[str, str], ...] | list[Tuple[str, str]] = ()
|
||||
filter_entry: CommanderFilterCacheEntry | None = None
|
||||
total_filtered = 0
|
||||
page_count = 1
|
||||
page_records: Sequence[CommanderRecord] = ()
|
||||
views: Tuple[CommanderView, ...] = ()
|
||||
theme_recommendations: Tuple[ThemeRecommendation, ...] = ()
|
||||
|
||||
if catalog is not None:
|
||||
theme_names = _theme_options_for_catalog(entries, etag=catalog.etag)
|
||||
color_options = _color_options_for_catalog(entries, etag=catalog.etag)
|
||||
filter_entry = _get_cached_filter_entry(
|
||||
catalog,
|
||||
query_value,
|
||||
theme_query,
|
||||
canon_color,
|
||||
theme_names,
|
||||
)
|
||||
total_filtered = len(filter_entry.records)
|
||||
page_count = max(1, ceil(total_filtered / PAGE_SIZE)) if total_filtered else 1
|
||||
if page > page_count:
|
||||
page = page_count
|
||||
if page < 1:
|
||||
page = 1
|
||||
start_index = (page - 1) * PAGE_SIZE
|
||||
end_index = start_index + PAGE_SIZE
|
||||
page_records = filter_entry.records[start_index:end_index]
|
||||
cached_views = filter_entry.page_views.get(page) if filter_entry else None
|
||||
if cached_views is None:
|
||||
theme_info = _build_theme_info(page_records)
|
||||
computed_views = tuple(_record_to_view(rec, theme_info) for rec in page_records)
|
||||
if filter_entry is not None:
|
||||
filter_entry.page_views[page] = computed_views
|
||||
if len(filter_entry.page_views) > 6:
|
||||
oldest_key = next(iter(filter_entry.page_views))
|
||||
if oldest_key != page:
|
||||
filter_entry.page_views.pop(oldest_key, None)
|
||||
views = computed_views
|
||||
else:
|
||||
views = cached_views
|
||||
theme_recommendations = filter_entry.theme_recommendations
|
||||
else:
|
||||
page = 1
|
||||
start_index = 0
|
||||
end_index = 0
|
||||
|
||||
page_start = start_index + 1 if total_filtered else 0
|
||||
page_end = start_index + len(page_records)
|
||||
has_prev = page > 1
|
||||
|
|
@ -494,12 +629,12 @@ async def commanders_index(
|
|||
context = {
|
||||
"request": request,
|
||||
"commanders": views,
|
||||
"query": q or "",
|
||||
"theme_query": theme_query,
|
||||
"query": query_value,
|
||||
"theme_query": theme_query,
|
||||
"color": canon_color,
|
||||
"color_options": color_options,
|
||||
"theme_options": theme_names,
|
||||
"theme_recommendations": theme_recommendations,
|
||||
"color_options": list(color_options) if color_options else [],
|
||||
"theme_options": theme_names,
|
||||
"theme_recommendations": theme_recommendations,
|
||||
"total_count": len(entries),
|
||||
"result_count": len(views),
|
||||
"result_total": total_filtered,
|
||||
|
|
@ -540,3 +675,23 @@ async def commanders_index_alias(
|
|||
page: int = Query(default=1, ge=1),
|
||||
) -> HTMLResponse:
|
||||
return await commanders_index(request, q=q, theme=theme, color=color, page=page)
|
||||
|
||||
|
||||
def prewarm_default_page() -> None:
|
||||
"""Prime the commander catalog caches for the default (no-filter) view."""
|
||||
|
||||
try:
|
||||
catalog = load_commander_catalog()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
try:
|
||||
_ensure_catalog_caches(catalog.etag)
|
||||
theme_options = _theme_options_for_catalog(catalog.entries, etag=catalog.etag)
|
||||
entry = _get_cached_filter_entry(catalog, "", "", "", theme_options)
|
||||
if 1 not in entry.page_views:
|
||||
page_records = entry.records[:PAGE_SIZE]
|
||||
theme_info = _build_theme_info(page_records)
|
||||
entry.page_views[1] = tuple(_record_to_view(rec, theme_info) for rec in page_records)
|
||||
except Exception:
|
||||
return
|
||||
|
|
|
|||
21
code/web/routes/telemetry.py
Normal file
21
code/web/routes/telemetry.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Any, Dict
|
||||
|
||||
from ..services.telemetry import log_frontend_event
|
||||
|
||||
router = APIRouter(prefix="/telemetry", tags=["telemetry"])
|
||||
|
||||
|
||||
class TelemetryEvent(BaseModel):
|
||||
event: str = Field(..., min_length=1)
|
||||
data: Dict[str, Any] | None = None
|
||||
|
||||
|
||||
@router.post("/events", status_code=204)
|
||||
async def ingest_event(payload: TelemetryEvent, request: Request) -> Response:
|
||||
log_frontend_event(request, event=payload.event, data=payload.data or {})
|
||||
return Response(status_code=204)
|
||||
Loading…
Add table
Add a link
Reference in a new issue