diff --git a/backend/server/adventures/management/commands/image_cleanup.py b/backend/server/adventures/management/commands/image_cleanup.py new file mode 100644 index 0000000..0f5e1ad --- /dev/null +++ b/backend/server/adventures/management/commands/image_cleanup.py @@ -0,0 +1,94 @@ +import os +from django.core.management.base import BaseCommand +from django.conf import settings +from adventures.models import AdventureImage, Attachment +from users.models import CustomUser + + +class Command(BaseCommand): + help = 'Find and prompt for deletion of unused image files and attachments in filesystem' + + def add_arguments(self, parser): + parser.add_argument( + '--dry-run', + action='store_true', + help='Show files that would be deleted without actually deleting them', + ) + + def handle(self, **options): + dry_run = options['dry_run'] + + # Get all image and attachment file paths from database + used_files = set() + + # Get AdventureImage file paths + for img in AdventureImage.objects.all(): + if img.image and img.image.name: + used_files.add(os.path.join(settings.MEDIA_ROOT, img.image.name)) + + # Get Attachment file paths + for attachment in Attachment.objects.all(): + if attachment.file and attachment.file.name: + used_files.add(os.path.join(settings.MEDIA_ROOT, attachment.file.name)) + + # Get user profile picture file paths + for user in CustomUser.objects.all(): + if user.profile_pic and user.profile_pic.name: + used_files.add(os.path.join(settings.MEDIA_ROOT, user.profile_pic.name)) + + # Find all files in media/images and media/attachments directories + media_root = settings.MEDIA_ROOT + all_files = [] + + # Scan images directory + images_dir = os.path.join(media_root, 'images') + # Scan attachments directory + attachments_dir = os.path.join(media_root, 'attachments') + if os.path.exists(attachments_dir): + for root, _, files in os.walk(attachments_dir): + for file in files: + all_files.append(os.path.join(root, file)) + + # Scan profile-pics directory + profile_pics_dir = os.path.join(media_root, 'profile-pics') + if os.path.exists(profile_pics_dir): + for root, _, files in os.walk(profile_pics_dir): + for file in files: + all_files.append(os.path.join(root, file)) + attachments_dir = os.path.join(media_root, 'attachments') + if os.path.exists(attachments_dir): + for root, _, files in os.walk(attachments_dir): + for file in files: + all_files.append(os.path.join(root, file)) + + # Find unused files + unused_files = [f for f in all_files if f not in used_files] + + if not unused_files: + self.stdout.write(self.style.SUCCESS('No unused files found.')) + return + + self.stdout.write(f'Found {len(unused_files)} unused files:') + for file_path in unused_files: + self.stdout.write(f' {file_path}') + + if dry_run: + self.stdout.write(self.style.WARNING('Dry run mode - no files were deleted.')) + return + + # Prompt for deletion + confirm = input('\nDo you want to delete these files? (yes/no): ') + if confirm.lower() in ['yes', 'y']: + deleted_count = 0 + for file_path in unused_files: + try: + os.remove(file_path) + self.stdout.write(f'Deleted: {file_path}') + deleted_count += 1 + except OSError as e: + self.stdout.write(self.style.ERROR(f'Error deleting {file_path}: {e}')) + + self.stdout.write(self.style.SUCCESS(f'Successfully deleted {deleted_count} files.')) + else: + self.stdout.write('Operation cancelled.') + diff --git a/backend/server/adventures/models.py b/backend/server/adventures/models.py index 40bb680..6a3d970 100644 --- a/backend/server/adventures/models.py +++ b/backend/server/adventures/models.py @@ -656,6 +656,15 @@ class Adventure(models.Model): return result + def delete(self, *args, **kwargs): + # Delete all associated AdventureImages first to trigger their filesystem cleanup + for image in self.images.all(): + image.delete() + # Delete all associated Attachment files first to trigger their filesystem cleanup + for attachment in self.attachments.all(): + attachment.delete() + super().delete(*args, **kwargs) + def __str__(self): return self.name @@ -838,6 +847,12 @@ class AdventureImage(models.Model): self.full_clean() # This calls clean() method super().save(*args, **kwargs) + def delete(self, *args, **kwargs): + # Remove file from disk when deleting AdventureImage + if self.image and os.path.isfile(self.image.path): + os.remove(self.image.path) + super().delete(*args, **kwargs) + def __str__(self): return self.image.url if self.image else f"Immich ID: {self.immich_id or 'No image'}" @@ -849,6 +864,11 @@ class Attachment(models.Model): adventure = models.ForeignKey(Adventure, related_name='attachments', on_delete=models.CASCADE) name = models.CharField(max_length=200, null=True, blank=True) + def delete(self, *args, **kwargs): + if self.file and os.path.isfile(self.file.path): + os.remove(self.file.path) + super().delete(*args, **kwargs) + def __str__(self): return self.file.url diff --git a/backend/server/adventures/views/adventure_image_view.py b/backend/server/adventures/views/adventure_image_view.py index ab7f8e1..537aab2 100644 --- a/backend/server/adventures/views/adventure_image_view.py +++ b/backend/server/adventures/views/adventure_image_view.py @@ -9,6 +9,7 @@ from adventures.serializers import AdventureImageSerializer from integrations.models import ImmichIntegration import uuid import requests +import os class AdventureImageViewSet(viewsets.ModelViewSet): serializer_class = AdventureImageSerializer @@ -155,7 +156,6 @@ class AdventureImageViewSet(viewsets.ModelViewSet): return super().update(request, *args, **kwargs) def perform_destroy(self, instance): - print("perform_destroy") return super().perform_destroy(instance) def destroy(self, request, *args, **kwargs):