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>
902 lines
32 KiB
Python
902 lines
32 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from sqlalchemy.orm import Session
|
|
from typing import List
|
|
|
|
from database import get_db
|
|
from models.shot import Shot
|
|
from models.episode import Episode
|
|
from models.project import Project, ProjectMember
|
|
from models.task import Task, TaskType, TaskStatus
|
|
from models.user import User, UserRole
|
|
from schemas.shot import (
|
|
ShotCreate, ShotUpdate, ShotResponse, ShotListResponse,
|
|
BulkShotCreate, BulkShotResponse, TaskStatusInfo
|
|
)
|
|
from utils.auth import get_current_user_from_token, require_permission
|
|
from services.shot_soft_deletion import ShotSoftDeletionService
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def get_current_user_with_db(
|
|
token_data: dict = Depends(get_current_user_from_token),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get current user with proper database dependency."""
|
|
from utils.auth import _get_user_from_db
|
|
return _get_user_from_db(db, token_data["user_id"])
|
|
|
|
|
|
def check_episode_access(episode_id: int, current_user: User, db: Session):
|
|
"""Check if user has access to the episode and its project."""
|
|
# Debug logging
|
|
print(f"[DEBUG] check_episode_access called")
|
|
print(f"[DEBUG] User: {current_user.email}, Role: {current_user.role}, is_admin: {current_user.is_admin}")
|
|
|
|
# Check if episode exists
|
|
episode = db.query(Episode).filter(Episode.id == episode_id).first()
|
|
if not episode:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Episode not found"
|
|
)
|
|
|
|
# Admins and coordinators have access to all episodes
|
|
if current_user.is_admin or current_user.role == UserRole.COORDINATOR:
|
|
print(f"[DEBUG] Access GRANTED - Admin or Coordinator")
|
|
return episode
|
|
|
|
# Check project access for artists and other roles
|
|
if current_user.role == UserRole.ARTIST:
|
|
print(f"[DEBUG] Checking project membership for artist")
|
|
member = db.query(ProjectMember).filter(
|
|
ProjectMember.project_id == episode.project_id,
|
|
ProjectMember.user_id == current_user.id
|
|
).first()
|
|
if not member:
|
|
print(f"[DEBUG] Access DENIED - Not a project member")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Access denied to this project"
|
|
)
|
|
print(f"[DEBUG] Access GRANTED - Project member")
|
|
|
|
return episode
|
|
|
|
|
|
def get_status_sort_order(status: str, project_custom_statuses: list = None) -> int:
|
|
"""Get sort order for task status, including custom statuses."""
|
|
# Default system status order
|
|
system_status_order = {
|
|
"not_started": 0,
|
|
"in_progress": 1,
|
|
"submitted": 2,
|
|
"retake": 3,
|
|
"approved": 4
|
|
}
|
|
|
|
# If it's a system status, return its order
|
|
if status in system_status_order:
|
|
return system_status_order[status]
|
|
|
|
# For custom statuses, use their defined order + offset to place them after system statuses
|
|
if project_custom_statuses:
|
|
for custom_status in project_custom_statuses:
|
|
if isinstance(custom_status, dict) and custom_status.get('id') == status:
|
|
# Custom statuses start after system statuses (5+)
|
|
return 5 + custom_status.get('order', 0)
|
|
|
|
# Unknown status defaults to 0 (same as not_started)
|
|
return 0
|
|
|
|
|
|
def get_project_custom_statuses(project_id: int, db: Session) -> list:
|
|
"""Get custom task statuses for a project."""
|
|
project = db.query(Project).filter(Project.id == project_id).first()
|
|
if not project or not project.custom_task_statuses:
|
|
return []
|
|
|
|
custom_statuses_data = project.custom_task_statuses
|
|
if isinstance(custom_statuses_data, str):
|
|
try:
|
|
import json
|
|
custom_statuses_data = json.loads(custom_statuses_data)
|
|
except (json.JSONDecodeError, TypeError):
|
|
return []
|
|
|
|
return custom_statuses_data if isinstance(custom_statuses_data, list) else []
|
|
|
|
|
|
# Standard shot task types (read-only)
|
|
STANDARD_SHOT_TASK_TYPES = ["layout", "animation", "simulation", "lighting", "compositing"]
|
|
|
|
|
|
def get_default_shot_task_types():
|
|
"""Get default task types for shots."""
|
|
return [
|
|
TaskType.LAYOUT.value,
|
|
TaskType.ANIMATION.value,
|
|
TaskType.LIGHTING.value,
|
|
TaskType.COMPOSITING.value
|
|
]
|
|
|
|
|
|
def get_all_shot_task_types(project_id: int, db: Session) -> List[str]:
|
|
"""Get all task types (standard + custom) for shots in a project."""
|
|
project = db.query(Project).filter(Project.id == project_id).first()
|
|
if not project:
|
|
return STANDARD_SHOT_TASK_TYPES
|
|
|
|
custom_types = project.custom_shot_task_types or []
|
|
return STANDARD_SHOT_TASK_TYPES + custom_types
|
|
|
|
|
|
def create_default_tasks_for_shot(shot: Shot, task_types: List[str], db: Session):
|
|
"""Create default tasks for a shot."""
|
|
created_tasks = []
|
|
|
|
for task_type in task_types:
|
|
task_name = f"{shot.name}_{task_type}"
|
|
task_description = f"{task_type.title()} task for shot {shot.name}"
|
|
|
|
task = Task(
|
|
project_id=shot.project_id,
|
|
episode_id=shot.episode_id,
|
|
shot_id=shot.id,
|
|
task_type=task_type,
|
|
name=task_name,
|
|
description=task_description
|
|
)
|
|
|
|
db.add(task)
|
|
created_tasks.append(task)
|
|
|
|
return created_tasks
|
|
|
|
|
|
@router.get("/", response_model=List[ShotListResponse])
|
|
async def list_shots(
|
|
episode_id: int = None,
|
|
project_id: int = None,
|
|
task_status_filter: str = None,
|
|
sort_by: str = None,
|
|
sort_direction: str = "asc",
|
|
skip: int = 0,
|
|
limit: int = 100,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user_with_db)
|
|
):
|
|
"""List shots with optional filtering by episode, project and task status"""
|
|
from sqlalchemy import func, case
|
|
from sqlalchemy.orm import selectinload, joinedload
|
|
|
|
# Build base query for shots (exclude soft deleted)
|
|
base_query = db.query(Shot).filter(Shot.deleted_at.is_(None))
|
|
|
|
# Filter by project_id if specified
|
|
if project_id:
|
|
# Check project access for artists
|
|
if current_user.role == UserRole.ARTIST:
|
|
member = db.query(ProjectMember).filter(
|
|
ProjectMember.project_id == project_id,
|
|
ProjectMember.user_id == current_user.id
|
|
).first()
|
|
if not member and not current_user.is_admin:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Access denied to this project"
|
|
)
|
|
base_query = base_query.filter(Shot.project_id == project_id)
|
|
|
|
# Filter by episode if specified
|
|
if episode_id:
|
|
check_episode_access(episode_id, current_user, db)
|
|
base_query = base_query.filter(Shot.episode_id == episode_id)
|
|
elif not project_id:
|
|
# If no episode or project specified, filter by user's accessible projects for artists
|
|
if current_user.role == UserRole.ARTIST:
|
|
accessible_projects = db.query(ProjectMember.project_id).filter(
|
|
ProjectMember.user_id == current_user.id
|
|
).subquery()
|
|
base_query = base_query.filter(Shot.project_id.in_(accessible_projects))
|
|
|
|
# Apply sorting if specified (for non-task-status fields)
|
|
if sort_by and not sort_by.endswith('_status'):
|
|
if sort_by in ['name', 'status', 'frame_start', 'frame_end', 'created_at', 'updated_at']:
|
|
sort_column = getattr(Shot, sort_by)
|
|
if sort_direction.lower() == 'desc':
|
|
base_query = base_query.order_by(sort_column.desc())
|
|
else:
|
|
base_query = base_query.order_by(sort_column.asc())
|
|
|
|
# OPTIMIZATION: Use single query with optimized JOIN to fetch shots and their tasks
|
|
# This replaces the N+1 query pattern with a single database operation
|
|
shots_with_tasks = (
|
|
base_query
|
|
.outerjoin(Task, (Task.shot_id == Shot.id) & (Task.deleted_at.is_(None)))
|
|
.options(
|
|
joinedload(Shot.episode).joinedload(Episode.project), # Eager load episode and project
|
|
selectinload(Shot.tasks).options( # Use selectinload for better performance with tasks
|
|
selectinload(Task.assigned_user) # Eager load assigned users
|
|
)
|
|
)
|
|
.add_columns(
|
|
Task.id.label('task_id'),
|
|
Task.task_type,
|
|
Task.status.label('task_status'),
|
|
Task.assigned_user_id,
|
|
Task.updated_at.label('task_updated_at') # Include task update time for better tracking
|
|
)
|
|
.offset(skip)
|
|
.limit(limit)
|
|
.all()
|
|
)
|
|
|
|
# OPTIMIZATION: Pre-fetch all project data and task types in a single query
|
|
# This eliminates the need for repeated project queries
|
|
project_ids = set()
|
|
for row in shots_with_tasks:
|
|
shot = row[0]
|
|
if shot.project_id not in project_ids:
|
|
project_ids.add(shot.project_id)
|
|
|
|
# Get all projects with their custom task types in one optimized query
|
|
project_data = {}
|
|
if project_ids:
|
|
projects = (
|
|
db.query(Project)
|
|
.filter(Project.id.in_(project_ids))
|
|
.all()
|
|
)
|
|
for project in projects:
|
|
custom_types = project.custom_shot_task_types or []
|
|
raw = project.custom_task_statuses
|
|
if isinstance(raw, str):
|
|
try:
|
|
import json
|
|
raw = json.loads(raw)
|
|
except (json.JSONDecodeError, TypeError):
|
|
raw = []
|
|
custom_statuses = raw if isinstance(raw, list) else []
|
|
project_data[project.id] = {
|
|
'task_types': STANDARD_SHOT_TASK_TYPES + custom_types,
|
|
'custom_statuses': custom_statuses
|
|
}
|
|
|
|
# OPTIMIZATION: Group results by shot and aggregate task data efficiently
|
|
shots_dict = {}
|
|
for row in shots_with_tasks:
|
|
shot = row[0] # Shot object
|
|
task_id = row[1] # task_id
|
|
task_type = row[2] # task_type
|
|
task_status = row[3] # task_status
|
|
assigned_user_id = row[4] # assigned_user_id
|
|
task_updated_at = row[5] # task_updated_at
|
|
|
|
if shot.id not in shots_dict:
|
|
# Initialize shot data with pre-fetched project data
|
|
project_info = project_data.get(shot.project_id, {
|
|
'task_types': STANDARD_SHOT_TASK_TYPES,
|
|
'custom_statuses': []
|
|
})
|
|
|
|
shots_dict[shot.id] = {
|
|
'shot': shot,
|
|
'tasks': [],
|
|
'task_status': {},
|
|
'task_details': [],
|
|
'project_info': project_info
|
|
}
|
|
|
|
# Initialize all task types as not started using pre-fetched data
|
|
for task_type_init in project_info['task_types']:
|
|
shots_dict[shot.id]['task_status'][task_type_init] = "not_started"
|
|
|
|
# Add task data if task exists
|
|
if task_id is not None:
|
|
shots_dict[shot.id]['tasks'].append({
|
|
'task_id': task_id,
|
|
'task_type': task_type,
|
|
'status': task_status,
|
|
'assigned_user_id': assigned_user_id,
|
|
'updated_at': task_updated_at
|
|
})
|
|
|
|
# Update task status
|
|
shots_dict[shot.id]['task_status'][task_type] = task_status
|
|
|
|
# Add to task details with enhanced information
|
|
shots_dict[shot.id]['task_details'].append(TaskStatusInfo(
|
|
task_type=task_type,
|
|
status=task_status,
|
|
task_id=task_id,
|
|
assigned_user_id=assigned_user_id
|
|
))
|
|
|
|
# Build response list efficiently
|
|
result = []
|
|
for shot_data in shots_dict.values():
|
|
shot = shot_data['shot']
|
|
|
|
# Create shot response with optimized data
|
|
shot_response = ShotListResponse.model_validate(shot)
|
|
shot_response.task_count = len(shot_data['tasks'])
|
|
shot_response.task_status = shot_data['task_status']
|
|
shot_response.task_details = shot_data['task_details']
|
|
|
|
result.append(shot_response)
|
|
|
|
# Apply task status filtering if specified
|
|
if task_status_filter:
|
|
try:
|
|
# Parse task status filter (format: "task_type:status")
|
|
task_type, status = task_status_filter.split(":")
|
|
filter_status = status # Use string directly instead of enum
|
|
result = [
|
|
shot for shot in result
|
|
if shot.task_status.get(task_type) == filter_status
|
|
]
|
|
except (ValueError, KeyError):
|
|
# Invalid filter format, ignore
|
|
pass
|
|
|
|
# Apply task status sorting if specified using pre-fetched custom status data
|
|
if sort_by and sort_by.endswith('_status'):
|
|
task_type = sort_by.replace('_status', '')
|
|
|
|
def get_status_order(shot):
|
|
status = shot.task_status.get(task_type, "not_started")
|
|
# Use pre-fetched custom statuses from shots_dict
|
|
shot_id = shot.id
|
|
if shot_id in shots_dict:
|
|
custom_statuses = shots_dict[shot_id]['project_info']['custom_statuses']
|
|
return get_status_sort_order(status, custom_statuses)
|
|
return get_status_sort_order(status, [])
|
|
|
|
reverse = sort_direction.lower() == 'desc'
|
|
result.sort(key=get_status_order, reverse=reverse)
|
|
|
|
return result
|
|
|
|
|
|
@router.post("/", response_model=ShotResponse, status_code=status.HTTP_201_CREATED)
|
|
async def create_shot(
|
|
shot: ShotCreate,
|
|
episode_id: int,
|
|
create_default_tasks: bool = True,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_permission('shot', 'create'))
|
|
):
|
|
"""Create a new shot in an episode"""
|
|
# Check episode access
|
|
episode = check_episode_access(episode_id, current_user, db)
|
|
|
|
# Auto-populate project_id from episode if not provided
|
|
if shot.project_id is None:
|
|
shot.project_id = episode.project_id
|
|
else:
|
|
# Validate that provided project_id matches episode's project
|
|
if shot.project_id != episode.project_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Project ID must match the episode's project"
|
|
)
|
|
|
|
# Validate frame range
|
|
if shot.frame_end < shot.frame_start:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Frame end must be greater than or equal to frame start"
|
|
)
|
|
|
|
# Check if shot name already exists in project (project-scoped uniqueness)
|
|
existing_shot = db.query(Shot).filter(
|
|
Shot.project_id == shot.project_id,
|
|
Shot.name == shot.name,
|
|
Shot.deleted_at.is_(None)
|
|
).first()
|
|
|
|
if existing_shot:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Shot with this name already exists in the project"
|
|
)
|
|
|
|
# Create new shot
|
|
db_shot = Shot(
|
|
episode_id=episode_id,
|
|
project_id=shot.project_id,
|
|
**shot.model_dump(exclude={'project_id'})
|
|
)
|
|
|
|
db.add(db_shot)
|
|
db.commit()
|
|
db.refresh(db_shot)
|
|
|
|
# Create default tasks if requested
|
|
task_count = 0
|
|
if create_default_tasks:
|
|
# Get all task types (standard + custom) for this project
|
|
all_task_types = get_all_shot_task_types(episode.project_id, db)
|
|
# Use default standard types for now (can be customized via project settings)
|
|
default_task_types = get_default_shot_task_types()
|
|
created_tasks = create_default_tasks_for_shot(db_shot, default_task_types, db)
|
|
db.commit()
|
|
task_count = len(created_tasks)
|
|
|
|
# Return response with task count
|
|
shot_data = ShotResponse.model_validate(db_shot)
|
|
shot_data.task_count = task_count
|
|
|
|
return shot_data
|
|
|
|
|
|
@router.post("/bulk", response_model=BulkShotResponse, status_code=status.HTTP_201_CREATED)
|
|
async def create_shots_bulk(
|
|
bulk_shot: BulkShotCreate,
|
|
episode_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_permission('shot', 'create'))
|
|
):
|
|
"""Create multiple shots with naming pattern and default tasks"""
|
|
# Check episode access
|
|
episode = check_episode_access(episode_id, current_user, db)
|
|
|
|
# Auto-populate project_id from episode - all shots in bulk operation belong to same project
|
|
project_id = episode.project_id
|
|
|
|
# Validate frame range
|
|
if bulk_shot.frame_end < bulk_shot.frame_start:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Frame end must be greater than or equal to frame start"
|
|
)
|
|
|
|
# Determine task types to create
|
|
if bulk_shot.task_types:
|
|
# Validate that all selected task types are valid (standard or custom)
|
|
all_valid_types = get_all_shot_task_types(episode.project_id, db)
|
|
invalid_types = [t for t in bulk_shot.task_types if t not in all_valid_types]
|
|
if invalid_types:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Invalid task types: {', '.join(invalid_types)}"
|
|
)
|
|
task_types = bulk_shot.task_types
|
|
else:
|
|
task_types = get_default_shot_task_types()
|
|
|
|
# Pre-validate all shot names for project-scoped uniqueness before creating any shots
|
|
shot_names_to_create = []
|
|
for i in range(bulk_shot.shot_count):
|
|
shot_number = bulk_shot.start_number + i
|
|
shot_name = f"{bulk_shot.name_prefix}{shot_number:0{bulk_shot.number_padding}d}"
|
|
shot_names_to_create.append(shot_name)
|
|
|
|
# Check for existing shots with any of the names in this project
|
|
existing_shots = db.query(Shot.name).filter(
|
|
Shot.project_id == project_id,
|
|
Shot.name.in_(shot_names_to_create),
|
|
Shot.deleted_at.is_(None)
|
|
).all()
|
|
|
|
if existing_shots:
|
|
existing_names = [shot.name for shot in existing_shots]
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"The following shot names already exist in project {project_id}: {', '.join(existing_names)}"
|
|
)
|
|
|
|
# Check for duplicate names within the bulk creation itself
|
|
if len(shot_names_to_create) != len(set(shot_names_to_create)):
|
|
duplicates = [name for name in shot_names_to_create if shot_names_to_create.count(name) > 1]
|
|
unique_duplicates = list(set(duplicates))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Duplicate shot names in bulk creation: {', '.join(unique_duplicates)}"
|
|
)
|
|
|
|
created_shots = []
|
|
total_tasks_created = 0
|
|
|
|
try:
|
|
# Create all shots - validation already done above
|
|
for i, shot_name in enumerate(shot_names_to_create):
|
|
shot_number = bulk_shot.start_number + i
|
|
|
|
# Create description from template
|
|
description = None
|
|
if bulk_shot.description_template:
|
|
description = bulk_shot.description_template.replace("{shot_name}", shot_name).replace("{shot_number}", str(shot_number))
|
|
|
|
# Create shot - project consistency guaranteed by using episode's project_id
|
|
db_shot = Shot(
|
|
project_id=project_id,
|
|
episode_id=episode_id,
|
|
name=shot_name,
|
|
description=description,
|
|
frame_start=bulk_shot.frame_start,
|
|
frame_end=bulk_shot.frame_end
|
|
)
|
|
|
|
db.add(db_shot)
|
|
db.flush() # Flush to get the shot ID
|
|
|
|
# Create default tasks if requested
|
|
task_count = 0
|
|
if bulk_shot.create_default_tasks:
|
|
created_tasks = create_default_tasks_for_shot(db_shot, task_types, db)
|
|
task_count = len(created_tasks)
|
|
total_tasks_created += task_count
|
|
|
|
# Add to response list
|
|
shot_data = ShotResponse.model_validate(db_shot)
|
|
shot_data.task_count = task_count
|
|
created_shots.append(shot_data)
|
|
|
|
# Commit all changes
|
|
db.commit()
|
|
|
|
message = f"Successfully created {len(created_shots)} shots in project {project_id}"
|
|
if total_tasks_created > 0:
|
|
message += f" with {total_tasks_created} tasks"
|
|
|
|
return BulkShotResponse(
|
|
created_shots=created_shots,
|
|
created_tasks_count=total_tasks_created,
|
|
message=message
|
|
)
|
|
|
|
except Exception as e:
|
|
db.rollback()
|
|
if isinstance(e, HTTPException):
|
|
raise e
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Error creating shots: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.get("/{shot_id}", response_model=ShotResponse)
|
|
async def get_shot(
|
|
shot_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user_with_db)
|
|
):
|
|
"""Get a specific shot by ID"""
|
|
from sqlalchemy.orm import joinedload, selectinload
|
|
|
|
# OPTIMIZATION: Use single query with optimized JOINs to fetch shot and all related data
|
|
# This replaces separate queries with a single database operation
|
|
shot_query = (
|
|
db.query(Shot)
|
|
.options(
|
|
joinedload(Shot.episode).joinedload(Episode.project), # Eager load episode and project
|
|
selectinload(Shot.tasks).options( # Use selectinload for better performance with tasks
|
|
selectinload(Task.assigned_user) # Eager load assigned users if needed
|
|
)
|
|
)
|
|
.filter(Shot.id == shot_id, Shot.deleted_at.is_(None))
|
|
)
|
|
|
|
shot = shot_query.first()
|
|
|
|
if not shot:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Shot not found"
|
|
)
|
|
|
|
# Check episode access
|
|
check_episode_access(shot.episode_id, current_user, db)
|
|
|
|
# OPTIMIZATION: Count tasks from the already loaded relationship
|
|
# This avoids a separate COUNT query
|
|
active_tasks = [task for task in shot.tasks if task.deleted_at is None]
|
|
task_count = len(active_tasks)
|
|
|
|
shot_data = ShotResponse.model_validate(shot)
|
|
shot_data.task_count = task_count
|
|
|
|
# Add project_name from episode.project (already eager loaded)
|
|
if shot.episode and shot.episode.project:
|
|
shot_data.project_name = shot.episode.project.name
|
|
|
|
# Add task status information (similar to list endpoint)
|
|
project = shot.episode.project if shot.episode else None
|
|
|
|
# Get all task types: standard + custom
|
|
from routers.shots import STANDARD_SHOT_TASK_TYPES
|
|
project_task_types = list(STANDARD_SHOT_TASK_TYPES) # Start with standard types
|
|
|
|
if project and project.custom_shot_task_types:
|
|
custom_types = project.custom_shot_task_types or []
|
|
if isinstance(custom_types, list):
|
|
for ct in custom_types:
|
|
if isinstance(ct, dict) and 'type' in ct:
|
|
project_task_types.append(ct['type'])
|
|
elif isinstance(ct, str):
|
|
project_task_types.append(ct)
|
|
|
|
# Initialize task_status and task_ids dictionaries
|
|
task_status_dict = {}
|
|
task_ids_dict = {}
|
|
task_details_list = []
|
|
|
|
# Initialize with default not_started for all project task types
|
|
for task_type_init in project_task_types:
|
|
task_status_dict[task_type_init] = "not_started"
|
|
|
|
# Build task information from active tasks
|
|
for task in active_tasks:
|
|
task_type = task.task_type.value if hasattr(task.task_type, 'value') else task.task_type
|
|
task_status = task.status.value if hasattr(task.status, 'value') else task.status
|
|
task_id = task.id
|
|
assigned_user_id = task.assigned_user_id
|
|
|
|
# Update task status
|
|
task_status_dict[task_type] = task_status
|
|
task_ids_dict[task_type] = task_id
|
|
|
|
# Add to task details
|
|
task_details_list.append(TaskStatusInfo(
|
|
task_type=task_type,
|
|
status=task_status,
|
|
task_id=task_id,
|
|
assigned_user_id=assigned_user_id
|
|
))
|
|
|
|
shot_data.task_status = task_status_dict
|
|
shot_data.task_ids = task_ids_dict
|
|
shot_data.task_details = task_details_list
|
|
|
|
return shot_data
|
|
|
|
|
|
@router.post("/{shot_id}/tasks", response_model=TaskStatusInfo, status_code=status.HTTP_201_CREATED)
|
|
async def create_shot_task(
|
|
shot_id: int,
|
|
task_type: str,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_permission('task', 'create'))
|
|
):
|
|
"""Create a new task for a shot"""
|
|
# Exclude soft deleted shots
|
|
shot = db.query(Shot).filter(
|
|
Shot.id == shot_id,
|
|
Shot.deleted_at.is_(None)
|
|
).first()
|
|
|
|
if not shot:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Shot not found"
|
|
)
|
|
|
|
# Check episode access
|
|
episode = check_episode_access(shot.episode_id, current_user, db)
|
|
|
|
# Check if task already exists (exclude soft deleted)
|
|
existing_task = db.query(Task).filter(
|
|
Task.shot_id == shot_id,
|
|
Task.task_type == task_type,
|
|
Task.deleted_at.is_(None)
|
|
).first()
|
|
|
|
if existing_task:
|
|
# Return existing task info instead of error
|
|
return TaskStatusInfo(
|
|
task_type=existing_task.task_type,
|
|
status=existing_task.status,
|
|
task_id=existing_task.id,
|
|
assigned_user_id=existing_task.assigned_user_id
|
|
)
|
|
|
|
# Create the task
|
|
task_name = f"{shot.name} - {task_type.title()}"
|
|
db_task = Task(
|
|
project_id=shot.project_id,
|
|
episode_id=shot.episode_id,
|
|
shot_id=shot.id,
|
|
task_type=task_type,
|
|
name=task_name,
|
|
description=f"{task_type.title()} task for {shot.name}",
|
|
status="not_started"
|
|
)
|
|
|
|
db.add(db_task)
|
|
db.commit()
|
|
db.refresh(db_task)
|
|
|
|
return TaskStatusInfo(
|
|
task_type=db_task.task_type,
|
|
status=db_task.status,
|
|
task_id=db_task.id,
|
|
assigned_user_id=db_task.assigned_user_id
|
|
)
|
|
|
|
|
|
@router.put("/{shot_id}", response_model=ShotResponse)
|
|
async def update_shot(
|
|
shot_id: int,
|
|
shot_update: ShotUpdate,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_permission('shot', 'edit'))
|
|
):
|
|
"""Update a shot"""
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
# OPTIMIZATION: Use eager loading to fetch shot with tasks in single query
|
|
db_shot = (
|
|
db.query(Shot)
|
|
.options(selectinload(Shot.tasks)) # Eager load tasks for counting
|
|
.filter(
|
|
Shot.id == shot_id,
|
|
Shot.deleted_at.is_(None)
|
|
)
|
|
.first()
|
|
)
|
|
|
|
if not db_shot:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Shot not found"
|
|
)
|
|
|
|
# Check episode access
|
|
check_episode_access(db_shot.episode_id, current_user, db)
|
|
|
|
# Validate project_id if provided
|
|
if shot_update.project_id is not None:
|
|
if shot_update.project_id != db_shot.project_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Cannot change project_id - must match episode's project"
|
|
)
|
|
|
|
# Validate frame range if both values are provided
|
|
frame_start = shot_update.frame_start if shot_update.frame_start is not None else db_shot.frame_start
|
|
frame_end = shot_update.frame_end if shot_update.frame_end is not None else db_shot.frame_end
|
|
|
|
if frame_end < frame_start:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Frame end must be greater than or equal to frame start"
|
|
)
|
|
|
|
# Check if new name conflicts with existing shots in the same project (project-scoped uniqueness)
|
|
if shot_update.name and shot_update.name != db_shot.name:
|
|
existing_shot = db.query(Shot).filter(
|
|
Shot.project_id == db_shot.project_id,
|
|
Shot.name == shot_update.name,
|
|
Shot.id != shot_id,
|
|
Shot.deleted_at.is_(None)
|
|
).first()
|
|
|
|
if existing_shot:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Shot with this name already exists in the project"
|
|
)
|
|
|
|
# Update only provided fields
|
|
update_data = shot_update.model_dump(exclude_unset=True)
|
|
for field, value in update_data.items():
|
|
setattr(db_shot, field, value)
|
|
|
|
db.commit()
|
|
db.refresh(db_shot)
|
|
|
|
# OPTIMIZATION: Count tasks using the relationship instead of separate query
|
|
# This avoids an additional database query
|
|
active_tasks = [task for task in db_shot.tasks if task.deleted_at is None]
|
|
task_count = len(active_tasks)
|
|
|
|
shot_data = ShotResponse.model_validate(db_shot)
|
|
shot_data.task_count = task_count
|
|
|
|
return shot_data
|
|
|
|
|
|
@router.get("/{shot_id}/deletion-info")
|
|
async def get_shot_deletion_info(
|
|
shot_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_permission('shot', 'delete'))
|
|
):
|
|
"""Get information about what will be deleted when deleting a shot"""
|
|
# Exclude soft deleted shots
|
|
db_shot = db.query(Shot).filter(
|
|
Shot.id == shot_id,
|
|
Shot.deleted_at.is_(None)
|
|
).first()
|
|
|
|
if not db_shot:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Shot not found"
|
|
)
|
|
|
|
# Check episode access
|
|
check_episode_access(db_shot.episode_id, current_user, db)
|
|
|
|
# Use the soft deletion service to get comprehensive deletion info
|
|
deletion_service = ShotSoftDeletionService()
|
|
deletion_info = deletion_service.get_deletion_info(shot_id, db)
|
|
|
|
if not deletion_info:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Shot not found"
|
|
)
|
|
|
|
return {
|
|
"shot_id": deletion_info.shot_id,
|
|
"shot_name": deletion_info.shot_name,
|
|
"episode_name": deletion_info.episode_name,
|
|
"project_name": deletion_info.project_name,
|
|
"task_count": deletion_info.task_count,
|
|
"submission_count": deletion_info.submission_count,
|
|
"attachment_count": deletion_info.attachment_count,
|
|
"note_count": deletion_info.note_count,
|
|
"review_count": deletion_info.review_count,
|
|
"total_file_size": deletion_info.total_file_size,
|
|
"file_count": deletion_info.file_count,
|
|
"affected_users": deletion_info.affected_users,
|
|
"last_activity_date": deletion_info.last_activity_date,
|
|
"created_at": deletion_info.created_at
|
|
}
|
|
|
|
|
|
@router.delete("/{shot_id}")
|
|
async def delete_shot(
|
|
shot_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_permission('shot', 'delete'))
|
|
):
|
|
"""Soft delete a shot and all its associated data"""
|
|
# Exclude soft deleted shots
|
|
db_shot = db.query(Shot).filter(
|
|
Shot.id == shot_id,
|
|
Shot.deleted_at.is_(None)
|
|
).first()
|
|
|
|
if not db_shot:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Shot not found"
|
|
)
|
|
|
|
# Check episode access
|
|
check_episode_access(db_shot.episode_id, current_user, db)
|
|
|
|
# Use the soft deletion service to perform cascading soft deletion
|
|
deletion_service = ShotSoftDeletionService()
|
|
result = deletion_service.soft_delete_shot_cascade(shot_id, db, current_user)
|
|
|
|
if not result.success:
|
|
db.rollback()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail={
|
|
"message": "Failed to delete shot",
|
|
"errors": result.errors
|
|
}
|
|
)
|
|
|
|
# Commit the transaction
|
|
db.commit()
|
|
|
|
return {
|
|
"message": f"Shot '{result.shot_name}' and all related data have been deleted",
|
|
"shot_id": result.shot_id,
|
|
"shot_name": result.shot_name,
|
|
"deleted_at": result.deleted_at,
|
|
"deleted_by": result.deleted_by,
|
|
"marked_deleted_tasks": result.marked_deleted_tasks,
|
|
"marked_deleted_submissions": result.marked_deleted_submissions,
|
|
"marked_deleted_attachments": result.marked_deleted_attachments,
|
|
"marked_deleted_notes": result.marked_deleted_notes,
|
|
"marked_deleted_reviews": result.marked_deleted_reviews,
|
|
"operation_duration": result.operation_duration
|
|
} |