diff --git a/docs/management.rst b/docs/management.rst index 7b0693850..1118f7e99 100644 --- a/docs/management.rst +++ b/docs/management.rst @@ -41,6 +41,13 @@ not reference the generated thumbnails by name somewhere else in your code. As long as all the original images still exist this will trigger a regeneration of all the thumbnails the Key Value Store knows about. +The command supports an optional ``--timeout`` parameter that can specify a +date filter criteria when deciding to delete a thumbnail. The timeout value can +be either a number of seconds or an ISO 8601 duration string supported by +``django.utils.dateparse.parse_duration``. For example, running +``python manage.py thumbnail clear_delete_referenced --timeout=P90D`` will +delete all thumbnails that were created more than 90 days ago. + .. _thumbnail-clear-delete-all: diff --git a/sorl/thumbnail/kvstores/base.py b/sorl/thumbnail/kvstores/base.py index 97958be14..99a3e5d93 100644 --- a/sorl/thumbnail/kvstores/base.py +++ b/sorl/thumbnail/kvstores/base.py @@ -79,13 +79,17 @@ def delete_thumbnails(self, image_file): # Delete the thumbnails key from store self._delete(image_file.key, identity='thumbnails') - def delete_all_thumbnail_files(self): + def delete_all_thumbnail_files(self, older_than=None): for key in self._find_keys(identity='thumbnails'): thumbnail_keys = self._get(key, identity='thumbnails') if thumbnail_keys: for key in thumbnail_keys: thumbnail = self._get(key) if thumbnail: + if older_than is not None: + created_time = thumbnail.storage.get_created_time(thumbnail.name) + if created_time > older_than: + continue thumbnail.delete() def cleanup(self): diff --git a/sorl/thumbnail/management/commands/thumbnail.py b/sorl/thumbnail/management/commands/thumbnail.py index 34f72dfbc..04a398b16 100644 --- a/sorl/thumbnail/management/commands/thumbnail.py +++ b/sorl/thumbnail/management/commands/thumbnail.py @@ -1,4 +1,8 @@ -from django.core.management.base import BaseCommand +from datetime import timedelta + +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone +from django.utils.dateparse import parse_duration from sorl.thumbnail import default from sorl.thumbnail.images import delete_all_thumbnails @@ -14,7 +18,9 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument('args', choices=VALID_LABELS, nargs=1) + parser.add_argument('--timeout') + # flake8: noqa: C901 def handle(self, *labels, **options): verbosity = int(options.get('verbosity')) label = labels[0] @@ -31,13 +37,25 @@ def handle(self, *labels, **options): return if label == 'clear_delete_referenced': + timeout_date = None + if options['timeout']: + # Optional deletion timeout duration + if options['timeout'].isdigit(): # A number of seconds + seconds = int(options['timeout']) + else: + # A duration string as supported by Django. + duration = parse_duration(options['timeout']) + if not duration: + raise CommandError(f"Unable to parse '{options['timeout']}' as a duration") + seconds = duration.seconds + timeout_date = timezone.now() - timedelta(seconds=seconds) if verbosity >= 1: - self.stdout.write( - "Delete all thumbnail files referenced in Key Value Store", - ending=' ... ' - ) + msg = "Delete all thumbnail files referenced in Key Value Store" + if timeout_date: + msg += f" older than {timeout_date.strftime('%Y-%m-%d %H:%M:%S')}" + self.stdout.write(msg, ending=' ... ') - default.kvstore.delete_all_thumbnail_files() + default.kvstore.delete_all_thumbnail_files(older_than=timeout_date) if verbosity >= 1: self.stdout.write('[Done]') diff --git a/tests/thumbnail_tests/storage.py b/tests/thumbnail_tests/storage.py index 6b0bfb736..308217d22 100644 --- a/tests/thumbnail_tests/storage.py +++ b/tests/thumbnail_tests/storage.py @@ -61,17 +61,17 @@ def url(self, name, *args, **kwargs): # slog.debug('url: %s' % name) return super().url(name, *args, **kwargs) - def accessed_time(self, name, *args, **kwargs): + def get_accessed_time(self, name, *args, **kwargs): slog.debug('accessed_time: %s' % name) - return super().accessed_time(name, *args, **kwargs) + return super().get_accessed_time(name, *args, **kwargs) - def created_time(self, name, *args, **kwargs): + def get_created_time(self, name, *args, **kwargs): slog.debug('created_time: %s' % name) - return super().created_time(name, *args, **kwargs) + return super().get_created_time(name, *args, **kwargs) - def modified_time(self, name, *args, **kwargs): + def get_modified_time(self, name, *args, **kwargs): slog.debug('modified_time: %s' % name) - return super().modified_time(name, *args, **kwargs) + return super().get_modified_time(name, *args, **kwargs) class TestStorage(TestStorageMixin, FileSystemStorage): diff --git a/tests/thumbnail_tests/test_commands.py b/tests/thumbnail_tests/test_commands.py index 31c48be9a..d56cbe949 100644 --- a/tests/thumbnail_tests/test_commands.py +++ b/tests/thumbnail_tests/test_commands.py @@ -1,7 +1,10 @@ import os +from datetime import datetime from io import StringIO +from unittest import mock from django.core import management +from django.core.management.base import CommandError from sorl.thumbnail.conf import settings @@ -44,6 +47,38 @@ def test_clear_delete_referenced_action(self): self.assertTrue(os.path.isfile(name2)) self.assertFalse(os.path.isfile(name3)) + def _test_clear_delete_referenced_timeout(self, timeout): + """ + Clear KV store and delete referenced thumbnails for thumbnails older + than the specified timeout. + """ + name1, name2 = self.make_test_thumbnails('400x300', '200x200') + out = StringIO() + with mock.patch('tests.thumbnail_tests.storage.TestStorage.get_created_time') as mocked: + mocked.return_value = datetime(2016, 9, 29, 12, 58, 27) + management.call_command( + 'thumbnail', 'clear_delete_referenced', f'--timeout={timeout}', + verbosity=1, stdout=out + ) + lines = out.getvalue().split("\n") + self.assertRegex( + lines[0], + "Delete all thumbnail files referenced in Key Value Store " + r"older than \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \.\.\. \[Done\]" + ) + self.assertFalse(os.path.isfile(name1)) + self.assertFalse(os.path.isfile(name2)) + + def test_clear_delete_referenced_timeout_digits(self): + self._test_clear_delete_referenced_timeout('7776000') + + def test_clear_delete_referenced_timeout_duration(self): + self._test_clear_delete_referenced_timeout('P180D') + + def test_clear_delete_referenced_timeout_invalid(self): + with self.assertRaisesMessage(CommandError, "Unable to parse 'XX360' as a duration"): + self._test_clear_delete_referenced_timeout('XX360') + def test_clear_delete_all_action(self): """ Clear KV store and delete all thumbnails """ name1, name2 = self.make_test_thumbnails('400x300', '200x200') diff --git a/tests/thumbnail_tests/utils.py b/tests/thumbnail_tests/utils.py index 443c35cac..d37590cb5 100644 --- a/tests/thumbnail_tests/utils.py +++ b/tests/thumbnail_tests/utils.py @@ -5,6 +5,7 @@ from contextlib import contextmanager from subprocess import check_output +from django.test import TestCase from PIL import Image, ImageDraw from sorl.thumbnail.conf import settings @@ -56,7 +57,7 @@ def __init__(self, name): self.name = name -class BaseTestCase(unittest.TestCase): +class BaseTestCase(TestCase): IMAGE_DIMENSIONS = [(500, 500), (100, 100), (200, 100), ] BACKEND = None ENGINE = None