Django model ImageField creates a nested folder each time it uploads file

Viewed 503

I'm sure I'm missing something silly, but I'm confused.

I have the following model for my profile:

class Profile(models.Model):
    user = models.OneToOneField(User, on_delete=models.CASCADE)
    image = models.ImageField(
        default="default.jpg",
        upload_to="profile_pics/",
        validators=[FileExtensionValidator(["jpeg", "jpg", "png"])],
    )

    def __str__(self):
        return f"{self.user.username} Profile"

    def save(self, *args, **kwargs):
        if self.image:
            self.image = make_thumbnail(self.image, size=(200, 200))
            super().save(*args, **kwargs)
        else:
            super().save(*args, **kwargs)

But the profile_pics folder keep nesting, so my folder structure starts to look like this:

enter image description here

My variables in settings.py look normal, I believe:

BASE_DIR = Path(__file__).resolve().parent.parent
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
MEDIA_URL = "/media/"

I believe the issue with the nesting folder originates with my save method in my Profile class, specifically this:

def save(self, *args, **kwargs):
    if self.image:
        self.image = make_thumbnail(self.image, size=(200, 200))
        super().save(*args, **kwargs)
    else:
        super().save(*args, **kwargs)

which is triggered by my signals:

@receiver(post_save, sender=User)
def create_profile(sender, instance, created, **kwargs):
    if created:
        Profile.objects.create(user=instance)


@receiver(post_save, sender=User)
def save_profile(sender, instance, **kwargs):
    instance.profile.save()

Why is this nesting the folders?

I'm using the same save method on the blog post images and there the folders don't nest.

What am I missing?

P.S.: In case it helps, this is make_thumbnail:

from io import BytesIO
from django.core.files import File
from PIL import Image

def make_thumbnail(image, size=(600, 600)):
    im = Image.open(image)
    if im.format == "JPEG":
        im.convert("RGB")
        im.thumbnail(size)
        thumb_io = BytesIO()
        im.save(thumb_io, "JPEG", quality=85)
        image = File(thumb_io, name=image.name)
    else:
        im.convert("RGBA")
        im.thumbnail(size)
        thumb_io = BytesIO()
        im.save(thumb_io, "PNG", quality=85)
        image = File(thumb_io, name=image.name)
    return image

Edit:

I incidentally arrived to this solution which is avoiding the main issue, although I'm not sure how efficient it may be:

def save(self, *args, **kwargs):
    if self.image:
        self.image = make_thumbnail(self.image, size=(200, 200))
        image_name = self.image.name
        ext = image_name.split(".")[-1]
        filename = "%s.%s" % (uuid.uuid4(), ext)
        clean_name = os.path.join("", filename)
        self.image.name = clean_name
        super().save(*args, **kwargs)
    else:
        super().save(*args, **kwargs)
3 Answers

There is a thing in django called 'duplicate signals'. This occurs everywhere your project imports the module where you define the signals, because signal registration runs as many times as it is imported.

Maybe you can solve your problem by passing a unique identifier as the dispatch_uid argument to identify your receiver function.

from django.core.signals import request_finished

request_finished.connect(my_callback, dispatch_uid="my_unique_identifier")

Source: https://docs.djangoproject.com/en/3.1/topics/signals/

I bet you wanted to adjust this post to your needs. The author doesn't have a problem, because he's "not reusing what he is saving into".

self.image has attribute name. When you check if it exists (if self.image), it already has a name. Then with each update you keep resizing already resized image, which also keeps adding already existing image name to upload_to path, so with each iteration it does upload_to + self.image.name. But self.image.name is already /profile_pics/....

To resolve this problem, just add is_resized column.

class Profile(models.Model):
    user = models.OneToOneField(User, on_delete=models.CASCADE)
    image = models.ImageField(
        default="default.jpg",
        upload_to="profile_pics/",
        validators=[FileExtensionValidator(["jpeg", "jpg", "png"])],
    )
    is_resized = models.BooleanField(default=False)

    def __str__(self):
        return f"{self.user.username} Profile"

    def save(self, *args, **kwargs):
        if self.image and not self.is_resized:
            self.is_resized = True
            self.image = make_thumbnail(self.image, size=(200, 200))
        super().save(*args, **kwargs)

Just remember to set is_resized to False whenever the image changes.

Side note, signals in general are a bad practice. I also don't think it's a good idea to have two signals that operate on the same objects.

If you really need to have them, consider replacing them with a single signal.

@receiver(post_save, sender=User)
def handle_user_profile(sender, instance, created, **kwargs):
    if created:
        Profile.objects.create(user=instance)
    else:
        instance.profile.save()

Though it'd be better if you run resizing the thumbnail in your view.

You can check if the "path" is already in the image URL.

if self.image:
    if "profile_pics/" not in self.image.url:
        self.image = make_thumbnail(self.image, size=(200, 200))
Related