LinkDesk/backend/routers/admin.py

800 lines
27 KiB
Python

"""
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
]
}