Artem Mezhenin
Artem Mezhenin

Reputation: 5757

How to upload files into BinaryField using FileField widget in Django Admin?

I want to create a model Changelog and make it editable from Admin page. Here is how it is defined in models.py:

class Changelog(models.Model):
    id = models.AutoField(primary_key=True, auto_created=True)
    title = models.TextField()
    description = models.TextField()
    link = models.TextField(null=True, blank=True)
    picture = models.BinaryField(null=True, blank=True)

title and description are required, link and picture are optional. I wanted to keep this model as simple as possible, so I chose BinaryField over FileField. In this case I wouldn't need to worry about separate folder I need to backup, because DB will be self-contained (I don't need to store filename or any other attributes, just image content).

I quickly realized, that Django Admin doesn't have a widget for BinaryField, so I tried to use widget for FileField. Here is what I did to accomplish that (admin.py):

class ChangelogForm(forms.ModelForm):

    picture = forms.FileField(required=False)

    def save(self, commit=True):
        if self.cleaned_data.get('picture') is not None:
            data = self.cleaned_data['picture'].file.read()
            self.instance.picture = data
        return self.instance

    def save_m2m(self):
        # FIXME: this function is required by ModelAdmin, otherwise save process will fail
        pass

    class Meta:
        model = Changelog
        fields = ['title', 'description', 'link', 'picture']


class ChangelogAdmin(admin.ModelAdmin):
    form = ChangelogForm

admin.site.register(Changelog, ChangelogAdmin)

As you can see it is a bit hacky. You also can create you own form field be subclassing forms.FileField, but code would be pretty much the same. It is working fine for me, but now I'm thinking is there are better/standard way to accomplish the same task?

Upvotes: 10

Views: 5503

Answers (5)

KlausCPH
KlausCPH

Reputation: 1835

None of the methods above worked out for my use case of storing a binary .xlsx file in the database. However, by tweaking @joerg's solution like this, it seems to work:

from django import forms
from django.db import models
from django.contrib import admin

class BinaryFieldWithUpload(forms.FileField):
    def to_python(self, data):
        data = super().to_python(data)
        return data.read() if data else None

class BinaryFileInputAdmin(admin.ModelAdmin):
    formfield_overrides = {
        models.BinaryField: {'form_class': BinaryFieldWithUpload},
    }

Upvotes: 1

joerg
joerg

Reputation: 124

For modern Django, I found the following approach works best for me:

class BinaryField(forms.FileField):
    def to_python(self, data):
        data = super().to_python(data)
        if data:
            data = base64.b64encode(data.read()).decode('ascii')
        return data

class BinaryFileInputAdmin(admin.ModelAdmin):
    formfield_overrides = {
        models.BinaryField: {'form_class': BinaryField},
    }

The individual model field still needs editable=True, of course.

Upvotes: 1

Ania Warzecha
Ania Warzecha

Reputation: 1796

A better and more standard way would be to create a Widget for this type of field.

class BinaryFileInput(forms.ClearableFileInput):

    def is_initial(self, value):
        """
        Return whether value is considered to be initial value.
        """
        return bool(value)

    def format_value(self, value):
        """Format the size of the value in the db.

        We can't render it's name or url, but we'd like to give some information
        as to wether this file is not empty/corrupt.
        """
        if self.is_initial(value):
            return f'{len(value)} bytes'


    def value_from_datadict(self, data, files, name):
        """Return the file contents so they can be put in the db."""
        upload = super().value_from_datadict(data, files, name)
        if upload:
            return upload.read()

So instead of subclassing the whole form you would just use the widget where it's needed, e.g. in the following way:

class MyModelAdmin(admin.ModelAdmin):
    formfield_overrides = {
        models.BinaryField: {'widget': BinaryFileInput()},
    }

As you already noticed the code is much the same but this is the right place to put one field to be handled in a specific manner. Effectively you want to change one field's appearance and the way of handling when used in a form, while you don't need to change the whole form.

Update

Since writing that response Django has introduced an editable field on the models and in order to get this working you need to set the model field to editable=True which is false for BinaryField by default.

Upvotes: 5

rpalloni
rpalloni

Reputation: 121

I actually followed @Ania's answer with some workaround since upload.read() was not saving image in the right encoding in Postrgres and image could not be rendered in an HTML template. Furthermore, re-saving the object will clear the binary field due to None value in the uploading field (Change) [this is something that Django handles only for ImageField and FileField] Finally, the clear checkbox was not properly working (data were deleted just because of the previous point, i.e. None in Change).

Here how I changed value_from_datadict() method to solve:

forms.py

class BinaryFileInput(forms.ClearableFileInput):
    # omitted

    def value_from_datadict(self, data, files, name):
    """Return the file contents so they can be put in the db."""
    #print(data)
    if 'image-clear' in data:
        return None
    else:
        upload = super().value_from_datadict(data, files, name)
        if upload:
            binary_file_data = upload.read()
            image_data = base64.b64encode(binary_file_data).decode('utf-8')
            return image_data
        else:
            if YourObject.objects.filter(pk=data['pk']).exists():
                return YourObject.objects.get(pk=data['pk']).get_image
            else:
                return None

Then I defined the field as a BinaryField() in models and retrieved the image data for the frontend with a @property:

models.py

image = models.BinaryField(verbose_name='Image', blank = True, null = True, editable=True) # editable in admin

@property
def get_image(self):
    '''
    store the image in Postgres as encoded string 
    then display that image in template using 
    <img src="data:image/<IMAGE_TYPE>;base64,<BASE64_ENCODED_IMAGE>">
    '''
    image_data = base64.b64encode(self.image).decode('utf-8')
    return image_data

And finally is rendered in the template with:

yourtemplate.html

<img src="data:image/jpg;base64,{{object.get_image}}" alt="photo">

Upvotes: 1

spookylukey
spookylukey

Reputation: 6576

An alternative solution is to create a file storage backend that actually saves the binary data, along with file name and type, in a BinaryField in the database. This allows you to stay within the paradigm of FileField, and have the metadata if you need it.

This would be a lot more work if you had to do it yourself, but it is already done in the form of db_file_storage.

Upvotes: 1

Related Questions