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 eef411d71..587184b57 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 f7b7468a2..6831f50ad 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 @@ -15,7 +19,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] @@ -32,13 +38,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/test_commands.py b/tests/thumbnail_tests/test_commands.py index 41a1ded0b..1a0e70611 100644 --- a/tests/thumbnail_tests/test_commands.py +++ b/tests/thumbnail_tests/test_commands.py @@ -1,8 +1,11 @@ +from datetime import datetime from io import StringIO +from unittest import mock import os import pytest from django.core import management +from django.core.management.base import CommandError from sorl.thumbnail.conf import settings from .models import Item @@ -47,6 +50,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')