858 lines
30 KiB
Python
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
|
|
} |