---
name: django-experto
description: >
  Patrones de producción para Django: ORM avanzado (select_related, prefetch_related,
  F/Q expressions, anotaciones), migraciones con datos, signals, Django REST Framework,
  Channels y testing con pytest-django. Cargar cuando se implemente cualquier
  componente Django: modelos, vistas, serializers, APIs REST, WebSockets o tareas Celery.
version: "1.0.0"
herramientasPermitidas: [Read]
exclusiones:
  - "No cargar para proyectos FastAPI — el ORM sync de Django y el sistema de migrations no aplican a SQLAlchemy async; cargar `fastapi-experto`."
  - "No cargar para scripting Python standalone que no use el stack de Django (manage.py, settings, apps) — si no hay `DJANGO_SETTINGS_MODULE`, los patrones de este skill no aplican; cargar `patrones-python`."
  - "No cargar para optimizar queries SQL en bases de datos no gestionadas por el ORM de Django — para PostgreSQL raw o sin Django ORM, cargar `sql-optimizacion` o `postgresql-experto`."
  - "No cargar para tareas Celery que no tienen relación con modelos Django — si la tarea Celery es procesamiento puro sin acceso al ORM, cargar `async-python` o `orquestacion-async`."
evolvable: true  # default para skill estandar
---
# Django Experto

## Cuándo NO cargar

- El proyecto usa FastAPI — los patrones de ORM async y DI son distintos; cargar `fastapi-experto`.
- No hay `DJANGO_SETTINGS_MODULE` en el contexto — si no es un proyecto Django, los patrones no aplican.
- La optimización de queries es para PostgreSQL sin ORM de Django — cargar `sql-optimizacion`.

## Estructura de proyecto recomendada

```
proyecto/
├── config/
│   ├── settings/
│   │   ├── base.py        # Configuración base
│   │   ├── development.py
│   │   └── production.py
│   ├── urls.py
│   └── wsgi.py / asgi.py
├── apps/
│   ├── usuarios/          # Cada app es un módulo de negocio
│   │   ├── models.py
│   │   ├── views.py
│   │   ├── serializers.py
│   │   ├── urls.py
│   │   ├── signals.py
│   │   ├── admin.py
│   │   └── tests/
│   └── facturas/
├── common/                # Código compartido entre apps
│   ├── models.py          # Modelos abstractos base
│   ├── permissions.py
│   └── pagination.py
└── manage.py
```

---

## ORM Avanzado

### select_related y prefetch_related

```python
# NUNCA: N+1 queries
facturas = Factura.objects.all()
for factura in facturas:
    print(factura.cliente.nombre)  # 1 query por factura

# BIEN: select_related para FK/OneToOne (JOIN)
facturas = Factura.objects.select_related("cliente", "empresa").all()

# BIEN: prefetch_related para ManyToMany y relaciones inversas
facturas = Factura.objects.prefetch_related("items", "items__producto").all()

# Prefetch con queryset personalizado
from django.db.models import Prefetch

facturas = Factura.objects.prefetch_related(
    Prefetch(
        "items",
        queryset=ItemFactura.objects.filter(activo=True).select_related("producto"),
        to_attr="items_activos",
    )
).all()
```

### Anotaciones y agregaciones

```python
from django.db.models import Sum, Count, F, Q

# Anotar totales calculados en la base de datos
facturas = Factura.objects.annotate(
    total_calculado=Sum(F("items__cantidad") * F("items__precio_unitario")),
    num_items=Count("items"),
).filter(total_calculado__gt=1000)

# Filtros complejos con Q
facturas_pendientes = Factura.objects.filter(
    Q(estatus="pendiente") | Q(estatus="vencida"),
    empresa=empresa_actual,
).exclude(cancelada=True)
```

### F expressions para actualizaciones atómicas

```python
from django.db.models import F

# Incrementar sin race condition
Producto.objects.filter(id=producto_id).update(
    stock=F("stock") - cantidad_vendida
)
```

### Modelo base abstracto

```python
# common/models.py
import uuid
from django.db import models

class ModeloBase(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    creado_en = models.DateTimeField(auto_now_add=True)
    actualizado_en = models.DateTimeField(auto_now=True)
    creado_por = models.EmailField(max_length=255, blank=True)
    actualizado_por = models.EmailField(max_length=255, blank=True)

    class Meta:
        abstract = True
```

---

## Migraciones

### Migraciones con datos (data migrations)

```python
# migrations/0023_migrar_estatus_factura.py
from django.db import migrations

def migrar_estatus(apps, schema_editor):
    Factura = apps.get_model("facturas", "Factura")
    Factura.objects.filter(estatus="activa").update(estatus="emitida")

def revertir_migrar_estatus(apps, schema_editor):
    Factura = apps.get_model("facturas", "Factura")
    Factura.objects.filter(estatus="emitida").update(estatus="activa")

class Migration(migrations.Migration):
    dependencies = [("facturas", "0022_factura_estatus_choices")]
    operations = [
        migrations.RunPython(migrar_estatus, revertir_migrar_estatus),
    ]
```

### Optimizaciones en migraciones de producción

Para tablas grandes: agregar columna nullable primero, luego data migration,
luego NOT NULL en siguiente deploy. Esto evita bloqueo de tabla.

---

## Signals

```python
# apps/facturas/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import Factura

@receiver(post_save, sender=Factura)
def notificar_cambio_estatus(sender, instance, created, **kwargs):
    if not created and instance.tracker.has_changed("estatus"):
        estatus_anterior = instance.tracker.previous("estatus")
        notificacion_service.enviar(
            usuario_id=instance.cliente_id,
            tipo="CAMBIO_ESTATUS_FACTURA",
            datos={"anterior": estatus_anterior, "nuevo": instance.estatus},
        )

# Conectar signals en AppConfig (apps/facturas/apps.py)
class FacturasConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "apps.facturas"

    def ready(self):
        import apps.facturas.signals  # noqa: F401
```

---

## Gotchas / Errores comunes no obvios

- **`select_related('empresa__ciudad')` carga la relación pero la query sigue siendo lenta en producción**: `select_related` hace un JOIN en SQL, pero si la tabla `empresa` tiene millones de registros, el JOIN puede ser más caro que dos queries separadas. Causa: `select_related` no siempre es más eficiente que `prefetch_related` — para relaciones de muchos-a-uno con alta cardinalidad, `prefetch_related` puede ser mejor porque hace una query separada con `IN`. Solución: comparar con `EXPLAIN ANALYZE` ambas aproximaciones antes de decidir — la elección depende de los datos reales, no de una regla absoluta.
- **Migration que agrega columna `NOT NULL` sin default falla en producción con millones de filas pero pasa en tests con datos vacíos**: `ALTER TABLE ADD COLUMN NOT NULL` en PostgreSQL adquiere un `ACCESS EXCLUSIVE LOCK` sobre la tabla completa durante la reescritura. Causa: en tests la tabla está vacía y la operación es instantánea; en producción con millones de filas, la tabla queda bloqueada segundos o minutos. Solución: para columnas NOT NULL en tablas grandes: (1) agregar columna `NULL` primero, (2) hacer backfill con `update_fields`, (3) agregar constraint NOT NULL con `NOT VALID`, (4) validar en una ventana de mantenimiento.
- **Signal `post_save` llama a código que lanza excepción, dejando la transacción en estado inconsistente**: la signal se ejecuta dentro de la transacción de `save()`, y si la signal falla, la transacción puede quedar en un estado de error que impide más queries. Causa: Django ejecuta signals síncronamente dentro de la transacción activa — si el código de la signal falla, la transacción original también puede fallar. Solución: usar `transaction.on_commit(lambda: mi_tarea.delay())` para code que debe ejecutarse después del commit exitoso, especialmente para llamadas a Celery o servicios externos.
- **DRF `SerializerMethodField` que accede a `request.user` devuelve `AnonymousUser` en tests aunque el test está autenticado**: el contexto del serializer no se pasa automáticamente cuando se instancia el serializer directamente en tests. Causa: en la vista, DRF pasa `context={'request': request}` al serializer automáticamente, pero al instanciar `MySerializer(instance)` directamente en tests sin el contexto, `self.context.get('request')` devuelve `None`. Solución: pasar el contexto explícitamente: `MySerializer(instance, context={'request': mock_request})`.

## Referencias especializadas

| Tema | Archivo |
|------|---------|
| Custom QuerySets, F/Q expressions, window functions, bulk ops | [recursos/orm-avanzado.md](recursos/orm-avanzado.md) |
| ViewSets, Serializers, filtros, paginación, throttling, versioning | [recursos/drf-patrones.md](recursos/drf-patrones.md) |
| pytest-django, Factory Boy, fixtures, mocking, performance | [recursos/testing-django.md](recursos/testing-django.md) |
| Async views, Channels, WebSockets, Celery, ASGI | [recursos/async-django.md](recursos/async-django.md) |
