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
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue