db2c414c1a
Users can now hold multiple roles, each with its own editable set of create/edit/delete-style permissions across assets, shots, tasks, task assignment, review approve/retake, submissions, uploads, and notes (including internal vs. client note visibility). The 4 existing roles (coordinator/director/artist/developer) are migrated into the new system as system roles, seeded to reproduce today's actual behavior exactly; admins can create custom roles (e.g. "Reviewer", "Outsourcing") via the new Role Management page and assign multiple roles to a user via a new "Manage Roles" action on the Team page. Backend: - New Role/Permission models and role_permissions/user_roles tables, plus a one-off, idempotent seed/backfill migration script. - New require_permission()/user_has_permission() dependency, wired into the actual mutation endpoints across shots/assets/tasks/reviews, always preserving existing ownership- and self-service-based access (e.g. artists editing their own task status, own notes, own uploads, own submissions) as an unconditional fallback alongside the new permission checks - nothing that worked before now requires a role. - New endpoints: PUT/DELETE on task submissions (wires up soft-deletion columns that existed on the model but were never exposed), plus full role CRUD and per-user role assignment. - Along the way: fixed newly-created users not being linked to their matching system role (silently leaving them with zero permissions), and unified an inconsistency between the single vs. bulk task status endpoints that allowed different roles to bulk-update status. Frontend: - Role Management page with a grouped, human-readable permission editor (icons, plain-language action labels, per-resource select-all, live selected count) replacing an earlier dense matrix prototype. - hasPermission() added to the existing usePermission() composable without touching its current isAdmin/isCoordinatorOrAdmin consumers. - Note composer gets an Internal/Client toggle; submissions gain inline edit/delete actions gated the same ownership-or-permission way as notes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1704 lines
68 KiB
Python
1704 lines
68 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, NoteType
|
|
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, SubmissionUpdate, SubmissionResponse,
|
|
BulkStatusUpdate, BulkAssignment, BulkActionResult
|
|
)
|
|
from utils.auth import get_current_user_from_token, _get_user_from_db, require_role, require_permission, user_has_permission
|
|
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 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_permission('task', 'create'))
|
|
):
|
|
"""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,
|
|
"assigned_user_avatar_url": db_task.assigned_user.avatar_url 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 not user_has_permission(current_user, 'task', 'change_status', db):
|
|
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_permission('assignment', 'edit'))
|
|
):
|
|
"""
|
|
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,
|
|
"assigned_user_avatar_url": task.assigned_user.avatar_url 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 not user_has_permission(current_user, 'task', 'edit', db):
|
|
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")
|
|
|
|
assignment_action = 'edit' if task.assigned_user_id else 'create'
|
|
if not user_has_permission(current_user, 'assignment', assignment_action, db):
|
|
raise HTTPException(status_code=403, detail="Not authorized to assign this task")
|
|
|
|
# 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:
|
|
if not user_has_permission(current_user, 'assignment', 'delete', db):
|
|
raise HTTPException(status_code=403, detail="Not authorized to unassign this task")
|
|
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,
|
|
"assigned_user_avatar_url": task.assigned_user.avatar_url 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:
|
|
if task.assigned_user_id != current_user.id:
|
|
raise HTTPException(status_code=403, detail="Not authorized to update this task")
|
|
elif not user_has_permission(current_user, 'task', 'change_status', db):
|
|
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,
|
|
"assigned_user_avatar_url": task.assigned_user.avatar_url 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(get_current_user)
|
|
):
|
|
"""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")
|
|
|
|
# Assigning a previously-unassigned task requires assignment:create;
|
|
# reassigning an already-assigned task requires assignment:edit.
|
|
assignment_action = 'edit' if task.assigned_user_id else 'create'
|
|
if not user_has_permission(current_user, 'assignment', assignment_action, db):
|
|
raise HTTPException(status_code=403, detail="Not authorized to assign this task")
|
|
|
|
# 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,
|
|
"assigned_user_avatar_url": task.assigned_user.avatar_url 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_permission('task', 'delete'))
|
|
):
|
|
"""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()
|
|
|
|
# Filter by note-type view permission (a note whose type the viewer can't
|
|
# see is dropped entirely - any reply to it is dropped too, since it
|
|
# would otherwise reference content the viewer isn't allowed to see)
|
|
can_view_internal = user_has_permission(current_user, 'note', 'view_internal', db)
|
|
can_view_client = user_has_permission(current_user, 'note', 'view_client', db)
|
|
notes = [
|
|
note for note in notes
|
|
if (note.note_type == NoteType.INTERNAL and can_view_internal)
|
|
or (note.note_type == NoteType.CLIENT and can_view_client)
|
|
]
|
|
|
|
# Build threaded structure
|
|
notes_dict = {}
|
|
root_notes = []
|
|
|
|
for note in notes:
|
|
note_data = {
|
|
"id": note.id,
|
|
"content": note.content,
|
|
"note_type": note.note_type,
|
|
"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,
|
|
"user_avatar_url": note.user.avatar_url,
|
|
"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; everyone else needs note:create
|
|
if current_user.role == UserRole.ARTIST:
|
|
if task.assigned_user_id != current_user.id:
|
|
raise HTTPException(status_code=403, detail="Not authorized to add notes to this task")
|
|
elif not user_has_permission(current_user, 'note', 'create', db):
|
|
raise HTTPException(status_code=403, detail="Not authorized to add notes")
|
|
|
|
# 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,
|
|
note_type=note.note_type,
|
|
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,
|
|
"note_type": db_note.note_type,
|
|
"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,
|
|
"user_avatar_url": db_note.user.avatar_url,
|
|
"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 or note:edit
|
|
if (note.user_id != current_user.id and not current_user.is_admin
|
|
and not user_has_permission(current_user, 'note', 'edit', db)):
|
|
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,
|
|
"note_type": note.note_type,
|
|
"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,
|
|
"user_avatar_url": note.user.avatar_url,
|
|
"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 or note:delete
|
|
if (note.user_id != current_user.id and not current_user.is_admin
|
|
and not user_has_permission(current_user, 'note', 'delete', db)):
|
|
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; everyone else needs upload:create
|
|
if current_user.role == UserRole.ARTIST:
|
|
if task.assigned_user_id != current_user.id:
|
|
raise HTTPException(status_code=403, detail="Not authorized to upload attachments to this task")
|
|
elif not user_has_permission(current_user, 'upload', 'create', db):
|
|
raise HTTPException(status_code=403, detail="Not authorized to upload attachments")
|
|
|
|
# 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 have admin permission or upload:delete
|
|
if (attachment.user_id != current_user.id and not current_user.is_admin
|
|
and not user_has_permission(current_user, 'upload', 'delete', db)):
|
|
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 the assigned artist can submit work, unless the caller holds submission:create
|
|
if (task.assigned_user_id != current_user.id
|
|
and not user_has_permission(current_user, 'submission', 'create', db)):
|
|
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)
|
|
|
|
|
|
@router.put("/{task_id}/submissions/{submission_id}", response_model=SubmissionResponse)
|
|
async def update_task_submission(
|
|
task_id: int,
|
|
submission_id: int,
|
|
submission_update: SubmissionUpdate,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Update a submission's notes. Users can only update their own submissions."""
|
|
|
|
submission = db.query(Submission).options(
|
|
joinedload(Submission.user)
|
|
).filter(
|
|
Submission.id == submission_id,
|
|
Submission.task_id == task_id,
|
|
Submission.deleted_at.is_(None)
|
|
).first()
|
|
if not submission:
|
|
raise HTTPException(status_code=404, detail="Submission not found")
|
|
|
|
# Users can only update their own submissions, unless they have admin permission or submission:edit
|
|
if (submission.user_id != current_user.id and not current_user.is_admin
|
|
and not user_has_permission(current_user, 'submission', 'edit', db)):
|
|
raise HTTPException(status_code=403, detail="Not authorized to update this submission")
|
|
|
|
if submission_update.notes is not None:
|
|
submission.notes = submission_update.notes
|
|
db.commit()
|
|
db.refresh(submission)
|
|
|
|
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": None,
|
|
"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
|
|
}
|
|
|
|
return SubmissionResponse(**submission_data)
|
|
|
|
|
|
@router.delete("/{task_id}/submissions/{submission_id}")
|
|
async def delete_task_submission(
|
|
task_id: int,
|
|
submission_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Soft delete a submission. Users can only delete their own submissions."""
|
|
|
|
submission = db.query(Submission).filter(
|
|
Submission.id == submission_id,
|
|
Submission.task_id == task_id,
|
|
Submission.deleted_at.is_(None)
|
|
).first()
|
|
if not submission:
|
|
raise HTTPException(status_code=404, detail="Submission not found")
|
|
|
|
# Users can only delete their own submissions, unless they have admin permission or submission:delete
|
|
if (submission.user_id != current_user.id and not current_user.is_admin
|
|
and not user_has_permission(current_user, 'submission', 'delete', db)):
|
|
raise HTTPException(status_code=403, detail="Not authorized to delete this submission")
|
|
|
|
submission.deleted_at = datetime.utcnow()
|
|
submission.deleted_by = current_user.id
|
|
db.commit()
|
|
|
|
return {"message": "Submission deleted successfully"} |