diff --git a/backend/migrate_project_custom_departments.py b/backend/migrate_project_custom_departments.py
new file mode 100644
index 0000000..b470930
--- /dev/null
+++ b/backend/migrate_project_custom_departments.py
@@ -0,0 +1,111 @@
+#!/usr/bin/env python3
+"""
+Migration script to add the custom_departments column to the projects table.
+
+Usage:
+ python migrate_project_custom_departments.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 custom_departments 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", "custom_departments"):
+ print("Column custom_departments already exists, skipping...")
+ else:
+ print("Adding column: custom_departments")
+ cursor.execute("ALTER TABLE projects ADD COLUMN custom_departments TEXT")
+ cursor.execute("UPDATE projects SET custom_departments = '[]' WHERE custom_departments IS NULL")
+
+ # project_members.department_role was previously backed by SQLAlchemy's
+ # Enum(DepartmentRole) type, which stores the enum MEMBER NAME (e.g. "LAYOUT"),
+ # not its value ("layout"). The old Optional[DepartmentRole] schema silently
+ # normalized this back to lowercase on read. Now that the column is a plain
+ # string, normalize any existing uppercase values so they match the standard
+ # department strings used everywhere else.
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='project_members'")
+ if cursor.fetchone():
+ department_name_to_value = {
+ 'LAYOUT': 'layout',
+ 'ANIMATION': 'animation',
+ 'LIGHTING': 'lighting',
+ 'COMPOSITE': 'composite',
+ 'MODELING': 'modeling',
+ 'RIGGING': 'rigging',
+ 'SURFACING': 'surfacing',
+ }
+ for old_value, new_value in department_name_to_value.items():
+ cursor.execute(
+ "UPDATE project_members SET department_role = ? WHERE department_role = ?",
+ (new_value, old_value)
+ )
+ if cursor.rowcount > 0:
+ print(f"Normalized {cursor.rowcount} project members from '{old_value}' to '{new_value}'")
+
+ 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 '[]').")
+
+ 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 - Project Custom Departments Migration")
+ print("=" * 60)
+
+ migrate_database()
+
+ print("\nMigration completed successfully!")
diff --git a/backend/migrate_task_department.py b/backend/migrate_task_department.py
new file mode 100644
index 0000000..fd1a74a
--- /dev/null
+++ b/backend/migrate_task_department.py
@@ -0,0 +1,85 @@
+#!/usr/bin/env python3
+"""
+Migration script to add the department column to the tasks table.
+
+Usage:
+ python migrate_task_department.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 department column to the tasks 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='tasks'")
+ if not cursor.fetchone():
+ print("Tasks table not found. Creating new database schema...")
+ conn.close()
+ return
+
+ if check_column_exists(cursor, "tasks", "department"):
+ print("Column department already exists, skipping...")
+ else:
+ print("Adding column: department")
+ cursor.execute("ALTER TABLE tasks ADD COLUMN department VARCHAR")
+
+ conn.commit()
+
+ cursor.execute("SELECT COUNT(*) FROM tasks")
+ task_count = cursor.fetchone()[0]
+ print(f"Migration completed successfully! {task_count} tasks 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 - Task Department Migration")
+ print("=" * 60)
+
+ migrate_database()
+
+ print("\nMigration completed successfully!")
diff --git a/backend/models/__init__.py b/backend/models/__init__.py
index 2881687..992c300 100644
--- a/backend/models/__init__.py
+++ b/backend/models/__init__.py
@@ -1,5 +1,5 @@
# Models package
-from .user import User, UserRole, DepartmentRole
+from .user import User, UserRole
from .project import Project, ProjectMember, ProjectStatus
from .episode import Episode, EpisodeStatus
from .asset import Asset, AssetCategory, AssetStatus
@@ -17,7 +17,7 @@ from .role import Role, Permission, role_permissions, user_roles
__all__ = [
# User models
- "User", "UserRole", "DepartmentRole",
+ "User", "UserRole",
# Project models
"Project", "ProjectMember", "ProjectStatus",
# Episode models
diff --git a/backend/models/project.py b/backend/models/project.py
index b302ceb..9fa3a8b 100644
--- a/backend/models/project.py
+++ b/backend/models/project.py
@@ -2,7 +2,6 @@ from sqlalchemy import Column, Integer, String, DateTime, Date, Enum, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from database import Base
-from .user import DepartmentRole
import enum
@@ -54,6 +53,9 @@ class Project(Base):
# Custom task statuses
custom_task_statuses = Column(JSON, nullable=True) # Custom task statuses for project
+ # Custom departments
+ custom_departments = Column(JSON, nullable=True) # Custom departments for project (in addition to standard ones)
+
# Submission configuration per task type
submission_config_by_task_type = Column(JSON, nullable=True) # Allowed file types, naming pattern, required flag per task type
@@ -80,7 +82,7 @@ class ProjectMember(Base):
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
- department_role = Column(Enum(DepartmentRole), nullable=True)
+ department_role = Column(String, nullable=True) # Free-form: standard department or a project's custom department
joined_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
diff --git a/backend/models/task.py b/backend/models/task.py
index 4903bff..54dbfb2 100644
--- a/backend/models/task.py
+++ b/backend/models/task.py
@@ -56,6 +56,7 @@ class Task(Base):
name = Column(String, nullable=False, index=True)
description = Column(Text)
status = Column(String, nullable=False, default="not_started") # Changed from Enum to String to support custom statuses
+ department = Column(String, nullable=True) # Standard or project-custom department, independent of assignee
start_date = Column(Date)
deadline = Column(Date)
created_at = Column(DateTime(timezone=True), server_default=func.now())
diff --git a/backend/models/user.py b/backend/models/user.py
index 0fd1292..02bd5b5 100644
--- a/backend/models/user.py
+++ b/backend/models/user.py
@@ -12,16 +12,6 @@ class UserRole(str, enum.Enum):
DEVELOPER = "developer"
-class DepartmentRole(str, enum.Enum):
- LAYOUT = "layout"
- ANIMATION = "animation"
- LIGHTING = "lighting"
- COMPOSITE = "composite"
- MODELING = "modeling"
- RIGGING = "rigging"
- SURFACING = "surfacing"
-
-
class User(Base):
__tablename__ = "users"
diff --git a/backend/routers/projects.py b/backend/routers/projects.py
index e448a25..38cc05e 100644
--- a/backend/routers/projects.py
+++ b/backend/routers/projects.py
@@ -1078,6 +1078,264 @@ async def delete_custom_task_type(
return _build_all_task_types_response(db_project)
+# Department Management Endpoints
+
+# Standard departments (read-only)
+STANDARD_DEPARTMENTS = ["layout", "animation", "lighting", "composite", "modeling", "rigging", "surfacing"]
+
+
+def _build_all_departments_response(db_project: Project):
+ """Helper function to build AllDepartmentsResponse"""
+ from schemas.department import AllDepartmentsResponse
+
+ custom_departments = db_project.custom_departments or []
+ all_departments = STANDARD_DEPARTMENTS + custom_departments
+
+ return AllDepartmentsResponse(
+ departments=all_departments,
+ standard_departments=STANDARD_DEPARTMENTS,
+ custom_departments=custom_departments
+ )
+
+
+@router.get("/{project_id}/departments")
+async def get_all_departments(
+ project_id: int,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user_with_db)
+):
+ """Get all departments (standard + custom) for a project"""
+ 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"
+ )
+
+ return _build_all_departments_response(db_project)
+
+
+@router.post("/{project_id}/departments", status_code=status.HTTP_201_CREATED)
+async def add_department(
+ project_id: int,
+ department_data: dict,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(require_coordinator_or_admin)
+):
+ """Add a new custom department to a project"""
+ from schemas.department import CustomDepartmentCreate
+
+ try:
+ department_create = CustomDepartmentCreate(**department_data)
+ except Exception as e:
+ raise HTTPException(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ detail=str(e)
+ )
+
+ 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"
+ )
+
+ custom_departments = db_project.custom_departments or []
+
+ if department_create.department in STANDARD_DEPARTMENTS:
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail=f"Department '{department_create.department}' is a standard department and cannot be added as custom"
+ )
+
+ if department_create.department in custom_departments:
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail=f"Department '{department_create.department}' already exists"
+ )
+
+ custom_departments.append(department_create.department)
+
+ db_project.custom_departments = custom_departments
+ flag_modified(db_project, 'custom_departments')
+
+ try:
+ db.commit()
+ db.refresh(db_project)
+ except Exception as e:
+ db.rollback()
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Failed to add department"
+ )
+
+ return _build_all_departments_response(db_project)
+
+
+@router.put("/{project_id}/departments/{department}")
+async def update_department(
+ project_id: int,
+ department: str,
+ update_data: dict,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(require_coordinator_or_admin)
+):
+ """Update a custom department name, cascading the rename to members and tasks using it"""
+ from schemas.department import CustomDepartmentUpdate
+ from models.task import Task
+
+ try:
+ department_update = CustomDepartmentUpdate(**update_data)
+ except Exception as e:
+ raise HTTPException(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ detail=str(e)
+ )
+
+ if department != department_update.old_name:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Department in URL does not match old_name in request body"
+ )
+
+ 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"
+ )
+
+ custom_departments = db_project.custom_departments or []
+
+ if department_update.old_name not in custom_departments:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"Custom department '{department_update.old_name}' not found"
+ )
+
+ if department_update.new_name in STANDARD_DEPARTMENTS:
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail=f"Department '{department_update.new_name}' is a standard department"
+ )
+
+ if department_update.new_name in custom_departments and department_update.new_name != department_update.old_name:
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail=f"Department '{department_update.new_name}' already exists"
+ )
+
+ custom_departments = [
+ department_update.new_name if d == department_update.old_name else d
+ for d in custom_departments
+ ]
+
+ db_project.custom_departments = custom_departments
+ flag_modified(db_project, 'custom_departments')
+
+ # Cascade rename to project members using this department
+ members_to_update = db.query(ProjectMember).filter(
+ ProjectMember.project_id == project_id,
+ ProjectMember.department_role == department_update.old_name
+ ).all()
+ for member in members_to_update:
+ member.department_role = department_update.new_name
+
+ # Cascade rename to tasks using this department
+ tasks_to_update = db.query(Task).filter(
+ Task.project_id == project_id,
+ Task.department == department_update.old_name
+ ).all()
+ for task in tasks_to_update:
+ task.department = department_update.new_name
+
+ try:
+ db.commit()
+ db.refresh(db_project)
+ except Exception as e:
+ db.rollback()
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Failed to update department"
+ )
+
+ return _build_all_departments_response(db_project)
+
+
+@router.delete("/{project_id}/departments/{department}")
+async def delete_department(
+ project_id: int,
+ department: str,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(require_coordinator_or_admin)
+):
+ """Delete a custom department (blocked if any member or task is currently using it)"""
+ from models.task import Task
+
+ if department in STANDARD_DEPARTMENTS:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Standard departments cannot be deleted"
+ )
+
+ 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"
+ )
+
+ custom_departments = db_project.custom_departments or []
+
+ if department not in custom_departments:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"Custom department '{department}' not found"
+ )
+
+ members_using_department = db.query(ProjectMember).filter(
+ ProjectMember.project_id == project_id,
+ ProjectMember.department_role == department
+ ).all()
+
+ tasks_using_department = db.query(Task).filter(
+ Task.project_id == project_id,
+ Task.department == department
+ ).all()
+
+ if members_using_department or tasks_using_department:
+ raise HTTPException(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ detail={
+ "error": f"Cannot delete department '{department}' because it is currently in use",
+ "department": department,
+ "member_count": len(members_using_department),
+ "task_count": len(tasks_using_department)
+ }
+ )
+
+ custom_departments.remove(department)
+
+ db_project.custom_departments = custom_departments
+ flag_modified(db_project, 'custom_departments')
+
+ try:
+ db.commit()
+ db.refresh(db_project)
+ except Exception as e:
+ db.rollback()
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Failed to delete department"
+ )
+
+ return _build_all_departments_response(db_project)
+
+
# Submission Configuration Endpoints
@router.get("/{project_id}/submission-config", response_model=ProjectSubmissionConfig)
diff --git a/backend/routers/tasks.py b/backend/routers/tasks.py
index 42ee6df..20b1d95 100644
--- a/backend/routers/tasks.py
+++ b/backend/routers/tasks.py
@@ -9,7 +9,7 @@ from datetime import datetime
from database import get_db
from models.task import Task, ProductionNote, TaskAttachment, Submission, Review, NoteType
-from models.user import User, UserRole, DepartmentRole
+from models.user import User, UserRole
from models.project import Project, ProjectMember
from models.asset import Asset
from models.shot import Shot
@@ -248,6 +248,7 @@ async def get_tasks(
"name": task.name,
"task_type": task.task_type,
"status": task.status,
+ "department": task.department,
"start_date": task.start_date,
"deadline": task.deadline,
"project_id": task.project_id,
@@ -330,6 +331,7 @@ async def get_my_tasks(
name=task.name,
task_type=task.task_type,
status=task.status,
+ department=task.department,
start_date=task.start_date,
deadline=task.deadline,
project_id=task.project_id,
@@ -740,6 +742,7 @@ async def get_task(
"description": task.description,
"task_type": task.task_type,
"status": task.status,
+ "department": task.department,
"start_date": task.start_date,
"deadline": task.deadline,
"project_id": task.project_id,
@@ -866,6 +869,7 @@ async def update_task(
"description": task.description,
"task_type": task.task_type,
"status": task.status,
+ "department": task.department,
"start_date": task.start_date,
"deadline": task.deadline,
"project_id": task.project_id,
@@ -958,6 +962,7 @@ async def update_task_status(
"description": task.description,
"task_type": task.task_type,
"status": task.status,
+ "department": task.department,
"start_date": task.start_date,
"deadline": task.deadline,
"project_id": task.project_id,
@@ -1024,13 +1029,13 @@ async def assign_task(
# Check if user's department role matches task type (optional validation)
task_to_department_mapping = {
- "layout": DepartmentRole.LAYOUT,
- "animation": DepartmentRole.ANIMATION,
- "lighting": DepartmentRole.LIGHTING,
- "compositing": DepartmentRole.COMPOSITE,
- "modeling": DepartmentRole.MODELING,
- "rigging": DepartmentRole.RIGGING,
- "surfacing": DepartmentRole.SURFACING,
+ "layout": "layout",
+ "animation": "animation",
+ "lighting": "lighting",
+ "compositing": "composite",
+ "modeling": "modeling",
+ "rigging": "rigging",
+ "surfacing": "surfacing",
"simulation": None # Simulation can be handled by multiple departments
}
@@ -1064,6 +1069,7 @@ async def assign_task(
"description": task.description,
"task_type": task.task_type,
"status": task.status,
+ "department": task.department,
"start_date": task.start_date,
"deadline": task.deadline,
"project_id": task.project_id,
diff --git a/backend/schemas/department.py b/backend/schemas/department.py
new file mode 100644
index 0000000..46fd6d2
--- /dev/null
+++ b/backend/schemas/department.py
@@ -0,0 +1,50 @@
+"""
+Pydantic schemas for department management
+"""
+from pydantic import BaseModel, Field, validator
+from typing import List
+import re
+
+
+class CustomDepartmentCreate(BaseModel):
+ """Schema for creating a new custom department"""
+ department: str = Field(..., min_length=2, max_length=50, description="Department name")
+
+ @validator('department')
+ def validate_department_name(cls, v):
+ """Validate department name format"""
+ if not re.match(r'^[a-z0-9_]{2,50}$', v):
+ raise ValueError(
+ 'Department name must be 2-50 characters, lowercase alphanumeric with underscores only'
+ )
+ return v
+
+
+class CustomDepartmentUpdate(BaseModel):
+ """Schema for updating a custom department name"""
+ old_name: str = Field(..., description="Current department name")
+ new_name: str = Field(..., min_length=2, max_length=50, description="New department name")
+
+ @validator('new_name')
+ def validate_department_name(cls, v):
+ """Validate department name format"""
+ if not re.match(r'^[a-z0-9_]{2,50}$', v):
+ raise ValueError(
+ 'Department name must be 2-50 characters, lowercase alphanumeric with underscores only'
+ )
+ return v
+
+
+class AllDepartmentsResponse(BaseModel):
+ """Schema for response containing all departments (standard + custom)"""
+ departments: List[str] = Field(..., description="All departments")
+ standard_departments: List[str] = Field(..., description="Standard departments (read-only)")
+ custom_departments: List[str] = Field(..., description="Custom departments")
+
+
+class DepartmentInUseError(BaseModel):
+ """Schema for error when trying to delete a department in use"""
+ error: str = Field(..., description="Error message")
+ department: str = Field(..., description="Department that is in use")
+ member_count: int = Field(..., description="Number of project members using this department")
+ task_count: int = Field(..., description="Number of tasks using this department")
diff --git a/backend/schemas/project.py b/backend/schemas/project.py
index 7e13eb5..69756cf 100644
--- a/backend/schemas/project.py
+++ b/backend/schemas/project.py
@@ -5,7 +5,6 @@ from enum import Enum
import re
from models.project import ProjectStatus, ProjectType
-from models.user import DepartmentRole
# Technical Specifications Schemas
@@ -67,16 +66,6 @@ class ProjectTechnicalSpecs(BaseModel):
raise ValueError('Delivery image resolution must be in format "WIDTHxHEIGHT" (e.g., "1920x1080")')
return v
- @validator('delivery_movie_specs_by_department')
- def validate_delivery_movie_specs_by_department(cls, v):
- if v is None:
- return {}
- allowed_departments = ['layout', 'animation', 'lighting', 'composite', 'modeling', 'rigging', 'surfacing']
- for dept in v.keys():
- if dept not in allowed_departments:
- raise ValueError(f'Department must be one of: {", ".join(allowed_departments)}')
- return v
-
# Default delivery movie specifications per department
DEFAULT_DELIVERY_MOVIE_SPECS = {
@@ -135,17 +124,6 @@ class ProjectBase(BaseModel):
raise ValueError('Delivery image resolution must be in format "WIDTHxHEIGHT" (e.g., "1920x1080")')
return v
- @validator('delivery_movie_specs_by_department')
- def validate_delivery_movie_specs_by_department(cls, v):
- if v is None:
- return {}
- allowed_departments = ['layout', 'animation', 'lighting', 'composite', 'modeling', 'rigging', 'surfacing']
- for dept in v.keys():
- if dept not in allowed_departments:
- raise ValueError(f'Department must be one of: {", ".join(allowed_departments)}')
- return v
-
-
class ProjectCreate(ProjectBase):
pass
@@ -178,20 +156,9 @@ class ProjectUpdate(BaseModel):
raise ValueError('Delivery image resolution must be in format "WIDTHxHEIGHT" (e.g., "1920x1080")')
return v
- @validator('delivery_movie_specs_by_department')
- def validate_delivery_movie_specs_by_department(cls, v):
- if v is None:
- return v
- allowed_departments = ['layout', 'animation', 'lighting', 'composite', 'modeling', 'rigging', 'surfacing']
- for dept in v.keys():
- if dept not in allowed_departments:
- raise ValueError(f'Department must be one of: {", ".join(allowed_departments)}')
- return v
-
-
class ProjectMemberBase(BaseModel):
user_id: int
- department_role: Optional[DepartmentRole] = None
+ department_role: Optional[str] = None
class ProjectMemberCreate(ProjectMemberBase):
@@ -199,7 +166,7 @@ class ProjectMemberCreate(ProjectMemberBase):
class ProjectMemberUpdate(BaseModel):
- department_role: Optional[DepartmentRole] = None
+ department_role: Optional[str] = None
class ProjectMemberResponse(ProjectMemberBase):
diff --git a/backend/schemas/task.py b/backend/schemas/task.py
index 54bab00..ce4c3c9 100644
--- a/backend/schemas/task.py
+++ b/backend/schemas/task.py
@@ -4,7 +4,6 @@ from datetime import date, datetime
from enum import Enum
from models.task import TaskType, TaskStatus, ReviewDecision, AttachmentType, NoteType
-from models.user import DepartmentRole
class TaskBase(BaseModel):
@@ -14,6 +13,7 @@ class TaskBase(BaseModel):
start_date: Optional[date] = None
deadline: Optional[date] = None
status: str = "not_started" # Changed from TaskStatus enum to str to support custom statuses
+ department: Optional[str] = None # Standard or project-custom department, independent of assignee
class TaskCreate(TaskBase):
@@ -31,6 +31,7 @@ class TaskUpdate(BaseModel):
start_date: Optional[date] = None
deadline: Optional[date] = None
status: Optional[str] = None # Changed from TaskStatus enum to str to support custom statuses
+ department: Optional[str] = None # Standard or project-custom department, independent of assignee
assigned_user_id: Optional[int] = None
@@ -70,6 +71,7 @@ class TaskListResponse(BaseModel):
name: str
task_type: str # Changed from TaskType enum to str to support custom task types
status: str # Changed from TaskStatus enum to str to support custom statuses
+ department: Optional[str] = None # Standard or project-custom department, independent of assignee
start_date: Optional[date] = None
deadline: Optional[date] = None
project_id: int
diff --git a/frontend/src/components/project/DepartmentSpecsPanel.vue b/frontend/src/components/project/DepartmentSpecsPanel.vue
index 4605a57..f33dcae 100644
--- a/frontend/src/components/project/DepartmentSpecsPanel.vue
+++ b/frontend/src/components/project/DepartmentSpecsPanel.vue
@@ -104,7 +104,7 @@
import { computed } from 'vue'
import {
Monitor, FileVideo, Settings, Star, Clock, Image, Copy, FolderOpen, AlertCircle,
- Palette, Zap, Lightbulb, Layers, Box, Wrench, Paintbrush
+ Palette, Zap, Lightbulb, Layers, Box, Wrench, Paintbrush, Tag
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
@@ -144,7 +144,8 @@ const getDepartmentIcon = (department: string) => {
rigging: Wrench,
surfacing: Paintbrush
}
- return icons[department] || Box
+ // Fallback for project-custom departments not in the standard icon map above
+ return icons[department] || Tag
}
const getFrameRateLabel = (frameRate: number) => {
diff --git a/frontend/src/components/project/ProjectMemberManagement.vue b/frontend/src/components/project/ProjectMemberManagement.vue
index f89fbfb..8fa44b6 100644
--- a/frontend/src/components/project/ProjectMemberManagement.vue
+++ b/frontend/src/components/project/ProjectMemberManagement.vue
@@ -235,6 +235,7 @@ import { useAvatarUrl } from '@/composables/useAvatarUrl'
import { useToast } from '@/components/ui/toast/use-toast'
import { projectService, type ProjectMember } from '@/services/project'
import { userService } from '@/services/user'
+import { useDepartmentsStore } from '@/stores/departments'
import type { User } from '@/types/auth'
interface Props {
@@ -248,16 +249,14 @@ const emit = defineEmits<{
const { toast } = useToast()
const { getAvatarUrl } = useAvatarUrl()
+const departmentsStore = useDepartmentsStore()
-const departmentRoles = [
- { value: 'layout', label: 'Layout' },
- { value: 'animation', label: 'Animation' },
- { value: 'lighting', label: 'Lighting' },
- { value: 'composite', label: 'Composite' },
- { value: 'modeling', label: 'Modeling' },
- { value: 'rigging', label: 'Rigging' },
- { value: 'surfacing', label: 'Surfacing' },
-]
+const departmentRoles = computed(() => {
+ return departmentsStore.getAllDepartmentOptions(props.projectId).map(department => ({
+ value: department,
+ label: department.replace(/_/g, ' ').replace(/^\w/, c => c.toUpperCase())
+ }))
+})
// State
const members = ref
+ Add custom departments beyond the standard ones. Departments are used on tasks and team member assignments.
+ Departments
+ All Departments
+