Unit 5: Models and Migrations and Django Admin

INT253 — Web Development In Python Using Django 10 min read

I. Orientation: The Model Layer in Django's MTV Architecture

Django's model layer (introduced with Django 0.90, 2005; ORM matured through the django.db.models API) is the single, authoritative definition of application data. Django follows the DRY principle: you write the schema once as a Python class, and Django derives the database tables, the SQL, the Python query API, the admin interface and form validation from it.

Defining properties and conventions the rest of the unit relies on:

  • Model = table: One class subclassing django.db.models.Model maps to one database table; one class attribute maps to one column; one instance maps to one row.
  • Active Record pattern: Instances carry their own persistence methods — obj.save(), obj.delete() — rather than requiring a separate session/unit-of-work object.
  • Implicit primary key: Django adds id = models.AutoField(primary_key=True) (or BigAutoField, per DEFAULT_AUTO_FIELD) unless you declare primary_key=True yourself.
  • Default table naming: <app_label>_<lowercased model name> — model Book in app library becomes table library_book. Overridable with class Meta: db_table = '...'.
  • Database-agnostic: The same model definitions run on PostgreSQL, MySQL, SQLite, Oracle; Django's backend translates to dialect-specific SQL.
  • Migrations as version control for schema: Schema changes are Python files stored in <app>/migrations/, committed to git alongside code.
  • App registration is mandatory: A model is invisible to makemigrations unless its app appears in INSTALLED_APPS.

II. Creating Models — Declaring the Schema in Python

A. Anatomy of a Model Class

A model is a plain Python class whose attributes are Field instances describing column type and constraints.

PYTHON
# blog/models.py
from django.db import models

class Author(models.Model):
    name  = models.CharField(max_length=100)
    email = models.EmailField(unique=True)

class Post(models.Model):
    title      = models.CharField(max_length=200)
    slug       = models.SlugField(unique=True)
    body       = models.TextField()
    price      = models.DecimalField(max_digits=6, decimal_places=2, default=0)
    published  = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ['-created_at']
        verbose_name_plural = 'posts'

    def __str__(self):
        return self.title
  • Field types map to SQL types: CharField(max_length=200)varchar(200); TextFieldtext; IntegerFieldinteger; DateTimeFieldtimestamp; BooleanFieldboolean.
  • max_length is compulsory on CharField: Omitting it raises system check error fields.E120 at startup.
  • auto_now_add vs auto_now: The former stamps the time only at row creation; the latter rewrites on every save() — used for created_at/updated_at respectively.
  • Common field options:
    • null=True — permits SQL NULL in the column; blank=True — permits an empty value in form validation. Avoid null=True on string fields (two "empty" states).
    • default=..., unique=True, db_index=True, choices=[('D','Draft'),('P','Published')] (adds get_<field>_display()).
  • class Meta: Non-column configuration — ordering, db_table, unique_together/constraints, verbose_name.
  • __str__: Controls the human-readable label shown in the shell and throughout the admin; without it objects display as Post object (1).

III. Working with Migrations — Versioned Schema Evolution

A. Purpose and Principle

Migrations translate the difference between the current model state and the last recorded state into ordered, replayable operations, so every developer and every server converges on the same schema.

  • Two-step workflow:
    BASH
      python manage.py makemigrations blog     # write 0001_initial.py
      python manage.py migrate                 # execute SQL against the DB
  • makemigrations compares models to the historical state built from existing migration files and emits e.g. CreateModel, AddField, AlterField, RemoveField, RenameField, RunPython.
  • migrate applies unapplied migrations in dependency order and records each in the django_migrations table (columns: app, name, applied).
  • Inspection and control:
    • python manage.py showmigrations[X] marks applied.
    • python manage.py sqlmigrate blog 0001 — prints the exact SQL without running it.
    • python manage.py migrate blog 0002 — rolls back to 0002 by reversing later migrations.
    • python manage.py migrate --fake — marks as applied without executing (for pre-existing schemas).
  • Adding a non-nullable field to a populated table: Django prompts interactively for a one-off default, or you supply default= / null=True in the field.
  • Data migrations: Use RunPython(forwards, backwards) and access models via apps.get_model('blog', 'Post') — never import the live model class, whose definition may have moved on.

IV. Using the Django Shell to Explore Models — Insert, Update and Delete

A. Launching and Importing

python manage.py shell starts a Python REPL with DJANGO_SETTINGS_MODULE configured and the app registry loaded; shell_plus (django-extensions) auto-imports models.

B. Insert

  • Two-step: p = Post(title='Hello', body='...'); p.save() — issues INSERT, then populates p.id.
  • One-step: Post.objects.create(title='Hello', body='...') instantiates and saves in one call.
  • Bulk: Post.objects.bulk_create([Post(title='A'), Post(title='B')]) — a single INSERT statement, but skips save() and signals.
  • Idempotent: obj, created = Post.objects.get_or_create(slug='hello', defaults={'title':'Hello'}).

C. Update

  • Instance-level: mutate then save — p.title = 'Edited'; p.save() issues an UPDATE on all columns (limit with p.save(update_fields=['title'])).
  • Queryset-level: Post.objects.filter(published=False).update(published=True) — one SQL UPDATE, returns the number of rows affected, bypasses save() and signals.
  • Atomic arithmetic: Post.objects.filter(pk=1).update(views=F('views') + 1) avoids read-modify-write races.

D. Delete

  • Single row: p.delete() returns (1, {'blog.Post': 1}).
  • Set: Post.objects.filter(published=False).delete().
  • Cascade: deleting an Author deletes dependent Post rows when the FK uses on_delete=models.CASCADE.

V. Using Object Relational Mapping (ORM) — Querying Without SQL

A. Managers and QuerySets

Every model gets a default manager objects; calling a method on it returns a QuerySet, which is lazy — no SQL runs until the result is iterated, sliced, printed, or passed to len()/list().

  • Retrieval: Post.objects.all(); Post.objects.get(pk=3) raises DoesNotExist or MultipleObjectsReturned; first() / last() return None when empty.
  • Field lookups (double-underscore syntax):
    PYTHON
      Post.objects.filter(title__icontains='django')
      Post.objects.filter(created_at__year=2024, published=True)
      Post.objects.exclude(price__gte=100).order_by('-created_at')[:5]

    __exact, __iexact, __contains, __in, __gt, __lte, __startswith, __isnull, __range.
  • Complex conditions: Q objects give OR/NOT — Post.objects.filter(Q(published=True) | Q(author__name='Ada')).
  • Aggregation and annotation:
    PYTHON
      from django.db.models import Count, Avg
      Author.objects.annotate(n=Count('post')).filter(n__gt=2)
      Post.objects.aggregate(Avg('price'))   # {'price__avg': 240.5}
  • Performance: select_related('author') performs a SQL JOIN for forward FKs; prefetch_related('post_set') issues a second query for reverse/M2M sets — both cure the N+1 query problem. Inspect generated SQL with print(qs.query).
  • Escape hatch: Post.objects.raw('SELECT * FROM blog_post') for hand-written SQL.

VI. Models Using Foreign Keys — Modelling Relationships

A. The Three Relationship Fields

  • ForeignKey (many-to-one): author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name='posts') creates column author_id with a REFERENCES constraint.
  • OneToOneField: a ForeignKey with unique=True — used for profile extensions of User.
  • ManyToManyField: tags = models.ManyToManyField(Tag) creates a hidden join table blog_post_tags with post_id/tag_id.

B. on_delete Behaviours (mandatory since Django 2.0)

  • CASCADE: delete children with the parent. PROTECT: raise ProtectedError. SET_NULL: requires null=True. SET_DEFAULT, DO_NOTHING.

C. Traversal in Both Directions

  • Forward: post.author.email — one extra query unless select_related was used.
  • Reverse: author.posts.all() when related_name='posts'; otherwise the default author.post_set.all().
  • Cross-relation lookups: Post.objects.filter(author__email__endswith='@x.com') — the ORM builds the JOIN automatically.
  • Self- and lazy references: models.ForeignKey('self', ...) for tree structures; 'app.Model' as a string when the target is defined later.

VII. Django Admin — The Auto-Generated Management Interface

A. Enabling and Registering

The admin (django.contrib.admin, enabled by default in startproject) reads model metadata and builds full CRUD screens at /admin/.

  • Prerequisites: django.contrib.admin, auth, contenttypes, sessions, messages in INSTALLED_APPS; path('admin/', admin.site.urls) in urls.py; python manage.py createsuperuser.
  • Registration:
    PYTHON
      # blog/admin.py
      from django.contrib import admin
      from .models import Post
    
      @admin.register(Post)
      class PostAdmin(admin.ModelAdmin):
          list_display  = ('title', 'author', 'published', 'created_at')
          list_filter   = ('published', 'created_at')
          search_fields = ('title', 'body')
          prepopulated_fields = {'slug': ('title',)}
          list_editable = ('published',)
          ordering = ('-created_at',)
  • Related editing: admin.TabularInline / StackedInline embed child rows (comments) inside the parent form.
  • Customisation: readonly_fields, fieldsets, raw_id_fields for large FK sets, and custom actions for bulk operations.

B. Adding Groups and Users

  • Where: the Authentication and Authorization block on the admin index exposes Users and Groups from django.contrib.auth.
  • Adding a user: username + password on the add form, then a change form for first_name, email, is_active, is_staff, is_superuser, group membership and per-user permissions.
  • is_staff vs is_superuser: is_staff=True alone grants login to /admin/ but shows only models the user has permissions for; is_superuser=True implicitly grants every permission.
  • Groups: a named bundle of permissions (Editors, Moderators); assigning a user to a group is the maintainable alternative to per-user grants.
  • Programmatically:
    PYTHON
      from django.contrib.auth.models import User, Group
      u = User.objects.create_user('ravi', 'ravi@x.com', 'Pass@123')
      g = Group.objects.get(name='Editors')
      u.groups.add(g); u.is_staff = True; u.save()

C. Users and Permissions

  • Automatic permissions: for each model Django creates four Permission rows — add_post, change_post, delete_post, view_post (the last added in Django 2.1) — named <app_label>.<codename>.
  • Checking: user.has_perm('blog.change_post'); in templates {% if perms.blog.add_post %}; in views @permission_required('blog.add_post') and @login_required.
  • Resolution order: superuser → user's own user_permissions → permissions of all groups the user belongs to.
  • Custom permissions: declared in Meta
    PYTHON
      class Meta:
          permissions = [('can_publish', 'Can publish a post')]
  • Object-level control: override ModelAdmin.get_queryset() so editors see only their own rows, since built-in permissions are per-model, not per-row.

VIII. Database Configuration — Configuring and Setting Up the Database Connection

A. The DATABASES Setting

All connection details live in the DATABASES dictionary in settings.py; the default alias is compulsory.

PYTHON
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': 'blogdb',
        'USER': 'bloguser',
        'PASSWORD': os.environ['DB_PASSWORD'],
        'HOST': '127.0.0.1',
        'PORT': '5432',
        'CONN_MAX_AGE': 600,
        'OPTIONS': {'connect_timeout': 10},
    }
}
  • ENGINE values: ...backends.sqlite3 (default; NAME is a file path such as BASE_DIR / 'db.sqlite3'), ...postgresql, ...mysql, ...oracle.
  • Drivers: PostgreSQL needs psycopg2-binary; MySQL needs mysqlclient; SQLite is bundled with Python — install before the first migrate, else ImproperlyConfigured.
  • CONN_MAX_AGE: seconds a connection is reused; 0 closes after each request, None keeps it forever.
  • Verification: python manage.py dbshell opens the native client; python manage.py check --database default validates settings.
  • Secrets: keep PASSWORD out of source control via environment variables or django-environ.

B. Beyond a Single Database

  • Multiple aliases: add e.g. 'replica' alongside 'default' and route with DATABASE_ROUTERS or Model.objects.using('replica').
  • Legacy schemas: python manage.py inspectdb > models.py reverse-engineers models with managed = False, so migrations will not attempt to alter those tables.
  • Transactions: default is autocommit; wrap multi-step writes in with transaction.atomic(): so a failure rolls the whole block back, or set ATOMIC_REQUESTS = True to wrap every request.