LinkDesk/backend/services/asset_soft_deletion.py

483 lines
18 KiB
Python

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