Django

Combine Class Based Views without mixing them

One of the down sides of Django's Class Based Views (CBV) I have experienced is that CBV tend to lock views on templates and drive the developer to conceive all interactions on the same page / view. Because of all the shortcuts inherited by TemplateView, for a while, I struggled designing complex mixin classes based on the somewhat naive assumption that actions on the same object should logically be grouped together. Here is an alternative:

  • easier to code
  • easier to test
  • more modular (for future evolutions)

A simple situation: display client details and the form to edit it

In this example, our main view should should display client information alongside with a form to edit it and her consultations logs. Moreover, we would like the form's initial data to be populated with the client information (pure U.X. considerations). Finally, redirections should include messages to inform the user of the success of the procedure (or, eventually, the reasons for the failure). This would be quite a lot of twisting if we were to put it in a Mixin class and we might expose ourselves to some diamond problem.

Two jobs, two urls, two classes

To divide responsibilities, let's say we would like

  • ClientDetails to be responsible for the information aggregation and display. This calls for a TemplateView or a DetailView
  • ClientEdit will be responsible for the form generation, validation and appending changes to the model's instance. This calls for an UpdateView

But first things first, each view should have their own path

path('client/<int:pk>', ClientDetails.as_view(), name='client_details'),
path('client/<int:pk>/edit', ClientEdit.as_view(), name='client_edit'),

Display information

This is quite standard. TemplateView needs a template_name and get_context_data() can be overridden to include more information to be displayed: in our case, our client's consultations

class ClientDetails(TemplateView):
    template_name = "console/details.html"
    success_url = 'client_details'

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["client"] = Client.objects.get(pk=self.kwargs['pk'])
        context["consultations"] = Consultation.objects.filter(fk_client=self.kwargs['pk'])
        return context

Edit

Here again, nothing fancy about it. UpdateView needs:

  • some model
  • some pk (or slug) to know which instance to update (in our case, this information is provided by the url dispatcher)
  • a template named ${model name}_form
  • success_url (as class or instance attribute) to redirect the users after his request. Here, I choose to redirect him to the page he submitted the form from (i.e. HTTP_REFERER)

With that in mind, here is the class:

class ClientEdit(UpdateView):
    model = Client
    form_class = AddClientForm

    def form_valid(self, form):
        messages.add_message(self.request, messages.SUCCESS,
                             "Successfully updated")
        self.success_url = self.request.META.get('HTTP_REFERER', '/console/clients')
        return super().form_valid(form)

    def form_invalid(self, form):
        for e in form.errors.items():
            messages.add_message(self.request, messages.WARNING,
                                 "Invalid {}: {}".format(e[0], e[1][0]))

And here is client_form.html

<form method="post" action="/console/client/{{client.id}}/edit">{% csrf_token %}
    {{ form }}
    <button class="button is-link is-outlined">Update</button>
</form>

How to properly include ClientEdit forms into ClientDetails rendering?

The answer is in the question: we just need need to add the following in console/details.html

{% include "console/client_form.html" %}

But, as you probably already experienced it, this is not sufficient, for form does not exists in ClientDetails's context. We can add it to get_context_data without bypassing ClientEdit's role with the following trick:

class ClientDetails(TemplateView):
    template_name = "console/details.html"
    success_url = 'client_details'

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["client"] = Client.objects.get(pk=self.kwargs['pk'])
        context["consultations"] = Consultation.objects.filter(fk_client=self.kwargs['pk'])
        context["form"] = ClientEdit().get_form_class()(initial = context["client"].__dict__) ## HERE
        return context

ClientEdit remains the factory class building the form and we are still using its template. ClientDetails is just responsible for adding it to its context and add initial data based on the client displayed.

Messages

console/details.html should include the following to display messages generated by ClientEdit

 {% if messages %}
      <ul class="messages">
        {% for message in messages %}
          <li{% if message.tags %} class="message is-{{ message.tags }}"{% endif %}>
        <div class="message-body">{{ message }}</div>
          </li>
        {% endfor %}
      </ul>
  {% endif %}

How to test this?

A straightforward way to cover the form's initial data would be to analyze the context returned by get_context_data and check that client attributes appears in the form. Like this:

class TestClientDetails(TestCase):
    fixtures = ['clients.yaml','consultation.yaml']

    def setUp(self):
        self.request = RequestFactory().get('/console/client/2')
        self.view = ClientDetails()

    def test_initial_values_of_form_match(self): 
        self.view.setup(self.request, pk=2)
        context = self.view.get_context_data()
        form = context["form"]
        self.assertTrue(context["client"].first_name in str(form))
        self.assertTrue(context["client"].last_name in str(form))
        self.assertTrue(context["client"].phone in str(form))
        self.assertTrue(context["client"].mail in str(form))
        self.assertTrue(context["client"].notes in str(form))