Models & the admin
A Django model is a Python class that maps to a database table. Define it once and you get the table, a query API, forms and an admin screen for free.
A model worth shipping
# shop/models.py
from django.db import models
from django.urls import reverse
class Category(models.Model):
name = models.CharField(max_length=60, unique=True)
slug = models.SlugField(max_length=60, unique=True)
class Meta:
verbose_name_plural = "categories"
ordering = ["name"]
def __str__(self):
return self.name
class Product(models.Model):
name = models.CharField(max_length=120)
slug = models.SlugField(max_length=140, unique=True)
category = models.ForeignKey(Category, on_delete=models.PROTECT, related_name="products")
price = models.DecimalField(max_digits=10, decimal_places=2)
gst_rate = models.DecimalField(max_digits=4, decimal_places=2, default=18)
in_stock = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at"]
indexes = [models.Index(fields=["category", "in_stock"])]
constraints = [
models.CheckConstraint(condition=models.Q(price__gt=0), name="price_positive"),
]
def __str__(self):
return self.name
def price_with_gst(self):
return self.price * (1 + self.gst_rate / 100)
def get_absolute_url(self):
return reverse("shop:product_detail", args=[self.slug])
CheckConstraint(condition=...) is the Django 5.1 spelling; older code used check=. Note DecimalField for money —
never FloatField, which will quietly turn 1999.99 into 1999.9899999.
Choosing fields and delete behaviour
| Need | Field |
|---|---|
| Short text | CharField(max_length=...) |
| Long text | TextField() |
| Money | DecimalField(max_digits, decimal_places) |
| Whole numbers | IntegerField, PositiveIntegerField |
| Yes/no | BooleanField |
| Timestamps | DateTimeField(auto_now_add=True) for created, auto_now=True for updated |
| Links between tables | ForeignKey, ManyToManyField, OneToOneField |
on_delete is a business decision: CASCADE deletes the children with the parent (order items with the order),
PROTECT refuses to delete a category that still has products, SET_NULL keeps the row and blanks the link.
Migrations
python manage.py makemigrations shop
python manage.py sqlmigrate shop 0001 # see the SQL before it runs
python manage.py migrate
python manage.py createsuperuser
Migration files are code: commit them, review them, and never edit a migration that has already run on a shared database — write a new one instead.
An admin people can actually work in
# shop/admin.py
from django.contrib import admin
from .models import Category, Product
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
list_display = ("name", "category", "price", "in_stock", "created_at")
list_filter = ("in_stock", "category")
search_fields = ("name", "slug")
list_editable = ("price", "in_stock")
list_select_related = ("category",)
prepopulated_fields = {"slug": ("name",)}
date_hierarchy = "created_at"
actions = ["mark_out_of_stock"]
@admin.action(description="Mark selected products out of stock")
def mark_out_of_stock(self, request, queryset):
updated = queryset.update(in_stock=False)
self.message_user(request, f"{updated} products marked out of stock.")
admin.site.register(Category)
Ten minutes of ModelAdmin configuration often removes the need for an internal CRUD app entirely. Use
admin.TabularInline to edit order lines inside the order screen.
Common mistakes
FloatFieldfor prices; rounding errors show up in GST totals.- No
__str__, so every dropdown reads "Product object (17)". - Forgetting
related_name, then writingproduct_seteverywhere. - Changing a field and not running
makemigrations, so production and code drift apart. - Giving staff full admin access instead of a group with only the permissions they need.
Practice
Model a sweet shop: Category, Sweet (price, weight in grams, shelf life days) and Batch (sweet, made_on,
quantity). Add a constraint that quantity cannot be negative, register all three in the admin with useful
list_display and filters, and add an action that marks a batch as expired.