Files
LinkDesk/backend/routers/shots.py
T
indigo db2c414c1a Add multi-role permission system with a Role Management admin page
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>
2026-07-18 20:41:17 +08:00

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
}