Add shot/asset typing and owned task types to departments
Departments now carry a type (shot or asset) and own a list of task types (e.g. Animation: blocking/primary_pass/second_pass, Composite: first_pass/second_pass, plus a new Simulation department), additive to the existing flat Custom Task Type system. When a task's type belongs to a department, its department is derived and kept in sync server-side across create/update paths; Task Type is now editable in the Task Detail panel and Department options are filtered to the task's shot/asset scope.
This commit is contained in:
+277
-20
@@ -1080,21 +1080,41 @@ async def delete_custom_task_type(
|
||||
|
||||
# Department Management Endpoints
|
||||
|
||||
# Standard departments (read-only)
|
||||
STANDARD_DEPARTMENTS = ["layout", "animation", "lighting", "composite", "modeling", "rigging", "surfacing"]
|
||||
# Standard departments (read-only): name, whether they apply to shots or assets,
|
||||
# and the task types they own.
|
||||
STANDARD_DEPARTMENTS = [
|
||||
{"name": "layout", "type": "shot", "task_types": ["layout"]},
|
||||
{"name": "animation", "type": "shot", "task_types": ["blocking", "primary_pass", "second_pass"]},
|
||||
{"name": "simulation", "type": "shot", "task_types": ["simulation"]},
|
||||
{"name": "lighting", "type": "shot", "task_types": ["lighting"]},
|
||||
{"name": "composite", "type": "shot", "task_types": ["first_pass", "second_pass"]},
|
||||
{"name": "modeling", "type": "asset", "task_types": ["modeling"]},
|
||||
{"name": "rigging", "type": "asset", "task_types": ["rigging"]},
|
||||
{"name": "surfacing", "type": "asset", "task_types": ["surfacing"]},
|
||||
]
|
||||
STANDARD_DEPARTMENT_NAMES = [d["name"] for d in STANDARD_DEPARTMENTS]
|
||||
|
||||
|
||||
def _find_custom_department(custom_departments: list, name: str):
|
||||
"""Find a custom department dict by name, or None."""
|
||||
for department in custom_departments:
|
||||
if department["name"] == name:
|
||||
return department
|
||||
return None
|
||||
|
||||
|
||||
def _build_all_departments_response(db_project: Project):
|
||||
"""Helper function to build AllDepartmentsResponse"""
|
||||
from schemas.department import AllDepartmentsResponse
|
||||
from schemas.department import AllDepartmentsResponse, DepartmentInfo
|
||||
|
||||
custom_departments = db_project.custom_departments or []
|
||||
all_departments = STANDARD_DEPARTMENTS + custom_departments
|
||||
standard_infos = [DepartmentInfo(**d) for d in STANDARD_DEPARTMENTS]
|
||||
custom_infos = [DepartmentInfo(**d) for d in custom_departments]
|
||||
|
||||
return AllDepartmentsResponse(
|
||||
departments=all_departments,
|
||||
standard_departments=STANDARD_DEPARTMENTS,
|
||||
custom_departments=custom_departments
|
||||
departments=standard_infos + custom_infos,
|
||||
standard_departments=standard_infos,
|
||||
custom_departments=custom_infos
|
||||
)
|
||||
|
||||
|
||||
@@ -1144,19 +1164,23 @@ async def add_department(
|
||||
|
||||
custom_departments = db_project.custom_departments or []
|
||||
|
||||
if department_create.department in STANDARD_DEPARTMENTS:
|
||||
if department_create.department in STANDARD_DEPARTMENT_NAMES:
|
||||
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:
|
||||
if _find_custom_department(custom_departments, department_create.department):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Department '{department_create.department}' already exists"
|
||||
)
|
||||
|
||||
custom_departments.append(department_create.department)
|
||||
custom_departments.append({
|
||||
"name": department_create.department,
|
||||
"type": department_create.department_type,
|
||||
"task_types": department_create.task_types
|
||||
})
|
||||
|
||||
db_project.custom_departments = custom_departments
|
||||
flag_modified(db_project, 'custom_departments')
|
||||
@@ -1209,29 +1233,28 @@ async def update_department(
|
||||
)
|
||||
|
||||
custom_departments = db_project.custom_departments or []
|
||||
existing = _find_custom_department(custom_departments, department_update.old_name)
|
||||
|
||||
if department_update.old_name not in custom_departments:
|
||||
if not existing:
|
||||
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:
|
||||
if department_update.new_name in STANDARD_DEPARTMENT_NAMES:
|
||||
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:
|
||||
if (department_update.new_name != department_update.old_name
|
||||
and _find_custom_department(custom_departments, department_update.new_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
|
||||
]
|
||||
existing["name"] = department_update.new_name
|
||||
|
||||
db_project.custom_departments = custom_departments
|
||||
flag_modified(db_project, 'custom_departments')
|
||||
@@ -1275,7 +1298,7 @@ async def delete_department(
|
||||
"""Delete a custom department (blocked if any member or task is currently using it)"""
|
||||
from models.task import Task
|
||||
|
||||
if department in STANDARD_DEPARTMENTS:
|
||||
if department in STANDARD_DEPARTMENT_NAMES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Standard departments cannot be deleted"
|
||||
@@ -1291,7 +1314,7 @@ async def delete_department(
|
||||
|
||||
custom_departments = db_project.custom_departments or []
|
||||
|
||||
if department not in custom_departments:
|
||||
if not _find_custom_department(custom_departments, department):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Custom department '{department}' not found"
|
||||
@@ -1318,7 +1341,7 @@ async def delete_department(
|
||||
}
|
||||
)
|
||||
|
||||
custom_departments.remove(department)
|
||||
custom_departments = [d for d in custom_departments if d["name"] != department]
|
||||
|
||||
db_project.custom_departments = custom_departments
|
||||
flag_modified(db_project, 'custom_departments')
|
||||
@@ -1336,6 +1359,240 @@ async def delete_department(
|
||||
return _build_all_departments_response(db_project)
|
||||
|
||||
|
||||
@router.post("/{project_id}/departments/{department}/task-types", status_code=status.HTTP_201_CREATED)
|
||||
async def add_department_task_type(
|
||||
project_id: int,
|
||||
department: str,
|
||||
task_type_data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
):
|
||||
"""Add a task type to a custom department"""
|
||||
from schemas.department import DepartmentTaskTypeCreate
|
||||
|
||||
try:
|
||||
task_type_create = DepartmentTaskTypeCreate(**task_type_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"
|
||||
)
|
||||
|
||||
if department in STANDARD_DEPARTMENT_NAMES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Standard departments' task types are fixed and cannot be modified"
|
||||
)
|
||||
|
||||
custom_departments = db_project.custom_departments or []
|
||||
existing = _find_custom_department(custom_departments, department)
|
||||
|
||||
if not existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Custom department '{department}' not found"
|
||||
)
|
||||
|
||||
if task_type_create.task_type in existing["task_types"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Task type '{task_type_create.task_type}' already exists in department '{department}'"
|
||||
)
|
||||
|
||||
existing["task_types"].append(task_type_create.task_type)
|
||||
|
||||
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 task type"
|
||||
)
|
||||
|
||||
return _build_all_departments_response(db_project)
|
||||
|
||||
|
||||
@router.put("/{project_id}/departments/{department}/task-types/{task_type}")
|
||||
async def rename_department_task_type(
|
||||
project_id: int,
|
||||
department: str,
|
||||
task_type: str,
|
||||
update_data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
):
|
||||
"""Rename a task type within a custom department, cascading the rename to matching tasks"""
|
||||
from schemas.department import DepartmentTaskTypeUpdate
|
||||
from models.task import Task
|
||||
|
||||
try:
|
||||
task_type_update = DepartmentTaskTypeUpdate(**update_data)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
if task_type != task_type_update.old_name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Task type 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"
|
||||
)
|
||||
|
||||
if department in STANDARD_DEPARTMENT_NAMES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Standard departments' task types are fixed and cannot be modified"
|
||||
)
|
||||
|
||||
custom_departments = db_project.custom_departments or []
|
||||
existing = _find_custom_department(custom_departments, department)
|
||||
|
||||
if not existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Custom department '{department}' not found"
|
||||
)
|
||||
|
||||
if task_type_update.old_name not in existing["task_types"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Task type '{task_type_update.old_name}' not found in department '{department}'"
|
||||
)
|
||||
|
||||
if (task_type_update.new_name != task_type_update.old_name
|
||||
and task_type_update.new_name in existing["task_types"]):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Task type '{task_type_update.new_name}' already exists in department '{department}'"
|
||||
)
|
||||
|
||||
existing["task_types"] = [
|
||||
task_type_update.new_name if t == task_type_update.old_name else t
|
||||
for t in existing["task_types"]
|
||||
]
|
||||
|
||||
db_project.custom_departments = custom_departments
|
||||
flag_modified(db_project, 'custom_departments')
|
||||
|
||||
# Cascade rename to tasks using this task type within this department
|
||||
tasks_to_update = db.query(Task).filter(
|
||||
Task.project_id == project_id,
|
||||
Task.department == department,
|
||||
Task.task_type == task_type_update.old_name
|
||||
).all()
|
||||
for task in tasks_to_update:
|
||||
task.task_type = task_type_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 rename department task type"
|
||||
)
|
||||
|
||||
return _build_all_departments_response(db_project)
|
||||
|
||||
|
||||
@router.delete("/{project_id}/departments/{department}/task-types/{task_type}")
|
||||
async def delete_department_task_type(
|
||||
project_id: int,
|
||||
department: str,
|
||||
task_type: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
):
|
||||
"""Delete a task type from a custom department (blocked if any task is currently using it)"""
|
||||
from models.task import Task
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
if department in STANDARD_DEPARTMENT_NAMES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Standard departments' task types are fixed and cannot be modified"
|
||||
)
|
||||
|
||||
custom_departments = db_project.custom_departments or []
|
||||
existing = _find_custom_department(custom_departments, department)
|
||||
|
||||
if not existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Custom department '{department}' not found"
|
||||
)
|
||||
|
||||
if task_type not in existing["task_types"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Task type '{task_type}' not found in department '{department}'"
|
||||
)
|
||||
|
||||
tasks_using_task_type = db.query(Task).filter(
|
||||
Task.project_id == project_id,
|
||||
Task.department == department,
|
||||
Task.task_type == task_type
|
||||
).all()
|
||||
|
||||
if tasks_using_task_type:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={
|
||||
"error": f"Cannot delete task type '{task_type}' because it is currently in use",
|
||||
"department": department,
|
||||
"task_type": task_type,
|
||||
"task_count": len(tasks_using_task_type)
|
||||
}
|
||||
)
|
||||
|
||||
existing["task_types"] = [t for t in existing["task_types"] if t != task_type]
|
||||
|
||||
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 task type"
|
||||
)
|
||||
|
||||
return _build_all_departments_response(db_project)
|
||||
|
||||
|
||||
# Submission Configuration Endpoints
|
||||
|
||||
@router.get("/{project_id}/submission-config", response_model=ProjectSubmissionConfig)
|
||||
|
||||
Reference in New Issue
Block a user