From python-experts
Provides 2025 Django patterns for project structure, settings, naming conventions, models with type hints and indexes, async support. Activates on Django models, views, URLs, forms, templates, commands, structure.
npx claudepluginhub jpoutrin/product-forge --plugin python-expertsThis skill uses the workspace's default tool permissions.
```
Provides Django 5.x expertise for async views, DRF, Celery, Django Channels; builds scalable apps with architecture, ORM optimization, testing, deployment.
Provides Django 5.x expertise for scalable web apps using async views, DRF, Celery, Django Channels, with architecture, testing, security, and deployment guidance.
Enforces opinionated Django patterns: 1-file-per-model organization, UUID primary keys, timestamps, soft deletes, Dynaconf config, uv/pyproject.toml deps, Docker structure for project setup and models.
Share bugs, ideas, or general feedback.
project_name/
├── config/ # Project config (rename from project_name/)
│ ├── settings/
│ │ ├── __init__.py
│ │ ├── base.py
│ │ ├── dev.py
│ │ └── prod.py
│ ├── urls.py
│ ├── wsgi.py
│ └── asgi.py # Required for async
├── apps/
│ ├── __init__.py
│ └── core/ # Shared utilities, base models
├── templates/
├── static/
├── manage.py
├── pyproject.toml # Modern Python packaging
└── requirements/
├── base.txt
├── dev.txt
└── prod.txt
# config/settings/base.py
import environ
env = environ.Env(
DEBUG=(bool, False),
)
environ.Env.read_env()
SECRET_KEY = env("SECRET_KEY")
DEBUG = env("DEBUG")
DATABASES = {"default": env.db()}
# .env
SECRET_KEY=your-secret-key
DEBUG=True
DATABASE_URL=postgres://user:pass@localhost:5432/dbname
| Component | Convention | Example |
|---|---|---|
| App | singular, lowercase | blog, user_profile |
| Model | singular PascalCase | Article, UserProfile |
| View (function) | noun_action | article_detail |
| View (class) | NounActionView | ArticleDetailView |
| URL name | app:noun-action | blog:article-detail |
| Template | app/noun_action.html | blog/article_detail.html |
from django.db import models
from django.urls import reverse
class TimestampedModel(models.Model):
"""Abstract base for created/updated timestamps."""
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
abstract = True
class Article(TimestampedModel):
class Status(models.TextChoices):
DRAFT = "draft", "Draft"
PUBLISHED = "published", "Published"
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=200, unique=True)
author = models.ForeignKey(
"auth.User",
on_delete=models.CASCADE,
related_name="articles",
)
status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.DRAFT,
db_index=True,
)
class Meta:
ordering = ["-created_at"]
indexes = [
models.Index(fields=["status", "created_at"]),
]
def __str__(self) -> str:
return self.title
def get_absolute_url(self) -> str:
return reverse("blog:article-detail", kwargs={"slug": self.slug})
Key patterns:
TextChoices / IntegerChoices for choices (not tuples)related_name, db_index on filtered fieldsfrom django.views.generic import ListView, DetailView, CreateView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Q
class ArticleListView(ListView):
model = Article
template_name = "blog/article_list.html"
context_object_name = "articles"
paginate_by = 20
def get_queryset(self):
qs = super().get_queryset().select_related("author")
if q := self.request.GET.get("q"):
qs = qs.filter(Q(title__icontains=q) | Q(body__icontains=q))
return qs
Use async for I/O-bound operations (external APIs, file ops). Requires ASGI server.
import httpx
from django.http import JsonResponse
from asgiref.sync import sync_to_async
async def weather_view(request):
"""Async view calling external API."""
city = request.GET.get("city", "London")
async with httpx.AsyncClient() as client:
response = await client.get(f"https://api.weather.com/{city}")
return JsonResponse(response.json())
async def article_list_async(request):
"""Async view with ORM (requires sync_to_async wrapper)."""
articles = await sync_to_async(list)(
Article.objects.select_related("author")[:20]
)
return JsonResponse({"articles": [a.title for a in articles]})
When to use async views:
httpx (async) not requestsNote: Django ORM is not fully async. Wrap ORM calls with sync_to_async().
# apps/blog/urls.py
from django.urls import path
from . import views
app_name = "blog"
urlpatterns = [
path("", views.ArticleListView.as_view(), name="article-list"),
path("<slug:slug>/", views.ArticleDetailView.as_view(), name="article-detail"),
]
# config/urls.py
urlpatterns = [
path("admin/", admin.site.urls),
path("blog/", include("apps.blog.urls")),
]
from django import forms
from .models import Article
class ArticleForm(forms.ModelForm):
class Meta:
model = Article
fields = ["title", "body", "status"]
widgets = {
"body": forms.Textarea(attrs={"rows": 10}),
}
def clean_title(self) -> str:
title = self.cleaned_data["title"]
if len(title) < 5:
raise forms.ValidationError("Title must be at least 5 characters.")
return title
templates/
├── base.html
├── includes/
│ ├── _pagination.html
│ └── _messages.html
└── blog/
├── article_list.html
└── article_detail.html
Prefix partials with underscore. Use {% url %} not hardcoded paths:
{% extends "base.html" %}
{% block content %}
<a href="{% url 'blog:article-detail' slug=article.slug %}">
{{ article.title }}
</a>
{% endblock %}
Add type hints throughout for mypy / IDE support:
from django.http import HttpRequest, HttpResponse
from django.db.models import QuerySet
def article_list(request: HttpRequest) -> HttpResponse:
articles: QuerySet[Article] = Article.objects.filter(status="published")
return render(request, "blog/article_list.html", {"articles": articles})
# pyproject.toml
[tool.mypy]
plugins = ["mypy_django_plugin.main"]
django_settings_module = "config.settings.dev"
# Run
mypy apps/
# Install
pip install uvicorn
# Development
uvicorn config.asgi:application --reload
# Production
gunicorn config.asgi:application -k uvicorn.workers.UvicornWorker -w 4
import pytest
from django.test import Client
from django.urls import reverse
@pytest.mark.django_db
def test_article_list_view(client: Client):
response = client.get(reverse("blog:article-list"))
assert response.status_code == 200
assert "articles" in response.context
@pytest.mark.django_db
async def test_async_view():
"""Async test for async views."""
from django.test import AsyncClient
client = AsyncClient()
response = await client.get("/api/weather/?city=Paris")
assert response.status_code == 200
Use pytest-django + factory_boy for ergonomic testing.
N+1 queries: Use select_related (FK) and prefetch_related (M2M). Check with django-debug-toolbar.
Sync calls in async views: Wrap ORM with sync_to_async(). Use httpx not requests.
Missing migrations: Run makemigrations after model changes. Commit migrations.
Hardcoded URLs: Use {% url %} in templates, reverse() in Python.
No indexes: Add db_index=True or Meta.indexes for filtered/ordered fields.
Fat views: Move business logic to model methods or a service layer.
# pyproject.toml dev dependencies
django-debug-toolbar # Query debugging
django-extensions # shell_plus, show_urls
django-environ # Environment variables
pytest-django # Testing
factory-boy # Test fixtures
mypy + django-stubs # Type checking
ruff # Linting (replaces flake8/isort/black)
Keep commands thin — delegate logic to services.
apps/blog/
├── management/
│ └── commands/
│ └── publish_scheduled.py
└── services/
└── publishing.py
# apps/blog/services/publishing.py
from dataclasses import dataclass
from django.utils import timezone
from apps.blog.models import Article
@dataclass
class PublishResult:
published_count: int
failed_ids: list[int]
def publish_scheduled_articles(dry_run: bool = False) -> PublishResult:
"""
Publish all articles scheduled for now or earlier.
Business logic lives here — testable without command scaffolding.
"""
now = timezone.now()
articles = Article.objects.filter(
status=Article.Status.SCHEDULED,
publish_at__lte=now,
)
if dry_run:
return PublishResult(published_count=articles.count(), failed_ids=[])
published = 0
failed = []
for article in articles:
try:
article.status = Article.Status.PUBLISHED
article.save(update_fields=["status", "updated_at"])
published += 1
except Exception:
failed.append(article.id)
return PublishResult(published_count=published, failed_ids=failed)
# apps/blog/management/commands/publish_scheduled.py
from django.core.management.base import BaseCommand, CommandError
from apps.blog.services.publishing import publish_scheduled_articles
class Command(BaseCommand):
help = "Publish articles that are scheduled for now or earlier"
def add_arguments(self, parser):
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be published without making changes",
)
def handle(self, *args, **options):
dry_run = options["dry_run"]
if dry_run:
self.stdout.write(self.style.WARNING("DRY RUN — no changes will be made"))
result = publish_scheduled_articles(dry_run=dry_run)
if result.failed_ids:
self.stderr.write(
self.style.ERROR(f"Failed to publish: {result.failed_ids}")
)
self.stdout.write(
self.style.SUCCESS(f"Published {result.published_count} articles")
)
# apps/blog/tests/test_services.py
import pytest
from django.utils import timezone
from apps.blog.services.publishing import publish_scheduled_articles
from apps.blog.models import Article
@pytest.mark.django_db
def test_publish_scheduled_articles(article_factory):
# Create scheduled article in the past
article = article_factory(
status=Article.Status.SCHEDULED,
publish_at=timezone.now() - timezone.timedelta(hours=1),
)
result = publish_scheduled_articles()
assert result.published_count == 1
article.refresh_from_db()
assert article.status == Article.Status.PUBLISHED
@pytest.mark.django_db
def test_publish_dry_run_no_changes(article_factory):
article = article_factory(
status=Article.Status.SCHEDULED,
publish_at=timezone.now() - timezone.timedelta(hours=1),
)
result = publish_scheduled_articles(dry_run=True)
assert result.published_count == 1
article.refresh_from_db()
assert article.status == Article.Status.SCHEDULED # Unchanged
| Layer | Responsibility |
|---|---|
| Command | Parse args, call service, format output, exit codes |
| Service | Business logic, DB operations, return typed results |
Command should NOT:
Benefits: