Add per-task-type submission configuration with client-side movie spec checks
Coordinators can configure, per task type, accepted file extensions, a
naming convention, and (for video files) required resolution/format/
codec/frame rate. All checking happens entirely client-side in the
browser before upload - the backend only stores and validates the
configuration itself, never inspects submitted files.
- backend: submission_config_by_task_type JSON column on Project,
schemas/submission_config.py for validation, GET/PUT
/projects/{id}/submission-config endpoints. Added .mxf/.ma/.usd(a/c)
to the supported file formats.
- frontend: new Submissions tab in Project Settings
(SubmissionConfigManager.vue) to configure rules per task type.
TaskSubmissions.vue enforces them before upload: extension/naming
checks via regex, and real video resolution/codec/frame rate
detection via mediainfo.js (parses container metadata directly,
so it works for formats browsers can't play natively like MXF,
ProRes, or DNxHD).
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Migration script to add the submission_config_by_task_type column to the projects table.
|
||||
|
||||
Usage:
|
||||
python migrate_project_submission_config.py
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_database_path():
|
||||
"""Get the database path, trying multiple possible locations."""
|
||||
possible_paths = [
|
||||
"vfx_project_management.db", # Primary database
|
||||
"database.db",
|
||||
"../vfx_project_management.db"
|
||||
]
|
||||
|
||||
for path in possible_paths:
|
||||
if Path(path).exists():
|
||||
return path
|
||||
|
||||
return "vfx_project_management.db"
|
||||
|
||||
|
||||
def check_column_exists(cursor, table_name, column_name):
|
||||
"""Check if a column exists in a table."""
|
||||
cursor.execute(f"PRAGMA table_info({table_name})")
|
||||
columns = [column[1] for column in cursor.fetchall()]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def migrate_database():
|
||||
"""Add submission_config_by_task_type column to the projects table."""
|
||||
db_path = get_database_path()
|
||||
print(f"Using database: {db_path}")
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='projects'")
|
||||
if not cursor.fetchone():
|
||||
print("Projects table not found. Creating new database schema...")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
if check_column_exists(cursor, "projects", "submission_config_by_task_type"):
|
||||
print("Column submission_config_by_task_type already exists, skipping...")
|
||||
else:
|
||||
print("Adding column: submission_config_by_task_type")
|
||||
cursor.execute("ALTER TABLE projects ADD COLUMN submission_config_by_task_type JSON")
|
||||
|
||||
conn.commit()
|
||||
|
||||
cursor.execute("SELECT COUNT(*) FROM projects")
|
||||
project_count = cursor.fetchone()[0]
|
||||
print(f"Migration completed successfully! {project_count} projects unaffected (column defaults to NULL).")
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"Database error: {e}")
|
||||
if conn:
|
||||
conn.rollback()
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"Unexpected error: {e}")
|
||||
if conn:
|
||||
conn.rollback()
|
||||
sys.exit(1)
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("VFX Project Management - Submission Configuration Migration")
|
||||
print("=" * 60)
|
||||
|
||||
migrate_database()
|
||||
|
||||
print("\nMigration completed successfully!")
|
||||
@@ -53,7 +53,10 @@ class Project(Base):
|
||||
|
||||
# Custom task statuses
|
||||
custom_task_statuses = Column(JSON, nullable=True) # Custom task statuses for project
|
||||
|
||||
|
||||
# Submission configuration per task type
|
||||
submission_config_by_task_type = Column(JSON, nullable=True) # Allowed file types, naming pattern, required flag per task type
|
||||
|
||||
# Project thumbnail
|
||||
thumbnail_path = Column(String, nullable=True) # Path to project thumbnail image
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from schemas.project import (
|
||||
ProjectTechnicalSpecs, DeliveryMovieSpec, DEFAULT_DELIVERY_MOVIE_SPECS,
|
||||
ProjectSettings, ProjectSettingsUpdate, DEFAULT_ASSET_TASKS, DEFAULT_SHOT_TASKS
|
||||
)
|
||||
from schemas.submission_config import ProjectSubmissionConfig, SubmissionTypeConfig
|
||||
from utils.auth import get_current_user, require_role, get_current_user_from_token
|
||||
|
||||
router = APIRouter()
|
||||
@@ -1077,6 +1078,87 @@ async def delete_custom_task_type(
|
||||
return _build_all_task_types_response(db_project)
|
||||
|
||||
|
||||
# Submission Configuration Endpoints
|
||||
|
||||
@router.get("/{project_id}/submission-config", response_model=ProjectSubmissionConfig)
|
||||
async def get_project_submission_config(
|
||||
project_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_with_db)
|
||||
):
|
||||
"""Get the project's per-task-type submission configuration"""
|
||||
project = db.query(Project).filter(Project.id == project_id).first()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Project not found"
|
||||
)
|
||||
|
||||
if current_user.role == UserRole.ARTIST:
|
||||
member = db.query(ProjectMember).filter(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id
|
||||
).first()
|
||||
if not member:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to this project"
|
||||
)
|
||||
|
||||
config_data = project.submission_config_by_task_type
|
||||
if isinstance(config_data, str):
|
||||
try:
|
||||
config_data = json.loads(config_data)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
config_data = {}
|
||||
|
||||
return ProjectSubmissionConfig(config_by_task_type=config_data or {})
|
||||
|
||||
|
||||
@router.put("/{project_id}/submission-config", response_model=ProjectSubmissionConfig)
|
||||
async def update_project_submission_config(
|
||||
project_id: int,
|
||||
submission_config: ProjectSubmissionConfig,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
):
|
||||
"""Replace the project's per-task-type submission configuration"""
|
||||
db_project = db.query(Project).filter(Project.id == project_id).first()
|
||||
|
||||
if not db_project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Project not found"
|
||||
)
|
||||
|
||||
all_types_response = _build_all_task_types_response(db_project)
|
||||
valid_task_types = set(all_types_response.asset_task_types) | set(all_types_response.shot_task_types)
|
||||
for task_type in submission_config.config_by_task_type.keys():
|
||||
if task_type not in valid_task_types:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"'{task_type}' is not a valid task type for this project"
|
||||
)
|
||||
|
||||
db_project.submission_config_by_task_type = {
|
||||
task_type: config.dict()
|
||||
for task_type, config in submission_config.config_by_task_type.items()
|
||||
}
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(db_project)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update submission configuration"
|
||||
)
|
||||
|
||||
return ProjectSubmissionConfig(config_by_task_type=db_project.submission_config_by_task_type or {})
|
||||
|
||||
|
||||
# Project Thumbnail Management Endpoints
|
||||
|
||||
@router.post("/{project_id}/thumbnail", status_code=status.HTTP_201_CREATED)
|
||||
|
||||
@@ -1567,7 +1567,7 @@ async def submit_work(
|
||||
|
||||
# Validate file using file handler
|
||||
file_handler.validate_file(file, file_handler.MAX_SUBMISSION_SIZE, db)
|
||||
|
||||
|
||||
# Get next version number
|
||||
latest_submission = db.query(Submission).filter(
|
||||
Submission.task_id == task_id,
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
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 naming_pattern: {name} (shot/asset name), {task_type}, {version}
|
||||
NAMING_PATTERN_TOKEN_RE = re.compile(r'\{(name|task_type|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: Optional[str] = Field(None, description="Filename pattern using {name}, {task_type}, {version} tokens")
|
||||
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):
|
||||
if v is None or v == '':
|
||||
return None
|
||||
# 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}, {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)
|
||||
@@ -21,21 +21,23 @@ class FileHandler:
|
||||
# Supported VFX media formats
|
||||
SUPPORTED_FORMATS = {
|
||||
# Video formats
|
||||
'.mov', '.mp4', '.avi', '.mkv', '.webm',
|
||||
'.mov', '.mp4', '.avi', '.mkv', '.webm', '.mxf',
|
||||
# Image formats
|
||||
'.exr', '.jpg', '.jpeg', '.png', '.tiff', '.tif', '.dpx', '.hdr',
|
||||
# Document formats
|
||||
'.pdf', '.txt', '.doc', '.docx',
|
||||
# Archive formats
|
||||
'.zip', '.rar', '.7z'
|
||||
'.zip', '.rar', '.7z',
|
||||
# Scene formats
|
||||
'.ma', '.usd', '.usda', '.usdc'
|
||||
}
|
||||
|
||||
|
||||
# File size limits (in bytes)
|
||||
MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 # 10MB for attachments
|
||||
MAX_SUBMISSION_SIZE = 500 * 1024 * 1024 # 500MB for submissions (fallback)
|
||||
|
||||
|
||||
# Movie file extensions that should use global upload limit
|
||||
MOVIE_EXTENSIONS = {'.mov', '.mp4', '.avi', '.mkv', '.webm'}
|
||||
MOVIE_EXTENSIONS = {'.mov', '.mp4', '.avi', '.mkv', '.webm', '.mxf'}
|
||||
|
||||
# Thumbnail settings
|
||||
THUMBNAIL_SIZE = (200, 200)
|
||||
|
||||
Reference in New Issue
Block a user