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 }