Search code examples
pythondjangodjango-modelsimagefield

django set image delete old reference and prevent delete default


despite of many mordern website employ an OSS for serving image, I still want to build a backend to manage small thumbnails locally.

however, django image field is a bit tricky.

there are three views I may change image reference:

  • models.py
  • views.py
  • forms.py

I used to do it simply by:

forms.py

request.user.profile.image = self.files['image']

and I always have a default

models.py

class Profile(models.Model):
    user = models.OneToOneField(User, on_delete=models.CASCADE)
    image = ProcessedImageField(
        default='profile/default.jpg', 
        upload_to='profile', 
        processors=[ResizeToFill(*THUMBNAIL_SIZE)],
        format='JPEG',
        options={'quality': THUMBNAIL_QUALITY}, 
    )

After a lot of testing, I found that it always result in an issue, can be among:

  • default image file get deleted.

  • if the image has been set before, it holds an value that is not the default, when I reset it, the old referenced file is not deleted and will occupy disk storage.

to do it perfectly, I decided to write a global function for import, whenever I want to set an image, call it

from django.conf import settings

def setImage(instance, attr, file):
    """ instance will be saved """
    if file:
        ifield = getattr(instance, attr)
        # old reference, can be default
        iurl = ifield.url
        # default
        durl = settings.MEDIA_URL + instance._meta.get_field(attr).get_default()
        if iurl != durl:
            # old reference is not default
            # delete old reference and free up space
            ifield.delete(save=True)
        # set new file 
        setattr(ifield, attr, file)
    instance.save()

pretty straight-forward. however, in testing, I found the image will never be set. Following are the possibale reasons I have eliminated:

  • form multipart enctype attribute
  • ajax processData, contentType is correctly set
  • save in model class is not overriden

if it's all ok, where went wrong? I logged out all the values.

setImage(self.user.profile, 'image', self.files['image'])
# self.files['image'] has valid value and is passed 
# to setImage, which I think, is not garbage collected
def setImage(instance, attr, file):
    """ instance will be saved """
    print('======')
    print(file)
    if file:
        ifield = getattr(instance, attr)
        iurl = ifield.url
        durl = settings.MEDIA_URL + instance._meta.get_field(attr).get_default()
        print(iurl)
        print(durl)
        if iurl != durl:
            ifield.delete(save=True)
            print(f'--- del {iurl}')
        setattr(ifield, attr, file)
        print('----res')
        print(getattr(ifield, attr))
        print(ifield.image)
    print('--- ins')
    print(instance)
    instance.save()
    print('--- after save')
    print(instance.image.url)
    print(getattr(instance, attr))

the field has a default value, and I upload the screen shot in testing.

======
Screen Shot 2022-11-03 at 10.59.41 pm.png
/media/profile/default.jpg
/media/profile/default.jpg
----res
Screen Shot 2022-11-03 at 10.59.41 pm.png
Screen Shot 2022-11-03 at 10.59.41 pm.png
--- ins
tracey
--- after save
/media/profile/default.jpg
profile/default.jpg

why the image is not setting, anybody have any ideas?


Solutions

after tons of testing, only a line went wrong and just simple a wrong variable.

def setImage(instance, attr, file):
    """ instance will be saved """
    if file:
        ifield = getattr(instance, attr)
        iurl = ifield.url
        durl = settings.MEDIA_URL + instance._meta.get_field(attr).get_default()
        if iurl != durl:
            ifield.delete(save=True)
        setattr(instance, attr, file)
        instance.save()

for form update validation, here's a method I've worked out and testified working:


def clean_image(form, attr:str, field:str):
    """ 
    form.instance.attr -> ImageFile
    form.cleaned_data[field] -> ImageFile
    """
    upload_img = form.cleaned_data[field]
    if form.instance:
        # condition: update
        # create does not matter 
        ifield = getattr(form.instance, attr)
        iurl = ifield.url
        if isinstance(upload_img, InMemoryUploadedFile):
            # upload new file
            # else, the file is not changed
            durl = settings.MEDIA_URL + form.instance._meta.get_field(attr).get_default()
            if iurl != durl:
                ifield.delete(save=True)
    return upload_img

e.g. you can call it simply like:

class Project(models.Model):
    image = ProcessedImageField(
        default='projects/default.jpg', 
        upload_to='projects', 
        processors=[ResizeToFill(*THUMBNAIL_SIZE)],
        options={'quality': THUMBNAIL_QUALITY}, 
        format='JPEG',
    )

class ProjectCreateForm(forms.ModelForm):
    class Meta:
        model = Project
        fields = ['name', 'image']
    
    def clean_image(self):
        return clean_image(self, 'image', 'image')

Solution

  • Honestly, I did not test your function to say what is wrong with it. Instead I implemented it in my own way taking your model as a base. If you really want a set_image(instance, attr, file) function, you can adapt it from this answer create_profile_ajax at views.py.

    settings.py

    DEFAULT_IMAGE_URL = 'profile/default.jpg'
    

    models.py

    class Profile(models.Model):
        user = models.OneToOneField(User, on_delete=models.CASCADE)
        image = ProcessedImageField(
            default=settings.DEFAULT_IMAGE_URL,
            upload_to='avatars',
            processors=[ResizeToFill(100, 50)],
            format='JPEG',
            options={'quality': 60},
            blank=True,
            null=True
        )
    
        @staticmethod
        def default_image_absolute_url():
            return settings.MEDIA_URL + settings.DEFAULT_IMAGE_URL
        
        @staticmethod
        def default_image_url():
            return settings.DEFAULT_IMAGE_URL
    

    forms.py

    class ProfileForm(forms.ModelForm):
    
        class Meta:
            model = Profile
            fields = ['image']
    

    views.py

    @login_required
    def create_profile(request):
        form = ProfileForm()
        return render(request, 'profile/create.html', {'form': form})
    
    
    def create_profile_ajax(request):
        image = request.FILES.get('image')
        profile, created = Profile.objects.get_or_create(user=request.user)
    
        if image:
            if profile.image.url == Profile.default_image_absolute_url():
                profile.image = image
            else:
                profile.image.delete()
                profile.image = image
        else:
            profile.image = Profile.default_image_url()
        
        profile.save()
        profile.refresh_from_db()
    
        return JsonResponse({'new_image_url': profile.image.url})
    

    profile/create.html (csrf with ajax)

    {% extends 'base.html' %}
    
    {% block content %}
    {{form.as_p}}
    <input type="submit" value="Create" onclick="sendProfile()">
    
    <img src="{{request.user.profile.image.url}}" 
        id="thumb" 
        width="500" 
        height="600" 
        {% if not request.user.profile.image %} hidden {% endif %} 
        style="object-fit: contain;">
    
    
    <script>
        function getCookie(name) {
            ...
        }
    
        function sendProfile() {
            const csrftoken = getCookie('csrftoken');
            var input = document.getElementById('id_image');
            var data = new FormData()
            data.append('image', input.files[0])
    
            fetch('/your/ajax/url/', {
            method: 'POST',
            headers: {
                'X-CSRFToken': csrftoken
            },
            body: data
            })
            .then((response) => response.json())
            .then((data) => {
                var thumb = document.getElementById('thumb');
                thumb.src = data.new_image_url;
                thumb.hidden = false;
                input.value = '';
            });
        }
    </script>
    {% endblock %}
    

    quotting FormData documentation:

    When using FormData to submit POST requests using XMLHttpRequest or the Fetch_API with the multipart/form-data Content-Type (e.g. when uploading Files and Blobs to the server), do not explicitly set the Content-Type header on the request.