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