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