Skills
Backend

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_KEY e credenciais nunca no repo; sempre via env var.
  • Migrations geradas com makemigrations ficam 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.
  • .env no .gitignore, sempre.
  • .env.example versionado 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)). Use BigAutoField só quando há razão concreta (ex.: integração legada).
  • Timestamps: todo model herda created_at / updated_at via base abstrata.
  • Sem lógica de negócio em models — só persistência, invariantes simples e __str__. Lógica vai para services.py.
  • Managers customizados para querysets que repetem (active(), for_user(user)), nunca @classmethod na model.
  • Meta.ordering padrão por -created_at para 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: prefira PROTECT para FKs com sentido de negócio; CASCADE só 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

  • ModelSerializer para CRUD direto sobre uma model.
  • Serializer para 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 em validate(self, attrs).
  • Escrita com FK por ID, leitura com objeto aninhado — use dois serializers (*WriteSerializer / *ReadSerializer) ou to_representation.
  • Nunca exponha password, hashes, tokens, ou campos internos (is_staff, flags) sem write_only/read_only explí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).
  • APIView para endpoints que não são CRUD (login, ações cruzadas, webhooks).
  • @action para operações sobre um recurso que não cabem em CRUD (/users/{id}/activate/).
  • Paginação padrão: PageNumberPagination com page_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; sem if user.is_staff espalhado (use permission class).
  • get_queryset sempre filtra por escopo do usuário — vazamento entre tenants/users é o bug mais comum.
  • Status codes corretos: 201 em create, 204 em delete/action sem body, 400 em validação, 403 em permission, 404 em 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-db para não pagar o custo de recriar o banco a cada rodada. SQLite não é um substituto válido: CheckConstraint, select_for_update, JSONField e 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.py lê melhor e conflita menos em merge do que um único test_models.py de 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_db em todo teste que toca banco (transaction=True quando o código usa select_for_update/on_commit).
  • Sem assert True nem testes que só rodam sem verificar nada.
  • Controle o tempo com freezegun quando o teste depende de timezone.now() — sem isso, teste falha esporadicamente na virada do dia ou perto de fuso horário.
  • Bloqueie rede real em teste (fixture autouse que falha em qualquer socket externo) — evita que uma chamada HTTP esquecida sem mock passe despercebida até quebrar em CI.
  • Use pytest-randomly para 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_commit está sob django_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.