Django Drf
Convenções Django 5 + Django REST Framework — models, serializers, views/viewsets, services, env vars, testes. Invoque ao criar model, serializer, view, endpoint ou teste num projeto Django.
Django + DRF
Banco padrão: PostgreSQL. Config: django-environ. Testes: pytest-django + factory_boy.
Referência
| Arquivo | Conteúdo |
|---|---|
| reference/folder-structure.md | Organização de config/, apps/, requirements |
| reference/models.md | PK, timestamps, managers, on_delete, choices |
| reference/serializers-and-views.md | Serializers, ViewSets, @action, paginação, services |
| reference/env-vars.md | django-environ, variáveis obrigatórias |
| reference/testing.md | pytest-django, factories, N+1, matriz de permissão |
Regras inegociáveis
- Lógica de negócio em
services.py, nunca em views ou models. - Nenhuma query de banco direto em views — só via managers ou services.
fields = '__all__'é proibido em serializers — sempre liste explicitamente.- Todo endpoint tem teste de integração que bate no banco real (Postgres, não SQLite — ver
reference/testing.md). get_queryset()filtra por escopo do usuário em todo viewset que retorna dados de usuário.SECRET_KEYe credenciais nunca no repo; sempre via env var.- Migrations geradas com
makemigrationsficam commitadas; nunca edite migration aplicada em outra branch sem coordenar.
Skills relacionadas
- Autenticação:
backend/jwt-cookie-auth
Documentos de referência
Variáveis de ambiente
fonte ↗Variáveis de ambiente
Referência de backend/django-drf. Volte ao índice para o quando-invocar.
Usar django-environ. .env por ambiente, .env.example versionado com placeholders.
# config/settings/base.py
import environ
env = environ.Env()
environ.Env.read_env() # lê .env na raiz
SECRET_KEY = env('DJANGO_SECRET_KEY')
DEBUG = env.bool('DJANGO_DEBUG', default=False)
ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS', default=[])
DATABASES = {'default': env.db('DATABASE_URL')}
CORS_ALLOWED_ORIGINS = env.list('CORS_ALLOWED_ORIGINS', default=[])
Variáveis obrigatórias em todo projeto:
| Variável | Descrição |
|---|---|
DJANGO_SECRET_KEY |
Chave do Django. Nunca commit, sempre rotacionável. |
DJANGO_DEBUG |
True só em local. Em prod, False. |
DJANGO_ALLOWED_HOSTS |
Lista separada por vírgula. |
DATABASE_URL |
URL completa (postgres://user:pass@host:port/db). |
DJANGO_SETTINGS_MODULE |
config.settings.local / config.settings.production. |
CORS_ALLOWED_ORIGINS |
Origens permitidas pelo django-cors-headers. |
.envno.gitignore, sempre..env.exampleversionado com placeholders (DJANGO_SECRET_KEY=changeme).- Secrets reais em vault (1Password, AWS Secrets Manager, etc.), nunca no repo.
Se o projeto usa backend/jwt-cookie-auth, há variáveis e ajustes de CORS adicionais — ver a
referência daquele skill.
Estrutura de pastas
fonte ↗Estrutura de pastas
Referência de backend/django-drf. Volte ao índice para o quando-invocar.
project/
├── config/
│ ├── settings/
│ │ ├── base.py
│ │ ├── local.py
│ │ └── production.py
│ ├── urls.py
│ └── wsgi.py
├── apps/
│ └── <domain>/ # uma app por domínio (users, billing, etc.)
│ ├── models.py
│ ├── serializers.py
│ ├── views.py
│ ├── urls.py
│ ├── services.py # lógica de negócio (não na view, não no model)
│ └── tests/
│ ├── test_models.py
│ ├── test_serializers.py
│ └── test_views.py
├── manage.py
└── requirements/
├── base.txt
├── local.txt
└── production.txt
Models
fonte ↗Models
Referência de backend/django-drf. Volte ao índice para o quando-invocar.
- PK: UUID por padrão (
UUIDField(primary_key=True, default=uuid4)). UseBigAutoFieldsó quando há razão concreta (ex.: integração legada). - Timestamps: todo model herda
created_at/updated_atvia base abstrata. - Sem lógica de negócio em models — só persistência, invariantes simples e
__str__. Lógica vai paraservices.py. - Managers customizados para querysets que repetem (
active(),for_user(user)), nunca@classmethodna model. - Meta.ordering padrão por
-created_atpara listagens.
# apps/core/models.py
import uuid
from django.db import models
class BaseModel(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
abstract = True
ordering = ['-created_at']
# apps/billing/models.py
from apps.core.models import BaseModel
class Subscription(BaseModel):
user = models.ForeignKey('users.User', on_delete=models.PROTECT, related_name='subscriptions')
plan = models.ForeignKey('Plan', on_delete=models.PROTECT)
status = models.CharField(max_length=20, choices=[('active', 'Active'), ('canceled', 'Canceled')])
def __str__(self):
return f'{self.user.email} → {self.plan.name}'
on_delete: prefiraPROTECTpara FKs com sentido de negócio;CASCADEsó para dependência forte (ex.: filhos órfãos de fato).- Choices em listas no topo do arquivo ou como
TextChoices/IntegerChoices— nunca strings mágicas espalhadas.
Serializers, Views/ViewSets e Services
fonte ↗Serializers, Views/ViewSets e Services
Referência de backend/django-drf. Volte ao índice para o quando-invocar.
Serializers
ModelSerializerpara CRUD direto sobre uma model.Serializerpara shapes que não batem 1:1 com model (login, ações, agregados).- Validações de campo vão em
validate_<field>; cross-field vai emvalidate(self, attrs). - Escrita com FK por ID, leitura com objeto aninhado — use dois serializers (
*WriteSerializer/*ReadSerializer) outo_representation. - Nunca exponha
password, hashes, tokens, ou campos internos (is_staff, flags) semwrite_only/read_onlyexplícito.
# apps/users/serializers.py
from rest_framework import serializers
from .models import User
class UserSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True, min_length=8)
class Meta:
model = User
fields = ['id', 'email', 'name', 'password', 'created_at']
read_only_fields = ['id', 'created_at']
def validate_email(self, value: str) -> str:
if User.objects.filter(email__iexact=value).exists():
raise serializers.ValidationError('Email já cadastrado.')
return value.lower()
def create(self, validated_data: dict) -> User:
# delega para o service — não escreva lógica de criação aqui
from .services import create_user
return create_user(**validated_data)
fields = '__all__'é proibido. Liste explicitamente — evita expor campos novos sem querer ao adicionar coluna.- Serializer aninhado só para leitura. Para escrita, aceite IDs (
PrimaryKeyRelatedField).
Views / ViewSets
ModelViewSet+ Router para CRUD padrão (list,retrieve,create,update,destroy).APIViewpara endpoints que não são CRUD (login, ações cruzadas, webhooks).@actionpara operações sobre um recurso que não cabem em CRUD (/users/{id}/activate/).- Paginação padrão:
PageNumberPaginationcompage_size = 20. - Permissions declaradas explicitamente em cada viewset — não confie só na global.
# apps/billing/views.py
from rest_framework import viewsets, permissions, status
from rest_framework.decorators import action
from rest_framework.response import Response
from .models import Subscription
from .serializers import SubscriptionSerializer
from . import services
class SubscriptionViewSet(viewsets.ModelViewSet):
serializer_class = SubscriptionSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
# nunca retorne objetos de outros usuários
return Subscription.objects.for_user(self.request.user)
@action(detail=True, methods=['post'])
def cancel(self, request, pk=None):
subscription = self.get_object()
services.cancel_subscription(subscription, reason=request.data.get('reason'))
return Response(status=status.HTTP_204_NO_CONTENT)
- Views são finas: validação via serializer → chamada a
services.*→ resposta. Sem queries em handlers; semif user.is_staffespalhado (use permission class). get_querysetsempre filtra por escopo do usuário — vazamento entre tenants/users é o bug mais comum.- Status codes corretos:
201em create,204em delete/action sem body,400em validação,403em permission,404em not found.
Paginação
# config/settings/base.py
REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 20,
}
URLs
# apps/billing/urls.py
from rest_framework.routers import DefaultRouter
from .views import SubscriptionViewSet
router = DefaultRouter()
router.register('subscriptions', SubscriptionViewSet, basename='subscription')
urlpatterns = router.urls
Services
Lógica de negócio fica em services.py, não em views nem em models:
# apps/billing/services.py
def create_subscription(user: User, plan: Plan) -> Subscription:
# validações, criação, side effects
...
Views chamam services. Models são só persistência.
Testes
fonte ↗Testes
Referência de backend/django-drf. Volte ao índice para o quando-invocar.
Stack: pytest + pytest-django + factory_boy.
Regra de ouro: teste deriva do requisito, não do código
Ao escrever teste para código já existente, formule primeiro o comportamento esperado em uma frase, sem olhar a implementação. Se divergir do código, é bug — não é motivo para ajustar o teste.
- Factories, não fixtures globais com dados grandes. Cada factory em
apps/<domain>/tests/factories.py. - Banco real: Postgres, o mesmo motor de produção — via container, com
--reuse-dbpara não pagar o custo de recriar o banco a cada rodada. SQLite não é um substituto válido:CheckConstraint,select_for_update,JSONFielde ordenação/collation se comportam diferente entre os dois motores, e teste verde em SQLite não garante nada sobre Postgres em produção. - Não moque a ORM — moque só bordas externas (HTTP, S3, email).
- Todo endpoint precisa de teste de integração: status code + shape da resposta + efeito no banco.
- Organize por comportamento, não só por camada técnica:
test_criacao_pedido.py,test_cancelamento.pylê melhor e conflita menos em merge do que um únicotest_models.pyde 600 linhas acumulando tudo que toca aquele model.
# conftest.py
import pytest
from rest_framework.test import APIClient
from apps.users.tests.factories import UserFactory
@pytest.fixture
def api_client() -> APIClient:
return APIClient()
@pytest.fixture
def user():
return UserFactory()
@pytest.fixture
def authenticated_client(api_client, user) -> APIClient:
api_client.force_authenticate(user=user)
return api_client
# apps/users/tests/factories.py
import factory
from apps.users.models import User
class UserFactory(factory.django.DjangoModelFactory):
class Meta:
model = User
email = factory.Sequence(lambda n: f'user{n}@example.com')
name = factory.Faker('name')
# apps/billing/tests/test_listagem_assinaturas.py
import pytest
from apps.users.tests.factories import UserFactory
from apps.billing.tests.factories import SubscriptionFactory
@pytest.mark.django_db
def test_user_lists_only_own_subscriptions(api_client):
user = UserFactory()
other = UserFactory()
own = SubscriptionFactory(user=user)
SubscriptionFactory(user=other) # não deve aparecer
api_client.force_authenticate(user=user)
res = api_client.get('/api/subscriptions/')
assert res.status_code == 200
ids = {s['id'] for s in res.json()['results']}
assert ids == {str(own.id)}
Use set()/{...} para comparar coleções quando a ordem não é parte do contrato testado — com um item só a asserção posicional (== [x]) passa por acaso; com dois vira flake no primeiro reorder inocente. Se a ordenação é parte do requisito, teste a ordenação explicitamente, em teste separado.
Matriz de permissão
Todo endpoint que retorna ou altera dado de usuário precisa de teste parametrizado cobrindo pelo menos: dono, outro usuário, anônimo, admin (quando existir). Para objeto de outro dono, o retorno esperado é 404, não 403 — 403 confirma para quem pergunta que o recurso existe, o que já é um vazamento de informação.
@pytest.mark.django_db
@pytest.mark.parametrize('as_user,expected_status', [
('owner', 200),
('other', 404),
(None, 401),
])
def test_get_subscription_permission_matrix(api_client, as_user, expected_status, request):
owner = UserFactory()
subscription = SubscriptionFactory(user=owner)
if as_user == 'owner':
api_client.force_authenticate(user=owner)
elif as_user == 'other':
api_client.force_authenticate(user=UserFactory())
res = api_client.get(f'/api/subscriptions/{subscription.id}/')
assert res.status_code == expected_status
N+1
Teste de queries é o item mais barato que evita alguém adicionar um SerializerMethodField com query embutida daqui a seis meses. Com um registro só, N+1 não aparece — o teste precisa de um lote (20+ objetos).
@pytest.mark.django_db
def test_list_subscriptions_does_not_n_plus_1(django_assert_num_queries, authenticated_client, user):
SubscriptionFactory.create_batch(25, user=user)
with django_assert_num_queries(2): # 1 count + 1 select, sem crescer com o tamanho do lote
authenticated_client.get('/api/subscriptions/')
Transações e concorrência
select_for_update e transaction.on_commit não funcionam sob django_db normal — a transação de teste nunca commita de verdade, então o código sob teste roda em condições que não existem em produção. Use @pytest.mark.django_db(transaction=True) para esses casos.
@pytest.mark.django_db(transaction=True)
def test_reserva_de_estoque_e_atomica(user):
...
Regras gerais
@pytest.mark.django_dbem todo teste que toca banco (transaction=Truequando o código usaselect_for_update/on_commit).- Sem
assert Truenem testes que só rodam sem verificar nada. - Controle o tempo com
freezegunquando o teste depende detimezone.now()— sem isso, teste falha esporadicamente na virada do dia ou perto de fuso horário. - Bloqueie rede real em teste (fixture
autouseque falha em qualquer socket externo) — evita que uma chamada HTTP esquecida sem mock passe despercebida até quebrar em CI. - Use
pytest-randomlypara randomizar a ordem dos testes — ordem fixa esconde acoplamento entre testes (um teste que depende de objeto criado por outro).
Cobertura: patch coverage + mutation testing, não ratchet global
Cobertura global como ratchet (.harness/baseline.json só sobe) é gamificável — importar um módulo já sobe o número sem verificar nada — e recompensa testar código fácil (getter, serializer trivial) em vez do código difícil. Sob geração automática, isso empurra para o caminho mais barato: teste raso que só sobe o número.
Duas métricas que substituem isso de forma honesta:
- Patch coverage: cobertura das linhas alteradas no diff da PR, com piso alto (85–90%). Impede código novo sem teste, sem premiar teste de enfeite em código antigo já existente.
- Mutation testing (
mutmut) nos módulos críticos (services.py, permissões, cálculo). A ferramenta altera o código de propósito e verifica se algum teste quebra; mutante sobrevivente é comportamento sem cobertura real — a única métrica que um teste vazio não engana.
Checklist antes de terminar
- Testou contra Postgres, não SQLite.
- Todo endpoint novo tem teste de dono / outro usuário / anônimo (matriz de permissão).
- Endpoint de listagem tem teste de N+1 com lote de 20+ objetos.
- Código com
select_for_update/on_commitestá sobdjango_db(transaction=True). - Nenhuma asserção de ordenação posicional em coleção sem ordenação explícita no contrato.
- Patch coverage do diff está acima do piso combinado no projeto.