From 6961cbc6398e9e36de42d21023855f4645007990 Mon Sep 17 00:00:00 2001 From: earthcomfy <hanagetbel93@gmail.com> Date: Thu, 13 Mar 2025 21:09:06 +0300 Subject: [PATCH 1/4] fix: enhance thumbnail handling for public and private files --- filer/admin/folderadmin.py | 26 +++++++++++- filer/utils/filer_easy_thumbnails.py | 13 +++++- tests/test_models.py | 7 ++-- tests/test_thumbnails.py | 62 ++++++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 6 deletions(-) create mode 100644 tests/test_thumbnails.py diff --git a/filer/admin/folderadmin.py b/filer/admin/folderadmin.py index 8b3190f8e..4de19fea6 100644 --- a/filer/admin/folderadmin.py +++ b/filer/admin/folderadmin.py @@ -375,8 +375,30 @@ def directory_listing(self, request, folder_id=None, viewtype=None): .order_by("-modified") ) file_qs = file_qs.annotate( - thumbnail_name=Subquery(thumbnail_qs.filter(name__contains=f"__{size}_").values_list("name")[:1]), - thumbnailx2_name=Subquery(thumbnail_qs.filter(name__contains=f"__{size_x2}_").values_list("name")[:1]) + # For private files (Filer pattern) + thumbnail_name_filer=Subquery( + thumbnail_qs.filter(name__contains=f"__{size}_").values_list("name")[:1] + ), + thumbnailx2_name_filer=Subquery( + thumbnail_qs.filter(name__contains=f"__{size_x2}_").values_list("name")[ + :1 + ] + ), + # For public files (easy_thumbnails pattern) + thumbnail_name_easy=Subquery( + thumbnail_qs.filter(name__contains=f".{size}_q85_crop").values_list( + "name" + )[:1] + ), + thumbnailx2_name_easy=Subquery( + thumbnail_qs.filter(name__contains=f".{size_x2}_q85_crop").values_list( + "name" + )[:1] + ), + thumbnail_name=Coalesce("thumbnail_name_filer", "thumbnail_name_easy"), + thumbnailx2_name=Coalesce( + "thumbnailx2_name_filer", "thumbnailx2_name_easy" + ), ).select_related("owner") try: diff --git a/filer/utils/filer_easy_thumbnails.py b/filer/utils/filer_easy_thumbnails.py index 00b2f4b43..9b678b755 100644 --- a/filer/utils/filer_easy_thumbnails.py +++ b/filer/utils/filer_easy_thumbnails.py @@ -27,8 +27,19 @@ def get_thumbnail_name(self, thumbnail_options, transparent=False): """ A version of ``Thumbnailer.get_thumbnail_name`` that produces a reproducible thumbnail name that can be converted back to the original - filename. + filename. For public files, it uses easy_thumbnails default naming. """ + is_public = False + if hasattr(self, "thumbnail_storage"): + is_public = "PrivateFileSystemStorage" not in str( + self.thumbnail_storage.__class__ + ) + + if is_public: + return super(ThumbnailerNameMixin, self).get_thumbnail_name( + thumbnail_options, transparent + ) + path, source_filename = os.path.split(self.name) source_extension = os.path.splitext(source_filename)[1][1:].lower() preserve_extensions = self.thumbnail_preserve_extensions diff --git a/tests/test_models.py b/tests/test_models.py index 3d52e2502..6ee7ac204 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -35,13 +35,14 @@ def tearDown(self): for f in File.objects.all(): f.delete() - def create_filer_image(self, owner=None): + def create_filer_image(self, owner=None, is_public=True): if owner is None: owner = self.superuser file_obj = DjangoFile(open(self.filename, 'rb'), name=self.image_name) image = Image.objects.create(owner=owner, original_filename=self.image_name, - file=file_obj) + file=file_obj, + is_public=is_public) return image def test_create_folder_structure(self): @@ -80,7 +81,7 @@ def test_create_clipboard_item(self): self.assertEqual(Clipboard.objects.count(), 1) def test_create_icons(self): - image = self.create_filer_image() + image = self.create_filer_image(is_public=False) image.save() icons = image.icons file_basename = os.path.basename(image.file.path) diff --git a/tests/test_thumbnails.py b/tests/test_thumbnails.py new file mode 100644 index 000000000..4fbc1ce2a --- /dev/null +++ b/tests/test_thumbnails.py @@ -0,0 +1,62 @@ +import os + +from django.conf import settings +from django.core.files import File as DjangoFile +from django.test import TestCase, override_settings + +from filer.models.filemodels import File +from filer.settings import FILER_IMAGE_MODEL +from filer.utils.loader import load_model +from tests.helpers import create_image, create_superuser + +Image = load_model(FILER_IMAGE_MODEL) + + +def custom_namer(thumbnailer, **kwargs): + path, filename = os.path.split(thumbnailer.name) + return os.path.join(path, f"custom_prefix_{filename}") + + +class ThumbnailNameTests(TestCase): + def setUp(self): + self.superuser = create_superuser() + self.img = create_image() + self.image_name = "test_file.jpg" + self.filename = os.path.join(settings.FILE_UPLOAD_TEMP_DIR, self.image_name) + self.img.save(self.filename, "JPEG") + + def tearDown(self): + os.remove(self.filename) + for f in File.objects.all(): + f.delete() + + def create_filer_image(self, is_public=True): + with open(self.filename, "rb") as f: + file_obj = DjangoFile(f) + image = Image.objects.create( + owner=self.superuser, + original_filename=self.image_name, + file=file_obj, + is_public=is_public, + ) + return image + + def test_thumbnailer_class_for_public_files(self): + image = self.create_filer_image(is_public=True) + thumbnailer = image.easy_thumbnails_thumbnailer + name = thumbnailer.get_thumbnail_name({"size": (100, 100)}) + self.assertNotIn("__", name) + + def test_thumbnailer_class_for_private_files(self): + image = self.create_filer_image(is_public=False) + thumbnailer = image.easy_thumbnails_thumbnailer + name = thumbnailer.get_thumbnail_name({"size": (100, 100)}) + self.assertIn("__", name) + + @override_settings(THUMBNAIL_NAMER="tests.test_thumbnails.custom_namer") + def test_thumbnail_custom_namer(self): + image = self.create_filer_image(is_public=True) + thumbnailer = image.easy_thumbnails_thumbnailer + name = thumbnailer.get_thumbnail_name({"size": (100, 100)}) + filename = os.path.basename(name) + self.assertTrue(filename.startswith("custom_prefix_")) From e939b4660e77f5a7aceebdc291b65349f34689f9 Mon Sep 17 00:00:00 2001 From: earthcomfy <hanagetbel93@gmail.com> Date: Wed, 19 Mar 2025 22:33:42 +0300 Subject: [PATCH 2/4] fix: similar implementation for private and public files --- filer/admin/folderadmin.py | 26 +-------- filer/utils/filer_easy_thumbnails.py | 82 +++++++++------------------- tests/test_models.py | 9 ++- tests/test_thumbnails.py | 13 ++++- 4 files changed, 44 insertions(+), 86 deletions(-) diff --git a/filer/admin/folderadmin.py b/filer/admin/folderadmin.py index 38a738a8c..75e33975f 100644 --- a/filer/admin/folderadmin.py +++ b/filer/admin/folderadmin.py @@ -376,30 +376,8 @@ def directory_listing(self, request, folder_id=None, viewtype=None): .order_by("-modified") ) file_qs = file_qs.annotate( - # For private files (Filer pattern) - thumbnail_name_filer=Subquery( - thumbnail_qs.filter(name__contains=f"__{size}_").values_list("name")[:1] - ), - thumbnailx2_name_filer=Subquery( - thumbnail_qs.filter(name__contains=f"__{size_x2}_").values_list("name")[ - :1 - ] - ), - # For public files (easy_thumbnails pattern) - thumbnail_name_easy=Subquery( - thumbnail_qs.filter(name__contains=f".{size}_q85_crop").values_list( - "name" - )[:1] - ), - thumbnailx2_name_easy=Subquery( - thumbnail_qs.filter(name__contains=f".{size_x2}_q85_crop").values_list( - "name" - )[:1] - ), - thumbnail_name=Coalesce("thumbnail_name_filer", "thumbnail_name_easy"), - thumbnailx2_name=Coalesce( - "thumbnailx2_name_filer", "thumbnailx2_name_easy" - ), + thumbnail_name=Subquery(thumbnail_qs.filter(name__contains=f".{size}_").values_list("name")[:1]), + thumbnailx2_name=Subquery(thumbnail_qs.filter(name__contains=f".{size_x2}_").values_list("name")[:1]) ).select_related("owner") try: diff --git a/filer/utils/filer_easy_thumbnails.py b/filer/utils/filer_easy_thumbnails.py index 9b678b755..c4d4a3aa6 100644 --- a/filer/utils/filer_easy_thumbnails.py +++ b/filer/utils/filer_easy_thumbnails.py @@ -3,12 +3,11 @@ from easy_thumbnails.files import Thumbnailer - -# match the source filename using `__` as the seperator. ``opts_and_ext`` is non -# greedy so it should match the last occurence of `__`. -# in ``ThumbnailerNameMixin.get_thumbnail_name`` we ensure that there is no `__` -# in the opts part. -RE_ORIGINAL_FILENAME = re.compile(r"^(?P<source_filename>.*)__(?P<opts_and_ext>.*?)$") +# easy-thumbnails default pattern +# e.g: source.jpg.100x100_q80_crop_upscale.jpg +RE_ORIGINAL_FILENAME = re.compile( + r"^(?P<source_filename>.*?)\.(?P<opts_and_ext>[^.]+\.[^.]+)$" +) def thumbnail_to_original_filename(thumbnail_name): @@ -19,15 +18,15 @@ def thumbnail_to_original_filename(thumbnail_name): class ThumbnailerNameMixin: - thumbnail_basedir = '' - thumbnail_subdir = '' - thumbnail_prefix = '' + thumbnail_basedir = "" + thumbnail_subdir = "" + thumbnail_prefix = "" def get_thumbnail_name(self, thumbnail_options, transparent=False): """ - A version of ``Thumbnailer.get_thumbnail_name`` that produces a - reproducible thumbnail name that can be converted back to the original - filename. For public files, it uses easy_thumbnails default naming. + Get thumbnail name using easy-thumbnails pattern. + For public files: Uses configurable naming via THUMBNAIL_NAMER + For private files: Uses easy-thumbnails default naming pattern regardless of THUMBNAIL_NAMER """ is_public = False if hasattr(self, "thumbnail_storage"): @@ -35,53 +34,26 @@ def get_thumbnail_name(self, thumbnail_options, transparent=False): self.thumbnail_storage.__class__ ) - if is_public: - return super(ThumbnailerNameMixin, self).get_thumbnail_name( - thumbnail_options, transparent - ) - path, source_filename = os.path.split(self.name) - source_extension = os.path.splitext(source_filename)[1][1:].lower() - preserve_extensions = self.thumbnail_preserve_extensions - if preserve_extensions is True or source_extension == 'svg' or \ - isinstance(preserve_extensions, (list, tuple)) and source_extension in preserve_extensions: - extension = source_extension - elif transparent: - extension = self.thumbnail_transparency_extension - else: - extension = self.thumbnail_extension - extension = extension or 'jpg' - - thumbnail_options = thumbnail_options.copy() - size = tuple(thumbnail_options.pop('size')) - initial_opts = ['{}x{}'.format(*size)] - quality = thumbnail_options.pop('quality', self.thumbnail_quality) - if extension == 'jpg': - initial_opts.append(f'q{quality}') - elif extension == 'svg': - thumbnail_options.pop('subsampling', None) - thumbnail_options.pop('upscale', None) - - opts = list(thumbnail_options.items()) - opts.sort() # Sort the options so the file name is consistent. - opts = ['{}'.format(v is not True and f'{k}-{v}' or k) - for k, v in opts if v] - all_opts = '_'.join(initial_opts + opts) - - basedir = self.thumbnail_basedir - subdir = self.thumbnail_subdir - - # make sure our magic delimiter is not used in all_opts - all_opts = all_opts.replace('__', '_') - filename = f'{source_filename}__{all_opts}.{extension}' + thumbnail_name = super(ThumbnailerNameMixin, self).get_thumbnail_name( + thumbnail_options, transparent + ) + if is_public: + return thumbnail_name - return os.path.join(basedir, path, subdir, filename) + base_thumb_name = os.path.basename(thumbnail_name) + return os.path.join( + self.thumbnail_basedir, + path, + self.thumbnail_subdir, + f"{source_filename}.{base_thumb_name}", + ) class ActionThumbnailerMixin: - thumbnail_basedir = '' - thumbnail_subdir = '' - thumbnail_prefix = '' + thumbnail_basedir = "" + thumbnail_subdir = "" + thumbnail_prefix = "" def get_thumbnail_name(self, thumbnail_options, transparent=False): """ @@ -101,7 +73,7 @@ def thumbnail_exists(self, thumbnail_name): class FilerThumbnailer(ThumbnailerNameMixin, Thumbnailer): def __init__(self, *args, **kwargs): - self.thumbnail_basedir = kwargs.pop('thumbnail_basedir', '') + self.thumbnail_basedir = kwargs.pop("thumbnail_basedir", "") super().__init__(*args, **kwargs) diff --git a/tests/test_models.py b/tests/test_models.py index 6ee7ac204..f9466a5d5 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -35,14 +35,13 @@ def tearDown(self): for f in File.objects.all(): f.delete() - def create_filer_image(self, owner=None, is_public=True): + def create_filer_image(self, owner=None): if owner is None: owner = self.superuser file_obj = DjangoFile(open(self.filename, 'rb'), name=self.image_name) image = Image.objects.create(owner=owner, original_filename=self.image_name, - file=file_obj, - is_public=is_public) + file=file_obj) return image def test_create_folder_structure(self): @@ -81,14 +80,14 @@ def test_create_clipboard_item(self): self.assertEqual(Clipboard.objects.count(), 1) def test_create_icons(self): - image = self.create_filer_image(is_public=False) + image = self.create_filer_image() image.save() icons = image.icons file_basename = os.path.basename(image.file.path) self.assertEqual(len(icons), len(filer_settings.FILER_ADMIN_ICON_SIZES)) for size in filer_settings.FILER_ADMIN_ICON_SIZES: self.assertEqual(os.path.basename(icons[size]), - file_basename + '__{}x{}_q85_crop_subsampling-2_upscale.jpg'.format(size, size)) + file_basename + '.{}x{}_q85_crop_upscale.jpg'.format(size, size)) def test_access_icons_property(self): """Test IconsMixin that calls static on a non-existent file""" diff --git a/tests/test_thumbnails.py b/tests/test_thumbnails.py index 4fbc1ce2a..b758d4ff7 100644 --- a/tests/test_thumbnails.py +++ b/tests/test_thumbnails.py @@ -45,13 +45,13 @@ def test_thumbnailer_class_for_public_files(self): image = self.create_filer_image(is_public=True) thumbnailer = image.easy_thumbnails_thumbnailer name = thumbnailer.get_thumbnail_name({"size": (100, 100)}) - self.assertNotIn("__", name) + self.assertRegex(name, r"^.*\..*\.[^.]+$") def test_thumbnailer_class_for_private_files(self): image = self.create_filer_image(is_public=False) thumbnailer = image.easy_thumbnails_thumbnailer name = thumbnailer.get_thumbnail_name({"size": (100, 100)}) - self.assertIn("__", name) + self.assertRegex(name, r"^.*\..*\.[^.]+$") @override_settings(THUMBNAIL_NAMER="tests.test_thumbnails.custom_namer") def test_thumbnail_custom_namer(self): @@ -60,3 +60,12 @@ def test_thumbnail_custom_namer(self): name = thumbnailer.get_thumbnail_name({"size": (100, 100)}) filename = os.path.basename(name) self.assertTrue(filename.startswith("custom_prefix_")) + + @override_settings(THUMBNAIL_NAMER="tests.test_thumbnails.custom_namer") + def test_private_thumbnail_ignores_custom_namer(self): + image = self.create_filer_image(is_public=False) + thumbnailer = image.easy_thumbnails_thumbnailer + name = thumbnailer.get_thumbnail_name({"size": (100, 100)}) + filename = os.path.basename(name) + self.assertFalse(filename.startswith("custom_prefix_")) + self.assertRegex(name, r"^.*\..*\.[^.]+$") From 1908ab553c236f609d5e42d1b5a4c1ef84fe8491 Mon Sep 17 00:00:00 2001 From: Fabian Braun <fsbraun@gmx.de> Date: Thu, 20 Mar 2025 12:01:01 +0100 Subject: [PATCH 3/4] Update filer/utils/filer_easy_thumbnails.py Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- filer/utils/filer_easy_thumbnails.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/filer/utils/filer_easy_thumbnails.py b/filer/utils/filer_easy_thumbnails.py index c4d4a3aa6..b60fec008 100644 --- a/filer/utils/filer_easy_thumbnails.py +++ b/filer/utils/filer_easy_thumbnails.py @@ -30,9 +30,8 @@ def get_thumbnail_name(self, thumbnail_options, transparent=False): """ is_public = False if hasattr(self, "thumbnail_storage"): - is_public = "PrivateFileSystemStorage" not in str( - self.thumbnail_storage.__class__ - ) + from filer.storage import PrivateFileSystemStorage + is_public = not isinstance(self.thumbnail_storage, PrivateFileSystemStorage) path, source_filename = os.path.split(self.name) thumbnail_name = super(ThumbnailerNameMixin, self).get_thumbnail_name( From 1934f899ad9610f9c27b6eab72d19954ababc7e1 Mon Sep 17 00:00:00 2001 From: earthcomfy <hanagetbel93@gmail.com> Date: Mon, 24 Mar 2025 23:08:12 +0300 Subject: [PATCH 4/4] fix: use context manager to use default namer for private files --- filer/utils/filer_easy_thumbnails.py | 41 ++++++++++++++++++---------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/filer/utils/filer_easy_thumbnails.py b/filer/utils/filer_easy_thumbnails.py index b60fec008..ac3bb93d8 100644 --- a/filer/utils/filer_easy_thumbnails.py +++ b/filer/utils/filer_easy_thumbnails.py @@ -1,7 +1,9 @@ import os import re +from contextlib import contextmanager from easy_thumbnails.files import Thumbnailer +from easy_thumbnails.namers import default # easy-thumbnails default pattern # e.g: source.jpg.100x100_q80_crop_upscale.jpg @@ -17,6 +19,19 @@ def thumbnail_to_original_filename(thumbnail_name): return None +@contextmanager +def use_default_namer(thumbnailer): + """ + Context manager to use the default easy-thumbnails namer for private files. + """ + original_namer = thumbnailer.thumbnail_namer + thumbnailer.thumbnail_namer = default + try: + yield + finally: + thumbnailer.thumbnail_namer = original_namer + + class ThumbnailerNameMixin: thumbnail_basedir = "" thumbnail_subdir = "" @@ -30,23 +45,19 @@ def get_thumbnail_name(self, thumbnail_options, transparent=False): """ is_public = False if hasattr(self, "thumbnail_storage"): - from filer.storage import PrivateFileSystemStorage - is_public = not isinstance(self.thumbnail_storage, PrivateFileSystemStorage) + is_public = "PrivateFileSystemStorage" not in str( + self.thumbnail_storage.__class__ + ) - path, source_filename = os.path.split(self.name) - thumbnail_name = super(ThumbnailerNameMixin, self).get_thumbnail_name( - thumbnail_options, transparent - ) if is_public: - return thumbnail_name - - base_thumb_name = os.path.basename(thumbnail_name) - return os.path.join( - self.thumbnail_basedir, - path, - self.thumbnail_subdir, - f"{source_filename}.{base_thumb_name}", - ) + return super(ThumbnailerNameMixin, self).get_thumbnail_name( + thumbnail_options, transparent + ) + + with use_default_namer(self): + return super(ThumbnailerNameMixin, self).get_thumbnail_name( + thumbnail_options, transparent + ) class ActionThumbnailerMixin: