LinkDesk/backend/services/shot_soft_deletion.py

486 lines
18 KiB
Python

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