Skip to content

Exercise: Employees & Volunteers in the Admin

You'll:

  • migrate the photo field and the manage_animal permission, and load sample data with accounts for the admin
  • set up the list page for adoption applications
  • finish AnimalAdmin's has_change_permission(), so only employees can change an adopted animal
  • run the tests, and use the admin as an employee and as a volunteer

What's provided in your branch:

  • the photo field, save(), clean(), and the manage_animal permission on the models, and the admin's registrations, list settings, and review table
  • AnimalAdmin's get_readonly_fields(), as we learned in this chapter
  • animals/formsets.py, whose OneApprovalFormSet refuses a save that approves two applications for one animal
  • the full seed command, with photos, application statuses, and the accounts, and seed_photos/ with the animals' photos
  • tests for the admin

Before You Start

Stop the development server with Ctrl+C if it's running. Then set up the starting state for this exercise:

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

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

Files that changed in this chapter:
  • pyproject.toml (modified, adds Pillow)
  • config/settings.py (modified, adds MEDIA_ROOT and MEDIA_URL)
  • config/urls.py (modified, serves uploaded files while DEBUG is on)
  • accounts/admin.py (modified, registers the user model with UserAdmin)
  • animals/models.py (modified, adds the photo field and the manage_animal permission to Animal, and save() and clean() to AdoptionApplication)
  • animals/admin.py (modified, registers both models, with a review table of applications on an animal's page and a read-only status without manage_animal; you'll finish the list page for applications and has_change_permission())
  • animals/formsets.py (new, OneApprovalFormSet, which refuses a save of the review table that approves two applications)
  • animals/management/commands/seed.py (modified, the full sample data: photos, application statuses, and the --reset and --accounts options)
  • seed_photos/ (new, the animals' photos and their credits)
  • tests/test_admin.py (new)
  • tests/conftest.py (new, fixtures that create animals and applications)
  • tests/test_seed.py (removed)
  • AGENTS.md (modified, adds the seed's options, the admin's address, and rules for animals/admin.py)
  • README.md (modified, describes the admin and seed_photos/)

Step 1: Migrate and Load the Sample Data

The photo field and the manage_animal permission need a migration. Generate and apply it:

shell
uv run manage.py makemigrations
uv run manage.py migrate

makemigrations writes a migration with ~ Change Meta options on animal and + Add field photo to animal, or prints No changes detected if your migrations already have them.

Then load the sample data, with the accounts for the admin.

Warning

--reset deletes every animal and adoption application in the database, and the photos the command saved, before it creates new ones.

shell
uv run manage.py seed --reset --accounts

You'll log in with these accounts in Step 5:

  • admin / rescue-admin, the admin account. If you already created admin, the command leaves it as it is.
  • volunteer / rescue-volunteer, in the Volunteers group.
  • employee / rescue-employee, in the Employees group.

The seed creates the Volunteers and Employees groups with their permissions, and puts each account in its group. Only the Employees group has manage_animal.

Step 2: Set Up the List Page for Adoption Applications

Employees need to see every application in one place. At the bottom of animals/admin.py, AdoptionApplicationAdmin is registered with its list columns, and the TODOs ask for a filter and a search box:

animals/admin.py
@admin.register(AdoptionApplication)
class AdoptionApplicationAdmin(admin.ModelAdmin):
    list_display = ["applicant_name", "animal", "status", "created_at"]
    # TODO: Add list_filter, to filter the list by status.
    # TODO: Add search_fields, to search by the applicant's name and email, and by
    # the animal's name across the foreign key (animal__name).

Replace the TODOs with list_filter and search_fields, like AnimalAdmin's, above it in animals/admin.py. The field names are the ones on AdoptionApplication in animals/models.py.

Step 3: Close Adopted Animals to Volunteers

An adopted animal's record is closed: only someone with the manage_animal permission should change it. In animals/admin.py, AnimalAdmin has get_readonly_fields(), as we learned in this chapter, and has_change_permission() below it:

animals/admin.py
    def get_readonly_fields(self, request, obj=None):
        fields = list(super().get_readonly_fields(request, obj))
        if not request.user.has_perm("animals.manage_animal"):
            fields.append("status")
        return fields

    def has_change_permission(self, request, obj=None):
        # obj is None when Django asks about the model as a whole, as the
        # admin's index and list pages do.
        # TODO: Return False when obj is adopted (Animal.Status.ADOPTED) and
        # the user doesn't have animals.manage_animal, checked as above.
        # Leave every other case to super(), below.
        return super().has_change_permission(request, obj)

Replace the TODO with an if and its return False. Only an adopted animal and a user without manage_animal stop there. Everything else reaches the last line, return super().has_change_permission(request, obj), Django's own check of "Can change animal". Our rule only narrows that check.

Why not return has_perm() for an adopted animal?

For our groups, the result is the same. But it replaces Django's check for an adopted animal instead of narrowing it: a user with manage_animal but without "Can change animal" could change only adopted animals. Returning False and leaving the rest to super() keeps Django's check for every animal.

has_change_permission() is called with the record on that record's page, and with obj=None for the list. When it returns False for a record the user can view, the admin shows that record's page with every field as text and no Save button.

Step 4: Run the Tests

shell
uv run pytest

All tests should pass.

Step 5: Use the Admin as an Employee and a Volunteer

Start the server with uv run manage.py runserver, and log in to the admin at http://127.0.0.1:8000/admin/ as employee.

  1. Click Adoption applications. The list has a filter for the status.
  2. Search for pepper. You should see the applications from Jose and Priya, who both applied for Pepper.
  3. Go to Animals, open Cobalt, set the status to Adopted, and click Save.
  4. Log out, and log in as volunteer. The admin has no Adoption applications.
  5. Open Cobalt. Every field is text, and there's no Save button.
  6. Open any other animal. Its status is text.

Success

The admin is ready for employees and volunteers if the tests pass, the applications list has its filter and search, and a volunteer can't change an adopted animal.

Troubleshooting Tips

A search test fails with FieldError

An entry in search_fields isn't a field on AdoptionApplication. The server doesn't check these names when it starts, so the error only shows up when a search runs. The animal's name is animal__name, with two underscores:

  • animal_name gives Cannot resolve keyword 'animal_name' into field.
  • animal alone gives Unsupported lookup 'icontains' for ForeignKey.
Admin tests fail with FieldDoesNotExist, or the server prints SystemCheckError

A name in list_filter isn't a field on AdoptionApplication. Run uv run manage.py check to see which entry it is, and compare it with the field names in animals/models.py.

AttributeError: 'NoneType' object has no attribute 'status'

has_change_permission() read obj.status when obj is None, as it is for the admin's index and list pages. Check obj is not None before reading its status, as the comment above the TODO says.

As employee, the status is text

The Employees group was made before the manage_animal permission existed, so it doesn't have it.

  1. Log in as admin.
  2. Open Groups, then Employees.
  3. Add Animals | animal | Can change an animal's status, and change adopted animals, and click Save.
The admin refuses admin / rescue-admin

An admin account you created earlier keeps its own password, since the seed leaves it as it is. Log in with your password, or set a new one with uv run manage.py changepassword admin.

Compare with the Solution

shell
git diff solutions/ch04 -- animals/admin.py

Your migration is in animals/migrations/, which Git ignores, so the diff doesn't show it.

See the solution on GitHub: animals/admin.py.