# Django REST Framework — Patrones de Producción

## ViewSets con acciones personalizadas (@action)

```python
# apps/facturas/views.py
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated

from .models import Factura
from .serializers import FacturaSerializer, FacturaPagoSerializer


class FacturaViewSet(viewsets.ModelViewSet):
    serializer_class = FacturaSerializer
    permission_classes = [IsAuthenticated]

    def get_queryset(self):
        # Filtrar por empresa del usuario — nunca exponer datos de otras empresas
        return (
            Factura.objects
            .filter(empresa=self.request.user.empresa)
            .select_related("cliente", "empresa")
            .prefetch_related("items__producto")
        )

    # Acción de detalle: POST /facturas/{id}/cancelar/
    @action(detail=True, methods=["post"], url_path="cancelar")
    def cancelar(self, request: Request, pk: int | None = None) -> Response:
        factura = self.get_object()  # aplica permisos de objeto automáticamente
        if factura.estatus != "emitida":
            return Response(
                {"detail": "Solo se pueden cancelar facturas emitidas."},
                status=status.HTTP_422_UNPROCESSABLE_ENTITY,
            )
        factura.cancelar(motivo=request.data.get("motivo", ""))
        return Response(FacturaSerializer(factura).data)

    # Acción de lista: GET /facturas/vencidas/
    @action(detail=False, methods=["get"], url_path="vencidas")
    def vencidas(self, request: Request) -> Response:
        qs = self.get_queryset().vencidas().con_totales()
        page = self.paginate_queryset(qs)
        if page is not None:
            serializer = self.get_serializer(page, many=True)
            return self.get_paginated_response(serializer.data)
        return Response(self.get_serializer(qs, many=True).data)

    # Serializer dinámico según la acción
    def get_serializer_class(self):
        if self.action in ("cancelar",):
            return FacturaPagoSerializer
        return FacturaSerializer
```

---

## Serializers anidados con create/update override

```python
# apps/facturas/serializers.py
from rest_framework import serializers
from django.db import transaction
from .models import Factura, ItemFactura


class ItemFacturaSerializer(serializers.ModelSerializer):
    class Meta:
        model = ItemFactura
        fields = ["id", "producto_id", "descripcion", "cantidad", "precio_unitario"]
        read_only_fields = ["id"]


class FacturaSerializer(serializers.ModelSerializer):
    # Serializer anidado de escritura — lista de items dentro de la factura
    items = ItemFacturaSerializer(many=True)

    class Meta:
        model = Factura
        fields = [
            "id", "folio", "cliente_id", "items",
            "estatus", "creado_en", "total_neto",
        ]
        read_only_fields = ["id", "folio", "estatus", "creado_en"]

    @transaction.atomic
    def create(self, validated_data: dict) -> Factura:
        items_data = validated_data.pop("items")
        factura = Factura.objects.create(**validated_data)
        ItemFactura.objects.bulk_create([
            ItemFactura(factura=factura, **item) for item in items_data
        ])
        return factura

    @transaction.atomic
    def update(self, instance: Factura, validated_data: dict) -> Factura:
        items_data = validated_data.pop("items", None)
        # Actualizar campos de la factura
        for attr, value in validated_data.items():
            setattr(instance, attr, value)
        instance.save()

        if items_data is not None:
            # Estrategia: borrar y recrear items (aceptable para listas pequeñas)
            instance.items.all().delete()
            ItemFactura.objects.bulk_create([
                ItemFactura(factura=instance, **item) for item in items_data
            ])
        return instance
```

---

## SerializerMethodField para campos calculados

```python
from rest_framework import serializers


class ClienteSerializer(serializers.ModelSerializer):
    # Campo calculado que no existe en el modelo
    nombre_completo = serializers.SerializerMethodField()
    # Campo condicional según el usuario que hace la petición
    saldo_disponible = serializers.SerializerMethodField()

    class Meta:
        model = Cliente
        fields = ["id", "nombre", "apellido", "nombre_completo", "saldo_disponible"]

    def get_nombre_completo(self, obj: Cliente) -> str:
        return f"{obj.nombre} {obj.apellido}".strip()

    def get_saldo_disponible(self, obj: Cliente) -> float | None:
        # Solo staff puede ver el saldo
        request = self.context.get("request")
        if request and request.user.is_staff:
            return float(obj.saldo)
        return None
```

---

## Validación a nivel de campo y objeto

```python
from rest_framework import serializers
from django.utils import timezone


class PagoSerializer(serializers.ModelSerializer):
    class Meta:
        model = Pago
        fields = ["factura_id", "monto", "fecha_pago", "referencia_bancaria"]

    # Validación de campo individual
    def validate_monto(self, value: float) -> float:
        if value <= 0:
            raise serializers.ValidationError("El monto debe ser mayor a cero.")
        if value > 1_000_000:
            raise serializers.ValidationError("Monto supera el límite permitido.")
        return value

    def validate_fecha_pago(self, value) -> object:
        if value > timezone.now().date():
            raise serializers.ValidationError("La fecha de pago no puede ser futura.")
        return value

    # Validación cruzada entre campos
    def validate(self, attrs: dict) -> dict:
        factura_id = attrs.get("factura_id")
        monto = attrs.get("monto")

        try:
            factura = Factura.objects.get(id=factura_id)
        except Factura.DoesNotExist:
            raise serializers.ValidationError({"factura_id": "Factura no encontrada."})

        if factura.estatus == "cancelada":
            raise serializers.ValidationError("No se puede pagar una factura cancelada.")

        saldo_pendiente = factura.total - factura.total_pagado
        if monto > saldo_pendiente:
            raise serializers.ValidationError(
                {"monto": f"El monto excede el saldo pendiente de {saldo_pendiente}."}
            )

        attrs["factura"] = factura  # pasar objeto ya cargado a create()
        return attrs
```

---

## Paginación personalizada

```python
# common/pagination.py
from rest_framework.pagination import PageNumberPagination, CursorPagination
from rest_framework.response import Response


class PaginacionEstandar(PageNumberPagination):
    page_size = 25
    page_size_query_param = "tamanio"  # ?tamanio=50
    max_page_size = 200

    def get_paginated_response(self, data) -> Response:
        return Response({
            "total": self.page.paginator.count,
            "paginas": self.page.paginator.num_pages,
            "siguiente": self.get_next_link(),
            "anterior": self.get_previous_link(),
            "resultados": data,
        })


class PaginacionCursor(CursorPagination):
    """
    Para feeds de alta frecuencia: más eficiente que offset en tablas grandes.
    No permite saltar a página arbitraria; solo siguiente/anterior.
    """
    page_size = 50
    ordering = "-creado_en"  # DEBE tener índice en la base de datos
    cursor_query_param = "cursor"
```

---

## Filtros con django-filter

```python
# apps/facturas/filters.py
import django_filters
from django.db.models import QuerySet
from .models import Factura


class FacturaFilter(django_filters.FilterSet):
    # Rango de fechas
    fecha_desde = django_filters.DateFilter(
        field_name="creado_en__date", lookup_expr="gte",
        label="Fecha desde (YYYY-MM-DD)",
    )
    fecha_hasta = django_filters.DateFilter(
        field_name="creado_en__date", lookup_expr="lte",
        label="Fecha hasta (YYYY-MM-DD)",
    )
    # Búsqueda por texto parcial
    cliente_nombre = django_filters.CharFilter(
        field_name="cliente__nombre", lookup_expr="icontains",
    )
    # Filtro de relación: IDs múltiples ?etiqueta=1&etiqueta=2
    etiqueta = django_filters.ModelMultipleChoiceFilter(
        field_name="etiquetas",
        queryset=Etiqueta.objects.all(),
    )
    # Rango de montos
    total_min = django_filters.NumberFilter(field_name="total", lookup_expr="gte")
    total_max = django_filters.NumberFilter(field_name="total", lookup_expr="lte")

    class Meta:
        model = Factura
        fields = ["estatus", "empresa_id", "cliente_nombre"]


# apps/facturas/views.py — integrar con ViewSet
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter, OrderingFilter

class FacturaViewSet(viewsets.ModelViewSet):
    filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
    filterset_class = FacturaFilter
    search_fields = ["folio", "cliente__nombre", "cliente__rfc"]
    ordering_fields = ["creado_en", "total", "estatus"]
    ordering = ["-creado_en"]  # orden por defecto
```

---

## Permisos personalizados

```python
# common/permissions.py
from rest_framework.permissions import BasePermission
from rest_framework.request import Request
from rest_framework.views import APIView


class EsAdministradorEmpresa(BasePermission):
    """Solo administradores de la empresa propietaria del objeto."""
    message = "Se requiere rol de administrador en la empresa."

    def has_permission(self, request: Request, view: APIView) -> bool:
        return (
            request.user.is_authenticated
            and hasattr(request.user, "empresa")
            and request.user.rol in ("admin", "superadmin")
        )

    def has_object_permission(
        self, request: Request, view: APIView, obj: object
    ) -> bool:
        # El objeto debe pertenecer a la misma empresa que el usuario
        return getattr(obj, "empresa_id", None) == request.user.empresa_id


class EsPropietarioOSoloLectura(BasePermission):
    """Propietario puede todo; otros solo GET/HEAD/OPTIONS."""
    def has_object_permission(
        self, request: Request, view: APIView, obj: object
    ) -> bool:
        if request.method in ("GET", "HEAD", "OPTIONS"):
            return True
        return obj.creado_por_id == request.user.id
```

---

## Throttling por usuario y por endpoint

```python
# config/settings/base.py
REST_FRAMEWORK = {
    "DEFAULT_THROTTLE_CLASSES": [
        "rest_framework.throttling.AnonRateThrottle",
        "rest_framework.throttling.UserRateThrottle",
    ],
    "DEFAULT_THROTTLE_RATES": {
        "anon": "100/day",
        "user": "1000/hour",
        "envio_email": "10/minute",   # scope personalizado
        "exportacion": "5/hour",
    },
}

# Throttle con scope personalizado
from rest_framework.throttling import UserRateThrottle

class ThrottleEnvioEmail(UserRateThrottle):
    scope = "envio_email"

class ThrottleExportacion(UserRateThrottle):
    scope = "exportacion"

# Aplicar por ViewSet o por acción
class NotificacionViewSet(viewsets.GenericViewSet):
    @action(detail=False, methods=["post"])
    def enviar_masivo(self, request):
        self.throttle_classes = [ThrottleEnvioEmail]
        self.check_throttles(request)
        # ...
```

---

## Exception handler personalizado

```python
# common/exceptions.py
import logging
from rest_framework.views import exception_handler
from rest_framework.response import Response
from rest_framework import status

logger = logging.getLogger(__name__)


def handler_excepciones(exc: Exception, context: dict) -> Response | None:
    """
    Normaliza todos los errores a formato consistente:
    {"codigo": "...", "mensaje": "...", "detalles": {...}}
    """
    # Delegar primero al handler estándar de DRF
    response = exception_handler(exc, context)

    if response is None:
        # Excepción no manejada por DRF — loggear y retornar 500
        logger.exception(
            "Excepción no controlada en API",
            extra={"view": context.get("view").__class__.__name__},
        )
        return Response(
            {"codigo": "ERROR_INTERNO", "mensaje": "Error interno del servidor."},
            status=status.HTTP_500_INTERNAL_SERVER_ERROR,
        )

    # Normalizar formato de respuesta de error
    data = response.data
    if isinstance(data, dict) and "detail" in data:
        data = {"codigo": "ERROR_API", "mensaje": str(data["detail"])}
    elif isinstance(data, list):
        data = {"codigo": "VALIDACION", "mensaje": "Errores de validación.", "detalles": data}

    response.data = data
    return response


# config/settings/base.py
REST_FRAMEWORK = {
    "EXCEPTION_HANDLER": "common.exceptions.handler_excepciones",
}
```

---

## Versioning de API

```python
# config/urls.py — URLPathVersioning: /api/v1/facturas/
from rest_framework.versioning import URLPathVersioning, AcceptHeaderVersioning

urlpatterns = [
    path("api/<str:version>/", include("apps.urls")),
]

# config/settings/base.py
REST_FRAMEWORK = {
    "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
    "ALLOWED_VERSIONS": ["v1", "v2"],
    "DEFAULT_VERSION": "v1",
    "VERSION_PARAM": "version",
}

# En la vista: acceder a la versión solicitada
class FacturaViewSet(viewsets.ModelViewSet):
    def get_serializer_class(self):
        if self.request.version == "v2":
            return FacturaSerializerV2
        return FacturaSerializer  # v1 por defecto

# AcceptHeaderVersioning: Accept: application/json; version=2
# Útil cuando no se quiere cambiar la URL
REST_FRAMEWORK = {
    "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.AcceptHeaderVersioning",
}
```
