How Do You Embed Images in a Django Blog From a WYSIWYG Editor?

How to add images to you blog pages using a WYSIWYG editor widget in a blog built with Django.

Background

Getting images into your blog articles can be tricky. More consideration is needed than with text or simple markup. Whereas text and HTML are stored in a model field, your images are objects that live in a file system (server's file system, S3 bucket, etc.) and then referenced by an image tag "src" attribute.

I'm going to approach adding images to a post as a two-step solution: First, the image record is stored in a separate table from the blog article and linked back with a ForeignKey. When the user uploads it, the image is stored on the file system and the image record store's the URL pointing to the resource. And second, the URL generated from step one is inserted into the content of the article as an image tag using a WYSIWYG editor.

If you'd like to follow along locally, clone the demo project and follow the instructions in the README.md.

Image Model

Let's assume we have some basic model Article that we use to write and store blog posts. To store images that will exist in Article content, we'll use the model ContentImage and point back to Article with the ForeignKey "article."

# core.models.py

from django.db import Models


class ContentImage(models.Model):
    article = models.ForeignKey(Article, on_delete=models.CASCADE)
    image = models.ImageField(upload_to='content-images')

First, you need the Pillow library installed to use the Django ImageField. This can be accomplished with pip install pillow. On the ForeignKey "article," the on_delete parameter is set to CASCADE since I don't want images to stick around for articles that no longer exist.

Next, I have my "image" field. The images themselves will be stored in a sub-directory named "content-images." This directory is nested in the MEDIA_ROOT.

# app.settings.py

MEDIA_URL = '/media/'
STATIC_URL = '/static/'

MEDIA_ROOT = os.path.join(BASE_DIR, 'files/media')
STATIC_ROOT = os.path.join(BASE_DIR, 'files/static')

Last, you need to configure Django to be able to serve media files in development.

# app.urls.py

from django.contrib import admin
from django.urls import path
from django.conf.urls.static import static
from django.conf import settings

urlpatterns = [
    path('admin/', admin.site.urls),
]

if settings.DEBUG:  # pragma: no cover
    urlpatterns += static(settings.STATIC_URL,
                          document_root=settings.STATIC_ROOT)
    urlpatterns += static(settings.MEDIA_URL,
                          document_root=settings.MEDIA_ROOT)

Supporting Models

There are three additional models to ContentImage used to demonstrate a more realistic implementation. First and mentioned before, there's the Article model.

# core.models.py

class Article(models.Model):
    title = models.CharField(max_length=100)
    category = models.ForeignKey('core.Category', on_delete=models.PROTECT)
    tags = models.ManyToManyField('core.Tag')
    content = models.TextField(blank=True, null=True)
    published_at = models.DateTimeField(blank=True, null=True)

    def __str__(self):
        return self.title

We're likely going to want to index our content against items like "category" or "tags" to provide context to reader and to have the ability to suggest additional content (check out this article on producing "read next" suggestions: Next Article Suggestion in Django).

class Category(models.Model):
    name = models.CharField(max_length=100)

    def __str__(self):
        return self.name


class Tag(models.Model):
    name = models.CharField(max_length=50)

    def __str__(self):
        return self.name

While these models aren't germane to this discussion, know they exist in the example project. Example data is provided as "fixtures" and can be installed by following the instructions in the example project's README.md file. Furthermore, while these objects don't have their own dedicated views, they're registered with the admin app so you can work with the data there.

Create, Read, Update & Delete (CRUD)

With our ContentImage model, we're going to want to establish views to execute basic CRUD operations. For creations, updates & deletes I used Django Class Based Views and Django Model Forms.

Since I want to associate each image with a particular article to make organization and maintenance easier, the class-based views need to be aware of the article we're working with. For a CreateView, I'll pass as the ID to the view as a URL parameter. The Article ID will be used to retrieve the Article object in the ModelForm.

# demo.urls.py

from django.urls import path

from .views import ContentImageCreateView


urlpatterns = [
    ...
    path('add-image/<int:article>/', ContentImageCreateView.as_view(), name='create-image'),
]


# demo.views.py

from django.views.generic import CreateView
from django.urls import reverse_lazy

from .forms import ContentImageForm


class ContentImageCreateView(CreateView):
    template_name = 'demo/image-form.html'
    form_class = ContentImageForm
    success_url = reverse_lazy('article-list')
    form_title = 'Create Image'

    def get_form_kwargs(self):
        kwargs = super().get_form_kwargs()
        kwargs['article_id'] = self.kwargs.get('article', None)
        return kwargs

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['form_title'] = self.form_title
        return context


# demo.forms.py

from django import forms
from django.shortcuts import get_object_or_404

from core.models import ContentImage, Article


class ContentImageForm(forms.ModelForm):

    class Meta:
        model = ContentImage
        fields = ('image',)

    def __init__(self, *args, **kwargs):
        article_id = kwargs.pop('article_id', None)
        super().__init__(*args, **kwargs)

        self.article_id = article_id

    def clean(self):
        cleaned_data = super().clean()
        if not self.instance.id:
            # new image
            if self.article_id:
                article = get_object_or_404(Article, pk=self.article_id)
                self.instance.article = article
            else:
                raise ImproperlyConfigured("Expected kwarg 'article_id' "
                                           "cannot be none for create view.")
        return cleaned_data

Then for the update view, we need to retrieve the ContentImage object instead of Article. We'll also want to override the get and post methods since we're inheriting from the CreateView to preserve upstream methods. Both methods set self.object to None in CreateView preventing us from successfully setting self.object in our UpdateView equivalent.

# demo.urls.py

urlpatterns = [
    ...
    path('update-image/<int:image>/', ContentImageUpdateView.as_view(),
         name='update-image'),
]

# demo.views.py

# noinspection PyAttributeOutsideInit
class ContentImageUpdateView(ContentImageCreateView):
    model = ContentImage
    pk_url_kwarg = 'image'

    # override get/post to retrieve object (otherwise set to none)

    def get(self, request, *args, **kwargs):
        self.object = self.get_object()
        return self.render_to_response(self.get_context_data())

    def post(self, request, *args, **kwargs):
        self.object = self.get_object()

        form = self.get_form()
        if form.is_valid():
            return self.form_valid(form)
        return self.form_invalid(form)

Updating Or Deleting Images

Interestingly, when you delete or update an image record stored in the table, the actual image file the record points to remains unaffected. This means that overtime orphaned image files will accumulate. We need to eliminate these files since their presence will consume resources and contribute to unnecessary cost.

To purge the files, I'm going to use a custom signal. This isn't the only way to achieve the desired result. There's a package, django-cleanup that promises the help with this exact problem. However, I don't have experience using this package. Instead, I'll implement the required functionality directly.

For my approach, I want to catch two signals: pre_save and pre_deleteThese signals come in both the "pre" and "post" variety. We want to be sure we use the "pre" form so that way we're still able to retrieve the original resource.

In handling deletions, we can simply delete the file directly from the instance passed to the signal. For updates (pre_save), additional logic will be needed. We don't want to delete an image if a field other than the "image" field is changing.

# core.signals.py

from django.db.models.signals import pre_delete, pre_save
from django.dispatch import receiver
from django.shortcuts import get_object_or_404

from core.models import ContentImage


# user deletes image
@receiver(pre_delete, sender=ContentImage)
def delete_content_image(instance, **_kwargs):
    instance.image.delete(save=False)


# user updates image
@receiver(pre_save, sender=ContentImage)
def change_content_image(instance, **_kwargs):
    if instance.id:
        # perform for updates only
        old_image = get_object_or_404(
            ContentImage,
            **{'id': instance.id}
        )
        if old_image.image.url != instance.image.url:
            old_image.image.delete(save=False)

As you can see from the second function, we compare the original object to the image associated with the updated record. If the image URLs aren't equal then we know that the user has updated the image file and we destroy the resource.

Last, this signals file needs to be registered.

# core.apps.py

from django.apps import AppConfig


class CoreConfig(AppConfig):
    default_auto_field = 'django.db.models.BigAutoField'
    name = 'core'
    
    def ready(self):
        # noinspection PyUnresolvedReferences
        from . import signals

Final Thoughts

Images are important to constructing quality content. But, images add a bit of complexity to an app. Generally, it's going to be a two-step process without further effort customizing a WYSIWYG editor. First, you add the image to the system. Then you retrieve the URL and embed this into the content. 

During this process, we'll want to make sure we take the appropriate steps so that we don't orphan files and create unnecessary cost. And with that, we have a complete solution. Is there room for improvement? Absolutely, but the example here will get you up and running.

Source code for the demo project available at Github.

Details
Published
March 25, 2024
Next
June 25, 2025

How To Resize Images On Upload in Django

Resize uploaded images during Django form validation using Pillow to prevent large files from reaching your storage system.