483 lines
18 KiB
Python
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 |