1957 lines
67 KiB
Python
1957 lines
67 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, status, Query, UploadFile
|
|
from sqlalchemy.orm import Session, joinedload
|
|
from sqlalchemy.orm.attributes import flag_modified
|
|
from sqlalchemy import func
|
|
from typing import List, Optional
|
|
import json
|
|
|
|
from database import get_db
|
|
from models.project import Project, ProjectMember
|
|
from models.user import User, UserRole
|
|
from models.episode import Episode
|
|
from models.asset import Asset
|
|
from schemas.project import (
|
|
ProjectCreate, ProjectUpdate, ProjectResponse, ProjectListResponse,
|
|
ProjectMemberCreate, ProjectMemberUpdate, ProjectMemberResponse,
|
|
ProjectTechnicalSpecs, DeliveryMovieSpec, DEFAULT_DELIVERY_MOVIE_SPECS,
|
|
ProjectSettings, ProjectSettingsUpdate, DEFAULT_ASSET_TASKS, DEFAULT_SHOT_TASKS
|
|
)
|
|
from utils.auth import get_current_user, require_role, get_current_user_from_token
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def get_current_user_with_db(
|
|
token_data: dict = Depends(get_current_user_from_token),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get current user with proper database dependency."""
|
|
from utils.auth import _get_user_from_db
|
|
return _get_user_from_db(db, token_data["user_id"])
|
|
|
|
|
|
def require_coordinator_or_admin(
|
|
token_data: dict = Depends(get_current_user_from_token),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Require coordinator or admin role."""
|
|
from utils.auth import _get_user_from_db
|
|
current_user = _get_user_from_db(db, token_data["user_id"])
|
|
|
|
if current_user.role != UserRole.COORDINATOR and not current_user.is_admin:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Insufficient permissions"
|
|
)
|
|
return current_user
|
|
|
|
|
|
def require_admin(
|
|
token_data: dict = Depends(get_current_user_from_token),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Require admin role."""
|
|
from utils.auth import _get_user_from_db
|
|
current_user = _get_user_from_db(db, token_data["user_id"])
|
|
|
|
if not current_user.is_admin:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Insufficient permissions"
|
|
)
|
|
return current_user
|
|
|
|
|
|
@router.get("/", response_model=List[ProjectListResponse])
|
|
async def list_projects(
|
|
skip: int = 0,
|
|
limit: int = 100,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user_with_db)
|
|
):
|
|
"""List all projects with summary information"""
|
|
# Artists can only see projects they're members of
|
|
if current_user.role == UserRole.ARTIST:
|
|
projects = db.query(Project).join(ProjectMember).filter(
|
|
ProjectMember.user_id == current_user.id
|
|
).offset(skip).limit(limit).all()
|
|
else:
|
|
projects = db.query(Project).offset(skip).limit(limit).all()
|
|
|
|
# Add summary information
|
|
result = []
|
|
for project in projects:
|
|
member_count = db.query(ProjectMember).filter(ProjectMember.project_id == project.id).count()
|
|
episode_count = db.query(Episode).filter(Episode.project_id == project.id).count()
|
|
asset_count = db.query(Asset).filter(Asset.project_id == project.id).count()
|
|
|
|
project_data = ProjectListResponse.model_validate(project)
|
|
project_data.member_count = member_count
|
|
project_data.episode_count = episode_count
|
|
project_data.asset_count = asset_count
|
|
|
|
# Add thumbnail URL if thumbnail exists
|
|
if project.thumbnail_path:
|
|
project_data.thumbnail_url = f"/files/projects/{project.id}/thumbnail"
|
|
|
|
result.append(project_data)
|
|
|
|
return result
|
|
|
|
|
|
@router.post("/", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
|
|
async def create_project(
|
|
project: ProjectCreate,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_coordinator_or_admin)
|
|
):
|
|
"""Create a new project"""
|
|
# Check if code_name already exists
|
|
existing_project = db.query(Project).filter(Project.code_name == project.code_name).first()
|
|
if existing_project:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Project with code name '{project.code_name}' already exists"
|
|
)
|
|
|
|
db_project = Project(**project.model_dump())
|
|
db.add(db_project)
|
|
|
|
try:
|
|
db.commit()
|
|
db.refresh(db_project)
|
|
except Exception as e:
|
|
db.rollback()
|
|
if "UNIQUE constraint failed" in str(e):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Project with code name '{project.code_name}' already exists"
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to create project"
|
|
)
|
|
|
|
return ProjectResponse.model_validate(db_project)
|
|
|
|
|
|
@router.get("/{project_id}", response_model=ProjectResponse)
|
|
async def get_project(
|
|
project_id: int,
|
|
include_members: bool = False,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user_with_db)
|
|
):
|
|
"""Get a specific project by ID"""
|
|
query = db.query(Project)
|
|
|
|
if include_members:
|
|
query = query.options(joinedload(Project.project_members).joinedload(ProjectMember.user))
|
|
|
|
project = query.filter(Project.id == project_id).first()
|
|
|
|
if not project:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Project not found"
|
|
)
|
|
|
|
# Check if artist has access to this project
|
|
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"
|
|
)
|
|
|
|
# Build project response without members first
|
|
project_dict = {
|
|
'id': project.id,
|
|
'name': project.name,
|
|
'code_name': project.code_name,
|
|
'client_name': project.client_name,
|
|
'description': project.description,
|
|
'project_type': project.project_type,
|
|
'status': project.status,
|
|
'start_date': project.start_date,
|
|
'end_date': project.end_date,
|
|
'created_at': project.created_at,
|
|
'updated_at': project.updated_at,
|
|
'thumbnail_path': project.thumbnail_path,
|
|
'custom_asset_task_types': project.custom_asset_task_types,
|
|
'custom_shot_task_types': project.custom_shot_task_types,
|
|
'project_members': []
|
|
}
|
|
|
|
project_data = ProjectResponse(**project_dict)
|
|
|
|
# Add thumbnail URL if thumbnail exists
|
|
if project.thumbnail_path:
|
|
project_data.thumbnail_url = f"/files/projects/{project.id}/thumbnail"
|
|
|
|
if include_members and project.project_members:
|
|
members = []
|
|
for member in project.project_members:
|
|
member_data = ProjectMemberResponse(
|
|
id=member.id,
|
|
user_id=member.user_id,
|
|
project_id=member.project_id,
|
|
department_role=member.department_role,
|
|
joined_at=member.joined_at,
|
|
user_email=member.user.email,
|
|
user_first_name=member.user.first_name,
|
|
user_last_name=member.user.last_name
|
|
)
|
|
members.append(member_data)
|
|
project_data.project_members = members
|
|
|
|
return project_data
|
|
|
|
|
|
@router.put("/{project_id}", response_model=ProjectResponse)
|
|
async def update_project(
|
|
project_id: int,
|
|
project_update: ProjectUpdate,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_coordinator_or_admin)
|
|
):
|
|
"""Update 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"
|
|
)
|
|
|
|
# Check if code_name is being updated and if it already exists
|
|
update_data = project_update.model_dump(exclude_unset=True)
|
|
if 'code_name' in update_data:
|
|
existing_project = db.query(Project).filter(
|
|
Project.code_name == update_data['code_name'],
|
|
Project.id != project_id
|
|
).first()
|
|
if existing_project:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Project with code name '{update_data['code_name']}' already exists"
|
|
)
|
|
|
|
# Update only provided fields
|
|
for field, value in update_data.items():
|
|
setattr(db_project, field, value)
|
|
|
|
try:
|
|
db.commit()
|
|
db.refresh(db_project)
|
|
except Exception as e:
|
|
db.rollback()
|
|
if "UNIQUE constraint failed" in str(e):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Project with code name '{update_data.get('code_name', 'unknown')}' already exists"
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to update project"
|
|
)
|
|
|
|
# Create response without project members to avoid validation issues
|
|
# The frontend doesn't need member data for project updates
|
|
|
|
# Handle delivery_movie_specs_by_department - convert from JSON string if needed
|
|
delivery_specs = {}
|
|
if db_project.delivery_movie_specs_by_department:
|
|
specs_data = db_project.delivery_movie_specs_by_department
|
|
if isinstance(specs_data, str):
|
|
try:
|
|
specs_data = json.loads(specs_data)
|
|
except (json.JSONDecodeError, TypeError):
|
|
specs_data = {}
|
|
|
|
if isinstance(specs_data, dict):
|
|
for dept, spec_data in specs_data.items():
|
|
if isinstance(spec_data, dict):
|
|
delivery_specs[dept] = DeliveryMovieSpec(**spec_data)
|
|
else:
|
|
# Skip invalid spec data
|
|
continue
|
|
|
|
project_dict = {
|
|
'id': db_project.id,
|
|
'name': db_project.name,
|
|
'code_name': db_project.code_name,
|
|
'client_name': db_project.client_name,
|
|
'project_type': db_project.project_type,
|
|
'description': db_project.description,
|
|
'status': db_project.status,
|
|
'start_date': db_project.start_date,
|
|
'end_date': db_project.end_date,
|
|
'frame_rate': db_project.frame_rate,
|
|
'data_drive_path': db_project.data_drive_path,
|
|
'publish_storage_path': db_project.publish_storage_path,
|
|
'delivery_image_resolution': db_project.delivery_image_resolution,
|
|
'delivery_movie_specs_by_department': delivery_specs,
|
|
'created_at': db_project.created_at,
|
|
'updated_at': db_project.updated_at,
|
|
'thumbnail_url': f"/files/projects/{db_project.id}/thumbnail" if db_project.thumbnail_path else None,
|
|
'project_members': None # Explicitly set to None to avoid validation issues
|
|
}
|
|
|
|
return ProjectResponse.model_validate(project_dict)
|
|
|
|
|
|
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_project(
|
|
project_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_admin)
|
|
):
|
|
"""Delete a project (admin only)"""
|
|
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"
|
|
)
|
|
|
|
db.delete(db_project)
|
|
db.commit()
|
|
|
|
|
|
# Project Member Management Endpoints
|
|
|
|
@router.get("/{project_id}/members", response_model=List[ProjectMemberResponse])
|
|
async def list_project_members(
|
|
project_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user_with_db)
|
|
):
|
|
"""List all members of a project"""
|
|
# Check if project exists
|
|
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"
|
|
)
|
|
|
|
# Check access for artists
|
|
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"
|
|
)
|
|
|
|
members = db.query(ProjectMember).options(
|
|
joinedload(ProjectMember.user)
|
|
).filter(ProjectMember.project_id == project_id).all()
|
|
|
|
result = []
|
|
for member in members:
|
|
member_data = ProjectMemberResponse(
|
|
id=member.id,
|
|
user_id=member.user_id,
|
|
project_id=member.project_id,
|
|
department_role=member.department_role,
|
|
joined_at=member.joined_at,
|
|
user_email=member.user.email,
|
|
user_first_name=member.user.first_name,
|
|
user_last_name=member.user.last_name
|
|
)
|
|
result.append(member_data)
|
|
|
|
return result
|
|
|
|
|
|
@router.post("/{project_id}/members", response_model=ProjectMemberResponse, status_code=status.HTTP_201_CREATED)
|
|
async def add_project_member(
|
|
project_id: int,
|
|
member: ProjectMemberCreate,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_coordinator_or_admin)
|
|
):
|
|
"""Add a member to a project"""
|
|
# Check if project exists
|
|
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"
|
|
)
|
|
|
|
# Check if user exists
|
|
user = db.query(User).filter(User.id == member.user_id).first()
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="User not found"
|
|
)
|
|
|
|
# Check if user is already a member
|
|
existing_member = db.query(ProjectMember).filter(
|
|
ProjectMember.project_id == project_id,
|
|
ProjectMember.user_id == member.user_id
|
|
).first()
|
|
|
|
if existing_member:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="User is already a member of this project"
|
|
)
|
|
|
|
# Create new project member
|
|
db_member = ProjectMember(
|
|
project_id=project_id,
|
|
user_id=member.user_id,
|
|
department_role=member.department_role
|
|
)
|
|
|
|
db.add(db_member)
|
|
db.commit()
|
|
db.refresh(db_member)
|
|
|
|
# Load user data for response
|
|
db_member = db.query(ProjectMember).options(
|
|
joinedload(ProjectMember.user)
|
|
).filter(ProjectMember.id == db_member.id).first()
|
|
|
|
return ProjectMemberResponse(
|
|
id=db_member.id,
|
|
user_id=db_member.user_id,
|
|
project_id=db_member.project_id,
|
|
department_role=db_member.department_role,
|
|
joined_at=db_member.joined_at,
|
|
user_email=db_member.user.email,
|
|
user_first_name=db_member.user.first_name,
|
|
user_last_name=db_member.user.last_name
|
|
)
|
|
|
|
|
|
@router.put("/{project_id}/members/{member_id}", response_model=ProjectMemberResponse)
|
|
async def update_project_member(
|
|
project_id: int,
|
|
member_id: int,
|
|
member_update: ProjectMemberUpdate,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_coordinator_or_admin)
|
|
):
|
|
"""Update a project member's department role"""
|
|
db_member = db.query(ProjectMember).options(
|
|
joinedload(ProjectMember.user)
|
|
).filter(
|
|
ProjectMember.id == member_id,
|
|
ProjectMember.project_id == project_id
|
|
).first()
|
|
|
|
if not db_member:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Project member not found"
|
|
)
|
|
|
|
# Update department role
|
|
if member_update.department_role is not None:
|
|
db_member.department_role = member_update.department_role
|
|
|
|
db.commit()
|
|
db.refresh(db_member)
|
|
|
|
return ProjectMemberResponse(
|
|
id=db_member.id,
|
|
user_id=db_member.user_id,
|
|
project_id=db_member.project_id,
|
|
department_role=db_member.department_role,
|
|
joined_at=db_member.joined_at,
|
|
user_email=db_member.user.email,
|
|
user_first_name=db_member.user.first_name,
|
|
user_last_name=db_member.user.last_name
|
|
)
|
|
|
|
|
|
@router.delete("/{project_id}/members/{member_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def remove_project_member(
|
|
project_id: int,
|
|
member_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_coordinator_or_admin)
|
|
):
|
|
"""Remove a member from a project"""
|
|
db_member = db.query(ProjectMember).filter(
|
|
ProjectMember.id == member_id,
|
|
ProjectMember.project_id == project_id
|
|
).first()
|
|
|
|
if not db_member:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Project member not found"
|
|
)
|
|
|
|
db.delete(db_member)
|
|
db.commit()
|
|
|
|
|
|
# Technical Specifications Endpoints
|
|
|
|
@router.get("/{project_id}/technical-specs", response_model=ProjectTechnicalSpecs)
|
|
async def get_project_technical_specs(
|
|
project_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user_with_db)
|
|
):
|
|
"""Get project technical specifications"""
|
|
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"
|
|
)
|
|
|
|
# Check access for artists
|
|
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"
|
|
)
|
|
|
|
# Convert delivery_movie_specs_by_department from JSON to dict of DeliveryMovieSpec
|
|
delivery_specs = {}
|
|
if project.delivery_movie_specs_by_department:
|
|
# Handle both string (JSON) and dict cases
|
|
specs_data = project.delivery_movie_specs_by_department
|
|
if isinstance(specs_data, str):
|
|
try:
|
|
specs_data = json.loads(specs_data)
|
|
except (json.JSONDecodeError, TypeError):
|
|
specs_data = {}
|
|
|
|
if isinstance(specs_data, dict):
|
|
for dept, spec_data in specs_data.items():
|
|
if isinstance(spec_data, dict):
|
|
delivery_specs[dept] = DeliveryMovieSpec(**spec_data)
|
|
else:
|
|
# Skip invalid spec data
|
|
continue
|
|
|
|
return ProjectTechnicalSpecs(
|
|
frame_rate=project.frame_rate,
|
|
data_drive_path=project.data_drive_path,
|
|
publish_storage_path=project.publish_storage_path,
|
|
delivery_image_resolution=project.delivery_image_resolution,
|
|
delivery_movie_specs_by_department=delivery_specs
|
|
)
|
|
|
|
|
|
@router.put("/{project_id}/technical-specs", response_model=ProjectTechnicalSpecs)
|
|
async def update_project_technical_specs(
|
|
project_id: int,
|
|
tech_specs: ProjectTechnicalSpecs,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_coordinator_or_admin)
|
|
):
|
|
"""Update project technical specifications"""
|
|
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"
|
|
)
|
|
|
|
# Update technical specifications
|
|
db_project.frame_rate = tech_specs.frame_rate
|
|
db_project.data_drive_path = tech_specs.data_drive_path
|
|
db_project.publish_storage_path = tech_specs.publish_storage_path
|
|
db_project.delivery_image_resolution = tech_specs.delivery_image_resolution
|
|
|
|
# Convert delivery_movie_specs_by_department to JSON for storage
|
|
if tech_specs.delivery_movie_specs_by_department:
|
|
delivery_specs_json = {}
|
|
for dept, spec in tech_specs.delivery_movie_specs_by_department.items():
|
|
delivery_specs_json[dept] = spec.dict()
|
|
db_project.delivery_movie_specs_by_department = delivery_specs_json
|
|
else:
|
|
db_project.delivery_movie_specs_by_department = {}
|
|
|
|
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 technical specifications"
|
|
)
|
|
|
|
# Return updated specs
|
|
delivery_specs = {}
|
|
if db_project.delivery_movie_specs_by_department:
|
|
# Handle both string (JSON) and dict cases
|
|
specs_data = db_project.delivery_movie_specs_by_department
|
|
if isinstance(specs_data, str):
|
|
try:
|
|
specs_data = json.loads(specs_data)
|
|
except (json.JSONDecodeError, TypeError):
|
|
specs_data = {}
|
|
|
|
if isinstance(specs_data, dict):
|
|
for dept, spec_data in specs_data.items():
|
|
if isinstance(spec_data, dict):
|
|
delivery_specs[dept] = DeliveryMovieSpec(**spec_data)
|
|
else:
|
|
# Skip invalid spec data
|
|
continue
|
|
|
|
return ProjectTechnicalSpecs(
|
|
frame_rate=db_project.frame_rate,
|
|
data_drive_path=db_project.data_drive_path,
|
|
publish_storage_path=db_project.publish_storage_path,
|
|
delivery_image_resolution=db_project.delivery_image_resolution,
|
|
delivery_movie_specs_by_department=delivery_specs
|
|
)
|
|
|
|
|
|
@router.post("/{project_id}/technical-specs/defaults", response_model=ProjectTechnicalSpecs)
|
|
async def set_default_technical_specs(
|
|
project_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_coordinator_or_admin)
|
|
):
|
|
"""Set default technical specifications 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"
|
|
)
|
|
|
|
# Set default values
|
|
db_project.frame_rate = 24.0 # Default frame rate
|
|
db_project.delivery_image_resolution = "1920x1080" # Default HD resolution
|
|
|
|
# Set default delivery movie specs
|
|
default_specs_json = {}
|
|
for dept, spec in DEFAULT_DELIVERY_MOVIE_SPECS.items():
|
|
default_specs_json[dept] = spec.dict()
|
|
db_project.delivery_movie_specs_by_department = default_specs_json
|
|
|
|
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 set default technical specifications"
|
|
)
|
|
|
|
# Return updated specs
|
|
return ProjectTechnicalSpecs(
|
|
frame_rate=db_project.frame_rate,
|
|
data_drive_path=db_project.data_drive_path,
|
|
publish_storage_path=db_project.publish_storage_path,
|
|
delivery_image_resolution=db_project.delivery_image_resolution,
|
|
delivery_movie_specs_by_department=DEFAULT_DELIVERY_MOVIE_SPECS
|
|
)
|
|
|
|
|
|
# Project Settings Endpoints
|
|
|
|
@router.get("/{project_id}/settings", response_model=ProjectSettings)
|
|
async def get_project_settings(
|
|
project_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user_with_db)
|
|
):
|
|
"""Get project-specific settings for upload location and task templates"""
|
|
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"
|
|
)
|
|
|
|
# Check access for artists
|
|
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"
|
|
)
|
|
|
|
# Return project settings with defaults if not set
|
|
return ProjectSettings(
|
|
upload_data_location=project.upload_data_location,
|
|
asset_task_templates=project.asset_task_templates if project.asset_task_templates else DEFAULT_ASSET_TASKS.copy(),
|
|
shot_task_templates=project.shot_task_templates if project.shot_task_templates else DEFAULT_SHOT_TASKS.copy(),
|
|
enabled_asset_tasks=project.enabled_asset_tasks if project.enabled_asset_tasks else {},
|
|
enabled_shot_tasks=project.enabled_shot_tasks if project.enabled_shot_tasks else DEFAULT_SHOT_TASKS.copy()
|
|
)
|
|
|
|
|
|
@router.put("/{project_id}/settings", response_model=ProjectSettings)
|
|
async def update_project_settings(
|
|
project_id: int,
|
|
settings: ProjectSettingsUpdate,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_coordinator_or_admin)
|
|
):
|
|
"""Update project-specific settings for upload location and task templates"""
|
|
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"
|
|
)
|
|
|
|
# Update settings fields only if provided
|
|
if settings.upload_data_location is not None:
|
|
db_project.upload_data_location = settings.upload_data_location
|
|
|
|
if settings.asset_task_templates is not None:
|
|
db_project.asset_task_templates = settings.asset_task_templates
|
|
|
|
if settings.shot_task_templates is not None:
|
|
db_project.shot_task_templates = settings.shot_task_templates
|
|
|
|
if settings.enabled_asset_tasks is not None:
|
|
db_project.enabled_asset_tasks = settings.enabled_asset_tasks
|
|
|
|
if settings.enabled_shot_tasks is not None:
|
|
db_project.enabled_shot_tasks = settings.enabled_shot_tasks
|
|
|
|
try:
|
|
db.commit()
|
|
db.refresh(db_project)
|
|
except Exception as e:
|
|
db.rollback()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to update project settings: {str(e)}"
|
|
)
|
|
|
|
# Return updated settings
|
|
return ProjectSettings(
|
|
upload_data_location=db_project.upload_data_location,
|
|
asset_task_templates=db_project.asset_task_templates if db_project.asset_task_templates else DEFAULT_ASSET_TASKS.copy(),
|
|
shot_task_templates=db_project.shot_task_templates if db_project.shot_task_templates else DEFAULT_SHOT_TASKS.copy(),
|
|
enabled_asset_tasks=db_project.enabled_asset_tasks if db_project.enabled_asset_tasks else {},
|
|
enabled_shot_tasks=db_project.enabled_shot_tasks if db_project.enabled_shot_tasks else DEFAULT_SHOT_TASKS.copy()
|
|
)
|
|
|
|
|
|
# Custom Task Type Management Endpoints
|
|
|
|
# Standard task types (read-only)
|
|
STANDARD_ASSET_TASK_TYPES = ["modeling", "surfacing", "rigging"]
|
|
STANDARD_SHOT_TASK_TYPES = ["layout", "animation", "simulation", "lighting", "compositing"]
|
|
|
|
|
|
def _build_all_task_types_response(db_project: Project):
|
|
"""Helper function to build AllTaskTypesResponse"""
|
|
from schemas.custom_task_type import AllTaskTypesResponse
|
|
|
|
custom_asset_types = db_project.custom_asset_task_types or []
|
|
custom_shot_types = db_project.custom_shot_task_types or []
|
|
|
|
all_asset_types = STANDARD_ASSET_TASK_TYPES + custom_asset_types
|
|
all_shot_types = STANDARD_SHOT_TASK_TYPES + custom_shot_types
|
|
|
|
return AllTaskTypesResponse(
|
|
asset_task_types=all_asset_types,
|
|
shot_task_types=all_shot_types,
|
|
standard_asset_types=STANDARD_ASSET_TASK_TYPES,
|
|
standard_shot_types=STANDARD_SHOT_TASK_TYPES,
|
|
custom_asset_types=custom_asset_types,
|
|
custom_shot_types=custom_shot_types
|
|
)
|
|
|
|
|
|
@router.get("/{project_id}/custom-task-types")
|
|
async def get_all_task_types(
|
|
project_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user_with_db)
|
|
):
|
|
"""Get all task types (standard + custom) for a project"""
|
|
from schemas.custom_task_type import AllTaskTypesResponse
|
|
|
|
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_task_types_response(db_project)
|
|
|
|
|
|
@router.post("/{project_id}/custom-task-types", status_code=status.HTTP_201_CREATED)
|
|
async def add_custom_task_type(
|
|
project_id: int,
|
|
task_type_data: dict,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_coordinator_or_admin)
|
|
):
|
|
"""Add a new custom task type to a project"""
|
|
from schemas.custom_task_type import CustomTaskTypeCreate
|
|
|
|
# Validate input
|
|
try:
|
|
task_type_create = CustomTaskTypeCreate(**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"
|
|
)
|
|
|
|
# Get existing custom task types
|
|
if task_type_create.category == "asset":
|
|
custom_types = db_project.custom_asset_task_types or []
|
|
standard_types = STANDARD_ASSET_TASK_TYPES
|
|
else: # shot
|
|
custom_types = db_project.custom_shot_task_types or []
|
|
standard_types = STANDARD_SHOT_TASK_TYPES
|
|
|
|
# Check if task type already exists (in standard or custom)
|
|
if task_type_create.task_type in standard_types:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=f"Task type '{task_type_create.task_type}' is a standard task type and cannot be added as custom"
|
|
)
|
|
|
|
if task_type_create.task_type in custom_types:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=f"Task type '{task_type_create.task_type}' already exists"
|
|
)
|
|
|
|
# Add new custom task type
|
|
custom_types.append(task_type_create.task_type)
|
|
|
|
# Update database - need to flag as modified for JSON columns
|
|
if task_type_create.category == "asset":
|
|
db_project.custom_asset_task_types = custom_types
|
|
flag_modified(db_project, 'custom_asset_task_types')
|
|
else:
|
|
db_project.custom_shot_task_types = custom_types
|
|
flag_modified(db_project, 'custom_shot_task_types')
|
|
|
|
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 custom task type"
|
|
)
|
|
|
|
return _build_all_task_types_response(db_project)
|
|
|
|
|
|
@router.put("/{project_id}/custom-task-types/{task_type}")
|
|
async def update_custom_task_type(
|
|
project_id: int,
|
|
task_type: str,
|
|
update_data: dict,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_coordinator_or_admin)
|
|
):
|
|
"""Update a custom task type name"""
|
|
from schemas.custom_task_type import CustomTaskTypeUpdate
|
|
from models.task import Task
|
|
|
|
# Validate input
|
|
try:
|
|
task_type_update = CustomTaskTypeUpdate(**update_data)
|
|
except Exception as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=str(e)
|
|
)
|
|
|
|
# Verify task_type matches old_name
|
|
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"
|
|
)
|
|
|
|
# Get existing custom task types
|
|
if task_type_update.category == "asset":
|
|
custom_types = db_project.custom_asset_task_types or []
|
|
standard_types = STANDARD_ASSET_TASK_TYPES
|
|
else: # shot
|
|
custom_types = db_project.custom_shot_task_types or []
|
|
standard_types = STANDARD_SHOT_TASK_TYPES
|
|
|
|
# Check if old task type exists in custom types
|
|
if task_type_update.old_name not in custom_types:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Custom task type '{task_type_update.old_name}' not found"
|
|
)
|
|
|
|
# Check if new name conflicts with standard or existing custom types
|
|
if task_type_update.new_name in standard_types:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=f"Task type '{task_type_update.new_name}' is a standard task type"
|
|
)
|
|
|
|
if task_type_update.new_name in custom_types and task_type_update.new_name != task_type_update.old_name:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=f"Task type '{task_type_update.new_name}' already exists"
|
|
)
|
|
|
|
# Update custom task type name in the list
|
|
custom_types = [task_type_update.new_name if t == task_type_update.old_name else t for t in custom_types]
|
|
|
|
# Update database - need to flag as modified for JSON columns
|
|
if task_type_update.category == "asset":
|
|
db_project.custom_asset_task_types = custom_types
|
|
flag_modified(db_project, 'custom_asset_task_types')
|
|
else:
|
|
db_project.custom_shot_task_types = custom_types
|
|
flag_modified(db_project, 'custom_shot_task_types')
|
|
|
|
# Update all tasks using this task type
|
|
if task_type_update.category == "asset":
|
|
tasks_to_update = db.query(Task).join(Asset).filter(
|
|
Asset.project_id == project_id,
|
|
Task.task_type == task_type_update.old_name
|
|
).all()
|
|
else: # shot
|
|
from models.shot import Shot
|
|
tasks_to_update = db.query(Task).join(Shot).filter(
|
|
Shot.project_id == project_id,
|
|
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 update custom task type"
|
|
)
|
|
|
|
return _build_all_task_types_response(db_project)
|
|
|
|
|
|
@router.delete("/{project_id}/custom-task-types/{task_type}")
|
|
async def delete_custom_task_type(
|
|
project_id: int,
|
|
task_type: str,
|
|
category: str = Query(..., description="Category: 'asset' or 'shot'"),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_coordinator_or_admin)
|
|
):
|
|
"""Delete a custom task type"""
|
|
from models.task import Task
|
|
from schemas.custom_task_type import TaskTypeInUseError
|
|
|
|
# Validate category
|
|
if category not in ["asset", "shot"]:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Category must be 'asset' or 'shot'"
|
|
)
|
|
|
|
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"
|
|
)
|
|
|
|
# Get existing custom task types
|
|
if category == "asset":
|
|
custom_types = db_project.custom_asset_task_types or []
|
|
else: # shot
|
|
custom_types = db_project.custom_shot_task_types or []
|
|
|
|
# Check if task type exists in custom types
|
|
if task_type not in custom_types:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Custom task type '{task_type}' not found"
|
|
)
|
|
|
|
# Check if task type is in use
|
|
if category == "asset":
|
|
tasks_using_type = db.query(Task).join(Asset).filter(
|
|
Asset.project_id == project_id,
|
|
Task.task_type == task_type
|
|
).all()
|
|
else: # shot
|
|
from models.shot import Shot
|
|
tasks_using_type = db.query(Task).join(Shot).filter(
|
|
Shot.project_id == project_id,
|
|
Task.task_type == task_type
|
|
).all()
|
|
|
|
if tasks_using_type:
|
|
task_ids = [task.id for task in tasks_using_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",
|
|
"task_type": task_type,
|
|
"task_count": len(tasks_using_type),
|
|
"task_ids": task_ids
|
|
}
|
|
)
|
|
|
|
# Remove custom task type
|
|
custom_types.remove(task_type)
|
|
|
|
# Update database - need to flag as modified for JSON columns
|
|
if category == "asset":
|
|
db_project.custom_asset_task_types = custom_types
|
|
flag_modified(db_project, 'custom_asset_task_types')
|
|
else:
|
|
db_project.custom_shot_task_types = custom_types
|
|
flag_modified(db_project, 'custom_shot_task_types')
|
|
|
|
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 custom task type"
|
|
)
|
|
|
|
return _build_all_task_types_response(db_project)
|
|
|
|
|
|
# Project Thumbnail Management Endpoints
|
|
|
|
@router.post("/{project_id}/thumbnail", status_code=status.HTTP_201_CREATED)
|
|
async def upload_project_thumbnail(
|
|
project_id: int,
|
|
file: UploadFile,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_coordinator_or_admin)
|
|
):
|
|
"""Upload a thumbnail image for a project"""
|
|
from PIL import Image
|
|
import io
|
|
from pathlib import Path
|
|
import hashlib
|
|
from datetime import datetime
|
|
from utils.file_handler import file_handler
|
|
|
|
# Verify project exists
|
|
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"
|
|
)
|
|
|
|
# Validate file type
|
|
allowed_formats = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
|
|
file_extension = Path(file.filename).suffix.lower()
|
|
|
|
if file_extension not in allowed_formats:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Invalid file format. Allowed formats: {', '.join(allowed_formats)}"
|
|
)
|
|
|
|
# Read file content
|
|
file_content = await file.read()
|
|
file_size = len(file_content)
|
|
|
|
# Validate file size (10MB max)
|
|
max_size = 10 * 1024 * 1024 # 10MB
|
|
if file_size > max_size:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
|
detail="File too large. Maximum size is 10MB"
|
|
)
|
|
|
|
# Use FileHandler's project thumbnails directory
|
|
project_thumbnails_dir = file_handler.project_thumbnails_dir
|
|
|
|
# Delete old thumbnail if exists
|
|
if db_project.thumbnail_path:
|
|
absolute_old_thumbnail = file_handler.resolve_absolute_path(db_project.thumbnail_path)
|
|
old_thumbnail = Path(absolute_old_thumbnail)
|
|
if old_thumbnail.exists():
|
|
try:
|
|
old_thumbnail.unlink()
|
|
except Exception:
|
|
pass # Continue even if deletion fails
|
|
|
|
# Generate unique filename
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
hash_input = f"{file.filename}{timestamp}".encode()
|
|
short_hash = hashlib.md5(hash_input).hexdigest()[:8]
|
|
unique_filename = f"project_{project_id}_{timestamp}_{short_hash}{file_extension}"
|
|
|
|
# Process image
|
|
try:
|
|
image = Image.open(io.BytesIO(file_content))
|
|
|
|
# Convert to RGB if necessary
|
|
if image.mode in ('RGBA', 'LA', 'P'):
|
|
# Create white background for transparency
|
|
background = Image.new('RGB', image.size, (255, 255, 255))
|
|
if image.mode == 'RGBA':
|
|
background.paste(image, mask=image.split()[3]) # Use alpha channel as mask
|
|
else:
|
|
background.paste(image)
|
|
image = background
|
|
elif image.mode != 'RGB':
|
|
image = image.convert('RGB')
|
|
|
|
# Resize image maintaining aspect ratio
|
|
# Maximum dimensions: 800x600
|
|
max_width = 800
|
|
max_height = 600
|
|
|
|
# Calculate new dimensions
|
|
width, height = image.size
|
|
aspect_ratio = width / height
|
|
|
|
if width > max_width or height > max_height:
|
|
if aspect_ratio > max_width / max_height:
|
|
# Width is the limiting factor
|
|
new_width = max_width
|
|
new_height = int(max_width / aspect_ratio)
|
|
else:
|
|
# Height is the limiting factor
|
|
new_height = max_height
|
|
new_width = int(max_height * aspect_ratio)
|
|
|
|
image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
|
|
|
# Save processed image
|
|
thumbnail_path = project_thumbnails_dir / unique_filename
|
|
image.save(thumbnail_path, 'JPEG', quality=90, optimize=True)
|
|
|
|
except Exception as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Failed to process image: {str(e)}"
|
|
)
|
|
|
|
# Update project with thumbnail path (store as relative path)
|
|
db_project.thumbnail_path = file_handler.store_relative_path(str(thumbnail_path))
|
|
|
|
try:
|
|
db.commit()
|
|
db.refresh(db_project)
|
|
except Exception as e:
|
|
db.rollback()
|
|
# Clean up uploaded file
|
|
if thumbnail_path.exists():
|
|
thumbnail_path.unlink()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to update project thumbnail"
|
|
)
|
|
|
|
return {
|
|
"message": "Thumbnail uploaded successfully",
|
|
"thumbnail_url": f"/files/projects/{project_id}/thumbnail"
|
|
}
|
|
|
|
|
|
@router.delete("/{project_id}/thumbnail", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_project_thumbnail(
|
|
project_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_coordinator_or_admin)
|
|
):
|
|
"""Delete a project thumbnail"""
|
|
from pathlib import Path
|
|
from utils.file_handler import file_handler
|
|
|
|
# Verify project exists
|
|
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"
|
|
)
|
|
|
|
# Check if thumbnail exists
|
|
if not db_project.thumbnail_path:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Project has no thumbnail"
|
|
)
|
|
|
|
# Delete thumbnail file using FileHandler's delete method
|
|
if not file_handler.delete_file(db_project.thumbnail_path):
|
|
# If FileHandler delete fails, try manual deletion as fallback
|
|
absolute_thumbnail_path = file_handler.resolve_absolute_path(db_project.thumbnail_path)
|
|
thumbnail_path = Path(absolute_thumbnail_path)
|
|
if thumbnail_path.exists():
|
|
try:
|
|
thumbnail_path.unlink()
|
|
except Exception as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to delete thumbnail file: {str(e)}"
|
|
)
|
|
|
|
# Update project to clear thumbnail_path
|
|
db_project.thumbnail_path = None
|
|
|
|
try:
|
|
db.commit()
|
|
except Exception as e:
|
|
db.rollback()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to update project"
|
|
)
|
|
|
|
|
|
# Custom Task Status Management Endpoints
|
|
|
|
# System task statuses (built-in, read-only)
|
|
SYSTEM_TASK_STATUSES = [
|
|
{"id": "not_started", "name": "Not Started", "color": "#6B7280"},
|
|
{"id": "in_progress", "name": "In Progress", "color": "#3B82F6"},
|
|
{"id": "submitted", "name": "Submitted", "color": "#F59E0B"},
|
|
{"id": "approved", "name": "Approved", "color": "#10B981"},
|
|
{"id": "retake", "name": "Retake", "color": "#EF4444"}
|
|
]
|
|
|
|
|
|
@router.get("/{project_id}/task-statuses")
|
|
async def get_all_task_statuses(
|
|
project_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user_with_db)
|
|
):
|
|
"""Get all task statuses (system + custom) for a project"""
|
|
from schemas.custom_task_status import AllTaskStatusesResponse, CustomTaskStatus, SystemTaskStatus
|
|
|
|
# Verify project exists
|
|
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"
|
|
)
|
|
|
|
# Check access for artists
|
|
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"
|
|
)
|
|
|
|
# Build system statuses list
|
|
system_statuses = [
|
|
SystemTaskStatus(
|
|
id=status["id"],
|
|
name=status["name"],
|
|
color=status["color"],
|
|
is_system=True
|
|
)
|
|
for status in SYSTEM_TASK_STATUSES
|
|
]
|
|
|
|
# Get custom statuses from project
|
|
custom_statuses = []
|
|
default_status_id = "not_started" # Default to system status
|
|
|
|
if db_project.custom_task_statuses:
|
|
custom_statuses_data = db_project.custom_task_statuses
|
|
if isinstance(custom_statuses_data, str):
|
|
try:
|
|
custom_statuses_data = json.loads(custom_statuses_data)
|
|
except (json.JSONDecodeError, TypeError):
|
|
custom_statuses_data = []
|
|
|
|
if isinstance(custom_statuses_data, list):
|
|
for status_data in custom_statuses_data:
|
|
if isinstance(status_data, dict):
|
|
custom_status = CustomTaskStatus(**status_data)
|
|
custom_statuses.append(custom_status)
|
|
|
|
# Check if this is the default status
|
|
if status_data.get('is_default', False):
|
|
default_status_id = status_data.get('id')
|
|
|
|
return AllTaskStatusesResponse(
|
|
statuses=custom_statuses,
|
|
system_statuses=system_statuses,
|
|
default_status_id=default_status_id
|
|
)
|
|
|
|
|
|
# Default color palette for custom task statuses
|
|
DEFAULT_STATUS_COLOR_PALETTE = [
|
|
"#8B5CF6", # Purple
|
|
"#EC4899", # Pink
|
|
"#14B8A6", # Teal
|
|
"#F97316", # Orange
|
|
"#06B6D4", # Cyan
|
|
"#84CC16", # Lime
|
|
"#A855F7", # Violet
|
|
"#F43F5E", # Rose
|
|
"#22D3EE", # Sky
|
|
"#FACC15", # Yellow
|
|
]
|
|
|
|
|
|
@router.post("/{project_id}/task-statuses", status_code=status.HTTP_201_CREATED)
|
|
async def create_custom_task_status(
|
|
project_id: int,
|
|
status_create: dict,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_coordinator_or_admin)
|
|
):
|
|
"""Create a new custom task status for a project"""
|
|
from schemas.custom_task_status import (
|
|
CustomTaskStatusCreate,
|
|
CustomTaskStatus,
|
|
CustomTaskStatusResponse,
|
|
AllTaskStatusesResponse,
|
|
SystemTaskStatus
|
|
)
|
|
import uuid
|
|
|
|
# Validate input
|
|
try:
|
|
status_create = CustomTaskStatusCreate(**status_create)
|
|
except Exception as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=str(e)
|
|
)
|
|
|
|
# Verify project exists
|
|
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"
|
|
)
|
|
|
|
# Get existing custom statuses
|
|
custom_statuses_data = db_project.custom_task_statuses or []
|
|
if isinstance(custom_statuses_data, str):
|
|
try:
|
|
custom_statuses_data = json.loads(custom_statuses_data)
|
|
except (json.JSONDecodeError, TypeError):
|
|
custom_statuses_data = []
|
|
|
|
# Validate status name uniqueness within project
|
|
existing_names = [s.get('name', '').lower() for s in custom_statuses_data if isinstance(s, dict)]
|
|
system_names = [s['name'].lower() for s in SYSTEM_TASK_STATUSES]
|
|
|
|
if status_create.name.lower() in existing_names:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=f"Status with name '{status_create.name}' already exists in this project"
|
|
)
|
|
|
|
if status_create.name.lower() in system_names:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=f"Status name '{status_create.name}' conflicts with a system status"
|
|
)
|
|
|
|
# Auto-assign color from palette if not provided
|
|
if status_create.color is None:
|
|
# Get colors already in use
|
|
used_colors = [s.get('color', '').upper() for s in custom_statuses_data if isinstance(s, dict)]
|
|
|
|
# Find first available color from palette
|
|
assigned_color = None
|
|
for color in DEFAULT_STATUS_COLOR_PALETTE:
|
|
if color.upper() not in used_colors:
|
|
assigned_color = color
|
|
break
|
|
|
|
# If all palette colors are used, cycle back to the first one
|
|
if assigned_color is None:
|
|
assigned_color = DEFAULT_STATUS_COLOR_PALETTE[len(custom_statuses_data) % len(DEFAULT_STATUS_COLOR_PALETTE)]
|
|
else:
|
|
assigned_color = status_create.color
|
|
|
|
# Generate unique status ID
|
|
status_id = f"custom_{uuid.uuid4().hex[:8]}"
|
|
|
|
# Determine order (append to end)
|
|
max_order = max([s.get('order', -1) for s in custom_statuses_data if isinstance(s, dict)], default=-1)
|
|
new_order = max_order + 1
|
|
|
|
# Create new status object
|
|
new_status = {
|
|
"id": status_id,
|
|
"name": status_create.name,
|
|
"color": assigned_color,
|
|
"order": new_order,
|
|
"is_default": False # New statuses are never default by default
|
|
}
|
|
|
|
# Add status to project's custom_task_statuses JSON array
|
|
custom_statuses_data.append(new_status)
|
|
db_project.custom_task_statuses = custom_statuses_data
|
|
|
|
# Use flag_modified for JSON column updates
|
|
flag_modified(db_project, 'custom_task_statuses')
|
|
|
|
try:
|
|
db.commit()
|
|
db.refresh(db_project)
|
|
except Exception as e:
|
|
db.rollback()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to create custom task status: {str(e)}"
|
|
)
|
|
|
|
# Build response with created status and all statuses
|
|
created_status = CustomTaskStatus(**new_status)
|
|
|
|
# Build all statuses for response
|
|
system_statuses = [
|
|
SystemTaskStatus(
|
|
id=status["id"],
|
|
name=status["name"],
|
|
color=status["color"],
|
|
is_system=True
|
|
)
|
|
for status in SYSTEM_TASK_STATUSES
|
|
]
|
|
|
|
custom_statuses = [CustomTaskStatus(**s) for s in custom_statuses_data if isinstance(s, dict)]
|
|
|
|
# Find default status
|
|
default_status_id = "not_started"
|
|
for status_data in custom_statuses_data:
|
|
if isinstance(status_data, dict) and status_data.get('is_default', False):
|
|
default_status_id = status_data.get('id')
|
|
break
|
|
|
|
all_statuses = AllTaskStatusesResponse(
|
|
statuses=custom_statuses,
|
|
system_statuses=system_statuses,
|
|
default_status_id=default_status_id
|
|
)
|
|
|
|
return CustomTaskStatusResponse(
|
|
message=f"Custom task status '{status_create.name}' created successfully",
|
|
status=created_status,
|
|
all_statuses=all_statuses
|
|
)
|
|
|
|
|
|
@router.put("/{project_id}/task-statuses/{status_id}")
|
|
async def update_custom_task_status(
|
|
project_id: int,
|
|
status_id: str,
|
|
status_update: dict,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_coordinator_or_admin)
|
|
):
|
|
"""Update a custom task status"""
|
|
from schemas.custom_task_status import (
|
|
CustomTaskStatusUpdate,
|
|
CustomTaskStatus,
|
|
CustomTaskStatusResponse,
|
|
AllTaskStatusesResponse,
|
|
SystemTaskStatus
|
|
)
|
|
|
|
# Validate input
|
|
try:
|
|
status_update = CustomTaskStatusUpdate(**status_update)
|
|
except Exception as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=str(e)
|
|
)
|
|
|
|
# Verify project exists
|
|
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"
|
|
)
|
|
|
|
# Get existing custom statuses
|
|
custom_statuses_data = db_project.custom_task_statuses or []
|
|
if isinstance(custom_statuses_data, str):
|
|
try:
|
|
custom_statuses_data = json.loads(custom_statuses_data)
|
|
except (json.JSONDecodeError, TypeError):
|
|
custom_statuses_data = []
|
|
|
|
# Find the status to update
|
|
status_to_update = None
|
|
status_index = None
|
|
for idx, status_data in enumerate(custom_statuses_data):
|
|
if isinstance(status_data, dict) and status_data.get('id') == status_id:
|
|
status_to_update = status_data
|
|
status_index = idx
|
|
break
|
|
|
|
if status_to_update is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Custom task status with ID '{status_id}' not found"
|
|
)
|
|
|
|
# Validate name uniqueness if name is being changed
|
|
if status_update.name is not None and status_update.name != status_to_update.get('name'):
|
|
# Check against other custom statuses
|
|
existing_names = [
|
|
s.get('name', '').lower()
|
|
for i, s in enumerate(custom_statuses_data)
|
|
if isinstance(s, dict) and i != status_index
|
|
]
|
|
|
|
# Check against system statuses
|
|
system_names = [s['name'].lower() for s in SYSTEM_TASK_STATUSES]
|
|
|
|
if status_update.name.lower() in existing_names:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=f"Status with name '{status_update.name}' already exists in this project"
|
|
)
|
|
|
|
if status_update.name.lower() in system_names:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=f"Status name '{status_update.name}' conflicts with a system status"
|
|
)
|
|
|
|
# Update name
|
|
status_to_update['name'] = status_update.name
|
|
|
|
# Update color if provided
|
|
if status_update.color is not None:
|
|
status_to_update['color'] = status_update.color
|
|
|
|
# Handle is_default flag
|
|
if status_update.is_default is not None:
|
|
if status_update.is_default:
|
|
# If setting as default, unset other default statuses
|
|
for status_data in custom_statuses_data:
|
|
if isinstance(status_data, dict):
|
|
status_data['is_default'] = False
|
|
|
|
# Set this status as default
|
|
status_to_update['is_default'] = True
|
|
else:
|
|
# Just unset this status as default
|
|
status_to_update['is_default'] = False
|
|
|
|
# Update the status in the list
|
|
custom_statuses_data[status_index] = status_to_update
|
|
db_project.custom_task_statuses = custom_statuses_data
|
|
|
|
# Use flag_modified for JSON column updates
|
|
flag_modified(db_project, 'custom_task_statuses')
|
|
|
|
try:
|
|
db.commit()
|
|
db.refresh(db_project)
|
|
except Exception as e:
|
|
db.rollback()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to update custom task status: {str(e)}"
|
|
)
|
|
|
|
# Build response with updated status and all statuses
|
|
updated_status = CustomTaskStatus(**status_to_update)
|
|
|
|
# Build all statuses for response
|
|
system_statuses = [
|
|
SystemTaskStatus(
|
|
id=status["id"],
|
|
name=status["name"],
|
|
color=status["color"],
|
|
is_system=True
|
|
)
|
|
for status in SYSTEM_TASK_STATUSES
|
|
]
|
|
|
|
custom_statuses = [CustomTaskStatus(**s) for s in custom_statuses_data if isinstance(s, dict)]
|
|
|
|
# Find default status
|
|
default_status_id = "not_started"
|
|
for status_data in custom_statuses_data:
|
|
if isinstance(status_data, dict) and status_data.get('is_default', False):
|
|
default_status_id = status_data.get('id')
|
|
break
|
|
|
|
all_statuses = AllTaskStatusesResponse(
|
|
statuses=custom_statuses,
|
|
system_statuses=system_statuses,
|
|
default_status_id=default_status_id
|
|
)
|
|
|
|
return CustomTaskStatusResponse(
|
|
message=f"Custom task status updated successfully",
|
|
status=updated_status,
|
|
all_statuses=all_statuses
|
|
)
|
|
|
|
|
|
@router.delete("/{project_id}/task-statuses/{status_id}")
|
|
async def delete_custom_task_status(
|
|
project_id: int,
|
|
status_id: str,
|
|
reassign_to_status_id: Optional[str] = Query(None, description="Status ID to reassign tasks to"),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_coordinator_or_admin)
|
|
):
|
|
"""Delete a custom task status"""
|
|
from schemas.custom_task_status import (
|
|
CustomTaskStatus,
|
|
CustomTaskStatusResponse,
|
|
AllTaskStatusesResponse,
|
|
SystemTaskStatus,
|
|
TaskStatusInUseError
|
|
)
|
|
from models.task import Task
|
|
|
|
# Verify project exists
|
|
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"
|
|
)
|
|
|
|
# Get existing custom statuses
|
|
custom_statuses_data = db_project.custom_task_statuses or []
|
|
if isinstance(custom_statuses_data, str):
|
|
try:
|
|
custom_statuses_data = json.loads(custom_statuses_data)
|
|
except (json.JSONDecodeError, TypeError):
|
|
custom_statuses_data = []
|
|
|
|
# Find the status to delete
|
|
status_to_delete = None
|
|
status_index = None
|
|
for idx, status_data in enumerate(custom_statuses_data):
|
|
if isinstance(status_data, dict) and status_data.get('id') == status_id:
|
|
status_to_delete = status_data
|
|
status_index = idx
|
|
break
|
|
|
|
if status_to_delete is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Custom task status with ID '{status_id}' not found"
|
|
)
|
|
|
|
# Prevent deletion of last status
|
|
if len(custom_statuses_data) == 1:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="Cannot delete the last custom status. At least one custom status must remain."
|
|
)
|
|
|
|
# Check if status is in use by any tasks
|
|
tasks_using_status = db.query(Task).filter(
|
|
Task.project_id == project_id,
|
|
Task.status == status_id
|
|
).all()
|
|
|
|
if tasks_using_status:
|
|
task_ids = [task.id for task in tasks_using_status]
|
|
|
|
# If status is in use and no reassignment provided, return error
|
|
if reassign_to_status_id is None:
|
|
error_response = TaskStatusInUseError(
|
|
error=f"Cannot delete status '{status_to_delete.get('name')}' because it is currently in use by {len(tasks_using_status)} task(s)",
|
|
status_id=status_id,
|
|
status_name=status_to_delete.get('name', 'Unknown'),
|
|
task_count=len(tasks_using_status),
|
|
task_ids=task_ids
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=error_response.dict()
|
|
)
|
|
|
|
# Validate reassignment status exists
|
|
reassign_status_exists = False
|
|
|
|
# Check if it's a system status
|
|
system_status_ids = [s['id'] for s in SYSTEM_TASK_STATUSES]
|
|
if reassign_to_status_id in system_status_ids:
|
|
reassign_status_exists = True
|
|
else:
|
|
# Check if it's a custom status (and not the one being deleted)
|
|
for status_data in custom_statuses_data:
|
|
if isinstance(status_data, dict) and status_data.get('id') == reassign_to_status_id:
|
|
if status_data.get('id') != status_id:
|
|
reassign_status_exists = True
|
|
break
|
|
|
|
if not reassign_status_exists:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Reassignment status ID '{reassign_to_status_id}' not found or is the same as the status being deleted"
|
|
)
|
|
|
|
# Reassign all tasks to the new status
|
|
for task in tasks_using_status:
|
|
task.status = reassign_to_status_id
|
|
|
|
# Check if deleting default status
|
|
was_default = status_to_delete.get('is_default', False)
|
|
|
|
# Remove the status from the list
|
|
custom_statuses_data.pop(status_index)
|
|
|
|
# If we deleted the default status, auto-assign new default
|
|
if was_default and len(custom_statuses_data) > 0:
|
|
# Set the first remaining custom status as default
|
|
custom_statuses_data[0]['is_default'] = True
|
|
|
|
# Update the project
|
|
db_project.custom_task_statuses = custom_statuses_data
|
|
|
|
# Use flag_modified for JSON column updates
|
|
flag_modified(db_project, 'custom_task_statuses')
|
|
|
|
try:
|
|
db.commit()
|
|
db.refresh(db_project)
|
|
except Exception as e:
|
|
db.rollback()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to delete custom task status: {str(e)}"
|
|
)
|
|
|
|
# Build response with all remaining statuses
|
|
system_statuses = [
|
|
SystemTaskStatus(
|
|
id=status["id"],
|
|
name=status["name"],
|
|
color=status["color"],
|
|
is_system=True
|
|
)
|
|
for status in SYSTEM_TASK_STATUSES
|
|
]
|
|
|
|
custom_statuses = [CustomTaskStatus(**s) for s in custom_statuses_data if isinstance(s, dict)]
|
|
|
|
# Find default status
|
|
default_status_id = "not_started" # Default to system status
|
|
for status_data in custom_statuses_data:
|
|
if isinstance(status_data, dict) and status_data.get('is_default', False):
|
|
default_status_id = status_data.get('id')
|
|
break
|
|
|
|
all_statuses = AllTaskStatusesResponse(
|
|
statuses=custom_statuses,
|
|
system_statuses=system_statuses,
|
|
default_status_id=default_status_id
|
|
)
|
|
|
|
message = f"Custom task status '{status_to_delete.get('name')}' deleted successfully"
|
|
if tasks_using_status:
|
|
message += f" and {len(tasks_using_status)} task(s) reassigned"
|
|
|
|
return CustomTaskStatusResponse(
|
|
message=message,
|
|
status=None,
|
|
all_statuses=all_statuses
|
|
)
|
|
|
|
|
|
@router.patch("/{project_id}/task-statuses/reorder")
|
|
async def reorder_custom_task_statuses(
|
|
project_id: int,
|
|
reorder_data: dict,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_coordinator_or_admin)
|
|
):
|
|
"""Reorder custom task statuses"""
|
|
from schemas.custom_task_status import (
|
|
CustomTaskStatusReorder,
|
|
CustomTaskStatus,
|
|
CustomTaskStatusResponse,
|
|
AllTaskStatusesResponse,
|
|
SystemTaskStatus
|
|
)
|
|
|
|
# Validate input
|
|
try:
|
|
reorder_request = CustomTaskStatusReorder(**reorder_data)
|
|
except Exception as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=str(e)
|
|
)
|
|
|
|
# Verify project exists
|
|
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"
|
|
)
|
|
|
|
# Get existing custom statuses
|
|
custom_statuses_data = db_project.custom_task_statuses or []
|
|
if isinstance(custom_statuses_data, str):
|
|
try:
|
|
custom_statuses_data = json.loads(custom_statuses_data)
|
|
except (json.JSONDecodeError, TypeError):
|
|
custom_statuses_data = []
|
|
|
|
# Validate all status IDs are present
|
|
existing_status_ids = {s.get('id') for s in custom_statuses_data if isinstance(s, dict)}
|
|
provided_status_ids = set(reorder_request.status_ids)
|
|
|
|
# Check if all provided IDs exist
|
|
missing_ids = provided_status_ids - existing_status_ids
|
|
if missing_ids:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Status IDs not found: {', '.join(missing_ids)}"
|
|
)
|
|
|
|
# Check if all existing IDs are provided
|
|
extra_ids = existing_status_ids - provided_status_ids
|
|
if extra_ids:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Missing status IDs in reorder request: {', '.join(extra_ids)}"
|
|
)
|
|
|
|
# Create a mapping of status_id to status data
|
|
status_map = {
|
|
s.get('id'): s
|
|
for s in custom_statuses_data
|
|
if isinstance(s, dict)
|
|
}
|
|
|
|
# Reorder statuses and update order field
|
|
reordered_statuses = []
|
|
for order, status_id in enumerate(reorder_request.status_ids):
|
|
status_data = status_map[status_id].copy()
|
|
status_data['order'] = order
|
|
reordered_statuses.append(status_data)
|
|
|
|
# Update the project with reordered statuses
|
|
db_project.custom_task_statuses = reordered_statuses
|
|
|
|
# Use flag_modified for JSON column updates
|
|
flag_modified(db_project, 'custom_task_statuses')
|
|
|
|
try:
|
|
db.commit()
|
|
db.refresh(db_project)
|
|
except Exception as e:
|
|
db.rollback()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to reorder custom task statuses: {str(e)}"
|
|
)
|
|
|
|
# Build response with reordered statuses
|
|
system_statuses = [
|
|
SystemTaskStatus(
|
|
id=status["id"],
|
|
name=status["name"],
|
|
color=status["color"],
|
|
is_system=True
|
|
)
|
|
for status in SYSTEM_TASK_STATUSES
|
|
]
|
|
|
|
custom_statuses = [CustomTaskStatus(**s) for s in reordered_statuses if isinstance(s, dict)]
|
|
|
|
# Find default status
|
|
default_status_id = "not_started" # Default to system status
|
|
for status_data in reordered_statuses:
|
|
if isinstance(status_data, dict) and status_data.get('is_default', False):
|
|
default_status_id = status_data.get('id')
|
|
break
|
|
|
|
all_statuses = AllTaskStatusesResponse(
|
|
statuses=custom_statuses,
|
|
system_statuses=system_statuses,
|
|
default_status_id=default_status_id
|
|
)
|
|
|
|
return CustomTaskStatusResponse(
|
|
message="Custom task statuses reordered successfully",
|
|
status=None,
|
|
all_statuses=all_statuses
|
|
)
|