# Django Async y Channels — Patrones de Producción

## Async views: cuándo y cómo usarlas

Django soporta vistas async desde la versión 3.1. Una vista async permite liberar
el hilo mientras espera I/O (llamadas HTTP externas, operaciones de archivos),
pero el ORM de Django es **síncrono**: cada query bloquea. La solución es
`sync_to_async`.

```python
# apps/reportes/views.py
import asyncio
from django.http import JsonResponse
from django.views import View
from asgiref.sync import sync_to_async

from apps.clientes.models import Cliente
from apps.facturas.services import calcular_resumen


class ResumenClienteView(View):
    """
    Vista async que hace consultas ORM y llamadas HTTP externas en paralelo.
    """

    async def get(self, request, cliente_id: int) -> JsonResponse:
        # sync_to_async envuelve una función síncrona para llamarla desde async
        obtener_cliente = sync_to_async(
            Cliente.objects.select_related("empresa").get
        )
        try:
            cliente = await obtener_cliente(id=cliente_id)
        except Cliente.DoesNotExist:
            return JsonResponse({"error": "Cliente no encontrado."}, status=404)

        # Ejecutar ORM y llamada HTTP externa en paralelo
        resumen, datos_externos = await asyncio.gather(
            sync_to_async(calcular_resumen)(cliente.id),
            self._obtener_datos_externos(cliente.rfc),
        )

        return JsonResponse({"resumen": resumen, "externos": datos_externos})

    async def _obtener_datos_externos(self, rfc: str) -> dict:
        import httpx
        async with httpx.AsyncClient(timeout=5.0) as client:
            respuesta = await client.get(f"https://api.sat.mx/rfc/{rfc}")
            respuesta.raise_for_status()
            return respuesta.json()
```

---

## sync_to_async y async_to_sync

```python
from asgiref.sync import sync_to_async, async_to_sync


# sync_to_async — llamar código síncrono desde un contexto async
# thread_sensitive=True (default): corre en el mismo hilo principal
# thread_sensitive=False: corre en un hilo de pool — para operaciones CPU-bound

obtener_facturas = sync_to_async(
    lambda: list(Factura.objects.filter(estatus="pendiente").select_related("cliente")),
    thread_sensitive=False,  # pool de hilos para operaciones pesadas
)
facturas = await obtener_facturas()

# Decorador: convierte función síncrona completa en async
@sync_to_async
def crear_usuario_sync(email: str, nombre: str) -> Usuario:
    return Usuario.objects.create_user(email=email, nombre=nombre)

usuario = await crear_usuario_sync("a@b.com", "Ana")


# async_to_sync — llamar código async desde un contexto síncrono
# Útil en management commands, scripts de migración o Celery tasks

@async_to_sync
async def tarea_async_en_celery():
    async with httpx.AsyncClient() as client:
        await client.post("https://webhook.ejemplo.com/evento", json={"tipo": "SYNC"})

# En una tarea Celery síncrona:
@shared_task
def notificar_webhook():
    tarea_async_en_celery()  # compatible con Celery síncrono
```

---

## Django Channels: configuración y channel layers

```python
# config/asgi.py
import os
from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack
from channels.security.websocket import AllowedHostsOriginValidator

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")

django_asgi_app = get_asgi_application()

from apps.notificaciones.routing import websocket_urlpatterns  # noqa: E402

application = ProtocolTypeRouter({
    "http": django_asgi_app,
    "websocket": AllowedHostsOriginValidator(
        AuthMiddlewareStack(
            URLRouter(websocket_urlpatterns)
        )
    ),
})


# config/settings/base.py
CHANNEL_LAYERS = {
    "default": {
        "BACKEND": "channels_redis.core.RedisChannelLayer",
        "CONFIG": {
            "hosts": [("redis", 6379)],
            "capacity": 1500,        # mensajes máximos en cola por canal
            "expiry": 60,            # segundos de TTL para mensajes
        },
    },
}

# apps/notificaciones/routing.py
from django.urls import re_path
from . import consumers

websocket_urlpatterns = [
    re_path(r"ws/notificaciones/(?P<empresa_id>\d+)/$", consumers.NotificacionConsumer.as_asgi()),
]
```

---

## WebSocket consumer completo

```python
# apps/notificaciones/consumers.py
import json
from channels.generic.websocket import AsyncWebsocketConsumer
from channels.db import database_sync_to_async
from django.contrib.auth.models import AnonymousUser


class NotificacionConsumer(AsyncWebsocketConsumer):
    """
    Consumer WebSocket para notificaciones en tiempo real por empresa.
    Grupo: notificaciones_empresa_{empresa_id}
    """

    async def connect(self) -> None:
        # Rechazar conexiones no autenticadas
        if isinstance(self.scope["user"], AnonymousUser):
            await self.close(code=4001)
            return

        self.empresa_id = self.scope["url_route"]["kwargs"]["empresa_id"]
        self.nombre_grupo = f"notificaciones_empresa_{self.empresa_id}"

        # Verificar que el usuario pertenece a la empresa
        tiene_acceso = await self._verificar_acceso()
        if not tiene_acceso:
            await self.close(code=4003)
            return

        # Unirse al grupo del channel layer (Redis)
        await self.channel_layer.group_add(self.nombre_grupo, self.channel_name)
        await self.accept()

        # Enviar notificaciones pendientes al conectar
        pendientes = await self._obtener_pendientes()
        if pendientes:
            await self.send(text_data=json.dumps({
                "tipo": "pendientes",
                "datos": pendientes,
            }))

    async def disconnect(self, close_code: int) -> None:
        if hasattr(self, "nombre_grupo"):
            await self.channel_layer.group_discard(self.nombre_grupo, self.channel_name)

    async def receive(self, text_data: str) -> None:
        """Manejar mensajes entrantes del cliente WebSocket."""
        try:
            datos = json.loads(text_data)
        except json.JSONDecodeError:
            await self.send(text_data=json.dumps({"error": "JSON inválido"}))
            return

        tipo = datos.get("tipo")
        if tipo == "marcar_leida":
            notificacion_id = datos.get("id")
            await self._marcar_leida(notificacion_id)
            await self.send(text_data=json.dumps({
                "tipo": "confirmacion",
                "id": notificacion_id,
            }))

    # Manejador de eventos del channel layer — llamado por group_send
    async def notificacion_nueva(self, evento: dict) -> None:
        """Recibe mensaje del channel layer y lo reenvía al WebSocket."""
        await self.send(text_data=json.dumps({
            "tipo": "nueva",
            "datos": evento["datos"],
        }))

    @database_sync_to_async
    def _verificar_acceso(self) -> bool:
        usuario = self.scope["user"]
        return str(usuario.empresa_id) == str(self.empresa_id)

    @database_sync_to_async
    def _obtener_pendientes(self) -> list:
        from apps.notificaciones.models import Notificacion
        return list(
            Notificacion.objects
            .filter(empresa_id=self.empresa_id, leida=False)
            .order_by("-creado_en")[:20]
            .values("id", "mensaje", "tipo", "creado_en")
        )

    @database_sync_to_async
    def _marcar_leida(self, notificacion_id: int) -> None:
        from apps.notificaciones.models import Notificacion
        Notificacion.objects.filter(id=notificacion_id).update(leida=True)


# Enviar desde cualquier parte del sistema (no solo desde consumers)
# apps/notificaciones/services.py
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync

def enviar_notificacion_ws(empresa_id: int, mensaje: str, tipo: str = "info") -> None:
    """Envía un evento a todos los WebSockets conectados de la empresa."""
    channel_layer = get_channel_layer()
    async_to_sync(channel_layer.group_send)(
        f"notificaciones_empresa_{empresa_id}",
        {
            "type": "notificacion_nueva",  # mapea al método del consumer
            "datos": {"mensaje": mensaje, "tipo": tipo},
        },
    )
```

---

## Celery con Django: configuración completa

```python
# config/celery.py
import os
from celery import Celery
from celery.schedules import crontab

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")

app = Celery("mi_proyecto")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()  # descubre tasks.py en todas las INSTALLED_APPS


# config/settings/base.py — configuración de Celery
CELERY_BROKER_URL = env("CELERY_BROKER_URL", default="redis://localhost:6379/0")
CELERY_RESULT_BACKEND = env("CELERY_RESULT_BACKEND", default="redis://localhost:6379/1")
CELERY_ACCEPT_CONTENT = ["json"]
CELERY_TASK_SERIALIZER = "json"
CELERY_RESULT_SERIALIZER = "json"
CELERY_TIMEZONE = "America/Mexico_City"

# Routing: separar colas por prioridad
CELERY_TASK_ROUTES = {
    "apps.facturas.tasks.*": {"queue": "facturas"},
    "apps.reportes.tasks.*": {"queue": "reportes"},
    "apps.notificaciones.tasks.*": {"queue": "emails", "priority": 9},
}

# Beat schedule: tareas periódicas
CELERY_BEAT_SCHEDULE = {
    "cerrar-facturas-vencidas": {
        "task": "apps.facturas.tasks.cerrar_facturas_vencidas",
        "schedule": crontab(hour=2, minute=0),  # cada día a las 2 AM
    },
    "limpiar-sesiones-expiradas": {
        "task": "apps.usuarios.tasks.limpiar_sesiones",
        "schedule": crontab(hour=3, minute=30, day_of_week="sunday"),
    },
}


# Chord y Chain para flujos de tareas
# apps/reportes/tasks.py
from celery import shared_task, chord, chain
from celery.utils.log import get_task_logger

logger = get_task_logger(__name__)


@shared_task(bind=True, max_retries=3)
def generar_reporte_mensual(self, empresa_id: int, mes: int, anio: int) -> dict:
    """Genera reporte mensual con retry automático."""
    try:
        datos = ReporteService().generar(empresa_id=empresa_id, mes=mes, anio=anio)
        return {"estado": "ok", "registros": len(datos)}
    except Exception as exc:
        logger.error("Error generando reporte", exc_info=exc)
        raise self.retry(exc=exc, countdown=60 * (2 ** self.request.retries))


@shared_task
def enviar_reporte_por_email(resultado: dict, destinatario: str) -> None:
    """Callback de chord: recibe resultados de tareas paralelas."""
    logger.info("Enviando reporte", resultado=resultado, destinatario=destinatario)


def orquestar_reportes_mensuales(empresa_ids: list[int], mes: int, anio: int) -> None:
    """Genera reportes en paralelo y envía resumen al terminar."""
    tareas = [generar_reporte_mensual.s(eid, mes, anio) for eid in empresa_ids]
    workflow = chord(tareas)(
        enviar_reporte_por_email.s(destinatario="ops@empresa.com")
    )
    workflow.delay()
```

---

## ASGI deployment: Daphne, Uvicorn, Hypercorn

```python
# Opción 1: Daphne — servidor oficial de Django Channels
# pip install daphne
# Procfile / supervisord:
# web: daphne -b 0.0.0.0 -p 8000 config.asgi:application
# channels: celery -A config worker -Q facturas,emails --concurrency=4
# beat: celery -A config beat --scheduler django_celery_beat.schedulers:DatabaseScheduler


# Opción 2: Uvicorn con Gunicorn como gestor de procesos
# pip install uvicorn[standard] gunicorn
# gunicorn config.asgi:application \
#   --worker-class uvicorn.workers.UvicornWorker \
#   --workers 4 \
#   --bind 0.0.0.0:8000 \
#   --timeout 60


# Opción 3: Hypercorn — soporte HTTP/2 y HTTP/3
# pip install hypercorn
# hypercorn config.asgi:application --bind 0.0.0.0:8000 --workers 4


# docker-compose.yml (fragmento para producción con Channels)
# services:
#   web:
#     command: daphne -b 0.0.0.0 -p 8000 config.asgi:application
#     environment:
#       - DJANGO_SETTINGS_MODULE=config.settings.production
#   worker:
#     command: celery -A config worker -Q default,facturas,emails -c 4
#   beat:
#     command: celery -A config beat -l info
#   redis:
#     image: redis:7-alpine
#     command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
```

---

## Reglas clave para async en Django

```
BIEN:  async def vista(request) con await sync_to_async(qs.get)()
MAL:   async def vista(request) con Cliente.objects.get() directo — bloquea el event loop

BIEN:  @database_sync_to_async en métodos de Consumer
MAL:   queries ORM directas en métodos async de Consumer sin envoltura

BIEN:  CELERY_TASK_ALWAYS_EAGER=True en settings de test
MAL:   tests que dependen de Redis real para verificar tareas Celery

BIEN:  channel_layer.group_send() para fan-out a múltiples WebSockets
MAL:   iterar sobre conexiones activas manualmente sin channel layers
```
