Django: Authentication,
Models & Data
The complete reference — from class User(models.Model) to a fully-modelled ISP ERP. No assumptions. Analogy-first. Every section is a tool you keep.
models and Model — the inventory most documentation never shows youWhat is inside models
When you write from django.db import models, you pick up a namespace — a toolbox — containing every database-related tool Django wants to give you. It is not one thing. It is five categories of things organised under one roof so you only need one import.
django.db is a package (a folder of Python files). models is a sub-package inside it. When you import it, Python runs its __init__.py, which pulls in field classes, the Model base class, query tools, and everything else — all available on the models name. The dot in models.CharField means: reach inside the models namespace and retrieve CharField. There is nothing magical about the dot.
Category 1 — Field classes
The things you put on your models. Each is a Python class that knows what Python type it holds, what SQL column type it maps to, and how to validate its value. When you write title = models.CharField(max_length=200), you are instantiating a field class and assigning it as a class attribute. The ModelBase metaclass picks it up and registers it in the table schema.
# The field classes inside models — what you use constantly
models.CharField(max_length=n) # short text → VARCHAR(n)
models.TextField() # unlimited text → TEXT
models.EmailField() # email + validation → VARCHAR(254)
models.URLField() # URL + validation
models.SlugField() # URL-safe text
models.IntegerField() # whole numbers → INTEGER
models.PositiveIntegerField() # whole numbers ≥ 0
models.BigIntegerField() # large integers → BIGINT
models.DecimalField(max_digits, decimal_places)# exact money → NUMERIC
models.FloatField() # approximate → FLOAT (not for money)
models.BooleanField() # True/False → BOOLEAN
models.DateField() # date only → DATE
models.DateTimeField() # date + time → TIMESTAMP
models.DurationField() # timedelta → INTERVAL
models.JSONField() # dict/list → JSONB (Postgres)
models.UUIDField() # UUID → UUID
models.GenericIPAddressField() # IPv4 or IPv6
models.FileField(upload_to='...') # stores path, not file
models.ImageField() # FileField + image validation
# — Relationship fields —
models.ForeignKey(to, on_delete) # many-to-one → FK column
models.OneToOneField(to, on_delete) # one-to-one → unique FK column
models.ManyToManyField(to) # many-to-many → junction table
Category 2 — Query expression tools
Used in queryset calls to build SQL logic that goes beyond simple equality. These are not field classes — they are expression builders.
# Query tools inside models
models.Q # complex OR / AND / NOT: Q(status='open') | Q(priority='p1')
models.F # reference another column: F('speed_down') * 2
models.Value # wrap a literal in an expression
models.Case # SQL CASE WHEN … THEN … END
models.When # individual condition inside Case
models.Subquery # embed a queryset as a correlated subquery
models.Exists # subquery that resolves to True/False
models.OuterRef # reference the outer query from inside a Subquery
models.ExpressionWrapper # wrap an expression with an explicit output type
# ISP example — flag connections running below contracted speed
from django.db.models import F, Case, When, Value, CharField
Connection.objects.annotate(
health=Case(
When(actual_speed__lt=F('contracted_speed'), then=Value('degraded')),
default=Value('healthy'),
output_field=CharField(),
)
)
Category 3 — Aggregation functions
Used with .aggregate() and .annotate() to collapse rows into computed values. These are also inside models and importable directly from django.db.models.
# Aggregation functions inside models
models.Sum # total of a column
models.Count # count rows
models.Avg # average value
models.Max # maximum value
models.Min # minimum value
models.StdDev # standard deviation
models.Variance # statistical variance
# ISP billing example
from django.db.models import Sum, Count, Avg
Invoice.objects.filter(status='paid').aggregate(
total_collected = Sum('amount'),
invoice_count = Count('id'),
average_invoice = Avg('amount'),
)
# → {'total_collected': Decimal('8430000'), 'invoice_count': 47, 'average_invoice': ...}
Category 4 — Index and constraint classes
Used inside the inner Meta class to define database-level structural constraints. They live in models and generate SQL DDL when migrations run.
models.Index(fields=[...], name='...') # CREATE INDEX
models.UniqueConstraint(fields=[...], name='...') # composite UNIQUE constraint
models.CheckConstraint(check=Q(...), name='...') # SQL CHECK constraint
# Example — enforce no duplicate invoice per customer per billing period
class Meta:
constraints = [
models.UniqueConstraint(
fields=['customer', 'period'],
name='unique_customer_period_invoice',
),
models.CheckConstraint(
check=Q(amount__gt=0),
name='invoice_amount_positive',
),
]
indexes = [
models.Index(fields=['status', 'due_date'], name='invoice_status_due_idx'),
]
Category 5 — Manager and QuerySet base classes
models.Manager # base class for all managers — inherit to write a custom one
models.QuerySet # the lazy SQL builder — what .filter() and .all() return
# Writing a custom Manager by inheriting from models.Manager
class ActiveConnectionManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(status='active')
class Connection(models.Model):
status = models.CharField(max_length=20)
objects = models.Manager() # default: all rows
active = ActiveConnectionManager() # filtered: active rows only
# Now you can do:
Connection.active.all() # only active connections
Connection.objects.all() # all connections
Category 6 — The Model class itself
The centrepiece of the package. Every model you write inherits from it. It is one item in the toolbox — but it is the item everything else exists to support. What lives inside it is the subject of the next section.
models.Model # the base class — import and inherit to make any model
models, you almost never need a second import for database-related work. from django.db import models gives you fields, relationships, Q, F, aggregation, constraints, indexes, Manager, QuerySet, and Model — all at once. The only exceptions are things like from django.db.models import Sum when you want shorter names.
What is inside Model
When your class inherits from models.Model, it silently acquires everything listed below. You did not write any of it. It arrived through inheritance. This is the complete inventory.
Think of Model as a very experienced colleague who has already solved every generic database problem once. When you write class Invoice(models.Model), you are saying: I want all the solutions that colleague has already built. The three fields and two methods you write are just the parts that are specific to your problem. The colleague's hundred solutions come for free.
1 — save(*args, **kwargs)
The most-called method. Decides whether to run INSERT (new object, pk is None) or UPDATE (existing object, pk is set). Fires pre_save and post_save signals around the database operation. Override it to compute derived fields before every save.
def save(self, *args, **kwargs):
self.slug = slugify(self.name) # compute before saving
super().save(*args, **kwargs) # always call super() — never skip it
# update_fields — write only the columns that changed
invoice.save(update_fields=['status', 'paid_at'])
# → UPDATE billing_invoice SET status=…, paid_at=… WHERE id=… (not all columns)
2 — delete(*args, **kwargs)
Deletes the row. Also traverses every ForeignKey pointing at this object and applies the on_delete behaviour declared on each one — cascades, nullifies, or blocks. Fires pre_delete and post_delete signals. Note: calling .delete() on a QuerySet is a different path — it is a mass DELETE that does not fire instance-level signals.
3 — objects — the default Manager
objects is not defined on Invoice. It arrives through Model. It is an instance of Manager that Django attaches to every model class at class creation time. It is the entry point for all queries. When you write a custom manager and assign it to objects, you replace this default.
Invoice.objects.all() # SELECT * FROM billing_invoice
Invoice.objects.filter(...) # SELECT … WHERE …
Invoice.objects.get(id=1) # exactly one row or exception
Invoice.objects.create(...) # INSERT + return instance
Invoice.objects.count() # SELECT COUNT(*)
Invoice.objects.exists() # SELECT 1 LIMIT 1 — fastest existence check
4 — pk and the automatic id field
Unless you declare a field with primary_key=True, Model automatically adds id = AutoField(primary_key=True) to your table — an auto-incrementing integer. pk is a shortcut alias that always points to whichever field is the primary key, regardless of what you named it. So instance.pk and instance.id are the same thing in most projects.
5 — _meta — the Options object
The internal metadata store. Every model class gets one. It is how the admin, migrations, serialisers, and forms understand your model's structure without having to inspect it manually. You read it when you need to introspect a model programmatically.
Invoice._meta.fields # list of all field instances on this model
Invoice._meta.db_table # 'billing_invoice' — the actual SQL table name
Invoice._meta.app_label # 'billing' — the app this model belongs to
Invoice._meta.get_field('amount') # retrieve a specific field object by name
Invoice._meta.pk # the primary key field instance
Invoice._meta.ordering # the default ordering list from Meta
Invoice._meta.indexes # list of Index objects
Invoice._meta.constraints # list of Constraint objects
Invoice._meta.verbose_name # 'Invoice'
Invoice._meta.verbose_name_plural # 'Invoices'
Invoice._meta.abstract # True if no database table
# Practical use — dynamically list all field names on a model
field_names = [f.name for f in Invoice._meta.fields]
6 — full_clean(), clean(), clean_fields(), validate_unique()
The validation chain. full_clean() calls the others in order: validate field types → run your custom logic → check unique constraints. The admin and ModelForm call full_clean() automatically. A raw save() does not. If you want validation enforced regardless of how save is called, put self.full_clean() inside your save() override.
class Invoice(models.Model):
amount = models.DecimalField(max_digits=12, decimal_places=2)
due_date = models.DateField()
def clean(self):
# Custom cross-field validation — called by full_clean()
from django.core.exceptions import ValidationError
from django.utils import timezone
if self.due_date and self.due_date < timezone.now().date():
raise ValidationError({'due_date': 'Due date cannot be in the past'})
if self.amount <= 0:
raise ValidationError({'amount': 'Amount must be positive'})
7 — __init__(**kwargs)
The constructor. Runs when you write Invoice(customer=c, amount=150000). Sets up the instance, assigns field values, and records the original state of each field so Django knows what changed before the next save(). You almost never override this — override save() or use post_init signals instead.
8 — __str__()
The default returns something useless like Invoice object (1). You always override it. The output appears in the admin list view, in the shell, in str(instance), and in ForeignKey select widgets in forms.
def __str__(self):
return f'INV-{self.pk:06d} | {self.customer} | UGX {self.amount:,.0f}'
# → 'INV-000047 | [email protected] | UGX 150,000'
9 — __eq__() and __hash__()
Two instances are equal if they are the same model class and have the same pk. This means you can put model instances in Python sets, use them as dictionary keys, and compare them with == — and it will behave exactly as you expect. You almost never need to override either of these.
10 — refresh_from_db(fields=None)
Re-reads this object's data from the database, discarding whatever is held in memory. Essential in multi-process systems where another worker may have updated the row since you loaded it. Pass fields=['status'] to only refresh specific columns.
invoice = Invoice.objects.get(id=42)
# ... time passes, another process marks it paid ...
invoice.refresh_from_db() # re-reads the whole row
invoice.refresh_from_db(fields=['status']) # re-reads only status
11 — get_absolute_url()
Not defined on Model by default — but expected as a convention. The admin's "View on site" button calls it. Template tags like {% url %} work with it. You define it yourself using reverse(). Every model that has a detail page should have one.
def get_absolute_url(self):
from django.urls import reverse
return reverse('billing:invoice-detail', args=[self.pk])
12 — ModelBase — the metaclass that runs at class creation time
This is the deepest layer. A metaclass is a class whose job is to build other classes. ModelBase is the metaclass of Model — it intercepts the creation of every class that inherits from Model. It runs before your code ever creates a single instance. When Python reads:
class Invoice(models.Model):
amount = models.DecimalField(max_digits=12, decimal_places=2)
ModelBase.__new__ intercepts it and does all of this before your application even starts:
1. Collects every field instance you declared as a class attribute (amount, due_date, etc.) and removes them from the class dict — they are stored properly in _meta instead.
2. Builds the _meta object (the Options instance) and populates it with field lists, table name, ordering, and all other metadata.
3. Contributes the default objects Manager to the class if you did not supply one.
4. Registers the model in Django's central app registry — which is how makemigrations, the admin, and apps.get_model() can find it.
5. Sets up reverse accessor names for all ForeignKey and ManyToMany relationships on the model.
This is why Invoice is a fully database-aware entity the moment Python finishes reading the class definition — before you instantiate it once. Three lines of your code. Several hundred lines of ModelBase.
save() — writes to the database. delete() — removes from the database. objects — queries the database. pk — the primary key, however it is named. _meta — everything about the model's structure. full_clean() — validates before saving. __init__ — sets up an instance. __str__ — readable representation. __eq__ — equality by pk. refresh_from_db() — re-read from database. ModelBase — wires everything together when the class is defined.
The inheritance chain
Everything in Django auth is layered inheritance. Each layer adds a slice of behaviour. By the time you write your own User class, you are sitting on top of five layers of carefully engineered code you never have to touch.
An abstract model has no database table. When you inherit from it, Django takes every field and method it defines and places them directly into your model's table. You get the behaviour. The database sees only one table: yours. AbstractUser does not create a table called abstractuser. It donates everything to User.
PermissionsMixin contributesIt is a separate abstract model added alongside AbstractBaseUser. It adds the groups ManyToMany, the user_permissions ManyToMany, and the methods has_perm() and has_module_perms(). These are what power Django's entire permissions system. When you write user.has_perm('billing.view_invoice'), it is PermissionsMixin that answers the question.
Two paths
One question decides which base class to use.
Path A — AbstractUser (recommended)
# accounts/models.py — Path A
from django.contrib.auth.models import AbstractUser
from django.db import models
from .managers import UserManager
class User(AbstractUser):
# Redirect the login field from username to email
USERNAME_FIELD = 'email'
REQUIRED_FIELDS = [] # empty removes username from createsuperuser prompt
email = models.EmailField(unique=True)
phone = models.CharField(max_length=20, blank=True)
ROLE_CHOICES = [
('superuser', 'Superuser'),
('admin', 'Administrator'),
('noc', 'NOC Engineer'),
('billing', 'Billing Staff'),
('customer', 'Customer'),
]
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='customer')
objects = UserManager()
def __str__(self):
return f'{self.email} [{self.role}]'
Path B — AbstractBaseUser (full control)
# accounts/models.py — Path B
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
from django.db import models
from .managers import UserManager
class User(AbstractBaseUser, PermissionsMixin):
# Every field you want must be declared — nothing is inherited
email = models.EmailField(unique=True)
full_name = models.CharField(max_length=255)
phone = models.CharField(max_length=20, blank=True)
is_active = models.BooleanField(default=True)
is_staff = models.BooleanField(default=False)
date_joined = models.DateTimeField(auto_now_add=True)
ROLE_CHOICES = [
('superuser', 'Superuser'),
('admin', 'Administrator'),
('noc', 'NOC Engineer'),
('billing', 'Billing Staff'),
('customer', 'Customer'),
]
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='customer')
USERNAME_FIELD = 'email'
REQUIRED_FIELDS = ['full_name']
objects = UserManager()
def __str__(self):
return self.email
The custom UserManager
The Manager is Django's query interface. For users, it must also wrap creation with password hashing. create_user calls set_password(). If you ever bypass the manager and call User(password=raw).save(), the plaintext hits the database. That is the only rule that matters here.
user = User(email='[email protected]', password='secret'); user.save() — stores secret in plaintext. Always use create_user() which calls set_password() before saving.
# accounts/managers.py
from django.contrib.auth.models import BaseUserManager
class UserManager(BaseUserManager):
def create_user(self, email, password=None, **extra_fields):
if not email:
raise ValueError('An email address is required')
email = self.normalize_email(email) # lowercases domain: [email protected] → [email protected]
user = self.model(email=email, **extra_fields)
user.set_password(password) # PBKDF2/Argon2 hash — never plaintext
user.save(using=self._db)
return user
def create_superuser(self, email, password=None, **extra_fields):
# setdefault: only applies if the caller did not pass the key
extra_fields.setdefault('is_staff', True)
extra_fields.setdefault('is_superuser', True)
extra_fields.setdefault('is_active', True)
extra_fields.setdefault('role', 'superuser')
return self.create_user(email, password, **extra_fields)
# Role-specific convenience methods
def create_noc_engineer(self, email, password=None, **extra_fields):
extra_fields.setdefault('role', 'noc')
return self.create_user(email, password, **extra_fields)
def create_billing_staff(self, email, password=None, **extra_fields):
extra_fields.setdefault('role', 'billing')
return self.create_user(email, password, **extra_fields)
# Custom querysets — pre-filtered by role
def noc_team(self):
return self.filter(role='noc', is_active=True)
def customers(self):
return self.filter(role='customer')
normalize_email doesIt lowercases the domain part of the email address. [email protected] becomes [email protected]. The local part (before the @) is left exactly as typed — RFC 5321 says local parts are case-sensitive, though in practice no mail server treats them that way. Django is correct to only normalise the domain.
ISP ERP role model
Five archetypes cover every person in an ISP ERP. The role field on your User model carries this, supplemented by is_staff and is_superuser for Django admin access control.
| Role | is_staff | is_superuser | Admin | Purpose |
|---|---|---|---|---|
| superuser | True | True | Full | CTO, senior engineering. Bypasses all permission checks. |
| admin | True | False | Scoped | Office manager. Assigned per-model permissions in admin. |
| noc | False | False | None | NOC engineers. Network dashboards, incident management. |
| billing | False | False | None | Billing staff. Invoices, payments, account status. |
| customer | False | False | None | Subscribers. Their own account and usage only. |
# accounts/decorators.py — gate any view by role
from functools import wraps
from django.http import HttpResponseForbidden
from django.contrib.auth.views import redirect_to_login
def role_required(*roles):
def decorator(view_func):
@wraps(view_func)
def _wrapped(request, *args, **kwargs):
if not request.user.is_authenticated:
return redirect_to_login(request.get_full_path())
if request.user.role not in roles:
return HttpResponseForbidden('Access denied.')
return view_func(request, *args, **kwargs)
return _wrapped
return decorator
# Usage
@role_required('noc', 'admin', 'superuser')
def network_dashboard(request): ...
@role_required('billing', 'admin', 'superuser')
def billing_overview(request): ...
@role_required('customer')
def my_account(request): ...
Settings & wiring
AUTH_USER_MODEL must be set before manage.py migrate runs for the first time. Set it when you create the project. Changing it after initial migration requires manually rewriting the migration history — painful and error-prone.
# settings.py
AUTH_USER_MODEL = 'accounts.User' # app_label.ModelName
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'accounts', # ← must be present
'billing',
'network',
'customers',
]
# Stronger password hashing (pip install argon2-cffi)
PASSWORD_HASHERS = [
'django.contrib.auth.hashers.Argon2PasswordHasher',
'django.contrib.auth.hashers.PBKDF2PasswordHasher',
]
LOGIN_URL = '/accounts/login/'
LOGIN_REDIRECT_URL = '/dashboard/'
LOGOUT_REDIRECT_URL = '/'
settings.AUTH_USER_MODEL. This avoids circular imports and keeps your apps decoupled. See Section 11 for the pattern.
Admin registration
# accounts/admin.py
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from .models import User
class UserAdmin(BaseUserAdmin):
list_display = ('email', 'full_name', 'role', 'is_staff', 'is_active')
list_filter = ('role', 'is_staff', 'is_active')
search_fields = ('email', 'full_name')
ordering = ('email',)
fieldsets = (
(None, {'fields': ('email', 'password')}),
('Personal', {'fields': ('full_name', 'phone')}),
('Role', {'fields': ('role',)}),
('Permissions', {'fields': ('is_active', 'is_staff', 'is_superuser',
'groups', 'user_permissions')}),
('Dates', {'fields': ('last_login', 'date_joined')}),
)
add_fieldsets = (
(None, {
'classes': ('wide',),
'fields': ('email', 'full_name', 'role', 'password1', 'password2'),
}),
)
admin.site.register(User, UserAdmin)
Complete auth example
The full four-file setup. Copy these, change accounts to your app name, run migrations.
# Terminal — after all four files are in place
python manage.py makemigrations accounts
python manage.py migrate
python manage.py createsuperuser # prompts: email, password
# Programmatic user creation
from accounts.models import User
noc = User.objects.create_noc_engineer(
email='[email protected]',
password='securepassword',
full_name='Irene Chalya Phillip',
)
billing = User.objects.create_billing_staff(
email='[email protected]',
password='securepassword',
)
# Deactivate without deleting
noc.is_active = False
noc.save()
# Change password
noc.set_password('newpassword')
noc.save()
What a model is — the full story
A Django model is three things simultaneously: a Python class, a database table schema, and a query interface. One class definition does all three jobs.
1. Schema: When you run makemigrations, Django reads every field you declared and generates SQL to create the table — column names, types, constraints, indexes. You never wrote CREATE TABLE.
2. Python object: Every row in the table becomes a Python object. invoice = Invoice.objects.get(id=1) gives you an Invoice instance with attributes you can read and modify like any Python object.
3. Query interface: Invoice.objects is the Manager. It translates .filter(status='unpaid') into SELECT * FROM billing_invoice WHERE status = 'unpaid'. You write Python. Django writes SQL.
# The anatomy of a model
from django.db import models
class Package(models.Model):
# ── Fields ─────────────────────────────────────────────────────
name = models.CharField(max_length=100)
speed_down = models.PositiveIntegerField(help_text='Mbps')
speed_up = models.PositiveIntegerField(help_text='Mbps')
price_ugx = models.DecimalField(max_digits=10, decimal_places=2)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
# ── Meta ────────────────────────────────────────────────────────
class Meta:
ordering = ['price_ugx']
verbose_name = 'Service Package'
# ── Methods ─────────────────────────────────────────────────────
def __str__(self):
return f'{self.name} ({self.speed_down}/{self.speed_up} Mbps)'
@property
def speed_label(self):
return f'{self.speed_down}Mbps down / {self.speed_up}Mbps up'
Field types — the full toolkit
Each field class maps a Python type to a SQL column type. Choosing the right one matters for data integrity, storage efficiency, and query performance.
| Field | Python type | SQL type | When to use |
|---|---|---|---|
CharField(max_length=n) | str | VARCHAR(n) | Short text with a known max length. Names, codes, labels. |
TextField() | str | TEXT | Long text with no length limit. Notes, descriptions, logs. |
EmailField() | str | VARCHAR(254) | Email addresses. Validates format automatically. |
URLField() | str | VARCHAR(200) | URLs. Validates format. |
SlugField() | str | VARCHAR(50) | URL-safe identifiers. Letters, numbers, hyphens only. |
IntegerField() | int | INTEGER | Whole numbers, can be negative. |
PositiveIntegerField() | int | INTEGER | Whole numbers ≥ 0. Speeds, counts, quantities. |
BigIntegerField() | int | BIGINT | Very large integers. Traffic bytes, sequence numbers. |
DecimalField(max_digits, decimal_places) | Decimal | NUMERIC | Money. Never use FloatField for currency. |
FloatField() | float | FLOAT | Scientific measurements where rounding is acceptable. |
BooleanField() | bool | BOOLEAN | True/False flags. is_active, is_paid, is_verified. |
DateField() | date | DATE | Calendar dates without time. Due dates, birth dates. |
DateTimeField() | datetime | TIMESTAMP | Dates with time. Events, log entries, created_at. |
TimeField() | time | TIME | Time of day only. Maintenance windows, shift times. |
DurationField() | timedelta | INTERVAL | Lengths of time. Session duration, SLA counters. |
JSONField() | dict/list | JSONB (Postgres) | Flexible structured data. Config, metadata, SNMP data. |
UUIDField() | UUID | UUID | Universally unique IDs. Public-facing record identifiers. |
GenericIPAddressField() | str | INET/CHAR | IPv4 or IPv6 addresses. Customer IPs, device IPs. |
FileField(upload_to=...) | str (path) | VARCHAR | File uploads. Stores path, not the file itself. |
ImageField() | str (path) | VARCHAR | Image uploads. Validates it is actually an image. |
DecimalField for currency. FloatField uses IEEE 754 floating point — it cannot represent most decimal fractions exactly. 0.1 + 0.2 ≠ 0.3 in binary. Billing discrepancies will appear. DecimalField stores as exact decimal.
Field options
Every field accepts keyword arguments that control its behaviour at the database level and in forms. These are the ones you will use constantly.
# The most common field options with ISP context
class Connection(models.Model):
# null=True → database column may be NULL
# blank=True → form validation allows empty string
# Rule: use both together for optional fields, or neither
account_number = models.CharField(max_length=20, unique=True)
static_ip = models.GenericIPAddressField(null=True, blank=True)
# default — value used if not supplied at creation
STATUS = [('active','Active'), ('suspended','Suspended'), ('cancelled','Cancelled')]
status = models.CharField(max_length=20, choices=STATUS, default='active')
# unique=True → database UNIQUE constraint
mac_address = models.CharField(max_length=17, unique=True, null=True, blank=True)
# db_index=True → creates a database index for faster lookups
vlan_id = models.PositiveIntegerField(db_index=True)
# auto_now_add → set once at creation, never changed
# auto_now → updated every time .save() is called
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# verbose_name — label shown in admin and forms
bandwidth_cap = models.PositiveIntegerField(
verbose_name='Monthly data cap (GB)',
null=True, blank=True,
help_text='Leave blank for unlimited',
)
# editable=False — excluded from forms, admin
internal_ref = models.CharField(max_length=50, editable=False)
null vs blanknull=True affects the database — the column may store NULL. blank=True affects form validation — the field is not required. For string fields, Django convention is to use blank=True without null=True and store empty string rather than NULL. For non-string fields (dates, integers, relations), use null=True, blank=True together for optional values.
choices — validated dropdownA list of (stored_value, display_label) tuples. Django stores the first value in the column and uses the second for display in forms and admin. Define as a class-level constant so you can reference the values throughout your code without magic strings. Access display value with instance.get_status_display().
Relationships
Most of the data architecture work in any application is deciding how models relate to each other. Django gives you three relationship types that map to three SQL patterns.
ForeignKey — one-to-many
The most common relationship. One Customer has many Invoices. The ForeignKey lives on the "many" side — Invoice — pointing at Customer.
from django.conf import settings
class Invoice(models.Model):
# Always use settings.AUTH_USER_MODEL — never import User directly
customer = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT, # block deletion of a customer with invoices
related_name='invoices', # lets you do customer.invoices.all()
)
amount = models.DecimalField(max_digits=12, decimal_places=2)
due_date = models.DateField()
STATUS = [('unpaid','Unpaid'), ('paid','Paid'), ('overdue','Overdue')]
status = models.CharField(max_length=10, choices=STATUS, default='unpaid')
# Usage
customer = User.objects.get(email='[email protected]')
invoices = customer.invoices.filter(status='unpaid') # reverse relation
invoice = Invoice.objects.get(id=1)
owner = invoice.customer # forward relation — returns User object
| on_delete option | What happens when the parent is deleted | Use when |
|---|---|---|
CASCADE | Delete the child rows too | Child has no meaning without parent (connection → equipment) |
PROTECT | Raise an error — block the deletion | You never want orphaned financial records (customer → invoices) |
SET_NULL | Set the FK to NULL (requires null=True) | Record should persist but lose its link (incident → resolved_by) |
SET_DEFAULT | Set to the field's default value | Rare. Assign to a placeholder/default record. |
DO_NOTHING | Do nothing — leaves orphaned rows | Almost never. Breaks referential integrity. |
ManyToManyField
A customer can have multiple service packages. A package can serve multiple customers. Django creates a hidden junction table automatically.
class Customer(models.Model):
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
packages = models.ManyToManyField(
'network.Package',
blank=True,
related_name='customers',
through='Connection', # use a through model when the junction has extra data
)
class Connection(models.Model):
# The "through" model — the junction table with extra fields
customer = models.ForeignKey(Customer, on_delete=models.CASCADE)
package = models.ForeignKey('network.Package', on_delete=models.PROTECT)
start_date = models.DateField()
static_ip = models.GenericIPAddressField(null=True, blank=True)
STATUS = [('active','Active'), ('suspended','Suspended')]
status = models.CharField(max_length=20, choices=STATUS, default='active')
# Usage
customer.packages.all() # all packages this customer has
package.customers.filter(status='active') # all active customers on this package
OneToOneField
Exactly one of A belongs to exactly one of B. Use it to extend a model without changing it — the classic "profile" pattern.
class CustomerProfile(models.Model):
# Extends User with customer-specific data
# Creates a 1:1 link — no customer has two profiles
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='customer_profile',
)
address = models.TextField(blank=True)
id_number = models.CharField(max_length=30, blank=True)
credit_limit = models.DecimalField(max_digits=10, decimal_places=2, default=0)
# Usage
user.customer_profile.address # access profile from user
profile.user.email # access user from profile
related_name='invoices' on a ForeignKey pointing at Customer, Django adds customer.invoices as a reverse manager. Without it, the default name is customer.invoice_set. Always set related_name explicitly — it makes your code read like English and avoids clashes when two FKs in the same model point at the same target.
Model Meta
The inner Meta class is where you put everything about the model that isn't a field — ordering, table names, constraints, indexes, display names.
class Invoice(models.Model):
customer = models.ForeignKey(Customer, on_delete=models.PROTECT, related_name='invoices')
amount = models.DecimalField(max_digits=12, decimal_places=2)
due_date = models.DateField()
period = models.CharField(max_length=7) # '2026-01'
status = models.CharField(max_length=10, default='unpaid')
class Meta:
# Default ordering when you call .all() or .filter()
ordering = ['-due_date'] # '-' prefix means descending
# Custom table name (default would be appname_invoice)
db_table = 'billing_invoice'
# Admin display names
verbose_name = 'Invoice'
verbose_name_plural = 'Invoices'
# Composite unique constraint — no duplicate invoice for same customer+period
constraints = [
models.UniqueConstraint(
fields=['customer', 'period'],
name='unique_customer_period_invoice',
)
]
# Database indexes for query performance
indexes = [
models.Index(fields=['status', 'due_date'], name='invoice_status_due_idx'),
models.Index(fields=['period'], name='invoice_period_idx'),
]
Add an index on any field you regularly filter or order by — especially ForeignKey fields (already indexed by Django), status fields, date fields used in range queries, and any field used in a WHERE clause in a high-traffic query. Indexes speed up reads at the cost of slightly slower writes. For an ISP ERP, index status on invoices, connections, and incidents; index customer FKs everywhere; index created_at on log tables.
Model methods
A model is a Python class. It can have methods. Putting business logic on the model keeps it reusable — the same logic works in views, management commands, API endpoints, background tasks, and the shell.
from django.db import models
from django.utils import timezone
from decimal import Decimal
class Invoice(models.Model):
customer = models.ForeignKey('customers.Customer', on_delete=models.PROTECT, related_name='invoices')
amount = models.DecimalField(max_digits=12, decimal_places=2)
tax_rate = models.DecimalField(max_digits=5, decimal_places=4, default='0.18') # 18% VAT
due_date = models.DateField()
paid_at = models.DateTimeField(null=True, blank=True)
status = models.CharField(max_length=10, default='unpaid')
created_at = models.DateTimeField(auto_now_add=True)
# ── __str__ ─────────────────────────────────────────────────────
# Shown in admin list, shell repr, and anywhere Django prints an object
def __str__(self):
return f'INV-{self.pk:06d} | {self.customer} | {self.amount}'
# ── Properties ──────────────────────────────────────────────────
# Computed values that behave like fields — accessed without ()
@property
def tax_amount(self):
return (self.amount * self.tax_rate).quantize(Decimal('0.01'))
@property
def total_with_tax(self):
return self.amount + self.tax_amount
@property
def is_overdue(self):
return self.status == 'unpaid' and self.due_date < timezone.now().date()
# ── Business logic methods ───────────────────────────────────────
def mark_paid(self, payment=None):
self.status = 'paid'
self.paid_at = timezone.now()
self.save(update_fields=['status', 'paid_at'])
def mark_overdue(self):
if self.is_overdue:
self.status = 'overdue'
self.save(update_fields=['status'])
# ── save() override ──────────────────────────────────────────────
# Runs every time .save() is called — good for derived fields
def save(self, *args, **kwargs):
# Auto-update status if overdue before saving
if self.status == 'unpaid' and self.due_date and self.due_date < timezone.now().date():
self.status = 'overdue'
super().save(*args, **kwargs)
# ── Class methods ────────────────────────────────────────────────
# Act on the model class, not an instance
@classmethod
def overdue_count(cls):
return cls.objects.filter(status='overdue').count()
# ── get_absolute_url ─────────────────────────────────────────────
# Used by admin "View on site" button and templates
def get_absolute_url(self):
from django.urls import reverse
return reverse('billing:invoice-detail', args=[self.pk])
update_fields — save only what changedupdate_fields=['status', 'paid_at'] to save() generates UPDATE ... SET status=..., paid_at=... WHERE id=... instead of updating every column. Essential for high-concurrency tables where multiple processes may be updating different fields simultaneously. Always use it in methods that change only specific fields.
QuerySet operations
A QuerySet is a lazy SQL builder. Nothing hits the database until you evaluate it — iterate over it, call len(), slice it, or call a terminal method. This lets you chain operations freely and Django generates one efficient SQL query at the end.
# Retrieval
Invoice.objects.all() # SELECT * — lazy
Invoice.objects.filter(status='unpaid') # WHERE status = 'unpaid'
Invoice.objects.exclude(status='paid') # WHERE status != 'paid'
Invoice.objects.get(id=1) # one result or raises exception
Invoice.objects.first() # first result or None
Invoice.objects.last() # last result or None
# Chaining — every method returns a new QuerySet
unpaid_overdue = (
Invoice.objects
.filter(status='unpaid')
.filter(due_date__lt=timezone.now().date())
.select_related('customer')
.order_by('due_date')
)
# Field lookups — the double-underscore syntax
Invoice.objects.filter(amount__gt=100000) # amount > 100000
Invoice.objects.filter(amount__lte=50000) # amount <= 50000
Invoice.objects.filter(due_date__year=2026) # WHERE YEAR(due_date) = 2026
Invoice.objects.filter(due_date__range=(start, end)) # BETWEEN
Invoice.objects.filter(customer__email__icontains='sprint') # join + ILIKE
Invoice.objects.filter(status__in=['unpaid', 'overdue']) # IN (...)
Invoice.objects.filter(paid_at__isnull=True) # IS NULL
# Creation
inv = Invoice.objects.create(customer=cust, amount=150000, due_date=date(2026,6,1))
inv, created = Invoice.objects.get_or_create(customer=cust, period='2026-05', defaults={'amount':150000})
Invoice.objects.update_or_create(customer=cust, period='2026-05', defaults={'amount':175000})
# Bulk operations — one SQL statement each
Invoice.objects.bulk_create([
Invoice(customer=c, amount=150000, due_date=date(2026,6,1))
for c in customers
])
Invoice.objects.filter(status='unpaid').update(status='overdue') # mass update
Invoice.objects.filter(period__lt='2023-01').delete() # mass delete
# Terminal methods — evaluate the QuerySet NOW
Invoice.objects.count() # SELECT COUNT(*)
Invoice.objects.exists() # SELECT 1 LIMIT 1 — fastest exists check
Invoice.objects.values('status', 'amount') # returns dicts, not objects
Invoice.objects.values_list('id', flat=True) # flat list of IDs
Advanced queries
Aggregation, annotation, Q objects for complex logic, and the two performance tools that will save you from N+1 query disasters.
# Aggregation — collapse rows into a single value
from django.db.models import Sum, Count, Avg, Max, Min
Invoice.objects.aggregate(
total = Sum('amount'),
count = Count('id'),
average = Avg('amount'),
largest = Max('amount'),
)
# Returns: {'total': Decimal('8430000'), 'count': 47, ...}
# Annotation — add a computed column to each row
from django.db.models import Count
customers_with_invoice_count = (
Customer.objects
.annotate(invoice_count=Count('invoices'))
.filter(invoice_count__gt=3)
.order_by('-invoice_count')
)
# Each Customer object now has a .invoice_count attribute
# Q objects — OR logic and complex conditions
from django.db.models import Q
# Find invoices that are overdue OR from customer with ID 42
Invoice.objects.filter(
Q(status='overdue') | Q(customer_id=42)
)
# NOT — invoices that are NOT paid and NOT cancelled
Invoice.objects.filter(
~Q(status='paid') & ~Q(status='cancelled')
)
# F objects — reference another field in the same row
from django.db.models import F
# Find connections where actual speed < contracted speed (degraded)
Connection.objects.filter(actual_speed__lt=F('contracted_speed'))
# Increment a counter without loading the object into Python
Connection.objects.filter(id=1).update(traffic_gb=F('traffic_gb') + 10)
# select_related — follow ForeignKeys in ONE SQL JOIN (not N+1 queries)
invoices = Invoice.objects.select_related('customer', 'customer__user')
for inv in invoices:
print(inv.customer.user.email) # no extra queries — already JOINed
# prefetch_related — follow ManyToMany and reverse FKs efficiently
customers = Customer.objects.prefetch_related('invoices', 'connections')
for c in customers:
# invoices already fetched — no N+1
total = sum(inv.amount for inv in c.invoices.all())
If you loop over 100 invoices and access inv.customer inside the loop, Django fires a separate SQL query for each customer — 100 extra queries. select_related('customer') fetches all customers in one JOIN. Always use it when you know you'll access related objects in a loop.
select_related does a SQL JOIN — use for ForeignKey and OneToOne (one row per join). prefetch_related does separate queries and joins in Python — use for ManyToMany and reverse FK relations where the join would produce duplicate rows. Use both together when you need multi-level traversal.
Complete ISP ERP data model
Ten models covering everything a Sprint-class ISP ERP needs. This is the schema you build once and extend — authentication, customer management, billing, network, and NOC.
email (unique)
full_name
phone
role (choices)
is_staff / is_active
user (O2O→User)
address
id_number
credit_limit
balance
name
speed_down (Mbps)
speed_up (Mbps)
price_ugx (Decimal)
is_active
customer (FK→CustomerProfile)
package (FK→Package)
static_ip
vlan_id
status (choices)
customer (FK→CustomerProfile)
period (YYYY-MM)
amount (Decimal)
due_date
status (choices)
invoice (FK→Invoice)
received_by (FK→User)
amount (Decimal)
method (choices)
reference
connection (FK→Connection)
type (choices)
serial_number
mac_address
model
raised_by (FK→User)
assigned_to (FK→User)
title / description
severity (choices)
status (choices)
network (CIDR)
gateway
connection (FK→Connection)
is_allocated
notes
incident (FK→Incident)
author (FK→User)
message (TextField)
created_at
is_resolution
# network/models.py — Package and Connection
from django.db import models
from django.conf import settings
class Package(models.Model):
name = models.CharField(max_length=100)
speed_down = models.PositiveIntegerField(help_text='Mbps downstream')
speed_up = models.PositiveIntegerField(help_text='Mbps upstream')
price_ugx = models.DecimalField(max_digits=10, decimal_places=2)
is_active = models.BooleanField(default=True)
description = models.TextField(blank=True)
class Meta:
ordering = ['price_ugx']
def __str__(self):
return f'{self.name} — UGX {self.price_ugx:,}/mo'
class Connection(models.Model):
STATUS = [
('pending', 'Pending Installation'),
('active', 'Active'),
('suspended', 'Suspended'),
('cancelled', 'Cancelled'),
]
customer = models.ForeignKey('customers.CustomerProfile', on_delete=models.PROTECT, related_name='connections')
package = models.ForeignKey(Package, on_delete=models.PROTECT, related_name='connections')
static_ip = models.GenericIPAddressField(null=True, blank=True, unique=True)
vlan_id = models.PositiveIntegerField(null=True, blank=True)
status = models.CharField(max_length=20, choices=STATUS, default='pending')
start_date = models.DateField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
notes = models.TextField(blank=True)
class Meta:
ordering = ['-created_at']
indexes = [models.Index(fields=['status'], name='conn_status_idx')]
def __str__(self):
return f'{self.customer} → {self.package} [{self.status}]'
def suspend(self):
self.status = 'suspended'
self.save(update_fields=['status'])
def reactivate(self):
self.status = 'active'
self.save(update_fields=['status'])
# noc/models.py — Incident and NOCLog
from django.db import models
from django.conf import settings
class Incident(models.Model):
SEVERITY = [('p1','P1 Critical'), ('p2','P2 High'), ('p3','P3 Medium'), ('p4','P4 Low')]
STATUS = [('open','Open'), ('investigating','Investigating'), ('resolved','Resolved'), ('closed','Closed')]
title = models.CharField(max_length=255)
description = models.TextField()
severity = models.CharField(max_length=5, choices=SEVERITY, default='p3')
status = models.CharField(max_length=20, choices=STATUS, default='open')
raised_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='raised_incidents')
assigned_to = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='assigned_incidents')
created_at = models.DateTimeField(auto_now_add=True)
resolved_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['severity', '-created_at']
@property
def duration(self):
from django.utils import timezone
end = self.resolved_at or timezone.now()
return end - self.created_at
Migrations
Migrations are Django's version control for your database schema. Every change to a model — adding a field, changing max_length, adding an index — must be captured in a migration before it reaches the database.
Think of migrations like git commits for your database. makemigrations is git add + git commit — it reads your models and records what changed. migrate is git push — it applies the recorded changes to the actual database. Never edit your database schema by hand. Always go through migrations.
# The four commands you use constantly
# 1. Generate migrations from model changes
python manage.py makemigrations
python manage.py makemigrations accounts # for a specific app only
python manage.py makemigrations --name add_phone_to_user accounts # custom name
# 2. Apply pending migrations to the database
python manage.py migrate
python manage.py migrate accounts # for a specific app only
python manage.py migrate accounts 0003 # migrate to a specific version
# 3. See migration status
python manage.py showmigrations # all apps, all migrations, applied/pending
python manage.py showmigrations accounts # specific app
# 4. See what SQL a migration would run
python manage.py sqlmigrate accounts 0001 # inspect before applying
# Data migration — when you need to populate data, not just change schema
from django.db import migrations
def set_default_roles(apps, schema_editor):
# Always get the model from the historical registry — not a direct import
User = apps.get_model('accounts', 'User')
User.objects.filter(role='').update(role='customer')
class Migration(migrations.Migration):
dependencies = [('accounts', '0003_user_role')]
operations = [
migrations.RunPython(set_default_roles, migrations.RunPython.noop),
]
migrate on a fresh database. If you have too many, use squashmigrations to consolidate them — never delete manually.
migrate before deploying new code that expects new columns. The sequence is: run migrations → deploy code. Never the reverse. Adding nullable columns is safe. Removing or renaming columns requires care — the old code will break if the column disappears while it is still running.
Quick reference
User.objects.create_user(
email='[email protected]',
password='secure',
role='noc'
)
user = User.objects.get(
email='[email protected]'
)
user.set_password('newpass')
user.save()
User.objects.filter(
role='noc',
is_active=True
)
user.is_active = False
user.save(
update_fields=['is_active']
)
from django.conf import settings
class Invoice(models.Model):
customer = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT
)
from django.db.models import Sum
Invoice.objects.filter(
status='unpaid'
).aggregate(
total=Sum('amount')
)['total']
Invoice.objects
.select_related('customer')
.prefetch_related(
'customer__connections'
)
inv, created = Invoice.objects\
.get_or_create(
customer=cust,
period='2026-05',
defaults={'amount': 150000}
)
Invoice.objects.filter(
status='unpaid',
due_date__lt=today
).update(
status='overdue'
)
from django.db.models import Q
Invoice.objects.filter(
Q(status='overdue') |
Q(customer_id=42)
)
has_unpaid = Invoice.objects.filter(
customer=cust,
status='unpaid'
).exists()
# After every model change:
python manage.py makemigrations
python manage.py migrate
# Check status:
python manage.py showmigrations
What ModelAdmin is
The Django admin is not magic. It is a set of class-based views that read your model's _meta and render forms, lists, and detail pages automatically. ModelAdmin is the base class that controls all of that rendering. You inherit from it and override attributes or methods to change the behaviour.
admin.ModelAdmin lives at django.contrib.admin. When you write class InvoiceAdmin(admin.ModelAdmin), you inherit a class that already knows how to render a list view, a change form, a delete confirmation, a search bar, filters, and pagination. Everything you add is a customisation of something that already works out of the box. You are not building admin from scratch — you are tuning it.
# The minimal registration — works, but shows nothing useful
from django.contrib import admin
from .models import Invoice
admin.site.register(Invoice) # Django renders everything with defaults
# The decorator syntax — cleaner, equivalent
@admin.register(Invoice)
class InvoiceAdmin(admin.ModelAdmin):
pass # still works — all defaults
What ModelAdmin gives you for free
A table showing all rows, searchable and sortable. Paginated. Select and bulk-delete. All rendered by changelist_view().
A form for editing a single row. Fields are rendered based on the model's field types. Validation runs. Save/delete buttons. All rendered by change_view().
A page warning you about cascading deletes before you lose data. Runs the on_delete handlers. All rendered by delete_view().
ModelAdmin has ~100 attributes you can override. The main categories are: list_display, list_filter, search_fields (for the list view); fieldsets, readonly_fields, form (for the change form); and methods like save_model(), delete_model(), get_queryset() (for business logic). Sections 20–24 cover all of these in detail.
What a ModelForm is
A form that reads your model's fields and renders an HTML form automatically. It knows which fields are required, what type of input widget to use, and how to validate on save.
When to use a ModelForm instead of a plain Form. How to define Meta, which fields to include, which to exclude. How validation flows from field level to form level to model level. How commit=False lets you modify data before saving. Comparing ModelForm validation to model.full_clean().
Start here if you are...
Model contains, then build a custom user. Complete example at 07.
models and Model contain). Then 08–13 (model structure). Then 14–15 (querying).
commit=False, and when to use Form vs. ModelForm.
How sections depend on each other
If you want to jump around: this map shows which sections assume knowledge from others. Anything is readable on its own — but some make more sense after others.
This manual is a reference, not a tutorial. You can enter at any section that matches your immediate question. But if you're feeling lost, the prerequisite chains above tell you what to read first. Think of them as "read X to understand Y", not "you must have read X".
What you now know
If you've read this manual from start to finish, you can now:
Custom User model, BaseUserManager, roles, permissions. Handle authentication for multi-role applications.
Fields, relationships, Meta options, indexes, constraints. Design a schema that enforces integrity and scales.
filter(), exclude(), complex Q logic, aggregation, annotations, subqueries. Write queries that translate to efficient SQL.
Generate migrations, apply them, understand dependencies. Make schema changes without breaking production.
ModelAdmin for any use case. List display, filters, search, inlines, actions, method overrides. An admin your team actually wants to use.
ModelForm, validation, commit=False, cross-field logic. Forms that feel right and enforce your business rules.
List customisation
The list view is what your team sees when they open a model in the admin. These attributes control what columns appear, what filters are available, and how records are searched and ordered.
@admin.register(Invoice)
class InvoiceAdmin(admin.ModelAdmin):
# Columns shown in the list view
# Can be field names OR method names defined on the ModelAdmin or the model
list_display = ('invoice_ref', 'customer', 'amount', 'status', 'due_date', 'coloured_status')
# Right-hand filter sidebar
list_filter = ('status', 'due_date')
# Fields that the search box queries
search_fields = ('customer__user__email', 'customer__user__full_name')
# Click a column header to sort by it — these are the allowed ones
ordering = ('-due_date',)
# Rows per page
list_per_page = 50
# Date drill-down bar at the top of the list
date_hierarchy = 'due_date'
# Edit these fields directly in the list without opening the detail page
list_editable = ('status',)
# Clicking any of these columns opens the detail page
list_display_links = ('invoice_ref', 'customer')
# ── Custom column — a method on ModelAdmin ──────────────────────
def invoice_ref(self, obj):
return f'INV-{obj.pk:06d}'
invoice_ref.short_description = 'Reference'
invoice_ref.admin_order_field = 'pk' # makes the column sortable
# ── Custom column with HTML — coloured status badge ──────────────
from django.utils.html import format_html
def coloured_status(self, obj):
colours = {
'unpaid': '#B8922A',
'paid': '#3a7a35',
'overdue': '#b03a2e',
}
colour = colours.get(obj.status, '#6B6760')
return format_html(
'<span style="color:{};font-weight:600;">{}</span>',
colour, obj.get_status_display()
)
coloured_status.short_description = 'Status'
search_fields = ('customer__user__email',) traverses relationships exactly like a QuerySet lookup. Django generates a JOIN and searches across the related table. You can go as deep as needed — but every level is a JOIN, so keep it to two levels for performance on large tables.
Form layout — fieldsets & readonly
The detail page — where you create or edit a record — is controlled by fieldsets for new records and add_fieldsets for creation. readonly_fields makes fields visible but uneditable.
@admin.register(Invoice)
class InvoiceAdmin(admin.ModelAdmin):
# Fields that show but cannot be edited
readonly_fields = ('created_at', 'invoice_ref', 'total_with_tax')
# fieldsets: a list of (section_title, options_dict) tuples
# Each section groups related fields visually on the detail page
fieldsets = (
('Invoice Details', {
'fields': ('invoice_ref', 'customer', 'period'),
}),
('Amounts', {
'fields': ('amount', 'tax_rate', 'total_with_tax'),
# 'classes': ('collapse',) ← makes the section collapsible
}),
('Status', {
'fields': ('status', 'due_date', 'paid_at'),
}),
('Metadata', {
'fields': ('created_at',),
'classes': ('collapse',), # collapsed by default — clean
}),
)
# add_fieldsets: used only on the CREATE form (the + button)
# Typically simpler than fieldsets — only what you need to fill in
add_fieldsets = (
(None, {
'classes': ('wide',),
'fields': ('customer', 'period', 'amount', 'due_date'),
}),
)
readonly_fields. Put a property named total_with_tax on your model and list it there — the admin will display its computed value as a read-only field on the form. This is how you surface computed data without a database column.
Inline models
Inlines let you edit related records on the same page as their parent. Open a Customer and see all their Connections and Invoices in the same form — no navigation required. This is the single most powerful usability feature in the Django admin.
An Inline is a sub-form embedded inside a parent form. You define it on the child model — the one with the ForeignKey — and attach it to the parent admin. Django renders the child records as a table (Tabular) or as stacked individual forms (Stacked) below the parent fields.
# billing/admin.py
from django.contrib import admin
from .models import Invoice, Payment
from network.models import Connection
# ── TabularInline — compact table layout ─────────────────────────
class PaymentInline(admin.TabularInline):
model = Payment
extra = 0 # don't show empty extra rows by default
readonly_fields = ('received_by', 'created_at')
fields = ('amount', 'method', 'reference', 'received_by', 'created_at')
can_delete = False # never delete a payment from the admin
# ── StackedInline — full form per record ─────────────────────────
class ConnectionInline(admin.StackedInline):
model = Connection
extra = 0
show_change_link = True # link to full Connection detail page
fields = ('package', 'static_ip', 'vlan_id', 'status', 'start_date')
readonly_fields = ('created_at',)
# ── Invoices inline ───────────────────────────────────────────────
class InvoiceInline(admin.TabularInline):
model = Invoice
extra = 0
show_change_link = True
readonly_fields = ('period', 'amount', 'status', 'due_date')
can_delete = False
# ── Parent admin — Customer — with all three inlines ─────────────
from customers.models import CustomerProfile
@admin.register(CustomerProfile)
class CustomerAdmin(admin.ModelAdmin):
list_display = ('user', 'credit_limit', 'balance')
search_fields = ('user__email', 'user__full_name')
inlines = [ConnectionInline, InvoiceInline]
# Open a customer → see their connections + invoices on the same page
# ── Invoice admin — Payments inline ──────────────────────────────
@admin.register(Invoice)
class InvoiceAdmin(admin.ModelAdmin):
list_display = ('__str__', 'status', 'due_date', 'amount')
inlines = [PaymentInline]
# Open an invoice → see all payments below it
Custom actions
Actions are the "Action" dropdown in the list view. The default action is "Delete selected". You write your own to do anything — mark invoices overdue, suspend connections, export to CSV, send notification emails.
@admin.register(Invoice)
class InvoiceAdmin(admin.ModelAdmin):
list_display = ('__str__', 'status', 'due_date')
actions = ['mark_overdue', 'mark_paid', 'export_csv']
# ── Action: mark selected unpaid invoices as overdue ─────────────
@admin.action(description='Mark selected invoices as overdue')
def mark_overdue(self, request, queryset):
updated = queryset.filter(status='unpaid').update(status='overdue')
self.message_user(request, f'{updated} invoice(s) marked as overdue.')
# ── Action: mark selected as paid ────────────────────────────────
@admin.action(description='Mark selected invoices as paid')
def mark_paid(self, request, queryset):
from django.utils import timezone
updated = queryset.exclude(status='paid').update(
status='paid', paid_at=timezone.now()
)
self.message_user(request, f'{updated} invoice(s) marked as paid.')
# ── Action: export selected invoices to CSV ───────────────────────
@admin.action(description='Export selected to CSV')
def export_csv(self, request, queryset):
import csv
from django.http import HttpResponse
response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = 'attachment; filename="invoices.csv"'
writer = csv.writer(response)
writer.writerow(['Reference', 'Customer', 'Amount', 'Status', 'Due Date'])
for inv in queryset.select_related('customer__user'):
writer.writerow([
f'INV-{inv.pk:06d}',
inv.customer.user.email,
inv.amount,
inv.get_status_display(),
inv.due_date,
])
return response
Method overrides
Four method overrides cover almost every custom admin behaviour need: scoping what a user can see, intercepting the save, controlling permissions, and limiting what can be deleted.
@admin.register(Invoice)
class InvoiceAdmin(admin.ModelAdmin):
# ── get_queryset — scope what this admin user sees ───────────────
# Billing staff see only their customers. Superusers see everything.
def get_queryset(self, request):
qs = super().get_queryset(request)
if request.user.is_superuser:
return qs
return qs.filter(customer__region=request.user.region)
# ── save_model — intercept every admin save ──────────────────────
# Run custom logic, log the change, set a field from the request
def save_model(self, request, obj, form, change):
if not change: # change=False means this is a CREATE
obj.created_by = request.user
super().save_model(request, obj, form, change)
# ── has_delete_permission — control who can delete ───────────────
def has_delete_permission(self, request, obj=None):
# Only superusers can delete invoices — billing staff cannot
return request.user.is_superuser
# ── has_add_permission — control who can create ──────────────────
def has_add_permission(self, request):
return request.user.role in ('admin', 'superuser', 'billing')
# ── has_change_permission — control who can edit ─────────────────
def has_change_permission(self, request, obj=None):
if obj and obj.status == 'paid':
return request.user.is_superuser # paid invoices: superuser only
return request.user.role in ('admin', 'superuser', 'billing')
# ── formfield_for_foreignkey — filter dropdown options ───────────
# Limit the package dropdown to active packages only
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == 'package':
kwargs['queryset'] = Package.objects.filter(is_active=True)
return super().formfield_for_foreignkey(db_field, request, **kwargs)
get_queryset(request) — what rows are visible to this user. save_model(request, obj, form, change) — intercept the save. delete_model(request, obj) — intercept the delete. has_add_permission(request) — can this user see the + button. has_change_permission(request, obj) — can this user open the edit form. has_delete_permission(request, obj) — can this user delete. has_view_permission(request, obj) — can this user see the record at all. formfield_for_foreignkey(db_field, request) — filter dropdown options. get_readonly_fields(request, obj) — dynamically decide which fields are read-only based on the request or object state.
What a ModelForm is
A ModelForm is a Form that was generated from a Model. It reads your model's fields, creates matching form fields automatically, runs validation, and knows how to save a validated form back to the database. It is the bridge between an HTTP POST and a database row.
Without ModelForm, you would receive a POST request, manually extract each value from request.POST, validate each one separately, convert types, check database constraints, handle errors, and then call model.save(). That is 40 lines of code per model. ModelForm collapses that into about 10 — and it handles all edge cases you would miss.
1. Your Invoice model has an amount field — DecimalField. 2. InvoiceForm inherits from ModelForm and reads that field. It generates an HTML <input type="text"> with appropriate attributes and a Python DecimalField form field that validates the input. 3. The user fills in the form and submits. 4. InvoiceForm(request.POST) parses the POST data, validates types and constraints, and runs your custom validation. 5. form.save() creates or updates the database row.
# billing/forms.py — a minimal ModelForm
from django import forms
from .models import Invoice
class InvoiceForm(forms.ModelForm):
class Meta:
model = Invoice # which model to generate from
fields = ['customer', 'period', 'amount', 'due_date']
That is the complete minimum. Django reads Invoice's field definitions and creates matching form fields: a ModelChoiceField for customer, a CharField for period, a DecimalField for amount, a DateField for due_date. Each one validates its own type automatically.
The Meta inner class
Just like a model's Meta, a ModelForm's inner Meta class controls its behaviour. It is where you declare which model, which fields, and how to render them.
class InvoiceForm(forms.ModelForm):
class Meta:
model = Invoice
# Option A: list exactly the fields you want
fields = ['customer', 'period', 'amount', 'due_date']
# Option B: all fields except these (use with caution)
# exclude = ['created_at', 'paid_at']
# Custom HTML widget for a field
widgets = {
'due_date': forms.DateInput(attrs={'type': 'date'}), # browser date picker
'period': forms.TextInput(attrs={'placeholder': '2026-05'}),
'amount': forms.NumberInput(attrs={'step': '0.01'}),
}
# Override the label shown next to the field in the form
labels = {
'amount': 'Invoice Amount (UGX)',
'due_date': 'Payment Due By',
'period': 'Billing Period (YYYY-MM)',
}
# Help text shown below the field
help_texts = {
'period': 'Format: 2026-05 for May 2026',
}
# Error messages per field and per error type
error_messages = {
'amount': {
'required': 'Invoice amount is required.',
'invalid': 'Enter a valid amount.',
},
}
fields = '__all__' includes every model field in the form — including created_at, paid_at, and any field you did not intend to expose. Always list fields explicitly. This is also a security requirement — a malicious POST can set any unguarded field.
Validation
Validation happens in layers. Field-level validation runs first, then cross-field validation. You write your custom rules by adding methods to the form class. If any layer raises a ValidationError, the form is invalid and form.errors is populated.
class InvoiceForm(forms.ModelForm):
class Meta:
model = Invoice
fields = ['customer', 'period', 'amount', 'due_date']
# ── Field-level validation ───────────────────────────────────────
# Method name: clean_FIELDNAME — runs after the field's own type check
def clean_amount(self):
amount = self.cleaned_data['amount'] # already type-converted to Decimal
if amount <= 0:
raise forms.ValidationError('Amount must be greater than zero.')
return amount # always return the value from a clean_ method
def clean_period(self):
period = self.cleaned_data['period']
import re
if not re.match(r'^\d{4}-\d{2}$', period):
raise forms.ValidationError('Period must be in YYYY-MM format.')
return period
# ── Cross-field validation ────────────────────────────────────────
# clean() runs after all field-level clean methods succeed
# cleaned_data holds all validated values — safe to read any field
def clean(self):
cleaned = super().clean() # always call super() first
due_date = cleaned.get('due_date')
period = cleaned.get('period')
# Cross-field rule: due_date must be within the billing period's month
if due_date and period:
year, month = period.split('-')
if str(due_date.year) != year or f'{due_date.month:02d}' != month:
raise forms.ValidationError(
'Due date must fall within the billing period month.'
)
return cleaned
clean method, always read from self.cleaned_data — never from self.data. self.data is raw HTTP POST strings. self.cleaned_data is already type-converted and sanitised. An amount in cleaned_data is a Decimal. In self.data it is a raw string that may be empty, malformed, or injected.
Saving — the commit argument
form.save() writes the validated data to the database and returns the model instance. The commit=False variant gives you the instance without saving it — letting you set additional fields before the final save. This is the pattern you reach for constantly.
# commit=True (default) — save immediately
invoice = form.save() # INSERT or UPDATE, returns the instance
# commit=False — build the instance without saving
invoice = form.save(commit=False)
invoice.created_by = request.user # set fields the form didn't include
invoice.status = 'unpaid'
invoice.save() # now save to database
# If the form has ManyToMany fields, you must call save_m2m() after
# commit=False — Django cannot save M2M until the instance has a pk
invoice = form.save(commit=False)
invoice.save()
form.save_m2m() # saves any ManyToMany field data from the form
# Editing an existing record — pass instance to the form
invoice = Invoice.objects.get(id=1)
form = InvoiceForm(request.POST, instance=invoice)
if form.is_valid():
form.save() # runs UPDATE, not INSERT
Forms in views — the full request cycle
The same form class handles both GET (render the empty or pre-populated form) and POST (validate and save). The pattern is always the same three lines of branching logic. Once you have it in your head, every form view you write for the rest of your career follows the same shape.
# billing/views.py — create view
from django.shortcuts import render, redirect
from .forms import InvoiceForm
def create_invoice(request):
if request.method == 'POST':
form = InvoiceForm(request.POST) # bind POST data to the form
if form.is_valid(): # runs all clean_ methods
invoice = form.save(commit=False)
invoice.created_by = request.user
invoice.save()
return redirect('billing:invoice-list')
# form.errors is now populated — fall through to re-render
else:
form = InvoiceForm() # unbound — empty form for GET
return render(request, 'billing/invoice_form.html', {'form': form})
# billing/views.py — edit view (pass instance)
def edit_invoice(request, pk):
invoice = Invoice.objects.get(pk=pk)
if request.method == 'POST':
form = InvoiceForm(request.POST, instance=invoice) # bound + instance
if form.is_valid():
form.save()
return redirect('billing:invoice-detail', pk=invoice.pk)
else:
form = InvoiceForm(instance=invoice) # pre-populated with existing data
return render(request, 'billing/invoice_form.html', {'form': form})
<!-- billing/templates/billing/invoice_form.html -->
<form method="post">
{% csrf_token %} <!-- always required -- Django will reject without it -->
{{ form.as_p }} <!-- render all fields as <p> tags -->
<button type="submit">Save Invoice</button>
</form>
<!-- Or render fields individually for full layout control -->
<form method="post">
{% csrf_token %}
<div class="field">
{{ form.amount.label_tag }}
{{ form.amount }}
{% if form.amount.errors %}
<ul class="errors">{% for e in form.amount.errors %}<li>{{ e }}</li>{% endfor %}</ul>
{% endif %}
</div>
<button type="submit">Save</button>
</form>
Form vs ModelForm — when to use which
ModelForm is for forms that map to a database model. Plain Form is for everything else — search filters, contact forms, login forms, calculated inputs that do not map to a single model row.
The form creates or edits a single model instance. Creating a connection, editing a customer profile, logging an incident, recording a payment. The fields map directly to model fields. form.save() is the end state you want.
The form does not map to one model. A date-range filter on the invoice list. A search box. A multi-step wizard that assembles data from several models. A contact/support form that sends an email. Any form where form.save() makes no sense.
# A plain Form — does not inherit from ModelForm, has no Meta model
from django import forms
class InvoiceFilterForm(forms.Form):
status = forms.ChoiceField(choices=[('','All'), ('unpaid','Unpaid'), ('overdue','Overdue')], required=False)
date_from = forms.DateField(required=False, widget=forms.DateInput(attrs={'type':'date'}))
date_to = forms.DateField(required=False, widget=forms.DateInput(attrs={'type':'date'}))
# Usage in a view — no .save(), just read cleaned_data and filter
def invoice_list(request):
form = InvoiceFilterForm(request.GET or None)
invoices = Invoice.objects.all()
if form.is_valid():
if form.cleaned_data['status']:
invoices = invoices.filter(status=form.cleaned_data['status'])
if form.cleaned_data['date_from']:
invoices = invoices.filter(due_date__gte=form.cleaned_data['date_from'])
return render(request, 'billing/invoice_list.html', {'form': form, 'invoices': invoices})
InvoiceForm() — no data attached, renders empty fields, is_valid() always returns False. Bound-invalid: InvoiceForm(request.POST) after is_valid() returns False — has data, has errors, re-renders with error messages. Bound-valid: InvoiceForm(request.POST) after is_valid() returns True — cleaned_data is populated, safe to call .save(). Your view logic is just routing between these three states.