mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2026-03-19 11:46:30 +01:00
refactor: backend standardization (service layer, validation, route splitting) + image cache and Scryfall API fixes
This commit is contained in:
parent
e81b47bccf
commit
f784741416
35 changed files with 7054 additions and 4344 deletions
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue