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

1""" 

2Management command to clean up old unused cached search images. 

3 

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. 

7 

8Usage: 

9 python manage.py cleanup_search_images --days=30 

10 python manage.py cleanup_search_images --days=30 --dry-run 

11""" 

12 

13import logging 

14from datetime import timedelta 

15 

16from django.core.cache import cache 

17from django.core.management.base import BaseCommand 

18from django.utils import timezone 

19 

20from apps.recipes.models import CachedSearchImage 

21 

22logger = logging.getLogger(__name__) 

23 

24CLEANUP_CACHE_KEY = "search_image_cleanup_last_run" 

25 

26 

27class Command(BaseCommand): 

28 help = "Delete cached search images older than specified days (default: 30)" 

29 

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 ) 

42 

43 def handle(self, *args, **options): 

44 days = options["days"] 

45 dry_run = options["dry_run"] 

46 now = timezone.now() 

47 

48 # Calculate cutoff date 

49 cutoff_date = now - timedelta(days=days) 

50 

51 # Find old cached images 

52 old_images = CachedSearchImage.objects.filter(last_accessed_at__lt=cutoff_date) 

53 

54 count = old_images.count() 

55 

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 

61 

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 ) 

68 

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

78 

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 

83 

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) 

91 

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

97 

98 remaining = CachedSearchImage.objects.count() 

99 self._record_run(now, deleted_count, remaining) 

100 

101 self.stdout.write( 

102 self.style.SUCCESS(f"Successfully deleted {deleted_count} cached image(s) older than {days} days.") 

103 ) 

104 

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 ) 

111 

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 ) 

← Back to Dashboard