1594 lines
63 KiB
Python
1594 lines
63 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, status
|
|
from sqlalchemy.orm import Session, joinedload
|
|
from sqlalchemy import and_, or_
|
|
from typing import List, Optional
|
|
import os
|
|
import shutil
|
|
import json
|
|
from datetime import datetime
|
|
|
|
from database import get_db
|
|
from models.task import Task, ProductionNote, TaskAttachment, Submission, Review
|
|
from models.user import User, UserRole, DepartmentRole
|
|
from models.project import Project, ProjectMember
|
|
from models.asset import Asset
|
|
from models.shot import Shot
|
|
from models.episode import Episode
|
|
from models.episode import Episode
|
|
from schemas.task import (
|
|
TaskCreate, TaskUpdate, TaskResponse, TaskListResponse, TaskStatusUpdate, TaskAssignment,
|
|
ProductionNoteCreate, ProductionNoteUpdate, ProductionNoteResponse,
|
|
TaskAttachmentCreate, TaskAttachmentResponse,
|
|
SubmissionCreate, SubmissionResponse,
|
|
BulkStatusUpdate, BulkAssignment, BulkActionResult
|
|
)
|
|
from utils.auth import get_current_user_from_token, _get_user_from_db, require_role
|
|
from utils.notifications import notification_service
|
|
from utils.file_handler import file_handler
|
|
|
|
router = APIRouter()
|
|
|
|
# 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"}
|
|
]
|
|
|
|
|
|
def get_project_default_status(db: Session, project_id: int) -> str:
|
|
"""Get the default status for a project (custom or system)."""
|
|
project = db.query(Project).filter(Project.id == project_id).first()
|
|
if not project:
|
|
return "not_started"
|
|
|
|
# Check for custom statuses
|
|
if project.custom_task_statuses:
|
|
custom_statuses_data = 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) and status_data.get('is_default', False):
|
|
return status_data.get('id', 'not_started')
|
|
|
|
# Default to system status
|
|
return "not_started"
|
|
|
|
|
|
def validate_task_status(db: Session, project_id: int, status_value: str) -> bool:
|
|
"""
|
|
Validate that a status exists for a project (either system or custom).
|
|
Returns True if valid, False otherwise.
|
|
"""
|
|
# Check system statuses first
|
|
system_status_ids = [s["id"] for s in SYSTEM_TASK_STATUSES]
|
|
if status_value in system_status_ids:
|
|
return True
|
|
|
|
# Check custom statuses for the project
|
|
project = db.query(Project).filter(Project.id == project_id).first()
|
|
if not project:
|
|
return False
|
|
|
|
if project.custom_task_statuses:
|
|
custom_statuses_data = project.custom_task_statuses
|
|
if isinstance(custom_statuses_data, str):
|
|
try:
|
|
custom_statuses_data = json.loads(custom_statuses_data)
|
|
except (json.JSONDecodeError, TypeError):
|
|
return False
|
|
|
|
if isinstance(custom_statuses_data, list):
|
|
for status_data in custom_statuses_data:
|
|
if isinstance(status_data, dict) and status_data.get('id') == status_value:
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def require_admin_or_coordinator(
|
|
token_data: dict = Depends(get_current_user_from_token),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Dependency to require admin permission or coordinator role."""
|
|
current_user = _get_user_from_db(db, token_data["user_id"])
|
|
if not current_user.is_admin and current_user.role != UserRole.COORDINATOR:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Admin permission or Coordinator role required"
|
|
)
|
|
return current_user
|
|
|
|
|
|
def get_current_user(
|
|
token_data: dict = Depends(get_current_user_from_token),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get current user with proper database dependency."""
|
|
return _get_user_from_db(db, token_data["user_id"])
|
|
|
|
|
|
def require_role(required_roles: list):
|
|
"""Create a dependency that requires specific user roles."""
|
|
def role_checker(
|
|
token_data: dict = Depends(get_current_user_from_token),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
current_user = _get_user_from_db(db, token_data["user_id"])
|
|
if current_user.role not in required_roles:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Insufficient permissions"
|
|
)
|
|
return current_user
|
|
return role_checker
|
|
|
|
|
|
@router.get("/", response_model=List[TaskListResponse])
|
|
async def get_tasks(
|
|
project_id: Optional[int] = Query(None, description="Filter by project ID"),
|
|
shot_id: Optional[int] = Query(None, description="Filter by shot ID"),
|
|
asset_id: Optional[int] = Query(None, description="Filter by asset ID"),
|
|
assigned_user_id: Optional[int] = Query(None, description="Filter by assigned user ID"),
|
|
status: Optional[str] = Query(None, description="Filter by task status"),
|
|
task_type: Optional[str] = Query(None, description="Filter by task type"),
|
|
department_role: Optional[str] = Query(None, description="Filter by department role for assignment"),
|
|
skip: int = Query(0, ge=0),
|
|
limit: int = Query(100, ge=1, le=1000),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Get tasks with filtering options.
|
|
Artists see only their assigned tasks unless they have coordinator+ role.
|
|
"""
|
|
try:
|
|
query = db.query(Task).options(
|
|
joinedload(Task.project),
|
|
joinedload(Task.episode),
|
|
joinedload(Task.shot),
|
|
joinedload(Task.asset),
|
|
joinedload(Task.assigned_user)
|
|
)
|
|
|
|
# Determine project context from shot/asset if not provided
|
|
context_project_id = project_id
|
|
|
|
# Apply shot/asset filter first and exclude tasks from deleted shots/assets
|
|
if shot_id:
|
|
# Only include tasks from non-deleted shots
|
|
query = query.filter(Task.shot_id == shot_id).join(Shot).filter(Shot.deleted_at.is_(None))
|
|
# Get the shot to find its project if not already specified
|
|
if not context_project_id:
|
|
shot = db.query(Shot).join(Episode).filter(
|
|
Shot.id == shot_id,
|
|
Shot.deleted_at.is_(None)
|
|
).first()
|
|
if shot and shot.episode:
|
|
context_project_id = shot.episode.project_id
|
|
|
|
if asset_id:
|
|
# Only include tasks from non-deleted assets
|
|
query = query.filter(Task.asset_id == asset_id).join(Asset).filter(Asset.deleted_at.is_(None))
|
|
# Get the asset to find its project if not already specified
|
|
if not context_project_id:
|
|
asset = db.query(Asset).filter(
|
|
Asset.id == asset_id,
|
|
Asset.deleted_at.is_(None)
|
|
).first()
|
|
if asset:
|
|
context_project_id = asset.project_id
|
|
|
|
# Exclude tasks that are soft deleted or belong to deleted shots/assets
|
|
if not shot_id and not asset_id:
|
|
# When not filtering by specific shot/asset, exclude tasks from deleted parents
|
|
query = query.outerjoin(Shot, Task.shot_id == Shot.id).outerjoin(Asset, Task.asset_id == Asset.id)
|
|
query = query.filter(
|
|
Task.deleted_at.is_(None),
|
|
or_(
|
|
and_(Task.shot_id.is_(None), Task.asset_id.is_(None)), # Tasks without shot/asset
|
|
and_(Task.shot_id.isnot(None), Shot.deleted_at.is_(None)), # Tasks with non-deleted shot
|
|
and_(Task.asset_id.isnot(None), Asset.deleted_at.is_(None)) # Tasks with non-deleted asset
|
|
)
|
|
)
|
|
else:
|
|
# When filtering by shot/asset, just exclude soft deleted tasks
|
|
query = query.filter(Task.deleted_at.is_(None))
|
|
|
|
# Role-based filtering
|
|
if current_user.is_admin:
|
|
# Admins can see all tasks
|
|
if context_project_id:
|
|
query = query.filter(Task.project_id == context_project_id)
|
|
elif current_user.role in [UserRole.COORDINATOR, UserRole.DIRECTOR]:
|
|
# Coordinators and directors can see all tasks in their projects
|
|
if context_project_id:
|
|
query = query.filter(Task.project_id == context_project_id)
|
|
elif current_user.role == UserRole.ARTIST:
|
|
if shot_id or asset_id:
|
|
# When viewing a specific shot/asset, check project membership
|
|
if context_project_id:
|
|
is_member = db.query(ProjectMember).filter(
|
|
and_(
|
|
ProjectMember.project_id == context_project_id,
|
|
ProjectMember.user_id == current_user.id
|
|
)
|
|
).first()
|
|
if is_member:
|
|
# Project members can see all tasks for the shot/asset
|
|
pass # No additional filtering needed
|
|
else:
|
|
# Non-members only see their assigned tasks
|
|
query = query.filter(Task.assigned_user_id == current_user.id)
|
|
else:
|
|
# When browsing all tasks, artists only see their assigned tasks
|
|
query = query.filter(Task.assigned_user_id == current_user.id)
|
|
|
|
if assigned_user_id:
|
|
query = query.filter(Task.assigned_user_id == assigned_user_id)
|
|
|
|
if status:
|
|
query = query.filter(Task.status == status)
|
|
|
|
if task_type:
|
|
query = query.filter(Task.task_type == task_type)
|
|
|
|
# Department role filtering for task assignment
|
|
if department_role and (current_user.role == UserRole.COORDINATOR or current_user.is_admin):
|
|
# Find users with matching department role in the project
|
|
if context_project_id:
|
|
subquery = db.query(ProjectMember.user_id).filter(
|
|
and_(
|
|
ProjectMember.project_id == context_project_id,
|
|
ProjectMember.department_role == department_role
|
|
)
|
|
)
|
|
query = query.filter(Task.assigned_user_id.in_(subquery))
|
|
|
|
tasks = query.offset(skip).limit(limit).all()
|
|
|
|
# Build response with related entity names
|
|
result = []
|
|
for task in tasks:
|
|
task_data = {
|
|
"id": task.id,
|
|
"name": task.name,
|
|
"task_type": task.task_type,
|
|
"status": task.status,
|
|
"deadline": task.deadline,
|
|
"project_id": task.project_id,
|
|
"project_name": task.project.name if task.project else None,
|
|
"episode_id": task.episode_id,
|
|
"episode_name": task.episode.name if task.episode else None,
|
|
"shot_id": task.shot_id,
|
|
"shot_name": task.shot.name if task.shot else None,
|
|
"asset_id": task.asset_id,
|
|
"asset_name": task.asset.name if task.asset else None,
|
|
"assigned_user_id": task.assigned_user_id,
|
|
"assigned_user_name": f"{task.assigned_user.first_name} {task.assigned_user.last_name}" if task.assigned_user else None,
|
|
"created_at": task.created_at,
|
|
"updated_at": task.updated_at
|
|
}
|
|
result.append(TaskListResponse(**task_data))
|
|
|
|
return result
|
|
except Exception as e:
|
|
import traceback
|
|
print(f"ERROR in get_tasks: {str(e)}")
|
|
print(traceback.format_exc())
|
|
raise HTTPException(status_code=500, detail=f"Error fetching tasks: {str(e)}")
|
|
|
|
|
|
@router.get("/my-tasks", response_model=List[TaskListResponse])
|
|
async def get_my_tasks(
|
|
project_id: Optional[int] = Query(None, description="Filter by project ID"),
|
|
status: Optional[str] = Query(None, description="Filter by task status"),
|
|
skip: int = Query(0, ge=0),
|
|
limit: int = Query(100, ge=1, le=1000),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Get all tasks assigned to the current user across all projects.
|
|
This endpoint is designed for the My Tasks page.
|
|
"""
|
|
try:
|
|
# Build query to get tasks assigned to current user
|
|
query = db.query(Task).options(
|
|
joinedload(Task.project),
|
|
joinedload(Task.episode),
|
|
joinedload(Task.shot),
|
|
joinedload(Task.asset),
|
|
joinedload(Task.assigned_user)
|
|
)
|
|
|
|
# Always filter by assignee (current user)
|
|
query = query.filter(Task.assigned_user_id == current_user.id)
|
|
|
|
# Exclude soft-deleted tasks and tasks from deleted parents
|
|
query = query.outerjoin(Shot, Task.shot_id == Shot.id).outerjoin(Asset, Task.asset_id == Asset.id)
|
|
query = query.filter(
|
|
Task.deleted_at.is_(None),
|
|
or_(
|
|
and_(Task.shot_id.is_(None), Task.asset_id.is_(None)), # Tasks without shot/asset
|
|
and_(Task.shot_id.isnot(None), Shot.deleted_at.is_(None)), # Tasks with non-deleted shot
|
|
and_(Task.asset_id.isnot(None), Asset.deleted_at.is_(None)) # Tasks with non-deleted asset
|
|
)
|
|
)
|
|
|
|
# Filter by project if provided
|
|
if project_id:
|
|
query = query.filter(Task.project_id == project_id)
|
|
|
|
# Filter by status if provided
|
|
if status:
|
|
query = query.filter(Task.status == status)
|
|
|
|
# Apply pagination
|
|
query = query.order_by(Task.deadline.asc().nullslast(), Task.updated_at.desc())
|
|
tasks = query.offset(skip).limit(limit).all()
|
|
|
|
# Build response
|
|
result = []
|
|
for task in tasks:
|
|
result.append(TaskListResponse(
|
|
id=task.id,
|
|
name=task.name,
|
|
task_type=task.task_type,
|
|
status=task.status,
|
|
deadline=task.deadline,
|
|
project_id=task.project_id,
|
|
project_name=task.project.name if task.project else "Unknown",
|
|
episode_id=task.episode_id,
|
|
episode_name=task.episode.name if task.episode else None,
|
|
shot_id=task.shot_id,
|
|
shot_name=task.shot.name if task.shot else None,
|
|
asset_id=task.asset_id,
|
|
asset_name=task.asset.name if task.asset else None,
|
|
assigned_user_id=task.assigned_user_id,
|
|
assigned_user_name=f"{task.assigned_user.first_name} {task.assigned_user.last_name}" if task.assigned_user else None,
|
|
created_at=task.created_at,
|
|
updated_at=task.updated_at
|
|
))
|
|
|
|
return result
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
import traceback
|
|
print(f"ERROR in get_my_tasks: {str(e)}")
|
|
print(traceback.format_exc())
|
|
raise HTTPException(status_code=500, detail=f"Error fetching my tasks: {str(e)}")
|
|
|
|
|
|
@router.post("/", response_model=TaskResponse)
|
|
async def create_task(
|
|
task: TaskCreate,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_admin_or_coordinator)
|
|
):
|
|
"""Create a new task. Only coordinators and users with admin permission can create tasks."""
|
|
|
|
# Verify project exists
|
|
project = db.query(Project).filter(Project.id == task.project_id).first()
|
|
if not project:
|
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
|
|
# Verify episode exists if specified
|
|
if task.episode_id:
|
|
episode = db.query(Episode).filter(Episode.id == task.episode_id).first()
|
|
if not episode or episode.project_id != task.project_id:
|
|
raise HTTPException(status_code=404, detail="Episode not found or not in specified project")
|
|
|
|
# Verify shot exists if specified
|
|
if task.shot_id:
|
|
shot = db.query(Shot).filter(Shot.id == task.shot_id).first()
|
|
if not shot:
|
|
raise HTTPException(status_code=404, detail="Shot not found")
|
|
if task.episode_id and shot.episode_id != task.episode_id:
|
|
raise HTTPException(status_code=400, detail="Shot does not belong to specified episode")
|
|
|
|
# Verify asset exists if specified
|
|
if task.asset_id:
|
|
asset = db.query(Asset).filter(Asset.id == task.asset_id).first()
|
|
if not asset or asset.project_id != task.project_id:
|
|
raise HTTPException(status_code=404, detail="Asset not found or not in specified project")
|
|
|
|
# Verify assigned user exists and is a project member if specified
|
|
if task.assigned_user_id:
|
|
assigned_user = db.query(User).filter(User.id == task.assigned_user_id).first()
|
|
if not assigned_user:
|
|
raise HTTPException(status_code=404, detail="Assigned user not found")
|
|
|
|
# Check if user is a project member
|
|
project_member = db.query(ProjectMember).filter(
|
|
and_(
|
|
ProjectMember.user_id == task.assigned_user_id,
|
|
ProjectMember.project_id == task.project_id
|
|
)
|
|
).first()
|
|
if not project_member:
|
|
raise HTTPException(status_code=400, detail="Assigned user is not a member of this project")
|
|
|
|
# Use default status if not specified
|
|
task_data = task.model_dump()
|
|
if not task_data.get('status') or task_data['status'] == 'not_started':
|
|
task_data['status'] = get_project_default_status(db, task.project_id)
|
|
else:
|
|
# Validate the provided status
|
|
if not validate_task_status(db, task.project_id, task_data['status']):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Invalid status '{task_data['status']}' for this project"
|
|
)
|
|
|
|
# Create task
|
|
db_task = Task(**task_data)
|
|
db.add(db_task)
|
|
db.commit()
|
|
db.refresh(db_task)
|
|
|
|
# Load related entities for response
|
|
db_task = db.query(Task).options(
|
|
joinedload(Task.project),
|
|
joinedload(Task.episode),
|
|
joinedload(Task.shot),
|
|
joinedload(Task.asset),
|
|
joinedload(Task.assigned_user)
|
|
).filter(Task.id == db_task.id).first()
|
|
|
|
# Build response
|
|
task_data = {
|
|
**task.model_dump(),
|
|
"id": db_task.id,
|
|
"created_at": db_task.created_at,
|
|
"updated_at": db_task.updated_at,
|
|
"project_name": db_task.project.name if db_task.project else None,
|
|
"episode_name": db_task.episode.name if db_task.episode else None,
|
|
"shot_name": db_task.shot.name if db_task.shot else None,
|
|
"asset_name": db_task.asset.name if db_task.asset else None,
|
|
"assigned_user_name": f"{db_task.assigned_user.first_name} {db_task.assigned_user.last_name}" if db_task.assigned_user else None,
|
|
"assigned_user_email": db_task.assigned_user.email if db_task.assigned_user else None
|
|
}
|
|
|
|
return TaskResponse(**task_data)
|
|
|
|
|
|
# Bulk action endpoints (must be before /{task_id} routes)
|
|
@router.put("/bulk/status", response_model=BulkActionResult)
|
|
async def bulk_update_task_status(
|
|
bulk_update: BulkStatusUpdate,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Update status for multiple tasks atomically.
|
|
Coordinators and admins can update any tasks.
|
|
Artists can only update their own assigned tasks.
|
|
Validates that the status is valid for each task's project.
|
|
"""
|
|
|
|
if not bulk_update.task_ids:
|
|
raise HTTPException(status_code=400, detail="No task IDs provided")
|
|
|
|
success_count = 0
|
|
failed_count = 0
|
|
errors = []
|
|
|
|
try:
|
|
# Start transaction
|
|
# Fetch all tasks in one query
|
|
tasks = db.query(Task).filter(Task.id.in_(bulk_update.task_ids)).all()
|
|
|
|
if not tasks:
|
|
# Return error result instead of raising exception
|
|
return BulkActionResult(
|
|
success_count=0,
|
|
failed_count=len(bulk_update.task_ids),
|
|
errors=[{"task_id": tid, "error": "Task not found"} for tid in bulk_update.task_ids]
|
|
)
|
|
|
|
# Create a map of task IDs to tasks for quick lookup
|
|
task_map = {task.id: task for task in tasks}
|
|
|
|
# Verify permissions and status validity for all tasks before making any changes
|
|
for task_id in bulk_update.task_ids:
|
|
task = task_map.get(task_id)
|
|
|
|
if not task:
|
|
errors.append({
|
|
"task_id": task_id,
|
|
"error": "Task not found"
|
|
})
|
|
failed_count += 1
|
|
continue
|
|
|
|
# Permission check
|
|
if current_user.role == UserRole.ARTIST:
|
|
if task.assigned_user_id != current_user.id:
|
|
errors.append({
|
|
"task_id": task_id,
|
|
"error": "Not authorized to update this task"
|
|
})
|
|
failed_count += 1
|
|
continue
|
|
elif current_user.role not in [UserRole.COORDINATOR, UserRole.DIRECTOR] and not current_user.is_admin:
|
|
errors.append({
|
|
"task_id": task_id,
|
|
"error": "Insufficient permissions"
|
|
})
|
|
failed_count += 1
|
|
continue
|
|
|
|
# Validate status for the task's project
|
|
if not validate_task_status(db, task.project_id, bulk_update.status):
|
|
errors.append({
|
|
"task_id": task_id,
|
|
"error": f"Invalid status '{bulk_update.status}' for task's project"
|
|
})
|
|
failed_count += 1
|
|
continue
|
|
|
|
# If any task failed permission or validation check, rollback all changes
|
|
if failed_count > 0:
|
|
db.rollback()
|
|
return BulkActionResult(
|
|
success_count=0,
|
|
failed_count=failed_count,
|
|
errors=errors
|
|
)
|
|
|
|
# All permission checks and validations passed, update all tasks
|
|
updated_task_ids = []
|
|
for task_id in bulk_update.task_ids:
|
|
task = task_map.get(task_id)
|
|
if task:
|
|
old_status = task.status # Store old status for consistency tracking
|
|
task.status = bulk_update.status
|
|
updated_task_ids.append((task_id, old_status, bulk_update.status))
|
|
success_count += 1
|
|
|
|
# Commit all changes atomically
|
|
db.commit()
|
|
|
|
# DATA CONSISTENCY: Propagate bulk task updates and validate consistency
|
|
from services.data_consistency import create_data_consistency_service
|
|
consistency_service = create_data_consistency_service(db)
|
|
|
|
for task_id, old_status, new_status in updated_task_ids:
|
|
propagation_result = consistency_service.propagate_task_update(
|
|
task_id=task_id,
|
|
old_status=old_status,
|
|
new_status=new_status
|
|
)
|
|
|
|
# Log any consistency issues (but don't fail the request)
|
|
if not propagation_result.get('success') or not propagation_result.get('validation_result', {}).get('valid'):
|
|
import logging
|
|
logger = logging.getLogger(__name__)
|
|
logger.warning(f"Bulk task update consistency issue for task {task_id}: {propagation_result}")
|
|
|
|
return BulkActionResult(
|
|
success_count=success_count,
|
|
failed_count=failed_count,
|
|
errors=errors if errors else None
|
|
)
|
|
|
|
except Exception as e:
|
|
db.rollback()
|
|
import traceback
|
|
print(f"ERROR in bulk_update_task_status: {str(e)}")
|
|
print(traceback.format_exc())
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Failed to update tasks: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.put("/bulk/assign", response_model=BulkActionResult)
|
|
async def bulk_assign_tasks(
|
|
bulk_assignment: BulkAssignment,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_admin_or_coordinator)
|
|
):
|
|
"""
|
|
Assign multiple tasks to a user atomically.
|
|
Only coordinators and admins can perform bulk assignments.
|
|
"""
|
|
|
|
if not bulk_assignment.task_ids:
|
|
raise HTTPException(status_code=400, detail="No task IDs provided")
|
|
|
|
success_count = 0
|
|
failed_count = 0
|
|
errors = []
|
|
|
|
try:
|
|
# Verify assigned user exists
|
|
assigned_user = db.query(User).filter(User.id == bulk_assignment.assigned_user_id).first()
|
|
if not assigned_user:
|
|
raise HTTPException(status_code=404, detail="Assigned user not found")
|
|
|
|
# Fetch all tasks in one query
|
|
tasks = db.query(Task).filter(Task.id.in_(bulk_assignment.task_ids)).all()
|
|
|
|
if not tasks:
|
|
# Return error result instead of raising exception
|
|
return BulkActionResult(
|
|
success_count=0,
|
|
failed_count=len(bulk_assignment.task_ids),
|
|
errors=[{"task_id": tid, "error": "Task not found"} for tid in bulk_assignment.task_ids]
|
|
)
|
|
|
|
# Create a map of task IDs to tasks for quick lookup
|
|
task_map = {task.id: task for task in tasks}
|
|
|
|
# Verify user is a member of all task projects before making any changes
|
|
project_ids = set(task.project_id for task in tasks)
|
|
|
|
for project_id in project_ids:
|
|
project_member = db.query(ProjectMember).filter(
|
|
and_(
|
|
ProjectMember.user_id == bulk_assignment.assigned_user_id,
|
|
ProjectMember.project_id == project_id
|
|
)
|
|
).first()
|
|
|
|
if not project_member:
|
|
# Find all tasks in this project and mark them as failed
|
|
for task in tasks:
|
|
if task.project_id == project_id:
|
|
errors.append({
|
|
"task_id": task.id,
|
|
"error": f"User is not a member of project {project_id}"
|
|
})
|
|
failed_count += 1
|
|
|
|
# If any task failed validation, rollback all changes
|
|
if failed_count > 0:
|
|
db.rollback()
|
|
return BulkActionResult(
|
|
success_count=0,
|
|
failed_count=failed_count,
|
|
errors=errors
|
|
)
|
|
|
|
# All validation passed, assign all tasks
|
|
for task_id in bulk_assignment.task_ids:
|
|
task = task_map.get(task_id)
|
|
if task:
|
|
task.assigned_user_id = bulk_assignment.assigned_user_id
|
|
success_count += 1
|
|
|
|
# Send notification to assigned user
|
|
notification_service.notify_task_assigned(db, task, assigned_user, current_user)
|
|
|
|
# Commit all changes atomically
|
|
db.commit()
|
|
|
|
return BulkActionResult(
|
|
success_count=success_count,
|
|
failed_count=failed_count,
|
|
errors=errors if errors else None
|
|
)
|
|
|
|
except HTTPException:
|
|
db.rollback()
|
|
raise
|
|
except Exception as e:
|
|
db.rollback()
|
|
import traceback
|
|
print(f"ERROR in bulk_assign_tasks: {str(e)}")
|
|
print(traceback.format_exc())
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Failed to assign tasks: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.get("/{task_id}", response_model=TaskResponse)
|
|
async def get_task(
|
|
task_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Get a specific task by ID."""
|
|
|
|
task = db.query(Task).options(
|
|
joinedload(Task.project),
|
|
joinedload(Task.episode),
|
|
joinedload(Task.shot),
|
|
joinedload(Task.asset),
|
|
joinedload(Task.assigned_user)
|
|
).outerjoin(Shot, Task.shot_id == Shot.id).outerjoin(Asset, Task.asset_id == Asset.id).filter(
|
|
Task.id == task_id,
|
|
Task.deleted_at.is_(None),
|
|
or_(
|
|
and_(Task.shot_id.is_(None), Task.asset_id.is_(None)), # Tasks without shot/asset
|
|
and_(Task.shot_id.isnot(None), Shot.deleted_at.is_(None)), # Tasks with non-deleted shot
|
|
and_(Task.asset_id.isnot(None), Asset.deleted_at.is_(None)) # Tasks with non-deleted asset
|
|
)
|
|
).first()
|
|
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
|
|
# Artists can only view their own tasks
|
|
if current_user.role == UserRole.ARTIST and task.assigned_user_id != current_user.id:
|
|
raise HTTPException(status_code=403, detail="Not authorized to view this task")
|
|
|
|
# Build response
|
|
task_data = {
|
|
"id": task.id,
|
|
"name": task.name,
|
|
"description": task.description,
|
|
"task_type": task.task_type,
|
|
"status": task.status,
|
|
"deadline": task.deadline,
|
|
"project_id": task.project_id,
|
|
"episode_id": task.episode_id,
|
|
"shot_id": task.shot_id,
|
|
"asset_id": task.asset_id,
|
|
"assigned_user_id": task.assigned_user_id,
|
|
"created_at": task.created_at,
|
|
"updated_at": task.updated_at,
|
|
"project_name": task.project.name if task.project else None,
|
|
"episode_name": task.episode.name if task.episode else None,
|
|
"shot_name": task.shot.name if task.shot else None,
|
|
"asset_name": task.asset.name if task.asset else None,
|
|
"assigned_user_name": f"{task.assigned_user.first_name} {task.assigned_user.last_name}" if task.assigned_user else None,
|
|
"assigned_user_email": task.assigned_user.email if task.assigned_user else None
|
|
}
|
|
|
|
return TaskResponse(**task_data)
|
|
|
|
|
|
@router.put("/{task_id}", response_model=TaskResponse)
|
|
async def update_task(
|
|
task_id: int,
|
|
task_update: TaskUpdate,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Update a task. Coordinators can update any field, artists can only update status."""
|
|
|
|
task = db.query(Task).outerjoin(Shot, Task.shot_id == Shot.id).outerjoin(Asset, Task.asset_id == Asset.id).filter(
|
|
Task.id == task_id,
|
|
Task.deleted_at.is_(None),
|
|
or_(
|
|
and_(Task.shot_id.is_(None), Task.asset_id.is_(None)), # Tasks without shot/asset
|
|
and_(Task.shot_id.isnot(None), Shot.deleted_at.is_(None)), # Tasks with non-deleted shot
|
|
and_(Task.asset_id.isnot(None), Asset.deleted_at.is_(None)) # Tasks with non-deleted asset
|
|
)
|
|
).first()
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
|
|
# Permission check
|
|
if current_user.role == UserRole.ARTIST:
|
|
if task.assigned_user_id != current_user.id:
|
|
raise HTTPException(status_code=403, detail="Not authorized to update this task")
|
|
# Artists can only update status
|
|
if task_update.model_dump(exclude_unset=True).keys() - {"status"}:
|
|
raise HTTPException(status_code=403, detail="Artists can only update task status")
|
|
elif current_user.role != UserRole.COORDINATOR and not current_user.is_admin:
|
|
raise HTTPException(status_code=403, detail="Not authorized to update tasks")
|
|
|
|
# Verify assigned user if being updated
|
|
if task_update.assigned_user_id is not None:
|
|
if task_update.assigned_user_id != 0: # 0 means unassign
|
|
assigned_user = db.query(User).filter(User.id == task_update.assigned_user_id).first()
|
|
if not assigned_user:
|
|
raise HTTPException(status_code=404, detail="Assigned user not found")
|
|
|
|
# Check if user is a project member
|
|
project_member = db.query(ProjectMember).filter(
|
|
and_(
|
|
ProjectMember.user_id == task_update.assigned_user_id,
|
|
ProjectMember.project_id == task.project_id
|
|
)
|
|
).first()
|
|
if not project_member:
|
|
raise HTTPException(status_code=400, detail="Assigned user is not a member of this project")
|
|
else:
|
|
task_update.assigned_user_id = None
|
|
|
|
# Validate status if being updated
|
|
update_data = task_update.model_dump(exclude_unset=True)
|
|
old_status = task.status # Store old status for consistency tracking
|
|
if 'status' in update_data:
|
|
if not validate_task_status(db, task.project_id, update_data['status']):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Invalid status '{update_data['status']}' for this project"
|
|
)
|
|
|
|
# Update task
|
|
for field, value in update_data.items():
|
|
setattr(task, field, value)
|
|
|
|
db.commit()
|
|
db.refresh(task)
|
|
|
|
# DATA CONSISTENCY: Propagate task update and validate consistency
|
|
if 'status' in update_data:
|
|
from services.data_consistency import create_data_consistency_service
|
|
consistency_service = create_data_consistency_service(db)
|
|
propagation_result = consistency_service.propagate_task_update(
|
|
task_id=task.id,
|
|
old_status=old_status,
|
|
new_status=task.status
|
|
)
|
|
|
|
# Log any consistency issues (but don't fail the request)
|
|
if not propagation_result.get('success') or not propagation_result.get('validation_result', {}).get('valid'):
|
|
import logging
|
|
logger = logging.getLogger(__name__)
|
|
logger.warning(f"Task update consistency issue: {propagation_result}")
|
|
|
|
# Load related entities for response
|
|
task = db.query(Task).options(
|
|
joinedload(Task.project),
|
|
joinedload(Task.episode),
|
|
joinedload(Task.shot),
|
|
joinedload(Task.asset),
|
|
joinedload(Task.assigned_user)
|
|
).filter(Task.id == task_id).first()
|
|
|
|
# Build response
|
|
task_data = {
|
|
"id": task.id,
|
|
"name": task.name,
|
|
"description": task.description,
|
|
"task_type": task.task_type,
|
|
"status": task.status,
|
|
"deadline": task.deadline,
|
|
"project_id": task.project_id,
|
|
"episode_id": task.episode_id,
|
|
"shot_id": task.shot_id,
|
|
"asset_id": task.asset_id,
|
|
"assigned_user_id": task.assigned_user_id,
|
|
"created_at": task.created_at,
|
|
"updated_at": task.updated_at,
|
|
"project_name": task.project.name if task.project else None,
|
|
"episode_name": task.episode.name if task.episode else None,
|
|
"shot_name": task.shot.name if task.shot else None,
|
|
"asset_name": task.asset.name if task.asset else None,
|
|
"assigned_user_name": f"{task.assigned_user.first_name} {task.assigned_user.last_name}" if task.assigned_user else None,
|
|
"assigned_user_email": task.assigned_user.email if task.assigned_user else None
|
|
}
|
|
|
|
return TaskResponse(**task_data)
|
|
|
|
|
|
@router.put("/{task_id}/status", response_model=TaskResponse)
|
|
async def update_task_status(
|
|
task_id: int,
|
|
status_update: TaskStatusUpdate,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Update task status. Artists can update their own tasks, coordinators can update any."""
|
|
|
|
task = db.query(Task).outerjoin(Shot, Task.shot_id == Shot.id).outerjoin(Asset, Task.asset_id == Asset.id).filter(
|
|
Task.id == task_id,
|
|
Task.deleted_at.is_(None),
|
|
or_(
|
|
and_(Task.shot_id.is_(None), Task.asset_id.is_(None)), # Tasks without shot/asset
|
|
and_(Task.shot_id.isnot(None), Shot.deleted_at.is_(None)), # Tasks with non-deleted shot
|
|
and_(Task.asset_id.isnot(None), Asset.deleted_at.is_(None)) # Tasks with non-deleted asset
|
|
)
|
|
).first()
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
|
|
# Permission check
|
|
if current_user.role == UserRole.ARTIST and task.assigned_user_id != current_user.id:
|
|
raise HTTPException(status_code=403, detail="Not authorized to update this task")
|
|
elif current_user.role not in [UserRole.ARTIST, UserRole.COORDINATOR] and not current_user.is_admin:
|
|
raise HTTPException(status_code=403, detail="Not authorized to update task status")
|
|
|
|
# Validate the status for the task's project
|
|
if not validate_task_status(db, task.project_id, status_update.status):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Invalid status '{status_update.status}' for this project"
|
|
)
|
|
|
|
old_status = task.status # Store old status for consistency tracking
|
|
task.status = status_update.status
|
|
db.commit()
|
|
db.refresh(task)
|
|
|
|
# DATA CONSISTENCY: Propagate task status update and validate consistency
|
|
from services.data_consistency import create_data_consistency_service
|
|
consistency_service = create_data_consistency_service(db)
|
|
propagation_result = consistency_service.propagate_task_update(
|
|
task_id=task.id,
|
|
old_status=old_status,
|
|
new_status=task.status
|
|
)
|
|
|
|
# Log any consistency issues (but don't fail the request)
|
|
if not propagation_result.get('success') or not propagation_result.get('validation_result', {}).get('valid'):
|
|
import logging
|
|
logger = logging.getLogger(__name__)
|
|
logger.warning(f"Task status update consistency issue: {propagation_result}")
|
|
|
|
# Load related entities for response
|
|
task = db.query(Task).options(
|
|
joinedload(Task.project),
|
|
joinedload(Task.episode),
|
|
joinedload(Task.shot),
|
|
joinedload(Task.asset),
|
|
joinedload(Task.assigned_user)
|
|
).filter(Task.id == task_id).first()
|
|
|
|
# Build response
|
|
task_data = {
|
|
"id": task.id,
|
|
"name": task.name,
|
|
"description": task.description,
|
|
"task_type": task.task_type,
|
|
"status": task.status,
|
|
"deadline": task.deadline,
|
|
"project_id": task.project_id,
|
|
"episode_id": task.episode_id,
|
|
"shot_id": task.shot_id,
|
|
"asset_id": task.asset_id,
|
|
"assigned_user_id": task.assigned_user_id,
|
|
"created_at": task.created_at,
|
|
"updated_at": task.updated_at,
|
|
"project_name": task.project.name if task.project else None,
|
|
"episode_name": task.episode.name if task.episode else None,
|
|
"shot_name": task.shot.name if task.shot else None,
|
|
"asset_name": task.asset.name if task.asset else None,
|
|
"assigned_user_name": f"{task.assigned_user.first_name} {task.assigned_user.last_name}" if task.assigned_user else None,
|
|
"assigned_user_email": task.assigned_user.email if task.assigned_user else None
|
|
}
|
|
|
|
return TaskResponse(**task_data)
|
|
|
|
|
|
@router.put("/{task_id}/assign", response_model=TaskResponse)
|
|
async def assign_task(
|
|
task_id: int,
|
|
assignment: TaskAssignment,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_admin_or_coordinator)
|
|
):
|
|
"""Assign a task to a user with department role filtering."""
|
|
|
|
task = db.query(Task).outerjoin(Shot, Task.shot_id == Shot.id).outerjoin(Asset, Task.asset_id == Asset.id).filter(
|
|
Task.id == task_id,
|
|
Task.deleted_at.is_(None),
|
|
or_(
|
|
and_(Task.shot_id.is_(None), Task.asset_id.is_(None)), # Tasks without shot/asset
|
|
and_(Task.shot_id.isnot(None), Shot.deleted_at.is_(None)), # Tasks with non-deleted shot
|
|
and_(Task.asset_id.isnot(None), Asset.deleted_at.is_(None)) # Tasks with non-deleted asset
|
|
)
|
|
).first()
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
|
|
# Verify assigned user exists and is a project member
|
|
assigned_user = db.query(User).filter(User.id == assignment.assigned_user_id).first()
|
|
if not assigned_user:
|
|
raise HTTPException(status_code=404, detail="Assigned user not found")
|
|
|
|
# Check if user is a project member with appropriate department role
|
|
project_member = db.query(ProjectMember).filter(
|
|
and_(
|
|
ProjectMember.user_id == assignment.assigned_user_id,
|
|
ProjectMember.project_id == task.project_id
|
|
)
|
|
).first()
|
|
|
|
if not project_member:
|
|
raise HTTPException(status_code=400, detail="User is not a member of this project")
|
|
|
|
# Check if user's department role matches task type (optional validation)
|
|
task_to_department_mapping = {
|
|
"layout": DepartmentRole.LAYOUT,
|
|
"animation": DepartmentRole.ANIMATION,
|
|
"lighting": DepartmentRole.LIGHTING,
|
|
"compositing": DepartmentRole.COMPOSITE,
|
|
"modeling": DepartmentRole.MODELING,
|
|
"rigging": DepartmentRole.RIGGING,
|
|
"surfacing": DepartmentRole.SURFACING,
|
|
"simulation": None # Simulation can be handled by multiple departments
|
|
}
|
|
|
|
# task.task_type is already a string, not an enum
|
|
task_type_str = task.task_type if isinstance(task.task_type, str) else task.task_type.value
|
|
expected_department = task_to_department_mapping.get(task_type_str)
|
|
if expected_department and project_member.department_role != expected_department:
|
|
# This is a warning, not an error - coordinators can override
|
|
pass
|
|
|
|
task.assigned_user_id = assignment.assigned_user_id
|
|
db.commit()
|
|
db.refresh(task)
|
|
|
|
# Send notification to assigned user
|
|
notification_service.notify_task_assigned(db, task, assigned_user, current_user)
|
|
|
|
# Load related entities for response
|
|
task = db.query(Task).options(
|
|
joinedload(Task.project),
|
|
joinedload(Task.episode),
|
|
joinedload(Task.shot),
|
|
joinedload(Task.asset),
|
|
joinedload(Task.assigned_user)
|
|
).filter(Task.id == task_id).first()
|
|
|
|
# Build response
|
|
task_data = {
|
|
"id": task.id,
|
|
"name": task.name,
|
|
"description": task.description,
|
|
"task_type": task.task_type,
|
|
"status": task.status,
|
|
"deadline": task.deadline,
|
|
"project_id": task.project_id,
|
|
"episode_id": task.episode_id,
|
|
"shot_id": task.shot_id,
|
|
"asset_id": task.asset_id,
|
|
"assigned_user_id": task.assigned_user_id,
|
|
"created_at": task.created_at,
|
|
"updated_at": task.updated_at,
|
|
"project_name": task.project.name if task.project else None,
|
|
"episode_name": task.episode.name if task.episode else None,
|
|
"shot_name": task.shot.name if task.shot else None,
|
|
"asset_name": task.asset.name if task.asset else None,
|
|
"assigned_user_name": f"{task.assigned_user.first_name} {task.assigned_user.last_name}" if task.assigned_user else None,
|
|
"assigned_user_email": task.assigned_user.email if task.assigned_user else None
|
|
}
|
|
|
|
return TaskResponse(**task_data)
|
|
|
|
|
|
@router.delete("/{task_id}")
|
|
async def delete_task(
|
|
task_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_admin_or_coordinator)
|
|
):
|
|
"""Delete a task. Only coordinators and users with admin permission can delete tasks."""
|
|
|
|
task = db.query(Task).outerjoin(Shot, Task.shot_id == Shot.id).outerjoin(Asset, Task.asset_id == Asset.id).filter(
|
|
Task.id == task_id,
|
|
Task.deleted_at.is_(None),
|
|
or_(
|
|
and_(Task.shot_id.is_(None), Task.asset_id.is_(None)), # Tasks without shot/asset
|
|
and_(Task.shot_id.isnot(None), Shot.deleted_at.is_(None)), # Tasks with non-deleted shot
|
|
and_(Task.asset_id.isnot(None), Asset.deleted_at.is_(None)) # Tasks with non-deleted asset
|
|
)
|
|
).first()
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
|
|
db.delete(task)
|
|
db.commit()
|
|
|
|
return {"message": "Task deleted successfully"}
|
|
|
|
|
|
# Production Notes endpoints
|
|
@router.get("/{task_id}/notes", response_model=List[ProductionNoteResponse])
|
|
async def get_task_notes(
|
|
task_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Get all notes for a task, organized in threaded structure."""
|
|
|
|
task = db.query(Task).outerjoin(Shot, Task.shot_id == Shot.id).outerjoin(Asset, Task.asset_id == Asset.id).filter(
|
|
Task.id == task_id,
|
|
Task.deleted_at.is_(None),
|
|
or_(
|
|
and_(Task.shot_id.is_(None), Task.asset_id.is_(None)), # Tasks without shot/asset
|
|
and_(Task.shot_id.isnot(None), Shot.deleted_at.is_(None)), # Tasks with non-deleted shot
|
|
and_(Task.asset_id.isnot(None), Asset.deleted_at.is_(None)) # Tasks with non-deleted asset
|
|
)
|
|
).first()
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
|
|
# Artists can only view notes for their own tasks
|
|
if current_user.role == UserRole.ARTIST and task.assigned_user_id != current_user.id:
|
|
raise HTTPException(status_code=403, detail="Not authorized to view notes for this task")
|
|
|
|
# Get all active notes for the task with user information (exclude soft deleted)
|
|
notes = db.query(ProductionNote).options(
|
|
joinedload(ProductionNote.user)
|
|
).filter(
|
|
ProductionNote.task_id == task_id,
|
|
ProductionNote.deleted_at.is_(None)
|
|
).order_by(ProductionNote.created_at).all()
|
|
|
|
# Build threaded structure
|
|
notes_dict = {}
|
|
root_notes = []
|
|
|
|
for note in notes:
|
|
note_data = {
|
|
"id": note.id,
|
|
"content": note.content,
|
|
"parent_note_id": note.parent_note_id,
|
|
"task_id": note.task_id,
|
|
"user_id": note.user_id,
|
|
"created_at": note.created_at,
|
|
"updated_at": note.updated_at,
|
|
"user_first_name": note.user.first_name,
|
|
"user_last_name": note.user.last_name,
|
|
"user_email": note.user.email,
|
|
"child_notes": []
|
|
}
|
|
|
|
notes_dict[note.id] = note_data
|
|
|
|
if note.parent_note_id is None:
|
|
root_notes.append(note_data)
|
|
else:
|
|
if note.parent_note_id in notes_dict:
|
|
notes_dict[note.parent_note_id]["child_notes"].append(note_data)
|
|
|
|
return [ProductionNoteResponse(**note) for note in root_notes]
|
|
|
|
|
|
@router.post("/{task_id}/notes", response_model=ProductionNoteResponse)
|
|
async def create_task_note(
|
|
task_id: int,
|
|
note: ProductionNoteCreate,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Create a new note for a task."""
|
|
|
|
task = db.query(Task).outerjoin(Shot, Task.shot_id == Shot.id).outerjoin(Asset, Task.asset_id == Asset.id).filter(
|
|
Task.id == task_id,
|
|
Task.deleted_at.is_(None),
|
|
or_(
|
|
and_(Task.shot_id.is_(None), Task.asset_id.is_(None)), # Tasks without shot/asset
|
|
and_(Task.shot_id.isnot(None), Shot.deleted_at.is_(None)), # Tasks with non-deleted shot
|
|
and_(Task.asset_id.isnot(None), Asset.deleted_at.is_(None)) # Tasks with non-deleted asset
|
|
)
|
|
).first()
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
|
|
# Artists can only add notes to their own tasks
|
|
if current_user.role == UserRole.ARTIST and task.assigned_user_id != current_user.id:
|
|
raise HTTPException(status_code=403, detail="Not authorized to add notes to this task")
|
|
|
|
# Verify parent note exists if specified
|
|
if note.parent_note_id:
|
|
parent_note = db.query(ProductionNote).filter(
|
|
and_(
|
|
ProductionNote.id == note.parent_note_id,
|
|
ProductionNote.task_id == task_id,
|
|
ProductionNote.deleted_at.is_(None)
|
|
)
|
|
).first()
|
|
if not parent_note:
|
|
raise HTTPException(status_code=404, detail="Parent note not found")
|
|
|
|
# Create note
|
|
db_note = ProductionNote(
|
|
task_id=task_id,
|
|
user_id=current_user.id,
|
|
content=note.content,
|
|
parent_note_id=note.parent_note_id
|
|
)
|
|
db.add(db_note)
|
|
db.commit()
|
|
db.refresh(db_note)
|
|
|
|
# Load user information for response
|
|
db_note = db.query(ProductionNote).options(
|
|
joinedload(ProductionNote.user)
|
|
).filter(ProductionNote.id == db_note.id).first()
|
|
|
|
note_data = {
|
|
"id": db_note.id,
|
|
"content": db_note.content,
|
|
"parent_note_id": db_note.parent_note_id,
|
|
"task_id": db_note.task_id,
|
|
"user_id": db_note.user_id,
|
|
"created_at": db_note.created_at,
|
|
"updated_at": db_note.updated_at,
|
|
"user_first_name": db_note.user.first_name,
|
|
"user_last_name": db_note.user.last_name,
|
|
"user_email": db_note.user.email,
|
|
"child_notes": []
|
|
}
|
|
|
|
return ProductionNoteResponse(**note_data)
|
|
|
|
|
|
@router.put("/{task_id}/notes/{note_id}", response_model=ProductionNoteResponse)
|
|
async def update_task_note(
|
|
task_id: int,
|
|
note_id: int,
|
|
note_update: ProductionNoteUpdate,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Update a note. Users can only update their own notes."""
|
|
|
|
note = db.query(ProductionNote).options(
|
|
joinedload(ProductionNote.user)
|
|
).filter(
|
|
and_(
|
|
ProductionNote.id == note_id,
|
|
ProductionNote.task_id == task_id,
|
|
ProductionNote.deleted_at.is_(None)
|
|
)
|
|
).first()
|
|
|
|
if not note:
|
|
raise HTTPException(status_code=404, detail="Note not found")
|
|
|
|
# Users can only update their own notes, unless they have admin permission
|
|
if note.user_id != current_user.id and not current_user.is_admin:
|
|
raise HTTPException(status_code=403, detail="Not authorized to update this note")
|
|
|
|
note.content = note_update.content
|
|
db.commit()
|
|
db.refresh(note)
|
|
|
|
note_data = {
|
|
"id": note.id,
|
|
"content": note.content,
|
|
"parent_note_id": note.parent_note_id,
|
|
"task_id": note.task_id,
|
|
"user_id": note.user_id,
|
|
"created_at": note.created_at,
|
|
"updated_at": note.updated_at,
|
|
"user_first_name": note.user.first_name,
|
|
"user_last_name": note.user.last_name,
|
|
"user_email": note.user.email,
|
|
"child_notes": []
|
|
}
|
|
|
|
return ProductionNoteResponse(**note_data)
|
|
|
|
|
|
@router.delete("/{task_id}/notes/{note_id}")
|
|
async def delete_task_note(
|
|
task_id: int,
|
|
note_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Delete a note. Users can only delete their own notes."""
|
|
|
|
note = db.query(ProductionNote).filter(
|
|
and_(
|
|
ProductionNote.id == note_id,
|
|
ProductionNote.task_id == task_id,
|
|
ProductionNote.deleted_at.is_(None)
|
|
)
|
|
).first()
|
|
|
|
if not note:
|
|
raise HTTPException(status_code=404, detail="Note not found")
|
|
|
|
# Users can only delete their own notes, unless they have admin permission
|
|
if note.user_id != current_user.id and not current_user.is_admin:
|
|
raise HTTPException(status_code=403, detail="Not authorized to delete this note")
|
|
|
|
db.delete(note)
|
|
db.commit()
|
|
|
|
return {"message": "Note deleted successfully"}
|
|
|
|
|
|
# Task Attachments endpoints
|
|
@router.get("/{task_id}/attachments", response_model=List[TaskAttachmentResponse])
|
|
async def get_task_attachments(
|
|
task_id: int,
|
|
attachment_type: Optional[str] = Query(None, description="Filter by attachment type"),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Get all attachments for a task."""
|
|
|
|
task = db.query(Task).filter(Task.id == task_id).first()
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
|
|
# Artists can only view attachments for their own tasks
|
|
if current_user.role == UserRole.ARTIST and task.assigned_user_id != current_user.id:
|
|
raise HTTPException(status_code=403, detail="Not authorized to view attachments for this task")
|
|
|
|
query = db.query(TaskAttachment).options(
|
|
joinedload(TaskAttachment.user)
|
|
).filter(
|
|
TaskAttachment.task_id == task_id,
|
|
TaskAttachment.deleted_at.is_(None)
|
|
)
|
|
|
|
if attachment_type:
|
|
query = query.filter(TaskAttachment.attachment_type == attachment_type)
|
|
|
|
attachments = query.order_by(TaskAttachment.uploaded_at.desc()).all()
|
|
|
|
result = []
|
|
for attachment in attachments:
|
|
attachment_data = {
|
|
"id": attachment.id,
|
|
"task_id": attachment.task_id,
|
|
"user_id": attachment.user_id,
|
|
"file_name": attachment.file_name,
|
|
"file_path": attachment.file_path,
|
|
"file_type": attachment.file_type,
|
|
"file_size": attachment.file_size,
|
|
"attachment_type": attachment.attachment_type,
|
|
"description": attachment.description,
|
|
"uploaded_at": attachment.uploaded_at,
|
|
"user_first_name": attachment.user.first_name,
|
|
"user_last_name": attachment.user.last_name,
|
|
"download_url": f"/files/attachments/{attachment.id}",
|
|
"thumbnail_url": f"/files/attachments/{attachment.id}?thumbnail=true" if file_handler.is_image_file(attachment.file_path) else None
|
|
}
|
|
result.append(TaskAttachmentResponse(**attachment_data))
|
|
|
|
return result
|
|
|
|
|
|
@router.post("/{task_id}/attachments", response_model=TaskAttachmentResponse)
|
|
async def upload_task_attachment(
|
|
task_id: int,
|
|
file: UploadFile = File(...),
|
|
attachment_type: str = "reference",
|
|
description: Optional[str] = None,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Upload an attachment for a task."""
|
|
|
|
task = db.query(Task).filter(Task.id == task_id).first()
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
|
|
# Artists can only upload attachments to their own tasks
|
|
if current_user.role == UserRole.ARTIST and task.assigned_user_id != current_user.id:
|
|
raise HTTPException(status_code=403, detail="Not authorized to upload attachments to this task")
|
|
|
|
# Validate file using file handler
|
|
file_handler.validate_file(file, file_handler.MAX_ATTACHMENT_SIZE, db)
|
|
|
|
# Save file using file handler
|
|
file_path, file_size = await file_handler.save_file(file, task_id, "attachment", db=db)
|
|
|
|
# Create thumbnail for images
|
|
thumbnail_path = None
|
|
if file_handler.is_image_file(file_path):
|
|
thumbnail_path = file_handler.create_thumbnail(file_path)
|
|
|
|
# Create attachment record
|
|
db_attachment = TaskAttachment(
|
|
task_id=task_id,
|
|
user_id=current_user.id,
|
|
file_name=file.filename or "unknown",
|
|
file_path=file_path,
|
|
file_type=file.content_type or "application/octet-stream",
|
|
file_size=file_size,
|
|
attachment_type=attachment_type,
|
|
description=description
|
|
)
|
|
db.add(db_attachment)
|
|
db.commit()
|
|
db.refresh(db_attachment)
|
|
|
|
# Load user information for response
|
|
db_attachment = db.query(TaskAttachment).options(
|
|
joinedload(TaskAttachment.user)
|
|
).filter(TaskAttachment.id == db_attachment.id).first()
|
|
|
|
attachment_data = {
|
|
"id": db_attachment.id,
|
|
"task_id": db_attachment.task_id,
|
|
"user_id": db_attachment.user_id,
|
|
"file_name": db_attachment.file_name,
|
|
"file_path": db_attachment.file_path,
|
|
"file_type": db_attachment.file_type,
|
|
"file_size": db_attachment.file_size,
|
|
"attachment_type": db_attachment.attachment_type,
|
|
"description": db_attachment.description,
|
|
"uploaded_at": db_attachment.uploaded_at,
|
|
"user_first_name": db_attachment.user.first_name,
|
|
"user_last_name": db_attachment.user.last_name,
|
|
"download_url": f"/files/attachments/{db_attachment.id}",
|
|
"thumbnail_url": f"/files/attachments/{db_attachment.id}?thumbnail=true" if file_handler.is_image_file(db_attachment.file_path) else None
|
|
}
|
|
|
|
return TaskAttachmentResponse(**attachment_data)
|
|
|
|
|
|
@router.delete("/{task_id}/attachments/{attachment_id}")
|
|
async def delete_task_attachment(
|
|
task_id: int,
|
|
attachment_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Delete a task attachment. Users can only delete their own attachments."""
|
|
|
|
attachment = db.query(TaskAttachment).filter(
|
|
and_(
|
|
TaskAttachment.id == attachment_id,
|
|
TaskAttachment.task_id == task_id,
|
|
TaskAttachment.deleted_at.is_(None)
|
|
)
|
|
).first()
|
|
|
|
if not attachment:
|
|
raise HTTPException(status_code=404, detail="Attachment not found")
|
|
|
|
# Users can only delete their own attachments, unless they're admin/coordinator
|
|
if (attachment.user_id != current_user.id and
|
|
not current_user.is_admin and current_user.role != UserRole.COORDINATOR):
|
|
raise HTTPException(status_code=403, detail="Not authorized to delete this attachment")
|
|
|
|
# Delete file from filesystem using file handler
|
|
file_handler.delete_file(attachment.file_path)
|
|
|
|
db.delete(attachment)
|
|
db.commit()
|
|
|
|
return {"message": "Attachment deleted successfully"}
|
|
|
|
|
|
# Submission endpoints
|
|
@router.get("/{task_id}/submissions", response_model=List[SubmissionResponse])
|
|
async def get_task_submissions(
|
|
task_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Get all submissions for a task."""
|
|
|
|
task = db.query(Task).filter(Task.id == task_id).first()
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
|
|
# Artists can only view submissions for their own tasks
|
|
if current_user.role == UserRole.ARTIST and task.assigned_user_id != current_user.id:
|
|
raise HTTPException(status_code=403, detail="Not authorized to view submissions for this task")
|
|
|
|
submissions = db.query(Submission).options(
|
|
joinedload(Submission.user),
|
|
joinedload(Submission.reviews).joinedload(Review.reviewer)
|
|
).filter(
|
|
Submission.task_id == task_id,
|
|
Submission.deleted_at.is_(None)
|
|
).order_by(Submission.submitted_at.desc()).all()
|
|
|
|
result = []
|
|
for submission in submissions:
|
|
# Get latest review
|
|
latest_review = None
|
|
if submission.reviews:
|
|
latest_review_obj = max(submission.reviews, key=lambda r: r.reviewed_at)
|
|
latest_review = {
|
|
"id": latest_review_obj.id,
|
|
"submission_id": latest_review_obj.submission_id,
|
|
"reviewer_id": latest_review_obj.reviewer_id,
|
|
"decision": latest_review_obj.decision,
|
|
"feedback": latest_review_obj.feedback,
|
|
"reviewed_at": latest_review_obj.reviewed_at,
|
|
"reviewer_first_name": latest_review_obj.reviewer.first_name,
|
|
"reviewer_last_name": latest_review_obj.reviewer.last_name
|
|
}
|
|
|
|
submission_data = {
|
|
"id": submission.id,
|
|
"task_id": submission.task_id,
|
|
"user_id": submission.user_id,
|
|
"file_path": submission.file_path,
|
|
"file_name": submission.file_name,
|
|
"version_number": submission.version_number,
|
|
"notes": submission.notes,
|
|
"submitted_at": submission.submitted_at,
|
|
"user_first_name": submission.user.first_name,
|
|
"user_last_name": submission.user.last_name,
|
|
"latest_review": latest_review,
|
|
"download_url": f"/files/submissions/{submission.id}",
|
|
"thumbnail_url": f"/files/submissions/{submission.id}?thumbnail=true" if file_handler.is_image_file(submission.file_path) else None,
|
|
"stream_url": f"/files/submissions/{submission.id}/stream" if file_handler.is_video_file(submission.file_path) else None
|
|
}
|
|
result.append(SubmissionResponse(**submission_data))
|
|
|
|
return result
|
|
|
|
|
|
@router.post("/{task_id}/submit", response_model=SubmissionResponse)
|
|
async def submit_work(
|
|
task_id: int,
|
|
file: UploadFile = File(...),
|
|
notes: Optional[str] = None,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Submit work for a task. Only assigned artists can submit work."""
|
|
|
|
task = db.query(Task).filter(Task.id == task_id).first()
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
|
|
# Only assigned artist can submit work
|
|
if task.assigned_user_id != current_user.id:
|
|
raise HTTPException(status_code=403, detail="Only the assigned artist can submit work for this task")
|
|
|
|
# Validate file using file handler
|
|
file_handler.validate_file(file, file_handler.MAX_SUBMISSION_SIZE, db)
|
|
|
|
# Get next version number
|
|
latest_submission = db.query(Submission).filter(
|
|
Submission.task_id == task_id,
|
|
Submission.deleted_at.is_(None)
|
|
).order_by(Submission.version_number.desc()).first()
|
|
|
|
version_number = (latest_submission.version_number + 1) if latest_submission else 1
|
|
|
|
# Save file using file handler with version number
|
|
file_path, file_size = await file_handler.save_file(file, task_id, "submission", version_number, db=db)
|
|
|
|
# Create thumbnail for images
|
|
thumbnail_path = None
|
|
if file_handler.is_image_file(file_path):
|
|
thumbnail_path = file_handler.create_thumbnail(file_path)
|
|
|
|
# Create submission record
|
|
db_submission = Submission(
|
|
task_id=task_id,
|
|
user_id=current_user.id,
|
|
file_path=file_path,
|
|
file_name=file.filename or "unknown",
|
|
version_number=version_number,
|
|
notes=notes
|
|
)
|
|
db.add(db_submission)
|
|
|
|
# Update task status to submitted
|
|
task.status = "submitted"
|
|
|
|
db.commit()
|
|
db.refresh(db_submission)
|
|
|
|
# Send notification to directors/coordinators
|
|
notification_service.notify_work_submitted(db, db_submission, task)
|
|
|
|
# Load user information for response
|
|
db_submission = db.query(Submission).options(
|
|
joinedload(Submission.user)
|
|
).filter(Submission.id == db_submission.id).first()
|
|
|
|
submission_data = {
|
|
"id": db_submission.id,
|
|
"task_id": db_submission.task_id,
|
|
"user_id": db_submission.user_id,
|
|
"file_path": db_submission.file_path,
|
|
"file_name": db_submission.file_name,
|
|
"version_number": db_submission.version_number,
|
|
"notes": db_submission.notes,
|
|
"submitted_at": db_submission.submitted_at,
|
|
"user_first_name": db_submission.user.first_name,
|
|
"user_last_name": db_submission.user.last_name,
|
|
"latest_review": None,
|
|
"download_url": f"/files/submissions/{db_submission.id}",
|
|
"thumbnail_url": f"/files/submissions/{db_submission.id}?thumbnail=true" if file_handler.is_image_file(db_submission.file_path) else None,
|
|
"stream_url": f"/files/submissions/{db_submission.id}/stream" if file_handler.is_video_file(db_submission.file_path) else None
|
|
}
|
|
|
|
return SubmissionResponse(**submission_data) |