Exercise: Employees & Volunteers in the Admin
You'll:
- migrate the
photofield and themanage_animalpermission, and load sample data with accounts for the admin - set up the list page for adoption applications
- finish
AnimalAdmin'shas_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 themanage_animalpermission on the models, and the admin's registrations, list settings, and review table AnimalAdmin'sget_readonly_fields(), as we learned in this chapteranimals/formsets.py, whoseOneApprovalFormSetrefuses a save that approves two applications for one animal- the full
seedcommand, with photos, application statuses, and the accounts, andseed_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:
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, addsMEDIA_ROOTandMEDIA_URL)config/urls.py(modified, serves uploaded files whileDEBUGis on)accounts/admin.py(modified, registers the user model withUserAdmin)animals/models.py(modified, adds thephotofield and themanage_animalpermission toAnimal, andsave()andclean()toAdoptionApplication)animals/admin.py(modified, registers both models, with a review table of applications on an animal's page and a read-only status withoutmanage_animal; you'll finish the list page for applications andhas_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--resetand--accountsoptions)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 foranimals/admin.py)README.md(modified, describes the admin andseed_photos/)
Step 1: Migrate and Load the Sample Data
The photo field and the manage_animal permission need a migration. Generate and apply it:
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.
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 createdadmin, 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:
@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:
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
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.
- Click Adoption applications. The list has a filter for the status.
- Search for
pepper. You should see the applications from Jose and Priya, who both applied for Pepper. - Go to Animals, open Cobalt, set the status to Adopted, and click Save.
- Log out, and log in as
volunteer. The admin has no Adoption applications. - Open Cobalt. Every field is text, and there's no Save button.
- 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_namegivesCannot resolve keyword 'animal_name' into field.animalalone givesUnsupported 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.
- Log in as
admin. - Open Groups, then Employees.
- 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
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.