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