← Volver al Índice Principal

📹 Plataformas Online - API de Zoom (Cuenta Gratuita)

OAuth 2.0 User-Level - Compatible con Zoom Basic

Universidad Tecnológica de Hermosillo - UTH 2026-1

¡Práctica 100% GRATUITA con Zoom Basic!

Esta guía usa OAuth 2.0 User-Level - Compatible con cuentas Zoom Basic (gratuitas)

✅ Sin costo ($0) | ✅ Acceso completo a la API | ✅ Perfecto para estudiantes

📝 Requiere: Solo una cuenta Zoom gratuita (Basic) - No necesitas plan de pago

0. 🎯 Introducción - ¿Qué vamos a lograr?

🏆 Objetivo del Proyecto:

Crear un Sistema de Gestión de Reuniones Virtuales con Zoom API y Django que permita:

  • Crear reuniones automáticamente: Desde tu aplicación Django sin entrar a Zoom
  • Gestionar agendamiento: Programar clases, citas médicas, entrevistas
  • Obtener enlaces de reunión: Compartir URLs únicas con participantes
  • Listar reuniones programadas: Ver todas las reuniones futuras y pasadas
  • Actualizar configuraciones: Cambiar horario, contraseña, permisos
  • Eliminar reuniones: Cancelar reuniones programadas
  • Webhooks en tiempo real: Recibir notificaciones cuando inicia/termina reunión
  • Panel de administración: Gestionar todo desde interfaz Django

🎬 ¿Cómo Funciona el Sistema Completo?

Flujo de Uso Final:

  1. El estudiante crea cuenta Zoom Marketplace (GRATIS):
    • Registra cuenta en marketplace.zoom.us (sin costo)
    • Crea app OAuth (tipo "User-managed")
    • Obtiene credenciales: Client ID + Client Secret (solo 2)
    • ✅ NO requiere Account ID (eso es para cuentas de pago)
  2. Usuario autoriza la aplicación:
    • Primera vez: hace clic en "Autorizar con Zoom"
    • Zoom pide permiso para gestionar reuniones
    • Usuario acepta (solo se hace una vez)
    • Django recibe Access Token + Refresh Token
  3. Django gestiona tokens automáticamente:
    • Access Token válido por 1 hora (se guarda en caché)
    • Refresh Token válido por 90 días
    • Renovación automática cuando expira
    • Usuario NO necesita volver a autorizar
  4. Usuario solicita crear reunión:
    • Llena formulario: tema, fecha/hora, duración
    • Envía petición POST a Django
  5. Django llama a Zoom API:
    • POST https://api.zoom.us/v2/users/me/meetings
    • Envía JSON con configuración de reunión
  6. Zoom responde con datos de reunión:
    • ID de reunión: 85123456789
    • URL de anfitrión: https://zoom.us/s/85123456789?zak=...
    • URL de participante: https://zoom.us/j/85123456789
    • Contraseña de reunión (si está habilitada)
  7. Django guarda reunión en BD MySQL:
    • Tabla: reuniones
    • Campos: zoom_meeting_id, tema, start_time, join_url, password
  8. Usuario recibe enlace de reunión:
    • Email automático con URL y contraseña
    • Notificación en la app
    • Puede copiar enlace para compartir
  9. Webhooks notifican eventos:
    • Reunión iniciada → Actualizar estado en BD
    • Participante se unió → Registrar asistencia
    • Reunión terminada → Generar reporte de duración

⚙️ ¿Qué Hace Cada Tecnología?

Componente Función Específica en el Sistema ¿Por qué lo usamos?
Zoom API Servicio de videollamadas en la nube Líder del mercado, estable, fácil integración, 40 min gratis
OAuth 2.0 User-Level (Cuenta Gratuita) Autenticación automática sin usuario Método oficial desde 2023, reemplaza JWT deprecado
Django Framework backend que gestiona lógica de negocio Python, ORM, admin panel, fácil integración con APIs REST
Requests (Python) Librería HTTP para llamar a Zoom API Simple, legible, manejo de errores, sesiones HTTP
MySQL Almacenar reuniones, usuarios, asistencias Relacional, ACID, integridad de datos críticos
Celery (opcional) Tareas asíncronas (envío de emails, recordatorios) No bloquea servidor, procesa en background
Webhooks Notificaciones push de Zoom a Django Eventos en tiempo real sin polling, ahorra peticiones API

📊 Arquitectura del Sistema Completo

┌─────────────────────────────────────────────────────────────────────┐
│   NAVEGADOR (Frontend)                                              │
│   - HTML/CSS/JavaScript                                             │
│   - Formulario de creación de reunión                               │
└──────────────────────┬──────────────────────────────────────────────┘
                       │
                       │ HTTP POST
                       │ /api/reuniones/crear/
                       │ {"tema": "Clase de Django", "fecha": "...", ...}
                       │
                       ▼
┌─────────────────────────────────────────────────────────────────────┐
│   SERVIDOR DJANGO (Backend)                                         │
│                                                                      │
│  ┌────────────────┐     ┌────────────────┐     ┌────────────────┐ │
│  │  views.py      │     │ services/      │     │ models.py      │ │
│  │                │     │ zoom_service   │     │                │ │
│  │ Recibe request │────▶│                │     │ ORM (MySQL)    │ │
│  │                │     │ Llama Zoom API │     │                │ │
│  └────────────────┘     └────────┬───────┘     └────────────────┘ │
│                                  │                                  │
│                                  │ 1. Obtener Access Token          │
│                                  │ POST /oauth/token                │
│                                  │                                  │
│                                  │ 2. Crear Reunión                 │
│                                  │ POST /v2/users/me/meetings       │
│                                  │                                  │
└──────────────────────────────────┼──────────────────────────────────┘
                                   │
                                   │ HTTPS + JSON
                                   │ Authorization: Bearer {token}
                                   │
                                   ▼
┌─────────────────────────────────────────────────────────────────────┐
│   ZOOM API (api.zoom.us)                                            │
│                                                                      │
│  ┌──────────────────────────────────────────────────────────────┐  │
│  │ OAuth Server                                                  │  │
│  │ POST /oauth/token                                             │  │
│  │ → Retorna: {"access_token": "eyJ...", "expires_in": 3600}    │  │
│  └──────────────────────────────────────────────────────────────┘  │
│                                                                      │
│  ┌──────────────────────────────────────────────────────────────┐  │
│  │ Meetings API                                                  │  │
│  │ POST /v2/users/me/meetings                                    │  │
│  │ → Crea reunión y retorna:                                     │  │
│  │   {                                                            │  │
│  │     "id": 85123456789,                                         │  │
│  │     "join_url": "https://zoom.us/j/85123456789",              │  │
│  │     "start_url": "https://zoom.us/s/85123456789?zak=...",     │  │
│  │     "password": "abc123"                                       │  │
│  │   }                                                            │  │
│  └──────────────────────────────────────────────────────────────┘  │
│                                                                      │
│  ┌──────────────────────────────────────────────────────────────┐  │
│  │ Webhooks                                                       │  │
│  │ Cuando inicia reunión → POST a tu endpoint                     │  │
│  │ https://tuapp.com/webhooks/zoom/                               │  │
│  └──────────────────────────────────────────────────────────────┘  │
└──────────────────────────────────┬───────────────────────────────────┘
                                   │
                                   │ Respuesta JSON
                                   │
                                   ▼
┌─────────────────────────────────────────────────────────────────────┐
│   DJANGO → MySQL                                                    │
│   Guarda: Reunion(zoom_meeting_id=85123456789, ...)                │
│                                                                      │
│   DJANGO → Usuario                                                  │
│   Email: "Tu reunión está lista: https://zoom.us/j/85123456789"    │
└─────────────────────────────────────────────────────────────────────┘

FLUJO DE WEBHOOKS (Tiempo Real):
1. Usuario inicia reunión en Zoom
2. Zoom envía: POST https://tuapp.com/webhooks/zoom/
   {
     "event": "meeting.started",
     "payload": {
       "object": {
         "id": "85123456789",
         "host_id": "...",
         "start_time": "2026-01-16T10:00:00Z"
       }
     }
   }
3. Django procesa webhook → Actualiza estado en BD
4. Envía notificación a participantes vía email/SMS
                    

⏱️ Tiempo Estimado Total: 2-3 horas

Fase Tiempo Qué harás
PASO 1: Cuenta Zoom 15 min Crear cuenta Marketplace, configurar app OAuth
PASO 2: Proyecto Django 10 min Crear proyecto, instalar requests, configurar settings
PASO 3: Servicio Zoom 30 min Crear ZoomService, autenticación, crear reunión
PASO 4: Modelos Django 20 min Modelo Reunion, migraciones, admin
PASO 5: API REST 25 min ViewSets, serializers, URLs
PASO 6: Webhooks 30 min Endpoint webhook, verificación, procesamiento
PASO 7: Frontend 20 min Formulario HTML, JavaScript para crear reunión
PASO 8: Pruebas 15 min Crear reunión, obtener enlace, probar webhook

🔐 Tipos de Autenticación en Zoom (Comparación)

Método Estado Uso Complejidad
JWT ❌ Deprecado (Jun 2023) OAuth User-Level (legacy) Baja
OAuth 2.0 User-Level (Cuenta Gratuita) ✅ Recomendado Automatización backend (esta guía) Media
OAuth 2.0 (User-managed) ✅ Activo Apps públicas (usuarios autorizan) Alta

¿Cuál usar?

  • OAuth 2.0 User-Level (Cuenta Gratuita) (esta guía): Para apps internas que crean reuniones automáticamente
  • OAuth 2.0 User-managed: Para apps públicas donde usuarios autorizan acceso a SU cuenta Zoom

1. 📌 Introducción a Zoom API

🎯 Objetivo de Aprendizaje:

Aprender a integrar la API de Zoom en aplicaciones Django para crear, gestionar y automatizar reuniones virtuales, permitiendo a los usuarios programar videoconferencias directamente desde tu aplicación web.

¿Qué es Zoom API?

Zoom API es una interfaz de programación que permite a desarrolladores integrar las funcionalidades de Zoom (videollamadas, webinars, grabaciones, etc.) en sus propias aplicaciones.

Casos de Uso Prácticos

  • Educación: Plataforma LMS que crea automáticamente reuniones para clases
  • Telemedicina: Sistema de citas médicas con consultas virtuales
  • Recursos Humanos: Portal de entrevistas que agenda reuniones con candidatos
  • Atención al Cliente: Soporte técnico con videollamadas programadas
  • Eventos Virtuales: Gestión de webinars y conferencias online

Tipos de Autenticación en Zoom

Tipo Uso Principal Complejidad Recomendado Para
JWT (Deprecated 2023) OAuth User-Level Baja Proyectos legacy
OAuth 2.0 User-Level (Cuenta Gratuita) Automatización Media Backend automation ⭐
OAuth 2.0 User authorization Alta Aplicaciones públicas

✅ Método Recomendado para Estudiantes:

En esta guía usaremos OAuth 2.0 User-Level (también llamado "OAuth App" o "User-managed OAuth") que funciona con cuentas Zoom Basic (gratuitas) y permite acceso completo a la API sin necesidad de plan de pago.

2. 🧠 Saber - Conceptos Fundamentales (Detallado)

¿Cómo Funciona OAuth 2.0 User-Level?

A diferencia del JWT (deprecado) y Server-to-Server OAuth (requiere pago), OAuth 2.0 User-Level sigue el estándar OAuth 2.0 "Authorization Code Flow" y funciona con cuentas gratuitas.

🔐 Flujo de Autenticación Paso a Paso:

  1. Configuración inicial (1 sola vez):
    • Vas a marketplace.zoom.us
    • Creas app tipo "OAuth" (User-managed)
    • Obtienes: Client ID + Client Secret (solo 2 credenciales)
    • Configuras scopes (permisos): meeting:write:meeting, meeting:read:meeting
    • ✅ NO necesitas Account ID (eso es para Server-to-Server que requiere pago)
  2. Usuario autoriza la app (primera vez):
    Flujo de Autorización
    # 1. Django genera URL de autorización
    authorization_url = (
        f"https://zoom.us/oauth/authorize"
        f"?response_type=code"
        f"&client_id={CLIENT_ID}"
        f"&redirect_uri={REDIRECT_URI}"
    )
    
    # 2. Usuario hace clic y va a Zoom
    # 3. Zoom pregunta: "¿Autorizar a la app gestionar tus reuniones?"
    # 4. Usuario acepta
    # 5. Zoom redirige a: http://localhost:8000/zoom/callback/?code=ABC123
  3. Django intercambia código por tokens:
    Python - Obtener Token
    import requests
    import base64
    
    # Credenciales de tu app
    client_id = "tu_client_id"
    CLIENT_ID = "tu_client_id"
    CLIENT_SECRET = "tu_client_secret"
    
    # Codificar credenciales en Base64
    credentials = f"{CLIENT_ID}:{CLIENT_SECRET}"
    encoded_credentials = base64.b64encode(credentials.encode()).decode()
    
    # Solicitar token
    response = requests.post(
        f"https://zoom.us/oauth/token?grant_type=account_credentials&client_id={client_id}",
        headers={
            "Authorization": f"Basic {encoded_credentials}",
            "Content-Type": "application/x-www-form-urlencoded"
        }
    )
    
    token_data = response.json()
    # Respuesta:
    # {
    #   "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    #   "token_type": "bearer",
    #   "expires_in": 3600,  # 1 hora
    #   "scope": "meeting:write meeting:read"
    # }
    
    access_token = token_data["access_token"]
  4. Usar Token en Peticiones:
    Python - Crear Reunión con Token
    # Usar token en header Authorization
    headers = {
        "Authorization": f"Bearer {access_token}",
        "Content-Type": "application/json"
    }
    
    # Crear reunión
    meeting_data = {
        "topic": "Clase de Python",
        "type": 2,
        "start_time": "2026-01-20T10:00:00Z",
        "duration": 60
    }
    
    response = requests.post(
        "https://api.zoom.us/v2/users/me/meetings",
        headers=headers,
        json=meeting_data
    )
    
    meeting = response.json()
    print(f"URL de reunión: {meeting['join_url']}")
  5. Renovar Token Automáticamente:
    • El token expira en 1 hora (expires_in: 3600)
    • Tu servicio debe detectar expiración (error 401)
    • Solicitar nuevo token repitiendo paso 2
    • Opción: Renovar proactivamente a los 50 minutos

Componentes de Zoom API (Explicados)

Client ID:

Identificador único de tu cuenta Zoom Marketplace (formato: abc123XYZ). Se obtiene en la configuración de tu app OAuth 2.0 User-Level (Cuenta Gratuita). Identifica a qué organización pertenece tu app.

Client ID:

Identificador público de tu aplicación OAuth (formato: A1B2C3D4E5F6G7H8). Es seguro compartirlo en código frontend. Se usa junto con Client Secret para autenticación.

Client Secret:

Clave secreta para autenticar tu aplicación (formato: aBcDeFgHiJkLmNoPqRsTuVwXyZ123456). ¡NUNCA compartir públicamente! Debe estar en variables de entorno, no en código. Es como la contraseña de tu app.

Access Token:

Token JWT temporal (válido 1 hora) que se usa para hacer peticiones autenticadas a la API. Formato: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.... Se incluye en header: Authorization: Bearer {token}. Cuando expira, obtienes error 401 y debes solicitar uno nuevo.

Scopes (Permisos):

Definen qué puede hacer tu app. Ejemplos:

  • meeting:write - Crear y modificar reuniones
  • meeting:read - Leer información de reuniones
  • user:read - Leer información de usuarios
  • webinar:write - Crear webinars
  • recording:read - Acceder a grabaciones

Endpoints Principales de Zoom API (Guía Completa)

Endpoint Método Descripción Ejemplo de Uso
/oauth/token POST Obtener Access Token Autenticación inicial
/users GET Listar usuarios de la cuenta Ver quiénes pueden hospedar
/users/me GET Datos del usuario actual Obtener info del host
/users/{userId}/meetings POST Crear reunión para un usuario Programar clase
/users/{userId}/meetings GET Listar reuniones del usuario Ver agenda del profesor
/meetings/{meetingId} GET Obtener detalles de reunión Ver URL y contraseña
/meetings/{meetingId} PATCH Actualizar reunión Cambiar horario
/meetings/{meetingId} DELETE Eliminar reunión Cancelar clase
/users/{userId}/webinars POST Crear webinar Conferencia pública
/meetings/{meetingId}/recordings GET Obtener grabaciones Descargar clase grabada

Estructura de una Reunión

JSON - Estructura de Meeting
{
    "topic": "Clase de Servicios Web",  // Título de la reunión
    "type": 2,  // 1=Instant, 2=Scheduled, 3=Recurring (no fixed time), 8=Recurring (fixed time)
    "start_time": "2026-01-20T10:00:00Z",  // Fecha y hora en formato ISO 8601 (UTC)
    "duration": 60,  // Duración en minutos
    "timezone": "America/Hermosillo",  // Zona horaria de Honduras
    "password": "abc123",  // Contraseña opcional para seguridad
    "agenda": "Discusión sobre APIs REST",  // Descripción de la reunión
    "settings": {  // Configuraciones adicionales
        "host_video": true,  // Host con video encendido al iniciar
        "participant_video": true,  // Participantes con video encendido
        "join_before_host": false,  // Permitir unirse antes que el host
        "mute_upon_entry": true,  // Silenciar al entrar
        "waiting_room": true,  // Activar sala de espera
        "audio": "both"  // "both", "telephony", "voip"
    }
}

3. 🏗️ Arquitectura del Sistema

Flujo de Autenticación OAuth 2.0 User-Level (Cuenta Gratuita)

  1. Aplicación Django solicita Access Token a Zoom
  2. Envía Client ID + Client ID + Client Secret
  3. Zoom OAuth Server valida credenciales
  4. Retorna Access Token (válido 1 hora)
  5. Django usa el token para hacer peticiones API
  6. Al expirar, se solicita nuevo token automáticamente

Diagrama de Componentes

Arquitectura
┌─────────────────┐
│   Usuario Web   │
└────────┬────────┘
         │ 1. Solicita crear reunión
         ▼
┌─────────────────┐
│  Django Views   │ ← Interfaz de usuario
└────────┬────────┘
         │ 2. Llama servicio
         ▼
┌─────────────────┐
│  ZoomService    │ ← Lógica de negocio
└────────┬────────┘
         │ 3. Obtiene token
         ▼
┌─────────────────┐
│  Zoom OAuth     │ ← Autenticación
└────────┬────────┘
         │ 4. Retorna token
         ▼
┌─────────────────┐
│   Zoom API      │ ← Crear meeting
└────────┬────────┘
         │ 5. Retorna detalles
         ▼
┌─────────────────┐
│  MySQL DB       │ ← Guardar reunión
└─────────────────┘

4. 💻 Saber Hacer - Implementación con Django

Paso 1: Crear Aplicación OAuth en Zoom Marketplace (100% GRATIS)

✅ Requisitos:

  • ✅ Cuenta Zoom Basic (gratuita) - Crear cuenta aquí
  • ✅ Acceso a internet
  • ❌ NO necesitas plan de pago

📝 Guía Completa Paso a Paso (Actualizada 2026):

⚠️ IMPORTANTE: Todos los pasos están con los textos EXACTOS en inglés que verás en Zoom Marketplace.
No traduzcas, busca exactamente lo que dice aquí.

  1. 📌 PASO 1: Ir a Zoom Marketplace
    • 🌐 Abre: https://marketplace.zoom.us/
    • 🔐 Haz clic en "Sign In" (esquina superior derecha)
    • ✅ Inicia sesión con tu cuenta Zoom (la misma que usas para reuniones)
    • 📧 Si no tienes cuenta: "Sign Up, It's Free"
  2. 📌 PASO 2: Crear Nueva App
    • 📋 En la esquina superior derecha, haz clic en "Develop"
    • 📂 Se abrirá un menú desplegable
    • ➕ Haz clic en "Build App"
    • 🎯 Te llevará a una página que dice: "Create an App"
    🖼️ EXACTAMENTE lo que verás (actualizado Enero 2026):
    ┌──────────────────────────────────────────────────────────────────┐
    │              What kind of app are you creating?                  │
    ├──────────────────────────────────────────────────────────────────┤
    │                                                                  │
    │  ┌────────────────────────────────────────────────────────────┐ │
    │  │  📱 General App                          [Create]          │ │  ← ✅ SELECCIONA ESTE
    │  │                                                            │ │
    │  │  Build a general app to integrate Zoom APIs and           │ │
    │  │  products using OAuth 2.0 to access authorized user       │ │
    │  │  data and/or connect with third-party services through    │ │
    │  │  the Zoom platform via direct API interactions.           │ │
    │  └────────────────────────────────────────────────────────────┘ │
    │                                                                  │
    │  ┌────────────────────────────────────────────────────────────┐ │
    │  │  ⚙️ Server to Server OAuth App          [Create]         │ │  ← ❌ NO ESTE
    │  │                                                            │ │     (requiere
    │  │  Build an app that provides server-to-server              │ │      plan pago)
    │  │  interaction with Zoom APIs.                              │ │
    │  └────────────────────────────────────────────────────────────┘ │
    │                                                                  │
    │  ┌────────────────────────────────────────────────────────────┐ │
    │  │  🔔 Webhook Only App                     [Create]         │ │  ← ❌ NO ESTE
    │  │                                                            │ │     (solo para
    │  │  Build an app that can receive event-based                │ │      webhooks)
    │  │  notifications for Zoom account events.                   │ │
    │  └────────────────────────────────────────────────────────────┘ │
    │                                                                  │
    └──────────────────────────────────────────────────────────────────┘
    • Haz clic en el botón "Create" dentro de la tarjeta "General App"
    • Esta opción incluye OAuth 2.0 para cuenta gratuita
    • NO selecciones "Server to Server OAuth App" (requiere plan de pago)
    • NO selecciones "Webhook Only App" (solo recibe notificaciones)
    📝 IMPORTANTE:
    Zoom cambió la interfaz en Enero 2026. Ahora "General App" reemplaza la antigua opción "OAuth".
    General App usa OAuth 2.0 internamente, así que es la opción correcta para cuentas gratuitas.
  3. 📌 PASO 3: Información Básica de la App
    📝 Formulario que verás (en inglés):
    • App name (campo de texto):
      • Escribe: Sistema Reuniones UTH
      • O cualquier nombre que quieras (máximo 50 caracteres)
    • Choose app type (selector desplegable):
      • Abre el dropdown
      • ✅ Selecciona: "User-managed app"
      • ❌ NO selecciones "Account-level app"
    • Would you like to publish this app on Zoom App Marketplace? (radio buttons):
      • 🔘 Selecciona: "No, this app is only for use in my account"
      • ⚠️ NO selecciones "Yes" (no necesitas publicarla)
    • Botón al final: Haz clic en "Create" (botón azul)
  4. 📌 PASO 4: Información de la Aplicación (App Information)
    Te redirigirá a una página con pestañas. Estarás en "App Information"
    • Short description (campo de texto):
      • Escribe: Sistema de gestión de reuniones para estudiantes UTH
    • Long description (campo de texto largo):
      • Escribe: Aplicación educativa para gestionar reuniones de Zoom mediante Django
    • Developer Contact Information (sección):
      • Developer name: Tu nombre completo
      • Developer email: tu_email@example.com
    • Botón al final: Haz clic en "Continue" (botón azul)
  5. 📌 PASO 5: Features (Características)
    ✅ En esta página NO necesitas cambiar nada
    • Verás checkboxes de características disponibles
    • ✅ Puedes dejarlas todas sin marcar (opcional)
    • Botón al final: Haz clic en "Continue"
  6. 📌 PASO 6: Scopes (Permisos) - MUY IMPORTANTE ⚠️
    ⚠️ CRÍTICO: Aquí defines qué puede hacer tu app
    • Verás una página que dice: "Add Scopes"
    • Hay un buscador que dice: "Search scopes"
    • Busca y agrega estos 3 scopes:
    • 1. Crear reuniones:
      🔍 Escribe en el buscador: meeting:write
      ✅ Marca el checkbox: "meeting:write:meeting"
      📝 Descripción que verás: "View and manage all user meetings"
    • 2. Leer reuniones:
      🔍 Escribe en el buscador: meeting:read
      ✅ Marca el checkbox: "meeting:read:meeting"
      📝 Descripción que verás: "View all user meetings"
    • 3. Leer información del usuario:
      🔍 Escribe en el buscador: user:read
      ✅ Marca el checkbox: "user:read:user"
      📝 Descripción que verás: "View all user information"
    • Verifica que los 3 scopes estén en la lista "Added scopes"
    • Botón al final: Haz clic en "Continue"
  7. 📌 PASO 7: Redirect URL for OAuth (CRÍTICO) 🔐
    ⚠️ ESTE ES EL PASO MÁS IMPORTANTE - Copia exactamente como se muestra
    • Verás una sección: "OAuth Redirect URL"
    • Debajo dice: "Redirect URL for OAuth"
    • Agregar Redirect URL:
      📝 En el campo de texto escribe EXACTAMENTE:
      http://localhost:8000/zoom/oauth/callback/
      ➕ Haz clic en el botón "+ Add"
      ✅ Debe aparecer en la lista debajo
    • OAuth allow list (opcional pero recomendado):
      📝 En el campo de texto escribe:
      http://localhost:8000
      ➕ Haz clic en "+ Add"
    • ⚠️ MUY IMPORTANTE:
      • NO olvides hacer clic en "+ Add" después de pegar cada URL
      • La URL DEBE terminar con / (diagonal final)
      • Usa http:// NO https:// para localhost
    • Botón al final: Haz clic en "Continue"
  8. 📌 PASO 8: Obtener Credenciales 🔑
    ✅ ¡Ya casi terminas! Ahora obtén tus credenciales
    • 📑 En el menú izquierdo, haz clic en la pestaña: "App Credentials"
    • Verás una página con dos valores importantes:
    • 🔑 Client ID:
      📋 Lo verás algo como: A1B2C3D4E5F6G7H8I9J0
      📝 Haz clic en el botón "Copy" que está al lado
      💾 Pégalo en un archivo de texto seguro
    • 🔐 Client Secret:
      📋 Lo verás algo como: abc123DEF456ghi789JKL012mno345
      🔓 Haz clic en "View" para revelarlo
      📝 Haz clic en "Copy"
      💾 Pégalo en el mismo archivo de texto seguro
    • 🔒 SEGURIDAD CRÍTICA:
      • ❌ NUNCA compartas tu Client Secret con nadie
      • ❌ NO lo subas a GitHub público
      • ❌ NO lo pegues en Discord, WhatsApp, etc.
      • ✅ Guárdalo en un archivo .env (lo veremos después)
      • ✅ Agrégalo a .gitignore
  9. 📌 PASO 9: Activar la App ✅
    ¡Último paso!
    • Busca en la parte superior un toggle/switch que dice:
    • "Activation" o "Development / Production"
      🔄 Cambia el estado a: "Activated" o mueve el switch a ON
      ✅ Debe volverse verde/azul cuando esté activado
    • ¡LISTO! Tu app OAuth está configurada y lista para usar

⚠️ RESUMEN - VERIFICACIÓN FINAL:

Antes de continuar, verifica que tengas:

  • ✅ Tipo de app: "General App" (NO "Server to Server OAuth App")
  • ✅ App type: "User-managed app"
  • ✅ Scopes agregados:
    • meeting:write:meeting
    • meeting:read:meeting
    • user:read:user
  • ✅ Redirect URL: http://localhost:8000/zoom/oauth/callback/
  • ✅ Client ID guardado (20 caracteres aprox.)
  • ✅ Client Secret guardado (32 caracteres aprox.)
  • ✅ App activada (status: "Activated")

✅ Si tienes todo esto, ¡estás listo para continuar con Django!

Paso 2: Configurar Django

PowerShell - Instalación
# Activar entorno virtual
.\venv\Scripts\Activate  # Activa el entorno virtual de Python

# Instalar requests (para peticiones HTTP)
pip install requests==2.31.0  # Biblioteca para hacer peticiones HTTP a APIs externas

# Instalar python-decouple (variables de entorno)
pip install python-decouple==3.8  # Maneja variables de entorno de forma segura

# Actualizar requirements
pip freeze > requirements.txt  # Guarda todas las dependencias instaladas
.env (crear en raíz del proyecto)
# ========================================
# Credenciales Zoom OAuth User-Level
# Compatible con cuentas Zoom Basic (GRATIS)
# ========================================

# Credenciales de tu app OAuth (de Zoom Marketplace)
ZOOM_CLIENT_ID=tu_client_id_aqui  # Client ID (20 caracteres)
ZOOM_CLIENT_SECRET=tu_client_secret_aqui  # Client Secret (¡NO compartir!)

# URLs de Zoom API
ZOOM_OAUTH_AUTHORIZE_URL=https://zoom.us/oauth/authorize  # URL de autorización
ZOOM_OAUTH_TOKEN_URL=https://zoom.us/oauth/token  # URL para obtener token
ZOOM_API_BASE_URL=https://api.zoom.us/v2  # URL base de la API

# URL de redirección (debe coincidir con Marketplace)
ZOOM_REDIRECT_URI=http://localhost:8000/zoom/oauth/callback/  # Callback OAuth

# ⚠️ IMPORTANTE: NO subas este archivo a GitHub
# Agrega .env al .gitignore
reuniones_project/settings.py
from decouple import config  # Importa función para leer .env

# ========================================
# Configuración Zoom OAuth User-Level
# Compatible con cuentas Basic (GRATIS)
# ========================================

# Credenciales OAuth (solo 2, no Account ID)
ZOOM_CLIENT_ID = config('ZOOM_CLIENT_ID')  # Client ID de tu app
ZOOM_CLIENT_SECRET = config('ZOOM_CLIENT_SECRET')  # Client Secret (mantener secreto)

# URLs de Zoom
ZOOM_OAUTH_AUTHORIZE_URL = config('ZOOM_OAUTH_AUTHORIZE_URL')  # URL autorización
ZOOM_OAUTH_TOKEN_URL = config('ZOOM_OAUTH_TOKEN_URL')  # URL obtener token
ZOOM_API_BASE_URL = config('ZOOM_API_BASE_URL')  # Endpoint base API v2

# URL de redirección OAuth
ZOOM_REDIRECT_URI = config('ZOOM_REDIRECT_URI')  # Callback después de autorización

# NOTA: En producción, usa variables de entorno del servidor
# NO uses archivo .env en producción

5. 🚀 Proyecto: Sistema de Gestión de Reuniones

Paso 1: Crear Modelos

reuniones/models.py
from django.db import models  # ORM de Django
from django.contrib.auth.models import User  # Modelo de usuario

class Reunion(models.Model):
    """Modelo para almacenar reuniones de Zoom"""
    
    # Información básica
    titulo = models.CharField(max_length=200)  # Título de la reunión
    descripcion = models.TextField(blank=True)  # Agenda/descripción (opcional)
    
    # Información de Zoom
    zoom_meeting_id = models.CharField(max_length=50, unique=True)  # ID único de Zoom (ej: 123456789)
    zoom_meeting_password = models.CharField(max_length=20, blank=True)  # Contraseña de la reunión
    join_url = models.URLField()  # URL para participantes unirse
    start_url = models.URLField()  # URL para host iniciar reunión
    
    # Fechas y horarios
    fecha_inicio = models.DateTimeField()  # Fecha y hora programada
    duracion = models.IntegerField()  # Duración en minutos
    zona_horaria = models.CharField(max_length=50, default='America/Hermosillo')  # Zona horaria
    
    # Relaciones
    creador = models.ForeignKey(User, on_delete=models.CASCADE)  # Usuario que creó la reunión
    
    # Configuraciones
    sala_espera = models.BooleanField(default=True)  # Activar sala de espera
    grabar_automaticamente = models.BooleanField(default=False)  # Grabar automáticamente
    
    # Metadatos
    creado = models.DateTimeField(auto_now_add=True)  # Fecha de creación en Django
    actualizado = models.DateTimeField(auto_now=True)  # Fecha de última modificación
    
    class Meta:
        ordering = ['-fecha_inicio']  # Ordenar por fecha descendente
        verbose_name_plural = 'Reuniones'  # Nombre en plural en admin
    
    def __str__(self):
        return f"{self.titulo} - {self.fecha_inicio.strftime('%d/%m/%Y %H:%M')}"


class Participante(models.Model):
    """Participantes invitados a reuniones"""
    
    reunion = models.ForeignKey(Reunion, on_delete=models.CASCADE, related_name='participantes')  # Reunión asociada
    usuario = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True)  # Usuario si está registrado
    
    # Si el participante no está registrado
    nombre = models.CharField(max_length=100, blank=True)  # Nombre del invitado externo
    email = models.EmailField()  # Email del participante
    
    # Estado
    asistio = models.BooleanField(default=False)  # Marcado si asistió a la reunión
    invitacion_enviada = models.BooleanField(default=False)  # Si se envió correo de invitación
    
    creado = models.DateTimeField(auto_now_add=True)  # Fecha de creación
    
    def __str__(self):
        nombre_completo = self.usuario.get_full_name() if self.usuario else self.nombre  # Obtiene nombre
        return f"{nombre_completo} - {self.reunion.titulo}"

Paso 2: Crear Servicio de Zoom (OAuth User-Level)

reuniones/zoom_service.py - CÓDIGO COMPLETO
# ========================================
# reuniones/zoom_service.py
# Servicio para OAuth User-Level (cuenta gratis)
# ========================================

import requests  # Para peticiones HTTP
from django.conf import settings  # Acceso a settings
from django.core.cache import cache  # Sistema de caché
import base64  # Codificación Base64
from datetime import datetime  # Manejo de fechas

class ZoomService:
    """
    Servicio para interactuar con Zoom API usando OAuth 2.0 User-Level.
    Compatible con cuentas Zoom Basic (gratuitas).
    """
    
    def __init__(self):
        # Credenciales OAuth (solo 2, no Account ID)
        self.client_id = settings.ZOOM_CLIENT_ID
        self.client_secret = settings.ZOOM_CLIENT_SECRET
        self.redirect_uri = settings.ZOOM_REDIRECT_URI
        
        # URLs de Zoom
        self.authorize_url = settings.ZOOM_OAUTH_AUTHORIZE_URL
        self.token_url = settings.ZOOM_OAUTH_TOKEN_URL
        self.api_base_url = settings.ZOOM_API_BASE_URL
    
    def get_authorization_url(self):
        """
        Genera URL para que el usuario autorice la app.
        El usuario hace clic en esta URL y autoriza.
        
        Returns:
            str: URL de autorización de Zoom
        """
        return (
            f"{self.authorize_url}"
            f"?response_type=code"
            f"&client_id={self.client_id}"
            f"&redirect_uri={self.redirect_uri}"
        )
    
    def exchange_code_for_token(self, code):
        """
        Intercambia el código de autorización por Access Token.
        
        Args:
            code: Código recibido en callback después de autorizar
        
        Returns:
            dict con access_token, refresh_token, expires_in
        """
        # Crear Basic Auth header
        credentials = f"{self.client_id}:{self.client_secret}"
        b64_credentials = base64.b64encode(credentials.encode()).decode()
        
        headers = {
            'Authorization': f'Basic {b64_credentials}',
            'Content-Type': 'application/x-www-form-urlencoded'
        }
        
        data = {
            'grant_type': 'authorization_code',
            'code': code,
            'redirect_uri': self.redirect_uri
        }
        
        response = requests.post(self.token_url, headers=headers, data=data)
        
        if response.status_code == 200:
            token_data = response.json()
            
            # Guardar tokens en caché (55 minutos)
            cache.set('zoom_access_token', token_data['access_token'], 3300)
            cache.set('zoom_refresh_token', token_data['refresh_token'], 86400)
            
            return token_data
        else:
            raise Exception(f"Error obteniendo token: {response.text}")
    
    def refresh_access_token(self):
        """
        Renueva el Access Token usando el Refresh Token.
        
        Returns:
            str: Nuevo access token
        """
        refresh_token = cache.get('zoom_refresh_token')
        
        if not refresh_token:
            raise Exception("No hay refresh token disponible")
        
        credentials = f"{self.client_id}:{self.client_secret}"
        b64_credentials = base64.b64encode(credentials.encode()).decode()
        
        headers = {
            'Authorization': f'Basic {b64_credentials}',
            'Content-Type': 'application/x-www-form-urlencoded'
        }
        
        data = {
            'grant_type': 'refresh_token',
            'refresh_token': refresh_token
        }
        
        response = requests.post(self.token_url, headers=headers, data=data)
        
        if response.status_code == 200:
            token_data = response.json()
            cache.set('zoom_access_token', token_data['access_token'], 3300)
            return token_data['access_token']
        else:
            raise Exception(f"Error renovando token: {response.text}")
    
    def get_access_token(self):
        """
        Obtiene Access Token (desde caché o renovando).
        
        Returns:
            str: Access token válido
        """
        access_token = cache.get('zoom_access_token')
        
        if access_token:
            return access_token
        
        # Si no hay token, intentar renovar
        return self.refresh_access_token()
    
    def crear_reunion(self, topic, start_time, duration, timezone='America/Hermosillo'):
        """
        Crea una reunión en Zoom.
        
        Args:
            topic: Título de la reunión
            start_time: Fecha/hora inicio (formato: "2024-03-15T10:00:00")
            duration: Duración en minutos
            timezone: Zona horaria
        
        Returns:
            dict con datos de la reunión creada
        """
        access_token = self.get_access_token()
        
        headers = {
            'Authorization': f'Bearer {access_token}',
            'Content-Type': 'application/json'
        }
        
        data = {
            'topic': topic,
            'type': 2,  # Reunión programada
            'start_time': start_time,
            'duration': duration,
            'timezone': timezone,
            'settings': {
                'host_video': True,
                'participant_video': True,
                'join_before_host': False,
                'mute_upon_entry': True,
                'waiting_room': True,
                'audio': 'both'
            }
        }
        
        # Obtener user ID
        user_response = requests.get(
            f"{self.api_base_url}/users/me",
            headers=headers
        )
        user_id = user_response.json()['id']
        
        # Crear reunión
        response = requests.post(
            f"{self.api_base_url}/users/{user_id}/meetings",
            headers=headers,
            json=data
        )
        
        if response.status_code == 201:
            return response.json()
        else:
            raise Exception(f"Error creando reunión: {response.text}")
    
    def listar_reuniones(self):
        """
        Lista todas las reuniones programadas del usuario.
        
        Returns:
            list: Lista de reuniones
        """
        access_token = self.get_access_token()
        
        headers = {
            'Authorization': f'Bearer {access_token}'
        }
        
        # Obtener user ID
        user_response = requests.get(
            f"{self.api_base_url}/users/me",
            headers=headers
        )
        user_id = user_response.json()['id']
        
        # Listar reuniones
        response = requests.get(
            f"{self.api_base_url}/users/{user_id}/meetings",
            headers=headers
        )
        
        if response.status_code == 200:
            return response.json()['meetings']
        else:
            raise Exception(f"Error listando reuniones: {response.text}")
    
    def eliminar_reunion(self, meeting_id):
        """
        Elimina una reunión de Zoom.
        
        Args:
            meeting_id: ID de la reunión
        
        Returns:
            bool: True si se eliminó correctamente
        """
        access_token = self.get_access_token()
        
        headers = {
            'Authorization': f'Bearer {access_token}'
        }
        
        response = requests.delete(
            f"{self.api_base_url}/meetings/{meeting_id}",
            headers=headers
        )
        
        if response.status_code == 204:
            return True
        else:
            raise Exception(f"Error eliminando reunión: {response.text}")

Paso 3: Crear Views (Vistas Django)

reuniones/views.py - CÓDIGO COMPLETO OAUTH
# ========================================
# reuniones/views.py
# Vistas para OAuth User-Level (gratuito)
# ========================================

from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from django.http import JsonResponse
from django.core.cache import cache
from .zoom_service import ZoomService
from .models import Reunion, Participante
from datetime import datetime

# =====================================
# VISTAS DE AUTENTICACIÓN OAUTH
# =====================================

def zoom_login(request):
    """
    Redirige al usuario a la página de autorización de Zoom.
    Primera vez que el usuario autoriza la app.
    """
    zoom_service = ZoomService()
    authorization_url = zoom_service.get_authorization_url()
    return redirect(authorization_url)


def zoom_oauth_callback(request):
    """
    Callback de Zoom después de que el usuario autoriza.
    Recibe el código y lo intercambia por access token.
    """
    code = request.GET.get('code')
    
    if not code:
        messages.error(request, '❌ Error: No se recibió código de autorización')
        return redirect('inicio')
    
    try:
        zoom_service = ZoomService()
        token_data = zoom_service.exchange_code_for_token(code)
        
        messages.success(request, '✅ Autorización exitosa! Ya puedes crear reuniones.')
        return redirect('inicio')
    
    except Exception as e:
        messages.error(request, f'❌ Error al autorizar: {str(e)}')
        return redirect('inicio')


def verificar_autorizacion(request):
    """
    API para verificar si ya hay token (usuario ya autorizó).
    Usado por JavaScript en el frontend.
    """
    tiene_token = cache.get('zoom_access_token') is not None
    return JsonResponse({'autorizado': tiene_token})


# =====================================
# VISTAS PRINCIPALES
# =====================================

def inicio(request):
    """
    Página de inicio.
    Muestra botón de autorizar si no hay token.
    """
    tiene_token = cache.get('zoom_access_token') is not None
    
    context = {
        'autorizado': tiene_token
    }
    return render(request, 'reuniones/inicio.html', context)


@login_required
def crear_reunion(request):
    """
    Vista para crear una reunión de Zoom.
    """
    if request.method == 'POST':
        try:
            # Obtener datos del formulario
            topic = request.POST.get('topic')
            start_time = request.POST.get('start_time')  # "2024-03-15T10:00"
            duration = int(request.POST.get('duration'))
            
            # Convertir a formato ISO 8601
            start_datetime = datetime.strptime(start_time, '%Y-%m-%dT%H:%M')
            start_time_iso = start_datetime.strftime('%Y-%m-%dT%H:%M:%S')
            
            # Crear reunión en Zoom
            zoom_service = ZoomService()
            meeting_data = zoom_service.crear_reunion(
                topic=topic,
                start_time=start_time_iso,
                duration=duration
            )
            
            # Guardar en base de datos
            reunion = Reunion.objects.create(
                titulo=topic,
                zoom_meeting_id=meeting_data['id'],
                join_url=meeting_data['join_url'],
                start_url=meeting_data['start_url'],
                fecha_inicio=start_datetime,
                duracion=duration,
                creador=request.user
            )
            
            messages.success(request, f'✅ Reunión "{topic}" creada exitosamente!')
            return redirect('lista_reuniones')
        
        except Exception as e:
            messages.error(request, f'❌ Error al crear reunión: {str(e)}')
    
    return render(request, 'reuniones/crear_reunion.html')


@login_required
def lista_reuniones(request):
    """
    Lista todas las reuniones creadas.
    """
    reuniones = Reunion.objects.filter(creador=request.user)
    
    context = {
        'reuniones': reuniones
    }
    return render(request, 'reuniones/lista_reuniones.html', context)


@login_required
def detalle_reunion(request, reunion_id):
    """
    Muestra detalles de una reunión específica.
    """
    reunion = get_object_or_404(Reunion, id=reunion_id, creador=request.user)
    
    context = {
        'reunion': reunion
    }
    return render(request, 'reuniones/detalle_reunion.html', context)


@login_required
def eliminar_reunion(request, reunion_id):
    """
    Elimina una reunión de Zoom y de la base de datos.
    """
    reunion = get_object_or_404(Reunion, id=reunion_id, creador=request.user)
    
    try:
        # Eliminar de Zoom
        zoom_service = ZoomService()
        zoom_service.eliminar_reunion(reunion.zoom_meeting_id)
        
        # Eliminar de base de datos
        titulo = reunion.titulo
        reunion.delete()
        
        messages.success(request, f'✅ Reunión "{titulo}" eliminada correctamente.')
    
    except Exception as e:
        messages.error(request, f'❌ Error al eliminar: {str(e)}')
    
    return redirect('lista_reuniones')


@login_required
def sincronizar_reuniones(request):
    """
    Sincroniza reuniones desde Zoom API.
    Útil para obtener reuniones creadas directamente en Zoom.
    """
    try:
        zoom_service = ZoomService()
        meetings = zoom_service.listar_reuniones()
        
        count = 0
        for meeting in meetings:
            # Crear o actualizar en base de datos
            Reunion.objects.update_or_create(
                zoom_meeting_id=meeting['id'],
                defaults={
                    'titulo': meeting['topic'],
                    'join_url': meeting['join_url'],
                    'start_url': meeting.get('start_url', ''),
                    'fecha_inicio': datetime.strptime(
                        meeting['start_time'], 
                        '%Y-%m-%dT%H:%M:%SZ'
                    ),
                    'duracion': meeting['duration'],
                    'creador': request.user
                }
            )
            count += 1
        
        messages.success(request, f'✅ Sincronizadas {count} reuniones desde Zoom.')
    
    except Exception as e:
        messages.error(request, f'❌ Error al sincronizar: {str(e)}')
    
    return redirect('lista_reuniones')

Paso 4: Configurar URLs

reuniones/urls.py - RUTAS COMPLETAS
# reuniones/urls.py
from django.urls import path
from . import views

urlpatterns = [
    # ===== Autenticación OAuth =====
    path('zoom/login/', views.zoom_login, name='zoom_login'),
    path('zoom/oauth/callback/', views.zoom_oauth_callback, name='zoom_oauth_callback'),
    path('api/verificar-autorizacion/', views.verificar_autorizacion, name='verificar_autorizacion'),
    
    # ===== Vistas principales =====
    path('', views.inicio, name='inicio'),
    path('crear/', views.crear_reunion, name='crear_reunion'),
    path('lista/', views.lista_reuniones, name='lista_reuniones'),
    path('detalle/<int:reunion_id>/', views.detalle_reunion, name='detalle_reunion'),
    path('eliminar/<int:reunion_id>/', views.eliminar_reunion, name='eliminar_reunion'),
    path('sincronizar/', views.sincronizar_reuniones, name='sincronizar_reuniones'),
]

⚠️ Configuración en el proyecto principal

No olvides incluir las URLs en el archivo principal:

reuniones_project/urls.py
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('reuniones.urls')),  # Incluir URLs de la app
]

6. 🔔 Webhooks de Zoom

¿Qué son los Webhooks?

Los webhooks son notificaciones HTTP que Zoom envía a tu aplicación cuando ocurren eventos (reunión iniciada, participante entró, reunión finalizada, etc.).

Eventos Disponibles

Evento Descripción Uso
meeting.started Reunión iniciada Marcar inicio en BD
meeting.ended Reunión finalizada Calcular duración real
meeting.participant_joined Participante entró Registro de asistencia
recording.completed Grabación lista Descargar y almacenar

Implementar Webhook en Django

reuniones/views.py
from django.views.decorators.csrf import csrf_exempt  # Desactivar CSRF para webhook
from django.http import JsonResponse  # Respuesta JSON
import json  # Parser JSON

@csrf_exempt  # Zoom no puede enviar CSRF token
def zoom_webhook(request):
    """
    Endpoint que recibe notificaciones de Zoom
    URL debe ser pública: https://tudominio.com/api/zoom/webhook/
    """
    
    if request.method == 'POST':  # Zoom envía POST
        
        # Parsear payload JSON
        payload = json.loads(request.body)  # Convierte string a dict
        
        # Obtener tipo de evento
        event_type = payload.get('event')  # Ejemplo: "meeting.participant_joined"
        
        # Validación de URL (solo primera vez)
        if event_type == 'endpoint.url_validation':  # Zoom valida la URL
            plain_token = payload.get('payload', {}).get('plainToken')  # Token enviado
            return JsonResponse({  # Responder con token encriptado
                'plainToken': plain_token,
                'encryptedToken': plain_token  # En producción encriptar con SHA256
            })
        
        # Procesar evento de participante
        if event_type == 'meeting.participant_joined':
            meeting_id = payload.get('payload', {}).get('object', {}).get('id')  # ID reunión
            participant_name = payload.get('payload', {}).get('object', {}).get('participant', {}).get('user_name')  # Nombre
            
            # Actualizar asistencia en base de datos
            try:
                reunion = Reunion.objects.get(zoom_meeting_id=meeting_id)  # Busca reunión
                participante = Participante.objects.filter(  # Busca participante
                    reunion=reunion,
                    nombre__icontains=participant_name  # Coincidencia parcial
                ).first()
                
                if participante:
                    participante.asistio = True  # Marca asistencia
                    participante.save()  # Guarda en BD
            except Reunion.DoesNotExist:
                pass  # Reunión no encontrada
        
        # Responder con éxito a Zoom
        return JsonResponse({'status': 'success'}, status=200)  # Zoom espera 200 OK
    
    return JsonResponse({'error': 'Method not allowed'}, status=405)  # Solo POST permitido

🎨 TEMPLATES HTML - DISEÑO PROFESIONAL

⏱️ FASE 4: Crear Interfaz de Usuario (40 minutos)

Objetivo: Crear interfaz moderna con colores de Zoom (azul #2D8CFF)

Archivos a crear: 5 templates HTML con diseño espectacular

📄 Template 1: base.html (Template Base con Diseño Zoom)

reuniones/templates/reuniones/base.html
<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% block title %}Zoom Meetings Manager{% endblock %}</title>
    
    <!-- Bootstrap 5 -->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
    
    <!-- Font Awesome -->
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
    
    <style>
        :root {
            --zoom-blue: #2D8CFF;
            --zoom-dark: #0E71EB;
            --zoom-light: #E8F4FF;
            --dark: #1A1A1A;
            --gray: #f5f5f5;
        }
        
        body {
            font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            min-height: 100vh;
            padding: 20px;
        }
        
        .main-container {
            max-width: 1600px;
            margin: 0 auto;
            background: white;
            border-radius: 20px;
            box-shadow: 0 25px 50px rgba(0,0,0,0.3);
            overflow: hidden;
        }
        
        /* Navbar estilo Zoom */
        .navbar-zoom {
            background: linear-gradient(135deg, var(--zoom-blue) 0%, var(--zoom-dark) 100%);
            padding: 20px 40px;
            box-shadow: 0 4px 12px rgba(45, 140, 255, 0.3);
        }
        
        .navbar-brand {
            font-size: 1.5rem;
            font-weight: 700;
            color: white !important;
            display: flex;
            align-items: center;
            gap: 12px;
        }
        
        .navbar-brand i {
            font-size: 2rem;
            animation: pulse 2s infinite;
        }
        
        @keyframes pulse {
            0%, 100% { transform: scale(1); }
            50% { transform: scale(1.1); }
        }
        
        .nav-link-zoom {
            color: white !important;
            padding: 10px 20px;
            margin: 0 5px;
            border-radius: 12px;
            transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
            font-weight: 500;
        }
        
        .nav-link-zoom:hover {
            background: rgba(255,255,255,0.2);
            transform: translateY(-2px);
            box-shadow: 0 4px 12px rgba(255,255,255,0.3);
        }
        
        .nav-link-zoom.active {
            background: white;
            color: var(--zoom-blue) !important;
        }
        
        .content-wrapper {
            padding: 50px;
        }
        
        /* Cards con efecto glassmorphism */
        .card-zoom {
            background: linear-gradient(135deg, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0.7) 100%);
            backdrop-filter: blur(10px);
            border: none;
            border-radius: 20px;
            box-shadow: 0 8px 32px rgba(0,0,0,0.1);
            transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
            overflow: hidden;
        }
        
        .card-zoom:hover {
            transform: translateY(-10px) scale(1.02);
            box-shadow: 0 20px 60px rgba(45, 140, 255, 0.3);
        }
        
        .card-zoom::before {
            content: '';
            position: absolute;
            top: 0;
            left: 0;
            right: 0;
            height: 5px;
            background: linear-gradient(90deg, var(--zoom-blue), var(--zoom-dark));
        }
        
        .card-body {
            padding: 30px;
        }
        
        /* Botones estilo Zoom */
        .btn-zoom {
            background: linear-gradient(135deg, var(--zoom-blue) 0%, var(--zoom-dark) 100%);
            color: white;
            border: none;
            padding: 12px 30px;
            border-radius: 12px;
            font-weight: 600;
            transition: all 0.3s;
            box-shadow: 0 4px 15px rgba(45, 140, 255, 0.4);
        }
        
        .btn-zoom:hover {
            transform: translateY(-3px);
            box-shadow: 0 8px 25px rgba(45, 140, 255, 0.6);
            color: white;
        }
        
        .btn-zoom i {
            margin-right: 8px;
        }
        
        /* Stats cards con animación */
        .stat-card {
            background: linear-gradient(135deg, var(--zoom-blue) 0%, var(--zoom-dark) 100%);
            color: white;
            border-radius: 20px;
            padding: 30px;
            text-align: center;
            position: relative;
            overflow: hidden;
        }
        
        .stat-card::before {
            content: '';
            position: absolute;
            top: -50%;
            right: -50%;
            width: 200%;
            height: 200%;
            background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%);
            animation: rotate 20s linear infinite;
        }
        
        @keyframes rotate {
            from { transform: rotate(0deg); }
            to { transform: rotate(360deg); }
        }
        
        .stat-card h2 {
            font-size: 3rem;
            font-weight: 700;
            margin: 15px 0;
        }
        
        .stat-card i {
            font-size: 3rem;
            opacity: 0.9;
        }
        
        /* Footer moderno */
        footer {
            background: linear-gradient(135deg, #1A1A1A 0%, #2c3e50 100%);
            color: white;
            padding: 50px;
            text-align: center;
            margin-top: 50px;
        }
        
        footer a {
            color: var(--zoom-blue);
            text-decoration: none;
            transition: all 0.3s;
        }
        
        footer a:hover {
            color: white;
            text-shadow: 0 0 10px var(--zoom-blue);
        }
        
        /* Mensajes animados */
        .alert {
            border: none;
            border-radius: 15px;
            box-shadow: 0 4px 15px rgba(0,0,0,0.1);
            animation: slideInDown 0.5s;
        }
        
        @keyframes slideInDown {
            from {
                opacity: 0;
                transform: translateY(-30px);
            }
            to {
                opacity: 1;
                transform: translateY(0);
            }
        }
    </style>
    
    {% block extra_css %}{% endblock %}
</head>
<body>
    <div class="main-container">
        <!-- Navbar Zoom-Style -->
        <nav class="navbar navbar-zoom navbar-expand-lg">
            <div class="container-fluid">
                <a class="navbar-brand" href="{% url 'inicio' %}">
                    <i class="fas fa-video"></i>
                    Zoom Manager
                </a>
                
                <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
                    <span class="navbar-toggler-icon"></span>
                </button>
                
                <div class="collapse navbar-collapse" id="navbarNav">
                    <ul class="navbar-nav ms-auto">
                        <li class="nav-item">
                            <a class="nav-link-zoom" href="{% url 'inicio' %}">
                                <i class="fas fa-home"></i> Inicio
                            </a>
                        </li>
                        <li class="nav-item">
                            <a class="nav-link-zoom" href="{% url 'lista_reuniones' %}">
                                <i class="fas fa-list"></i> Mis Reuniones
                            </a>
                        </li>
                        <li class="nav-item">
                            <a class="nav-link-zoom active" href="{% url 'crear_reunion' %}">
                                <i class="fas fa-plus-circle"></i> Nueva Reunión
                            </a>
                        </li>
                    </ul>
                </div>
            </div>
        </nav>
        
        <!-- Mensajes Django -->
        {% if messages %}
            <div class="container mt-4">
                {% for message in messages %}
                    <div class="alert alert-{{ message.tags }} alert-dismissible fade show">
                        {{ message }}
                        <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
                    </div>
                {% endfor %}
            </div>
        {% endif %}
        
        <!-- Contenido Principal -->
        <div class="content-wrapper">
            {% block content %}{% endblock %}
        </div>
        
        <!-- Footer -->
        <footer>
            <p><i class="fas fa-video"></i> Zoom Meetings Manager - UTH 2026</p>
            <p><small>Desarrollado con Django + Zoom API</small></p>
            <p style="margin-top: 20px;">
                <a href="https://developers.zoom.us/" target="_blank">Zoom API Docs</a> |
                <a href="https://marketplace.zoom.us/" target="_blank">Marketplace</a>
            </p>
        </footer>
    </div>
    
    <!-- Bootstrap JS -->
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
    {% block extra_js %}{% endblock %}
</body>
</html>

📄 Template 2: inicio.html (Dashboard con Autorización OAuth)

reuniones/templates/reuniones/inicio.html
{% extends 'reuniones/base.html' %}

{% block title %}Inicio - Zoom Manager{% endblock %}

{% block content %}
<div class="text-center mb-5">
    <h1 class="display-3" style="color: #2D8CFF; font-weight: 700;">
        <i class="fas fa-video"></i> Dashboard de Reuniones
    </h1>
    <p class="lead">Gestiona tus reuniones de Zoom desde Django</p>
</div>

<!-- Estado de Autorización OAuth -->
{% if not autorizado %}
<div class="alert alert-warning text-center" style="border-radius: 15px; padding: 30px; background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%); color: white; border: none;">
    <div style="font-size: 60px; margin-bottom: 20px;">🔐</div>
    <h3 style="color: white; font-weight: 700;">¡Autoriza la aplicación primero!</h3>
    <p style="font-size: 1.2em; margin: 20px 0; opacity: 0.95;">
        Para crear y gestionar reuniones, necesitas autorizar esta aplicación con tu cuenta Zoom.
    </p>
    <p style="margin-bottom: 25px; opacity: 0.9;">
        ✅ Es seguro | ✅ Solo se hace una vez | ✅ Puedes revocar el acceso cuando quieras
    </p>
    <a href="{% url 'zoom_login' %}" class="btn btn-lg" style="background: white; color: #f59e0b; font-weight: 700; padding: 15px 40px; border-radius: 10px; box-shadow: 0 5px 20px rgba(0,0,0,0.2);">
        <i class="fas fa-key"></i> Autorizar con Zoom
    </a>
</div>
{% else %}
<div class="alert alert-success text-center" style="border-radius: 15px; padding: 20px; background: linear-gradient(135deg, #10b981 0%, #059669 100%); color: white; border: none;">
    <i class="fas fa-check-circle fa-2x" style="margin-bottom: 10px;"></i>
    <h5 style="color: white; margin: 0;">✅ Aplicación autorizada - ¡Listo para crear reuniones!</h5>
</div>

<!-- Stats Cards -->
<div class="row mb-5">
    <div class="col-md-4 mb-4">
        <div class="stat-card">
            <i class="fas fa-calendar-check"></i>
            <h2>{{ total_reuniones|default:0 }}</h2>
            <p>Reuniones Totales</p>
        </div>
    </div>
    
    <div class="col-md-4 mb-4">
        <div class="stat-card" style="background: linear-gradient(135deg, #10b981 0%, #059669 100%);">
            <i class="fas fa-clock"></i>
            <h2>{{ proximas_reuniones|default:0 }}</h2>
            <p>Próximas Reuniones</p>
        </div>
    </div>
    
    <div class="col-md-4 mb-4">
        <div class="stat-card" style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);">
            <i class="fas fa-history"></i>
            <h2>{{ reuniones_pasadas|default:0 }}</h2>
            <p>Reuniones Pasadas</p>
        </div>
    </div>
</div>

<!-- Action Cards -->
<div class="row">
    <div class="col-md-6 mb-4">
        <div class="card-zoom" style="position: relative;">
            <div class="card-body text-center">
                <i class="fas fa-plus-circle fa-4x mb-3" style="color: #2D8CFF;"></i>
                <h3>Crear Nueva Reunión</h3>
                <p class="text-muted">Programa una reunión de Zoom con un clic</p>
                <a href="{% url 'crear_reunion' %}" class="btn btn-zoom">
                    <i class="fas fa-video"></i> Nueva Reunión
                </a>
            </div>
        </div>
    </div>
    
    <div class="col-md-6 mb-4">
        <div class="card-zoom" style="position: relative;">
            <div class="card-body text-center">
                <i class="fas fa-list-alt fa-4x mb-3" style="color: #10b981;"></i>
                <h3>Ver Mis Reuniones</h3>
                <p class="text-muted">Administra todas tus reuniones programadas</p>
                <a href="{% url 'lista_reuniones' %}" class="btn btn-zoom" style="background: linear-gradient(135deg, #10b981, #059669);">
                    <i class="fas fa-calendar"></i> Ver Reuniones
                </a>
            </div>
        </div>
    </div>
</div>
{% endif %}
{% endblock %}

✅ CHECKPOINT 6: Templates OAuth actualizados

Archivos creados hasta ahora:

  • base.html - Template base con diseño Zoom espectacular
  • inicio.html - Dashboard con autorización OAuth

Nuevas características OAuth:

  • 🔐 Detección automática de estado de autorización
  • ⚡ Banner llamativo si no está autorizado
  • ✅ Indicador verde cuando está autorizado
  • 🎯 Botón de autorización con estilo Zoom
  • 📱 Diseño responsive y moderno

📄 Template 3: crear_reunion.html (Formulario Crear Reuniones)

reuniones/templates/reuniones/crear_reunion.html
{% extends 'reuniones/base.html' %}

{% block title %}Crear Reunión - Zoom Manager{% endblock %}

{% block content %}
<div class="row justify-content-center">
    <div class="col-lg-8">
        <!-- Header -->
        <div class="text-center mb-4">
            <h1 class="display-4" style="color: #2D8CFF; font-weight: 700;">
                <i class="fas fa-video-plus"></i> Nueva Reunión Zoom
            </h1>
            <p class="lead text-muted">Crea una reunión de Zoom en segundos</p>
        </div>

        <!-- Card Formulario -->
        <div class="card-zoom">
            <div class="card-body p-5">
                <form method="post" id="formReunion">
                    {% csrf_token %}
                    
                    <!-- Título de la Reunión -->
                    <div class="mb-4">
                        <label for="topic" class="form-label">
                            <i class="fas fa-heading text-primary"></i> 
                            <strong>Título de la Reunión</strong>
                            <span class="text-danger">*</span>
                        </label>
                        <input type="text" 
                               class="form-control form-control-lg" 
                               id="topic" 
                               name="topic" 
                               placeholder="Ej: Reunión de equipo - Proyecto Web"
                               required
                               style="border-radius: 10px; border: 2px solid #e5e7eb;">
                        <small class="text-muted">
                            <i class="fas fa-info-circle"></i> 
                            Será visible para todos los participantes
                        </small>
                    </div>

                    <!-- Fecha y Hora -->
                    <div class="row mb-4">
                        <div class="col-md-6">
                            <label for="start_date" class="form-label">
                                <i class="fas fa-calendar text-success"></i> 
                                <strong>Fecha</strong>
                                <span class="text-danger">*</span>
                            </label>
                            <input type="date" 
                                   class="form-control form-control-lg" 
                                   id="start_date" 
                                   name="start_date" 
                                   required
                                   style="border-radius: 10px; border: 2px solid #e5e7eb;">
                        </div>
                        
                        <div class="col-md-6">
                            <label for="start_time" class="form-label">
                                <i class="fas fa-clock text-warning"></i> 
                                <strong>Hora</strong>
                                <span class="text-danger">*</span>
                            </label>
                            <input type="time" 
                                   class="form-control form-control-lg" 
                                   id="start_time" 
                                   name="start_time" 
                                   required
                                   style="border-radius: 10px; border: 2px solid #e5e7eb;">
                        </div>
                    </div>

                    <!-- Duración -->
                    <div class="mb-4">
                        <label for="duration" class="form-label">
                            <i class="fas fa-hourglass-half text-info"></i> 
                            <strong>Duración (minutos)</strong>
                            <span class="text-danger">*</span>
                        </label>
                        <select class="form-select form-select-lg" 
                                id="duration" 
                                name="duration" 
                                required
                                style="border-radius: 10px; border: 2px solid #e5e7eb;">
                            <option value="">Selecciona la duración</option>
                            <option value="15">15 minutos (Reunión rápida)</option>
                            <option value="30">30 minutos</option>
                            <option value="40" selected>40 minutos (Límite gratis)</option>
                            <option value="60">1 hora</option>
                            <option value="90">1 hora 30 minutos</option>
                            <option value="120">2 horas</option>
                        </select>
                        <small class="text-muted">
                            <i class="fas fa-lightbulb"></i> 
                            Cuentas gratuitas tienen límite de 40 minutos para 3+ participantes
                        </small>
                    </div>

                    <!-- Descripción (Opcional) -->
                    <div class="mb-4">
                        <label for="agenda" class="form-label">
                            <i class="fas fa-list-ul text-secondary"></i> 
                            <strong>Agenda / Descripción</strong>
                            <span class="badge bg-secondary ms-2">Opcional</span>
                        </label>
                        <textarea class="form-control" 
                                  id="agenda" 
                                  name="agenda" 
                                  rows="4"
                                  placeholder="Describe los temas a tratar en la reunión..."
                                  style="border-radius: 10px; border: 2px solid #e5e7eb;"></textarea>
                    </div>

                    <!-- Configuración de Seguridad -->
                    <div class="card mb-4" style="background: #f9fafb; border: 2px dashed #d1d5db; border-radius: 10px;">
                        <div class="card-body">
                            <h5 class="mb-3">
                                <i class="fas fa-shield-alt text-danger"></i> 
                                Configuración de Seguridad
                            </h5>
                            
                            <div class="form-check form-switch mb-2">
                                <input class="form-check-input" 
                                       type="checkbox" 
                                       id="waiting_room" 
                                       name="waiting_room" 
                                       checked>
                                <label class="form-check-label" for="waiting_room">
                                    <strong>Sala de espera</strong> 
                                    <small class="text-muted">(Recomendado)</small>
                                </label>
                            </div>
                            
                            <div class="form-check form-switch mb-2">
                                <input class="form-check-input" 
                                       type="checkbox" 
                                       id="join_before_host" 
                                       name="join_before_host">
                                <label class="form-check-label" for="join_before_host">
                                    <strong>Permitir entrada antes del anfitrión</strong>
                                </label>
                            </div>
                            
                            <div class="form-check form-switch">
                                <input class="form-check-input" 
                                       type="checkbox" 
                                       id="mute_upon_entry" 
                                       name="mute_upon_entry" 
                                       checked>
                                <label class="form-check-label" for="mute_upon_entry">
                                    <strong>Silenciar participantes al entrar</strong>
                                </label>
                            </div>
                        </div>
                    </div>

                    <!-- Botones de Acción -->
                    <div class="d-grid gap-2">
                        <button type="submit" class="btn btn-zoom btn-lg">
                            <i class="fas fa-video me-2"></i> 
                            Crear Reunión de Zoom
                        </button>
                        <a href="{% url 'inicio' %}" class="btn btn-outline-secondary btn-lg">
                            <i class="fas fa-arrow-left me-2"></i> 
                            Cancelar
                        </a>
                    </div>
                </form>
            </div>
        </div>
    </div>
</div>

<!-- Script para establecer fecha mínima (hoy) -->
<script>
document.addEventListener('DOMContentLoaded', function() {
    const dateInput = document.getElementById('start_date');
    const today = new Date().toISOString().split('T')[0];
    dateInput.min = today;
    
    // Establecer fecha actual por defecto
    if (!dateInput.value) {
        dateInput.value = today;
    }
    
    // Establecer hora actual + 1 hora por defecto
    const timeInput = document.getElementById('start_time');
    if (!timeInput.value) {
        const now = new Date();
        now.setHours(now.getHours() + 1);
        const hours = String(now.getHours()).padStart(2, '0');
        const minutes = String(now.getMinutes()).padStart(2, '0');
        timeInput.value = `${hours}:${minutes}`;
    }
});
</script>
{% endblock %}

📄 Template 4: lista_reuniones.html (Tabla de Reuniones)

reuniones/templates/reuniones/lista_reuniones.html
{% extends 'reuniones/base.html' %}

{% block title %}Mis Reuniones - Zoom Manager{% endblock %}

{% block content %}
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
    <div>
        <h1 class="display-4" style="color: #2D8CFF; font-weight: 700;">
            <i class="fas fa-calendar-alt"></i> Mis Reuniones
        </h1>
        <p class="lead text-muted">
            Total: <strong>{{ reuniones.count }}</strong> 
            reunión{{ reuniones.count|pluralize:"es" }}
        </p>
    </div>
    <a href="{% url 'crear_reunion' %}" class="btn btn-zoom btn-lg">
        <i class="fas fa-plus-circle me-2"></i> 
        Nueva Reunión
    </a>
</div>

{% if reuniones %}
    <!-- Tabla de Reuniones -->
    <div class="card-zoom">
        <div class="table-responsive">
            <table class="table table-hover mb-0">
                <thead style="background: linear-gradient(135deg, #2D8CFF, #0E71EB); color: white;">
                    <tr>
                        <th class="py-3">
                            <i class="fas fa-heading"></i> Título
                        </th>
                        <th class="py-3">
                            <i class="fas fa-calendar"></i> Fecha y Hora
                        </th>
                        <th class="py-3 text-center">
                            <i class="fas fa-hourglass-half"></i> Duración
                        </th>
                        <th class="py-3 text-center">
                            <i class="fas fa-info-circle"></i> Estado
                        </th>
                        <th class="py-3 text-center">
                            <i class="fas fa-cogs"></i> Acciones
                        </th>
                    </tr>
                </thead>
                <tbody>
                    {% for reunion in reuniones %}
                    <tr style="border-bottom: 1px solid #e5e7eb;">
                        <td class="align-middle">
                            <div class="d-flex align-items-center">
                                <div class="me-3" 
                                     style="width: 50px; height: 50px; background: linear-gradient(135deg, #2D8CFF, #0E71EB); border-radius: 10px; display: flex; align-items: center; justify-content: center; color: white; font-size: 24px;">
                                    <i class="fas fa-video"></i>
                                </div>
                                <div>
                                    <strong style="font-size: 1.1em; color: #1f2937;">
                                        {{ reunion.topic }}
                                    </strong>
                                    <br>
                                    <small class="text-muted">
                                        <i class="fas fa-fingerprint"></i> 
                                        ID: {{ reunion.meeting_id }}
                                    </small>
                                </div>
                            </div>
                        </td>
                        
                        <td class="align-middle">
                            <div>
                                <i class="fas fa-calendar-day text-success"></i>
                                <strong>{{ reunion.fecha_inicio|date:"d/m/Y" }}</strong>
                            </div>
                            <div class="text-muted">
                                <i class="fas fa-clock text-warning"></i>
                                {{ reunion.fecha_inicio|date:"H:i" }} hrs
                            </div>
                        </td>
                        
                        <td class="align-middle text-center">
                            <span class="badge bg-info" style="font-size: 0.95em; padding: 8px 12px;">
                                {{ reunion.duracion }} min
                            </span>
                        </td>
                        
                        <td class="align-middle text-center">
                            {% now "U" as timestamp %}
                            {% if reunion.fecha_inicio.timestamp > timestamp|add:"0" %}
                                <span class="badge bg-success" style="font-size: 0.95em; padding: 8px 12px;">
                                    <i class="fas fa-clock"></i> Próxima
                                </span>
                            {% else %}
                                <span class="badge bg-secondary" style="font-size: 0.95em; padding: 8px 12px;">
                                    <i class="fas fa-check"></i> Finalizada
                                </span>
                            {% endif %}
                        </td>
                        
                        <td class="align-middle text-center">
                            <div class="btn-group" role="group">
                                <a href="{% url 'detalle_reunion' reunion.id %}" 
                                   class="btn btn-sm btn-outline-primary"
                                   title="Ver detalles">
                                    <i class="fas fa-eye"></i>
                                </a>
                                
                                <a href="{{ reunion.start_url }}" 
                                   target="_blank"
                                   class="btn btn-sm btn-outline-success"
                                   title="Iniciar como anfitrión">
                                    <i class="fas fa-play"></i>
                                </a>
                                
                                <button type="button" 
                                        class="btn btn-sm btn-outline-danger"
                                        onclick="confirmarEliminacion('{{ reunion.id }}', '{{ reunion.topic }}')"
                                        title="Eliminar reunión">
                                    <i class="fas fa-trash"></i>
                                </button>
                            </div>
                        </td>
                    </tr>
                    {% endfor %}
                </tbody>
            </table>
        </div>
    </div>
{% else %}
    <!-- Estado Vacío -->
    <div class="card-zoom text-center p-5">
        <div style="font-size: 100px; color: #d1d5db; margin-bottom: 20px;">
            <i class="fas fa-calendar-times"></i>
        </div>
        <h3 class="text-muted mb-4">No tienes reuniones programadas</h3>
        <p class="text-muted mb-4">
            Crea tu primera reunión de Zoom para comenzar
        </p>
        <a href="{% url 'crear_reunion' %}" class="btn btn-zoom btn-lg">
            <i class="fas fa-plus-circle me-2"></i> 
            Crear Mi Primera Reunión
        </a>
    </div>
{% endif %}

<!-- Modal de Confirmación de Eliminación -->
<div class="modal fade" id="modalEliminar" tabindex="-1">
    <div class="modal-dialog modal-dialog-centered">
        <div class="modal-content" style="border-radius: 15px; border: none;">
            <div class="modal-header" style="background: linear-gradient(135deg, #ef4444, #dc2626); color: white; border-radius: 15px 15px 0 0;">
                <h5 class="modal-title">
                    <i class="fas fa-exclamation-triangle"></i> 
                    Confirmar Eliminación
                </h5>
                <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
            </div>
            <div class="modal-body text-center p-4">
                <div style="font-size: 60px; color: #ef4444; margin-bottom: 20px;">
                    <i class="fas fa-trash-alt"></i>
                </div>
                <p style="font-size: 1.2em; margin-bottom: 10px;">
                    ¿Estás seguro de eliminar la reunión?
                </p>
                <p id="nombreReunion" style="font-weight: 700; color: #2D8CFF; font-size: 1.1em;"></p>
                <p class="text-muted">
                    Esta acción no se puede deshacer.
                </p>
            </div>
            <div class="modal-footer" style="border: none; padding: 20px;">
                <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
                    <i class="fas fa-times"></i> Cancelar
                </button>
                <form id="formEliminar" method="post" style="display: inline;">
                    {% csrf_token %}
                    <button type="submit" class="btn btn-danger">
                        <i class="fas fa-trash"></i> Eliminar
                    </button>
                </form>
            </div>
        </div>
    </div>
</div>

<script>
function confirmarEliminacion(reunionId, nombreReunion) {
    document.getElementById('nombreReunion').textContent = nombreReunion;
    document.getElementById('formEliminar').action = `/zoom/reunion/${reunionId}/eliminar/`;
    const modal = new bootstrap.Modal(document.getElementById('modalEliminar'));
    modal.show();
}
</script>
{% endblock %}

📄 Template 5: detalle_reunion.html (Detalle con Código QR)

reuniones/templates/reuniones/detalle_reunion.html
{% extends 'reuniones/base.html' %}

{% block title %}{{ reunion.topic }} - Detalle{% endblock %}

{% block content %}
<!-- Botón Volver -->
<div class="mb-4">
    <a href="{% url 'lista_reuniones' %}" class="btn btn-outline-secondary">
        <i class="fas fa-arrow-left me-2"></i> 
        Volver a Mis Reuniones
    </a>
</div>

<!-- Header de la Reunión -->
<div class="card-zoom mb-4">
    <div class="card-body">
        <div class="d-flex justify-content-between align-items-start">
            <div>
                <h1 class="display-5 mb-3" style="color: #2D8CFF; font-weight: 700;">
                    <i class="fas fa-video me-2"></i>
                    {{ reunion.topic }}
                </h1>
                <p class="text-muted mb-0">
                    <i class="fas fa-fingerprint"></i> 
                    ID de Reunión: <strong>{{ reunion.meeting_id }}</strong>
                </p>
            </div>
            
            {% now "U" as timestamp %}
            {% if reunion.fecha_inicio.timestamp > timestamp|add:"0" %}
                <span class="badge bg-success" style="font-size: 1.2em; padding: 12px 20px;">
                    <i class="fas fa-clock"></i> Próxima
                </span>
            {% else %}
                <span class="badge bg-secondary" style="font-size: 1.2em; padding: 12px 20px;">
                    <i class="fas fa-check"></i> Finalizada
                </span>
            {% endif %}
        </div>
    </div>
</div>

<div class="row">
    <!-- Columna Izquierda: Información -->
    <div class="col-lg-8 mb-4">
        <!-- Información de la Reunión -->
        <div class="card-zoom mb-4">
            <div class="card-body">
                <h4 class="mb-4" style="color: #2D8CFF;">
                    <i class="fas fa-info-circle"></i> 
                    Información General
                </h4>
                
                <div class="row g-3">
                    <div class="col-md-6">
                        <div class="p-3" style="background: #f0f9ff; border-radius: 10px; border-left: 4px solid #2D8CFF;">
                            <div class="text-muted mb-1">
                                <i class="fas fa-calendar-day"></i> Fecha
                            </div>
                            <div style="font-size: 1.3em; font-weight: 700; color: #1f2937;">
                                {{ reunion.fecha_inicio|date:"d/m/Y" }}
                            </div>
                            <small class="text-muted">
                                {{ reunion.fecha_inicio|date:"l" }}
                            </small>
                        </div>
                    </div>
                    
                    <div class="col-md-6">
                        <div class="p-3" style="background: #fef3c7; border-radius: 10px; border-left: 4px solid #f59e0b;">
                            <div class="text-muted mb-1">
                                <i class="fas fa-clock"></i> Hora de Inicio
                            </div>
                            <div style="font-size: 1.3em; font-weight: 700; color: #1f2937;">
                                {{ reunion.fecha_inicio|date:"H:i" }} hrs
                            </div>
                            <small class="text-muted">
                                Zona horaria local
                            </small>
                        </div>
                    </div>
                    
                    <div class="col-md-6">
                        <div class="p-3" style="background: #e0f2fe; border-radius: 10px; border-left: 4px solid #0ea5e9;">
                            <div class="text-muted mb-1">
                                <i class="fas fa-hourglass-half"></i> Duración
                            </div>
                            <div style="font-size: 1.3em; font-weight: 700; color: #1f2937;">
                                {{ reunion.duracion }} minutos
                            </div>
                        </div>
                    </div>
                    
                    <div class="col-md-6">
                        <div class="p-3" style="background: #dcfce7; border-radius: 10px; border-left: 4px solid #10b981;">
                            <div class="text-muted mb-1">
                                <i class="fas fa-user"></i> Creador
                            </div>
                            <div style="font-size: 1.3em; font-weight: 700; color: #1f2937;">
                                {{ reunion.creador.get_full_name|default:reunion.creador.username }}
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </div>

        <!-- Enlaces de Acceso -->
        <div class="card-zoom">
            <div class="card-body">
                <h4 class="mb-4" style="color: #2D8CFF;">
                    <i class="fas fa-link"></i> 
                    Enlaces de Acceso
                </h4>
                
                <!-- Link para Anfitrión -->
                <div class="mb-4 p-4" style="background: linear-gradient(135deg, #10b981 0%, #059669 100%); border-radius: 12px; color: white;">
                    <div class="d-flex justify-content-between align-items-center mb-2">
                        <h5 class="mb-0" style="color: white;">
                            <i class="fas fa-star"></i> 
                            Enlace para Anfitrión
                        </h5>
                        <a href="{{ reunion.start_url }}" 
                           target="_blank" 
                           class="btn btn-light btn-sm">
                            <i class="fas fa-external-link-alt"></i> 
                            Abrir
                        </a>
                    </div>
                    <div class="input-group">
                        <input type="text" 
                               class="form-control" 
                               value="{{ reunion.start_url }}" 
                               id="startUrl" 
                               readonly
                               style="background: white; font-family: monospace;">
                        <button class="btn btn-light" 
                                onclick="copiarTexto('startUrl')">
                            <i class="fas fa-copy"></i> Copiar
                        </button>
                    </div>
                    <small style="opacity: 0.9;">
                        <i class="fas fa-info-circle"></i> 
                        Solo tú puedes usar este enlace para iniciar la reunión
                    </small>
                </div>
                
                <!-- Link para Participantes -->
                <div class="p-4" style="background: linear-gradient(135deg, #2D8CFF 0%, #0E71EB 100%); border-radius: 12px; color: white;">
                    <div class="d-flex justify-content-between align-items-center mb-2">
                        <h5 class="mb-0" style="color: white;">
                            <i class="fas fa-users"></i> 
                            Enlace para Participantes
                        </h5>
                        <a href="{{ reunion.join_url }}" 
                           target="_blank" 
                           class="btn btn-light btn-sm">
                            <i class="fas fa-external-link-alt"></i> 
                            Abrir
                        </a>
                    </div>
                    <div class="input-group">
                        <input type="text" 
                               class="form-control" 
                               value="{{ reunion.join_url }}" 
                               id="joinUrl" 
                               readonly
                               style="background: white; font-family: monospace;">
                        <button class="btn btn-light" 
                                onclick="copiarTexto('joinUrl')">
                            <i class="fas fa-copy"></i> Copiar
                        </button>
                    </div>
                    <small style="opacity: 0.9;">
                        <i class="fas fa-share-alt"></i> 
                        Comparte este enlace con tus participantes
                    </small>
                </div>
            </div>
        </div>
    </div>

    <!-- Columna Derecha: Código QR -->
    <div class="col-lg-4 mb-4">
        <div class="card-zoom text-center sticky-top" style="top: 20px;">
            <div class="card-body">
                <h4 class="mb-4" style="color: #2D8CFF;">
                    <i class="fas fa-qrcode"></i> 
                    Código QR
                </h4>
                
                <!-- Código QR generado con API -->
                <div class="mb-3 p-3" style="background: white; border-radius: 15px; box-shadow: 0 4px 15px rgba(0,0,0,0.1);">
                    <img src="https://api.qrserver.com/v1/create-qr-code/?size=300x300&data={{ reunion.join_url|urlencode }}" 
                         alt="Código QR"
                         class="img-fluid"
                         style="border-radius: 10px;">
                </div>
                
                <p class="text-muted mb-3">
                    <i class="fas fa-mobile-alt"></i> 
                    Escanea con tu celular para unirte
                </p>
                
                <a href="https://api.qrserver.com/v1/create-qr-code/?size=1000x1000&data={{ reunion.join_url|urlencode }}" 
                   download="reunion_qr_{{ reunion.meeting_id }}.png"
                   class="btn btn-outline-primary w-100 mb-2">
                    <i class="fas fa-download"></i> 
                    Descargar QR
                </a>
                
                <button onclick="window.print()" 
                        class="btn btn-outline-secondary w-100">
                    <i class="fas fa-print"></i> 
                    Imprimir
                </button>
            </div>
        </div>

        <!-- Botones de Acción -->
        <div class="card-zoom mt-4">
            <div class="card-body">
                <h5 class="mb-3">
                    <i class="fas fa-bolt"></i> 
                    Acciones Rápidas
                </h5>
                
                <a href="{{ reunion.start_url }}" 
                   target="_blank"
                   class="btn btn-success w-100 mb-2 btn-lg">
                    <i class="fas fa-play-circle"></i> 
                    Iniciar Reunión
                </a>
                
                <a href="{{ reunion.join_url }}" 
                   target="_blank"
                   class="btn btn-zoom w-100 mb-2">
                    <i class="fas fa-sign-in-alt"></i> 
                    Unirse a Reunión
                </a>
                
                <hr>
                
                <form method="post" action="{% url 'eliminar_reunion' reunion.id %}">
                    {% csrf_token %}
                    <button type="submit" 
                            class="btn btn-danger w-100"
                            onclick="return confirm('¿Estás seguro de eliminar esta reunión?')">
                        <i class="fas fa-trash"></i> 
                        Eliminar Reunión
                    </button>
                </form>
            </div>
        </div>
    </div>
</div>

<!-- Script para copiar texto -->
<script>
function copiarTexto(inputId) {
    const input = document.getElementById(inputId);
    input.select();
    input.setSelectionRange(0, 99999); // Para móviles
    
    document.execCommand('copy');
    
    // Feedback visual
    const btn = event.target.closest('button');
    const originalHtml = btn.innerHTML;
    btn.innerHTML = '<i class="fas fa-check"></i> ¡Copiado!';
    btn.classList.add('btn-success');
    btn.classList.remove('btn-light');
    
    setTimeout(() => {
        btn.innerHTML = originalHtml;
        btn.classList.remove('btn-success');
        btn.classList.add('btn-light');
    }, 2000);
}
</script>
{% endblock %}

✅ CHECKPOINT 7: Templates completos

Todos los templates HTML creados:

  • base.html - Template base con diseño Zoom espectacular
  • inicio.html - Dashboard con autorización OAuth
  • crear_reunion.html - Formulario completo para crear reuniones
  • lista_reuniones.html - Tabla interactiva de reuniones
  • detalle_reunion.html - Vista detallada con código QR

Características implementadas:

  • 🎨 Diseño profesional con colores de Zoom (#2D8CFF)
  • 📱 100% Responsive (móvil, tablet, desktop)
  • ✨ Animaciones y efectos visuales
  • 🔐 Integración OAuth completa
  • 📊 Estadísticas en dashboard
  • 📅 Formulario de creación con validación
  • 📋 Tabla con estado de reuniones
  • 🔗 Enlaces copiables con un clic
  • 📱 Código QR generado automáticamente
  • 💾 Descarga e impresión de QR
  • 🗑️ Eliminación con confirmación
  • ⚡ Acciones rápidas (iniciar, unirse)

🛡️ Mejores Prácticas y Seguridad

🔐 Seguridad de Credenciales Zoom

❌ NUNCA hagas esto:

  1. Hardcodear Client ID y Client Secret en código
  2. Subir credenciales a GitHub público
  3. Compartir Access Token entre usuarios
  4. Usar HTTP (sin SSL/TLS) para webhooks
  5. Guardar contraseñas de reunión en logs
  6. Deshabilitar verificación de webhooks

✅ SÍ haz esto:

Implementar las medidas de seguridad descritas en la documentación oficial de Zoom y seguir el principio de menor privilegio al configurar scopes.

📚 Recursos Adicionales y Referencias

📖 Documentación Oficial

🆘 SOLUCIÓN DE PROBLEMAS (TROUBLESHOOTING)

📋 Guía Rápida de Diagnóstico

Si algo no funciona, sigue este orden:

  1. 🔍 Lee el mensaje de error COMPLETO
  2. 📝 Identifica el tipo de error (OAuth, API, código, etc.)
  3. 🔧 Busca la solución específica en las secciones de abajo
  4. Verifica el fix ejecutando el código de nuevo
  5. Si persiste, revisa logs y contacta soporte

🔴 Problema 1: Error de Autenticación OAuth 401 Unauthorized

❌ Síntomas:

Error en Python/Django
requests.exceptions.HTTPError: 401 Client Error: Unauthorized for url

Response JSON:
{
    "code": 124,
    "message": "Invalid access token."
}

O también:

{
    "code": 104,
    "message": "Invalid access token, does not contain scopes."
}

✅ Soluciones (prueba en orden):

Solución 1: Verificar Credenciales OAuth
  1. 🔍 Abre tu archivo de configuración de Zoom
  2. 🔑 Verifica que tengas las 3 credenciales:
    • ZOOM_CLIENT_ID # Solo 2 credenciales (no Client ID) - Formato: abc123XYZ
    • ZOOM_CLIENT_ID - Formato: A1B2C3D4E5F6G7H8
    • ZOOM_CLIENT_SECRET - Formato: ABC123def456GHI789
  3. 🌐 Ve a marketplace.zoom.us
  4. 📋 Abre tu app → Pestaña "App Credentials"
  5. 🔍 Compara credenciales (copia/pega para evitar errores)
Solución 2: Verificar que la App esté Activada
  1. 🌐 Ve a Zoom Marketplace
  2. 📱 Abre tu app
  3. 👁️ Verifica estado en la parte superior
  4. ✅ Si dice "Development", necesitas activarla:
    • Haz clic en "Activate your app"
    • Confirma la activación
    • Espera 1-2 minutos
    • Estado debe cambiar a "Activated"
Solución 3: Verificar Scopes Configurados
Scopes REQUERIDOS para el proyecto
# En Marketplace → tu app → "Scopes"
# Asegúrate de tener TODOS estos scopes:

meeting:write:admin     # Crear reuniones
meeting:read:admin      # Leer reuniones
meeting:update:admin    # Actualizar reuniones
meeting:delete:admin    # Eliminar reuniones
user:read:admin         # Leer info de usuario

# Opcionales (para webhooks):
meeting:read:admin      # Recibir eventos de reuniones

⚠️ Si agregas scopes nuevos:

  • Debes RE-ACTIVAR la app
  • Espera 2-3 minutos
  • Solicita nuevo Access Token
Solución 4: Regenerar Client Secret
  1. 🌐 Marketplace → tu app → "App Credentials"
  2. 🔄 Haz clic en "Regenerate" junto a Client Secret
  3. 📋 COPIA el nuevo secret inmediatamente (solo se muestra una vez)
  4. 💾 Actualiza tu configuración Django/Python
  5. 🔄 Reinicia el servidor Django
Solución 5: Script de Prueba de Autenticación
test_zoom_auth.py - Script de verificación
import requests
import base64

# ⚠️ REEMPLAZA con tus credenciales reales:
ZOOM_CLIENT_ID  # Solo 2 credenciales (no Client ID) = "abc123XYZ"
ZOOM_CLIENT_ID = "A1B2C3D4E5F6G7H8"
ZOOM_CLIENT_SECRET = "ABC123def456GHI789"

def test_zoom_auth():
    """Prueba autenticación con Zoom OAuth"""
    print("🔍 Probando autenticación con Zoom...")
    print(f"📋 Client ID: {ZOOM_CLIENT_ID  # Solo 2 credenciales (no Client ID)}")
    print(f"📋 Client ID: {ZOOM_CLIENT_ID}")
    print(f"📋 Client Secret: {ZOOM_CLIENT_SECRET[:10]}...")
    
    # Crear credenciales Base64
    credentials = f"{ZOOM_CLIENT_ID}:{ZOOM_CLIENT_SECRET}"
    encoded_credentials = base64.b64encode(credentials.encode()).decode()
    
    # Headers
    headers = {
        'Authorization': f'Basic {encoded_credentials}',
        'Content-Type': 'application/x-www-form-urlencoded'
    }
    
    # Body
    data = {
        'grant_type': 'account_credentials',
        'client_id': ZOOM_CLIENT_ID  # Solo 2 credenciales (no Client ID)
    }
    
    # Request
    oauth_url = "https://zoom.us/oauth/token"
    
    try:
        response = requests.post(oauth_url, headers=headers, data=data)
        
        if response.status_code == 200:
            token_data = response.json()
            print("\n✅ ¡AUTENTICACIÓN EXITOSA!")
            print(f"📊 Access Token: {token_data['access_token'][:20]}...")
            print(f"⏰ Expira en: {token_data['expires_in']} segundos")
            print(f"🔑 Token Type: {token_data['token_type']}")
            print(f"📜 Scope: {token_data.get('scope', 'N/A')}")
            return token_data['access_token']
        else:
            print(f"\n❌ ERROR {response.status_code}")
            print(f"📄 Response: {response.text}")
            return None
            
    except Exception as e:
        print(f"\n❌ EXCEPCIÓN: {e}")
        return None

if __name__ == "__main__":
    token = test_zoom_auth()
    
    if token:
        print("\n🎉 ¡Puedes continuar con el proyecto!")
    else:
        print("\n🔧 Revisa las soluciones anteriores.")

🔴 Problema 2: Error al Crear Reunión (400 Bad Request)

❌ Síntomas:

Error al crear reunión
requests.exceptions.HTTPError: 400 Client Error: Bad Request

Response JSON:
{
    "code": 300,
    "message": "Invalid parameter: start_time."
}

O también:

{
    "code": 200,
    "message": "Meeting does not exist."
}

✅ Soluciones:

Solución 1: Verificar Formato de Fecha/Hora (ISO 8601)
Formato CORRECTO de start_time
from datetime import datetime, timedelta

# ✅ FORMATO CORRECTO (ISO 8601 con zona horaria):
start_time = datetime.now() + timedelta(hours=2)
start_time_str = start_time.strftime("%Y-%m-%dT%H:%M:%S")
# Ejemplo: "2026-01-20T15:30:00"

# ❌ FORMATOS INCORRECTOS:
# "20/01/2026 15:30"        ← NO formato ISO
# "2026-01-20 15:30:00"     ← Falta la T
# "2026/01/20T15:30:00"     ← Barras en vez de guiones

# Ejemplo de JSON correcto:
meeting_data = {
    "topic": "Reunión de Prueba",
    "type": 2,  # 1=instant, 2=scheduled
    "start_time": start_time_str,
    "duration": 60,  # minutos
    "timezone": "America/Hermosillo",
    "settings": {
        "host_video": True,
        "participant_video": True
    }
}
Solución 2: Verificar Zona Horaria
Zonas horarias comunes en México
# Zona horaria DEBE ser válida según IANA Time Zone Database

# ✅ ZONAS CORRECTAS para México:
"America/Mexico_City"     # Ciudad de México, Monterrey
"America/Hermosillo"      # Sonora (sin horario de verano)
"America/Tijuana"         # Tijuana, Mexicali
"America/Cancun"          # Quintana Roo

# ❌ ZONAS INCORRECTAS:
"Mexico/Ciudad"           # ← NO existe
"GMT-6"                   # ← Use nombre, no offset
"CST"                     # ← Ambiguo
Solución 3: Verificar User ID

Para crear reuniones, necesitas el User ID correcto:

Obtener User ID
# Opción 1: Usar "me" (recomendado)
user_id = "me"
url = f"https://api.zoom.us/v2/users/{user_id}/meetings"

# Opción 2: Obtener User ID específico
def get_user_id(access_token):
    headers = {'Authorization': f'Bearer {access_token}'}
    response = requests.get("https://api.zoom.us/v2/users/me", headers=headers)
    user_data = response.json()
    return user_data['id']  # Ejemplo: "abc123DEF456"

🔴 Problema 3: "Module not found: requests"

❌ Síntomas:

Error al ejecutar código Python
ModuleNotFoundError: No module named 'requests'

✅ Soluciones:

Solución 1: Verificar Entorno Virtual Activado
PowerShell - Verificar venv
# Verifica si el prompt tiene (venv):
# (venv) PS C:\proyecto> ← ✅ ACTIVADO
# PS C:\proyecto>       ← ❌ NO ACTIVADO

# Si NO está activado:
.\venv\Scripts\Activate
Solución 2: Instalar requests
PowerShell - Instalar requests
pip install requests

# Verificar instalación:
pip list | Select-String "requests"

# Deberías ver:
# requests     2.31.0

🔴 Problema 4: Webhooks No Llegan a Django

❌ Síntomas:

  • Configuraste webhook en Zoom Marketplace
  • La reunión inicia/termina pero Django no recibe notificación
  • No hay logs en Django sobre webhooks

✅ Soluciones:

Solución 1: Verificar ngrok Funcionando
Terminal - Verificar ngrok
# Terminal 1: Ejecutar Django
python manage.py runserver

# Terminal 2: Ejecutar ngrok
ngrok http 8000

# Deberías ver:
# Forwarding  https://abc123.ngrok.io -> http://localhost:8000

# ⚠️ IMPORTANTE: Copia la URL https:// (NO http://)
Solución 2: Configurar URL en Zoom Marketplace
  1. 🌐 Ve a Marketplace → tu app → "Feature"
  2. 📋 En "Event Subscriptions", agrega tu URL de ngrok:
    • https://abc123.ngrok.io/zoom/webhook/
  3. ✅ Zoom enviará request de verificación
  4. 🔍 Verifica que Django responda correctamente
Solución 3: Implementar Endpoint de Verificación
zoom/views.py - Webhook endpoint
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
import json

@csrf_exempt
def zoom_webhook(request):
    if request.method == 'POST':
        payload = json.loads(request.body)
        
        # Verificación inicial de Zoom
        if payload.get('event') == 'endpoint.url_validation':
            return JsonResponse({
                'plainToken': payload['payload']['plainToken'],
                'encryptedToken': payload['payload']['encryptedToken']
            })
        
        # Procesar eventos
        event_type = payload.get('event')
        print(f"📩 Webhook recibido: {event_type}")
        
        return JsonResponse({'status': 'success'})
    
    return JsonResponse({'error': 'Method not allowed'}, status=405)

🔴 Problema 5: "User does not belong to this account"

❌ Síntomas:

Error de permisos
{
    "code": 1001,
    "message": "User does not belong to this account."
}

✅ Soluciones:

Solución 1: Usar "me" en lugar de User ID específico
Corrección en código
# ✅ CORRECTO:
url = "https://api.zoom.us/v2/users/me/meetings"

# ❌ INCORRECTO (si el ID no pertenece a tu cuenta):
url = "https://api.zoom.us/v2/users/abc123DEF/meetings"
Solución 2: Verificar Tipo de Cuenta Zoom

⚠️ IMPORTANTE:

  • Necesitas cuenta Zoom PRO o superior
  • Cuentas FREE Basic NO pueden usar API
  • Verifica tu plan en: zoom.us/profile

🔴 Problema 6: Reunión se Crea pero No Aparece en Zoom App

❌ Síntomas:

  • API responde 201 Created exitosamente
  • Django guarda la reunión en BD
  • Pero NO aparece en la app de Zoom

✅ Soluciones:

Solución 1: Verificar Hora de Inicio (Futuro)
Verificación de tiempo
from datetime import datetime, timedelta

# ✅ CORRECTO: Reunión en el FUTURO
start_time = datetime.now() + timedelta(hours=2)

# ❌ INCORRECTO: Reunión en el PASADO
start_time = datetime(2026, 1, 1, 10, 0)  # ← Ya pasó
Solución 2: Hacer Refresh en Zoom App
  1. 📱 Abre la app de Zoom
  2. 🔄 Haz clic en "Reuniones"
  3. ⬇️ Jala hacia abajo para refrescar
  4. ⏳ Espera 10-20 segundos
  5. ✅ La reunión debería aparecer
Solución 3: Verificar Tipo de Reunión
Tipos de reunión
# type en JSON:
1 = Instant meeting  # ← NO aparece en lista, es inmediata
2 = Scheduled meeting # ← SÍ aparece en lista
3 = Recurring meeting (no fixed time)
8 = Recurring meeting (fixed time)

# Para que aparezca en lista, usa type: 2
meeting_data = {
    "type": 2,
    # ... resto del JSON
}

📞 ¿Aún tienes problemas?

🛠️ Pasos adicionales de debugging:

  1. Revisa logs de Django:
    PowerShell
    python manage.py runserver --noreload
  2. Verifica códigos de error Zoom:
    Códigos comunes
    124 = Invalid access token
    104 = Invalid access token (scopes)
    300 = Invalid parameter
    200 = Meeting does not exist
    1001 = User does not belong to account
    3000 = Cannot access webinar info
  3. Consulta documentación oficial:
  4. Checklist Final: Verifica los pasos que ya intentaste

✅ Checklist Final de Verificación:

Item ¿Cómo verificar? Estado
App creada en Marketplace marketplace.zoom.us → "Manage"
App tipo OAuth 2.0 User-Level (Cuenta Gratuita) Tipo visible en dashboard
Scopes configurados App → "Scopes" → Ver lista
App activada Estado: "Activated" (verde)
Credenciales correctas Client ID, Client ID, Secret copiados
Access Token obtenido Ejecutar test_zoom_auth.py
requests instalado pip list | Select-String requests
Cuenta Zoom PRO/Superior zoom.us/profile → Ver plan

7. 📖 Glosario de Términos

OAuth 2.0 User-Level (Cuenta Gratuita):

Método de autenticación de Zoom para aplicaciones backend que no requieren interacción del usuario. Reemplazó a JWT en 2023.

Access Token:

Token temporal (válido 1 hora) que se usa para autenticar peticiones a la API de Zoom. Se obtiene usando credenciales OAuth.

Meeting ID:

Identificador numérico único de cada reunión de Zoom (ej: 123 456 789). Se usa para unirse y gestionar la reunión.

Join URL:

Enlace web que permite a los participantes unirse a una reunión de Zoom directamente desde el navegador o app.

Start URL:

Enlace especial para que el host inicie la reunión. Solo debe compartirse con el organizador.

Webhook:

Notificación HTTP que Zoom envía a tu servidor cuando ocurre un evento (reunión iniciada, participante entró, etc.).

Waiting Room (Sala de Espera):

Función de seguridad que retiene a participantes antes de permitirles entrar a la reunión. El host los admite manualmente.

Webinar:

Tipo especial de reunión con roles diferenciados: panelistas (pueden hablar/compartir) y asistentes (solo ven/escuchan).

PMI (Personal Meeting ID):

ID de reunión personal permanente asignado a cada usuario. Alternativa a crear nuevas reuniones cada vez.

Recurring Meeting:

Reunión que se repite con cierta frecuencia (diaria, semanal, mensual). Usa el mismo Meeting ID en todas las ocurrencias.

📦 ENTREGABLES DEL PROYECTO

📋 Checklist Final del Proyecto

Asegúrate de cumplir con TODOS estos requisitos antes de entregar:

✅ 1. Código Fuente Completo

Estructura de carpetas requerida:

Estructura del Proyecto
zoom_project/
├── manage.py
├── requirements.txt                    # ✅ REQUERIDO
├── README.md                           # ✅ REQUERIDO
├── .gitignore                          # ✅ REQUERIDO
├── .env.example                        # ✅ Template de variables
├── zoom_credentials_TEMPLATE.txt      # ✅ Template sin credenciales reales
├── zoom_project/
│   ├── __init__.py
│   ├── settings.py                     # ✅ Con configuración Zoom
│   ├── urls.py
│   └── wsgi.py
└── reuniones/
    ├── __init__.py
    ├── admin.py                        # ✅ Con modelos registrados
    ├── models.py                       # ✅ Modelo Reunion
    ├── views.py                        # ✅ Todas las views funcionando
    ├── urls.py                         # ✅ Rutas configuradas
    ├── zoom_service.py                 # ✅ Servicio de Zoom API
    ├── templates/
    │   └── reuniones/
    │       ├── base.html               # ✅ REQUERIDO
    │       ├── inicio.html             # ✅ REQUERIDO
    │       ├── crear_reunion.html      # ✅ REQUERIDO
    │       ├── lista_reuniones.html    # ✅ REQUERIDO
    │       └── detalle_reunion.html    # ✅ REQUERIDO
    └── migrations/
        └── (archivos de migraciones)

✅ 2. Archivo README.md Completo

README.md - Template
# 📹 Zoom Meetings Manager - Django + Zoom API

## 📋 Descripción del Proyecto
Sistema de gestión de reuniones de Zoom integrado con Django que permite:
- Crear reuniones automáticamente desde Django
- Programar clases, citas médicas, entrevistas
- Obtener enlaces de reunión únicos
- Listar reuniones programadas
- Webhooks en tiempo real
- Panel de administración completo

## 👨‍🎓 Información del Alumno
- **Nombre Completo:** [TU NOMBRE]
- **Matrícula:** [TU MATRÍCULA]
- **Carrera:** [TU CARRERA]
- **Semestre:** [TU SEMESTRE]
- **Materia:** Servicios Web RESTful
- **Profesor:** [NOMBRE DEL PROFESOR]
- **Ciclo:** 2026-1

## 🚀 Tecnologías Utilizadas
- Python 3.x
- Django 4.2.x
- Zoom API (OAuth 2.0 User-Level (Cuenta Gratuita))
- requests 2.31.0
- MySQL
- Bootstrap 5.3.0
- ngrok (para webhooks)

## ⚙️ Instalación y Configuración

### 1. Clonar el repositorio
```bash
git clone [URL_DE_TU_REPO]
cd zoom_project
```

### 2. Crear entorno virtual
```bash
python -m venv venv
.\venv\Scripts\Activate  # Windows
source venv/bin/activate # Linux/Mac
```

### 3. Instalar dependencias
```bash
pip install -r requirements.txt
```

### 4. Configurar Zoom Marketplace
1. Crea cuenta en https://marketplace.zoom.us
2. Crea app OAuth 2.0 User-Level (Cuenta Gratuita)
3. Configura scopes necesarios
4. Obtén credenciales (Client ID, Client ID, Client Secret)
5. Activa la app

### 5. Configurar variables de entorno
```bash
# Copia el template:
copy .env.example .env

# Edita .env con tus credenciales:
ZOOM_CLIENT_ID  # Solo 2 credenciales (no Client ID)=abc123XYZ
ZOOM_CLIENT_ID=A1B2C3D4E5F6G7H8
ZOOM_CLIENT_SECRET=ABC123def456GHI789
```

### 6. Aplicar migraciones
```bash
python manage.py makemigrations
python manage.py migrate
```

### 7. Crear superusuario
```bash
python manage.py createsuperuser
```

### 8. Ejecutar servidor
```bash
python manage.py runserver
```

### 9. Acceder al sistema
- Frontend: http://127.0.0.1:8000/
- Admin: http://127.0.0.1:8000/admin/

## 📸 Capturas de Pantalla
(Ver carpeta `screenshots/`)
1. Dashboard principal
2. Formulario crear reunión
3. Lista de reuniones
4. Vista detallada
5. Zoom Marketplace app
6. Reunión creada en Zoom app

## 🧪 Pruebas Realizadas
- ✅ Autenticación OAuth con Zoom verificada
- ✅ Crear reunión funcional
- ✅ Listar reuniones desde BD y API
- ✅ Actualizar/eliminar reuniones
- ✅ Webhooks recibiendo eventos
- ✅ Templates renderizando correctamente

## 🔐 Seguridad
- ⚠️ `.env` está en `.gitignore`
- ⚠️ Credenciales NO incluidas en el código
- ⚠️ Variables de entorno para producción
- ⚠️ CSRF protection habilitado

## 📝 Notas Adicionales
[Agrega aquí cualquier nota relevante sobre tu implementación]

## 📚 Referencias
- Zoom API: https://developers.zoom.us/
- Zoom Marketplace: https://marketplace.zoom.us/
- Django Docs: https://docs.djangoproject.com/

## 📄 Licencia
Este proyecto es para fines educativos - UTH 2026-1

✅ 3. Archivo requirements.txt

requirements.txt - Ejemplo
Django==4.2.9
requests==2.31.0
mysqlclient==2.2.1
python-dotenv==1.0.0

# NOTA: Genera el tuyo con:
# pip freeze > requirements.txt

✅ 4. Archivo .gitignore

.gitignore
# Python
__pycache__/
*.py[cod]
*.so
venv/
ENV/

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

# Credentials (IMPORTANTE)
.env
zoom_credentials.txt
*.key
*.pem

# IDE
.vscode/
.idea/

# OS
.DS_Store
Thumbs.db

✅ 5. Template de Variables de Entorno

.env.example
# Zoom API Credentials
ZOOM_CLIENT_ID  # Solo 2 credenciales (no Client ID)=your_client_id_here
ZOOM_CLIENT_ID=your_client_id_here
ZOOM_CLIENT_SECRET=your_client_secret_here

# Database
DB_NAME=zoom_db
DB_USER=root
DB_PASSWORD=your_password_here
DB_HOST=localhost
DB_PORT=3306

# Django
SECRET_KEY=your_django_secret_key_here
DEBUG=True

✅ 6. Checklist Final de Entrega

🎯 Verifica que tengas TODO esto:

Item Estado Notas
☐ Código fuente completo Todos los archivos .py
☐ requirements.txt Con todas las dependencias
☐ README.md completo Con tu información personal
☐ .gitignore configurado SIN credenciales en repo
☐ 12 capturas de pantalla En carpeta screenshots/
☐ .env.example Template sin credenciales reales
☐ App Zoom activada Estado: Activated (verde)
☐ BD con datos de prueba Al menos 5 reuniones
☐ Sistema funcionando runserver sin errores
☐ Reporte en Word/PDF Con todas las secciones
☐ Video demostración (opcional) 5-10 min mostrando funcionalidad
☐ Reunión visible en Zoom app Prueba end-to-end completa

⚠️ ERRORES COMUNES QUE DEBES EVITAR:

  • ❌ Subir `.env` con credenciales reales a GitHub
  • ❌ No incluir `requirements.txt` o que esté incompleto
  • ❌ README.md genérico sin tu información personal
  • ❌ Capturas borrosas o sin contexto
  • ❌ App de Zoom en estado "Development" (debe estar "Activated")
  • ❌ Base de datos vacía sin datos de prueba
  • ❌ Código con errores que impiden ejecutar servidor
  • ❌ Hardcodear credenciales en settings.py
  • ❌ No incluir .gitignore
  • ❌ Reporte sin capturas de pantalla
  • ❌ No probar que la reunión aparezca en Zoom app
  • ❌ Usar JWT (deprecado) en lugar de OAuth

🎉 ¡Felicidades si completaste todo!

Has desarrollado un sistema completo que:

  • ✅ Integra Zoom API con OAuth 2.0 moderno
  • ✅ Crea reuniones automáticamente desde Django
  • ✅ Gestiona reuniones con CRUD completo
  • ✅ Tiene interfaz de usuario moderna
  • ✅ Implementa webhooks en tiempo real
  • ✅ Sigue mejores prácticas de seguridad
  • ✅ Está documentado profesionalmente

Este proyecto demuestra tu capacidad para:

  • 🎯 Integrar APIs de terceros (Zoom)
  • 🎯 Implementar OAuth 2.0 OAuth User-Level
  • 🎯 Crear sistemas web funcionales
  • 🎯 Manejar webhooks y eventos en tiempo real
  • 🎯 Resolver problemas técnicos de forma independiente