- name
- django-workflow
- description
- Django framework workflow guidelines. Activate when working with Django projects, manage.py, django-admin, or Django-specific patterns.
- location
- user
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.
# Django Workflow
## Tool Grid
| Task | Tool | Command |
|------|------|---------|
| Lint | Ruff | `uv run ruff check .` |
| Format | Ruff | `uv run ruff format .` |
| Type check | django-stubs + mypy | `uv run mypy .` |
| Security | bandit | `uv run bandit -r .` |
| Test | pytest-django | `uv run pytest` |
| Migrations | squawk | `squawk migrations/*.sql` |
| Dev server | Django | `uv run python manage.py runserver` |
---
## Django 5.x Features
### Composite Primary Keys (Django 5.2+)
Django 5.2 introduces native composite primary key support. You SHOULD use `CompositePrimaryKey` for junction tables and legacy database integration:
```python
from django.db import models
class OrderItem(models.Model):
order = models.ForeignKey("Order", on_delete=models.CASCADE)
product = models.ForeignKey("Product", on_delete=models.CASCADE)
quantity = models.PositiveIntegerField()
class Meta:
constraints = [
models.CompositePrimaryKey("order", "product"),
]
```
### Async Views
You SHOULD prefer async views for I/O-bound operations. Django 5.x has improved async ORM support:
```python
from django.http import JsonResponse
async def fetch_items(request):
items = [item async for item in Item.objects.filter(active=True)]
return JsonResponse({"items": [i.name for i in items]})
```
Key async patterns:
- Use `async for` with QuerySets
- Use `await` with `aget()`, `afirst()`, `acount()`, `aexists()`
- Sync ORM calls in async views trigger `SynchronousOnlyOperation` exceptions
### Template Partials
You MAY use the `{% include %}` tag with the `only` keyword to create isolated partials:
```html
{% include "components/card.html" with title=item.title only %}
```
### Built-in CSP (Content Security Policy)
Django 5.x includes built-in CSP middleware. You SHOULD configure it in settings:
```python
# settings/base.py
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"csp.middleware.CSPMiddleware", # or django.middleware.csp.ContentSecurityPolicyMiddleware
# ...
]
CONTENT_SECURITY_POLICY = {
"DIRECTIVES": {
"default-src": ["'self'"],
"script-src": ["'self'"],
"style-src": ["'self'", "'unsafe-inline'"],
"img-src": ["'self'", "data:", "https:"],
}
}
```
### Django Tasks (Background Tasks)
For simple background tasks, you MAY use Django's built-in task system (Django 5.1+):
```python
from django.tasks import task
@task
def send_welcome_email(user_id: int) -> None:
user = User.objects.get(pk=user_id)
# Send email logic
```
For complex workflows, Celery remains RECOMMENDED.
---
## Architecture Patterns
### Fat Models to Services Pattern
Business logic MUST NOT reside in views. You MUST use the services pattern:
```python
# services/order_service.py
from dataclasses import dataclass
from decimal import Decimal
from django.db import transaction
@dataclass
class OrderService:
"""Order-related business operations."""
@staticmethod
@transaction.atomic
def create_order(user, cart_items: list) -> "Order":
"""Create order from cart items with inventory validation."""
order = Order.objects.create(user=user, status=Order.Status.PENDING)
for item in cart_items:
if item.product.stock < item.quantity:
raise InsufficientStockError(item.product)
OrderItem.objects.create(
order=order,
product=item.product,
quantity=item.quantity,
price=item.product.price,
)
item.product.stock -= item.quantity
item.product.save(update_fields=["stock"])
return order
@staticmethod
def calculate_total(order: "Order") -> Decimal:
"""Calculate order total with discounts applied."""
return sum(item.subtotal for item in order.items.all())
```
### Selectors Pattern
Query logic MUST be encapsulated in selectors. Views MUST NOT contain complex querysets:
```python
# selectors/product_selectors.py
from django.db.models import QuerySet, Q, Prefetch
class ProductSelectors:
"""Product query encapsulation."""
@staticmethod
def get_active_products() -> QuerySet:
return Product.objects.filter(
is_active=True,
stock__gt=0,
).select_related("category")
@staticmethod
def search_products(query: str) -> QuerySet:
return Product.objects.filter(
Q(name__icontains=query) | Q(description__icontains=query),
is_active=True,
)
@staticmethod
def get_product_with_reviews(product_id: int) -> Product:
return Product.objects.prefetch_related(
Prefetch(
"reviews",
queryset=Review.objects.filter(approved=True).order_by("-created_at"),
)
).get(pk=product_id)
```
---
## Model Organization
### Field Ordering
Models MUST follow this field ordering:
```python
from django.db import models
from django.utils.translation import gettext_lazy as _
class Product(models.Model):
"""Product model with standardized field ordering."""
# 1. Primary key (if custom)
# id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
# 2. Foreign keys and relations
category = models.ForeignKey(
"Category",
on_delete=models.PROTECT,
related_name="products",
)
# 3. Required fields
name = models.CharField(_("name"), max_length=200)
slug = models.SlugField(_("slug"), max_length=200, unique=True)
price = models.DecimalField(_("price"), max_digits=10, decimal_places=2)
# 4. Optional fields
description = models.TextField(_("description"), blank=True)
image = models.ImageField(_("image"), upload_to="products/", blank=True)
# 5. Boolean flags
is_active = models.BooleanField(_("active"), default=True)
is_featured = models.BooleanField(_("featured"), default=False)
# 6. Timestamps
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
updated_at = models.DateTimeField(_("updated at"), auto_now=True)
# 7. Meta class
class Meta:
verbose_name = _("product")
verbose_name_plural = _("products")
ordering = ["-created_at"]
indexes = [
models.Index(fields=["slug"]),
models.Index(fields=["category", "is_active"]),
]
# 8. String representation
def __str__(self) -> str:
return self.name
# 9. Save/delete overrides
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
# 10. Custom properties
@property
def is_in_stock(self) -> bool:
return self.stock > 0
# 11. Instance methods
def apply_discount(self, percentage: Decimal) -> Decimal:
return self.price * (1 - percentage / 100)
```
---
## Settings Organization
### Directory Structure
Settings MUST be organized in a package:
```
config/
├── settings/
│ ├── __init__.py # Imports from environment-specific module
│ ├── base.py # Shared settings
│ ├── local.py # Local development
│ ├── production.py # Production settings
│ └── test.py # Test settings
```
### Base Settings Pattern
```python
# config/settings/base.py
from pathlib import Path
import os
BASE_DIR = Path(__file__).resolve().parent.parent.parent
# Security - MUST be overridden in production
SECRET_KEY = "django-insecure-CHANGE-ME"
DEBUG = False
ALLOWED_HOSTS: list[str] = []
# Application definition
DJANGO_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
]
THIRD_PARTY_APPS = [
"rest_framework",
"django_extensions",
]
LOCAL_APPS = [
"apps.users",
"apps.products",
]
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
```
```python
# config/settings/local.py
from .base import *
DEBUG = True
SECRET_KEY = "local-dev-only-key"
ALLOWED_HOSTS = ["localhost", "127.0.0.1"]
# Debug toolbar
INSTALLED_APPS += ["debug_toolbar"]
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"]
INTERNAL_IPS = ["127.0.0.1"]
```
---
## URL Patterns
### URL Organization
URLs MUST use `include()` and namespacing:
```python
# config/urls.py
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path("admin/", admin.site.urls),
path("api/v1/", include("apps.api.urls", namespace="api-v1")),
path("products/", include("apps.products.urls", namespace="products")),
path("users/", include("apps.users.urls", namespace="users")),
]
```
```python
# apps/products/urls.py
from django.urls import path
from . import views
app_name = "products"
urlpatterns = [
path("", views.ProductListView.as_view(), name="list"),
path("<slug:slug>/", views.ProductDetailView.as_view(), name="detail"),
path("category/<slug:category_slug>/", views.CategoryView.as_view(), name="category"),
]
```
### URL Naming Conventions
- List views: `<app>:list` (e.g., `products:list`)
- Detail views: `<app>:detail` (e.g., `products:detail`)
- Create views: `<app>:create`
- Update views: `<app>:update`
- Delete views: `<app>:delete`
---
## Views
### Class-Based vs Function-Based Views
You SHOULD use CBVs for standard CRUD operations and FBVs for simple, one-off logic:
**Use CBVs when:**
- Standard CRUD operations
- Pagination, filtering, or sorting needed
- Template rendering with context
- Code reuse via mixins
**Use FBVs when:**
- Simple API endpoints
- Webhooks or callbacks
- Complex conditional logic that doesn't fit CBV flow
### CBV Example
```python
from django.views.generic import ListView, DetailView
from django.contrib.auth.mixins import LoginRequiredMixin
class ProductListView(ListView):
model = Product
template_name = "products/list.html"
context_object_name = "products"
paginate_by = 20
def get_queryset(self):
return ProductSelectors.get_active_products()
class ProductDetailView(DetailView):
model = Product
template_name = "products/detail.html"
slug_url_kwarg = "slug"
def get_object(self):
return ProductSelectors.get_product_with_reviews(self.kwargs["slug"])
```
---
## Forms and Validation
### Form Organization
Forms MUST separate validation from processing:
```python
from django import forms
from django.core.exceptions import ValidationError
class OrderForm(forms.ModelForm):
"""Order creation form with custom validation."""
class Meta:
model = Order
fields = ["shipping_address", "payment_method"]
def clean_shipping_address(self):
address = self.cleaned_data["shipping_address"]
if not address.is_deliverable:
raise ValidationError("Shipping not available to this address.")
return address
View on GitHub