14. Creating Forms from Models

Just as in Django, forms can be created from models and rendered by django-formset.

Say, we use a model similar to that described in the Django documentation, ie. myapp.models.Article:

models.py
from django.db import models

class Reporter(models.Model):
    full_name = models.CharField(max_length=70)

class Article(models.Model):
    pub_date = models.DateField()
    headline = models.CharField(max_length=200)
    content = models.TextField()
    reporter = models.ForeignKey(Reporter, on_delete=models.CASCADE)
    teaser = models.FileField(
        upload_to='images',
        blank=True,
        help_text="Maximum file size for uploading is 1MB",
    )

We then use that model to create a form class. There however is a caveat: Some Django widgets used for rendering HTML fields shall or must be replaced by alternative widgets offered by the django-formset-library. This is because, compared to their pure HTML counterpart, they greatly enhance the user experience functionality of many input elements. When using a FileField, the widget must be replaced, because an asynchronous file upload otherwise wouldn’t work.

More on this topic can be found in Alternative Widgets.

The form to edit the above model Article shall optionally override field reporter which contains more than 900 entries and therefore is user unfriendly to be rendered by a HTML <select>-element. It also must override field teaser which is used to accept uploaded files. The widget for field pub_date shall be replaced by a DateInput, because that enforces a client-side validation of inputted dates. The field content is overridden by the widget RichtextArea, allowing to format the text using various styles.

forms.py
from django.forms.models import ModelForm
from formset.widgets import DateInput, Selectize, UploadedFileInput
from formset.widgets.richtext import RichTextarea
from testapp.models.article import Article

class ArticleForm(ModelForm):
    class Meta:
        model = Article
        fields = ['pub_date', 'headline', 'content', 'reporter', 'teaser']
        widgets = {
            'pub_date': DateInput,
            'content': RichTextarea,
            'reporter': Selectize(search_lookup='full_name__icontains'),
            'teaser': UploadedFileInput,
        }

14.1. Extra Meta options

Django’s ModelForm class offers a few options to customize the form’s behavior. The Meta option class is used for anything that’s not part of the fields themselves and can be used to override some of the field’s behavior. In addition to the options provided by Django itself, django-formset adds two more options to the Meta-class of a formset.form.ModelForm, namely disabled_fields and fields_map. The latter is explained in detail in section Fields Mapping. The option disabled_fields is an optional list of field names, which can be used to disable the named model fields. Those fields then are mapped to form fields, containing the HTML property disabled so that they are visible but not editable by the user.

14.2. Detail View for ModelForm

To display and validate data from this Article, we use the well known detail view offered by Django for updating models:

views.py
from django.views.generic import UpdateView
from formset.upload import FileUploadMixin
from formset.views import FormViewMixin, IncompleteSelectResponseMixin

class ArticleEditView(FileUploadMixin, IncompleteSelectResponseMixin, FormViewMixin, UpdateView):
    model = Article
    form_class = ArticleForm
    template_name = 'form.html'
    success_url = '/success'

Drag file here

Note

After submission, the content of these form fields is stored in the database. Therefore after reloading this page, the same content will reappear in the form.

This view class inherits from UpdateView, which is responsible for displaying the form and validating the submitted data, just as we would do it using a classic Django view class. The other three mixin classes serve the following purposes:

Add class formset.views.FormViewMixin to a view class inheriting from one of the Django form view classes. It is required to respond with a JsonResponse rather than a HttpResponse whenever a form submission validates or fails.

The ArticleForm uses an incomplete Selectize widget. This means that the client must fetch additional data from the server, whenever the user makes a lookup. In order to do that, the already existing endpoint for the form submission is used. The class formset.views.IncompleteSelectResponseMixin intercepts these fetch requests, and forwards them to the widget implementing the Selectize widget. By doing so, we don’t have to specify any additional endpoint for these lookups.

The ArticleForm implements a file upload field. File uploads are handled asynchronous, which means that the payload is uploaded before the form is submitted. The class formset.views.FileUploadMixin intercepts these file uploads, stores them to a temporary location and returns a signed handle, so that whenever the form is submitted, that file can be moved to its final destination.

14.3. Complete CRUD View

In a CRUD application, we usually add a Django View to add, update and delete an instance of our model. The Django documentation proposes to create one view for each of these tasks, a CreateView, an UpdateView and a DeleteView and add routes to each of them using the URL patterns.

With django-formset we instead can combine them into one view class. This is because we can add extra context data to the form’s control buttons. This additional data then is submitted together with the form’s payload and can be used to distinguish between create, update and delete.

As an example let’s use a simpler model, offering just one editable field:

models.py
class Annotation(models.Model):
    content = models.CharField(max_length=200)

The form and view classes required to edit this model then may look something like this:

views.py
from django.http.response import JsonResponse
from testapp.models.annotation import Annotation

class AnnotationForm(ModelForm):
    class Meta:
        model = Annotation
        fields = '__all__'

class AnnotationEditView(FormViewMixin, UpdateView):
    model = Annotation
    form_class = AnnotationForm
    template_name = 'crud-form.html'
    success_url = '/success'

    def get_context_data(self, **kwargs):
        context_data = super().get_context_data(**kwargs)
        if self.object:
            context_data['change'] = True
        else:
            context_data['add'] = True
        return context_data

    def form_valid(self, form):
        if extra_data := self.get_extra_data():
            if extra_data.get('add') is True:
                form.instance.save()
            if extra_data.get('delete') is True:
                form.instance.delete()
                return JsonResponse({'success_url': self.get_success_url()})
        return super().form_valid(form)

In method get_context_data we determine, whether a new object shall be added or an existing object shall be changed. This context data then is added to the rendering context and the view then is rendered by a template with button settings, depending on these values:

crud-form.html
<django-formset endpoint="{{ request.path }}" csrf-token="{{ csrf_token }}">
  {% render_form form %}
  {% if add %}
    <button type="button" df-click="submit({add: true}) -> proceed">{% trans "Add" %}</button>
  {% else %}
    <button type="button" df-click="submit({update: true}) -> proceed">{% trans "Update" %}</button>
    <button type="button" df-click="submit({delete: true}) -> proceed">{% trans "Delete" %}</button>
  {% endif %}
</django-formset>

Method form_valid is called by Django, after a form has been validated in order to save its cleaned data. Here we examine the extra data submitted together with the form’s payload. In the form template from above, the submit buttons “Add”, “Update” and “Delete” do pass extra data together with the submitted form data, using the submit() action when the corresponding button is clicked. We use that extra information in our view to distinguish between creating, updating or deleting an instance.

In a real world application, this above example is oversimplified. Normally, one has to distinguish between an add view and various details views using a unique key as identifier. If the above view would be connected to a URL router, the patterns may be defined as:

urlpatterns = [
    ...
    path('', AnnotationEditView.as_view(),  # list view not handled here
        name='list-annotation'
    ),
    path('add/', AnnotationEditView.as_view(extra_context={'add': True}),
        name='add-annotation',
    ),
    path('<int:pk>/', AnnotationEditView.as_view(extra_context={'change': True}),
        name='change-annotation',
    ),
    ...
]

In the view class itself, the two methods get_object() and get_success_url() must be adopted as well. Here it’s up to the developer to decide how the workflow should look like, after an object has been successfully saved.

class AnnotationEditView(FormViewMixin, UpdateView):
    ...
    extra_context = None

    def get_object(self, queryset=None):
        if queryset is None:
            queryset = self.get_queryset()
        # use `querset` and `self.form_kwargs` to find the object to change
        ...

    def get_success_url():
        if extra_data := self.get_extra_data():
            # use `extra_data` to determine the success_url
            ...

In a real world application, please remember to check if the current user has proper add-, change- and delete permissions. The Django views running inside this documentation use the session-ID to assign saved objects to their users.

Note

The list view is not handled explicitly here, because it doesn’t differ compared to a classic Django view.