c09710f4e5
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.
2298 lines
79 KiB
Python
2298 lines
79 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 schemas.submission_config import ProjectSubmissionConfig, SubmissionTypeConfig
|
|
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,
|
|
user_avatar_url=member.user.avatar_url
|
|
)
|
|
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)
|
|
|
|
|
|
# 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)
|
|
async def get_project_submission_config(
|
|
project_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user_with_db)
|
|
):
|
|
"""Get the project's per-task-type submission configuration"""
|
|
project = db.query(Project).filter(Project.id == project_id).first()
|
|
|
|
if not project:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Project not found"
|
|
)
|
|
|
|
if current_user.role == UserRole.ARTIST:
|
|
member = db.query(ProjectMember).filter(
|
|
ProjectMember.project_id == project_id,
|
|
ProjectMember.user_id == current_user.id
|
|
).first()
|
|
if not member:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Access denied to this project"
|
|
)
|
|
|
|
config_data = project.submission_config_by_task_type
|
|
if isinstance(config_data, str):
|
|
try:
|
|
config_data = json.loads(config_data)
|
|
except (json.JSONDecodeError, TypeError):
|
|
config_data = {}
|
|
|
|
return ProjectSubmissionConfig(config_by_task_type=config_data or {})
|
|
|
|
|
|
@router.put("/{project_id}/submission-config", response_model=ProjectSubmissionConfig)
|
|
async def update_project_submission_config(
|
|
project_id: int,
|
|
submission_config: ProjectSubmissionConfig,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_coordinator_or_admin)
|
|
):
|
|
"""Replace the project's per-task-type submission configuration"""
|
|
db_project = db.query(Project).filter(Project.id == project_id).first()
|
|
|
|
if not db_project:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Project not found"
|
|
)
|
|
|
|
all_types_response = _build_all_task_types_response(db_project)
|
|
valid_task_types = set(all_types_response.asset_task_types) | set(all_types_response.shot_task_types)
|
|
for task_type in submission_config.config_by_task_type.keys():
|
|
if task_type not in valid_task_types:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=f"'{task_type}' is not a valid task type for this project"
|
|
)
|
|
|
|
db_project.submission_config_by_task_type = {
|
|
task_type: config.dict()
|
|
for task_type, config in submission_config.config_by_task_type.items()
|
|
}
|
|
|
|
try:
|
|
db.commit()
|
|
db.refresh(db_project)
|
|
except Exception:
|
|
db.rollback()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to update submission configuration"
|
|
)
|
|
|
|
return ProjectSubmissionConfig(config_by_task_type=db_project.submission_config_by_task_type or {})
|
|
|
|
|
|
# Project Thumbnail Management Endpoints
|
|
|
|
@router.post("/{project_id}/thumbnail", status_code=status.HTTP_201_CREATED)
|
|
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
|
|
)
|