Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix #8307 -- Saving a model with an ImageField with width_field or height_field no longer results in an extra read operation #18070

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
13 changes: 12 additions & 1 deletion django/db/models/fields/files.py
Expand Up @@ -88,10 +88,15 @@ def open(self, mode="rb"):
# to further manipulate the underlying file, as well as update the
# associated model instance.

def _set_instance_attribute(self, name, content):
# Existence of this method is primarily motivated by custom logic
# in ImageFieldFile._set_instance_attribute
setattr(self.instance, self.field.attname, name)

def save(self, name, content, save=True):
name = self.field.generate_filename(self.instance, name)
self.name = self.storage.save(name, content, max_length=self.field.max_length)
setattr(self.instance, self.field.attname, self.name)
self._set_instance_attribute(self.name, content)
self._committed = True

# Save the object because it has changed, unless save is False
Expand Down Expand Up @@ -380,6 +385,12 @@ def __set__(self, instance, value):


class ImageFieldFile(ImageFile, FieldFile):
def _set_instance_attribute(self, name, content):
setattr(self.instance, self.field.attname, content)
# Update the name in case generate_filename() or storage.save() changed
# it, but bypass the descriptor to avoid re-reading the file.
self.instance.__dict__[self.field.attname] = self.name

def delete(self, save=True):
# Clear the image dimensions cache
if hasattr(self, "_dimensions_cache"):
Expand Down
7 changes: 7 additions & 0 deletions docs/ref/models/fields.txt
Expand Up @@ -1361,6 +1361,13 @@ can change the maximum length using the :attr:`~CharField.max_length` argument.
The default form widget for this field is a
:class:`~django.forms.ClearableFileInput`.

john-parton marked this conversation as resolved.
Show resolved Hide resolved
.. versionchanged:: 5.1

In older versions, auto-populating ``height_field`` and ``width_field``
would always result in re-reading the file from the storage backend. If
your storage backend resizes images, the actual image's width and height
will not match the auto-populated fields.

``IntegerField``
----------------

Expand Down
5 changes: 5 additions & 0 deletions docs/releases/5.1.txt
Expand Up @@ -419,6 +419,11 @@ Miscellaneous
* The undocumented ``django.urls.converters.get_converter()`` function is
removed.

* :class:`~django.db.models.ImageField` no longer re-reads the file from the
storage backend after ``width_field`` and ``height_field`` are set. If
your storage backend resizes images, the actual image's width and height will
not match the auto-populated fields.

* The minimum supported version of SQLite is increased from 3.27.0 to 3.31.0.

.. _deprecated-features-5.1:
Expand Down
19 changes: 19 additions & 0 deletions tests/model_fields/models.py
Expand Up @@ -13,6 +13,8 @@
from django.utils.functional import SimpleLazyObject
from django.utils.translation import gettext_lazy as _

from .storage import NoReadFileSystemStorage

try:
from PIL import Image
except ImportError:
Expand Down Expand Up @@ -373,6 +375,23 @@ class PersonTwoImages(models.Model):
width_field="headshot_width",
)

class PersonNoReadImage(models.Model):
"""
Model that defines an ImageField with a storage backend that does not
support reading.

This supports a test to avoid a double-read performance problem.
"""

mugshot = models.ImageField(
upload_to="tests",
storage=NoReadFileSystemStorage(),
width_field="mugshot_width",
height_field="mugshot_height",
)
mugshot_width = models.IntegerField()
mugshot_height = models.IntegerField()


class CustomJSONDecoder(json.JSONDecoder):
def __init__(self, object_hook=None, *args, **kwargs):
Expand Down
6 changes: 6 additions & 0 deletions tests/model_fields/storage.py
@@ -0,0 +1,6 @@
from django.core.files.storage.filesystem import FileSystemStorage


class NoReadFileSystemStorage(FileSystemStorage):
def open(self, *args, **kwargs):
raise AssertionError("This storage class does not support reading.")
28 changes: 27 additions & 1 deletion tests/model_fields/test_imagefield.py
Expand Up @@ -18,6 +18,7 @@
from .models import (
Person,
PersonDimensionsFirst,
PersonNoReadImage,
PersonTwoImages,
PersonWithHeight,
PersonWithHeightAndWidth,
Expand All @@ -30,7 +31,7 @@ class Person:
pass

PersonWithHeight = PersonWithHeightAndWidth = PersonDimensionsFirst = Person
PersonTwoImages = Person
PersonTwoImages = PersonNoReadImage = Person


class ImageFieldTestMixin(SerializeMixin):
Expand Down Expand Up @@ -469,3 +470,28 @@ def test_dimensions(self):
# Dimensions were recalculated, and hence file should have opened.
self.assertIs(p.mugshot.was_opened, True)
self.assertIs(p.headshot.was_opened, True)


@skipIf(Image is None, "Pillow is required to test ImageField")
class NoReadTests(ImageFieldTestMixin, TestCase):
def test_width_height_correct_name_mangling_correct(self):
instance1 = PersonNoReadImage()

instance1.mugshot.save("mug", self.file1)

self.assertEqual(instance1.mugshot_width, 4)
self.assertEqual(instance1.mugshot_height, 8)

instance1.save()

self.assertEqual(instance1.mugshot_width, 4)
self.assertEqual(instance1.mugshot_height, 8)

instance2 = PersonNoReadImage()
instance2.mugshot.save("mug", self.file1)
instance2.save()

self.assertNotEqual(instance1.mugshot.name, instance2.mugshot.name)

self.assertEqual(instance1.mugshot_width, instance2.mugshot_width)
self.assertEqual(instance1.mugshot_height, instance2.mugshot_height)