Init Repo
This commit is contained in:
@@ -0,0 +1,483 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user