Init Repo

This commit is contained in:
2026-02-28 03:22:04 +08:00
commit de59b57ee7
883 changed files with 156857 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# Routers package
+239
View File
@@ -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
+800
View File
@@ -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
]
}
+742
View File
@@ -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
}
+572
View File
@@ -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
)
+187
View File
@@ -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
}
+270
View File
@@ -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
}
+294
View File
@@ -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)
+447
View File
@@ -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
)
+178
View File
@@ -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
+295
View File
@@ -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
+166
View File
@@ -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"}
+858
View File
@@ -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
+604
View File
@@ -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
}