mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2026-03-24 14:06:31 +01:00
feat: add Budget Mode with price cache infrastructure and stale price warnings (#61)
This commit is contained in:
parent
1aa8e4d7e8
commit
8643b72108
42 changed files with 6976 additions and 2753 deletions
|
|
@ -243,6 +243,20 @@ async def build_alternatives(
|
|||
return HTMLResponse(cached)
|
||||
|
||||
def _render_and_cache(_items: list[dict]):
|
||||
# Enrich each item with USD price from PriceService (best-effort)
|
||||
try:
|
||||
from ..services.price_service import get_price_service
|
||||
_svc = get_price_service()
|
||||
_svc._ensure_loaded()
|
||||
_prices = _svc._cache # keyed by lowercase card name → {usd, usd_foil, ...}
|
||||
for _it in _items:
|
||||
if not _it.get("price"):
|
||||
_nm = (_it.get("name_lower") or str(_it.get("name", ""))).lower()
|
||||
_entry = _prices.get(_nm)
|
||||
if _entry:
|
||||
_it["price"] = _entry.get("usd")
|
||||
except Exception:
|
||||
pass
|
||||
html_str = templates.get_template("build/_alternatives.html").render({
|
||||
"request": request,
|
||||
"name": name_disp,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from ..app import (
|
|||
ENABLE_BATCH_BUILD,
|
||||
DEFAULT_THEME_MATCH_MODE,
|
||||
THEME_POOL_SECTIONS,
|
||||
ENABLE_BUDGET_MODE,
|
||||
)
|
||||
from ..services.build_utils import (
|
||||
step5_ctx_from_result,
|
||||
|
|
@ -113,6 +114,7 @@ async def build_new_modal(request: Request) -> HTMLResponse:
|
|||
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
|
||||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||
"enable_batch_build": ENABLE_BATCH_BUILD,
|
||||
"enable_budget_mode": ENABLE_BUDGET_MODE,
|
||||
"ideals_ui_mode": WEB_IDEALS_UI, # 'input' or 'slider'
|
||||
"multi_copy_archetypes_js": _ARCHETYPE_JS_MAP,
|
||||
"form": {
|
||||
|
|
@ -425,6 +427,10 @@ async def build_new_submit(
|
|||
enforcement_mode: str = Form("warn"),
|
||||
allow_illegal: bool = Form(False),
|
||||
fuzzy_matching: bool = Form(True),
|
||||
# Budget (optional)
|
||||
budget_total: float | None = Form(None),
|
||||
card_ceiling: float | None = Form(None),
|
||||
pool_tolerance: str | None = Form(None), # percent string; blank/None treated as 0% (hard cap at ceiling)
|
||||
# Build count for multi-build
|
||||
build_count: int = Form(1),
|
||||
# Quick Build flag
|
||||
|
|
@ -474,6 +480,9 @@ async def build_new_submit(
|
|||
"partner_enabled": partner_form_state["partner_enabled"],
|
||||
"secondary_commander": partner_form_state["secondary_commander"],
|
||||
"background": partner_form_state["background"],
|
||||
"budget_total": budget_total,
|
||||
"card_ceiling": card_ceiling,
|
||||
"pool_tolerance": pool_tolerance if pool_tolerance is not None else "15",
|
||||
}
|
||||
|
||||
commander_detail = lookup_commander_detail(commander)
|
||||
|
|
@ -501,6 +510,7 @@ async def build_new_submit(
|
|||
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
|
||||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||
"enable_batch_build": ENABLE_BATCH_BUILD,
|
||||
"enable_budget_mode": ENABLE_BUDGET_MODE,
|
||||
"multi_copy_archetypes_js": _ARCHETYPE_JS_MAP,
|
||||
"form": _form_state(suggested),
|
||||
"tag_slot_html": None,
|
||||
|
|
@ -527,6 +537,7 @@ async def build_new_submit(
|
|||
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
|
||||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||
"enable_batch_build": ENABLE_BATCH_BUILD,
|
||||
"enable_budget_mode": ENABLE_BUDGET_MODE,
|
||||
"multi_copy_archetypes_js": _ARCHETYPE_JS_MAP,
|
||||
"form": _form_state(commander),
|
||||
"tag_slot_html": None,
|
||||
|
|
@ -633,6 +644,7 @@ async def build_new_submit(
|
|||
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
|
||||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||
"enable_batch_build": ENABLE_BATCH_BUILD,
|
||||
"enable_budget_mode": ENABLE_BUDGET_MODE,
|
||||
"multi_copy_archetypes_js": _ARCHETYPE_JS_MAP,
|
||||
"form": _form_state(primary_commander_name),
|
||||
"tag_slot_html": tag_slot_html,
|
||||
|
|
@ -773,6 +785,7 @@ async def build_new_submit(
|
|||
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
|
||||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||
"enable_batch_build": ENABLE_BATCH_BUILD,
|
||||
"enable_budget_mode": ENABLE_BUDGET_MODE,
|
||||
"multi_copy_archetypes_js": _ARCHETYPE_JS_MAP,
|
||||
"form": _form_state(sess.get("commander", "")),
|
||||
"tag_slot_html": None,
|
||||
|
|
@ -906,7 +919,31 @@ async def build_new_submit(
|
|||
# If exclude parsing fails, log but don't block the build
|
||||
import logging
|
||||
logging.warning(f"Failed to parse exclude cards: {e}")
|
||||
|
||||
|
||||
# Store budget config in session (only when a total is provided)
|
||||
try:
|
||||
if ENABLE_BUDGET_MODE and budget_total and float(budget_total) > 0:
|
||||
budget_cfg: dict = {"total": float(budget_total), "mode": "soft"}
|
||||
if card_ceiling and float(card_ceiling) > 0:
|
||||
budget_cfg["card_ceiling"] = float(card_ceiling)
|
||||
# pool_tolerance: blank/None → 0.0 (hard cap at ceiling); digit string → float (e.g. "15" → 0.15)
|
||||
# Absence of key in budget_cfg means M4 falls back to the env-level default.
|
||||
_tol = (pool_tolerance or "").strip()
|
||||
try:
|
||||
budget_cfg["pool_tolerance"] = float(_tol) / 100.0 if _tol else 0.0
|
||||
except ValueError:
|
||||
budget_cfg["pool_tolerance"] = 0.15 # bad input → safe default
|
||||
sess["budget_config"] = budget_cfg
|
||||
else:
|
||||
sess.pop("budget_config", None)
|
||||
except Exception:
|
||||
sess.pop("budget_config", None)
|
||||
|
||||
# Assign a stable build ID for this build run (used by JS to detect true new builds,
|
||||
# distinct from per-stage summary_token which increments every stage)
|
||||
import time as _time
|
||||
sess["build_id"] = str(int(_time.time() * 1000))
|
||||
|
||||
# Clear any old staged build context
|
||||
for k in ["build_ctx", "locks", "replace_mode"]:
|
||||
if k in sess:
|
||||
|
|
@ -1285,9 +1322,9 @@ def quick_build_progress(request: Request):
|
|||
# Return Step 5 which will replace the whole wizard div
|
||||
response = templates.TemplateResponse("build/_step5.html", ctx)
|
||||
response.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
# Tell HTMX to target #wizard and swap outerHTML to replace the container
|
||||
# Tell HTMX to target #wizard and swap innerHTML (keeps #wizard in DOM for subsequent interactions)
|
||||
response.headers["HX-Retarget"] = "#wizard"
|
||||
response.headers["HX-Reswap"] = "outerHTML"
|
||||
response.headers["HX-Reswap"] = "innerHTML"
|
||||
return response
|
||||
# Fallback if no result yet
|
||||
return HTMLResponse('Build complete. Please refresh.')
|
||||
|
|
@ -1302,3 +1339,124 @@ def quick_build_progress(request: Request):
|
|||
response = templates.TemplateResponse("build/_quick_build_progress_content.html", ctx)
|
||||
response.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return response
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# M5: Budget swap route — swap one card in the finalized deck and re-evaluate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/budget-swap", response_class=HTMLResponse)
|
||||
async def budget_swap(
|
||||
request: Request,
|
||||
old_card: str = Form(...),
|
||||
new_card: str = Form(...),
|
||||
):
|
||||
"""Replace one card in the session budget snapshot and re-render the review panel."""
|
||||
sid = request.cookies.get("sid") or request.headers.get("X-Session-ID")
|
||||
if not sid:
|
||||
return HTMLResponse("")
|
||||
sess = get_session(sid)
|
||||
|
||||
snapshot: list[str] = list(sess.get("budget_deck_snapshot") or [])
|
||||
if not snapshot:
|
||||
return HTMLResponse("")
|
||||
|
||||
old_lower = old_card.strip().lower()
|
||||
new_name = new_card.strip()
|
||||
replaced = False
|
||||
for i, name in enumerate(snapshot):
|
||||
if name.strip().lower() == old_lower:
|
||||
snapshot[i] = new_name
|
||||
replaced = True
|
||||
break
|
||||
if not replaced:
|
||||
return HTMLResponse("")
|
||||
sess["budget_deck_snapshot"] = snapshot
|
||||
|
||||
# Re-evaluate budget
|
||||
budget_cfg = sess.get("budget_config") or {}
|
||||
try:
|
||||
budget_total = float(budget_cfg.get("total") or 0)
|
||||
except Exception:
|
||||
budget_total = 0.0
|
||||
if budget_total <= 0:
|
||||
return HTMLResponse("")
|
||||
|
||||
budget_mode = str(budget_cfg.get("mode", "soft")).strip().lower()
|
||||
try:
|
||||
card_ceiling = float(budget_cfg.get("card_ceiling")) if budget_cfg.get("card_ceiling") else None
|
||||
except Exception:
|
||||
card_ceiling = None
|
||||
include_cards = [str(c).strip() for c in (sess.get("include_cards") or []) if str(c).strip()]
|
||||
color_identity: list[str] | None = None
|
||||
try:
|
||||
ci_raw = sess.get("color_identity")
|
||||
if ci_raw and isinstance(ci_raw, list):
|
||||
color_identity = [str(c).upper() for c in ci_raw]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from ..services.budget_evaluator import BudgetEvaluatorService
|
||||
svc = BudgetEvaluatorService()
|
||||
report = svc.evaluate_deck(
|
||||
snapshot,
|
||||
budget_total,
|
||||
mode=budget_mode,
|
||||
card_ceiling=card_ceiling,
|
||||
include_cards=include_cards,
|
||||
color_identity=color_identity,
|
||||
)
|
||||
except Exception:
|
||||
return HTMLResponse("")
|
||||
|
||||
total_price = float(report.get("total_price", 0.0))
|
||||
tolerance = bc.BUDGET_TOTAL_TOLERANCE
|
||||
over_budget_review = total_price > budget_total * (1.0 + tolerance)
|
||||
|
||||
overage_pct = round((total_price - budget_total) / budget_total * 100, 1) if (budget_total and over_budget_review) else 0.0
|
||||
include_set = {c.lower().strip() for c in include_cards}
|
||||
|
||||
# Use price_breakdown sorted by price desc — most expensive cards contribute most to total overage
|
||||
breakdown = report.get("price_breakdown") or []
|
||||
priced = sorted(
|
||||
[e for e in breakdown
|
||||
if not e.get("is_include") and (e.get("price") is not None) and float(e.get("price") or 0.0) > 0],
|
||||
key=lambda x: -float(x.get("price") or 0.0),
|
||||
)
|
||||
over_cards_out: list[dict] = []
|
||||
for entry in priced[:6]:
|
||||
name = entry.get("card", "")
|
||||
price = float(entry.get("price") or 0.0)
|
||||
is_include = name.lower().strip() in include_set
|
||||
try:
|
||||
alts_raw = svc.find_cheaper_alternatives(
|
||||
name,
|
||||
max_price=max(0.0, price - 0.01),
|
||||
region="usd",
|
||||
color_identity=color_identity,
|
||||
)
|
||||
except Exception:
|
||||
alts_raw = []
|
||||
over_cards_out.append({
|
||||
"name": name,
|
||||
"price": price,
|
||||
"swap_disabled": is_include,
|
||||
"alternatives": [
|
||||
{"name": a["name"], "price": a.get("price"), "shared_tags": a.get("shared_tags", [])}
|
||||
for a in alts_raw[:3]
|
||||
],
|
||||
})
|
||||
|
||||
ctx = {
|
||||
"request": request,
|
||||
"budget_review_visible": over_budget_review,
|
||||
"over_budget_review": over_budget_review,
|
||||
"budget_review_total": round(total_price, 2),
|
||||
"budget_review_cap": round(budget_total, 2),
|
||||
"budget_overage_pct": overage_pct,
|
||||
"over_budget_cards": over_cards_out,
|
||||
}
|
||||
response = templates.TemplateResponse("build/_budget_review.html", ctx)
|
||||
response.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return response
|
||||
|
|
|
|||
|
|
@ -948,7 +948,10 @@ async def build_step5_start_get(request: Request) -> HTMLResponse:
|
|||
|
||||
@router.post("/step5/start", response_class=HTMLResponse)
|
||||
async def build_step5_start(request: Request) -> HTMLResponse:
|
||||
return RedirectResponse("/build", status_code=302)
|
||||
"""(Re)start the build from step 4 review page."""
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
commander = sess.get("commander")
|
||||
if not commander:
|
||||
resp = templates.TemplateResponse(
|
||||
"build/_step1.html",
|
||||
|
|
@ -957,7 +960,8 @@ async def build_step5_start(request: Request) -> HTMLResponse:
|
|||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
try:
|
||||
# Initialize step-by-step build context and run first stage
|
||||
import time as _time
|
||||
sess["build_id"] = str(int(_time.time() * 1000))
|
||||
sess["build_ctx"] = start_ctx_from_session(sess)
|
||||
show_skipped = False
|
||||
try:
|
||||
|
|
@ -966,18 +970,15 @@ async def build_step5_start(request: Request) -> HTMLResponse:
|
|||
except Exception:
|
||||
pass
|
||||
res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=show_skipped)
|
||||
# Save summary to session for deck_summary partial to access
|
||||
if res.get("summary"):
|
||||
sess["summary"] = res["summary"]
|
||||
status = "Stage complete" if not res.get("done") else "Build complete"
|
||||
# If Multi-Copy ran first, mark applied to prevent redundant rebuilds on Continue
|
||||
try:
|
||||
if res.get("label") == "Multi-Copy Package" and sess.get("multi_copy"):
|
||||
mc = sess.get("multi_copy")
|
||||
sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}"
|
||||
except Exception:
|
||||
pass
|
||||
# Note: no redirect; the inline compliance panel will render inside Step 5
|
||||
sess["last_step"] = 5
|
||||
ctx = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped)
|
||||
resp = templates.TemplateResponse("build/_step5.html", ctx)
|
||||
|
|
@ -985,14 +986,7 @@ async def build_step5_start(request: Request) -> HTMLResponse:
|
|||
_merge_hx_trigger(resp, {"step5:refresh": {"token": ctx.get("summary_token", 0)}})
|
||||
return resp
|
||||
except Exception as e:
|
||||
# Surface a friendly error on the step 5 screen with normalized context
|
||||
err_ctx = step5_error_ctx(
|
||||
request,
|
||||
sess,
|
||||
f"Failed to start build: {e}",
|
||||
include_name=False,
|
||||
)
|
||||
# Ensure commander stays visible if set
|
||||
err_ctx = step5_error_ctx(request, sess, f"Failed to start build: {e}", include_name=False)
|
||||
err_ctx["commander"] = commander
|
||||
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
from pathlib import Path
|
||||
import csv
|
||||
import io
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from ..app import templates
|
||||
from ..services.orchestrator import tags_for_commander
|
||||
from ..services.summary_utils import format_theme_label, format_theme_list, summary_ctx
|
||||
from ..app import ENABLE_BUDGET_MODE
|
||||
|
||||
|
||||
router = APIRouter(prefix="/decks")
|
||||
|
|
@ -402,6 +404,47 @@ async def decks_view(request: Request, name: str) -> HTMLResponse:
|
|||
"commander_role_label": format_theme_label("Commander"),
|
||||
}
|
||||
)
|
||||
|
||||
# Budget evaluation (only when budget_config is stored in the sidecar meta)
|
||||
if ENABLE_BUDGET_MODE:
|
||||
budget_config = meta_info.get("budget_config") if isinstance(meta_info, dict) else None
|
||||
if isinstance(budget_config, dict) and budget_config.get("total"):
|
||||
try:
|
||||
from ..services.budget_evaluator import BudgetEvaluatorService
|
||||
card_counts = _read_deck_counts(p)
|
||||
decklist = list(card_counts.keys())
|
||||
color_identity = meta_info.get("color_identity") if isinstance(meta_info, dict) else None
|
||||
include_cards = list(meta_info.get("include_cards") or []) if isinstance(meta_info, dict) else []
|
||||
svc = BudgetEvaluatorService()
|
||||
budget_report = svc.evaluate_deck(
|
||||
decklist=decklist,
|
||||
budget_total=float(budget_config["total"]),
|
||||
mode=str(budget_config.get("mode", "soft")),
|
||||
card_ceiling=float(budget_config["card_ceiling"]) if budget_config.get("card_ceiling") else None,
|
||||
color_identity=color_identity,
|
||||
include_cards=include_cards or None,
|
||||
)
|
||||
ctx["budget_report"] = budget_report
|
||||
ctx["budget_config"] = budget_config
|
||||
# M8: Price charts
|
||||
try:
|
||||
from ..services.budget_evaluator import compute_price_category_breakdown, compute_price_histogram
|
||||
_breakdown = budget_report.get("price_breakdown") or []
|
||||
_card_tags: Dict[str, List[str]] = {}
|
||||
if isinstance(summary, dict):
|
||||
_tb = (summary.get("type_breakdown") or {}).get("cards") or {}
|
||||
for _clist in _tb.values():
|
||||
for _c in (_clist or []):
|
||||
if isinstance(_c, dict) and _c.get("name"):
|
||||
_card_tags[_c["name"]] = list(_c.get("tags") or [])
|
||||
_enriched = [{**item, "tags": _card_tags.get(item.get("card", ""), [])} for item in _breakdown]
|
||||
ctx["price_category_chart"] = compute_price_category_breakdown(_enriched)
|
||||
ctx["price_histogram_chart"] = compute_price_histogram(_breakdown)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return templates.TemplateResponse("decks/view.html", ctx)
|
||||
|
||||
|
||||
|
|
@ -486,3 +529,144 @@ async def decks_compare(request: Request, A: Optional[str] = None, B: Optional[s
|
|||
"metaB": metaB,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/pickups", response_class=HTMLResponse)
|
||||
async def decks_pickups(request: Request, name: str) -> HTMLResponse:
|
||||
"""Show the pickups list for a deck that was built with budget mode enabled."""
|
||||
base = _deck_dir()
|
||||
p = (base / name).resolve()
|
||||
if not _safe_within(base, p) or not (p.exists() and p.is_file() and p.suffix.lower() == ".csv"):
|
||||
return templates.TemplateResponse(
|
||||
"decks/index.html",
|
||||
{"request": request, "items": _list_decks(), "error": "Deck not found."},
|
||||
)
|
||||
|
||||
meta_info: Dict[str, Any] = {}
|
||||
commander_name = ""
|
||||
sidecar = p.with_suffix(".summary.json")
|
||||
if sidecar.exists():
|
||||
try:
|
||||
import json as _json
|
||||
payload = _json.loads(sidecar.read_text(encoding="utf-8"))
|
||||
if isinstance(payload, dict):
|
||||
meta_info = payload.get("meta") or {}
|
||||
commander_name = meta_info.get("commander") or ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
budget_config = meta_info.get("budget_config") if isinstance(meta_info, dict) else None
|
||||
budget_report = None
|
||||
error_msg = None
|
||||
|
||||
if not ENABLE_BUDGET_MODE:
|
||||
error_msg = "Budget mode is not enabled (set ENABLE_BUDGET_MODE=1)."
|
||||
elif not isinstance(budget_config, dict) or not budget_config.get("total"):
|
||||
error_msg = "Budget mode was not enabled when this deck was built."
|
||||
else:
|
||||
try:
|
||||
from ..services.budget_evaluator import BudgetEvaluatorService
|
||||
card_counts = _read_deck_counts(p)
|
||||
decklist = list(card_counts.keys())
|
||||
color_identity = meta_info.get("color_identity") if isinstance(meta_info, dict) else None
|
||||
include_cards = list(meta_info.get("include_cards") or []) if isinstance(meta_info, dict) else []
|
||||
svc = BudgetEvaluatorService()
|
||||
budget_report = svc.evaluate_deck(
|
||||
decklist=decklist,
|
||||
budget_total=float(budget_config["total"]),
|
||||
mode=str(budget_config.get("mode", "soft")),
|
||||
card_ceiling=float(budget_config["card_ceiling"]) if budget_config.get("card_ceiling") else None,
|
||||
color_identity=color_identity,
|
||||
include_cards=include_cards or None,
|
||||
)
|
||||
except Exception as exc:
|
||||
error_msg = f"Budget evaluation failed: {exc}"
|
||||
|
||||
stale_prices: set[str] = set()
|
||||
stale_prices_global = False
|
||||
try:
|
||||
from ..services.price_service import get_price_service
|
||||
from code.settings import PRICE_STALE_WARNING_HOURS
|
||||
_psvc = get_price_service()
|
||||
_psvc._ensure_loaded()
|
||||
if PRICE_STALE_WARNING_HOURS > 0:
|
||||
_stale = _psvc.get_stale_cards(PRICE_STALE_WARNING_HOURS)
|
||||
if _stale and len(_stale) > len(_psvc._cache) * 0.5:
|
||||
stale_prices_global = True
|
||||
else:
|
||||
stale_prices = _stale
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"decks/pickups.html",
|
||||
{
|
||||
"request": request,
|
||||
"name": p.name,
|
||||
"commander": commander_name,
|
||||
"budget_config": budget_config,
|
||||
"budget_report": budget_report,
|
||||
"error": error_msg,
|
||||
"stale_prices": stale_prices,
|
||||
"stale_prices_global": stale_prices_global,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/download-csv")
|
||||
async def decks_download_csv(name: str) -> Response:
|
||||
"""Serve a CSV export with live prices fetched at download time."""
|
||||
base = _deck_dir()
|
||||
p = (base / name).resolve()
|
||||
if not _safe_within(base, p) or not (p.exists() and p.is_file() and p.suffix.lower() == ".csv"):
|
||||
return HTMLResponse("File not found", status_code=404)
|
||||
try:
|
||||
with p.open("r", encoding="utf-8", newline="") as f:
|
||||
reader = csv.reader(f)
|
||||
headers = next(reader, [])
|
||||
data_rows = list(reader)
|
||||
except Exception:
|
||||
return HTMLResponse("Could not read CSV", status_code=500)
|
||||
|
||||
# Strip any stale baked Price column
|
||||
if "Price" in headers:
|
||||
price_idx = headers.index("Price")
|
||||
headers = [h for i, h in enumerate(headers) if i != price_idx]
|
||||
data_rows = [[v for i, v in enumerate(row) if i != price_idx] for row in data_rows]
|
||||
|
||||
name_idx = headers.index("Name") if "Name" in headers else 0
|
||||
card_names = [
|
||||
row[name_idx] for row in data_rows
|
||||
if row and len(row) > name_idx and row[name_idx] and row[name_idx] != "Total"
|
||||
]
|
||||
|
||||
prices_map: Dict[str, Any] = {}
|
||||
try:
|
||||
from ..services.price_service import get_price_service
|
||||
prices_map = get_price_service().get_prices_batch(card_names) or {}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(headers + ["Price"])
|
||||
for row in data_rows:
|
||||
if not row:
|
||||
continue
|
||||
name_val = row[name_idx] if len(row) > name_idx else ""
|
||||
if name_val == "Total":
|
||||
continue
|
||||
price_val = prices_map.get(name_val)
|
||||
writer.writerow(row + [f"{price_val:.2f}" if price_val is not None else ""])
|
||||
|
||||
if prices_map:
|
||||
total = sum(v for v in prices_map.values() if v is not None)
|
||||
empty = [""] * len(headers)
|
||||
empty[name_idx] = "Total"
|
||||
writer.writerow(empty + [f"{total:.2f}"])
|
||||
|
||||
return Response(
|
||||
content=output.getvalue().encode("utf-8"),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f'attachment; filename="{p.name}"'},
|
||||
)
|
||||
|
|
|
|||
111
code/web/routes/price.py
Normal file
111
code/web/routes/price.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
"""Price API routes for card price lookups.
|
||||
|
||||
Provides endpoints for single-card and batch price queries backed by
|
||||
the PriceService (Scryfall bulk data + JSON cache).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import List, Optional
|
||||
from urllib.parse import unquote
|
||||
|
||||
from fastapi import APIRouter, Body, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from code.web.services.price_service import get_price_service
|
||||
from code.web.decorators.telemetry import track_route_access, log_route_errors
|
||||
|
||||
router = APIRouter(prefix="/api/price")
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
@track_route_access("price_cache_stats")
|
||||
async def price_cache_stats():
|
||||
"""Return cache telemetry for the PriceService."""
|
||||
svc = get_price_service()
|
||||
return JSONResponse(svc.cache_stats())
|
||||
|
||||
|
||||
@router.post("/refresh")
|
||||
@track_route_access("price_cache_refresh")
|
||||
async def refresh_price_cache():
|
||||
"""Trigger a background rebuild of the price cache and parquet price columns.
|
||||
|
||||
Returns immediately — the rebuild runs in a daemon thread.
|
||||
"""
|
||||
def _run() -> None:
|
||||
try:
|
||||
from code.file_setup.setup import refresh_prices_parquet
|
||||
refresh_prices_parquet()
|
||||
except Exception as exc:
|
||||
import logging
|
||||
logging.getLogger(__name__).error("Manual price refresh failed: %s", exc)
|
||||
|
||||
t = threading.Thread(target=_run, daemon=True, name="price-manual-refresh")
|
||||
t.start()
|
||||
return JSONResponse({"ok": True, "message": "Price cache refresh started in background."})
|
||||
|
||||
|
||||
@router.get("/{card_name:path}")
|
||||
@track_route_access("price_lookup")
|
||||
@log_route_errors("price_lookup")
|
||||
async def get_card_price(
|
||||
card_name: str,
|
||||
region: str = Query("usd", pattern="^(usd|eur)$"),
|
||||
foil: bool = Query(False),
|
||||
):
|
||||
"""Look up the price for a single card.
|
||||
|
||||
Args:
|
||||
card_name: Card name (URL-encoded, case-insensitive).
|
||||
region: Price region — ``usd`` or ``eur``.
|
||||
foil: If true, return the foil price.
|
||||
|
||||
Returns:
|
||||
JSON with ``card_name``, ``price`` (float or null), ``region``,
|
||||
``foil``, ``found`` (bool).
|
||||
"""
|
||||
name = unquote(card_name).strip()
|
||||
svc = get_price_service()
|
||||
price = svc.get_price(name, region=region, foil=foil)
|
||||
return JSONResponse({
|
||||
"card_name": name,
|
||||
"price": price,
|
||||
"region": region,
|
||||
"foil": foil,
|
||||
"found": price is not None,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/batch")
|
||||
@track_route_access("price_batch_lookup")
|
||||
@log_route_errors("price_batch_lookup")
|
||||
async def get_prices_batch(
|
||||
card_names: List[str] = Body(..., max_length=100),
|
||||
region: str = Query("usd", pattern="^(usd|eur)$"),
|
||||
foil: bool = Query(False),
|
||||
):
|
||||
"""Look up prices for multiple cards in a single request.
|
||||
|
||||
Request body: JSON array of card name strings (max 100).
|
||||
|
||||
Args:
|
||||
card_names: List of card names.
|
||||
region: Price region — ``usd`` or ``eur``.
|
||||
foil: If true, return foil prices.
|
||||
|
||||
Returns:
|
||||
JSON with ``prices`` (dict name→float|null) and ``missing`` (list
|
||||
of names with no price data).
|
||||
"""
|
||||
svc = get_price_service()
|
||||
prices = svc.get_prices_batch(card_names, region=region, foil=foil)
|
||||
missing = [n for n, p in prices.items() if p is None]
|
||||
return JSONResponse({
|
||||
"prices": prices,
|
||||
"missing": missing,
|
||||
"region": region,
|
||||
"foil": foil,
|
||||
"total": len(card_names),
|
||||
"found": len(card_names) - len(missing),
|
||||
})
|
||||
|
|
@ -196,10 +196,14 @@ async def download_github():
|
|||
async def setup_index(request: Request) -> HTMLResponse:
|
||||
import code.settings as settings
|
||||
from code.file_setup.image_cache import ImageCache
|
||||
|
||||
from code.web.services.price_service import get_price_service
|
||||
from code.web.app import PRICE_AUTO_REFRESH
|
||||
|
||||
image_cache = ImageCache()
|
||||
return templates.TemplateResponse("setup/index.html", {
|
||||
"request": request,
|
||||
"similarity_enabled": settings.ENABLE_CARD_SIMILARITIES,
|
||||
"image_cache_enabled": image_cache.is_enabled()
|
||||
"image_cache_enabled": image_cache.is_enabled(),
|
||||
"price_cache_built_at": get_price_service().get_cache_built_at(),
|
||||
"price_auto_refresh": PRICE_AUTO_REFRESH,
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue