buy the book ribbon

A Simple Form

🚧 Warning, Chapter update in progress

This chapter is currently in the process of being rewritten for the 3e.

The code listings should all be valid, and work with Python3.12 + Django 4, but I haven’t reviewed the chapter text in detail yet.

At the end of the last chapter, we were left with the thought that there was too much duplication in the validation handling bits of our views. Django encourages you to use form classes to do the work of validating user input, and choosing what error messages to display. Let’s see how that works.

As we go through the chapter, we’ll also spend a bit of time tidying up our unit tests, and making sure each of them tests only one thing at a time.

Moving Validation Logic into a Form

In Django, a complex view is a code smell. Could some of that logic be pushed out to a form? Or to some custom methods on the model class? Or (perhaps best of all) to a non-Django module that represents your business logic?

Forms have several superpowers in Django:

  • They can process user input and validate it for errors.

  • They can be used in templates to render HTML input elements, and error messages too.

  • And, as we’ll see later, some of them can even save data to the database for you.

You don’t have to use all three form superpowers in every form. You may prefer to roll your own HTML, or do your own saving. But they are an excellent place to keep validation logic.

Exploring the Forms API with a Unit Test

Let’s do a little experimenting with forms by using a unit test. My plan is to iterate towards a complete solution, and hopefully introduce forms gradually enough that they’ll make sense if you’ve never seen them before.

First we add a new file for our form unit tests, and we start with a test that just looks at the form HTML:

src/lists/tests/test_forms.py
from django.test import TestCase

from lists.forms import ItemForm


class ItemFormTest(TestCase):

    def test_form_renders_item_text_input(self):
        form = ItemForm()
        self.fail(form.as_p())

form.as_p() renders the form as HTML. This unit test is using a self.fail for some exploratory coding. You could just as easily use a manage.py shell session, although you’d need to keep reloading your code for each change.

Let’s make a minimal form. It inherits from the base Form class, and has a single field called item_text:

src/lists/forms.py
from django import forms

class ItemForm(forms.Form):
    item_text = forms.CharField()

We now see a failure message which tells us what the autogenerated form HTML will look like:

AssertionError: <p>
    <label for="id_item_text">Item text:</label>
    <input type="text" name="item_text" required id="id_item_text">
[...]
  </p>

It’s already pretty close to what we have in base.html. We’re missing the placeholder attribute and the Bootstrap CSS classes. Let’s make our unit test into a test for that:

src/lists/tests/test_forms.py
class ItemFormTest(TestCase):

    def test_form_item_input_has_placeholder_and_css_classes(self):
        form = ItemForm()
        self.assertIn('placeholder="Enter a to-do item"', form.as_p())
        self.assertIn('class="form-control form-control-lg"', form.as_p())

That gives us a fail which justifies some real coding. How can we customise the input for a form field? Using a "widget". Here it is with just the placeholder:

src/lists/forms.py
class ItemForm(forms.Form):
    item_text = forms.CharField(
        widget=forms.widgets.TextInput(
            attrs={
                "placeholder": "Enter a to-do item",
            }
        ),
    )

That gives:

AssertionError: 'class="form-control form-control-lg"' not found in '<p>\n
<label for="id_item_text">Item text:</label>\n    <input type="text"
name="item_text" placeholder="Enter a to-do item" required id="id_item_text">\n
\n    \n      \n    \n  </p>'

And then:

src/lists/forms.py
        widget=forms.fields.TextInput(
            attrs={
                "placeholder": "Enter a to-do item",
                "class": "form-control input-lg",
            }
        ),
Doing this sort of widget customisation would get tedious if we had a much larger, more complex form. Check out django-crispy-forms and django-floppyforms for some help.
Development-Driven Tests: Using Unit Tests for Exploratory Coding

Does this feel a bit like development-driven tests? That’s OK, now and again.

When you’re exploring a new API, you’re absolutely allowed to mess about with it for a while before you get back to rigorous TDD. You might use the interactive console, or write some exploratory code (but you have to promise the Testing Goat that you’ll throw it away and rewrite it properly later).

Here we’re actually using a unit test as a way of experimenting with the forms API. It’s actually a pretty good way of learning how it works.

Switching to a Django ModelForm

What’s next? We want our form to reuse the validation code that we’ve already defined on our model. Django provides a special class which can autogenerate a form for a model, called ModelForm. As you’ll see, it’s configured using a special attribute called Meta:

src/lists/forms.py
from django import forms

from lists.models import Item

class ItemForm(forms.models.ModelForm):

    class Meta:
        model = Item
        fields = ("text",)

In Meta we specify which model the form is for, and which fields we want it to use.

ModelForms do all sorts of smart stuff, like assigning sensible HTML form input types to different types of field, and applying default validation. Check out the docs for more info.

We now have some different-looking form HTML:

AssertionError: 'placeholder="Enter a to-do item"' not found in '<p>\n
<label for="id_text">Text:</label>\n    <textarea name="text" cols="40"
rows="10" required id="id_text">\n</textarea>\n    \n    \n      \n    \n
</p>'

It’s lost our placeholder and CSS class. But you can also see that it’s using name="text" instead of name="item_text". We can probably live with that. But it’s using a textarea instead of a normal input, and that’s not the UI we want for our app. Thankfully, you can override widgets for ModelForm fields, similarly to the way we did it with the normal form:

src/lists/forms.py
class ItemForm(forms.models.ModelForm):
    class Meta:
        model = Item
        fields = ("text",)
        widgets = {
            "text": forms.widgets.TextInput(
                attrs={
                    "placeholder": "Enter a to-do item",
                    "class": "form-control form-control-lg",
                }
            ),
        }

That gets the test passing.

Testing and Customising Form Validation

Now let’s see if the ModelForm has picked up the same validation rules which we defined on the model. We’ll also learn how to pass data into the form, as if it came from the user:

src/lists/tests/test_forms.py (ch11l008)
    def test_form_validation_for_blank_items(self):
        form = ItemForm(data={"text": ""})
        form.save()

That gives us:

ValueError: The Item could not be created because the data didn't validate.

Good: the form won’t allow you to save if you give it an empty item text.

Now let’s see if we can get it to use the specific error message that we want. The API for checking form validation before we try to save any data is a function called is_valid:

src/lists/tests/test_forms.py (ch11l009)
def test_form_validation_for_blank_items(self):
    form = ItemForm(data={"text": ""})
    self.assertFalse(form.is_valid())
    self.assertEqual(form.errors["text"], ["You can't have an empty list item"])

Calling form.is_valid() returns True or False, but it also has the side effect of validating the input data, and populating the errors attribute. It’s a dictionary mapping the names of fields to lists of errors for those fields (it’s possible for a field to have more than one error).

That gives us:

AssertionError: ['This field is required.'] != ["You can't have an empty list
item"]

Django already has a default error message that we could present to the user—​you might use it if you were in a hurry to build your web app, but we care enough to make our message special. Customising it means changing error_messages, another Meta variable:

src/lists/forms.py (ch11l010)
    class Meta:
        model = Item
        fields = ("text",)
        widgets = {
            "text": forms.widgets.TextInput(
                attrs={
                    "placeholder": "Enter a to-do item",
                    "class": "form-control form-control-lg",
                }
            ),
        }
        error_messages = {"text": {"required": "You can't have an empty list item"}}
OK

You know what would be even better than messing about with all these error strings? Having a constant:

src/lists/forms.py (ch11l011)
EMPTY_ITEM_ERROR = "You can't have an empty list item"
[...]

        error_messages = {"text": {"required": EMPTY_ITEM_ERROR}}

Rerun the tests to see that they pass…​OK. Now we change the test:

src/lists/tests/test_forms.py (ch11l012)
from lists.forms import EMPTY_ITEM_ERROR, ItemForm
[...]

    def test_form_validation_for_blank_items(self):
        form = ItemForm(data={"text": ""})
        self.assertFalse(form.is_valid())
        self.assertEqual(form.errors["text"], [EMPTY_ITEM_ERROR])

And the tests still pass:

OK

Great. Totes committable:

$ git status # should show forms.py and test_forms.py
$ git add src/lists
$ git commit -m "new form for list items"

Using the Form in Our Views

I had originally thought to extend this form to capture uniqueness validation as well as empty-item validation. But there’s a sort of corollary to the "deploy as early as possible" lean methodology, which is "merge code as early as possible". In other words: while building this bit of forms code, it would be easy to go on for ages, adding more and more functionality to the form—​I should know, because that’s exactly what I did during the drafting of this chapter, and I ended up doing all sorts of work making an all-singing, all-dancing form class before I realised it wouldn’t really work for our most basic use case.

So, instead, try to use your new bit of code as soon as possible. This makes sure you never have unused bits of code lying around, and that you start checking your code against "the real world" as soon as possible.

We have a form class which can render some HTML and do validation of at least one kind of error—​let’s start using it! We should be able to use it in our base.html template, and so in all of our views.

Using the Form in a View with a GET Request

Let’s start in our unit tests for the home view. We’ll add a new method that checks whether we’re using the right kind of form:

src/lists/tests/test_views.py (ch11l013)
from lists.forms import ItemForm
[...]

class HomePageTest(TestCase):
    def test_uses_home_template(self):
        [...]

    def test_home_page_uses_item_form(self):
        response = self.client.get("/")
        self.assertIsInstance(response.context["form"], ItemForm)  (1)
1 assertIsInstance checks that our form is of the correct class.

That gives us:

KeyError: 'form'

So we use the form in our home page view:

src/lists/views.py (ch11l014)
[...]
from lists.forms import ItemForm
from lists.models import Item, List


def home_page(request):
    return render(request, "home.html", {"form": ItemForm()})

OK, now let’s try using it in the template—​we replace the old <input ..> with {{ form.text }}:

src/lists/templates/base.html (ch11l015)
  <form method="POST" action="{% block form_action %}{% endblock %}" >
    {{ form.text }}
    {% csrf_token %}
    {% if error %}
      <div class="invalid-feedback">{{ error }}</div>
    {% endif %}
  </form>

{{ form.text }} renders just the HTML input for the text field of the form.

A Big Find and Replace

One thing we have done, though, is changed our form—​it no longer uses the same id and name attributes. You’ll see if we run our functional tests that they fail the first time they try to find the input box:

selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: [id="id_new_item"]; [...]

We’ll need to fix this, and it’s going to involve a big find and replace. Before we do that, let’s do a commit, to keep the rename separate from the logic change:

$ git diff # review changes in base.html, views.py and its tests
$ git commit -am "use new form in home_page, simplify tests. NB breaks stuff"

Let’s fix the functional tests. A quick grep shows us there are several places where we’re using id_new_item:

$ grep id_new_item src/functional_tests/test*
src/functional_tests/test_layout_and_styling.py:        inputbox =
self.browser.find_element(By.ID, "id_new_item")
src/functional_tests/test_layout_and_styling.py:        inputbox =
self.browser.find_element(By.ID, "id_new_item")
src/functional_tests/test_list_item_validation.py:
self.browser.find_element(By.ID, "id_new_item").send_keys(Keys.ENTER)
[...]

That’s a good call for a refactor. Let’s make a new helper method in base.py:

src/functional_tests/base.py (ch11l018)
class FunctionalTest(StaticLiveServerTestCase):
    [...]
    def get_item_input_box(self):
        return self.browser.find_element(By.ID, "id_text")

And then we use it throughout—​I had to make four changes in test_simple_list_creation.py, two in test_layout_and_styling.py, and six in test_list_item_validation.py, for example:

src/functional_tests/test_simple_list_creation.py
    # She is invited to enter a to-do item straight away
    inputbox = self.get_item_input_box()

Or:

src/functional_tests/test_list_item_validation.py
    # an empty list item. She hits Enter on the empty input box
    self.browser.get(self.live_server_url)
    self.get_item_input_box().send_keys(Keys.ENTER)

I won’t show you every single one; I’m sure you can manage this for yourself! You can redo the grep to check that you’ve caught them all.

We’re past the first step, but now we have to bring the rest of the application code in line with the change. We need to find any occurrences of the old id (id_new_item) and name (item_text) and replace them too, with id_text and text, respectively:

$ grep -r id_new_item src/lists/

Good, there are no references to id_new_item left. What about name/ item_text?

$ grep -Ir item_text src/lists
src/lists/views.py:    item = Item(text=request.POST["item_text"], list=nulist)
src/lists/views.py:            item = Item(text=request.POST["item_text"],
list=our_list)
src/lists/migrations/0003_list.py:        ("lists", "0002_item_text"),
src/lists/tests/test_views.py:        response = self.client.post("/lists/new",
data={"item_text": ""})
src/lists/tests/test_views.py:        self.client.post("/lists/new",
[...]
src/lists/tests/test_views.py:            data={"item_text": ""},

We can ignore the migration which is just using item_text as metadata. So the changes we need to make are all in views.py and test_views.py.

We can go ahead and make those. Once we’re done, we rerun the unit tests to check that everything still works:

$ python src/manage.py test lists
[...]
Ran 17 tests in 0.126s

OK

And the functional tests too:

$ python src/manage.py test functional_tests
[...]
  File "...goat-book/src/functional_tests/test_layout_and_styling.py", line 27,
in test_layout_and_styling
    inputbox = self.get_item_input_box()
               ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "...goat-book/src/functional_tests/base.py", line 45, in
get_item_input_box
    return self.browser.find_element(By.ID, "id_text")
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
[...]
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: [id="id_text"]; [...]

[...]
FAILED (errors=3)

Not quite! Let’s look at where this is happening—​if you check the line number from one of the failures, you’ll see that each time after we’ve submitted a first item, the input box has disappeared from the lists page.

Checking views.py and the new_list view we can see it’s because if we detect a validation error, we’re not actually passing the form to the home.html template:

src/lists/views.py
except ValidationError:
    nulist.delete()
    error = "You can't have an empty list item"
    return render(request, "home.html", {"error": error})

We’ll want to use the form in this view too. Before we make any more changes though, let’s do a commit:

$ git status
$ git commit -am "rename all item input ids and names. still broken"

Using the Form in a View That Takes POST Requests

Now we want to adjust the unit tests for the new_list view, especially the one that deals with validation. Let’s take a look at it now:

src/lists/tests/test_views.py
class NewListTest(TestCase):
    [...]

    def test_validation_errors_are_sent_back_to_home_page_template(self):
        response = self.client.post("/lists/new", data={"text": ""})
        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed(response, "home.html")
        expected_error = escape("You can't have an empty list item")
        self.assertContains(response, expected_error)

Let’s add a check that we send our form to the template. While we’re at it, we’ll use our constant instead of the hardcoded string for that error message:

src/lists/tests/test_views.py (ch14l023)
from lists.forms import ItemForm, EMPTY_ITEM_ERROR
[...]

class NewListTest(TestCase):
    [...]

    def test_validation_errors_are_sent_back_to_home_page_template(self):
        response = self.client.post("/lists/new", data={"text": ""})
        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed(response, "home.html")
        self.assertIsInstance(response.context["form"], ItemForm)
        self.assertContains(response, escape(EMPTY_ITEM_ERROR))

We get an expected failure:

$ python src/manage.py test lists
[...]
    self.assertIsInstance(response.context["form"], ItemForm)
                          ~~~~~~~~~~~~~~~~^^^^^^^^
[...]
KeyError: 'form'

And here’s how we use the form in the view:

src/lists/views.py (ch14l024)
def new_list(request):
    form = ItemForm(data=request.POST)  (1)
    if form.is_valid():  (2)
        nulist = List.objects.create()
        Item.objects.create(text=request.POST["text"], list=nulist)
        return redirect(nulist)
    else:
        return render(request, "home.html", {"form": form})  (3)
1 We pass the request.POST data into the form’s constructor.
2 We use form.is_valid() to determine whether this is a good or a bad submission.
3 In the invalid case, we pass the form down to the template, instead of our hardcoded error string.

That view is now looking much nicer!

But, we have a regression:

    self.assertContains(response, escape(EMPTY_ITEM_ERROR))
[...]
AssertionError: False is not true : Couldn't find 'You can&#x27;t have an empty
list item' in response

Using the Form to Display Errors in the Template

We’re failing because we’re not yet using the form to display errors in the template:

src/lists/templates/base.html (ch14l026)
  <form method="POST" action="{% block form_action %}{% endblock %}" >
    {{ form.text }}
    {% csrf_token %}
    {% if form.errors %}  (1)
      <div class="invalid-feedback">{{ form.errors.text }}</div>  (2)
    {% endif %}
  </form>
1 form.errors contains a list of all the errors for the form.
2 form.errors.text is magical django template syntax for form.errors["text"], ie the list of errors for the text field in particular

What does that do to our tests?

======================================================================
FAIL: test_validation_errors_end_up_on_lists_page (lists.tests.test_views.ListV
iewTest.test_validation_errors_end_up_on_lists_page)
 ---------------------------------------------------------------------
[...]
AssertionError: False is not true : Couldn't find 'You can&#x27;t have an empty
list item' in response

An unexpected failure—​it’s actually in the tests for our final view, view_list. Because we’ve changed the way errors are displayed in all templates, we’re no longer showing the error that we manually pass into the template.

That means we’re going to need to rework view_list as well, before we can get back to a working state.

Using the Form in the Other View

This view handles both GET and POST requests. Let’s start with checking that the form is used in GET requests. We can have a new test for that:

src/lists/tests/test_views.py (ch14l027)
class ListViewTest(TestCase):
    [...]

    def test_displays_item_form(self):
        mylist = List.objects.create()
        response = self.client.get(f"/lists/{mylist.id}/")
        self.assertIsInstance(response.context["form"], ItemForm)
        self.assertContains(response, 'name="text"')

That gives:

KeyError: 'form'

Here’s a minimal implementation. We initialise our ItemForm() at the end of the view, and change the render() call so it passes "form" to the template:

src/lists/views.py (ch14l028)
def view_list(request, list_id):
    [...]
    form = ItemForm()
    return render(
        request,
        "list.html",
        {"list": our_list, "form": form, "error": error},
    )

A Helper Method for Several Short Tests

Next we want to use the form errors in the second view. We’ll split our current single test for the invalid case (test_validation_errors_end_up_on_lists_page) into several separate ones:

src/lists/tests/test_views.py (ch14l030)
class ListViewTest(TestCase):
    [...]

    def post_invalid_input(self):
        mylist = List.objects.create()
        return self.client.post(
            f"/lists/{mylist.id}/",
            data={"text": ""},
        )

    def test_for_invalid_input_nothing_saved_to_db(self):
        self.post_invalid_input()
        self.assertEqual(Item.objects.count(), 0)

    def test_for_invalid_input_renders_list_template(self):
        response = self.post_invalid_input()
        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed(response, "list.html")

    def test_for_invalid_input_passes_form_to_template(self):
        response = self.post_invalid_input()
        self.assertIsInstance(response.context["form"], ItemForm)

    def test_for_invalid_input_shows_error_on_page(self):
        response = self.post_invalid_input()
        self.assertContains(response, escape(EMPTY_ITEM_ERROR))

By making a little helper function, post_invalid_input(), we can make four separate tests without duplicating lots of lines of code.

We’ve seen this several times now. It often feels more natural to write view tests as a single, monolithic block of assertions—​the view should do this and this and this, then return that with this. But breaking things out into multiple tests is often worthwhile; as we saw in previous chapters, it helps you isolate the exact problem you have when you later accidentally introduce a bug. Helper methods are one of the tools that lower the psychological barrier, by reducing boilerplate and keeping the tests readable.

For example, now we can see there’s just one failure, and it’s a clear one:

======================================================================
FAIL: test_for_invalid_input_shows_error_on_page (lists.tests.test_views.ListVi
ewTest.test_for_invalid_input_shows_error_on_page)
 ---------------------------------------------------------------------
[...]
AssertionError: False is not true : Couldn't find 'You can&#x27;t have an empty
list item' in response

Now let’s see if we can properly rewrite the view to use our form. Here’s a first cut:

src/lists/views.py (ch14l031)
def view_list(request, list_id):
    our_list = List.objects.get(id=list_id)
    if request.method == "POST":
        form = ItemForm(data=request.POST)
        if form.is_valid():
            Item.objects.create(text=request.POST["text"], list=our_list)
            return redirect(our_list)
    else:
        form = ItemForm()
    return render(request, "list.html", {"list": our_list, "form": form})

That gets the unit tests passing:

Ran 21 tests in 0.086s

OK

How about the FTs?

======================================================================
ERROR: test_cannot_add_empty_list_items (functional_tests.test_list_item_valida
tion.ItemValidationTest.test_cannot_add_empty_list_items)
 ---------------------------------------------------------------------
[...]
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: .invalid-feedback; [...]

Nope.

An Unexpected Benefit: Free Client-Side Validation from HTML5

What’s going on here? Let’s add our usual time.sleep before the error, and take a look at what’s happening or spin up the site manually with manage.py runserver if you prefer (see HTML5 validation says no).

The input with a popup saying 'please fill out this field'
Figure 1. HTML5 validation says no

It seems like the browser is preventing the user from even submitting the input when it’s empty.

It’s because Django has added the required attribute to the HTML input (take another look at our as_p() printouts from earlier if you don’t believe me). This is a feature of HTML5, and browsers nowadays will do some validation at the client side if they see it, preventing users from even submitting invalid input.

Let’s change our FT to reflect that:

src/functional_tests/test_list_item_validation.py (ch14l032)
class ItemValidationTest(FunctionalTest):
    def test_cannot_add_empty_list_items(self):
        # Edith goes to the home page and accidentally tries to submit
        # an empty list item. She hits Enter on the empty input box
        self.browser.get(self.live_server_url)
        self.get_item_input_box().send_keys(Keys.ENTER)

        # The browser intercepts the request, and does not load the list page
        self.wait_for(
            lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_text:invalid")  (1)
        )

        # She starts typing some text for the new item and the error disappears
        self.get_item_input_box().send_keys("Buy milk")
        self.wait_for(
            lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_text:valid")  (2)
        )

        # And she can submit it successfully
        self.get_item_input_box().send_keys(Keys.ENTER)
        self.wait_for_row_in_list_table("1: Buy milk")

        # Perversely, she now decides to submit a second blank list item
        self.get_item_input_box().send_keys(Keys.ENTER)

        # Again, the browser will not comply
        self.wait_for_row_in_list_table("1: Buy milk")
        self.wait_for(
            lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_text:invalid")
        )

        # And she can make it happy by filling some text in
        self.get_item_input_box().send_keys("Make tea")
        self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR,
                "#id_text:valid",
            )
        )
        self.get_item_input_box().send_keys(Keys.ENTER)
        self.wait_for_row_in_list_table("1: Buy milk")
        self.wait_for_row_in_list_table("2: Make tea")
1 Instead of checking for our custom error message, we check using the CSS pseudoselector :invalid, which the browser applies to any HTML5 input that has invalid input.
2 And its converse in the case of valid inputs.

See how useful and flexible our self.wait_for() function is turning out to be?

Our FT does look quite different from how it started though, doesn’t it? I’m sure that’s raising a lot of questions in your mind right now. Put a pin in them for a moment; I promise we’ll talk. Let’s first see if we’re back to passing tests:

$ python src/manage.py test functional_tests
[...]
....
 ---------------------------------------------------------------------
Ran 4 tests in 12.154s

OK

A Pat on the Back

First let’s give ourselves a massive pat on the back: we’ve just made a major change to our small app—​that input field, with its name and ID, is absolutely critical to making everything work. We’ve touched seven or eight different files, doing a refactor that’s quite involved…​this is the kind of thing that, without tests, would seriously worry me. In fact, I might well have decided that it wasn’t worth messing with code that works. But, because we have a full tests suite, we can delve around, tidying things up, safe in the knowledge that the tests are there to spot any mistakes we make. It just makes it that much likelier that you’re going to keep refactoring, keep tidying up, keep gardening, keep tending your code, keep everything neat and tidy and clean and smooth and precise and concise and functional and good.

  • Remove duplication of validation logic in views

And it’s definitely time for a commit:

$ git diff
$ git commit -am "use form in all views, back to working state"

But Have We Wasted a Lot of Time?

But what about our custom error message? What about all that effort rendering the form in our HTML template? We’re not even passing those errors from Django to the user if the browser is intercepting the requests before the user even makes them? And our FT isn’t even testing that stuff any more!

Well, you’re quite right. But there are two or three reasons all our time hasn’t been wasted. Firstly, client-side validation isn’t enough to guarantee you’re protected from bad inputs, so you always need the server side as well if you really care about data integrity; using a form is a nice way of encapsulating that logic.

Also, not all browsers (cough—​Safari—​cough) fully implement HTML5, so some users are still going to see our custom error message. And if or when we come to letting users access our data via an API (see [appendix_rest_api]), then our validation messages will come back into use.

On top of that, we’ll be able to reuse all our validation and forms code and the front-end .has-error classes in the next chapter, when we do some more advanced validation that can’t be done by HTML5 magic.

But you know, even if all that wasn’t true, you still can’t beat yourself up for occasionally going down a blind alley while you’re coding. None of us can see the future, and we should concentrate on finding the right solution rather than the time "wasted" on the wrong solution.

Using the Form’s Own Save Method

There are a couple more things we can do to make our views even simpler. I’ve mentioned that forms are supposed to be able to save data to the database for us. Our case won’t quite work out of the box, because the item needs to know what list to save to, but it’s not hard to fix that.

We start, as always, with a test. Just to illustrate what the problem is, let’s see what happens if we just try to call form.save():

src/lists/tests/test_forms.py (ch11l033)
    def test_form_save_handles_saving_to_a_list(self):
        form = ItemForm(data={"text": "do me"})
        new_item = form.save()

Django isn’t happy, because an item needs to belong to a list:

django.db.utils.IntegrityError: NOT NULL constraint failed: lists_item.list_id

Our solution is to tell the form’s save method what list it should save to.

src/lists/tests/test_forms.py (ch14l034)
from lists.models import Item, List
[...]

    def test_form_save_handles_saving_to_a_list(self):
        mylist = List.objects.create()
        form = ItemForm(data={"text": "do me"})
        new_item = form.save(for_list=mylist)  (1)
        self.assertEqual(new_item, Item.objects.get())  (2)
        self.assertEqual(new_item.text, "do me")
        self.assertEqual(new_item.list, mylist)
1 We’ll imagine that the .save() method takes a for_list= argument:
2 We then make sure that the item is correctly saved to the database, with the right attributes:

The tests fail as expected, because as usual, it’s still only wishful thinking:

    new_item = form.save(for_list=mylist)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: BaseModelForm.save() got an unexpected keyword argument 'for_list'

Here’s how we can implement a custom save method:

src/lists/forms.py (ch14l035)
    def save(self, for_list):
        self.instance.list = for_list
        return super().save()

The .instance attribute on a form represents the database object that is being modified or created. And I only learned that as I was writing this chapter! There are other ways of getting this to work, including manually creating the object yourself, or using the commit=False argument to save, but this way seemed neatest. We’ll explore a different way of making a form "know" what list it’s for in the next chapter.

Ran 22 tests in 0.086s
OK

Finally, we can refactor our views. new_list first:

src/lists/views.py (ch14l036)
def new_list(request):
    form = ItemForm(data=request.POST)
    if form.is_valid():
        nulist = List.objects.create()
        form.save(for_list=nulist)
        return redirect(nulist)
    else:
        return render(request, "home.html", {"form": form})

Rerun the test to check that everything still passes:

Ran 22 tests in 0.086s
OK

And now view_list:

src/lists/views.py (ch14l037)
def view_list(request, list_id):
    our_list = List.objects.get(id=list_id)
    if request.method == "POST":
        form = ItemForm(data=request.POST)
        if form.is_valid():
            form.save(for_list=our_list)
            return redirect(our_list)
    else:
        form = ItemForm()
    return render(request, "list.html", {"list": our_list, "form": form})

And we still have full passes:

Ran 22 tests in 0.111s
OK

and:

Ran 4 tests in 14.367s
OK

Great! Our two views are now looking very much like "normal" Django views: they take information from a user’s request, combine it with some custom logic or information from the URL (list_id), pass it to a form for validation and possible saving, and then redirect or render a template.

Forms and validation are really important in Django, and in web programming in general, so let’s try to make a slightly more complicated one in the next chapter.

Tips
Thin views

If you find yourself looking at complex views, and having to write a lot of tests for them, it’s time to start thinking about whether that logic could be moved elsewhere: possibly to a form, like we’ve done here. + Another possible place would be a custom method on the model class. And—​once the complexity of the app demands it—​out of Django-specific files and into your own classes and functions, that capture your core business logic.

Each test should test one thing

The heuristic is to be suspicious if there’s more than one assertion in a test. Sometimes two assertions are closely related, so they belong together. But often your first draft of a test ends up testing multiple behaviours, and it’s worth rewriting it as several tests. Helper functions can keep them from getting too bloated.

Comments