diff --git a/backend/migrate_project_submission_config.py b/backend/migrate_project_submission_config.py new file mode 100644 index 0000000..2fa79b6 --- /dev/null +++ b/backend/migrate_project_submission_config.py @@ -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!") diff --git a/backend/models/project.py b/backend/models/project.py index f2a721d..b302ceb 100644 --- a/backend/models/project.py +++ b/backend/models/project.py @@ -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 diff --git a/backend/routers/projects.py b/backend/routers/projects.py index 66f77dd..e448a25 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -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) diff --git a/backend/routers/tasks.py b/backend/routers/tasks.py index 3718b55..7d33057 100644 --- a/backend/routers/tasks.py +++ b/backend/routers/tasks.py @@ -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, diff --git a/backend/schemas/submission_config.py b/backend/schemas/submission_config.py new file mode 100644 index 0000000..8f83451 --- /dev/null +++ b/backend/schemas/submission_config.py @@ -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) diff --git a/backend/utils/file_handler.py b/backend/utils/file_handler.py index d8d94b9..21b487e 100644 --- a/backend/utils/file_handler.py +++ b/backend/utils/file_handler.py @@ -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) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index aee832a..9d8c0fc 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,6 +16,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-vue-next": "^0.294.0", + "mediainfo.js": "^0.3.7", "pinia": "^2.1.7", "reka-ui": "^2.6.1", "shadcn-vue": "^2.3.2", @@ -2789,6 +2790,60 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -4053,6 +4108,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-east-asian-width": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", @@ -4817,6 +4881,21 @@ "node": ">= 0.8" } }, + "node_modules/mediainfo.js": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/mediainfo.js/-/mediainfo.js-0.3.7.tgz", + "integrity": "sha512-mgsmb2GrCTAguVcTohW7KrF+QXNaihrbljj75pLWDQcTTQyqfGoZG4Dy++AZ+FDVCT14LHvVGC9SXxcF5VePag==", + "license": "BSD-2-Clause", + "dependencies": { + "yargs": "^18.0.0" + }, + "bin": { + "mediainfo.js": "dist/esm/cli.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/merge-descriptors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", @@ -7595,12 +7674,70 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC" }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7658737..3d64fba 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-vue-next": "^0.294.0", + "mediainfo.js": "^0.3.7", "pinia": "^2.1.7", "reka-ui": "^2.6.1", "shadcn-vue": "^2.3.2", diff --git a/frontend/src/components/project/SubmissionConfigManager.vue b/frontend/src/components/project/SubmissionConfigManager.vue new file mode 100644 index 0000000..39de198 --- /dev/null +++ b/frontend/src/components/project/SubmissionConfigManager.vue @@ -0,0 +1,294 @@ + + + diff --git a/frontend/src/components/task/TaskDetailPanel.vue b/frontend/src/components/task/TaskDetailPanel.vue index b791e2e..d93bee3 100644 --- a/frontend/src/components/task/TaskDetailPanel.vue +++ b/frontend/src/components/task/TaskDetailPanel.vue @@ -201,7 +201,14 @@ - + diff --git a/frontend/src/components/task/TaskSubmissions.vue b/frontend/src/components/task/TaskSubmissions.vue index bec5258..8b91a42 100644 --- a/frontend/src/components/task/TaskSubmissions.vue +++ b/frontend/src/components/task/TaskSubmissions.vue @@ -22,6 +22,25 @@
+
+
+ + Allowed: {{ submissionConfig.allowed_extensions.join(', ') }} + + Required +
+
+ Naming: {{ submissionConfig.naming_pattern }} +
+
+ Movie spec: + {{ submissionConfig.movie_resolution }} + .{{ submissionConfig.movie_format }} + ({{ submissionConfig.movie_codec }}) + {{ submissionConfig.movie_frame_rate }}fps +
+
+