mtg_python_deckbuilder/code/web/routes/configs.py

247 lines
8.7 KiB
Python

from __future__ import annotations
from fastapi import APIRouter, Request, Form, UploadFile, File
from fastapi.responses import HTMLResponse
from pathlib import Path
import os
import json
from ..app import templates
from ..services.build_utils import owned_set as owned_set_helper, owned_names as owned_names_helper
from ..services.summary_utils import summary_ctx
from ..services import orchestrator as orch
router = APIRouter(prefix="/configs")
def _config_dir() -> Path:
# Prefer explicit env var if provided, else default to ./config
p = os.getenv("DECK_CONFIG")
if p:
# If env points to a file, use its parent dir; else treat as dir
pp = Path(p)
return (pp.parent if pp.suffix else pp).resolve()
return (Path.cwd() / "config").resolve()
def _list_configs() -> list[dict]:
d = _config_dir()
try:
d.mkdir(parents=True, exist_ok=True)
except Exception:
pass
items: list[dict] = []
for p in sorted(d.glob("*.json"), key=lambda x: x.stat().st_mtime, reverse=True):
meta = {"name": p.name, "path": str(p), "mtime": p.stat().st_mtime}
try:
with p.open("r", encoding="utf-8") as f:
data = json.load(f)
meta["commander"] = data.get("commander")
tags = [t for t in [data.get("primary_tag"), data.get("secondary_tag"), data.get("tertiary_tag")] if t]
meta["tags"] = tags
meta["bracket_level"] = data.get("bracket_level")
except Exception:
pass
items.append(meta)
return items
@router.get("/", response_class=HTMLResponse)
async def configs_index(request: Request) -> HTMLResponse:
items = _list_configs()
# Load example deck.json from the config directory, if present
example_json = None
example_name = "deck.json"
try:
example_path = _config_dir() / example_name
if example_path.exists() and example_path.is_file():
example_json = example_path.read_text(encoding="utf-8")
except Exception:
example_json = None
return templates.TemplateResponse(
"configs/index.html",
{"request": request, "items": items, "example_json": example_json, "example_name": example_name},
)
@router.get("/view", response_class=HTMLResponse)
async def configs_view(request: Request, name: str) -> HTMLResponse:
base = _config_dir()
p = (base / name).resolve()
# Safety: ensure the resolved path is within config dir
try:
if base not in p.parents and p != base:
raise ValueError("Access denied")
except Exception:
pass
if not (p.exists() and p.is_file() and p.suffix.lower() == ".json"):
return templates.TemplateResponse(
"configs/index.html",
{"request": request, "items": _list_configs(), "error": "Config not found."},
)
try:
data = json.loads(p.read_text(encoding="utf-8"))
except Exception as e:
return templates.TemplateResponse(
"configs/index.html",
{"request": request, "items": _list_configs(), "error": f"Failed to read JSON: {e}"},
)
return templates.TemplateResponse(
"configs/view.html",
{"request": request, "path": str(p), "name": p.name, "data": data},
)
@router.post("/run", response_class=HTMLResponse)
async def configs_run(request: Request, name: str = Form(...), use_owned_only: str | None = Form(None)) -> HTMLResponse:
base = _config_dir()
p = (base / name).resolve()
try:
if base not in p.parents and p != base:
raise ValueError("Access denied")
except Exception:
pass
if not (p.exists() and p.is_file() and p.suffix.lower() == ".json"):
return templates.TemplateResponse(
"configs/index.html",
{"request": request, "items": _list_configs(), "error": "Config not found."},
)
try:
cfg = json.loads(p.read_text(encoding="utf-8"))
except Exception as e:
return templates.TemplateResponse(
"configs/index.html",
{"request": request, "items": _list_configs(), "error": f"Failed to read JSON: {e}"},
)
commander = cfg.get("commander", "")
tags = [t for t in [cfg.get("primary_tag"), cfg.get("secondary_tag"), cfg.get("tertiary_tag")] if t]
bracket = int(cfg.get("bracket_level") or 0)
ideals = cfg.get("ideal_counts", {}) or {}
# Optional combine mode for tags (AND/OR); support a few aliases
try:
tag_mode = (str(cfg.get("tag_mode") or cfg.get("combine_mode") or cfg.get("mode") or "AND").upper())
if tag_mode not in ("AND", "OR"):
tag_mode = "AND"
except Exception:
tag_mode = "AND"
# Optional owned-only for headless runs via JSON flag or form override
owned_flag = False
try:
uo = cfg.get("use_owned_only")
if isinstance(uo, bool):
owned_flag = uo
elif isinstance(uo, str):
owned_flag = uo.strip().lower() in ("1","true","yes","on")
except Exception:
owned_flag = False
# Form override takes precedence if provided
if use_owned_only is not None:
owned_flag = str(use_owned_only).strip().lower() in ("1","true","yes","on")
owned_names = owned_names_helper() if owned_flag else None
# Optional combos preferences
prefer_combos = False
try:
pc = cfg.get("prefer_combos")
if isinstance(pc, bool):
prefer_combos = pc
elif isinstance(pc, str):
prefer_combos = pc.strip().lower() in ("1","true","yes","on")
except Exception:
prefer_combos = False
combo_target_count = None
try:
ctc = cfg.get("combo_target_count")
if isinstance(ctc, int):
combo_target_count = ctc
elif isinstance(ctc, str) and ctc.strip().isdigit():
combo_target_count = int(ctc.strip())
except Exception:
combo_target_count = None
combo_balance = None
try:
cb = cfg.get("combo_balance")
if isinstance(cb, str) and cb.strip().lower() in ("early","late","mix"):
combo_balance = cb.strip().lower()
except Exception:
combo_balance = None
# Run build headlessly with orchestrator
res = orch.run_build(
commander=commander,
tags=tags,
bracket=bracket,
ideals=ideals,
tag_mode=tag_mode,
use_owned_only=owned_flag,
owned_names=owned_names,
# Thread combo prefs through staged headless run
prefer_combos=prefer_combos,
combo_target_count=combo_target_count,
combo_balance=combo_balance,
)
if not res.get("ok"):
return templates.TemplateResponse(
"configs/run_result.html",
{
"request": request,
"ok": False,
"error": res.get("error") or "Build failed",
"log": res.get("log", ""),
"cfg_name": p.name,
"commander": commander,
"tag_mode": tag_mode,
"use_owned_only": owned_flag,
"owned_set": owned_set_helper(),
},
)
ctx = {
"request": request,
"ok": True,
"log": res.get("log", ""),
"csv_path": res.get("csv_path"),
"txt_path": res.get("txt_path"),
"summary": res.get("summary"),
"cfg_name": p.name,
"commander": commander,
"tag_mode": tag_mode,
"use_owned_only": owned_flag,
}
ctx.update(summary_ctx(summary=res.get("summary"), commander=commander, tags=tags))
return templates.TemplateResponse("configs/run_result.html", ctx)
@router.post("/upload", response_class=HTMLResponse)
async def configs_upload(request: Request, file: UploadFile = File(...)) -> HTMLResponse:
# Optional helper: allow uploading a JSON config
try:
content = await file.read()
data = json.loads(content.decode("utf-8"))
# Minimal validation
if not data.get("commander"):
raise ValueError("Missing 'commander'")
except Exception as e:
return templates.TemplateResponse(
"configs/index.html",
{"request": request, "items": _list_configs(), "error": f"Invalid JSON: {e}"},
)
# Save to config dir with original filename (or unique)
d = _config_dir()
d.mkdir(parents=True, exist_ok=True)
fname = file.filename or "config.json"
out = d / fname
i = 1
while out.exists():
stem = out.stem
out = d / f"{stem}_{i}.json"
i += 1
out.write_text(json.dumps(data, indent=2), encoding="utf-8")
return templates.TemplateResponse(
"configs/index.html",
{"request": request, "items": _list_configs(), "notice": f"Uploaded {out.name}"},
)