db2c414c1a
Users can now hold multiple roles, each with its own editable set of create/edit/delete-style permissions across assets, shots, tasks, task assignment, review approve/retake, submissions, uploads, and notes (including internal vs. client note visibility). The 4 existing roles (coordinator/director/artist/developer) are migrated into the new system as system roles, seeded to reproduce today's actual behavior exactly; admins can create custom roles (e.g. "Reviewer", "Outsourcing") via the new Role Management page and assign multiple roles to a user via a new "Manage Roles" action on the Team page. Backend: - New Role/Permission models and role_permissions/user_roles tables, plus a one-off, idempotent seed/backfill migration script. - New require_permission()/user_has_permission() dependency, wired into the actual mutation endpoints across shots/assets/tasks/reviews, always preserving existing ownership- and self-service-based access (e.g. artists editing their own task status, own notes, own uploads, own submissions) as an unconditional fallback alongside the new permission checks - nothing that worked before now requires a role. - New endpoints: PUT/DELETE on task submissions (wires up soft-deletion columns that existed on the model but were never exposed), plus full role CRUD and per-user role assignment. - Along the way: fixed newly-created users not being linked to their matching system role (silently leaving them with zero permissions), and unified an inconsistency between the single vs. bulk task status endpoints that allowed different roles to bulk-update status. Frontend: - Role Management page with a grouped, human-readable permission editor (icons, plain-language action labels, per-resource select-all, live selected count) replacing an earlier dense matrix prototype. - hasPermission() added to the existing usePermission() composable without touching its current isAdmin/isCoordinatorOrAdmin consumers. - Note composer gets an Internal/Client toggle; submissions gain inline edit/delete actions gated the same ownership-or-permission way as notes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
735 lines
26 KiB
Python
735 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, require_permission
|
|
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 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_permission('asset', 'create'))
|
|
):
|
|
"""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
|
|
asset_data.task_details = [
|
|
TaskStatusInfo(
|
|
task_type=task.task_type,
|
|
status=task.status,
|
|
task_id=task.id,
|
|
assigned_user_id=task.assigned_user_id
|
|
)
|
|
for task in active_tasks
|
|
]
|
|
|
|
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_permission('task', 'create'))
|
|
):
|
|
"""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_permission('asset', 'edit'))
|
|
):
|
|
"""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_permission('asset', 'delete'))
|
|
):
|
|
"""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_permission('asset', 'delete'))
|
|
):
|
|
"""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
|
|
} |