PYTHON / DJANGO
The Django admin
Register models with ModelAdmin classes to get a staff CRUD interface, and tune the changelist and change form with list_display, fieldsets and inlines.
What you will learn
- Register a model in <app>/admin.py with admin.site.register or the @admin.register decorator
- Shape the changelist using list_display, list_filter, search_fields and ordering
- Shape the edit form using fieldsets, readonly_fields and inlines for related rows
- Read admin.site._registry: one model class maps to exactly one ModelAdmin instance
Understanding The Django admin
django.contrib.admin is not magic bolted onto Django, it is an ordinary installed app with its own model (LogEntry), URLconf, views and templates. Its views are generic: they read a model's _meta (field names, types, verbose names, relations) and generate a list page and an edit form from that metadata, which is why you get working create/read/update/delete pages without writing a single view. It only exists at the URL where you mount it, normally path("admin/", admin.site.urls) in the root urls.py.
The piece that decides what appears there is a registry. When the admin app starts, its AppConfig.ready() runs autodiscover, which imports the module named admin from every app in INSTALLED_APPS; the import side effect is calls to admin.site.register(Model, SomeAdmin), which fill a dict mapping model class to a single ModelAdmin instance. Nothing scans your models directory, so a model is invisible in the admin until some imported module registers it, and a file called admins.py or a model registered in models.py of an app that is not installed will never be picked up.
A ModelAdmin is configuration, not code you call. Attributes like list_display, list_filter, search_fields and list_per_page configure the changelist view, while fields, fieldsets, readonly_fields, prepopulated_fields and inlines configure the change form's underlying ModelForm. Because it is declarative, Django validates it with the admin system checks at startup, so a typo in list_display stops the server with an admin.E1xx error instead of failing when someone opens the page. On top of that, every page is gated by request.user.is_staff plus the per-model add/change/delete/view permissions, so the admin is a tool for trusted staff rather than an end-user interface.
import django
from django.conf import settings
# A real project puts this in settings.py; configured inline so the file runs alone.
settings.configure(
INSTALLED_APPS=[
"django.contrib.contenttypes",
"django.contrib.auth",
"django.contrib.admin.apps.SimpleAdminConfig",
],
DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}},
)
django.setup()
from django.contrib import admin
from django.db import models
class Book(models.Model):
title = models.CharField(max_length=200)
author = models.CharField(max_length=100)
published = models.DateField()
in_stock = models.BooleanField(default=True)
class Meta:
app_label = "library"
def __str__(self):
return self.title
admin.register(Book)
class BookAdmin(admin.ModelAdmin):
list_display = ("title", "author", "published", "in_stock")
list_filter = ("in_stock", "published")
search_fields = ("title", "author")
ordering = ("title",)
model_admin = admin.site._registry[Book]
print("registered models:", [m.__name__ for m in admin.site._registry])
print("ModelAdmin in use:", type(model_admin).__name__)
print("changelist columns:", model_admin.get_list_display(request=None))
print("sidebar filters:", model_admin.list_filter)
print("default form fields:", [f.name for f in Book._meta.fields if not f.primary_key])The admin is a generic set of views driven by model metadata plus a per-model ModelAdmin registered in admin.site, so you configure pages declaratively instead of writing them.
Worked examples
A computed column in list_display
Shows that list_display entries may be ModelAdmin methods, with @admin.display supplying the column header and sort key.
import django
from django.conf import settings
settings.configure(
INSTALLED_APPS=[
"django.contrib.contenttypes",
"django.contrib.auth",
"django.contrib.admin.apps.SimpleAdminConfig",
],
DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}},
)
django.setup()
from decimal import Decimal
from django.contrib import admin
from django.db import models
class Order(models.Model):
reference = models.CharField(max_length=20)
total = models.DecimalField(max_digits=8, decimal_places=2)
paid = models.BooleanField(default=False)
class Meta:
app_label = "shop"
class OrderAdmin(admin.ModelAdmin):
list_display = ("reference", "total", "total_with_vat", "paid")
admin.display(description="Total inc. VAT", ordering="total")
def total_with_vat(self, obj):
return obj.total * Decimal("1.2")
admin.site.register(Order, OrderAdmin)
order_admin = admin.site._registry[Order]
row = Order(reference="A-1001", total=Decimal("50.00"), paid=True)
print(order_admin.get_list_display(request=None))
print(order_admin.total_with_vat(row))
print(OrderAdmin.total_with_vat.short_description)
print(OrderAdmin.total_with_vat.admin_order_field)Example explained
Line 1"total_with_vat" is not a model field, so the changelist resolves it as a method on the ModelAdmin and calls it with each row object.
Line 2@admin.display stores short_description on the function, and that string becomes the column header instead of the method name.
Line 3ordering="total" sets admin_order_field, which is what makes the header clickable: the sort happens in SQL on the real column, not on the computed value.
Line 4The method receives the object, so it can combine fields, but it runs once per row and cannot be sorted or filtered by itself.
Grouping the change form with fieldsets
Demonstrates how fieldsets decide which fields reach the form and how a non-editable field must be listed in readonly_fields.
import django
from django.conf import settings
settings.configure(
INSTALLED_APPS=[
"django.contrib.contenttypes",
"django.contrib.auth",
"django.contrib.admin.apps.SimpleAdminConfig",
],
DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}},
)
django.setup()
from django.contrib import admin
from django.contrib.admin.utils import flatten_fieldsets
from django.db import models
class Article(models.Model):
title = models.CharField(max_length=200)
slug = models.SlugField()
body = models.TextField()
status = models.CharField(max_length=10, default="draft")
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
app_label = "blog"
admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
fieldsets = (
(None, {"fields": ("title", "slug", "body")}),
("Publication", {"fields": ("status", "created_at"), "classes": ("collapse",)}),
)
readonly_fields = ("created_at",)
article_admin = admin.site._registry[Article]
for heading, opts in article_admin.get_fieldsets(None):
print(f"{heading or '(main)'}: {list(opts['fields'])} classes={opts.get('classes', ())}")
print("readonly:", article_admin.readonly_fields)
print("on the form:", flatten_fieldsets(article_admin.get_fieldsets(None)))Example explained
Line 1A fieldset is a (heading, options) pair; a heading of None renders the first block with no title.
Line 2flatten_fieldsets shows the exact field list the admin passes to modelform_factory, so anything missing from fieldsets is simply not part of the form.
Line 3created_at uses auto_now_add, which makes it non-editable, so it can only appear if it is also in readonly_fields, where it renders as plain text.
Line 4"collapse" is a CSS class the admin's JavaScript reacts to; it hides the block behind a Show link rather than changing what is saved.
Editing related rows with an inline
Shows how a TabularInline attaches a child model's rows to the parent's change form and finds the foreign key by itself.
import django
from django.conf import settings
settings.configure(
INSTALLED_APPS=[
"django.contrib.contenttypes",
"django.contrib.auth",
"django.contrib.admin.apps.SimpleAdminConfig",
],
DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}},
)
django.setup()
from django.contrib import admin
from django.db import models
class Author(models.Model):
name = models.CharField(max_length=100)
class Meta:
app_label = "library"
class Book(models.Model):
author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="books")
title = models.CharField(max_length=200)
class Meta:
app_label = "library"
class BookInline(admin.TabularInline):
model = Book
extra = 2
admin.register(Author)
class AuthorAdmin(admin.ModelAdmin):
inlines = [BookInline]
author_admin = admin.site._registry[Author]
for inline_cls in author_admin.get_inlines(request=None, obj=None):
fk = inline_cls.model._meta.get_field("author")
print(inline_cls.__name__, "->", inline_cls.model.__name__)
print("blank rows:", inline_cls.extra)
print("parent link:", fk.name, "->", fk.related_model.__name__)Example explained
Line 1An inline declares only model; Django locates the single ForeignKey pointing back to the parent, and you need fk_name only when there are two such keys.
Line 2extra = 2 renders two empty child rows, which are ignored on save if left blank because the inline formset skips unchanged empty forms.
Line 3get_inlines returns the classes, and the admin instantiates them per request so it can hide an inline the user has no permission to change.
Line 4Parent and children are submitted in one POST, so the Author and its Books are created together in a single transaction.
Important notes
Using "Delete selected" on the changelist deletes through the queryset, so a custom Model.delete() override is never called; override delete_queryset() on the ModelAdmin if that logic matters.
A ForeignKey listed in list_display costs one extra query per row; add list_select_related or a select_related in get_queryset() to keep the page from doing dozens of round trips.
Common mistakes
Writing the ModelAdmin but never calling register (or naming the file admins.py): autodiscover imports only <app>/admin.py of installed apps, so the model silently never appears on the admin index and there is no error message.
Putting a ManyToManyField or a reverse relation name in list_display: the admin system check raises admin.E109 at startup and runserver refuses to boot, because one table cell cannot render many related rows.
Logging in with a user whose is_staff is False: the login form just repeats that you need a staff account, which beginners read as a wrong password and they reset it over and over.
Try it yourself
Change, predict, then run
Write an admin.py for a Task model with fields title, done, due_date and an assignee ForeignKey: show all four columns in the changelist, filter by done, search title, and put due_date and assignee in a collapsed "Scheduling" fieldset. Print get_list_display(None) and get_fieldsets(None) to confirm your configuration.
Open the Python workspaceCheck your understanding
A ModelAdmin sets fields = ("title", "author") for a model whose published DateField is required and has no default. What happens when a staff user adds a record through the admin?
- The form shows published anyway, because required model fields are always included.
- The form omits published and Django reports "This field is required" above the form.
- The form has no published field, and saving raises a database IntegrityError, because fields left out of the form are also left out of validation.
- The admin fills published with the current date before saving.
Show answer
fields controls which fields the admin passes to modelform_factory, so published is not on the form at all. The ModelForm's _post_clean calls full_clean() with the missing field excluded, so no "required" error is produced (making option 2 tempting) and a NULL reaches the column, where the NOT NULL constraint fails. If you restrict fields, every omitted field needs a default, null=True, or a value set in save_model().