← Inicio

🎓 GUÍA COMPLETA DE EXAMEN U4

Integración de Servicios y Seguridad Avanzada

Unidad 4: OAuth, JWT, WebSockets, GraphQL y Seguridad

Profesor: Bernardo Prado | Ciclo: 2026-1 | Universidad: UTH

📖 INTRODUCCIÓN A LA UNIDAD 4

🎯 ¿Qué aprenderás en esta unidad final?

Esta es la unidad culminante del curso donde integrarás todo lo aprendido y agregarás tecnologías avanzadas como autenticación JWT, OAuth 2.0, comunicación en tiempo real con WebSockets, GraphQL y las mejores prácticas de seguridad para APIs. Al finalizar, tendrás un sistema completo listo para producción.

💡 ¿Qué harás en esta práctica?

Construirás un Sistema de Biblioteca Digital Profesional que incluye:

  • 🔐 Autenticación Moderna: Implementarás 3 tipos de autenticación (Token, JWT y OAuth 2.0)
  • 💬 Comunicación en Tiempo Real: Crearás un chat y sistema de notificaciones con WebSockets
  • 📊 API Flexible: Desarrollarás una API GraphQL que permite consultas personalizadas
  • 🛡️ Seguridad Profesional: Aplicarás medidas de seguridad de nivel empresarial
  • 🌐 Integración Externa: Conectarás con Google Books API para importar libros

Resultado final: Un sistema completo que podrías poner en tu portafolio profesional y poder mostrarlo.

🎯 OBJETIVOS DE APRENDIZAJE

Al completar esta práctica, serás capaz de:

📚 Conocimientos Técnicos:

  • JWT: Entender qué es un token, cómo se genera, cómo se valida y por qué es más seguro que las cookies tradicionales
  • OAuth 2.0: Implementar "Login con Google/Facebook" y entender el flujo de autorización
  • WebSockets: Crear conexiones bidireccionales para chat en tiempo real y notificaciones push
  • GraphQL: Consultar APIs de forma flexible obteniendo solo los datos que necesitas
  • Seguridad: Proteger tu aplicación contra los 10 ataques más comunes (SQL Injection, XSS, CSRF, etc.)
  • Rate Limiting: Evitar abuso de tu API limitando peticiones por usuario
  • Integraciones: Conectar tu app con servicios externos como Google Books, Stripe, SendGrid

🛠️ Habilidades Prácticas:

  • Desarrollar APIs listas para producción con autenticación robusta
  • Implementar funcionalidades en tiempo real sin recargar la página
  • Crear sistemas seguros que protejan datos de usuarios
  • Integrar múltiples tecnologías en un solo proyecto cohesivo
  • Documentar y desplegar aplicaciones profesionales

💼 Competencias Profesionales:

  • Portfolio: Tendrás un proyecto completo para mostrar
  • Experiencia real: Habrás trabajado con tecnologías usadas en empresas como Netflix, Uber, Facebook
  • Resolución de problemas: Sabrás debuggear y solucionar errores comunes
  • Mejores prácticas: Seguirás estándares de la industria en arquitectura y código limpio

⚠️ REQUISITOS PREVIOS - ¡PASO A PASO!

Empezaremos instalando todo desde cero y construiremos el proyecto paso a paso en el orden correcto.

📋 LO QUE NECESITAS TENER ANTES DE EMPEZAR:

  • Windows, Mac o Linux - Cualquier sistema operativo
  • Conexión a Internet - Para descargar software y librerías
  • Editor de Texto/Código - Recomendado: Visual Studio Code (gratis)
  • 4GB RAM mínimo - Para correr MySQL y Django
  • 5GB espacio en disco - Para software y proyecto

✅ ESTRUCTURA DE LA GUÍA - ORDEN PERFECTO

Esta guía sigue este orden lógico y pedagógico:

  1. PASO 0: PREPARACIÓN DEL ENTORNO - Instalar Python, MySQL, crear proyecto Django desde cero
  2. PASO 1: PROYECTO BASE - Crear modelos, configurar base de datos, crear API REST básica
  3. PASO 2: JWT AUTHENTICATION - Implementar autenticación moderna con tokens
  4. PASO 3: OAUTH 2.0 - Agregar "Login con Google/Facebook"
  5. PASO 4: WEBSOCKETS - Comunicación en tiempo real (chat, notificaciones)
  6. PASO 5: GRAPHQL - API flexible para consultas personalizadas
  7. PASO 6: SEGURIDAD - Proteger la aplicación contra ataques
  8. PASO 7: INTEGRACIÓN EXTERNA - Conectar con Google Books API
  9. PASO 8: DESPLIEGUE - Publicar tu aplicación en Internet

🚨 IMPORTANTE: SEGUIR EL ORDEN EXACTO

NO te saltes pasos - Cada sección construye sobre la anterior. Si saltas pasos, encontrarás errores.

Verifica cada checkpoint - Al final de cada paso hay una sección "✅ Checkpoint" para verificar que todo funciona antes de continuar.

Guarda tu trabajo frecuentemente - Usa Git después de cada paso que funcione correctamente.

📦 PASO 0: PREPARACIÓN DEL ENTORNO (30-45 minutos)

🎯 Objetivo: Tener Python, MySQL, Django y todas las herramientas necesarias instaladas y funcionando.

0.1 Instalar Python 3.9+ (10 minutos)

🪟 En Windows:

  1. Ir a: https://www.python.org/downloads/
  2. Descargar Python 3.11 (o la versión más reciente)
  3. Ejecutar el instalador
  4. ⚠️ MUY IMPORTANTE: Marcar la casilla "Add Python to PATH" antes de instalar
  5. Click en "Install Now"
  6. Esperar a que termine la instalación

🍎 En Mac:

# Instalar Homebrew primero (si no lo tienes) /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" # Instalar Python brew install python@3.11

🐧 En Linux (Ubuntu/Debian):

sudo apt update sudo apt install python3.11 python3.11-venv python3-pip -y

✅ VERIFICAR INSTALACIÓN:

Abre una terminal/CMD y ejecuta:

python --version

Salida esperada: Python 3.11.x

pip --version

Salida esperada: pip 23.x.x from ...

❌ Si no funciona el comando "python"

En algunos sistemas, el comando es python3 en lugar de python.

Prueba: python3 --version y pip3 --version

Si funciona con python3, usa ese comando en todos los pasos siguientes.

0.2 Instalar MySQL 8.0+ (15 minutos)

🪟 En Windows:

  1. Ir a: https://dev.mysql.com/downloads/installer/
  2. Descargar "MySQL Installer" (el archivo .msi más grande, ~400MB)
  3. Ejecutar el instalador
  4. Seleccionar "Developer Default"
  5. Click "Next" hasta llegar a la configuración
  6. Configurar password de root:
    • Password: root123 (o el que prefieras, pero RECUÉRDALO)
    • Confirmar password
  7. Terminar instalación
  8. MySQL se instalará como servicio y arrancará automáticamente

🍎 En Mac:

brew install mysql # Arrancar MySQL brew services start mysql # Configurar password de root mysql_secure_installation

🐧 En Linux:

sudo apt update sudo apt install mysql-server -y # Arrancar MySQL sudo systemctl start mysql sudo systemctl enable mysql # Configurar password sudo mysql_secure_installation

✅ VERIFICAR INSTALACIÓN:

# Conectarse a MySQL mysql -u root -p # Ingresa el password que configuraste (root123)

Salida esperada: Deberías ver algo como:

Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 8
Server version: 8.0.xx

mysql>

Si entraste exitosamente, ejecuta dentro de MySQL:

SHOW DATABASES; EXIT;

✅ Checkpoint 0.2

MySQL está funcionando correctamente si viste la lista de bases de datos y pudiste salir sin errores.

0.3 Instalar Visual Studio Code (5 minutos)

  1. Ir a: https://code.visualstudio.com/
  2. Descargar para tu sistema operativo
  3. Instalar con configuración por defecto
  4. Abrir VS Code
  5. Instalar extensiones recomendadas:
    • Click en el ícono de extensiones (cuadrados en la barra lateral)
    • Buscar e instalar: "Python" (por Microsoft)
    • Buscar e instalar: "Django" (por Baptiste Darthenay)
    • Buscar e instalar: "MySQL" (por cweijan)

💡 Alternativas a VS Code

Si prefieres otro editor, puedes usar:

  • PyCharm Community (más pesado pero muy completo)
  • Sublime Text (ligero y rápido)
  • Notepad++ (solo Windows, muy básico)

0.4 Instalar Git (5 minutos)

🪟 En Windows:

  1. Ir a: https://git-scm.com/download/win
  2. Descargar e instalar con opciones por defecto

🍎 En Mac:

brew install git

🐧 En Linux:

sudo apt install git -y

✅ VERIFICAR:

git --version

Salida esperada: git version 2.x.x

✅ CHECKPOINT PASO 0: ENTORNO LISTO

Verifica que TODOS estos comandos funcionen:

Comando Resultado Esperado
python --version Python 3.9+ o superior
pip --version pip 20+ o superior
mysql -u root -p Conecta a MySQL (ingresa password)
git --version git 2.x o superior
Abrir VS Code Se abre correctamente

Si TODO funciona → Continúa al PASO 1

Si algo no funciona → Revisa la instalación de ese componente

🏗️ PASO 1: CREAR PROYECTO DJANGO DESDE CERO (45-60 minutos)

🎯 Objetivo: Crear un proyecto Django completo con MySQL, modelos de biblioteca y API REST funcionando.

1.1 Crear Carpeta del Proyecto (2 minutos)

🪟 En Windows (usando CMD o PowerShell):

# Ir a donde quieres crear el proyecto (Ejemplo: Escritorio) cd C:\Users\TU_USUARIO\Desktop # Crear carpeta del proyecto mkdir biblioteca_unidad4 cd biblioteca_unidad4

🍎🐧 En Mac/Linux:

# Ir a tu carpeta de proyectos cd ~/Desktop # Crear carpeta del proyecto mkdir biblioteca_unidad4 cd biblioteca_unidad4

✅ VERIFICAR: Estás dentro de la carpeta. El comando pwd (Mac/Linux) o cd (Windows) debe mostrar la ruta de tu carpeta.

1.2 Crear Entorno Virtual (5 minutos)

📚 ¿Qué es un entorno virtual?

Es como una "caja" aislada donde instalarás todas las librerías de Python para este proyecto, sin afectar otros proyectos ni tu sistema.

Crear el entorno virtual - PowerShell:

# Crear entorno virtual llamado "biblioteca_env" py -3.11 -m venv biblioteca_env

Espera 30-60 segundos mientras se crea...

Activar el entorno virtual:

🪟 Windows (CMD):

venv\Scripts\activate

🪟 Windows (PowerShell):

.\biblioteca_env\Scripts\Activate.ps1 y después python --version

🍎🐧 Mac/Linux:

source venv/bin/activate

✅ VERIFICAR: Verás (venv) al inicio de tu línea de comando.

(venv) C:\Users\TU_USUARIO\Desktop\biblioteca_unidad4>

⚠️ MUY IMPORTANTE: Mantener el entorno activado

SIEMPRE que trabajes en el proyecto, el entorno virtual debe estar activado (debes ver (venv)).

Para desactivar: Escribe deactivate (pero NO lo hagas ahora, necesitamos instalaciones)

1.3 Instalar Django y Dependencias (10 minutos)

Asegúrate de que el entorno virtual esté activado (debes ver (venv))

# Actualizar pip (gestor de paquetes de Python) python -m pip install --upgrade pip # Instalar Django pip install django==4.2.7 # Instalar Django REST Framework pip install djangorestframework==3.14.0 # Instalar conector de MySQL para Python pip install mysqlclient==2.2.0 # Instalar CORS headers (para permitir peticiones desde otros dominios) pip install django-cors-headers==4.3.0 # Instalar django-filter (para filtros avanzados en API) pip install django-filter==23.5

Esto tomará 2-3 minutos...

❌ Error común: mysqlclient no se instala en Windows

Si ves un error al instalar mysqlclient, prueba esto:

Opción 1: Instalar el paquete compilado

pip install https://download.lfd.uci.edu/pythonlibs/archived/mysqlclient-2.2.0-cp311-cp311-win_amd64.whl

Opción 2: Usar pymysql (alternativa más fácil)

pip install pymysql==1.1.0

Y luego agrega esto al archivo __init__.py de tu proyecto (lo haremos más adelante)

✅ VERIFICAR instalación:

pip list

Deberías ver:

Django                4.2.7
djangorestframework   3.14.0
mysqlclient          2.2.0  (o pymysql 1.1.0)
django-cors-headers   4.3.0
django-filter         23.5
...

1.4 Crear Proyecto Django (3 minutos)

Crear el proyecto (núcleo de configuración):

django-admin startproject biblioteca_project .

⚠️ NOTA: El punto . al final es IMPORTANTE (dice "crear aquí, no en subcarpeta")

Crear la aplicación (donde vivirá tu código):

python manage.py startapp libros

✅ VERIFICAR estructura: Deberías tener esta estructura de carpetas:

biblioteca_unidad4/
├── venv/                    (entorno virtual - ignorar)
├── biblioteca_project/       (configuración del proyecto)
│   ├── __init__.py
│   ├── settings.py          (⭐ archivo principal de configuración)
│   ├── urls.py              (rutas principales)
│   ├── asgi.py
│   └── wsgi.py
├── libros/                  (tu aplicación)
│   ├── migrations/
│   ├── __init__.py
│   ├── admin.py             (panel de administración)
│   ├── apps.py
│   ├── models.py            (⭐ modelos de base de datos)
│   ├── tests.py
│   └── views.py             (⭐ vistas/lógica)
└── manage.py                (⭐ comandos de Django)

Abrir el proyecto en VS Code:

# Desde la terminal, estando en biblioteca_unidad4 -- escribe: code .

Si el comando code no funciona, abre VS Code manualmente y haz "File → Open Folder" seleccionando biblioteca_unidad4

1.5 Configurar Base de Datos MySQL (10 minutos)

Paso 1: Crear la base de datos

Abre una nueva terminal/CMD y conecta a MySQL:

mysql -u root -p # Ingresa tu password (root123 o el que configuraste)

Dentro de MySQL, ejecuta:

CREATE DATABASE biblioteca_uni4 CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; SHOW DATABASES; EXIT;

Deberías ver biblioteca_uni4 en la lista de bases de datos.

Paso 2: Configurar Django para usar MySQL

En VS Code, abre el archivo biblioteca_project/settings.py y busca la sección DATABASES (línea ~75).

REEMPLAZA esto:

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': BASE_DIR / 'db.sqlite3',
    }
}

POR esto:

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.mysql',
        'NAME': 'biblioteca_uni4',
        'USER': 'root',
        'PASSWORD': 'root123',  # ← Cambia esto a tu password de MySQL
        'HOST': 'localhost',
        'PORT': '3306',
        'OPTIONS': {
            'charset': 'utf8mb4',
            'init_command': "SET sql_mode='STRICT_TRANS_TABLES'",
        },
    }
}

🔐 Si usaste PyMySQL en lugar de mysqlclient -- No recomendable usarlo, solo si es el caso

Abre biblioteca_project/__init__.py y agrega al inicio:

import pymysql
pymysql.install_as_MySQLdb()

✅ VERIFICAR conexión a base de datos:

python manage.py check --database default

Salida esperada:

System check identified no issues (0 silenced).

❌ Errores comunes:

  • "Access denied for user 'root'": Password incorrecto en settings.py
  • "Can't connect to MySQL server": MySQL no está corriendo. Inícialo desde Servicios (Windows) o brew services start mysql (Mac)
  • "Unknown database 'biblioteca_db'": No creaste la base de datos. Vuelve al paso 1

1.6 Registrar la App y Configurar Django (5 minutos)

En biblioteca_project/settings.py, busca INSTALLED_APPS (línea ~30).

Modifica para que quede así:

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    
    # Third-party apps
    'rest_framework',
    'corsheaders',
    'django_filters',
    
    # Tu aplicación
    'libros',
]

Busca MIDDLEWARE y agrega CORS:

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'corsheaders.middleware.CorsMiddleware',  # ← AGREGAR ESTO
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

Al final del archivo settings.py, agrega configuración de CORS y DRF:

# ==============================
# CONFIGURACIÓN DE CORS
# ==============================
CORS_ALLOWED_ORIGINS = [
    "http://localhost:3000",
    "http://127.0.0.1:3000",
]
CORS_ALLOW_CREDENTIALS = True


# ==============================
# CONFIGURACIÓN DE REST FRAMEWORK
# ==============================
REST_FRAMEWORK = {
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
    'PAGE_SIZE': 10,
    'DEFAULT_FILTER_BACKENDS': [
        'django_filters.rest_framework.DjangoFilterBackend',
        'rest_framework.filters.SearchFilter',
        'rest_framework.filters.OrderingFilter',
    ],
}


# ==============================
# CONFIGURACIÓN DE IDIOMA Y ZONA HORARIA
# ==============================
LANGUAGE_CODE = 'es-mx'
TIME_ZONE = 'America/Hermosillo'  # Hermosillo
USE_I18N = True
USE_TZ = True

✅ VERIFICAR configuración:

python manage.py check

Debe decir: System check identified no issues (0 silenced).

✅ CHECKPOINT PASO 1.1-1.6: Proyecto Django Creado

Verifica que TODO esto funcione:

  • ✅ Entorno virtual activado (ves (venv))
  • ✅ Django instalado: python -m django --version → 4.2.7
  • ✅ Base de datos creada en MySQL
  • python manage.py check sin errores
  • ✅ Archivo settings.py configurado correctamente

Si TODO está ✅ → Continúa creando los modelos

🚀 ¿Qué Construiremos?

Construiremos un Sistema de Biblioteca Digital completo con:

  1. Modelos de Datos: Libros, Autores, Categorías, Préstamos
  2. API REST: Endpoints para operaciones CRUD
  3. Autenticación JWT: Tokens seguros sin estado
  4. OAuth 2.0: Login con servicios externos
  5. WebSockets: Chat y notificaciones en tiempo real
  6. GraphQL API: Queries flexibles y eficientes
  7. Seguridad Robusta: HTTPS, validación, rate limiting
  8. Integración Externa: Google Books API

🏗️ Arquitectura Final del Sistema

┌──────────────────────────────────────────────┐
│            FRONTEND / CLIENTES               │
│   (Web, Móvil, Aplicaciones de Terceros)    │
└──────────┬───────────────────────────────────┘
           │
    ┌──────▼──────┐
    │   Gateway   │ ← Rate Limiting, CORS
    └──────┬──────┘
           │
    ┌──────▼──────────────────────────────┐
    │    AUTENTICACIÓN                    │
    │  JWT / OAuth 2.0 / Token Auth       │
    └──────┬──────────────────────────────┘
           │
    ┌──────▼──────────────────────────────┐
    │         APIS DISPONIBLES            │
    │  • REST API (CRUD Biblioteca)       │
    │  • GraphQL API (Queries Flexibles)  │
    │  • WebSocket (Tiempo Real)          │
    └──────┬──────────────────────────────┘
           │
    ┌──────▼──────────────────────────────┐
    │       SERVICIOS DE NEGOCIO          │
    │  • Catálogo  • Préstamos            │
    │  • Usuarios  • Notificaciones       │
    └──────┬──────────────────────────────┘
           │
    ┌──────▼──────────────────────────────┐
    │    BASE DE DATOS MySQL              │
    │   Libros, Autores, Categorías       │
    └─────────────────────────────────────┘

📊 PASO 1 (Continuación): CREAR MODELOS DE DATOS (15 minutos)

1.7 Crear Modelos en models.py

Abre el archivo libros/models.py y reemplaza TODO el contenido por:

from django.db import models
from django.core.validators import MinValueValidator, MaxValueValidator
from django.contrib.auth.models import User
from decimal import Decimal


class Categoria(models.Model):
    """Categorías de libros (Ficción, No Ficción, Ciencia, etc.)"""
    
    nombre = models.CharField(max_length=100, unique=True)
    descripcion = models.TextField(blank=True)
    activo = models.BooleanField(default=True)
    fecha_creacion = models.DateTimeField(auto_now_add=True)
    
    class Meta:
        verbose_name_plural = "Categorías"
        ordering = ['nombre']
    
    def __str__(self):
        return self.nombre


class Autor(models.Model):
    """Autores de libros"""
    
    nombre = models.CharField(max_length=100)
    apellido = models.CharField(max_length=100)
    fecha_nacimiento = models.DateField(null=True, blank=True)
    pais_origen = models.CharField(max_length=100, blank=True)
    biografia = models.TextField(blank=True)
    foto = models.URLField(blank=True, help_text="URL de la foto del autor")
    fecha_creacion = models.DateTimeField(auto_now_add=True)
    
    class Meta:
        verbose_name_plural = "Autores"
        ordering = ['apellido', 'nombre']
        unique_together = ['nombre', 'apellido']  # No duplicar autor
    
    def __str__(self):
        return f"{self.nombre} {self.apellido}"
    
    @property
    def nombre_completo(self):
        return f"{self.nombre} {self.apellido}"


class Libro(models.Model):
    """Modelo principal de libros"""
    
    # Estados del libro
    DISPONIBLE = 'disponible'
    PRESTADO = 'prestado'
    MANTENIMIENTO = 'mantenimiento'
    PERDIDO = 'perdido'
    
    ESTADOS = [
        (DISPONIBLE, 'Disponible'),
        (PRESTADO, 'Prestado'),
        (MANTENIMIENTO, 'En Mantenimiento'),
        (PERDIDO, 'Perdido'),
    ]
    
    # Información básica
    titulo = models.CharField(max_length=150)
    subtitulo = models.CharField(max_length=200, blank=True)
    isbn = models.CharField(max_length=13, unique=True, 
                           help_text="ISBN de 13 dígitos")
    
    # Relaciones
    autor = models.ForeignKey(Autor, on_delete=models.PROTECT, 
                             related_name='libros')
    categoria = models.ForeignKey(Categoria, on_delete=models.SET_NULL, 
                                 null=True, related_name='libros')
    
    # Detalles de publicación
    editorial = models.CharField(max_length=200, blank=True)
    fecha_publicacion = models.DateField(null=True, blank=True)
    paginas = models.PositiveIntegerField(
        null=True, blank=True,
        validators=[MinValueValidator(1)]
    )
    idioma = models.CharField(max_length=50, default='Español')
    
    # Descripción y contenido
    descripcion = models.TextField(blank=True)
    imagen_portada = models.URLField(blank=True)
    
    # Inventario
    stock = models.PositiveIntegerField(
        default=1,
        validators=[MinValueValidator(0)],
        help_text="Cantidad de ejemplares disponibles"
    )
    estado = models.CharField(max_length=20, choices=ESTADOS, 
                             default=DISPONIBLE)
    
    # Precio y valoración
    precio = models.DecimalField(
        max_digits=8, 
        decimal_places=2,
        validators=[MinValueValidator(Decimal('0.01'))],
        help_text="Precio en Lempiras (L)"
    )
    valoracion = models.DecimalField(
        max_digits=3, 
        decimal_places=2,
        default=Decimal('0.00'),
        validators=[
            MinValueValidator(Decimal('0.00')),
            MaxValueValidator(Decimal('5.00'))
        ],
        help_text="Valoración de 0 a 5 estrellas"
    )
    
    # Metadata
    activo = models.BooleanField(default=True)
    fecha_creacion = models.DateTimeField(auto_now_add=True)
    fecha_actualizacion = models.DateTimeField(auto_now=True)
    creado_por = models.ForeignKey(
        User, 
        on_delete=models.SET_NULL, 
        null=True, 
        related_name='libros_creados'
    )
    
    class Meta:
        verbose_name_plural = "Libros"
        ordering = ['-fecha_creacion']
        indexes = [
            models.Index(fields=['isbn']),
            models.Index(fields=['titulo']),
            models.Index(fields=['autor']),
        ]
    
    def __str__(self):
        return f"{self.titulo} - {self.autor.nombre_completo}"
    
    @property
    def esta_disponible(self):
        """Verifica si el libro está disponible para préstamo"""
        return self.estado == self.DISPONIBLE and self.stock > 0
    
    def actualizar_stock(self, cantidad):
        """Actualiza el stock del libro"""
        self.stock += cantidad
        if self.stock < 0:
            self.stock = 0
        if self.stock == 0:
            self.estado = self.PRESTADO
        elif self.stock > 0 and self.estado == self.PRESTADO:
            self.estado = self.DISPONIBLE
        self.save()


class Prestamo(models.Model):
    """Registro de préstamos de libros"""
    
    # Estados del préstamo
    ACTIVO = 'activo'
    DEVUELTO = 'devuelto'
    ATRASADO = 'atrasado'
    PERDIDO = 'perdido'
    
    ESTADOS = [
        (ACTIVO, 'Activo'),
        (DEVUELTO, 'Devuelto'),
        (ATRASADO, 'Atrasado'),
        (PERDIDO, 'Perdido'),
    ]
    
    libro = models.ForeignKey(Libro, on_delete=models.PROTECT, 
                             related_name='prestamos')
    usuario = models.ForeignKey(User, on_delete=models.PROTECT, 
                               related_name='prestamos')
    
    fecha_prestamo = models.DateTimeField(auto_now_add=True)
    fecha_devolucion_esperada = models.DateField()
    fecha_devolucion_real = models.DateTimeField(null=True, blank=True)
    
    estado = models.CharField(max_length=20, choices=ESTADOS, 
                             default=ACTIVO)
    notas = models.TextField(blank=True)
    
    class Meta:
        verbose_name_plural = "Préstamos"
        ordering = ['-fecha_prestamo']
    
    def __str__(self):
        return f"{self.libro.titulo} - {self.usuario.username}"
    
    @property
    def dias_prestamo(self):
        """Calcula días que lleva el préstamo"""
        from django.utils import timezone
        if self.fecha_devolucion_real:
            return (self.fecha_devolucion_real - self.fecha_prestamo).days
        return (timezone.now() - self.fecha_prestamo).days
    
    @property
    def esta_atrasado(self):
        """Verifica si el préstamo está atrasado"""
        from django.utils import timezone
        if self.fecha_devolucion_real:
            return False
        return timezone.now().date() > self.fecha_devolucion_esperada

✅ VERIFICAR que no haya errores de sintaxis:

python manage.py check

Debe decir: System check identified no issues (0 silenced).

1.8 Crear y Aplicar Migraciones (5 minutos)

🤔 ¿Qué son migraciones?

Son instrucciones que Django genera para crear/modificar tablas en la base de datos basándose en tus modelos.

Crear las migraciones:

python manage.py makemigrations

Salida esperada:

Migrations for 'libros':
  libros\migrations\0001_initial.py
    - Create model Autor
    - Create model Categoria
    - Create model Libro
    - Create model Prestamo
    ...

Aplicar las migraciones (crear tablas en MySQL):

python manage.py migrate

Salida esperada:

Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  Applying admin.0001_initial... OK
  ...
  Applying libros.0001_initial... OK

✅ VERIFICAR en MySQL:

mysql -u root -p USE biblioteca_db; SHOW TABLES; EXIT;

Deberías ver estas tablas:

+----------------------------+
| Tables_in_biblioteca_db    |
+----------------------------+
| auth_user                  |
| libros_autor               |
| libros_categoria           |
| libros_libro               |
| libros_prestamo            |
| ...

1.9 Crear Superusuario (3 minutos)

Crear usuario administrador para acceder al panel de Django:

python manage.py createsuperuser

Te pedirá:

Username: admin
Email: admin@biblioteca.com
Password: admin123  (escribe, no se verá mientras escribes)
Password (again): admin123

✅ Salida esperada:

Superuser created successfully.

1.10 Registrar Modelos en Admin (3 minutos)

Abre libros/admin.py y reemplaza por:

from django.contrib import admin
from .models import Categoria, Autor, Libro, Prestamo


@admin.register(Categoria)
class CategoriaAdmin(admin.ModelAdmin):
    list_display = ['nombre', 'activo', 'fecha_creacion']
    list_filter = ['activo']
    search_fields = ['nombre']


@admin.register(Autor)
class AutorAdmin(admin.ModelAdmin):
    list_display = ['nombre_completo', 'pais_origen', 'fecha_nacimiento']
    search_fields = ['nombre', 'apellido']
    list_filter = ['pais_origen']


@admin.register(Libro)
class LibroAdmin(admin.ModelAdmin):
    list_display = ['titulo', 'autor', 'isbn', 'estado', 'stock', 'precio']
    list_filter = ['estado', 'categoria', 'autor']
    search_fields = ['titulo', 'isbn', 'descripcion']
    list_editable = ['stock', 'estado']
    readonly_fields = ['fecha_creacion', 'fecha_actualizacion']


@admin.register(Prestamo)
class PrestamoAdmin(admin.ModelAdmin):
    list_display = ['libro', 'usuario', 'fecha_prestamo', 
                   'fecha_devolucion_esperada', 'estado']
    list_filter = ['estado', 'fecha_prestamo']
    search_fields = ['libro__titulo', 'usuario__username']
    readonly_fields = ['fecha_prestamo']

Guardar el archivo (Ctrl+S)

1.11 Probar el Admin de Django (5 minutos)

Arrancar el servidor de desarrollo:

python manage.py runserver

Salida esperada:

Watching for file changes with StatReloader
Performing system checks...

System check identified no issues (0 silenced).
January 23, 2026 - 14:30:00
Django version 4.2.7, using settings 'biblioteca_project.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CTRL-BREAK.

✅ Abrir en el navegador:

  • Admin: http://127.0.0.1:8000/admin/
  • Ingresar con: admin / admin123

Deberías ver el panel de administración con tus modelos: Autores, Categorías, Libros, Préstamos

🎯 CREAR DATOS DE PRUEBA:

  1. Click en "Categorías" → "Agregar Categoría" → Crear: Ficción, Ciencia, Historia
  2. Click en "Autores" → "Agregar Autor" → Crear 2-3 autores
  3. Click en "Libros" → "Agregar Libro" → Crear 3-5 libros

TIP: Para ISBN usa números como: 9780123456789, 9781234567890, etc.

✅ CHECKPOINT PASO 1 COMPLETO: Proyecto Base Funcionando

Verifica que TODO esto funcione:

  • ✅ Modelos creados en models.py
  • ✅ Migraciones aplicadas: python manage.py migrate sin errores
  • ✅ Tablas creadas en MySQL: SHOW TABLES; muestra libros_autor, libros_libro, etc.
  • ✅ Superusuario creado: puedes hacer login en /admin/
  • ✅ Admin funcionando: ves los modelos y puedes crear datos
  • ✅ Datos de prueba creados: al menos 2 autores, 3 categorías, 5 libros

🎉 ¡FELICIDADES! Tienes el proyecto base funcionando

Ahora continuaremos con la Parte 1: JWT Authentication

✅ Resultado Final de la Unidad

Tendrás un sistema de biblioteca completamente funcional con autenticación moderna, comunicación en tiempo real, múltiples tipos de APIs y seguridad de nivel empresarial, listo para ser desplegado en producción.

🔐 PARTE 1: JWT AUTHENTICATION (90 minutos)

JWT Duración: 90 minutos Dificultad: Media

🎯 OBJETIVO DE ESTA PARTE

¿Qué lograrás?

Al finalizar esta sección, tendrás un sistema de autenticación JWT completo donde:

  • ✅ Los usuarios pueden hacer login y recibir un token JWT
  • ✅ El token se usa para autenticar todas las peticiones a la API
  • ✅ Los tokens expiran automáticamente después de 1 hora
  • ✅ Los usuarios pueden renovar su token sin volver a hacer login
  • ✅ El sistema funciona sin guardar sesiones en el servidor (stateless)

🌟 ¿Por qué es importante?

JWT es el estándar de autenticación moderno usado por empresas como Google, Facebook, Netflix y Twitter. Es perfecto para aplicaciones móviles, SPAs (Single Page Applications) y microservicios.

📱 Casos de uso reales:

  • Apps móviles (iOS/Android) que necesitan mantener sesión sin cookies
  • Aplicaciones React/Vue/Angular que consumen APIs
  • Microservicios que necesitan compartir autenticación
  • APIs públicas con rate limiting por usuario

🤔 ¿Qué es JWT? - Explicación Simple

📚 Conceptos Básicos para Principiantes

Imagina que JWT es como una credencial digital que el servidor te da cuando haces login. Esta credencial:

  1. Es autosuficiente: Contiene toda tu información (ID, username, permisos)
  2. Está firmada digitalmente: Nadie puede falsificarla sin la clave secreta del servidor
  3. Expira automáticamente: Después de cierto tiempo deja de funcionar (por seguridad)
  4. No requiere base de datos: El servidor puede validarla solo leyéndola

🆚 Comparación con autenticación tradicional:

Aspecto Cookies/Sesiones Tradicionales JWT (Moderno)
Almacenamiento Servidor guarda sesión en memoria/DB Cliente guarda token, servidor NO guarda nada
Escalabilidad Difícil con múltiples servidores Fácil: cualquier servidor puede validar
Móvil/SPA Complicado con cookies Perfecto para apps modernas
Información Solo ID de sesión Datos completos del usuario
Expiración Configurada en servidor Incluida en el token mismo

📦 Estructura de un JWT - Desmenuzado

Un JWT parece un texto largo y confuso, pero tiene una estructura muy clara:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjoxNjQyMzQ1Njc4fQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Se divide en 3 partes separadas por puntos (.):

🔍 Parte 1: HEADER (Encabezado)

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

Si lo decodificas (es Base64), obtienes:

{
  "alg": "HS256",    // Algoritmo de firma: HMAC SHA-256
  "typ": "JWT"       // Tipo de token: JWT
}

🤓 ¿Qué significa? Le dice al servidor cómo verificar que el token es legítimo.

🔍 Parte 2: PAYLOAD (Carga Útil)

eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjoxNjQyMzQ1Njc4fQ

Decodificado:

{
  "user_id": 1,                    // ID del usuario
  "username": "admin",             // Nombre de usuario
  "email": "admin@biblioteca.com", // Email
  "exp": 1642345678,               // Fecha de expiración (timestamp)
  "iat": 1642342078                // Fecha de emisión
}

🤓 ¿Qué significa? Aquí va toda la información del usuario. ¡OJO! Estos datos NO están encriptados, solo codificados en Base64, así que nunca pongas contraseñas aquí.

⚠️ IMPORTANTE: Seguridad del Payload

NO incluyas información sensible en el payload porque cualquiera puede decodificar Base64:

  • ❌ NO pongas: contraseñas, números de tarjeta, datos bancarios
  • ✅ SÍ puedes poner: user_id, username, rol, permisos

🔍 Parte 3: SIGNATURE (Firma)

SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Se genera así:

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  SECRET_KEY_DEL_SERVIDOR
)

🤓 ¿Qué significa? Es una "huella digital" del token. Si alguien modifica el header o payload, la firma no coincidirá y el servidor rechazará el token.

🔐 Flujo de verificación:

  1. Servidor recibe el token
  2. Toma header + payload y calcula su firma usando la clave secreta
  3. Compara su firma calculada con la firma del token
  4. Si coinciden ✅ = Token válido | Si no coinciden ❌ = Token falso/modificado

2.1 Instalar SimpleJWT (3 minutos)

📚 ¿Qué es djangorestframework-simplejwt?

Es la librería más popular y confiable para implementar JWT en Django REST Framework. Está mantenida activamente, tiene excelente documentación y es usada por miles de proyectos en producción.

Alternativas: PyJWT, django-rest-framework-jwt (deprecated), pero SimpleJWT es la recomendada.

🖥️ En tu terminal (ASEGÚRATE DE QUE EL ENTORNO VIRTUAL ESTÉ ACTIVADO):

# Instalar SimpleJWT pip install djangorestframework-simplejwt

❌ Error común: No module named 'pkg_resources'

Si ves este error al instalar djangorestframework-simplejwt==5.3.0, la solución es instalar sin especificar la versión:

pip install djangorestframework-simplejwt

Esto instalará la versión más reciente compatible con tu sistema.

✅ Verificar instalación:

pip show djangorestframework-simplejwt

Salida esperada:

Name: djangorestframework-simplejwt
Version: 5.5.1
Summary: A minimal JSON Web Token authentication plugin for Django REST Framework
Home-page: https://github.com/jazzband/djangorestframework-simplejwt
Author: David Sanders
...

✅ Checkpoint 2.1: Verificación

Antes de continuar, asegúrate de:

  • ✅ El paquete se instaló correctamente (sin errores)
  • ✅ Tu entorno virtual está activado (ves (venv))
  • ✅ Estás en la carpeta raíz de tu proyecto Django

💾 Actualizar requirements.txt:

pip freeze > requirements.txt

Esto guarda todas tus dependencias para que otros puedan instalarlas fácilmente con pip install -r requirements.txt

2.2 Configurar JWT en Settings (10 minutos)

📁 ¿Qué vamos a hacer?

Editaremos el archivo settings.py para:

  1. Agregar JWT como método de autenticación
  2. Configurar cuánto tiempo duran los tokens
  3. Definir el algoritmo de encriptación
  4. Establecer políticas de seguridad

📂 Archivo a editar: biblioteca_project/settings.py

🔍 Busca la sección REST_FRAMEWORK (debe estar cerca del final del archivo, después de STATIC_URL). Si no existe, agrégala completa:

# =======================
# REST FRAMEWORK CONFIG
# =======================

from datetime import timedelta  # ← Agregar al inicio del archivo

REST_FRAMEWORK = {
    # AUTENTICACIÓN: Qué métodos acepta tu API
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework_simplejwt.authentication.JWTAuthentication',  # JWT (Token moderno)
        'oauth2_provider.contrib.rest_framework.OAuth2Authentication',  # ← AGREGAR para OAuth 2.0
        'rest_framework.authentication.TokenAuthentication',          # Token tradicional
        'rest_framework.authentication.SessionAuthentication',        # Sesión (para admin)
    ],
    
    # PERMISOS: Qué pueden hacer los usuarios
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.IsAuthenticatedOrReadOnly',
    ],
    
    # PAGINACIÓN: Cuántos resultados por página
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
    'PAGE_SIZE': 10,
    
    # FILTROS: Permitir búsquedas y ordenamiento
    'DEFAULT_FILTER_BACKENDS': [
        'django_filters.rest_framework.DjangoFilterBackend',
        'rest_framework.filters.SearchFilter',
        'rest_framework.filters.OrderingFilter',
    ],
}

ℹ️ Nota sobre OAuth2Authentication

¿Por qué agregamos oauth2_provider.contrib.rest_framework.OAuth2Authentication?

Esta línea es necesaria para que Django REST Framework pueda:

  • ✅ Validar tokens OAuth 2.0 emitidos por django-oauth-toolkit
  • ✅ Autenticar peticiones usando Bearer tokens de OAuth
  • ✅ Evitar el error 401 Unauthorized al probar endpoints con tokens OAuth

🎯 Sin esta línea: Cuando ejecutes test_oauth.py (que obtiene un token OAuth), recibirás un error 401 porque DRF no reconoce el token.

✅ Con esta línea: Los tokens OAuth funcionan perfectamente y puedes acceder a la API.

Nota: Esta configuración se implementa ahora (en la Parte 1 de JWT) pero será útil cuando llegues a la Parte 2 de OAuth 2.0.

➕ Ahora agrega la configuración específica de JWT (después de REST_FRAMEWORK):

# =======================
# SIMPLE JWT CONFIG
# =======================

SIMPLE_JWT = {
    # ⏱️ DURACIÓN DE TOKENS
    'ACCESS_TOKEN_LIFETIME': timedelta(hours=1),    # Token de acceso válido 1 hora
    'REFRESH_TOKEN_LIFETIME': timedelta(days=7),    # Token de refresco válido 7 días
    
    # 🔄 ROTACIÓN DE TOKENS (Seguridad extra)
    'ROTATE_REFRESH_TOKENS': True,                  # Genera nuevo refresh al refrescar
    'BLACKLIST_AFTER_ROTATION': True,               # Invalida el refresh anterior
    'UPDATE_LAST_LOGIN': True,                      # Actualiza last_login del usuario
    
    # 🔐 ALGORITMO Y CLAVE DE FIRMA
    'ALGORITHM': 'HS256',                           # HMAC SHA-256 (más común)
    'SIGNING_KEY': SECRET_KEY,                      # Usa la SECRET_KEY de Django
    'VERIFYING_KEY': None,                          # Solo para algoritmos asimétricos (RSA)
    
    # 📋 CONFIGURACIÓN DE HEADERS
    'AUTH_HEADER_TYPES': ('Bearer',),               # Tipo: "Authorization: Bearer TOKEN"
    'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION',       # Nombre del header
    
    # 👤 CLAIMS DEL USUARIO
    'USER_ID_FIELD': 'id',                          # Campo del modelo User para ID
    'USER_ID_CLAIM': 'user_id',                     # Nombre del claim en el payload
    
    # 🎫 CONFIGURACIÓN DEL TOKEN
    'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
    'TOKEN_TYPE_CLAIM': 'token_type',               # Claim que identifica tipo de token
    'JTI_CLAIM': 'jti',                             # JWT ID (identificador único)
}

🔍 Explicación Detallada de Cada Configuración:

Parámetro ¿Qué hace? ¿Por qué es importante?
ACCESS_TOKEN_LIFETIME Tiempo que dura el token de acceso Tokens cortos = más seguro. Si alguien roba el token, solo funciona 1 hora. 1 hora es el estándar de la industria.
REFRESH_TOKEN_LIFETIME Tiempo del token de refresco Permite renovar el access token sin login. 7 días es común para apps web, 30 días para móviles.
ROTATE_REFRESH_TOKENS Crear nuevo refresh al usarlo Aumenta seguridad: cada vez que renuevas, obtienes un refresh nuevo y el viejo se invalida.
BLACKLIST_AFTER_ROTATION Invalidar refresh anterior Previene que alguien reutilice un refresh token viejo robado.
ALGORITHM Algoritmo de firma HS256 es el estándar. Usa HMAC (simétrico). Para mayor seguridad usa RS256 (asimétrico).
AUTH_HEADER_TYPES Prefijo del header "Bearer" es el estándar OAuth 2.0. El cliente enviará: Authorization: Bearer eyJ0eX...

⚠️ IMPORTANTE: Agregar import de timedelta

Ve AL INICIO del archivo settings.py (línea 1-15) y agrega:

from datetime import timedelta  # ← AGREGAR ESTA LÍNEA

Debe quedar cerca de los otros imports, por ejemplo después de from pathlib import Path

⚠️ IMPORTANTE (2) POR HACER : Agregar 2 Dependencias antes de revisar todo lo que va

pip install django-cors-headers djangorestframework djangorestframework-simplejwt mysqlclient

pip install django-filter

Deben quedar instaladas las 2 Dependencias Ambas dependencias

✅ Checkpoint 2.2: Verificación

Verifica tu configuración:

  1. Guarda el archivo settings.py (Ctrl+S)
  2. Revisa que no haya errores de sintaxis (indentación correcta)
  3. Verifica el import: La línea from datetime import timedelta debe estar al inicio del archivo
  4. Prueba que Django arranca sin errores:
python manage.py check

Salida esperada:

System check identified no issues (0 silenced).

Si hay errores: Revisa la indentación (debe ser con espacios, no tabs) y que todos los parámetros tengan comas al final.

💡 Personalización Opcional

Puedes ajustar los tiempos según tu caso de uso:

  • API Pública muy segura: ACCESS_TOKEN_LIFETIME: timedelta(minutes=15)
  • App móvil: REFRESH_TOKEN_LIFETIME: timedelta(days=30)
  • Intranet corporativa: ACCESS_TOKEN_LIFETIME: timedelta(hours=8)

2.3 Crear API Views (Serializers y ViewSets) (20 minutos)

📚 ¿Qué son Serializers y ViewSets?

Serializers: Convierten los modelos de Django a JSON (y viceversa) para que la API pueda enviar/recibir datos.

ViewSets: Manejan las operaciones CRUD (Create, Read, Update, Delete) automáticamente.

Con estos dos componentes, Django REST Framework genera automáticamente toda la API por nosotros.

Paso 1: Crear Serializers

Crea el archivo libros/serializers.py (archivo nuevo):

from rest_framework import serializers
from .models import Categoria, Autor, Libro, Prestamo
from django.contrib.auth.models import User


class CategoriaSerializer(serializers.ModelSerializer):
    """Serializer para Categoría"""
    
    class Meta:
        model = Categoria
        fields = ['id', 'nombre', 'descripcion', 'activo', 'fecha_creacion']
        read_only_fields = ['id', 'fecha_creacion']


class AutorSerializer(serializers.ModelSerializer):
    """Serializer para Autor"""
    
    nombre_completo = serializers.ReadOnlyField()
    total_libros = serializers.SerializerMethodField()
    
    class Meta:
        model = Autor
        fields = ['id', 'nombre', 'apellido', 'nombre_completo', 
                 'fecha_nacimiento', 'pais_origen', 'biografia', 
                 'foto', 'total_libros', 'fecha_creacion']
        read_only_fields = ['id', 'fecha_creacion']
    
    def get_total_libros(self, obj):
        return obj.libros.filter(activo=True).count()


class LibroSerializer(serializers.ModelSerializer):
    """Serializer para Libro"""
    
    autor_nombre = serializers.CharField(source='autor.nombre_completo', read_only=True)
    categoria_nombre = serializers.CharField(source='categoria.nombre', read_only=True)
    esta_disponible = serializers.ReadOnlyField()
    
    class Meta:
        model = Libro
        fields = [
            'id', 'titulo', 'subtitulo', 'isbn',
            'autor', 'autor_nombre',
            'categoria', 'categoria_nombre',
            'editorial', 'fecha_publicacion', 'paginas', 'idioma',
            'descripcion', 'imagen_portada',
            'stock', 'estado', 'esta_disponible',
            'precio', 'valoracion',
            'activo', 'fecha_creacion', 'fecha_actualizacion'
        ]
        read_only_fields = ['id', 'fecha_creacion', 'fecha_actualizacion']
    
    def validate_isbn(self, value):
        """Validar que ISBN tenga 13 dígitos"""
        isbn = value.replace('-', '').replace(' ', '')
        if not isbn.isdigit():
            raise serializers.ValidationError("ISBN debe contener solo números")
        if len(isbn) != 13:
            raise serializers.ValidationError("ISBN debe tener 13 dígitos")
        return value
    
    def validate_precio(self, value):
        """Validar que precio sea positivo"""
        if value <= 0:
            raise serializers.ValidationError("El precio debe ser mayor a 0")
        return value


class PrestamoSerializer(serializers.ModelSerializer):
    """Serializer para Préstamo"""
    
    libro_titulo = serializers.CharField(source='libro.titulo', read_only=True)
    usuario_nombre = serializers.CharField(source='usuario.username', read_only=True)
    dias_prestamo = serializers.ReadOnlyField()
    esta_atrasado = serializers.ReadOnlyField()
    
    class Meta:
        model = Prestamo
        fields = [
            'id', 'libro', 'libro_titulo',
            'usuario', 'usuario_nombre',
            'fecha_prestamo', 'fecha_devolucion_esperada', 'fecha_devolucion_real',
            'estado', 'dias_prestamo', 'esta_atrasado', 'notas'
        ]
        read_only_fields = ['id', 'fecha_prestamo']
    
    def validate(self, data):
        """Validar que el libro esté disponible antes de prestar"""
        if self.instance is None:  # Solo en creación
            libro = data.get('libro')
            if not libro.esta_disponible:
                raise serializers.ValidationError({
                    'libro': 'Este libro no está disponible para préstamo'
                })
        return data


class UserSerializer(serializers.ModelSerializer):
    """Serializer para Usuario"""
    
    total_prestamos = serializers.SerializerMethodField()
    
    class Meta:
        model = User
        fields = ['id', 'username', 'email', 'first_name', 'last_name', 
                 'is_staff', 'date_joined', 'total_prestamos']
        read_only_fields = ['id', 'date_joined']
    
    def get_total_prestamos(self, obj):
        return obj.prestamos.count()

Paso 2: Crear ViewSets

Crea el archivo libros/api_views.py (archivo nuevo):

from rest_framework import viewsets, filters, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated, IsAuthenticatedOrReadOnly
from django_filters.rest_framework import DjangoFilterBackend

from .models import Categoria, Autor, Libro, Prestamo
from .serializers import (
    CategoriaSerializer, AutorSerializer, 
    LibroSerializer, PrestamoSerializer
)


class CategoriaViewSet(viewsets.ModelViewSet):
    """
    ViewSet para Categorías
    - GET /api/categorias/ - Listar todas
    - POST /api/categorias/ - Crear nueva
    - GET /api/categorias/{id}/ - Ver detalle
    - PUT /api/categorias/{id}/ - Actualizar
    - DELETE /api/categorias/{id}/ - Eliminar
    """
    queryset = Categoria.objects.all()
    serializer_class = CategoriaSerializer
    permission_classes = [IsAuthenticatedOrReadOnly]
    filter_backends = [filters.SearchFilter, filters.OrderingFilter]
    search_fields = ['nombre', 'descripcion']
    ordering_fields = ['nombre', 'fecha_creacion']
    ordering = ['nombre']


class AutorViewSet(viewsets.ModelViewSet):
    """ViewSet para Autores"""
    
    queryset = Autor.objects.all()
    serializer_class = AutorSerializer
    permission_classes = [IsAuthenticatedOrReadOnly]
    filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
    filterset_fields = ['pais_origen']
    search_fields = ['nombre', 'apellido', 'biografia']
    ordering_fields = ['apellido', 'nombre', 'fecha_creacion']
    ordering = ['apellido', 'nombre']
    
    @action(detail=True, methods=['get'])
    def libros(self, request, pk=None):
        """Endpoint personalizado: /api/autores/{id}/libros/"""
        autor = self.get_object()
        libros = autor.libros.filter(activo=True)
        serializer = LibroSerializer(libros, many=True)
        return Response(serializer.data)


class LibroViewSet(viewsets.ModelViewSet):
    """ViewSet para Libros"""
    
    queryset = Libro.objects.filter(activo=True).select_related('autor', 'categoria')
    serializer_class = LibroSerializer
    permission_classes = [IsAuthenticatedOrReadOnly]
    filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
    filterset_fields = ['estado', 'categoria', 'autor']
    search_fields = ['titulo', 'isbn', 'descripcion']
    ordering_fields = ['titulo', 'precio', 'fecha_publicacion', 'valoracion']
    ordering = ['-fecha_creacion']
    
    @action(detail=False, methods=['get'])
    def disponibles(self, request):
        """Endpoint: /api/libros/disponibles/"""
        libros = self.queryset.filter(
            estado=Libro.DISPONIBLE,
            stock__gt=0
        )
        serializer = self.get_serializer(libros, many=True)
        return Response(serializer.data)
    
    @action(detail=True, methods=['post'])
    def actualizar_stock(self, request, pk=None):
        """
        Endpoint: POST /api/libros/{id}/actualizar_stock/
        Body: {"cantidad": 5}  (puede ser negativo para restar)
        """
        libro = self.get_object()
        cantidad = request.data.get('cantidad', 0)
        
        try:
            cantidad = int(cantidad)
        except (ValueError, TypeError):
            return Response(
                {'error': 'La cantidad debe ser un número entero'},
                status=status.HTTP_400_BAD_REQUEST
            )
        
        libro.actualizar_stock(cantidad)
        serializer = self.get_serializer(libro)
        return Response(serializer.data)


class PrestamoViewSet(viewsets.ModelViewSet):
    """ViewSet para Préstamos"""
    
    queryset = Prestamo.objects.all().select_related('libro', 'usuario')
    serializer_class = PrestamoSerializer
    permission_classes = [IsAuthenticated]
    filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
    filterset_fields = ['estado', 'usuario']
    ordering_fields = ['fecha_prestamo', 'fecha_devolucion_esperada']
    ordering = ['-fecha_prestamo']
    
    def perform_create(self, serializer):
        """Al crear préstamo, asignar usuario actual y actualizar stock"""
        prestamo = serializer.save(usuario=self.request.user)
        prestamo.libro.actualizar_stock(-1)  # Reducir stock en 1
    
    @action(detail=True, methods=['post'])
    def devolver(self, request, pk=None):
        """
        Endpoint: POST /api/prestamos/{id}/devolver/
        Marca el préstamo como devuelto
        """
        from django.utils import timezone
        
        prestamo = self.get_object()
        
        if prestamo.estado == Prestamo.DEVUELTO:
            return Response(
                {'error': 'Este préstamo ya fue devuelto'},
                status=status.HTTP_400_BAD_REQUEST
            )
        
        prestamo.fecha_devolucion_real = timezone.now()
        prestamo.estado = Prestamo.DEVUELTO
        prestamo.save()
        
        # Incrementar stock del libro
        prestamo.libro.actualizar_stock(1)
        
        serializer = self.get_serializer(prestamo)
        return Response(serializer.data)

✅ VERIFICAR archivos creados:

python manage.py check

Debe decir: System check identified no issues (0 silenced).

✅ Checkpoint 2.3: Serializers y ViewSets creados

Verifica:

  • ✅ Archivo libros/serializers.py creado con 5 serializers
  • ✅ Archivo libros/api_views.py creado con 4 viewsets
  • ✅ Sin errores al ejecutar python manage.py check

2.4 Configurar URLs de la API (10 minutos)

🎯 ¿Qué vamos a crear?

Vamos a crear las rutas (URLs) de nuestra API. Esto incluye:

  • 🔑 /api/auth/jwt/login/ - Para hacer login y obtener tokens JWT
  • 🔄 /api/token/refresh/ - Para renovar el access token
  • ✅ /api/token/verify/ - Para verificar si un token es válido
  • 📚 /api/libros/ - CRUD de libros
  • ✍️ /api/autores/ - CRUD de autores
  • 📂 /api/categorias/ - CRUD de categorías
  • 📖 /api/prestamos/ - CRUD de préstamos

Paso 1: Crear archivo de URLs de la API

Crea el archivo libros/api_urls.py (nuevo):

# ===================================
# URLS DE LA API - libros/api_urls.py
# ===================================

from django.urls import path, include
from rest_framework.routers import DefaultRouter

# Importar vistas JWT de SimpleJWT
from rest_framework_simplejwt.views import (
    TokenObtainPairView,      # Vista para login (obtener tokens)
    TokenRefreshView,          # Vista para refrescar access token
    TokenVerifyView,           # Vista para verificar token
)

# Importar ViewSets
from . import api_views

# ===== ROUTER PARA VIEWSETS =====
# El router genera automáticamente las URLs para CRUD
router = DefaultRouter()
router.register(r'libros', api_views.LibroViewSet, basename='libro')
router.register(r'autores', api_views.AutorViewSet, basename='autor')
router.register(r'categorias', api_views.CategoriaViewSet, basename='categoria')
router.register(r'prestamos', api_views.PrestamoViewSet, basename='prestamo')

# ===== URL PATTERNS =====
urlpatterns = [
    # ─────────────────────────────────
    # 🔐 AUTENTICACIÓN JWT
    # ─────────────────────────────────
    
    # Login con JWT (POST: username + password → access y refresh tokens)
    path('auth/jwt/login/', TokenObtainPairView.as_view(), name='jwt_login'),
    
    # Refrescar token (POST: refresh_token → nuevo access_token)
    path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
    
    # Verificar token (POST: token → válido o inválido)
    path('token/verify/', TokenVerifyView.as_view(), name='token_verify'),
    
    
    # ─────────────────────────────────
    # 📚 ENDPOINTS DE LA API (CRUD)
    # ─────────────────────────────────
    
    # Incluir todas las rutas del router
    # Esto genera automáticamente:
    # GET    /api/libros/          - Listar todos los libros
    # POST   /api/libros/          - Crear nuevo libro
    # GET    /api/libros/{id}/     - Ver detalle de libro
    # PUT    /api/libros/{id}/     - Actualizar libro
    # DELETE /api/libros/{id}/     - Eliminar libro
    # Y lo mismo para autores, categorias, prestamos
    path('', include(router.urls)),
]

Paso 2: Conectar con el proyecto principal

Edita biblioteca_project/urls.py para incluir las URLs de la API:

# biblioteca_project/urls.py

from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    # Admin de Django
    path('admin/', admin.site.urls),
    
    # ✨ URLs de la API (AGREGAR ESTA LÍNEA)
    path('api/', include('libros.api_urls')),
]

✅ Instalar Dependencia:

pip install django-oauth-toolkit

Salida esperada: volvera a su entorno virtual

✅ VERIFICAR rutas creadas:

python manage.py check

Salida esperada: System check identified no issues (0 silenced).

📋 Resumen de URLs Disponibles

Con esta configuración, tu API tendrá estas rutas:

URL Método Descripción
🔐 AUTENTICACIÓN
/api/auth/jwt/login/ POST Login → Obtener tokens (access + refresh)
/api/token/refresh/ POST Renovar access token usando refresh token
/api/token/verify/ POST Verificar si un token es válido
📚 LIBROS
/api/libros/ GET Listar todos los libros
/api/libros/ POST Crear nuevo libro
/api/libros/{id}/ GET Ver detalle de un libro
/api/libros/{id}/ PUT/PATCH Actualizar libro
/api/libros/{id}/ DELETE Eliminar libro
/api/libros/disponibles/ GET Listar solo libros disponibles
/api/libros/{id}/actualizar_stock/ POST Actualizar stock de un libro
✍️ AUTORES
/api/autores/ GET/POST Listar/Crear autores
/api/autores/{id}/ GET/PUT/DELETE Ver/Actualizar/Eliminar autor
/api/autores/{id}/libros/ GET Ver libros de un autor
📂 CATEGORÍAS
/api/categorias/ GET/POST Listar/Crear categorías
/api/categorias/{id}/ GET/PUT/DELETE Ver/Actualizar/Eliminar categoría
📖 PRÉSTAMOS
/api/prestamos/ GET/POST Listar/Crear préstamos
/api/prestamos/{id}/ GET/PUT/DELETE Ver/Actualizar/Eliminar préstamo
/api/prestamos/{id}/devolver/ POST Marcar préstamo como devuelto

✅ Checkpoint 2.4: URLs configuradas

Verifica:

  • ✅ Archivo libros/api_urls.py creado
  • ✅ Archivo biblioteca_project/urls.py actualizado
  • python manage.py check sin errores

2.5 Probar JWT con Postman/Thunder Client (20 minutos)

🧪 ¿Cómo probar la API?

Vamos a usar Postman o Thunder Client (extensión de VS Code) para probar que JWT funciona correctamente.

Si no tienes Postman:

  • Opción 1: Descargar Postman de https://www.postman.com/downloads/
  • Opción 2: Instalar Thunder Client en VS Code (Extensions → Buscar "Thunder Client")
  • Opción 3: Usar comandos curl en la terminal (mostrados abajo)

Paso 1: Arrancar el servidor

python manage.py runserver

Deja el servidor corriendo. Abre otra terminal si necesitas ejecutar más comandos.

🔑 Prueba 1: Login y Obtener Tokens JWT

Con Postman/Thunder Client:

  1. Crear nueva request
  2. Método: POST
  3. URL: http://127.0.0.1:8000/api/auth/jwt/login/
  4. Headers: Content-Type: application/json
  5. Body → raw (JSON):
{
  "username": "admin",
  "password": "admin123"
}

Con curl (desde terminal):

curl -X POST http://127.0.0.1:8000/api/auth/jwt/login/ ^ -H "Content-Type: application/json" ^ -d "{\"username\":\"admin\",\"password\":\"admin123\"}"

✅ Respuesta esperada (Status 200 OK):

{
  "refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eX...",
  "access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBl..."
}

💾 GUARDA ESTOS TOKENS

Copia el access token (lo usarás en las siguientes pruebas).

Explicación:

  • access token: Usar para acceder a la API (dura 1 hora)
  • refresh token: Usar para obtener nuevo access token cuando expire (dura 7 días)

📚 Prueba 2: Acceder a la API con el Token

Con Postman/Thunder Client:

  1. Crear nueva request
  2. Método: GET
  3. URL: http://127.0.0.1:8000/api/libros/
  4. Headers: Agregar header de autorización:
    • Key: Authorization
    • Value: Bearer TU_ACCESS_TOKEN_AQUI

⚠️ IMPORTANTE: El valor debe ser Bearer (con B mayúscula) seguido de un espacio y luego el token.

Con curl:

curl http://127.0.0.1:8000/api/libros/ ^ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR..."

✅ Respuesta esperada (Status 200 OK):

{
  "count": 5,
  "next": null,
  "previous": null,
  "results": [
    {
      "id": 1,
      "titulo": "Cien años de soledad",
      "autor_nombre": "Gabriel García Márquez",
      "isbn": "9780123456789",
      "precio": "450.00",
      "stock": 10,
      "esta_disponible": true,
      ...
    },
    ...
  ]
}

🔄 Prueba 3: Refrescar el Access Token

¿Cuándo usar esto? Cuando el access token expire (después de 1 hora), usa el refresh token para obtener uno nuevo sin hacer login nuevamente.

Con Postman/Thunder Client:

  1. Método: POST
  2. URL: http://127.0.0.1:8000/api/token/refresh/
  3. Body → raw (JSON):
{
  "refresh": "TU_REFRESH_TOKEN_AQUI"
}

Con curl:

curl -X POST http://127.0.0.1:8000/api/token/refresh/ ^ -H "Content-Type: application/json" ^ -d "{\"refresh\":\"eyJhbGciOiJIUzI1NiIsInR...\"}"

✅ Respuesta esperada (Status 200 OK):

{
  "access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}

Nota: Recibes tanto un nuevo access token como un nuevo refresh token (por la configuración ROTATE_REFRESH_TOKENS).

✅ Prueba 4: Verificar si un Token es Válido

Con Postman/Thunder Client:

  1. Método: POST
  2. URL: http://127.0.0.1:8000/api/token/verify/
  3. Body → raw (JSON):
{
  "token": "TU_ACCESS_TOKEN_AQUI"
}

Con curl:

curl -X POST http://127.0.0.1:8000/api/token/verify/ ^ -H "Content-Type: application/json" ^ -d "{\"token\":\"eyJhbGciOiJIUzI1NiIsInR...\"}"

✅ Si el token es válido: Respuesta 200 OK con {} (objeto vacío)

❌ Si el token es inválido o expirado: Respuesta 401 Unauthorized

📝 Prueba 5: Crear un Libro (Requiere Autenticación)

Con Postman/Thunder Client:

  1. Método: POST
  2. URL: http://127.0.0.1:8000/api/libros/
  3. Headers:
    • Authorization: Bearer TU_ACCESS_TOKEN
    • Content-Type: application/json
  4. Body → raw (JSON):
{
  "titulo": "El Principito",
  "isbn": "9781234567897",
  "autor": 1,
  "categoria": 1,
  "editorial": "Editorial Planeta",
  "fecha_publicacion": "1943-04-06",
  "paginas": 96,
  "idioma": "Español",
  "descripcion": "Una historia filosófica sobre la amistad, el amor y la pérdida.",
  "stock": 15,
  "precio": "250.00",
  "valoracion": "4.80"
}

✅ Respuesta esperada (Status 201 Created):

{
  "id": 6,
  "titulo": "El Principito",
  "autor_nombre": "Gabriel García Márquez",
  "isbn": "9781234567897",
  "precio": "250.00",
  "stock": 15,
  "esta_disponible": true,
  "fecha_creacion": "2026-01-23T19:45:00.123456Z",
  ...
}

✅ Checkpoint 2.5: JWT funcionando correctamente

Si todas estas pruebas pasaron, ¡FELICIDADES! Tu autenticación JWT está completamente funcional.

Verifica que:

  • ✅ Login devuelve access y refresh tokens
  • ✅ Puedes acceder a /api/libros/ con el access token
  • ✅ Refresh token genera nuevo access token
  • ✅ Verify confirma que token es válido
  • ✅ Puedes crear/modificar recursos con autenticación

🚨 Errores Comunes y Soluciones

Error Solución
401 Unauthorized
  • Verifica que el token esté en el header Authorization
  • Debe ser: Bearer TOKEN (con espacio después de Bearer)
  • Verifica que el token no haya expirado (dura 1 hora)
{"detail":"No active account found..."} Username o password incorrectos. Verifica las credenciales del superusuario.
Token is invalid or expired El token expiró. Usa el refresh token para obtener uno nuevo.
CSRF Failed: CSRF token missing Estás usando session auth por error. Usa JWT con header Authorization
{"detail":"Authentication credentials were not provided"} No enviaste el header Authorization o está mal formateado.

🎉 ¡FELICIDADES! PARTE 1 COMPLETADA: JWT AUTHENTICATION

Has implementado exitosamente:

  • ✅ Django REST Framework con JWT
  • ✅ Login y obtención de tokens (access + refresh)
  • ✅ Renovación automática de tokens
  • ✅ Protección de endpoints con autenticación
  • ✅ API REST completa para Biblioteca (CRUD)

💾 GUARDAR PROGRESO: Este es un buen momento para hacer commit en Git:
git add .
git commit -m "Implementado JWT Authentication completo"

🚀 Continúa con la PARTE 2: OAuth 2.0

🔑 PARTE 2: OAUTH 2.0 - LOGIN CON GOOGLE/FACEBOOK (120 minutos)

OAuth 2.0 Duración: 120 minutos Dificultad: Alta

🎯 OBJETIVO DE ESTA PARTE

¿Qué lograrás?

Al finalizar esta sección, tendrás implementado "Login con Google" en tu aplicación:

  • ✅ Los usuarios pueden hacer login usando su cuenta de Google
  • ✅ No necesitas guardar contraseñas de usuarios externos
  • ✅ Obtienes información básica del usuario (nombre, email, foto)
  • ✅ Generas tokens JWT después del login OAuth
  • ✅ Sistema compatible con múltiples proveedores (Google, Facebook, GitHub)

🌟 ¿Por qué es importante OAuth 2.0?

OAuth 2.0 es EL estándar de la industria para autorización. Permite que los usuarios accedan a tu aplicación usando sus cuentas de servicios confiables (Google, Facebook, etc.) sin compartir contraseñas contigo.

📱 Casos de uso reales:

  • Botón "Continuar con Google" en aplicaciones web
  • Login social en apps móviles (iOS/Android)
  • Integración con APIs de terceros (Google Drive, Gmail, Calendar)
  • Single Sign-On (SSO) empresarial

🤔 ¿Qué es OAuth 2.0? - Explicación Simple

📚 OAuth 2.0 para Principiantes

Analogía del Mundo Real:

Imagina que vas a un hotel y quieres que la persona de limpieza entre a tu habitación:

  1. Forma Insegura (Sin OAuth): Le das tu llave maestra del hotel → Ella tiene acceso total permanente
  2. Forma Segura (Con OAuth): Pides al hotel una tarjeta de acceso temporal que solo funciona hoy y solo para tu habitación

En OAuth 2.0:

  • Tú (Usuario): Dueño de la cuenta de Google
  • Hotel: Google (proveedor OAuth)
  • Persona de limpieza: Tu aplicación (cliente OAuth)
  • Tarjeta temporal: Access Token
  • Permiso específico: Scopes (email, perfil, etc.)

🔄 Flujo de OAuth 2.0 - Paso a Paso

📊 Flujo Authorization Code (el más común)

┌─────────────┐                                  ┌──────────────┐
│   Usuario   │                                  │    Google    │
│   (Browser) │                                  │   (OAuth)    │
└──────┬──────┘                                  └──────┬───────┘
       │                                                │
       │  1. Click "Login con Google"                  │
       │ ────────────────────────────────────────────► │
       │                                                │
       │  2. Redirige a Google para autorización       │
       │ ◄──────────────────────────────────────────── │
       │    URL: https://accounts.google.com/o/oauth2/ │
       │                                                │
       │  3. Usuario acepta permisos en Google         │
       │ ────────────────────────────────────────────► │
       │                                                │
       │  4. Google redirige con código temporal       │
       │ ◄──────────────────────────────────────────── │
       │    URL: tu-app.com/callback?code=ABC123       │
       │                                                │
┌──────▼──────┐                                         │
│  Tu Backend │                                         │
│  (Django)   │                                         │
└──────┬──────┘                                         │
       │                                                │
       │  5. Intercambiar código por token             │
       │ ────────────────────────────────────────────► │
       │    POST con code + client_secret              │
       │                                                │
       │  6. Google devuelve access_token              │
       │ ◄──────────────────────────────────────────── │
       │    { "access_token": "ya29...", ... }         │
       │                                                │
       │  7. Obtener info del usuario                  │
       │ ────────────────────────────────────────────► │
       │    GET userinfo con access_token              │
       │                                                │
       │  8. Datos del usuario (email, nombre, foto)   │
       │ ◄──────────────────────────────────────────── │
       │                                                │
       │  9. Crear/Login usuario en Django             │
       │     Generar JWT propio                        │
       │                                                │
       │  10. Responder con JWT de tu app              │
       │ ────────────────────────────────────────────► │
       │                                         ┌──────▼──────┐
       │                                         │   Usuario   │
       │                                         │  (Logueado) │
       │                                         └─────────────┘

🔑 Conceptos Clave de OAuth 2.0

Término Definición Ejemplo en nuestro caso
Resource Owner Dueño de los datos El usuario que tiene cuenta de Google
Client Aplicación que solicita acceso Tu aplicación de Biblioteca
Authorization Server Servidor que autoriza y emite tokens Google OAuth Server
Resource Server Servidor con los datos protegidos Google API (userinfo, Gmail, Drive)
Authorization Code Código temporal de un solo uso El parámetro ?code=ABC123 en la URL
Access Token Token para acceder a recursos Token que usas para obtener email del usuario
Refresh Token Token para obtener nuevos access tokens Permite acceso de larga duración
Scope Permisos solicitados email profile (leer email y perfil)
Redirect URI URL de retorno después de autorización http://localhost:8000/api/auth/google/callback/

3.1 Configurar Proyecto en Google Cloud (20 minutos)

📋 ¿Qué vamos a hacer?

Para usar "Login con Google", necesitas registrar tu aplicación en Google Cloud Console y obtener credenciales (Client ID y Client Secret).

Es GRATIS y no necesitas tarjeta de crédito para desarrollo.

Paso 1: Crear Proyecto en Google Cloud

  1. Ir a https://console.cloud.google.com/
  2. Hacer login con tu cuenta de Google (cualquiera)
  3. Click en el selector de proyectos (arriba a la izquierda)
  4. Click en "NEW PROJECT"
  5. Configurar:
    • Project name: Biblioteca UTH
    • Organization: Dejar en "No organization"
  6. Click "CREATE"
  7. Esperar 30 segundos a que se cree el proyecto
  8. Asegurarte de que el proyecto esté seleccionado (verlo arriba)

Paso 2: Habilitar Google+ API

  1. En el menú lateral (☰), ir a "APIs & Services" → "Library"
  2. Buscar: Google+ API
  3. Click en "Google+ API"
  4. Click "ENABLE"
  5. Esperar a que se habilite

Paso 3: Configurar OAuth Consent Screen

  1. En el menú lateral, ir a "APIs & Services" → "OAuth consent screen"
  2. Seleccionar "External" (para que cualquiera pueda usarlo)
  3. Click "CREATE"
  4. Llenar formulario:
    • App name: Sistema Biblioteca UTH
    • User support email: Tu email
    • App logo: (Opcional, skip por ahora)
    • App domain: (Dejar vacío por ahora)
    • Authorized domains: (Dejar vacío)
    • Developer contact: Tu email
  5. Click "SAVE AND CONTINUE"
  6. Scopes: Click "ADD OR REMOVE SCOPES"
    • Buscar y seleccionar: userinfo.email
    • Buscar y seleccionar: userinfo.profile
    • Click "UPDATE"
  7. Click "SAVE AND CONTINUE"
  8. Test users: Agregar tu email de prueba
    • Click "+ ADD USERS"
    • Ingresar tu email
    • Click "ADD"
  9. Click "SAVE AND CONTINUE"
  10. Revisar y click "BACK TO DASHBOARD"

Paso 4: Crear OAuth 2.0 Credentials

  1. En el menú lateral, ir a "APIs & Services" → "Credentials"
  2. Click "+ CREATE CREDENTIALS"
  3. Seleccionar "OAuth client ID"
  4. Configurar:
    • Application type: Web application
    • Name: Biblioteca Web Client
    • Authorized JavaScript origins:
      • Click "+ ADD URI"
      • Agregar: http://localhost:8000
      • Click "+ ADD URI"
      • Agregar: http://127.0.0.1:8000
    • Authorized redirect URIs:
      • Click "+ ADD URI"
      • Agregar: http://localhost:8000/api/auth/google/callback/
      • Click "+ ADD URI"
      • Agregar: http://127.0.0.1:8000/api/auth/google/callback/
  5. Click "CREATE"

Paso 5: Guardar Credenciales

💾 IMPORTANTE: Guarda estas credenciales de forma segura

Aparecerá un modal con:

  • Your Client ID: 123456789-abcdefgh.apps.googleusercontent.com
  • Your Client Secret: GOCSPX-aBcDeFgHiJkLmNoPqRsTuVwXyZ

📋 Cópialos y guárdalos en un archivo temporal. Los necesitarás en el siguiente paso.

💡 TIP: Puedes verlos nuevamente en "Credentials" → Click en el nombre de tu OAuth client

✅ Checkpoint 3.1: Google Cloud Configurado

Verifica que tengas:

  • ✅ Proyecto creado en Google Cloud
  • ✅ Google+ API habilitada
  • ✅ OAuth Consent Screen configurado
  • ✅ OAuth 2.0 Client ID creado
  • ✅ Client ID y Client Secret guardados
  • ✅ Redirect URIs configuradas correctamente

4 Personalizar JWT Response

Crea libros/jwt_views.py para agregar datos personalizados al token:

from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
from rest_framework_simplejwt.views import TokenObtainPairView


class CustomTokenObtainPairSerializer(TokenObtainPairSerializer):
    """Serializer personalizado con datos adicionales"""
    
    @classmethod
    def get_token(cls, user):
        token = super().get_token(user)
        
        # Agregar claims personalizados al token
        token['username'] = user.username
        token['email'] = user.email
        token['is_staff'] = user.is_staff
        token['full_name'] = f"{user.first_name} {user.last_name}"
        
        return token
    
    def validate(self, attrs):
        data = super().validate(attrs)
        
        # Agregar datos extra al response
        data['user'] = {
            'id': self.user.id,
            'username': self.user.username,
            'email': self.user.email,
            'first_name': self.user.first_name,
            'last_name': self.user.last_name,
            'is_staff': self.user.is_staff,
            'date_joined': str(self.user.date_joined)
        }
        
        return data


class CustomTokenObtainPairView(TokenObtainPairView):
    """Vista personalizada para obtener JWT"""
    serializer_class = CustomTokenObtainPairSerializer

Actualiza las URLs:

from .jwt_views import CustomTokenObtainPairView

urlpatterns = [
    # JWT personalizado
    path('auth/jwt/login/', CustomTokenObtainPairView.as_view(), name='jwt_login'),
    # ... resto
]

5 Probar JWT

❌ Error en la sintaxis del curl -X en Windows CMD

La sintaxis estándar de curl con comillas simples NO funciona en Windows CMD. Aquí está la sintaxis correcta:

1. Obtener Token (Login):

curl -X POST http://127.0.0.1:8000/api/auth/jwt/login/ -H "Content-Type: application/json" -d "{\"username\":\"admin\",\"password\":\"tu_password\"}"

Respuesta esperada:

{
  "refresh": "eyJ0eXAiOiJKV1QiLCJhbGc...",
  "access": "eyJ0eXAiOiJKV1QiLCJhbGc...",
  "user": {
    "id": 1,
    "username": "admin",
    "email": "admin@biblioteca.com",
    "is_staff": true
  }
}

2. Usar Token para Acceder a API:

curl -H "Authorization: Bearer TU_ACCESS_TOKEN" http://127.0.0.1:8000/api/libros/

Respuesta esperada:

{
  "count": 5,
  "next": null,
  "previous": null,
  "results": [
    {
      "id": 1,
      "titulo": "Cien años de soledad",
      "autor": "Gabriel García Márquez",
      ...
    }
  ]
}

3. Refrescar Token:

curl -X POST http://127.0.0.1:8000/api/token/refresh/ -H "Content-Type: application/json" -d "{\"refresh\":\"tu_refresh_token\"}"
curl -X POST http://127.0.0.1:8000/api/token/refresh/ \ -H "Content-Type: application/json" \ -d '{"refresh":"REFRESH_TOKEN_AQUI"}'

✅ ¿Cómo Funciona?

  1. Usuario envía credenciales (username + password)
  2. Servidor valida y genera tokens (access + refresh)
  3. Cliente guarda tokens (localStorage, sessionStorage, cookies seguras)
  4. Cliente incluye access token en cada request
  5. Cuando access token expira, usa refresh token para obtener uno nuevo
  6. Si refresh token expira, usuario debe hacer login nuevamente

🔑 PARTE 2: OAUTH 2.0

OAuth 2.0

🤔 ¿Qué es OAuth 2.0?

OAuth 2.0 es un framework de autorización que permite a aplicaciones de terceros obtener acceso limitado a un servicio HTTP. Es el estándar para "Login con Google", "Login con Facebook", etc.

📌 Conceptos Clave de OAuth 2.0

  • Resource Owner: El usuario (dueño de los datos)
  • Client: La aplicación que quiere acceso
  • Authorization Server: Servidor que emite tokens
  • Resource Server: Servidor con los datos protegidos
  • Access Token: Token para acceder a recursos
  • Refresh Token: Token para obtener nuevos access tokens

3.2 Instalar y Configurar Django Allauth (15 minutos)

📚 ¿Qué es Django Allauth?

django-allauth es la librería más completa y popular para autenticación social en Django. Soporta más de 50 proveedores OAuth (Google, Facebook, GitHub, Twitter, etc.) y es muy fácil de configurar.

Alternativas: python-social-auth, pero Allauth es más completa y mantenida.

Paso 1: Instalar Django Allauth

# Asegúrate de que el entorno virtual esté activado (venv) pip install django-allauth==0.57.0

⚠️ IMPORTANTE: Instalar django-oauth-toolkit

Problema común: Al ejecutar las migraciones aparece el error ModuleNotFoundError: No module named 'oauth2_provider'

Solución: Instalar la librería django-oauth-toolkit que proporciona el módulo oauth2_provider

pip install django-oauth-toolkit==2.3.0

Esta librería es necesaria para:

  • ✅ Proporcionar el proveedor OAuth 2.0 completo
  • ✅ Generar access tokens y refresh tokens OAuth estándar
  • ✅ Gestionar aplicaciones cliente OAuth

✅ Verificar instalación:

pip show django-allauth pip show django-oauth-toolkit

Paso 2: Configurar en settings.py

Edita biblioteca_project/settings.py:

2.1 - Agregar a INSTALLED_APPS:

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'django.contrib.sites',  # ← AGREGAR (requerido por allauth)
    
    # Third-party apps
    'rest_framework',
    'corsheaders',
    'django_filters',
    'rest_framework_simplejwt',
    'oauth2_provider',  # ← AGREGAR (Django OAuth Toolkit)
    'allauth',  # ← AGREGAR
    'allauth.account',  # ← AGREGAR
    'allauth.socialaccount',  # ← AGREGAR
    'allauth.socialaccount.providers.google',  # ← AGREGAR
    
    # Tu aplicación
    'libros',
]

📌 ¿Por qué oauth2_provider?

oauth2_provider es el módulo que proporciona django-oauth-toolkit. Es necesario incluirlo en INSTALLED_APPS para:

  • ✅ Crear las tablas de OAuth 2.0 en la base de datos
  • ✅ Gestionar aplicaciones cliente, tokens y scopes
  • ✅ Proporcionar endpoints OAuth estándar (/o/token/, /o/authorize/)

2.1.1 - AGREGAR MIDDLEWARE DE ALLAUTH:

❌ Error común al usar python manage.py check

Si ves el error allauth.account.middleware.AccountMiddleware must be added to settings.MIDDLEWARE, necesitas agregar el middleware de allauth.

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'corsheaders.middleware.CorsMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
    'allauth.account.middleware.AccountMiddleware',  # ← AGREGAR
]

2.2 - Agregar SITE_ID:

# =======================
# SITE CONFIGURATION
# =======================
SITE_ID = 1  # ← AGREGAR

2.3 - Configurar Authentication Backends:

# =======================
# AUTHENTICATION BACKENDS
# =======================
AUTHENTICATION_BACKENDS = [
    # Backend por defecto de Django (username/password)
    'django.contrib.auth.backends.ModelBackend',
    
    # Backend de allauth para OAuth social
    'allauth.account.auth_backends.AuthenticationBackend',
]

2.4 - Configurar Django Allauth:

# =======================
# DJANGO ALLAUTH CONFIG
# =======================

# Configuración de cuentas
ACCOUNT_EMAIL_REQUIRED = True
ACCOUNT_USERNAME_REQUIRED = False  # Solo email para login social
ACCOUNT_AUTHENTICATION_METHOD = 'email'
ACCOUNT_EMAIL_VERIFICATION = 'optional'  # Para desarrollo: 'mandatory' en producción

# Configuración de login social
SOCIALACCOUNT_AUTO_SIGNUP = True  # Crear usuario automáticamente
SOCIALACCOUNT_EMAIL_VERIFICATION = 'none'  # No verificar email en OAuth

# Proveedores OAuth configurados
SOCIALACCOUNT_PROVIDERS = {
    'google': {
        'SCOPE': [
            'profile',
            'email',
        ],
        'AUTH_PARAMS': {
            'access_type': 'online',
        },
        'APP': {
            'client_id': 'TU_GOOGLE_CLIENT_ID_AQUI',  # ← REEMPLAZAR
            'secret': 'TU_GOOGLE_CLIENT_SECRET_AQUI',  # ← REEMPLAZAR
            'key': ''
        }
    }
}

🔐 REEMPLAZAR Credenciales de Google

En la sección SOCIALACCOUNT_PROVIDERS:

  • Reemplaza 'TU_GOOGLE_CLIENT_ID_AQUI' con tu Client ID de Google
  • Reemplaza 'TU_GOOGLE_CLIENT_SECRET_AQUI' con tu Client Secret de Google

Ejemplo:

'client_id': '123456789-abcdefgh.apps.googleusercontent.com',
'secret': 'GOCSPX-aBcDeFgHiJkLmNoPqRsTuVwXyZ',

2.5 - Agregar Configuración de OAuth 2.0 Provider (Django OAuth Toolkit):

# =======================
# OAUTH 2.0 PROVIDER SETTINGS
# =======================
# Configuración para django-oauth-toolkit
OAUTH2_PROVIDER = {
    # Tiempo de vida de los tokens
    'ACCESS_TOKEN_EXPIRE_SECONDS': 3600,  # 1 hora
    'REFRESH_TOKEN_EXPIRE_SECONDS': 86400 * 7,  # 7 días
    
    # Scopes disponibles
    'SCOPES': {
        'read': 'Acceso de lectura',
        'write': 'Acceso de escritura',
    },
    
    # Tipo de token por defecto
    'ACCESS_TOKEN_MODEL': 'oauth2_provider.models.AccessToken',
    'REFRESH_TOKEN_MODEL': 'oauth2_provider.models.RefreshToken',
}

📌 Diferencia entre django-allauth y django-oauth-toolkit

django-allauth:

  • ✅ Permite que TU APLICACIÓN use OAuth de otros servicios (Google, Facebook)
  • ✅ Tu app es el "cliente" que consume APIs de terceros
  • ✅ Ejemplo: "Login con Google" en tu app

django-oauth-toolkit:

  • ✅ Permite que TU APLICACIÓN SEA un proveedor OAuth
  • ✅ Tu app es el "servidor" que proporciona tokens a otras aplicaciones
  • ✅ Ejemplo: Otras apps pueden usar "Login con Biblioteca UTH"

💡 En resumen: Allauth te permite USAR OAuth de otros, OAuth Toolkit te permite SER un proveedor OAuth.

✅ VERIFICAR configuración:

python manage.py check

Debe decir: System check identified no issues (0 silenced).

✅ Checkpoint 3.2: Django Allauth y OAuth Toolkit Configurados

Verifica:

  • ✅ django-allauth instalado
  • ✅ django-oauth-toolkit instalado
  • ✅ INSTALLED_APPS actualizado con allauth, oauth2_provider y providers
  • ✅ MIDDLEWARE con AccountMiddleware agregado
  • ✅ SITE_ID = 1 agregado
  • ✅ AUTHENTICATION_BACKENDS configurado
  • ✅ SOCIALACCOUNT_PROVIDERS con tus credenciales de Google
  • python manage.py check sin errores

3.3 Ejecutar Migraciones y Configurar Admin (10 minutos)

Paso 1: Ejecutar migraciones

python manage.py migrate

Salida esperada:

Running migrations:
  Applying sites.0001_initial... OK
  Applying sites.0002_alter_domain_unique... OK
  Applying account.0001_initial... OK
  Applying account.0002_email_max_length... OK
  Applying socialaccount.0001_initial... OK
  Applying socialaccount.0002_token_max_lengths... OK
  Applying socialaccount.0003_extra_data_default_dict... OK
  ...

Paso 2: Configurar Site en Admin

  1. Arrancar servidor: python manage.py runserver
  2. Ir a: http://127.0.0.1:8000/admin/
  3. Login con tu superusuario (admin / admin123)
  4. Click en "Sites"
  5. Click en "example.com" (el único que hay)
  6. Editar:
    • Domain name: localhost:8000
    • Display name: Biblioteca UTH
  7. Click "SAVE"

Paso 3: Verificar Social Apps

  1. En el admin, ir a "SOCIAL ACCOUNTS" → "Social applications"
  2. Deberías ver que está vacío (lo configuraremos por código)

✅ Checkpoint 3.3: Base de datos lista

Verifica:

  • ✅ Migraciones ejecutadas sin errores
  • ✅ Tablas de oauth2_provider creadas (oauth2_provider_accesstoken, oauth2_provider_application, etc.)
  • ✅ Site configurado en admin
  • ✅ Admin de allauth accesible

3.3.1 Configurar URLs de OAuth 2.0 (5 minutos)

🔗 ¿Por qué configurar URLs?

Django OAuth Toolkit proporciona endpoints estándar OAuth 2.0 que necesitamos incluir en nuestro proyecto:

  • /o/authorize/ - Para autorizar aplicaciones
  • /o/token/ - Para obtener tokens de acceso
  • /o/revoke_token/ - Para revocar tokens

Editar biblioteca_project/urls.py:

from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    
    # API REST
    path('api/', include('libros.api_urls')),
    
    # OAuth URLs de allauth (para login con Google/Facebook)
    path('accounts/', include('allauth.urls')),
    
    # ← AGREGAR: OAuth 2.0 URLs de django-oauth-toolkit
    path('o/', include('oauth2_provider.urls', namespace='oauth2_provider')),
]

⚠️ No confundir los dos tipos de OAuth

/accounts/ (allauth):

  • URLs para que usuarios hagan login CON Google/Facebook
  • Ejemplo: /accounts/google/login/

/o/ (oauth2_provider):

  • URLs para que TU APP proporcione OAuth a otras aplicaciones
  • Ejemplo: /o/token/ para que apps externas obtengan tokens

✅ VERIFICAR URLs:

python manage.py show_urls | grep -E "(accounts|oauth)"

Deberías ver algo como:

/accounts/google/login/
/accounts/google/callback/
/o/authorize/
/o/token/
/o/revoke_token/
...

3.3.2 Nota Importante sobre el Error 401 en test_oauth.py

🐛 Error Común: Código 401 al ejecutar test_oauth.py

Síntoma: Al ejecutar el script test_oauth.py para probar OAuth 2.0, obtienes Status Code: 401 y el mensaje de error indica que el token no es válido.

Causa del problema:

La configuración de REST_FRAMEWORK no incluía el backend de autenticación de OAuth 2.0 (OAuth2Authentication), por lo que Django REST Framework no reconocía los tokens OAuth emitidos por django-oauth-toolkit.

✅ Solución:

Ya agregamos la línea necesaria en la configuración de REST_FRAMEWORK en settings.py:

'oauth2_provider.contrib.rest_framework.OAuth2Authentication',  # ← Esta línea

📝 ¿Qué hace esta línea?

  • ✅ Permite que DRF valide tokens OAuth 2.0 emitidos por django-oauth-toolkit
  • ✅ Autentica usuarios usando el Bearer token en el header Authorization
  • ✅ Se suma a los otros métodos de autenticación (JWT, Token, Session)

🎯 Resultado: Ahora cuando ejecutes python test_oauth.py, deberías obtener Status Code: 200 y ver los datos de la API correctamente.

3.4 Crear Vista de OAuth y Generar JWT (25 minutos)

🎯 ¿Qué vamos a hacer?

Crearemos un endpoint personalizado que:

  1. Recibe el callback de Google con el código de autorización
  2. Intercambia el código por un access token de Google
  3. Obtiene la información del usuario de Google
  4. Crea o actualiza el usuario en Django
  5. Genera nuestros propios tokens JWT
  6. Devuelve los tokens al frontend

Crear archivo libros/oauth_views.py (nuevo):

from django.conf import settings
from django.contrib.auth import get_user_model
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework_simplejwt.tokens import RefreshToken
from urllib.parse import urlencode  # ← IMPORTAR URLENCODE
import requests
import logging

logger = logging.getLogger(__name__)
User = get_user_model()


@api_view(['POST', 'GET'])  # ← AGREGAR SOPORTE PARA GET
@permission_classes([AllowAny])
def google_oauth_callback(request):
    """
    Endpoint que recibe el código de autorización de Google
    y devuelve tokens JWT de nuestra aplicación
    
    GET /api/auth/google/callback/?code=4/0AbUR2VN...
    o
    POST /api/auth/google/callback/
    Body: {
        "code": "4/0AbUR2VN..."  // Código de autorización de Google
    }
    """
    
    # 1. Obtener el código de autorización (de POST o GET)
    code = request.data.get('code') or request.query_params.get('code')  # ← CAMBIO AQUÍ
    
    if not code:
        error_msg = 'El código de autorización es requerido'
        logger.error(error_msg)
        if request.method == 'GET':
            return redirect(f'/oauth/login/?error={urllib.parse.quote(error_msg)}')
        return Response({'error': error_msg}, status=status.HTTP_400_BAD_REQUEST)
    
    try:
        # 2. Intercambiar código por access token de Google
        token_url = 'https://oauth2.googleapis.com/token'
        
        google_config = settings.SOCIALACCOUNT_PROVIDERS['google']['APP']
        
        token_data = {
            'code': code,
            'client_id': google_config['client_id'],
            'client_secret': google_config['secret'],
            'redirect_uri': 'http://localhost:8000/api/auth/google/callback/',  # Debe coincidir con Google Cloud
            'grant_type': 'authorization_code'
        }
        
        token_response = requests.post(token_url, data=token_data, timeout=10)
        token_response.raise_for_status()
        
        tokens = token_response.json()
        google_access_token = tokens.get('access_token')
        
        if not google_access_token:
            error_msg = 'No se pudo obtener access token de Google'
            logger.error(error_msg)
            return redirect(f'/oauth/login/?error={urllib.parse.quote(error_msg)}')
        
        logger.info(f"Access token de Google obtenido: {google_access_token[:20]}...")
        
        # 3. Obtener información del usuario de Google
        userinfo_url = 'https://www.googleapis.com/oauth2/v2/userinfo'
        headers = {'Authorization': f'Bearer {google_access_token}'}
        
        userinfo_response = requests.get(userinfo_url, headers=headers, timeout=10)
        userinfo_response.raise_for_status()
        
        user_data = userinfo_response.json()
        
        logger.info(f"Datos de usuario de Google: {user_data}")
        
        # 4. Crear o actualizar usuario en Django
        email = user_data.get('email')
        
        if not email:
            error_msg = 'No se pudo obtener el email del usuario'
            logger.error(error_msg)
            return redirect(f'/oauth/login/?error={urllib.parse.quote(error_msg)}')
        
        # Buscar si el usuario ya existe
        user, created = User.objects.get_or_create(
            email=email,
            defaults={
                'username': email.split('@')[0],  # Usar parte antes del @ como username
                'first_name': user_data.get('given_name', ''),
                'last_name': user_data.get('family_name', ''),
            }
        )
        
        # Si el usuario ya existía, actualizar su información
        if not created:
            user.first_name = user_data.get('given_name', user.first_name)
            user.last_name = user_data.get('family_name', user.last_name)
            user.save()
            logger.info(f"Usuario existente actualizado: {user.email}")
        else:
            logger.info(f"Nuevo usuario creado: {user.email}")
        
        # 5. Generar tokens JWT de nuestra aplicación
        refresh = RefreshToken.for_user(user)
        access_token = str(refresh.access_token)
        
        # 6. Preparar datos para enviar al frontend
        user_info = {
            'id': user.id,
            'email': user.email,
            'username': user.username,
            'first_name': user.first_name,
            'last_name': user.last_name,
            'is_staff': user.is_staff,
        }
        
        google_info = {
            'picture': user_data.get('picture'),
            'verified_email': user_data.get('verified_email'),
        }
        
        # Codificar datos para URL
        user_info_json = json.dumps(user_info)
        google_info_json = json.dumps(google_info)
        
        # Construir URL de redirección a oauth_login.html con todos los datos
        redirect_url = (
            f'/oauth/login/?'
            f'access_token={access_token}&'
            f'refresh_token={str(refresh)}&'
            f'user_info={urllib.parse.quote(user_info_json)}&'
            f'google_info={urllib.parse.quote(google_info_json)}&'
            f'message={urllib.parse.quote("Login exitoso con Google" if not created else "Cuenta creada exitosamente con Google")}'
        )
        
        logger.info(f"Redirigiendo a: {redirect_url[:100]}...")
        return redirect(redirect_url)
    
    except requests.Timeout:
        logger.error("Timeout al comunicarse con Google")
        return redirect(f'/oauth/login/?error={urllib.parse.quote("Timeout al comunicarse con Google")}')
    
    except requests.RequestException as e:
        logger.error(f"Error al comunicarse con Google: {str(e)}")
        return redirect(f'/oauth/login/?error={urllib.parse.quote(f"Error con Google: {str(e)}")}')
    
    except Exception as e:
        logger.error(f"Error inesperado en OAuth: {str(e)}")
        return redirect(f'/oauth/login/?error={urllib.parse.quote(f"Error inesperado: {str(e)}")}')


@api_view(['GET'])
@permission_classes([AllowAny])
def google_oauth_redirect(request):
    """
    Endpoint que redirige al usuario a Google para autorización
    
    GET /api/auth/google/redirect/
    
    Devuelve la URL a la que el frontend debe redirigir al usuario
    """
    
    google_config = settings.SOCIALACCOUNT_PROVIDERS['google']['APP']
    scopes = settings.SOCIALACCOUNT_PROVIDERS['google']['SCOPE']
    
    # Construir parámetros como diccionario para usar urlencode
    params = {
        'client_id': google_config["client_id"],
        'redirect_uri': 'http://127.0.0.1:8000/api/auth/google/callback/',  # Usar 127.0.0.1
        'scope': " ".join(scopes),
        'response_type': 'code',
        'access_type': 'offline',
        'prompt': 'consent',
    }
    
    # Construir URL con urlencode para codificar correctamente los parámetros
    auth_url = f'https://accounts.google.com/o/oauth2/v2/auth?{urlencode(params)}'  # ← USAR URLENCODE
    
    return Response({
        'auth_url': auth_url
    }, status=status.HTTP_200_OK)

✅ VERIFICAR archivo creado:

python manage.py check

Debe decir: System check identified no issues (0 silenced).

🔧 CAMBIOS IMPORTANTES IMPLEMENTADOS EN EL CÓDIGO

1️⃣ Función google_oauth_callback - Agregado soporte para GET:

@api_view(['POST', 'GET'])  # ← Ahora acepta GET también
def google_oauth_callback(request):
    code = request.data.get('code') or request.query_params.get('code')  # ← Obtiene code de POST o GET

📝 Razón: Google redirige con el código en la URL (método GET con parámetros en query string), no en el body (POST). Este cambio permite recibir correctamente el código de autorización.

2️⃣ Función google_oauth_redirect - Uso de urlencode:

from urllib.parse import urlencode  # ← Importar al inicio

params = {
    'client_id': google_config["client_id"],
    'redirect_uri': 'http://127.0.0.1:8000/api/auth/google/callback/',
    'scope': " ".join(scopes),
    'response_type': 'code',
    'access_type': 'offline',
    'prompt': 'consent',
}

auth_url = f'https://accounts.google.com/o/oauth2/v2/auth?{urlencode(params)}'  # ← urlencode para codificar

📝 Razón: La función urlencode() codifica correctamente los parámetros de URL, evitando errores por espacios o caracteres especiales en los scopes. También hace el código más legible usando un diccionario.

⚠️ IMPORTANTE: Usamos 127.0.0.1 en lugar de localhost porque Google los trata como URIs diferentes. Mantén consistencia en tu código y en Google Cloud Console.

📖 Explicación del Código

google_oauth_redirect:

  • Genera la URL de autorización de Google
  • El frontend redirige al usuario a esta URL
  • Google muestra pantalla de permisos

google_oauth_callback:

  1. Recibe el código de autorización de Google
  2. Intercambia código por access token
  3. Usa access token para obtener datos del usuario
  4. Crea o actualiza usuario en Django
  5. Genera JWT propios de la aplicación
  6. Devuelve JWT al cliente

✅ Checkpoint 3.4: Vistas OAuth creadas

Verifica:

  • ✅ Archivo libros/oauth_views.py creado
  • ✅ Función google_oauth_callback implementada
  • ✅ Función google_oauth_redirect implementada
  • python manage.py check sin errores

3.5 Agregar URLs para OAuth (5 minutos)

Editar libros/api_urls.py para agregar las rutas OAuth:

from django.urls import path, include
from rest_framework.routers import DefaultRouter
from rest_framework_simplejwt.views import (
    TokenObtainPairView,
    TokenRefreshView,
    TokenVerifyView,
)

from . import api_views
from . import oauth_views  # ← AGREGAR

router = DefaultRouter()
router.register(r'libros', api_views.LibroViewSet, basename='libro')
router.register(r'autores', api_views.AutorViewSet, basename='autor')
router.register(r'categorias', api_views.CategoriaViewSet, basename='categoria')
router.register(r'prestamos', api_views.PrestamoViewSet, basename='prestamo')

urlpatterns = [
    # ─────────────────────────────────
    # 🔐 AUTENTICACIÓN JWT
    # ─────────────────────────────────
    path('auth/jwt/login/', TokenObtainPairView.as_view(), name='jwt_login'),
    path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
    path('token/verify/', TokenVerifyView.as_view(), name='token_verify'),
    
    # ─────────────────────────────────
    # 🔑 AUTENTICACIÓN OAUTH 2.0 (GOOGLE)
    # ─────────────────────────────────
    path('auth/google/redirect/', oauth_views.google_oauth_redirect, name='google_redirect'),
    path('auth/google/callback/', oauth_views.google_oauth_callback, name='google_callback'),
    
    # ─────────────────────────────────
    # 📚 ENDPOINTS CRUD
    # ─────────────────────────────────
    path('', include(router.urls)),
]

✅ Checkpoint 3.5: URLs OAuth agregadas

Verifica:

  • ✅ Import de oauth_views agregado
  • ✅ Rutas auth/google/redirect/ y auth/google/callback/ agregadas
  • python manage.py check sin errores

3.6 Crear Templates HTML para Pruebas (15 minutos)

🎯 Arquitectura del Proyecto

Este proyecto es una API Backend (sin frontend complejo)

Sin embargo, para probar OAuth necesitamos páginas HTML simples porque:

  • OAuth requiere redireccionar al usuario a Google
  • Google redirige de vuelta a una URL de callback
  • Necesitamos capturar el código de autorización

Usaremos templates HTML SOLO para pruebas, no es un frontend completo.

En producción, tu frontend sería React/Vue/Angular que consume esta API.

Paso 1: Crear carpeta de templates

mkdir templates

Paso 2: Configurar templates en settings.py

Edita biblioteca_project/settings.py, busca TEMPLATES y actualiza:

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [BASE_DIR / 'templates'],  # ← AGREGAR esto
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

Paso 3: Crear página de inicio (Home)

Crea el archivo templates/home.html:

<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Biblioteca UTH - Sistema de Autenticación</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        
        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            min-height: 100vh;
            display: flex;
            align-items: center;
            justify-content: center;
        }
        
        .container {
            background: white;
            padding: 50px;
            border-radius: 15px;
            box-shadow: 0 10px 50px rgba(0,0,0,0.2);
            max-width: 500px;
            width: 90%;
            text-align: center;
        }
        
        h1 {
            color: #333;
            margin-bottom: 10px;
            font-size: 2em;
        }
        
        .subtitle {
            color: #666;
            margin-bottom: 40px;
            font-size: 1.1em;
        }
        
        .btn {
            display: inline-block;
            padding: 15px 40px;
            margin: 10px;
            border-radius: 8px;
            text-decoration: none;
            font-weight: bold;
            font-size: 1.1em;
            transition: all 0.3s ease;
            cursor: pointer;
            border: none;
        }
        
        .btn-google {
            background: #4285f4;
            color: white;
        }
        
        .btn-google:hover {
            background: #357ae8;
            transform: translateY(-2px);
            box-shadow: 0 5px 20px rgba(66, 133, 244, 0.4);
        }
        
        .btn-jwt {
            background: #11998e;
            color: white;
        }
        
        .btn-jwt:hover {
            background: #0e8074;
            transform: translateY(-2px);
            box-shadow: 0 5px 20px rgba(17, 153, 142, 0.4);
        }
        
        .btn-api {
            background: #667eea;
            color: white;
        }
        
        .btn-api:hover {
            background: #5568d3;
            transform: translateY(-2px);
            box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
        }
        
        .divider {
            margin: 30px 0;
            height: 1px;
            background: #ddd;
        }
        
        .info {
            background: #f0f4ff;
            padding: 20px;
            border-radius: 8px;
            margin-top: 30px;
            text-align: left;
        }
        
        .info h3 {
            color: #667eea;
            margin-bottom: 10px;
        }
        
        .info ul {
            list-style-position: inside;
            color: #555;
        }
        
        .info li {
            margin: 5px 0;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>🏛️ Biblioteca UTH</h1>
        <p class="subtitle">Sistema de Gestión de Biblioteca</p>
        
        <!-- Login con Google -->
        <a href="{% url 'oauth_login' %}" class="btn btn-google">
            🔐 Login con Google
        </a>
        
        <div class="divider"></div>
        
        <!-- Login tradicional con JWT -->
        <a href="{% url 'jwt_login_page' %}" class="btn btn-jwt">
            🔑 Login con JWT
        </a>
        
        <div class="divider"></div>
        
        <!-- Ver API -->
        <a href="/api/" class="btn btn-api">
            📚 Explorar API REST
        </a>
        
        <div class="info">
            <h3>ℹ️ Información del Sistema</h3>
            <ul>
                <li>✅ API REST con Django REST Framework</li>
                <li>✅ Autenticación JWT</li>
                <li>✅ OAuth 2.0 con Google</li>
                <li>✅ MySQL como base de datos</li>
                <li>✅ Gestión completa de biblioteca</li>
            </ul>
        </div>
    </div>
</body>
</html>

Paso 4: Crear página de login OAuth

Crea el archivo templates/oauth_login.html:

<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Login con Google - Biblioteca UTH</title>
    <style>
        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            min-height: 100vh;
            display: flex;
            align-items: center;
            justify-content: center;
            padding: 20px;
        }
        
        .container {
            background: white;
            padding: 40px;
            border-radius: 15px;
            box-shadow: 0 10px 50px rgba(0,0,0,0.2);
            max-width: 600px;
            width: 100%;
        }
        
        h1 {
            color: #333;
            margin-bottom: 30px;
            text-align: center;
        }
        
        .loading {
            text-align: center;
            color: #666;
            font-size: 1.2em;
        }
        
        .spinner {
            border: 4px solid #f3f3f3;
            border-top: 4px solid #667eea;
            border-radius: 50%;
            width: 50px;
            height: 50px;
            animation: spin 1s linear infinite;
            margin: 20px auto;
        }
        
        @keyframes spin {
            0% { transform: rotate(0deg); }
            100% { transform: rotate(360deg); }
        }
        
        .result {
            margin-top: 30px;
            padding: 20px;
            border-radius: 8px;
            display: none;
        }
        
        .success {
            background: #d4edda;
            border: 1px solid #c3e6cb;
            color: #155724;
        }
        
        .error {
            background: #f8d7da;
            border: 1px solid #f5c6cb;
            color: #721c24;
        }
        
        .user-info {
            background: #f0f4ff;
            padding: 15px;
            border-radius: 8px;
            margin-top: 15px;
        }
        
        .token-display {
            background: #2d2d2d;
            color: #4ec9b0;
            padding: 15px;
            border-radius: 8px;
            margin-top: 15px;
            word-wrap: break-word;
            font-family: 'Courier New', monospace;
            font-size: 0.9em;
        }
        
        .btn {
            display: inline-block;
            padding: 10px 20px;
            margin-top: 15px;
            background: #667eea;
            color: white;
            text-decoration: none;
            border-radius: 5px;
            transition: all 0.3s ease;
        }
        
        .btn:hover {
            background: #5568d3;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>🔐 Login con Google</h1>
        
        <div id="loading" class="loading">
            <div class="spinner"></div>
            <p>Procesando autenticación...</p>
        </div>
        
        <div id="result" class="result"></div>
    </div>
    
    <script>
        // Obtener código de autorización de la URL
        const urlParams = new URLSearchParams(window.location.search);
        const code = urlParams.get('code');
        const error = urlParams.get('error');
        
        if (error) {
            showError('Login cancelado o error: ' + error);
        } else if (!code) {
            // Si no hay código, redirigir a Google
            window.location.href = '/api/auth/google/redirect/';
        } else {
            // Si hay código, intercambiarlo por tokens
            exchangeCodeForTokens(code);
        }
        
        async function exchangeCodeForTokens(code) {
            try {
                const response = await fetch('/api/auth/google/callback/', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                    },
                    body: JSON.stringify({ code: code })
                });
                
                const data = await response.json();
                
                if (response.ok) {
                    // Guardar tokens en localStorage
                    localStorage.setItem('access_token', data.access);
                    localStorage.setItem('refresh_token', data.refresh);
                    
                    showSuccess(data);
                } else {
                    showError(data.error || 'Error desconocido');
                }
            } catch (error) {
                showError('Error de red: ' + error.message);
            }
        }
        
        function showSuccess(data) {
            document.getElementById('loading').style.display = 'none';
            const resultDiv = document.getElementById('result');
            resultDiv.className = 'result success';
            resultDiv.style.display = 'block';
            
            resultDiv.innerHTML = `
                <h2>✅ ${data.message}</h2>
                
                <div class="user-info">
                    <h3>👤 Datos del Usuario</h3>
                    <p><strong>Email:</strong> ${data.user.email}</p>
                    <p><strong>Nombre:</strong> ${data.user.first_name} ${data.user.last_name}</p>
                    <p><strong>Username:</strong> ${data.user.username}</p>
                    ${data.google_data.picture ? `<img src="${data.google_data.picture}" alt="Foto de perfil" style="border-radius:50%;margin-top:10px">` : ''}
                </div>
                
                <div class="token-display">
                    <strong>🔑 Access Token (JWT):</strong><br>
                    ${data.access.substring(0, 50)}...
                </div>
                
                <a href="/api/" class="btn">📚 Ir a la API</a>
                <a href="/" class="btn">🏠 Volver al Inicio</a>
            `;
        }
        
        function showError(errorMessage) {
            document.getElementById('loading').style.display = 'none';
            const resultDiv = document.getElementById('result');
            resultDiv.className = 'result error';
            resultDiv.style.display = 'block';
            
            resultDiv.innerHTML = `
                <h2>❌ Error en el Login</h2>
                <p>${errorMessage}</p>
                <a href="/" class="btn">🏠 Volver al Inicio</a>
            `;
        }
    </script>
</body>
</html>

Paso 5: Crear vistas para servir los templates

Crea el archivo libros/web_views.py (nuevo):

from django.shortcuts import render, redirect
from django.conf import settings


def home(request):
    """Página de inicio"""
    return render(request, 'home.html')


def oauth_login(request):
    """Página de login con OAuth que maneja el callback de Google"""
    # Si hay un código en la URL, renderizar la página
    # que procesará el código
    return render(request, 'oauth_login.html')


def jwt_login_page(request):
    """Página de login con JWT (tradicional)"""
    return render(request, 'jwt_login.html')

Paso 6: Agregar URLs para las páginas web

Edita biblioteca_project/urls.py:

from django.contrib import admin
from django.urls import path, include
from libros import web_views  # ← AGREGAR

urlpatterns = [
    path('admin/', admin.site.urls),
    
    # URLs de la API
    path('api/', include('libros.api_urls')),
    
    # URLs de páginas web (para pruebas)
    path('', web_views.home, name='home'),
    path('oauth/login/', web_views.oauth_login, name='oauth_login'),
    path('login/jwt/', web_views.jwt_login_page, name='jwt_login_page'),
]

✅ Checkpoint 3.6: Templates creados

Verifica la estructura:

biblioteca_unidad4/
├── templates/
│   ├── home.html
│   └── oauth_login.html
├── libros/
│   ├── web_views.py  (nuevo)
│   ├── oauth_views.py
│   └── ...
└── ...

Verifica que Django encuentre los templates:

python manage.py check

3.7 Crear Página de Login JWT Completa (10 minutos)

Paso 1: Crear archivo templates/jwt_login.html:

📋 ¿Qué vamos a crear?

Una página completa de login tradicional con username/password que:

  • Genera tokens JWT al hacer login
  • Guarda los tokens en localStorage del navegador
  • Muestra el token generado
  • Redirige automáticamente a la API
  • Incluye tabs de Login/Registro
  • CSS y JavaScript inline (todo en un archivo)

COPIAR TODO ESTE CÓDIGO en templates/jwt_login.html:

<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Login JWT - Biblioteca UTH</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
            min-height: 100vh;
            display: flex;
            align-items: center;
            justify-content: center;
            padding: 20px;
        }
        
        .container {
            background: white;
            padding: 40px;
            border-radius: 15px;
            box-shadow: 0 10px 50px rgba(0,0,0,0.2);
            max-width: 500px;
            width: 100%;
        }
        
        h1 {
            color: #333;
            margin-bottom: 30px;
            text-align: center;
        }
        
        .form-group {
            margin-bottom: 20px;
        }
        
        label {
            display: block;
            margin-bottom: 5px;
            color: #555;
            font-weight: bold;
        }
        
        input {
            width: 100%;
            padding: 12px;
            border: 2px solid #ddd;
            border-radius: 8px;
            font-size: 1em;
            transition: all 0.3s ease;
        }
        
        input:focus {
            outline: none;
            border-color: #11998e;
        }
        
        .btn {
            width: 100%;
            padding: 15px;
            background: #11998e;
            color: white;
            border: none;
            border-radius: 8px;
            font-size: 1.1em;
            font-weight: bold;
            cursor: pointer;
            transition: all 0.3s ease;
        }
        
        .btn:hover {
            background: #0e8074;
            transform: translateY(-2px);
        }
        
        .btn:disabled {
            background: #ccc;
            cursor: not-allowed;
            transform: none;
        }
        
        .result {
            margin-top: 20px;
            padding: 15px;
            border-radius: 8px;
            display: none;
        }
        
        .success {
            background: #d4edda;
            border: 1px solid #c3e6cb;
            color: #155724;
        }
        
        .error {
            background: #f8d7da;
            border: 1px solid #f5c6cb;
            color: #721c24;
        }
        
        .token-display {
            background: #2d2d2d;
            color: #4ec9b0;
            padding: 15px;
            border-radius: 8px;
            margin-top: 15px;
            word-wrap: break-word;
            font-family: 'Courier New', monospace;
            font-size: 0.85em;
            max-height: 150px;
            overflow-y: auto;
        }
        
        .link {
            display: inline-block;
            margin-top: 15px;
            color: #11998e;
            text-decoration: none;
            font-weight: bold;
        }
        
        .link:hover {
            text-decoration: underline;
        }

        .hint {
            font-size: 0.85em;
            color: #999;
            margin-top: 5px;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>🔑 Login con JWT</h1>
        
        <form id="loginForm">
            <div class="form-group">
                <label for="username">Usuario:</label>
                <input type="text" id="username" name="username" required placeholder="admin">
                <div class="hint">Usa: admin</div>
            </div>
            
            <div class="form-group">
                <label for="password">Contraseña:</label>
                <input type="password" id="password" name="password" required placeholder="••••••••">
                <div class="hint">Usa: admin123</div>
            </div>
            
            <button type="submit" class="btn" id="submitBtn">Iniciar Sesión</button>
        </form>
        
        <div id="result" class="result"></div>
        
        <a href="/" class="link">← Volver al inicio</a>
    </div>
    
    <script>
        document.getElementById('loginForm').addEventListener('submit', async function(e) {
            e.preventDefault();
            
            const submitBtn = document.getElementById('submitBtn');
            const resultDiv = document.getElementById('result');
            
            submitBtn.disabled = true;
            submitBtn.textContent = 'Iniciando sesión...';
            
            const username = document.getElementById('username').value;
            const password = document.getElementById('password').value;
            
            try {
                const response = await fetch('/api/auth/jwt/login/', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                    },
                    body: JSON.stringify({
                        username: username,
                        password: password
                    })
                });
                
                const data = await response.json();
                
                if (response.ok) {
                    localStorage.setItem('access_token', data.access);
                    localStorage.setItem('refresh_token', data.refresh);
                    
                    resultDiv.className = 'result success';
                    resultDiv.style.display = 'block';
                    resultDiv.innerHTML = `
                        <h3>✅ Login Exitoso</h3>
                        <p>Tokens JWT generados correctamente</p>
                        
                        <div class="token-display">
                            <strong>Access Token:</strong><br>
                            ${data.access}
                        </div>
                        
                        <a href="/api/" class="btn" style="margin-top:15px;text-decoration:none;display:inline-block">
                            📚 Ir a la API
                        </a>
                    `;
                    
                    document.getElementById('loginForm').reset();
                    
                } else {
                    resultDiv.className = 'result error';
                    resultDiv.style.display = 'block';
                    resultDiv.innerHTML = `
                        <h3>❌ Error en el Login</h3>
                        <p>${data.detail || 'Credenciales incorrectas'}</p>
                    `;
                }
            } catch (error) {
                resultDiv.className = 'result error';
                resultDiv.style.display = 'block';
                resultDiv.innerHTML = `
                    <h3>❌ Error de Conexión</h3>
                    <p>${error.message}</p>
                `;
            } finally {
                submitBtn.disabled = false;
                submitBtn.textContent = 'Iniciar Sesión';
            }
        });
    </script>
</body>
</html>

✅ Checkpoint 3.7: Página JWT Login creada

Verifica:

  • ✅ Archivo templates/jwt_login.html creado
  • ✅ Formulario con campos username y password
  • ✅ JavaScript para hacer POST a la API
  • ✅ Guardar tokens en localStorage
  • ✅ CSS inline (no necesita archivos externos)

Prueba rápida:

  1. Arrancar servidor: python manage.py runserver
  2. Ir a: http://127.0.0.1:8000/login/jwt/
  3. Login con: admin / admin123
  4. Deberías ver el token JWT generado

3.8 Probar Todo el Sistema OAuth + JWT (20 minutos)

🧪 Prueba Completa End-to-End

Ahora probaremos TODAS las formas de autenticación implementadas:

  1. Login tradicional con JWT (username/password)
  2. Login con Google OAuth 2.0
  3. Acceso a la API REST con tokens

Paso 1: Verificar que el servidor esté corriendo

python manage.py runserver

Paso 2: Probar página de inicio

  1. Abrir navegador en: http://127.0.0.1:8000/
  2. Deberías ver la página con 3 botones:
    • 🔐 Login con Google
    • 🔑 Login con JWT
    • 📚 Explorar API REST

Paso 3: Probar Login JWT

  1. Click en "🔑 Login con JWT"
  2. O ir directamente a: http://127.0.0.1:8000/login/jwt/
  3. Ingresar credenciales:
    • Usuario: admin
    • Contraseña: admin123
  4. Click "Iniciar Sesión"
  5. ✅ Salida esperada:
    • Mensaje: "Login Exitoso"
    • Token JWT visible (eyJhbGc...)
    • Botón "Ir a la API"

Paso 4: Probar Login con Google

❌ Error común: redirect_uri_mismatch al hacer Login con Google

Si al intentar hacer login con Google recibes un error "Error 400: redirect_uri_mismatch", esto significa que las URIs de redirección no coinciden entre tu código y Google Cloud Console.

Causas principales:

  • Las URIs en Google Cloud Console NO coinciden exactamente con las del código
  • Inconsistencias entre localhost y 127.0.0.1
  • URIs sin el slash final (/)
  • Email de prueba no agregado como Test User en Google Console
🔧 VER SOLUCIÓN COMPLETA AL ERROR OAUTH

Guía paso a paso para resolver el error redirect_uri_mismatch

Solución rápida:

  1. Verificar que oauth_views.py use http://127.0.0.1:8000/api/auth/google/callback/ en ambas funciones
  2. En Google Cloud Console → Credenciales → Agregar URI: http://127.0.0.1:8000/api/auth/google/callback/
  3. Agregar tu email como Test User en Google Console → OAuth consent screen
  4. Esperar 5 minutos y probar en modo incógnito usando http://127.0.0.1:8000/
  1. Volver a la página de inicio: http://127.0.0.1:8000/
  2. Click en "🔐 Login con Google"
  3. O ir a: http://127.0.0.1:8000/oauth/login/
  4. Te redirigirá a Google
  5. Selecciona tu cuenta de Google (debe estar en la lista de test users)
  6. Acepta los permisos
  7. ✅ Salida esperada:
    • Redirige de vuelta a tu app
    • Mensaje: "Login exitoso con Google" o "Cuenta creada exitosamente"
    • Tu información de usuario (nombre, email, foto)
    • Token JWT generado

Paso 5: Probar API con el token

Después de cualquier login (JWT o Google), el token se guarda en localStorage.

Opción A: Usar el navegador (interfaz navegable de DRF)

  1. Click en el botón "📚 Ir a la API" o visita: http://127.0.0.1:8000/api/
  2. Verás la interfaz navegable de Django REST Framework
  3. Explora los endpoints disponibles

Opción B: Usar Postman/Thunder Client

📝 Ejemplo: Obtener lista de libros con el token

1. Copiar el token del localStorage:

Abre DevTools (F12) → Console → Ejecuta:

localStorage.getItem('access_token')

Copia el token que aparece

2. Hacer request en Postman:

  • Method: GET
  • URL: http://127.0.0.1:8000/api/libros/
  • Headers:
    • Key: Authorization
    • Value: Bearer TU_TOKEN_AQUI

✅ Respuesta esperada:

[
    {
        "id": 1,
        "titulo": "Don Quijote de la Mancha",
        "isbn": "978-84-376-0494-7",
        "fecha_publicacion": "1605-01-16",
        "stock": 5,
        "categoria": 1,
        "autores": [1]
    },
    ...
]

Paso 6: Probar CRUD completo

a) Crear un nuevo libro (POST):

POST http://127.0.0.1:8000/api/libros/
Headers: Authorization: Bearer TU_TOKEN
Body (JSON):
{
    "titulo": "Cien Años de Soledad",
    "isbn": "978-03-071-4728-7",
    "fecha_publicacion": "1967-05-30",
    "stock": 3,
    "categoria": 1,
    "autores": [1]
}

b) Actualizar un libro (PUT):

PUT http://127.0.0.1:8000/api/libros/1/
Headers: Authorization: Bearer TU_TOKEN
Body (JSON):
{
    "titulo": "Don Quijote de la Mancha (Edición Actualizada)",
    "stock": 10
}

c) Eliminar un libro (DELETE):

DELETE http://127.0.0.1:8000/api/libros/5/
Headers: Authorization: Bearer TU_TOKEN

🚨 Errores Comunes y Soluciones

Error Solución
401 Unauthorized
  • Token expirado o inválido
  • Verifica que el header sea: Authorization: Bearer TOKEN
  • Renueva el token haciendo login nuevamente
403 Forbidden No tienes permisos para esa acción. Usa una cuenta de admin.
redirect_uri_mismatch (Google)
  • La URL en Google Cloud no coincide
  • Debe ser: http://localhost:8000/oauth/login/
  • Agrega también: http://127.0.0.1:8000/oauth/login/
Página en blanco después de Google login
  • Abre DevTools (F12) y revisa Console
  • Verifica que oauth_views.py esté correcto
  • Revisa que las URLs estén bien configuradas
CSRF verification failed
  • Añade @csrf_exempt en la vista (ya lo tienes)
  • O incluye el CSRF token en el request

✅ Checkpoint 3.8: Sistema completo probado

Verifica que hayas probado:

  • ✅ Página de inicio funcionando
  • ✅ Login JWT con admin/admin123
  • ✅ Login con Google OAuth funcionando
  • ✅ Token guardado en localStorage
  • ✅ API REST accesible con el token
  • ✅ CRUD de libros funcionando
  • ✅ Endpoints protegidos rechazando requests sin token

Si todo funciona: ¡FELICIDADES! 🎉 Tienes un sistema completo de autenticación con JWT + OAuth 2.0

🎉 ¡FELICIDADES! PARTE 2 COMPLETADA AL 100%: OAuth 2.0 + JWT

Has implementado exitosamente:

  • ✅ Proyecto configurado en Google Cloud Console
  • ✅ Django Allauth instalado y configurado
  • ✅ OAuth 2.0 flow completo implementado
  • ✅ Login con Google funcionando
  • ✅ Login tradicional con JWT funcionando
  • ✅ Creación automática de usuarios desde OAuth
  • ✅ Generación de JWT después de OAuth
  • ✅ 3 Templates HTML funcionales (home, oauth_login, jwt_login)
  • ✅ API REST protegida con autenticación
  • ✅ CRUD completo de todas las entidades
  • ✅ Sistema de pruebas completo

💾 GUARDAR PROGRESO: Hacer commit en Git:
git add .
git commit -m "✅ JWT + OAuth 2.0 completos al 100% con templates"

📊 PROGRESO DEL PROYECTO:

✅ PASO 0: Preparación del Entorno (COMPLETO)

✅ PASO 1: Proyecto Django + MySQL (COMPLETO)

✅ PARTE 1: JWT Authentication (COMPLETO)

✅ PARTE 2: OAuth 2.0 + Templates (COMPLETO AL 100%)

⏭️ PARTE 3: WebSockets (Opcional)

⏭️ PARTE 4: GraphQL (Opcional)

🚀 ¡YA TIENES UN PROYECTO !

Con JWT + OAuth 2.0 + Templates

Edita biblioteca_project/settings.py:

INSTALLED_APPS = [
    # ... apps existentes
    'oauth2_provider',
]

# OAuth2 Provider Settings
OAUTH2_PROVIDER = {
    'SCOPES': {
        'read': 'Read scope - Permite leer datos',
        'write': 'Write scope - Permite escribir datos',
        'groups': 'Access to groups - Acceso a grupos de usuario'
    },
    'ACCESS_TOKEN_EXPIRE_SECONDS': 3600,  # 1 hora
    'REFRESH_TOKEN_EXPIRE_SECONDS': 86400,  # 1 día
    'AUTHORIZATION_CODE_EXPIRE_SECONDS': 600,  # 10 minutos
    'ROTATE_REFRESH_TOKEN': True,
}

3 Agregar URLs OAuth

from django.urls import path, include

urlpatterns = [
    # ... URLs existentes
    path('o/', include('oauth2_provider.urls', namespace='oauth2_provider')),
]

4 Ejecutar Migraciones

python manage.py migrate

5 Crear Aplicación OAuth

Desde el admin de Django:

  1. Ir a: http://127.0.0.1:8000/admin/
  2. OAuth2 Provider → Applications → Add Application
  3. Configurar:
    • User: admin (tu usuario)
    • Client type: Confidential
    • Authorization grant type: Resource owner password-based
    • Name: Biblioteca Mobile App
  4. Guardar y copiar Client ID y Client Secret

6 Probar OAuth 2.0

Crea un script de prueba test_oauth.py:

import requests

# Configuración
TOKEN_URL = 'http://127.0.0.1:8000/o/token/'
API_URL = 'http://127.0.0.1:8000/api/libros/'

CLIENT_ID = 'tu_client_id_aqui'
CLIENT_SECRET = 'tu_client_secret_aqui'
USERNAME = 'admin'
PASSWORD = 'tu_password'

print("=== Obteniendo Token OAuth 2.0 ===")

# Obtener token
response = requests.post(TOKEN_URL, data={
    'grant_type': 'password',
    'username': USERNAME,
    'password': PASSWORD,
    'client_id': CLIENT_ID,
    'client_secret': CLIENT_SECRET,
    'scope': 'read write'
})

if response.status_code == 200:
    token_data = response.json()
    access_token = token_data['access_token']
    
    print(f"✅ Token obtenido: {access_token[:50]}...")
    
    # Usar token para acceder a API
    headers = {'Authorization': f'Bearer {access_token}'}
    api_response = requests.get(API_URL, headers=headers)
    
    print(f"Status Code: {api_response.status_code}")
    print(f"Data: {api_response.json()}")
else:
    print(f"❌ Error: {response.status_code}")
    print(response.json())
python test_oauth.py

🔄 PARTE 3: WEBSOCKETS EN TIEMPO REAL

WebSockets

🤔 ¿Qué son WebSockets?

WebSockets proporcionan comunicación bidireccional en tiempo real entre cliente y servidor. A diferencia de HTTP (request-response), WebSockets mantienen una conexión abierta y persistente.

💡 Casos de Uso de WebSockets

1 Instalar Django Channels

Django Channels extiende Django para manejar WebSockets y protocolos asíncronos:

pip install channels==4.0.0
pip install channels-redis==4.1.0
pip install daphne==4.0.0
pip freeze > requirements.txt

2 Configurar Channels

Edita settings.py:

INSTALLED_APPS = [
    'daphne',  # ← AGREGAR AL PRINCIPIO
    'django.contrib.admin',
    # ... resto de apps
    'channels',
]

# ASGI Application
ASGI_APPLICATION = 'biblioteca_project.asgi.application'

# Channel Layers - Opción 1: Con Redis (RECOMENDADO para producción)
CHANNEL_LAYERS = {
    'default': {
        'BACKEND': 'channels_redis.core.RedisChannelLayer',
        'CONFIG': {
            "hosts": [('127.0.0.1', 6379)],
        },
    },
}

# Opción 2: En memoria (SOLO para desarrollo)
# CHANNEL_LAYERS = {
#     'default': {
#         'BACKEND': 'channels.layers.InMemoryChannelLayer'
#     }
# }

⚠️ Nota sobre Redis

Para producción, necesitas Redis instalado. En Windows:

choco install redis

O usa Docker:

docker run -p 6379:6379 redis:alpine

3 Configurar ASGI

Edita biblioteca_project/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', 'biblioteca_project.settings')

django_asgi_app = get_asgi_application()

from libros.routing import websocket_urlpatterns

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

4 Crear WebSocket Consumer

Crea libros/consumers.py:

import json
from channels.generic.websocket import AsyncWebsocketConsumer
from channels.db import database_sync_to_async
from .models import Libro


class NotificacionesConsumer(AsyncWebsocketConsumer):
    """Consumer para notificaciones en tiempo real"""
    
    async def connect(self):
        """Cuando un cliente se conecta"""
        self.room_group_name = 'notificaciones'
        
        # Unirse al grupo
        await self.channel_layer.group_add(
            self.room_group_name,
            self.channel_name
        )
        
        await self.accept()
        
        # Mensaje de bienvenida
        await self.send(text_data=json.dumps({
            'type': 'connection',
            'message': '✅ Conectado a notificaciones en tiempo real'
        }))
    
    async def disconnect(self, close_code):
        """Cuando un cliente se desconecta"""
        await self.channel_layer.group_discard(
            self.room_group_name,
            self.channel_name
        )
    
    async def receive(self, text_data):
        """Recibir mensaje del cliente"""
        data = json.loads(text_data)
        message_type = data.get('type')
        
        if message_type == 'libro_update':
            libro_id = data.get('libro_id')
            await self.notificar_cambio_libro(libro_id)
    
    async def notificar_cambio_libro(self, libro_id):
        """Notificar a todos sobre cambio en libro"""
        libro_data = await self.get_libro_data(libro_id)
        
        # Broadcast a todo el grupo
        await self.channel_layer.group_send(
            self.room_group_name,
            {
                'type': 'libro_actualizado',
                'libro': libro_data
            }
        )
    
    async def libro_actualizado(self, event):
        """Enviar notificación al cliente"""
        await self.send(text_data=json.dumps({
            'type': 'libro_actualizado',
            'libro': event['libro']
        }))
    
    @database_sync_to_async
    def get_libro_data(self, libro_id):
        """Obtener datos del libro (sync to async)"""
        try:
            libro = Libro.objects.get(pk=libro_id)
            return {
                'id': libro.id,
                'titulo': libro.titulo,
                'stock': libro.stock,
                'disponible': libro.esta_disponible
            }
        except Libro.DoesNotExist:
            return None


class ChatConsumer(AsyncWebsocketConsumer):
    """Consumer para chat de biblioteca"""
    
    async def connect(self):
        self.room_name = self.scope['url_route']['kwargs']['room_name']
        self.room_group_name = f'chat_{self.room_name}'
        
        await self.channel_layer.group_add(
            self.room_group_name,
            self.channel_name
        )
        
        await self.accept()
        
        # Notificar que alguien se conectó
        await self.channel_layer.group_send(
            self.room_group_name,
            {
                'type': 'user_join',
                'message': f'Un usuario se unió al chat'
            }
        )
    
    async def disconnect(self, close_code):
        await self.channel_layer.group_discard(
            self.room_group_name,
            self.channel_name
        )
    
    async def receive(self, text_data):
        data = json.loads(text_data)
        message = data['message']
        username = data.get('username', 'Anónimo')
        
        # Enviar mensaje a todos en la sala
        await self.channel_layer.group_send(
            self.room_group_name,
            {
                'type': 'chat_message',
                'message': message,
                'username': username
            }
        )
    
    async def chat_message(self, event):
        """Recibir mensaje del grupo y enviarlo al WebSocket"""
        await self.send(text_data=json.dumps({
            'type': 'message',
            'message': event['message'],
            'username': event['username']
        }))
    
    async def user_join(self, event):
        """Usuario se unió"""
        await self.send(text_data=json.dumps({
            'type': 'system',
            'message': event['message']
        }))

5 Crear Routing para WebSockets

Crea libros/routing.py:

from django.urls import re_path
from . import consumers

websocket_urlpatterns = [
    re_path(r'ws/notificaciones/$', consumers.NotificacionesConsumer.as_asgi()),
    re_path(r'ws/chat/(?P\w+)/$', consumers.ChatConsumer.as_asgi()),
]

6 Ejecutar con Daphne

daphne -p 8000 biblioteca_project.asgi:application

O con el comando tradicional (ahora usa Daphne automáticamente):

python manage.py runserver

7 Cliente JavaScript para WebSocket

En tu HTML/JavaScript:

<script>
// Conectar al WebSocket
const chatSocket = new WebSocket(
    'ws://' + window.location.host + '/ws/chat/general/'
);

chatSocket.onopen = function(e) {
    console.log('✅ WebSocket conectado');
};

chatSocket.onmessage = function(e) {
    const data = JSON.parse(e.data);
    console.log('Mensaje recibido:', data);
    
    // Mostrar mensaje en el chat
    if (data.type === 'message') {
        addMessage(data.username, data.message);
    }
};

chatSocket.onclose = function(e) {
    console.error('❌ WebSocket cerrado');
};

chatSocket.onerror = function(e) {
    console.error('Error en WebSocket:', e);
};

// Enviar mensaje
function sendMessage(message) {
    chatSocket.send(JSON.stringify({
        'message': message,
        'username': 'Tu Nombre'
    }));
}

function addMessage(username, message) {
    const messagesDiv = document.getElementById('chat-messages');
    messagesDiv.innerHTML += `<p><strong>${username}:</strong> ${message}</p>`;
}
</script>

📊 PARTE 4: GRAPHQL

GraphQL

🤔 ¿Qué es GraphQL?

GraphQL es un lenguaje de consulta y manipulación de datos para APIs. Desarrollado por Facebook, permite a los clientes solicitar exactamente los datos que necesitan.

💡 Ventajas de GraphQL vs REST

AspectoRESTGraphQL
EndpointsMúltiples endpointsUn solo endpoint
Over-fetchingRecibe datos no necesariosSolo los datos solicitados
Under-fetchingMúltiples requestsUna sola request
VersionadoRequiere versiones (/v1/, /v2/)Sin versiones necesarias
DocumentaciónManual (Swagger)Auto-documentado (Introspection)
FlexibilidadEstructura fijaCliente decide estructura

1 Instalar Graphene

pip install graphene-django==3.1.5
pip freeze > requirements.txt

2 Configurar GraphQL

En settings.py:

INSTALLED_APPS = [
    # ... apps existentes
    'graphene_django',
]

# GraphQL Settings
GRAPHENE = {
    'SCHEMA': 'libros.schema.schema',
    'MIDDLEWARE': [
        'graphene_django.debug.DjangoDebugMiddleware',
    ],
}

3 Crear Schema GraphQL

Crea libros/schema.py:

import graphene
from graphene_django import DjangoObjectType
from .models import Libro, Autor, Categoria


# ===== TYPES (Tipos de Datos) =====

class AutorType(DjangoObjectType):
    class Meta:
        model = Autor
        fields = '__all__'


class CategoriaType(DjangoObjectType):
    class Meta:
        model = Categoria
        fields = '__all__'


class LibroType(DjangoObjectType):
    class Meta:
        model = Libro
        fields = '__all__'
    
    esta_disponible = graphene.Boolean()
    
    def resolve_esta_disponible(self, info):
        return self.esta_disponible


# ===== QUERIES (Consultas) =====

class Query(graphene.ObjectType):
    # Queries simples
    all_libros = graphene.List(LibroType)
    all_autores = graphene.List(AutorType)
    all_categorias = graphene.List(CategoriaType)
    
    # Queries con argumentos
    libro = graphene.Field(
        LibroType,
        id=graphene.Int(),
        isbn=graphene.String()
    )
    
    libros_por_autor = graphene.List(
        LibroType,
        autor_id=graphene.Int(required=True)
    )
    
    libros_disponibles = graphene.List(LibroType)
    
    buscar_libros = graphene.List(
        LibroType,
        titulo=graphene.String(required=True)
    )
    
    # Resolvers
    def resolve_all_libros(self, info):
        return Libro.objects.filter(activo=True)
    
    def resolve_all_autores(self, info):
        return Autor.objects.all()
    
    def resolve_all_categorias(self, info):
        return Categoria.objects.all()
    
    def resolve_libro(self, info, id=None, isbn=None):
        if id:
            return Libro.objects.get(pk=id)
        if isbn:
            return Libro.objects.get(isbn=isbn)
        return None
    
    def resolve_libros_por_autor(self, info, autor_id):
        return Libro.objects.filter(
            autor_id=autor_id,
            activo=True
        )
    
    def resolve_libros_disponibles(self, info):
        return Libro.objects.filter(
            estado=Libro.DISPONIBLE,
            stock__gt=0,
            activo=True
        )
    
    def resolve_buscar_libros(self, info, titulo):
        return Libro.objects.filter(
            titulo__icontains=titulo,
            activo=True
        )


# ===== MUTATIONS (Modificaciones) =====

class ActualizarStockLibro(graphene.Mutation):
    class Arguments:
        libro_id = graphene.Int(required=True)
        cantidad = graphene.Int(required=True)
    
    libro = graphene.Field(LibroType)
    mensaje = graphene.String()
    
    def mutate(self, info, libro_id, cantidad):
        libro = Libro.objects.get(pk=libro_id)
        libro.actualizar_stock(cantidad)
        
        return ActualizarStockLibro(
            libro=libro,
            mensaje=f"Stock actualizado a {libro.stock}"
        )


class CrearAutor(graphene.Mutation):
    class Arguments:
        nombre = graphene.String(required=True)
        fecha_nacimiento = graphene.Date(required=True)
        pais_origen = graphene.String(required=True)
        biografia = graphene.String()
    
    autor = graphene.Field(AutorType)
    
    def mutate(self, info, nombre, fecha_nacimiento, pais_origen, biografia=None):
        autor = Autor.objects.create(
            nombre=nombre,
            fecha_nacimiento=fecha_nacimiento,
            pais_origen=pais_origen,
            biografia=biografia
        )
        return CrearAutor(autor=autor)


class Mutation(graphene.ObjectType):
    actualizar_stock_libro = ActualizarStockLibro.Field()
    crear_autor = CrearAutor.Field()


# ===== SCHEMA =====

schema = graphene.Schema(query=Query, mutation=Mutation)

4 Configurar URL GraphQL

En urls.py:

from graphene_django.views import GraphQLView
from django.views.decorators.csrf import csrf_exempt

urlpatterns = [
    # ... URLs existentes
    path('graphql/', csrf_exempt(GraphQLView.as_view(graphiql=True))),
]

5 Probar GraphQL

Abre http://127.0.0.1:8000/graphql/ en tu navegador.

Ejemplo Query 1: Obtener todos los libros con autor

query {
  allLibros {
    id
    titulo
    isbn
    precio
    stock
    estaDisponible
    autor {
      nombre
      paisOrigen
    }
  }
}

Ejemplo Query 2: Buscar libros por título

query {
  buscarLibros(titulo: "Cien") {
    id
    titulo
    autor {
      nombre
    }
    precio
  }
}

Ejemplo Mutation: Actualizar stock

mutation {
  actualizarStockLibro(libroId: 1, cantidad: 5) {
    libro {
      id
      titulo
      stock
    }
    mensaje
  }
}

✅ Ventajas de GraphQL en Acción

Con GraphQL, el cliente decide exactamente qué datos necesita. ¡Una sola query puede reemplazar múltiples endpoints REST!

🛡️ PARTE 5: SEGURIDAD EN APIS

Security

🔒 Seguridad es Fundamental

La seguridad no es opcional en aplicaciones web modernas. Debes proteger tu API contra ataques comunes y asegurar los datos de tus usuarios.

⚠️ Amenazas Comunes

1 HTTPS en Producción

Configuración segura para producción en settings.py:

# Solo para PRODUCCIÓN (no desarrollo)
if not DEBUG:
    # Forzar HTTPS
    SECURE_SSL_REDIRECT = True
    
    # Cookies seguras
    SESSION_COOKIE_SECURE = True
    CSRF_COOKIE_SECURE = True
    
    # Headers de seguridad
    SECURE_BROWSER_XSS_FILTER = True
    SECURE_CONTENT_TYPE_NOSNIFF = True
    X_FRAME_OPTIONS = 'DENY'
    
    # HSTS (HTTP Strict Transport Security)
    SECURE_HSTS_SECONDS = 31536000  # 1 año
    SECURE_HSTS_INCLUDE_SUBDOMAINS = True
    SECURE_HSTS_PRELOAD = True
    
    # Proxy SSL headers
    SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')

2 Validación de Entrada

Crea libros/validators.py:

from django.core.exceptions import ValidationError
import re


def validar_isbn(value):
    """Validar formato ISBN-10 o ISBN-13"""
    isbn = re.sub(r'[\s-]', '', value)
    
    if len(isbn) not in [10, 13]:
        raise ValidationError(
            'ISBN debe tener 10 o 13 dígitos'
        )
    
    if not isbn.isdigit():
        raise ValidationError(
            'ISBN debe contener solo dígitos'
        )
    
    return isbn


def prevenir_sql_injection(value):
    """Prevenir SQL Injection básico"""
    patrones_peligrosos = [
        r'\bDROP\b', r'\bDELETE\b', r'\bUPDATE\b',
        r'\bINSERT\b', r'\bSELECT\b', r';--', 
        r"'OR'1'='1", r'\bUNION\b'
    ]
    
    for patron in patrones_peligrosos:
        if re.search(patron, value, re.IGNORECASE):
            raise ValidationError(
                'Entrada contiene caracteres no permitidos'
            )
    
    return value


def sanitizar_html(value):
    """Eliminar tags HTML peligrosos"""
    # Remover scripts
    value = re.sub(r']*>.*?', '', value, flags=re.DOTALL | re.IGNORECASE)
    
    # Remover eventos JavaScript
    value = re.sub(r'on\w+\s*=\s*["\'].*?["\']', '', value, flags=re.IGNORECASE)
    
    # Remover tags HTML básicos (opcional)
    value = re.sub(r'<[^>]+>', '', value)
    
    return value.strip()


def validar_password_fuerte(value):
    """Validar contraseña fuerte"""
    if len(value) < 8:
        raise ValidationError('La contraseña debe tener al menos 8 caracteres')
    
    if not re.search(r'[A-Z]', value):
        raise ValidationError('Debe contener al menos una mayúscula')
    
    if not re.search(r'[a-z]', value):
        raise ValidationError('Debe contener al menos una minúscula')
    
    if not re.search(r'\d', value):
        raise ValidationError('Debe contener al menos un número')
    
    if not re.search(r'[!@#$%^&*(),.?":{}|<>]', value):
        raise ValidationError('Debe contener al menos un carácter especial')
    
    return value

3 Middleware de Seguridad

Crea libros/middleware.py:

from django.core.cache import cache
from django.http import JsonResponse, HttpResponsePermanentRedirect
from django.conf import settings
import logging

logger = logging.getLogger(__name__)


class SecurityMiddleware:
    """Middleware de seguridad personalizado"""
    
    def __init__(self, get_response):
        self.get_response = get_response
    
    def __call__(self, request):
        # Verificar headers de seguridad
        if not request.is_secure() and not request.META.get('HTTP_X_FORWARDED_PROTO') == 'https':
            if hasattr(settings, 'SECURE_SSL_REDIRECT') and settings.SECURE_SSL_REDIRECT:
                return HttpResponsePermanentRedirect(
                    request.build_absolute_uri().replace('http://', 'https://')
                )
        
        response = self.get_response(request)
        
        # Agregar headers de seguridad
        response['X-Content-Type-Options'] = 'nosniff'
        response['X-Frame-Options'] = 'DENY'
        response['X-XSS-Protection'] = '1; mode=block'
        
        return response


class RateLimitMiddleware:
    """Limitar requests por IP"""
    
    def __init__(self, get_response):
        self.get_response = get_response
        self.limit = 100  # requests
        self.period = 3600  # 1 hora en segundos
    
    def __call__(self, request):
        ip = self.get_client_ip(request)
        
        # Solo para rutas /api/
        if request.path.startswith('/api/'):
            cache_key = f'rate_limit_{ip}'
            requests_count = cache.get(cache_key, 0)
            
            if requests_count >= self.limit:
                logger.warning(f'Rate limit exceeded for IP: {ip}')
                return JsonResponse({
                    'error': 'Rate limit exceeded',
                    'detail': f'Máximo {self.limit} requests por hora'
                }, status=429)
            
            # Incrementar contador
            cache.set(cache_key, requests_count + 1, self.period)
        
        response = self.get_response(request)
        return response
    
    def get_client_ip(self, request):
        """Obtener IP real del cliente"""
        x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
        if x_forwarded_for:
            ip = x_forwarded_for.split(',')[0].strip()
        else:
            ip = request.META.get('REMOTE_ADDR')
        return ip

Agregar a settings.py:

MIDDLEWARE = [
    # ... middlewares existentes
    'libros.middleware.SecurityMiddleware',
    'libros.middleware.RateLimitMiddleware',
]

🔐 Checklist de Seguridad

⚡ PARTE 6: RATE LIMITING Y THROTTLING

🚦 Control de Tráfico de API

Rate Limiting y Throttling limitan la cantidad de requests que un cliente puede hacer en un período de tiempo. Protege contra abuso y asegura disponibilidad.

1 Throttling en DRF

Crea libros/throttles.py:

from rest_framework.throttling import UserRateThrottle, AnonRateThrottle


class BurstRateThrottle(UserRateThrottle):
    """Límite para ráfagas cortas"""
    scope = 'burst'


class SustainedRateThrottle(UserRateThrottle):
    """Límite sostenido"""
    scope = 'sustained'


class AnonBurstRateThrottle(AnonRateThrottle):
    """Límite para usuarios anónimos"""
    scope = 'anon_burst'


class PremiumUserThrottle(UserRateThrottle):
    """Límite más alto para usuarios premium"""
    scope = 'premium'
    
    def allow_request(self, request, view):
        # Usuarios premium tienen límite más alto
        if request.user.is_authenticated and hasattr(request.user, 'is_premium'):
            if request.user.is_premium:
                return True
        
        return super().allow_request(request, view)

2 Configurar en Settings

REST_FRAMEWORK = {
    # ... configuración existente
    
    'DEFAULT_THROTTLE_CLASSES': [
        'libros.throttles.BurstRateThrottle',
        'libros.throttles.SustainedRateThrottle',
    ],
    
    'DEFAULT_THROTTLE_RATES': {
        'burst': '60/min',        # 60 por minuto
        'sustained': '1000/day',  # 1000 por día
        'anon_burst': '20/min',   # Anónimos: 20 por minuto
        'premium': '10000/day',   # Premium: 10000 por día
    }
}

3 Aplicar a Vistas Específicas

from rest_framework.decorators import api_view, throttle_classes
from .throttles import BurstRateThrottle

@api_view(['GET'])
@throttle_classes([BurstRateThrottle])
def api_intensiva(request):
    # Esta ruta tiene throttling especial
    return Response({'data': 'información'})

🌐 PARTE 7: CORS Y CSRF

🔄 CORS (Cross-Origin Resource Sharing)

Configuración ya realizada en Unidad 2. Recordatorio:

# En settings.py

# Orígenes permitidos para CORS
CORS_ALLOWED_ORIGINS = [
    "http://localhost:3000",
    "http://127.0.0.1:3000",
    "https://tudominio.com",
    "https://www.tudominio.com",
]

# Permitir credenciales
CORS_ALLOW_CREDENTIALS = True

# Headers permitidos
CORS_ALLOW_HEADERS = [
    'accept',
    'accept-encoding',
    'authorization',
    'content-type',
    'dnt',
    'origin',
    'user-agent',
    'x-csrftoken',
    'x-requested-with',
]

🛡️ CSRF (Cross-Site Request Forgery)

# Orígenes confiables para CSRF
CSRF_TRUSTED_ORIGINS = [
    "https://tudominio.com",
    "https://www.tudominio.com",
]

# Cookie CSRF segura en producción
if not DEBUG:
    CSRF_COOKIE_SECURE = True
    CSRF_COOKIE_HTTPONLY = True
    CSRF_COOKIE_SAMESITE = 'Strict'

🔗 PARTE 8: INTEGRACIÓN CON SERVICIOS EXTERNOS

1 Integración con Google Books API

Crea libros/external_services.py:

import requests
from decimal import Decimal
import logging

logger = logging.getLogger(__name__)


class GoogleBooksAPI:
    """Cliente para Google Books API"""
    
    BASE_URL = 'https://www.googleapis.com/books/v1/volumes'
    TIMEOUT = 10  # segundos
    
    @classmethod
    def buscar_libro(cls, isbn):
        """Buscar libro por ISBN"""
        url = f"{cls.BASE_URL}?q=isbn:{isbn}"
        
        try:
            response = requests.get(url, timeout=cls.TIMEOUT)
            response.raise_for_status()
            
            data = response.json()
            
            if data.get('totalItems', 0) > 0:
                return cls._parsear_libro(data['items'][0])
            
            logger.info(f'Libro con ISBN {isbn} no encontrado en Google Books')
            return None
            
        except requests.Timeout:
            logger.error('Timeout al consultar Google Books API')
            return None
        except requests.RequestException as e:
            logger.error(f'Error al consultar Google Books: {e}')
            return None
    
    @classmethod
    def _parsear_libro(cls, item):
        """Parsear respuesta de Google Books"""
        volume_info = item.get('volumeInfo', {})
        
        return {
            'titulo': volume_info.get('title'),
            'subtitulo': volume_info.get('subtitle', ''),
            'autores': volume_info.get('authors', []),
            'editorial': volume_info.get('publisher'),
            'fecha_publicacion': volume_info.get('publishedDate'),
            'descripcion': volume_info.get('description'),
            'paginas': volume_info.get('pageCount'),
            'categorias': volume_info.get('categories', []),
            'imagen_portada': volume_info.get('imageLinks', {}).get('thumbnail'),
            'idioma': volume_info.get('language'),
            'isbn_10': cls._extraer_isbn(volume_info, 'ISBN_10'),
            'isbn_13': cls._extraer_isbn(volume_info, 'ISBN_13'),
        }
    
    @classmethod
    def _extraer_isbn(cls, volume_info, tipo):
        """Extraer ISBN específico"""
        identifiers = volume_info.get('industryIdentifiers', [])
        for identifier in identifiers:
            if identifier.get('type') == tipo:
                return identifier.get('identifier')
        return None

2 Endpoint para Importar Libro

Agrega a api_views.py:

from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAdminUser
from rest_framework.response import Response
from .external_services import GoogleBooksAPI

@api_view(['POST'])
@permission_classes([IsAdminUser])
def importar_desde_google_books(request):
    """Importar libro desde Google Books por ISBN"""
    isbn = request.data.get('isbn')
    
    if not isbn:
        return Response({
            'error': 'ISBN es requerido'
        }, status=400)
    
    # Buscar en Google Books
    data = GoogleBooksAPI.buscar_libro(isbn)
    
    if not data:
        return Response({
            'error': 'Libro no encontrado en Google Books'
        }, status=404)
    
    # Aquí puedes crear el libro automáticamente
    # o devolver los datos para que el usuario los complete
    
    return Response({
        'mensaje': 'Libro encontrado',
        'data': data
    }, status=200)

📊 RÚBRICA DE EVALUACIÓN UNIDAD 4

CriterioDescripción
1. JWT Implementation
Configuración correctaSettings y middleware bien configurados
Endpoints funcionandoLogin, refresh y verify operativos
PersonalizaciónClaims custom y response personalizado
2. OAuth 2.0
ConfiguraciónOAuth toolkit correctamente configurado
Flujo completoObtención y uso de tokens funciona
3. WebSockets
Channels configuradoASGI y routing correctos
Consumers implementadosChat o notificaciones funcionando
Frontend funcionalCliente JavaScript conectando
4. GraphQL
Schema definidoTypes, queries y mutations bien estructurados
Queries funcionandoAl menos 5 queries diferentes
Mutations implementadasAl menos 2 mutations funcionales
5. Seguridad
HTTPS y headersConfiguración segura en producción
Validación de entradaValidators implementados
Rate limitingThrottling configurado y funcionando
CORS configuradoCORS y CSRF correctamente configurados
6. Integraciones
API externa integradaGoogle Books u otra API funcionando
Funcionalidad completaImport o feature usando la API
7. Proyecto Final
Código completoTodas las unidades integradas
DesplegadoURL funcional en producción

RUBRICA TOTAL

🎯 CONCLUSIÓN DEL CURSO

¡Felicidades! Has completado las 4 unidades del curso completo

📚 Unidad 1: Django + MySQL + GitHub + Deploy ✅

🔌 Unidad 2: API REST + DRF + Autenticación ✅

🐳 Unidad 3: SOAP + Microservicios + Docker ✅

🔐 Unidad 4: Seguridad + WebSockets + GraphQL + OAuth ✅

🚀 Tecnologías Dominadas

Django REST API SOAP GraphQL JWT OAuth 2.0 WebSockets Docker MySQL

🎓 ¡Eres ahora un desarrollador de servicios web completo!

Estás listo para construir aplicaciones web robustas, seguras y escalables

🚀 DESPLIEGUE EN PRODUCCIÓN - PYTHONANYWHERE

🌐 ¿Por qué PythonAnywhere?

PythonAnywhere es una plataforma de hosting especializada en Python que te permite desplegar aplicaciones Django de forma gratuita y sencilla. Perfecto para proyectos académicos y portafolio profesional.

Ventajas:

⚠️ LIMITACIONES DE LA CUENTA GRATUITA

💡 Solución: Desplegaremos las partes core (JWT, OAuth, REST API, GraphQL) y documentaremos que WebSockets funciona en local.

1 Crear Cuenta en PythonAnywhere (5 minutos)

  1. Ve a: https://www.pythonanywhere.com/
  2. Click en "Start running Python online in less than a minute!"
  3. Click en "Create a Beginner account" (gratis)
  4. Completa el formulario:
    • Username: tu nombre o nickname (será parte de la URL: https://tuusername.pythonanywhere.com)
    • Email: tu correo (recibirás confirmación)
    • Password: contraseña segura
  5. Acepta términos y crea cuenta
  6. Verifica tu email (revisa spam si no llega)

✅ Cuenta creada exitosamente

Tu URL será: https://TUUSERNAME.pythonanywhere.com

Ejemplo: https://bibliotecauth.pythonanywhere.com

2 Preparar Proyecto para Despliegue (15 minutos)

Paso 2.1: Crear archivo de dependencias

En tu proyecto local, crea requirements.txt con todas las librerías:

pip freeze > requirements.txt

O créalo manualmente con estas dependencias mínimas:

Django==5.1.5
djangorestframework==3.15.2
djangorestframework-simplejwt==5.4.0
django-allauth==65.3.0
django-cors-headers==4.6.0
PyJWT==2.10.1
requests==2.32.3
mysqlclient==2.2.6
gunicorn==23.0.0
whitenoise==6.8.2

Paso 2.2: Crear archivo de configuración de producción

Crea biblioteca_project/settings_production.py:

from .settings import *

# Configuración de producción
DEBUG = False

ALLOWED_HOSTS = [
    'tuusername.pythonanywhere.com',  # Reemplaza con tu username
    'localhost',
    '127.0.0.1',
]

# Base de datos MySQL de PythonAnywhere
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.mysql',
        'NAME': 'tuusername$biblioteca',  # Reemplaza con tu username
        'USER': 'tuusername',  # Tu username de PythonAnywhere
        'PASSWORD': 'tu_password_mysql',  # La crearás en PythonAnywhere
        'HOST': 'tuusername.mysql.pythonanywhere-services.com',
        'PORT': '3306',
        'OPTIONS': {
            'init_command': "SET sql_mode='STRICT_TRANS_TABLES'",
        },
    }
}

# Archivos estáticos (CSS, JavaScript, Images)
STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')

# Middleware para servir archivos estáticos
MIDDLEWARE.insert(1, 'whitenoise.middleware.WhiteNoiseMiddleware')

# Configuración de seguridad
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_BROWSER_XSS_FILTER = True

# CORS (ajustar según necesites)
CORS_ALLOWED_ORIGINS = [
    'https://tuusername.pythonanywhere.com',
]

# OAuth redirect URIs para producción
# Actualiza oauth_views.py para usar esta variable
OAUTH_REDIRECT_URI = 'https://tuusername.pythonanywhere.com/api/auth/google/callback/'

Paso 2.3: Modificar oauth_views.py para usar configuración dinámica

Edita libros/oauth_views.py para detectar entorno:

from django.conf import settings

# En google_oauth_callback (línea ~45)
# Reemplaza la redirect_uri hardcodeada por:
redirect_uri = getattr(settings, 'OAUTH_REDIRECT_URI', 'http://127.0.0.1:8000/api/auth/google/callback/')

token_data = {
    'code': code,
    'client_id': google_config['client_id'],
    'client_secret': google_config['secret'],
    'redirect_uri': redirect_uri,  # ← Usar variable dinámica
    'grant_type': 'authorization_code'
}

# En google_oauth_redirect (línea ~160)
# Hacer lo mismo:
redirect_uri = getattr(settings, 'OAUTH_REDIRECT_URI', 'http://127.0.0.1:8000/api/auth/google/callback/')

auth_url = (
    'https://accounts.google.com/o/oauth2/v2/auth'
    f'?client_id={google_config["client_id"]}'
    f'&redirect_uri={redirect_uri}'  # ← Usar variable dinámica
    f'&scope={" ".join(scopes)}'
    '&response_type=code'
    '&access_type=offline'
    '&prompt=consent'
)

3 Subir Código a GitHub (10 minutos)

🔗 ¿Por qué GitHub?

PythonAnywhere permite clonar proyectos directamente desde GitHub, lo que facilita el despliegue y actualizaciones.

Paso 3.1: Crear archivo .gitignore

Antes de subir, crea .gitignore en la raíz del proyecto:

# Python
*.pyc
__pycache__/
*.py[cod]
*$py.class
venv/
env/
ENV/

# Django
*.log
db.sqlite3
db.sqlite3-journal
staticfiles/
media/

# Secrets
.env
secrets.json

# IDEs
.vscode/
.idea/
*.swp
*.swo

Paso 3.2: Inicializar repositorio Git

cd biblioteca_project git init git add . git commit -m "Initial commit - Biblioteca API con JWT y OAuth"

Paso 3.3: Crear repositorio en GitHub

  1. Ve a github.com y login
  2. Click en "New repository" (botón verde)
  3. Nombre: biblioteca-django-api
  4. Descripción: "API REST con Django, JWT, OAuth 2.0 y GraphQL"
  5. Público o Privado (recomendado: Público para portafolio)
  6. NO marques "Initialize with README" (ya tienes código local)
  7. Click "Create repository"

Paso 3.4: Subir código a GitHub

git remote add origin https://github.com/TU_USUARIO/biblioteca-django-api.git git branch -M main git push -u origin main

✅ Código subido a GitHub

Tu repositorio está en: https://github.com/TU_USUARIO/biblioteca-django-api

4 Configurar Base de Datos MySQL en PythonAnywhere (10 minutos)

  1. Login en PythonAnywhere
  2. En el Dashboard, click en "Databases" (menú superior)
  3. En "MySQL", verás:
    • MySQL password: Campo para establecer contraseña
  4. Establece una contraseña segura y guárdala (la necesitarás en settings)
  5. Click en "Initialize MySQL"
  6. Una vez inicializado, verás:
    • MySQL hostname: TUUSERNAME.mysql.pythonanywhere-services.com
  7. En "Create a new database", ingresa: biblioteca
  8. Click "Create"
  9. Verás la base de datos creada: TUUSERNAME$biblioteca

📝 Anota estos datos (los usarás después):

  • Host: TUUSERNAME.mysql.pythonanywhere-services.com
  • Database: TUUSERNAME$biblioteca
  • User: TUUSERNAME
  • Password: La que acabas de crear

5 Clonar Proyecto en PythonAnywhere (15 minutos)

Paso 5.1: Abrir consola Bash

  1. En PythonAnywhere Dashboard, click en "Consoles"
  2. En "Start a new console", click en "Bash"
  3. Se abrirá una terminal en el navegador

Paso 5.2: Clonar repositorio de GitHub

# Clonar tu repositorio
git clone https://github.com/TU_USUARIO/biblioteca-django-api.git

# Entrar al directorio
cd biblioteca-django-api

# Ver archivos
ls -la

Paso 5.3: Crear entorno virtual

# Crear virtualenv con Python 3.10
mkvirtualenv --python=/usr/bin/python3.10 biblioteca-env

# Activar entorno (se activa automáticamente al crear)
workon biblioteca-env

# Instalar dependencias
pip install -r requirements.txt

⏱️ La instalación puede tardar 5-10 minutos

Especialmente mysqlclient puede tardar. Si hay error, intenta:

pip install --upgrade pip
pip install mysqlclient

Paso 5.4: Configurar settings para producción

# Editar settings_production.py
nano biblioteca_project/settings_production.py

# Actualiza con tus datos reales:
# - ALLOWED_HOSTS con tu username.pythonanywhere.com
# - DATABASES con tus credenciales MySQL
# - OAUTH_REDIRECT_URI con tu dominio

# Guardar: Ctrl+O, Enter, Ctrl+X

Paso 5.5: Migrar base de datos

# Configurar variable de entorno para usar settings de producción
export DJANGO_SETTINGS_MODULE=biblioteca_project.settings_production

# Hacer migraciones
python manage.py makemigrations
python manage.py migrate

# Crear superusuario
python manage.py createsuperuser

# Recolectar archivos estáticos
python manage.py collectstatic --noinput

6 Configurar Aplicación Web en PythonAnywhere (15 minutos)

Paso 6.1: Crear Web App

  1. En Dashboard, click en "Web" (menú superior)
  2. Click en "Add a new web app"
  3. Click "Next" (acepta el dominio gratis)
  4. Selecciona "Manual configuration"
  5. Selecciona "Python 3.10"
  6. Click "Next"

Paso 6.2: Configurar rutas del proyecto

En la página de configuración web, actualiza estas secciones:

📁 Code

🐍 Virtualenv

⚙️ WSGI Configuration File

Click en el link del archivo WSGI (algo como /var/www/TUUSERNAME_pythonanywhere_com_wsgi.py)

Se abrirá un editor. BORRA TODO y pega este código:

import os
import sys

# Agregar proyecto al path
path = '/home/TUUSERNAME/biblioteca-django-api'  # Reemplaza TUUSERNAME
if path not in sys.path:
    sys.path.insert(0, path)

# Configurar Django settings
os.environ['DJANGO_SETTINGS_MODULE'] = 'biblioteca_project.settings_production'

# Importar aplicación Django
from django.core.wsgi import get_wsgi_application
application = get_wsgi_application()

Click "Save" (botón verde arriba a la derecha)

📂 Static files

Scroll hasta la sección "Static files" y agrega:

URL Directory
/static/ /home/TUUSERNAME/biblioteca-django-api/staticfiles

Paso 6.3: Recargar aplicación

  1. Scroll hasta arriba de la página
  2. Click en el botón verde grande "Reload TUUSERNAME.pythonanywhere.com"
  3. Espera 10-15 segundos

7 Configurar OAuth con Dominio de Producción (10 minutos)

Paso 7.1: Agregar URI de producción en Google Console

  1. Ve a Google Cloud Console
  2. Selecciona tu proyecto OAuth
  3. Ve a APIs y servicios → Credenciales
  4. Click en tu "ID de cliente de OAuth 2.0"
  5. En "URIs de redireccionamiento autorizados", agrega:
https://TUUSERNAME.pythonanywhere.com/api/auth/google/callback/

Click "GUARDAR" y espera 5 minutos para que se propague

Paso 7.2: Actualizar Site en Django Admin

  1. Ve a: https://TUUSERNAME.pythonanywhere.com/admin/
  2. Login con tu superusuario
  3. Click en "Sites"
  4. Click en el site existente
  5. Actualiza:
    • Domain name: TUUSERNAME.pythonanywhere.com
    • Display name: Biblioteca UTH
  6. Click "Save"

8 Probar Aplicación en Producción (10 minutos) Probar con Postman/Thunder Client

✅ URLs para probar:

Endpoint URL Método
Página de inicio https://TUUSERNAME.pythonanywhere.com/ GET
Admin Django https://TUUSERNAME.pythonanywhere.com/admin/ GET
API REST https://TUUSERNAME.pythonanywhere.com/api/ GET
Login JWT https://TUUSERNAME.pythonanywhere.com/api/auth/jwt/login/ POST
OAuth Google https://TUUSERNAME.pythonanywhere.com/oauth/login/ GET
GraphQL https://TUUSERNAME.pythonanywhere.com/graphql/ GET/POST
Libros API https://TUUSERNAME.pythonanywhere.com/api/libros/ GET

🧪 Pruebas recomendadas:

  1. Admin: Verifica que puedas acceder y gestionar datos
  2. Login JWT: Obtén un token y úsalo en Postman
  3. OAuth Google: Haz login con tu cuenta de Google (debe estar en test users)
  4. API REST: Consulta endpoints con autenticación JWT
  5. GraphQL: Ejecuta queries en el playground

9 Troubleshooting Común (Referencia)

❌ Error: Internal Server Error (500)

Causa: Error en configuración o código

Solución:

  1. Ve a Dashboard → Web → Sección "Log files"
  2. Click en "Error log"
  3. Lee el error específico (última línea)
  4. Errores comunes:
    • ModuleNotFoundError: Falta instalar una librería → pip install LIBRERIA
    • ImproperlyConfigured: Error en settings.py
    • OperationalError: Credenciales MySQL incorrectas

❌ Error: Disallowed Host

Causa: ALLOWED_HOSTS no incluye tu dominio

Solución:

nano biblioteca_project/settings_production.py

# Verifica que ALLOWED_HOSTS tenga:
ALLOWED_HOSTS = ['tuusername.pythonanywhere.com']

# Guarda y recarga la web app

❌ Error: Static files no cargan (CSS/JS)

Causa: No ejecutaste collectstatic o ruta incorrecta

Solución:

workon biblioteca-env
cd biblioteca-django-api
python manage.py collectstatic --noinput

# Verifica que se creó la carpeta:
ls -la staticfiles/

# En Web tab, verifica la ruta en "Static files":
# URL: /static/
# Directory: /home/TUUSERNAME/biblioteca-django-api/staticfiles

❌ OAuth no funciona: redirect_uri_mismatch

Causa: URI no agregada en Google Console

Solución:

  1. Verifica que en Google Console tengas: https://tuusername.pythonanywhere.com/api/auth/google/callback/
  2. Espera 5-10 minutos después de agregar
  3. Usa modo incógnito para probar

10 Actualizar Código (para futuras modificaciones)

Cuando hagas cambios en tu proyecto local y quieras actualizarlo en producción:

# 1. En tu PC (local):
git add .
git commit -m "Descripción de cambios"
git push origin main

# 2. En PythonAnywhere (consola Bash):
cd biblioteca-django-api
git pull origin main

# 3. Si hay cambios en modelos:
workon biblioteca-env
python manage.py makemigrations
python manage.py migrate

# 4. Si hay archivos estáticos nuevos:
python manage.py collectstatic --noinput

# 5. Recargar web app:
# Ve a Web tab → Click "Reload TUUSERNAME.pythonanywhere.com"

🎉 ¡APLICACIÓN DESPLEGADA EXITOSAMENTE!

Tu API REST con autenticación JWT y OAuth 2.0 está ahora en producción y accesible públicamente.

✅ Lo que funciona en producción:

📝 Limitaciones de cuenta gratuita:

🔗 Comparte tu proyecto:

💼 Úsalo en tu CV y portfolio:

Este proyecto demuestra habilidades profesionales en desarrollo backend, APIs REST, autenticación moderna, seguridad y despliegue en producción.

📚 Recursos Adicionales

📝 RESUMEN DE CORRECCIONES IMPLEMENTADAS EN ESTA GUÍA

🔧 Cambios Realizados para Corregir Errores

1️⃣ ERROR: ModuleNotFoundError: No module named 'oauth2_provider'

📍 Ubicación del problema: Al ejecutar python manage.py migrate después de configurar OAuth 2.0

❌ Causa: Faltaba instalar la librería django-oauth-toolkit que proporciona el módulo oauth2_provider

✅ Solución implementada:

  • Se agregó la instalación de django-oauth-toolkit==2.3.0 en la sección Paso 3.2
  • Se incluyó 'oauth2_provider' en INSTALLED_APPS del settings.py
  • Se agregó una caja de advertencia destacada en color amarillo explicando la importancia de esta librería

📄 Ubicación en la guía: Sección "PARTE 2: OAUTH 2.0" → Paso 3.2

2️⃣ ERROR: Código 401 Unauthorized al ejecutar test_oauth.py

📍 Ubicación del problema: Después de crear el script test_oauth.py (Paso 6: Probar OAuth 2.0)

❌ Causa: La configuración REST_FRAMEWORK no incluía OAuth2Authentication, por lo que DRF no reconocía los tokens OAuth emitidos por django-oauth-toolkit

✅ Solución implementada:

  • Se agregó la línea 'oauth2_provider.contrib.rest_framework.OAuth2Authentication' en DEFAULT_AUTHENTICATION_CLASSES dentro de REST_FRAMEWORK
  • Se agregó una nota explicativa sobre por qué es necesaria esta línea
  • Se creó la sección Paso 3.3.2 específicamente para documentar este error y su solución

📄 Ubicación en la guía:

  • Configuración: Sección "PARTE 1: JWT" → Paso 2.3 (REST_FRAMEWORK)
  • Explicación del error: Sección "PARTE 2: OAUTH 2.0" → Paso 3.3.2

3️⃣ MEJORAS ADICIONALES IMPLEMENTADAS

✨ Se agregaron las siguientes secciones para mayor claridad:

  1. Configuración OAuth 2.0 Provider Settings:
    • Se agregó la sección OAUTH2_PROVIDER en settings.py
    • Configuración de tiempos de expiración de tokens OAuth
    • Definición de scopes disponibles
  2. Configuración de URLs de OAuth:
    • Nueva sección Paso 3.3.1 para configurar las URLs de django-oauth-toolkit
    • Inclusión de path('o/', include('oauth2_provider.urls')) en urls.py
    • Explicación de la diferencia entre /accounts/ (allauth) y /o/ (oauth2_provider)
  3. Diferenciación entre librerías:
    • Caja informativa explicando la diferencia entre django-allauth y django-oauth-toolkit
    • Casos de uso de cada una

📚 Resumen de Librerías Necesarias para OAuth 2.0

Para que OAuth 2.0 funcione completamente en tu proyecto, necesitas instalar:

pip install django-allauth==0.57.0
pip install django-oauth-toolkit==2.3.0

¿Qué hace cada una?

  • django-allauth: Permite login social (Login con Google/Facebook) - Tu app CONSUME OAuth de otros
  • django-oauth-toolkit: Permite que tu app SEA un proveedor OAuth - Otras apps pueden usar tus tokens

✅ Checklist: Verifica que todo esté configurado

Antes de probar OAuth 2.0, asegúrate de tener:

Configuración Estado
django-allauth instalado
django-oauth-toolkit instalado
'oauth2_provider' en INSTALLED_APPS
OAuth2Authentication en REST_FRAMEWORK
OAUTH2_PROVIDER configurado en settings.py
URLs de oauth2_provider agregadas (path('o/', ...))
Migraciones ejecutadas sin errores
Credenciales de Google configuradas en settings.py

🎯 Con estas correcciones, la guía ahora está completa y funcional

Última actualización: Febrero 2026 | Profesor: Bernardo Prado

🔧 CORRECCIONES ADICIONALES

⚠️ ¿PERSISTEN ERRORES EN TU SISTEMA?

Si después de seguir toda la guía aún experimentas problemas, estas correcciones adicionales pueden resolver situaciones específicas que varían según tu entorno de desarrollo o errores comunes que se presentan durante la implementación.

🛠️

VERSIÓN 3.0

Correcciones para Desarrollo Local

✅ Configuración de Base de Datos MySQL

✅ Configuración OAuth URLs en Google Console

✅ Corrección Script oauth_login.html

✅ Implementación WebSockets Completa

✅ Configuración API de Google Books

👉 CLICK PARA VER CORRECCIONES →

🚀

VERSIÓN 4.0

Correcciones para PythonAnywhere

✅ Solución Incompatibilidad autobahn

✅ Error al instalar txaio

✅ Error en migraciones (importar os)

✅ Página no carga datos (whitenoise)

✅ Configuración settings_production.py

👉 CLICK PARA VER CORRECCIONES →

🐛

VERSIÓN 5.0

Errores Comunes Reportados

✅ Error: Invalid model reference OAuth

✅ Error: Unknown command show_urls

✅ Error: redirect_uri mismatch + loop

✅ Error: Token OAuth retorna 401

✅ Experiencia real de estudiantes

👉 CLICK PARA VER SOLUCIONES →

📚 ¿Cuál guía debo usar?

Situación Guía Recomendada
⚙️ Estás desarrollando en tu computadora local (localhost:8000) VERSIÓN 3.0
🚀 Ya vas a desplegar o desplegaste en PythonAnywhere VERSIÓN 4.0
🐛 Te aparecieron errores específicos siguiendo la guía paso a paso VERSIÓN 5.0
🔐 Problemas con OAuth o login de Google VERSIÓN 3.0 o 5.0
💬 WebSockets no funcionan correctamente VERSIÓN 3.0
🐛 Error "Something went wrong" en producción VERSIÓN 4.0
📦 Problemas instalando dependencias en PythonAnywhere VERSIÓN 4.0
❌ Error: "Invalid model reference" en OAuth VERSIÓN 5.0
❌ Error: "Unknown command: show_urls" VERSIÓN 5.0
❌ Token OAuth retorna 401 en tests VERSIÓN 5.0

💡 Consejos Importantes