Coverage for apps / recipes / management / commands / cleanup_search_images.py: 98%
51 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-12 10:49 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-12 10:49 +0000
1"""
2Management command to clean up old unused cached search images.
4Deletes CachedSearchImage records and files that haven't been accessed
5in the specified number of days. Actively used images (displayed in search
6or reused during recipe import) are preserved via last_accessed_at updates.
8Usage:
9 python manage.py cleanup_search_images --days=30
10 python manage.py cleanup_search_images --days=30 --dry-run
11"""
13import logging
14from datetime import timedelta
16from django.core.cache import cache
17from django.core.management.base import BaseCommand
18from django.utils import timezone
20from apps.recipes.models import CachedSearchImage
22logger = logging.getLogger(__name__)
24CLEANUP_CACHE_KEY = "search_image_cleanup_last_run"
27class Command(BaseCommand):
28 help = "Delete cached search images older than specified days (default: 30)"
30 def add_arguments(self, parser):
31 parser.add_argument(
32 "--days",
33 type=int,
34 default=30,
35 help="Delete images not accessed in this many days (default: 30)",
36 )
37 parser.add_argument(
38 "--dry-run",
39 action="store_true",
40 help="Show what would be deleted without actually deleting",
41 )
43 def handle(self, *args, **options):
44 days = options["days"]
45 dry_run = options["dry_run"]
46 now = timezone.now()
48 # Calculate cutoff date
49 cutoff_date = now - timedelta(days=days)
51 # Find old cached images
52 old_images = CachedSearchImage.objects.filter(last_accessed_at__lt=cutoff_date)
54 count = old_images.count()
56 if count == 0:
57 self.stdout.write(self.style.SUCCESS(f"No cached images older than {days} days found."))
58 if not dry_run:
59 self._record_run(now, 0, CachedSearchImage.objects.count())
60 return
62 # Show what will be deleted
63 self.stdout.write(
64 self.style.WARNING(
65 f"Found {count} cached image(s) not accessed since {cutoff_date.strftime('%Y-%m-%d %H:%M:%S')}"
66 )
67 )
69 if dry_run:
70 self.stdout.write(self.style.NOTICE("\n[DRY RUN] Would delete the following images:"))
71 for img in old_images[:10]: # Show first 10
72 self.stdout.write(
73 f" - ID {img.id}: {img.external_url[:80]}... "
74 f"(last accessed: {img.last_accessed_at.strftime('%Y-%m-%d')})"
75 )
76 if count > 10:
77 self.stdout.write(f" ... and {count - 10} more")
79 self.stdout.write(
80 self.style.NOTICE(f"\n[DRY RUN] Run without --dry-run to actually delete {count} image(s)")
81 )
82 return
84 # Actually delete the images
85 deleted_count = 0
86 for img in old_images:
87 try:
88 # Delete the file from disk if it exists
89 if img.image:
90 img.image.delete(save=False)
92 # Delete the database record
93 img.delete()
94 deleted_count += 1
95 except Exception as e:
96 logger.error(f"Failed to delete cached image {img.id}: {e}")
98 remaining = CachedSearchImage.objects.count()
99 self._record_run(now, deleted_count, remaining)
101 self.stdout.write(
102 self.style.SUCCESS(f"Successfully deleted {deleted_count} cached image(s) older than {days} days.")
103 )
105 if deleted_count < count:
106 self.stdout.write(
107 self.style.WARNING(
108 f"Warning: {count - deleted_count} image(s) failed to delete. Check logs for details."
109 )
110 )
112 @staticmethod
113 def _record_run(now, deleted, remaining):
114 cache.set(
115 CLEANUP_CACHE_KEY,
116 {"time": now.isoformat(), "deleted": deleted, "remaining": remaining},
117 timeout=None,
118 )