refactor: backend standardization (service layer, validation, route splitting) + image cache and Scryfall API fixes

This commit is contained in:
matt 2026-03-17 16:34:50 -07:00
parent e81b47bccf
commit f784741416
35 changed files with 7054 additions and 4344 deletions

View 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",
]

View 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)

View 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

View 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

View 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