Assumptions, Defaults, and the Week Long Bug

Published on:
Oct. 29, 2017

So I’m writing this app to let people make bingo cards and play bingo, right? Part of this app will obviously be letting users create bingo cards. Because of some requirements of how the cards will be displayed, I had to split the Bingo Cards and Bingo Squares into two different database tables with a “many to one” relationship. That is: each card will have many squares, but each square will only relate to one card.

Having a many to one relationship means I need one HTML form to be able to create a Bingo Card and all of its Squares at once. Luckily, Django has a built in tool for dealing with exactly this relationship. Unluckily, it doesn’t quite work exactly how I thought it did at the start of this week. Please grab a beverage and a snack and join me for a tale of debugging and challenging assumptions.

The Situation


Underneath all the forms, views, and templates, I had two objects related with via a ForeignKey. Here are the relevant parts of my models:

class BingoCard(models.Model):   
    ...
    Stuff
    ...


class BingoCardSquare(models.Model):
    ...
    card = models.ForeignKey(
        BingoCard,
        related_name='squares',
        on_delete=models.CASCADE,
    )
    ...

For reference, the full file is available on github.

In order to create multiple objects at once, Django creates a layer of abstraction on top of normal forms called Formsets. For creating multiple “Child” objects related to a “Parent,” Django creates another abstraction layer called Inline formsets. After reading the Django docs, here was my forms.py:

from django.forms.models import inlineformset_factory
from bingo.forms import CrispyBaseModelForm
from .models import BingoCard, BingoCardSquare


class BingoCardForm(ModelForm):
    class Meta:
        model = BingoCard
        fields = ('title', 'free_space', 'creator', 'private')


class BingoSquareForm(ModelForm):
    class Meta:
        model = BingoCardSquare
        exclude = ('created_date',)


BingoSquareFormset = inlineformset_factory(
    BingoCard,
    BingoCardSquare,
    form=BingoSquareForm,
    min_num=24,
    max_num=24,
    validate_min=True,
    validate_max=True,
)

Now that I had my forms, I needed to test them to make sure they worked.

The Tests


I wrote tests that followed the docs to the letter. My full tests can be found on github along with the rest of the project, but here is the relevant part of my initial tests:

class BingoSquareFormsetTests(TestCase):
    
    def setUp(self):

        # Create user
        self.user = User.objects.get_or_create(
            username='FormsetTestUser',
            email='something@yahoo.org'
        )[0]
        self.user.set_password('bingo')
        self.user.save()

        # Create card
        self.card = BingoCard.objects.get_or_create(
            title='FormsetTest',
            free_space='free_space',
            creator=self.user,
        )[0]

        # Formset data
        self.data = {
            'form-TOTAL_FORMS': 24,
            'form-INITIAL_FORMS': 0,
            'form-MAX_NUM_FORMS': 24,
            'form-MIN_NUM_FORMS': 24,
        }

        # iteratively add squares to data dict
        for i in range(24):
            text_key = 'form-{}-text'.format(i)
            text_value = 'square {}'.format(i)
            self.data[text_key] = text_value

    def test_formset_accepts_valid_data(self):

        formset = BingoSquareFormset(self.data)
        formset.instance = self.card
        self.assertTrue(formset.is_valid())

If you’re unfamiliar with testing django projects you should start here. If you’re already familiar, let’s break down what’s happening here. In the test’s setUp method I create User and BingoCard objects, and a dictionary of data to fill the formset. After running the for loop, the dictionary will have data for 24 new bingo squares (equal to the min and max from the formset).

Here is where things went awry. Despite having the right number of dictionary entries and having the ManagementForm data required by the formset my form was returning the following error:

Traceback (most recent call last):
  File "/home/jwelborn/Documents/projects/Bingo/bingo/cards/tests/test_forms.py", line 279, in test_squares_created_correctly
    if formset.is_valid():
  File "/home/jwelborn/Documents/projects/Bingo/bingoenv/lib/python3.6/site-packages/django/forms/formsets.py", line 321, in is_valid
    self.errors
  File "/home/jwelborn/Documents/projects/Bingo/bingoenv/lib/python3.6/site-packages/django/forms/formsets.py", line 295, in errors
    self.full_clean()
  File "/home/jwelborn/Documents/projects/Bingo/bingoenv/lib/python3.6/site-packages/django/forms/formsets.py", line 343, in full_clean
    for i in range(0, self.total_form_count()):
  File "/home/jwelborn/Documents/projects/Bingo/bingoenv/lib/python3.6/site-packages/django/forms/formsets.py", line 116, in total_form_count
    return min(self.management_form.cleaned_data[TOTAL_FORM_COUNT], self.absolute_max)
  File "/home/jwelborn/Documents/projects/Bingo/bingoenv/lib/python3.6/site-packages/django/utils/functional.py", line 35, in __get__
    res = instance.__dict__[self.name] = self.func(instance)
  File "/home/jwelborn/Documents/projects/Bingo/bingoenv/lib/python3.6/site-packages/django/forms/formsets.py", line 98, in management_form
    code='missing_management_form',
django.core.exceptions.ValidationError: ['ManagementForm data is missing or has been tampered with']

 Yikes.

Debugging


After checking for spelling and syntax errors, I found myself staring at code that I really believed should work. When that happens I usually fall back on two pieces of advice.

  1. You can’t debug code you don’t understand.
  2. Bugs are caused by assumptions. If you believe code should work and it doesn’t, at least one assumption is wrong.

Following that advice sounds hard. Instead I googled it. I read the docs, blog after blog, forum posts, and Stack Overflow question after Stack Overflow question. I got nowhere. I wrote a view to house the form I could’t test to see if the code would magically work inside of a view. It didn’t. I set a pdb trace point to make sure my dictionary was populated correctly. It was.

After three days of stalling and hoping things would magically get better, I finally took a second look at that advice. I was using a function, inlineformset_factory, that I had no idea how worked. I was also making several assumptions.

  1. My code matched the documentation and tutorials.
  2. I was using Inline Formsets as intended.
  3. My test data was what my forms needed.

One of these was obviously wrong. To find out which one I went back to my python debugger. I set two trace points. One was right before the formset was created (just above formset = BingoSquareFormset(self.data), and the other was just before calling is_valid on the formset.

I followed both of these trace points step by step through the Django internals of making and validating formsets. Deep inside of these internals, I found a clue.

Finding the Answer


During the creation of a formset using inlineformset_factory, Django creates an InlineFormset object. Inline formsets have a complicated and long inheritance chain. One of their ancestors is a BaseFormSet. Base Form Sets have an attribute called prefix that isn’t mentioned in the documentation on Inline Form Sets.

When inlineformset_factory was creating my Inline Form Set, Django was assigning “squares” as the Form set’s prefix. Unfortunately even the Base Form Set documentation is pretty vague on what this means in terms of what data needs to be passed to a form upon creation. Still, it was something. Another round of googling led me to a comment on one of the previously read blogs that had my answer.

When Django creates Form Sets, the Set’s prefix is set to the “related name” on the child model. A little bit of trial and error with my test data led me to the following fix:

class BingoSquareFormTests(TestCase):
    def setUp(self):
        ...
        same old stuff
        ...

        self.data = {
            'squares-TOTAL_FORMS': 24,
            'squares-INITIAL_FORMS': 0,
            'squares-MAX_NUM_FORMS': 24,
            'squares-MIN_NUM_FORMS': 24,
        }

        for i in range(24):
            text_key = 'squares-{}-text'.format(i)
            text_value = 'square {}'.format(i)
            self.data[text_key] = text_value

Instead of the word “form” I needed to start each key in the dictionary with “squares”.

Moral?


For something that ended up being such a simple fix, this was a pain to diagnose. It’s easy to try and blame this on documentation, but ultimately I spent three days afraid of my debugger. I’m not afraid of my debugger anymore. Solving this was really empowering. I dove into one of the biggest codebases of any open source project and found a solution to a problem. It felt surprisingly good to find a solution to an issue that got radio silence everywhere on the internet where I asked for help. Maybe I can really do this whole “build an app” thing!