Init Repo
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Services module for VFX Project Management System.
|
||||
|
||||
This module contains business logic services for handling complex operations
|
||||
like soft deletion and recovery of shots and assets.
|
||||
"""
|
||||
|
||||
from .shot_soft_deletion import ShotSoftDeletionService, DeletionInfo, DeletionResult
|
||||
from .asset_soft_deletion import AssetSoftDeletionService, AssetDeletionInfo, AssetDeletionResult
|
||||
from .recovery_service import (
|
||||
RecoveryService, RecoveryInfo, RecoveryResult, DeletedShot, DeletedAsset,
|
||||
PermanentDeleteResult, BulkPermanentDeleteResult
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'ShotSoftDeletionService',
|
||||
'DeletionInfo',
|
||||
'DeletionResult',
|
||||
'AssetSoftDeletionService',
|
||||
'AssetDeletionInfo',
|
||||
'AssetDeletionResult',
|
||||
'RecoveryService',
|
||||
'RecoveryInfo',
|
||||
'RecoveryResult',
|
||||
'DeletedShot',
|
||||
'DeletedAsset',
|
||||
'PermanentDeleteResult',
|
||||
'BulkPermanentDeleteResult'
|
||||
]
|
||||
@@ -0,0 +1,483 @@
|
||||
"""
|
||||
Asset Soft Deletion Service
|
||||
|
||||
This service handles the soft deletion of assets and all related data including:
|
||||
- Tasks associated with the asset
|
||||
- Submissions for those tasks
|
||||
- Production notes for those tasks
|
||||
- Task attachments for those tasks
|
||||
- Reviews for those submissions
|
||||
|
||||
All operations are performed within database transactions to ensure atomicity.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy import func
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from models.asset import Asset
|
||||
from models.task import Task, Submission, Review, ProductionNote, TaskAttachment
|
||||
from models.user import User
|
||||
from models.activity import Activity, ActivityType
|
||||
from utils.activity import ActivityService
|
||||
|
||||
|
||||
class AssetDeletionInfo:
|
||||
"""Information about what will be deleted when an asset is soft deleted."""
|
||||
|
||||
def __init__(self):
|
||||
self.asset_id: int = 0
|
||||
self.asset_name: str = ""
|
||||
self.asset_category: str = ""
|
||||
self.project_name: str = ""
|
||||
|
||||
# Counts of items that will be marked as deleted
|
||||
self.task_count: int = 0
|
||||
self.submission_count: int = 0
|
||||
self.attachment_count: int = 0
|
||||
self.note_count: int = 0
|
||||
self.review_count: int = 0
|
||||
|
||||
# File information (preserved, not deleted)
|
||||
self.total_file_size: int = 0
|
||||
self.file_count: int = 0
|
||||
|
||||
# Affected users
|
||||
self.affected_users: List[Dict[str, Any]] = []
|
||||
|
||||
# Timestamps
|
||||
self.last_activity_date: Optional[str] = None
|
||||
self.created_at: str = ""
|
||||
|
||||
|
||||
class AssetDeletionResult:
|
||||
"""Result of an asset soft deletion operation."""
|
||||
|
||||
def __init__(self):
|
||||
self.success: bool = False
|
||||
self.asset_id: int = 0
|
||||
self.asset_name: str = ""
|
||||
|
||||
# Database update results
|
||||
self.marked_deleted_tasks: int = 0
|
||||
self.marked_deleted_submissions: int = 0
|
||||
self.marked_deleted_attachments: int = 0
|
||||
self.marked_deleted_notes: int = 0
|
||||
self.marked_deleted_reviews: int = 0
|
||||
|
||||
# Timing
|
||||
self.operation_duration: float = 0.0
|
||||
self.deleted_at: str = ""
|
||||
self.deleted_by: int = 0
|
||||
|
||||
# Errors
|
||||
self.errors: List[str] = []
|
||||
self.warnings: List[str] = []
|
||||
|
||||
|
||||
class AssetSoftDeletionService:
|
||||
"""Service for handling asset soft deletion operations."""
|
||||
|
||||
def __init__(self):
|
||||
self.activity_service = ActivityService()
|
||||
|
||||
def get_deletion_info(self, asset_id: int, db: Session) -> Optional[AssetDeletionInfo]:
|
||||
"""
|
||||
Get information about what will be deleted when an asset is soft deleted.
|
||||
|
||||
Args:
|
||||
asset_id: ID of the asset to analyze
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
AssetDeletionInfo object with counts and affected users, or None if asset not found
|
||||
"""
|
||||
try:
|
||||
# Get the asset (only if not already deleted)
|
||||
asset = db.query(Asset).filter(
|
||||
Asset.id == asset_id,
|
||||
Asset.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
if not asset:
|
||||
return None
|
||||
|
||||
info = AssetDeletionInfo()
|
||||
info.asset_id = asset.id
|
||||
info.asset_name = asset.name
|
||||
info.asset_category = asset.category.value if asset.category else ""
|
||||
info.project_name = asset.project.name if asset.project else ""
|
||||
info.created_at = asset.created_at.isoformat() if asset.created_at else ""
|
||||
|
||||
# Get all active tasks for this asset
|
||||
tasks = db.query(Task).filter(
|
||||
Task.asset_id == asset_id,
|
||||
Task.deleted_at.is_(None)
|
||||
).all()
|
||||
|
||||
info.task_count = len(tasks)
|
||||
|
||||
if not tasks:
|
||||
return info
|
||||
|
||||
task_ids = [task.id for task in tasks]
|
||||
|
||||
# Count submissions
|
||||
submissions = db.query(Submission).filter(
|
||||
Submission.task_id.in_(task_ids),
|
||||
Submission.deleted_at.is_(None)
|
||||
).all()
|
||||
info.submission_count = len(submissions)
|
||||
|
||||
# Count attachments and calculate file sizes
|
||||
attachments = db.query(TaskAttachment).filter(
|
||||
TaskAttachment.task_id.in_(task_ids),
|
||||
TaskAttachment.deleted_at.is_(None)
|
||||
).all()
|
||||
info.attachment_count = len(attachments)
|
||||
info.file_count = len(attachments) + len(submissions)
|
||||
info.total_file_size = sum(att.file_size for att in attachments if att.file_size)
|
||||
|
||||
# Count production notes
|
||||
notes = db.query(ProductionNote).filter(
|
||||
ProductionNote.task_id.in_(task_ids),
|
||||
ProductionNote.deleted_at.is_(None)
|
||||
).all()
|
||||
info.note_count = len(notes)
|
||||
|
||||
# Count reviews
|
||||
if submissions:
|
||||
submission_ids = [sub.id for sub in submissions]
|
||||
reviews = db.query(Review).filter(
|
||||
Review.submission_id.in_(submission_ids),
|
||||
Review.deleted_at.is_(None)
|
||||
).all()
|
||||
info.review_count = len(reviews)
|
||||
|
||||
# Get affected users
|
||||
info.affected_users = self._get_affected_users(tasks, submissions, notes, db)
|
||||
|
||||
# Get last activity date
|
||||
info.last_activity_date = self._get_last_activity_date(asset_id, task_ids, db)
|
||||
|
||||
return info
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
raise Exception(f"Database error while getting deletion info: {str(e)}")
|
||||
|
||||
def soft_delete_asset_cascade(self, asset_id: int, db: Session, current_user: User) -> AssetDeletionResult:
|
||||
"""
|
||||
Perform cascading soft deletion of an asset and all related data.
|
||||
|
||||
Args:
|
||||
asset_id: ID of the asset to soft delete
|
||||
db: Database session
|
||||
current_user: User performing the deletion
|
||||
|
||||
Returns:
|
||||
AssetDeletionResult with operation details
|
||||
"""
|
||||
start_time = datetime.utcnow()
|
||||
result = AssetDeletionResult()
|
||||
result.asset_id = asset_id
|
||||
result.deleted_by = current_user.id
|
||||
|
||||
try:
|
||||
# Get the asset (only if not already deleted)
|
||||
asset = db.query(Asset).filter(
|
||||
Asset.id == asset_id,
|
||||
Asset.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
if not asset:
|
||||
result.errors.append("Asset not found or already deleted")
|
||||
return result
|
||||
|
||||
result.asset_name = asset.name
|
||||
deleted_at = datetime.utcnow()
|
||||
result.deleted_at = deleted_at.isoformat()
|
||||
|
||||
# Get deletion info for logging
|
||||
deletion_info = self.get_deletion_info(asset_id, db)
|
||||
|
||||
# Mark related data as deleted
|
||||
self._mark_related_data_deleted(asset_id, db, current_user, deleted_at, result)
|
||||
|
||||
# Mark the asset as deleted
|
||||
asset.deleted_at = deleted_at
|
||||
asset.deleted_by = current_user.id
|
||||
db.flush()
|
||||
|
||||
# Log the deletion activity
|
||||
self._log_asset_deletion(asset, current_user, deletion_info, db)
|
||||
|
||||
result.success = True
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
result.errors.append(f"Database error during deletion: {str(e)}")
|
||||
except Exception as e:
|
||||
result.errors.append(f"Unexpected error during deletion: {str(e)}")
|
||||
|
||||
# Calculate operation duration
|
||||
end_time = datetime.utcnow()
|
||||
result.operation_duration = (end_time - start_time).total_seconds()
|
||||
|
||||
return result
|
||||
|
||||
def _mark_related_data_deleted(self, asset_id: int, db: Session, current_user: User,
|
||||
deleted_at: datetime, result: AssetDeletionResult) -> None:
|
||||
"""
|
||||
Mark all data related to an asset as deleted.
|
||||
|
||||
Args:
|
||||
asset_id: ID of the asset
|
||||
db: Database session
|
||||
current_user: User performing the deletion
|
||||
deleted_at: Timestamp for deletion
|
||||
result: AssetDeletionResult to update with counts
|
||||
"""
|
||||
# Get all active tasks for this asset
|
||||
tasks = db.query(Task).filter(
|
||||
Task.asset_id == asset_id,
|
||||
Task.deleted_at.is_(None)
|
||||
).all()
|
||||
|
||||
if not tasks:
|
||||
return
|
||||
|
||||
task_ids = [task.id for task in tasks]
|
||||
|
||||
# Mark tasks as deleted
|
||||
task_update_count = db.query(Task).filter(
|
||||
Task.asset_id == asset_id,
|
||||
Task.deleted_at.is_(None)
|
||||
).update({
|
||||
Task.deleted_at: deleted_at,
|
||||
Task.deleted_by: current_user.id
|
||||
}, synchronize_session=False)
|
||||
result.marked_deleted_tasks = task_update_count
|
||||
|
||||
# Mark submissions as deleted
|
||||
submission_update_count = db.query(Submission).filter(
|
||||
Submission.task_id.in_(task_ids),
|
||||
Submission.deleted_at.is_(None)
|
||||
).update({
|
||||
Submission.deleted_at: deleted_at,
|
||||
Submission.deleted_by: current_user.id
|
||||
}, synchronize_session=False)
|
||||
result.marked_deleted_submissions = submission_update_count
|
||||
|
||||
# Mark attachments as deleted
|
||||
attachment_update_count = db.query(TaskAttachment).filter(
|
||||
TaskAttachment.task_id.in_(task_ids),
|
||||
TaskAttachment.deleted_at.is_(None)
|
||||
).update({
|
||||
TaskAttachment.deleted_at: deleted_at,
|
||||
TaskAttachment.deleted_by: current_user.id
|
||||
}, synchronize_session=False)
|
||||
result.marked_deleted_attachments = attachment_update_count
|
||||
|
||||
# Mark production notes as deleted
|
||||
note_update_count = db.query(ProductionNote).filter(
|
||||
ProductionNote.task_id.in_(task_ids),
|
||||
ProductionNote.deleted_at.is_(None)
|
||||
).update({
|
||||
ProductionNote.deleted_at: deleted_at,
|
||||
ProductionNote.deleted_by: current_user.id
|
||||
}, synchronize_session=False)
|
||||
result.marked_deleted_notes = note_update_count
|
||||
|
||||
# Mark reviews as deleted (for submissions that belong to these tasks)
|
||||
# First get submission IDs
|
||||
submission_ids = [sub.id for sub in db.query(Submission.id).filter(
|
||||
Submission.task_id.in_(task_ids)
|
||||
).all()]
|
||||
|
||||
if submission_ids:
|
||||
review_update_count = db.query(Review).filter(
|
||||
Review.submission_id.in_(submission_ids),
|
||||
Review.deleted_at.is_(None)
|
||||
).update({
|
||||
Review.deleted_at: deleted_at,
|
||||
Review.deleted_by: current_user.id
|
||||
}, synchronize_session=False)
|
||||
result.marked_deleted_reviews = review_update_count
|
||||
|
||||
db.flush()
|
||||
|
||||
def _get_affected_users(self, tasks: List[Task], submissions: List[Submission],
|
||||
notes: List[ProductionNote], db: Session) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get list of users affected by the asset deletion.
|
||||
|
||||
Args:
|
||||
tasks: List of tasks that will be deleted
|
||||
submissions: List of submissions that will be deleted
|
||||
notes: List of production notes that will be deleted
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
List of affected user information
|
||||
"""
|
||||
user_data = {}
|
||||
|
||||
# Collect user IDs and their involvement
|
||||
for task in tasks:
|
||||
if task.assigned_user_id:
|
||||
if task.assigned_user_id not in user_data:
|
||||
user_data[task.assigned_user_id] = {
|
||||
'task_count': 0,
|
||||
'submission_count': 0,
|
||||
'note_count': 0,
|
||||
'last_activity_date': None
|
||||
}
|
||||
user_data[task.assigned_user_id]['task_count'] += 1
|
||||
if task.updated_at:
|
||||
current_date = user_data[task.assigned_user_id]['last_activity_date']
|
||||
if not current_date or task.updated_at > current_date:
|
||||
user_data[task.assigned_user_id]['last_activity_date'] = task.updated_at
|
||||
|
||||
for submission in submissions:
|
||||
if submission.user_id not in user_data:
|
||||
user_data[submission.user_id] = {
|
||||
'task_count': 0,
|
||||
'submission_count': 0,
|
||||
'note_count': 0,
|
||||
'last_activity_date': None
|
||||
}
|
||||
user_data[submission.user_id]['submission_count'] += 1
|
||||
if submission.submitted_at:
|
||||
current_date = user_data[submission.user_id]['last_activity_date']
|
||||
if not current_date or submission.submitted_at > current_date:
|
||||
user_data[submission.user_id]['last_activity_date'] = submission.submitted_at
|
||||
|
||||
for note in notes:
|
||||
if note.user_id not in user_data:
|
||||
user_data[note.user_id] = {
|
||||
'task_count': 0,
|
||||
'submission_count': 0,
|
||||
'note_count': 0,
|
||||
'last_activity_date': None
|
||||
}
|
||||
user_data[note.user_id]['note_count'] += 1
|
||||
if note.updated_at:
|
||||
current_date = user_data[note.user_id]['last_activity_date']
|
||||
if not current_date or note.updated_at > current_date:
|
||||
user_data[note.user_id]['last_activity_date'] = note.updated_at
|
||||
|
||||
# Get user details
|
||||
affected_users = []
|
||||
if user_data:
|
||||
users = db.query(User).filter(User.id.in_(user_data.keys())).all()
|
||||
for user in users:
|
||||
data = user_data[user.id]
|
||||
affected_users.append({
|
||||
'id': user.id,
|
||||
'name': f"{user.first_name} {user.last_name}",
|
||||
'email': user.email,
|
||||
'role': user.role,
|
||||
'task_count': data['task_count'],
|
||||
'submission_count': data['submission_count'],
|
||||
'note_count': data['note_count'],
|
||||
'last_activity_date': data['last_activity_date'].isoformat() if data['last_activity_date'] else None
|
||||
})
|
||||
|
||||
return affected_users
|
||||
|
||||
def _get_last_activity_date(self, asset_id: int, task_ids: List[int], db: Session) -> Optional[str]:
|
||||
"""
|
||||
Get the most recent activity date for the asset and its tasks.
|
||||
|
||||
Args:
|
||||
asset_id: ID of the asset
|
||||
task_ids: List of task IDs
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
ISO formatted date string of last activity, or None
|
||||
"""
|
||||
try:
|
||||
# Get the most recent activity from various sources
|
||||
dates = []
|
||||
|
||||
# Asset updated_at
|
||||
asset = db.query(Asset.updated_at).filter(Asset.id == asset_id).first()
|
||||
if asset and asset.updated_at:
|
||||
dates.append(asset.updated_at)
|
||||
|
||||
if task_ids:
|
||||
# Task updated_at
|
||||
task_dates = db.query(func.max(Task.updated_at)).filter(
|
||||
Task.id.in_(task_ids)
|
||||
).scalar()
|
||||
if task_dates:
|
||||
dates.append(task_dates)
|
||||
|
||||
# Submission submitted_at
|
||||
submission_dates = db.query(func.max(Submission.submitted_at)).filter(
|
||||
Submission.task_id.in_(task_ids)
|
||||
).scalar()
|
||||
if submission_dates:
|
||||
dates.append(submission_dates)
|
||||
|
||||
# Production note updated_at
|
||||
note_dates = db.query(func.max(ProductionNote.updated_at)).filter(
|
||||
ProductionNote.task_id.in_(task_ids)
|
||||
).scalar()
|
||||
if note_dates:
|
||||
dates.append(note_dates)
|
||||
|
||||
if dates:
|
||||
return max(dates).isoformat()
|
||||
|
||||
return None
|
||||
|
||||
except SQLAlchemyError:
|
||||
return None
|
||||
|
||||
def _log_asset_deletion(self, asset: Asset, current_user: User, deletion_info: Optional[AssetDeletionInfo], db: Session) -> None:
|
||||
"""
|
||||
Log the asset deletion activity.
|
||||
|
||||
Args:
|
||||
asset: The asset that was deleted
|
||||
current_user: User who performed the deletion
|
||||
deletion_info: Information about what was deleted
|
||||
db: Database session
|
||||
"""
|
||||
try:
|
||||
# Create activity record
|
||||
activity = Activity(
|
||||
type=ActivityType.ASSET_UPDATED, # We'll use ASSET_UPDATED for now, could add ASSET_DELETED later
|
||||
user_id=current_user.id,
|
||||
project_id=asset.project_id,
|
||||
asset_id=asset.id,
|
||||
description=f"Asset '{asset.name}' was deleted by {current_user.first_name} {current_user.last_name}",
|
||||
activity_metadata={
|
||||
'action': 'soft_delete',
|
||||
'asset_name': asset.name,
|
||||
'asset_category': asset.category.value if asset.category else None,
|
||||
'project_name': asset.project.name if asset.project else None,
|
||||
'deleted_counts': {
|
||||
'tasks': deletion_info.task_count if deletion_info else 0,
|
||||
'submissions': deletion_info.submission_count if deletion_info else 0,
|
||||
'attachments': deletion_info.attachment_count if deletion_info else 0,
|
||||
'notes': deletion_info.note_count if deletion_info else 0,
|
||||
'reviews': deletion_info.review_count if deletion_info else 0
|
||||
},
|
||||
'affected_users_count': len(deletion_info.affected_users) if deletion_info else 0
|
||||
}
|
||||
)
|
||||
|
||||
db.add(activity)
|
||||
db.flush()
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
# Don't fail the deletion if logging fails, just add a warning
|
||||
pass
|
||||
@@ -0,0 +1,902 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Batch Operations Service
|
||||
|
||||
This service provides efficient batch operations for soft deletion and recovery
|
||||
of multiple shots and assets. It optimizes database operations by using bulk
|
||||
updates and proper transaction management.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy import func, and_, or_
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from models.shot import Shot
|
||||
from models.asset import Asset
|
||||
from models.task import Task, Submission, Review, ProductionNote, TaskAttachment
|
||||
from models.user import User
|
||||
from models.activity import Activity, ActivityType
|
||||
from services.shot_soft_deletion import ShotSoftDeletionService, DeletionResult
|
||||
from services.asset_soft_deletion import AssetSoftDeletionService, AssetDeletionResult
|
||||
from services.recovery_service import RecoveryService, RecoveryResult
|
||||
|
||||
|
||||
class BatchDeletionItem:
|
||||
"""Information about an item in a batch deletion."""
|
||||
|
||||
def __init__(self):
|
||||
self.id: int = 0
|
||||
self.name: str = ""
|
||||
self.type: str = "" # 'shot' or 'asset'
|
||||
self.success: bool = False
|
||||
self.error: Optional[str] = None
|
||||
self.deleted_counts: Dict[str, int] = {}
|
||||
|
||||
|
||||
class BatchDeletionResult:
|
||||
"""Result of a batch deletion operation."""
|
||||
|
||||
def __init__(self):
|
||||
self.total_items: int = 0
|
||||
self.successful_deletions: int = 0
|
||||
self.failed_deletions: int = 0
|
||||
self.operation_duration: float = 0.0
|
||||
self.items: List[BatchDeletionItem] = []
|
||||
|
||||
# Aggregate counts
|
||||
self.total_deleted_tasks: int = 0
|
||||
self.total_deleted_submissions: int = 0
|
||||
self.total_deleted_attachments: int = 0
|
||||
self.total_deleted_notes: int = 0
|
||||
self.total_deleted_reviews: int = 0
|
||||
|
||||
|
||||
class BatchRecoveryItem:
|
||||
"""Information about an item in a batch recovery."""
|
||||
|
||||
def __init__(self):
|
||||
self.id: int = 0
|
||||
self.name: str = ""
|
||||
self.type: str = "" # 'shot' or 'asset'
|
||||
self.success: bool = False
|
||||
self.error: Optional[str] = None
|
||||
self.recovered_counts: Dict[str, int] = {}
|
||||
|
||||
|
||||
class BatchRecoveryResult:
|
||||
"""Result of a batch recovery operation."""
|
||||
|
||||
def __init__(self):
|
||||
self.total_items: int = 0
|
||||
self.successful_recoveries: int = 0
|
||||
self.failed_recoveries: int = 0
|
||||
self.operation_duration: float = 0.0
|
||||
self.items: List[BatchRecoveryItem] = []
|
||||
|
||||
# Aggregate counts
|
||||
self.total_recovered_tasks: int = 0
|
||||
self.total_recovered_submissions: int = 0
|
||||
self.total_recovered_attachments: int = 0
|
||||
self.total_recovered_notes: int = 0
|
||||
self.total_recovered_reviews: int = 0
|
||||
|
||||
|
||||
class BatchOperationsService:
|
||||
"""Service for efficient batch operations on shots and assets."""
|
||||
|
||||
def __init__(self):
|
||||
self.shot_deletion_service = ShotSoftDeletionService()
|
||||
self.asset_deletion_service = AssetSoftDeletionService()
|
||||
self.recovery_service = RecoveryService()
|
||||
|
||||
def batch_delete_shots(self, shot_ids: List[int], db: Session, current_user: User,
|
||||
batch_size: int = 50) -> BatchDeletionResult:
|
||||
"""
|
||||
Efficiently delete multiple shots in batches.
|
||||
|
||||
Args:
|
||||
shot_ids: List of shot IDs to delete
|
||||
db: Database session
|
||||
current_user: User performing the deletion
|
||||
batch_size: Number of shots to process in each batch
|
||||
|
||||
Returns:
|
||||
BatchDeletionResult with operation details
|
||||
"""
|
||||
start_time = datetime.utcnow()
|
||||
result = BatchDeletionResult()
|
||||
result.total_items = len(shot_ids)
|
||||
|
||||
try:
|
||||
# Process shots in batches to avoid memory issues
|
||||
for i in range(0, len(shot_ids), batch_size):
|
||||
batch_shot_ids = shot_ids[i:i + batch_size]
|
||||
|
||||
# Process each shot in the batch
|
||||
for shot_id in batch_shot_ids:
|
||||
item = BatchDeletionItem()
|
||||
item.id = shot_id
|
||||
item.type = "shot"
|
||||
|
||||
try:
|
||||
# Get deletion info first
|
||||
deletion_info = self.shot_deletion_service.get_deletion_info(shot_id, db)
|
||||
if not deletion_info:
|
||||
item.success = False
|
||||
item.error = "Shot not found or already deleted"
|
||||
result.failed_deletions += 1
|
||||
result.items.append(item)
|
||||
continue
|
||||
|
||||
# Perform deletion manually without nested transaction
|
||||
deleted_at = datetime.utcnow()
|
||||
|
||||
# Get the shot
|
||||
shot = db.query(Shot).filter(
|
||||
Shot.id == shot_id,
|
||||
Shot.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
if not shot:
|
||||
item.success = False
|
||||
item.error = "Shot not found or already deleted"
|
||||
result.failed_deletions += 1
|
||||
result.items.append(item)
|
||||
continue
|
||||
|
||||
# Mark related data as deleted
|
||||
tasks = db.query(Task).filter(
|
||||
Task.shot_id == shot_id,
|
||||
Task.deleted_at.is_(None)
|
||||
).all()
|
||||
|
||||
task_ids = [task.id for task in tasks] if tasks else []
|
||||
|
||||
# Update tasks
|
||||
task_update_count = db.query(Task).filter(
|
||||
Task.shot_id == shot_id,
|
||||
Task.deleted_at.is_(None)
|
||||
).update({
|
||||
Task.deleted_at: deleted_at,
|
||||
Task.deleted_by: current_user.id
|
||||
}, synchronize_session=False)
|
||||
|
||||
# Update related data
|
||||
submission_update_count = 0
|
||||
attachment_update_count = 0
|
||||
note_update_count = 0
|
||||
review_update_count = 0
|
||||
|
||||
if task_ids:
|
||||
submission_update_count = db.query(Submission).filter(
|
||||
Submission.task_id.in_(task_ids),
|
||||
Submission.deleted_at.is_(None)
|
||||
).update({
|
||||
Submission.deleted_at: deleted_at,
|
||||
Submission.deleted_by: current_user.id
|
||||
}, synchronize_session=False)
|
||||
|
||||
attachment_update_count = db.query(TaskAttachment).filter(
|
||||
TaskAttachment.task_id.in_(task_ids),
|
||||
TaskAttachment.deleted_at.is_(None)
|
||||
).update({
|
||||
TaskAttachment.deleted_at: deleted_at,
|
||||
TaskAttachment.deleted_by: current_user.id
|
||||
}, synchronize_session=False)
|
||||
|
||||
note_update_count = db.query(ProductionNote).filter(
|
||||
ProductionNote.task_id.in_(task_ids),
|
||||
ProductionNote.deleted_at.is_(None)
|
||||
).update({
|
||||
ProductionNote.deleted_at: deleted_at,
|
||||
ProductionNote.deleted_by: current_user.id
|
||||
}, synchronize_session=False)
|
||||
|
||||
# Update reviews
|
||||
submission_ids = [s.id for s in db.query(Submission.id).filter(
|
||||
Submission.task_id.in_(task_ids)
|
||||
).all()]
|
||||
|
||||
if submission_ids:
|
||||
review_update_count = db.query(Review).filter(
|
||||
Review.submission_id.in_(submission_ids),
|
||||
Review.deleted_at.is_(None)
|
||||
).update({
|
||||
Review.deleted_at: deleted_at,
|
||||
Review.deleted_by: current_user.id
|
||||
}, synchronize_session=False)
|
||||
|
||||
# Mark the shot as deleted
|
||||
shot.deleted_at = deleted_at
|
||||
shot.deleted_by = current_user.id
|
||||
db.flush()
|
||||
|
||||
# Success
|
||||
item.success = True
|
||||
item.name = shot.name
|
||||
item.deleted_counts = {
|
||||
'tasks': task_update_count,
|
||||
'submissions': submission_update_count,
|
||||
'attachments': attachment_update_count,
|
||||
'notes': note_update_count,
|
||||
'reviews': review_update_count
|
||||
}
|
||||
|
||||
result.successful_deletions += 1
|
||||
result.total_deleted_tasks += task_update_count
|
||||
result.total_deleted_submissions += submission_update_count
|
||||
result.total_deleted_attachments += attachment_update_count
|
||||
result.total_deleted_notes += note_update_count
|
||||
result.total_deleted_reviews += review_update_count
|
||||
|
||||
except Exception as e:
|
||||
item.success = False
|
||||
item.error = str(e)
|
||||
result.failed_deletions += 1
|
||||
|
||||
result.items.append(item)
|
||||
|
||||
# Commit batch to avoid long-running transactions
|
||||
db.commit()
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
# Mark remaining items as failed
|
||||
for shot_id in shot_ids[len(result.items):]:
|
||||
item = BatchDeletionItem()
|
||||
item.id = shot_id
|
||||
item.type = "shot"
|
||||
item.success = False
|
||||
item.error = f"Batch operation failed: {str(e)}"
|
||||
result.items.append(item)
|
||||
result.failed_deletions += 1
|
||||
|
||||
# Calculate operation duration
|
||||
end_time = datetime.utcnow()
|
||||
result.operation_duration = (end_time - start_time).total_seconds()
|
||||
|
||||
return result
|
||||
|
||||
def batch_delete_assets(self, asset_ids: List[int], db: Session, current_user: User,
|
||||
batch_size: int = 50) -> BatchDeletionResult:
|
||||
"""
|
||||
Efficiently delete multiple assets in batches.
|
||||
|
||||
Args:
|
||||
asset_ids: List of asset IDs to delete
|
||||
db: Database session
|
||||
current_user: User performing the deletion
|
||||
batch_size: Number of assets to process in each batch
|
||||
|
||||
Returns:
|
||||
BatchDeletionResult with operation details
|
||||
"""
|
||||
start_time = datetime.utcnow()
|
||||
result = BatchDeletionResult()
|
||||
result.total_items = len(asset_ids)
|
||||
|
||||
try:
|
||||
# Process assets in batches to avoid memory issues
|
||||
for i in range(0, len(asset_ids), batch_size):
|
||||
batch_asset_ids = asset_ids[i:i + batch_size]
|
||||
|
||||
# Process each asset in the batch
|
||||
for asset_id in batch_asset_ids:
|
||||
item = BatchDeletionItem()
|
||||
item.id = asset_id
|
||||
item.type = "asset"
|
||||
|
||||
try:
|
||||
# Get deletion info first
|
||||
deletion_info = self.asset_deletion_service.get_deletion_info(asset_id, db)
|
||||
if not deletion_info:
|
||||
item.success = False
|
||||
item.error = "Asset not found or already deleted"
|
||||
result.failed_deletions += 1
|
||||
result.items.append(item)
|
||||
continue
|
||||
|
||||
# Perform deletion manually without nested transaction
|
||||
deleted_at = datetime.utcnow()
|
||||
|
||||
# Get the asset
|
||||
asset = db.query(Asset).filter(
|
||||
Asset.id == asset_id,
|
||||
Asset.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
if not asset:
|
||||
item.success = False
|
||||
item.error = "Asset not found or already deleted"
|
||||
result.failed_deletions += 1
|
||||
result.items.append(item)
|
||||
continue
|
||||
|
||||
# Mark related data as deleted
|
||||
tasks = db.query(Task).filter(
|
||||
Task.asset_id == asset_id,
|
||||
Task.deleted_at.is_(None)
|
||||
).all()
|
||||
|
||||
task_ids = [task.id for task in tasks] if tasks else []
|
||||
|
||||
# Update tasks
|
||||
task_update_count = db.query(Task).filter(
|
||||
Task.asset_id == asset_id,
|
||||
Task.deleted_at.is_(None)
|
||||
).update({
|
||||
Task.deleted_at: deleted_at,
|
||||
Task.deleted_by: current_user.id
|
||||
}, synchronize_session=False)
|
||||
|
||||
# Update related data
|
||||
submission_update_count = 0
|
||||
attachment_update_count = 0
|
||||
note_update_count = 0
|
||||
review_update_count = 0
|
||||
|
||||
if task_ids:
|
||||
submission_update_count = db.query(Submission).filter(
|
||||
Submission.task_id.in_(task_ids),
|
||||
Submission.deleted_at.is_(None)
|
||||
).update({
|
||||
Submission.deleted_at: deleted_at,
|
||||
Submission.deleted_by: current_user.id
|
||||
}, synchronize_session=False)
|
||||
|
||||
attachment_update_count = db.query(TaskAttachment).filter(
|
||||
TaskAttachment.task_id.in_(task_ids),
|
||||
TaskAttachment.deleted_at.is_(None)
|
||||
).update({
|
||||
TaskAttachment.deleted_at: deleted_at,
|
||||
TaskAttachment.deleted_by: current_user.id
|
||||
}, synchronize_session=False)
|
||||
|
||||
note_update_count = db.query(ProductionNote).filter(
|
||||
ProductionNote.task_id.in_(task_ids),
|
||||
ProductionNote.deleted_at.is_(None)
|
||||
).update({
|
||||
ProductionNote.deleted_at: deleted_at,
|
||||
ProductionNote.deleted_by: current_user.id
|
||||
}, synchronize_session=False)
|
||||
|
||||
# Update reviews
|
||||
submission_ids = [s.id for s in db.query(Submission.id).filter(
|
||||
Submission.task_id.in_(task_ids)
|
||||
).all()]
|
||||
|
||||
if submission_ids:
|
||||
review_update_count = db.query(Review).filter(
|
||||
Review.submission_id.in_(submission_ids),
|
||||
Review.deleted_at.is_(None)
|
||||
).update({
|
||||
Review.deleted_at: deleted_at,
|
||||
Review.deleted_by: current_user.id
|
||||
}, synchronize_session=False)
|
||||
|
||||
# Mark the asset as deleted
|
||||
asset.deleted_at = deleted_at
|
||||
asset.deleted_by = current_user.id
|
||||
db.flush()
|
||||
|
||||
# Success
|
||||
item.success = True
|
||||
item.name = asset.name
|
||||
item.deleted_counts = {
|
||||
'tasks': task_update_count,
|
||||
'submissions': submission_update_count,
|
||||
'attachments': attachment_update_count,
|
||||
'notes': note_update_count,
|
||||
'reviews': review_update_count
|
||||
}
|
||||
|
||||
result.successful_deletions += 1
|
||||
result.total_deleted_tasks += task_update_count
|
||||
result.total_deleted_submissions += submission_update_count
|
||||
result.total_deleted_attachments += attachment_update_count
|
||||
result.total_deleted_notes += note_update_count
|
||||
result.total_deleted_reviews += review_update_count
|
||||
|
||||
except Exception as e:
|
||||
item.success = False
|
||||
item.error = str(e)
|
||||
result.failed_deletions += 1
|
||||
|
||||
result.items.append(item)
|
||||
|
||||
# Commit batch to avoid long-running transactions
|
||||
db.commit()
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
# Mark remaining items as failed
|
||||
for asset_id in asset_ids[len(result.items):]:
|
||||
item = BatchDeletionItem()
|
||||
item.id = asset_id
|
||||
item.type = "asset"
|
||||
item.success = False
|
||||
item.error = f"Batch operation failed: {str(e)}"
|
||||
result.items.append(item)
|
||||
result.failed_deletions += 1
|
||||
|
||||
# Calculate operation duration
|
||||
end_time = datetime.utcnow()
|
||||
result.operation_duration = (end_time - start_time).total_seconds()
|
||||
|
||||
return result
|
||||
|
||||
def batch_recover_shots(self, shot_ids: List[int], db: Session, current_user: User,
|
||||
batch_size: int = 50) -> BatchRecoveryResult:
|
||||
"""
|
||||
Efficiently recover multiple shots in batches.
|
||||
|
||||
Args:
|
||||
shot_ids: List of shot IDs to recover
|
||||
db: Database session
|
||||
current_user: User performing the recovery
|
||||
batch_size: Number of shots to process in each batch
|
||||
|
||||
Returns:
|
||||
BatchRecoveryResult with operation details
|
||||
"""
|
||||
start_time = datetime.utcnow()
|
||||
result = BatchRecoveryResult()
|
||||
result.total_items = len(shot_ids)
|
||||
|
||||
try:
|
||||
# Process shots in batches to avoid memory issues
|
||||
for i in range(0, len(shot_ids), batch_size):
|
||||
batch_shot_ids = shot_ids[i:i + batch_size]
|
||||
|
||||
# Process each shot in the batch
|
||||
for shot_id in batch_shot_ids:
|
||||
item = BatchRecoveryItem()
|
||||
item.id = shot_id
|
||||
item.type = "shot"
|
||||
|
||||
try:
|
||||
# Get the deleted shot
|
||||
shot = db.query(Shot).filter(
|
||||
Shot.id == shot_id,
|
||||
Shot.deleted_at.isnot(None)
|
||||
).first()
|
||||
|
||||
if not shot:
|
||||
item.success = False
|
||||
item.error = "Shot not found or not deleted"
|
||||
result.failed_recoveries += 1
|
||||
result.items.append(item)
|
||||
continue
|
||||
|
||||
# Get deleted task IDs
|
||||
task_ids = [t.id for t in db.query(Task.id).filter(
|
||||
Task.shot_id == shot_id,
|
||||
Task.deleted_at.isnot(None)
|
||||
).all()]
|
||||
|
||||
# Recover tasks
|
||||
recovered_tasks = db.query(Task).filter(
|
||||
Task.shot_id == shot_id,
|
||||
Task.deleted_at.isnot(None)
|
||||
).update({
|
||||
Task.deleted_at: None,
|
||||
Task.deleted_by: None
|
||||
}, synchronize_session=False)
|
||||
|
||||
# Recover related data
|
||||
recovered_submissions = 0
|
||||
recovered_attachments = 0
|
||||
recovered_notes = 0
|
||||
recovered_reviews = 0
|
||||
|
||||
if task_ids:
|
||||
recovered_submissions = db.query(Submission).filter(
|
||||
Submission.task_id.in_(task_ids),
|
||||
Submission.deleted_at.isnot(None)
|
||||
).update({
|
||||
Submission.deleted_at: None,
|
||||
Submission.deleted_by: None
|
||||
}, synchronize_session=False)
|
||||
|
||||
recovered_attachments = db.query(TaskAttachment).filter(
|
||||
TaskAttachment.task_id.in_(task_ids),
|
||||
TaskAttachment.deleted_at.isnot(None)
|
||||
).update({
|
||||
TaskAttachment.deleted_at: None,
|
||||
TaskAttachment.deleted_by: None
|
||||
}, synchronize_session=False)
|
||||
|
||||
recovered_notes = db.query(ProductionNote).filter(
|
||||
ProductionNote.task_id.in_(task_ids),
|
||||
ProductionNote.deleted_at.isnot(None)
|
||||
).update({
|
||||
ProductionNote.deleted_at: None,
|
||||
ProductionNote.deleted_by: None
|
||||
}, synchronize_session=False)
|
||||
|
||||
# Recover reviews
|
||||
submission_ids = [s.id for s in db.query(Submission.id).filter(
|
||||
Submission.task_id.in_(task_ids)
|
||||
).all()]
|
||||
|
||||
if submission_ids:
|
||||
recovered_reviews = db.query(Review).filter(
|
||||
Review.submission_id.in_(submission_ids),
|
||||
Review.deleted_at.isnot(None)
|
||||
).update({
|
||||
Review.deleted_at: None,
|
||||
Review.deleted_by: None
|
||||
}, synchronize_session=False)
|
||||
|
||||
# Recover the shot
|
||||
shot.deleted_at = None
|
||||
shot.deleted_by = None
|
||||
db.flush()
|
||||
|
||||
# Success
|
||||
item.success = True
|
||||
item.name = shot.name
|
||||
item.recovered_counts = {
|
||||
'tasks': recovered_tasks,
|
||||
'submissions': recovered_submissions,
|
||||
'attachments': recovered_attachments,
|
||||
'notes': recovered_notes,
|
||||
'reviews': recovered_reviews
|
||||
}
|
||||
|
||||
result.successful_recoveries += 1
|
||||
result.total_recovered_tasks += recovered_tasks
|
||||
result.total_recovered_submissions += recovered_submissions
|
||||
result.total_recovered_attachments += recovered_attachments
|
||||
result.total_recovered_notes += recovered_notes
|
||||
result.total_recovered_reviews += recovered_reviews
|
||||
|
||||
except Exception as e:
|
||||
item.success = False
|
||||
item.error = str(e)
|
||||
result.failed_recoveries += 1
|
||||
|
||||
result.items.append(item)
|
||||
|
||||
# Commit batch to avoid long-running transactions
|
||||
db.commit()
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
# Mark remaining items as failed
|
||||
for shot_id in shot_ids[len(result.items):]:
|
||||
item = BatchRecoveryItem()
|
||||
item.id = shot_id
|
||||
item.type = "shot"
|
||||
item.success = False
|
||||
item.error = f"Batch operation failed: {str(e)}"
|
||||
result.items.append(item)
|
||||
result.failed_recoveries += 1
|
||||
|
||||
# Calculate operation duration
|
||||
end_time = datetime.utcnow()
|
||||
result.operation_duration = (end_time - start_time).total_seconds()
|
||||
|
||||
return result
|
||||
|
||||
def batch_recover_assets(self, asset_ids: List[int], db: Session, current_user: User,
|
||||
batch_size: int = 50) -> BatchRecoveryResult:
|
||||
"""
|
||||
Efficiently recover multiple assets in batches.
|
||||
|
||||
Args:
|
||||
asset_ids: List of asset IDs to recover
|
||||
db: Database session
|
||||
current_user: User performing the recovery
|
||||
batch_size: Number of assets to process in each batch
|
||||
|
||||
Returns:
|
||||
BatchRecoveryResult with operation details
|
||||
"""
|
||||
start_time = datetime.utcnow()
|
||||
result = BatchRecoveryResult()
|
||||
result.total_items = len(asset_ids)
|
||||
|
||||
try:
|
||||
# Process assets in batches to avoid memory issues
|
||||
for i in range(0, len(asset_ids), batch_size):
|
||||
batch_asset_ids = asset_ids[i:i + batch_size]
|
||||
|
||||
# Process each asset in the batch
|
||||
for asset_id in batch_asset_ids:
|
||||
item = BatchRecoveryItem()
|
||||
item.id = asset_id
|
||||
item.type = "asset"
|
||||
|
||||
try:
|
||||
# Get the deleted asset
|
||||
asset = db.query(Asset).filter(
|
||||
Asset.id == asset_id,
|
||||
Asset.deleted_at.isnot(None)
|
||||
).first()
|
||||
|
||||
if not asset:
|
||||
item.success = False
|
||||
item.error = "Asset not found or not deleted"
|
||||
result.failed_recoveries += 1
|
||||
result.items.append(item)
|
||||
continue
|
||||
|
||||
# Get deleted task IDs
|
||||
task_ids = [t.id for t in db.query(Task.id).filter(
|
||||
Task.asset_id == asset_id,
|
||||
Task.deleted_at.isnot(None)
|
||||
).all()]
|
||||
|
||||
# Recover tasks
|
||||
recovered_tasks = db.query(Task).filter(
|
||||
Task.asset_id == asset_id,
|
||||
Task.deleted_at.isnot(None)
|
||||
).update({
|
||||
Task.deleted_at: None,
|
||||
Task.deleted_by: None
|
||||
}, synchronize_session=False)
|
||||
|
||||
# Recover related data
|
||||
recovered_submissions = 0
|
||||
recovered_attachments = 0
|
||||
recovered_notes = 0
|
||||
recovered_reviews = 0
|
||||
|
||||
if task_ids:
|
||||
recovered_submissions = db.query(Submission).filter(
|
||||
Submission.task_id.in_(task_ids),
|
||||
Submission.deleted_at.isnot(None)
|
||||
).update({
|
||||
Submission.deleted_at: None,
|
||||
Submission.deleted_by: None
|
||||
}, synchronize_session=False)
|
||||
|
||||
recovered_attachments = db.query(TaskAttachment).filter(
|
||||
TaskAttachment.task_id.in_(task_ids),
|
||||
TaskAttachment.deleted_at.isnot(None)
|
||||
).update({
|
||||
TaskAttachment.deleted_at: None,
|
||||
TaskAttachment.deleted_by: None
|
||||
}, synchronize_session=False)
|
||||
|
||||
recovered_notes = db.query(ProductionNote).filter(
|
||||
ProductionNote.task_id.in_(task_ids),
|
||||
ProductionNote.deleted_at.isnot(None)
|
||||
).update({
|
||||
ProductionNote.deleted_at: None,
|
||||
ProductionNote.deleted_by: None
|
||||
}, synchronize_session=False)
|
||||
|
||||
# Recover reviews
|
||||
submission_ids = [s.id for s in db.query(Submission.id).filter(
|
||||
Submission.task_id.in_(task_ids)
|
||||
).all()]
|
||||
|
||||
if submission_ids:
|
||||
recovered_reviews = db.query(Review).filter(
|
||||
Review.submission_id.in_(submission_ids),
|
||||
Review.deleted_at.isnot(None)
|
||||
).update({
|
||||
Review.deleted_at: None,
|
||||
Review.deleted_by: None
|
||||
}, synchronize_session=False)
|
||||
|
||||
# Recover the asset
|
||||
asset.deleted_at = None
|
||||
asset.deleted_by = None
|
||||
db.flush()
|
||||
|
||||
# Success
|
||||
item.success = True
|
||||
item.name = asset.name
|
||||
item.recovered_counts = {
|
||||
'tasks': recovered_tasks,
|
||||
'submissions': recovered_submissions,
|
||||
'attachments': recovered_attachments,
|
||||
'notes': recovered_notes,
|
||||
'reviews': recovered_reviews
|
||||
}
|
||||
|
||||
result.successful_recoveries += 1
|
||||
result.total_recovered_tasks += recovered_tasks
|
||||
result.total_recovered_submissions += recovered_submissions
|
||||
result.total_recovered_attachments += recovered_attachments
|
||||
result.total_recovered_notes += recovered_notes
|
||||
result.total_recovered_reviews += recovered_reviews
|
||||
|
||||
except Exception as e:
|
||||
item.success = False
|
||||
item.error = str(e)
|
||||
result.failed_recoveries += 1
|
||||
|
||||
result.items.append(item)
|
||||
|
||||
# Commit batch to avoid long-running transactions
|
||||
db.commit()
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
# Mark remaining items as failed
|
||||
for asset_id in asset_ids[len(result.items):]:
|
||||
item = BatchRecoveryItem()
|
||||
item.id = asset_id
|
||||
item.type = "asset"
|
||||
item.success = False
|
||||
item.error = f"Batch operation failed: {str(e)}"
|
||||
result.items.append(item)
|
||||
result.failed_recoveries += 1
|
||||
|
||||
# Calculate operation duration
|
||||
end_time = datetime.utcnow()
|
||||
result.operation_duration = (end_time - start_time).total_seconds()
|
||||
|
||||
return result
|
||||
|
||||
def get_batch_deletion_preview(self, shot_ids: List[int], asset_ids: List[int],
|
||||
db: Session) -> Dict[str, Any]:
|
||||
"""
|
||||
Get preview information for a batch deletion operation.
|
||||
|
||||
Args:
|
||||
shot_ids: List of shot IDs to delete
|
||||
asset_ids: List of asset IDs to delete
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Dictionary with preview information
|
||||
"""
|
||||
preview = {
|
||||
'total_shots': len(shot_ids),
|
||||
'total_assets': len(asset_ids),
|
||||
'total_items': len(shot_ids) + len(asset_ids),
|
||||
'estimated_tasks': 0,
|
||||
'estimated_submissions': 0,
|
||||
'estimated_attachments': 0,
|
||||
'estimated_notes': 0,
|
||||
'estimated_reviews': 0,
|
||||
'affected_users': set(),
|
||||
'projects_affected': set()
|
||||
}
|
||||
|
||||
try:
|
||||
# Count tasks for shots
|
||||
if shot_ids:
|
||||
shot_task_count = db.query(Task).filter(
|
||||
Task.shot_id.in_(shot_ids),
|
||||
Task.deleted_at.is_(None)
|
||||
).count()
|
||||
preview['estimated_tasks'] += shot_task_count
|
||||
|
||||
# Get affected users and projects for shots
|
||||
shot_users = db.query(Task.assigned_user_id).filter(
|
||||
Task.shot_id.in_(shot_ids),
|
||||
Task.deleted_at.is_(None),
|
||||
Task.assigned_user_id.isnot(None)
|
||||
).distinct().all()
|
||||
preview['affected_users'].update([u[0] for u in shot_users])
|
||||
|
||||
shot_projects = db.query(Shot.episode_id).join(Shot.episode).filter(
|
||||
Shot.id.in_(shot_ids)
|
||||
).distinct().all()
|
||||
preview['projects_affected'].update([p[0] for p in shot_projects])
|
||||
|
||||
# Count tasks for assets
|
||||
if asset_ids:
|
||||
asset_task_count = db.query(Task).filter(
|
||||
Task.asset_id.in_(asset_ids),
|
||||
Task.deleted_at.is_(None)
|
||||
).count()
|
||||
preview['estimated_tasks'] += asset_task_count
|
||||
|
||||
# Get affected users and projects for assets
|
||||
asset_users = db.query(Task.assigned_user_id).filter(
|
||||
Task.asset_id.in_(asset_ids),
|
||||
Task.deleted_at.is_(None),
|
||||
Task.assigned_user_id.isnot(None)
|
||||
).distinct().all()
|
||||
preview['affected_users'].update([u[0] for u in asset_users])
|
||||
|
||||
asset_projects = db.query(Asset.project_id).filter(
|
||||
Asset.id.in_(asset_ids)
|
||||
).distinct().all()
|
||||
preview['projects_affected'].update([p[0] for p in asset_projects])
|
||||
|
||||
# Get all task IDs for counting related items
|
||||
all_task_ids = []
|
||||
if shot_ids:
|
||||
shot_task_ids = [t.id for t in db.query(Task.id).filter(
|
||||
Task.shot_id.in_(shot_ids),
|
||||
Task.deleted_at.is_(None)
|
||||
).all()]
|
||||
all_task_ids.extend(shot_task_ids)
|
||||
|
||||
if asset_ids:
|
||||
asset_task_ids = [t.id for t in db.query(Task.id).filter(
|
||||
Task.asset_id.in_(asset_ids),
|
||||
Task.deleted_at.is_(None)
|
||||
).all()]
|
||||
all_task_ids.extend(asset_task_ids)
|
||||
|
||||
if all_task_ids:
|
||||
# Count submissions
|
||||
preview['estimated_submissions'] = db.query(Submission).filter(
|
||||
Submission.task_id.in_(all_task_ids),
|
||||
Submission.deleted_at.is_(None)
|
||||
).count()
|
||||
|
||||
# Count attachments
|
||||
preview['estimated_attachments'] = db.query(TaskAttachment).filter(
|
||||
TaskAttachment.task_id.in_(all_task_ids),
|
||||
TaskAttachment.deleted_at.is_(None)
|
||||
).count()
|
||||
|
||||
# Count notes
|
||||
preview['estimated_notes'] = db.query(ProductionNote).filter(
|
||||
ProductionNote.task_id.in_(all_task_ids),
|
||||
ProductionNote.deleted_at.is_(None)
|
||||
).count()
|
||||
|
||||
# Count reviews
|
||||
submission_ids = [s.id for s in db.query(Submission.id).filter(
|
||||
Submission.task_id.in_(all_task_ids),
|
||||
Submission.deleted_at.is_(None)
|
||||
).all()]
|
||||
|
||||
if submission_ids:
|
||||
preview['estimated_reviews'] = db.query(Review).filter(
|
||||
Review.submission_id.in_(submission_ids),
|
||||
Review.deleted_at.is_(None)
|
||||
).count()
|
||||
|
||||
# Convert sets to counts
|
||||
preview['affected_users_count'] = len(preview['affected_users'])
|
||||
preview['projects_affected_count'] = len(preview['projects_affected'])
|
||||
|
||||
# Remove the sets from the response (not JSON serializable)
|
||||
del preview['affected_users']
|
||||
del preview['projects_affected']
|
||||
|
||||
except SQLAlchemyError:
|
||||
# Return basic preview on error
|
||||
pass
|
||||
|
||||
return preview
|
||||
|
||||
def optimize_batch_size(self, total_items: int, estimated_related_items: int) -> int:
|
||||
"""
|
||||
Calculate optimal batch size based on the number of items and their complexity.
|
||||
|
||||
Args:
|
||||
total_items: Total number of items to process
|
||||
estimated_related_items: Estimated number of related items (tasks, submissions, etc.)
|
||||
|
||||
Returns:
|
||||
Optimal batch size
|
||||
"""
|
||||
# Base batch size
|
||||
base_batch_size = 50
|
||||
|
||||
# Adjust based on complexity
|
||||
if estimated_related_items == 0:
|
||||
# No related items, can use larger batches
|
||||
return min(100, total_items)
|
||||
|
||||
# Calculate complexity factor
|
||||
complexity_factor = estimated_related_items / total_items if total_items > 0 else 1
|
||||
|
||||
if complexity_factor > 100:
|
||||
# Very complex items, use smaller batches
|
||||
return min(10, total_items)
|
||||
elif complexity_factor > 50:
|
||||
# Moderately complex items
|
||||
return min(25, total_items)
|
||||
else:
|
||||
# Simple items, use base batch size
|
||||
return min(base_batch_size, total_items)
|
||||
@@ -0,0 +1,421 @@
|
||||
"""
|
||||
Data Consistency Service for Shot/Asset Task Status Optimization
|
||||
|
||||
This service ensures data consistency between individual task updates and aggregated views,
|
||||
and provides real-time update propagation mechanisms.
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional, Any, Set
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, or_, func
|
||||
from datetime import datetime
|
||||
import json
|
||||
import logging
|
||||
|
||||
from models.task import Task
|
||||
from models.shot import Shot
|
||||
from models.asset import Asset
|
||||
from models.project import Project
|
||||
from models.episode import Episode
|
||||
from schemas.shot import TaskStatusInfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DataConsistencyService:
|
||||
"""Service for maintaining data consistency between individual task updates and aggregated views."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def validate_task_aggregation_consistency(self, entity_id: int, entity_type: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Validate that aggregated task data matches individual task records.
|
||||
|
||||
Args:
|
||||
entity_id: ID of the shot or asset
|
||||
entity_type: 'shot' or 'asset'
|
||||
|
||||
Returns:
|
||||
Dict containing validation results and any inconsistencies found
|
||||
"""
|
||||
logger.info(f"Validating task aggregation consistency for {entity_type} {entity_id}")
|
||||
|
||||
# Get individual task records
|
||||
if entity_type == 'shot':
|
||||
tasks = self.db.query(Task).filter(
|
||||
and_(
|
||||
Task.shot_id == entity_id,
|
||||
Task.deleted_at.is_(None)
|
||||
)
|
||||
).all()
|
||||
entity = self.db.query(Shot).filter(Shot.id == entity_id).first()
|
||||
elif entity_type == 'asset':
|
||||
tasks = self.db.query(Task).filter(
|
||||
and_(
|
||||
Task.asset_id == entity_id,
|
||||
Task.deleted_at.is_(None)
|
||||
)
|
||||
).all()
|
||||
entity = self.db.query(Asset).filter(Asset.id == entity_id).first()
|
||||
else:
|
||||
raise ValueError(f"Invalid entity_type: {entity_type}")
|
||||
|
||||
if not entity:
|
||||
return {
|
||||
'valid': False,
|
||||
'error': f'{entity_type.title()} not found',
|
||||
'inconsistencies': []
|
||||
}
|
||||
|
||||
# Build expected aggregated data from individual tasks
|
||||
expected_task_status = {}
|
||||
expected_task_details = []
|
||||
|
||||
# Get all task types for the project
|
||||
project = self.db.query(Project).filter(Project.id == entity.project_id).first()
|
||||
if not project:
|
||||
return {
|
||||
'valid': False,
|
||||
'error': 'Project not found',
|
||||
'inconsistencies': []
|
||||
}
|
||||
|
||||
# Get standard and custom task types
|
||||
if entity_type == 'shot':
|
||||
standard_types = ["layout", "animation", "simulation", "lighting", "compositing"]
|
||||
custom_types = project.custom_shot_task_types or []
|
||||
else: # asset
|
||||
standard_types = ["modeling", "surfacing", "rigging"]
|
||||
custom_types = project.custom_asset_task_types or []
|
||||
|
||||
all_task_types = standard_types + custom_types
|
||||
|
||||
# Initialize all task types as not_started
|
||||
for task_type in all_task_types:
|
||||
expected_task_status[task_type] = "not_started"
|
||||
|
||||
# Update with actual task data
|
||||
for task in tasks:
|
||||
expected_task_status[task.task_type] = task.status
|
||||
expected_task_details.append(TaskStatusInfo(
|
||||
task_type=task.task_type,
|
||||
status=task.status,
|
||||
task_id=task.id,
|
||||
assigned_user_id=task.assigned_user_id
|
||||
))
|
||||
|
||||
# Get current aggregated data using the optimized query
|
||||
if entity_type == 'shot':
|
||||
aggregated_data = self._get_shot_aggregated_data(entity_id)
|
||||
else:
|
||||
aggregated_data = self._get_asset_aggregated_data(entity_id)
|
||||
|
||||
# Compare expected vs actual aggregated data
|
||||
inconsistencies = []
|
||||
|
||||
# Check task_status consistency
|
||||
for task_type, expected_status in expected_task_status.items():
|
||||
actual_status = aggregated_data.get('task_status', {}).get(task_type)
|
||||
if actual_status != expected_status:
|
||||
inconsistencies.append({
|
||||
'type': 'task_status_mismatch',
|
||||
'task_type': task_type,
|
||||
'expected': expected_status,
|
||||
'actual': actual_status
|
||||
})
|
||||
|
||||
# Check task_details consistency
|
||||
actual_task_details = aggregated_data.get('task_details', [])
|
||||
expected_task_ids = {detail.task_id for detail in expected_task_details}
|
||||
actual_task_ids = {detail.get('task_id') for detail in actual_task_details if detail.get('task_id')}
|
||||
|
||||
if expected_task_ids != actual_task_ids:
|
||||
inconsistencies.append({
|
||||
'type': 'task_details_mismatch',
|
||||
'expected_task_ids': list(expected_task_ids),
|
||||
'actual_task_ids': list(actual_task_ids),
|
||||
'missing_tasks': list(expected_task_ids - actual_task_ids),
|
||||
'extra_tasks': list(actual_task_ids - expected_task_ids)
|
||||
})
|
||||
|
||||
return {
|
||||
'valid': len(inconsistencies) == 0,
|
||||
'entity_id': entity_id,
|
||||
'entity_type': entity_type,
|
||||
'inconsistencies': inconsistencies,
|
||||
'expected_task_status': expected_task_status,
|
||||
'actual_task_status': aggregated_data.get('task_status', {}),
|
||||
'validation_timestamp': datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
def _get_shot_aggregated_data(self, shot_id: int) -> Dict[str, Any]:
|
||||
"""Get aggregated task data for a shot using the optimized query."""
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
|
||||
shot_with_tasks = (
|
||||
self.db.query(Shot)
|
||||
.filter(and_(Shot.id == shot_id, Shot.deleted_at.is_(None)))
|
||||
.outerjoin(Task, (Task.shot_id == Shot.id) & (Task.deleted_at.is_(None)))
|
||||
.options(
|
||||
joinedload(Shot.episode).joinedload(Episode.project),
|
||||
selectinload(Shot.tasks).options(selectinload(Task.assigned_user))
|
||||
)
|
||||
.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')
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
if not shot_with_tasks:
|
||||
return {'task_status': {}, 'task_details': []}
|
||||
|
||||
# Process the results similar to the optimized list_shots implementation
|
||||
shot = shot_with_tasks[0][0]
|
||||
project = shot.episode.project if shot.episode else None
|
||||
|
||||
if not project:
|
||||
return {'task_status': {}, 'task_details': []}
|
||||
|
||||
# Get task types
|
||||
standard_types = ["layout", "animation", "simulation", "lighting", "compositing"]
|
||||
custom_types = project.custom_shot_task_types or []
|
||||
all_task_types = standard_types + custom_types
|
||||
|
||||
# Initialize task status
|
||||
task_status = {}
|
||||
for task_type in all_task_types:
|
||||
task_status[task_type] = "not_started"
|
||||
|
||||
# Build task details
|
||||
task_details = []
|
||||
for row in shot_with_tasks:
|
||||
task_id = row[1]
|
||||
task_type = row[2]
|
||||
task_status_val = row[3]
|
||||
assigned_user_id = row[4]
|
||||
|
||||
if task_id is not None:
|
||||
task_status[task_type] = task_status_val
|
||||
task_details.append({
|
||||
'task_type': task_type,
|
||||
'status': task_status_val,
|
||||
'task_id': task_id,
|
||||
'assigned_user_id': assigned_user_id
|
||||
})
|
||||
|
||||
return {
|
||||
'task_status': task_status,
|
||||
'task_details': task_details
|
||||
}
|
||||
|
||||
def _get_asset_aggregated_data(self, asset_id: int) -> Dict[str, Any]:
|
||||
"""Get aggregated task data for an asset using the optimized query."""
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
|
||||
asset_with_tasks = (
|
||||
self.db.query(Asset)
|
||||
.filter(and_(Asset.id == asset_id, Asset.deleted_at.is_(None)))
|
||||
.outerjoin(Task, (Task.asset_id == Asset.id) & (Task.deleted_at.is_(None)))
|
||||
.options(
|
||||
joinedload(Asset.project),
|
||||
selectinload(Asset.tasks).options(selectinload(Task.assigned_user))
|
||||
)
|
||||
.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')
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
if not asset_with_tasks:
|
||||
return {'task_status': {}, 'task_details': []}
|
||||
|
||||
# Process the results similar to the optimized list_assets implementation
|
||||
asset = asset_with_tasks[0][0]
|
||||
project = asset.project
|
||||
|
||||
if not project:
|
||||
return {'task_status': {}, 'task_details': []}
|
||||
|
||||
# Get task types
|
||||
standard_types = ["modeling", "surfacing", "rigging"]
|
||||
custom_types = project.custom_asset_task_types or []
|
||||
all_task_types = standard_types + custom_types
|
||||
|
||||
# Initialize task status
|
||||
task_status = {}
|
||||
for task_type in all_task_types:
|
||||
task_status[task_type] = "not_started"
|
||||
|
||||
# Build task details
|
||||
task_details = []
|
||||
for row in asset_with_tasks:
|
||||
task_id = row[1]
|
||||
task_type = row[2]
|
||||
task_status_val = row[3]
|
||||
assigned_user_id = row[4]
|
||||
|
||||
if task_id is not None:
|
||||
task_status[task_type] = task_status_val
|
||||
task_details.append({
|
||||
'task_type': task_type,
|
||||
'status': task_status_val,
|
||||
'task_id': task_id,
|
||||
'assigned_user_id': assigned_user_id
|
||||
})
|
||||
|
||||
return {
|
||||
'task_status': task_status,
|
||||
'task_details': task_details
|
||||
}
|
||||
|
||||
def validate_bulk_consistency(self, entity_ids: List[int], entity_type: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Validate consistency for multiple entities at once.
|
||||
|
||||
Args:
|
||||
entity_ids: List of shot or asset IDs
|
||||
entity_type: 'shot' or 'asset'
|
||||
|
||||
Returns:
|
||||
Dict containing bulk validation results
|
||||
"""
|
||||
logger.info(f"Validating bulk consistency for {len(entity_ids)} {entity_type}s")
|
||||
|
||||
results = []
|
||||
total_inconsistencies = 0
|
||||
|
||||
for entity_id in entity_ids:
|
||||
validation_result = self.validate_task_aggregation_consistency(entity_id, entity_type)
|
||||
results.append(validation_result)
|
||||
if not validation_result['valid']:
|
||||
total_inconsistencies += len(validation_result.get('inconsistencies', []))
|
||||
|
||||
return {
|
||||
'total_entities': len(entity_ids),
|
||||
'valid_entities': sum(1 for r in results if r['valid']),
|
||||
'invalid_entities': sum(1 for r in results if not r['valid']),
|
||||
'total_inconsistencies': total_inconsistencies,
|
||||
'results': results,
|
||||
'validation_timestamp': datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
def propagate_task_update(self, task_id: int, old_status: Optional[str] = None, new_status: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Propagate task status changes to ensure real-time updates in aggregated data.
|
||||
|
||||
Args:
|
||||
task_id: ID of the updated task
|
||||
old_status: Previous task status (optional)
|
||||
new_status: New task status (optional)
|
||||
|
||||
Returns:
|
||||
Dict containing propagation results
|
||||
"""
|
||||
logger.info(f"Propagating task update for task {task_id}")
|
||||
|
||||
# Get the task and its parent entity
|
||||
task = self.db.query(Task).filter(Task.id == task_id).first()
|
||||
if not task:
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Task not found',
|
||||
'task_id': task_id
|
||||
}
|
||||
|
||||
# Determine entity type and ID
|
||||
if task.shot_id:
|
||||
entity_type = 'shot'
|
||||
entity_id = task.shot_id
|
||||
elif task.asset_id:
|
||||
entity_type = 'asset'
|
||||
entity_id = task.asset_id
|
||||
else:
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Task is not associated with a shot or asset',
|
||||
'task_id': task_id
|
||||
}
|
||||
|
||||
# Validate consistency after the update
|
||||
validation_result = self.validate_task_aggregation_consistency(entity_id, entity_type)
|
||||
|
||||
# Log the propagation
|
||||
propagation_log = {
|
||||
'task_id': task_id,
|
||||
'entity_type': entity_type,
|
||||
'entity_id': entity_id,
|
||||
'old_status': old_status,
|
||||
'new_status': new_status or task.status,
|
||||
'consistency_valid': validation_result['valid'],
|
||||
'inconsistencies': validation_result.get('inconsistencies', []),
|
||||
'timestamp': datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
logger.info(f"Task update propagated: {json.dumps(propagation_log)}")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'task_id': task_id,
|
||||
'entity_type': entity_type,
|
||||
'entity_id': entity_id,
|
||||
'validation_result': validation_result,
|
||||
'propagation_log': propagation_log
|
||||
}
|
||||
|
||||
def get_consistency_report(self, project_id: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate a comprehensive consistency report for shots and assets.
|
||||
|
||||
Args:
|
||||
project_id: Optional project ID to filter by
|
||||
|
||||
Returns:
|
||||
Dict containing comprehensive consistency report
|
||||
"""
|
||||
logger.info(f"Generating consistency report for project {project_id}")
|
||||
|
||||
# Get all shots and assets
|
||||
shot_query = self.db.query(Shot.id).filter(Shot.deleted_at.is_(None))
|
||||
asset_query = self.db.query(Asset.id).filter(Asset.deleted_at.is_(None))
|
||||
|
||||
if project_id:
|
||||
shot_query = shot_query.filter(Shot.project_id == project_id)
|
||||
asset_query = asset_query.filter(Asset.project_id == project_id)
|
||||
|
||||
shot_ids = [row[0] for row in shot_query.all()]
|
||||
asset_ids = [row[0] for row in asset_query.all()]
|
||||
|
||||
# Validate consistency for all entities
|
||||
shot_results = self.validate_bulk_consistency(shot_ids, 'shot') if shot_ids else {'total_entities': 0, 'valid_entities': 0, 'invalid_entities': 0, 'total_inconsistencies': 0, 'results': []}
|
||||
asset_results = self.validate_bulk_consistency(asset_ids, 'asset') if asset_ids else {'total_entities': 0, 'valid_entities': 0, 'invalid_entities': 0, 'total_inconsistencies': 0, 'results': []}
|
||||
|
||||
return {
|
||||
'project_id': project_id,
|
||||
'shots': shot_results,
|
||||
'assets': asset_results,
|
||||
'summary': {
|
||||
'total_entities': shot_results['total_entities'] + asset_results['total_entities'],
|
||||
'valid_entities': shot_results['valid_entities'] + asset_results['valid_entities'],
|
||||
'invalid_entities': shot_results['invalid_entities'] + asset_results['invalid_entities'],
|
||||
'total_inconsistencies': shot_results['total_inconsistencies'] + asset_results['total_inconsistencies'],
|
||||
'consistency_percentage': (
|
||||
(shot_results['valid_entities'] + asset_results['valid_entities']) /
|
||||
max(1, shot_results['total_entities'] + asset_results['total_entities'])
|
||||
) * 100
|
||||
},
|
||||
'report_timestamp': datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
|
||||
def create_data_consistency_service(db: Session) -> DataConsistencyService:
|
||||
"""Factory function to create a DataConsistencyService instance."""
|
||||
return DataConsistencyService(db)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,486 @@
|
||||
"""
|
||||
Shot Soft Deletion Service
|
||||
|
||||
This service handles the soft deletion of shots and all related data including:
|
||||
- Tasks associated with the shot
|
||||
- Submissions for those tasks
|
||||
- Production notes for those tasks
|
||||
- Task attachments for those tasks
|
||||
- Reviews for those submissions
|
||||
|
||||
All operations are performed within database transactions to ensure atomicity.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy import func
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from models.shot import Shot
|
||||
from models.task import Task, Submission, Review, ProductionNote, TaskAttachment
|
||||
from models.user import User
|
||||
from models.activity import Activity, ActivityType
|
||||
from utils.activity import ActivityService
|
||||
|
||||
|
||||
class DeletionInfo:
|
||||
"""Information about what will be deleted when a shot is soft deleted."""
|
||||
|
||||
def __init__(self):
|
||||
self.shot_id: int = 0
|
||||
self.shot_name: str = ""
|
||||
self.episode_name: str = ""
|
||||
self.project_id: int = 0
|
||||
self.project_name: str = ""
|
||||
|
||||
# Counts of items that will be marked as deleted
|
||||
self.task_count: int = 0
|
||||
self.submission_count: int = 0
|
||||
self.attachment_count: int = 0
|
||||
self.note_count: int = 0
|
||||
self.review_count: int = 0
|
||||
|
||||
# File information (preserved, not deleted)
|
||||
self.total_file_size: int = 0
|
||||
self.file_count: int = 0
|
||||
|
||||
# Affected users
|
||||
self.affected_users: List[Dict[str, Any]] = []
|
||||
|
||||
# Timestamps
|
||||
self.last_activity_date: Optional[str] = None
|
||||
self.created_at: str = ""
|
||||
|
||||
|
||||
class DeletionResult:
|
||||
"""Result of a shot soft deletion operation."""
|
||||
|
||||
def __init__(self):
|
||||
self.success: bool = False
|
||||
self.shot_id: int = 0
|
||||
self.shot_name: str = ""
|
||||
|
||||
# Database update results
|
||||
self.marked_deleted_tasks: int = 0
|
||||
self.marked_deleted_submissions: int = 0
|
||||
self.marked_deleted_attachments: int = 0
|
||||
self.marked_deleted_notes: int = 0
|
||||
self.marked_deleted_reviews: int = 0
|
||||
|
||||
# Timing
|
||||
self.operation_duration: float = 0.0
|
||||
self.deleted_at: str = ""
|
||||
self.deleted_by: int = 0
|
||||
|
||||
# Errors
|
||||
self.errors: List[str] = []
|
||||
self.warnings: List[str] = []
|
||||
|
||||
|
||||
class ShotSoftDeletionService:
|
||||
"""Service for handling shot soft deletion operations."""
|
||||
|
||||
def __init__(self):
|
||||
self.activity_service = ActivityService()
|
||||
|
||||
def get_deletion_info(self, shot_id: int, db: Session) -> Optional[DeletionInfo]:
|
||||
"""
|
||||
Get information about what will be deleted when a shot is soft deleted.
|
||||
|
||||
Args:
|
||||
shot_id: ID of the shot to analyze
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
DeletionInfo object with counts and affected users, or None if shot not found
|
||||
"""
|
||||
try:
|
||||
# Get the shot (only if not already deleted)
|
||||
shot = db.query(Shot).filter(
|
||||
Shot.id == shot_id,
|
||||
Shot.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
if not shot:
|
||||
return None
|
||||
|
||||
info = DeletionInfo()
|
||||
info.shot_id = shot.id
|
||||
info.shot_name = shot.name
|
||||
info.episode_name = shot.episode.name if shot.episode else ""
|
||||
info.project_id = shot.project_id
|
||||
info.project_name = shot.project.name if shot.project else ""
|
||||
info.created_at = shot.created_at.isoformat() if shot.created_at else ""
|
||||
|
||||
# Get all active tasks for this shot
|
||||
tasks = db.query(Task).filter(
|
||||
Task.shot_id == shot_id,
|
||||
Task.deleted_at.is_(None)
|
||||
).all()
|
||||
|
||||
info.task_count = len(tasks)
|
||||
|
||||
if not tasks:
|
||||
return info
|
||||
|
||||
task_ids = [task.id for task in tasks]
|
||||
|
||||
# Count submissions
|
||||
submissions = db.query(Submission).filter(
|
||||
Submission.task_id.in_(task_ids),
|
||||
Submission.deleted_at.is_(None)
|
||||
).all()
|
||||
info.submission_count = len(submissions)
|
||||
|
||||
# Count attachments and calculate file sizes
|
||||
attachments = db.query(TaskAttachment).filter(
|
||||
TaskAttachment.task_id.in_(task_ids),
|
||||
TaskAttachment.deleted_at.is_(None)
|
||||
).all()
|
||||
info.attachment_count = len(attachments)
|
||||
info.file_count = len(attachments) + len(submissions)
|
||||
info.total_file_size = sum(att.file_size for att in attachments if att.file_size)
|
||||
|
||||
# Count production notes
|
||||
notes = db.query(ProductionNote).filter(
|
||||
ProductionNote.task_id.in_(task_ids),
|
||||
ProductionNote.deleted_at.is_(None)
|
||||
).all()
|
||||
info.note_count = len(notes)
|
||||
|
||||
# Count reviews
|
||||
if submissions:
|
||||
submission_ids = [sub.id for sub in submissions]
|
||||
reviews = db.query(Review).filter(
|
||||
Review.submission_id.in_(submission_ids),
|
||||
Review.deleted_at.is_(None)
|
||||
).all()
|
||||
info.review_count = len(reviews)
|
||||
|
||||
# Get affected users
|
||||
info.affected_users = self._get_affected_users(tasks, submissions, notes, db)
|
||||
|
||||
# Get last activity date
|
||||
info.last_activity_date = self._get_last_activity_date(shot_id, task_ids, db)
|
||||
|
||||
return info
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
raise Exception(f"Database error while getting deletion info: {str(e)}")
|
||||
|
||||
def soft_delete_shot_cascade(self, shot_id: int, db: Session, current_user: User) -> DeletionResult:
|
||||
"""
|
||||
Perform cascading soft deletion of a shot and all related data.
|
||||
|
||||
Args:
|
||||
shot_id: ID of the shot to soft delete
|
||||
db: Database session
|
||||
current_user: User performing the deletion
|
||||
|
||||
Returns:
|
||||
DeletionResult with operation details
|
||||
"""
|
||||
start_time = datetime.utcnow()
|
||||
result = DeletionResult()
|
||||
result.shot_id = shot_id
|
||||
result.deleted_by = current_user.id
|
||||
|
||||
try:
|
||||
# Get the shot (only if not already deleted)
|
||||
shot = db.query(Shot).filter(
|
||||
Shot.id == shot_id,
|
||||
Shot.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
if not shot:
|
||||
result.errors.append("Shot not found or already deleted")
|
||||
return result
|
||||
|
||||
result.shot_name = shot.name
|
||||
deleted_at = datetime.utcnow()
|
||||
result.deleted_at = deleted_at.isoformat()
|
||||
|
||||
# Get deletion info for logging
|
||||
deletion_info = self.get_deletion_info(shot_id, db)
|
||||
|
||||
# Mark related data as deleted
|
||||
self._mark_related_data_deleted(shot_id, db, current_user, deleted_at, result)
|
||||
|
||||
# Mark the shot as deleted
|
||||
shot.deleted_at = deleted_at
|
||||
shot.deleted_by = current_user.id
|
||||
db.flush()
|
||||
|
||||
# Log the deletion activity
|
||||
self._log_shot_deletion(shot, current_user, deletion_info, db)
|
||||
|
||||
result.success = True
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
result.errors.append(f"Database error during deletion: {str(e)}")
|
||||
except Exception as e:
|
||||
result.errors.append(f"Unexpected error during deletion: {str(e)}")
|
||||
|
||||
# Calculate operation duration
|
||||
end_time = datetime.utcnow()
|
||||
result.operation_duration = (end_time - start_time).total_seconds()
|
||||
|
||||
return result
|
||||
|
||||
def _mark_related_data_deleted(self, shot_id: int, db: Session, current_user: User,
|
||||
deleted_at: datetime, result: DeletionResult) -> None:
|
||||
"""
|
||||
Mark all data related to a shot as deleted.
|
||||
|
||||
Args:
|
||||
shot_id: ID of the shot
|
||||
db: Database session
|
||||
current_user: User performing the deletion
|
||||
deleted_at: Timestamp for deletion
|
||||
result: DeletionResult to update with counts
|
||||
"""
|
||||
# Get all active tasks for this shot
|
||||
tasks = db.query(Task).filter(
|
||||
Task.shot_id == shot_id,
|
||||
Task.deleted_at.is_(None)
|
||||
).all()
|
||||
|
||||
if not tasks:
|
||||
return
|
||||
|
||||
task_ids = [task.id for task in tasks]
|
||||
|
||||
# Mark tasks as deleted
|
||||
task_update_count = db.query(Task).filter(
|
||||
Task.shot_id == shot_id,
|
||||
Task.deleted_at.is_(None)
|
||||
).update({
|
||||
Task.deleted_at: deleted_at,
|
||||
Task.deleted_by: current_user.id
|
||||
}, synchronize_session=False)
|
||||
result.marked_deleted_tasks = task_update_count
|
||||
|
||||
# Mark submissions as deleted
|
||||
submission_update_count = db.query(Submission).filter(
|
||||
Submission.task_id.in_(task_ids),
|
||||
Submission.deleted_at.is_(None)
|
||||
).update({
|
||||
Submission.deleted_at: deleted_at,
|
||||
Submission.deleted_by: current_user.id
|
||||
}, synchronize_session=False)
|
||||
result.marked_deleted_submissions = submission_update_count
|
||||
|
||||
# Mark attachments as deleted
|
||||
attachment_update_count = db.query(TaskAttachment).filter(
|
||||
TaskAttachment.task_id.in_(task_ids),
|
||||
TaskAttachment.deleted_at.is_(None)
|
||||
).update({
|
||||
TaskAttachment.deleted_at: deleted_at,
|
||||
TaskAttachment.deleted_by: current_user.id
|
||||
}, synchronize_session=False)
|
||||
result.marked_deleted_attachments = attachment_update_count
|
||||
|
||||
# Mark production notes as deleted
|
||||
note_update_count = db.query(ProductionNote).filter(
|
||||
ProductionNote.task_id.in_(task_ids),
|
||||
ProductionNote.deleted_at.is_(None)
|
||||
).update({
|
||||
ProductionNote.deleted_at: deleted_at,
|
||||
ProductionNote.deleted_by: current_user.id
|
||||
}, synchronize_session=False)
|
||||
result.marked_deleted_notes = note_update_count
|
||||
|
||||
# Mark reviews as deleted (for submissions that belong to these tasks)
|
||||
# First get submission IDs
|
||||
submission_ids = [sub.id for sub in db.query(Submission.id).filter(
|
||||
Submission.task_id.in_(task_ids)
|
||||
).all()]
|
||||
|
||||
if submission_ids:
|
||||
review_update_count = db.query(Review).filter(
|
||||
Review.submission_id.in_(submission_ids),
|
||||
Review.deleted_at.is_(None)
|
||||
).update({
|
||||
Review.deleted_at: deleted_at,
|
||||
Review.deleted_by: current_user.id
|
||||
}, synchronize_session=False)
|
||||
result.marked_deleted_reviews = review_update_count
|
||||
|
||||
db.flush()
|
||||
|
||||
def _get_affected_users(self, tasks: List[Task], submissions: List[Submission],
|
||||
notes: List[ProductionNote], db: Session) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get list of users affected by the shot deletion.
|
||||
|
||||
Args:
|
||||
tasks: List of tasks that will be deleted
|
||||
submissions: List of submissions that will be deleted
|
||||
notes: List of production notes that will be deleted
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
List of affected user information
|
||||
"""
|
||||
user_data = {}
|
||||
|
||||
# Collect user IDs and their involvement
|
||||
for task in tasks:
|
||||
if task.assigned_user_id:
|
||||
if task.assigned_user_id not in user_data:
|
||||
user_data[task.assigned_user_id] = {
|
||||
'task_count': 0,
|
||||
'submission_count': 0,
|
||||
'note_count': 0,
|
||||
'last_activity_date': None
|
||||
}
|
||||
user_data[task.assigned_user_id]['task_count'] += 1
|
||||
if task.updated_at:
|
||||
current_date = user_data[task.assigned_user_id]['last_activity_date']
|
||||
if not current_date or task.updated_at > current_date:
|
||||
user_data[task.assigned_user_id]['last_activity_date'] = task.updated_at
|
||||
|
||||
for submission in submissions:
|
||||
if submission.user_id not in user_data:
|
||||
user_data[submission.user_id] = {
|
||||
'task_count': 0,
|
||||
'submission_count': 0,
|
||||
'note_count': 0,
|
||||
'last_activity_date': None
|
||||
}
|
||||
user_data[submission.user_id]['submission_count'] += 1
|
||||
if submission.submitted_at:
|
||||
current_date = user_data[submission.user_id]['last_activity_date']
|
||||
if not current_date or submission.submitted_at > current_date:
|
||||
user_data[submission.user_id]['last_activity_date'] = submission.submitted_at
|
||||
|
||||
for note in notes:
|
||||
if note.user_id not in user_data:
|
||||
user_data[note.user_id] = {
|
||||
'task_count': 0,
|
||||
'submission_count': 0,
|
||||
'note_count': 0,
|
||||
'last_activity_date': None
|
||||
}
|
||||
user_data[note.user_id]['note_count'] += 1
|
||||
if note.updated_at:
|
||||
current_date = user_data[note.user_id]['last_activity_date']
|
||||
if not current_date or note.updated_at > current_date:
|
||||
user_data[note.user_id]['last_activity_date'] = note.updated_at
|
||||
|
||||
# Get user details
|
||||
affected_users = []
|
||||
if user_data:
|
||||
users = db.query(User).filter(User.id.in_(user_data.keys())).all()
|
||||
for user in users:
|
||||
data = user_data[user.id]
|
||||
affected_users.append({
|
||||
'id': user.id,
|
||||
'name': f"{user.first_name} {user.last_name}",
|
||||
'email': user.email,
|
||||
'role': user.role,
|
||||
'task_count': data['task_count'],
|
||||
'submission_count': data['submission_count'],
|
||||
'note_count': data['note_count'],
|
||||
'last_activity_date': data['last_activity_date'].isoformat() if data['last_activity_date'] else None
|
||||
})
|
||||
|
||||
return affected_users
|
||||
|
||||
def _get_last_activity_date(self, shot_id: int, task_ids: List[int], db: Session) -> Optional[str]:
|
||||
"""
|
||||
Get the most recent activity date for the shot and its tasks.
|
||||
|
||||
Args:
|
||||
shot_id: ID of the shot
|
||||
task_ids: List of task IDs
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
ISO formatted date string of last activity, or None
|
||||
"""
|
||||
try:
|
||||
# Get the most recent activity from various sources
|
||||
dates = []
|
||||
|
||||
# Shot updated_at
|
||||
shot = db.query(Shot.updated_at).filter(Shot.id == shot_id).first()
|
||||
if shot and shot.updated_at:
|
||||
dates.append(shot.updated_at)
|
||||
|
||||
if task_ids:
|
||||
# Task updated_at
|
||||
task_dates = db.query(func.max(Task.updated_at)).filter(
|
||||
Task.id.in_(task_ids)
|
||||
).scalar()
|
||||
if task_dates:
|
||||
dates.append(task_dates)
|
||||
|
||||
# Submission submitted_at
|
||||
submission_dates = db.query(func.max(Submission.submitted_at)).filter(
|
||||
Submission.task_id.in_(task_ids)
|
||||
).scalar()
|
||||
if submission_dates:
|
||||
dates.append(submission_dates)
|
||||
|
||||
# Production note updated_at
|
||||
note_dates = db.query(func.max(ProductionNote.updated_at)).filter(
|
||||
ProductionNote.task_id.in_(task_ids)
|
||||
).scalar()
|
||||
if note_dates:
|
||||
dates.append(note_dates)
|
||||
|
||||
if dates:
|
||||
return max(dates).isoformat()
|
||||
|
||||
return None
|
||||
|
||||
except SQLAlchemyError:
|
||||
return None
|
||||
|
||||
def _log_shot_deletion(self, shot: Shot, current_user: User, deletion_info: Optional[DeletionInfo], db: Session) -> None:
|
||||
"""
|
||||
Log the shot deletion activity.
|
||||
|
||||
Args:
|
||||
shot: The shot that was deleted
|
||||
current_user: User who performed the deletion
|
||||
deletion_info: Information about what was deleted
|
||||
db: Database session
|
||||
"""
|
||||
try:
|
||||
# Create activity record
|
||||
activity = Activity(
|
||||
type=ActivityType.SHOT_UPDATED, # We'll use SHOT_UPDATED for now, could add SHOT_DELETED later
|
||||
user_id=current_user.id,
|
||||
project_id=shot.project_id,
|
||||
shot_id=shot.id,
|
||||
description=f"Shot '{shot.name}' was deleted by {current_user.first_name} {current_user.last_name}",
|
||||
activity_metadata={
|
||||
'action': 'soft_delete',
|
||||
'shot_name': shot.name,
|
||||
'episode_name': shot.episode.name if shot.episode else None,
|
||||
'project_id': shot.project_id,
|
||||
'project_name': shot.project.name if shot.project else None,
|
||||
'deleted_counts': {
|
||||
'tasks': deletion_info.task_count if deletion_info else 0,
|
||||
'submissions': deletion_info.submission_count if deletion_info else 0,
|
||||
'attachments': deletion_info.attachment_count if deletion_info else 0,
|
||||
'notes': deletion_info.note_count if deletion_info else 0,
|
||||
'reviews': deletion_info.review_count if deletion_info else 0
|
||||
},
|
||||
'affected_users_count': len(deletion_info.affected_users) if deletion_info else 0
|
||||
}
|
||||
)
|
||||
|
||||
db.add(activity)
|
||||
db.flush()
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
# Don't fail the deletion if logging fails, just add a warning
|
||||
pass
|
||||
Reference in New Issue
Block a user