2026-04-15 17:11:42 -05:00

21 KiB

name, description, license
name description license
django Guides development with Django 5.2.11. Use when writing models, views, URL routing, templates, forms, migrations, the Django admin, Django REST Framework (DRF) APIs, or pytest-django tests. Internal Use Only

Django 5.2.11

Overview

Use this skill whenever working with Django — whether building views, writing models and migrations, working with templates and forms, configuring the admin, building REST APIs with DRF, or writing tests with pytest-django.

Keywords: django, models, views, urls, templates, forms, migrations, admin, rest framework, drf, serializers, viewsets, pytest, testing, async, orm, queryset

Version: Django 5.2.11 (LTS — security updates through 2028), Python 3.11, PostgreSQL

Official docs: https://docs.djangoproject.com/en/5.2/


Views & URL Routing

Function-Based Views

from django.http import HttpResponse, Http404
from django.shortcuts import render, get_object_or_404, redirect

def article_detail(request, pk):
    article = get_object_or_404(Article, pk=pk)
    return render(request, "articles/detail.html", {"article": article})

Async Views

Async views require an ASGI server to gain performance benefits. All handlers in a view must be either sync or async — never mixed.

async def article_detail(request, pk):
    article = await Article.objects.aget(pk=pk)
    return render(request, "articles/detail.html", {"article": article})

Class-Based Views

from django.views import View
from django.views.generic import ListView, DetailView, CreateView, UpdateView, DeleteView

class ArticleListView(ListView):
    model = Article
    template_name = "articles/list.html"
    context_object_name = "articles"
    paginate_by = 20

    def get_queryset(self):
        return Article.objects.filter(published=True).order_by("-created_at")

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["categories"] = Category.objects.all()
        return context

Generic CBV quick-reference:

Class Purpose
TemplateView Renders a template; override get_context_data()
ListView List of objects; use paginate_by for pagination
DetailView Single object by pk or slug
CreateView Form to create an object; set fields or form_class
UpdateView Form to edit an existing object
DeleteView Confirm and delete an object
RedirectView HTTP redirect; set url or pattern_name
FormView Generic form handling without a model

Access Control Mixins & Decorators

from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.contrib.auth.decorators import login_required, permission_required

# CBV
class MyView(LoginRequiredMixin, View):
    login_url = "/login/"

class AdminOnlyView(PermissionRequiredMixin, View):
    permission_required = "myapp.change_article"

# FBV
@login_required
def my_view(request): ...

@permission_required("myapp.add_article")
def create_article(request): ...

URL Configuration

# urls.py
from django.urls import path, re_path, include

app_name = "articles"  # namespace

urlpatterns = [
    path("", views.ArticleListView.as_view(), name="list"),
    path("<int:pk>/", views.ArticleDetailView.as_view(), name="detail"),
    path("<int:pk>/edit/", views.ArticleUpdateView.as_view(), name="edit"),
    path("<slug:slug>/", views.article_by_slug, name="by-slug"),
    re_path(r"^archive/(?P<year>[0-9]{4})/$", views.archive, name="archive"),
]

Including in the root URLconf:

urlpatterns = [
    path("articles/", include("articles.urls")),
    path("admin/", admin.site.urls),
    path("api/", include("api.urls")),
]

Reversing URLs:

from django.urls import reverse, reverse_lazy

reverse("articles:detail", args=[pk])
reverse("articles:list", query={"page": 2})   # Django 5.2 — adds ?page=2
reverse_lazy("articles:list")                  # safe for use in class attributes

Path converters:

Converter Matches
<int:pk> Zero or positive integer
<str:name> Any non-empty string without /
<slug:slug> ASCII letters, numbers, hyphens, underscores
<uuid:id> UUID string
<path:rest> Any non-empty string including /

Templates & Forms

Template Basics

All templates extend a base:

{% extends "base.html" %}

{% block extra_head %}
  <link rel="stylesheet" href="{% static 'myapp/style.css' %}">
{% endblock %}

{% block content %}
  <h1>{{ article.title }}</h1>
  {% for tag in article.tags.all %}
    <span>{{ tag.name }}</span>
  {% endfor %}
{% endblock %}

{% block extra_scripts %}
  <script src="{% static 'myapp/app.js' %}"></script>
{% endblock %}

Commonly used template tags:

{% url 'articles:detail' article.pk %}
{% static 'myapp/logo.png' %}
{% include '_sidebar.html' %}
{% with total=articles.count %}{{ total }}{% endwith %}
{% if user.is_authenticated %}...{% endif %}
{% csrf_token %}

Built-in filters:

{{ value|default:"n/a" }}
{{ body|truncatewords:30 }}
{{ created_at|date:"M j, Y" }}
{{ price|floatformat:2 }}
{{ name|lower }}

Forms

from django import forms
from .models import Article

class ArticleForm(forms.ModelForm):
    class Meta:
        model = Article
        fields = ["title", "body", "category", "published"]
        widgets = {
            "body": forms.Textarea(attrs={"rows": 10}),
        }

    def clean_title(self):
        title = self.cleaned_data["title"]
        if len(title) < 5:
            raise forms.ValidationError("Title must be at least 5 characters.")
        return title

    def clean(self):
        cleaned_data = super().clean()
        # Cross-field validation here
        return cleaned_data

Handling a form in a view:

def create_article(request):
    if request.method == "POST":
        form = ArticleForm(request.POST)
        if form.is_valid():
            article = form.save(commit=False)
            article.author = request.user
            article.save()
            return redirect("articles:detail", pk=article.pk)
    else:
        form = ArticleForm()
    return render(request, "articles/form.html", {"form": form})

Rendering forms in templates:

<form method="post">
  {% csrf_token %}
  {{ form.as_p }}
  <button type="submit">Save</button>
</form>

{# Manual field rendering #}
<div class="field{% if form.title.errors %} error{% endif %}">
  {{ form.title.label_tag }}
  {{ form.title }}
  {{ form.title.errors }}
</div>

New in Django 5.2 — form widgets:

  • forms.ColorInput<input type="color">
  • forms.SearchInput<input type="search">
  • forms.TelInput<input type="tel">

Django 5.2 — simple_block_tag

Create template block tags that capture content:

from django import template

register = template.Library()

@register.simple_block_tag
def card(content, title=""):
    return f'<div class="card"><h2>{title}</h2>{content}</div>'

Models

Defining Models

from django.db import models

class Article(models.Model):
    title = models.CharField(max_length=200)
    slug = models.SlugField(unique=True)
    body = models.TextField()
    author = models.ForeignKey(
        "auth.User",
        on_delete=models.SET_NULL,
        null=True,
        related_name="articles",
    )
    category = models.ForeignKey(
        "Category",
        on_delete=models.PROTECT,
        related_name="articles",
    )
    tags = models.ManyToManyField("Tag", blank=True, related_name="articles")
    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 = "articles"
        indexes = [models.Index(fields=["slug"])]

    def __str__(self):
        return self.title

    def get_absolute_url(self):
        from django.urls import reverse
        return reverse("articles:detail", args=[self.pk])

on_delete options:

Option Behavior
CASCADE Delete child when parent is deleted
PROTECT Raise ProtectedError if related objects exist
SET_NULL Set FK to NULL (requires null=True)
SET_DEFAULT Set FK to field default
DO_NOTHING Do nothing (risks integrity errors)

Django 5.2 — Composite Primary Keys

class Release(models.Model):
    pk = models.CompositePrimaryKey("version", "name")
    version = models.IntegerField()
    name = models.CharField(max_length=20)

QuerySet Patterns

# Filtering
Article.objects.filter(published=True, author__username="john")
Article.objects.exclude(created_at__year=2023)
Article.objects.filter(title__icontains="django")

# get_or_create / update_or_create
article, created = Article.objects.get_or_create(
    slug="my-article",
    defaults={"title": "My Article", "body": "..."},
)

# Aggregation
from django.db.models import Count, Avg, Q
Article.objects.annotate(comment_count=Count("comments"))
Article.objects.aggregate(avg_views=Avg("views"))

# Complex filters with Q
Article.objects.filter(
    Q(title__icontains="django") | Q(body__icontains="django")
)

# F expressions (compare fields without fetching)
from django.db.models import F
Article.objects.filter(views__gt=F("likes") * 2)

# select_related (SQL JOIN for FK/OneToOne)
Article.objects.select_related("author", "category")

# prefetch_related (separate query for M2M/reverse FK)
Article.objects.prefetch_related("tags", "comments")

# Bulk operations
Article.objects.filter(published=False).update(published=True)
Article.objects.bulk_create([Article(...), Article(...)])

# Async ORM (for async views)
article = await Article.objects.aget(pk=pk)
articles = [a async for a in Article.objects.filter(published=True)]

Migrations

python manage.py makemigrations          # Generate migration files
python manage.py migrate                 # Apply migrations
python manage.py showmigrations          # Show status
python manage.py sqlmigrate myapp 0003   # Preview SQL
python manage.py migrate myapp 0002      # Roll back to 0002

Data migration:

# Create empty migration, then add:
from django.db import migrations

def populate_slugs(apps, schema_editor):
    Article = apps.get_model("articles", "Article")  # always use historical model
    for article in Article.objects.all():
        article.slug = article.title.lower().replace(" ", "-")
        article.save()

class Migration(migrations.Migration):
    dependencies = [("articles", "0003_article_slug")]
    operations = [migrations.RunPython(populate_slugs, migrations.RunPython.noop)]

Django Admin

Registering Models

from django.contrib import admin
from .models import Article, Category

@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
    list_display = ["title", "author", "category", "published", "created_at"]
    list_filter = ["published", "category", "created_at"]
    search_fields = ["title", "body", "author__username"]
    search_help_text = "Search by title, body, or author username"
    readonly_fields = ["created_at", "updated_at"]
    ordering = ["-created_at"]
    list_per_page = 50

    fieldsets = [
        (None, {"fields": ["title", "slug", "author", "category"]}),
        ("Content", {"fields": ["body", "tags"]}),
        ("Publishing", {"fields": ["published"]}),
        ("Metadata", {"classes": ["collapse"], "fields": ["created_at", "updated_at"]}),
    ]

    @admin.action(description="Mark selected articles as published")
    def make_published(self, request, queryset):
        queryset.update(published=True)
        self.message_user(request, "Selected articles marked as published.")

    actions = ["make_published"]

    def save_model(self, request, obj, form, change):
        if not change:
            obj.author = request.user
        super().save_model(request, obj, form, change)

Inlines

class CommentInline(admin.TabularInline):
    model = Comment
    extra = 0
    readonly_fields = ["created_at"]

@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
    inlines = [CommentInline]

Django REST Framework

Setup

# settings.py
INSTALLED_APPS = [..., "rest_framework", "rest_framework.authtoken"]

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework.authentication.TokenAuthentication",
        "rest_framework.authentication.SessionAuthentication",
    ],
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.IsAuthenticated",
    ],
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
    "PAGE_SIZE": 20,
}

Serializers

from rest_framework import serializers
from .models import Article

class ArticleSerializer(serializers.ModelSerializer):
    author_username = serializers.CharField(source="author.username", read_only=True)
    comment_count = serializers.SerializerMethodField()

    class Meta:
        model = Article
        fields = ["id", "title", "slug", "body", "author_username",
                  "published", "created_at", "comment_count"]
        read_only_fields = ["id", "slug", "created_at"]

    def get_comment_count(self, obj):
        return obj.comments.count()

    def validate_title(self, value):
        if len(value) < 5:
            raise serializers.ValidationError("Title must be at least 5 characters.")
        return value

    def validate(self, data):
        # Cross-field validation
        return data

    def create(self, validated_data):
        validated_data["author"] = self.context["request"].user
        return super().create(validated_data)

ViewSets & Routers

from rest_framework import viewsets, permissions
from rest_framework.decorators import action
from rest_framework.response import Response

class ArticleViewSet(viewsets.ModelViewSet):
    serializer_class = ArticleSerializer
    permission_classes = [permissions.IsAuthenticated]

    def get_queryset(self):
        return Article.objects.filter(published=True).select_related("author")

    @action(detail=False, methods=["get"])
    def my_articles(self, request):
        qs = Article.objects.filter(author=request.user)
        serializer = self.get_serializer(qs, many=True)
        return Response(serializer.data)

    @action(detail=True, methods=["post"])
    def publish(self, request, pk=None):
        article = self.get_object()
        article.published = True
        article.save()
        return Response({"status": "published"})
# urls.py
from rest_framework.routers import DefaultRouter
from . import views

router = DefaultRouter()
router.register(r"articles", views.ArticleViewSet, basename="article")

urlpatterns = router.urls

ViewSet types:

Class Actions included
ViewSet None — define all manually
GenericViewSet None — mix in DRF mixins
ReadOnlyModelViewSet list, retrieve
ModelViewSet list, create, retrieve, update, partial_update, destroy

Per-View Auth/Permission Override

from rest_framework.permissions import AllowAny, IsAdminUser

class ArticleViewSet(viewsets.ModelViewSet):
    def get_permissions(self):
        if self.action in ["list", "retrieve"]:
            return [AllowAny()]
        return [IsAuthenticated()]

Bearer Token Authentication

For requests requiring a bearer token (e.g., from Vision-Frontend to Vision-BackendAPI):

# Include in request headers:
# Authorization: Bearer <token>

class BearerTokenAuthentication(authentication.BaseAuthentication):
    def authenticate(self, request):
        auth_header = request.headers.get("Authorization", "")
        if not auth_header.startswith("Bearer "):
            return None
        token = auth_header.split(" ", 1)[1]
        # Validate token against Wristband or token store
        ...

Testing with pytest-django

Configuration

# pytest.ini or pyproject.toml [tool.pytest.ini_options]
[pytest]
DJANGO_SETTINGS_MODULE = myproject.settings.test

Database Access

Database access is blocked by default. Grant it via mark or fixture:

import pytest

@pytest.mark.django_db
def test_article_creation():
    article = Article.objects.create(title="Hello", body="World")
    assert article.pk is not None

@pytest.mark.django_db(transaction=True)
def test_with_transactions():
    # Needed when testing code that uses transactions explicitly
    ...

@pytest.mark.django_db(reset_sequences=True)
def test_with_reset_sequences():
    # Needed when auto-increment IDs matter
    ...

Key Fixtures

def test_view(client):
    """Anonymous test client"""
    response = client.get("/articles/")
    assert response.status_code == 200

def test_authenticated(client, django_user_model):
    """Log in and test protected views"""
    user = django_user_model.objects.create_user(username="tester", password="pass")
    client.force_login(user)
    response = client.get("/dashboard/")
    assert response.status_code == 200

def test_request_factory(rf):
    """RequestFactory — builds request objects without the middleware stack"""
    request = rf.get("/articles/")
    request.user = AnonymousUser()
    response = ArticleListView.as_view()(request)
    assert response.status_code == 200

def test_email(mailoutbox):
    """Assert emails sent during a test"""
    send_welcome_email("user@example.com")
    assert len(mailoutbox) == 1
    assert mailoutbox[0].subject == "Welcome!"

def test_with_settings(settings):
    """Override settings within a test"""
    settings.DEBUG = True
    settings.CACHES = {"default": {"BACKEND": "django.core.cache.backends.dummy.DummyCache"}}
    ...

DRF Testing

from rest_framework.test import APIClient

@pytest.fixture
def api_client():
    return APIClient()

@pytest.mark.django_db
def test_article_api(api_client, django_user_model):
    user = django_user_model.objects.create_user(username="tester", password="pass")
    api_client.force_authenticate(user=user)
    response = api_client.get("/api/articles/")
    assert response.status_code == 200
    assert "results" in response.data

Fixtures with Database Access

@pytest.fixture
def article(db):
    """Use the db fixture (not the mark) when a fixture needs DB access"""
    return Article.objects.create(title="Test Article", body="Body")

@pytest.fixture
def published_article(article):
    article.published = True
    article.save()
    return article

Django 5.2 — Notable New Features

Feature Details
Composite Primary Keys CompositePrimaryKey("field1", "field2") on the pk attribute
Auto model imports in shell manage.py shell imports all models automatically
reverse() query argument reverse("name", query={"page": 2}) appends query string
HttpResponse.text String representation of response content
HttpRequest.get_preferred_type() Query client's preferred media type
preserve_request on redirects Preserves HTTP method on redirect()
AlterConstraint migration op Change constraint metadata without drop/recreate
New form widgets ColorInput, SearchInput, TelInput
simple_block_tag decorator Create block-capturing template tags
Async auth methods aauthenticate(), alogin(), alogout(), ahas_perm()
CharField.max_length optional On SQLite only; still required on PostgreSQL
QuerySet.explain() options memory and serialize kwargs on PostgreSQL 17+

Settings Reference

Database (PostgreSQL)

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": env("DB_NAME"),
        "USER": env("DB_USER"),
        "PASSWORD": env("DB_PASSWORD"),
        "HOST": env("DB_HOST", default="127.0.0.1"),
        "PORT": env("DB_PORT", default="5432"),
    }
}

DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"

Security Checklist

SECRET_KEY = env("DJANGO_SECRET_KEY")   # Never hardcode
DEBUG = env.bool("DEBUG", default=False)
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS")

# HTTPS in production
SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 31536000
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True
SECURE_CONTENT_TYPE_NOSNIFF = True

Static & Media Files

STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"

Logging

LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "handlers": {
        "console": {"class": "logging.StreamHandler"},
    },
    "root": {
        "handlers": ["console"],
        "level": "WARNING",
    },
    "loggers": {
        "django": {"handlers": ["console"], "level": "INFO", "propagate": False},
    },
}