Field Manual · Issue 002

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 internals Model internals AbstractUser BaseUserManager ForeignKey QuerySet Migrations ISP ERP Schema
Part 0
Foundations
What is actually inside models and Model — the inventory most documentation never shows you
00a

What 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.

The import, explained

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
The one-import rule
Because Django organises everything under 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.
00b

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.

The mental model before the list

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:

The summary in one sentence per item

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.

Part I
Authentication
Custom users, managers, roles, and wiring them to Django's admin
01

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.

models.Model ORM base
save(), delete(), objects Manager — all DB operations
└─
AbstractBaseUser abstract
password (hashed), last_login, set_password(), check_password()
   └─
+ PermissionsMixin mixin
groups, user_permissions, has_perm(), has_module_perms()
      └─
AbstractUser abstract
username, email, first_name, is_staff, is_active, date_joined
         └─
YOUR User your code
role, phone, organisation — whatever your application needs
What "abstract" means

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.

What PermissionsMixin contributes

It 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.

02

Two paths

One question decides which base class to use.

Do you want Django's standard fields — just with email as login instead of username?
Yes → Path A — AbstractUserYou keep first_name, last_name, is_staff, date_joined, all the standard fields. You just redirect the login field to email and add your own extras. Use this for 90% of projects including your ISP ERP.
No → Path B — AbstractBaseUserYou get only password + last_login. Every field you want — email, name, phone — you declare yourself. Total control, more work. Use for platforms with unusual login flows (phone OTP, SSO only, tenant isolation).

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
03

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.

Never do this
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')
What normalize_email does

It 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.

04

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.

Roleis_staffis_superuserAdminPurpose
superuserTrueTrueFullCTO, senior engineering. Bypasses all permission checks.
adminTrueFalseScopedOffice manager. Assigned per-model permissions in admin.
nocFalseFalseNoneNOC engineers. Network dashboards, incident management.
billingFalseFalseNoneBilling staff. Invoices, payments, account status.
customerFalseFalseNoneSubscribers. 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): ...
05

Settings & wiring

Do this before your first migration — not after
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 = '/'
ForeignKey to User from other apps
Never import your User class directly into other apps' models. Use the string reference: settings.AUTH_USER_MODEL. This avoids circular imports and keeps your apps decoupled. See Section 11 for the pattern.
06

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)
07

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()
Part II
Models & Data
Fields, relationships, Meta, model methods — how to build the data layer for any application
08

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.

The 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'
09

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.

FieldPython typeSQL typeWhen to use
CharField(max_length=n)strVARCHAR(n)Short text with a known max length. Names, codes, labels.
TextField()strTEXTLong text with no length limit. Notes, descriptions, logs.
EmailField()strVARCHAR(254)Email addresses. Validates format automatically.
URLField()strVARCHAR(200)URLs. Validates format.
SlugField()strVARCHAR(50)URL-safe identifiers. Letters, numbers, hyphens only.
IntegerField()intINTEGERWhole numbers, can be negative.
PositiveIntegerField()intINTEGERWhole numbers ≥ 0. Speeds, counts, quantities.
BigIntegerField()intBIGINTVery large integers. Traffic bytes, sequence numbers.
DecimalField(max_digits, decimal_places)DecimalNUMERICMoney. Never use FloatField for currency.
FloatField()floatFLOATScientific measurements where rounding is acceptable.
BooleanField()boolBOOLEANTrue/False flags. is_active, is_paid, is_verified.
DateField()dateDATECalendar dates without time. Due dates, birth dates.
DateTimeField()datetimeTIMESTAMPDates with time. Events, log entries, created_at.
TimeField()timeTIMETime of day only. Maintenance windows, shift times.
DurationField()timedeltaINTERVALLengths of time. Session duration, SLA counters.
JSONField()dict/listJSONB (Postgres)Flexible structured data. Config, metadata, SNMP data.
UUIDField()UUIDUUIDUniversally unique IDs. Public-facing record identifiers.
GenericIPAddressField()strINET/CHARIPv4 or IPv6 addresses. Customer IPs, device IPs.
FileField(upload_to=...)str (path)VARCHARFile uploads. Stores path, not the file itself.
ImageField()str (path)VARCHARImage uploads. Validates it is actually an image.
Money rule
Always use 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.
10

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 blank

null=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 dropdown

A 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().

11

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.

The mental model
Ask yourself: how many of A can belong to one B? One customer has many invoices (ForeignKey on Invoice pointing at Customer). A customer can subscribe to many packages and a package can have many customers (ManyToMany). Each connection has exactly one CPE device and that device belongs to exactly one connection (OneToOne).

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 optionWhat happens when the parent is deletedUse when
CASCADEDelete the child rows tooChild has no meaning without parent (connection → equipment)
PROTECTRaise an error — block the deletionYou never want orphaned financial records (customer → invoices)
SET_NULLSet the FK to NULL (requires null=True)Record should persist but lose its link (incident → resolved_by)
SET_DEFAULTSet to the field's default valueRare. Assign to a placeholder/default record.
DO_NOTHINGDo nothing — leaves orphaned rowsAlmost 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 — the reverse accessor
When you set 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.
12

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'),
        ]
When to add indexes

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.

13

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 changed
Passing update_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.
Part III
Querying
QuerySet operations, lookups, aggregation, and performance patterns
14

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
15

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())
The N+1 problem

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 vs prefetch_related

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.

Part IV
ISP ERP & Migrations
A real data model for a real ISP — and how to evolve it safely
16

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.

User
id (auto)
email (unique)
full_name
phone
role (choices)
is_staff / is_active
CustomerProfile
id (auto)
user (O2O→User)
address
id_number
credit_limit
balance
Package
id (auto)
name
speed_down (Mbps)
speed_up (Mbps)
price_ugx (Decimal)
is_active
Connection
id (auto)
customer (FK→CustomerProfile)
package (FK→Package)
static_ip
vlan_id
status (choices)
Invoice
id (auto)
customer (FK→CustomerProfile)
period (YYYY-MM)
amount (Decimal)
due_date
status (choices)
Payment
id (auto)
invoice (FK→Invoice)
received_by (FK→User)
amount (Decimal)
method (choices)
reference
Equipment
id (auto)
connection (FK→Connection)
type (choices)
serial_number
mac_address
model
Incident
id (auto)
raised_by (FK→User)
assigned_to (FK→User)
title / description
severity (choices)
status (choices)
IPPool
id (auto)
network (CIDR)
gateway
connection (FK→Connection)
is_allocated
notes
NOCLog
id (auto)
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
17

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.

The mental model

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),
    ]
Never delete migration files
Migration files are how Django reconstructs your schema from scratch. Deleting them breaks migrate on a fresh database. If you have too many, use squashmigrations to consolidate them — never delete manually.
Migrations in production
Always run 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.
Reference
Quick Reference
The operations you reach for every session
18

Quick reference

Create user (safe)
User.objects.create_user(
  email='[email protected]',
  password='secure',
  role='noc'
)
Change password
user = User.objects.get(
  email='[email protected]'
)
user.set_password('newpass')
user.save()
Filter by role
User.objects.filter(
  role='noc',
  is_active=True
)
Deactivate user
user.is_active = False
user.save(
  update_fields=['is_active']
)
ForeignKey from other app
from django.conf import settings

class Invoice(models.Model):
  customer = models.ForeignKey(
    settings.AUTH_USER_MODEL,
    on_delete=models.PROTECT
  )
Sum a column
from django.db.models import Sum

Invoice.objects.filter(
  status='unpaid'
).aggregate(
  total=Sum('amount')
)['total']
Avoid N+1 queries
Invoice.objects
  .select_related('customer')
  .prefetch_related(
    'customer__connections'
  )
Get or create
inv, created = Invoice.objects\
  .get_or_create(
    customer=cust,
    period='2026-05',
    defaults={'amount': 150000}
  )
Mass update
Invoice.objects.filter(
  status='unpaid',
  due_date__lt=today
).update(
  status='overdue'
)
Complex OR query
from django.db.models import Q

Invoice.objects.filter(
  Q(status='overdue') |
  Q(customer_id=42)
)
Exists (fastest check)
has_unpaid = Invoice.objects.filter(
  customer=cust,
  status='unpaid'
).exists()
Migration workflow
# After every model change:
python manage.py makemigrations
python manage.py migrate

# Check status:
python manage.py showmigrations
Part V
Custom Admin
Inlines, actions, scoped querysets, save overrides — building an admin that works for your team
19

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.

The inheritance line

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

List view

A table showing all rows, searchable and sortable. Paginated. Select and bulk-delete. All rendered by changelist_view().

Change form

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().

Delete confirmation

A page warning you about cascading deletes before you lose data. Runs the on_delete handlers. All rendered by delete_view().

The class structure
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.
Part VI
ModelForm
Forms that know your model — rendering, validation, saving, and using them in views
25

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.

This section — what you'll learn

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().

Guided
How to use this manual
Three reading paths depending on what you need right now

Start here if you are...

Setting up authentication for the first time
Path A: Linear Read Part 0 (Foundations), then Part I (Authentication 01–07). You'll learn what Model contains, then build a custom user. Complete example at 07.
Path B: Targeted Already know what inheritance is? Jump to 03 (Custom UserManager). Need admin? Go to 06. Want the full example? Sections 07.
Building a data layer from scratch
Path A: Methodical Start with 00a00b (what models and Model contain). Then 0813 (model structure). Then 1415 (querying).
Path B: Fast-track Skim 00b (the mental model). Jump to 09 (field types), 11 (relationships), 12 (Meta). Reference 18 for quick patterns.
Admin interface — controlling what your team can edit
Path A: Comprehensive Read 1924 (all ModelAdmin customisation). Each section shows what you can control and why.
Path B: Copy-paste Jump straight to the quick reference 18. Copy a pattern. Adjust to your needs. Return to the full section only if you need to understand the "why".
Building user-facing forms in views
Path A: Deep dive Read 2530 (ModelForm in full). Understand validation, saving with commit=False, and when to use Form vs. ModelForm.
Path B: By example Jump to 29 (ModelForm in views). See a working GET/POST handler. Reference sections 2528 as questions come up.
meta

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.

To understand 14–15 (Querying)
00a 09 11 14–15 (foundations → fields → relations → queries)
To understand 19–24 (Admin)
00b 08 19–24 (Model inheritance → what a model is → customising admin)
To understand 25–30 (ModelForm)
09–10 25–26 27–30 (fields & options → ModelForm Meta → validation & views)
The philosophy

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".

end

What you now know

If you've read this manual from start to finish, you can now:

Design a user system

Custom User model, BaseUserManager, roles, permissions. Handle authentication for multi-role applications.

Build a data layer

Fields, relationships, Meta options, indexes, constraints. Design a schema that enforces integrity and scales.

Query efficiently

filter(), exclude(), complex Q logic, aggregation, annotations, subqueries. Write queries that translate to efficient SQL.

Migrate schema safely

Generate migrations, apply them, understand dependencies. Make schema changes without breaking production.

Customize the admin

ModelAdmin for any use case. List display, filters, search, inlines, actions, method overrides. An admin your team actually wants to use.

Build user-facing forms

ModelForm, validation, commit=False, cross-field logic. Forms that feel right and enforce your business rules.

20

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 double-underscore traversal
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.
21

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 can be model properties or ModelAdmin methods
Anything callable that returns a value can go in 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.
22

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.

The mental model

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
23

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
24

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)
The full override reference

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.

Part VI
ModelForm
The bridge between your model and your HTML form — validation, saving, and the request cycle
25

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.

The problem it solves

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.

The chain from Model to HTML to database

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.

26

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.',
            },
        }
Never use fields = '__all__'
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.
27

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
cleaned_data — the only safe place to read values
Inside any 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.
28

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
29

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>
30

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.

Use ModelForm when

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.

# 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})
The form state machine
A form is always in one of three states. Unbound: 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.