Skip to content

Exercise: A Seed Command

You'll:

  • migrate the full AdoptionApplication model
  • finish a seed command that creates sample animals and adoption applications
  • run the tests and the command
  • write queries for the new rows in the shell

What's provided in your branch:

  • the full AdoptionApplication model, with its foreign key to Animal, contact fields, status, and submitted time
  • a seed command that creates Peanut and Maria's application for her, with TODOs for the rest
  • tests for the seed command

Before You Start

Quit the shell with exit() if it's open. Then set up the starting state for this exercise:

shell
git stash -u
git fetch --all
git checkout -b my-ch03 --no-track origin/ch03
uv sync

If Git prints No local changes to save, there was nothing to set aside.

Files that changed in this chapter:
  • animals/models.py (modified, adds AdoptionApplication, with its contact fields, status, and submitted time)
  • animals/management/ (new, the seed command; you'll fill in its TODOs)
  • tests/test_seed.py (new)
  • tests/conftest.py and tests/test_models.py (removed)
  • AGENTS.md (modified, adds the seed command and a rule for imports)
  • README.md (modified, describes the second model and the command)

Step 1: Check Your Migrations

The branch has the full AdoptionApplication model, with contact fields, a status, and the time it was submitted. Git ignores the animals migrations, so yours are still in animals/migrations/. Before we migrate the full model, list them and whether each one is applied:

shell
uv run manage.py showmigrations animals

If the last migration is one of Animal's, such as 0001_initial or 0002_animal_status, there's nothing to undo, and you can go on to Step 2.

If the last migration creates AdoptionApplication, it's for the model with two fields, and the full model needs a new one. Here, it's 0003_adoptionapplication:

Text
animals
 [X] 0001_initial
 [X] 0002_animal_status
 [X] 0003_adoptionapplication

Undo it by migrating the app back to the migration before it, then deleting its file. See "How do I undo a migration?" on Django Migrations. With the numbers above:

shell
uv run manage.py migrate animals 0002
rm animals/migrations/0003_adoptionapplication.py

If yours is 0002_adoptionapplication, the migration before it is 0001_initial:

shell
uv run manage.py migrate animals 0001
rm animals/migrations/0002_adoptionapplication.py

Unapplying the migration drops the applications table. The animals stay.

Step 2: Migrate the Full Model

Generate and apply the migration:

shell
uv run manage.py makemigrations
uv run manage.py migrate
Text
Migrations for 'animals':
  animals/migrations/0003_adoptionapplication.py
    + Create model AdoptionApplication

Your migration's number and name may differ.

What the full model adds
animals/models.py
class AdoptionApplication(models.Model):
    """One visitor's request to adopt one animal."""

    class Status(models.TextChoices):
        SUBMITTED = "submitted", "Submitted"
        APPROVED = "approved", "Approved"
        DECLINED = "declined", "Declined"

    animal = models.ForeignKey(
        Animal, on_delete=models.CASCADE, related_name="applications"
    )
    applicant_name = models.CharField(max_length=100)
    email = models.EmailField()
    phone = models.CharField(
        max_length=20,
        blank=True,
        help_text="A US number, like 555-010-2000",
    )
    message = models.TextField(blank=True)
    status = models.CharField(max_length=10, choices=Status, default=Status.SUBMITTED)
    # Set once, when the row is first saved.
    created_at = models.DateTimeField("submitted time", auto_now_add=True)

    def __str__(self):
        return f"{self.applicant_name} for {self.animal}"
  • email: An EmailField is a CharField whose validation checks that the value looks like an email address.
  • phone and message: Optional, with blank=True. help_text is shown next to the field in forms and in the admin.
  • status: An application's own Status choices, starting as submitted.
  • created_at: auto_now_add=True sets the time when the row is first saved. The first argument, "submitted time", is the field's verbose_name, the readable name that forms and the admin show.
  • __str__: The applicant's name and the animal's, such as "Maria for Peanut".

Step 3: Fill in the Seed Command

We want a command that fills the database with three animals and two adoption applications, and that can run again to replace them.

manage.py runs our commands just like migrate. A management command is a module named after the command. It lives in an app's management/commands/ folder. Ours, animals/management/commands/seed.py, runs with uv run manage.py seed. We use a command instead of a plain script because manage.py loads the project's settings before any model is imported.

Open animals/management/commands/seed.py and fill in the TODOs to create Rocket, Smokey, and Aisha's application:

animals/management/commands/seed.py
from datetime import date, timedelta

from django.core.management.base import BaseCommand

from animals.models import AdoptionApplication, Animal


class Command(BaseCommand):
    help = "Replace every animal with three animals and two adoption applications."

    def handle(self, *args, **options):
        # Deleting the animals also deletes their applications, so the command
        # can run again.
        Animal.objects.all().delete()

        peanut = Animal.objects.create(
            name="Peanut",
            species=Animal.Species.CAT,
            birth_date=date.today() - timedelta(days=60),
        )
        # TODO: Create Rocket, a dog born 3 years ago (3 * 365 days),
        # with a breed of "Beagle mix".
        # TODO: Create Smokey, a cat born 4 years ago (4 * 365 days),
        # with a status of pending.

        AdoptionApplication.objects.create(
            animal=peanut, applicant_name="Maria", email="maria@example.com"
        )
        # TODO: Create Aisha's application for Peanut, with an email of
        # aisha@example.com.

        self.stdout.write("Created 3 animals and 2 adoption applications.")
  • Command, a subclass of BaseCommand, is the class Django looks for.
  • help is what uv run manage.py help seed prints.
  • handle() runs when the command does.
  • self.stdout.write() writes a line to the output.

Step 4: Run the Tests

shell
uv run pytest

All tests should pass. The tests for the command check the rows it leaves in the database, and that it can run twice.

Step 5: Run the Command

shell
uv run manage.py seed
Text
Created 3 animals and 2 adoption applications.

Step 6: Query the New Rows

Start the shell:

shell
uv run manage.py shell

Write a query for each of these:

  1. The animals that aren't available.
  2. Every animal, youngest first.
  3. The email addresses on Peanut's applications.
Solution
Python REPL
>>> Animal.objects.exclude(status="available")
<QuerySet [<Animal: Smokey>]>
>>> Animal.objects.order_by("-birth_date")
<QuerySet [<Animal: Peanut>, <Animal: Rocket>, <Animal: Smokey>]>
>>> peanut = Animal.objects.get(name="Peanut")
>>> peanut.applications.values_list("email", flat=True)
<QuerySet ['maria@example.com', 'aisha@example.com']>

Success

The full model is migrated and the seed command works if the tests pass, the command prints its line, and your queries find these rows.

Troubleshooting Tips

makemigrations asks for a default for created_at

It says It is impossible to add the field 'created_at' with 'auto_now_add=True' to adoptionapplication without providing a default. The migration for the model with two fields is still there. So Django sees the full model's fields as new columns for a table that already has rows. Choose 2 to quit, then undo that migration, as in Step 1.

ProgrammingError: column "email" of relation "animals_adoptionapplication" does not exist

The two-field model's migration file was deleted before the migration was unapplied. The database still has the table and a record of a migration with the same name as the new one. That's why migrate skipped the new migration. Migrate back to the previous migration, as in Step 1, then forward:

shell
uv run manage.py migrate animals 0002
uv run manage.py migrate

If your applications migration is 0002_adoptionapplication, migrate back to 0001.

Compare with the Solution

shell
git diff solutions/ch03 -- animals/management/

No output, or only the TODO comments you kept, means your command matches the solution.

See the solution on GitHub: animals/management/commands/seed.py.