from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from typing import List, Dict from database import get_db from models.asset import Asset, AssetCategory from models.project import Project, ProjectMember from models.task import Task, TaskType, TaskStatus from models.user import User, UserRole from schemas.asset import AssetCreate, AssetUpdate, AssetResponse, AssetListResponse, TaskStatusInfo from schemas.task import TaskCreate from utils.auth import get_current_user_from_token from services.asset_soft_deletion import AssetSoftDeletionService 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 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 [] def check_project_access(project_id: int, current_user: User, db: Session): """Check if user has access to the project.""" # Check if project exists project = db.query(Project).filter(Project.id == project_id).first() if not project: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Project not found" ) # Check 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: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this project" ) return project # Standard asset task types (read-only) STANDARD_ASSET_TASK_TYPES = ["modeling", "surfacing", "rigging"] # Default asset tasks by category (using string values instead of enums) DEFAULT_ASSET_TASKS = { AssetCategory.CHARACTERS: [TaskType.MODELING.value, TaskType.SURFACING.value, TaskType.RIGGING.value], AssetCategory.PROPS: [TaskType.MODELING.value, TaskType.SURFACING.value], AssetCategory.SETS: [TaskType.MODELING.value, TaskType.SURFACING.value], AssetCategory.VEHICLES: [TaskType.MODELING.value, TaskType.SURFACING.value, TaskType.RIGGING.value] } def get_default_asset_task_types(category: AssetCategory) -> List[str]: """Get default task types for an asset category.""" return DEFAULT_ASSET_TASKS.get(category, []) def get_all_asset_task_types(project_id: int, db: Session) -> List[str]: """Get all task types (standard + custom) for assets in a project.""" project = db.query(Project).filter(Project.id == project_id).first() if not project: return STANDARD_ASSET_TASK_TYPES custom_types = project.custom_asset_task_types or [] return STANDARD_ASSET_TASK_TYPES + custom_types def create_default_tasks_for_asset(asset: Asset, task_types: List[str], db: Session) -> List[Task]: """Create default tasks for an asset.""" created_tasks = [] for task_type in task_types: # Create task name based on type task_name = f"{asset.name} - {task_type.title()}" # Create the task db_task = Task( project_id=asset.project_id, asset_id=asset.id, task_type=task_type, name=task_name, description=f"Default {task_type} task for {asset.name}", status="not_started" ) db.add(db_task) created_tasks.append(db_task) return created_tasks @router.get("/categories", response_model=List[str]) async def list_asset_categories(): """List all available asset categories""" return [category.value for category in AssetCategory] @router.get("/default-tasks/{category}", response_model=List[str]) async def get_default_tasks_for_category( category: AssetCategory, project_id: int = None, db: Session = Depends(get_db), current_user: User = Depends(get_current_user_with_db) ): """Get default task types for an asset category (includes custom types if project_id provided)""" task_types = get_default_asset_task_types(category) # If project_id is provided, include custom task types if project_id: all_types = get_all_asset_task_types(project_id, db) # Return only the types that are relevant for this category # For now, return all available types (standard + custom) return all_types return task_types # task_types are already strings, no need for .value @router.get("/", response_model=List[AssetListResponse]) async def list_assets( project_id: int = None, category: AssetCategory = 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 assets with optional filtering by project and category""" from sqlalchemy.orm import joinedload, selectinload # Build base query for assets (exclude soft deleted) base_query = db.query(Asset).filter(Asset.deleted_at.is_(None)) # Filter by project if specified if project_id: check_project_access(project_id, current_user, db) base_query = base_query.filter(Asset.project_id == project_id) else: # If no 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(Asset.project_id.in_(accessible_projects)) # Filter by category if specified if category: base_query = base_query.filter(Asset.category == category) # Apply sorting if specified (for non-task-status fields) if sort_by and not sort_by.endswith('_status'): if sort_by in ['name', 'category', 'status', 'created_at', 'updated_at']: sort_column = getattr(Asset, 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 assets and their tasks # This replaces the N+1 query pattern with a single database operation assets_with_tasks = ( base_query .outerjoin(Task, (Task.asset_id == Asset.id) & (Task.deleted_at.is_(None))) .options( joinedload(Asset.project), # Eager load project selectinload(Asset.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 assets_with_tasks: asset = row[0] if asset.project_id not in project_ids: project_ids.add(asset.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_asset_task_types or [] project_data[project.id] = { 'task_types': STANDARD_ASSET_TASK_TYPES + custom_types, 'custom_statuses': get_project_custom_statuses(project.id, db) } # OPTIMIZATION: Group results by asset and aggregate task data efficiently assets_dict = {} for row in assets_with_tasks: asset = row[0] # Asset 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 asset.id not in assets_dict: # Initialize asset data with pre-fetched project data project_info = project_data.get(asset.project_id, { 'task_types': STANDARD_ASSET_TASK_TYPES, 'custom_statuses': [] }) assets_dict[asset.id] = { 'asset': asset, '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']: assets_dict[asset.id]['task_status'][task_type_init] = "not_started" # Add task data if task exists if task_id is not None: assets_dict[asset.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 assets_dict[asset.id]['task_status'][task_type] = task_status # Add to task details with enhanced information assets_dict[asset.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 asset_data in assets_dict.values(): asset = asset_data['asset'] # Create asset response with optimized data asset_response = AssetListResponse.model_validate(asset) asset_response.task_count = len(asset_data['tasks']) asset_response.task_status = asset_data['task_status'] asset_response.task_details = asset_data['task_details'] result.append(asset_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 = [ asset for asset in result if asset.task_status.get(task_type) == filter_status ] except (ValueError, KeyError): # Invalid filter format, ignore pass # Apply task status sorting if specified if sort_by and sort_by.endswith('_status'): task_type = sort_by.replace('_status', '') # Get custom statuses for proper sorting using pre-fetched data def get_status_order(asset): status = asset.task_status.get(task_type, "not_started") # Use pre-fetched custom statuses from project_data asset_project_data = project_data.get(getattr(asset, 'project_id', None), {}) custom_statuses = asset_project_data.get('custom_statuses', []) return get_status_sort_order(status, custom_statuses) reverse = sort_direction.lower() == 'desc' result.sort(key=get_status_order, reverse=reverse) return result @router.post("/", response_model=AssetResponse, status_code=status.HTTP_201_CREATED) async def create_asset( asset: AssetCreate, project_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_coordinator_or_admin) ): """Create a new asset in a project with optional default tasks""" # Check project access check_project_access(project_id, current_user, db) # Check if asset name already exists in project (exclude soft deleted) existing_asset = db.query(Asset).filter( Asset.project_id == project_id, Asset.name == asset.name, Asset.deleted_at.is_(None) ).first() if existing_asset: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Asset with this name already exists in the project" ) # Create new asset (exclude fields that don't belong to Asset model) asset_data = asset.model_dump(exclude={'create_default_tasks', 'selected_task_types'}) db_asset = Asset( project_id=project_id, **asset_data ) db.add(db_asset) db.flush() # Flush to get the asset ID # Create default tasks if requested task_count = 0 if asset.create_default_tasks: # Determine which task types to create if asset.selected_task_types: # Use the selected task types (already strings, can include custom types) task_types = asset.selected_task_types # Validate that all selected task types are valid (standard or custom) all_valid_types = get_all_asset_task_types(project_id, db) invalid_types = [t for t in 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)}" ) else: # Use default task types for the asset category task_types = get_default_asset_task_types(asset.category) # Create the tasks created_tasks = create_default_tasks_for_asset(db_asset, task_types, db) task_count = len(created_tasks) db.commit() db.refresh(db_asset) # Add task count asset_data = AssetResponse.model_validate(db_asset) asset_data.task_count = task_count return asset_data @router.get("/{asset_id}", response_model=AssetResponse) async def get_asset( asset_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user_with_db) ): """Get a specific asset by ID""" from sqlalchemy.orm import joinedload, selectinload # OPTIMIZATION: Use single query with optimized JOINs to fetch asset and all related data # This replaces separate queries with a single database operation asset_query = ( db.query(Asset) .options( joinedload(Asset.project), # Eager load project selectinload(Asset.tasks).options( # Use selectinload for better performance with tasks selectinload(Task.assigned_user) # Eager load assigned users if needed ) ) .filter(Asset.id == asset_id, Asset.deleted_at.is_(None)) ) asset = asset_query.first() if not asset: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Asset not found" ) # Check project access check_project_access(asset.project_id, current_user, db) # OPTIMIZATION: Count tasks from the already loaded relationship # This avoids a separate COUNT query active_tasks = [task for task in asset.tasks if task.deleted_at is None] task_count = len(active_tasks) asset_data = AssetResponse.model_validate(asset) asset_data.task_count = task_count return asset_data @router.get("/{asset_id}/task-status", response_model=List[TaskStatusInfo]) async def get_asset_task_status( asset_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user_with_db) ): """Get detailed task status for a specific asset""" # Exclude soft deleted assets asset = db.query(Asset).filter( Asset.id == asset_id, Asset.deleted_at.is_(None) ).first() if not asset: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Asset not found" ) # Check project access check_project_access(asset.project_id, current_user, db) # Get all active tasks for this asset (exclude soft deleted) tasks = db.query(Task).filter( Task.asset_id == asset.id, Task.deleted_at.is_(None) ).all() # Build detailed task status information task_details = [] for task in tasks: task_details.append(TaskStatusInfo( task_type=task.task_type, status=task.status, task_id=task.id, assigned_user_id=task.assigned_user_id )) return task_details @router.post("/{asset_id}/tasks", response_model=TaskStatusInfo, status_code=status.HTTP_201_CREATED) async def create_asset_task( asset_id: int, task_type: str, # Changed from TaskType enum to str db: Session = Depends(get_db), current_user: User = Depends(require_coordinator_or_admin) ): """Create a new task for an asset""" # Exclude soft deleted assets asset = db.query(Asset).filter( Asset.id == asset_id, Asset.deleted_at.is_(None) ).first() if not asset: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Asset not found" ) # Check project access check_project_access(asset.project_id, current_user, db) # Check if task already exists (exclude soft deleted) existing_task = db.query(Task).filter( Task.asset_id == asset_id, Task.task_type == task_type, Task.deleted_at.is_(None) ).first() if existing_task: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Task already exists for this asset and task type" ) # Create the task task_name = f"{asset.name} - {task_type.title()}" db_task = Task( project_id=asset.project_id, asset_id=asset.id, task_type=task_type, name=task_name, description=f"{task_type.title()} task for {asset.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("/{asset_id}", response_model=AssetResponse) async def update_asset( asset_id: int, asset_update: AssetUpdate, db: Session = Depends(get_db), current_user: User = Depends(require_coordinator_or_admin) ): """Update an asset""" # Exclude soft deleted assets db_asset = db.query(Asset).filter( Asset.id == asset_id, Asset.deleted_at.is_(None) ).first() if not db_asset: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Asset not found" ) # Check project access check_project_access(db_asset.project_id, current_user, db) # Check if new name conflicts with existing assets in the same project if asset_update.name and asset_update.name != db_asset.name: existing_asset = db.query(Asset).filter( Asset.project_id == db_asset.project_id, Asset.name == asset_update.name, Asset.id != asset_id, Asset.deleted_at.is_(None) ).first() if existing_asset: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Asset with this name already exists in the project" ) # Update only provided fields update_data = asset_update.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(db_asset, field, value) db.commit() db.refresh(db_asset) # Add task count (exclude soft deleted tasks) task_count = db.query(Task).filter( Task.asset_id == db_asset.id, Task.deleted_at.is_(None) ).count() asset_data = AssetResponse.model_validate(db_asset) asset_data.task_count = task_count return asset_data @router.get("/{asset_id}/deletion-info") async def get_asset_deletion_info( asset_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_coordinator_or_admin) ): """Get information about what will be deleted when deleting an asset""" # Exclude soft deleted assets db_asset = db.query(Asset).filter( Asset.id == asset_id, Asset.deleted_at.is_(None) ).first() if not db_asset: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Asset not found" ) # Check project access check_project_access(db_asset.project_id, current_user, db) # Use the soft deletion service to get comprehensive deletion info deletion_service = AssetSoftDeletionService() deletion_info = deletion_service.get_deletion_info(asset_id, db) if not deletion_info: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Asset not found" ) return { "asset_id": deletion_info.asset_id, "asset_name": deletion_info.asset_name, "asset_category": deletion_info.asset_category, "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("/{asset_id}") async def delete_asset( asset_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_coordinator_or_admin) ): """Soft delete an asset and all its associated data""" # Exclude soft deleted assets db_asset = db.query(Asset).filter( Asset.id == asset_id, Asset.deleted_at.is_(None) ).first() if not db_asset: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Asset not found" ) # Check project access check_project_access(db_asset.project_id, current_user, db) # Use the soft deletion service to perform cascading soft deletion deletion_service = AssetSoftDeletionService() result = deletion_service.soft_delete_asset_cascade(asset_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 asset", "errors": result.errors } ) # Commit the transaction db.commit() return { "message": f"Asset '{result.asset_name}' and all related data have been deleted", "asset_id": result.asset_id, "asset_name": result.asset_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 }