Init Repo
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Routers package
|
||||
@@ -0,0 +1,239 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from models.activity import Activity, ActivityType
|
||||
from models.project import ProjectMember
|
||||
from schemas.activity import ActivityResponse
|
||||
from utils.auth import get_current_user
|
||||
from utils.activity import ActivityService
|
||||
|
||||
router = APIRouter(prefix="/activities", tags=["activities"])
|
||||
|
||||
|
||||
@router.get("/project/{project_id}", response_model=List[ActivityResponse])
|
||||
def get_project_activities(
|
||||
project_id: int,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
type_filter: Optional[ActivityType] = None,
|
||||
days: Optional[int] = Query(None, ge=1, le=90),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get activity feed for a specific project (excludes activities for deleted records)."""
|
||||
# Verify user has access to the project
|
||||
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:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=403, detail="Access denied to this project")
|
||||
|
||||
# Use ActivityService to get activities excluding deleted records
|
||||
activities = ActivityService.get_activities_excluding_deleted(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
type_filter=type_filter,
|
||||
days=days
|
||||
)
|
||||
|
||||
return activities
|
||||
|
||||
|
||||
@router.get("/task/{task_id}", response_model=List[ActivityResponse])
|
||||
def get_task_activities(
|
||||
task_id: int,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get activity timeline for a specific task (excludes activities for deleted records)."""
|
||||
from models.task import Task
|
||||
|
||||
# Verify user has access to the task and it's not deleted
|
||||
task = db.query(Task).filter(Task.id == task_id, Task.deleted_at.is_(None)).first()
|
||||
if not task:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
# Check if user is a member of the project
|
||||
member = db.query(ProjectMember).filter(
|
||||
ProjectMember.project_id == task.project_id,
|
||||
ProjectMember.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not member and not current_user.is_admin:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=403, detail="Access denied to this task")
|
||||
|
||||
# Use ActivityService to get activities excluding deleted records
|
||||
activities = ActivityService.get_activities_excluding_deleted(
|
||||
db=db,
|
||||
task_id=task_id,
|
||||
skip=skip,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
return activities
|
||||
|
||||
|
||||
@router.get("/user/{user_id}", response_model=List[ActivityResponse])
|
||||
def get_user_activities(
|
||||
user_id: int,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
days: Optional[int] = Query(None, ge=1, le=90),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get activity history for a specific user (excludes activities for deleted records)."""
|
||||
# Users can only view their own activity unless they're admin
|
||||
if user_id != current_user.id and not current_user.is_admin:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Use ActivityService to get activities excluding deleted records
|
||||
activities = ActivityService.get_activities_excluding_deleted(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
days=days
|
||||
)
|
||||
|
||||
return activities
|
||||
|
||||
|
||||
@router.get("/recent", response_model=List[ActivityResponse])
|
||||
def get_recent_activities(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=50),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get recent activities from all projects the user has access to (excludes activities for deleted records)."""
|
||||
# Get all projects the user is a member of
|
||||
project_ids = db.query(ProjectMember.project_id).filter(
|
||||
ProjectMember.user_id == current_user.id
|
||||
).all()
|
||||
|
||||
project_ids = [pid[0] for pid in project_ids]
|
||||
|
||||
if not project_ids and not current_user.is_admin:
|
||||
return []
|
||||
|
||||
# For non-admin users, filter by their project access
|
||||
if not current_user.is_admin:
|
||||
# Get activities from user's projects, excluding deleted records
|
||||
all_activities = []
|
||||
for project_id in project_ids:
|
||||
activities = ActivityService.get_activities_excluding_deleted(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
skip=0,
|
||||
limit=limit * 2 # Get more to account for filtering
|
||||
)
|
||||
all_activities.extend(activities)
|
||||
|
||||
# Sort by created_at and apply pagination
|
||||
all_activities.sort(key=lambda x: x.created_at, reverse=True)
|
||||
return all_activities[skip:skip + limit]
|
||||
else:
|
||||
# Admin gets all activities excluding deleted records
|
||||
activities = ActivityService.get_activities_excluding_deleted(
|
||||
db=db,
|
||||
skip=skip,
|
||||
limit=limit
|
||||
)
|
||||
return activities
|
||||
|
||||
|
||||
# Admin-only endpoints that include activities for deleted records
|
||||
@router.get("/admin/project/{project_id}/all", response_model=List[ActivityResponse])
|
||||
def get_project_activities_including_deleted(
|
||||
project_id: int,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
type_filter: Optional[ActivityType] = None,
|
||||
days: Optional[int] = Query(None, ge=1, le=90),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get all activity feed for a specific project including deleted records (admin only)."""
|
||||
if not current_user.is_admin:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
# Use ActivityService to get all activities including deleted records
|
||||
activities = ActivityService.get_activities_including_deleted(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
type_filter=type_filter,
|
||||
days=days
|
||||
)
|
||||
|
||||
return activities
|
||||
|
||||
|
||||
@router.get("/admin/user/{user_id}/all", response_model=List[ActivityResponse])
|
||||
def get_user_activities_including_deleted(
|
||||
user_id: int,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
days: Optional[int] = Query(None, ge=1, le=90),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get all activity history for a specific user including deleted records (admin only)."""
|
||||
if not current_user.is_admin:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
# Use ActivityService to get all activities including deleted records
|
||||
activities = ActivityService.get_activities_including_deleted(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
days=days
|
||||
)
|
||||
|
||||
return activities
|
||||
|
||||
|
||||
@router.get("/admin/all", response_model=List[ActivityResponse])
|
||||
def get_all_activities_including_deleted(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
type_filter: Optional[ActivityType] = None,
|
||||
days: Optional[int] = Query(None, ge=1, le=90),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get all activities including deleted records (admin only)."""
|
||||
if not current_user.is_admin:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
# Use ActivityService to get all activities including deleted records
|
||||
activities = ActivityService.get_activities_including_deleted(
|
||||
db=db,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
type_filter=type_filter,
|
||||
days=days
|
||||
)
|
||||
|
||||
return activities
|
||||
@@ -0,0 +1,800 @@
|
||||
"""
|
||||
Admin Router
|
||||
|
||||
This router contains admin-only endpoints for managing soft-deleted data
|
||||
and other administrative functions.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
from database import get_db
|
||||
from models.user import User, UserRole
|
||||
from utils.auth import get_current_user_from_token
|
||||
from services.recovery_service import RecoveryService
|
||||
from services.batch_operations import BatchOperationsService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Simple rate limiting storage (in production, use Redis or similar)
|
||||
_rate_limit_storage = defaultdict(list)
|
||||
PERMANENT_DELETE_RATE_LIMIT = 10 # Max 10 permanent delete operations per minute per user
|
||||
|
||||
|
||||
class BulkRecoveryRequest(BaseModel):
|
||||
shot_ids: List[int] = []
|
||||
asset_ids: List[int] = []
|
||||
|
||||
|
||||
class BulkDeletionRequest(BaseModel):
|
||||
shot_ids: List[int] = []
|
||||
asset_ids: List[int] = []
|
||||
batch_size: Optional[int] = 50
|
||||
|
||||
|
||||
class BatchPreviewRequest(BaseModel):
|
||||
shot_ids: List[int] = []
|
||||
asset_ids: List[int] = []
|
||||
|
||||
|
||||
class PermanentDeleteRequest(BaseModel):
|
||||
confirmation_token: str
|
||||
|
||||
|
||||
class BulkPermanentDeleteRequest(BaseModel):
|
||||
shot_ids: List[int] = []
|
||||
asset_ids: List[int] = []
|
||||
confirmation_token: str
|
||||
|
||||
|
||||
def require_admin(
|
||||
token_data: dict = Depends(get_current_user_from_token),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Require admin role."""
|
||||
from utils.auth import _get_user_from_db
|
||||
current_user = _get_user_from_db(db, token_data["user_id"])
|
||||
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin permission required"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
def check_permanent_delete_rate_limit(user_id: int):
|
||||
"""Check if user has exceeded permanent delete rate limit."""
|
||||
current_time = time.time()
|
||||
user_requests = _rate_limit_storage[user_id]
|
||||
|
||||
# Remove requests older than 1 minute
|
||||
user_requests[:] = [req_time for req_time in user_requests if current_time - req_time < 60]
|
||||
|
||||
if len(user_requests) >= PERMANENT_DELETE_RATE_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"Rate limit exceeded. Maximum {PERMANENT_DELETE_RATE_LIMIT} permanent delete operations per minute."
|
||||
)
|
||||
|
||||
# Add current request
|
||||
user_requests.append(current_time)
|
||||
|
||||
|
||||
def validate_confirmation_token(token: str, expected_action: str):
|
||||
"""Validate confirmation token for permanent delete operations."""
|
||||
# Simple token validation - in production, use proper token generation/validation
|
||||
expected_token = f"CONFIRM_{expected_action}_PERMANENT_DELETE"
|
||||
if token != expected_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid confirmation token. Permanent deletion requires explicit confirmation."
|
||||
)
|
||||
|
||||
|
||||
@router.get("/deleted-shots/")
|
||||
async def get_deleted_shots(
|
||||
project_id: Optional[int] = Query(None, description="Filter by project ID"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Get list of deleted shots for admin recovery interface"""
|
||||
recovery_service = RecoveryService()
|
||||
deleted_shots = recovery_service.get_deleted_shots(project_id, db)
|
||||
|
||||
return [
|
||||
{
|
||||
"id": shot.id,
|
||||
"name": shot.name,
|
||||
"episode_name": shot.episode_name,
|
||||
"project_id": shot.project_id,
|
||||
"project_name": shot.project_name,
|
||||
"deleted_at": shot.deleted_at,
|
||||
"deleted_by": shot.deleted_by,
|
||||
"deleted_by_name": shot.deleted_by_name,
|
||||
"task_count": shot.task_count,
|
||||
"submission_count": shot.submission_count,
|
||||
"attachment_count": shot.attachment_count,
|
||||
"note_count": shot.note_count,
|
||||
"review_count": shot.review_count
|
||||
}
|
||||
for shot in deleted_shots
|
||||
]
|
||||
|
||||
|
||||
@router.get("/deleted-assets/")
|
||||
async def get_deleted_assets(
|
||||
project_id: Optional[int] = Query(None, description="Filter by project ID"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Get list of deleted assets for admin recovery interface"""
|
||||
recovery_service = RecoveryService()
|
||||
deleted_assets = recovery_service.get_deleted_assets(project_id, db)
|
||||
|
||||
return [
|
||||
{
|
||||
"id": asset.id,
|
||||
"name": asset.name,
|
||||
"category": asset.category,
|
||||
"project_id": asset.project_id,
|
||||
"project_name": asset.project_name,
|
||||
"deleted_at": asset.deleted_at,
|
||||
"deleted_by": asset.deleted_by,
|
||||
"deleted_by_name": asset.deleted_by_name,
|
||||
"task_count": asset.task_count,
|
||||
"submission_count": asset.submission_count,
|
||||
"attachment_count": asset.attachment_count,
|
||||
"note_count": asset.note_count,
|
||||
"review_count": asset.review_count
|
||||
}
|
||||
for asset in deleted_assets
|
||||
]
|
||||
|
||||
|
||||
@router.get("/shots/{shot_id}/recovery-preview")
|
||||
async def get_shot_recovery_preview(
|
||||
shot_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Get information about what will be recovered when restoring a shot"""
|
||||
recovery_service = RecoveryService()
|
||||
recovery_info = recovery_service.preview_shot_recovery(shot_id, db)
|
||||
|
||||
if not recovery_info:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Deleted shot not found"
|
||||
)
|
||||
|
||||
return {
|
||||
"shot_id": recovery_info.shot_id,
|
||||
"name": recovery_info.name,
|
||||
"episode_name": recovery_info.episode_name,
|
||||
"project_id": recovery_info.project_id,
|
||||
"project_name": recovery_info.project_name,
|
||||
"task_count": recovery_info.task_count,
|
||||
"submission_count": recovery_info.submission_count,
|
||||
"attachment_count": recovery_info.attachment_count,
|
||||
"note_count": recovery_info.note_count,
|
||||
"review_count": recovery_info.review_count,
|
||||
"deleted_at": recovery_info.deleted_at,
|
||||
"deleted_by": recovery_info.deleted_by,
|
||||
"deleted_by_name": recovery_info.deleted_by_name,
|
||||
"files_preserved": recovery_info.files_preserved,
|
||||
"file_count": recovery_info.file_count
|
||||
}
|
||||
|
||||
|
||||
@router.get("/assets/{asset_id}/recovery-preview")
|
||||
async def get_asset_recovery_preview(
|
||||
asset_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Get information about what will be recovered when restoring an asset"""
|
||||
recovery_service = RecoveryService()
|
||||
recovery_info = recovery_service.preview_asset_recovery(asset_id, db)
|
||||
|
||||
if not recovery_info:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Deleted asset not found"
|
||||
)
|
||||
|
||||
return {
|
||||
"asset_id": recovery_info.asset_id,
|
||||
"name": recovery_info.name,
|
||||
"project_name": recovery_info.project_name,
|
||||
"task_count": recovery_info.task_count,
|
||||
"submission_count": recovery_info.submission_count,
|
||||
"attachment_count": recovery_info.attachment_count,
|
||||
"note_count": recovery_info.note_count,
|
||||
"review_count": recovery_info.review_count,
|
||||
"deleted_at": recovery_info.deleted_at,
|
||||
"deleted_by": recovery_info.deleted_by,
|
||||
"deleted_by_name": recovery_info.deleted_by_name,
|
||||
"files_preserved": recovery_info.files_preserved,
|
||||
"file_count": recovery_info.file_count
|
||||
}
|
||||
|
||||
|
||||
@router.post("/shots/{shot_id}/recover")
|
||||
async def recover_shot(
|
||||
shot_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Recover a soft-deleted shot and all its related data"""
|
||||
recovery_service = RecoveryService()
|
||||
result = recovery_service.recover_shot(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 recover shot",
|
||||
"errors": result.errors
|
||||
}
|
||||
)
|
||||
|
||||
# Commit the transaction
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"Shot '{result.name}' and all related data have been recovered",
|
||||
"shot_id": result.shot_id,
|
||||
"name": result.name,
|
||||
"recovered_at": result.recovered_at,
|
||||
"recovered_by": result.recovered_by,
|
||||
"recovered_tasks": result.recovered_tasks,
|
||||
"recovered_submissions": result.recovered_submissions,
|
||||
"recovered_attachments": result.recovered_attachments,
|
||||
"recovered_notes": result.recovered_notes,
|
||||
"recovered_reviews": result.recovered_reviews,
|
||||
"operation_duration": result.operation_duration
|
||||
}
|
||||
|
||||
|
||||
@router.post("/assets/{asset_id}/recover")
|
||||
async def recover_asset(
|
||||
asset_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Recover a soft-deleted asset and all its related data"""
|
||||
recovery_service = RecoveryService()
|
||||
result = recovery_service.recover_asset(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 recover asset",
|
||||
"errors": result.errors
|
||||
}
|
||||
)
|
||||
|
||||
# Commit the transaction
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"Asset '{result.name}' and all related data have been recovered",
|
||||
"asset_id": result.asset_id,
|
||||
"name": result.name,
|
||||
"recovered_at": result.recovered_at,
|
||||
"recovered_by": result.recovered_by,
|
||||
"recovered_tasks": result.recovered_tasks,
|
||||
"recovered_submissions": result.recovered_submissions,
|
||||
"recovered_attachments": result.recovered_attachments,
|
||||
"recovered_notes": result.recovered_notes,
|
||||
"recovered_reviews": result.recovered_reviews,
|
||||
"operation_duration": result.operation_duration
|
||||
}
|
||||
|
||||
|
||||
@router.post("/shots/bulk-recover")
|
||||
async def bulk_recover_shots(
|
||||
request: BulkRecoveryRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Bulk recover multiple shots"""
|
||||
if not request.shot_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No shot IDs provided"
|
||||
)
|
||||
|
||||
recovery_service = RecoveryService()
|
||||
result = recovery_service.bulk_recover_shots(request.shot_ids, db, current_user)
|
||||
|
||||
# Commit the transaction
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"total_items": result.total_items,
|
||||
"successful_recoveries": result.successful_recoveries,
|
||||
"failed_recoveries": result.failed_recoveries,
|
||||
"results": [
|
||||
{
|
||||
"success": r.success,
|
||||
"shot_id": r.shot_id,
|
||||
"name": r.name,
|
||||
"recovered_tasks": r.recovered_tasks,
|
||||
"recovered_submissions": r.recovered_submissions,
|
||||
"recovered_attachments": r.recovered_attachments,
|
||||
"recovered_notes": r.recovered_notes,
|
||||
"recovered_reviews": r.recovered_reviews,
|
||||
"operation_duration": r.operation_duration,
|
||||
"errors": r.errors,
|
||||
"warnings": r.warnings
|
||||
}
|
||||
for r in result.results
|
||||
],
|
||||
"errors": [
|
||||
{
|
||||
"item_id": e.item_id,
|
||||
"item_type": e.item_type,
|
||||
"error": e.error
|
||||
}
|
||||
for e in result.errors
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/assets/bulk-recover")
|
||||
async def bulk_recover_assets(
|
||||
request: BulkRecoveryRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Bulk recover multiple assets"""
|
||||
if not request.asset_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No asset IDs provided"
|
||||
)
|
||||
|
||||
recovery_service = RecoveryService()
|
||||
result = recovery_service.bulk_recover_assets(request.asset_ids, db, current_user)
|
||||
|
||||
# Commit the transaction
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"total_items": result.total_items,
|
||||
"successful_recoveries": result.successful_recoveries,
|
||||
"failed_recoveries": result.failed_recoveries,
|
||||
"results": [
|
||||
{
|
||||
"success": r.success,
|
||||
"asset_id": r.asset_id,
|
||||
"name": r.name,
|
||||
"recovered_tasks": r.recovered_tasks,
|
||||
"recovered_submissions": r.recovered_submissions,
|
||||
"recovered_attachments": r.recovered_attachments,
|
||||
"recovered_notes": r.recovered_notes,
|
||||
"recovered_reviews": r.recovered_reviews,
|
||||
"operation_duration": r.operation_duration,
|
||||
"errors": r.errors,
|
||||
"warnings": r.warnings
|
||||
}
|
||||
for r in result.results
|
||||
],
|
||||
"errors": [
|
||||
{
|
||||
"item_id": e.item_id,
|
||||
"item_type": e.item_type,
|
||||
"error": e.error
|
||||
}
|
||||
for e in result.errors
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/recovery-stats/")
|
||||
async def get_recovery_stats(
|
||||
project_id: Optional[int] = Query(None, description="Filter by project ID"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Get recovery statistics for admin dashboard"""
|
||||
recovery_service = RecoveryService()
|
||||
stats = recovery_service.get_recovery_stats(project_id, db)
|
||||
|
||||
return {
|
||||
"deleted_shots_count": stats.deleted_shots_count,
|
||||
"deleted_assets_count": stats.deleted_assets_count,
|
||||
"total_deleted_tasks": stats.total_deleted_tasks,
|
||||
"total_deleted_files": stats.total_deleted_files,
|
||||
"oldest_deletion_date": stats.oldest_deletion_date
|
||||
}
|
||||
|
||||
|
||||
# Permanent Delete Endpoints
|
||||
|
||||
@router.delete("/shots/{shot_id}/permanent")
|
||||
async def permanent_delete_shot(
|
||||
shot_id: int,
|
||||
request: PermanentDeleteRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Permanently delete a soft-deleted shot and all its related data"""
|
||||
# Check rate limit
|
||||
check_permanent_delete_rate_limit(current_user.id)
|
||||
|
||||
# Validate confirmation token
|
||||
validate_confirmation_token(request.confirmation_token, "SHOT")
|
||||
|
||||
recovery_service = RecoveryService()
|
||||
result = recovery_service.permanent_delete_shot(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 permanently delete shot",
|
||||
"errors": result.errors,
|
||||
"warnings": result.warnings
|
||||
}
|
||||
)
|
||||
|
||||
# Commit the transaction
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"Shot '{result.name}' has been permanently deleted",
|
||||
"shot_id": result.shot_id,
|
||||
"name": result.name,
|
||||
"deleted_at": result.deleted_at,
|
||||
"deleted_by": result.deleted_by,
|
||||
"deleted_tasks": result.deleted_tasks,
|
||||
"deleted_submissions": result.deleted_submissions,
|
||||
"deleted_attachments": result.deleted_attachments,
|
||||
"deleted_notes": result.deleted_notes,
|
||||
"deleted_reviews": result.deleted_reviews,
|
||||
"deleted_files": result.deleted_files,
|
||||
"database_records_deleted": result.database_records_deleted,
|
||||
"operation_duration": result.operation_duration,
|
||||
"warnings": result.warnings
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/assets/{asset_id}/permanent")
|
||||
async def permanent_delete_asset(
|
||||
asset_id: int,
|
||||
request: PermanentDeleteRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Permanently delete a soft-deleted asset and all its related data"""
|
||||
# Check rate limit
|
||||
check_permanent_delete_rate_limit(current_user.id)
|
||||
|
||||
# Validate confirmation token
|
||||
validate_confirmation_token(request.confirmation_token, "ASSET")
|
||||
|
||||
recovery_service = RecoveryService()
|
||||
result = recovery_service.permanent_delete_asset(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 permanently delete asset",
|
||||
"errors": result.errors,
|
||||
"warnings": result.warnings
|
||||
}
|
||||
)
|
||||
|
||||
# Commit the transaction
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"Asset '{result.name}' has been permanently deleted",
|
||||
"asset_id": result.asset_id,
|
||||
"name": result.name,
|
||||
"deleted_at": result.deleted_at,
|
||||
"deleted_by": result.deleted_by,
|
||||
"deleted_tasks": result.deleted_tasks,
|
||||
"deleted_submissions": result.deleted_submissions,
|
||||
"deleted_attachments": result.deleted_attachments,
|
||||
"deleted_notes": result.deleted_notes,
|
||||
"deleted_reviews": result.deleted_reviews,
|
||||
"deleted_files": result.deleted_files,
|
||||
"database_records_deleted": result.database_records_deleted,
|
||||
"operation_duration": result.operation_duration,
|
||||
"warnings": result.warnings
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/shots/bulk-permanent")
|
||||
async def bulk_permanent_delete_shots(
|
||||
request: BulkPermanentDeleteRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Permanently delete multiple shots in bulk"""
|
||||
if not request.shot_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No shot IDs provided"
|
||||
)
|
||||
|
||||
# Check rate limit (bulk operations count as multiple operations)
|
||||
for _ in request.shot_ids:
|
||||
check_permanent_delete_rate_limit(current_user.id)
|
||||
|
||||
# Validate confirmation token
|
||||
validate_confirmation_token(request.confirmation_token, "BULK_SHOTS")
|
||||
|
||||
recovery_service = RecoveryService()
|
||||
result = recovery_service.bulk_permanent_delete_shots(request.shot_ids, db, current_user)
|
||||
|
||||
# Commit the transaction
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"Bulk permanent deletion completed: {result.successful_deletions} successful, {result.failed_deletions} failed",
|
||||
"total_items": result.total_items,
|
||||
"successful_deletions": result.successful_deletions,
|
||||
"failed_deletions": result.failed_deletions,
|
||||
"deleted_items": result.deleted_items,
|
||||
"files_deleted": result.files_deleted,
|
||||
"database_records_deleted": result.database_records_deleted,
|
||||
"errors": result.errors
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/assets/bulk-permanent")
|
||||
async def bulk_permanent_delete_assets(
|
||||
request: BulkPermanentDeleteRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Permanently delete multiple assets in bulk"""
|
||||
if not request.asset_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No asset IDs provided"
|
||||
)
|
||||
|
||||
# Check rate limit (bulk operations count as multiple operations)
|
||||
for _ in request.asset_ids:
|
||||
check_permanent_delete_rate_limit(current_user.id)
|
||||
|
||||
# Validate confirmation token
|
||||
validate_confirmation_token(request.confirmation_token, "BULK_ASSETS")
|
||||
|
||||
recovery_service = RecoveryService()
|
||||
result = recovery_service.bulk_permanent_delete_assets(request.asset_ids, db, current_user)
|
||||
|
||||
# Commit the transaction
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"Bulk permanent deletion completed: {result.successful_deletions} successful, {result.failed_deletions} failed",
|
||||
"total_items": result.total_items,
|
||||
"successful_deletions": result.successful_deletions,
|
||||
"failed_deletions": result.failed_deletions,
|
||||
"deleted_items": result.deleted_items,
|
||||
"files_deleted": result.files_deleted,
|
||||
"database_records_deleted": result.database_records_deleted,
|
||||
"errors": result.errors
|
||||
}
|
||||
|
||||
|
||||
# Batch Operations Endpoints
|
||||
|
||||
@router.post("/batch-deletion-preview")
|
||||
async def get_batch_deletion_preview(
|
||||
request: BatchPreviewRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Get preview information for a batch deletion operation"""
|
||||
if not request.shot_ids and not request.asset_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No shot or asset IDs provided"
|
||||
)
|
||||
|
||||
batch_service = BatchOperationsService()
|
||||
preview = batch_service.get_batch_deletion_preview(
|
||||
request.shot_ids, request.asset_ids, db
|
||||
)
|
||||
|
||||
return preview
|
||||
|
||||
|
||||
@router.post("/shots/batch-delete")
|
||||
async def batch_delete_shots(
|
||||
request: BulkDeletionRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Batch delete multiple shots"""
|
||||
if not request.shot_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No shot IDs provided"
|
||||
)
|
||||
|
||||
batch_service = BatchOperationsService()
|
||||
result = batch_service.batch_delete_shots(
|
||||
request.shot_ids, db, current_user, request.batch_size or 50
|
||||
)
|
||||
|
||||
# Commit the transaction
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"total_items": result.total_items,
|
||||
"successful_deletions": result.successful_deletions,
|
||||
"failed_deletions": result.failed_deletions,
|
||||
"operation_duration": result.operation_duration,
|
||||
"total_deleted_tasks": result.total_deleted_tasks,
|
||||
"total_deleted_submissions": result.total_deleted_submissions,
|
||||
"total_deleted_attachments": result.total_deleted_attachments,
|
||||
"total_deleted_notes": result.total_deleted_notes,
|
||||
"total_deleted_reviews": result.total_deleted_reviews,
|
||||
"items": [
|
||||
{
|
||||
"id": item.id,
|
||||
"name": item.name,
|
||||
"type": item.type,
|
||||
"success": item.success,
|
||||
"error": item.error,
|
||||
"deleted_counts": item.deleted_counts
|
||||
}
|
||||
for item in result.items
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/assets/batch-delete")
|
||||
async def batch_delete_assets(
|
||||
request: BulkDeletionRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Batch delete multiple assets"""
|
||||
if not request.asset_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No asset IDs provided"
|
||||
)
|
||||
|
||||
batch_service = BatchOperationsService()
|
||||
result = batch_service.batch_delete_assets(
|
||||
request.asset_ids, db, current_user, request.batch_size or 50
|
||||
)
|
||||
|
||||
# Commit the transaction
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"total_items": result.total_items,
|
||||
"successful_deletions": result.successful_deletions,
|
||||
"failed_deletions": result.failed_deletions,
|
||||
"operation_duration": result.operation_duration,
|
||||
"total_deleted_tasks": result.total_deleted_tasks,
|
||||
"total_deleted_submissions": result.total_deleted_submissions,
|
||||
"total_deleted_attachments": result.total_deleted_attachments,
|
||||
"total_deleted_notes": result.total_deleted_notes,
|
||||
"total_deleted_reviews": result.total_deleted_reviews,
|
||||
"items": [
|
||||
{
|
||||
"id": item.id,
|
||||
"name": item.name,
|
||||
"type": item.type,
|
||||
"success": item.success,
|
||||
"error": item.error,
|
||||
"deleted_counts": item.deleted_counts
|
||||
}
|
||||
for item in result.items
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/shots/batch-recover")
|
||||
async def batch_recover_shots_enhanced(
|
||||
request: BulkRecoveryRequest,
|
||||
batch_size: Optional[int] = Query(50, description="Batch size for processing"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Enhanced batch recover multiple shots with configurable batch size"""
|
||||
if not request.shot_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No shot IDs provided"
|
||||
)
|
||||
|
||||
batch_service = BatchOperationsService()
|
||||
result = batch_service.batch_recover_shots(
|
||||
request.shot_ids, db, current_user, batch_size
|
||||
)
|
||||
|
||||
# Commit the transaction
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"total_items": result.total_items,
|
||||
"successful_recoveries": result.successful_recoveries,
|
||||
"failed_recoveries": result.failed_recoveries,
|
||||
"operation_duration": result.operation_duration,
|
||||
"total_recovered_tasks": result.total_recovered_tasks,
|
||||
"total_recovered_submissions": result.total_recovered_submissions,
|
||||
"total_recovered_attachments": result.total_recovered_attachments,
|
||||
"total_recovered_notes": result.total_recovered_notes,
|
||||
"total_recovered_reviews": result.total_recovered_reviews,
|
||||
"items": [
|
||||
{
|
||||
"id": item.id,
|
||||
"name": item.name,
|
||||
"type": item.type,
|
||||
"success": item.success,
|
||||
"error": item.error,
|
||||
"recovered_counts": item.recovered_counts
|
||||
}
|
||||
for item in result.items
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/assets/batch-recover")
|
||||
async def batch_recover_assets_enhanced(
|
||||
request: BulkRecoveryRequest,
|
||||
batch_size: Optional[int] = Query(50, description="Batch size for processing"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Enhanced batch recover multiple assets with configurable batch size"""
|
||||
if not request.asset_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No asset IDs provided"
|
||||
)
|
||||
|
||||
batch_service = BatchOperationsService()
|
||||
result = batch_service.batch_recover_assets(
|
||||
request.asset_ids, db, current_user, batch_size
|
||||
)
|
||||
|
||||
# Commit the transaction
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"total_items": result.total_items,
|
||||
"successful_recoveries": result.successful_recoveries,
|
||||
"failed_recoveries": result.failed_recoveries,
|
||||
"operation_duration": result.operation_duration,
|
||||
"total_recovered_tasks": result.total_recovered_tasks,
|
||||
"total_recovered_submissions": result.total_recovered_submissions,
|
||||
"total_recovered_attachments": result.total_recovered_attachments,
|
||||
"total_recovered_notes": result.total_recovered_notes,
|
||||
"total_recovered_reviews": result.total_recovered_reviews,
|
||||
"items": [
|
||||
{
|
||||
"id": item.id,
|
||||
"name": item.name,
|
||||
"type": item.type,
|
||||
"success": item.success,
|
||||
"error": item.error,
|
||||
"recovered_counts": item.recovered_counts
|
||||
}
|
||||
for item in result.items
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,742 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import timedelta
|
||||
from typing import List
|
||||
import json
|
||||
|
||||
from database import get_db
|
||||
from models.user import User, UserRole
|
||||
from models.api_key import APIKey, APIKeyScope
|
||||
from models.api_key_usage import APIKeyUsage
|
||||
from schemas.auth import UserLogin, UserRegister, Token, RefreshToken
|
||||
from schemas.api_key import APIKeyCreate, APIKeyResponse, APIKeyWithToken, APIKeyUpdate, APIKeyUsageLog
|
||||
from utils.auth import (
|
||||
verify_password,
|
||||
get_password_hash,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
verify_token,
|
||||
security,
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||
REFRESH_TOKEN_EXPIRE_DAYS,
|
||||
generate_api_key,
|
||||
hash_api_key,
|
||||
get_current_user_flexible,
|
||||
require_role
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/register", response_model=dict)
|
||||
async def register(user_data: UserRegister, db: Session = Depends(get_db)):
|
||||
"""Register a new user account."""
|
||||
# Check if user already exists
|
||||
existing_user = db.query(User).filter(User.email == user_data.email).first()
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already registered"
|
||||
)
|
||||
|
||||
# Create new user
|
||||
hashed_password = get_password_hash(user_data.password)
|
||||
new_user = User(
|
||||
email=user_data.email,
|
||||
password_hash=hashed_password,
|
||||
first_name=user_data.first_name,
|
||||
last_name=user_data.last_name,
|
||||
role=UserRole.ARTIST, # Default role
|
||||
is_approved=False # Requires admin approval
|
||||
)
|
||||
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
|
||||
return {
|
||||
"message": "User registered successfully. Awaiting admin approval.",
|
||||
"user_id": new_user.id
|
||||
}
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(user_credentials: UserLogin, db: Session = Depends(get_db)):
|
||||
"""Authenticate user and return JWT tokens."""
|
||||
# Find user by email
|
||||
print(user_credentials.email)
|
||||
user = db.query(User).filter(User.email == user_credentials.email).first()
|
||||
if not user or not verify_password(user_credentials.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect email or password"
|
||||
)
|
||||
|
||||
# Check if user is approved
|
||||
if not user.is_approved:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Account not approved by administrator"
|
||||
)
|
||||
|
||||
# Create tokens
|
||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
refresh_token_expires = timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
|
||||
token_data = {"sub": str(user.id), "email": user.email, "role": user.role}
|
||||
|
||||
access_token = create_access_token(
|
||||
data=token_data,
|
||||
expires_delta=access_token_expires
|
||||
)
|
||||
refresh_token = create_refresh_token(
|
||||
data=token_data,
|
||||
expires_delta=refresh_token_expires
|
||||
)
|
||||
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=Token)
|
||||
async def refresh_token(refresh_data: RefreshToken, db: Session = Depends(get_db)):
|
||||
"""Refresh access token using refresh token."""
|
||||
# Verify refresh token
|
||||
payload = verify_token(refresh_data.refresh_token, "refresh")
|
||||
if payload is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token"
|
||||
)
|
||||
|
||||
# Get user from database
|
||||
user_id = payload.get("sub")
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user or not user.is_approved:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found or not approved"
|
||||
)
|
||||
|
||||
# Create new tokens
|
||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
refresh_token_expires = timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
|
||||
token_data = {"sub": str(user.id), "email": user.email, "role": user.role}
|
||||
|
||||
new_access_token = create_access_token(
|
||||
data=token_data,
|
||||
expires_delta=access_token_expires
|
||||
)
|
||||
new_refresh_token = create_refresh_token(
|
||||
data=token_data,
|
||||
expires_delta=refresh_token_expires
|
||||
)
|
||||
|
||||
return Token(
|
||||
access_token=new_access_token,
|
||||
refresh_token=new_refresh_token
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout():
|
||||
"""Logout user (client should discard tokens)."""
|
||||
return {"message": "Successfully logged out"}
|
||||
|
||||
|
||||
# API Key Management Endpoints
|
||||
|
||||
@router.post("/api-keys", response_model=APIKeyWithToken)
|
||||
async def create_api_key(
|
||||
api_key_data: APIKeyCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_flexible)
|
||||
):
|
||||
"""Create a new API key. Only developers and admins can create API keys."""
|
||||
# Check if user has permission to create API keys
|
||||
if current_user.role != UserRole.DEVELOPER and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only developers and users with admin permission can create API keys"
|
||||
)
|
||||
|
||||
# Determine target user for the API key
|
||||
target_user_id = current_user.id # Default to current user
|
||||
target_user = current_user
|
||||
|
||||
# If user_id is specified and current user has admin permission, allow creating for other users
|
||||
if api_key_data.user_id is not None:
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only users with admin permission can create API keys for other users"
|
||||
)
|
||||
|
||||
# Verify target user exists and is approved
|
||||
target_user = db.query(User).filter(User.id == api_key_data.user_id).first()
|
||||
if not target_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Target user not found"
|
||||
)
|
||||
|
||||
if not target_user.is_approved:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot create API key for unapproved user"
|
||||
)
|
||||
|
||||
target_user_id = target_user.id
|
||||
|
||||
# Generate API key
|
||||
api_key_token = generate_api_key()
|
||||
key_hash = hash_api_key(api_key_token)
|
||||
|
||||
# Convert scopes to JSON string
|
||||
scopes_json = json.dumps([scope.value for scope in api_key_data.scopes])
|
||||
|
||||
# Create API key record
|
||||
new_api_key = APIKey(
|
||||
user_id=target_user_id,
|
||||
key_hash=key_hash,
|
||||
name=api_key_data.name,
|
||||
scopes=scopes_json,
|
||||
expires_at=api_key_data.expires_at
|
||||
)
|
||||
|
||||
db.add(new_api_key)
|
||||
db.commit()
|
||||
db.refresh(new_api_key)
|
||||
|
||||
# Convert scopes back to list for response
|
||||
scopes_list = json.loads(new_api_key.scopes)
|
||||
|
||||
api_key_response = APIKeyResponse(
|
||||
id=new_api_key.id,
|
||||
user_id=new_api_key.user_id,
|
||||
name=new_api_key.name,
|
||||
scopes=scopes_list,
|
||||
is_active=new_api_key.is_active,
|
||||
expires_at=new_api_key.expires_at,
|
||||
last_used_at=new_api_key.last_used_at,
|
||||
created_at=new_api_key.created_at,
|
||||
user_email=target_user.email
|
||||
)
|
||||
|
||||
return APIKeyWithToken(
|
||||
api_key=api_key_response,
|
||||
token=api_key_token
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api-keys", response_model=List[APIKeyResponse])
|
||||
async def list_api_keys(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_flexible),
|
||||
user_id: int = None
|
||||
):
|
||||
"""List API keys. Developers see their own, admins can see all or filter by user."""
|
||||
# Developers and users with admin permission can see API keys
|
||||
if current_user.role != UserRole.DEVELOPER and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only developers and users with admin permission can manage API keys"
|
||||
)
|
||||
|
||||
# Build query based on user permissions and parameters
|
||||
if current_user.is_admin:
|
||||
if user_id is not None:
|
||||
# Admin requesting specific user's API keys
|
||||
api_keys = db.query(APIKey).filter(APIKey.user_id == user_id).all()
|
||||
else:
|
||||
# Admin requesting all API keys
|
||||
api_keys = db.query(APIKey).all()
|
||||
else:
|
||||
# Developer can only see their own API keys
|
||||
if user_id is not None and user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Developers can only view their own API keys"
|
||||
)
|
||||
api_keys = db.query(APIKey).filter(APIKey.user_id == current_user.id).all()
|
||||
|
||||
result = []
|
||||
for api_key in api_keys:
|
||||
scopes_list = json.loads(api_key.scopes)
|
||||
|
||||
# Get user email for admin view
|
||||
user_email = None
|
||||
if current_user.is_admin:
|
||||
user = db.query(User).filter(User.id == api_key.user_id).first()
|
||||
if user:
|
||||
user_email = user.email
|
||||
|
||||
result.append(APIKeyResponse(
|
||||
id=api_key.id,
|
||||
user_id=api_key.user_id,
|
||||
name=api_key.name,
|
||||
scopes=scopes_list,
|
||||
is_active=api_key.is_active,
|
||||
expires_at=api_key.expires_at,
|
||||
last_used_at=api_key.last_used_at,
|
||||
created_at=api_key.created_at,
|
||||
user_email=user_email
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.put("/api-keys/{api_key_id}", response_model=APIKeyResponse)
|
||||
async def update_api_key(
|
||||
api_key_id: int,
|
||||
api_key_data: APIKeyUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_flexible)
|
||||
):
|
||||
"""Update an API key."""
|
||||
# Check if user has permission
|
||||
if current_user.role != UserRole.DEVELOPER and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only developers and users with admin permission can manage API keys"
|
||||
)
|
||||
|
||||
# Get API key with different access rules for admin vs developer
|
||||
if current_user.is_admin:
|
||||
# Users with admin permission can update any API key
|
||||
api_key = db.query(APIKey).filter(APIKey.id == api_key_id).first()
|
||||
else:
|
||||
# Developers can only update their own API keys
|
||||
api_key = db.query(APIKey).filter(
|
||||
APIKey.id == api_key_id,
|
||||
APIKey.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="API key not found"
|
||||
)
|
||||
|
||||
# Update fields
|
||||
if api_key_data.name is not None:
|
||||
api_key.name = api_key_data.name
|
||||
|
||||
if api_key_data.scopes is not None:
|
||||
scopes_json = json.dumps([scope.value for scope in api_key_data.scopes])
|
||||
api_key.scopes = scopes_json
|
||||
|
||||
if api_key_data.is_active is not None:
|
||||
api_key.is_active = api_key_data.is_active
|
||||
|
||||
if api_key_data.expires_at is not None:
|
||||
api_key.expires_at = api_key_data.expires_at
|
||||
|
||||
db.commit()
|
||||
db.refresh(api_key)
|
||||
|
||||
# Convert scopes back to list for response
|
||||
scopes_list = json.loads(api_key.scopes)
|
||||
|
||||
# Get user email for admin view
|
||||
user_email = None
|
||||
if current_user.is_admin:
|
||||
user = db.query(User).filter(User.id == api_key.user_id).first()
|
||||
if user:
|
||||
user_email = user.email
|
||||
|
||||
return APIKeyResponse(
|
||||
id=api_key.id,
|
||||
user_id=api_key.user_id,
|
||||
name=api_key.name,
|
||||
scopes=scopes_list,
|
||||
is_active=api_key.is_active,
|
||||
expires_at=api_key.expires_at,
|
||||
last_used_at=api_key.last_used_at,
|
||||
created_at=api_key.created_at,
|
||||
user_email=user_email
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/api-keys/{api_key_id}")
|
||||
async def delete_api_key(
|
||||
api_key_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_flexible)
|
||||
):
|
||||
"""Delete (revoke) an API key."""
|
||||
# Check if user has permission
|
||||
if current_user.role != UserRole.DEVELOPER and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only developers and users with admin permission can manage API keys"
|
||||
)
|
||||
|
||||
# Get API key with different access rules for admin vs developer
|
||||
if current_user.is_admin:
|
||||
# Users with admin permission can delete any API key
|
||||
api_key = db.query(APIKey).filter(APIKey.id == api_key_id).first()
|
||||
else:
|
||||
# Developers can only delete their own API keys
|
||||
api_key = db.query(APIKey).filter(
|
||||
APIKey.id == api_key_id,
|
||||
APIKey.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="API key not found"
|
||||
)
|
||||
|
||||
# Delete the API key
|
||||
db.delete(api_key)
|
||||
db.commit()
|
||||
|
||||
return {"message": "API key revoked successfully"}
|
||||
|
||||
|
||||
@router.get("/api-keys/{api_key_id}/usage", response_model=List[APIKeyUsageLog])
|
||||
async def get_api_key_usage(
|
||||
api_key_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_flexible),
|
||||
limit: int = 100
|
||||
):
|
||||
"""Get usage logs for an API key."""
|
||||
# Check if user has permission
|
||||
if current_user.role != UserRole.DEVELOPER and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only developers and users with admin permission can view API key usage"
|
||||
)
|
||||
|
||||
# Verify API key access with different rules for admin vs developer
|
||||
if current_user.is_admin:
|
||||
# Users with admin permission can view usage for any API key
|
||||
api_key = db.query(APIKey).filter(APIKey.id == api_key_id).first()
|
||||
else:
|
||||
# Developers can only view usage for their own API keys
|
||||
api_key = db.query(APIKey).filter(
|
||||
APIKey.id == api_key_id,
|
||||
APIKey.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="API key not found"
|
||||
)
|
||||
|
||||
# Get usage logs
|
||||
usage_logs = db.query(APIKeyUsage).filter(
|
||||
APIKeyUsage.api_key_id == api_key_id
|
||||
).order_by(APIKeyUsage.timestamp.desc()).limit(limit).all()
|
||||
|
||||
return [
|
||||
APIKeyUsageLog(
|
||||
api_key_id=log.api_key_id,
|
||||
endpoint=log.endpoint,
|
||||
method=log.method,
|
||||
timestamp=log.timestamp,
|
||||
ip_address=log.ip_address,
|
||||
user_agent=log.user_agent
|
||||
)
|
||||
for log in usage_logs
|
||||
]
|
||||
|
||||
|
||||
# Admin-only endpoints for API key management
|
||||
|
||||
@router.get("/admin/users/{user_id}/api-keys", response_model=List[APIKeyResponse])
|
||||
async def list_user_api_keys_admin(
|
||||
user_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_flexible)
|
||||
):
|
||||
"""Admin endpoint to list API keys for a specific user."""
|
||||
# Only users with admin permission can access this endpoint
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin permission required to access this endpoint"
|
||||
)
|
||||
|
||||
# Verify target user exists
|
||||
target_user = db.query(User).filter(User.id == user_id).first()
|
||||
if not target_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
# Get API keys for the specified user
|
||||
api_keys = db.query(APIKey).filter(APIKey.user_id == user_id).all()
|
||||
|
||||
result = []
|
||||
for api_key in api_keys:
|
||||
scopes_list = json.loads(api_key.scopes)
|
||||
result.append(APIKeyResponse(
|
||||
id=api_key.id,
|
||||
user_id=api_key.user_id,
|
||||
name=api_key.name,
|
||||
scopes=scopes_list,
|
||||
is_active=api_key.is_active,
|
||||
expires_at=api_key.expires_at,
|
||||
last_used_at=api_key.last_used_at,
|
||||
created_at=api_key.created_at,
|
||||
user_email=target_user.email
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/admin/users/{user_id}/api-keys", response_model=APIKeyWithToken)
|
||||
async def create_api_key_for_user_admin(
|
||||
user_id: int,
|
||||
api_key_data: APIKeyCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_flexible)
|
||||
):
|
||||
"""Admin endpoint to create an API key for a specific user."""
|
||||
# Only users with admin permission can access this endpoint
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin permission required to access this endpoint"
|
||||
)
|
||||
|
||||
# Verify target user exists and is approved
|
||||
target_user = db.query(User).filter(User.id == user_id).first()
|
||||
if not target_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
if not target_user.is_approved:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot create API key for unapproved user"
|
||||
)
|
||||
|
||||
# Generate API key
|
||||
api_key_token = generate_api_key()
|
||||
key_hash = hash_api_key(api_key_token)
|
||||
|
||||
# Convert scopes to JSON string
|
||||
scopes_json = json.dumps([scope.value for scope in api_key_data.scopes])
|
||||
|
||||
# Create API key record
|
||||
new_api_key = APIKey(
|
||||
user_id=user_id,
|
||||
key_hash=key_hash,
|
||||
name=api_key_data.name,
|
||||
scopes=scopes_json,
|
||||
expires_at=api_key_data.expires_at
|
||||
)
|
||||
|
||||
db.add(new_api_key)
|
||||
db.commit()
|
||||
db.refresh(new_api_key)
|
||||
|
||||
# Convert scopes back to list for response
|
||||
scopes_list = json.loads(new_api_key.scopes)
|
||||
|
||||
api_key_response = APIKeyResponse(
|
||||
id=new_api_key.id,
|
||||
user_id=new_api_key.user_id,
|
||||
name=new_api_key.name,
|
||||
scopes=scopes_list,
|
||||
is_active=new_api_key.is_active,
|
||||
expires_at=new_api_key.expires_at,
|
||||
last_used_at=new_api_key.last_used_at,
|
||||
created_at=new_api_key.created_at,
|
||||
user_email=target_user.email
|
||||
)
|
||||
|
||||
return APIKeyWithToken(
|
||||
api_key=api_key_response,
|
||||
token=api_key_token
|
||||
)
|
||||
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Data Consistency API endpoints for validating and monitoring task aggregation consistency.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional, Dict, Any
|
||||
|
||||
from database import get_db
|
||||
from models.user import User, UserRole
|
||||
from services.data_consistency import create_data_consistency_service
|
||||
from utils.auth import get_current_user_from_token, _get_user_from_db
|
||||
|
||||
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."""
|
||||
return _get_user_from_db(db, token_data["user_id"])
|
||||
|
||||
|
||||
def require_admin_or_coordinator(
|
||||
token_data: dict = Depends(get_current_user_from_token),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Require admin or coordinator role for consistency operations."""
|
||||
current_user = _get_user_from_db(db, token_data["user_id"])
|
||||
if current_user.role not in [UserRole.COORDINATOR] and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Admin or Coordinator role required for consistency operations"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
@router.get("/validate/{entity_type}/{entity_id}")
|
||||
async def validate_entity_consistency(
|
||||
entity_type: str,
|
||||
entity_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_with_db)
|
||||
):
|
||||
"""
|
||||
Validate task aggregation consistency for a specific shot or asset.
|
||||
|
||||
Args:
|
||||
entity_type: 'shot' or 'asset'
|
||||
entity_id: ID of the shot or asset
|
||||
"""
|
||||
if entity_type not in ['shot', 'asset']:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="entity_type must be 'shot' or 'asset'"
|
||||
)
|
||||
|
||||
consistency_service = create_data_consistency_service(db)
|
||||
|
||||
try:
|
||||
result = consistency_service.validate_task_aggregation_consistency(entity_id, entity_type)
|
||||
return result
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Validation failed: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/validate/bulk")
|
||||
async def validate_bulk_consistency(
|
||||
entity_ids: List[int],
|
||||
entity_type: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_or_coordinator)
|
||||
):
|
||||
"""
|
||||
Validate task aggregation consistency for multiple shots or assets.
|
||||
|
||||
Args:
|
||||
entity_ids: List of shot or asset IDs
|
||||
entity_type: 'shot' or 'asset'
|
||||
"""
|
||||
if entity_type not in ['shot', 'asset']:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="entity_type must be 'shot' or 'asset'"
|
||||
)
|
||||
|
||||
if len(entity_ids) > 100:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Maximum 100 entities can be validated at once"
|
||||
)
|
||||
|
||||
consistency_service = create_data_consistency_service(db)
|
||||
|
||||
try:
|
||||
result = consistency_service.validate_bulk_consistency(entity_ids, entity_type)
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Bulk validation failed: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/report")
|
||||
async def get_consistency_report(
|
||||
project_id: Optional[int] = Query(None, description="Filter by project ID"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_or_coordinator)
|
||||
):
|
||||
"""
|
||||
Generate a comprehensive consistency report for shots and assets.
|
||||
|
||||
Args:
|
||||
project_id: Optional project ID to filter by
|
||||
"""
|
||||
consistency_service = create_data_consistency_service(db)
|
||||
|
||||
try:
|
||||
result = consistency_service.get_consistency_report(project_id)
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Report generation failed: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/propagate/{task_id}")
|
||||
async def propagate_task_update(
|
||||
task_id: int,
|
||||
old_status: Optional[str] = None,
|
||||
new_status: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_or_coordinator)
|
||||
):
|
||||
"""
|
||||
Manually propagate a task update and validate consistency.
|
||||
|
||||
Args:
|
||||
task_id: ID of the task to propagate
|
||||
old_status: Previous task status (optional)
|
||||
new_status: New task status (optional)
|
||||
"""
|
||||
consistency_service = create_data_consistency_service(db)
|
||||
|
||||
try:
|
||||
result = consistency_service.propagate_task_update(task_id, old_status, new_status)
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Task propagation failed: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def consistency_health_check(
|
||||
project_id: Optional[int] = Query(None, description="Filter by project ID"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_with_db)
|
||||
):
|
||||
"""
|
||||
Quick health check for data consistency across the system.
|
||||
|
||||
Args:
|
||||
project_id: Optional project ID to filter by
|
||||
"""
|
||||
consistency_service = create_data_consistency_service(db)
|
||||
|
||||
try:
|
||||
report = consistency_service.get_consistency_report(project_id)
|
||||
|
||||
# Extract key health metrics
|
||||
summary = report['summary']
|
||||
health_status = "healthy" if summary['consistency_percentage'] >= 95 else "degraded" if summary['consistency_percentage'] >= 80 else "unhealthy"
|
||||
|
||||
return {
|
||||
'status': health_status,
|
||||
'consistency_percentage': summary['consistency_percentage'],
|
||||
'total_entities': summary['total_entities'],
|
||||
'valid_entities': summary['valid_entities'],
|
||||
'invalid_entities': summary['invalid_entities'],
|
||||
'total_inconsistencies': summary['total_inconsistencies'],
|
||||
'project_id': project_id,
|
||||
'timestamp': report['report_timestamp']
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'status': 'error',
|
||||
'error': str(e),
|
||||
'timestamp': None
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from database import get_db
|
||||
from models.user import User, UserRole
|
||||
from models.project import Project, ProjectMember
|
||||
from models.task import Task, Submission
|
||||
from models.api_key import APIKey
|
||||
from models.api_key_usage import APIKeyUsage
|
||||
from schemas.project import ProjectResponse
|
||||
from schemas.task import TaskResponse, SubmissionResponse
|
||||
from schemas.api_key import APIKeyUsageLog
|
||||
from utils.auth import get_current_user_flexible, require_api_key_scope
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/projects", response_model=List[ProjectResponse])
|
||||
async def get_all_projects_for_developer(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_api_key_scope("read:projects"))
|
||||
):
|
||||
"""Get read-only access to all projects for developers."""
|
||||
# Ensure user is a developer
|
||||
if current_user.role != UserRole.DEVELOPER:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only developers can access this endpoint"
|
||||
)
|
||||
|
||||
# Get all projects
|
||||
projects = db.query(Project).all()
|
||||
|
||||
result = []
|
||||
for project in projects:
|
||||
# Get member count
|
||||
member_count = db.query(ProjectMember).filter(ProjectMember.project_id == project.id).count()
|
||||
|
||||
result.append(ProjectResponse(
|
||||
id=project.id,
|
||||
name=project.name,
|
||||
description=project.description,
|
||||
status=project.status,
|
||||
start_date=project.start_date,
|
||||
end_date=project.end_date,
|
||||
created_at=project.created_at,
|
||||
updated_at=project.updated_at,
|
||||
member_count=member_count
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/tasks", response_model=List[TaskResponse])
|
||||
async def get_all_tasks_for_developer(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_api_key_scope("read:tasks")),
|
||||
project_id: int = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
):
|
||||
"""Get read-only access to all tasks across projects for developers."""
|
||||
# Ensure user is a developer
|
||||
if current_user.role != UserRole.DEVELOPER:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only developers can access this endpoint"
|
||||
)
|
||||
|
||||
# Build query
|
||||
query = db.query(Task)
|
||||
|
||||
# Filter by project if specified
|
||||
if project_id:
|
||||
query = query.filter(Task.project_id == project_id)
|
||||
|
||||
# Apply pagination
|
||||
tasks = query.offset(offset).limit(limit).all()
|
||||
|
||||
result = []
|
||||
for task in tasks:
|
||||
# Get assigned user info
|
||||
assigned_user = None
|
||||
if task.assigned_user_id:
|
||||
user = db.query(User).filter(User.id == task.assigned_user_id).first()
|
||||
if user:
|
||||
assigned_user = {
|
||||
"id": user.id,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"email": user.email
|
||||
}
|
||||
|
||||
# Get project info
|
||||
project = db.query(Project).filter(Project.id == task.project_id).first()
|
||||
project_info = None
|
||||
if project:
|
||||
project_info = {
|
||||
"id": project.id,
|
||||
"name": project.name
|
||||
}
|
||||
|
||||
result.append(TaskResponse(
|
||||
id=task.id,
|
||||
project_id=task.project_id,
|
||||
episode_id=task.episode_id,
|
||||
shot_id=task.shot_id,
|
||||
asset_id=task.asset_id,
|
||||
assigned_user_id=task.assigned_user_id,
|
||||
task_type=task.task_type,
|
||||
name=task.name,
|
||||
description=task.description,
|
||||
status=task.status,
|
||||
deadline=task.deadline,
|
||||
created_at=task.created_at,
|
||||
updated_at=task.updated_at,
|
||||
assigned_user=assigned_user,
|
||||
project=project_info
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/submissions", response_model=List[SubmissionResponse])
|
||||
async def get_all_submissions_for_developer(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_api_key_scope("read:submissions")),
|
||||
project_id: int = None,
|
||||
task_id: int = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
):
|
||||
"""Get read-only access to all submissions for developers."""
|
||||
# Ensure user is a developer
|
||||
if current_user.role != UserRole.DEVELOPER:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only developers can access this endpoint"
|
||||
)
|
||||
|
||||
# Build query
|
||||
query = db.query(Submission).filter(Submission.deleted_at.is_(None))
|
||||
|
||||
# Filter by task if specified
|
||||
if task_id:
|
||||
query = query.filter(Submission.task_id == task_id)
|
||||
elif project_id:
|
||||
# Filter by project through task relationship
|
||||
query = query.join(Task).filter(
|
||||
Task.project_id == project_id,
|
||||
Task.deleted_at.is_(None)
|
||||
)
|
||||
|
||||
# Apply pagination
|
||||
submissions = query.offset(offset).limit(limit).all()
|
||||
|
||||
result = []
|
||||
for submission in submissions:
|
||||
# Get task info
|
||||
task = db.query(Task).filter(Task.id == submission.task_id).first()
|
||||
task_info = None
|
||||
if task:
|
||||
task_info = {
|
||||
"id": task.id,
|
||||
"name": task.name,
|
||||
"task_type": task.task_type,
|
||||
"project_id": task.project_id
|
||||
}
|
||||
|
||||
# Get user info
|
||||
user = db.query(User).filter(User.id == submission.user_id).first()
|
||||
user_info = None
|
||||
if user:
|
||||
user_info = {
|
||||
"id": user.id,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"email": user.email
|
||||
}
|
||||
|
||||
result.append(SubmissionResponse(
|
||||
id=submission.id,
|
||||
task_id=submission.task_id,
|
||||
user_id=submission.user_id,
|
||||
file_path=submission.file_path,
|
||||
file_name=submission.file_name,
|
||||
version_number=submission.version_number,
|
||||
notes=submission.notes,
|
||||
submitted_at=submission.submitted_at,
|
||||
task=task_info,
|
||||
user=user_info
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/api-usage", response_model=List[APIKeyUsageLog])
|
||||
async def get_api_usage_logs(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_flexible),
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
):
|
||||
"""Get API key usage logs for the current developer."""
|
||||
# Ensure user is a developer
|
||||
if current_user.role != UserRole.DEVELOPER:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only developers can access this endpoint"
|
||||
)
|
||||
|
||||
# Get usage logs for all API keys belonging to the current user
|
||||
usage_logs = db.query(APIKeyUsage).join(
|
||||
APIKey, APIKeyUsage.api_key_id == APIKey.id
|
||||
).filter(
|
||||
APIKey.user_id == current_user.id
|
||||
).order_by(
|
||||
APIKeyUsage.timestamp.desc()
|
||||
).offset(offset).limit(limit).all()
|
||||
|
||||
return [
|
||||
APIKeyUsageLog(
|
||||
api_key_id=log.api_key_id,
|
||||
endpoint=log.endpoint,
|
||||
method=log.method,
|
||||
timestamp=log.timestamp,
|
||||
ip_address=log.ip_address,
|
||||
user_agent=log.user_agent
|
||||
)
|
||||
for log in usage_logs
|
||||
]
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_developer_stats(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_flexible)
|
||||
):
|
||||
"""Get statistics for developer dashboard."""
|
||||
# Ensure user is a developer
|
||||
if current_user.role != UserRole.DEVELOPER:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only developers can access this endpoint"
|
||||
)
|
||||
|
||||
# Get various counts
|
||||
total_projects = db.query(Project).count()
|
||||
total_tasks = db.query(Task).count()
|
||||
total_submissions = db.query(Submission).count()
|
||||
|
||||
# Get API key usage count for current user
|
||||
api_usage_count = db.query(APIKeyUsage).join(
|
||||
APIKey, APIKeyUsage.api_key_id == APIKey.id
|
||||
).filter(
|
||||
APIKey.user_id == current_user.id
|
||||
).count()
|
||||
|
||||
return {
|
||||
"total_projects": total_projects,
|
||||
"total_tasks": total_tasks,
|
||||
"total_submissions": total_submissions,
|
||||
"api_usage_count": api_usage_count
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from typing import List
|
||||
|
||||
from database import get_db
|
||||
from models.episode import Episode
|
||||
from models.project import Project, ProjectMember
|
||||
from models.user import User, UserRole
|
||||
from models.shot import Shot
|
||||
from schemas.episode import EpisodeCreate, EpisodeUpdate, EpisodeResponse, EpisodeListResponse
|
||||
from utils.auth import get_current_user, require_role, get_current_user_from_token
|
||||
|
||||
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
|
||||
|
||||
|
||||
@router.get("/", response_model=List[EpisodeListResponse])
|
||||
async def list_episodes(
|
||||
project_id: int = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_with_db)
|
||||
):
|
||||
"""List episodes, optionally filtered by project"""
|
||||
query = db.query(Episode)
|
||||
|
||||
if project_id:
|
||||
# Check if project exists and user has access
|
||||
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"
|
||||
)
|
||||
|
||||
query = query.filter(Episode.project_id == project_id)
|
||||
else:
|
||||
# For artists, only show episodes from projects they're members of
|
||||
if current_user.role == UserRole.ARTIST:
|
||||
user_project_ids = db.query(ProjectMember.project_id).filter(
|
||||
ProjectMember.user_id == current_user.id
|
||||
).subquery()
|
||||
query = query.filter(Episode.project_id.in_(user_project_ids))
|
||||
|
||||
episodes = query.order_by(Episode.episode_number).offset(skip).limit(limit).all()
|
||||
|
||||
# Add shot count for each episode
|
||||
result = []
|
||||
for episode in episodes:
|
||||
shot_count = db.query(Shot).filter(Shot.episode_id == episode.id).count()
|
||||
episode_data = EpisodeListResponse.model_validate(episode)
|
||||
episode_data.shot_count = shot_count
|
||||
result.append(episode_data)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/", response_model=EpisodeResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_episode(
|
||||
episode: EpisodeCreate,
|
||||
project_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
):
|
||||
"""Create a new episode within a 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 if episode number is already used in this project
|
||||
existing_episode = db.query(Episode).filter(
|
||||
Episode.project_id == project_id,
|
||||
Episode.episode_number == episode.episode_number
|
||||
).first()
|
||||
|
||||
if existing_episode:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Episode number {episode.episode_number} already exists in this project"
|
||||
)
|
||||
|
||||
# Create new episode
|
||||
db_episode = Episode(
|
||||
project_id=project_id,
|
||||
**episode.model_dump()
|
||||
)
|
||||
|
||||
db.add(db_episode)
|
||||
db.commit()
|
||||
db.refresh(db_episode)
|
||||
|
||||
episode_data = EpisodeResponse.model_validate(db_episode)
|
||||
episode_data.shot_count = 0 # New episode has no shots yet
|
||||
|
||||
return episode_data
|
||||
|
||||
|
||||
@router.get("/{episode_id}", response_model=EpisodeResponse)
|
||||
async def get_episode(
|
||||
episode_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_with_db)
|
||||
):
|
||||
"""Get a specific episode by ID"""
|
||||
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"
|
||||
)
|
||||
|
||||
# Check access for artists
|
||||
if current_user.role == UserRole.ARTIST:
|
||||
member = db.query(ProjectMember).filter(
|
||||
ProjectMember.project_id == episode.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 episode"
|
||||
)
|
||||
|
||||
# Add shot count
|
||||
shot_count = db.query(Shot).filter(Shot.episode_id == episode.id).count()
|
||||
episode_data = EpisodeResponse.model_validate(episode)
|
||||
episode_data.shot_count = shot_count
|
||||
|
||||
return episode_data
|
||||
|
||||
|
||||
@router.put("/{episode_id}", response_model=EpisodeResponse)
|
||||
async def update_episode(
|
||||
episode_id: int,
|
||||
episode_update: EpisodeUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
):
|
||||
"""Update an episode"""
|
||||
db_episode = db.query(Episode).filter(Episode.id == episode_id).first()
|
||||
|
||||
if not db_episode:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Episode not found"
|
||||
)
|
||||
|
||||
# If updating episode number, check for conflicts
|
||||
update_data = episode_update.model_dump(exclude_unset=True)
|
||||
if 'episode_number' in update_data:
|
||||
existing_episode = db.query(Episode).filter(
|
||||
Episode.project_id == db_episode.project_id,
|
||||
Episode.episode_number == update_data['episode_number'],
|
||||
Episode.id != episode_id
|
||||
).first()
|
||||
|
||||
if existing_episode:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Episode number {update_data['episode_number']} already exists in this project"
|
||||
)
|
||||
|
||||
# Update only provided fields
|
||||
for field, value in update_data.items():
|
||||
setattr(db_episode, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_episode)
|
||||
|
||||
# Add shot count
|
||||
shot_count = db.query(Shot).filter(Shot.episode_id == db_episode.id).count()
|
||||
episode_data = EpisodeResponse.model_validate(db_episode)
|
||||
episode_data.shot_count = shot_count
|
||||
|
||||
return episode_data
|
||||
|
||||
|
||||
@router.delete("/{episode_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_episode(
|
||||
episode_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
):
|
||||
"""Delete an episode"""
|
||||
db_episode = db.query(Episode).filter(Episode.id == episode_id).first()
|
||||
|
||||
if not db_episode:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Episode not found"
|
||||
)
|
||||
|
||||
db.delete(db_episode)
|
||||
db.commit()
|
||||
|
||||
|
||||
# Project-specific episode endpoints
|
||||
|
||||
@router.get("/projects/{project_id}/episodes", response_model=List[EpisodeListResponse])
|
||||
async def list_project_episodes(
|
||||
project_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_with_db)
|
||||
):
|
||||
"""List all episodes for a specific 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"
|
||||
)
|
||||
|
||||
episodes = db.query(Episode).filter(
|
||||
Episode.project_id == project_id
|
||||
).order_by(Episode.episode_number).offset(skip).limit(limit).all()
|
||||
|
||||
# Add shot count for each episode
|
||||
result = []
|
||||
for episode in episodes:
|
||||
shot_count = db.query(Shot).filter(Shot.episode_id == episode.id).count()
|
||||
episode_data = EpisodeListResponse.model_validate(episode)
|
||||
episode_data.shot_count = shot_count
|
||||
result.append(episode_data)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/episodes", response_model=EpisodeResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_project_episode(
|
||||
project_id: int,
|
||||
episode: EpisodeCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
):
|
||||
"""Create a new episode within a specific project"""
|
||||
return await create_episode(episode, project_id, db, current_user)
|
||||
@@ -0,0 +1,447 @@
|
||||
"""
|
||||
File serving router for VFX Project Management System.
|
||||
Handles authenticated file serving, thumbnails, and access control.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from pathlib import Path
|
||||
import os
|
||||
import mimetypes
|
||||
from typing import Optional
|
||||
|
||||
from database import get_db
|
||||
from models.task import Task, TaskAttachment, Submission
|
||||
from models.user import User, UserRole
|
||||
from utils.auth import get_current_user_from_token, _get_user_from_db
|
||||
from utils.file_handler import file_handler
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_current_user(
|
||||
token_data: dict = Depends(get_current_user_from_token),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get current user with proper database dependency."""
|
||||
return _get_user_from_db(db, token_data["user_id"])
|
||||
|
||||
|
||||
def check_file_access_permission(user: User, task: Task, db: Session) -> bool:
|
||||
"""Check if user has permission to access files for a task."""
|
||||
from models.project import ProjectMember
|
||||
|
||||
# Admins and coordinators can access all files
|
||||
if user.role == UserRole.COORDINATOR or user.is_admin:
|
||||
return True
|
||||
|
||||
# Directors can access all files for review
|
||||
if user.role == UserRole.DIRECTOR:
|
||||
return True
|
||||
|
||||
# Artists can access files for their assigned tasks
|
||||
if task.assigned_user_id == user.id:
|
||||
return True
|
||||
|
||||
# Artists can also access files for tasks in projects they're members of
|
||||
if user.role == UserRole.ARTIST:
|
||||
# Get the project_id from the task's asset or shot
|
||||
project_id = None
|
||||
if task.asset_id:
|
||||
from models.asset import Asset
|
||||
asset = db.query(Asset).filter(Asset.id == task.asset_id).first()
|
||||
if asset:
|
||||
project_id = asset.project_id
|
||||
elif task.shot_id:
|
||||
from models.shot import Shot
|
||||
shot = db.query(Shot).filter(Shot.id == task.shot_id).first()
|
||||
if shot:
|
||||
from models.episode import Episode
|
||||
episode = db.query(Episode).filter(Episode.id == shot.episode_id).first()
|
||||
if episode:
|
||||
project_id = episode.project_id
|
||||
|
||||
# Check if user is a project member
|
||||
if project_id:
|
||||
member = db.query(ProjectMember).filter(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == user.id
|
||||
).first()
|
||||
if member:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@router.get("/attachments/{attachment_id}")
|
||||
async def serve_attachment(
|
||||
attachment_id: int,
|
||||
thumbnail: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Serve a task attachment file with access control."""
|
||||
|
||||
# Get attachment
|
||||
attachment = db.query(TaskAttachment).filter(
|
||||
TaskAttachment.id == attachment_id,
|
||||
TaskAttachment.deleted_at.is_(None)
|
||||
).first()
|
||||
if not attachment:
|
||||
raise HTTPException(status_code=404, detail="Attachment not found")
|
||||
|
||||
# Get associated task
|
||||
task = db.query(Task).filter(Task.id == attachment.task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Associated task not found")
|
||||
|
||||
# Check permissions
|
||||
if not check_file_access_permission(current_user, task, db):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to access this file")
|
||||
|
||||
# Determine file path
|
||||
if thumbnail and file_handler.is_image_file(attachment.file_path):
|
||||
# Try to serve thumbnail
|
||||
thumbnail_path = file_handler.get_thumbnail_path(attachment.file_path)
|
||||
absolute_thumbnail_path = file_handler.resolve_absolute_path(thumbnail_path)
|
||||
if os.path.exists(absolute_thumbnail_path):
|
||||
file_path = absolute_thumbnail_path
|
||||
filename = f"thumb_{attachment.file_name}"
|
||||
else:
|
||||
# Create thumbnail on-demand
|
||||
created_thumbnail = file_handler.create_thumbnail(attachment.file_path)
|
||||
if created_thumbnail:
|
||||
absolute_created_thumbnail = file_handler.resolve_absolute_path(created_thumbnail)
|
||||
if os.path.exists(absolute_created_thumbnail):
|
||||
file_path = absolute_created_thumbnail
|
||||
filename = f"thumb_{attachment.file_name}"
|
||||
else:
|
||||
# Fall back to original file
|
||||
file_path = file_handler.resolve_absolute_path(attachment.file_path)
|
||||
filename = attachment.file_name
|
||||
else:
|
||||
# Fall back to original file
|
||||
file_path = file_handler.resolve_absolute_path(attachment.file_path)
|
||||
filename = attachment.file_name
|
||||
else:
|
||||
file_path = file_handler.resolve_absolute_path(attachment.file_path)
|
||||
filename = attachment.file_name
|
||||
|
||||
# Check if file exists
|
||||
if not os.path.exists(file_path):
|
||||
raise HTTPException(status_code=404, detail="File not found on disk")
|
||||
|
||||
# Get MIME type
|
||||
mime_type, _ = mimetypes.guess_type(file_path)
|
||||
if not mime_type:
|
||||
mime_type = 'application/octet-stream'
|
||||
|
||||
# Return file
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename=filename,
|
||||
media_type=mime_type
|
||||
)
|
||||
|
||||
|
||||
@router.get("/submissions/{submission_id}")
|
||||
async def serve_submission(
|
||||
submission_id: int,
|
||||
thumbnail: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Serve a submission file with access control."""
|
||||
|
||||
# Get submission
|
||||
submission = db.query(Submission).filter(
|
||||
Submission.id == submission_id,
|
||||
Submission.deleted_at.is_(None)
|
||||
).first()
|
||||
if not submission:
|
||||
raise HTTPException(status_code=404, detail="Submission not found")
|
||||
|
||||
# Get associated task
|
||||
task = db.query(Task).filter(Task.id == submission.task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Associated task not found")
|
||||
|
||||
# Check permissions
|
||||
if not check_file_access_permission(current_user, task, db):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to access this file")
|
||||
|
||||
# Determine file path
|
||||
if thumbnail and file_handler.is_image_file(submission.file_path):
|
||||
# Try to serve thumbnail
|
||||
thumbnail_path = file_handler.get_thumbnail_path(submission.file_path)
|
||||
absolute_thumbnail_path = file_handler.resolve_absolute_path(thumbnail_path)
|
||||
if os.path.exists(absolute_thumbnail_path):
|
||||
file_path = absolute_thumbnail_path
|
||||
filename = f"thumb_{submission.file_name}"
|
||||
else:
|
||||
# Create thumbnail on-demand
|
||||
created_thumbnail = file_handler.create_thumbnail(submission.file_path)
|
||||
if created_thumbnail:
|
||||
absolute_created_thumbnail = file_handler.resolve_absolute_path(created_thumbnail)
|
||||
if os.path.exists(absolute_created_thumbnail):
|
||||
file_path = absolute_created_thumbnail
|
||||
filename = f"thumb_{submission.file_name}"
|
||||
else:
|
||||
# Fall back to original file
|
||||
file_path = file_handler.resolve_absolute_path(submission.file_path)
|
||||
filename = submission.file_name
|
||||
else:
|
||||
# Fall back to original file
|
||||
file_path = file_handler.resolve_absolute_path(submission.file_path)
|
||||
filename = submission.file_name
|
||||
else:
|
||||
file_path = file_handler.resolve_absolute_path(submission.file_path)
|
||||
filename = submission.file_name
|
||||
|
||||
# Check if file exists
|
||||
if not os.path.exists(file_path):
|
||||
raise HTTPException(status_code=404, detail="File not found on disk")
|
||||
|
||||
# Get MIME type
|
||||
mime_type, _ = mimetypes.guess_type(file_path)
|
||||
if not mime_type:
|
||||
mime_type = 'application/octet-stream'
|
||||
|
||||
# Return file
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename=filename,
|
||||
media_type=mime_type
|
||||
)
|
||||
|
||||
|
||||
@router.get("/submissions/{submission_id}/stream")
|
||||
async def stream_submission(
|
||||
submission_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Stream a submission file for video playback."""
|
||||
|
||||
# Get submission
|
||||
submission = db.query(Submission).filter(
|
||||
Submission.id == submission_id,
|
||||
Submission.deleted_at.is_(None)
|
||||
).first()
|
||||
if not submission:
|
||||
raise HTTPException(status_code=404, detail="Submission not found")
|
||||
|
||||
# Get associated task
|
||||
task = db.query(Task).filter(Task.id == submission.task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Associated task not found")
|
||||
|
||||
# Check permissions
|
||||
if not check_file_access_permission(current_user, task, db):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to access this file")
|
||||
|
||||
file_path = file_handler.resolve_absolute_path(submission.file_path)
|
||||
|
||||
# Check if file exists
|
||||
if not os.path.exists(file_path):
|
||||
raise HTTPException(status_code=404, detail="File not found on disk")
|
||||
|
||||
# Only stream video files
|
||||
if not file_handler.is_video_file(file_path):
|
||||
raise HTTPException(status_code=400, detail="File is not a video")
|
||||
|
||||
# Get MIME type
|
||||
mime_type, _ = mimetypes.guess_type(file_path)
|
||||
if not mime_type:
|
||||
mime_type = 'video/mp4' # Default for video
|
||||
|
||||
def iterfile(file_path: str):
|
||||
"""Generator to stream file in chunks."""
|
||||
with open(file_path, mode="rb") as file_like:
|
||||
while True:
|
||||
chunk = file_like.read(1024 * 1024) # 1MB chunks
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(
|
||||
iterfile(file_path),
|
||||
media_type=mime_type,
|
||||
headers={
|
||||
"Content-Disposition": f"inline; filename={submission.file_name}",
|
||||
"Accept-Ranges": "bytes"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/info/attachment/{attachment_id}")
|
||||
async def get_attachment_info(
|
||||
attachment_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get file information for an attachment."""
|
||||
|
||||
# Get attachment
|
||||
attachment = db.query(TaskAttachment).filter(
|
||||
TaskAttachment.id == attachment_id,
|
||||
TaskAttachment.deleted_at.is_(None)
|
||||
).first()
|
||||
if not attachment:
|
||||
raise HTTPException(status_code=404, detail="Attachment not found")
|
||||
|
||||
# Get associated task
|
||||
task = db.query(Task).filter(Task.id == attachment.task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Associated task not found")
|
||||
|
||||
# Check permissions
|
||||
if not check_file_access_permission(current_user, task, db):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to access this file")
|
||||
|
||||
# Get file info
|
||||
file_info = file_handler.get_file_info(attachment.file_path)
|
||||
|
||||
return {
|
||||
"id": attachment.id,
|
||||
"file_name": attachment.file_name,
|
||||
"file_type": attachment.file_type,
|
||||
"file_size": attachment.file_size,
|
||||
"attachment_type": attachment.attachment_type,
|
||||
"description": attachment.description,
|
||||
"uploaded_at": attachment.uploaded_at,
|
||||
"is_image": file_handler.is_image_file(attachment.file_path),
|
||||
"is_video": file_handler.is_video_file(attachment.file_path),
|
||||
"has_thumbnail": file_handler.is_image_file(attachment.file_path),
|
||||
"file_exists": file_info.get('exists', False),
|
||||
**file_info
|
||||
}
|
||||
|
||||
|
||||
@router.get("/info/submission/{submission_id}")
|
||||
async def get_submission_info(
|
||||
submission_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get file information for a submission."""
|
||||
|
||||
# Get submission
|
||||
submission = db.query(Submission).filter(
|
||||
Submission.id == submission_id,
|
||||
Submission.deleted_at.is_(None)
|
||||
).first()
|
||||
if not submission:
|
||||
raise HTTPException(status_code=404, detail="Submission not found")
|
||||
|
||||
# Get associated task
|
||||
task = db.query(Task).filter(Task.id == submission.task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Associated task not found")
|
||||
|
||||
# Check permissions
|
||||
if not check_file_access_permission(current_user, task, db):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to access this file")
|
||||
|
||||
# Get file info
|
||||
file_info = file_handler.get_file_info(submission.file_path)
|
||||
|
||||
return {
|
||||
"id": submission.id,
|
||||
"file_name": submission.file_name,
|
||||
"version_number": submission.version_number,
|
||||
"notes": submission.notes,
|
||||
"submitted_at": submission.submitted_at,
|
||||
"is_image": file_handler.is_image_file(submission.file_path),
|
||||
"is_video": file_handler.is_video_file(submission.file_path),
|
||||
"has_thumbnail": file_handler.is_image_file(submission.file_path),
|
||||
"file_exists": file_info.get('exists', False),
|
||||
**file_info
|
||||
}
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/thumbnail")
|
||||
async def serve_project_thumbnail(
|
||||
project_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Serve a project thumbnail image with access control."""
|
||||
from models.project import Project
|
||||
from models.project import ProjectMember
|
||||
|
||||
# Get project
|
||||
project = db.query(Project).filter(Project.id == project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
# Check if user has access to this project
|
||||
# Artists can only access projects they're members of
|
||||
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=403, detail="Access denied to this project")
|
||||
|
||||
# Check if project has a thumbnail
|
||||
if not project.thumbnail_path:
|
||||
raise HTTPException(status_code=404, detail="Project has no thumbnail")
|
||||
|
||||
# Resolve to absolute path for file serving
|
||||
absolute_thumbnail_path = file_handler.resolve_absolute_path(project.thumbnail_path)
|
||||
|
||||
# Check if file exists
|
||||
if not os.path.exists(absolute_thumbnail_path):
|
||||
raise HTTPException(status_code=404, detail="Thumbnail file not found on disk")
|
||||
|
||||
# Get MIME type
|
||||
mime_type, _ = mimetypes.guess_type(absolute_thumbnail_path)
|
||||
if not mime_type:
|
||||
mime_type = 'image/jpeg' # Default for thumbnails
|
||||
|
||||
# Return file
|
||||
return FileResponse(
|
||||
path=absolute_thumbnail_path,
|
||||
media_type=mime_type
|
||||
)
|
||||
|
||||
|
||||
@router.get("/users/{user_id}/avatar")
|
||||
async def serve_user_avatar(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Serve user avatar (public access for simplicity)."""
|
||||
|
||||
# Get user
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Check if user has avatar
|
||||
if not user.avatar_url:
|
||||
raise HTTPException(status_code=404, detail="User has no avatar")
|
||||
|
||||
# Note: Avatar access is public for simplicity since img tags can't send auth headers
|
||||
# More restrictive access control can be added later if needed
|
||||
|
||||
# Resolve to absolute path for file serving
|
||||
absolute_avatar_path = file_handler.resolve_absolute_path(user.avatar_url)
|
||||
|
||||
# Check if file exists
|
||||
if not os.path.exists(absolute_avatar_path):
|
||||
raise HTTPException(status_code=404, detail="Avatar file not found on disk")
|
||||
|
||||
# Get MIME type
|
||||
mime_type, _ = mimetypes.guess_type(absolute_avatar_path)
|
||||
if not mime_type:
|
||||
mime_type = 'image/jpeg' # Default for avatars
|
||||
|
||||
# Return file
|
||||
return FileResponse(
|
||||
path=absolute_avatar_path,
|
||||
media_type=mime_type
|
||||
)
|
||||
@@ -0,0 +1,178 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc, func
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from models.notification import Notification, UserNotificationPreference, NotificationType
|
||||
from schemas.notification import (
|
||||
NotificationResponse,
|
||||
NotificationMarkRead,
|
||||
NotificationPreferencesResponse,
|
||||
NotificationPreferencesUpdate,
|
||||
NotificationStats
|
||||
)
|
||||
from utils.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||
|
||||
|
||||
@router.get("", response_model=List[NotificationResponse])
|
||||
def get_notifications(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
unread_only: bool = Query(False),
|
||||
type_filter: Optional[NotificationType] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get notifications for the current user."""
|
||||
query = db.query(Notification).filter(Notification.user_id == current_user.id)
|
||||
|
||||
if unread_only:
|
||||
query = query.filter(Notification.read == False)
|
||||
|
||||
if type_filter:
|
||||
query = query.filter(Notification.type == type_filter)
|
||||
|
||||
notifications = query.order_by(desc(Notification.created_at)).offset(skip).limit(limit).all()
|
||||
return notifications
|
||||
|
||||
|
||||
@router.get("/stats", response_model=NotificationStats)
|
||||
def get_notification_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get notification statistics for the current user."""
|
||||
total = db.query(Notification).filter(Notification.user_id == current_user.id).count()
|
||||
unread = db.query(Notification).filter(
|
||||
Notification.user_id == current_user.id,
|
||||
Notification.read == False
|
||||
).count()
|
||||
|
||||
# Get counts by type
|
||||
by_type_query = db.query(
|
||||
Notification.type,
|
||||
func.count(Notification.id).label('count')
|
||||
).filter(
|
||||
Notification.user_id == current_user.id,
|
||||
Notification.read == False
|
||||
).group_by(Notification.type).all()
|
||||
|
||||
by_type = {str(type_): count for type_, count in by_type_query}
|
||||
|
||||
return NotificationStats(total=total, unread=unread, by_type=by_type)
|
||||
|
||||
|
||||
@router.post("/mark-read")
|
||||
def mark_notifications_read(
|
||||
data: NotificationMarkRead,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Mark notifications as read."""
|
||||
notifications = db.query(Notification).filter(
|
||||
Notification.id.in_(data.notification_ids),
|
||||
Notification.user_id == current_user.id
|
||||
).all()
|
||||
|
||||
if not notifications:
|
||||
raise HTTPException(status_code=404, detail="No notifications found")
|
||||
|
||||
for notification in notifications:
|
||||
notification.read = True
|
||||
notification.read_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"message": f"Marked {len(notifications)} notifications as read"}
|
||||
|
||||
|
||||
@router.post("/mark-all-read")
|
||||
def mark_all_notifications_read(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Mark all notifications as read for the current user."""
|
||||
count = db.query(Notification).filter(
|
||||
Notification.user_id == current_user.id,
|
||||
Notification.read == False
|
||||
).update({
|
||||
"read": True,
|
||||
"read_at": datetime.utcnow()
|
||||
})
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"message": f"Marked {count} notifications as read"}
|
||||
|
||||
|
||||
@router.delete("/{notification_id}")
|
||||
def delete_notification(
|
||||
notification_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Delete a notification."""
|
||||
notification = db.query(Notification).filter(
|
||||
Notification.id == notification_id,
|
||||
Notification.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not notification:
|
||||
raise HTTPException(status_code=404, detail="Notification not found")
|
||||
|
||||
db.delete(notification)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Notification deleted"}
|
||||
|
||||
|
||||
@router.get("/preferences", response_model=NotificationPreferencesResponse)
|
||||
def get_notification_preferences(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get notification preferences for the current user."""
|
||||
preferences = db.query(UserNotificationPreference).filter(
|
||||
UserNotificationPreference.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not preferences:
|
||||
# Create default preferences
|
||||
preferences = UserNotificationPreference(user_id=current_user.id)
|
||||
db.add(preferences)
|
||||
db.commit()
|
||||
db.refresh(preferences)
|
||||
|
||||
return preferences
|
||||
|
||||
|
||||
@router.put("/preferences", response_model=NotificationPreferencesResponse)
|
||||
def update_notification_preferences(
|
||||
data: NotificationPreferencesUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update notification preferences for the current user."""
|
||||
preferences = db.query(UserNotificationPreference).filter(
|
||||
UserNotificationPreference.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not preferences:
|
||||
preferences = UserNotificationPreference(user_id=current_user.id)
|
||||
db.add(preferences)
|
||||
|
||||
# Update all fields
|
||||
for field, value in data.model_dump().items():
|
||||
setattr(preferences, field, value)
|
||||
|
||||
preferences.updated_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
db.refresh(preferences)
|
||||
|
||||
return preferences
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,295 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import and_
|
||||
from typing import List, Optional
|
||||
|
||||
from database import get_db
|
||||
from models.task import Task, Submission, Review, TaskStatus
|
||||
from models.user import User, UserRole
|
||||
from schemas.task import ReviewCreate, ReviewResponse, SubmissionResponse
|
||||
from utils.auth import get_current_user_from_token, _get_user_from_db
|
||||
from utils.notifications import notification_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_current_user(
|
||||
token_data: dict = Depends(get_current_user_from_token),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get current user with proper database dependency."""
|
||||
return _get_user_from_db(db, token_data["user_id"])
|
||||
|
||||
|
||||
def require_director_coordinator_or_admin(
|
||||
token_data: dict = Depends(get_current_user_from_token),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Dependency to require director, coordinator role, or admin permission."""
|
||||
current_user = _get_user_from_db(db, token_data["user_id"])
|
||||
if (current_user.role not in [UserRole.DIRECTOR, UserRole.COORDINATOR] and
|
||||
not current_user.is_admin):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Director role, Coordinator role, or Admin permission required"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
def require_role(required_roles: list):
|
||||
"""Create a dependency that requires specific user roles."""
|
||||
def role_checker(
|
||||
token_data: dict = Depends(get_current_user_from_token),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
current_user = _get_user_from_db(db, token_data["user_id"])
|
||||
if current_user.role not in required_roles:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Insufficient permissions"
|
||||
)
|
||||
return current_user
|
||||
return role_checker
|
||||
|
||||
|
||||
@router.get("/pending", response_model=List[SubmissionResponse])
|
||||
async def get_pending_reviews(
|
||||
project_id: Optional[int] = Query(None, description="Filter by project ID"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_director_coordinator_or_admin)
|
||||
):
|
||||
"""Get all submissions pending review. Only directors, coordinators, and users with admin permission can access."""
|
||||
|
||||
# Get submissions that don't have any reviews yet or have retake reviews
|
||||
query = db.query(Submission).options(
|
||||
joinedload(Submission.user),
|
||||
joinedload(Submission.task).joinedload(Task.project),
|
||||
joinedload(Submission.reviews).joinedload("reviewer")
|
||||
).join(Task).filter(
|
||||
Submission.deleted_at.is_(None),
|
||||
Task.deleted_at.is_(None)
|
||||
)
|
||||
|
||||
# Filter by project if specified
|
||||
if project_id:
|
||||
query = query.filter(Task.project_id == project_id)
|
||||
|
||||
# Only get submitted tasks
|
||||
query = query.filter(Task.status == "submitted")
|
||||
|
||||
submissions = query.order_by(Submission.submitted_at.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
# Filter to only include submissions that need review
|
||||
pending_submissions = []
|
||||
for submission in submissions:
|
||||
# Check if submission has any approved reviews
|
||||
has_approved_review = any(review.decision == "approved" for review in submission.reviews)
|
||||
|
||||
if not has_approved_review:
|
||||
# Get latest review
|
||||
latest_review = None
|
||||
if submission.reviews:
|
||||
latest_review_obj = max(submission.reviews, key=lambda r: r.reviewed_at)
|
||||
latest_review = {
|
||||
"id": latest_review_obj.id,
|
||||
"submission_id": latest_review_obj.submission_id,
|
||||
"reviewer_id": latest_review_obj.reviewer_id,
|
||||
"decision": latest_review_obj.decision,
|
||||
"feedback": latest_review_obj.feedback,
|
||||
"reviewed_at": latest_review_obj.reviewed_at,
|
||||
"reviewer_first_name": latest_review_obj.reviewer.first_name,
|
||||
"reviewer_last_name": latest_review_obj.reviewer.last_name
|
||||
}
|
||||
|
||||
submission_data = {
|
||||
"id": submission.id,
|
||||
"task_id": submission.task_id,
|
||||
"user_id": submission.user_id,
|
||||
"file_path": submission.file_path,
|
||||
"file_name": submission.file_name,
|
||||
"version_number": submission.version_number,
|
||||
"notes": submission.notes,
|
||||
"submitted_at": submission.submitted_at,
|
||||
"user_first_name": submission.user.first_name,
|
||||
"user_last_name": submission.user.last_name,
|
||||
"latest_review": latest_review
|
||||
}
|
||||
pending_submissions.append(SubmissionResponse(**submission_data))
|
||||
|
||||
return pending_submissions
|
||||
|
||||
|
||||
@router.post("/{submission_id}/approve", response_model=ReviewResponse)
|
||||
async def approve_submission(
|
||||
submission_id: int,
|
||||
review: ReviewCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_director_coordinator_or_admin)
|
||||
):
|
||||
"""Approve a submission. Only directors, coordinators, and users with admin permission can approve."""
|
||||
|
||||
submission = db.query(Submission).options(
|
||||
joinedload(Submission.task)
|
||||
).filter(
|
||||
Submission.id == submission_id,
|
||||
Submission.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
if not submission:
|
||||
raise HTTPException(status_code=404, detail="Submission not found")
|
||||
|
||||
# Check if submission is in submitted state
|
||||
if submission.task.status != "submitted":
|
||||
raise HTTPException(status_code=400, detail="Submission is not in submitted state")
|
||||
|
||||
# Force decision to approved
|
||||
review.decision = "approved"
|
||||
|
||||
# Create review record
|
||||
db_review = Review(
|
||||
submission_id=submission_id,
|
||||
reviewer_id=current_user.id,
|
||||
decision=review.decision,
|
||||
feedback=review.feedback
|
||||
)
|
||||
db.add(db_review)
|
||||
|
||||
# Update task status to approved
|
||||
submission.task.status = "approved"
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_review)
|
||||
|
||||
# Send notification to artist
|
||||
notification_service.notify_submission_reviewed(db, submission, db_review, current_user)
|
||||
|
||||
# Load reviewer information for response
|
||||
db_review = db.query(Review).options(
|
||||
joinedload(Review.reviewer)
|
||||
).filter(Review.id == db_review.id).first()
|
||||
|
||||
review_data = {
|
||||
"id": db_review.id,
|
||||
"submission_id": db_review.submission_id,
|
||||
"reviewer_id": db_review.reviewer_id,
|
||||
"decision": db_review.decision,
|
||||
"feedback": db_review.feedback,
|
||||
"reviewed_at": db_review.reviewed_at,
|
||||
"reviewer_first_name": db_review.reviewer.first_name,
|
||||
"reviewer_last_name": db_review.reviewer.last_name
|
||||
}
|
||||
|
||||
return ReviewResponse(**review_data)
|
||||
|
||||
|
||||
@router.post("/{submission_id}/retake", response_model=ReviewResponse)
|
||||
async def request_retake(
|
||||
submission_id: int,
|
||||
review: ReviewCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_director_coordinator_or_admin)
|
||||
):
|
||||
"""Request a retake for a submission. Only directors, coordinators, and users with admin permission can request retakes."""
|
||||
|
||||
submission = db.query(Submission).options(
|
||||
joinedload(Submission.task)
|
||||
).filter(
|
||||
Submission.id == submission_id,
|
||||
Submission.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
if not submission:
|
||||
raise HTTPException(status_code=404, detail="Submission not found")
|
||||
|
||||
# Check if submission is in submitted state
|
||||
if submission.task.status != "submitted":
|
||||
raise HTTPException(status_code=400, detail="Submission is not in submitted state")
|
||||
|
||||
# Force decision to retake and require feedback
|
||||
review.decision = "retake"
|
||||
if not review.feedback or review.feedback.strip() == "":
|
||||
raise HTTPException(status_code=400, detail="Feedback is required when requesting a retake")
|
||||
|
||||
# Create review record
|
||||
db_review = Review(
|
||||
submission_id=submission_id,
|
||||
reviewer_id=current_user.id,
|
||||
decision=review.decision,
|
||||
feedback=review.feedback
|
||||
)
|
||||
db.add(db_review)
|
||||
|
||||
# Update task status to retake
|
||||
submission.task.status = "retake"
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_review)
|
||||
|
||||
# Send notification to artist
|
||||
notification_service.notify_submission_reviewed(db, submission, db_review, current_user)
|
||||
|
||||
# Load reviewer information for response
|
||||
db_review = db.query(Review).options(
|
||||
joinedload(Review.reviewer)
|
||||
).filter(Review.id == db_review.id).first()
|
||||
|
||||
review_data = {
|
||||
"id": db_review.id,
|
||||
"submission_id": db_review.submission_id,
|
||||
"reviewer_id": db_review.reviewer_id,
|
||||
"decision": db_review.decision,
|
||||
"feedback": db_review.feedback,
|
||||
"reviewed_at": db_review.reviewed_at,
|
||||
"reviewer_first_name": db_review.reviewer.first_name,
|
||||
"reviewer_last_name": db_review.reviewer.last_name
|
||||
}
|
||||
|
||||
return ReviewResponse(**review_data)
|
||||
|
||||
|
||||
@router.get("/{submission_id}/reviews", response_model=List[ReviewResponse])
|
||||
async def get_submission_reviews(
|
||||
submission_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get all reviews for a submission."""
|
||||
|
||||
submission = db.query(Submission).options(
|
||||
joinedload(Submission.task)
|
||||
).filter(
|
||||
Submission.id == submission_id,
|
||||
Submission.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
if not submission:
|
||||
raise HTTPException(status_code=404, detail="Submission not found")
|
||||
|
||||
# Artists can only view reviews for their own submissions
|
||||
if current_user.role == UserRole.ARTIST and submission.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized to view reviews for this submission")
|
||||
|
||||
reviews = db.query(Review).options(
|
||||
joinedload(Review.reviewer)
|
||||
).filter(
|
||||
Review.submission_id == submission_id,
|
||||
Review.deleted_at.is_(None)
|
||||
).order_by(Review.reviewed_at.desc()).all()
|
||||
|
||||
result = []
|
||||
for review in reviews:
|
||||
review_data = {
|
||||
"id": review.id,
|
||||
"submission_id": review.submission_id,
|
||||
"reviewer_id": review.reviewer_id,
|
||||
"decision": review.decision,
|
||||
"feedback": review.feedback,
|
||||
"reviewed_at": review.reviewed_at,
|
||||
"reviewer_first_name": review.reviewer.first_name,
|
||||
"reviewer_last_name": review.reviewer.last_name
|
||||
}
|
||||
result.append(ReviewResponse(**review_data))
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,166 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from database import get_db
|
||||
from models.global_settings import GlobalSettings
|
||||
from schemas.global_settings import (
|
||||
GlobalSetting,
|
||||
GlobalSettingCreate,
|
||||
GlobalSettingUpdate,
|
||||
UploadLimitResponse,
|
||||
UploadLimitUpdate
|
||||
)
|
||||
from utils.auth import get_current_user, require_admin_permission
|
||||
from models.user import User
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
# Default upload limit in MB (1GB)
|
||||
DEFAULT_UPLOAD_LIMIT_MB = 1000
|
||||
UPLOAD_LIMIT_KEY = "global_upload_limit_mb"
|
||||
|
||||
|
||||
def get_or_create_upload_limit_setting(db: Session) -> GlobalSettings:
|
||||
"""Get or create the upload limit setting with default value"""
|
||||
setting = db.query(GlobalSettings).filter(
|
||||
GlobalSettings.setting_key == UPLOAD_LIMIT_KEY
|
||||
).first()
|
||||
|
||||
if not setting:
|
||||
setting = GlobalSettings(
|
||||
setting_key=UPLOAD_LIMIT_KEY,
|
||||
setting_value=str(DEFAULT_UPLOAD_LIMIT_MB),
|
||||
description="Global upload size limit for movie files in MB"
|
||||
)
|
||||
db.add(setting)
|
||||
db.commit()
|
||||
db.refresh(setting)
|
||||
|
||||
return setting
|
||||
|
||||
|
||||
@router.get("/upload-limit", response_model=UploadLimitResponse)
|
||||
async def get_upload_limit(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get the global upload size limit for movie files"""
|
||||
setting = get_or_create_upload_limit_setting(db)
|
||||
|
||||
return UploadLimitResponse(
|
||||
upload_limit_mb=int(setting.setting_value),
|
||||
description=setting.description or "Global upload size limit for movie files"
|
||||
)
|
||||
|
||||
|
||||
@router.put("/upload-limit", response_model=UploadLimitResponse)
|
||||
async def update_upload_limit(
|
||||
upload_limit: UploadLimitUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_permission)
|
||||
):
|
||||
"""Update the global upload size limit for movie files (admin only)"""
|
||||
setting = get_or_create_upload_limit_setting(db)
|
||||
|
||||
setting.setting_value = str(upload_limit.upload_limit_mb)
|
||||
db.commit()
|
||||
db.refresh(setting)
|
||||
|
||||
return UploadLimitResponse(
|
||||
upload_limit_mb=int(setting.setting_value),
|
||||
description=setting.description or "Global upload size limit for movie files"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=List[GlobalSetting])
|
||||
async def get_all_settings(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_permission)
|
||||
):
|
||||
"""Get all global settings (admin only)"""
|
||||
settings = db.query(GlobalSettings).all()
|
||||
return settings
|
||||
|
||||
|
||||
@router.post("/", response_model=GlobalSetting)
|
||||
async def create_setting(
|
||||
setting: GlobalSettingCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_permission)
|
||||
):
|
||||
"""Create a new global setting (admin only)"""
|
||||
# Check if setting already exists
|
||||
existing = db.query(GlobalSettings).filter(
|
||||
GlobalSettings.setting_key == setting.setting_key
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Setting with key '{setting.setting_key}' already exists"
|
||||
)
|
||||
|
||||
db_setting = GlobalSettings(**setting.dict())
|
||||
db.add(db_setting)
|
||||
db.commit()
|
||||
db.refresh(db_setting)
|
||||
|
||||
return db_setting
|
||||
|
||||
|
||||
@router.put("/{setting_key}", response_model=GlobalSetting)
|
||||
async def update_setting(
|
||||
setting_key: str,
|
||||
setting_update: GlobalSettingUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_permission)
|
||||
):
|
||||
"""Update a specific global setting (admin only)"""
|
||||
setting = db.query(GlobalSettings).filter(
|
||||
GlobalSettings.setting_key == setting_key
|
||||
).first()
|
||||
|
||||
if not setting:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Setting with key '{setting_key}' not found"
|
||||
)
|
||||
|
||||
for field, value in setting_update.dict(exclude_unset=True).items():
|
||||
setattr(setting, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(setting)
|
||||
|
||||
return setting
|
||||
|
||||
|
||||
@router.delete("/{setting_key}")
|
||||
async def delete_setting(
|
||||
setting_key: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_permission)
|
||||
):
|
||||
"""Delete a global setting (admin only)"""
|
||||
setting = db.query(GlobalSettings).filter(
|
||||
GlobalSettings.setting_key == setting_key
|
||||
).first()
|
||||
|
||||
if not setting:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Setting with key '{setting_key}' not found"
|
||||
)
|
||||
|
||||
# Prevent deletion of critical settings
|
||||
if setting_key == UPLOAD_LIMIT_KEY:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot delete the global upload limit setting"
|
||||
)
|
||||
|
||||
db.delete(setting)
|
||||
db.commit()
|
||||
|
||||
return {"message": f"Setting '{setting_key}' deleted successfully"}
|
||||
@@ -0,0 +1,858 @@
|
||||
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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,604 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from passlib.context import CryptContext
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
import os
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
|
||||
from database import get_db
|
||||
from models.user import User, UserRole
|
||||
from models.project import ProjectMember
|
||||
from models.task import Task
|
||||
from schemas.user import UserResponse, UserApproval, UserRoleUpdate, UserUpdate, UserAdminUpdate, UserAdminCreate, UserAdminEdit, UserPasswordReset, UserPasswordChange
|
||||
from utils.auth import get_current_user_from_token, _get_user_from_db, require_admin_permission
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def require_admin_permission_with_db(
|
||||
token_data: dict = Depends(get_current_user_from_token),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Dependency to require admin permission."""
|
||||
current_user = _get_user_from_db(db, token_data["user_id"])
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin permission required"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
def require_admin_or_coordinator(
|
||||
token_data: dict = Depends(get_current_user_from_token),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Dependency to require admin permission or coordinator role."""
|
||||
current_user = _get_user_from_db(db, token_data["user_id"])
|
||||
if not current_user.is_admin and current_user.role != UserRole.COORDINATOR:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin permission or Coordinator role required"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
@router.post("/{user_id}/approve", response_model=dict)
|
||||
async def approve_user(
|
||||
user_id: int,
|
||||
approval_data: UserApproval,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_permission_with_db)
|
||||
):
|
||||
"""Approve or disapprove a user account (Admin permission required)."""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
user.is_approved = approval_data.is_approved
|
||||
db.commit()
|
||||
|
||||
action = "approved" if approval_data.is_approved else "disapproved"
|
||||
return {
|
||||
"message": f"User {user.email} has been {action}",
|
||||
"user_id": user.id,
|
||||
"is_approved": user.is_approved
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{user_id}/role", response_model=dict)
|
||||
async def update_user_role(
|
||||
user_id: int,
|
||||
role_data: UserRoleUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_permission_with_db)
|
||||
):
|
||||
"""Update user role (Admin permission required)."""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
# Prevent admin from changing their own role
|
||||
if user.id == current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot change your own role"
|
||||
)
|
||||
|
||||
user.role = role_data.role
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"User {user.email} role updated to {role_data.role}",
|
||||
"user_id": user.id,
|
||||
"role": user.role
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{user_id}/admin", response_model=dict)
|
||||
async def update_user_admin_permission(
|
||||
user_id: int,
|
||||
admin_data: UserAdminUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_permission_with_db)
|
||||
):
|
||||
"""Grant or revoke admin permission (Admin permission required)."""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
# Prevent user from revoking their own admin permission if they're the only admin
|
||||
if user.id == current_user.id and not admin_data.is_admin:
|
||||
admin_count = db.query(User).filter(User.is_admin == True).count()
|
||||
if admin_count <= 1:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot revoke admin permission - you are the only admin user"
|
||||
)
|
||||
|
||||
user.is_admin = admin_data.is_admin
|
||||
db.commit()
|
||||
|
||||
action = "granted" if admin_data.is_admin else "revoked"
|
||||
return {
|
||||
"message": f"Admin permission {action} for user {user.email}",
|
||||
"user_id": user.id,
|
||||
"is_admin": user.is_admin
|
||||
}
|
||||
|
||||
|
||||
@router.get("/", response_model=List[UserResponse])
|
||||
async def list_users(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_or_coordinator)
|
||||
):
|
||||
"""List all users (Admin and Coordinator only)."""
|
||||
users = db.query(User).offset(skip).limit(limit).all()
|
||||
return users
|
||||
|
||||
|
||||
@router.get("/pending", response_model=List[UserResponse])
|
||||
async def list_pending_users(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_permission_with_db)
|
||||
):
|
||||
"""List users pending approval (Admin permission required)."""
|
||||
pending_users = db.query(User).filter(User.is_approved == False).all()
|
||||
return pending_users
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_current_user_profile(
|
||||
token_data: dict = Depends(get_current_user_from_token),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get current user's profile."""
|
||||
current_user = _get_user_from_db(db, token_data["user_id"])
|
||||
return current_user
|
||||
|
||||
|
||||
@router.put("/me", response_model=UserResponse)
|
||||
async def update_current_user_profile(
|
||||
user_update: UserUpdate,
|
||||
token_data: dict = Depends(get_current_user_from_token),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update current user's profile."""
|
||||
current_user = _get_user_from_db(db, token_data["user_id"])
|
||||
|
||||
# Users can only update their own name, not role or approval status
|
||||
if user_update.first_name is not None:
|
||||
current_user.first_name = user_update.first_name
|
||||
if user_update.last_name is not None:
|
||||
current_user.last_name = user_update.last_name
|
||||
|
||||
# Only users with admin permission can update role and approval status
|
||||
if user_update.role is not None or user_update.is_approved is not None or user_update.is_admin is not None:
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin permission required to update role, approval status, or admin permission"
|
||||
)
|
||||
if user_update.role is not None:
|
||||
current_user.role = user_update.role
|
||||
if user_update.is_approved is not None:
|
||||
current_user.is_approved = user_update.is_approved
|
||||
if user_update.is_admin is not None:
|
||||
current_user.is_admin = user_update.is_admin
|
||||
|
||||
db.commit()
|
||||
db.refresh(current_user)
|
||||
return current_user
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserResponse)
|
||||
async def get_user(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_or_coordinator)
|
||||
):
|
||||
"""Get user by ID (Admin and Coordinator only)."""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/admin/create", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def admin_create_user(
|
||||
user_data: UserAdminCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_permission_with_db)
|
||||
):
|
||||
"""Create a new user account (Admin permission required)."""
|
||||
# Check if email already exists
|
||||
existing_user = db.query(User).filter(User.email == user_data.email).first()
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already registered"
|
||||
)
|
||||
|
||||
# Hash the password
|
||||
hashed_password = pwd_context.hash(user_data.password)
|
||||
|
||||
# Create new user
|
||||
new_user = User(
|
||||
email=user_data.email,
|
||||
password_hash=hashed_password,
|
||||
first_name=user_data.first_name,
|
||||
last_name=user_data.last_name,
|
||||
role=user_data.role,
|
||||
is_approved=user_data.is_approved,
|
||||
is_admin=user_data.is_admin
|
||||
)
|
||||
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
|
||||
return new_user
|
||||
|
||||
|
||||
@router.put("/{user_id}", response_model=UserResponse)
|
||||
async def admin_edit_user(
|
||||
user_id: int,
|
||||
user_data: UserAdminEdit,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_permission_with_db)
|
||||
):
|
||||
"""Edit user account (Admin permission required)."""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
# Check if email is being changed and if it's already in use
|
||||
if user_data.email and user_data.email != user.email:
|
||||
existing_user = db.query(User).filter(User.email == user_data.email).first()
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already in use"
|
||||
)
|
||||
user.email = user_data.email
|
||||
|
||||
# Prevent admin from removing their own admin permission
|
||||
if user_data.is_admin is not None and user.id == current_user.id and not user_data.is_admin:
|
||||
admin_count = db.query(User).filter(User.is_admin == True).count()
|
||||
if admin_count <= 1:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot remove your own admin permission - you are the only admin user"
|
||||
)
|
||||
|
||||
# Update fields
|
||||
if user_data.first_name is not None:
|
||||
user.first_name = user_data.first_name
|
||||
if user_data.last_name is not None:
|
||||
user.last_name = user_data.last_name
|
||||
if user_data.role is not None:
|
||||
user.role = user_data.role
|
||||
if user_data.is_approved is not None:
|
||||
user.is_approved = user_data.is_approved
|
||||
if user_data.is_admin is not None:
|
||||
user.is_admin = user_data.is_admin
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@router.put("/{user_id}/password", response_model=dict)
|
||||
async def admin_reset_user_password(
|
||||
user_id: int,
|
||||
password_data: UserPasswordReset,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_permission_with_db)
|
||||
):
|
||||
"""Reset user password (Admin permission required)."""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
# Hash the new password
|
||||
hashed_password = pwd_context.hash(password_data.new_password)
|
||||
user.password_hash = hashed_password
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"Password reset successfully for user {user.email}",
|
||||
"user_id": user.id
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{user_id}/can-delete", response_model=dict)
|
||||
async def check_user_can_delete(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_permission_with_db)
|
||||
):
|
||||
"""Check if a user can be deleted (Admin permission required)."""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
# Check if trying to delete themselves
|
||||
if user.id == current_user.id:
|
||||
return {
|
||||
"can_delete": False,
|
||||
"reason": "Cannot delete your own account",
|
||||
"project_memberships": 0,
|
||||
"task_assignments": 0
|
||||
}
|
||||
|
||||
# Check for project memberships
|
||||
project_memberships = db.query(ProjectMember).filter(ProjectMember.user_id == user_id).count()
|
||||
|
||||
# Check for task assignments
|
||||
task_assignments = db.query(Task).filter(Task.assigned_user_id == user_id).count()
|
||||
|
||||
can_delete = project_memberships == 0 and task_assignments == 0
|
||||
reason = None
|
||||
|
||||
if not can_delete:
|
||||
reasons = []
|
||||
if project_memberships > 0:
|
||||
reasons.append(f"{project_memberships} project membership(s)")
|
||||
if task_assignments > 0:
|
||||
reasons.append(f"{task_assignments} task assignment(s)")
|
||||
reason = f"User has {' and '.join(reasons)}. Please remove user from projects and reassign tasks first."
|
||||
|
||||
return {
|
||||
"can_delete": can_delete,
|
||||
"reason": reason,
|
||||
"project_memberships": project_memberships,
|
||||
"task_assignments": task_assignments
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{user_id}", response_model=dict)
|
||||
async def admin_delete_user(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_permission_with_db)
|
||||
):
|
||||
"""Delete user account (Admin permission required)."""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
# Prevent admin from deleting themselves
|
||||
if user.id == current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot delete your own account"
|
||||
)
|
||||
|
||||
# Check for project memberships
|
||||
project_memberships = db.query(ProjectMember).filter(ProjectMember.user_id == user_id).count()
|
||||
if project_memberships > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Cannot delete user - user has {project_memberships} project membership(s). Please remove user from projects first."
|
||||
)
|
||||
|
||||
# Check for task assignments
|
||||
task_assignments = db.query(Task).filter(Task.assigned_user_id == user_id).count()
|
||||
if task_assignments > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Cannot delete user - user has {task_assignments} task assignment(s). Please reassign tasks first."
|
||||
)
|
||||
|
||||
# Delete the user
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"User {user.email} deleted successfully",
|
||||
"user_id": user_id
|
||||
}
|
||||
|
||||
|
||||
@router.post("/me/avatar", response_model=UserResponse)
|
||||
async def upload_avatar(
|
||||
file: UploadFile = File(...),
|
||||
token_data: dict = Depends(get_current_user_from_token),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Upload user avatar image."""
|
||||
from utils.file_handler import file_handler
|
||||
|
||||
current_user = _get_user_from_db(db, token_data["user_id"])
|
||||
|
||||
# Validate file format
|
||||
allowed_formats = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
|
||||
file_extension = Path(file.filename).suffix.lower()
|
||||
|
||||
if file_extension not in allowed_formats:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid file format. Allowed formats: {', '.join(allowed_formats)}"
|
||||
)
|
||||
|
||||
# Read file content
|
||||
file_content = await file.read()
|
||||
file_size = len(file_content)
|
||||
|
||||
# Validate file size (5MB max)
|
||||
max_size = 5 * 1024 * 1024 # 5MB
|
||||
if file_size > max_size:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail="File too large. Maximum size is 5MB"
|
||||
)
|
||||
|
||||
# Create avatars directory using FileHandler's base structure
|
||||
avatars_dir = file_handler.base_upload_dir / "avatars"
|
||||
avatars_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Generate unique filename
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
hash_input = f"{current_user.id}{timestamp}".encode()
|
||||
short_hash = hashlib.md5(hash_input).hexdigest()[:8]
|
||||
unique_filename = f"user_{current_user.id}_{timestamp}_{short_hash}{file_extension}"
|
||||
file_path = avatars_dir / unique_filename
|
||||
|
||||
# Save temporary file
|
||||
temp_path = avatars_dir / f"temp_{unique_filename}"
|
||||
with open(temp_path, "wb") as buffer:
|
||||
buffer.write(file_content)
|
||||
|
||||
try:
|
||||
# Process image: resize and crop to 200x200
|
||||
with Image.open(temp_path) as img:
|
||||
# Convert to RGB if necessary
|
||||
if img.mode in ('RGBA', 'LA', 'P'):
|
||||
# For RGBA, create white background
|
||||
if img.mode == 'RGBA':
|
||||
background = Image.new('RGB', img.size, (255, 255, 255))
|
||||
background.paste(img, mask=img.split()[3]) # Use alpha channel as mask
|
||||
img = background
|
||||
else:
|
||||
img = img.convert('RGB')
|
||||
|
||||
# Resize to 200x200 (crop to square first)
|
||||
width, height = img.size
|
||||
min_dimension = min(width, height)
|
||||
|
||||
# Crop to square from center
|
||||
left = (width - min_dimension) // 2
|
||||
top = (height - min_dimension) // 2
|
||||
right = left + min_dimension
|
||||
bottom = top + min_dimension
|
||||
img = img.crop((left, top, right, bottom))
|
||||
|
||||
# Resize to 200x200
|
||||
img = img.resize((200, 200), Image.Resampling.LANCZOS)
|
||||
|
||||
# Save processed image
|
||||
img.save(file_path, 'JPEG', quality=90)
|
||||
|
||||
# Delete temporary file
|
||||
os.remove(temp_path)
|
||||
|
||||
# Delete old avatar if exists
|
||||
if current_user.avatar_url:
|
||||
# Resolve old avatar path and delete
|
||||
old_avatar_absolute_path = file_handler.resolve_absolute_path(current_user.avatar_url)
|
||||
if os.path.exists(old_avatar_absolute_path):
|
||||
try:
|
||||
os.remove(old_avatar_absolute_path)
|
||||
except Exception:
|
||||
pass # Ignore errors deleting old avatar
|
||||
|
||||
# Store relative path in database using FileHandler
|
||||
relative_avatar_path = file_handler.store_relative_path(str(file_path))
|
||||
current_user.avatar_url = relative_avatar_path
|
||||
db.commit()
|
||||
db.refresh(current_user)
|
||||
|
||||
return current_user
|
||||
|
||||
except Exception as e:
|
||||
# Clean up temporary file on error
|
||||
if temp_path.exists():
|
||||
os.remove(temp_path)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to process image: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/me/avatar", response_model=UserResponse)
|
||||
async def remove_avatar(
|
||||
token_data: dict = Depends(get_current_user_from_token),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Remove user avatar image."""
|
||||
from utils.file_handler import file_handler
|
||||
|
||||
current_user = _get_user_from_db(db, token_data["user_id"])
|
||||
|
||||
# Delete avatar file if exists
|
||||
if current_user.avatar_url:
|
||||
# Resolve to absolute path for file deletion
|
||||
avatar_absolute_path = file_handler.resolve_absolute_path(current_user.avatar_url)
|
||||
if os.path.exists(avatar_absolute_path):
|
||||
try:
|
||||
os.remove(avatar_absolute_path)
|
||||
except Exception:
|
||||
pass # Ignore errors deleting avatar file
|
||||
|
||||
# Clear avatar URL
|
||||
current_user.avatar_url = None
|
||||
db.commit()
|
||||
db.refresh(current_user)
|
||||
|
||||
return current_user
|
||||
|
||||
|
||||
@router.put("/me/password", response_model=dict)
|
||||
async def change_password(
|
||||
password_data: UserPasswordChange,
|
||||
token_data: dict = Depends(get_current_user_from_token),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Change current user's password."""
|
||||
current_user = _get_user_from_db(db, token_data["user_id"])
|
||||
|
||||
# Verify current password
|
||||
if not pwd_context.verify(password_data.current_password, current_user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Incorrect current password"
|
||||
)
|
||||
|
||||
# Validate new password (basic validation)
|
||||
if len(password_data.new_password) < 8:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="New password must be at least 8 characters long"
|
||||
)
|
||||
|
||||
# Hash and update password
|
||||
current_user.password_hash = pwd_context.hash(password_data.new_password)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "Password changed successfully",
|
||||
"user_id": current_user.id
|
||||
}
|
||||
Reference in New Issue
Block a user