Add per-project Department management

Departments are now a customizable per-project list (standard + custom),
matching the existing custom task type/status pattern, instead of a fixed
7-value enum. They're usable directly on tasks (new field, independent of
assignee) and continue to drive team member department roles.
This commit is contained in:
2026-07-22 04:03:33 +08:00
parent 11e1369f2f
commit c09710f4e5
20 changed files with 1220 additions and 75 deletions
+258
View File
@@ -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)
+14 -8
View File
@@ -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,