LinkDesk/backend/routers/shots.py

858 lines
30 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
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 require_coordinator_or_admin(
token_data: dict = Depends(get_current_user_from_token),
db: Session = Depends(get_db)
):
"""Require coordinator or admin role."""
from utils.auth import _get_user_from_db
current_user = _get_user_from_db(db, token_data["user_id"])
if current_user.role != UserRole.COORDINATOR and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions"
)
return current_user
def 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 []
project_data[project.id] = {
'task_types': STANDARD_SHOT_TASK_TYPES + custom_types,
'custom_statuses': get_project_custom_statuses(project.id, db)
}
# 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_coordinator_or_admin)
):
"""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_coordinator_or_admin)
):
"""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
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_coordinator_or_admin)
):
"""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_coordinator_or_admin)
):
"""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_coordinator_or_admin)
):
"""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_coordinator_or_admin)
):
"""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
}