LinkDesk/backend/routers/assets.py

742 lines
26 KiB
Python

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
}