mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2026-03-19 19:56:31 +01:00
merge: backend standardization refactor into main
This commit is contained in:
commit
9fc90ed27d
53 changed files with 10720 additions and 5680 deletions
|
|
@ -41,6 +41,10 @@ dist/
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
# Exclude compiled TypeScript output (will be regenerated in Docker build)
|
||||||
|
code/web/static/js/
|
||||||
|
node_modules/
|
||||||
|
|
||||||
# Exclude OS files
|
# Exclude OS files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
|
||||||
58
CHANGELOG.md
58
CHANGELOG.md
|
|
@ -9,6 +9,55 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
### Added
|
### Added
|
||||||
|
- **Testing Standards Documentation**: Standards guide and base classes for new tests
|
||||||
|
- `docs/web_backend/testing.md` — patterns for route, service, validation, and error handler tests
|
||||||
|
- `code/tests/base_test_cases.py` — `RouteTestCase`, `ServiceTestCase`, `ErrorHandlerTestCase`, `ValidationTestMixin`
|
||||||
|
- Covers naming conventions, fixture setup, coverage targets, and what not to test
|
||||||
|
- **Error Handling Integration**: Custom exceptions now wired into the web layer
|
||||||
|
- `DeckBuilderError` handler in `app.py` — typed exceptions get correct HTTP status (not always 500)
|
||||||
|
- `deck_builder_error_response()` utility: JSON responses for API, HTML fragments for HTMX
|
||||||
|
- Status code mapping for 50+ exception classes (400/401/404/503/500)
|
||||||
|
- Web-specific exceptions: `SessionExpiredError` (401), `BuildNotFoundError` (404), `FeatureDisabledError` (404)
|
||||||
|
- `partner_suggestions.py` converted from raw `HTTPException` to typed exceptions
|
||||||
|
- Fixed pre-existing bug: `CommanderValidationError.__init__` now accepts optional `code` kwarg
|
||||||
|
- Error handling guide: `docs/web_backend/error_handling.md`
|
||||||
|
- **Backend Standardization Framework**: Improved code organization and maintainability
|
||||||
|
- Response builder utilities for consistent HTTP responses
|
||||||
|
- Telemetry decorators for route access tracking and error logging
|
||||||
|
- Route pattern documentation defining standards for all routes
|
||||||
|
- Split monolithic build route handler into focused, maintainable modules
|
||||||
|
- Step-based wizard routes consolidated into dedicated module
|
||||||
|
- New build flow and quick build automation extracted into focused module
|
||||||
|
- Alternative card suggestions extracted to standalone module
|
||||||
|
- Compliance/enforcement and card replacement extracted to focused module
|
||||||
|
- Foundation for integrating custom exceptions into web layer
|
||||||
|
- **Service Layer Architecture**: Base classes, interfaces, and registry for service standardization
|
||||||
|
- `BaseService`, `StateService`, `DataService`, `CachedService` abstract base classes
|
||||||
|
- Service protocols/interfaces for type-safe dependency injection
|
||||||
|
- `ServiceRegistry` for singleton/factory/lazy service patterns
|
||||||
|
- `SessionManager` refactored from global dict to thread-safe `StateService`
|
||||||
|
- **Validation Framework**: Centralized Pydantic models and validators
|
||||||
|
- Pydantic models for all key request types (`BuildRequest`, `CommanderSearchRequest`, etc.)
|
||||||
|
- `CardNameValidator` with normalization for diacritics, punctuation, multi-face cards
|
||||||
|
- `ThemeValidator`, `PowerBracketValidator`, `ColorIdentityValidator`
|
||||||
|
- `ValidationMessages` class for consistent user-facing error messages
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Image Cache Status UI**: Setup page status stuck on "Checking…"
|
||||||
|
- Stale `.download_status.json` from a failed run caused indefinite spinner
|
||||||
|
- Added error state handling in JS to show "Last download failed" with message
|
||||||
|
- Status endpoint now auto-cleans stale file after download completion/failure
|
||||||
|
- Last download result persisted to `.last_download_result.json` across restarts
|
||||||
|
- Card count now shown correctly (was double-counting by summing both size variants)
|
||||||
|
- Shows "+N new cards" from last download run
|
||||||
|
- **Scryfall Bulk Data API**: HTTP 400 error when triggering image download
|
||||||
|
- Scryfall now requires `Accept: application/json` on API endpoints
|
||||||
|
- Fixed `ScryfallBulkDataClient._make_request()` to include the header
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- **Permalink Feature**: Removed permalink generation and restoration functionality
|
||||||
|
- Deemed unnecessary for single-session deck building workflow
|
||||||
|
- Users can still export decks (CSV/TXT/JSON) or use headless configs for automation
|
||||||
- **Template Validation Tests**: Comprehensive test suite for HTML/Jinja2 templates
|
- **Template Validation Tests**: Comprehensive test suite for HTML/Jinja2 templates
|
||||||
- Validates Jinja2 syntax across all templates
|
- Validates Jinja2 syntax across all templates
|
||||||
- Checks HTML structure (balanced tags, unique IDs, proper attributes)
|
- Checks HTML structure (balanced tags, unique IDs, proper attributes)
|
||||||
|
|
@ -92,6 +141,15 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
||||||
- Optimized linting rules for development workflow
|
- Optimized linting rules for development workflow
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- **Deck Summary Display**: Fixed issue where deck summary cards would not display correctly in manual builds
|
||||||
|
- Card images and names now appear properly in both List and Thumbnails views
|
||||||
|
- Commander card displayed correctly in Step 5 sidebar
|
||||||
|
- Summary data now properly persists across wizard stages
|
||||||
|
- **Multi-Copy Package Detection**: Fixed bug preventing multi-copy suggestions from appearing in New Deck wizard
|
||||||
|
- Corrected key mismatch between archetype definitions ('tagsAny') and detection code ('tags_any')
|
||||||
|
- Multi-copy panel now properly displays when commander and theme tags match supported archetypes (e.g., Hare Apparent for Rabbit Kindred + Tokens Matter)
|
||||||
|
- Updated panel background color to match theme (now uses CSS variable instead of hardcoded value)
|
||||||
|
- Affects all 12 multi-copy archetypes (Hare Apparent, Slime Against Humanity, Dragon's Approach, etc.)
|
||||||
- **Card Data Auto-Refresh**: Fixed stale data issue when new sets are released
|
- **Card Data Auto-Refresh**: Fixed stale data issue when new sets are released
|
||||||
- Auto-refresh now deletes cached raw parquet file before downloading fresh data
|
- Auto-refresh now deletes cached raw parquet file before downloading fresh data
|
||||||
- Ensures new sets are included instead of reprocessing old cached data
|
- Ensures new sets are included instead of reprocessing old cached data
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,9 @@ COPY mypy.ini .
|
||||||
# Tailwind source is already in code/web/static/tailwind.css from COPY code/
|
# Tailwind source is already in code/web/static/tailwind.css from COPY code/
|
||||||
# TypeScript sources are in code/web/static/ts/ from COPY code/
|
# TypeScript sources are in code/web/static/ts/ from COPY code/
|
||||||
|
|
||||||
# Force fresh CSS build by removing any copied styles.css
|
# Force fresh builds by removing any compiled artifacts
|
||||||
RUN rm -f ./code/web/static/styles.css
|
RUN rm -f ./code/web/static/styles.css
|
||||||
|
RUN rm -rf ./code/web/static/js/*.js ./code/web/static/js/*.js.map
|
||||||
|
|
||||||
# Build CSS and TypeScript
|
# Build CSS and TypeScript
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,34 @@
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Summary
|
### Summary
|
||||||
Web UI improvements with Tailwind CSS migration, TypeScript conversion, component library, template validation tests, enhanced code quality tools, and optional card image caching for faster performance and better maintainability.
|
Backend standardization infrastructure (M1-M5 complete): response builders, telemetry, service layer, validation framework, error handling integration, and testing standards. Web UI improvements with Tailwind CSS migration, TypeScript conversion, component library, template validation tests, enhanced code quality tools, and optional card image caching. Bug fixes for image cache UI, Scryfall API compatibility, and container startup errors.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- **Testing Standards Documentation**: Developer guide and base classes for writing new tests
|
||||||
|
- `docs/web_backend/testing.md` covers route tests, service tests, HTMX patterns, naming conventions, and coverage targets
|
||||||
|
- `code/tests/base_test_cases.py` provides `RouteTestCase`, `ServiceTestCase`, `ErrorHandlerTestCase`, `ValidationTestMixin`
|
||||||
|
- **Error Handling Integration**: Custom exceptions now wired into the web layer
|
||||||
|
- Typed domain exceptions get correct HTTP status codes (not always 500)
|
||||||
|
- HTMX requests receive HTML error fragments; API requests receive JSON
|
||||||
|
- New web-specific exceptions: `SessionExpiredError`, `BuildNotFoundError`, `FeatureDisabledError`
|
||||||
|
- Error handling guide at `docs/web_backend/error_handling.md`
|
||||||
|
- **Backend Standardization Framework**: Improved code organization and maintainability
|
||||||
|
- Response builder utilities for standardized HTTP/JSON/HTMX responses
|
||||||
|
- Telemetry decorators for automatic route tracking and error logging
|
||||||
|
- Route pattern documentation with examples and migration guide
|
||||||
|
- Modular route organization with focused, maintainable modules
|
||||||
|
- Step-based wizard routes consolidated into dedicated module
|
||||||
|
- New build flow and quick build automation extracted into focused module
|
||||||
|
- Alternative card suggestions extracted to standalone module
|
||||||
|
- Compliance/enforcement and card replacement extracted to focused module
|
||||||
|
- Foundation for integrating custom exception hierarchy
|
||||||
|
- Benefits: Easier to maintain, extend, and test backend code
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- **Permalink Feature**: Removed permalink generation and restoration functionality
|
||||||
|
- Deemed unnecessary for single-session deck building workflow
|
||||||
|
- Simplified UI by removing "Copy Permalink" and "Open Permalink" buttons
|
||||||
|
- Users can still export decks (CSV/TXT/JSON) or use headless JSON configs for automation
|
||||||
- **Template Validation Tests**: Comprehensive test suite ensuring HTML/template quality
|
- **Template Validation Tests**: Comprehensive test suite ensuring HTML/template quality
|
||||||
- Validates Jinja2 syntax and structure
|
- Validates Jinja2 syntax and structure
|
||||||
- Checks for common HTML issues (duplicate IDs, balanced tags)
|
- Checks for common HTML issues (duplicate IDs, balanced tags)
|
||||||
|
|
@ -89,6 +114,20 @@ Web UI improvements with Tailwind CSS migration, TypeScript conversion, componen
|
||||||
_None_
|
_None_
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- **Image Cache Status UI**: Setup page status stuck on "Checking…" after a failed download
|
||||||
|
- Error state now shown correctly with failure message
|
||||||
|
- Card count display fixed (was double-counting by summing both size variants)
|
||||||
|
- Last download run stats ("+N new cards") persist across container restarts
|
||||||
|
- **Scryfall Bulk Data API**: HTTP 400 error fixed by adding required `Accept: application/json` header
|
||||||
|
- **Deck Summary Display**: Fixed issue where deck summary cards would not display correctly in manual builds
|
||||||
|
- Card images and names now appear properly in both List and Thumbnails views
|
||||||
|
- Commander card displayed correctly in Step 5 sidebar
|
||||||
|
- Summary data now properly persists across wizard stages
|
||||||
|
- **Multi-Copy Package Detection**: Fixed multi-copy suggestions not appearing in New Deck wizard
|
||||||
|
- Multi-copy panel now properly displays when commander and theme tags match supported archetypes
|
||||||
|
- Example: Hare Apparent now appears when building with Rabbit Kindred + Tokens Matter themes
|
||||||
|
- Panel styling now matches current theme (dark/light mode support)
|
||||||
|
- Affects all 12 multi-copy archetypes in the system
|
||||||
- **Card Data Auto-Refresh**: Fixed stale data issue when new sets are released
|
- **Card Data Auto-Refresh**: Fixed stale data issue when new sets are released
|
||||||
- Auto-refresh now deletes cached raw parquet file before downloading fresh data
|
- Auto-refresh now deletes cached raw parquet file before downloading fresh data
|
||||||
- Ensures new sets are included instead of reprocessing old cached data
|
- Ensures new sets are included instead of reprocessing old cached data
|
||||||
|
|
|
||||||
|
|
@ -1034,7 +1034,7 @@ def detect_viable_multi_copy_archetypes(builder) -> list[dict]:
|
||||||
continue
|
continue
|
||||||
# Tag triggers
|
# Tag triggers
|
||||||
trig = meta.get('triggers', {}) or {}
|
trig = meta.get('triggers', {}) or {}
|
||||||
any_tags = _normalize_tags_list(trig.get('tags_any', []) or [])
|
any_tags = _normalize_tags_list(trig.get('tagsAny', []) or [])
|
||||||
all_tags = _normalize_tags_list(trig.get('tags_all', []) or [])
|
all_tags = _normalize_tags_list(trig.get('tags_all', []) or [])
|
||||||
score = 0
|
score = 0
|
||||||
reasons: list[str] = []
|
reasons: list[str] = []
|
||||||
|
|
|
||||||
|
|
@ -496,14 +496,15 @@ class CommanderValidationError(DeckBuilderError):
|
||||||
missing required fields, or contains inconsistent information.
|
missing required fields, or contains inconsistent information.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, message: str, details: dict | None = None):
|
def __init__(self, message: str, details: dict | None = None, *, code: str = "CMD_VALID"):
|
||||||
"""Initialize commander validation error.
|
"""Initialize commander validation error.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
message: Description of the validation failure
|
message: Description of the validation failure
|
||||||
details: Additional context about the error
|
details: Additional context about the error
|
||||||
|
code: Error code (overridable by subclasses)
|
||||||
"""
|
"""
|
||||||
super().__init__(message, code="CMD_VALID", details=details)
|
super().__init__(message, code=code, details=details)
|
||||||
|
|
||||||
class CommanderTypeError(CommanderValidationError):
|
class CommanderTypeError(CommanderValidationError):
|
||||||
"""Raised when commander type validation fails.
|
"""Raised when commander type validation fails.
|
||||||
|
|
@ -1394,3 +1395,29 @@ class ThemePoolError(DeckBuilderError):
|
||||||
code="THEME_POOL_ERR",
|
code="THEME_POOL_ERR",
|
||||||
details=details
|
details=details
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Web layer exceptions ---
|
||||||
|
|
||||||
|
class SessionExpiredError(DeckBuilderError):
|
||||||
|
"""Raised when a required session is missing or has expired."""
|
||||||
|
|
||||||
|
def __init__(self, sid: str | None = None, details: dict | None = None):
|
||||||
|
message = "Session has expired or could not be found"
|
||||||
|
super().__init__(message, code="SESSION_EXPIRED", details=details or {"sid": sid})
|
||||||
|
|
||||||
|
|
||||||
|
class BuildNotFoundError(DeckBuilderError):
|
||||||
|
"""Raised when a requested build result is not found in the session."""
|
||||||
|
|
||||||
|
def __init__(self, sid: str | None = None, details: dict | None = None):
|
||||||
|
message = "Build result not found; please start a new build"
|
||||||
|
super().__init__(message, code="BUILD_NOT_FOUND", details=details or {"sid": sid})
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureDisabledError(DeckBuilderError):
|
||||||
|
"""Raised when a feature is accessed but has been disabled via environment config."""
|
||||||
|
|
||||||
|
def __init__(self, feature: str, details: dict | None = None):
|
||||||
|
message = f"Feature '{feature}' is currently disabled"
|
||||||
|
super().__init__(message, code="FEATURE_DISABLED", details=details or {"feature": feature})
|
||||||
|
|
@ -88,10 +88,42 @@ class ImageCache:
|
||||||
self.client = ScryfallBulkDataClient()
|
self.client = ScryfallBulkDataClient()
|
||||||
self._last_download_time: float = 0.0
|
self._last_download_time: float = 0.0
|
||||||
|
|
||||||
|
# In-memory index of available images (avoids repeated filesystem checks)
|
||||||
|
# Key: (size, sanitized_filename), Value: True if exists
|
||||||
|
self._image_index: dict[tuple[str, str], bool] = {}
|
||||||
|
self._index_built = False
|
||||||
|
|
||||||
def is_enabled(self) -> bool:
|
def is_enabled(self) -> bool:
|
||||||
"""Check if image caching is enabled via environment variable."""
|
"""Check if image caching is enabled via environment variable."""
|
||||||
return os.getenv("CACHE_CARD_IMAGES", "0") == "1"
|
return os.getenv("CACHE_CARD_IMAGES", "0") == "1"
|
||||||
|
|
||||||
|
def _build_image_index(self) -> None:
|
||||||
|
"""
|
||||||
|
Build in-memory index of cached images to avoid repeated filesystem checks.
|
||||||
|
This dramatically improves performance by eliminating stat() calls for every image.
|
||||||
|
"""
|
||||||
|
if self._index_built or not self.is_enabled():
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("Building image cache index...")
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
for size in IMAGE_SIZES:
|
||||||
|
size_dir = self.base_dir / size
|
||||||
|
if not size_dir.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Scan directory for .jpg files
|
||||||
|
for image_file in size_dir.glob("*.jpg"):
|
||||||
|
# Store just the filename without extension
|
||||||
|
filename = image_file.stem
|
||||||
|
self._image_index[(size, filename)] = True
|
||||||
|
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
total_images = len(self._image_index)
|
||||||
|
logger.info(f"Image index built: {total_images} images indexed in {elapsed:.3f}s")
|
||||||
|
self._index_built = True
|
||||||
|
|
||||||
def get_image_path(self, card_name: str, size: str = "normal") -> Optional[Path]:
|
def get_image_path(self, card_name: str, size: str = "normal") -> Optional[Path]:
|
||||||
"""
|
"""
|
||||||
Get local path to cached image if it exists.
|
Get local path to cached image if it exists.
|
||||||
|
|
@ -106,11 +138,16 @@ class ImageCache:
|
||||||
if not self.is_enabled():
|
if not self.is_enabled():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
safe_name = sanitize_filename(card_name)
|
# Build index on first access (lazy initialization)
|
||||||
image_path = self.base_dir / size / f"{safe_name}.jpg"
|
if not self._index_built:
|
||||||
|
self._build_image_index()
|
||||||
|
|
||||||
|
safe_name = sanitize_filename(card_name)
|
||||||
|
|
||||||
|
# Check in-memory index first (fast)
|
||||||
|
if (size, safe_name) in self._image_index:
|
||||||
|
return self.base_dir / size / f"{safe_name}.jpg"
|
||||||
|
|
||||||
if image_path.exists():
|
|
||||||
return image_path
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_image_url(self, card_name: str, size: str = "normal") -> str:
|
def get_image_url(self, card_name: str, size: str = "normal") -> str:
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ class ScryfallBulkDataClient:
|
||||||
try:
|
try:
|
||||||
req = Request(url)
|
req = Request(url)
|
||||||
req.add_header("User-Agent", "MTG-Deckbuilder/3.0 (Image Cache)")
|
req.add_header("User-Agent", "MTG-Deckbuilder/3.0 (Image Cache)")
|
||||||
|
req.add_header("Accept", "application/json")
|
||||||
with urlopen(req, timeout=30) as response:
|
with urlopen(req, timeout=30) as response:
|
||||||
import json
|
import json
|
||||||
return json.loads(response.read().decode("utf-8"))
|
return json.loads(response.read().decode("utf-8"))
|
||||||
|
|
|
||||||
224
code/tests/base_test_cases.py
Normal file
224
code/tests/base_test_cases.py
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
"""Base test case classes for the MTG Python Deckbuilder web layer.
|
||||||
|
|
||||||
|
Provides reusable base classes and mixins that reduce boilerplate in route,
|
||||||
|
service, and validation tests. Import what you need — don't inherit everything.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from code.tests.base_test_cases import RouteTestCase, ServiceTestCase
|
||||||
|
|
||||||
|
class TestMyRoute(RouteTestCase):
|
||||||
|
def test_something(self):
|
||||||
|
resp = self.client.get("/my-route")
|
||||||
|
self.assert_ok(resp)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Route test base
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class RouteTestCase:
|
||||||
|
"""Base class for route integration tests.
|
||||||
|
|
||||||
|
Provides a shared TestClient and assertion helpers. Subclasses can override
|
||||||
|
`app_fixture` to use a different FastAPI app (e.g., a minimal test app).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
class TestBuildWizard(RouteTestCase):
|
||||||
|
def test_step1_renders(self):
|
||||||
|
resp = self.get("/build/step1")
|
||||||
|
self.assert_ok(resp)
|
||||||
|
assert "Commander" in resp.text
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def setup_client(self, monkeypatch):
|
||||||
|
"""Create a TestClient for the full app. Override to customise."""
|
||||||
|
from code.web.app import app
|
||||||
|
with TestClient(app) as c:
|
||||||
|
self.client = c
|
||||||
|
yield
|
||||||
|
|
||||||
|
# --- Shorthand request helpers ---
|
||||||
|
|
||||||
|
def get(self, path: str, *, headers: dict | None = None, cookies: dict | None = None, **params) -> Any:
|
||||||
|
return self.client.get(path, headers=headers or {}, cookies=cookies or {}, params=params or {})
|
||||||
|
|
||||||
|
def post(self, path: str, data: dict | None = None, *, json: dict | None = None,
|
||||||
|
headers: dict | None = None, cookies: dict | None = None) -> Any:
|
||||||
|
return self.client.post(path, data=data, json=json, headers=headers or {}, cookies=cookies or {})
|
||||||
|
|
||||||
|
def htmx_get(self, path: str, *, cookies: dict | None = None, **params) -> Any:
|
||||||
|
"""GET with HX-Request header set (simulates HTMX fetch)."""
|
||||||
|
return self.client.get(path, headers={"HX-Request": "true"}, cookies=cookies or {}, params=params or {})
|
||||||
|
|
||||||
|
def htmx_post(self, path: str, data: dict | None = None, *, cookies: dict | None = None) -> Any:
|
||||||
|
"""POST with HX-Request header set (simulates HTMX form submission)."""
|
||||||
|
return self.client.post(path, data=data, headers={"HX-Request": "true"}, cookies=cookies or {})
|
||||||
|
|
||||||
|
# --- Assertion helpers ---
|
||||||
|
|
||||||
|
def assert_ok(self, resp, *, contains: str | None = None) -> None:
|
||||||
|
assert resp.status_code == 200, f"Expected 200, got {resp.status_code}: {resp.text[:200]}"
|
||||||
|
if contains:
|
||||||
|
assert contains in resp.text, f"Expected {contains!r} in response"
|
||||||
|
|
||||||
|
def assert_status(self, resp, status: int) -> None:
|
||||||
|
assert resp.status_code == status, f"Expected {status}, got {resp.status_code}: {resp.text[:200]}"
|
||||||
|
|
||||||
|
def assert_json_error(self, resp, *, status: int, error_type: str | None = None) -> dict:
|
||||||
|
"""Assert a standardized error JSON response (M4 format)."""
|
||||||
|
assert resp.status_code == status
|
||||||
|
data = resp.json()
|
||||||
|
assert data.get("error") is True, f"Expected error=True in: {data}"
|
||||||
|
assert "request_id" in data
|
||||||
|
if error_type:
|
||||||
|
assert data.get("error_type") == error_type, f"Expected error_type={error_type!r}, got {data.get('error_type')!r}"
|
||||||
|
return data
|
||||||
|
|
||||||
|
def assert_redirect(self, resp, *, to: str | None = None) -> None:
|
||||||
|
assert resp.status_code in (301, 302, 303, 307, 308), f"Expected redirect, got {resp.status_code}"
|
||||||
|
if to:
|
||||||
|
assert to in resp.headers.get("location", "")
|
||||||
|
|
||||||
|
def with_session(self, commander: str = "Atraxa, Praetors' Voice", **extra) -> tuple[str, dict]:
|
||||||
|
"""Create a session with basic commander state. Returns (sid, session_dict)."""
|
||||||
|
from code.web.services.tasks import get_session, new_sid
|
||||||
|
sid = new_sid()
|
||||||
|
sess = get_session(sid)
|
||||||
|
sess["commander"] = {"name": commander, "ok": True}
|
||||||
|
sess.update(extra)
|
||||||
|
return sid, sess
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Error handler test base
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ErrorHandlerTestCase:
|
||||||
|
"""Base for tests targeting the DeckBuilderError → HTTP response pipeline.
|
||||||
|
|
||||||
|
Spins up a minimal FastAPI app with only the error handler — no routes from
|
||||||
|
the real app, so tests are fast and isolated.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
class TestMyErrors(ErrorHandlerTestCase):
|
||||||
|
def test_custom_error(self):
|
||||||
|
from code.exceptions import ThemeSelectionError
|
||||||
|
self._register_raiser("/raise", ThemeSelectionError("bad theme"))
|
||||||
|
resp = self.error_client.get("/raise")
|
||||||
|
self.assert_json_error(resp, status=400, error_type="ThemeSelectionError")
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def setup_error_app(self):
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from code.exceptions import DeckBuilderError
|
||||||
|
from code.web.utils.responses import deck_builder_error_response
|
||||||
|
|
||||||
|
self._mini_app = FastAPI()
|
||||||
|
self._raisers: dict[str, Exception] = {}
|
||||||
|
|
||||||
|
app = self._mini_app
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def handler(request: Request, exc: Exception):
|
||||||
|
if isinstance(exc, DeckBuilderError):
|
||||||
|
return deck_builder_error_response(request, exc)
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
return JSONResponse({"error": "unhandled", "detail": str(exc)}, status_code=500)
|
||||||
|
|
||||||
|
with TestClient(app, raise_server_exceptions=False) as c:
|
||||||
|
self.error_client = c
|
||||||
|
yield
|
||||||
|
|
||||||
|
def _register_raiser(self, path: str, exc: Exception) -> None:
|
||||||
|
"""Add a GET endpoint that raises `exc` when called."""
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
@self._mini_app.get(path)
|
||||||
|
async def _raiser(request: Request):
|
||||||
|
raise exc
|
||||||
|
|
||||||
|
# Rebuild the client after adding the route
|
||||||
|
with TestClient(self._mini_app, raise_server_exceptions=False) as c:
|
||||||
|
self.error_client = c
|
||||||
|
|
||||||
|
def assert_json_error(self, resp, *, status: int, error_type: str | None = None) -> dict:
|
||||||
|
assert resp.status_code == status, f"Expected {status}, got {resp.status_code}: {resp.text[:200]}"
|
||||||
|
data = resp.json()
|
||||||
|
assert data.get("error") is True
|
||||||
|
if error_type:
|
||||||
|
assert data.get("error_type") == error_type
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Service test base
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ServiceTestCase:
|
||||||
|
"""Base class for service unit tests.
|
||||||
|
|
||||||
|
Provides helpers for creating mock dependencies and asserting common
|
||||||
|
service behaviors. No TestClient — services are tested directly.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
class TestSessionManager(ServiceTestCase):
|
||||||
|
def test_new_session_is_empty(self):
|
||||||
|
from code.web.services.tasks import SessionManager
|
||||||
|
mgr = SessionManager()
|
||||||
|
sess = mgr.get("new-key")
|
||||||
|
assert sess == {}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def make_mock(self, **attrs) -> MagicMock:
|
||||||
|
"""Create a MagicMock with the given attributes pre-set."""
|
||||||
|
m = MagicMock()
|
||||||
|
for k, v in attrs.items():
|
||||||
|
setattr(m, k, v)
|
||||||
|
return m
|
||||||
|
|
||||||
|
def assert_raises(self, exc_type, fn, *args, **kwargs):
|
||||||
|
"""Assert that fn(*args, **kwargs) raises exc_type."""
|
||||||
|
with pytest.raises(exc_type):
|
||||||
|
fn(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Validation test mixin
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ValidationTestMixin:
|
||||||
|
"""Mixin for Pydantic model validation tests.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
class TestBuildRequest(ValidationTestMixin):
|
||||||
|
MODEL = BuildRequest
|
||||||
|
|
||||||
|
def test_commander_required(self):
|
||||||
|
self.assert_validation_error(commander="")
|
||||||
|
"""
|
||||||
|
|
||||||
|
MODEL = None # Set in subclass
|
||||||
|
|
||||||
|
def build(self, **kwargs) -> Any:
|
||||||
|
"""Instantiate MODEL with kwargs. Raises ValidationError on invalid input."""
|
||||||
|
assert self.MODEL is not None, "Set MODEL in your test class"
|
||||||
|
return self.MODEL(**kwargs)
|
||||||
|
|
||||||
|
def assert_validation_error(self, **kwargs) -> None:
|
||||||
|
"""Assert that MODEL(**kwargs) raises a Pydantic ValidationError."""
|
||||||
|
from pydantic import ValidationError
|
||||||
|
assert self.MODEL is not None
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
self.MODEL(**kwargs)
|
||||||
252
code/tests/test_error_handling.py
Normal file
252
code/tests/test_error_handling.py
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
"""Tests for M4 error handling integration.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- DeckBuilderError → HTTP response conversion
|
||||||
|
- HTMX vs JSON response detection
|
||||||
|
- Status code mapping for exception hierarchy
|
||||||
|
- app.py DeckBuilderError exception handler
|
||||||
|
- Web-specific exceptions (SessionExpiredError, BuildNotFoundError, FeatureDisabledError)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
|
||||||
|
from code.exceptions import (
|
||||||
|
DeckBuilderError,
|
||||||
|
CommanderValidationError,
|
||||||
|
CommanderTypeError,
|
||||||
|
ThemeSelectionError,
|
||||||
|
SessionExpiredError,
|
||||||
|
BuildNotFoundError,
|
||||||
|
FeatureDisabledError,
|
||||||
|
CSVFileNotFoundError,
|
||||||
|
PriceAPIError,
|
||||||
|
)
|
||||||
|
from code.web.utils.responses import (
|
||||||
|
deck_error_to_status,
|
||||||
|
deck_builder_error_response,
|
||||||
|
is_htmx_request,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Unit tests: exception → status mapping
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDeckErrorToStatus:
|
||||||
|
def test_commander_validation_400(self):
|
||||||
|
assert deck_error_to_status(CommanderValidationError("bad")) == 400
|
||||||
|
|
||||||
|
def test_commander_type_400(self):
|
||||||
|
assert deck_error_to_status(CommanderTypeError("bad type")) == 400
|
||||||
|
|
||||||
|
def test_theme_selection_400(self):
|
||||||
|
assert deck_error_to_status(ThemeSelectionError("bad theme")) == 400
|
||||||
|
|
||||||
|
def test_session_expired_401(self):
|
||||||
|
assert deck_error_to_status(SessionExpiredError()) == 401
|
||||||
|
|
||||||
|
def test_build_not_found_404(self):
|
||||||
|
assert deck_error_to_status(BuildNotFoundError()) == 404
|
||||||
|
|
||||||
|
def test_feature_disabled_404(self):
|
||||||
|
assert deck_error_to_status(FeatureDisabledError("test_feature")) == 404
|
||||||
|
|
||||||
|
def test_csv_file_not_found_503(self):
|
||||||
|
assert deck_error_to_status(CSVFileNotFoundError("cards.csv")) == 503
|
||||||
|
|
||||||
|
def test_price_api_error_503(self):
|
||||||
|
assert deck_error_to_status(PriceAPIError("http://x", 500)) == 503
|
||||||
|
|
||||||
|
def test_base_deck_builder_error_500(self):
|
||||||
|
assert deck_error_to_status(DeckBuilderError("generic")) == 500
|
||||||
|
|
||||||
|
def test_subclass_not_in_map_falls_back_via_mro(self):
|
||||||
|
# CommanderTypeError is a subclass of CommanderValidationError
|
||||||
|
exc = CommanderTypeError("oops")
|
||||||
|
assert deck_error_to_status(exc) == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Unit tests: HTMX detection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestIsHtmxRequest:
|
||||||
|
def _make_request(self, hx_header: str | None = None) -> Request:
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/test",
|
||||||
|
"query_string": b"",
|
||||||
|
"headers": [],
|
||||||
|
}
|
||||||
|
if hx_header is not None:
|
||||||
|
scope["headers"] = [(b"hx-request", hx_header.encode())]
|
||||||
|
return Request(scope)
|
||||||
|
|
||||||
|
def test_htmx_true_header(self):
|
||||||
|
req = self._make_request("true")
|
||||||
|
assert is_htmx_request(req) is True
|
||||||
|
|
||||||
|
def test_no_htmx_header(self):
|
||||||
|
req = self._make_request()
|
||||||
|
assert is_htmx_request(req) is False
|
||||||
|
|
||||||
|
def test_htmx_false_header(self):
|
||||||
|
req = self._make_request("false")
|
||||||
|
assert is_htmx_request(req) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Unit tests: deck_builder_error_response
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDeckBuilderErrorResponse:
|
||||||
|
def _make_request(self, htmx: bool = False) -> Request:
|
||||||
|
headers = []
|
||||||
|
if htmx:
|
||||||
|
headers.append((b"hx-request", b"true"))
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/build/new",
|
||||||
|
"query_string": b"",
|
||||||
|
"headers": headers,
|
||||||
|
}
|
||||||
|
req = Request(scope)
|
||||||
|
req.state.request_id = "test-rid-123"
|
||||||
|
return req
|
||||||
|
|
||||||
|
def test_json_response_structure(self):
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
req = self._make_request(htmx=False)
|
||||||
|
exc = CommanderValidationError("Invalid commander")
|
||||||
|
resp = deck_builder_error_response(req, exc)
|
||||||
|
assert isinstance(resp, JSONResponse)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
import json
|
||||||
|
body = json.loads(resp.body)
|
||||||
|
assert body["error"] is True
|
||||||
|
assert body["status"] == 400
|
||||||
|
assert body["error_type"] == "CommanderValidationError"
|
||||||
|
assert body["message"] == "Invalid commander"
|
||||||
|
assert body["request_id"] == "test-rid-123"
|
||||||
|
assert "timestamp" in body
|
||||||
|
assert "path" in body
|
||||||
|
|
||||||
|
def test_htmx_response_is_html(self):
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
req = self._make_request(htmx=True)
|
||||||
|
exc = ThemeSelectionError("Invalid theme")
|
||||||
|
resp = deck_builder_error_response(req, exc)
|
||||||
|
assert isinstance(resp, HTMLResponse)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "Invalid theme" in resp.body.decode()
|
||||||
|
assert resp.headers.get("X-Request-ID") == "test-rid-123"
|
||||||
|
|
||||||
|
def test_request_id_in_response_header(self):
|
||||||
|
req = self._make_request(htmx=False)
|
||||||
|
exc = BuildNotFoundError()
|
||||||
|
resp = deck_builder_error_response(req, exc)
|
||||||
|
assert resp.headers.get("X-Request-ID") == "test-rid-123"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Integration tests: app exception handler
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def test_app():
|
||||||
|
"""Minimal FastAPI app that raises DeckBuilderErrors for testing."""
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
@app.get("/raise/commander")
|
||||||
|
async def raise_commander(request: Request):
|
||||||
|
raise CommanderValidationError("Commander 'Foo' not found")
|
||||||
|
|
||||||
|
@app.get("/raise/session")
|
||||||
|
async def raise_session(request: Request):
|
||||||
|
raise SessionExpiredError(sid="abc123")
|
||||||
|
|
||||||
|
@app.get("/raise/feature")
|
||||||
|
async def raise_feature(request: Request):
|
||||||
|
raise FeatureDisabledError("partner_suggestions")
|
||||||
|
|
||||||
|
@app.get("/raise/generic")
|
||||||
|
async def raise_generic(request: Request):
|
||||||
|
raise DeckBuilderError("Something went wrong")
|
||||||
|
|
||||||
|
# Wire the same handler as app.py
|
||||||
|
from code.exceptions import DeckBuilderError as DBE
|
||||||
|
from code.web.utils.responses import deck_builder_error_response
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def handler(request: Request, exc: Exception):
|
||||||
|
if isinstance(exc, DBE):
|
||||||
|
return deck_builder_error_response(request, exc)
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
return JSONResponse({"error": "unhandled"}, status_code=500)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def client(test_app):
|
||||||
|
return TestClient(test_app, raise_server_exceptions=False)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppExceptionHandler:
|
||||||
|
def test_commander_validation_returns_400(self, client):
|
||||||
|
resp = client.get("/raise/commander")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
data = resp.json()
|
||||||
|
assert data["error"] is True
|
||||||
|
assert data["error_type"] == "CommanderValidationError"
|
||||||
|
assert "Commander 'Foo' not found" in data["message"]
|
||||||
|
|
||||||
|
def test_session_expired_returns_401(self, client):
|
||||||
|
resp = client.get("/raise/session")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
data = resp.json()
|
||||||
|
assert data["error_type"] == "SessionExpiredError"
|
||||||
|
|
||||||
|
def test_feature_disabled_returns_404(self, client):
|
||||||
|
resp = client.get("/raise/feature")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
data = resp.json()
|
||||||
|
assert data["error_type"] == "FeatureDisabledError"
|
||||||
|
|
||||||
|
def test_generic_deck_builder_error_returns_500(self, client):
|
||||||
|
resp = client.get("/raise/generic")
|
||||||
|
assert resp.status_code == 500
|
||||||
|
data = resp.json()
|
||||||
|
assert data["error_type"] == "DeckBuilderError"
|
||||||
|
|
||||||
|
def test_htmx_commander_error_returns_html(self, client):
|
||||||
|
resp = client.get("/raise/commander", headers={"HX-Request": "true"})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "text/html" in resp.headers.get("content-type", "")
|
||||||
|
assert "Commander 'Foo' not found" in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Web-specific exception constructors
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestWebExceptions:
|
||||||
|
def test_session_expired_has_code(self):
|
||||||
|
exc = SessionExpiredError(sid="xyz")
|
||||||
|
assert exc.code == "SESSION_EXPIRED"
|
||||||
|
assert "xyz" in str(exc.details)
|
||||||
|
|
||||||
|
def test_build_not_found_has_code(self):
|
||||||
|
exc = BuildNotFoundError(sid="abc")
|
||||||
|
assert exc.code == "BUILD_NOT_FOUND"
|
||||||
|
|
||||||
|
def test_feature_disabled_has_feature_name(self):
|
||||||
|
exc = FeatureDisabledError("partner_suggestions")
|
||||||
|
assert exc.code == "FEATURE_DISABLED"
|
||||||
|
assert "partner_suggestions" in exc.message
|
||||||
|
|
@ -96,7 +96,6 @@ def _write_dataset(path: Path) -> Path:
|
||||||
def _fresh_client(tmp_path: Path) -> tuple[TestClient, Path]:
|
def _fresh_client(tmp_path: Path) -> tuple[TestClient, Path]:
|
||||||
dataset_path = _write_dataset(tmp_path / "partner_synergy.json")
|
dataset_path = _write_dataset(tmp_path / "partner_synergy.json")
|
||||||
os.environ["ENABLE_PARTNER_MECHANICS"] = "1"
|
os.environ["ENABLE_PARTNER_MECHANICS"] = "1"
|
||||||
os.environ["ENABLE_PARTNER_SUGGESTIONS"] = "1"
|
|
||||||
for module_name in (
|
for module_name in (
|
||||||
"code.web.app",
|
"code.web.app",
|
||||||
"code.web.routes.partner_suggestions",
|
"code.web.routes.partner_suggestions",
|
||||||
|
|
@ -177,7 +176,6 @@ def test_partner_suggestions_api_returns_ranked_candidates(tmp_path: Path) -> No
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
os.environ.pop("ENABLE_PARTNER_MECHANICS", None)
|
os.environ.pop("ENABLE_PARTNER_MECHANICS", None)
|
||||||
os.environ.pop("ENABLE_PARTNER_SUGGESTIONS", None)
|
|
||||||
for module_name in (
|
for module_name in (
|
||||||
"code.web.app",
|
"code.web.app",
|
||||||
"code.web.routes.partner_suggestions",
|
"code.web.routes.partner_suggestions",
|
||||||
|
|
@ -245,7 +243,6 @@ def test_partner_suggestions_api_refresh_flag(monkeypatch) -> None:
|
||||||
from code.web.services.partner_suggestions import PartnerSuggestionResult
|
from code.web.services.partner_suggestions import PartnerSuggestionResult
|
||||||
|
|
||||||
monkeypatch.setattr(route, "ENABLE_PARTNER_MECHANICS", True)
|
monkeypatch.setattr(route, "ENABLE_PARTNER_MECHANICS", True)
|
||||||
monkeypatch.setattr(route, "ENABLE_PARTNER_SUGGESTIONS", True)
|
|
||||||
|
|
||||||
captured: dict[str, bool] = {"refresh": False}
|
captured: dict[str, bool] = {"refresh": False}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
from starlette.testclient import TestClient
|
|
||||||
|
|
||||||
|
|
||||||
def test_permalink_includes_locks_and_restores_notice(monkeypatch):
|
|
||||||
# Lazy import to ensure fresh app state
|
|
||||||
import importlib
|
|
||||||
app_module = importlib.import_module('code.web.app')
|
|
||||||
client = TestClient(app_module.app)
|
|
||||||
|
|
||||||
# Seed a session with a commander and locks by calling /build and directly touching session via cookie path
|
|
||||||
# Start a session
|
|
||||||
r = client.get('/build')
|
|
||||||
assert r.status_code == 200
|
|
||||||
|
|
||||||
# Now set some session state by invoking endpoints that mutate session
|
|
||||||
# Simulate selecting commander and a lock
|
|
||||||
# Use /build/from to load a permalink-like payload directly
|
|
||||||
payload = {
|
|
||||||
"commander": "Atraxa, Praetors' Voice",
|
|
||||||
"tags": ["proliferate"],
|
|
||||||
"bracket": 3,
|
|
||||||
"ideals": {"ramp": 10, "lands": 36, "basic_lands": 18, "creatures": 28, "removal": 10, "wipes": 3, "card_advantage": 8, "protection": 4},
|
|
||||||
"tag_mode": "AND",
|
|
||||||
"flags": {"owned_only": False, "prefer_owned": False},
|
|
||||||
"locks": ["Swords to Plowshares", "Sol Ring"],
|
|
||||||
}
|
|
||||||
raw = json.dumps(payload, separators=(",", ":")).encode('utf-8')
|
|
||||||
token = base64.urlsafe_b64encode(raw).decode('ascii').rstrip('=')
|
|
||||||
r2 = client.get(f'/build/from?state={token}')
|
|
||||||
assert r2.status_code == 200
|
|
||||||
# Step 4 should contain the locks restored chip
|
|
||||||
body = r2.text
|
|
||||||
assert 'locks restored' in body.lower()
|
|
||||||
|
|
||||||
# Ask the server for a permalink now and ensure locks are present
|
|
||||||
r3 = client.get('/build/permalink')
|
|
||||||
assert r3.status_code == 200
|
|
||||||
data = r3.json()
|
|
||||||
# Prefer decoded state when token not provided
|
|
||||||
state = data.get('state') or {}
|
|
||||||
assert 'locks' in state
|
|
||||||
assert set([s.lower() for s in state.get('locks', [])]) == {"swords to plowshares", "sol ring"}
|
|
||||||
407
code/tests/test_service_layer.py
Normal file
407
code/tests/test_service_layer.py
Normal file
|
|
@ -0,0 +1,407 @@
|
||||||
|
"""Tests for service layer base classes, interfaces, and registry."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import time
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
from code.web.services.base import (
|
||||||
|
BaseService,
|
||||||
|
StateService,
|
||||||
|
DataService,
|
||||||
|
CachedService,
|
||||||
|
ServiceError,
|
||||||
|
ValidationError,
|
||||||
|
NotFoundError,
|
||||||
|
)
|
||||||
|
from code.web.services.registry import ServiceRegistry, get_registry, reset_registry
|
||||||
|
from code.web.services.tasks import SessionManager
|
||||||
|
|
||||||
|
|
||||||
|
class TestBaseService:
|
||||||
|
"""Test BaseService abstract base class."""
|
||||||
|
|
||||||
|
def test_validation_helper(self):
|
||||||
|
"""Test _validate helper method."""
|
||||||
|
service = BaseService()
|
||||||
|
|
||||||
|
# Should not raise on True
|
||||||
|
service._validate(True, "Should not raise")
|
||||||
|
|
||||||
|
# Should raise on False
|
||||||
|
with pytest.raises(ValidationError, match="Should raise"):
|
||||||
|
service._validate(False, "Should raise")
|
||||||
|
|
||||||
|
|
||||||
|
class MockStateService(StateService):
|
||||||
|
"""Mock state service for testing."""
|
||||||
|
|
||||||
|
def _initialize_state(self, key: str) -> Dict[str, Any]:
|
||||||
|
return {"created": time.time(), "data": f"init-{key}"}
|
||||||
|
|
||||||
|
def _should_cleanup(self, key: str, state: Dict[str, Any]) -> bool:
|
||||||
|
# Cleanup if "expired" flag is set
|
||||||
|
return state.get("expired", False)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStateService:
|
||||||
|
"""Test StateService base class."""
|
||||||
|
|
||||||
|
def test_get_state_creates_new(self):
|
||||||
|
"""Test that get_state creates new state."""
|
||||||
|
service = MockStateService()
|
||||||
|
state = service.get_state("test-key")
|
||||||
|
|
||||||
|
assert "created" in state
|
||||||
|
assert state["data"] == "init-test-key"
|
||||||
|
|
||||||
|
def test_get_state_returns_existing(self):
|
||||||
|
"""Test that get_state returns existing state."""
|
||||||
|
service = MockStateService()
|
||||||
|
|
||||||
|
state1 = service.get_state("test-key")
|
||||||
|
state1["custom"] = "value"
|
||||||
|
|
||||||
|
state2 = service.get_state("test-key")
|
||||||
|
assert state2 is state1
|
||||||
|
assert state2["custom"] == "value"
|
||||||
|
|
||||||
|
def test_set_and_get_value(self):
|
||||||
|
"""Test setting and getting state values."""
|
||||||
|
service = MockStateService()
|
||||||
|
|
||||||
|
service.set_state_value("key1", "field1", "value1")
|
||||||
|
assert service.get_state_value("key1", "field1") == "value1"
|
||||||
|
assert service.get_state_value("key1", "missing", "default") == "default"
|
||||||
|
|
||||||
|
def test_cleanup_state(self):
|
||||||
|
"""Test cleanup of expired state."""
|
||||||
|
service = MockStateService()
|
||||||
|
|
||||||
|
# Create some state
|
||||||
|
service.get_state("keep1")
|
||||||
|
service.get_state("keep2")
|
||||||
|
service.get_state("expire1")
|
||||||
|
service.get_state("expire2")
|
||||||
|
|
||||||
|
# Mark some as expired
|
||||||
|
service.set_state_value("expire1", "expired", True)
|
||||||
|
service.set_state_value("expire2", "expired", True)
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
removed = service.cleanup_state()
|
||||||
|
assert removed == 2
|
||||||
|
|
||||||
|
# Verify expired are gone
|
||||||
|
state = service._state
|
||||||
|
assert "keep1" in state
|
||||||
|
assert "keep2" in state
|
||||||
|
assert "expire1" not in state
|
||||||
|
assert "expire2" not in state
|
||||||
|
|
||||||
|
|
||||||
|
class MockDataService(DataService[Dict[str, Any]]):
|
||||||
|
"""Mock data service for testing."""
|
||||||
|
|
||||||
|
def __init__(self, data: Dict[str, Any]):
|
||||||
|
super().__init__()
|
||||||
|
self._mock_data = data
|
||||||
|
|
||||||
|
def _load_data(self) -> Dict[str, Any]:
|
||||||
|
return self._mock_data.copy()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDataService:
|
||||||
|
"""Test DataService base class."""
|
||||||
|
|
||||||
|
def test_lazy_loading(self):
|
||||||
|
"""Test that data is loaded lazily."""
|
||||||
|
service = MockDataService({"key": "value"})
|
||||||
|
|
||||||
|
assert not service.is_loaded()
|
||||||
|
data = service.get_data()
|
||||||
|
assert service.is_loaded()
|
||||||
|
assert data["key"] == "value"
|
||||||
|
|
||||||
|
def test_cached_loading(self):
|
||||||
|
"""Test that data is cached after first load."""
|
||||||
|
service = MockDataService({"key": "value"})
|
||||||
|
|
||||||
|
data1 = service.get_data()
|
||||||
|
data1["modified"] = True
|
||||||
|
|
||||||
|
data2 = service.get_data()
|
||||||
|
assert data2 is data1
|
||||||
|
assert data2["modified"]
|
||||||
|
|
||||||
|
def test_force_reload(self):
|
||||||
|
"""Test force reload of data."""
|
||||||
|
service = MockDataService({"key": "value"})
|
||||||
|
|
||||||
|
data1 = service.get_data()
|
||||||
|
data1["modified"] = True
|
||||||
|
|
||||||
|
data2 = service.get_data(force_reload=True)
|
||||||
|
assert data2 is not data1
|
||||||
|
assert "modified" not in data2
|
||||||
|
|
||||||
|
|
||||||
|
class MockCachedService(CachedService[str, int]):
|
||||||
|
"""Mock cached service for testing."""
|
||||||
|
|
||||||
|
def __init__(self, ttl_seconds: int | None = None, max_size: int | None = None):
|
||||||
|
super().__init__(ttl_seconds=ttl_seconds, max_size=max_size)
|
||||||
|
self.compute_count = 0
|
||||||
|
|
||||||
|
def _compute_value(self, key: str) -> int:
|
||||||
|
self.compute_count += 1
|
||||||
|
return len(key)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCachedService:
|
||||||
|
"""Test CachedService base class."""
|
||||||
|
|
||||||
|
def test_cache_hit(self):
|
||||||
|
"""Test that values are cached."""
|
||||||
|
service = MockCachedService()
|
||||||
|
|
||||||
|
value1 = service.get("hello")
|
||||||
|
assert value1 == 5
|
||||||
|
assert service.compute_count == 1
|
||||||
|
|
||||||
|
value2 = service.get("hello")
|
||||||
|
assert value2 == 5
|
||||||
|
assert service.compute_count == 1 # Should not recompute
|
||||||
|
|
||||||
|
def test_cache_miss(self):
|
||||||
|
"""Test cache miss computes new value."""
|
||||||
|
service = MockCachedService()
|
||||||
|
|
||||||
|
value1 = service.get("hello")
|
||||||
|
value2 = service.get("world")
|
||||||
|
|
||||||
|
assert value1 == 5
|
||||||
|
assert value2 == 5
|
||||||
|
assert service.compute_count == 2
|
||||||
|
|
||||||
|
def test_ttl_expiration(self):
|
||||||
|
"""Test TTL-based expiration."""
|
||||||
|
service = MockCachedService(ttl_seconds=1)
|
||||||
|
|
||||||
|
value1 = service.get("hello")
|
||||||
|
assert service.compute_count == 1
|
||||||
|
|
||||||
|
# Should hit cache immediately
|
||||||
|
value2 = service.get("hello")
|
||||||
|
assert service.compute_count == 1
|
||||||
|
|
||||||
|
# Wait for expiration
|
||||||
|
time.sleep(1.1)
|
||||||
|
|
||||||
|
value3 = service.get("hello")
|
||||||
|
assert service.compute_count == 2 # Should recompute
|
||||||
|
|
||||||
|
def test_max_size_limit(self):
|
||||||
|
"""Test cache size limit."""
|
||||||
|
service = MockCachedService(max_size=2)
|
||||||
|
|
||||||
|
service.get("key1")
|
||||||
|
service.get("key2")
|
||||||
|
service.get("key3") # Should evict oldest (key1)
|
||||||
|
|
||||||
|
# key1 should be evicted
|
||||||
|
assert len(service._cache) == 2
|
||||||
|
assert "key1" not in service._cache
|
||||||
|
assert "key2" in service._cache
|
||||||
|
assert "key3" in service._cache
|
||||||
|
|
||||||
|
def test_invalidate_single(self):
|
||||||
|
"""Test invalidating single cache entry."""
|
||||||
|
service = MockCachedService()
|
||||||
|
|
||||||
|
service.get("key1")
|
||||||
|
service.get("key2")
|
||||||
|
|
||||||
|
service.invalidate("key1")
|
||||||
|
|
||||||
|
assert "key1" not in service._cache
|
||||||
|
assert "key2" in service._cache
|
||||||
|
|
||||||
|
def test_invalidate_all(self):
|
||||||
|
"""Test invalidating entire cache."""
|
||||||
|
service = MockCachedService()
|
||||||
|
|
||||||
|
service.get("key1")
|
||||||
|
service.get("key2")
|
||||||
|
|
||||||
|
service.invalidate()
|
||||||
|
|
||||||
|
assert len(service._cache) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class MockService:
|
||||||
|
"""Mock service for registry testing."""
|
||||||
|
|
||||||
|
def __init__(self, value: str):
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
|
||||||
|
class TestServiceRegistry:
|
||||||
|
"""Test ServiceRegistry for dependency injection."""
|
||||||
|
|
||||||
|
def test_register_and_get_singleton(self):
|
||||||
|
"""Test registering and retrieving singleton."""
|
||||||
|
registry = ServiceRegistry()
|
||||||
|
instance = MockService("test")
|
||||||
|
|
||||||
|
registry.register_singleton(MockService, instance)
|
||||||
|
retrieved = registry.get(MockService)
|
||||||
|
|
||||||
|
assert retrieved is instance
|
||||||
|
assert retrieved.value == "test"
|
||||||
|
|
||||||
|
def test_register_and_get_factory(self):
|
||||||
|
"""Test registering and retrieving from factory."""
|
||||||
|
registry = ServiceRegistry()
|
||||||
|
|
||||||
|
registry.register_factory(MockService, lambda: MockService("factory"))
|
||||||
|
|
||||||
|
instance1 = registry.get(MockService)
|
||||||
|
instance2 = registry.get(MockService)
|
||||||
|
|
||||||
|
assert instance1 is not instance2 # Factory creates new instances
|
||||||
|
assert instance1.value == "factory"
|
||||||
|
assert instance2.value == "factory"
|
||||||
|
|
||||||
|
def test_lazy_singleton(self):
|
||||||
|
"""Test lazy-initialized singleton."""
|
||||||
|
registry = ServiceRegistry()
|
||||||
|
call_count = {"count": 0}
|
||||||
|
|
||||||
|
def factory():
|
||||||
|
call_count["count"] += 1
|
||||||
|
return MockService("lazy")
|
||||||
|
|
||||||
|
registry.register_lazy_singleton(MockService, factory)
|
||||||
|
|
||||||
|
instance1 = registry.get(MockService)
|
||||||
|
assert call_count["count"] == 1
|
||||||
|
|
||||||
|
instance2 = registry.get(MockService)
|
||||||
|
assert call_count["count"] == 1 # Should not call factory again
|
||||||
|
assert instance1 is instance2
|
||||||
|
|
||||||
|
def test_duplicate_registration_error(self):
|
||||||
|
"""Test error on duplicate registration."""
|
||||||
|
registry = ServiceRegistry()
|
||||||
|
registry.register_singleton(MockService, MockService("first"))
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="already registered"):
|
||||||
|
registry.register_singleton(MockService, MockService("second"))
|
||||||
|
|
||||||
|
def test_get_unregistered_error(self):
|
||||||
|
"""Test error on getting unregistered service."""
|
||||||
|
registry = ServiceRegistry()
|
||||||
|
|
||||||
|
with pytest.raises(KeyError, match="not registered"):
|
||||||
|
registry.get(MockService)
|
||||||
|
|
||||||
|
def test_try_get(self):
|
||||||
|
"""Test try_get returns None for unregistered."""
|
||||||
|
registry = ServiceRegistry()
|
||||||
|
|
||||||
|
result = registry.try_get(MockService)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
registry.register_singleton(MockService, MockService("test"))
|
||||||
|
result = registry.try_get(MockService)
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
def test_is_registered(self):
|
||||||
|
"""Test checking if service is registered."""
|
||||||
|
registry = ServiceRegistry()
|
||||||
|
|
||||||
|
assert not registry.is_registered(MockService)
|
||||||
|
|
||||||
|
registry.register_singleton(MockService, MockService("test"))
|
||||||
|
assert registry.is_registered(MockService)
|
||||||
|
|
||||||
|
def test_unregister(self):
|
||||||
|
"""Test unregistering a service."""
|
||||||
|
registry = ServiceRegistry()
|
||||||
|
registry.register_singleton(MockService, MockService("test"))
|
||||||
|
|
||||||
|
assert registry.is_registered(MockService)
|
||||||
|
registry.unregister(MockService)
|
||||||
|
assert not registry.is_registered(MockService)
|
||||||
|
|
||||||
|
def test_clear(self):
|
||||||
|
"""Test clearing all services."""
|
||||||
|
registry = ServiceRegistry()
|
||||||
|
registry.register_singleton(MockService, MockService("test"))
|
||||||
|
|
||||||
|
registry.clear()
|
||||||
|
assert not registry.is_registered(MockService)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSessionManager:
|
||||||
|
"""Test SessionManager refactored service."""
|
||||||
|
|
||||||
|
def test_new_session_id(self):
|
||||||
|
"""Test creating new session IDs."""
|
||||||
|
manager = SessionManager()
|
||||||
|
|
||||||
|
sid1 = manager.new_session_id()
|
||||||
|
sid2 = manager.new_session_id()
|
||||||
|
|
||||||
|
assert isinstance(sid1, str)
|
||||||
|
assert isinstance(sid2, str)
|
||||||
|
assert sid1 != sid2
|
||||||
|
assert len(sid1) == 32 # UUID hex is 32 chars
|
||||||
|
|
||||||
|
def test_get_session_creates_new(self):
|
||||||
|
"""Test get_session with None creates new."""
|
||||||
|
manager = SessionManager()
|
||||||
|
|
||||||
|
session = manager.get_session(None)
|
||||||
|
assert "created" in session
|
||||||
|
assert "updated" in session
|
||||||
|
|
||||||
|
def test_get_session_returns_existing(self):
|
||||||
|
"""Test get_session returns existing session."""
|
||||||
|
manager = SessionManager()
|
||||||
|
|
||||||
|
sid = manager.new_session_id()
|
||||||
|
session1 = manager.get_session(sid)
|
||||||
|
session1["custom"] = "data"
|
||||||
|
|
||||||
|
session2 = manager.get_session(sid)
|
||||||
|
assert session2 is session1
|
||||||
|
assert session2["custom"] == "data"
|
||||||
|
|
||||||
|
def test_set_and_get_value(self):
|
||||||
|
"""Test setting and getting session values."""
|
||||||
|
manager = SessionManager()
|
||||||
|
sid = manager.new_session_id()
|
||||||
|
|
||||||
|
manager.set_value(sid, "key1", "value1")
|
||||||
|
assert manager.get_value(sid, "key1") == "value1"
|
||||||
|
assert manager.get_value(sid, "missing", "default") == "default"
|
||||||
|
|
||||||
|
def test_cleanup_expired_sessions(self):
|
||||||
|
"""Test cleanup of expired sessions."""
|
||||||
|
manager = SessionManager(ttl_seconds=1)
|
||||||
|
|
||||||
|
sid1 = manager.new_session_id()
|
||||||
|
sid2 = manager.new_session_id()
|
||||||
|
|
||||||
|
manager.get_session(sid1)
|
||||||
|
time.sleep(1.1) # Let sid1 expire
|
||||||
|
manager.get_session(sid2) # sid2 is fresh
|
||||||
|
|
||||||
|
removed = manager.cleanup_state()
|
||||||
|
assert removed == 1
|
||||||
|
|
||||||
|
# sid1 should be gone, sid2 should exist
|
||||||
|
assert sid1 not in manager._state
|
||||||
|
assert sid2 in manager._state
|
||||||
340
code/tests/test_validation.py
Normal file
340
code/tests/test_validation.py
Normal file
|
|
@ -0,0 +1,340 @@
|
||||||
|
"""Tests for validation framework (models, validators, card names)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from code.web.validation.models import (
|
||||||
|
BuildRequest,
|
||||||
|
CommanderSearchRequest,
|
||||||
|
ThemeValidationRequest,
|
||||||
|
OwnedCardsImportRequest,
|
||||||
|
BatchBuildRequest,
|
||||||
|
CardReplacementRequest,
|
||||||
|
PowerBracket,
|
||||||
|
OwnedMode,
|
||||||
|
CommanderPartnerType,
|
||||||
|
)
|
||||||
|
from code.web.validation.card_names import CardNameValidator
|
||||||
|
from code.web.validation.validators import (
|
||||||
|
ThemeValidator,
|
||||||
|
PowerBracketValidator,
|
||||||
|
ColorIdentityValidator,
|
||||||
|
)
|
||||||
|
from code.web.validation.messages import ValidationMessages, MSG
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildRequest:
|
||||||
|
"""Test BuildRequest Pydantic model."""
|
||||||
|
|
||||||
|
def test_minimal_valid_request(self):
|
||||||
|
"""Test minimal valid build request."""
|
||||||
|
req = BuildRequest(commander="Atraxa, Praetors' Voice")
|
||||||
|
|
||||||
|
assert req.commander == "Atraxa, Praetors' Voice"
|
||||||
|
assert req.themes == []
|
||||||
|
assert req.power_bracket == PowerBracket.BRACKET_2
|
||||||
|
assert req.owned_mode == OwnedMode.OFF
|
||||||
|
|
||||||
|
def test_full_valid_request(self):
|
||||||
|
"""Test fully populated build request."""
|
||||||
|
req = BuildRequest(
|
||||||
|
commander="Kess, Dissident Mage",
|
||||||
|
themes=["Spellslinger", "Graveyard"],
|
||||||
|
power_bracket=PowerBracket.BRACKET_3,
|
||||||
|
owned_mode=OwnedMode.PREFER,
|
||||||
|
must_include=["Counterspell", "Lightning Bolt"],
|
||||||
|
must_exclude=["Armageddon"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert req.commander == "Kess, Dissident Mage"
|
||||||
|
assert len(req.themes) == 2
|
||||||
|
assert req.power_bracket == PowerBracket.BRACKET_3
|
||||||
|
assert len(req.must_include) == 2
|
||||||
|
|
||||||
|
def test_commander_whitespace_stripped(self):
|
||||||
|
"""Test commander name whitespace is stripped."""
|
||||||
|
req = BuildRequest(commander=" Atraxa ")
|
||||||
|
assert req.commander == "Atraxa"
|
||||||
|
|
||||||
|
def test_commander_empty_fails(self):
|
||||||
|
"""Test empty commander name fails validation."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
BuildRequest(commander="")
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
BuildRequest(commander=" ")
|
||||||
|
|
||||||
|
def test_themes_deduplicated(self):
|
||||||
|
"""Test themes are deduplicated case-insensitively."""
|
||||||
|
req = BuildRequest(
|
||||||
|
commander="Test",
|
||||||
|
themes=["Spellslinger", "spellslinger", "SPELLSLINGER", "Tokens"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(req.themes) == 2
|
||||||
|
assert "Spellslinger" in req.themes
|
||||||
|
assert "Tokens" in req.themes
|
||||||
|
|
||||||
|
def test_partner_validation_requires_name(self):
|
||||||
|
"""Test partner mode requires partner name."""
|
||||||
|
with pytest.raises(ValidationError, match="Partner mode requires partner_name"):
|
||||||
|
BuildRequest(
|
||||||
|
commander="Kydele, Chosen of Kruphix",
|
||||||
|
partner_mode=CommanderPartnerType.PARTNER
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_partner_valid_with_name(self):
|
||||||
|
"""Test partner mode valid with name."""
|
||||||
|
req = BuildRequest(
|
||||||
|
commander="Kydele, Chosen of Kruphix",
|
||||||
|
partner_mode=CommanderPartnerType.PARTNER,
|
||||||
|
partner_name="Thrasios, Triton Hero"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert req.partner_mode == CommanderPartnerType.PARTNER
|
||||||
|
assert req.partner_name == "Thrasios, Triton Hero"
|
||||||
|
|
||||||
|
def test_background_requires_name(self):
|
||||||
|
"""Test background mode requires background name."""
|
||||||
|
with pytest.raises(ValidationError, match="Background mode requires background_name"):
|
||||||
|
BuildRequest(
|
||||||
|
commander="Erinis, Gloom Stalker",
|
||||||
|
partner_mode=CommanderPartnerType.BACKGROUND
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_custom_theme_requires_both(self):
|
||||||
|
"""Test custom theme requires both name and tags."""
|
||||||
|
with pytest.raises(ValidationError, match="Custom theme requires both name and tags"):
|
||||||
|
BuildRequest(
|
||||||
|
commander="Test",
|
||||||
|
custom_theme_name="My Theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError, match="Custom theme tags require theme name"):
|
||||||
|
BuildRequest(
|
||||||
|
commander="Test",
|
||||||
|
custom_theme_tags=["Tag1", "Tag2"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommanderSearchRequest:
|
||||||
|
"""Test CommanderSearchRequest model."""
|
||||||
|
|
||||||
|
def test_valid_search(self):
|
||||||
|
"""Test valid search request."""
|
||||||
|
req = CommanderSearchRequest(query="Atraxa")
|
||||||
|
assert req.query == "Atraxa"
|
||||||
|
assert req.limit == 10
|
||||||
|
|
||||||
|
def test_custom_limit(self):
|
||||||
|
"""Test custom limit."""
|
||||||
|
req = CommanderSearchRequest(query="Test", limit=25)
|
||||||
|
assert req.limit == 25
|
||||||
|
|
||||||
|
def test_empty_query_fails(self):
|
||||||
|
"""Test empty query fails."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
CommanderSearchRequest(query="")
|
||||||
|
|
||||||
|
def test_limit_bounds(self):
|
||||||
|
"""Test limit must be within bounds."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
CommanderSearchRequest(query="Test", limit=0)
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
CommanderSearchRequest(query="Test", limit=101)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCardNameValidator:
|
||||||
|
"""Test card name validation and normalization."""
|
||||||
|
|
||||||
|
def test_normalize_lowercase(self):
|
||||||
|
"""Test normalization converts to lowercase."""
|
||||||
|
assert CardNameValidator.normalize("Atraxa, Praetors' Voice") == "atraxa, praetors' voice"
|
||||||
|
|
||||||
|
def test_normalize_removes_diacritics(self):
|
||||||
|
"""Test normalization removes diacritics."""
|
||||||
|
assert CardNameValidator.normalize("Dánitha Capashen") == "danitha capashen"
|
||||||
|
assert CardNameValidator.normalize("Gisela, the Broken Blade") == "gisela, the broken blade"
|
||||||
|
|
||||||
|
def test_normalize_standardizes_apostrophes(self):
|
||||||
|
"""Test normalization standardizes apostrophes."""
|
||||||
|
assert CardNameValidator.normalize("Atraxa, Praetors' Voice") == CardNameValidator.normalize("Atraxa, Praetors' Voice")
|
||||||
|
assert CardNameValidator.normalize("Atraxa, Praetors` Voice") == CardNameValidator.normalize("Atraxa, Praetors' Voice")
|
||||||
|
|
||||||
|
def test_normalize_collapses_whitespace(self):
|
||||||
|
"""Test normalization collapses whitespace."""
|
||||||
|
assert CardNameValidator.normalize("Test Card") == "test card"
|
||||||
|
assert CardNameValidator.normalize(" Test ") == "test"
|
||||||
|
|
||||||
|
def test_validator_caches_normalization(self):
|
||||||
|
"""Test validator caches normalized lookups."""
|
||||||
|
validator = CardNameValidator()
|
||||||
|
validator._card_names = {"Atraxa, Praetors' Voice"}
|
||||||
|
validator._normalized_map = {
|
||||||
|
"atraxa, praetors' voice": "Atraxa, Praetors' Voice"
|
||||||
|
}
|
||||||
|
validator._loaded = True
|
||||||
|
|
||||||
|
# Should find exact match
|
||||||
|
assert validator.is_valid("Atraxa, Praetors' Voice")
|
||||||
|
|
||||||
|
|
||||||
|
class TestThemeValidator:
|
||||||
|
"""Test theme validation."""
|
||||||
|
|
||||||
|
def test_validate_themes_separates_valid_invalid(self):
|
||||||
|
"""Test validation separates valid from invalid themes."""
|
||||||
|
validator = ThemeValidator()
|
||||||
|
validator._themes = {"Spellslinger", "spellslinger", "Tokens", "tokens"}
|
||||||
|
validator._loaded = True
|
||||||
|
|
||||||
|
valid, invalid = validator.validate_themes(["Spellslinger", "Invalid", "Tokens"])
|
||||||
|
|
||||||
|
assert "Spellslinger" in valid
|
||||||
|
assert "Tokens" in valid
|
||||||
|
assert "Invalid" in invalid
|
||||||
|
|
||||||
|
|
||||||
|
class TestPowerBracketValidator:
|
||||||
|
"""Test power bracket validation."""
|
||||||
|
|
||||||
|
def test_valid_brackets(self):
|
||||||
|
"""Test valid bracket values (1-4)."""
|
||||||
|
assert PowerBracketValidator.is_valid_bracket(1)
|
||||||
|
assert PowerBracketValidator.is_valid_bracket(2)
|
||||||
|
assert PowerBracketValidator.is_valid_bracket(3)
|
||||||
|
assert PowerBracketValidator.is_valid_bracket(4)
|
||||||
|
|
||||||
|
def test_invalid_brackets(self):
|
||||||
|
"""Test invalid bracket values."""
|
||||||
|
assert not PowerBracketValidator.is_valid_bracket(0)
|
||||||
|
assert not PowerBracketValidator.is_valid_bracket(5)
|
||||||
|
assert not PowerBracketValidator.is_valid_bracket(-1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestColorIdentityValidator:
|
||||||
|
"""Test color identity validation."""
|
||||||
|
|
||||||
|
def test_parse_comma_separated(self):
|
||||||
|
"""Test parsing comma-separated colors."""
|
||||||
|
colors = ColorIdentityValidator.parse_colors("W,U,B")
|
||||||
|
assert colors == {"W", "U", "B"}
|
||||||
|
|
||||||
|
def test_parse_concatenated(self):
|
||||||
|
"""Test parsing concatenated colors."""
|
||||||
|
colors = ColorIdentityValidator.parse_colors("WUB")
|
||||||
|
assert colors == {"W", "U", "B"}
|
||||||
|
|
||||||
|
def test_parse_empty(self):
|
||||||
|
"""Test parsing empty string."""
|
||||||
|
colors = ColorIdentityValidator.parse_colors("")
|
||||||
|
assert colors == set()
|
||||||
|
|
||||||
|
def test_colorless_subset_any(self):
|
||||||
|
"""Test colorless cards valid in any deck."""
|
||||||
|
validator = ColorIdentityValidator()
|
||||||
|
assert validator.is_subset({"C"}, {"W", "U"})
|
||||||
|
assert validator.is_subset(set(), {"R", "G"})
|
||||||
|
|
||||||
|
def test_subset_validation(self):
|
||||||
|
"""Test subset validation."""
|
||||||
|
validator = ColorIdentityValidator()
|
||||||
|
|
||||||
|
# Valid: card colors subset of commander
|
||||||
|
assert validator.is_subset({"W", "U"}, {"W", "U", "B"})
|
||||||
|
|
||||||
|
# Invalid: card has colors not in commander
|
||||||
|
assert not validator.is_subset({"W", "U", "B"}, {"W", "U"})
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidationMessages:
|
||||||
|
"""Test validation message formatting."""
|
||||||
|
|
||||||
|
def test_format_commander_invalid(self):
|
||||||
|
"""Test commander invalid message formatting."""
|
||||||
|
msg = MSG.format_commander_invalid("Test Commander")
|
||||||
|
assert "Test Commander" in msg
|
||||||
|
assert "not found" in msg
|
||||||
|
|
||||||
|
def test_format_themes_invalid(self):
|
||||||
|
"""Test multiple invalid themes formatting."""
|
||||||
|
msg = MSG.format_themes_invalid(["Theme1", "Theme2"])
|
||||||
|
assert "Theme1" in msg
|
||||||
|
assert "Theme2" in msg
|
||||||
|
|
||||||
|
def test_format_bracket_exceeded(self):
|
||||||
|
"""Test bracket exceeded message formatting."""
|
||||||
|
msg = MSG.format_bracket_exceeded("Mana Crypt", 4, 2)
|
||||||
|
assert "Mana Crypt" in msg
|
||||||
|
assert "4" in msg
|
||||||
|
assert "2" in msg
|
||||||
|
|
||||||
|
def test_format_color_mismatch(self):
|
||||||
|
"""Test color mismatch message formatting."""
|
||||||
|
msg = MSG.format_color_mismatch("Card", "WUB", "WU")
|
||||||
|
assert "Card" in msg
|
||||||
|
assert "WUB" in msg
|
||||||
|
assert "WU" in msg
|
||||||
|
|
||||||
|
|
||||||
|
class TestBatchBuildRequest:
|
||||||
|
"""Test batch build request validation."""
|
||||||
|
|
||||||
|
def test_valid_batch(self):
|
||||||
|
"""Test valid batch request."""
|
||||||
|
base = BuildRequest(commander="Test")
|
||||||
|
req = BatchBuildRequest(base_config=base, count=5)
|
||||||
|
|
||||||
|
assert req.count == 5
|
||||||
|
assert req.base_config.commander == "Test"
|
||||||
|
|
||||||
|
def test_count_limit(self):
|
||||||
|
"""Test batch count limit."""
|
||||||
|
base = BuildRequest(commander="Test")
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
BatchBuildRequest(base_config=base, count=11)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCardReplacementRequest:
|
||||||
|
"""Test card replacement request validation."""
|
||||||
|
|
||||||
|
def test_valid_replacement(self):
|
||||||
|
"""Test valid replacement request."""
|
||||||
|
req = CardReplacementRequest(card_name="Sol Ring", reason="Too powerful")
|
||||||
|
|
||||||
|
assert req.card_name == "Sol Ring"
|
||||||
|
assert req.reason == "Too powerful"
|
||||||
|
|
||||||
|
def test_whitespace_stripped(self):
|
||||||
|
"""Test whitespace is stripped."""
|
||||||
|
req = CardReplacementRequest(card_name=" Sol Ring ")
|
||||||
|
assert req.card_name == "Sol Ring"
|
||||||
|
|
||||||
|
def test_empty_name_fails(self):
|
||||||
|
"""Test empty card name fails."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
CardReplacementRequest(card_name="")
|
||||||
|
|
||||||
|
|
||||||
|
class TestOwnedCardsImportRequest:
|
||||||
|
"""Test owned cards import validation."""
|
||||||
|
|
||||||
|
def test_valid_import(self):
|
||||||
|
"""Test valid import request."""
|
||||||
|
req = OwnedCardsImportRequest(format_type="csv", content="Name\nSol Ring\n")
|
||||||
|
|
||||||
|
assert req.format_type == "csv"
|
||||||
|
assert "Sol Ring" in req.content
|
||||||
|
|
||||||
|
def test_invalid_format(self):
|
||||||
|
"""Test invalid format fails."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
OwnedCardsImportRequest(format_type="invalid", content="test")
|
||||||
|
|
||||||
|
def test_empty_content_fails(self):
|
||||||
|
"""Test empty content fails."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
OwnedCardsImportRequest(format_type="csv", content="")
|
||||||
|
|
@ -22,6 +22,8 @@ from .services.combo_utils import detect_all as _detect_all
|
||||||
from .services.theme_catalog_loader import prewarm_common_filters, load_index
|
from .services.theme_catalog_loader import prewarm_common_filters, load_index
|
||||||
from .services.commander_catalog_loader import load_commander_catalog
|
from .services.commander_catalog_loader import load_commander_catalog
|
||||||
from .services.tasks import get_session, new_sid, set_session_value
|
from .services.tasks import get_session, new_sid, set_session_value
|
||||||
|
from code.exceptions import DeckBuilderError
|
||||||
|
from .utils.responses import deck_builder_error_response
|
||||||
|
|
||||||
# Logger for app-level logging
|
# Logger for app-level logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -166,6 +168,7 @@ SHOW_SETUP = _as_bool(os.getenv("SHOW_SETUP"), True)
|
||||||
SHOW_DIAGNOSTICS = _as_bool(os.getenv("SHOW_DIAGNOSTICS"), False)
|
SHOW_DIAGNOSTICS = _as_bool(os.getenv("SHOW_DIAGNOSTICS"), False)
|
||||||
SHOW_COMMANDERS = _as_bool(os.getenv("SHOW_COMMANDERS"), True)
|
SHOW_COMMANDERS = _as_bool(os.getenv("SHOW_COMMANDERS"), True)
|
||||||
SHOW_VIRTUALIZE = _as_bool(os.getenv("WEB_VIRTUALIZE"), False)
|
SHOW_VIRTUALIZE = _as_bool(os.getenv("WEB_VIRTUALIZE"), False)
|
||||||
|
CACHE_CARD_IMAGES = _as_bool(os.getenv("CACHE_CARD_IMAGES"), False)
|
||||||
ENABLE_THEMES = _as_bool(os.getenv("ENABLE_THEMES"), True)
|
ENABLE_THEMES = _as_bool(os.getenv("ENABLE_THEMES"), True)
|
||||||
ENABLE_PWA = _as_bool(os.getenv("ENABLE_PWA"), False)
|
ENABLE_PWA = _as_bool(os.getenv("ENABLE_PWA"), False)
|
||||||
ENABLE_PRESETS = _as_bool(os.getenv("ENABLE_PRESETS"), False)
|
ENABLE_PRESETS = _as_bool(os.getenv("ENABLE_PRESETS"), False)
|
||||||
|
|
@ -173,8 +176,7 @@ ALLOW_MUST_HAVES = _as_bool(os.getenv("ALLOW_MUST_HAVES"), True)
|
||||||
SHOW_MUST_HAVE_BUTTONS = _as_bool(os.getenv("SHOW_MUST_HAVE_BUTTONS"), False)
|
SHOW_MUST_HAVE_BUTTONS = _as_bool(os.getenv("SHOW_MUST_HAVE_BUTTONS"), False)
|
||||||
ENABLE_CUSTOM_THEMES = _as_bool(os.getenv("ENABLE_CUSTOM_THEMES"), True)
|
ENABLE_CUSTOM_THEMES = _as_bool(os.getenv("ENABLE_CUSTOM_THEMES"), True)
|
||||||
WEB_IDEALS_UI = os.getenv("WEB_IDEALS_UI", "slider").strip().lower() # 'input' or 'slider'
|
WEB_IDEALS_UI = os.getenv("WEB_IDEALS_UI", "slider").strip().lower() # 'input' or 'slider'
|
||||||
ENABLE_PARTNER_MECHANICS = _as_bool(os.getenv("ENABLE_PARTNER_MECHANICS"), True)
|
ENABLE_PARTNER_MECHANICS = _as_bool(os.getenv("ENABLE_PARTNER_GESTIONS"), True)
|
||||||
ENABLE_PARTNER_SUGGESTIONS = _as_bool(os.getenv("ENABLE_PARTNER_SUGGESTIONS"), True)
|
|
||||||
ENABLE_BATCH_BUILD = _as_bool(os.getenv("ENABLE_BATCH_BUILD"), True)
|
ENABLE_BATCH_BUILD = _as_bool(os.getenv("ENABLE_BATCH_BUILD"), True)
|
||||||
RANDOM_MODES = _as_bool(os.getenv("RANDOM_MODES"), True) # initial snapshot (legacy)
|
RANDOM_MODES = _as_bool(os.getenv("RANDOM_MODES"), True) # initial snapshot (legacy)
|
||||||
RANDOM_UI = _as_bool(os.getenv("RANDOM_UI"), True)
|
RANDOM_UI = _as_bool(os.getenv("RANDOM_UI"), True)
|
||||||
|
|
@ -310,13 +312,12 @@ templates.env.globals.update({
|
||||||
"enable_presets": ENABLE_PRESETS,
|
"enable_presets": ENABLE_PRESETS,
|
||||||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||||
"enable_partner_mechanics": ENABLE_PARTNER_MECHANICS,
|
"enable_partner_mechanics": ENABLE_PARTNER_MECHANICS,
|
||||||
"enable_partner_suggestions": ENABLE_PARTNER_SUGGESTIONS,
|
|
||||||
"allow_must_haves": ALLOW_MUST_HAVES,
|
"allow_must_haves": ALLOW_MUST_HAVES,
|
||||||
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
|
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
|
||||||
"default_theme": DEFAULT_THEME,
|
"default_theme": DEFAULT_THEME,
|
||||||
"random_modes": RANDOM_MODES,
|
"random_modes": RANDOM_MODES,
|
||||||
"random_ui": RANDOM_UI,
|
"random_ui": RANDOM_UI,
|
||||||
"random_max_attempts": RANDOM_MAX_ATTEMPTS,
|
"card_images_cached": CACHE_CARD_IMAGES,
|
||||||
"random_timeout_ms": RANDOM_TIMEOUT_MS,
|
"random_timeout_ms": RANDOM_TIMEOUT_MS,
|
||||||
"random_reroll_throttle_ms": int(RANDOM_REROLL_THROTTLE_MS),
|
"random_reroll_throttle_ms": int(RANDOM_REROLL_THROTTLE_MS),
|
||||||
"theme_picker_diagnostics": THEME_PICKER_DIAGNOSTICS,
|
"theme_picker_diagnostics": THEME_PICKER_DIAGNOSTICS,
|
||||||
|
|
@ -2256,6 +2257,15 @@ async def setup_status():
|
||||||
|
|
||||||
# Routers
|
# Routers
|
||||||
from .routes import build as build_routes # noqa: E402
|
from .routes import build as build_routes # noqa: E402
|
||||||
|
from .routes import build_validation as build_validation_routes # noqa: E402
|
||||||
|
from .routes import build_multicopy as build_multicopy_routes # noqa: E402
|
||||||
|
from .routes import build_include_exclude as build_include_exclude_routes # noqa: E402
|
||||||
|
from .routes import build_themes as build_themes_routes # noqa: E402
|
||||||
|
from .routes import build_partners as build_partners_routes # noqa: E402
|
||||||
|
from .routes import build_wizard as build_wizard_routes # noqa: E402
|
||||||
|
from .routes import build_newflow as build_newflow_routes # noqa: E402
|
||||||
|
from .routes import build_alternatives as build_alternatives_routes # noqa: E402
|
||||||
|
from .routes import build_compliance as build_compliance_routes # noqa: E402
|
||||||
from .routes import configs as config_routes # noqa: E402
|
from .routes import configs as config_routes # noqa: E402
|
||||||
from .routes import decks as decks_routes # noqa: E402
|
from .routes import decks as decks_routes # noqa: E402
|
||||||
from .routes import setup as setup_routes # noqa: E402
|
from .routes import setup as setup_routes # noqa: E402
|
||||||
|
|
@ -2269,6 +2279,15 @@ from .routes import card_browser as card_browser_routes # noqa: E402
|
||||||
from .routes import compare as compare_routes # noqa: E402
|
from .routes import compare as compare_routes # noqa: E402
|
||||||
from .routes import api as api_routes # noqa: E402
|
from .routes import api as api_routes # noqa: E402
|
||||||
app.include_router(build_routes.router)
|
app.include_router(build_routes.router)
|
||||||
|
app.include_router(build_validation_routes.router, prefix="/build")
|
||||||
|
app.include_router(build_multicopy_routes.router, prefix="/build")
|
||||||
|
app.include_router(build_include_exclude_routes.router, prefix="/build")
|
||||||
|
app.include_router(build_themes_routes.router, prefix="/build")
|
||||||
|
app.include_router(build_partners_routes.router, prefix="/build")
|
||||||
|
app.include_router(build_wizard_routes.router, prefix="/build")
|
||||||
|
app.include_router(build_newflow_routes.router, prefix="/build")
|
||||||
|
app.include_router(build_alternatives_routes.router)
|
||||||
|
app.include_router(build_compliance_routes.router)
|
||||||
app.include_router(config_routes.router)
|
app.include_router(config_routes.router)
|
||||||
app.include_router(decks_routes.router)
|
app.include_router(decks_routes.router)
|
||||||
app.include_router(setup_routes.router)
|
app.include_router(setup_routes.router)
|
||||||
|
|
@ -2284,7 +2303,7 @@ app.include_router(api_routes.router)
|
||||||
|
|
||||||
# Warm validation cache early to reduce first-call latency in tests and dev
|
# Warm validation cache early to reduce first-call latency in tests and dev
|
||||||
try:
|
try:
|
||||||
build_routes.warm_validation_name_cache()
|
build_validation_routes.warm_validation_name_cache()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
@ -2386,6 +2405,13 @@ async def starlette_http_exception_handler(request: Request, exc: StarletteHTTPE
|
||||||
|
|
||||||
@app.exception_handler(Exception)
|
@app.exception_handler(Exception)
|
||||||
async def unhandled_exception_handler(request: Request, exc: Exception):
|
async def unhandled_exception_handler(request: Request, exc: Exception):
|
||||||
|
# Handle DeckBuilderError subclasses with structured responses before falling to 500
|
||||||
|
if isinstance(exc, DeckBuilderError):
|
||||||
|
rid = getattr(request.state, "request_id", None) or uuid.uuid4().hex
|
||||||
|
logging.getLogger("web").warning(
|
||||||
|
f"DeckBuilderError [rid={rid}] {type(exc).__name__} {request.method} {request.url.path}: {exc}"
|
||||||
|
)
|
||||||
|
return deck_builder_error_response(request, exc)
|
||||||
rid = getattr(request.state, "request_id", None) or uuid.uuid4().hex
|
rid = getattr(request.state, "request_id", None) or uuid.uuid4().hex
|
||||||
logging.getLogger("web").error(
|
logging.getLogger("web").error(
|
||||||
f"Unhandled exception [rid={rid}] {request.method} {request.url.path}", exc_info=True
|
f"Unhandled exception [rid={rid}] {request.method} {request.url.path}", exc_info=True
|
||||||
|
|
|
||||||
1
code/web/decorators/__init__.py
Normal file
1
code/web/decorators/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Decorators for route handlers."""
|
||||||
97
code/web/decorators/telemetry.py
Normal file
97
code/web/decorators/telemetry.py
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
"""Telemetry decorators for route handlers.
|
||||||
|
|
||||||
|
Provides decorators to automatically track route access, build times, and other metrics.
|
||||||
|
"""
|
||||||
|
from functools import wraps
|
||||||
|
from typing import Callable, Any
|
||||||
|
import time
|
||||||
|
from code.logging_util import get_logger
|
||||||
|
|
||||||
|
LOGGER = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def track_route_access(event_name: str):
|
||||||
|
"""Decorator to track route access with telemetry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event_name: Name of the telemetry event to log
|
||||||
|
|
||||||
|
Example:
|
||||||
|
@router.get("/build/new")
|
||||||
|
@track_route_access("build_start")
|
||||||
|
async def start_build(request: Request):
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
def decorator(func: Callable) -> Callable:
|
||||||
|
@wraps(func)
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
result = await func(*args, **kwargs)
|
||||||
|
elapsed_ms = int((time.time() - start_time) * 1000)
|
||||||
|
LOGGER.debug(f"Route {event_name} completed in {elapsed_ms}ms")
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
elapsed_ms = int((time.time() - start_time) * 1000)
|
||||||
|
LOGGER.error(f"Route {event_name} failed after {elapsed_ms}ms: {e}")
|
||||||
|
raise
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def track_build_time(operation: str):
|
||||||
|
"""Decorator to track deck building operation timing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
operation: Description of the build operation
|
||||||
|
|
||||||
|
Example:
|
||||||
|
@track_build_time("commander_selection")
|
||||||
|
async def select_commander(request: Request):
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
def decorator(func: Callable) -> Callable:
|
||||||
|
@wraps(func)
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
start_time = time.time()
|
||||||
|
result = await func(*args, **kwargs)
|
||||||
|
elapsed_ms = int((time.time() - start_time) * 1000)
|
||||||
|
LOGGER.info(f"Build operation '{operation}' took {elapsed_ms}ms")
|
||||||
|
return result
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def log_route_errors(route_name: str):
|
||||||
|
"""Decorator to log route errors with context.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
route_name: Name of the route for error context
|
||||||
|
|
||||||
|
Example:
|
||||||
|
@router.post("/build/create")
|
||||||
|
@log_route_errors("build_create")
|
||||||
|
async def create_deck(request: Request):
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
def decorator(func: Callable) -> Callable:
|
||||||
|
@wraps(func)
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
try:
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
# Extract request if available
|
||||||
|
request = None
|
||||||
|
for arg in args:
|
||||||
|
if hasattr(arg, "url") and hasattr(arg, "state"):
|
||||||
|
request = arg
|
||||||
|
break
|
||||||
|
|
||||||
|
request_id = getattr(request.state, "request_id", "unknown") if request else "unknown"
|
||||||
|
LOGGER.error(
|
||||||
|
f"Error in route '{route_name}' [request_id={request_id}]: {e}",
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
1
code/web/middleware/__init__.py
Normal file
1
code/web/middleware/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Middleware modules for the web application."""
|
||||||
|
|
@ -31,18 +31,46 @@ async def get_download_status():
|
||||||
import json
|
import json
|
||||||
|
|
||||||
status_file = Path("card_files/images/.download_status.json")
|
status_file = Path("card_files/images/.download_status.json")
|
||||||
|
last_result_file = Path("card_files/images/.last_download_result.json")
|
||||||
|
|
||||||
if not status_file.exists():
|
if not status_file.exists():
|
||||||
# Check cache statistics if no download in progress
|
# No active download - return cache stats plus last download result if available
|
||||||
stats = _image_cache.cache_statistics()
|
stats = _image_cache.cache_statistics()
|
||||||
|
last_download = None
|
||||||
|
if last_result_file.exists():
|
||||||
|
try:
|
||||||
|
with last_result_file.open('r', encoding='utf-8') as f:
|
||||||
|
last_download = json.load(f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return JSONResponse({
|
return JSONResponse({
|
||||||
"running": False,
|
"running": False,
|
||||||
|
"last_download": last_download,
|
||||||
"stats": stats
|
"stats": stats
|
||||||
})
|
})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with status_file.open('r', encoding='utf-8') as f:
|
with status_file.open('r', encoding='utf-8') as f:
|
||||||
status = json.load(f)
|
status = json.load(f)
|
||||||
|
|
||||||
|
# If download is complete (or errored), persist result, clean up status file
|
||||||
|
if not status.get("running", False):
|
||||||
|
try:
|
||||||
|
with last_result_file.open('w', encoding='utf-8') as f:
|
||||||
|
json.dump(status, f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
status_file.unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
cache_stats = _image_cache.cache_statistics()
|
||||||
|
return JSONResponse({
|
||||||
|
"running": False,
|
||||||
|
"last_download": status,
|
||||||
|
"stats": cache_stats
|
||||||
|
})
|
||||||
|
|
||||||
return JSONResponse(status)
|
return JSONResponse(status)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not read status file: {e}")
|
logger.warning(f"Could not read status file: {e}")
|
||||||
|
|
@ -136,7 +164,7 @@ async def get_card_image(size: str, card_name: str, face: str = Query(default="f
|
||||||
image_path = _image_cache.get_image_path(card_name, size)
|
image_path = _image_cache.get_image_path(card_name, size)
|
||||||
|
|
||||||
if image_path and image_path.exists():
|
if image_path and image_path.exists():
|
||||||
logger.info(f"Serving cached image: {card_name} ({size}, {face})")
|
logger.debug(f"Serving cached image: {card_name} ({size}, {face})")
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
image_path,
|
image_path,
|
||||||
media_type="image/jpeg",
|
media_type="image/jpeg",
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
615
code/web/routes/build_alternatives.py
Normal file
615
code/web/routes/build_alternatives.py
Normal file
|
|
@ -0,0 +1,615 @@
|
||||||
|
"""Build Alternatives Route
|
||||||
|
|
||||||
|
Phase 5 extraction from build.py:
|
||||||
|
- GET /build/alternatives - Role-based card suggestions with tag overlap fallback
|
||||||
|
|
||||||
|
This module provides intelligent alternative card suggestions based on deck role,
|
||||||
|
tags, and builder context. Supports owned-only filtering and caching.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, Query
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from typing import Any
|
||||||
|
from ..app import templates
|
||||||
|
from ..services.tasks import get_session, new_sid
|
||||||
|
from ..services.build_utils import owned_set as owned_set_helper, builder_present_names, builder_display_map
|
||||||
|
from deck_builder.builder import DeckBuilder
|
||||||
|
from deck_builder import builder_constants as bc
|
||||||
|
from deck_builder import builder_utils as bu
|
||||||
|
from ..services.alts_utils import get_cached as _alts_get_cached, set_cached as _alts_set_cached
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/build")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/alternatives", response_class=HTMLResponse)
|
||||||
|
async def build_alternatives(
|
||||||
|
request: Request,
|
||||||
|
name: str,
|
||||||
|
stage: str | None = None,
|
||||||
|
owned_only: int = Query(0),
|
||||||
|
refresh: int = Query(0),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Suggest alternative cards for a given card name, preferring role-specific pools.
|
||||||
|
|
||||||
|
Strategy:
|
||||||
|
1) Determine the seed card's role from the current deck (Role field) or optional `stage` hint.
|
||||||
|
2) Build a candidate pool from the combined DataFrame using the same filters as the build phase
|
||||||
|
for that role (ramp/removal/wipes/card_advantage/protection).
|
||||||
|
3) Exclude commander, lands (where applicable), in-deck, locked, and the seed itself; then sort
|
||||||
|
by edhrecRank/manaValue. Apply owned-only filter if requested.
|
||||||
|
4) Fall back to tag-overlap similarity when role cannot be determined or data is missing.
|
||||||
|
|
||||||
|
Returns an HTML partial listing up to ~10 alternatives with Replace buttons.
|
||||||
|
"""
|
||||||
|
sid = request.cookies.get("sid") or new_sid()
|
||||||
|
sess = get_session(sid)
|
||||||
|
ctx = sess.get("build_ctx") or {}
|
||||||
|
b = ctx.get("builder") if isinstance(ctx, dict) else None
|
||||||
|
# Owned library
|
||||||
|
owned_set = owned_set_helper()
|
||||||
|
require_owned = bool(int(owned_only or 0)) or bool(sess.get("use_owned_only"))
|
||||||
|
refresh_requested = bool(int(refresh or 0))
|
||||||
|
# If builder context missing, show a guidance message
|
||||||
|
if not b:
|
||||||
|
html = '<div class="alts"><div class="muted">Start the build to see alternatives.</div></div>'
|
||||||
|
return HTMLResponse(html)
|
||||||
|
try:
|
||||||
|
name_disp = str(name).strip()
|
||||||
|
name_l = name_disp.lower()
|
||||||
|
commander_l = str((sess.get("commander") or "")).strip().lower()
|
||||||
|
locked_set = {str(x).strip().lower() for x in (sess.get("locks", []) or [])}
|
||||||
|
# Exclusions from prior inline replacements
|
||||||
|
alts_exclude = {str(x).strip().lower() for x in (sess.get("alts_exclude", []) or [])}
|
||||||
|
alts_exclude_v = int(sess.get("alts_exclude_v") or 0)
|
||||||
|
|
||||||
|
# Resolve role from stage hint or current library entry
|
||||||
|
stage_hint = (stage or "").strip().lower()
|
||||||
|
stage_map = {
|
||||||
|
"ramp": "ramp",
|
||||||
|
"removal": "removal",
|
||||||
|
"wipes": "wipe",
|
||||||
|
"wipe": "wipe",
|
||||||
|
"board_wipe": "wipe",
|
||||||
|
"card_advantage": "card_advantage",
|
||||||
|
"draw": "card_advantage",
|
||||||
|
"protection": "protection",
|
||||||
|
# Additional mappings for creature stages
|
||||||
|
"creature": "creature",
|
||||||
|
"creatures": "creature",
|
||||||
|
"primary": "creature",
|
||||||
|
"secondary": "creature",
|
||||||
|
# Land-related hints
|
||||||
|
"land": "land",
|
||||||
|
"lands": "land",
|
||||||
|
"utility": "land",
|
||||||
|
"misc": "land",
|
||||||
|
"fetch": "land",
|
||||||
|
"dual": "land",
|
||||||
|
}
|
||||||
|
hinted_role = stage_map.get(stage_hint) if stage_hint else None
|
||||||
|
lib = getattr(b, "card_library", {}) or {}
|
||||||
|
# Case-insensitive lookup in deck library
|
||||||
|
lib_key = None
|
||||||
|
try:
|
||||||
|
if name_disp in lib:
|
||||||
|
lib_key = name_disp
|
||||||
|
else:
|
||||||
|
lm = {str(k).strip().lower(): k for k in lib.keys()}
|
||||||
|
lib_key = lm.get(name_l)
|
||||||
|
except Exception:
|
||||||
|
lib_key = None
|
||||||
|
entry = lib.get(lib_key) if lib_key else None
|
||||||
|
role = hinted_role or (entry.get("Role") if isinstance(entry, dict) else None)
|
||||||
|
if isinstance(role, str):
|
||||||
|
role = role.strip().lower()
|
||||||
|
|
||||||
|
# Build role-specific pool from combined DataFrame
|
||||||
|
items: list[dict] = []
|
||||||
|
|
||||||
|
def _clean(value: Any) -> str:
|
||||||
|
try:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, float) and value != value:
|
||||||
|
return ""
|
||||||
|
text = str(value)
|
||||||
|
return text.strip()
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _normalize_tags(raw: Any) -> list[str]:
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
if isinstance(raw, (list, tuple, set)):
|
||||||
|
return [str(t).strip() for t in raw if str(t).strip()]
|
||||||
|
if isinstance(raw, str):
|
||||||
|
txt = raw.strip()
|
||||||
|
if not txt:
|
||||||
|
return []
|
||||||
|
if txt.startswith("[") and txt.endswith("]"):
|
||||||
|
try:
|
||||||
|
import json as _json
|
||||||
|
parsed = _json.loads(txt)
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
return [str(t).strip() for t in parsed if str(t).strip()]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return [s.strip() for s in txt.split(',') if s.strip()]
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _meta_from_row(row_obj: Any) -> dict[str, Any]:
|
||||||
|
meta = {
|
||||||
|
"mana": "",
|
||||||
|
"rarity": "",
|
||||||
|
"role": "",
|
||||||
|
"tags": [],
|
||||||
|
"hover_simple": True,
|
||||||
|
}
|
||||||
|
if row_obj is None:
|
||||||
|
meta["role"] = _clean(used_role or "")
|
||||||
|
return meta
|
||||||
|
|
||||||
|
def _pull(*keys: str) -> Any:
|
||||||
|
for key in keys:
|
||||||
|
try:
|
||||||
|
if isinstance(row_obj, dict):
|
||||||
|
val = row_obj.get(key)
|
||||||
|
elif hasattr(row_obj, "get"):
|
||||||
|
val = row_obj.get(key)
|
||||||
|
else:
|
||||||
|
val = getattr(row_obj, key, None)
|
||||||
|
except Exception:
|
||||||
|
val = None
|
||||||
|
if val not in (None, ""):
|
||||||
|
if isinstance(val, float) and val != val:
|
||||||
|
continue
|
||||||
|
return val
|
||||||
|
return None
|
||||||
|
|
||||||
|
meta["mana"] = _clean(_pull("mana_cost", "manaCost", "mana", "manaValue", "cmc", "mv"))
|
||||||
|
meta["rarity"] = _clean(_pull("rarity"))
|
||||||
|
role_val = _pull("role", "primaryRole", "subRole")
|
||||||
|
if not role_val:
|
||||||
|
role_val = used_role or ""
|
||||||
|
meta["role"] = _clean(role_val)
|
||||||
|
tags_val = _pull("themeTags", "_ltags", "tags")
|
||||||
|
meta_tags = _normalize_tags(tags_val)
|
||||||
|
meta["tags"] = meta_tags
|
||||||
|
meta["hover_simple"] = not (meta["mana"] or meta["rarity"] or (meta_tags and len(meta_tags) > 0))
|
||||||
|
return meta
|
||||||
|
|
||||||
|
def _build_meta_map(df_obj) -> dict[str, dict[str, Any]]:
|
||||||
|
mapping: dict[str, dict[str, Any]] = {}
|
||||||
|
try:
|
||||||
|
if df_obj is None or not hasattr(df_obj, "iterrows"):
|
||||||
|
return mapping
|
||||||
|
for _, row in df_obj.iterrows():
|
||||||
|
try:
|
||||||
|
nm_val = str(row.get("name") or "").strip()
|
||||||
|
except Exception:
|
||||||
|
nm_val = ""
|
||||||
|
if not nm_val:
|
||||||
|
continue
|
||||||
|
key = nm_val.lower()
|
||||||
|
if key in mapping:
|
||||||
|
continue
|
||||||
|
mapping[key] = _meta_from_row(row)
|
||||||
|
except Exception:
|
||||||
|
return mapping
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
def _sampler(seq: list[str], limit: int) -> list[str]:
|
||||||
|
if limit <= 0:
|
||||||
|
return []
|
||||||
|
if len(seq) <= limit:
|
||||||
|
return list(seq)
|
||||||
|
rng = getattr(b, "rng", None)
|
||||||
|
try:
|
||||||
|
if rng is not None:
|
||||||
|
return rng.sample(seq, limit) if len(seq) >= limit else list(seq)
|
||||||
|
import random as _rnd
|
||||||
|
return _rnd.sample(seq, limit) if len(seq) >= limit else list(seq)
|
||||||
|
except Exception:
|
||||||
|
return list(seq[:limit])
|
||||||
|
used_role = role if isinstance(role, str) and role else None
|
||||||
|
# Promote to 'land' role when the seed card is a land (regardless of stored role)
|
||||||
|
try:
|
||||||
|
if entry and isinstance(entry, dict):
|
||||||
|
ctype = str(entry.get("Card Type") or entry.get("Type") or "").lower()
|
||||||
|
if "land" in ctype:
|
||||||
|
used_role = "land"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
df = getattr(b, "_combined_cards_df", None)
|
||||||
|
|
||||||
|
# Compute current deck fingerprint to avoid stale cached alternatives after stage changes
|
||||||
|
in_deck: set[str] = builder_present_names(b)
|
||||||
|
try:
|
||||||
|
import hashlib as _hl
|
||||||
|
deck_fp = _hl.md5(
|
||||||
|
("|".join(sorted(in_deck)) if in_deck else "").encode("utf-8")
|
||||||
|
).hexdigest()[:8]
|
||||||
|
except Exception:
|
||||||
|
deck_fp = str(len(in_deck))
|
||||||
|
|
||||||
|
# Use a cache key that includes the exclusions version and deck fingerprint
|
||||||
|
cache_key = (name_l, commander_l, used_role or "_fallback_", require_owned, alts_exclude_v, deck_fp)
|
||||||
|
cached = None
|
||||||
|
if used_role != 'land' and not refresh_requested:
|
||||||
|
cached = _alts_get_cached(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return HTMLResponse(cached)
|
||||||
|
|
||||||
|
def _render_and_cache(_items: list[dict]):
|
||||||
|
html_str = templates.get_template("build/_alternatives.html").render({
|
||||||
|
"request": request,
|
||||||
|
"name": name_disp,
|
||||||
|
"require_owned": require_owned,
|
||||||
|
"items": _items,
|
||||||
|
})
|
||||||
|
# Skip caching when used_role == land or refresh requested for per-call randomness
|
||||||
|
if used_role != 'land' and not refresh_requested:
|
||||||
|
try:
|
||||||
|
_alts_set_cached(cache_key, html_str)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return HTMLResponse(html_str)
|
||||||
|
|
||||||
|
# Helper: map display names
|
||||||
|
def _display_map_for(lower_pool: set[str]) -> dict[str, str]:
|
||||||
|
try:
|
||||||
|
return builder_display_map(b, lower_pool)
|
||||||
|
except Exception:
|
||||||
|
return {nm: nm for nm in lower_pool}
|
||||||
|
|
||||||
|
# Common exclusions
|
||||||
|
# in_deck already computed above
|
||||||
|
|
||||||
|
def _exclude(df0):
|
||||||
|
out = df0.copy()
|
||||||
|
if "name" in out.columns:
|
||||||
|
out["_lname"] = out["name"].astype(str).str.strip().str.lower()
|
||||||
|
mask = ~out["_lname"].isin({name_l} | in_deck | locked_set | alts_exclude | ({commander_l} if commander_l else set()))
|
||||||
|
out = out[mask]
|
||||||
|
return out
|
||||||
|
|
||||||
|
# If we have data and a recognized role, mirror the phase logic
|
||||||
|
if df is not None and hasattr(df, "copy") and (used_role in {"ramp","removal","wipe","card_advantage","protection","creature","land"}):
|
||||||
|
pool = df.copy()
|
||||||
|
try:
|
||||||
|
pool["_ltags"] = pool.get("themeTags", []).apply(bu.normalize_tag_cell)
|
||||||
|
except Exception:
|
||||||
|
# best-effort normalize
|
||||||
|
pool["_ltags"] = pool.get("themeTags", []).apply(lambda x: [str(t).strip().lower() for t in (x or [])] if isinstance(x, list) else [])
|
||||||
|
# Role-specific base filtering
|
||||||
|
if used_role != "land":
|
||||||
|
# Exclude lands for non-land roles
|
||||||
|
if "type" in pool.columns:
|
||||||
|
pool = pool[~pool["type"].fillna("").str.contains("Land", case=False, na=False)]
|
||||||
|
else:
|
||||||
|
# Keep only lands
|
||||||
|
if "type" in pool.columns:
|
||||||
|
pool = pool[pool["type"].fillna("").str.contains("Land", case=False, na=False)]
|
||||||
|
# Seed info to guide filtering
|
||||||
|
seed_is_basic = False
|
||||||
|
try:
|
||||||
|
seed_is_basic = bool(name_l in {b.strip().lower() for b in getattr(bc, 'BASIC_LANDS', [])})
|
||||||
|
except Exception:
|
||||||
|
seed_is_basic = False
|
||||||
|
if seed_is_basic:
|
||||||
|
# For basics: show other basics (different colors) to allow quick swaps
|
||||||
|
try:
|
||||||
|
pool = pool[pool['name'].astype(str).str.strip().str.lower().isin({x.lower() for x in getattr(bc, 'BASIC_LANDS', [])})]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# For non-basics: prefer other non-basics
|
||||||
|
try:
|
||||||
|
pool = pool[~pool['name'].astype(str).str.strip().str.lower().isin({x.lower() for x in getattr(bc, 'BASIC_LANDS', [])})]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Apply mono-color misc land filters (no debug CSV dependency)
|
||||||
|
try:
|
||||||
|
colors = list(getattr(b, 'color_identity', []) or [])
|
||||||
|
mono = len(colors) <= 1
|
||||||
|
mono_exclude = {n.lower() for n in getattr(bc, 'MONO_COLOR_MISC_LAND_EXCLUDE', [])}
|
||||||
|
mono_keep = {n.lower() for n in getattr(bc, 'MONO_COLOR_MISC_LAND_KEEP_ALWAYS', [])}
|
||||||
|
kindred_all = {n.lower() for n in getattr(bc, 'KINDRED_ALL_LAND_NAMES', [])}
|
||||||
|
any_color_phrases = [s.lower() for s in getattr(bc, 'ANY_COLOR_MANA_PHRASES', [])]
|
||||||
|
extra_rainbow_terms = [s.lower() for s in getattr(bc, 'MONO_COLOR_RAINBOW_TEXT_EXTRA', [])]
|
||||||
|
fetch_names = set()
|
||||||
|
for seq in getattr(bc, 'COLOR_TO_FETCH_LANDS', {}).values():
|
||||||
|
for nm in seq:
|
||||||
|
fetch_names.add(nm.lower())
|
||||||
|
for nm in getattr(bc, 'GENERIC_FETCH_LANDS', []):
|
||||||
|
fetch_names.add(nm.lower())
|
||||||
|
# World Tree check needs all five colors
|
||||||
|
need_all_colors = {'w','u','b','r','g'}
|
||||||
|
def _illegal_world_tree(nm: str) -> bool:
|
||||||
|
return nm == 'the world tree' and set(c.lower() for c in colors) != need_all_colors
|
||||||
|
# Text column fallback
|
||||||
|
text_col = 'text'
|
||||||
|
if text_col not in pool.columns:
|
||||||
|
for c in pool.columns:
|
||||||
|
if 'text' in c.lower():
|
||||||
|
text_col = c
|
||||||
|
break
|
||||||
|
def _exclude_row(row) -> bool:
|
||||||
|
nm_l = str(row['name']).strip().lower()
|
||||||
|
if mono and nm_l in mono_exclude and nm_l not in mono_keep and nm_l not in kindred_all:
|
||||||
|
return True
|
||||||
|
if mono and nm_l not in mono_keep and nm_l not in kindred_all:
|
||||||
|
try:
|
||||||
|
txt = str(row.get(text_col, '') or '').lower()
|
||||||
|
if any(p in txt for p in any_color_phrases + extra_rainbow_terms):
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if nm_l in fetch_names:
|
||||||
|
return True
|
||||||
|
if _illegal_world_tree(nm_l):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
pool = pool[~pool.apply(_exclude_row, axis=1)]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Optional sub-role filtering (only if enough depth)
|
||||||
|
try:
|
||||||
|
subrole = str((entry or {}).get('SubRole') or '').strip().lower()
|
||||||
|
if subrole:
|
||||||
|
# Heuristic categories for grouping
|
||||||
|
cat_map = {
|
||||||
|
'fetch': 'fetch',
|
||||||
|
'dual': 'dual',
|
||||||
|
'triple': 'triple',
|
||||||
|
'misc': 'misc',
|
||||||
|
'utility': 'misc',
|
||||||
|
'basic': 'basic'
|
||||||
|
}
|
||||||
|
target_cat = None
|
||||||
|
for key, val in cat_map.items():
|
||||||
|
if key in subrole:
|
||||||
|
target_cat = val
|
||||||
|
break
|
||||||
|
if target_cat and len(pool) > 25:
|
||||||
|
# Lightweight textual filter using known markers
|
||||||
|
def _cat_row(rname: str, rtype: str) -> str:
|
||||||
|
rl = rname.lower()
|
||||||
|
rt = rtype.lower()
|
||||||
|
if any(k in rl for k in ('vista','strand','delta','mire','heath','rainforest','mesa','foothills','catacombs','tarn','flat','expanse','wilds','landscape','tunnel','terrace','vista')):
|
||||||
|
return 'fetch'
|
||||||
|
if 'triple' in rt or 'three' in rt:
|
||||||
|
return 'triple'
|
||||||
|
if any(t in rt for t in ('forest','plains','island','swamp','mountain')) and any(sym in rt for sym in ('forest','plains','island','swamp','mountain')) and 'land' in rt:
|
||||||
|
# Basic-check crude
|
||||||
|
return 'basic'
|
||||||
|
return 'misc'
|
||||||
|
try:
|
||||||
|
tmp = pool.copy()
|
||||||
|
tmp['_cat'] = tmp.apply(lambda r: _cat_row(str(r.get('name','')), str(r.get('type',''))), axis=1)
|
||||||
|
sub_pool = tmp[tmp['_cat'] == target_cat]
|
||||||
|
if len(sub_pool) >= 10:
|
||||||
|
pool = sub_pool.drop(columns=['_cat'])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Exclude commander explicitly
|
||||||
|
if "name" in pool.columns and commander_l:
|
||||||
|
pool = pool[pool["name"].astype(str).str.strip().str.lower() != commander_l]
|
||||||
|
# Role-specific filter
|
||||||
|
def _is_wipe(tags: list[str]) -> bool:
|
||||||
|
return any(("board wipe" in t) or ("mass removal" in t) for t in tags)
|
||||||
|
def _is_removal(tags: list[str]) -> bool:
|
||||||
|
return any(("removal" in t) or ("spot removal" in t) for t in tags)
|
||||||
|
def _is_draw(tags: list[str]) -> bool:
|
||||||
|
return any(("draw" in t) or ("card advantage" in t) for t in tags)
|
||||||
|
def _matches_selected(tags: list[str]) -> bool:
|
||||||
|
try:
|
||||||
|
sel = [str(t).strip().lower() for t in (sess.get("tags") or []) if str(t).strip()]
|
||||||
|
if not sel:
|
||||||
|
return True
|
||||||
|
st = set(sel)
|
||||||
|
return any(any(s in t for s in st) for t in tags)
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
if used_role == "ramp":
|
||||||
|
pool = pool[pool["_ltags"].apply(lambda tags: any("ramp" in t for t in tags))]
|
||||||
|
elif used_role == "removal":
|
||||||
|
pool = pool[pool["_ltags"].apply(_is_removal) & ~pool["_ltags"].apply(_is_wipe)]
|
||||||
|
elif used_role == "wipe":
|
||||||
|
pool = pool[pool["_ltags"].apply(_is_wipe)]
|
||||||
|
elif used_role == "card_advantage":
|
||||||
|
pool = pool[pool["_ltags"].apply(_is_draw)]
|
||||||
|
elif used_role == "protection":
|
||||||
|
pool = pool[pool["_ltags"].apply(lambda tags: any("protection" in t for t in tags))]
|
||||||
|
elif used_role == "creature":
|
||||||
|
# Keep only creatures; bias toward selected theme tags when available
|
||||||
|
if "type" in pool.columns:
|
||||||
|
pool = pool[pool["type"].fillna("").str.contains("Creature", case=False, na=False)]
|
||||||
|
try:
|
||||||
|
pool = pool[pool["_ltags"].apply(_matches_selected)]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif used_role == "land":
|
||||||
|
# Already constrained to lands; no additional tag filter needed
|
||||||
|
pass
|
||||||
|
# Sort by priority like the builder
|
||||||
|
try:
|
||||||
|
pool = bu.sort_by_priority(pool, ["edhrecRank","manaValue"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Exclusions and ownership (for non-random roles this stays before slicing)
|
||||||
|
pool = _exclude(pool)
|
||||||
|
try:
|
||||||
|
if bool(sess.get("prefer_owned")) and getattr(b, "owned_card_names", None):
|
||||||
|
pool = bu.prefer_owned_first(pool, {str(n).lower() for n in getattr(b, "owned_card_names", set())})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
row_meta = _build_meta_map(pool)
|
||||||
|
# Land role: random 12 from top 60-100 window
|
||||||
|
if used_role == 'land':
|
||||||
|
import random as _rnd
|
||||||
|
total = len(pool)
|
||||||
|
if total == 0:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
cap = min(100, total)
|
||||||
|
floor = min(60, cap) # if fewer than 60 just use all
|
||||||
|
if cap <= 12:
|
||||||
|
window_size = cap
|
||||||
|
else:
|
||||||
|
if cap == floor:
|
||||||
|
window_size = cap
|
||||||
|
else:
|
||||||
|
rng_obj = getattr(b, 'rng', None)
|
||||||
|
if rng_obj:
|
||||||
|
window_size = rng_obj.randint(floor, cap)
|
||||||
|
else:
|
||||||
|
window_size = _rnd.randint(floor, cap)
|
||||||
|
window_df = pool.head(window_size)
|
||||||
|
names = window_df['name'].astype(str).str.strip().tolist()
|
||||||
|
# Random sample up to 12 distinct names
|
||||||
|
sample_n = min(12, len(names))
|
||||||
|
if sample_n > 0:
|
||||||
|
if getattr(b, 'rng', None):
|
||||||
|
chosen = getattr(b,'rng').sample(names, sample_n) if len(names) >= sample_n else names
|
||||||
|
else:
|
||||||
|
chosen = _rnd.sample(names, sample_n) if len(names) >= sample_n else names
|
||||||
|
lower_map = {n.strip().lower(): n for n in chosen}
|
||||||
|
display_map = _display_map_for(set(k for k in lower_map.keys()))
|
||||||
|
for nm_lc, orig in lower_map.items():
|
||||||
|
is_owned = (nm_lc in owned_set)
|
||||||
|
if require_owned and not is_owned:
|
||||||
|
continue
|
||||||
|
if nm_lc == name_l or (in_deck and nm_lc in in_deck):
|
||||||
|
continue
|
||||||
|
meta = row_meta.get(nm_lc) or _meta_from_row(None)
|
||||||
|
items.append({
|
||||||
|
'name': display_map.get(nm_lc, orig),
|
||||||
|
'name_lower': nm_lc,
|
||||||
|
'owned': is_owned,
|
||||||
|
'tags': meta.get('tags') or [],
|
||||||
|
'role': meta.get('role', ''),
|
||||||
|
'mana': meta.get('mana', ''),
|
||||||
|
'rarity': meta.get('rarity', ''),
|
||||||
|
'hover_simple': bool(meta.get('hover_simple', True)),
|
||||||
|
})
|
||||||
|
if items:
|
||||||
|
return _render_and_cache(items)
|
||||||
|
else:
|
||||||
|
# Default deterministic top-N (increase to 12 for parity)
|
||||||
|
lower_pool: list[str] = []
|
||||||
|
try:
|
||||||
|
lower_pool = pool["name"].astype(str).str.strip().str.lower().tolist()
|
||||||
|
except Exception:
|
||||||
|
lower_pool = []
|
||||||
|
display_map = _display_map_for(set(lower_pool))
|
||||||
|
iteration_order = lower_pool
|
||||||
|
if refresh_requested and len(lower_pool) > 12:
|
||||||
|
window_size = min(len(lower_pool), 30)
|
||||||
|
window = lower_pool[:window_size]
|
||||||
|
sampled = _sampler(window, min(window_size, 12))
|
||||||
|
seen_sampled = set(sampled)
|
||||||
|
iteration_order = sampled + [nm for nm in lower_pool if nm not in seen_sampled]
|
||||||
|
for nm_l in iteration_order:
|
||||||
|
is_owned = (nm_l in owned_set)
|
||||||
|
if require_owned and not is_owned:
|
||||||
|
continue
|
||||||
|
if nm_l == name_l or (in_deck and nm_l in in_deck):
|
||||||
|
continue
|
||||||
|
meta = row_meta.get(nm_l) or _meta_from_row(None)
|
||||||
|
items.append({
|
||||||
|
"name": display_map.get(nm_l, nm_l),
|
||||||
|
"name_lower": nm_l,
|
||||||
|
"owned": is_owned,
|
||||||
|
"tags": meta.get("tags") or [],
|
||||||
|
"role": meta.get("role", ""),
|
||||||
|
"mana": meta.get("mana", ""),
|
||||||
|
"rarity": meta.get("rarity", ""),
|
||||||
|
"hover_simple": bool(meta.get("hover_simple", True)),
|
||||||
|
})
|
||||||
|
if len(items) >= 12:
|
||||||
|
break
|
||||||
|
if items:
|
||||||
|
return _render_and_cache(items)
|
||||||
|
|
||||||
|
# Fallback: tag-similarity suggestions (previous behavior)
|
||||||
|
tags_idx = getattr(b, "_card_name_tags_index", {}) or {}
|
||||||
|
seed_tags = set(tags_idx.get(name_l) or [])
|
||||||
|
all_names = set(tags_idx.keys())
|
||||||
|
candidates: list[tuple[str, int]] = [] # (name, score)
|
||||||
|
for nm in all_names:
|
||||||
|
if nm == name_l:
|
||||||
|
continue
|
||||||
|
if commander_l and nm == commander_l:
|
||||||
|
continue
|
||||||
|
if in_deck and nm in in_deck:
|
||||||
|
continue
|
||||||
|
if locked_set and nm in locked_set:
|
||||||
|
continue
|
||||||
|
if nm in alts_exclude:
|
||||||
|
continue
|
||||||
|
tgs = set(tags_idx.get(nm) or [])
|
||||||
|
score = len(seed_tags & tgs)
|
||||||
|
if score <= 0:
|
||||||
|
continue
|
||||||
|
candidates.append((nm, score))
|
||||||
|
# If no tag-based candidates, try shared trigger tag from library entry
|
||||||
|
if not candidates and isinstance(entry, dict):
|
||||||
|
try:
|
||||||
|
trig = str(entry.get("TriggerTag") or "").strip().lower()
|
||||||
|
except Exception:
|
||||||
|
trig = ""
|
||||||
|
if trig:
|
||||||
|
for nm, tglist in tags_idx.items():
|
||||||
|
if nm == name_l:
|
||||||
|
continue
|
||||||
|
if nm in {str(k).strip().lower() for k in lib.keys()}:
|
||||||
|
continue
|
||||||
|
if trig in {str(t).strip().lower() for t in (tglist or [])}:
|
||||||
|
candidates.append((nm, 1))
|
||||||
|
def _owned(nm: str) -> bool:
|
||||||
|
return nm in owned_set
|
||||||
|
candidates.sort(key=lambda x: (-x[1], 0 if _owned(x[0]) else 1, x[0]))
|
||||||
|
if refresh_requested and len(candidates) > 1:
|
||||||
|
name_sequence = [nm for nm, _score in candidates]
|
||||||
|
sampled_names = _sampler(name_sequence, min(len(name_sequence), 10))
|
||||||
|
sampled_set = set(sampled_names)
|
||||||
|
reordered: list[tuple[str, int]] = []
|
||||||
|
for nm in sampled_names:
|
||||||
|
for cand_nm, cand_score in candidates:
|
||||||
|
if cand_nm == nm:
|
||||||
|
reordered.append((cand_nm, cand_score))
|
||||||
|
break
|
||||||
|
for cand_nm, cand_score in candidates:
|
||||||
|
if cand_nm not in sampled_set:
|
||||||
|
reordered.append((cand_nm, cand_score))
|
||||||
|
candidates = reordered
|
||||||
|
pool_lower = {nm for (nm, _s) in candidates}
|
||||||
|
display_map = _display_map_for(pool_lower)
|
||||||
|
seen = set()
|
||||||
|
for nm, score in candidates:
|
||||||
|
if nm in seen:
|
||||||
|
continue
|
||||||
|
seen.add(nm)
|
||||||
|
is_owned = (nm in owned_set)
|
||||||
|
if require_owned and not is_owned:
|
||||||
|
continue
|
||||||
|
items.append({
|
||||||
|
"name": display_map.get(nm, nm),
|
||||||
|
"name_lower": nm,
|
||||||
|
"owned": is_owned,
|
||||||
|
"tags": list(tags_idx.get(nm) or []),
|
||||||
|
"role": "",
|
||||||
|
"mana": "",
|
||||||
|
"rarity": "",
|
||||||
|
"hover_simple": True,
|
||||||
|
})
|
||||||
|
if len(items) >= 10:
|
||||||
|
break
|
||||||
|
return _render_and_cache(items)
|
||||||
|
except Exception as e:
|
||||||
|
return HTMLResponse(f'<div class="alts"><div class="muted">No alternatives: {e}</div></div>')
|
||||||
653
code/web/routes/build_compliance.py
Normal file
653
code/web/routes/build_compliance.py
Normal file
|
|
@ -0,0 +1,653 @@
|
||||||
|
"""Build Compliance and Card Replacement Routes
|
||||||
|
|
||||||
|
Phase 5 extraction from build.py:
|
||||||
|
- POST /build/replace - Inline card replacement with undo tracking
|
||||||
|
- POST /build/replace/undo - Undo last replacement
|
||||||
|
- GET /build/compare - Batch build comparison stub
|
||||||
|
- GET /build/compliance - Bracket compliance panel
|
||||||
|
- POST /build/enforce/apply - Apply bracket enforcement
|
||||||
|
- GET /build/enforcement - Full-page enforcement review
|
||||||
|
|
||||||
|
This module handles card replacement, bracket compliance checking, and enforcement.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, Form, Query
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
|
from typing import Any
|
||||||
|
import json
|
||||||
|
from ..app import templates
|
||||||
|
from ..services.tasks import get_session, new_sid
|
||||||
|
from ..services.build_utils import (
|
||||||
|
step5_ctx_from_result,
|
||||||
|
step5_error_ctx,
|
||||||
|
step5_empty_ctx,
|
||||||
|
owned_set as owned_set_helper,
|
||||||
|
)
|
||||||
|
from ..services import orchestrator as orch
|
||||||
|
from deck_builder.builder import DeckBuilder
|
||||||
|
from html import escape as _esc
|
||||||
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/build")
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_hx_trigger(response: Any, payload: dict[str, Any]) -> None:
|
||||||
|
if not payload or response is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
existing = response.headers.get("HX-Trigger") if hasattr(response, "headers") else None
|
||||||
|
except Exception:
|
||||||
|
existing = None
|
||||||
|
try:
|
||||||
|
if existing:
|
||||||
|
try:
|
||||||
|
data = json.loads(existing)
|
||||||
|
except Exception:
|
||||||
|
data = {}
|
||||||
|
if isinstance(data, dict):
|
||||||
|
data.update(payload)
|
||||||
|
response.headers["HX-Trigger"] = json.dumps(data)
|
||||||
|
return
|
||||||
|
response.headers["HX-Trigger"] = json.dumps(payload)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
response.headers["HX-Trigger"] = json.dumps(payload)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/replace", response_class=HTMLResponse)
|
||||||
|
async def build_replace(request: Request, old: str = Form(...), new: str = Form(...), owned_only: str = Form("0")) -> HTMLResponse:
|
||||||
|
"""Inline replace: swap `old` with `new` in the current builder when possible, and suppress `old` from future alternatives.
|
||||||
|
|
||||||
|
Falls back to lock-and-rerun guidance if no active builder is present.
|
||||||
|
"""
|
||||||
|
sid = request.cookies.get("sid") or new_sid()
|
||||||
|
sess = get_session(sid)
|
||||||
|
o_disp = str(old).strip()
|
||||||
|
n_disp = str(new).strip()
|
||||||
|
o = o_disp.lower()
|
||||||
|
n = n_disp.lower()
|
||||||
|
owned_only_flag = str(owned_only or "").strip().lower()
|
||||||
|
owned_only_int = 1 if owned_only_flag in {"1", "true", "yes", "on"} else 0
|
||||||
|
|
||||||
|
# Maintain locks to bias future picks and enforcement
|
||||||
|
locks = set(sess.get("locks", []))
|
||||||
|
locks.discard(o)
|
||||||
|
locks.add(n)
|
||||||
|
sess["locks"] = list(locks)
|
||||||
|
# Track last replace for optional undo
|
||||||
|
try:
|
||||||
|
sess["last_replace"] = {"old": o, "new": n}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
ctx = sess.get("build_ctx") or {}
|
||||||
|
try:
|
||||||
|
ctx["locks"] = {str(x) for x in locks}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Record preferred replacements
|
||||||
|
try:
|
||||||
|
pref = ctx.get("preferred_replacements") if isinstance(ctx, dict) else None
|
||||||
|
if not isinstance(pref, dict):
|
||||||
|
pref = {}
|
||||||
|
ctx["preferred_replacements"] = pref
|
||||||
|
pref[o] = n
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
b: DeckBuilder | None = ctx.get("builder") if isinstance(ctx, dict) else None
|
||||||
|
if b is not None:
|
||||||
|
try:
|
||||||
|
lib = getattr(b, "card_library", {}) or {}
|
||||||
|
# Find the exact key for `old` in a case-insensitive manner
|
||||||
|
old_key = None
|
||||||
|
if o_disp in lib:
|
||||||
|
old_key = o_disp
|
||||||
|
else:
|
||||||
|
for k in list(lib.keys()):
|
||||||
|
if str(k).strip().lower() == o:
|
||||||
|
old_key = k
|
||||||
|
break
|
||||||
|
if old_key is None:
|
||||||
|
raise KeyError("old card not in deck")
|
||||||
|
old_info = dict(lib.get(old_key) or {})
|
||||||
|
role = str(old_info.get("Role") or "").strip()
|
||||||
|
subrole = str(old_info.get("SubRole") or "").strip()
|
||||||
|
try:
|
||||||
|
count = int(old_info.get("Count", 1))
|
||||||
|
except Exception:
|
||||||
|
count = 1
|
||||||
|
# Remove old entry
|
||||||
|
try:
|
||||||
|
del lib[old_key]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Resolve canonical name and info for new
|
||||||
|
df = getattr(b, "_combined_cards_df", None)
|
||||||
|
new_key = n_disp
|
||||||
|
card_type = ""
|
||||||
|
mana_cost = ""
|
||||||
|
trigger_tag = str(old_info.get("TriggerTag") or "")
|
||||||
|
if df is not None:
|
||||||
|
try:
|
||||||
|
row = df[df["name"].astype(str).str.strip().str.lower() == n]
|
||||||
|
if not row.empty:
|
||||||
|
new_key = str(row.iloc[0]["name"]) or n_disp
|
||||||
|
card_type = str(row.iloc[0].get("type", row.iloc[0].get("type_line", "")) or "")
|
||||||
|
mana_cost = str(row.iloc[0].get("mana_cost", row.iloc[0].get("manaCost", "")) or "")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
lib[new_key] = {
|
||||||
|
"Count": count,
|
||||||
|
"Card Type": card_type,
|
||||||
|
"Mana Cost": mana_cost,
|
||||||
|
"Role": role,
|
||||||
|
"SubRole": subrole,
|
||||||
|
"AddedBy": "Replace",
|
||||||
|
"TriggerTag": trigger_tag,
|
||||||
|
}
|
||||||
|
# Mirror preferred replacements onto the builder for enforcement
|
||||||
|
try:
|
||||||
|
cur = getattr(b, "preferred_replacements", {}) or {}
|
||||||
|
cur[str(o)] = str(n)
|
||||||
|
setattr(b, "preferred_replacements", cur)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Update alternatives exclusion set and bump version to invalidate caches
|
||||||
|
try:
|
||||||
|
ex = {str(x).strip().lower() for x in (sess.get("alts_exclude", []) or [])}
|
||||||
|
ex.add(o)
|
||||||
|
sess["alts_exclude"] = list(ex)
|
||||||
|
sess["alts_exclude_v"] = int(sess.get("alts_exclude_v") or 0) + 1
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Success panel and OOB updates (refresh compliance panel)
|
||||||
|
# Compute ownership of the new card for UI badge update
|
||||||
|
is_owned = (n in owned_set_helper())
|
||||||
|
refresh = (
|
||||||
|
'<div hx-get="/build/alternatives?name='
|
||||||
|
+ quote_plus(new_key)
|
||||||
|
+ f'&owned_only={owned_only_int}" hx-trigger="load delay:80ms" '
|
||||||
|
'hx-target="closest .alts" hx-swap="outerHTML" aria-hidden="true"></div>'
|
||||||
|
)
|
||||||
|
html = (
|
||||||
|
'<div class="alts" style="margin-top:.35rem; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0f1115;">'
|
||||||
|
f'<div>Replaced <strong>{o_disp}</strong> with <strong>{new_key}</strong>.</div>'
|
||||||
|
'<div class="muted" style="margin-top:.35rem;">Compliance panel will refresh.</div>'
|
||||||
|
'<div style="margin-top:.35rem; display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">'
|
||||||
|
'<button type="button" class="btn" onclick="try{this.closest(\'.alts\').remove();}catch(_){}">Close</button>'
|
||||||
|
'</div>'
|
||||||
|
+ refresh +
|
||||||
|
'</div>'
|
||||||
|
)
|
||||||
|
# Inline mutate the nearest card tile to reflect the new card without a rerun
|
||||||
|
mutator = """
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
try{
|
||||||
|
var panel = document.currentScript && document.currentScript.previousElementSibling && document.currentScript.previousElementSibling.classList && document.currentScript.previousElementSibling.classList.contains('alts') ? document.currentScript.previousElementSibling : null;
|
||||||
|
if(!panel){ return; }
|
||||||
|
var oldName = panel.getAttribute('data-old') || '';
|
||||||
|
var newName = panel.getAttribute('data-new') || '';
|
||||||
|
var isOwned = panel.getAttribute('data-owned') === '1';
|
||||||
|
var isLocked = panel.getAttribute('data-locked') === '1';
|
||||||
|
var tile = panel.closest('.card-tile');
|
||||||
|
if(!tile) return;
|
||||||
|
tile.setAttribute('data-card-name', newName);
|
||||||
|
var img = tile.querySelector('img.card-thumb');
|
||||||
|
if(img){
|
||||||
|
var base = 'https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(newName) + '&format=image&version=';
|
||||||
|
img.src = base + 'normal';
|
||||||
|
img.setAttribute('srcset',
|
||||||
|
'https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(newName) + '&format=image&version=small 160w, ' +
|
||||||
|
'https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(newName) + '&format=image&version=normal 488w, ' +
|
||||||
|
'https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(newName) + '&format=image&version=large 672w'
|
||||||
|
);
|
||||||
|
img.setAttribute('alt', newName + ' image');
|
||||||
|
img.setAttribute('data-card-name', newName);
|
||||||
|
}
|
||||||
|
var nameEl = tile.querySelector('.name');
|
||||||
|
if(nameEl){ nameEl.textContent = newName; }
|
||||||
|
var own = tile.querySelector('.owned-badge');
|
||||||
|
if(own){
|
||||||
|
own.textContent = isOwned ? '✔' : '✖';
|
||||||
|
own.title = isOwned ? 'Owned' : 'Not owned';
|
||||||
|
tile.setAttribute('data-owned', isOwned ? '1' : '0');
|
||||||
|
}
|
||||||
|
tile.classList.toggle('locked', isLocked);
|
||||||
|
var imgBtn = tile.querySelector('.img-btn');
|
||||||
|
if(imgBtn){
|
||||||
|
try{
|
||||||
|
var valsAttr = imgBtn.getAttribute('hx-vals') || '{}';
|
||||||
|
var obj = JSON.parse(valsAttr.replace(/"/g, '"'));
|
||||||
|
obj.name = newName;
|
||||||
|
imgBtn.setAttribute('hx-vals', JSON.stringify(obj));
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
var lockBtn = tile.querySelector('.lock-box .btn-lock');
|
||||||
|
if(lockBtn){
|
||||||
|
try{
|
||||||
|
var v = lockBtn.getAttribute('hx-vals') || '{}';
|
||||||
|
var o = JSON.parse(v.replace(/"/g, '"'));
|
||||||
|
o.name = newName;
|
||||||
|
lockBtn.setAttribute('hx-vals', JSON.stringify(o));
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
}catch(_){}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
"""
|
||||||
|
chip = (
|
||||||
|
f'<div id="last-action" hx-swap-oob="true">'
|
||||||
|
f'<span class="chip" title="Click to dismiss">Replaced <strong>{o_disp}</strong> → <strong>{new_key}</strong></span>'
|
||||||
|
f'</div>'
|
||||||
|
)
|
||||||
|
# OOB fetch to refresh compliance panel
|
||||||
|
refresher = (
|
||||||
|
'<div hx-get="/build/compliance" hx-target="#compliance-panel" hx-swap="outerHTML" '
|
||||||
|
'hx-trigger="load" hx-swap-oob="true"></div>'
|
||||||
|
)
|
||||||
|
# Include data attributes on the panel div for the mutator script
|
||||||
|
data_owned = '1' if is_owned else '0'
|
||||||
|
data_locked = '1' if (n in locks) else '0'
|
||||||
|
prefix = '<div class="alts"'
|
||||||
|
replacement = (
|
||||||
|
'<div class="alts" '
|
||||||
|
+ 'data-old="' + _esc(o_disp) + '" '
|
||||||
|
+ 'data-new="' + _esc(new_key) + '" '
|
||||||
|
+ 'data-owned="' + data_owned + '" '
|
||||||
|
+ 'data-locked="' + data_locked + '"'
|
||||||
|
)
|
||||||
|
html = html.replace(prefix, replacement, 1)
|
||||||
|
return HTMLResponse(html + mutator + chip + refresher)
|
||||||
|
except Exception:
|
||||||
|
# Fall back to rerun guidance if inline swap fails
|
||||||
|
pass
|
||||||
|
# Fallback: advise rerun
|
||||||
|
hint = (
|
||||||
|
'<div class="alts" style="margin-top:.35rem; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0f1115;">'
|
||||||
|
f'<div>Locked <strong>{new}</strong> and unlocked <strong>{old}</strong>.</div>'
|
||||||
|
'<div class="muted" style="margin-top:.35rem;">Now click <em>Rerun Stage</em> with Replace: On to apply this change.</div>'
|
||||||
|
'<div style="margin-top:.35rem; display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">'
|
||||||
|
'<form hx-post="/build/step5/rerun" hx-target="#wizard" hx-swap="innerHTML" style="display:inline;">'
|
||||||
|
'<input type="hidden" name="show_skipped" value="1" />'
|
||||||
|
'<button type="submit" class="btn-rerun">Rerun stage</button>'
|
||||||
|
'</form>'
|
||||||
|
'<form hx-post="/build/replace/undo" hx-target="closest .alts" hx-swap="outerHTML" style="display:inline; margin:0;">'
|
||||||
|
f'<input type="hidden" name="old" value="{old}" />'
|
||||||
|
f'<input type="hidden" name="new" value="{new}" />'
|
||||||
|
'<button type="submit" class="btn" title="Undo this replace">Undo</button>'
|
||||||
|
'</form>'
|
||||||
|
'<button type="button" class="btn" onclick="try{this.closest(\'.alts\').remove();}catch(_){}">Close</button>'
|
||||||
|
'</div>'
|
||||||
|
'</div>'
|
||||||
|
)
|
||||||
|
chip = (
|
||||||
|
f'<div id="last-action" hx-swap-oob="true">'
|
||||||
|
f'<span class="chip" title="Click to dismiss">Replaced <strong>{old}</strong> → <strong>{new}</strong></span>'
|
||||||
|
f'</div>'
|
||||||
|
)
|
||||||
|
# Also add old to exclusions and bump version for future alt calls
|
||||||
|
try:
|
||||||
|
ex = {str(x).strip().lower() for x in (sess.get("alts_exclude", []) or [])}
|
||||||
|
ex.add(o)
|
||||||
|
sess["alts_exclude"] = list(ex)
|
||||||
|
sess["alts_exclude_v"] = int(sess.get("alts_exclude_v") or 0) + 1
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return HTMLResponse(hint + chip)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/replace/undo", response_class=HTMLResponse)
|
||||||
|
async def build_replace_undo(request: Request, old: str = Form(None), new: str = Form(None)) -> HTMLResponse:
|
||||||
|
"""Undo the last replace by restoring the previous lock state (best-effort)."""
|
||||||
|
sid = request.cookies.get("sid") or new_sid()
|
||||||
|
sess = get_session(sid)
|
||||||
|
last = sess.get("last_replace") or {}
|
||||||
|
try:
|
||||||
|
# Prefer provided args, else fallback to last recorded
|
||||||
|
o = (str(old).strip().lower() if old else str(last.get("old") or "")).strip()
|
||||||
|
n = (str(new).strip().lower() if new else str(last.get("new") or "")).strip()
|
||||||
|
except Exception:
|
||||||
|
o, n = "", ""
|
||||||
|
locks = set(sess.get("locks", []))
|
||||||
|
changed = False
|
||||||
|
if n and n in locks:
|
||||||
|
locks.discard(n)
|
||||||
|
changed = True
|
||||||
|
if o:
|
||||||
|
locks.add(o)
|
||||||
|
changed = True
|
||||||
|
sess["locks"] = list(locks)
|
||||||
|
if sess.get("build_ctx"):
|
||||||
|
try:
|
||||||
|
sess["build_ctx"]["locks"] = {str(x) for x in locks}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Clear last_replace after undo
|
||||||
|
try:
|
||||||
|
if sess.get("last_replace"):
|
||||||
|
del sess["last_replace"]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Return confirmation panel and OOB chip
|
||||||
|
msg = 'Undid replace' if changed else 'No changes to undo'
|
||||||
|
html = (
|
||||||
|
'<div class="alts" style="margin-top:.35rem; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0f1115;">'
|
||||||
|
f'<div>{msg}.</div>'
|
||||||
|
'<div class="muted" style="margin-top:.35rem;">Rerun the stage to recompute picks if needed.</div>'
|
||||||
|
'<div style="margin-top:.35rem; display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">'
|
||||||
|
'<form hx-post="/build/step5/rerun" hx-target="#wizard" hx-swap="innerHTML" style="display:inline;">'
|
||||||
|
'<input type="hidden" name="show_skipped" value="1" />'
|
||||||
|
'<button type="submit" class="btn-rerun">Rerun stage</button>'
|
||||||
|
'</form>'
|
||||||
|
'<button type="button" class="btn" onclick="try{this.closest(\'.alts\').remove();}catch(_){}">Close</button>'
|
||||||
|
'</div>'
|
||||||
|
'</div>'
|
||||||
|
)
|
||||||
|
chip = (
|
||||||
|
f'<div id="last-action" hx-swap-oob="true">'
|
||||||
|
f'<span class="chip" title="Click to dismiss">{msg}</span>'
|
||||||
|
f'</div>'
|
||||||
|
)
|
||||||
|
return HTMLResponse(html + chip)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/compare")
|
||||||
|
async def build_compare(runA: str, runB: str):
|
||||||
|
"""Stub: return empty diffs; later we can diff summary files under deck_files."""
|
||||||
|
return JSONResponse({"ok": True, "added": [], "removed": [], "changed": []})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/compliance", response_class=HTMLResponse)
|
||||||
|
async def build_compliance_panel(request: Request) -> HTMLResponse:
|
||||||
|
"""Render a live Bracket compliance panel with manual enforcement controls.
|
||||||
|
|
||||||
|
Computes compliance against the current builder state without exporting, attaches a non-destructive
|
||||||
|
enforcement plan (swaps with added=None) when FAIL, and returns a reusable HTML partial.
|
||||||
|
Returns empty content when no active build context exists.
|
||||||
|
"""
|
||||||
|
sid = request.cookies.get("sid") or new_sid()
|
||||||
|
sess = get_session(sid)
|
||||||
|
ctx = sess.get("build_ctx") or {}
|
||||||
|
b: DeckBuilder | None = ctx.get("builder") if isinstance(ctx, dict) else None
|
||||||
|
if not b:
|
||||||
|
return HTMLResponse("")
|
||||||
|
# Compute compliance snapshot in-memory and attach planning preview
|
||||||
|
comp = None
|
||||||
|
try:
|
||||||
|
if hasattr(b, 'compute_and_print_compliance'):
|
||||||
|
comp = b.compute_and_print_compliance(base_stem=None)
|
||||||
|
except Exception:
|
||||||
|
comp = None
|
||||||
|
try:
|
||||||
|
if comp:
|
||||||
|
from ..services import orchestrator as orch
|
||||||
|
comp = orch._attach_enforcement_plan(b, comp)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not comp:
|
||||||
|
return HTMLResponse("")
|
||||||
|
# Build flagged metadata (role, owned) for visual tiles and role-aware alternatives
|
||||||
|
# For combo violations, expand pairs into individual cards (exclude commander) so each can be replaced.
|
||||||
|
flagged_meta: list[dict] = []
|
||||||
|
try:
|
||||||
|
cats = comp.get('categories') or {}
|
||||||
|
owned_lower = owned_set_helper()
|
||||||
|
lib = getattr(b, 'card_library', {}) or {}
|
||||||
|
commander_l = str((sess.get('commander') or '')).strip().lower()
|
||||||
|
# map category key -> display label
|
||||||
|
labels = {
|
||||||
|
'game_changers': 'Game Changers',
|
||||||
|
'extra_turns': 'Extra Turns',
|
||||||
|
'mass_land_denial': 'Mass Land Denial',
|
||||||
|
'tutors_nonland': 'Nonland Tutors',
|
||||||
|
'two_card_combos': 'Two-Card Combos',
|
||||||
|
}
|
||||||
|
seen_lower: set[str] = set()
|
||||||
|
for key, cat in cats.items():
|
||||||
|
try:
|
||||||
|
status = str(cat.get('status') or '').upper()
|
||||||
|
# Only surface tiles for WARN and FAIL
|
||||||
|
if status not in {"WARN", "FAIL"}:
|
||||||
|
continue
|
||||||
|
# For two-card combos, split pairs into individual cards and skip commander
|
||||||
|
if key == 'two_card_combos' and status == 'FAIL':
|
||||||
|
# Prefer the structured combos list to ensure we only expand counted pairs
|
||||||
|
pairs = []
|
||||||
|
try:
|
||||||
|
for p in (comp.get('combos') or []):
|
||||||
|
if p.get('cheap_early'):
|
||||||
|
pairs.append((str(p.get('a') or '').strip(), str(p.get('b') or '').strip()))
|
||||||
|
except Exception:
|
||||||
|
pairs = []
|
||||||
|
# Fallback to parsing flagged strings like "A + B"
|
||||||
|
if not pairs:
|
||||||
|
try:
|
||||||
|
for s in (cat.get('flagged') or []):
|
||||||
|
if not isinstance(s, str):
|
||||||
|
continue
|
||||||
|
parts = [x.strip() for x in s.split('+') if x and x.strip()]
|
||||||
|
if len(parts) == 2:
|
||||||
|
pairs.append((parts[0], parts[1]))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
for a, bname in pairs:
|
||||||
|
for nm in (a, bname):
|
||||||
|
if not nm:
|
||||||
|
continue
|
||||||
|
nm_l = nm.strip().lower()
|
||||||
|
if nm_l == commander_l:
|
||||||
|
# Don't prompt replacing the commander
|
||||||
|
continue
|
||||||
|
if nm_l in seen_lower:
|
||||||
|
continue
|
||||||
|
seen_lower.add(nm_l)
|
||||||
|
entry = lib.get(nm) or lib.get(nm_l) or lib.get(str(nm).strip()) or {}
|
||||||
|
role = entry.get('Role') or ''
|
||||||
|
flagged_meta.append({
|
||||||
|
'name': nm,
|
||||||
|
'category': labels.get(key, key.replace('_',' ').title()),
|
||||||
|
'role': role,
|
||||||
|
'owned': (nm_l in owned_lower),
|
||||||
|
'severity': status,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
# Default handling for list/tag categories
|
||||||
|
names = [n for n in (cat.get('flagged') or []) if isinstance(n, str)]
|
||||||
|
for nm in names:
|
||||||
|
nm_l = str(nm).strip().lower()
|
||||||
|
if nm_l in seen_lower:
|
||||||
|
continue
|
||||||
|
seen_lower.add(nm_l)
|
||||||
|
entry = lib.get(nm) or lib.get(str(nm).strip()) or lib.get(nm_l) or {}
|
||||||
|
role = entry.get('Role') or ''
|
||||||
|
flagged_meta.append({
|
||||||
|
'name': nm,
|
||||||
|
'category': labels.get(key, key.replace('_',' ').title()),
|
||||||
|
'role': role,
|
||||||
|
'owned': (nm_l in owned_lower),
|
||||||
|
'severity': status,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
flagged_meta = []
|
||||||
|
# Render partial
|
||||||
|
ctx2 = {"request": request, "compliance": comp, "flagged_meta": flagged_meta}
|
||||||
|
return templates.TemplateResponse("build/_compliance_panel.html", ctx2)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/enforce/apply", response_class=HTMLResponse)
|
||||||
|
async def build_enforce_apply(request: Request) -> HTMLResponse:
|
||||||
|
"""Apply bracket enforcement now using current locks as user guidance.
|
||||||
|
|
||||||
|
This adds lock placeholders if needed, runs enforcement + re-export, reloads compliance, and re-renders Step 5.
|
||||||
|
"""
|
||||||
|
sid = request.cookies.get("sid") or new_sid()
|
||||||
|
sess = get_session(sid)
|
||||||
|
# Ensure build context exists
|
||||||
|
ctx = sess.get("build_ctx") or {}
|
||||||
|
b: DeckBuilder | None = ctx.get("builder") if isinstance(ctx, dict) else None
|
||||||
|
if not b:
|
||||||
|
# No active build: show Step 5 with an error
|
||||||
|
err_ctx = step5_error_ctx(request, sess, "No active build context to enforce.")
|
||||||
|
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
|
||||||
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
|
_merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
|
||||||
|
return resp
|
||||||
|
# Ensure we have a CSV base stem for consistent re-exports
|
||||||
|
base_stem = None
|
||||||
|
try:
|
||||||
|
csv_path = ctx.get("csv_path")
|
||||||
|
if isinstance(csv_path, str) and csv_path:
|
||||||
|
import os as _os
|
||||||
|
base_stem = _os.path.splitext(_os.path.basename(csv_path))[0]
|
||||||
|
except Exception:
|
||||||
|
base_stem = None
|
||||||
|
# If missing, export once to establish base
|
||||||
|
if not base_stem:
|
||||||
|
try:
|
||||||
|
ctx["csv_path"] = b.export_decklist_csv()
|
||||||
|
import os as _os
|
||||||
|
base_stem = _os.path.splitext(_os.path.basename(ctx["csv_path"]))[0]
|
||||||
|
# Also produce a text export for completeness
|
||||||
|
ctx["txt_path"] = b.export_decklist_text(filename=base_stem + '.txt')
|
||||||
|
except Exception:
|
||||||
|
base_stem = None
|
||||||
|
# Add lock placeholders into the library before enforcement so user choices are present
|
||||||
|
try:
|
||||||
|
locks = {str(x).strip().lower() for x in (sess.get("locks", []) or [])}
|
||||||
|
if locks:
|
||||||
|
df = getattr(b, "_combined_cards_df", None)
|
||||||
|
lib_l = {str(n).strip().lower() for n in getattr(b, 'card_library', {}).keys()}
|
||||||
|
for lname in locks:
|
||||||
|
if lname in lib_l:
|
||||||
|
continue
|
||||||
|
target_name = None
|
||||||
|
card_type = ''
|
||||||
|
mana_cost = ''
|
||||||
|
try:
|
||||||
|
if df is not None and not df.empty:
|
||||||
|
row = df[df['name'].astype(str).str.lower() == lname]
|
||||||
|
if not row.empty:
|
||||||
|
target_name = str(row.iloc[0]['name'])
|
||||||
|
card_type = str(row.iloc[0].get('type', row.iloc[0].get('type_line', '')) or '')
|
||||||
|
mana_cost = str(row.iloc[0].get('mana_cost', row.iloc[0].get('manaCost', '')) or '')
|
||||||
|
except Exception:
|
||||||
|
target_name = None
|
||||||
|
if target_name:
|
||||||
|
b.card_library[target_name] = {
|
||||||
|
'Count': 1,
|
||||||
|
'Card Type': card_type,
|
||||||
|
'Mana Cost': mana_cost,
|
||||||
|
'Role': 'Locked',
|
||||||
|
'SubRole': '',
|
||||||
|
'AddedBy': 'Lock',
|
||||||
|
'TriggerTag': '',
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Thread preferred replacements from context onto builder so enforcement can honor them
|
||||||
|
try:
|
||||||
|
pref = ctx.get("preferred_replacements") if isinstance(ctx, dict) else None
|
||||||
|
if isinstance(pref, dict):
|
||||||
|
setattr(b, 'preferred_replacements', dict(pref))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Run enforcement + re-exports (tops up to 100 internally)
|
||||||
|
try:
|
||||||
|
rep = b.enforce_and_reexport(base_stem=base_stem, mode='auto')
|
||||||
|
except Exception as e:
|
||||||
|
err_ctx = step5_error_ctx(request, sess, f"Enforcement failed: {e}")
|
||||||
|
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
|
||||||
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
|
_merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
|
||||||
|
return resp
|
||||||
|
# Reload compliance JSON and summary
|
||||||
|
compliance = None
|
||||||
|
try:
|
||||||
|
if base_stem:
|
||||||
|
import os as _os
|
||||||
|
import json as _json
|
||||||
|
comp_path = _os.path.join('deck_files', f"{base_stem}_compliance.json")
|
||||||
|
if _os.path.exists(comp_path):
|
||||||
|
with open(comp_path, 'r', encoding='utf-8') as _cf:
|
||||||
|
compliance = _json.load(_cf)
|
||||||
|
except Exception:
|
||||||
|
compliance = None
|
||||||
|
# Rebuild Step 5 context (done state)
|
||||||
|
# Ensure csv/txt paths on ctx reflect current base
|
||||||
|
try:
|
||||||
|
import os as _os
|
||||||
|
ctx["csv_path"] = _os.path.join('deck_files', f"{base_stem}.csv") if base_stem else ctx.get("csv_path")
|
||||||
|
ctx["txt_path"] = _os.path.join('deck_files', f"{base_stem}.txt") if base_stem else ctx.get("txt_path")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Compute total_cards
|
||||||
|
try:
|
||||||
|
total_cards = 0
|
||||||
|
for _n, _e in getattr(b, 'card_library', {}).items():
|
||||||
|
try:
|
||||||
|
total_cards += int(_e.get('Count', 1))
|
||||||
|
except Exception:
|
||||||
|
total_cards += 1
|
||||||
|
except Exception:
|
||||||
|
total_cards = None
|
||||||
|
res = {
|
||||||
|
"done": True,
|
||||||
|
"label": "Complete",
|
||||||
|
"log_delta": "",
|
||||||
|
"idx": len(ctx.get("stages", []) or []),
|
||||||
|
"total": len(ctx.get("stages", []) or []),
|
||||||
|
"csv_path": ctx.get("csv_path"),
|
||||||
|
"txt_path": ctx.get("txt_path"),
|
||||||
|
"summary": getattr(b, 'build_deck_summary', lambda: None)(),
|
||||||
|
"total_cards": total_cards,
|
||||||
|
"added_total": 0,
|
||||||
|
"compliance": compliance or rep,
|
||||||
|
}
|
||||||
|
page_ctx = step5_ctx_from_result(request, sess, res, status_text="Build complete", show_skipped=True)
|
||||||
|
resp = templates.TemplateResponse(request, "build/_step5.html", page_ctx)
|
||||||
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
|
_merge_hx_trigger(resp, {"step5:refresh": {"token": page_ctx.get("summary_token", 0)}})
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/enforcement", response_class=HTMLResponse)
|
||||||
|
async def build_enforcement_fullpage(request: Request) -> HTMLResponse:
|
||||||
|
"""Full-page enforcement review: show compliance panel with swaps and controls."""
|
||||||
|
sid = request.cookies.get("sid") or new_sid()
|
||||||
|
sess = get_session(sid)
|
||||||
|
ctx = sess.get("build_ctx") or {}
|
||||||
|
b: DeckBuilder | None = ctx.get("builder") if isinstance(ctx, dict) else None
|
||||||
|
if not b:
|
||||||
|
# No active build
|
||||||
|
base = step5_empty_ctx(request, sess)
|
||||||
|
resp = templates.TemplateResponse("build/_step5.html", base)
|
||||||
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
|
return resp
|
||||||
|
# Compute compliance snapshot and attach planning preview
|
||||||
|
comp = None
|
||||||
|
try:
|
||||||
|
if hasattr(b, 'compute_and_print_compliance'):
|
||||||
|
comp = b.compute_and_print_compliance(base_stem=None)
|
||||||
|
except Exception:
|
||||||
|
comp = None
|
||||||
|
try:
|
||||||
|
if comp:
|
||||||
|
from ..services import orchestrator as orch
|
||||||
|
comp = orch._attach_enforcement_plan(b, comp)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
summary_token = int(sess.get("step5_summary_token", 0))
|
||||||
|
except Exception:
|
||||||
|
summary_token = 0
|
||||||
|
ctx2 = {"request": request, "compliance": comp, "summary_token": summary_token}
|
||||||
|
resp = templates.TemplateResponse(request, "build/enforcement.html", ctx2)
|
||||||
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
|
_merge_hx_trigger(resp, {"step5:refresh": {"token": ctx2.get("summary_token", 0)}})
|
||||||
|
return resp
|
||||||
216
code/web/routes/build_include_exclude.py
Normal file
216
code/web/routes/build_include_exclude.py
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
"""
|
||||||
|
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
|
||||||
349
code/web/routes/build_multicopy.py
Normal file
349
code/web/routes/build_multicopy.py
Normal file
|
|
@ -0,0 +1,349 @@
|
||||||
|
"""Multi-copy archetype routes for deck building.
|
||||||
|
|
||||||
|
Handles multi-copy package detection, selection, and integration with the deck builder.
|
||||||
|
Multi-copy archetypes allow multiple copies of specific cards (e.g., Hare Apparent, Dragon's Approach).
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
GET /multicopy/check - Check if commander/tags suggest multi-copy archetype
|
||||||
|
POST /multicopy/save - Save or skip multi-copy selection
|
||||||
|
GET /new/multicopy - Get multi-copy suggestions for New Deck modal (inline)
|
||||||
|
|
||||||
|
Created: 2026-02-20
|
||||||
|
Roadmap: R9 M1 Phase 2
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, Form, Query
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from html import escape as _esc
|
||||||
|
|
||||||
|
from deck_builder.builder import DeckBuilder
|
||||||
|
from deck_builder import builder_utils as bu, builder_constants as bc
|
||||||
|
from ..app import templates
|
||||||
|
from ..services.tasks import get_session, new_sid
|
||||||
|
from ..services import orchestrator as orch
|
||||||
|
from ..services.build_utils import owned_names as owned_names_helper
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _rebuild_ctx_with_multicopy(sess: dict) -> None:
|
||||||
|
"""Rebuild the staged context so Multi-Copy runs first, avoiding overfill.
|
||||||
|
|
||||||
|
This ensures the added cards are accounted for before lands and later phases,
|
||||||
|
which keeps totals near targets and shows the multi-copy additions ahead of basics.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sess: Session dictionary containing build state
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not sess or not sess.get("commander"):
|
||||||
|
return
|
||||||
|
# Build fresh ctx with the same options, threading multi_copy explicitly
|
||||||
|
opts = orch.bracket_options()
|
||||||
|
default_bracket = (opts[0]["level"] if opts else 1)
|
||||||
|
bracket_val = sess.get("bracket")
|
||||||
|
try:
|
||||||
|
safe_bracket = int(bracket_val) if bracket_val is not None else default_bracket
|
||||||
|
except Exception:
|
||||||
|
safe_bracket = int(default_bracket)
|
||||||
|
ideals_val = sess.get("ideals") or orch.ideal_defaults()
|
||||||
|
use_owned = bool(sess.get("use_owned_only"))
|
||||||
|
prefer = bool(sess.get("prefer_owned"))
|
||||||
|
owned_names = owned_names_helper() if (use_owned or prefer) else None
|
||||||
|
locks = list(sess.get("locks", []))
|
||||||
|
sess["build_ctx"] = orch.start_build_ctx(
|
||||||
|
commander=sess.get("commander"),
|
||||||
|
tags=sess.get("tags", []),
|
||||||
|
bracket=safe_bracket,
|
||||||
|
ideals=ideals_val,
|
||||||
|
tag_mode=sess.get("tag_mode", "AND"),
|
||||||
|
use_owned_only=use_owned,
|
||||||
|
prefer_owned=prefer,
|
||||||
|
owned_names=owned_names,
|
||||||
|
locks=locks,
|
||||||
|
custom_export_base=sess.get("custom_export_base"),
|
||||||
|
multi_copy=sess.get("multi_copy"),
|
||||||
|
prefer_combos=bool(sess.get("prefer_combos")),
|
||||||
|
combo_target_count=int(sess.get("combo_target_count", 2)),
|
||||||
|
combo_balance=str(sess.get("combo_balance", "mix")),
|
||||||
|
swap_mdfc_basics=bool(sess.get("swap_mdfc_basics")),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# If rebuild fails (e.g., commander not found in test), fall back to injecting
|
||||||
|
# a minimal Multi-Copy stage on the existing builder so the UI can render additions.
|
||||||
|
try:
|
||||||
|
ctx = sess.get("build_ctx")
|
||||||
|
if not isinstance(ctx, dict):
|
||||||
|
return
|
||||||
|
b = ctx.get("builder")
|
||||||
|
if b is None:
|
||||||
|
return
|
||||||
|
# Thread selection onto the builder; runner will be resilient without full DFs
|
||||||
|
try:
|
||||||
|
setattr(b, "_web_multi_copy", sess.get("multi_copy") or None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Ensure minimal structures exist
|
||||||
|
try:
|
||||||
|
if not isinstance(getattr(b, "card_library", None), dict):
|
||||||
|
b.card_library = {}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
if not isinstance(getattr(b, "ideal_counts", None), dict):
|
||||||
|
b.ideal_counts = {}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Inject a single Multi-Copy stage
|
||||||
|
ctx["stages"] = [{"key": "multi_copy", "label": "Multi-Copy Package", "runner_name": "__add_multi_copy__"}]
|
||||||
|
ctx["idx"] = 0
|
||||||
|
ctx["last_visible_idx"] = 0
|
||||||
|
except Exception:
|
||||||
|
# Leave existing context untouched on unexpected failure
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/multicopy/check", response_class=HTMLResponse)
|
||||||
|
async def multicopy_check(request: Request) -> HTMLResponse:
|
||||||
|
"""If current commander/tags suggest a multi-copy archetype, render a choose-one modal.
|
||||||
|
|
||||||
|
Returns empty content when not applicable to avoid flashing a modal unnecessarily.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HTMLResponse with multi-copy modal or empty string
|
||||||
|
"""
|
||||||
|
sid = request.cookies.get("sid") or new_sid()
|
||||||
|
sess = get_session(sid)
|
||||||
|
commander = str(sess.get("commander") or "").strip()
|
||||||
|
tags = list(sess.get("tags") or [])
|
||||||
|
if not commander:
|
||||||
|
return HTMLResponse("")
|
||||||
|
# Avoid re-prompting repeatedly for the same selection context
|
||||||
|
key = commander + "||" + ",".join(sorted([str(t).strip().lower() for t in tags if str(t).strip()]))
|
||||||
|
seen = set(sess.get("mc_seen_keys", []) or [])
|
||||||
|
if key in seen:
|
||||||
|
return HTMLResponse("")
|
||||||
|
# Build a light DeckBuilder seeded with commander + tags (no heavy data load required)
|
||||||
|
try:
|
||||||
|
tmp = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True)
|
||||||
|
df = tmp.load_commander_data()
|
||||||
|
row = df[df["name"].astype(str) == commander]
|
||||||
|
if row.empty:
|
||||||
|
return HTMLResponse("")
|
||||||
|
tmp._apply_commander_selection(row.iloc[0])
|
||||||
|
tmp.selected_tags = list(tags or [])
|
||||||
|
try:
|
||||||
|
tmp.primary_tag = tmp.selected_tags[0] if len(tmp.selected_tags) > 0 else None
|
||||||
|
tmp.secondary_tag = tmp.selected_tags[1] if len(tmp.selected_tags) > 1 else None
|
||||||
|
tmp.tertiary_tag = tmp.selected_tags[2] if len(tmp.selected_tags) > 2 else None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Establish color identity from the selected commander
|
||||||
|
try:
|
||||||
|
tmp.determine_color_identity()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Detect viable archetypes
|
||||||
|
results = bu.detect_viable_multi_copy_archetypes(tmp) or []
|
||||||
|
if not results:
|
||||||
|
# Remember this key to avoid re-checking until tags/commander change
|
||||||
|
try:
|
||||||
|
seen.add(key)
|
||||||
|
sess["mc_seen_keys"] = list(seen)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return HTMLResponse("")
|
||||||
|
# Render modal template with top N (cap small for UX)
|
||||||
|
items = results[:5]
|
||||||
|
ctx = {
|
||||||
|
"request": request,
|
||||||
|
"items": items,
|
||||||
|
"commander": commander,
|
||||||
|
"tags": tags,
|
||||||
|
}
|
||||||
|
return templates.TemplateResponse("build/_multi_copy_modal.html", ctx)
|
||||||
|
except Exception:
|
||||||
|
return HTMLResponse("")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/multicopy/save", response_class=HTMLResponse)
|
||||||
|
async def multicopy_save(
|
||||||
|
request: Request,
|
||||||
|
choice_id: str = Form(None),
|
||||||
|
count: int = Form(None),
|
||||||
|
thrumming: str | None = Form(None),
|
||||||
|
skip: str | None = Form(None),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Persist user selection (or skip) for multi-copy archetype in session and close modal.
|
||||||
|
|
||||||
|
Returns a tiny confirmation chip via OOB swap (optional) and removes the modal.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object
|
||||||
|
choice_id: Multi-copy archetype ID (e.g., 'hare_apparent')
|
||||||
|
count: Number of copies to include
|
||||||
|
thrumming: Whether to include Thrumming Stone
|
||||||
|
skip: Whether to skip multi-copy for this build
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HTMLResponse with confirmation chip (OOB swap)
|
||||||
|
"""
|
||||||
|
sid = request.cookies.get("sid") or new_sid()
|
||||||
|
sess = get_session(sid)
|
||||||
|
commander = str(sess.get("commander") or "").strip()
|
||||||
|
tags = list(sess.get("tags") or [])
|
||||||
|
key = commander + "||" + ",".join(sorted([str(t).strip().lower() for t in tags if str(t).strip()]))
|
||||||
|
# Update seen set to avoid re-prompt next load
|
||||||
|
seen = set(sess.get("mc_seen_keys", []) or [])
|
||||||
|
seen.add(key)
|
||||||
|
sess["mc_seen_keys"] = list(seen)
|
||||||
|
# Handle skip explicitly
|
||||||
|
if skip and str(skip).strip() in ("1","true","on","yes"):
|
||||||
|
# Clear any prior choice for this run
|
||||||
|
try:
|
||||||
|
if sess.get("multi_copy"):
|
||||||
|
del sess["multi_copy"]
|
||||||
|
if sess.get("mc_applied_key"):
|
||||||
|
del sess["mc_applied_key"]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Return nothing (modal will be removed client-side)
|
||||||
|
# Also emit an OOB chip indicating skip
|
||||||
|
chip = (
|
||||||
|
'<div id="last-action" hx-swap-oob="true">'
|
||||||
|
'<span class="chip" title="Click to dismiss">Dismissed multi-copy suggestions</span>'
|
||||||
|
'</div>'
|
||||||
|
)
|
||||||
|
return HTMLResponse(chip)
|
||||||
|
# Persist selection when provided
|
||||||
|
payload = None
|
||||||
|
try:
|
||||||
|
meta = bc.MULTI_COPY_ARCHETYPES.get(str(choice_id), {})
|
||||||
|
name = meta.get("name") or str(choice_id)
|
||||||
|
printed_cap = meta.get("printed_cap")
|
||||||
|
# Coerce count with bounds: default -> rec_window[0], cap by printed_cap when present
|
||||||
|
if count is None:
|
||||||
|
count = int(meta.get("default_count", 25))
|
||||||
|
try:
|
||||||
|
count = int(count)
|
||||||
|
except Exception:
|
||||||
|
count = int(meta.get("default_count", 25))
|
||||||
|
if isinstance(printed_cap, int) and printed_cap > 0:
|
||||||
|
count = max(1, min(printed_cap, count))
|
||||||
|
payload = {
|
||||||
|
"id": str(choice_id),
|
||||||
|
"name": name,
|
||||||
|
"count": int(count),
|
||||||
|
"thrumming": True if (thrumming and str(thrumming).strip() in ("1","true","on","yes")) else False,
|
||||||
|
}
|
||||||
|
sess["multi_copy"] = payload
|
||||||
|
# Mark as not yet applied so the next build start/continue can account for it once
|
||||||
|
try:
|
||||||
|
if sess.get("mc_applied_key"):
|
||||||
|
del sess["mc_applied_key"]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# If there's an active build context, rebuild it so Multi-Copy runs first
|
||||||
|
if sess.get("build_ctx"):
|
||||||
|
_rebuild_ctx_with_multicopy(sess)
|
||||||
|
except Exception:
|
||||||
|
payload = None
|
||||||
|
# Return OOB chip summarizing the selection
|
||||||
|
if payload:
|
||||||
|
chip = (
|
||||||
|
'<div id="last-action" hx-swap-oob="true">'
|
||||||
|
f'<span class="chip" title="Click to dismiss">Selected multi-copy: '
|
||||||
|
f"<strong>{_esc(payload.get('name',''))}</strong> x{int(payload.get('count',0))}"
|
||||||
|
f"{' + Thrumming Stone' if payload.get('thrumming') else ''}</span>"
|
||||||
|
'</div>'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
chip = (
|
||||||
|
'<div id="last-action" hx-swap-oob="true">'
|
||||||
|
'<span class="chip" title="Click to dismiss">Saved</span>'
|
||||||
|
'</div>'
|
||||||
|
)
|
||||||
|
return HTMLResponse(chip)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/new/multicopy", response_class=HTMLResponse)
|
||||||
|
async def build_new_multicopy(
|
||||||
|
request: Request,
|
||||||
|
commander: str = Query(""),
|
||||||
|
primary_tag: str | None = Query(None),
|
||||||
|
secondary_tag: str | None = Query(None),
|
||||||
|
tertiary_tag: str | None = Query(None),
|
||||||
|
tag_mode: str | None = Query("AND"),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Return multi-copy suggestions for the New Deck modal based on commander + selected tags.
|
||||||
|
|
||||||
|
This does not mutate the session; it simply renders a form snippet that posts with the main modal.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object
|
||||||
|
commander: Commander name
|
||||||
|
primary_tag: Primary theme tag
|
||||||
|
secondary_tag: Secondary theme tag
|
||||||
|
tertiary_tag: Tertiary theme tag
|
||||||
|
tag_mode: Tag matching mode (AND/OR)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HTMLResponse with multi-copy suggestions or empty string
|
||||||
|
"""
|
||||||
|
name = (commander or "").strip()
|
||||||
|
if not name:
|
||||||
|
return HTMLResponse("")
|
||||||
|
try:
|
||||||
|
tmp = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True)
|
||||||
|
df = tmp.load_commander_data()
|
||||||
|
row = df[df["name"].astype(str) == name]
|
||||||
|
if row.empty:
|
||||||
|
return HTMLResponse("")
|
||||||
|
tmp._apply_commander_selection(row.iloc[0])
|
||||||
|
tags = [t for t in [primary_tag, secondary_tag, tertiary_tag] if t]
|
||||||
|
tmp.selected_tags = list(tags or [])
|
||||||
|
try:
|
||||||
|
tmp.primary_tag = tmp.selected_tags[0] if len(tmp.selected_tags) > 0 else None
|
||||||
|
tmp.secondary_tag = tmp.selected_tags[1] if len(tmp.selected_tags) > 1 else None
|
||||||
|
tmp.tertiary_tag = tmp.selected_tags[2] if len(tmp.selected_tags) > 2 else None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
tmp.determine_color_identity()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
results = bu.detect_viable_multi_copy_archetypes(tmp) or []
|
||||||
|
# For the New Deck modal, only show suggestions where the matched tags intersect
|
||||||
|
# the explicitly selected tags (ignore commander-default themes).
|
||||||
|
sel_tags = {str(t).strip().lower() for t in (tags or []) if str(t).strip()}
|
||||||
|
def _matched_reason_tags(item: dict) -> set[str]:
|
||||||
|
out = set()
|
||||||
|
try:
|
||||||
|
for r in item.get('reasons', []) or []:
|
||||||
|
if not isinstance(r, str):
|
||||||
|
continue
|
||||||
|
rl = r.strip().lower()
|
||||||
|
if rl.startswith('tags:'):
|
||||||
|
body = rl.split('tags:', 1)[1].strip()
|
||||||
|
parts = [p.strip() for p in body.split(',') if p.strip()]
|
||||||
|
out.update(parts)
|
||||||
|
except Exception:
|
||||||
|
return set()
|
||||||
|
return out
|
||||||
|
if sel_tags:
|
||||||
|
results = [it for it in results if (_matched_reason_tags(it) & sel_tags)]
|
||||||
|
else:
|
||||||
|
# If no selected tags, do not show any multi-copy suggestions in the modal
|
||||||
|
results = []
|
||||||
|
if not results:
|
||||||
|
return HTMLResponse("")
|
||||||
|
items = results[:5]
|
||||||
|
ctx = {"request": request, "items": items}
|
||||||
|
return templates.TemplateResponse("build/_new_deck_multicopy.html", ctx)
|
||||||
|
except Exception:
|
||||||
|
return HTMLResponse("")
|
||||||
1262
code/web/routes/build_newflow.py
Normal file
1262
code/web/routes/build_newflow.py
Normal file
File diff suppressed because it is too large
Load diff
737
code/web/routes/build_partners.py
Normal file
737
code/web/routes/build_partners.py
Normal file
|
|
@ -0,0 +1,737 @@
|
||||||
|
"""
|
||||||
|
Partner mechanics routes and utilities for deck building.
|
||||||
|
|
||||||
|
Handles partner commanders, backgrounds, Doctor/Companion pairings,
|
||||||
|
and partner preview/validation functionality.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Iterable
|
||||||
|
from urllib.parse import quote_plus
|
||||||
|
from fastapi import APIRouter, Request, Form
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from ..app import (
|
||||||
|
ENABLE_PARTNER_MECHANICS,
|
||||||
|
)
|
||||||
|
from ..services.telemetry import log_partner_suggestion_selected
|
||||||
|
from ..services.partner_suggestions import get_partner_suggestions
|
||||||
|
from ..services.commander_catalog_loader import (
|
||||||
|
load_commander_catalog,
|
||||||
|
find_commander_record,
|
||||||
|
CommanderRecord,
|
||||||
|
normalized_restricted_labels,
|
||||||
|
shared_restricted_partner_label,
|
||||||
|
)
|
||||||
|
from deck_builder.background_loader import load_background_cards
|
||||||
|
from deck_builder.partner_selection import apply_partner_inputs
|
||||||
|
from deck_builder.builder import DeckBuilder
|
||||||
|
from exceptions import CommanderPartnerError
|
||||||
|
from code.logging_util import get_logger
|
||||||
|
|
||||||
|
|
||||||
|
LOGGER = get_logger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
_PARTNER_MODE_LABELS = {
|
||||||
|
"partner": "Partner",
|
||||||
|
"partner_restricted": "Partner (Restricted)",
|
||||||
|
"partner_with": "Partner With",
|
||||||
|
"background": "Choose a Background",
|
||||||
|
"doctor_companion": "Doctor & Companion",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_WUBRG_ORDER = ["W", "U", "B", "R", "G"]
|
||||||
|
_COLOR_NAME_MAP = {
|
||||||
|
"W": "White",
|
||||||
|
"U": "Blue",
|
||||||
|
"B": "Black",
|
||||||
|
"R": "Red",
|
||||||
|
"G": "Green",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _color_code(identity: Iterable[str]) -> str:
|
||||||
|
"""Convert color identity to standard WUBRG-ordered code."""
|
||||||
|
colors = [str(c).strip().upper() for c in identity if str(c).strip()]
|
||||||
|
if not colors:
|
||||||
|
return "C"
|
||||||
|
ordered: list[str] = [c for c in _WUBRG_ORDER if c in colors]
|
||||||
|
for color in colors:
|
||||||
|
if color not in ordered:
|
||||||
|
ordered.append(color)
|
||||||
|
return "".join(ordered) or "C"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_color_label(identity: Iterable[str]) -> str:
|
||||||
|
"""Format color identity as human-readable label with code."""
|
||||||
|
code = _color_code(identity)
|
||||||
|
if code == "C":
|
||||||
|
return "Colorless (C)"
|
||||||
|
names = [_COLOR_NAME_MAP.get(ch, ch) for ch in code]
|
||||||
|
return " / ".join(names) + f" ({code})"
|
||||||
|
|
||||||
|
|
||||||
|
def _partner_mode_label(mode: str | None) -> str:
|
||||||
|
"""Convert partner mode to display label."""
|
||||||
|
if not mode:
|
||||||
|
return "Partner Mechanics"
|
||||||
|
return _PARTNER_MODE_LABELS.get(mode, mode.title())
|
||||||
|
|
||||||
|
|
||||||
|
def _scryfall_image_url(card_name: str, version: str = "normal") -> str | None:
|
||||||
|
"""Generate Scryfall image URL for card."""
|
||||||
|
name = str(card_name or "").strip()
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
return f"https://api.scryfall.com/cards/named?fuzzy={quote_plus(name)}&format=image&version={version}"
|
||||||
|
|
||||||
|
|
||||||
|
def _scryfall_page_url(card_name: str) -> str | None:
|
||||||
|
"""Generate Scryfall search URL for card."""
|
||||||
|
name = str(card_name or "").strip()
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
return f"https://scryfall.com/search?q={quote_plus(name)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _secondary_role_label(mode: str | None, secondary_name: str | None) -> str | None:
|
||||||
|
"""Determine the role label for the secondary commander based on pairing mode."""
|
||||||
|
if not mode:
|
||||||
|
return None
|
||||||
|
mode_lower = mode.lower()
|
||||||
|
if mode_lower == "background":
|
||||||
|
return "Background"
|
||||||
|
if mode_lower == "partner_with":
|
||||||
|
return "Partner With"
|
||||||
|
if mode_lower == "doctor_companion":
|
||||||
|
record = find_commander_record(secondary_name or "") if secondary_name else None
|
||||||
|
if record and getattr(record, "is_doctor", False):
|
||||||
|
return "Doctor"
|
||||||
|
if record and getattr(record, "is_doctors_companion", False):
|
||||||
|
return "Doctor's Companion"
|
||||||
|
return "Doctor pairing"
|
||||||
|
return "Partner commander"
|
||||||
|
|
||||||
|
|
||||||
|
def _combined_to_payload(combined: Any) -> dict[str, Any]:
|
||||||
|
"""Convert CombinedCommander object to JSON-serializable payload."""
|
||||||
|
color_identity = tuple(getattr(combined, "color_identity", ()) or ())
|
||||||
|
warnings = list(getattr(combined, "warnings", []) or [])
|
||||||
|
mode_obj = getattr(combined, "partner_mode", None)
|
||||||
|
mode_value = getattr(mode_obj, "value", None) if mode_obj is not None else None
|
||||||
|
secondary = getattr(combined, "secondary_name", None)
|
||||||
|
secondary_image = _scryfall_image_url(secondary)
|
||||||
|
secondary_url = _scryfall_page_url(secondary)
|
||||||
|
secondary_role = _secondary_role_label(mode_value, secondary)
|
||||||
|
return {
|
||||||
|
"primary_name": getattr(combined, "primary_name", None),
|
||||||
|
"secondary_name": secondary,
|
||||||
|
"partner_mode": mode_value,
|
||||||
|
"partner_mode_label": _partner_mode_label(mode_value),
|
||||||
|
"color_identity": list(color_identity),
|
||||||
|
"color_code": _color_code(color_identity),
|
||||||
|
"color_label": _format_color_label(color_identity),
|
||||||
|
"theme_tags": list(getattr(combined, "theme_tags", []) or []),
|
||||||
|
"warnings": warnings,
|
||||||
|
"secondary_image_url": secondary_image,
|
||||||
|
"secondary_scryfall_url": secondary_url,
|
||||||
|
"secondary_role_label": secondary_role,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_partner_options(primary: CommanderRecord | None) -> tuple[list[dict[str, Any]], str | None]:
|
||||||
|
"""
|
||||||
|
Build list of valid partner options for a given primary commander.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (partner_options_list, variant_type) where variant is
|
||||||
|
"partner", "doctor_companion", or None
|
||||||
|
"""
|
||||||
|
if not ENABLE_PARTNER_MECHANICS:
|
||||||
|
return [], None
|
||||||
|
try:
|
||||||
|
catalog = load_commander_catalog()
|
||||||
|
except Exception:
|
||||||
|
return [], None
|
||||||
|
|
||||||
|
if primary is None:
|
||||||
|
return [], None
|
||||||
|
|
||||||
|
primary_name = primary.display_name.casefold()
|
||||||
|
primary_partner_targets = {target.casefold() for target in (primary.partner_with or ())}
|
||||||
|
primary_is_partner = bool(primary.is_partner or primary_partner_targets)
|
||||||
|
primary_restricted_labels = normalized_restricted_labels(primary)
|
||||||
|
primary_is_doctor = bool(primary.is_doctor)
|
||||||
|
primary_is_companion = bool(primary.is_doctors_companion)
|
||||||
|
|
||||||
|
variant: str | None = None
|
||||||
|
if primary_is_doctor or primary_is_companion:
|
||||||
|
variant = "doctor_companion"
|
||||||
|
elif primary_is_partner:
|
||||||
|
variant = "partner"
|
||||||
|
|
||||||
|
options: list[dict[str, Any]] = []
|
||||||
|
if variant is None:
|
||||||
|
return [], None
|
||||||
|
|
||||||
|
for record in catalog.entries:
|
||||||
|
if record.display_name.casefold() == primary_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
pairing_mode: str | None = None
|
||||||
|
role_label: str | None = None
|
||||||
|
restriction_label: str | None = None
|
||||||
|
record_name_cf = record.display_name.casefold()
|
||||||
|
is_direct_pair = bool(primary_partner_targets and record_name_cf in primary_partner_targets)
|
||||||
|
|
||||||
|
if variant == "doctor_companion":
|
||||||
|
if is_direct_pair:
|
||||||
|
pairing_mode = "partner_with"
|
||||||
|
role_label = "Partner With"
|
||||||
|
elif primary_is_doctor and record.is_doctors_companion:
|
||||||
|
pairing_mode = "doctor_companion"
|
||||||
|
role_label = "Doctor's Companion"
|
||||||
|
elif primary_is_companion and record.is_doctor:
|
||||||
|
pairing_mode = "doctor_companion"
|
||||||
|
role_label = "Doctor"
|
||||||
|
else:
|
||||||
|
if not record.is_partner or record.is_background:
|
||||||
|
continue
|
||||||
|
if primary_partner_targets:
|
||||||
|
if not is_direct_pair:
|
||||||
|
continue
|
||||||
|
pairing_mode = "partner_with"
|
||||||
|
role_label = "Partner With"
|
||||||
|
elif primary_restricted_labels:
|
||||||
|
restriction = shared_restricted_partner_label(primary, record)
|
||||||
|
if not restriction:
|
||||||
|
continue
|
||||||
|
pairing_mode = "partner_restricted"
|
||||||
|
restriction_label = restriction
|
||||||
|
else:
|
||||||
|
if record.partner_with:
|
||||||
|
continue
|
||||||
|
if not getattr(record, "has_plain_partner", False):
|
||||||
|
continue
|
||||||
|
if record.is_doctors_companion:
|
||||||
|
continue
|
||||||
|
pairing_mode = "partner"
|
||||||
|
|
||||||
|
if not pairing_mode:
|
||||||
|
continue
|
||||||
|
|
||||||
|
options.append(
|
||||||
|
{
|
||||||
|
"name": record.display_name,
|
||||||
|
"color_code": _color_code(record.color_identity),
|
||||||
|
"color_label": _format_color_label(record.color_identity),
|
||||||
|
"partner_with": list(record.partner_with or ()),
|
||||||
|
"pairing_mode": pairing_mode,
|
||||||
|
"role_label": role_label,
|
||||||
|
"restriction_label": restriction_label,
|
||||||
|
"mode_label": _partner_mode_label(pairing_mode),
|
||||||
|
"image_url": _scryfall_image_url(record.display_name),
|
||||||
|
"scryfall_url": _scryfall_page_url(record.display_name),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
options.sort(key=lambda item: item["name"].casefold())
|
||||||
|
return options, variant
|
||||||
|
|
||||||
|
|
||||||
|
def _build_background_options() -> list[dict[str, Any]]:
|
||||||
|
"""Build list of available background cards for Choose a Background commanders."""
|
||||||
|
if not ENABLE_PARTNER_MECHANICS:
|
||||||
|
return []
|
||||||
|
|
||||||
|
options: list[dict[str, Any]] = []
|
||||||
|
try:
|
||||||
|
catalog = load_background_cards()
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
LOGGER.warning("background_cards_missing fallback_to_commander_catalog", extra={"error": str(exc)})
|
||||||
|
catalog = None
|
||||||
|
except Exception as exc: # pragma: no cover - unexpected loader failure
|
||||||
|
LOGGER.warning("background_cards_failed fallback_to_commander_catalog", exc_info=exc)
|
||||||
|
catalog = None
|
||||||
|
|
||||||
|
if catalog and getattr(catalog, "entries", None):
|
||||||
|
seen: set[str] = set()
|
||||||
|
for card in catalog.entries:
|
||||||
|
name_key = card.display_name.casefold()
|
||||||
|
if name_key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(name_key)
|
||||||
|
options.append(
|
||||||
|
{
|
||||||
|
"name": card.display_name,
|
||||||
|
"color_code": _color_code(card.color_identity),
|
||||||
|
"color_label": _format_color_label(card.color_identity),
|
||||||
|
"image_url": _scryfall_image_url(card.display_name),
|
||||||
|
"scryfall_url": _scryfall_page_url(card.display_name),
|
||||||
|
"role_label": "Background",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if options:
|
||||||
|
options.sort(key=lambda item: item["name"].casefold())
|
||||||
|
return options
|
||||||
|
|
||||||
|
fallback_options = _background_options_from_commander_catalog()
|
||||||
|
if fallback_options:
|
||||||
|
return fallback_options
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
def _background_options_from_commander_catalog() -> list[dict[str, Any]]:
|
||||||
|
"""Fallback: load backgrounds from commander catalog when background_cards.json is unavailable."""
|
||||||
|
try:
|
||||||
|
catalog = load_commander_catalog()
|
||||||
|
except Exception as exc: # pragma: no cover - catalog load issues handled elsewhere
|
||||||
|
LOGGER.warning("commander_catalog_background_fallback_failed", exc_info=exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
seen: set[str] = set()
|
||||||
|
options: list[dict[str, Any]] = []
|
||||||
|
for record in getattr(catalog, "entries", ()):
|
||||||
|
if not getattr(record, "is_background", False):
|
||||||
|
continue
|
||||||
|
name = getattr(record, "display_name", None)
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
key = str(name).casefold()
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
color_identity = getattr(record, "color_identity", tuple())
|
||||||
|
options.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"color_code": _color_code(color_identity),
|
||||||
|
"color_label": _format_color_label(color_identity),
|
||||||
|
"image_url": _scryfall_image_url(name),
|
||||||
|
"scryfall_url": _scryfall_page_url(name),
|
||||||
|
"role_label": "Background",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
options.sort(key=lambda item: item["name"].casefold())
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
def _partner_ui_context(
|
||||||
|
commander_name: str,
|
||||||
|
*,
|
||||||
|
partner_enabled: bool,
|
||||||
|
secondary_selection: str | None,
|
||||||
|
background_selection: str | None,
|
||||||
|
combined_preview: dict[str, Any] | None,
|
||||||
|
warnings: Iterable[str] | None,
|
||||||
|
partner_error: str | None,
|
||||||
|
auto_note: str | None,
|
||||||
|
auto_assigned: bool | None = None,
|
||||||
|
auto_prefill_allowed: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Build complete partner UI context for rendering partner selection components.
|
||||||
|
|
||||||
|
This includes partner options, background options, preview payload,
|
||||||
|
suggestions, warnings, and all necessary state for the partner UI.
|
||||||
|
"""
|
||||||
|
record = find_commander_record(commander_name)
|
||||||
|
partner_options, partner_variant = _build_partner_options(record)
|
||||||
|
supports_backgrounds = bool(record.supports_backgrounds) if record else False
|
||||||
|
background_options = _build_background_options() if supports_backgrounds else []
|
||||||
|
|
||||||
|
selected_secondary = (secondary_selection or "").strip()
|
||||||
|
selected_background = (background_selection or "").strip()
|
||||||
|
warnings_list = list(warnings or [])
|
||||||
|
preview_payload: dict[str, Any] | None = combined_preview if isinstance(combined_preview, dict) else None
|
||||||
|
preview_error: str | None = None
|
||||||
|
|
||||||
|
auto_prefill_applied = False
|
||||||
|
auto_default_name: str | None = None
|
||||||
|
auto_note_value = auto_note
|
||||||
|
|
||||||
|
# Auto-prefill Partner With targets
|
||||||
|
if (
|
||||||
|
ENABLE_PARTNER_MECHANICS
|
||||||
|
and partner_variant == "partner"
|
||||||
|
and record
|
||||||
|
and record.partner_with
|
||||||
|
and not selected_secondary
|
||||||
|
and not selected_background
|
||||||
|
and auto_prefill_allowed
|
||||||
|
):
|
||||||
|
target_names = [name.strip() for name in record.partner_with if str(name).strip()]
|
||||||
|
for target in target_names:
|
||||||
|
for option in partner_options:
|
||||||
|
if option["name"].casefold() == target.casefold():
|
||||||
|
selected_secondary = option["name"]
|
||||||
|
auto_default_name = option["name"]
|
||||||
|
auto_prefill_applied = True
|
||||||
|
if not auto_note_value:
|
||||||
|
auto_note_value = f"Automatically paired with {option['name']} (Partner With)."
|
||||||
|
break
|
||||||
|
if auto_prefill_applied:
|
||||||
|
break
|
||||||
|
|
||||||
|
partner_active = bool((selected_secondary or selected_background) and ENABLE_PARTNER_MECHANICS)
|
||||||
|
partner_capable = bool(ENABLE_PARTNER_MECHANICS and (partner_options or background_options))
|
||||||
|
|
||||||
|
# Dynamic labels based on variant
|
||||||
|
placeholder = "Select a partner"
|
||||||
|
select_label = "Partner commander"
|
||||||
|
role_hint: str | None = None
|
||||||
|
if partner_variant == "doctor_companion" and record:
|
||||||
|
has_partner_with_option = any(option.get("pairing_mode") == "partner_with" for option in partner_options)
|
||||||
|
if record.is_doctor:
|
||||||
|
if has_partner_with_option:
|
||||||
|
placeholder = "Select a companion or Partner With match"
|
||||||
|
select_label = "Companion or Partner"
|
||||||
|
role_hint = "Choose a Doctor's Companion or Partner With match for this Doctor."
|
||||||
|
else:
|
||||||
|
placeholder = "Select a companion"
|
||||||
|
select_label = "Companion"
|
||||||
|
role_hint = "Choose a Doctor's Companion to pair with this Doctor."
|
||||||
|
elif record.is_doctors_companion:
|
||||||
|
if has_partner_with_option:
|
||||||
|
placeholder = "Select a Doctor or Partner With match"
|
||||||
|
select_label = "Doctor or Partner"
|
||||||
|
role_hint = "Choose a Doctor or Partner With pairing for this companion."
|
||||||
|
else:
|
||||||
|
placeholder = "Select a Doctor"
|
||||||
|
select_label = "Doctor partner"
|
||||||
|
role_hint = "Choose a Doctor to accompany this companion."
|
||||||
|
|
||||||
|
# Partner suggestions
|
||||||
|
suggestions_enabled = bool(ENABLE_PARTNER_MECHANICS)
|
||||||
|
suggestions_visible: list[dict[str, Any]] = []
|
||||||
|
suggestions_hidden: list[dict[str, Any]] = []
|
||||||
|
suggestions_total = 0
|
||||||
|
suggestions_metadata: dict[str, Any] = {}
|
||||||
|
suggestions_error: str | None = None
|
||||||
|
suggestions_loaded = False
|
||||||
|
|
||||||
|
if suggestions_enabled and record:
|
||||||
|
try:
|
||||||
|
suggestion_result = get_partner_suggestions(record.display_name)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
LOGGER.warning("partner suggestions failed", exc_info=exc)
|
||||||
|
suggestion_result = None
|
||||||
|
if suggestion_result is None:
|
||||||
|
suggestions_error = "Partner suggestions dataset is unavailable."
|
||||||
|
else:
|
||||||
|
suggestions_loaded = True
|
||||||
|
partner_names = [opt.get("name") for opt in (partner_options or []) if opt.get("name")]
|
||||||
|
background_names = [opt.get("name") for opt in (background_options or []) if opt.get("name")]
|
||||||
|
try:
|
||||||
|
visible, hidden = suggestion_result.flatten(partner_names, background_names, visible_limit=3)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
LOGGER.warning("partner suggestions flatten failed", exc_info=exc)
|
||||||
|
visible = []
|
||||||
|
hidden = []
|
||||||
|
suggestions_visible = visible
|
||||||
|
suggestions_hidden = hidden
|
||||||
|
suggestions_total = suggestion_result.total
|
||||||
|
if isinstance(suggestion_result.metadata, dict):
|
||||||
|
suggestions_metadata = dict(suggestion_result.metadata)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"partner_feature_available": ENABLE_PARTNER_MECHANICS,
|
||||||
|
"partner_capable": partner_capable,
|
||||||
|
"partner_enabled": partner_active,
|
||||||
|
"selected_secondary_commander": selected_secondary,
|
||||||
|
"selected_background": selected_background if supports_backgrounds else "",
|
||||||
|
"partner_options": partner_options if partner_options else [],
|
||||||
|
"background_options": background_options if background_options else [],
|
||||||
|
"primary_partner_with": list(record.partner_with) if record else [],
|
||||||
|
"primary_supports_backgrounds": supports_backgrounds,
|
||||||
|
"primary_is_partner": bool(record.is_partner) if record else False,
|
||||||
|
"primary_commander_display": record.display_name if record else commander_name,
|
||||||
|
"partner_preview": preview_payload,
|
||||||
|
"partner_warnings": warnings_list,
|
||||||
|
"partner_error": partner_error,
|
||||||
|
"partner_auto_note": auto_note_value,
|
||||||
|
"partner_auto_assigned": bool(auto_prefill_applied or auto_assigned),
|
||||||
|
"partner_auto_default": auto_default_name,
|
||||||
|
"partner_select_variant": partner_variant,
|
||||||
|
"partner_select_label": select_label,
|
||||||
|
"partner_select_placeholder": placeholder,
|
||||||
|
"partner_role_hint": role_hint,
|
||||||
|
"partner_suggestions_enabled": suggestions_enabled,
|
||||||
|
"partner_suggestions": suggestions_visible,
|
||||||
|
"partner_suggestions_hidden": suggestions_hidden,
|
||||||
|
"partner_suggestions_total": suggestions_total,
|
||||||
|
"partner_suggestions_metadata": suggestions_metadata,
|
||||||
|
"partner_suggestions_loaded": suggestions_loaded,
|
||||||
|
"partner_suggestions_error": suggestions_error,
|
||||||
|
"partner_suggestions_available": bool(suggestions_visible or suggestions_hidden),
|
||||||
|
"partner_suggestions_has_hidden": bool(suggestions_hidden),
|
||||||
|
"partner_suggestions_endpoint": "/api/partner/suggestions",
|
||||||
|
}
|
||||||
|
context["has_partner_options"] = bool(partner_options)
|
||||||
|
context["has_background_options"] = bool(background_options)
|
||||||
|
context["partner_hidden_value"] = "1" if partner_capable else "0"
|
||||||
|
context["partner_auto_opt_out"] = not bool(auto_prefill_allowed)
|
||||||
|
context["partner_prefill_available"] = bool(partner_variant == "partner" and partner_options)
|
||||||
|
|
||||||
|
# Generate preview if not provided
|
||||||
|
if preview_payload is None and ENABLE_PARTNER_MECHANICS and (selected_secondary or selected_background):
|
||||||
|
try:
|
||||||
|
builder = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True)
|
||||||
|
combined_obj = apply_partner_inputs(
|
||||||
|
builder,
|
||||||
|
primary_name=commander_name,
|
||||||
|
secondary_name=selected_secondary or None,
|
||||||
|
background_name=selected_background or None,
|
||||||
|
feature_enabled=True,
|
||||||
|
)
|
||||||
|
except CommanderPartnerError as exc:
|
||||||
|
preview_error = str(exc) or "Invalid partner selection."
|
||||||
|
except Exception as exc:
|
||||||
|
preview_error = f"Partner preview failed: {exc}"
|
||||||
|
else:
|
||||||
|
if combined_obj is not None:
|
||||||
|
preview_payload = _combined_to_payload(combined_obj)
|
||||||
|
if combined_obj.warnings:
|
||||||
|
for warn in combined_obj.warnings:
|
||||||
|
if warn not in warnings_list:
|
||||||
|
warnings_list.append(warn)
|
||||||
|
if preview_payload:
|
||||||
|
context["partner_preview"] = preview_payload
|
||||||
|
preview_tags = preview_payload.get("theme_tags")
|
||||||
|
if preview_tags:
|
||||||
|
context["partner_theme_tags"] = list(preview_tags)
|
||||||
|
if preview_error and not partner_error:
|
||||||
|
context["partner_error"] = preview_error
|
||||||
|
partner_error = preview_error
|
||||||
|
context["partner_warnings"] = warnings_list
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_partner_selection(
|
||||||
|
commander_name: str,
|
||||||
|
*,
|
||||||
|
feature_enabled: bool,
|
||||||
|
partner_enabled: bool,
|
||||||
|
secondary_candidate: str | None,
|
||||||
|
background_candidate: str | None,
|
||||||
|
auto_opt_out: bool = False,
|
||||||
|
selection_source: str | None = None,
|
||||||
|
) -> tuple[
|
||||||
|
str | None,
|
||||||
|
dict[str, Any] | None,
|
||||||
|
list[str],
|
||||||
|
str | None,
|
||||||
|
str | None,
|
||||||
|
str | None,
|
||||||
|
str | None,
|
||||||
|
bool,
|
||||||
|
]:
|
||||||
|
"""
|
||||||
|
Resolve and validate partner selection, applying auto-pairing when appropriate.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (error, preview_payload, warnings, auto_note, resolved_secondary,
|
||||||
|
resolved_background, partner_mode, auto_assigned_flag)
|
||||||
|
"""
|
||||||
|
if not (feature_enabled and ENABLE_PARTNER_MECHANICS):
|
||||||
|
return None, None, [], None, None, None, None, False
|
||||||
|
|
||||||
|
secondary = (secondary_candidate or "").strip()
|
||||||
|
background = (background_candidate or "").strip()
|
||||||
|
auto_note: str | None = None
|
||||||
|
auto_assigned = False
|
||||||
|
selection_source_clean = (selection_source or "").strip().lower() or None
|
||||||
|
|
||||||
|
record = find_commander_record(commander_name)
|
||||||
|
partner_options, partner_variant = _build_partner_options(record)
|
||||||
|
supports_backgrounds = bool(record and record.supports_backgrounds)
|
||||||
|
background_options = _build_background_options() if supports_backgrounds else []
|
||||||
|
|
||||||
|
if not partner_enabled and not secondary and not background:
|
||||||
|
return None, None, [], None, None, None, None, False
|
||||||
|
|
||||||
|
if not supports_backgrounds:
|
||||||
|
background = ""
|
||||||
|
if not partner_options:
|
||||||
|
secondary = ""
|
||||||
|
|
||||||
|
if secondary and background:
|
||||||
|
return "Provide either a secondary commander or a background, not both.", None, [], auto_note, secondary, background, None, False
|
||||||
|
|
||||||
|
option_lookup = {opt["name"].casefold(): opt for opt in partner_options}
|
||||||
|
if secondary:
|
||||||
|
key = secondary.casefold()
|
||||||
|
if key not in option_lookup:
|
||||||
|
return "Selected partner is not valid for this commander.", None, [], auto_note, secondary, background or None, None, False
|
||||||
|
|
||||||
|
if background:
|
||||||
|
normalized_backgrounds = {opt["name"].casefold() for opt in background_options}
|
||||||
|
if background.casefold() not in normalized_backgrounds:
|
||||||
|
return "Selected background is not available.", None, [], auto_note, secondary or None, background, None, False
|
||||||
|
|
||||||
|
# Auto-assign Partner With targets
|
||||||
|
if not secondary and not background and not auto_opt_out and partner_variant == "partner" and record and record.partner_with:
|
||||||
|
target_names = [name.strip() for name in record.partner_with if str(name).strip()]
|
||||||
|
for target in target_names:
|
||||||
|
opt = option_lookup.get(target.casefold())
|
||||||
|
if opt:
|
||||||
|
secondary = opt["name"]
|
||||||
|
auto_note = f"Automatically paired with {secondary} (Partner With)."
|
||||||
|
auto_assigned = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not secondary and not background:
|
||||||
|
return None, None, [], auto_note, None, None, None, auto_assigned
|
||||||
|
|
||||||
|
builder = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True)
|
||||||
|
try:
|
||||||
|
combined = apply_partner_inputs(
|
||||||
|
builder,
|
||||||
|
primary_name=commander_name,
|
||||||
|
secondary_name=secondary or None,
|
||||||
|
background_name=background or None,
|
||||||
|
feature_enabled=True,
|
||||||
|
selection_source=selection_source_clean,
|
||||||
|
)
|
||||||
|
except CommanderPartnerError as exc:
|
||||||
|
message = str(exc) or "Invalid partner selection."
|
||||||
|
return message, None, [], auto_note, secondary or None, background or None, None, auto_assigned
|
||||||
|
except Exception as exc:
|
||||||
|
return f"Partner selection failed: {exc}", None, [], auto_note, secondary or None, background or None, None, auto_assigned
|
||||||
|
|
||||||
|
if combined is None:
|
||||||
|
return "Unable to resolve partner selection.", None, [], auto_note, secondary or None, background or None, None, auto_assigned
|
||||||
|
|
||||||
|
payload = _combined_to_payload(combined)
|
||||||
|
warnings = payload.get("warnings", []) or []
|
||||||
|
mode = payload.get("partner_mode")
|
||||||
|
if mode == "background":
|
||||||
|
resolved_background = payload.get("secondary_name")
|
||||||
|
return None, payload, warnings, auto_note, None, resolved_background, mode, auto_assigned
|
||||||
|
return None, payload, warnings, auto_note, payload.get("secondary_name"), None, mode, auto_assigned
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/partner/preview", response_class=JSONResponse)
|
||||||
|
async def build_partner_preview(
|
||||||
|
request: Request,
|
||||||
|
commander: str = Form(...),
|
||||||
|
partner_enabled: str | None = Form(None),
|
||||||
|
secondary_commander: str | None = Form(None),
|
||||||
|
background: str | None = Form(None),
|
||||||
|
partner_auto_opt_out: str | None = Form(None),
|
||||||
|
scope: str | None = Form(None),
|
||||||
|
selection_source: str | None = Form(None),
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""
|
||||||
|
Preview a partner pairing and return combined commander details.
|
||||||
|
|
||||||
|
This endpoint validates partner selections and returns:
|
||||||
|
- Combined color identity and theme tags
|
||||||
|
- Partner preview payload with images and metadata
|
||||||
|
- Warnings about legality or capability mismatches
|
||||||
|
- Auto-pairing information for Partner With targets
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request
|
||||||
|
commander: Primary commander name
|
||||||
|
partner_enabled: Whether partner mechanics are enabled ("1"/"true"/etc.)
|
||||||
|
secondary_commander: Secondary partner commander name
|
||||||
|
background: Background card name (for Choose a Background commanders)
|
||||||
|
partner_auto_opt_out: Opt-out of auto-pairing for Partner With
|
||||||
|
scope: Request scope identifier
|
||||||
|
selection_source: Source of selection (e.g., "suggestion", "manual")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSONResponse with partner preview data and validation results
|
||||||
|
"""
|
||||||
|
partner_feature_enabled = ENABLE_PARTNER_MECHANICS
|
||||||
|
raw_partner_enabled = (partner_enabled or "").strip().lower()
|
||||||
|
partner_flag = partner_feature_enabled and raw_partner_enabled in {"1", "true", "on", "yes"}
|
||||||
|
auto_opt_out_flag = (partner_auto_opt_out or "").strip().lower() in {"1", "true", "on", "yes"}
|
||||||
|
selection_source_value = (selection_source or "").strip().lower() or None
|
||||||
|
|
||||||
|
try:
|
||||||
|
(
|
||||||
|
partner_error,
|
||||||
|
combined_payload,
|
||||||
|
partner_warnings,
|
||||||
|
partner_auto_note,
|
||||||
|
resolved_secondary,
|
||||||
|
resolved_background,
|
||||||
|
partner_mode,
|
||||||
|
partner_auto_assigned_flag,
|
||||||
|
) = _resolve_partner_selection(
|
||||||
|
commander,
|
||||||
|
feature_enabled=partner_feature_enabled,
|
||||||
|
partner_enabled=partner_flag,
|
||||||
|
secondary_candidate=secondary_commander,
|
||||||
|
background_candidate=background,
|
||||||
|
auto_opt_out=auto_opt_out_flag,
|
||||||
|
selection_source=selection_source_value,
|
||||||
|
)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"error": f"Partner preview failed: {exc}",
|
||||||
|
"scope": scope or "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
partner_ctx = _partner_ui_context(
|
||||||
|
commander,
|
||||||
|
partner_enabled=partner_flag,
|
||||||
|
secondary_selection=resolved_secondary or secondary_commander,
|
||||||
|
background_selection=resolved_background or background,
|
||||||
|
combined_preview=combined_payload,
|
||||||
|
warnings=partner_warnings,
|
||||||
|
partner_error=partner_error,
|
||||||
|
auto_note=partner_auto_note,
|
||||||
|
auto_assigned=partner_auto_assigned_flag,
|
||||||
|
auto_prefill_allowed=not auto_opt_out_flag,
|
||||||
|
)
|
||||||
|
|
||||||
|
preview_payload = partner_ctx.get("partner_preview")
|
||||||
|
theme_tags = partner_ctx.get("partner_theme_tags") or []
|
||||||
|
warnings_list = partner_ctx.get("partner_warnings") or partner_warnings or []
|
||||||
|
|
||||||
|
response = {
|
||||||
|
"ok": True,
|
||||||
|
"scope": scope or "",
|
||||||
|
"preview": preview_payload,
|
||||||
|
"theme_tags": theme_tags,
|
||||||
|
"warnings": warnings_list,
|
||||||
|
"auto_note": partner_auto_note,
|
||||||
|
"resolved_secondary": resolved_secondary,
|
||||||
|
"resolved_background": resolved_background,
|
||||||
|
"partner_mode": partner_mode,
|
||||||
|
"auto_assigned": bool(partner_auto_assigned_flag),
|
||||||
|
}
|
||||||
|
if partner_error:
|
||||||
|
response["error"] = partner_error
|
||||||
|
try:
|
||||||
|
log_partner_suggestion_selected(
|
||||||
|
request,
|
||||||
|
commander=commander,
|
||||||
|
scope=scope,
|
||||||
|
partner_enabled=partner_flag,
|
||||||
|
auto_opt_out=auto_opt_out_flag,
|
||||||
|
auto_assigned=bool(partner_auto_assigned_flag),
|
||||||
|
selection_source=selection_source_value,
|
||||||
|
secondary_candidate=secondary_commander,
|
||||||
|
background_candidate=background,
|
||||||
|
resolved_secondary=resolved_secondary,
|
||||||
|
resolved_background=resolved_background,
|
||||||
|
partner_mode=partner_mode,
|
||||||
|
has_preview=bool(preview_payload),
|
||||||
|
warnings=warnings_list,
|
||||||
|
error=response.get("error"),
|
||||||
|
)
|
||||||
|
except Exception: # pragma: no cover - telemetry should not break responses
|
||||||
|
pass
|
||||||
|
return JSONResponse(response)
|
||||||
257
code/web/routes/build_permalinks.py
Normal file
257
code/web/routes/build_permalinks.py
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
"""Build Permalinks and Lock Management Routes
|
||||||
|
|
||||||
|
Phase 5 extraction from build.py:
|
||||||
|
- POST /build/lock - Card lock toggle with HTMX swap
|
||||||
|
- GET /build/permalink - State serialization (base64 JSON)
|
||||||
|
- GET /build/from - State restoration from permalink
|
||||||
|
|
||||||
|
This module handles build state persistence and card lock management.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, Form, Query
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||||
|
from typing import Any
|
||||||
|
import json
|
||||||
|
import gzip
|
||||||
|
from ..app import ALLOW_MUST_HAVES, templates
|
||||||
|
from ..services.tasks import get_session, new_sid
|
||||||
|
from ..services import orchestrator as orch
|
||||||
|
from html import escape as _esc
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/build")
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_hx_trigger(response: Any, payload: dict[str, Any]) -> None:
|
||||||
|
if not payload or response is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
existing = response.headers.get("HX-Trigger") if hasattr(response, "headers") else None
|
||||||
|
except Exception:
|
||||||
|
existing = None
|
||||||
|
try:
|
||||||
|
if existing:
|
||||||
|
try:
|
||||||
|
data = json.loads(existing)
|
||||||
|
except Exception:
|
||||||
|
data = {}
|
||||||
|
if isinstance(data, dict):
|
||||||
|
data.update(payload)
|
||||||
|
response.headers["HX-Trigger"] = json.dumps(data)
|
||||||
|
return
|
||||||
|
response.headers["HX-Trigger"] = json.dumps(payload)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
response.headers["HX-Trigger"] = json.dumps(payload)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/lock")
|
||||||
|
async def build_lock(request: Request, name: str = Form(...), locked: int = Form(...), from_list: str = Form(None)) -> HTMLResponse:
|
||||||
|
"""Toggle card lock for a given card name (HTMX-based).
|
||||||
|
|
||||||
|
Maintains an in-session locks set and reflects changes in the build context.
|
||||||
|
Returns an updated HTML button with HTMX attributes for easy swapping.
|
||||||
|
"""
|
||||||
|
sid = request.cookies.get("sid") or new_sid()
|
||||||
|
sess = get_session(sid)
|
||||||
|
name_l = str(name).strip().lower()
|
||||||
|
locks = set(sess.get("locks", []))
|
||||||
|
is_locked = bool(int(locked or 0))
|
||||||
|
if is_locked:
|
||||||
|
locks.add(name_l)
|
||||||
|
else:
|
||||||
|
locks.discard(name_l)
|
||||||
|
sess["locks"] = list(locks)
|
||||||
|
# Update build context if it exists
|
||||||
|
try:
|
||||||
|
ctx = sess.get("build_ctx") or {}
|
||||||
|
if ctx and isinstance(ctx, dict):
|
||||||
|
ctx["locks"] = {str(x) for x in locks}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Build lock button HTML
|
||||||
|
if is_locked:
|
||||||
|
label = "🔒"
|
||||||
|
title = f"Unlock {name}"
|
||||||
|
next_state = 0
|
||||||
|
else:
|
||||||
|
label = "🔓"
|
||||||
|
title = f"Lock {name}"
|
||||||
|
next_state = 1
|
||||||
|
html = (
|
||||||
|
f'<button class="btn btn-lock" type="button" title="{_esc(title)}" '
|
||||||
|
f'hx-post="/build/lock" hx-target="this" hx-swap="outerHTML" '
|
||||||
|
f'hx-vals=\'{{"name":"{_esc(name)}","locked":{next_state}}}\'>{label}</button>'
|
||||||
|
)
|
||||||
|
# OOB chip and lock count update
|
||||||
|
lock_count = len(locks)
|
||||||
|
chip = (
|
||||||
|
f'<div id="locks-chip" hx-swap-oob="true">'
|
||||||
|
f'<span class="chip">🔒 {lock_count}</span>'
|
||||||
|
f'</div>'
|
||||||
|
)
|
||||||
|
# If coming from locked-cards list, remove the row on unlock
|
||||||
|
if from_list and not is_locked:
|
||||||
|
# Return empty content to remove the <li> parent of the button
|
||||||
|
html = ""
|
||||||
|
return HTMLResponse(html + chip)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/permalink")
|
||||||
|
async def build_permalink(request: Request):
|
||||||
|
"""Return a URL-safe JSON payload representing current run config (basic)."""
|
||||||
|
sid = request.cookies.get("sid") or new_sid()
|
||||||
|
sess = get_session(sid)
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"commander": sess.get("commander"),
|
||||||
|
"tags": sess.get("tags", []),
|
||||||
|
"bracket": sess.get("bracket"),
|
||||||
|
"ideals": sess.get("ideals"),
|
||||||
|
"locks": list(sess.get("locks", []) or []),
|
||||||
|
"tag_mode": sess.get("tag_mode", "AND"),
|
||||||
|
"flags": {
|
||||||
|
"owned_only": bool(sess.get("use_owned_only")),
|
||||||
|
"prefer_owned": bool(sess.get("prefer_owned")),
|
||||||
|
"swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
# Include random build fields if present
|
||||||
|
try:
|
||||||
|
rb = sess.get("random_build")
|
||||||
|
if isinstance(rb, dict) and rb:
|
||||||
|
random_payload: dict[str, Any] = {}
|
||||||
|
for key in ("seed", "theme", "constraints", "primary_theme", "secondary_theme", "tertiary_theme"):
|
||||||
|
if rb.get(key) is not None:
|
||||||
|
random_payload[key] = rb.get(key)
|
||||||
|
if isinstance(rb.get("resolved_themes"), list):
|
||||||
|
random_payload["resolved_themes"] = list(rb.get("resolved_themes") or [])
|
||||||
|
if isinstance(rb.get("resolved_theme_info"), dict):
|
||||||
|
random_payload["resolved_theme_info"] = dict(rb.get("resolved_theme_info"))
|
||||||
|
if rb.get("combo_fallback") is not None:
|
||||||
|
random_payload["combo_fallback"] = bool(rb.get("combo_fallback"))
|
||||||
|
if rb.get("synergy_fallback") is not None:
|
||||||
|
random_payload["synergy_fallback"] = bool(rb.get("synergy_fallback"))
|
||||||
|
if rb.get("fallback_reason") is not None:
|
||||||
|
random_payload["fallback_reason"] = rb.get("fallback_reason")
|
||||||
|
if isinstance(rb.get("requested_themes"), dict):
|
||||||
|
requested_payload = dict(rb.get("requested_themes"))
|
||||||
|
if "auto_fill_enabled" in requested_payload:
|
||||||
|
requested_payload["auto_fill_enabled"] = bool(requested_payload.get("auto_fill_enabled"))
|
||||||
|
random_payload["requested_themes"] = requested_payload
|
||||||
|
if rb.get("auto_fill_enabled") is not None:
|
||||||
|
random_payload["auto_fill_enabled"] = bool(rb.get("auto_fill_enabled"))
|
||||||
|
if rb.get("auto_fill_applied") is not None:
|
||||||
|
random_payload["auto_fill_applied"] = bool(rb.get("auto_fill_applied"))
|
||||||
|
auto_filled = rb.get("auto_filled_themes")
|
||||||
|
if isinstance(auto_filled, list):
|
||||||
|
random_payload["auto_filled_themes"] = list(auto_filled)
|
||||||
|
display = rb.get("display_themes")
|
||||||
|
if isinstance(display, list):
|
||||||
|
random_payload["display_themes"] = list(display)
|
||||||
|
if random_payload:
|
||||||
|
payload["random"] = random_payload
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Include exclude_cards if feature is enabled and present
|
||||||
|
if ALLOW_MUST_HAVES and sess.get("exclude_cards"):
|
||||||
|
payload["exclude_cards"] = sess.get("exclude_cards")
|
||||||
|
# Compress and base64 encode the JSON payload for shorter URLs
|
||||||
|
try:
|
||||||
|
import base64
|
||||||
|
raw = json.dumps(payload, separators=(',', ':')).encode("utf-8")
|
||||||
|
# Use gzip compression to significantly reduce permalink length
|
||||||
|
compressed = gzip.compress(raw, compresslevel=9)
|
||||||
|
token = base64.urlsafe_b64encode(compressed).decode("ascii").rstrip("=")
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse({"error": "Failed to generate permalink"}, status_code=500)
|
||||||
|
link = f"/build/from?state={token}"
|
||||||
|
return JSONResponse({
|
||||||
|
"permalink": link,
|
||||||
|
"state": payload,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/from")
|
||||||
|
async def build_from(request: Request, state: str | None = None) -> RedirectResponse:
|
||||||
|
"""Load a run from a permalink token and redirect to main build page."""
|
||||||
|
sid = request.cookies.get("sid") or new_sid()
|
||||||
|
sess = get_session(sid)
|
||||||
|
if state:
|
||||||
|
try:
|
||||||
|
import base64
|
||||||
|
import json as _json
|
||||||
|
pad = '=' * (-len(state) % 4)
|
||||||
|
compressed = base64.urlsafe_b64decode((state + pad).encode("ascii"))
|
||||||
|
# Decompress the state data
|
||||||
|
raw = gzip.decompress(compressed).decode("utf-8")
|
||||||
|
data = _json.loads(raw)
|
||||||
|
sess["commander"] = data.get("commander")
|
||||||
|
sess["tags"] = data.get("tags", [])
|
||||||
|
sess["bracket"] = data.get("bracket")
|
||||||
|
if data.get("ideals"):
|
||||||
|
sess["ideals"] = data.get("ideals")
|
||||||
|
sess["tag_mode"] = data.get("tag_mode", "AND")
|
||||||
|
flags = data.get("flags") or {}
|
||||||
|
sess["use_owned_only"] = bool(flags.get("owned_only"))
|
||||||
|
sess["prefer_owned"] = bool(flags.get("prefer_owned"))
|
||||||
|
sess["swap_mdfc_basics"] = bool(flags.get("swap_mdfc_basics"))
|
||||||
|
sess["locks"] = list(data.get("locks", []))
|
||||||
|
# Optional random build rehydration
|
||||||
|
try:
|
||||||
|
r = data.get("random") or {}
|
||||||
|
if r:
|
||||||
|
rb_payload: dict[str, Any] = {}
|
||||||
|
for key in ("seed", "theme", "constraints", "primary_theme", "secondary_theme", "tertiary_theme"):
|
||||||
|
if r.get(key) is not None:
|
||||||
|
rb_payload[key] = r.get(key)
|
||||||
|
if isinstance(r.get("resolved_themes"), list):
|
||||||
|
rb_payload["resolved_themes"] = list(r.get("resolved_themes") or [])
|
||||||
|
if isinstance(r.get("resolved_theme_info"), dict):
|
||||||
|
rb_payload["resolved_theme_info"] = dict(r.get("resolved_theme_info"))
|
||||||
|
if r.get("combo_fallback") is not None:
|
||||||
|
rb_payload["combo_fallback"] = bool(r.get("combo_fallback"))
|
||||||
|
if r.get("synergy_fallback") is not None:
|
||||||
|
rb_payload["synergy_fallback"] = bool(r.get("synergy_fallback"))
|
||||||
|
if r.get("fallback_reason") is not None:
|
||||||
|
rb_payload["fallback_reason"] = r.get("fallback_reason")
|
||||||
|
if isinstance(r.get("requested_themes"), dict):
|
||||||
|
requested_payload = dict(r.get("requested_themes"))
|
||||||
|
if "auto_fill_enabled" in requested_payload:
|
||||||
|
requested_payload["auto_fill_enabled"] = bool(requested_payload.get("auto_fill_enabled"))
|
||||||
|
rb_payload["requested_themes"] = requested_payload
|
||||||
|
if r.get("auto_fill_enabled") is not None:
|
||||||
|
rb_payload["auto_fill_enabled"] = bool(r.get("auto_fill_enabled"))
|
||||||
|
if r.get("auto_fill_applied") is not None:
|
||||||
|
rb_payload["auto_fill_applied"] = bool(r.get("auto_fill_applied"))
|
||||||
|
auto_filled = r.get("auto_filled_themes")
|
||||||
|
if isinstance(auto_filled, list):
|
||||||
|
rb_payload["auto_filled_themes"] = list(auto_filled)
|
||||||
|
display = r.get("display_themes")
|
||||||
|
if isinstance(display, list):
|
||||||
|
rb_payload["display_themes"] = list(display)
|
||||||
|
if "seed" in rb_payload:
|
||||||
|
try:
|
||||||
|
seed_int = int(rb_payload["seed"])
|
||||||
|
rb_payload["seed"] = seed_int
|
||||||
|
rb_payload.setdefault("recent_seeds", [seed_int])
|
||||||
|
except Exception:
|
||||||
|
rb_payload.setdefault("recent_seeds", [])
|
||||||
|
sess["random_build"] = rb_payload
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Import exclude_cards if feature is enabled and present
|
||||||
|
if ALLOW_MUST_HAVES and data.get("exclude_cards"):
|
||||||
|
sess["exclude_cards"] = data.get("exclude_cards")
|
||||||
|
|
||||||
|
sess["last_step"] = 4
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Redirect to main build page which will render the proper layout
|
||||||
|
resp = RedirectResponse(url="/build/", status_code=303)
|
||||||
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
|
return resp
|
||||||
205
code/web/routes/build_themes.py
Normal file
205
code/web/routes/build_themes.py
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
"""
|
||||||
|
Custom theme management routes for deck building.
|
||||||
|
|
||||||
|
Handles user-defined custom themes including adding, removing, choosing
|
||||||
|
suggestions, and switching between permissive/strict matching modes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from fastapi import APIRouter, Request, Form
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
|
from ..app import (
|
||||||
|
ENABLE_CUSTOM_THEMES,
|
||||||
|
USER_THEME_LIMIT,
|
||||||
|
DEFAULT_THEME_MATCH_MODE,
|
||||||
|
_sanitize_theme,
|
||||||
|
templates,
|
||||||
|
)
|
||||||
|
from ..services.tasks import get_session, new_sid
|
||||||
|
from ..services import custom_theme_manager as theme_mgr
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
_INVALID_THEME_MESSAGE = (
|
||||||
|
"Theme names can only include letters, numbers, spaces, hyphens, apostrophes, and underscores."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _custom_theme_context(
|
||||||
|
request: Request,
|
||||||
|
sess: dict,
|
||||||
|
*,
|
||||||
|
message: str | None = None,
|
||||||
|
level: str = "info",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Assemble the Additional Themes section context for the modal.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object
|
||||||
|
sess: Session dictionary
|
||||||
|
message: Optional status message to display
|
||||||
|
level: Message level ("info", "success", "warning", "error")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Context dictionary for rendering the additional themes template
|
||||||
|
"""
|
||||||
|
if not ENABLE_CUSTOM_THEMES:
|
||||||
|
return {
|
||||||
|
"request": request,
|
||||||
|
"theme_state": None,
|
||||||
|
"theme_message": message,
|
||||||
|
"theme_message_level": level,
|
||||||
|
"theme_limit": USER_THEME_LIMIT,
|
||||||
|
"enable_custom_themes": False,
|
||||||
|
}
|
||||||
|
theme_mgr.set_limit(sess, USER_THEME_LIMIT)
|
||||||
|
state = theme_mgr.get_view_state(sess, default_mode=DEFAULT_THEME_MATCH_MODE)
|
||||||
|
return {
|
||||||
|
"request": request,
|
||||||
|
"theme_state": state,
|
||||||
|
"theme_message": message,
|
||||||
|
"theme_message_level": level,
|
||||||
|
"theme_limit": USER_THEME_LIMIT,
|
||||||
|
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/themes/add", response_class=HTMLResponse)
|
||||||
|
async def build_theme_add(request: Request, theme: str = Form("")) -> HTMLResponse:
|
||||||
|
"""
|
||||||
|
Add a custom theme to the user's theme list.
|
||||||
|
|
||||||
|
Validates theme name format and enforces theme count limits.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object
|
||||||
|
theme: Theme name to add (will be trimmed and sanitized)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HTMLResponse with updated themes list and status message
|
||||||
|
"""
|
||||||
|
if not ENABLE_CUSTOM_THEMES:
|
||||||
|
return HTMLResponse("", status_code=204)
|
||||||
|
sid = request.cookies.get("sid") or new_sid()
|
||||||
|
sess = get_session(sid)
|
||||||
|
trimmed = theme.strip()
|
||||||
|
sanitized = _sanitize_theme(trimmed) if trimmed else ""
|
||||||
|
if trimmed and not sanitized:
|
||||||
|
ctx = _custom_theme_context(request, sess, message=_INVALID_THEME_MESSAGE, level="error")
|
||||||
|
else:
|
||||||
|
value = sanitized if sanitized is not None else trimmed
|
||||||
|
_, message, level = theme_mgr.add_theme(
|
||||||
|
sess,
|
||||||
|
value,
|
||||||
|
commander_tags=list(sess.get("tags", [])),
|
||||||
|
mode=sess.get("theme_match_mode", DEFAULT_THEME_MATCH_MODE),
|
||||||
|
limit=USER_THEME_LIMIT,
|
||||||
|
)
|
||||||
|
ctx = _custom_theme_context(request, sess, message=message, level=level)
|
||||||
|
resp = templates.TemplateResponse("build/_new_deck_additional_themes.html", ctx)
|
||||||
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/themes/remove", response_class=HTMLResponse)
|
||||||
|
async def build_theme_remove(request: Request, theme: str = Form("")) -> HTMLResponse:
|
||||||
|
"""
|
||||||
|
Remove a custom theme from the user's theme list.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object
|
||||||
|
theme: Theme name to remove
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HTMLResponse with updated themes list and status message
|
||||||
|
"""
|
||||||
|
if not ENABLE_CUSTOM_THEMES:
|
||||||
|
return HTMLResponse("", status_code=204)
|
||||||
|
sid = request.cookies.get("sid") or new_sid()
|
||||||
|
sess = get_session(sid)
|
||||||
|
value = _sanitize_theme(theme) or theme
|
||||||
|
_, message, level = theme_mgr.remove_theme(
|
||||||
|
sess,
|
||||||
|
value,
|
||||||
|
commander_tags=list(sess.get("tags", [])),
|
||||||
|
mode=sess.get("theme_match_mode", DEFAULT_THEME_MATCH_MODE),
|
||||||
|
)
|
||||||
|
ctx = _custom_theme_context(request, sess, message=message, level=level)
|
||||||
|
resp = templates.TemplateResponse("build/_new_deck_additional_themes.html", ctx)
|
||||||
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/themes/choose", response_class=HTMLResponse)
|
||||||
|
async def build_theme_choose(
|
||||||
|
request: Request,
|
||||||
|
original: str = Form(""),
|
||||||
|
choice: str = Form(""),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""
|
||||||
|
Replace an invalid theme with a suggested alternative.
|
||||||
|
|
||||||
|
When a user's custom theme doesn't perfectly match commander tags,
|
||||||
|
the system suggests alternatives. This route accepts the user's
|
||||||
|
choice from those suggestions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object
|
||||||
|
original: The original (invalid) theme name
|
||||||
|
choice: The selected suggestion to use instead
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HTMLResponse with updated themes list and status message
|
||||||
|
"""
|
||||||
|
if not ENABLE_CUSTOM_THEMES:
|
||||||
|
return HTMLResponse("", status_code=204)
|
||||||
|
sid = request.cookies.get("sid") or new_sid()
|
||||||
|
sess = get_session(sid)
|
||||||
|
selection = _sanitize_theme(choice) or choice
|
||||||
|
_, message, level = theme_mgr.choose_suggestion(
|
||||||
|
sess,
|
||||||
|
original,
|
||||||
|
selection,
|
||||||
|
commander_tags=list(sess.get("tags", [])),
|
||||||
|
mode=sess.get("theme_match_mode", DEFAULT_THEME_MATCH_MODE),
|
||||||
|
)
|
||||||
|
ctx = _custom_theme_context(request, sess, message=message, level=level)
|
||||||
|
resp = templates.TemplateResponse("build/_new_deck_additional_themes.html", ctx)
|
||||||
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/themes/mode", response_class=HTMLResponse)
|
||||||
|
async def build_theme_mode(request: Request, mode: str = Form("permissive")) -> HTMLResponse:
|
||||||
|
"""
|
||||||
|
Switch theme matching mode between permissive and strict.
|
||||||
|
|
||||||
|
- Permissive: Suggests alternatives for invalid themes
|
||||||
|
- Strict: Rejects invalid themes outright
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object
|
||||||
|
mode: Either "permissive" or "strict"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HTMLResponse with updated themes list and status message
|
||||||
|
"""
|
||||||
|
if not ENABLE_CUSTOM_THEMES:
|
||||||
|
return HTMLResponse("", status_code=204)
|
||||||
|
sid = request.cookies.get("sid") or new_sid()
|
||||||
|
sess = get_session(sid)
|
||||||
|
_, message, level = theme_mgr.set_mode(
|
||||||
|
sess,
|
||||||
|
mode,
|
||||||
|
commander_tags=list(sess.get("tags", [])),
|
||||||
|
)
|
||||||
|
ctx = _custom_theme_context(request, sess, message=message, level=level)
|
||||||
|
resp = templates.TemplateResponse("build/_new_deck_additional_themes.html", ctx)
|
||||||
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
|
return resp
|
||||||
379
code/web/routes/build_validation.py
Normal file
379
code/web/routes/build_validation.py
Normal file
|
|
@ -0,0 +1,379 @@
|
||||||
|
"""Validation endpoints for card name validation and include/exclude lists.
|
||||||
|
|
||||||
|
This module handles validation of card names and include/exclude lists for the deck builder,
|
||||||
|
including fuzzy matching, color identity validation, and limit enforcement.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from fastapi import APIRouter, Form, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from path_util import csv_dir as _csv_dir
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Read configuration directly to avoid circular import with app.py
|
||||||
|
def _as_bool(val: str | bool | None, default: bool = False) -> bool:
|
||||||
|
"""Convert environment variable to boolean."""
|
||||||
|
if isinstance(val, bool):
|
||||||
|
return val
|
||||||
|
if val is None:
|
||||||
|
return default
|
||||||
|
s = str(val).strip().lower()
|
||||||
|
return s in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
ALLOW_MUST_HAVES = _as_bool(os.getenv("ALLOW_MUST_HAVES"), True)
|
||||||
|
|
||||||
|
# Cache for available card names used by validation endpoints
|
||||||
|
_AVAILABLE_CARDS_CACHE: set[str] | None = None
|
||||||
|
_AVAILABLE_CARDS_NORM_SET: set[str] | None = None
|
||||||
|
_AVAILABLE_CARDS_NORM_MAP: dict[str, str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _available_cards() -> set[str]:
|
||||||
|
"""Fast load of available card names using the csv module (no pandas).
|
||||||
|
|
||||||
|
Reads only once and caches results in memory.
|
||||||
|
"""
|
||||||
|
global _AVAILABLE_CARDS_CACHE
|
||||||
|
if _AVAILABLE_CARDS_CACHE is not None:
|
||||||
|
return _AVAILABLE_CARDS_CACHE
|
||||||
|
try:
|
||||||
|
import csv
|
||||||
|
path = f"{_csv_dir()}/cards.csv"
|
||||||
|
with open(path, 'r', encoding='utf-8', newline='') as f:
|
||||||
|
reader = csv.DictReader(f)
|
||||||
|
fields = reader.fieldnames or []
|
||||||
|
name_col = None
|
||||||
|
for col in ['name', 'Name', 'card_name', 'CardName']:
|
||||||
|
if col in fields:
|
||||||
|
name_col = col
|
||||||
|
break
|
||||||
|
if name_col is None and fields:
|
||||||
|
# Heuristic: pick first field containing 'name'
|
||||||
|
for col in fields:
|
||||||
|
if 'name' in col.lower():
|
||||||
|
name_col = col
|
||||||
|
break
|
||||||
|
if name_col is None:
|
||||||
|
raise ValueError(f"No name-like column found in {path}: {fields}")
|
||||||
|
names: set[str] = set()
|
||||||
|
for row in reader:
|
||||||
|
try:
|
||||||
|
v = row.get(name_col)
|
||||||
|
if v:
|
||||||
|
names.add(str(v))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
_AVAILABLE_CARDS_CACHE = names
|
||||||
|
return _AVAILABLE_CARDS_CACHE
|
||||||
|
except Exception:
|
||||||
|
_AVAILABLE_CARDS_CACHE = set()
|
||||||
|
return _AVAILABLE_CARDS_CACHE
|
||||||
|
|
||||||
|
|
||||||
|
def _available_cards_normalized() -> tuple[set[str], dict[str, str]]:
|
||||||
|
"""Return cached normalized card names and mapping to originals."""
|
||||||
|
global _AVAILABLE_CARDS_NORM_SET, _AVAILABLE_CARDS_NORM_MAP
|
||||||
|
if _AVAILABLE_CARDS_NORM_SET is not None and _AVAILABLE_CARDS_NORM_MAP is not None:
|
||||||
|
return _AVAILABLE_CARDS_NORM_SET, _AVAILABLE_CARDS_NORM_MAP
|
||||||
|
# Build from available cards set
|
||||||
|
names = _available_cards()
|
||||||
|
try:
|
||||||
|
from code.deck_builder.include_exclude_utils import normalize_punctuation
|
||||||
|
except Exception:
|
||||||
|
# Fallback: identity normalization
|
||||||
|
def normalize_punctuation(x: str) -> str:
|
||||||
|
return str(x).strip().casefold()
|
||||||
|
norm_map: dict[str, str] = {}
|
||||||
|
for name in names:
|
||||||
|
try:
|
||||||
|
n = normalize_punctuation(name)
|
||||||
|
if n not in norm_map:
|
||||||
|
norm_map[n] = name
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
_AVAILABLE_CARDS_NORM_MAP = norm_map
|
||||||
|
_AVAILABLE_CARDS_NORM_SET = set(norm_map.keys())
|
||||||
|
return _AVAILABLE_CARDS_NORM_SET, _AVAILABLE_CARDS_NORM_MAP
|
||||||
|
|
||||||
|
|
||||||
|
def warm_validation_name_cache() -> None:
|
||||||
|
"""Pre-populate the available-cards caches to avoid first-call latency."""
|
||||||
|
try:
|
||||||
|
_ = _available_cards()
|
||||||
|
_ = _available_cards_normalized()
|
||||||
|
except Exception:
|
||||||
|
# Best-effort warmup; proceed silently on failure
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/validate/exclude_cards")
|
||||||
|
async def validate_exclude_cards(
|
||||||
|
request: Request,
|
||||||
|
exclude_cards: str = Form(default=""),
|
||||||
|
commander: str = Form(default="")
|
||||||
|
):
|
||||||
|
"""Legacy exclude cards validation endpoint - redirect to new unified endpoint."""
|
||||||
|
if not ALLOW_MUST_HAVES:
|
||||||
|
return JSONResponse({"error": "Feature not enabled"}, status_code=404)
|
||||||
|
|
||||||
|
# Call new unified endpoint
|
||||||
|
result = await validate_include_exclude_cards(
|
||||||
|
request=request,
|
||||||
|
include_cards="",
|
||||||
|
exclude_cards=exclude_cards,
|
||||||
|
commander=commander,
|
||||||
|
enforcement_mode="warn",
|
||||||
|
allow_illegal=False,
|
||||||
|
fuzzy_matching=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Transform to legacy format for backward compatibility
|
||||||
|
if hasattr(result, 'body'):
|
||||||
|
import json
|
||||||
|
data = json.loads(result.body)
|
||||||
|
if 'excludes' in data:
|
||||||
|
excludes = data['excludes']
|
||||||
|
return JSONResponse({
|
||||||
|
"count": excludes.get("count", 0),
|
||||||
|
"limit": excludes.get("limit", 15),
|
||||||
|
"over_limit": excludes.get("over_limit", False),
|
||||||
|
"cards": excludes.get("cards", []),
|
||||||
|
"duplicates": excludes.get("duplicates", {}),
|
||||||
|
"warnings": excludes.get("warnings", [])
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/validate/include_exclude")
|
||||||
|
async def validate_include_exclude_cards(
|
||||||
|
request: Request,
|
||||||
|
include_cards: str = Form(default=""),
|
||||||
|
exclude_cards: str = Form(default=""),
|
||||||
|
commander: str = Form(default=""),
|
||||||
|
enforcement_mode: str = Form(default="warn"),
|
||||||
|
allow_illegal: bool = Form(default=False),
|
||||||
|
fuzzy_matching: bool = Form(default=True)
|
||||||
|
):
|
||||||
|
"""Validate include/exclude card lists with comprehensive diagnostics."""
|
||||||
|
if not ALLOW_MUST_HAVES:
|
||||||
|
return JSONResponse({"error": "Feature not enabled"}, status_code=404)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from code.deck_builder.include_exclude_utils import (
|
||||||
|
parse_card_list_input, collapse_duplicates,
|
||||||
|
fuzzy_match_card_name, MAX_INCLUDES, MAX_EXCLUDES
|
||||||
|
)
|
||||||
|
from code.deck_builder.builder import DeckBuilder
|
||||||
|
|
||||||
|
# Parse inputs
|
||||||
|
include_list = parse_card_list_input(include_cards) if include_cards.strip() else []
|
||||||
|
exclude_list = parse_card_list_input(exclude_cards) if exclude_cards.strip() else []
|
||||||
|
|
||||||
|
# Collapse duplicates
|
||||||
|
include_unique, include_dupes = collapse_duplicates(include_list)
|
||||||
|
exclude_unique, exclude_dupes = collapse_duplicates(exclude_list)
|
||||||
|
|
||||||
|
# Initialize result structure
|
||||||
|
result = {
|
||||||
|
"includes": {
|
||||||
|
"count": len(include_unique),
|
||||||
|
"limit": MAX_INCLUDES,
|
||||||
|
"over_limit": len(include_unique) > MAX_INCLUDES,
|
||||||
|
"duplicates": include_dupes,
|
||||||
|
"cards": include_unique[:10] if len(include_unique) <= 10 else include_unique[:7] + ["..."],
|
||||||
|
"warnings": [],
|
||||||
|
"legal": [],
|
||||||
|
"illegal": [],
|
||||||
|
"color_mismatched": [],
|
||||||
|
"fuzzy_matches": {}
|
||||||
|
},
|
||||||
|
"excludes": {
|
||||||
|
"count": len(exclude_unique),
|
||||||
|
"limit": MAX_EXCLUDES,
|
||||||
|
"over_limit": len(exclude_unique) > MAX_EXCLUDES,
|
||||||
|
"duplicates": exclude_dupes,
|
||||||
|
"cards": exclude_unique[:10] if len(exclude_unique) <= 10 else exclude_unique[:7] + ["..."],
|
||||||
|
"warnings": [],
|
||||||
|
"legal": [],
|
||||||
|
"illegal": [],
|
||||||
|
"fuzzy_matches": {}
|
||||||
|
},
|
||||||
|
"conflicts": [], # Cards that appear in both lists
|
||||||
|
"confirmation_needed": [], # Cards needing fuzzy match confirmation
|
||||||
|
"overall_warnings": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check for conflicts (cards in both lists)
|
||||||
|
conflicts = set(include_unique) & set(exclude_unique)
|
||||||
|
if conflicts:
|
||||||
|
result["conflicts"] = list(conflicts)
|
||||||
|
result["overall_warnings"].append(f"Cards appear in both lists: {', '.join(list(conflicts)[:3])}{'...' if len(conflicts) > 3 else ''}")
|
||||||
|
|
||||||
|
# Size warnings based on actual counts
|
||||||
|
if result["includes"]["over_limit"]:
|
||||||
|
result["includes"]["warnings"].append(f"Too many includes: {len(include_unique)}/{MAX_INCLUDES}")
|
||||||
|
elif len(include_unique) > MAX_INCLUDES * 0.8: # 80% capacity warning
|
||||||
|
result["includes"]["warnings"].append(f"Approaching limit: {len(include_unique)}/{MAX_INCLUDES}")
|
||||||
|
|
||||||
|
if result["excludes"]["over_limit"]:
|
||||||
|
result["excludes"]["warnings"].append(f"Too many excludes: {len(exclude_unique)}/{MAX_EXCLUDES}")
|
||||||
|
elif len(exclude_unique) > MAX_EXCLUDES * 0.8: # 80% capacity warning
|
||||||
|
result["excludes"]["warnings"].append(f"Approaching limit: {len(exclude_unique)}/{MAX_EXCLUDES}")
|
||||||
|
|
||||||
|
# If we have a commander, do advanced validation (color identity, etc.)
|
||||||
|
if commander and commander.strip():
|
||||||
|
try:
|
||||||
|
# Create a temporary builder
|
||||||
|
builder = DeckBuilder()
|
||||||
|
|
||||||
|
# Set up commander FIRST (before setup_dataframes)
|
||||||
|
df = builder.load_commander_data()
|
||||||
|
commander_rows = df[df["name"] == commander.strip()]
|
||||||
|
|
||||||
|
if not commander_rows.empty:
|
||||||
|
# Apply commander selection (this sets commander_row properly)
|
||||||
|
builder._apply_commander_selection(commander_rows.iloc[0])
|
||||||
|
|
||||||
|
# Now setup dataframes (this will use the commander info)
|
||||||
|
builder.setup_dataframes()
|
||||||
|
|
||||||
|
# Get available card names for fuzzy matching
|
||||||
|
name_col = 'name' if 'name' in builder._full_cards_df.columns else 'Name'
|
||||||
|
available_cards = set(builder._full_cards_df[name_col].tolist())
|
||||||
|
|
||||||
|
# Validate includes with fuzzy matching
|
||||||
|
for card_name in include_unique:
|
||||||
|
if fuzzy_matching:
|
||||||
|
match_result = fuzzy_match_card_name(card_name, available_cards)
|
||||||
|
if match_result.matched_name:
|
||||||
|
if match_result.auto_accepted:
|
||||||
|
result["includes"]["fuzzy_matches"][card_name] = match_result.matched_name
|
||||||
|
result["includes"]["legal"].append(match_result.matched_name)
|
||||||
|
else:
|
||||||
|
# Needs confirmation
|
||||||
|
result["confirmation_needed"].append({
|
||||||
|
"input": card_name,
|
||||||
|
"suggestions": match_result.suggestions,
|
||||||
|
"confidence": match_result.confidence,
|
||||||
|
"type": "include"
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
result["includes"]["illegal"].append(card_name)
|
||||||
|
else:
|
||||||
|
# Exact match only
|
||||||
|
if card_name in available_cards:
|
||||||
|
result["includes"]["legal"].append(card_name)
|
||||||
|
else:
|
||||||
|
result["includes"]["illegal"].append(card_name)
|
||||||
|
|
||||||
|
# Validate excludes with fuzzy matching
|
||||||
|
for card_name in exclude_unique:
|
||||||
|
if fuzzy_matching:
|
||||||
|
match_result = fuzzy_match_card_name(card_name, available_cards)
|
||||||
|
if match_result.matched_name:
|
||||||
|
if match_result.auto_accepted:
|
||||||
|
result["excludes"]["fuzzy_matches"][card_name] = match_result.matched_name
|
||||||
|
result["excludes"]["legal"].append(match_result.matched_name)
|
||||||
|
else:
|
||||||
|
# Needs confirmation
|
||||||
|
result["confirmation_needed"].append({
|
||||||
|
"input": card_name,
|
||||||
|
"suggestions": match_result.suggestions,
|
||||||
|
"confidence": match_result.confidence,
|
||||||
|
"type": "exclude"
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
result["excludes"]["illegal"].append(card_name)
|
||||||
|
else:
|
||||||
|
# Exact match only
|
||||||
|
if card_name in available_cards:
|
||||||
|
result["excludes"]["legal"].append(card_name)
|
||||||
|
else:
|
||||||
|
result["excludes"]["illegal"].append(card_name)
|
||||||
|
|
||||||
|
# Color identity validation for includes (only if we have a valid commander with colors)
|
||||||
|
commander_colors = getattr(builder, 'color_identity', [])
|
||||||
|
if commander_colors:
|
||||||
|
color_validated_includes = []
|
||||||
|
for card_name in result["includes"]["legal"]:
|
||||||
|
if builder._validate_card_color_identity(card_name):
|
||||||
|
color_validated_includes.append(card_name)
|
||||||
|
else:
|
||||||
|
# Add color-mismatched cards to illegal instead of separate category
|
||||||
|
result["includes"]["illegal"].append(card_name)
|
||||||
|
|
||||||
|
# Update legal includes to only those that pass color identity
|
||||||
|
result["includes"]["legal"] = color_validated_includes
|
||||||
|
|
||||||
|
except Exception as validation_error:
|
||||||
|
# Advanced validation failed, but return basic validation
|
||||||
|
result["overall_warnings"].append(f"Advanced validation unavailable: {str(validation_error)}")
|
||||||
|
else:
|
||||||
|
# No commander provided, do basic fuzzy matching only
|
||||||
|
if fuzzy_matching and (include_unique or exclude_unique):
|
||||||
|
try:
|
||||||
|
# Use cached available cards set (1st call populates cache)
|
||||||
|
available_cards = _available_cards()
|
||||||
|
|
||||||
|
# Fast path: normalized exact matches via cached sets
|
||||||
|
norm_set, norm_map = _available_cards_normalized()
|
||||||
|
# Validate includes with fuzzy matching
|
||||||
|
for card_name in include_unique:
|
||||||
|
from code.deck_builder.include_exclude_utils import normalize_punctuation
|
||||||
|
n = normalize_punctuation(card_name)
|
||||||
|
if n in norm_set:
|
||||||
|
result["includes"]["fuzzy_matches"][card_name] = norm_map[n]
|
||||||
|
result["includes"]["legal"].append(norm_map[n])
|
||||||
|
continue
|
||||||
|
match_result = fuzzy_match_card_name(card_name, available_cards)
|
||||||
|
|
||||||
|
if match_result.matched_name and match_result.auto_accepted:
|
||||||
|
# Exact or high-confidence match
|
||||||
|
result["includes"]["fuzzy_matches"][card_name] = match_result.matched_name
|
||||||
|
result["includes"]["legal"].append(match_result.matched_name)
|
||||||
|
elif not match_result.auto_accepted and match_result.suggestions:
|
||||||
|
# Needs confirmation - has suggestions but low confidence
|
||||||
|
result["confirmation_needed"].append({
|
||||||
|
"input": card_name,
|
||||||
|
"suggestions": match_result.suggestions,
|
||||||
|
"confidence": match_result.confidence,
|
||||||
|
"type": "include"
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# No match found at all, add to illegal
|
||||||
|
result["includes"]["illegal"].append(card_name)
|
||||||
|
# Validate excludes with fuzzy matching
|
||||||
|
for card_name in exclude_unique:
|
||||||
|
from code.deck_builder.include_exclude_utils import normalize_punctuation
|
||||||
|
n = normalize_punctuation(card_name)
|
||||||
|
if n in norm_set:
|
||||||
|
result["excludes"]["fuzzy_matches"][card_name] = norm_map[n]
|
||||||
|
result["excludes"]["legal"].append(norm_map[n])
|
||||||
|
continue
|
||||||
|
match_result = fuzzy_match_card_name(card_name, available_cards)
|
||||||
|
if match_result.matched_name:
|
||||||
|
if match_result.auto_accepted:
|
||||||
|
result["excludes"]["fuzzy_matches"][card_name] = match_result.matched_name
|
||||||
|
result["excludes"]["legal"].append(match_result.matched_name)
|
||||||
|
else:
|
||||||
|
# Needs confirmation
|
||||||
|
result["confirmation_needed"].append({
|
||||||
|
"input": card_name,
|
||||||
|
"suggestions": match_result.suggestions,
|
||||||
|
"confidence": match_result.confidence,
|
||||||
|
"type": "exclude"
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# No match found, add to illegal
|
||||||
|
result["excludes"]["illegal"].append(card_name)
|
||||||
|
|
||||||
|
except Exception as fuzzy_error:
|
||||||
|
result["overall_warnings"].append(f"Fuzzy matching unavailable: {str(fuzzy_error)}")
|
||||||
|
|
||||||
|
return JSONResponse(result)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse({"error": str(e)}, status_code=400)
|
||||||
1462
code/web/routes/build_wizard.py
Normal file
1462
code/web/routes/build_wizard.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -2,14 +2,15 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import Iterable, List, Optional
|
from typing import Iterable, List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request
|
from fastapi import APIRouter, Query, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from deck_builder.combined_commander import PartnerMode
|
from deck_builder.combined_commander import PartnerMode
|
||||||
|
|
||||||
from ..app import ENABLE_PARTNER_MECHANICS, ENABLE_PARTNER_SUGGESTIONS
|
from ..app import ENABLE_PARTNER_MECHANICS
|
||||||
from ..services.partner_suggestions import get_partner_suggestions
|
from ..services.partner_suggestions import get_partner_suggestions
|
||||||
from ..services.telemetry import log_partner_suggestions_generated
|
from ..services.telemetry import log_partner_suggestions_generated
|
||||||
|
from code.exceptions import CommanderValidationError, FeatureDisabledError
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/partner", tags=["partner suggestions"])
|
router = APIRouter(prefix="/api/partner", tags=["partner suggestions"])
|
||||||
|
|
||||||
|
|
@ -64,12 +65,12 @@ async def partner_suggestions_api(
|
||||||
mode: Optional[List[str]] = Query(None, description="Restrict results to specific partner modes"),
|
mode: Optional[List[str]] = Query(None, description="Restrict results to specific partner modes"),
|
||||||
refresh: bool = Query(False, description="When true, force a dataset refresh before scoring"),
|
refresh: bool = Query(False, description="When true, force a dataset refresh before scoring"),
|
||||||
):
|
):
|
||||||
if not (ENABLE_PARTNER_MECHANICS and ENABLE_PARTNER_SUGGESTIONS):
|
if not ENABLE_PARTNER_MECHANICS:
|
||||||
raise HTTPException(status_code=404, detail="Partner suggestions are disabled")
|
raise FeatureDisabledError("partner_suggestions")
|
||||||
|
|
||||||
commander_name = (commander or "").strip()
|
commander_name = (commander or "").strip()
|
||||||
if not commander_name:
|
if not commander_name:
|
||||||
raise HTTPException(status_code=400, detail="Commander name is required")
|
raise CommanderValidationError("Commander name is required")
|
||||||
|
|
||||||
include_modes = _parse_modes(mode)
|
include_modes = _parse_modes(mode)
|
||||||
result = get_partner_suggestions(
|
result = get_partner_suggestions(
|
||||||
|
|
@ -79,7 +80,7 @@ async def partner_suggestions_api(
|
||||||
refresh_dataset=refresh,
|
refresh_dataset=refresh,
|
||||||
)
|
)
|
||||||
if result is None:
|
if result is None:
|
||||||
raise HTTPException(status_code=503, detail="Partner suggestion dataset is unavailable")
|
raise FeatureDisabledError("partner_suggestion_dataset")
|
||||||
|
|
||||||
partner_names = _coerce_name_list(partner)
|
partner_names = _coerce_name_list(partner)
|
||||||
background_names = _coerce_name_list(background)
|
background_names = _coerce_name_list(background)
|
||||||
|
|
|
||||||
306
code/web/services/base.py
Normal file
306
code/web/services/base.py
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
"""Base classes for web services.
|
||||||
|
|
||||||
|
Provides standardized patterns for service layer implementation including
|
||||||
|
state management, data loading, and caching.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any, Dict, Generic, Optional, TypeVar
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
K = TypeVar("K")
|
||||||
|
V = TypeVar("V")
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceError(Exception):
|
||||||
|
"""Base exception for service layer errors."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationError(ServiceError):
|
||||||
|
"""Validation failed."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class NotFoundError(ServiceError):
|
||||||
|
"""Resource not found."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class BaseService(ABC):
|
||||||
|
"""Abstract base class for all services.
|
||||||
|
|
||||||
|
Provides common patterns for initialization, validation, and error handling.
|
||||||
|
Services should be stateless where possible and inject dependencies via __init__.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Initialize service. Override in subclasses to inject dependencies."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _validate(self, condition: bool, message: str) -> None:
|
||||||
|
"""Validate a condition, raise ValidationError if false.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
condition: Condition to check
|
||||||
|
message: Error message if validation fails
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError: If condition is False
|
||||||
|
"""
|
||||||
|
if not condition:
|
||||||
|
raise ValidationError(message)
|
||||||
|
|
||||||
|
|
||||||
|
class StateService(BaseService):
|
||||||
|
"""Base class for services that manage mutable state.
|
||||||
|
|
||||||
|
Provides thread-safe state management with automatic cleanup.
|
||||||
|
Subclasses should implement _initialize_state and _should_cleanup.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._state: Dict[str, Dict[str, Any]] = {}
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
|
def get_state(self, key: str) -> Dict[str, Any]:
|
||||||
|
"""Get or create state for a key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: State key (e.g., session ID)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
State dictionary
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
if key not in self._state:
|
||||||
|
self._state[key] = self._initialize_state(key)
|
||||||
|
return self._state[key]
|
||||||
|
|
||||||
|
def set_state_value(self, key: str, field: str, value: Any) -> None:
|
||||||
|
"""Set a field in state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: State key
|
||||||
|
field: Field name
|
||||||
|
value: Value to set
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
state = self.get_state(key)
|
||||||
|
state[field] = value
|
||||||
|
|
||||||
|
def get_state_value(self, key: str, field: str, default: Any = None) -> Any:
|
||||||
|
"""Get a field from state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: State key
|
||||||
|
field: Field name
|
||||||
|
default: Default value if field not found
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Field value or default
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
state = self.get_state(key)
|
||||||
|
return state.get(field, default)
|
||||||
|
|
||||||
|
def cleanup_state(self) -> int:
|
||||||
|
"""Clean up expired or invalid state.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of entries cleaned up
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
to_remove = [k for k, v in self._state.items() if self._should_cleanup(k, v)]
|
||||||
|
for key in to_remove:
|
||||||
|
del self._state[key]
|
||||||
|
return len(to_remove)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def _initialize_state(self, key: str) -> Dict[str, Any]:
|
||||||
|
"""Initialize state for a new key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: State key
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Initial state dictionary
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def _should_cleanup(self, key: str, state: Dict[str, Any]) -> bool:
|
||||||
|
"""Check if state should be cleaned up.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: State key
|
||||||
|
state: State dictionary
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if state should be removed
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DataService(BaseService, Generic[T]):
|
||||||
|
"""Base class for services that load and manage data.
|
||||||
|
|
||||||
|
Provides patterns for lazy loading, validation, and refresh.
|
||||||
|
Subclasses should implement _load_data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._data: Optional[T] = None
|
||||||
|
self._loaded = False
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
|
def get_data(self, force_reload: bool = False) -> T:
|
||||||
|
"""Get data, loading if necessary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
force_reload: Force reload even if already loaded
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Loaded data
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ServiceError: If data loading fails
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
if force_reload or not self._loaded:
|
||||||
|
self._data = self._load_data()
|
||||||
|
self._loaded = True
|
||||||
|
if self._data is None:
|
||||||
|
raise ServiceError("Failed to load data")
|
||||||
|
return self._data
|
||||||
|
|
||||||
|
def is_loaded(self) -> bool:
|
||||||
|
"""Check if data is loaded.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if data is loaded
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
return self._loaded
|
||||||
|
|
||||||
|
def reload(self) -> T:
|
||||||
|
"""Force reload data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Reloaded data
|
||||||
|
"""
|
||||||
|
return self.get_data(force_reload=True)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def _load_data(self) -> T:
|
||||||
|
"""Load data from source.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Loaded data
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ServiceError: If loading fails
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CachedService(BaseService, Generic[K, V]):
|
||||||
|
"""Base class for services with caching behavior.
|
||||||
|
|
||||||
|
Provides thread-safe caching with TTL and size limits.
|
||||||
|
Subclasses should implement _compute_value.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, ttl_seconds: Optional[int] = None, max_size: Optional[int] = None) -> None:
|
||||||
|
"""Initialize cached service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ttl_seconds: Time-to-live for cache entries (None = no expiration)
|
||||||
|
max_size: Maximum cache size (None = no limit)
|
||||||
|
"""
|
||||||
|
super().__init__()
|
||||||
|
self._cache: Dict[K, tuple[V, float]] = {}
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._ttl_seconds = ttl_seconds
|
||||||
|
self._max_size = max_size
|
||||||
|
|
||||||
|
def get(self, key: K, force_recompute: bool = False) -> V:
|
||||||
|
"""Get cached value or compute it.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Cache key
|
||||||
|
force_recompute: Force recompute even if cached
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Cached or computed value
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
# Check cache
|
||||||
|
if not force_recompute and key in self._cache:
|
||||||
|
value, timestamp = self._cache[key]
|
||||||
|
if self._ttl_seconds is None or (now - timestamp) < self._ttl_seconds:
|
||||||
|
return value
|
||||||
|
|
||||||
|
# Compute new value
|
||||||
|
value = self._compute_value(key)
|
||||||
|
|
||||||
|
# Store in cache
|
||||||
|
self._cache[key] = (value, now)
|
||||||
|
|
||||||
|
# Enforce size limit (simple LRU: remove oldest)
|
||||||
|
if self._max_size is not None and len(self._cache) > self._max_size:
|
||||||
|
oldest_key = min(self._cache.keys(), key=lambda k: self._cache[k][1])
|
||||||
|
del self._cache[oldest_key]
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
def invalidate(self, key: Optional[K] = None) -> None:
|
||||||
|
"""Invalidate cache entry or entire cache.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Cache key to invalidate (None = invalidate all)
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
if key is None:
|
||||||
|
self._cache.clear()
|
||||||
|
elif key in self._cache:
|
||||||
|
del self._cache[key]
|
||||||
|
|
||||||
|
def cleanup_expired(self) -> int:
|
||||||
|
"""Remove expired cache entries.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of entries removed
|
||||||
|
"""
|
||||||
|
if self._ttl_seconds is None:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
now = time.time()
|
||||||
|
expired = [k for k, (_, ts) in self._cache.items() if (now - ts) >= self._ttl_seconds]
|
||||||
|
for key in expired:
|
||||||
|
del self._cache[key]
|
||||||
|
return len(expired)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def _compute_value(self, key: K) -> V:
|
||||||
|
"""Compute value for a cache key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Cache key
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Computed value
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ServiceError: If computation fails
|
||||||
|
"""
|
||||||
|
pass
|
||||||
318
code/web/services/interfaces.py
Normal file
318
code/web/services/interfaces.py
Normal file
|
|
@ -0,0 +1,318 @@
|
||||||
|
"""Service interfaces using Protocol for structural typing.
|
||||||
|
|
||||||
|
Defines contracts for different types of services without requiring inheritance.
|
||||||
|
Use these for type hints and dependency injection.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Protocol, Any, Dict, List, Optional, TypeVar, runtime_checkable
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
K = TypeVar("K")
|
||||||
|
V = TypeVar("V")
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class SessionService(Protocol):
|
||||||
|
"""Interface for session management services."""
|
||||||
|
|
||||||
|
def new_session_id(self) -> str:
|
||||||
|
"""Create a new session ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Unique session identifier
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_session(self, session_id: Optional[str]) -> Dict[str, Any]:
|
||||||
|
"""Get or create session state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session identifier (creates new if None)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Session state dictionary
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def set_value(self, session_id: str, key: str, value: Any) -> None:
|
||||||
|
"""Set a value in session state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session identifier
|
||||||
|
key: State key
|
||||||
|
value: Value to store
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_value(self, session_id: str, key: str, default: Any = None) -> Any:
|
||||||
|
"""Get a value from session state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session identifier
|
||||||
|
key: State key
|
||||||
|
default: Default value if key not found
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Stored value or default
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def cleanup_expired(self) -> int:
|
||||||
|
"""Clean up expired sessions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of sessions cleaned up
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class CardLoaderService(Protocol):
|
||||||
|
"""Interface for card data loading services."""
|
||||||
|
|
||||||
|
def get_cards(self, force_reload: bool = False) -> pd.DataFrame:
|
||||||
|
"""Get card data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
force_reload: Force reload from source
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DataFrame with card data
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def is_loaded(self) -> bool:
|
||||||
|
"""Check if card data is loaded.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if data is loaded
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class CatalogService(Protocol):
|
||||||
|
"""Interface for catalog services (commanders, themes, etc.)."""
|
||||||
|
|
||||||
|
def get_catalog(self, force_reload: bool = False) -> pd.DataFrame:
|
||||||
|
"""Get catalog data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
force_reload: Force reload from source
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DataFrame with catalog data
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def search(self, query: str, **filters: Any) -> pd.DataFrame:
|
||||||
|
"""Search catalog with filters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search query string
|
||||||
|
**filters: Additional filters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Filtered DataFrame
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class OwnedCardsService(Protocol):
|
||||||
|
"""Interface for owned cards management."""
|
||||||
|
|
||||||
|
def get_owned_names(self) -> List[str]:
|
||||||
|
"""Get list of owned card names.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of card names
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def add_owned_names(self, names: List[str]) -> None:
|
||||||
|
"""Add card names to owned list.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
names: Card names to add
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def remove_owned_name(self, name: str) -> bool:
|
||||||
|
"""Remove a card name from owned list.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Card name to remove
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if removed, False if not found
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def clear_owned(self) -> None:
|
||||||
|
"""Clear all owned cards."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def import_from_file(self, file_content: str, format_type: str) -> int:
|
||||||
|
"""Import owned cards from file content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_content: File content to parse
|
||||||
|
format_type: Format type (csv, txt, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of cards imported
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class CacheService(Protocol[K, V]):
|
||||||
|
"""Interface for caching services."""
|
||||||
|
|
||||||
|
def get(self, key: K, default: Optional[V] = None) -> Optional[V]:
|
||||||
|
"""Get cached value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Cache key
|
||||||
|
default: Default value if not found
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Cached value or default
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def set(self, key: K, value: V, ttl: Optional[int] = None) -> None:
|
||||||
|
"""Set cached value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Cache key
|
||||||
|
value: Value to cache
|
||||||
|
ttl: Time-to-live in seconds (None = no expiration)
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def invalidate(self, key: Optional[K] = None) -> None:
|
||||||
|
"""Invalidate cache entry or entire cache.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Cache key (None = invalidate all)
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def cleanup_expired(self) -> int:
|
||||||
|
"""Remove expired cache entries.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of entries removed
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class BuildOrchestratorService(Protocol):
|
||||||
|
"""Interface for deck build orchestration."""
|
||||||
|
|
||||||
|
def orchestrate_build(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
commander_name: str,
|
||||||
|
theme_tags: List[str],
|
||||||
|
**options: Any
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Orchestrate a deck build.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session identifier
|
||||||
|
commander_name: Commander card name
|
||||||
|
theme_tags: List of theme tags
|
||||||
|
**options: Additional build options
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Build result dictionary
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_build_status(self, session_id: str) -> Dict[str, Any]:
|
||||||
|
"""Get build status for a session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Build status dictionary
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class ValidationService(Protocol):
|
||||||
|
"""Interface for validation services."""
|
||||||
|
|
||||||
|
def validate_commander(self, name: str) -> tuple[bool, Optional[str]]:
|
||||||
|
"""Validate commander name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Card name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(is_valid, error_message) tuple
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def validate_themes(self, themes: List[str]) -> tuple[bool, List[str]]:
|
||||||
|
"""Validate theme tags.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
themes: List of theme tags
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(is_valid, invalid_themes) tuple
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def normalize_card_name(self, name: str) -> str:
|
||||||
|
"""Normalize card name for lookups.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Raw card name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Normalized card name
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class TelemetryService(Protocol):
|
||||||
|
"""Interface for telemetry/metrics services."""
|
||||||
|
|
||||||
|
def record_event(self, event_type: str, properties: Optional[Dict[str, Any]] = None) -> None:
|
||||||
|
"""Record a telemetry event.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event_type: Type of event
|
||||||
|
properties: Event properties
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def record_timing(self, operation: str, duration_ms: float) -> None:
|
||||||
|
"""Record operation timing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
operation: Operation name
|
||||||
|
duration_ms: Duration in milliseconds
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def increment_counter(self, counter_name: str, value: int = 1) -> None:
|
||||||
|
"""Increment a counter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
counter_name: Counter name
|
||||||
|
value: Increment value
|
||||||
|
"""
|
||||||
|
...
|
||||||
202
code/web/services/registry.py
Normal file
202
code/web/services/registry.py
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
"""Service registry for dependency injection.
|
||||||
|
|
||||||
|
Provides a centralized registry for managing service instances and dependencies.
|
||||||
|
Supports singleton and factory patterns with thread-safe access.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Callable, Dict, Optional, Type, TypeVar, cast
|
||||||
|
import threading
|
||||||
|
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceRegistry:
|
||||||
|
"""Thread-safe service registry for dependency injection.
|
||||||
|
|
||||||
|
Manages service instances and factories with support for:
|
||||||
|
- Singleton services (one instance per registry)
|
||||||
|
- Factory services (new instance per request)
|
||||||
|
- Lazy initialization
|
||||||
|
- Thread-safe access
|
||||||
|
|
||||||
|
Example:
|
||||||
|
registry = ServiceRegistry()
|
||||||
|
registry.register_singleton(SessionService, session_service_instance)
|
||||||
|
registry.register_factory(BuildService, lambda: BuildService(deps...))
|
||||||
|
|
||||||
|
# Get services
|
||||||
|
session_svc = registry.get(SessionService)
|
||||||
|
build_svc = registry.get(BuildService)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Initialize empty registry."""
|
||||||
|
self._singletons: Dict[Type[Any], Any] = {}
|
||||||
|
self._factories: Dict[Type[Any], Callable[[], Any]] = {}
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
|
def register_singleton(self, service_type: Type[T], instance: T) -> None:
|
||||||
|
"""Register a singleton service instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
service_type: Service type/interface
|
||||||
|
instance: Service instance to register
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If service already registered
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
if service_type in self._singletons or service_type in self._factories:
|
||||||
|
raise ValueError(f"Service {service_type.__name__} already registered")
|
||||||
|
self._singletons[service_type] = instance
|
||||||
|
|
||||||
|
def register_factory(self, service_type: Type[T], factory: Callable[[], T]) -> None:
|
||||||
|
"""Register a factory for creating service instances.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
service_type: Service type/interface
|
||||||
|
factory: Factory function that returns service instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If service already registered
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
if service_type in self._singletons or service_type in self._factories:
|
||||||
|
raise ValueError(f"Service {service_type.__name__} already registered")
|
||||||
|
self._factories[service_type] = factory
|
||||||
|
|
||||||
|
def register_lazy_singleton(self, service_type: Type[T], factory: Callable[[], T]) -> None:
|
||||||
|
"""Register a lazy-initialized singleton service.
|
||||||
|
|
||||||
|
The factory will be called once on first access, then the instance is cached.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
service_type: Service type/interface
|
||||||
|
factory: Factory function that returns service instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If service already registered
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
if service_type in self._singletons or service_type in self._factories:
|
||||||
|
raise ValueError(f"Service {service_type.__name__} already registered")
|
||||||
|
|
||||||
|
# Wrap factory to cache result
|
||||||
|
instance_cache: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
def lazy_factory() -> T:
|
||||||
|
if "instance" not in instance_cache:
|
||||||
|
instance_cache["instance"] = factory()
|
||||||
|
return instance_cache["instance"]
|
||||||
|
|
||||||
|
self._factories[service_type] = lazy_factory
|
||||||
|
|
||||||
|
def get(self, service_type: Type[T]) -> T:
|
||||||
|
"""Get service instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
service_type: Service type/interface
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Service instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: If service not registered
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
# Check singletons first
|
||||||
|
if service_type in self._singletons:
|
||||||
|
return cast(T, self._singletons[service_type])
|
||||||
|
|
||||||
|
# Check factories
|
||||||
|
if service_type in self._factories:
|
||||||
|
return cast(T, self._factories[service_type]())
|
||||||
|
|
||||||
|
raise KeyError(f"Service {service_type.__name__} not registered")
|
||||||
|
|
||||||
|
def try_get(self, service_type: Type[T]) -> Optional[T]:
|
||||||
|
"""Try to get service instance, return None if not registered.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
service_type: Service type/interface
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Service instance or None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return self.get(service_type)
|
||||||
|
except KeyError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def is_registered(self, service_type: Type[Any]) -> bool:
|
||||||
|
"""Check if service is registered.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
service_type: Service type/interface
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if registered
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
return service_type in self._singletons or service_type in self._factories
|
||||||
|
|
||||||
|
def unregister(self, service_type: Type[Any]) -> None:
|
||||||
|
"""Unregister a service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
service_type: Service type/interface
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
self._singletons.pop(service_type, None)
|
||||||
|
self._factories.pop(service_type, None)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Clear all registered services."""
|
||||||
|
with self._lock:
|
||||||
|
self._singletons.clear()
|
||||||
|
self._factories.clear()
|
||||||
|
|
||||||
|
def get_registered_types(self) -> list[Type[Any]]:
|
||||||
|
"""Get list of all registered service types.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of service types
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
return list(self._singletons.keys()) + list(self._factories.keys())
|
||||||
|
|
||||||
|
|
||||||
|
# Global registry instance
|
||||||
|
_global_registry: Optional[ServiceRegistry] = None
|
||||||
|
_global_registry_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def get_registry() -> ServiceRegistry:
|
||||||
|
"""Get the global service registry instance.
|
||||||
|
|
||||||
|
Creates registry on first access (lazy initialization).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Global ServiceRegistry instance
|
||||||
|
"""
|
||||||
|
global _global_registry
|
||||||
|
|
||||||
|
if _global_registry is None:
|
||||||
|
with _global_registry_lock:
|
||||||
|
if _global_registry is None:
|
||||||
|
_global_registry = ServiceRegistry()
|
||||||
|
|
||||||
|
return _global_registry
|
||||||
|
|
||||||
|
|
||||||
|
def reset_registry() -> None:
|
||||||
|
"""Reset the global registry (primarily for testing).
|
||||||
|
|
||||||
|
Clears all registered services and creates a new registry instance.
|
||||||
|
"""
|
||||||
|
global _global_registry
|
||||||
|
|
||||||
|
with _global_registry_lock:
|
||||||
|
_global_registry = ServiceRegistry()
|
||||||
|
|
@ -4,45 +4,194 @@ import time
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
# Extremely simple in-memory session/task store for MVP
|
from .base import StateService
|
||||||
_SESSIONS: Dict[str, Dict[str, Any]] = {}
|
from .interfaces import SessionService
|
||||||
_TTL_SECONDS = 60 * 60 * 8 # 8 hours
|
|
||||||
|
|
||||||
|
|
||||||
|
# Session TTL: 8 hours
|
||||||
|
SESSION_TTL_SECONDS = 60 * 60 * 8
|
||||||
|
|
||||||
|
|
||||||
|
class SessionManager(StateService):
|
||||||
|
"""Session management service.
|
||||||
|
|
||||||
|
Manages user sessions with automatic TTL-based cleanup.
|
||||||
|
Thread-safe with in-memory storage.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, ttl_seconds: int = SESSION_TTL_SECONDS) -> None:
|
||||||
|
"""Initialize session manager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ttl_seconds: Session time-to-live in seconds
|
||||||
|
"""
|
||||||
|
super().__init__()
|
||||||
|
self._ttl_seconds = ttl_seconds
|
||||||
|
|
||||||
|
def new_session_id(self) -> str:
|
||||||
|
"""Create a new session ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Unique session identifier
|
||||||
|
"""
|
||||||
|
return uuid.uuid4().hex
|
||||||
|
|
||||||
|
def touch_session(self, session_id: str) -> Dict[str, Any]:
|
||||||
|
"""Update session last access time.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Session state dictionary
|
||||||
|
"""
|
||||||
|
now = time.time()
|
||||||
|
state = self.get_state(session_id)
|
||||||
|
state["updated"] = now
|
||||||
|
return state
|
||||||
|
|
||||||
|
def get_session(self, session_id: Optional[str]) -> Dict[str, Any]:
|
||||||
|
"""Get or create session state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session identifier (creates new if None)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Session state dictionary
|
||||||
|
"""
|
||||||
|
if not session_id:
|
||||||
|
session_id = self.new_session_id()
|
||||||
|
return self.touch_session(session_id)
|
||||||
|
|
||||||
|
def set_value(self, session_id: str, key: str, value: Any) -> None:
|
||||||
|
"""Set a value in session state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session identifier
|
||||||
|
key: State key
|
||||||
|
value: Value to store
|
||||||
|
"""
|
||||||
|
self.touch_session(session_id)[key] = value
|
||||||
|
|
||||||
|
def get_value(self, session_id: str, key: str, default: Any = None) -> Any:
|
||||||
|
"""Get a value from session state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session identifier
|
||||||
|
key: State key
|
||||||
|
default: Default value if key not found
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Stored value or default
|
||||||
|
"""
|
||||||
|
return self.touch_session(session_id).get(key, default)
|
||||||
|
|
||||||
|
def _initialize_state(self, key: str) -> Dict[str, Any]:
|
||||||
|
"""Initialize state for a new session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Session ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Initial session state
|
||||||
|
"""
|
||||||
|
now = time.time()
|
||||||
|
return {"created": now, "updated": now}
|
||||||
|
|
||||||
|
def _should_cleanup(self, key: str, state: Dict[str, Any]) -> bool:
|
||||||
|
"""Check if session should be cleaned up.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Session ID
|
||||||
|
state: Session state
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if session is expired
|
||||||
|
"""
|
||||||
|
now = time.time()
|
||||||
|
updated = state.get("updated", 0)
|
||||||
|
return (now - updated) > self._ttl_seconds
|
||||||
|
|
||||||
|
|
||||||
|
# Global session manager instance
|
||||||
|
_session_manager: Optional[SessionManager] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_manager() -> SessionManager:
|
||||||
|
"""Get or create global session manager instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SessionManager instance
|
||||||
|
"""
|
||||||
|
global _session_manager
|
||||||
|
if _session_manager is None:
|
||||||
|
_session_manager = SessionManager()
|
||||||
|
return _session_manager
|
||||||
|
|
||||||
|
|
||||||
|
# Backward-compatible function API
|
||||||
def new_sid() -> str:
|
def new_sid() -> str:
|
||||||
return uuid.uuid4().hex
|
"""Create a new session ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Unique session identifier
|
||||||
|
"""
|
||||||
|
return _get_manager().new_session_id()
|
||||||
|
|
||||||
|
|
||||||
def touch_session(sid: str) -> Dict[str, Any]:
|
def touch_session(sid: str) -> Dict[str, Any]:
|
||||||
now = time.time()
|
"""Update session last access time.
|
||||||
s = _SESSIONS.get(sid)
|
|
||||||
if not s:
|
Args:
|
||||||
s = {"created": now, "updated": now}
|
sid: Session identifier
|
||||||
_SESSIONS[sid] = s
|
|
||||||
else:
|
Returns:
|
||||||
s["updated"] = now
|
Session state dictionary
|
||||||
return s
|
"""
|
||||||
|
return _get_manager().touch_session(sid)
|
||||||
|
|
||||||
|
|
||||||
def get_session(sid: Optional[str]) -> Dict[str, Any]:
|
def get_session(sid: Optional[str]) -> Dict[str, Any]:
|
||||||
if not sid:
|
"""Get or create session state.
|
||||||
sid = new_sid()
|
|
||||||
return touch_session(sid)
|
Args:
|
||||||
|
sid: Session identifier (creates new if None)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Session state dictionary
|
||||||
|
"""
|
||||||
|
return _get_manager().get_session(sid)
|
||||||
|
|
||||||
|
|
||||||
def set_session_value(sid: str, key: str, value: Any) -> None:
|
def set_session_value(sid: str, key: str, value: Any) -> None:
|
||||||
touch_session(sid)[key] = value
|
"""Set a value in session state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sid: Session identifier
|
||||||
|
key: State key
|
||||||
|
value: Value to store
|
||||||
|
"""
|
||||||
|
_get_manager().set_value(sid, key, value)
|
||||||
|
|
||||||
|
|
||||||
def get_session_value(sid: str, key: str, default: Any = None) -> Any:
|
def get_session_value(sid: str, key: str, default: Any = None) -> Any:
|
||||||
return touch_session(sid).get(key, default)
|
"""Get a value from session state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sid: Session identifier
|
||||||
|
key: State key
|
||||||
|
default: Default value if key not found
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Stored value or default
|
||||||
|
"""
|
||||||
|
return _get_manager().get_value(sid, key, default)
|
||||||
|
|
||||||
|
|
||||||
def cleanup_expired() -> None:
|
def cleanup_expired() -> int:
|
||||||
now = time.time()
|
"""Clean up expired sessions.
|
||||||
expired = [sid for sid, s in _SESSIONS.items() if now - s.get("updated", 0) > _TTL_SECONDS]
|
|
||||||
for sid in expired:
|
Returns:
|
||||||
try:
|
Number of sessions cleaned up
|
||||||
del _SESSIONS[sid]
|
"""
|
||||||
except Exception:
|
return _get_manager().cleanup_state()
|
||||||
pass
|
|
||||||
|
|
|
||||||
|
|
@ -1137,7 +1137,7 @@
|
||||||
.then(function(r){ return r.text(); })
|
.then(function(r){ return r.text(); })
|
||||||
.then(function(html){ slot.innerHTML = html; })
|
.then(function(html){ slot.innerHTML = html; })
|
||||||
.catch(function(){ slot.innerHTML = ''; });
|
.catch(function(){ slot.innerHTML = ''; });
|
||||||
}catch(_){ }
|
}catch(e){ }
|
||||||
}
|
}
|
||||||
// Listen for OOB updates to the tags slot to trigger fetch
|
// Listen for OOB updates to the tags slot to trigger fetch
|
||||||
document.body.addEventListener('htmx:afterSwap', function(ev){
|
document.body.addEventListener('htmx:afterSwap', function(ev){
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<div class="muted" style="font-size:12px; margin-bottom:.35rem;">We detected a viable multi-copy archetype for your commander/themes. Choose one or skip.</div>
|
<div class="muted" style="font-size:12px; margin-bottom:.35rem;">We detected a viable multi-copy archetype for your commander/themes. Choose one or skip.</div>
|
||||||
<div style="display:grid; gap:.5rem;">
|
<div style="display:grid; gap:.5rem;">
|
||||||
{% for it in items %}
|
{% for it in items %}
|
||||||
<label class="mc-option" style="display:grid; grid-template-columns: auto 1fr; gap:.5rem; align-items:flex-start; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0b0d12;">
|
<label class="mc-option" style="display:grid; grid-template-columns: auto 1fr; gap:.5rem; align-items:flex-start; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:var(--panel);">
|
||||||
<input type="radio" name="multi_choice_id" value="{{ it.id }}" {% if loop.first %}checked{% endif %} />
|
<input type="radio" name="multi_choice_id" value="{{ it.id }}" {% if loop.first %}checked{% endif %} />
|
||||||
<div>
|
<div>
|
||||||
<div><strong>{{ it.name }}</strong> {% if it.printed_cap %}<span class="muted">(Cap: {{ it.printed_cap }})</span>{% endif %}</div>
|
<div><strong>{{ it.name }}</strong> {% if it.printed_cap %}<span class="muted">(Cap: {{ it.printed_cap }})</span>{% endif %}</div>
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,6 @@
|
||||||
</aside>
|
</aside>
|
||||||
<div class="grow" data-skeleton>
|
<div class="grow" data-skeleton>
|
||||||
<div hx-get="/build/banner" hx-trigger="load"></div>
|
<div hx-get="/build/banner" hx-trigger="load"></div>
|
||||||
{% if locks_restored and locks_restored > 0 %}
|
|
||||||
<div class="muted" style="margin:.35rem 0;">
|
|
||||||
<span class="chip" title="Locks restored from permalink">🔒 {{ locks_restored }} locks restored</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<h4>Chosen Ideals</h4>
|
<h4>Chosen Ideals</h4>
|
||||||
<ul>
|
<ul>
|
||||||
{% for key, label in labels.items() %}
|
{% for key, label in labels.items() %}
|
||||||
|
|
|
||||||
|
|
@ -186,9 +186,7 @@
|
||||||
<span class="chip" title="Multi-Copy package summary"><span class="dot dot-purple"></span> {{ mc_summary }}</span>
|
<span class="chip" title="Multi-Copy package summary"><span class="dot dot-purple"></span> {{ mc_summary }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span id="locks-chip">{% if locks and locks|length > 0 %}<span class="chip" title="Locked cards">🔒 {{ locks|length }} locked</span>{% endif %}</span>
|
<span id="locks-chip">{% if locks and locks|length > 0 %}<span class="chip" title="Locked cards">🔒 {{ locks|length }} locked</span>{% endif %}</span>
|
||||||
<button type="button" class="btn ml-auto" title="Copy permalink"
|
|
||||||
onclick="(async()=>{try{const r=await fetch('/build/permalink');const j=await r.json();const url=(j.permalink?location.origin+j.permalink:location.href+'#'+btoa(JSON.stringify(j.state||{}))); await navigator.clipboard.writeText(url); toast && toast('Permalink copied');}catch(e){alert('Copied state to console'); console.log(e);}})()">Copy Permalink</button>
|
|
||||||
<button type="button" class="btn" title="Open a saved permalink" onclick="(function(){try{var token = prompt('Paste a /build/from?state=... URL or token:'); if(!token) return; var m = token.match(/state=([^&]+)/); var t = m? m[1] : token.trim(); if(!t) return; window.location.href = '/build/from?state=' + encodeURIComponent(t); }catch(_){}})()">Open Permalink…</button>
|
|
||||||
</div>
|
</div>
|
||||||
{% set pct = ((deck_count / 100.0) * 100.0) if deck_count else 0 %}
|
{% set pct = ((deck_count / 100.0) * 100.0) if deck_count else 0 %}
|
||||||
{% set pct_clamped = (pct if pct <= 100 else 100) %}
|
{% set pct_clamped = (pct if pct <= 100 else 100) %}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@
|
||||||
<a href="/decks/compare" class="btn" role="button" title="Compare two finished decks">Compare</a>
|
<a href="/decks/compare" class="btn" role="button" title="Compare two finished decks">Compare</a>
|
||||||
<button id="deck-compare-selected" type="button" title="Compare two selected decks" disabled>Compare selected</button>
|
<button id="deck-compare-selected" type="button" title="Compare two selected decks" disabled>Compare selected</button>
|
||||||
<button id="deck-compare-latest" type="button" title="Pick the latest two decks">Latest two</button>
|
<button id="deck-compare-latest" type="button" title="Pick the latest two decks">Latest two</button>
|
||||||
<button id="deck-open-permalink" type="button" title="Open a saved permalink">Open Permalink…</button>
|
|
||||||
<button id="deck-reset-all" type="button" title="Reset filter, sort, and theme">Reset all</button>
|
<button id="deck-reset-all" type="button" title="Reset filter, sort, and theme">Reset all</button>
|
||||||
<button id="deck-help" type="button" title="Keyboard shortcuts and tips" aria-haspopup="dialog" aria-controls="deck-help-modal">Help</button>
|
<button id="deck-help" type="button" title="Keyboard shortcuts and tips" aria-haspopup="dialog" aria-controls="deck-help-modal">Help</button>
|
||||||
<span id="deck-count" class="muted" aria-live="polite"></span>
|
<span id="deck-count" class="muted" aria-live="polite"></span>
|
||||||
|
|
@ -127,7 +126,6 @@
|
||||||
var txtOnlyCb = document.getElementById('deck-txt-only');
|
var txtOnlyCb = document.getElementById('deck-txt-only');
|
||||||
var cmpSelBtn = document.getElementById('deck-compare-selected');
|
var cmpSelBtn = document.getElementById('deck-compare-selected');
|
||||||
var cmpLatestBtn = document.getElementById('deck-compare-latest');
|
var cmpLatestBtn = document.getElementById('deck-compare-latest');
|
||||||
var openPermalinkBtn = document.getElementById('deck-open-permalink');
|
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
|
|
||||||
// Panels and themes discovery from data-tags-pipe
|
// Panels and themes discovery from data-tags-pipe
|
||||||
|
|
@ -416,18 +414,6 @@
|
||||||
} catch(_){ }
|
} catch(_){ }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Open permalink prompt
|
|
||||||
if (openPermalinkBtn) openPermalinkBtn.addEventListener('click', function(){
|
|
||||||
try{
|
|
||||||
var token = prompt('Paste a /build/from?state=... URL or token:');
|
|
||||||
if(!token) return;
|
|
||||||
var m = token.match(/state=([^&]+)/);
|
|
||||||
var t = m ? m[1] : token.trim();
|
|
||||||
if(!t) return;
|
|
||||||
window.location.href = '/build/from?state=' + encodeURIComponent(t);
|
|
||||||
}catch(_){ }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (resetAllBtn) resetAllBtn.addEventListener('click', function(){
|
if (resetAllBtn) resetAllBtn.addEventListener('click', function(){
|
||||||
// Clear UI state
|
// Clear UI state
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,7 @@
|
||||||
<div class="random-meta" style="display:flex; gap:12px; align-items:center; flex-wrap:wrap;">
|
<div class="random-meta" style="display:flex; gap:12px; align-items:center; flex-wrap:wrap;">
|
||||||
<span class="seed">Seed: <strong>{{ seed }}</strong></span>
|
<span class="seed">Seed: <strong>{{ seed }}</strong></span>
|
||||||
{% if theme %}<span class="theme">Theme: <strong>{{ theme }}</strong></span>{% endif %}
|
{% if theme %}<span class="theme">Theme: <strong>{{ theme }}</strong></span>{% endif %}
|
||||||
{% if permalink %}
|
|
||||||
<button class="btn btn-compact" type="button" aria-label="Copy permalink for this exact build" onclick="(async()=>{try{await navigator.clipboard.writeText(location.origin + '{{ permalink }}');(window.toast&&toast('Permalink copied'))||console.log('Permalink copied');}catch(e){alert('Copy failed');}})()">Copy Permalink</button>
|
|
||||||
{% endif %}
|
|
||||||
{% if show_diagnostics and diagnostics %}
|
{% if show_diagnostics and diagnostics %}
|
||||||
<span class="diag-badges" aria-label="Diagnostics" role="status" aria-live="polite" aria-atomic="true">
|
<span class="diag-badges" aria-label="Diagnostics" role="status" aria-live="polite" aria-atomic="true">
|
||||||
<span class="diag-badge" title="Attempts tried before acceptance" aria-label="Attempts tried before acceptance">
|
<span class="diag-badge" title="Attempts tried before acceptance" aria-label="Attempts tried before acceptance">
|
||||||
|
|
|
||||||
|
|
@ -148,10 +148,10 @@
|
||||||
</section>
|
</section>
|
||||||
<script>
|
<script>
|
||||||
(function(){
|
(function(){
|
||||||
// Minimal styling helper to unify button widths
|
// Minimal styling helper to unify button widths (only for content buttons)
|
||||||
try {
|
try {
|
||||||
var style = document.createElement('style');
|
var style = document.createElement('style');
|
||||||
style.textContent = '.btn{min-width:180px;}';
|
style.textContent = '.content .btn{min-width:180px;}';
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
} catch(e){}
|
} catch(e){}
|
||||||
function update(data){
|
function update(data){
|
||||||
|
|
@ -325,27 +325,38 @@
|
||||||
statusLine.style.color = '#94a3b8';
|
statusLine.style.color = '#94a3b8';
|
||||||
if (statsLine) statsLine.style.display = 'none';
|
if (statsLine) statsLine.style.display = 'none';
|
||||||
} else {
|
} else {
|
||||||
var totalCount = 0;
|
// Card count = use the max across sizes (each card has one image per size, so avoid double-counting)
|
||||||
var totalSizeMB = 0;
|
var cardCount = Math.max(
|
||||||
|
(stats.small && stats.small.count) || 0,
|
||||||
|
(stats.normal && stats.normal.count) || 0
|
||||||
|
);
|
||||||
|
var totalSizeMB = ((stats.small && stats.small.size_mb) || 0) + ((stats.normal && stats.normal.size_mb) || 0);
|
||||||
|
var sizeCount = (stats.small ? 1 : 0) + (stats.normal ? 1 : 0);
|
||||||
|
|
||||||
if (stats.small) {
|
if (cardCount > 0) {
|
||||||
totalCount += stats.small.count || 0;
|
|
||||||
totalSizeMB += stats.small.size_mb || 0;
|
|
||||||
}
|
|
||||||
if (stats.normal) {
|
|
||||||
totalCount += stats.normal.count || 0;
|
|
||||||
totalSizeMB += stats.normal.size_mb || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (totalCount > 0) {
|
|
||||||
statusLine.textContent = 'Cache exists';
|
statusLine.textContent = 'Cache exists';
|
||||||
statusLine.style.color = '#34d399';
|
statusLine.style.color = '#34d399';
|
||||||
if (statsLine) {
|
if (statsLine) {
|
||||||
statsLine.style.display = '';
|
statsLine.style.display = '';
|
||||||
statsLine.textContent = totalCount.toLocaleString() + ' images cached • ' + totalSizeMB.toFixed(1) + ' MB';
|
var statsText = cardCount.toLocaleString() + ' cards cached • ' + totalSizeMB.toFixed(1) + ' MB';
|
||||||
|
// If we have last download info, append new card count
|
||||||
|
if (data.last_download) {
|
||||||
|
var ld = data.last_download;
|
||||||
|
if (ld.stats && typeof ld.stats.downloaded === 'number') {
|
||||||
|
var newCards = sizeCount > 0 ? Math.round(ld.stats.downloaded / sizeCount) : ld.stats.downloaded;
|
||||||
|
if (newCards > 0) {
|
||||||
|
statsText += ' • Last run: +' + newCards.toLocaleString() + ' new cards';
|
||||||
|
} else {
|
||||||
|
statsText += ' • Last run: fully up to date';
|
||||||
|
}
|
||||||
|
} else if (ld.message) {
|
||||||
|
statsText += ' • ' + ld.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
statsLine.textContent = statsText;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
statusLine.textContent = 'No images cached';
|
statusLine.textContent = 'No cards cached';
|
||||||
statusLine.style.color = '#94a3b8';
|
statusLine.style.color = '#94a3b8';
|
||||||
if (statsLine) statsLine.style.display = 'none';
|
if (statsLine) statsLine.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
@ -354,6 +365,23 @@
|
||||||
// Hide download progress
|
// Hide download progress
|
||||||
if (downloadStatus) downloadStatus.style.display = 'none';
|
if (downloadStatus) downloadStatus.style.display = 'none';
|
||||||
if (progressBar) progressBar.style.display = 'none';
|
if (progressBar) progressBar.style.display = 'none';
|
||||||
|
} else if (data.phase === 'error' || data.message) {
|
||||||
|
// Previous download failed - show error and allow retry
|
||||||
|
statusLine.textContent = 'Last download failed';
|
||||||
|
statusLine.style.color = '#f87171';
|
||||||
|
if (statsLine) {
|
||||||
|
statsLine.style.display = '';
|
||||||
|
statsLine.textContent = data.message || 'Unknown error';
|
||||||
|
}
|
||||||
|
if (downloadStatus) downloadStatus.style.display = 'none';
|
||||||
|
if (progressBar) progressBar.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
// No stats, no error - likely no download attempted yet
|
||||||
|
statusLine.textContent = 'No cards cached';
|
||||||
|
statusLine.style.color = '#94a3b8';
|
||||||
|
if (statsLine) statsLine.style.display = 'none';
|
||||||
|
if (downloadStatus) downloadStatus.style.display = 'none';
|
||||||
|
if (progressBar) progressBar.style.display = 'none';
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function(){
|
.catch(function(){
|
||||||
|
|
|
||||||
1
code/web/utils/__init__.py
Normal file
1
code/web/utils/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Utility modules for the web application."""
|
||||||
252
code/web/utils/responses.py
Normal file
252
code/web/utils/responses.py
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
"""Response builder utilities for standardized HTTP responses.
|
||||||
|
|
||||||
|
Provides helper functions for creating consistent response objects across all routes.
|
||||||
|
"""
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
|
||||||
|
def build_error_response(
|
||||||
|
request: Request,
|
||||||
|
status_code: int,
|
||||||
|
error_type: str,
|
||||||
|
message: str,
|
||||||
|
detail: Optional[str] = None,
|
||||||
|
fields: Optional[Dict[str, list[str]]] = None
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""Build a standardized error response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object
|
||||||
|
status_code: HTTP status code
|
||||||
|
error_type: Type of error (e.g., "ValidationError", "NotFoundError")
|
||||||
|
message: User-friendly error message
|
||||||
|
detail: Additional error detail
|
||||||
|
fields: Field-level validation errors
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSONResponse with standardized error structure
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
request_id = getattr(request.state, "request_id", "unknown")
|
||||||
|
error_data = {
|
||||||
|
"status": status_code,
|
||||||
|
"error": error_type,
|
||||||
|
"message": message,
|
||||||
|
"path": str(request.url.path),
|
||||||
|
"request_id": request_id,
|
||||||
|
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||||
|
}
|
||||||
|
|
||||||
|
if detail:
|
||||||
|
error_data["detail"] = detail
|
||||||
|
if fields:
|
||||||
|
error_data["fields"] = fields
|
||||||
|
|
||||||
|
return JSONResponse(content=error_data, status_code=status_code)
|
||||||
|
|
||||||
|
|
||||||
|
def build_success_response(
|
||||||
|
data: Any,
|
||||||
|
status_code: int = 200,
|
||||||
|
headers: Optional[Dict[str, str]] = None
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""Build a standardized success response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Response data to return
|
||||||
|
status_code: HTTP status code (default 200)
|
||||||
|
headers: Optional additional headers
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSONResponse with data
|
||||||
|
"""
|
||||||
|
response = JSONResponse(content=data, status_code=status_code)
|
||||||
|
if headers:
|
||||||
|
for key, value in headers.items():
|
||||||
|
response.headers[key] = value
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def build_template_response(
|
||||||
|
request: Request,
|
||||||
|
templates: Jinja2Templates,
|
||||||
|
template_name: str,
|
||||||
|
context: Dict[str, Any],
|
||||||
|
status_code: int = 200
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Build a standardized template response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object
|
||||||
|
templates: Jinja2Templates instance
|
||||||
|
template_name: Name of template to render
|
||||||
|
context: Template context dictionary
|
||||||
|
status_code: HTTP status code (default 200)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HTMLResponse with rendered template
|
||||||
|
"""
|
||||||
|
# Ensure request is in context
|
||||||
|
if "request" not in context:
|
||||||
|
context["request"] = request
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
template_name,
|
||||||
|
context,
|
||||||
|
status_code=status_code
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_htmx_response(
|
||||||
|
content: str,
|
||||||
|
trigger: Optional[Dict[str, Any]] = None,
|
||||||
|
retarget: Optional[str] = None,
|
||||||
|
reswap: Optional[str] = None
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Build an HTMX partial response with appropriate headers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: HTML content to return
|
||||||
|
trigger: HTMX trigger events to fire
|
||||||
|
retarget: Optional HX-Retarget header
|
||||||
|
reswap: Optional HX-Reswap header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HTMLResponse with HTMX headers
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
response = HTMLResponse(content=content)
|
||||||
|
|
||||||
|
if trigger:
|
||||||
|
response.headers["HX-Trigger"] = json.dumps(trigger)
|
||||||
|
if retarget:
|
||||||
|
response.headers["HX-Retarget"] = retarget
|
||||||
|
if reswap:
|
||||||
|
response.headers["HX-Reswap"] = reswap
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def merge_hx_trigger(response: HTMLResponse, events: Dict[str, Any]) -> None:
|
||||||
|
"""Merge additional HTMX trigger events into an existing response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
response: Existing HTMLResponse
|
||||||
|
events: Additional trigger events to merge
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
if not events:
|
||||||
|
return
|
||||||
|
|
||||||
|
existing = response.headers.get("HX-Trigger")
|
||||||
|
if existing:
|
||||||
|
try:
|
||||||
|
existing_events = json.loads(existing)
|
||||||
|
existing_events.update(events)
|
||||||
|
response.headers["HX-Trigger"] = json.dumps(existing_events)
|
||||||
|
except (json.JSONDecodeError, AttributeError):
|
||||||
|
# If existing is a simple string, convert to dict
|
||||||
|
response.headers["HX-Trigger"] = json.dumps(events)
|
||||||
|
else:
|
||||||
|
response.headers["HX-Trigger"] = json.dumps(events)
|
||||||
|
|
||||||
|
|
||||||
|
# --- DeckBuilderError integration ---
|
||||||
|
|
||||||
|
def is_htmx_request(request: Request) -> bool:
|
||||||
|
"""Return True if the request was made by HTMX."""
|
||||||
|
try:
|
||||||
|
return request.headers.get("HX-Request") == "true"
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Map DeckBuilderError subclass names to HTTP status codes.
|
||||||
|
# More specific subclasses should appear before their parents.
|
||||||
|
_EXCEPTION_STATUS_MAP: list[tuple[str, int]] = [
|
||||||
|
# Web-specific
|
||||||
|
("SessionExpiredError", 401),
|
||||||
|
("BuildNotFoundError", 404),
|
||||||
|
("FeatureDisabledError", 404),
|
||||||
|
# Commander
|
||||||
|
("CommanderValidationError", 400),
|
||||||
|
("CommanderTypeError", 400),
|
||||||
|
("CommanderColorError", 400),
|
||||||
|
("CommanderTagError", 400),
|
||||||
|
("CommanderPartnerError", 400),
|
||||||
|
("CommanderSelectionError", 400),
|
||||||
|
("CommanderLoadError", 503),
|
||||||
|
# Theme
|
||||||
|
("ThemeSelectionError", 400),
|
||||||
|
("ThemeWeightError", 400),
|
||||||
|
("ThemeError", 400),
|
||||||
|
# Price
|
||||||
|
("PriceLimitError", 400),
|
||||||
|
("PriceValidationError", 400),
|
||||||
|
("PriceAPIError", 503),
|
||||||
|
("PriceError", 400),
|
||||||
|
# CSV / setup data unavailable
|
||||||
|
("CSVFileNotFoundError", 503),
|
||||||
|
("MTGJSONDownloadError", 503),
|
||||||
|
("EmptyDataFrameError", 503),
|
||||||
|
("CSVError", 503),
|
||||||
|
("MTGSetupError", 503),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def deck_error_to_status(exc: Exception) -> int:
|
||||||
|
"""Return the appropriate HTTP status code for a DeckBuilderError."""
|
||||||
|
exc_type = type(exc).__name__
|
||||||
|
for name, status in _EXCEPTION_STATUS_MAP:
|
||||||
|
if exc_type == name:
|
||||||
|
return status
|
||||||
|
# Walk MRO for inexact matches (subclasses not listed above)
|
||||||
|
for cls in type(exc).__mro__:
|
||||||
|
for name, status in _EXCEPTION_STATUS_MAP:
|
||||||
|
if cls.__name__ == name:
|
||||||
|
return status
|
||||||
|
return 500
|
||||||
|
|
||||||
|
|
||||||
|
def deck_builder_error_response(request: Request, exc: Exception) -> JSONResponse | HTMLResponse:
|
||||||
|
"""Convert a DeckBuilderError to an appropriate HTTP response.
|
||||||
|
|
||||||
|
Returns an HTML error fragment for HTMX requests, JSON otherwise.
|
||||||
|
Includes request_id and standardized structure.
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
status = deck_error_to_status(exc)
|
||||||
|
request_id = getattr(getattr(request, "state", None), "request_id", None) or "unknown"
|
||||||
|
|
||||||
|
# User-safe message: use .message attribute if present, else str()
|
||||||
|
message = getattr(exc, "message", None) or str(exc)
|
||||||
|
error_type = type(exc).__name__
|
||||||
|
code = getattr(exc, "code", error_type)
|
||||||
|
|
||||||
|
if is_htmx_request(request):
|
||||||
|
html = (
|
||||||
|
f'<div class="error-banner" role="alert">'
|
||||||
|
f'<strong>{status}</strong> {message}'
|
||||||
|
f'</div>'
|
||||||
|
)
|
||||||
|
return HTMLResponse(content=html, status_code=status, headers={"X-Request-ID": request_id})
|
||||||
|
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"error": True,
|
||||||
|
"status": status,
|
||||||
|
"error_type": error_type,
|
||||||
|
"code": code,
|
||||||
|
"message": message,
|
||||||
|
"path": str(request.url.path),
|
||||||
|
"request_id": request_id,
|
||||||
|
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||||
|
}
|
||||||
|
return JSONResponse(content=payload, status_code=status, headers={"X-Request-ID": request_id})
|
||||||
13
code/web/validation/__init__.py
Normal file
13
code/web/validation/__init__.py
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
"""Validation package for web application.
|
||||||
|
|
||||||
|
Provides centralized validation using Pydantic models and custom validators
|
||||||
|
for all web route inputs and business logic validation.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"models",
|
||||||
|
"validators",
|
||||||
|
"card_names",
|
||||||
|
"messages",
|
||||||
|
]
|
||||||
256
code/web/validation/card_names.py
Normal file
256
code/web/validation/card_names.py
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
"""Card name validation and normalization.
|
||||||
|
|
||||||
|
Provides utilities for validating and normalizing card names against
|
||||||
|
the card database, handling punctuation, case sensitivity, and multi-face cards.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional, Tuple, List, Set
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
|
||||||
|
class CardNameValidator:
|
||||||
|
"""Validates and normalizes card names against card database.
|
||||||
|
|
||||||
|
Handles:
|
||||||
|
- Case normalization
|
||||||
|
- Punctuation variants
|
||||||
|
- Multi-face cards (// separator)
|
||||||
|
- Accent/diacritic handling
|
||||||
|
- Fuzzy matching for common typos
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Initialize validator with card database."""
|
||||||
|
self._card_names: Set[str] = set()
|
||||||
|
self._normalized_map: dict[str, str] = {}
|
||||||
|
self._loaded = False
|
||||||
|
|
||||||
|
def _ensure_loaded(self) -> None:
|
||||||
|
"""Lazy-load card database on first use."""
|
||||||
|
if self._loaded:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
from deck_builder import builder_utils as bu
|
||||||
|
df = bu._load_all_cards_parquet()
|
||||||
|
|
||||||
|
if not df.empty and 'name' in df.columns:
|
||||||
|
for name in df['name'].dropna():
|
||||||
|
name_str = str(name).strip()
|
||||||
|
if name_str:
|
||||||
|
self._card_names.add(name_str)
|
||||||
|
# Map normalized version to original
|
||||||
|
normalized = self.normalize(name_str)
|
||||||
|
self._normalized_map[normalized] = name_str
|
||||||
|
|
||||||
|
self._loaded = True
|
||||||
|
except Exception:
|
||||||
|
# Defensive: if loading fails, validator still works but won't validate
|
||||||
|
self._loaded = True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def normalize(name: str) -> str:
|
||||||
|
"""Normalize card name for comparison.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Raw card name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Normalized card name (lowercase, no diacritics, standardized punctuation)
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Strip whitespace
|
||||||
|
cleaned = name.strip()
|
||||||
|
|
||||||
|
# Remove diacritics/accents
|
||||||
|
nfd = unicodedata.normalize('NFD', cleaned)
|
||||||
|
cleaned = ''.join(c for c in nfd if unicodedata.category(c) != 'Mn')
|
||||||
|
|
||||||
|
# Lowercase
|
||||||
|
cleaned = cleaned.lower()
|
||||||
|
|
||||||
|
# Standardize punctuation
|
||||||
|
cleaned = re.sub(r"[''`]", "'", cleaned) # Normalize apostrophes
|
||||||
|
cleaned = re.sub(r'["""]', '"', cleaned) # Normalize quotes
|
||||||
|
cleaned = re.sub(r'—', '-', cleaned) # Normalize dashes
|
||||||
|
|
||||||
|
# Collapse multiple spaces
|
||||||
|
cleaned = re.sub(r'\s+', ' ', cleaned)
|
||||||
|
|
||||||
|
return cleaned.strip()
|
||||||
|
|
||||||
|
def is_valid(self, name: str) -> bool:
|
||||||
|
"""Check if card name exists in database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Card name to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if card exists
|
||||||
|
"""
|
||||||
|
self._ensure_loaded()
|
||||||
|
|
||||||
|
if not name or not name.strip():
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Try exact match first
|
||||||
|
if name in self._card_names:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Try normalized match
|
||||||
|
normalized = self.normalize(name)
|
||||||
|
return normalized in self._normalized_map
|
||||||
|
|
||||||
|
def get_canonical_name(self, name: str) -> Optional[str]:
|
||||||
|
"""Get canonical (database) name for a card.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Card name (any capitalization/punctuation)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Canonical name if found, None otherwise
|
||||||
|
"""
|
||||||
|
self._ensure_loaded()
|
||||||
|
|
||||||
|
if not name or not name.strip():
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Return exact match if exists
|
||||||
|
if name in self._card_names:
|
||||||
|
return name
|
||||||
|
|
||||||
|
# Try normalized lookup
|
||||||
|
normalized = self.normalize(name)
|
||||||
|
return self._normalized_map.get(normalized)
|
||||||
|
|
||||||
|
def validate_and_normalize(self, name: str) -> Tuple[bool, Optional[str], Optional[str]]:
|
||||||
|
"""Validate and normalize a card name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Card name to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(is_valid, canonical_name, error_message) tuple
|
||||||
|
"""
|
||||||
|
if not name or not name.strip():
|
||||||
|
return False, None, "Card name cannot be empty"
|
||||||
|
|
||||||
|
canonical = self.get_canonical_name(name)
|
||||||
|
|
||||||
|
if canonical:
|
||||||
|
return True, canonical, None
|
||||||
|
else:
|
||||||
|
return False, None, f"Card '{name}' not found in database"
|
||||||
|
|
||||||
|
def is_valid_commander(self, name: str) -> Tuple[bool, Optional[str]]:
|
||||||
|
"""Check if card name is a valid commander.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Card name to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(is_valid, error_message) tuple
|
||||||
|
"""
|
||||||
|
self._ensure_loaded()
|
||||||
|
|
||||||
|
is_valid, canonical, error = self.validate_and_normalize(name)
|
||||||
|
|
||||||
|
if not is_valid:
|
||||||
|
return False, error
|
||||||
|
|
||||||
|
# Check if card can be commander (has Legendary type)
|
||||||
|
try:
|
||||||
|
from deck_builder import builder_utils as bu
|
||||||
|
df = bu._load_all_cards_parquet()
|
||||||
|
|
||||||
|
if not df.empty:
|
||||||
|
# Match by canonical name
|
||||||
|
card_row = df[df['name'] == canonical]
|
||||||
|
|
||||||
|
if card_row.empty:
|
||||||
|
return False, f"Card '{name}' not found"
|
||||||
|
|
||||||
|
# Check type line for Legendary
|
||||||
|
type_line = str(card_row['type'].iloc[0] if 'type' in card_row else '')
|
||||||
|
|
||||||
|
if 'Legendary' not in type_line and 'legendary' not in type_line.lower():
|
||||||
|
return False, f"'{name}' is not a Legendary creature (cannot be commander)"
|
||||||
|
|
||||||
|
# Check for Creature or Planeswalker
|
||||||
|
is_creature = 'Creature' in type_line or 'creature' in type_line.lower()
|
||||||
|
is_pw = 'Planeswalker' in type_line or 'planeswalker' in type_line.lower()
|
||||||
|
|
||||||
|
# Check for specific commander abilities
|
||||||
|
oracle_text = str(card_row['oracle'].iloc[0] if 'oracle' in card_row else '')
|
||||||
|
can_be_commander = ' can be your commander' in oracle_text.lower()
|
||||||
|
|
||||||
|
if not (is_creature or is_pw or can_be_commander):
|
||||||
|
return False, f"'{name}' cannot be a commander"
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
# Defensive: if check fails, assume valid if card exists
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
def validate_card_list(self, names: List[str]) -> Tuple[List[str], List[str]]:
|
||||||
|
"""Validate a list of card names.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
names: List of card names to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(valid_names, invalid_names) tuple with canonical names
|
||||||
|
"""
|
||||||
|
valid: List[str] = []
|
||||||
|
invalid: List[str] = []
|
||||||
|
|
||||||
|
for name in names:
|
||||||
|
is_valid, canonical, _ = self.validate_and_normalize(name)
|
||||||
|
if is_valid and canonical:
|
||||||
|
valid.append(canonical)
|
||||||
|
else:
|
||||||
|
invalid.append(name)
|
||||||
|
|
||||||
|
return valid, invalid
|
||||||
|
|
||||||
|
|
||||||
|
# Global validator instance
|
||||||
|
_validator: Optional[CardNameValidator] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_validator() -> CardNameValidator:
|
||||||
|
"""Get global card name validator instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CardNameValidator instance
|
||||||
|
"""
|
||||||
|
global _validator
|
||||||
|
if _validator is None:
|
||||||
|
_validator = CardNameValidator()
|
||||||
|
return _validator
|
||||||
|
|
||||||
|
|
||||||
|
# Convenience functions
|
||||||
|
def is_valid_card(name: str) -> bool:
|
||||||
|
"""Check if card name is valid."""
|
||||||
|
return get_validator().is_valid(name)
|
||||||
|
|
||||||
|
|
||||||
|
def get_canonical_name(name: str) -> Optional[str]:
|
||||||
|
"""Get canonical card name."""
|
||||||
|
return get_validator().get_canonical_name(name)
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_commander(name: str) -> Tuple[bool, Optional[str]]:
|
||||||
|
"""Check if card is a valid commander."""
|
||||||
|
return get_validator().is_valid_commander(name)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_card_list(names: List[str]) -> Tuple[List[str], List[str]]:
|
||||||
|
"""Validate a list of card names."""
|
||||||
|
return get_validator().validate_card_list(names)
|
||||||
129
code/web/validation/messages.py
Normal file
129
code/web/validation/messages.py
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
"""Error message templates for validation errors.
|
||||||
|
|
||||||
|
Provides consistent, user-friendly error messages for validation failures.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationMessages:
|
||||||
|
"""Standard validation error messages."""
|
||||||
|
|
||||||
|
# Commander validation
|
||||||
|
COMMANDER_REQUIRED = "Commander name is required"
|
||||||
|
COMMANDER_INVALID = "Commander '{name}' not found in database"
|
||||||
|
COMMANDER_NOT_LEGENDARY = "'{name}' is not a Legendary creature (cannot be commander)"
|
||||||
|
COMMANDER_CANNOT_COMMAND = "'{name}' cannot be a commander"
|
||||||
|
|
||||||
|
# Partner validation
|
||||||
|
PARTNER_REQUIRES_NAME = "Partner mode requires a partner commander name"
|
||||||
|
BACKGROUND_REQUIRES_NAME = "Background mode requires a background name"
|
||||||
|
PARTNER_NAME_REQUIRES_MODE = "Partner name specified but partner mode not set"
|
||||||
|
BACKGROUND_INVALID_MODE = "Background name only valid with background partner mode"
|
||||||
|
|
||||||
|
# Theme validation
|
||||||
|
THEME_INVALID = "Theme '{name}' not found in catalog"
|
||||||
|
THEMES_INVALID = "Invalid themes: {names}"
|
||||||
|
THEME_REQUIRED = "At least one theme is required"
|
||||||
|
|
||||||
|
# Card validation
|
||||||
|
CARD_NOT_FOUND = "Card '{name}' not found in database"
|
||||||
|
CARD_NAME_EMPTY = "Card name cannot be empty"
|
||||||
|
CARDS_NOT_FOUND = "Cards not found: {names}"
|
||||||
|
|
||||||
|
# Bracket validation
|
||||||
|
BRACKET_INVALID = "Power bracket must be between 1 and 4"
|
||||||
|
BRACKET_EXCEEDED = "'{name}' is bracket {card_bracket}, exceeds limit of {limit}"
|
||||||
|
|
||||||
|
# Color validation
|
||||||
|
COLOR_IDENTITY_MISMATCH = "Card '{name}' colors ({card_colors}) exceed commander colors ({commander_colors})"
|
||||||
|
|
||||||
|
# Custom theme validation
|
||||||
|
CUSTOM_THEME_REQUIRES_NAME_AND_TAGS = "Custom theme requires both name and tags"
|
||||||
|
CUSTOM_THEME_NAME_REQUIRED = "Custom theme tags require a theme name"
|
||||||
|
CUSTOM_THEME_TAGS_REQUIRED = "Custom theme name requires tags"
|
||||||
|
|
||||||
|
# List validation
|
||||||
|
MUST_INCLUDE_TOO_MANY = "Must-include list cannot exceed 99 cards"
|
||||||
|
MUST_EXCLUDE_TOO_MANY = "Must-exclude list cannot exceed 500 cards"
|
||||||
|
|
||||||
|
# Batch validation
|
||||||
|
BATCH_COUNT_INVALID = "Batch count must be between 1 and 10"
|
||||||
|
BATCH_COUNT_EXCEEDED = "Batch count cannot exceed 10"
|
||||||
|
|
||||||
|
# File validation
|
||||||
|
FILE_CONTENT_EMPTY = "File content cannot be empty"
|
||||||
|
FILE_FORMAT_INVALID = "File format '{format}' not supported"
|
||||||
|
|
||||||
|
# General
|
||||||
|
VALUE_REQUIRED = "Value is required"
|
||||||
|
VALUE_TOO_LONG = "Value exceeds maximum length of {max_length}"
|
||||||
|
VALUE_TOO_SHORT = "Value must be at least {min_length} characters"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_commander_invalid(name: str) -> str:
|
||||||
|
"""Format commander invalid message."""
|
||||||
|
return ValidationMessages.COMMANDER_INVALID.format(name=name)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_commander_not_legendary(name: str) -> str:
|
||||||
|
"""Format commander not legendary message."""
|
||||||
|
return ValidationMessages.COMMANDER_NOT_LEGENDARY.format(name=name)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_theme_invalid(name: str) -> str:
|
||||||
|
"""Format theme invalid message."""
|
||||||
|
return ValidationMessages.THEME_INVALID.format(name=name)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_themes_invalid(names: List[str]) -> str:
|
||||||
|
"""Format multiple invalid themes message."""
|
||||||
|
return ValidationMessages.THEMES_INVALID.format(names=", ".join(names))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_card_not_found(name: str) -> str:
|
||||||
|
"""Format card not found message."""
|
||||||
|
return ValidationMessages.CARD_NOT_FOUND.format(name=name)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_cards_not_found(names: List[str]) -> str:
|
||||||
|
"""Format multiple cards not found message."""
|
||||||
|
return ValidationMessages.CARDS_NOT_FOUND.format(names=", ".join(names))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_bracket_exceeded(name: str, card_bracket: int, limit: int) -> str:
|
||||||
|
"""Format bracket exceeded message."""
|
||||||
|
return ValidationMessages.BRACKET_EXCEEDED.format(
|
||||||
|
name=name,
|
||||||
|
card_bracket=card_bracket,
|
||||||
|
limit=limit
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_color_mismatch(name: str, card_colors: str, commander_colors: str) -> str:
|
||||||
|
"""Format color identity mismatch message."""
|
||||||
|
return ValidationMessages.COLOR_IDENTITY_MISMATCH.format(
|
||||||
|
name=name,
|
||||||
|
card_colors=card_colors,
|
||||||
|
commander_colors=commander_colors
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_file_format_invalid(format_type: str) -> str:
|
||||||
|
"""Format invalid file format message."""
|
||||||
|
return ValidationMessages.FILE_FORMAT_INVALID.format(format=format_type)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_value_too_long(max_length: int) -> str:
|
||||||
|
"""Format value too long message."""
|
||||||
|
return ValidationMessages.VALUE_TOO_LONG.format(max_length=max_length)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_value_too_short(min_length: int) -> str:
|
||||||
|
"""Format value too short message."""
|
||||||
|
return ValidationMessages.VALUE_TOO_SHORT.format(min_length=min_length)
|
||||||
|
|
||||||
|
|
||||||
|
# Convenience access
|
||||||
|
MSG = ValidationMessages
|
||||||
212
code/web/validation/models.py
Normal file
212
code/web/validation/models.py
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
"""Pydantic models for request validation.
|
||||||
|
|
||||||
|
Defines typed models for all web route inputs with automatic validation.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional, List
|
||||||
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class PowerBracket(int, Enum):
|
||||||
|
"""Power bracket enumeration (1-4)."""
|
||||||
|
BRACKET_1 = 1
|
||||||
|
BRACKET_2 = 2
|
||||||
|
BRACKET_3 = 3
|
||||||
|
BRACKET_4 = 4
|
||||||
|
|
||||||
|
|
||||||
|
class DeckMode(str, Enum):
|
||||||
|
"""Deck building mode."""
|
||||||
|
STANDARD = "standard"
|
||||||
|
RANDOM = "random"
|
||||||
|
HEADLESS = "headless"
|
||||||
|
|
||||||
|
|
||||||
|
class OwnedMode(str, Enum):
|
||||||
|
"""Owned cards usage mode."""
|
||||||
|
OFF = "off"
|
||||||
|
PREFER = "prefer"
|
||||||
|
ONLY = "only"
|
||||||
|
|
||||||
|
|
||||||
|
class CommanderPartnerType(str, Enum):
|
||||||
|
"""Commander partner configuration type."""
|
||||||
|
SINGLE = "single"
|
||||||
|
PARTNER = "partner"
|
||||||
|
BACKGROUND = "background"
|
||||||
|
PARTNER_WITH = "partner_with"
|
||||||
|
|
||||||
|
|
||||||
|
class BuildRequest(BaseModel):
|
||||||
|
"""Build request validation model."""
|
||||||
|
|
||||||
|
commander: str = Field(..., min_length=1, max_length=200, description="Commander card name")
|
||||||
|
themes: List[str] = Field(default_factory=list, max_length=5, description="Theme tags")
|
||||||
|
power_bracket: PowerBracket = Field(default=PowerBracket.BRACKET_2, description="Power bracket (1-4)")
|
||||||
|
|
||||||
|
# Partner configuration
|
||||||
|
partner_mode: Optional[CommanderPartnerType] = Field(default=None, description="Partner type")
|
||||||
|
partner_name: Optional[str] = Field(default=None, max_length=200, description="Partner commander name")
|
||||||
|
background_name: Optional[str] = Field(default=None, max_length=200, description="Background name")
|
||||||
|
|
||||||
|
# Owned cards
|
||||||
|
owned_mode: OwnedMode = Field(default=OwnedMode.OFF, description="Owned cards mode")
|
||||||
|
|
||||||
|
# Custom theme
|
||||||
|
custom_theme_name: Optional[str] = Field(default=None, max_length=100, description="Custom theme name")
|
||||||
|
custom_theme_tags: Optional[List[str]] = Field(default=None, max_length=20, description="Custom theme tags")
|
||||||
|
|
||||||
|
# Include/exclude lists
|
||||||
|
must_include: Optional[List[str]] = Field(default=None, max_length=99, description="Must-include card names")
|
||||||
|
must_exclude: Optional[List[str]] = Field(default=None, max_length=500, description="Must-exclude card names")
|
||||||
|
|
||||||
|
# Random modes
|
||||||
|
random_commander: bool = Field(default=False, description="Randomize commander")
|
||||||
|
random_themes: bool = Field(default=False, description="Randomize themes")
|
||||||
|
random_seed: Optional[int] = Field(default=None, ge=0, description="Random seed")
|
||||||
|
|
||||||
|
@field_validator("commander")
|
||||||
|
@classmethod
|
||||||
|
def validate_commander_not_empty(cls, v: str) -> str:
|
||||||
|
"""Ensure commander name is not just whitespace."""
|
||||||
|
if not v or not v.strip():
|
||||||
|
raise ValueError("Commander name cannot be empty")
|
||||||
|
return v.strip()
|
||||||
|
|
||||||
|
@field_validator("themes")
|
||||||
|
@classmethod
|
||||||
|
def validate_themes_unique(cls, v: List[str]) -> List[str]:
|
||||||
|
"""Ensure themes are unique and non-empty."""
|
||||||
|
if not v:
|
||||||
|
return []
|
||||||
|
|
||||||
|
cleaned = [t.strip() for t in v if t and t.strip()]
|
||||||
|
seen = set()
|
||||||
|
unique = []
|
||||||
|
for theme in cleaned:
|
||||||
|
lower = theme.lower()
|
||||||
|
if lower not in seen:
|
||||||
|
seen.add(lower)
|
||||||
|
unique.append(theme)
|
||||||
|
|
||||||
|
return unique
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_partner_consistency(self) -> "BuildRequest":
|
||||||
|
"""Validate partner configuration consistency."""
|
||||||
|
if self.partner_mode == CommanderPartnerType.PARTNER:
|
||||||
|
if not self.partner_name:
|
||||||
|
raise ValueError("Partner mode requires partner_name")
|
||||||
|
|
||||||
|
if self.partner_mode == CommanderPartnerType.BACKGROUND:
|
||||||
|
if not self.background_name:
|
||||||
|
raise ValueError("Background mode requires background_name")
|
||||||
|
|
||||||
|
if self.partner_name and not self.partner_mode:
|
||||||
|
raise ValueError("partner_name requires partner_mode to be set")
|
||||||
|
|
||||||
|
if self.background_name and self.partner_mode != CommanderPartnerType.BACKGROUND:
|
||||||
|
raise ValueError("background_name only valid with background partner_mode")
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_custom_theme_consistency(self) -> "BuildRequest":
|
||||||
|
"""Validate custom theme requires both name and tags."""
|
||||||
|
if self.custom_theme_name and not self.custom_theme_tags:
|
||||||
|
raise ValueError("Custom theme requires both name and tags")
|
||||||
|
|
||||||
|
if self.custom_theme_tags and not self.custom_theme_name:
|
||||||
|
raise ValueError("Custom theme tags require theme name")
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class CommanderSearchRequest(BaseModel):
|
||||||
|
"""Commander search/validation request."""
|
||||||
|
|
||||||
|
query: str = Field(..., min_length=1, max_length=200, description="Search query")
|
||||||
|
limit: int = Field(default=10, ge=1, le=100, description="Maximum results")
|
||||||
|
|
||||||
|
@field_validator("query")
|
||||||
|
@classmethod
|
||||||
|
def validate_query_not_empty(cls, v: str) -> str:
|
||||||
|
"""Ensure query is not just whitespace."""
|
||||||
|
if not v or not v.strip():
|
||||||
|
raise ValueError("Search query cannot be empty")
|
||||||
|
return v.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class ThemeValidationRequest(BaseModel):
|
||||||
|
"""Theme validation request."""
|
||||||
|
|
||||||
|
themes: List[str] = Field(..., min_length=1, max_length=10, description="Themes to validate")
|
||||||
|
|
||||||
|
@field_validator("themes")
|
||||||
|
@classmethod
|
||||||
|
def validate_themes_not_empty(cls, v: List[str]) -> List[str]:
|
||||||
|
"""Ensure themes are not empty."""
|
||||||
|
cleaned = [t.strip() for t in v if t and t.strip()]
|
||||||
|
if not cleaned:
|
||||||
|
raise ValueError("At least one valid theme required")
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
class OwnedCardsImportRequest(BaseModel):
|
||||||
|
"""Owned cards import request."""
|
||||||
|
|
||||||
|
format_type: str = Field(..., pattern="^(csv|txt|arena)$", description="File format")
|
||||||
|
content: str = Field(..., min_length=1, description="File content")
|
||||||
|
|
||||||
|
@field_validator("content")
|
||||||
|
@classmethod
|
||||||
|
def validate_content_not_empty(cls, v: str) -> str:
|
||||||
|
"""Ensure content is not empty."""
|
||||||
|
if not v or not v.strip():
|
||||||
|
raise ValueError("File content cannot be empty")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class BatchBuildRequest(BaseModel):
|
||||||
|
"""Batch build request for multiple variations."""
|
||||||
|
|
||||||
|
base_config: BuildRequest = Field(..., description="Base build configuration")
|
||||||
|
count: int = Field(..., ge=1, le=10, description="Number of builds to generate")
|
||||||
|
variation_seed: Optional[int] = Field(default=None, ge=0, description="Seed for variations")
|
||||||
|
|
||||||
|
@field_validator("count")
|
||||||
|
@classmethod
|
||||||
|
def validate_count_reasonable(cls, v: int) -> int:
|
||||||
|
"""Ensure batch count is reasonable."""
|
||||||
|
if v > 10:
|
||||||
|
raise ValueError("Batch count cannot exceed 10")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class CardReplacementRequest(BaseModel):
|
||||||
|
"""Card replacement request for compliance."""
|
||||||
|
|
||||||
|
card_name: str = Field(..., min_length=1, max_length=200, description="Card to replace")
|
||||||
|
reason: Optional[str] = Field(default=None, max_length=500, description="Replacement reason")
|
||||||
|
|
||||||
|
@field_validator("card_name")
|
||||||
|
@classmethod
|
||||||
|
def validate_card_name_not_empty(cls, v: str) -> str:
|
||||||
|
"""Ensure card name is not empty."""
|
||||||
|
if not v or not v.strip():
|
||||||
|
raise ValueError("Card name cannot be empty")
|
||||||
|
return v.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class DeckExportRequest(BaseModel):
|
||||||
|
"""Deck export request."""
|
||||||
|
|
||||||
|
format_type: str = Field(..., pattern="^(csv|txt|json|arena)$", description="Export format")
|
||||||
|
include_commanders: bool = Field(default=True, description="Include commanders in export")
|
||||||
|
include_lands: bool = Field(default=True, description="Include lands in export")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""Pydantic configuration."""
|
||||||
|
use_enum_values = True
|
||||||
223
code/web/validation/validators.py
Normal file
223
code/web/validation/validators.py
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
"""Custom validators for business logic validation.
|
||||||
|
|
||||||
|
Provides validators for themes, commanders, and other domain-specific validation.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import List, Tuple, Optional
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
class ThemeValidator:
|
||||||
|
"""Validates theme tags against theme catalog."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Initialize validator."""
|
||||||
|
self._themes: set[str] = set()
|
||||||
|
self._loaded = False
|
||||||
|
|
||||||
|
def _ensure_loaded(self) -> None:
|
||||||
|
"""Lazy-load theme catalog."""
|
||||||
|
if self._loaded:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ..services import theme_catalog_loader
|
||||||
|
catalog = theme_catalog_loader.get_theme_catalog()
|
||||||
|
|
||||||
|
if not catalog.empty and 'name' in catalog.columns:
|
||||||
|
for theme in catalog['name'].dropna():
|
||||||
|
theme_str = str(theme).strip()
|
||||||
|
if theme_str:
|
||||||
|
self._themes.add(theme_str)
|
||||||
|
# Also add lowercase version for case-insensitive matching
|
||||||
|
self._themes.add(theme_str.lower())
|
||||||
|
|
||||||
|
self._loaded = True
|
||||||
|
except Exception:
|
||||||
|
self._loaded = True
|
||||||
|
|
||||||
|
def is_valid(self, theme: str) -> bool:
|
||||||
|
"""Check if theme exists in catalog.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
theme: Theme tag to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if theme is valid
|
||||||
|
"""
|
||||||
|
self._ensure_loaded()
|
||||||
|
|
||||||
|
if not theme or not theme.strip():
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check exact match and case-insensitive
|
||||||
|
return theme in self._themes or theme.lower() in self._themes
|
||||||
|
|
||||||
|
def validate_themes(self, themes: List[str]) -> Tuple[List[str], List[str]]:
|
||||||
|
"""Validate a list of themes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
themes: List of theme tags
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(valid_themes, invalid_themes) tuple
|
||||||
|
"""
|
||||||
|
self._ensure_loaded()
|
||||||
|
|
||||||
|
valid: List[str] = []
|
||||||
|
invalid: List[str] = []
|
||||||
|
|
||||||
|
for theme in themes:
|
||||||
|
if not theme or not theme.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
if self.is_valid(theme):
|
||||||
|
valid.append(theme)
|
||||||
|
else:
|
||||||
|
invalid.append(theme)
|
||||||
|
|
||||||
|
return valid, invalid
|
||||||
|
|
||||||
|
def get_all_themes(self) -> List[str]:
|
||||||
|
"""Get all available themes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of theme names
|
||||||
|
"""
|
||||||
|
self._ensure_loaded()
|
||||||
|
# Return case-preserved versions
|
||||||
|
return sorted([t for t in self._themes if t and t[0].isupper()])
|
||||||
|
|
||||||
|
|
||||||
|
class PowerBracketValidator:
|
||||||
|
"""Validates power bracket values and card compliance."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_valid_bracket(bracket: int) -> bool:
|
||||||
|
"""Check if bracket value is valid (1-4).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bracket: Power bracket value
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if valid (1-4)
|
||||||
|
"""
|
||||||
|
return isinstance(bracket, int) and 1 <= bracket <= 4
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def validate_card_for_bracket(card_name: str, bracket: int) -> Tuple[bool, Optional[str]]:
|
||||||
|
"""Check if card is allowed in power bracket.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
card_name: Card name to check
|
||||||
|
bracket: Target power bracket (1-4)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(is_allowed, error_message) tuple
|
||||||
|
"""
|
||||||
|
if not PowerBracketValidator.is_valid_bracket(bracket):
|
||||||
|
return False, f"Invalid power bracket: {bracket}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
from deck_builder import builder_utils as bu
|
||||||
|
df = bu._load_all_cards_parquet()
|
||||||
|
|
||||||
|
if df.empty:
|
||||||
|
return True, None # Assume allowed if no data
|
||||||
|
|
||||||
|
card_row = df[df['name'] == card_name]
|
||||||
|
|
||||||
|
if card_row.empty:
|
||||||
|
return False, f"Card '{card_name}' not found"
|
||||||
|
|
||||||
|
# Check bracket column if it exists
|
||||||
|
if 'bracket' in card_row.columns:
|
||||||
|
card_bracket = card_row['bracket'].iloc[0]
|
||||||
|
if pd.notna(card_bracket):
|
||||||
|
card_bracket_int = int(card_bracket)
|
||||||
|
if card_bracket_int > bracket:
|
||||||
|
return False, f"'{card_name}' is bracket {card_bracket_int}, exceeds limit of {bracket}"
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
# Defensive: assume allowed if check fails
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
|
class ColorIdentityValidator:
|
||||||
|
"""Validates color identity constraints."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_colors(color_str: str) -> set[str]:
|
||||||
|
"""Parse color identity string to set.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
color_str: Color string (e.g., "W,U,B" or "Grixis")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Set of color codes (W, U, B, R, G, C)
|
||||||
|
"""
|
||||||
|
if not color_str:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
# Handle comma-separated
|
||||||
|
if ',' in color_str:
|
||||||
|
return {c.strip().upper() for c in color_str.split(',') if c.strip()}
|
||||||
|
|
||||||
|
# Handle concatenated (e.g., "WUB")
|
||||||
|
colors = set()
|
||||||
|
for char in color_str.upper():
|
||||||
|
if char in 'WUBRGC':
|
||||||
|
colors.add(char)
|
||||||
|
|
||||||
|
return colors
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_subset(card_colors: set[str], commander_colors: set[str]) -> bool:
|
||||||
|
"""Check if card colors are subset of commander colors.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
card_colors: Card's color identity
|
||||||
|
commander_colors: Commander's color identity
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if card is valid in commander's colors
|
||||||
|
"""
|
||||||
|
# Colorless cards (C) are valid in any deck
|
||||||
|
if card_colors == {'C'} or not card_colors:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check if card colors are subset of commander colors
|
||||||
|
return card_colors.issubset(commander_colors)
|
||||||
|
|
||||||
|
|
||||||
|
# Global validator instances
|
||||||
|
_theme_validator: Optional[ThemeValidator] = None
|
||||||
|
_bracket_validator: Optional[PowerBracketValidator] = None
|
||||||
|
_color_validator: Optional[ColorIdentityValidator] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_theme_validator() -> ThemeValidator:
|
||||||
|
"""Get global theme validator instance."""
|
||||||
|
global _theme_validator
|
||||||
|
if _theme_validator is None:
|
||||||
|
_theme_validator = ThemeValidator()
|
||||||
|
return _theme_validator
|
||||||
|
|
||||||
|
|
||||||
|
def get_bracket_validator() -> PowerBracketValidator:
|
||||||
|
"""Get global bracket validator instance."""
|
||||||
|
global _bracket_validator
|
||||||
|
if _bracket_validator is None:
|
||||||
|
_bracket_validator = PowerBracketValidator()
|
||||||
|
return _bracket_validator
|
||||||
|
|
||||||
|
|
||||||
|
def get_color_validator() -> ColorIdentityValidator:
|
||||||
|
"""Get global color validator instance."""
|
||||||
|
global _color_validator
|
||||||
|
if _color_validator is None:
|
||||||
|
_color_validator = ColorIdentityValidator()
|
||||||
|
return _color_validator
|
||||||
|
|
@ -24359,7 +24359,7 @@
|
||||||
"generated_from": "merge (analytics + curated YAML + whitelist)",
|
"generated_from": "merge (analytics + curated YAML + whitelist)",
|
||||||
"metadata_info": {
|
"metadata_info": {
|
||||||
"mode": "merge",
|
"mode": "merge",
|
||||||
"generated_at": "2026-02-20T11:11:25",
|
"generated_at": "2026-02-20T17:27:58",
|
||||||
"curated_yaml_files": 740,
|
"curated_yaml_files": 740,
|
||||||
"synergy_cap": 5,
|
"synergy_cap": 5,
|
||||||
"inference": "pmi",
|
"inference": "pmi",
|
||||||
|
|
|
||||||
123
docs/web_backend/error_handling.md
Normal file
123
docs/web_backend/error_handling.md
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
# Error Handling Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The web layer uses a layered error handling strategy:
|
||||||
|
|
||||||
|
1. **Typed domain exceptions** (`code/exceptions.py`) — raised by routes and services to express semantic failures
|
||||||
|
2. **Exception handlers** (`code/web/app.py`) — convert exceptions to appropriate HTTP responses
|
||||||
|
3. **Response utilities** (`code/web/utils/responses.py`) — build consistent JSON or HTML fragment responses
|
||||||
|
|
||||||
|
HTMX requests get an HTML error fragment; regular API requests get JSON.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exception Hierarchy
|
||||||
|
|
||||||
|
All custom exceptions inherit from `DeckBuilderError` (base) in `code/exceptions.py`.
|
||||||
|
|
||||||
|
### Status Code Mapping
|
||||||
|
|
||||||
|
| Exception | HTTP Status | Use When |
|
||||||
|
|---|---|---|
|
||||||
|
| `SessionExpiredError` | 401 | Session cookie is missing or stale |
|
||||||
|
| `BuildNotFoundError` | 404 | Session has no build result |
|
||||||
|
| `FeatureDisabledError` | 404 | Feature is off via env var |
|
||||||
|
| `CommanderValidationError` (and subclasses) | 400 | Invalid commander input |
|
||||||
|
| `ThemeSelectionError` | 400 | Invalid theme selection |
|
||||||
|
| `ThemeError` | 400 | General theme failure |
|
||||||
|
| `PriceLimitError`, `PriceValidationError` | 400 | Bad price constraint |
|
||||||
|
| `PriceAPIError` | 503 | External price API down |
|
||||||
|
| `CSVFileNotFoundError` | 503 | Card data files missing |
|
||||||
|
| `MTGJSONDownloadError` | 503 | Data download failure |
|
||||||
|
| `EmptyDataFrameError` | 503 | No card data available |
|
||||||
|
| `DeckBuilderError` (base, unrecognized) | 500 | Unexpected domain error |
|
||||||
|
|
||||||
|
### Web-Specific Exceptions
|
||||||
|
|
||||||
|
Added in M4, defined at the bottom of `code/exceptions.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
SessionExpiredError(sid="abc") # session missing or expired
|
||||||
|
BuildNotFoundError(sid="abc") # no build result in session
|
||||||
|
FeatureDisabledError("partner_suggestions") # feature toggled off
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Raising Exceptions in Routes
|
||||||
|
|
||||||
|
Prefer typed exceptions over `HTTPException` for domain failures:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Good — semantic, gets proper status code automatically
|
||||||
|
from code.exceptions import CommanderValidationError, FeatureDisabledError
|
||||||
|
|
||||||
|
raise CommanderValidationError("Commander 'Foo' not found")
|
||||||
|
raise FeatureDisabledError("batch_build")
|
||||||
|
|
||||||
|
# Still acceptable for HTTP-level concerns (rate limits, auth)
|
||||||
|
from fastapi import HTTPException
|
||||||
|
raise HTTPException(status_code=429, detail="rate_limited")
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep `HTTPException` for infrastructure concerns (rate limiting, feature flags that are pure routing decisions). Use custom exceptions for domain logic failures.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Response Shape
|
||||||
|
|
||||||
|
### JSON (non-HTMX)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": true,
|
||||||
|
"status": 400,
|
||||||
|
"error_type": "CommanderValidationError",
|
||||||
|
"code": "CMD_VALID",
|
||||||
|
"message": "Commander 'Foo' not found",
|
||||||
|
"path": "/build/step1/confirm",
|
||||||
|
"request_id": "a1b2c3d4",
|
||||||
|
"timestamp": "2026-03-17T12:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### HTML (HTMX requests)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="error-banner" role="alert">
|
||||||
|
<strong>400</strong> Commander 'Foo' not found
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
The `X-Request-ID` header is always set on both response types.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding a New Exception
|
||||||
|
|
||||||
|
1. Add the class to `code/exceptions.py` inheriting from the appropriate parent
|
||||||
|
2. Add an entry to `_EXCEPTION_STATUS_MAP` in `code/web/utils/responses.py` if the status code differs from the parent
|
||||||
|
3. Raise it in your route or service
|
||||||
|
4. The handler in `app.py` will pick it up automatically
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Error Handling
|
||||||
|
|
||||||
|
See `code/tests/test_error_handling.py` for patterns. Key fixtures:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Minimal app with DeckBuilderError handler
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def handler(request, exc):
|
||||||
|
if isinstance(exc, DeckBuilderError):
|
||||||
|
return deck_builder_error_response(request, exc)
|
||||||
|
...
|
||||||
|
|
||||||
|
client = TestClient(app, raise_server_exceptions=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
Always pass `raise_server_exceptions=False` so the handler runs during tests.
|
||||||
448
docs/web_backend/route_patterns.md
Normal file
448
docs/web_backend/route_patterns.md
Normal file
|
|
@ -0,0 +1,448 @@
|
||||||
|
# Route Handler Patterns
|
||||||
|
|
||||||
|
**Status**: ✅ Active Standard (R9 M1)
|
||||||
|
**Last Updated**: 2026-02-20
|
||||||
|
|
||||||
|
This document defines the standard patterns for FastAPI route handlers in the MTG Deckbuilder web application.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Overview](#overview)
|
||||||
|
- [Standard Route Pattern](#standard-route-pattern)
|
||||||
|
- [Decorators](#decorators)
|
||||||
|
- [Request Handling](#request-handling)
|
||||||
|
- [Response Building](#response-building)
|
||||||
|
- [Error Handling](#error-handling)
|
||||||
|
- [Examples](#examples)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
All route handlers should follow these principles:
|
||||||
|
- **Consistency**: Use standard patterns for request/response handling
|
||||||
|
- **Clarity**: Clear separation between validation, business logic, and response building
|
||||||
|
- **Observability**: Proper logging and telemetry
|
||||||
|
- **Error Handling**: Use custom exceptions, not HTTPException directly
|
||||||
|
- **Type Safety**: Full type hints for all parameters and return types
|
||||||
|
|
||||||
|
## Standard Route Pattern
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import APIRouter, Request, Query, Form
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
|
from ..decorators.telemetry import track_route_access, log_route_errors
|
||||||
|
from ..utils.responses import build_template_response, build_error_response
|
||||||
|
from exceptions import ValidationError, NotFoundError # From code/exceptions.py
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/endpoint", response_class=HTMLResponse)
|
||||||
|
@track_route_access("event_name") # Optional: for telemetry
|
||||||
|
@log_route_errors("route_name") # Optional: for error logging
|
||||||
|
async def endpoint_handler(
|
||||||
|
request: Request,
|
||||||
|
param: str = Query(..., description="Parameter description"),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""
|
||||||
|
Brief description of what this endpoint does.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object
|
||||||
|
param: Query parameter description
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HTMLResponse with rendered template
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError: When parameter validation fails
|
||||||
|
NotFoundError: When resource is not found
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 1. Validate inputs
|
||||||
|
if not param:
|
||||||
|
raise ValidationError("parameter_required", details={"param": "required"})
|
||||||
|
|
||||||
|
# 2. Call service layer (business logic)
|
||||||
|
from ..services.your_service import process_request
|
||||||
|
result = await process_request(param)
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
raise NotFoundError("resource_not_found", details={"param": param})
|
||||||
|
|
||||||
|
# 3. Build and return response
|
||||||
|
from ..app import templates
|
||||||
|
context = {
|
||||||
|
"result": result,
|
||||||
|
"param": param,
|
||||||
|
}
|
||||||
|
return build_template_response(
|
||||||
|
request, templates, "path/template.html", context
|
||||||
|
)
|
||||||
|
|
||||||
|
except (ValidationError, NotFoundError):
|
||||||
|
# Let custom exception handlers in app.py handle these
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
# Log unexpected errors and re-raise
|
||||||
|
LOGGER.error(f"Unexpected error in endpoint_handler: {e}", exc_info=True)
|
||||||
|
raise
|
||||||
|
```
|
||||||
|
|
||||||
|
## Decorators
|
||||||
|
|
||||||
|
### Telemetry Decorators
|
||||||
|
|
||||||
|
Located in [code/web/decorators/telemetry.py](../../code/web/decorators/telemetry.py):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from ..decorators.telemetry import (
|
||||||
|
track_route_access, # Track route access
|
||||||
|
track_build_time, # Track operation timing
|
||||||
|
log_route_errors, # Enhanced error logging
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/build/step1")
|
||||||
|
@track_route_access("build_step1_access")
|
||||||
|
@log_route_errors("build_step1")
|
||||||
|
async def step1_handler(request: Request):
|
||||||
|
# Route implementation
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use:**
|
||||||
|
- `@track_route_access`: For all user-facing routes (telemetry)
|
||||||
|
- `@track_build_time`: For deck building operations (performance monitoring)
|
||||||
|
- `@log_route_errors`: For routes with complex error handling
|
||||||
|
|
||||||
|
### Decorator Ordering
|
||||||
|
|
||||||
|
Order matters! Apply decorators from bottom to top:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@router.get("/endpoint") # 1. Router decorator (bottom)
|
||||||
|
@track_route_access("event") # 2. Telemetry (before error handler)
|
||||||
|
@log_route_errors("route") # 3. Error logging (top)
|
||||||
|
async def handler(...):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Request Handling
|
||||||
|
|
||||||
|
### Query Parameters
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import Query
|
||||||
|
|
||||||
|
@router.get("/search")
|
||||||
|
async def search_cards(
|
||||||
|
request: Request,
|
||||||
|
query: str = Query(..., min_length=1, max_length=100, description="Search query"),
|
||||||
|
limit: int = Query(20, ge=1, le=100, description="Results limit"),
|
||||||
|
) -> JSONResponse:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Form Data
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import Form
|
||||||
|
|
||||||
|
@router.post("/build/create")
|
||||||
|
async def create_deck(
|
||||||
|
request: Request,
|
||||||
|
commander: str = Form(..., description="Commander name"),
|
||||||
|
themes: list[str] = Form(default=[], description="Theme tags"),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON Body (Pydantic Models)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
class BuildRequest(BaseModel):
|
||||||
|
"""Build request validation model."""
|
||||||
|
commander: str = Field(..., min_length=1, max_length=200)
|
||||||
|
themes: list[str] = Field(default_factory=list, max_items=5)
|
||||||
|
power_bracket: int = Field(default=2, ge=1, le=4)
|
||||||
|
|
||||||
|
@router.post("/api/build")
|
||||||
|
async def api_build_deck(
|
||||||
|
request: Request,
|
||||||
|
build_req: BuildRequest,
|
||||||
|
) -> JSONResponse:
|
||||||
|
# build_req is automatically validated
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Session Data
|
||||||
|
|
||||||
|
```python
|
||||||
|
from ..services.tasks import get_session, set_session_value
|
||||||
|
|
||||||
|
@router.post("/step2")
|
||||||
|
async def step2_handler(request: Request):
|
||||||
|
sid = request.cookies.get("sid")
|
||||||
|
if not sid:
|
||||||
|
raise ValidationError("session_required")
|
||||||
|
|
||||||
|
session = get_session(sid)
|
||||||
|
commander = session.get("commander")
|
||||||
|
|
||||||
|
# Update session
|
||||||
|
set_session_value(sid, "step", "2")
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Response Building
|
||||||
|
|
||||||
|
### Template Responses
|
||||||
|
|
||||||
|
Use `build_template_response` from [code/web/utils/responses.py](../../code/web/utils/responses.py):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from ..utils.responses import build_template_response
|
||||||
|
from ..app import templates
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"title": "Page Title",
|
||||||
|
"data": result_data,
|
||||||
|
}
|
||||||
|
return build_template_response(
|
||||||
|
request, templates, "path/template.html", context
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON Responses
|
||||||
|
|
||||||
|
```python
|
||||||
|
from ..utils.responses import build_success_response
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"commander": "Atraxa, Praetors' Voice",
|
||||||
|
"themes": ["Proliferate", "Superfriends"],
|
||||||
|
}
|
||||||
|
return build_success_response(data, status_code=200)
|
||||||
|
```
|
||||||
|
|
||||||
|
### HTMX Partial Responses
|
||||||
|
|
||||||
|
```python
|
||||||
|
from ..utils.responses import build_htmx_response
|
||||||
|
|
||||||
|
html_content = templates.get_template("partials/result.html").render(context)
|
||||||
|
return build_htmx_response(
|
||||||
|
content=html_content,
|
||||||
|
trigger={"deckUpdated": {"commander": "Atraxa"}},
|
||||||
|
retarget="#result-container",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Responses
|
||||||
|
|
||||||
|
```python
|
||||||
|
from ..utils.responses import build_error_response
|
||||||
|
|
||||||
|
# Manual error response (prefer raising custom exceptions instead)
|
||||||
|
return build_error_response(
|
||||||
|
request,
|
||||||
|
status_code=400,
|
||||||
|
error_type="ValidationError",
|
||||||
|
message="Invalid commander name",
|
||||||
|
detail="Commander 'Foo' does not exist",
|
||||||
|
fields={"commander": ["Commander 'Foo' does not exist"]}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Use Custom Exceptions
|
||||||
|
|
||||||
|
**Always use custom exceptions** from [code/exceptions.py](../../code/exceptions.py), not `HTTPException`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from exceptions import (
|
||||||
|
ValidationError,
|
||||||
|
NotFoundError,
|
||||||
|
CommanderValidationError,
|
||||||
|
ThemeError,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ❌ DON'T DO THIS
|
||||||
|
from fastapi import HTTPException
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid input")
|
||||||
|
|
||||||
|
# ✅ DO THIS INSTEAD
|
||||||
|
raise ValidationError("Invalid input", code="VALIDATION_ERR", details={"field": "value"})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exception Hierarchy
|
||||||
|
|
||||||
|
See [code/exceptions.py](../../code/exceptions.py) for the full hierarchy. Common exceptions:
|
||||||
|
|
||||||
|
- `DeckBuilderError` - Base class for all custom exceptions
|
||||||
|
- `MTGSetupError` - Setup-related errors
|
||||||
|
- `CSVError` - Data loading errors
|
||||||
|
- `CommanderValidationError` - Commander validation failures
|
||||||
|
- `CommanderTypeError`, `CommanderColorError`, etc.
|
||||||
|
- `ThemeError` - Theme-related errors
|
||||||
|
- `PriceError` - Price checking errors
|
||||||
|
- `LibraryOrganizationError` - Deck organization errors
|
||||||
|
|
||||||
|
### Let Exception Handlers Handle It
|
||||||
|
|
||||||
|
The app.py exception handlers will convert custom exceptions to HTTP responses:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@router.get("/commander/{name}")
|
||||||
|
async def get_commander(request: Request, name: str):
|
||||||
|
# Validate
|
||||||
|
if not name:
|
||||||
|
raise ValidationError("Commander name required", code="CMD_NAME_REQUIRED")
|
||||||
|
|
||||||
|
# Business logic
|
||||||
|
try:
|
||||||
|
commander = await load_commander(name)
|
||||||
|
except CommanderNotFoundError as e:
|
||||||
|
# Re-raise to let global handler convert to 404
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Return success
|
||||||
|
return build_success_response({"commander": commander})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Example 1: Simple GET with Template Response
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from ..utils.responses import build_template_response
|
||||||
|
from ..decorators.telemetry import track_route_access
|
||||||
|
from ..app import templates
|
||||||
|
|
||||||
|
@router.get("/commanders", response_class=HTMLResponse)
|
||||||
|
@track_route_access("commanders_list_view")
|
||||||
|
async def list_commanders(request: Request) -> HTMLResponse:
|
||||||
|
"""Display the commanders catalog page."""
|
||||||
|
from ..services.commander_catalog_loader import load_commander_catalog
|
||||||
|
|
||||||
|
catalog = load_commander_catalog()
|
||||||
|
context = {"commanders": catalog.commanders}
|
||||||
|
|
||||||
|
return build_template_response(
|
||||||
|
request, templates, "commanders/list.html", context
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 2: POST with Form Data and Session
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import Request, Form
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from ..utils.responses import build_template_response, build_htmx_response
|
||||||
|
from ..services.tasks import get_session, set_session_value
|
||||||
|
from exceptions import CommanderValidationError
|
||||||
|
|
||||||
|
@router.post("/build/select_commander", response_class=HTMLResponse)
|
||||||
|
async def select_commander(
|
||||||
|
request: Request,
|
||||||
|
commander: str = Form(..., description="Selected commander name"),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Handle commander selection in deck builder wizard."""
|
||||||
|
# Validate commander
|
||||||
|
if not commander or len(commander) > 200:
|
||||||
|
raise CommanderValidationError(
|
||||||
|
f"Invalid commander name: {commander}",
|
||||||
|
code="CMD_INVALID",
|
||||||
|
details={"name": commander}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store in session
|
||||||
|
sid = request.cookies.get("sid")
|
||||||
|
if sid:
|
||||||
|
set_session_value(sid, "commander", commander)
|
||||||
|
|
||||||
|
# Return HTMX partial
|
||||||
|
from ..app import templates
|
||||||
|
context = {"commander": commander, "step": "themes"}
|
||||||
|
html = templates.get_template("build/step2_themes.html").render(context)
|
||||||
|
|
||||||
|
return build_htmx_response(
|
||||||
|
content=html,
|
||||||
|
trigger={"commanderSelected": {"name": commander}},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 3: API Endpoint with JSON Response
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from ..utils.responses import build_success_response
|
||||||
|
from exceptions import ThemeError
|
||||||
|
|
||||||
|
class ThemeSearchRequest(BaseModel):
|
||||||
|
"""Theme search request model."""
|
||||||
|
query: str = Field(..., min_length=1, max_length=100)
|
||||||
|
limit: int = Field(default=10, ge=1, le=50)
|
||||||
|
|
||||||
|
@router.post("/api/themes/search")
|
||||||
|
async def search_themes(
|
||||||
|
request: Request,
|
||||||
|
search: ThemeSearchRequest,
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""API endpoint to search for themes."""
|
||||||
|
from ..services.theme_catalog_loader import search_themes as _search
|
||||||
|
|
||||||
|
results = _search(search.query, limit=search.limit)
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
raise ThemeError(
|
||||||
|
f"No themes found matching '{search.query}'",
|
||||||
|
code="THEME_NOT_FOUND",
|
||||||
|
details={"query": search.query}
|
||||||
|
)
|
||||||
|
|
||||||
|
return build_success_response({
|
||||||
|
"query": search.query,
|
||||||
|
"count": len(results),
|
||||||
|
"themes": [{"id": t.id, "name": t.name} for t in results],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Guide
|
||||||
|
|
||||||
|
### For Existing Routes
|
||||||
|
|
||||||
|
When updating existing routes to follow this pattern:
|
||||||
|
|
||||||
|
1. **Add type hints** if missing
|
||||||
|
2. **Replace HTTPException** with custom exceptions
|
||||||
|
3. **Use response builders** instead of direct Response construction
|
||||||
|
4. **Add telemetry decorators** where appropriate
|
||||||
|
5. **Add docstrings** following the standard format
|
||||||
|
6. **Separate concerns**: validation → business logic → response
|
||||||
|
|
||||||
|
### Checklist
|
||||||
|
|
||||||
|
- [ ] Route has full type hints
|
||||||
|
- [ ] Uses custom exceptions (not HTTPException)
|
||||||
|
- [ ] Uses response builder utilities
|
||||||
|
- [ ] Has telemetry decorators (if applicable)
|
||||||
|
- [ ] Has complete docstring
|
||||||
|
- [ ] Separates validation, logic, and response
|
||||||
|
- [ ] Handles errors gracefully
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Related Documentation:**
|
||||||
|
- [Service Layer Architecture](./service_architecture.md) (M2)
|
||||||
|
- [Validation Framework](./validation.md) (M3)
|
||||||
|
- [Error Handling Guide](./error_handling.md) (M4)
|
||||||
|
- [Testing Standards](./testing.md) (M5)
|
||||||
|
|
||||||
|
**Last Updated**: 2026-02-20
|
||||||
|
**Roadmap**: R9 M1 - Route Handler Standardization
|
||||||
280
docs/web_backend/testing.md
Normal file
280
docs/web_backend/testing.md
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
# Web Backend Testing Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The test suite lives in `code/tests/`. All tests use **pytest** and run against the real FastAPI app via `TestClient`. This guide covers patterns, conventions, and how to write new tests correctly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# All tests
|
||||||
|
.venv/Scripts/python.exe -m pytest -q
|
||||||
|
|
||||||
|
# Specific files (always use explicit paths — no wildcards)
|
||||||
|
.venv/Scripts/python.exe -m pytest code/tests/test_commanders_route.py code/tests/test_validation.py -q
|
||||||
|
|
||||||
|
# Fast subset (locks + summary utils)
|
||||||
|
# Use the VS Code task: pytest-fast-locks
|
||||||
|
```
|
||||||
|
|
||||||
|
**Always use the full venv Python path** — never `python` or `pytest` directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test File Naming
|
||||||
|
|
||||||
|
Name test files by the **functionality they test**, not by milestone or ticket:
|
||||||
|
|
||||||
|
| Good | Bad |
|
||||||
|
|---|---|
|
||||||
|
| `test_commander_search.py` | `test_m3_validation.py` |
|
||||||
|
| `test_error_handling.py` | `test_phase2_routes.py` |
|
||||||
|
| `test_include_exclude_validation.py` | `test_milestone_4_fixes.py` |
|
||||||
|
|
||||||
|
One file per logical area. Merge overlapping coverage rather than creating many small files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Structure
|
||||||
|
|
||||||
|
### Class-based grouping (preferred)
|
||||||
|
|
||||||
|
Group related tests into classes. Use descriptive method names that read as sentences:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class TestCommanderSearch:
|
||||||
|
def test_empty_query_returns_no_candidates(self, client):
|
||||||
|
...
|
||||||
|
|
||||||
|
def test_exact_match_returns_top_result(self, client):
|
||||||
|
...
|
||||||
|
|
||||||
|
def test_fuzzy_match_works_for_misspellings(self, client):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Standalone functions (acceptable for simple cases)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_health_endpoint_returns_200(client):
|
||||||
|
resp = client.get("/health")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Route Tests
|
||||||
|
|
||||||
|
Route tests use `TestClient` against the real `app`. Use `monkeypatch` to isolate external dependencies (CSV reads, session state, orchestrator calls).
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from code.web.app import app
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(monkeypatch):
|
||||||
|
# Patch heavy dependencies before creating client
|
||||||
|
monkeypatch.setattr("code.web.services.orchestrator.commander_candidates", lambda q, limit=10: [])
|
||||||
|
with TestClient(app) as c:
|
||||||
|
yield c
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key rules:**
|
||||||
|
- Always use `with TestClient(app) as c:` (context manager) so lifespan events run
|
||||||
|
- Pass `raise_server_exceptions=False` when testing error handlers:
|
||||||
|
```python
|
||||||
|
with TestClient(app, raise_server_exceptions=False) as c:
|
||||||
|
yield c
|
||||||
|
```
|
||||||
|
- Set session cookies when routes read session state:
|
||||||
|
```python
|
||||||
|
resp = client.get("/build/step2", cookies={"sid": "test-sid"})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: route with session
|
||||||
|
|
||||||
|
```python
|
||||||
|
from code.web.services.tasks import get_session
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client_with_session(monkeypatch):
|
||||||
|
sid = "test-session-id"
|
||||||
|
session = get_session(sid)
|
||||||
|
session["commander"] = {"name": "Atraxa, Praetors' Voice", "ok": True}
|
||||||
|
|
||||||
|
with TestClient(app) as c:
|
||||||
|
c.cookies.set("sid", sid)
|
||||||
|
yield c
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: HTMX request
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_step2_htmx_partial(client_with_session):
|
||||||
|
resp = client_with_session.get(
|
||||||
|
"/build/step2",
|
||||||
|
headers={"HX-Request": "true"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# HTMX partials are HTML fragments, not full pages
|
||||||
|
assert "<html>" not in resp.text
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: error handler response shape
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_invalid_commander_returns_400(client):
|
||||||
|
resp = client.post("/build/step1/confirm", data={"name": ""})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
# Check standardized error shape from M4
|
||||||
|
data = resp.json()
|
||||||
|
assert data["error"] is True
|
||||||
|
assert "request_id" in data
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Service / Unit Tests
|
||||||
|
|
||||||
|
Service tests don't need `TestClient`. Test classes directly with mocked dependencies.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from code.web.services.base import BaseService, ValidationError
|
||||||
|
|
||||||
|
class TestMyService:
|
||||||
|
def test_validate_raises_on_false(self):
|
||||||
|
svc = BaseService()
|
||||||
|
with pytest.raises(ValidationError, match="must not be empty"):
|
||||||
|
svc._validate(False, "must not be empty")
|
||||||
|
```
|
||||||
|
|
||||||
|
For services with external I/O (CSV reads, API calls), use `monkeypatch` or `unittest.mock.patch`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
def test_catalog_loader_caches_result(monkeypatch):
|
||||||
|
mock_data = [{"name": "Test Commander"}]
|
||||||
|
with patch("code.web.services.commander_catalog_loader._load_from_disk", return_value=mock_data):
|
||||||
|
result = load_commander_catalog()
|
||||||
|
assert len(result.entries) == 1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Tests
|
||||||
|
|
||||||
|
Pydantic model tests are pure unit tests — no fixtures needed:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from code.web.validation.models import BuildRequest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
class TestBuildRequest:
|
||||||
|
def test_commander_required(self):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
BuildRequest(commander="")
|
||||||
|
|
||||||
|
def test_themes_defaults_to_empty_list(self):
|
||||||
|
req = BuildRequest(commander="Atraxa, Praetors' Voice")
|
||||||
|
assert req.themes == []
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exception / Error Handling Tests
|
||||||
|
|
||||||
|
Use a minimal inline app to test exception handlers in isolation — avoids loading the full app stack:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from code.exceptions import DeckBuilderError, CommanderValidationError
|
||||||
|
from code.web.utils.responses import deck_builder_error_response
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def error_test_client():
|
||||||
|
mini_app = FastAPI()
|
||||||
|
|
||||||
|
@mini_app.get("/raise")
|
||||||
|
async def raise_error(request: Request):
|
||||||
|
raise CommanderValidationError("test error")
|
||||||
|
|
||||||
|
@mini_app.exception_handler(Exception)
|
||||||
|
async def handler(request, exc):
|
||||||
|
if isinstance(exc, DeckBuilderError):
|
||||||
|
return deck_builder_error_response(request, exc)
|
||||||
|
raise exc
|
||||||
|
|
||||||
|
with TestClient(mini_app, raise_server_exceptions=False) as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
def test_commander_error_returns_400(error_test_client):
|
||||||
|
resp = error_test_client.get("/raise")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert resp.json()["error_type"] == "CommanderValidationError"
|
||||||
|
```
|
||||||
|
|
||||||
|
See `code/tests/test_error_handling.py` for complete examples.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment & Fixtures
|
||||||
|
|
||||||
|
### `conftest.py` globals
|
||||||
|
|
||||||
|
`code/tests/conftest.py` provides:
|
||||||
|
- `ensure_test_environment` (autouse) — sets `ALLOW_MUST_HAVES=1` and restores env after each test
|
||||||
|
|
||||||
|
### Test data
|
||||||
|
|
||||||
|
CSV test data lives in `csv_files/testdata/`. Point tests there with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
monkeypatch.setenv("CSV_FILES_DIR", str(Path("csv_files/testdata").resolve()))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clearing caches between tests
|
||||||
|
|
||||||
|
Some services use module-level caches. Clear them in fixtures to avoid cross-test pollution:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from code.web.services.commander_catalog_loader import clear_commander_catalog_cache
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_catalog():
|
||||||
|
clear_commander_catalog_cache()
|
||||||
|
yield
|
||||||
|
clear_commander_catalog_cache()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Coverage Targets
|
||||||
|
|
||||||
|
| Layer | Target | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Validation models | 95%+ | Pure Pydantic, easy to cover |
|
||||||
|
| Service layer | 80%+ | Mock external I/O |
|
||||||
|
| Route handlers | 70%+ | Cover happy path + key error paths |
|
||||||
|
| Exception handlers | 90%+ | Covered by `test_error_handling.py` |
|
||||||
|
| Utilities | 90%+ | `responses.py`, `telemetry.py` |
|
||||||
|
|
||||||
|
Run coverage:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.venv/Scripts/python.exe -m pytest --cov=code/web --cov-report=term-missing -q
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Not to Test
|
||||||
|
|
||||||
|
- Framework internals (FastAPI routing, Starlette middleware behavior)
|
||||||
|
- Trivial getters/setters with no logic
|
||||||
|
- Template rendering correctness (covered by template validation tests)
|
||||||
|
- Third-party library behavior (Pydantic, SQLAlchemy, etc.)
|
||||||
|
|
||||||
|
Focus tests on **your logic**: validation rules, session state transitions, error mapping, orchestrator integration.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue