""" Pydantic schemas for per-task-type submission configuration """ from pydantic import BaseModel, Field, validator from typing import Optional, Dict, List import re from utils.file_handler import file_handler # Tokens supported in a token-mode naming_pattern NAMING_PATTERN_TOKEN_RE = re.compile(r'\{(name|task_type|task_name|project_name|project_code|version)\}') NAMING_PATTERN_ALLOWED_CHARS_RE = re.compile(r'^[A-Za-z0-9_\-\.]*$') MOVIE_FORMATS = {ext.lstrip('.') for ext in file_handler.MOVIE_EXTENSIONS} MOVIE_CODECS = {'h264', 'h265', 'mjpeg', 'dnxhd', 'dnxhr', 'prores', 'uncompressed', 'avid', 'cineform'} MOVIE_FRAME_RATES = {23.976, 24, 30, 48, 60} RESOLUTION_RE = re.compile(r'^\d+x\d+$') class SubmissionTypeConfig(BaseModel): """Submission rules for a single task type. All checking against these rules happens client-side (in TaskSubmissions.vue) when an artist picks a file to submit - this schema only validates and stores the configuration itself, it is never used to inspect an uploaded file server-side. """ allowed_extensions: List[str] = Field(default_factory=list, description="Accepted file extensions, e.g. ['.mov', '.exr']") naming_pattern_is_regex: bool = Field(False, description="If true, naming_pattern is a raw regular expression instead of a token pattern") naming_pattern: Optional[str] = Field( None, description="Either a token pattern (using {name}, {task_type}, {task_name}, {project_name}, " "{project_code}, {version}) or, if naming_pattern_is_regex is set, a raw regular expression" ) check_naming: bool = Field(True, description="Whether the naming pattern is actively checked") required: bool = Field(False, description="Whether a submission is expected for this task type (informational only)") check_movie_spec: bool = Field(False, description="Whether to check video submissions against movie_resolution/movie_format/movie_codec/movie_frame_rate") movie_resolution: Optional[str] = Field(None, description="Required video resolution, e.g. '1920x1080'") movie_format: Optional[str] = Field(None, description="Required video format, e.g. 'mov'") movie_codec: Optional[str] = Field(None, description="Required video codec, e.g. 'h264'") movie_frame_rate: Optional[float] = Field(None, description="Required video frame rate, e.g. 23.976") @validator('allowed_extensions') def validate_allowed_extensions(cls, v): normalized = [] for ext in v: ext = ext.lower() if not ext.startswith('.'): ext = f'.{ext}' if ext not in file_handler.SUPPORTED_FORMATS: raise ValueError( f"Extension '{ext}' is not a supported file format. " f"Supported formats: {', '.join(sorted(file_handler.SUPPORTED_FORMATS))}" ) normalized.append(ext) return normalized @validator('naming_pattern') def validate_naming_pattern(cls, v, values): if v is None or v == '': return None if values.get('naming_pattern_is_regex'): try: re.compile(v) except re.error as e: raise ValueError(f'Invalid regular expression: {e}') return v # Token mode: strip out known tokens, then everything left must be safe literal characters stripped = NAMING_PATTERN_TOKEN_RE.sub('', v) if not NAMING_PATTERN_ALLOWED_CHARS_RE.match(stripped): raise ValueError( 'Naming pattern may only contain letters, numbers, "_", "-", "." ' 'and the tokens {name}, {task_type}, {task_name}, {project_name}, {project_code}, {version}' ) return v @validator('movie_resolution') def validate_movie_resolution(cls, v): if v is None or v == '': return None if not RESOLUTION_RE.match(v): raise ValueError('Movie resolution must be in format "WIDTHxHEIGHT" (e.g., "1920x1080")') return v @validator('movie_format') def validate_movie_format(cls, v): if v is None or v == '': return None v = v.lower().lstrip('.') if v not in MOVIE_FORMATS: raise ValueError(f'Movie format must be one of: {", ".join(sorted(MOVIE_FORMATS))}') return v @validator('movie_codec') def validate_movie_codec(cls, v): if v is None or v == '': return None v = v.lower() if v not in MOVIE_CODECS: raise ValueError(f'Movie codec must be one of: {", ".join(sorted(MOVIE_CODECS))}') return v @validator('movie_frame_rate') def validate_movie_frame_rate(cls, v): if v is None: return None if not any(abs(v - rate) < 0.001 for rate in MOVIE_FRAME_RATES): raise ValueError(f'Movie frame rate must be one of: {", ".join(str(r) for r in sorted(MOVIE_FRAME_RATES))}') return v class ProjectSubmissionConfig(BaseModel): """Submission configuration for a project, keyed by task type""" config_by_task_type: Dict[str, SubmissionTypeConfig] = Field(default_factory=dict)