Chapter 11. A Simple Form

At the end of the last chapter, we were left with the thought that there was too much duplication of code 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 only tests one thing at a time.

Moving Validation Logic into a Form

Tip

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 maybe even 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:

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:

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 auto-generated form HTML will look like:

    self.fail(form.as_p())
AssertionError: <p><label for="id_item_text">Item text:</label> <input
id="id_item_text" name="item_text" type="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:

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 input-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:

lists/forms.py

class ItemForm(forms.Form):
    item_text = forms.CharField(
        widget=forms.fields.TextInput(attrs={
            'placeholder': 'Enter a to-do item',
        }),
    )

That gives:

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

And then:

lists/forms.py

    widget=forms.fields.TextInput(attrs={
        'placeholder': 'Enter a to-do item',
        'class': 'form-control input-lg',
    }),

Note

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.

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 auto-generate a form for a model, called ModelForm. As you’ll see, it’s configured using a special attribute called Meta:

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><label
for="id_text">Text:</label> <textarea cols="40" id="id_text" name="text"
rows="10">\r\n</textarea></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:

lists/forms.py

class ItemForm(forms.models.ModelForm):

    class Meta:
        model = Item
        fields = ('text',)
        widgets = {
            'text': forms.fields.TextInput(attrs={
                'placeholder': 'Enter a to-do item',
                'class': 'form-control input-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:

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 and save any data is a function called is_valid:

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:

lists/forms.py (ch11l010)

    class Meta:
        model = Item
        fields = ('text',)
        widgets = {
            'text': forms.fields.TextInput(attrs={
                'placeholder': 'Enter a to-do item',
                'class': 'form-control input-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:

lists/forms.py (ch11l011)

EMPTY_LIST_ERROR = "You can't have an empty list item"
[...]

        error_messages = {
            'text': {'required': EMPTY_LIST_ERROR}
        }

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

lists/tests/test_forms.py (ch11l012)

from lists.forms import EMPTY_LIST_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_LIST_ERROR])

And the tests still pass:

OK

Great. Totes committable:

$ git status # should show lists/forms.py and tests/test_forms.py
$ git add 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 and 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 replace the old-style test_home_page_returns_correct_html and test_root_url_resolves_to_home_ page_view with a set of tests that use the Django test client. We leave the old tests in at first, to check that our new tests are equivalent:

lists/tests/test_views.py (ch11l013)

from lists.forms import ItemForm

class HomePageTest(TestCase):

    def test_root_url_resolves_to_home_page_view(self):
        [...]

    def test_home_page_returns_correct_html(self):
        request = HttpRequest()
        [...]


    def test_home_page_renders_home_template(self):
        response = self.client.get('/')
        self.assertTemplateUsed(response, 'home.html') #1


    def test_home_page_uses_item_form(self):
        response = self.client.get('/')
        self.assertIsInstance(response.context['form'], ItemForm) #2

1

We’ll use the helper method assertTemplateUsed to replace our old manual test of the template.

2

We use assertIsInstance to check that our view uses the right kind of form.

That gives us:

KeyError: 'form'

So we use the form in our home page view:

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 }}:

lists/templates/base.html (ch11l015)

    <form method="POST" action="{% block form_action %}{% endblock %}">
        {{ form.text }}
        {% csrf_token %}
        {% if error %}
            <div class="form-group has-error">

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

Now the old test is out of date:

    self.assertEqual(response.content.decode(), expected_html)
AssertionError: '<!DO[596 chars]     <input class="form-control input-lg"
id="[342 chars]l>\n' != '<!DO[596 chars]     \n                    \n
[233 chars]l>\n'

That error message is impossible to read though. Let’s clarify its message a little:

lists/tests/test_views.py (ch11l016)

class HomePageTest(TestCase):
    maxDiff = None #1
    [...]
    def test_home_page_returns_correct_html(self):
        request = HttpRequest()
        response = home_page(request)
        expected_html = render_to_string('home.html')
        self.assertMultiLineEqual(response.content.decode(), expected_html) #2

2

assertMultiLineEqual is useful for comparing long strings; it gives you a diff-style output, but it truncates long diffs by default…

1

…so that’s why we also need to set maxDiff = None on the test class.

Sure enough, it’s because our render_to_string call doesn’t know about the form:

[...]
                  <form method="POST" action="/lists/new">
-                     <input class="form-control input-lg" id="id_text"
name="text" placeholder="Enter a to-do item" type="text" />
+
[...]

But we can fix that:

lists/tests/test_views.py

def test_home_page_returns_correct_html(self):
    request = HttpRequest()
    response = home_page(request)
    expected_html = render_to_string('home.html', {'form': ItemForm()})
    self.assertMultiLineEqual(response.content.decode(), expected_html)

And that gets us back to passing. We’ve now reassured ourselves enough that the behaviour has stayed the same, so it’s now OK to delete the two old tests. The assertTemplateUsed and response.context checks from the new test are sufficient for testing a basic view with a GET request.

That leaves us with just two tests in HomePageTest:

lists/tests/test_views.py (ch11l017)

class HomePageTest(TestCase):

    def test_home_page_renders_home_template(self):
        [...]

    def test_home_page_uses_item_form(self):
        [...]

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 and find the input box:

selenium.common.exceptions.NoSuchElementException: Message: 'Unable to locate
element: {"method":"id","selector":"id_new_item"}' ; Stacktrace:

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 home.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 functional_tests/test*
functional_tests/test_layout_and_styling.py:        inputbox =
self.browser.find_element_by_id('id_new_item')
functional_tests/test_layout_and_styling.py:        inputbox =
self.browser.find_element_by_id('id_new_item')
functional_tests/test_list_item_validation.py:
self.browser.find_element_by_id('id_new_item').send_keys('\n')
[...]

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

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 three changes in test_simple_list_creation.py, two in test_layout_and_styling.py, and four in test_list_item_validation.py, eg:

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:

functional_tests/test_list_item_validation.py

    # an empty list item. She hits Enter on the empty input box
    self.browser.get(self.server_url)
    self.get_item_input_box().send_keys('\n')

I won’t show you every single one, I’m sure you can manage this for yourself! You can redo the grep to check 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 lists/
lists/static/base.css:#id_new_item {

That’s one change, and similarly for the name:

$ grep -Ir item_text lists
lists/views.py:    item = Item(text=request.POST['item_text'], list=list_)
lists/views.py:            item = Item(text=request.POST['item_text'],
lists/tests/test_views.py:            data={'item_text': 'A new list item'}
lists/tests/test_views.py:            data={'item_text': 'A new list item'}
lists/tests/test_views.py:        response = self.client.post('/lists/new',
data={'item_text': ''})
[...]

Once we’re done, we rerun the unit tests to check everything still works:

$ python3 manage.py test lists
Creating test database for alias 'default'...
.................
 ---------------------------------------------------------------------
Ran 17 tests in 0.126s

OK
Destroying test database for alias 'default'...

And the functional tests too:

$ python3 manage.py test functional_tests
[...]
  File "/workspace/superlists/functional_tests/test_simple_list_creation.py",
line 40, in test_can_start_a_list_and_retrieve_it_later
    return self.browser.find_element_by_id('id_text')
  File "/workspace/superlists/functional_tests/base.py", line 31, in
get_item_input_box
    return self.browser.find_element_by_id('id_text')
selenium.common.exceptions.NoSuchElementException: Message: 'Unable to locate
element: {"method":"id","selector":"id_text"}' ; Stacktrace:
[...]
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:

lists/views.py

except ValidationError:
    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:

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)

Adapting the Unit Tests for the new_list View

For a start this test is testing too many things at once, so we’ve got an opportunity to clarify things here. We should split out two different assertions:

  • If there’s a validation error, we should render the home template, with a 200.
  • If there’s a validation error, the response should contain our error text.

And we can add a new one too:

  • If there’s a validation error, we should pass our form object to the template.

And while we’re at it, we’ll use a constant instead of a hardcoded string for that error message:

lists/tests/test_views.py (ch11l023)

from lists.forms import ItemForm, EMPTY_LIST_ERROR
[...]

class NewListTest(TestCase):
    [...]

    def test_for_invalid_input_renders_home_template(self):
        response = self.client.post('/lists/new', data={'text': ''})
        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed(response, 'home.html')


    def test_validation_errors_are_shown_on_home_page(self):
        response = self.client.post('/lists/new', data={'text': ''})
        self.assertContains(response, escape(EMPTY_LIST_ERROR))


    def test_for_invalid_input_passes_form_to_template(self):
        response = self.client.post('/lists/new', data={'text': ''})
        self.assertIsInstance(response.context['form'], ItemForm)

Much better. Each test is now clearly testing one thing, and, with a bit of luck, just one will fail and tell us what to do:

$ python3 manage.py test lists
[...]
======================================================================
ERROR: test_for_invalid_input_passes_form_to_template
(lists.tests.test_views.NewListTest)
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "/workspace/superlists/lists/tests/test_views.py", line 55, in
test_for_invalid_input_passes_form_to_template
    self.assertIsInstance(response.context['form'], ItemForm)
[...]
KeyError: 'form'

 ---------------------------------------------------------------------
Ran 19 tests in 0.041s

FAILED (errors=1)

Using the Form in the View

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

lists/views.py

def new_list(request):
    form = ItemForm(data=request.POST) #1
    if form.is_valid(): #2
        list_ = List.objects.create()
        Item.objects.create(text=request.POST['text'], list=list_)
        return redirect(list_)
    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! And all our tests pass, except one:

    self.assertContains(response, escape(EMPTY_LIST_ERROR))
[...]
AssertionError: False is not true : Couldn't find 'You can&#39;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:

lists/templates/base.html (ch11l026)

    <form method="POST" action="{% block form_action %}{% endblock %}">
        {{ form.text }}
        {% csrf_token %}
        {% if form.errors %}1
            <div class="form-group has-error">
                <div class="help-block">{{ form.text.errors }}</div>2
            </div>
        {% endif %}
    </form>

1

form.errors contains a list of all the errors for the form.

2

form.text.errors is a list of just the errors for the text field.

What does that do to our tests?

FAIL: test_validation_errors_end_up_on_lists_page
(lists.tests.test_views.ListViewTest)
[...]
AssertionError: False is not true : Couldn't find 'You can&#39;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 the form is used in GET requests. We can have a new test for that:

lists/tests/test_views.py

class ListViewTest(TestCase):
    [...]

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

That gives:

KeyError: 'form'

Here’s a minimal implementation:

lists/views.py (ch11l028)

def view_list(request, list_id):
    [...]
    form = ItemForm()
    return render(request, 'list.html', {
        'list': 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:

lists/tests/test_views.py (ch11l030)

class ListViewTest(TestCase):
    [...]

    def post_invalid_input(self):
        list_ = List.objects.create()
        return self.client.post(
            '/lists/%d/' % (list_.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_LIST_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 definitely worthwhile; as we saw in previous chapters, it helps you isolate the exact problem you may have, when you later come and change your code and accidentally introduce a bug. Helper methods are one of the tools that lower the psychological barrier.

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.ListViewTest)
AssertionError: False is not true : Couldn't find 'You can&#39;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:

lists/views.py

def view_list(request, list_id):
    list_ = List.objects.get(id=list_id)
    form = ItemForm()
    if request.method == 'POST':
        form = ItemForm(data=request.POST)
        if form.is_valid():
            Item.objects.create(text=request.POST['text'], list=list_)
            return redirect(list_)
    return render(request, 'list.html', {'list': list_, "form": form})

That gets the unit tests passing:

Ran 23 tests in 0.086s

OK

How about the FTs?

$ python3 manage.py test functional_tests
Creating test database for alias 'default'...
...
 ---------------------------------------------------------------------
Ran 3 tests in 12.154s

OK
Destroying test database for alias 'default'...

Woohoo! Can you feel that feeling of relief wash over you? 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 in it, 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.

Definitely time for a commit:

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

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():

lists/tests/test_forms.py (ch11l032)

    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:

lists/tests/test_forms.py

from lists.models import Item, List
[...]

    def test_form_save_handles_saving_to_a_list(self):
        list_ = List.objects.create()
        form = ItemForm(data={'text': 'do me'})
        new_item = form.save(for_list=list_)
        self.assertEqual(new_item, Item.objects.first())
        self.assertEqual(new_item.text, 'do me')
        self.assertEqual(new_item.list, list_)

We then make sure that the item is correctly saved to the database, with the right attributes:

TypeError: save() got an unexpected keyword argument 'for_list'

And here’s how we can implement our custom save method:

lists/forms.py (ch11l034)

    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 is the neatest I think. We’ll explore a different way of making a form “know” what list it’s for in the next chapter:

Ran 24 tests in 0.086s

OK

Finally we can refactor our views. new_list first:

lists/views.py

def new_list(request):
    form = ItemForm(data=request.POST)
    if form.is_valid():
        list_ = List.objects.create()
        form.save(for_list=list_)
        return redirect(list_)
    else:
        return render(request, 'home.html', {"form": form})

Rerun the test to check everything still passes:

Ran 24 tests in 0.086s

OK

And now view_list:

lists/views.py

def view_list(request, list_id):
    list_ = List.objects.get(id=list_id)
    form = ItemForm()
    if request.method == 'POST':
        form = ItemForm(data=request.POST)
        if form.is_valid():
            form.save(for_list=list_)
            return redirect(list_)
    return render(request, 'list.html', {'list': list_, "form": form})

And we still have full passes:

Ran 24 tests in 0.111s

OK

and

Ran 3 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 see if we can’t make a slightly more complicated one in the next chapter.

Get Test-Driven Development with Python now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.