"""Pydantic data models for type safety and validation.""" import re from datetime import datetime from typing import Literal from pydantic import BaseModel, Field class VendorRow(BaseModel): """Vendor information from VENDOR table.""" vendor_id: int = Field(alias="VENDOR_ID") name: str = Field(alias="NAME") class Config: populate_by_name = True class ArtistRow(BaseModel): """Artist information from GLOBAL_PARTICIPANT table.""" artist_uuid: str = Field(alias="ARTIST_UUID") artist_name: str = Field(alias="ARTIST_NAME") spotify_id: str | None = Field(None, alias="SPOTIFY_ID") class Config: populate_by_name = True class VendorWithBrandRow(BaseModel): """Vendor with brand information for dropdown.""" vendor_id: int = Field(alias="VENDOR_ID") vendor_name: str = Field(alias="VENDOR_NAME") brand_name: str | None = Field( None, alias="BRAND_NAME" ) # Single brand display name def get_display_text(self) -> str: """Format for dropdown: 'Vendor Name (Brand) - ID: 123'""" brand_part = f" ({self.brand_name})" if self.brand_name else "" return f"{self.vendor_name}{brand_part} - ID: {self.vendor_id}" class Config: populate_by_name = True class RosterRow(BaseModel): """Roster entry from unified roster view (aggregated by unique vendor+artist+subaccount).""" vendor_id: int vendor_name: str | None = None brand_name: str | None = None # Single brand display name artist_uuid: str artist_name: str | None = None subaccount_id: int rep_type: str | None = None # Single value: 'MAIN', 'LOCAL', or NULL main_status: str | None = None country_codes: str | None = None # Comma-separated list of countries is_artist_team: bool | None = None created_at: datetime | None = None class AddArtistForm(BaseModel): """Form data for adding an artist to a roster.""" vendor_id: int artist_uuid: str subaccount_id: int roster_type: Literal["MAIN_REP", "LOCAL_REP", "ARTIST_ROSTER"] country_code: str | None = None status: str = "ACTIVE" is_artist_team: bool = False def validate_fields(self) -> list[str]: """ Validate form fields based on roster type. Returns: List of validation error messages (empty if valid) """ errors = [] if self.roster_type == "LOCAL_REP": if not self.country_code: errors.append("Country code is required for LOCAL_REP roster") elif len(self.country_code) != 2: errors.append("Country code must be exactly 2 characters (ISO Alpha-2)") elif not self.country_code.isupper(): errors.append("Country code must be uppercase (e.g., US, GB, JP)") elif not self.country_code.isalpha(): errors.append("Country code must contain only letters") elif self.roster_type in ["MAIN_REP", "ARTIST_ROSTER"]: if self.country_code: errors.append( f"Country code should not be provided for {self.roster_type} roster" ) return errors class RosterFilters(BaseModel): """Filters for roster view search.""" vendor_id: int | None = None vendor_name: str | None = None artist_uuid: str | None = None artist_name: str | None = None subaccount_id: int | None = None limit: int = 100 offset: int = 0 class BulkValidationError(BaseModel): """Validation error for bulk upload.""" row_number: int spotify_id: str artist_name: str error_type: Literal[ "DUPLICATE_SPOTIFY_ID", "ARTIST_NOT_FOUND", "VALIDATION_ERROR", "ALREADY_EXISTS_DIFFERENT_DATA", ] message: str def format_display(self) -> str: """Format error for display in UI.""" return f"Row {self.row_number}: {self.message}" class BulkArtistAlreadyExists(BaseModel): """Artist that already exists in roster with same data.""" row_number: int spotify_id: str artist_name: str roster_type: str def format_display(self) -> str: """Format for display in UI.""" return f"Row {self.row_number}: {self.artist_name} (already in {self.roster_type})" class BulkUploadResult(BaseModel): """Result of bulk upload operation.""" total_rows: int unique_artists: int duplicates_removed: int success_count: int skipped_count: int # Artists already in roster error_count: int errors: list[BulkValidationError] skipped_artists: list[BulkArtistAlreadyExists] def has_errors(self) -> bool: """Check if there are any errors.""" return self.error_count > 0 class VirtualParticipantForm(BaseModel): """Form data for creating virtual participant with custom list.""" name: str type: Literal["CUSTOM_LIST", "PENDING_ARTIST"] vendor_id: int spotify_id: str | None = None subaccount_id: int = 0 def validate_fields(self) -> list[str]: """ Validate form fields. Returns: List of validation error messages (empty if valid) """ errors = [] if not self.name or not self.name.strip(): errors.append("Name is required") if self.type == "PENDING_ARTIST" and self.spotify_id: if not re.match(r"^[a-zA-Z0-9]{22}$", self.spotify_id): errors.append("Spotify ID must be 22 alphanumeric characters") elif self.type == "CUSTOM_LIST" and self.spotify_id: errors.append("Spotify ID should not be provided for CUSTOM_LIST type") return errors class VirtualParticipantRow(BaseModel): """Virtual participant query result.""" id: str = Field(alias="ID") name: str = Field(alias="NAME") type: str = Field(alias="TYPE") spotify_id: str | None = Field(None, alias="SPOTIFY_ID") created_at: datetime | None = Field(None, alias="CREATED_AT") created_by: str | None = Field(None, alias="CREATED_BY") class Config: populate_by_name = True class CustomListRow(BaseModel): """Custom list query result.""" id: str = Field(alias="ID") name: str = Field(alias="NAME") vendor_id: int = Field(alias="VENDOR_ID") subaccount_id: int = Field(alias="SUBACCOUNT_ID") virtual_participant_id: str = Field(alias="VIRTUAL_PARTICIPANT_ID") class Config: populate_by_name = True class UpgradePendingArtistForm(BaseModel): """Form data for upgrading a pending artist with a Spotify ID.""" virtual_participant_id: str virtual_participant_name: str spotify_id: str def validate_fields(self) -> list[str]: """ Validate form fields. Returns: List of validation error messages (empty if valid) """ errors = [] if not self.virtual_participant_id or not self.virtual_participant_id.strip(): errors.append("Virtual participant ID is required") if not self.spotify_id or not self.spotify_id.strip(): errors.append("Spotify ID is required") elif not re.match(r"^[a-zA-Z0-9]{22}$", self.spotify_id): errors.append("Spotify ID must be 22 alphanumeric characters") return errors