¡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:
- 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)
- 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
- 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
- Usuario solicita crear reunión:
- Llena formulario: tema, fecha/hora, duración
- Envía petición POST a Django
- Django llama a Zoom API:
POST https://api.zoom.us/v2/users/me/meetings- Envía JSON con configuración de reunión
- 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)
- ID de reunión:
- Django guarda reunión en BD MySQL:
- Tabla:
reuniones - Campos: zoom_meeting_id, tema, start_time, join_url, password
- Tabla:
- Usuario recibe enlace de reunión:
- Email automático con URL y contraseña
- Notificación en la app
- Puede copiar enlace para compartir
- 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:
- 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)
- 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 - 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"] - 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']}") - 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
- El token expira en 1 hora (
Componentes de Zoom API (Explicados)
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.
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.
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.
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.
Definen qué puede hacer tu app. Ejemplos:
meeting:write- Crear y modificar reunionesmeeting:read- Leer información de reunionesuser:read- Leer información de usuarioswebinar:write- Crear webinarsrecording: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
{
"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)
- Aplicación Django solicita Access Token a Zoom
- Envía
Client ID + Client ID + Client Secret - Zoom OAuth Server valida credenciales
- Retorna Access Token (válido 1 hora)
- Django usa el token para hacer peticiones API
- Al expirar, se solicita nuevo token automáticamente
Diagrama de Componentes
┌─────────────────┐
│ 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í.
- 📌 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"
- 📌 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. - 📌 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)
- Escribe:
- 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)
- App name (campo de texto):
- 📌 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
- Escribe:
- Long description (campo de texto largo):
- Escribe:
Aplicación educativa para gestionar reuniones de Zoom mediante Django
- Escribe:
- 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)
- Short description (campo de texto):
- 📌 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"
- 📌 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"
- 📌 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://NOhttps://para localhost
- Botón al final: Haz clic en "Continue"
- 📌 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
- 📌 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:meetingmeeting:read:meetinguser: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
# 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
# ========================================
# 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
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
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
# 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
# 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
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:
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
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)
<!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)
{% 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)
{% 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)
{% 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)
{% 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:
- Hardcodear Client ID y Client Secret en código
- Subir credenciales a GitHub público
- Compartir Access Token entre usuarios
- Usar HTTP (sin SSL/TLS) para webhooks
- Guardar contraseñas de reunión en logs
- 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
- Zoom API Reference: developers.zoom.us/docs/api/
- OAuth 2.0 User-Level (Cuenta Gratuita) Guide: developers.zoom.us/docs/internal-apps/
- Webhook Events: developers.zoom.us/docs/api/rest/webhook-reference/
- Django Documentation: docs.djangoproject.com/
🆘 SOLUCIÓN DE PROBLEMAS (TROUBLESHOOTING)
📋 Guía Rápida de Diagnóstico
Si algo no funciona, sigue este orden:
- 🔍 Lee el mensaje de error COMPLETO
- 📝 Identifica el tipo de error (OAuth, API, código, etc.)
- 🔧 Busca la solución específica en las secciones de abajo
- ✅ Verifica el fix ejecutando el código de nuevo
- ❓ Si persiste, revisa logs y contacta soporte
🔴 Problema 1: Error de Autenticación OAuth 401 Unauthorized
❌ Síntomas:
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
- 🔍 Abre tu archivo de configuración de Zoom
- 🔑 Verifica que tengas las 3 credenciales:
ZOOM_CLIENT_ID # Solo 2 credenciales (no Client ID)- Formato: abc123XYZZOOM_CLIENT_ID- Formato: A1B2C3D4E5F6G7H8ZOOM_CLIENT_SECRET- Formato: ABC123def456GHI789- 🌐 Ve a marketplace.zoom.us
- 📋 Abre tu app → Pestaña "App Credentials"
- 🔍 Compara credenciales (copia/pega para evitar errores)
Solución 2: Verificar que la App esté Activada
- 🌐 Ve a Zoom Marketplace
- 📱 Abre tu app
- 👁️ Verifica estado en la parte superior
- ✅ 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
# 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
- 🌐 Marketplace → tu app → "App Credentials"
- 🔄 Haz clic en "Regenerate" junto a Client Secret
- 📋 COPIA el nuevo secret inmediatamente (solo se muestra una vez)
- 💾 Actualiza tu configuración Django/Python
- 🔄 Reinicia el servidor Django
Solución 5: Script de Prueba de Autenticació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:
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)
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
# 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:
# 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:
ModuleNotFoundError: No module named 'requests'
✅ Soluciones:
Solución 1: Verificar Entorno Virtual Activado
# 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
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 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
- 🌐 Ve a Marketplace → tu app → "Feature"
- 📋 En "Event Subscriptions", agrega tu URL de ngrok:
https://abc123.ngrok.io/zoom/webhook/- ✅ Zoom enviará request de verificación
- 🔍 Verifica que Django responda correctamente
Solución 3: Implementar Endpoint de Verificación
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:
{
"code": 1001,
"message": "User does not belong to this account."
}
✅ Soluciones:
Solución 1: Usar "me" en lugar de User ID específico
# ✅ 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)
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
- 📱 Abre la app de Zoom
- 🔄 Haz clic en "Reuniones"
- ⬇️ Jala hacia abajo para refrescar
- ⏳ Espera 10-20 segundos
- ✅ La reunión debería aparecer
Solución 3: Verificar Tipo 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:
- Revisa logs de Django:
PowerShell
python manage.py runserver --noreload - 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 - Consulta documentación oficial:
- 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
Método de autenticación de Zoom para aplicaciones backend que no requieren interacción del usuario. Reemplazó a JWT en 2023.
Token temporal (válido 1 hora) que se usa para autenticar peticiones a la API de Zoom. Se obtiene usando credenciales OAuth.
Identificador numérico único de cada reunión de Zoom (ej: 123 456 789). Se usa para unirse y gestionar la reunión.
Enlace web que permite a los participantes unirse a una reunión de Zoom directamente desde el navegador o app.
Enlace especial para que el host inicie la reunión. Solo debe compartirse con el organizador.
Notificación HTTP que Zoom envía a tu servidor cuando ocurre un evento (reunión iniciada, participante entró, etc.).
Función de seguridad que retiene a participantes antes de permitirles entrar a la reunión. El host los admite manualmente.
Tipo especial de reunión con roles diferenciados: panelistas (pueden hablar/compartir) y asistentes (solo ven/escuchan).
ID de reunión personal permanente asignado a cada usuario. Alternativa a crear nuevas reuniones cada vez.
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:
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
# 📹 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
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
# 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
# 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