ZetCode

Django admin site

last modified November 13, 2026 Python 3.15

The admin site is one of the best-known features of Django: from the models of a project it builds a complete interface for browsing, searching, and editing the data. In this article we register the models of a small book catalog, customize the list and the form of the pages with ModelAdmin, add an inline for the related records and our own actions, record who created an object, see how the admin respects permissions, and test it. The examples use Django 6.1 and Python 3.15.

The admin site

The package django.contrib.admin reads the metadata of the registered models and generates the pages for the list, the creation, the change, and the deletion of the objects. It is meant for the staff of a site, who maintain its content, and not for the visitors. It depends on the authentication, the sessions, and the messages frameworks, so django.contrib.auth, django.contrib.contenttypes, django.contrib.sessions, and django.contrib.messages must be installed; the project template of Django includes all of them. A user may enter the admin only when the flag is_staff is set, and what they may do inside is decided by the model permissions (see Django permissions and groups).

The example project

The project is created with uv; the steps are explained in Django introduction. The commands below create a project directory, install Django, generate the project with the configuration package config, start the app catalog, and create the folder demos for demo scripts.

$ mkdir admin
$ cd admin
$ uv init --bare
$ uv add django
$ uv run django-admin startproject config .
$ uv run python manage.py startapp catalog
$ mkdir demos

The trailing dot in startproject config . creates manage.py and the config package in the current directory instead of one level deeper. A new app is ignored by Django until it is listed in INSTALLED_APPS, so we add the app to the list in config/settings.py; the other entries are generated by startproject.

config/settings.py
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'catalog',
]

The listings in this article are of two kinds. A listing with a solid blue border and the label Project file is a file of the application: models, views, templates, tests, and settings. A listing with a dashed amber border and the label Demo script is a small helper in the demos folder; it sets up Django, runs a few lines of code, and prints the results, so that we can watch the behaviour. Demo scripts are not part of the application; we run one from the project directory with uv run python -m demos.name.

The app catalog contains authors, books, and reviews of the books. The admin is already connected to the project: the generated URL configuration has the pattern admin/, which is all that is needed to make the site available.

config/urls.py
"""
URL configuration for config project.

The `urlpatterns` list routes URLs to views. For more information please see:
    https://docs.djangoproject.com/en/6.1/topics/http/urls/
Examples:
Function views
    1. Add an import:  from my_app import views
    2. Add a URL to urlpatterns:  path('', views.home, name='home')
Class-based views
    1. Add an import:  from other_app.views import Home
    2. Add a URL to urlpatterns:  path('', Home.as_view(), name='home')
Including another URLconf
    1. Import the include() function: from django.urls import include, path
    2. Add a URL to urlpatterns:  path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path

urlpatterns = [
    path('admin/', admin.site.urls),
]

The documentation string at the top of the file is a part of what startproject writes; the only active code is the import of admin and the one URL pattern.

The models

An author has a name and a year of birth. A book belongs to an author, has a genre from a fixed set of choices, a price, a date of the publication, and a flag that tells whether it is in stock. The field created_by is the user who added the book; with editable=False it is left out of the forms, and we fill it ourselves in the admin. A review has a rating from 1 to 5 and an optional text. The relation from a book to an author uses PROTECT, so that an author with books cannot be deleted by accident.

catalog/models.py
from django.conf import settings
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models


class Author(models.Model):
    name = models.CharField(max_length=100, unique=True)
    born = models.PositiveSmallIntegerField(null=True, blank=True)

    class Meta:
        ordering = ['name']

    def __str__(self):
        return self.name


class Book(models.Model):
    class Genre(models.TextChoices):
        FICTION = 'fiction', 'Fiction'
        SCIFI = 'scifi', 'Science fiction'
        HISTORY = 'history', 'History'

    title = models.CharField(max_length=200)
    slug = models.SlugField(unique=True)
    author = models.ForeignKey(Author, on_delete=models.PROTECT, related_name='books')
    genre = models.CharField(max_length=20, choices=Genre, default=Genre.FICTION)
    price = models.DecimalField(max_digits=6, decimal_places=2)
    published = models.DateField()
    in_stock = models.BooleanField(default=True)
    created_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True,
                                   on_delete=models.SET_NULL, editable=False)

    class Meta:
        ordering = ['title']

    def __str__(self):
        return self.title


class Review(models.Model):
    book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name='reviews')
    rating = models.PositiveSmallIntegerField(
        validators=[MinValueValidator(1), MaxValueValidator(5)])
    text = models.TextField(blank=True)

    def __str__(self):
        return f'{self.book}: {self.rating}'

The choices come from the class Genre, a TextChoices enumeration; its labels are displayed in the admin. We generate and apply the migration, and create a superuser, the account which has all permissions. The command createsuperuser asks for the user name, the email address, and the password; with --noinput it takes the password from the environment variable DJANGO_SUPERUSER_PASSWORD.

$ uv run python manage.py makemigrations catalog
Migrations for 'catalog':
  catalog/migrations/0001_initial.py
    + Create model Author
    + Create model Book
    + Create model Review
$ uv run python manage.py migrate | tail -n 2
  Applying catalog.0001_initial... OK
  Applying sessions.0001_initial... OK
$ DJANGO_SUPERUSER_PASSWORD=admin-pass-123 uv run python manage.py createsuperuser --noinput --username root --email root@example.com
Superuser created successfully.

Registering models

A model appears in the admin when it is registered. The shortest form is admin.site.register(Author), which gives default pages that list the objects by their __str__(). To customize a model, we write a subclass of ModelAdmin and register it with the decorator @admin.register(Model). The module admin.py of an app is imported by Django at the startup, so the registrations happen automatically. The file below is the whole configuration of the admin for our app; the following sections explain its parts. At the top it sets three texts of the site: the heading of the pages, the title of the browser window, and the title of the index page.

catalog/admin.py
from django.contrib import admin, messages
from django.db.models import Avg, Count

from .models import Author, Book, Review

admin.site.site_header = 'Catalog administration'
admin.site.site_title = 'Catalog admin'
admin.site.index_title = 'Welcome to the catalog'


class ReviewInline(admin.TabularInline):
    model = Review
    extra = 1


@admin.register(Author)
class AuthorAdmin(admin.ModelAdmin):
    list_display = ('name', 'born', 'book_count')
    search_fields = ('name',)

    def get_queryset(self, request):
        return super().get_queryset(request).annotate(book_total=Count('books'))

    @admin.display(description='Books', ordering='book_total')
    def book_count(self, obj):
        return obj.book_total


@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
    list_display = ('title', 'author', 'genre', 'price', 'published', 'in_stock', 'rating')
    list_editable = ('price', 'in_stock')
    list_filter = ('genre', 'in_stock')
    search_fields = ('title', 'author__name')
    date_hierarchy = 'published'
    list_per_page = 10
    prepopulated_fields = {'slug': ('title',)}
    autocomplete_fields = ('author',)
    readonly_fields = ('created_by',)
    fieldsets = (
        (None, {'fields': ('title', 'slug', 'author')}),
        ('Details', {'fields': ('genre', 'price', 'published', 'in_stock')}),
        ('Audit', {'fields': ('created_by',), 'classes': ('collapse',)}),
    )
    inlines = [ReviewInline]
    actions = ['mark_in_stock', 'mark_out_of_stock']

    def get_queryset(self, request):
        return super().get_queryset(request).annotate(average_rating=Avg('reviews__rating'))

    @admin.display(description='Rating', ordering='average_rating')
    def rating(self, obj):
        if obj.average_rating is None:
            return '-'
        return f'{obj.average_rating:.1f}'

    def save_model(self, request, obj, form, change):
        if not change:
            obj.created_by = request.user
        super().save_model(request, obj, form, change)

    @admin.action(description='Mark selected books as in stock', permissions=['change'])
    def mark_in_stock(self, request, queryset):
        updated = queryset.update(in_stock=True)
        self.message_user(request, f'{updated} books marked as in stock', messages.SUCCESS)

    @admin.action(description='Mark selected books as out of stock', permissions=['change'])
    def mark_out_of_stock(self, request, queryset):
        updated = queryset.update(in_stock=False)
        self.message_user(request, f'{updated} books marked as out of stock', messages.WARNING)

The class ReviewInline lets us edit the reviews of a book on the page of the book. AuthorAdmin shows the number of the books of each author, and BookAdmin configures the list, the form, the inline, and the actions. With the file in place, we can start the development server and open http://127.0.0.1:8000/admin/ in the browser; the login form accepts the superuser that we have created.

$ uv run python manage.py runserver

The next sections check what the admin does with the help of the test client, which can log in as a user and request the admin pages like a browser would, so that the output is the same as the one the staff user gets.

Sample data

The seed script recreates the data used by the other scripts: seven authors, twelve books with their reviews, the superuser root, and the staff user viewer, who gets only the permissions to view the catalog. The scripts call the function seed() at the beginning, so each one starts from the same state. We run the module to see the counts.

demos/seed.py
import os

import django

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()

from datetime import date
from decimal import Decimal

from django.contrib.auth.models import Permission, User

from catalog.models import Author, Book, Review

BORN = {'Ursula K. Le Guin': 1929, 'Frank Herbert': 1920, 'Isaac Asimov': 1920, 'Mary Beard': 1955,
        'Peter Frankopan': 1971, 'Jane Austen': 1775, 'George Eliot': 1819}

BOOKS = [
    ('The Left Hand of Darkness', 'Ursula K. Le Guin', 'scifi', '9.99', date(2019, 3, 5), [5, 4]),
    ('The Dispossessed', 'Ursula K. Le Guin', 'scifi', '10.50', date(2020, 6, 1), [5]),
    ('A Wizard of Earthsea', 'Ursula K. Le Guin', 'fiction', '8.75', date(2020, 9, 12), []),
    ('Dune', 'Frank Herbert', 'scifi', '11.00', date(2021, 1, 20), [5, 5, 4]),
    ('Dune Messiah', 'Frank Herbert', 'scifi', '9.00', date(2021, 4, 2), [3]),
    ('Foundation', 'Isaac Asimov', 'scifi', '9.25', date(2021, 7, 7), [4]),
    ('I, Robot', 'Isaac Asimov', 'scifi', '8.50', date(2022, 2, 14), []),
    ('SPQR', 'Mary Beard', 'history', '14.90', date(2022, 5, 30), [4, 5]),
    ('The Silk Roads', 'Peter Frankopan', 'history', '13.40', date(2022, 10, 3), [4]),
    ('Persuasion', 'Jane Austen', 'fiction', '6.99', date(2023, 1, 9), []),
    ('Emma', 'Jane Austen', 'fiction', '7.25', date(2023, 3, 21), [3, 4]),
    ('Middlemarch', 'George Eliot', 'fiction', '9.80', date(2023, 8, 15), [5]),
]


def seed():
    """Recreate the sample data and the two staff users."""
    Review.objects.all().delete()
    Book.objects.all().delete()
    Author.objects.all().delete()
    User.objects.all().delete()

    root = User.objects.create_superuser('root', 'root@example.com', 'admin-pass-123')
    viewer = User.objects.create_user('viewer', password='viewer-pass-123', is_staff=True)
    viewer.user_permissions.add(*Permission.objects.filter(
        content_type__app_label='catalog', codename__startswith='view_'))

    for title, author, genre, price, published, ratings in BOOKS:
        author, _ = Author.objects.get_or_create(name=author, defaults={'born': BORN[author]})
        book = Book.objects.create(title=title, slug=title.lower().replace(',', '').replace(' ', '-'),
                                   author=author, genre=genre, price=Decimal(price),
                                   published=published)
        for rating in ratings:
            Review.objects.create(book=book, rating=rating)
    return root, viewer


if __name__ == '__main__':
    seed()
    print('authors:', Author.objects.count(), '| books:', Book.objects.count(),
          '| reviews:', Review.objects.count())
    print('users:', list(User.objects.values_list('username', 'is_staff', 'is_superuser')))

The user viewer has the flag is_staff, which lets them in, but only the three view_ permissions of the app catalog; we use him in the section about the permissions.

$ uv run python -m demos.seed
authors: 7 | books: 12 | reviews: 14
users: [('root', True, True), ('viewer', True, False)]

The change list

The page with the list of objects is configured by the attributes of BookAdmin. list_display names the columns; an item can be a field, a method of the model admin, or a method of the model. The method rating is decorated with @admin.display, which sets the title of the column and the field used for sorting; because of the annotation in get_queryset(), the average is computed by the database in the same query, and not once per row. list_editable turns the price and the stock flag into form fields that can be changed for all rows at once with the button Save. list_filter adds the sidebar with the filters, search_fields the search box, which looks up the title and, through author__name, the name of the author, and date_hierarchy the links for the years, months, and days. list_per_page is ten, so twelve books need two pages. The list of the authors counts the books in the same way as the ratings.

demos/pages.py
import os

import django

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()

from django.test import Client
from django.test.utils import setup_test_environment

setup_test_environment()

import logging
import re

from demos.seed import seed

logging.getLogger('django.request').setLevel(logging.ERROR)
root, viewer = seed()
client = Client()
client.force_login(root)


def get(url, **params):
    response = client.get(url, params)
    return response


def rows(response):
    return [row for row in re.findall(r'<th class="field-title"><a [^>]*>(.*?)</a>', response.content.decode())]


response = get('/admin/')
html = response.content.decode()
print('GET /admin/:', response.status_code, '|', re.search(r'<title>(.*?)</title>', html).group(1))
print('apps and models:', [(app['name'], [model['name'] for model in app['models']])
                     for app in response.context['app_list']])

response = get('/admin/catalog/book/')
print()
print('book list:', response.status_code, '| total:', response.context['cl'].result_count,
      '| pages:', response.context['cl'].paginator.num_pages)
print('columns:', [header['text'] for header in response.context['result_headers']][1:])
print('first page:', rows(response))

for label, params in [
        ('search "le guin"', {'q': 'le guin'}),
        ('search "dune"', {'q': 'dune'}),
        ('filter genre=history', {'genre__exact': 'history'}),
        ('filter genre=scifi', {'genre__exact': 'scifi'}),
        ('date_hierarchy year 2021', {'published__year': '2021'}),
        ('search + filter', {'q': 'asimov', 'genre__exact': 'scifi'})]:
    response = get('/admin/catalog/book/', **params)
    print(f'{label:26}', response.status_code, response.context['cl'].result_count, rows(response)[:3])

print()
print('authors with the number of books:')
response = get('/admin/catalog/author/')
for row in re.findall(r'<th class="field-name">.*?>(.*?)</a></th>.*?field-born[^>]*>(.*?)</td><td class="field-book_count">(.*?)</td>',
                      response.content.decode(), re.S):
    print(' ', row)

The script logs in the superuser and looks at the context of the list view, cl, which is a ChangeList. The index page uses the texts that we set on the site and lists the apps and the models. The list has twelve books on two pages, and its columns are ours; the admin has put the title in the first column and made it a link. The search finds the books by the title or by the author, the filters are the query parameters with the names of the fields, and several of them can be combined. The date_hierarchy narrows the list with published__year. The list of the authors shows the born year and the number of books from the annotation.

$ uv run python -m demos.pages
GET /admin/: 200 | Welcome to the catalog | Catalog admin
apps and models: [('Authentication and Authorization', ['Groups', 'Users']), ('Catalog', ['Authors', 'Books'])]

book list: 200 | total: 12 | pages: 2
columns: ['title', 'author', 'genre', 'price', 'published', 'in stock', 'Rating']
first page: ['A Wizard of Earthsea', 'Dune', 'Dune Messiah', 'Emma', 'Foundation', 'I, Robot', 'Middlemarch', 'Persuasion', 'SPQR', 'The Dispossessed']
search "le guin"           200 3 ['A Wizard of Earthsea', 'The Dispossessed', 'The Left Hand of Darkness']
search "dune"              200 2 ['Dune', 'Dune Messiah']
filter genre=history       200 2 ['SPQR', 'The Silk Roads']
filter genre=scifi         200 6 ['Dune', 'Dune Messiah', 'Foundation']
date_hierarchy year 2021   200 3 ['Dune', 'Dune Messiah', 'Foundation']
search + filter            200 2 ['Foundation', 'I, Robot']

authors with the number of books:
  ('Frank Herbert', '1920', '2')
  ('George Eliot', '1819', '1')
  ('Isaac Asimov', '1920', '2')
  ('Jane Austen', '1775', '2')
  ('Mary Beard', '1955', '1')
  ('Peter Frankopan', '1971', '1')
  ('Ursula K. Le Guin', '1929', '3')

The change form

The form of a book is configured with fieldsets, which group the fields under headings; the group with the class collapse is folded. The field created_by is in readonly_fields, which displays it but does not accept input. prepopulated_fields fills the slug from the title with JavaScript while the user types, and autocomplete_fields replaces the select box of the author with a search field that asks the admin of the author; it works because AuthorAdmin defines search_fields. The inline shows the reviews of the book as rows of a table below the form, and everything is saved in one request. The method save_model() is a hook: for a new object we set the author of the change before the object is saved.

The actions are the operations on the selected rows of the list. Django provides delete_selected; our functions are the methods decorated with @admin.action and listed in actions. An action gets the request and the queryset of the selected objects. The method message_user() uses the messages framework (see Django messages framework) to report the result. The argument permissions=['change'] shows the action only to users who have the change permission of the model.

The script sends the same data as the browser would send. The inline formset needs the hidden management fields that start with reviews-, because the form of the inline is a formset (see Django ModelForm). The script adds a book with a review, sends an invalid form, changes the price of the book, runs the actions on the history books, deletes a book with the two-step confirmation, and prints the history that the admin has recorded.

demos/changes.py
import os

import django

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()

from django.test import Client
from django.test.utils import setup_test_environment

setup_test_environment()

import logging
import re

from django.contrib.admin.models import LogEntry

from catalog.models import Author, Book
from demos.seed import seed

logging.getLogger('django.request').setLevel(logging.ERROR)
root, viewer = seed()
client = Client()
client.force_login(root)


def notice(response):
    html = response.content.decode()
    found = re.findall(r'<li class="(?:success|warning|error)">(.*?)</li>', html)
    return [re.sub(r'<[^>]+>', '', text) for text in found]


author = Author.objects.get(name='Frank Herbert')
add = {
    'title': 'Children of Dune', 'slug': 'children-of-dune', 'author': author.pk,
    'genre': 'scifi', 'price': '9.50', 'published': '2022-11-01', 'in_stock': 'on',
    'reviews-TOTAL_FORMS': '1', 'reviews-INITIAL_FORMS': '0',
    'reviews-MIN_NUM_FORMS': '0', 'reviews-MAX_NUM_FORMS': '1000',
    'reviews-0-rating': '4', 'reviews-0-text': 'Slow start',
    '_save': 'Save',
}
response = client.post('/admin/catalog/book/add/', add, follow=True)
print('add a book with a review:', response.status_code, notice(response))
book = Book.objects.get(slug='children-of-dune')
print('created_by:', book.created_by, '| reviews:', list(book.reviews.values_list('rating', 'text')))

bad = dict(add, slug='bad', title='', price='abc')
response = client.post('/admin/catalog/book/add/', bad)
print('invalid add:', response.status_code, '| errors:', dict(response.context['adminform'].form.errors))

edit = dict(add, price='12.00')
edit.update({'reviews-INITIAL_FORMS': '1', 'reviews-0-id': book.reviews.get().pk,
             'reviews-0-book': book.pk})
response = client.post(f'/admin/catalog/book/{book.pk}/change/', edit, follow=True)
book.refresh_from_db()
print('change the price:', response.status_code, notice(response), '| created_by kept:', book.created_by, '| price:', book.price)

ids = list(Book.objects.filter(genre='history').values_list('pk', flat=True))
response = client.post('/admin/catalog/book/', {
    'action': 'mark_out_of_stock', '_selected_action': ids}, follow=True)
print('action:', response.status_code, notice(response))
print('out of stock:', list(Book.objects.filter(in_stock=False).values_list('title', flat=True)))

response = client.post('/admin/catalog/book/', {
    'action': 'delete_selected', '_selected_action': [book.pk]})
print('delete_selected, step 1:', response.status_code, '| asks for confirmation:',
      'Are you sure' in response.content.decode())
response = client.post('/admin/catalog/book/', {
    'action': 'delete_selected', '_selected_action': [book.pk], 'post': 'yes'}, follow=True)
print('delete_selected, step 2:', response.status_code, notice(response))

print()
print('history of the admin (LogEntry):')
for entry in LogEntry.objects.order_by('action_time'):
    print(f'  {entry.user} {entry.get_action_flag_display():8} {entry.object_repr:18} {entry.get_change_message()}')

The successful save redirects to the list with a message. The book remembers the superuser who created it, and the later change does not replace that value, since save_model() sets it only when change is false. An invalid form shows the errors of the fields. The action updates two history books and reports it. The delete_selected action first asks for a confirmation and deletes the object only when the confirmed form is posted. At the end the table LogEntry, which the admin fills by itself, lists what has happened: who did it, what kind of action, to which object, and a description generated from the form, such as which fields were changed.

$ uv run python -m demos.changes
add a book with a review: 200 ['The book “Children of Dune” was added successfully.']
created_by: root | reviews: [(4, 'Slow start')]
invalid add: 200 | errors: {'title': ['This field is required.'], 'price': ['Enter a number.']}
change the price: 200 ['The book “Children of Dune” was changed successfully.'] | created_by kept: root | price: 12.00
action: 200 ['2 books marked as out of stock']
out of stock: ['SPQR', 'The Silk Roads']
delete_selected, step 1: 200 | asks for confirmation: True
delete_selected, step 2: 200 ['Successfully deleted 1 book.']

history of the admin (LogEntry):
  root Addition Children of Dune   Added. Added review “Children of Dune: 4”.
  root Change   Children of Dune   Changed Price.
  root Deletion Children of Dune   

Admin and permissions

The admin has no permission system of its own: it uses the model permissions add, change, delete, and view. A user without the is_staff flag is sent to the login form even if they have all permissions. A staff user sees only the models they have a permission for, and the buttons of the forbidden operations are not rendered. The methods has_add_permission(), has_change_permission(), has_delete_permission(), and has_view_permission() of ModelAdmin can be overridden for rules that depend on the object or the request.

demos/permissions.py
import os

import django

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()

from django.test import Client
from django.test.utils import setup_test_environment

setup_test_environment()

import logging

from django.contrib.auth.models import User

from catalog.models import Book
from demos.seed import seed

logging.getLogger('django.request').setLevel(logging.ERROR)
root, viewer = seed()
client = Client()
client.force_login(viewer)

print('permissions of viewer:', sorted(viewer.get_all_permissions()))
for label, method, url in [
        ('index', 'get', '/admin/'),
        ('book list', 'get', '/admin/catalog/book/'),
        ('add book', 'get', '/admin/catalog/book/add/'),
        ('change book', 'get', f'/admin/catalog/book/{Book.objects.first().pk}/change/'),
        ('auth users', 'get', '/admin/auth/user/')]:
    response = getattr(client, method)(url)
    print(f'{label:12} {response.status_code}')

html = client.get('/admin/catalog/book/').content.decode()
print('"Add book" button:', 'addlink' in html, '| actions drop-down:', 'name="action"' in html)

anonymous = Client()
response = anonymous.get('/admin/catalog/book/')
print('anonymous:', response.status_code, response['Location'])

plain = Client()
user = User.objects.create_user('plain', password='plain-pass-123')
plain.force_login(user)
response = plain.get('/admin/catalog/book/')
print('not staff:', response.status_code, response['Location'])

The user viewer can open the index and the list of books and the page of a book, which is displayed read-only, but gets 403 for the page to add a book and for the users, which belong to another app. The list has no button to add a book and no drop-down with the actions, since all our actions require the change permission. Anonymous visitors and users who are not staff are redirected to the login page of the admin.

$ uv run python -m demos.permissions
permissions of viewer: ['catalog.view_author', 'catalog.view_book', 'catalog.view_review']
index        200
book list    200
add book     403
change book  200
auth users   403
"Add book" button: False | actions drop-down: False
anonymous: 302 /admin/login/?next=/admin/catalog/book/
not staff: 302 /admin/login/?next=/admin/catalog/book/

Testing the admin

The admin pages are views like any others, so they can be tested with the client. The tests log in the superuser with force_login() and check the average rating in the list, the search through the relation, the count of books on the page of the authors, the field created_by, the action, the entry in the history, and the access of the user who can only view.

catalog/tests.py
from django.contrib.admin.models import LogEntry
from django.contrib.auth.models import Permission, User
from django.test import TestCase
from django.urls import reverse

from .models import Author, Book, Review


class AdminTests(TestCase):

    @classmethod
    def setUpTestData(cls):
        cls.root = User.objects.create_superuser('root', 'root@example.com', 'x')
        cls.viewer = User.objects.create_user('viewer', password='x', is_staff=True)
        cls.viewer.user_permissions.add(Permission.objects.get(codename='view_book'))
        cls.author = Author.objects.create(name='Ursula K. Le Guin')
        cls.book = Book.objects.create(title='Dune', slug='dune', author=cls.author,
                                       genre='scifi', price='9.00', published='2020-01-01')
        Review.objects.create(book=cls.book, rating=5)
        Review.objects.create(book=cls.book, rating=4)

    def setUp(self):
        self.client.force_login(self.root)

    def test_changelist_shows_the_average_rating(self):
        response = self.client.get(reverse('admin:catalog_book_changelist'))
        self.assertContains(response, '4.5')

    def test_search_by_author_name(self):
        response = self.client.get(reverse('admin:catalog_book_changelist'), {'q': 'guin'})
        self.assertEqual(response.context['cl'].result_count, 1)
        response = self.client.get(reverse('admin:catalog_book_changelist'), {'q': 'asimov'})
        self.assertEqual(response.context['cl'].result_count, 0)

    def test_author_list_counts_books(self):
        response = self.client.get(reverse('admin:catalog_author_changelist'))
        self.assertContains(response, '<td class="field-book_count">1</td>', html=True)

    def test_add_sets_created_by(self):
        data = {'title': 'Emma', 'slug': 'emma', 'author': self.author.pk, 'genre': 'fiction',
                'price': '7.25', 'published': '2023-03-21', 'in_stock': 'on',
                'reviews-TOTAL_FORMS': '0', 'reviews-INITIAL_FORMS': '0',
                'reviews-MIN_NUM_FORMS': '0', 'reviews-MAX_NUM_FORMS': '1000'}
        self.client.post(reverse('admin:catalog_book_add'), data)
        self.assertEqual(Book.objects.get(slug='emma').created_by, self.root)

    def test_action_marks_books(self):
        self.client.post(reverse('admin:catalog_book_changelist'), {
            'action': 'mark_out_of_stock', '_selected_action': [self.book.pk]})
        self.book.refresh_from_db()
        self.assertFalse(self.book.in_stock)

    def test_actions_are_logged(self):
        self.client.post(reverse('admin:catalog_book_change', args=[self.book.pk]), {
            'title': 'Dune', 'slug': 'dune', 'author': self.author.pk, 'genre': 'scifi',
            'price': '10.00', 'published': '2020-01-01', 'in_stock': 'on',
            'reviews-TOTAL_FORMS': '0', 'reviews-INITIAL_FORMS': '0',
            'reviews-MIN_NUM_FORMS': '0', 'reviews-MAX_NUM_FORMS': '1000'})
        entry = LogEntry.objects.get()
        self.assertEqual(entry.object_repr, 'Dune')
        self.assertIn('Price', entry.get_change_message())

    def test_viewer_can_list_but_not_add(self):
        self.client.force_login(self.viewer)
        self.assertEqual(self.client.get(reverse('admin:catalog_book_changelist')).status_code, 200)
        self.assertEqual(self.client.get(reverse('admin:catalog_book_add')).status_code, 403)

    def test_anonymous_user_is_sent_to_the_admin_login(self):
        self.client.logout()
        response = self.client.get(reverse('admin:catalog_book_changelist'))
        self.assertEqual(response.status_code, 302)
        self.assertIn('/admin/login/', response['Location'])

All eight tests pass. The tests use the names of the admin URLs, such as admin:catalog_book_changelist, which have the form admin:<app>_<model>_<page>.

$ uv run python manage.py test
Found 8 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
........
----------------------------------------------------------------------
Ran 8 tests in 0.579s

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

Source

Django documentation: The Django admin site

Django documentation: Admin actions

Django documentation: Customize the admin site

In this article we have registered models in the admin site, configured the list with list_display, filters, and search, built a form with fieldsets and an inline, written actions and a save_model() hook, seen the permissions at work, and tested the pages. Related articles are Django permissions and groups, Django custom user model, and Django models.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Django tutorials.