diff --git a/IMPORTACION_CURSOS_MYSQL.md b/IMPORTACION_CURSOS_MYSQL.md new file mode 100644 index 0000000..7f339a5 --- /dev/null +++ b/IMPORTACION_CURSOS_MYSQL.md @@ -0,0 +1,296 @@ +# Importación de Cursos desde MySQL + +## Descripción + +OpenCCB ahora soporta la importación completa de cursos desde una base de datos MySQL externa. Esta funcionalidad permite: + +1. **Importar metadatos** de cursos y planes de estudio +2. **Importar preguntas** del banco de preguntas MySQL +3. **Crear cursos completos** con estructura básica (módulos y lecciones) + +## Endpoints Implementados + +### 1. Importar Metadatos (Cursos y Planes) + +```bash +POST /question-bank/import-mysql-all +Content-Type: application/json +Authorization: Bearer + +{ + "import_metadata_only": true +} +``` + +**Respuesta:** +```json +{ + "success": true, + "message": "Metadatos importados exitosamente", + "metadata": { + "study_plans_imported": 5, + "courses_imported": 20, + "courses": [ + { + "id_cursos": 1, + "nombre_curso": "Inglés Básico", + "nombre_plan": "Plan Regular", + "duracion": 40, + "nivel_curso": 2 + } + ] + } +} +``` + +### 2. Ver Cursos Disponibles + +```bash +GET /question-bank/mysql-courses +Authorization: Bearer +``` + +**Respuesta:** +```json +[ + { + "id_cursos": 1, + "nombre_curso": "Inglés Básico", + "nombre_plan": "Plan Regular", + "duracion": 40, + "nivel_curso": 2 + }, + { + "id_cursos": 2, + "nombre_curso": "Inglés Intermedio", + "nombre_plan": "Plan Intensivo", + "duracion": 80, + "nivel_curso": 6 + } +] +``` + +### 3. Importar Curso Completo con Estructura + +```bash +POST /question-bank/import-course-mysql +Content-Type: application/json +Authorization: Bearer + +{ + "mysql_course_id": 1, + "title": "Opcional: Título personalizado", + "description": "Opcional: Descripción del curso", + "pacing_mode": "self_paced" +} +``` + +**Respuesta:** +```json +{ + "course_id": "uuid-del-curso", + "course_title": "Inglés Básico (Plan Regular)", + "mysql_course_id": 1, + "modules_created": 4, + "lessons_created": 20, + "message": "Curso 'Inglés Básico (Plan Regular)' importado exitosamente con 4 módulos y 20 lecciones" +} +``` + +## Estructura del Curso Importado + +Al importar un curso desde MySQL, se crea automáticamente una estructura básica basada en la duración del curso: + +### Cursos Regulares (40 horas) +- **4 módulos** con 5 lecciones cada uno (20 lecciones total) + - Módulo 1: Introducción y Fundamentos + - Módulo 2: Gramática Básica + - Módulo 3: Vocabulario Esencial + - Módulo 4: Práctica Integradora + +### Cursos Intensivos (80 horas) +- **8 módulos** con 4-6 lecciones cada uno (46 lecciones total) + - Módulo 1-2: Fundamentos Básicos y Gramática Esencial + - Módulo 3-4: Vocabulario Intermedio y Comprensión Auditiva + - Módulo 5-6: Expresión Oral y Lectura/Escritura + - Módulo 7-8: Práctica Avanzada y Proyecto Final + +### Tipos de Lecciones +Las lecciones se crean rotando entre diferentes tipos de contenido: +1. **video** - Contenido de video +2. **document** - Documentos de lectura (PDF, DOCX) +3. **interactive** - Actividades interactivas +4. **quiz** - Evaluaciones (cada 4ta lección es graded) + +## Scripts de Importación + +### Script Completo (Recomendado) + +```bash +./scripts/import_courses_mysql.sh +``` + +Este script: +1. Obtiene el token de autenticación +2. Importa todos los metadatos de cursos y planes +3. Muestra la lista de cursos disponibles +4. Permite importar un curso específico o todos + +### Script Manual (Paso a Paso) + +```bash +# 1. Obtener token +TOKEN=$(curl -s -X POST "http://localhost:3001/auth/login" \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@norteamericano.cl","password":"Admin123!"}' \ + | jq -r '.token') + +# 2. Importar metadatos +curl -X POST "http://localhost:3001/question-bank/import-mysql-all" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"import_metadata_only": true}' + +# 3. Ver cursos disponibles +curl -X GET "http://localhost:3001/question-bank/mysql-courses" \ + -H "Authorization: Bearer $TOKEN" | jq . + +# 4. Importar un curso específico +curl -X POST "http://localhost:3001/question-bank/import-course-mysql" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"mysql_course_id": 1}' +``` + +## Configuración Requerida + +### Variables de Entorno + +Asegúrate de configurar las siguientes variables en tu `.env`: + +```bash +# Conexión a MySQL externa +MYSQL_DATABASE_URL=mysql://usuario:contraseña@host:3306/base_de_datos + +# Credenciales de admin para importación +EMAIL="admin@norteamericano.cl" +PASSWORD="Admin123!" +``` + +### Estructura de la Base de Datos MySQL + +La base de datos MySQL debe tener las siguientes tablas: + +```sql +-- Planes de estudio +plandeestudios ( + idPlanDeEstudios INT PRIMARY KEY, + Nombre VARCHAR(255), + Activo BOOLEAN +) + +-- Cursos +curso ( + idCursos INT PRIMARY KEY, + idPlanDeEstudios INT, + NombreCurso VARCHAR(255), + NivelCurso INT, + Duracion INT, + Activo BOOLEAN, + FOREIGN KEY (idPlanDeEstudios) REFERENCES plandeestudios(idPlanDeEstudios) +) + +-- Banco de preguntas (opcional) +bancopreguntas ( + idPregunta INT PRIMARY KEY, + idCursos INT, + idPlanDeEstudios INT, + idTipoPregunta INT, + descripcion TEXT, + activo BOOLEAN, + FOREIGN KEY (idCursos) REFERENCES curso(idCursos) +) + +-- Respuestas (opcional) +bancorespuestas ( + idRespuesta INT PRIMARY KEY, + idPregunta INT, + descripcion TEXT, + resultado INT, + activo BOOLEAN, + FOREIGN KEY (idPregunta) REFERENCES bancopreguntas(idPregunta) +) +``` + +## Flujo de Importación Recomendado + +1. **Primera vez:** + ```bash + ./scripts/import_courses_mysql.sh + ``` + - Selecciona "all" para importar todos los cursos + +2. **Actualizaciones posteriores:** + ```bash + # Solo importar nuevos cursos + ./scripts/import_courses_mysql.sh + # Selecciona los cursos específicos que faltan + ``` + +3. **Importar solo preguntas:** + ```bash + ./scripts/import_mysql.sh + ``` + +## Solución de Problemas + +### Error: "MYSQL_DATABASE_URL not configured" +**Solución:** Agrega la variable `MYSQL_DATABASE_URL` en tu `.env` + +### Error: "Course with ID X not found in MySQL" +**Solución:** Verifica que el curso exista en la base de datos MySQL y que `Activo = 1` + +### Error: "Failed to connect to MySQL" +**Solución:** +- Verifica que la conexión a MySQL esté disponible +- Comprueba las credenciales en `MYSQL_DATABASE_URL` +- Asegúrate de que el firewall permita la conexión + +### Los cursos importados no tienen contenido +**Solución:** Esto es esperado. Los cursos se importan con una estructura básica (módulos y lecciones vacías). Debes completar el contenido de cada lección desde el Studio. + +## Notas Importantes + +1. **Los cursos importados no sobrescriben existentes** - Cada importación crea un nuevo curso +2. **El campo `imported_mysql_course_id`** se usa para rastrear el origen del curso +3. **Las preguntas del banco de preguntas** se importan por separado con `import_mysql.sh` +4. **La estructura generada es básica** - Debes personalizar el contenido de las lecciones + +## Ejemplo de Uso con curl + +```bash +# Login +RESPONSE=$(curl -s -X POST "http://localhost:3001/auth/login" \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@norteamericano.cl","password":"Admin123!"}') + +TOKEN=$(echo $RESPONSE | jq -r '.token') + +# Importar curso ID 5 +curl -X POST "http://localhost:3001/question-bank/import-course-mysql" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{ + "mysql_course_id": 5, + "title": "Curso Personalizado", + "description": "Descripción personalizada del curso", + "pacing_mode": "instructor_led" + }' | jq . +``` + +## Próximas Mejoras + +- [ ] Importación de contenido específico desde MySQL (si existe) +- [ ] Mapeo de prerrequisitos entre cursos +- [ ] Importación de usuarios inscritos +- [ ] Sincronización automática de cambios en MySQL diff --git a/scripts/import_courses_mysql.sh b/scripts/import_courses_mysql.sh new file mode 100755 index 0000000..5240cb1 --- /dev/null +++ b/scripts/import_courses_mysql.sh @@ -0,0 +1,149 @@ +#!/bin/bash + +# Import complete courses from MySQL with basic structure (modules and lessons) + +CMS_API_URL="http://localhost:3001" +EMAIL="admin@norteamericano.cl" +PASSWORD="Admin123!" + +echo "📚 Importación de Cursos desde MySQL" +echo "======================================" +echo "" + +# Step 1: Login to get JWT token +echo "🔑 Obteniendo token de autenticación..." +TOKEN=$(curl -s -X POST "$CMS_API_URL/auth/login" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\"}" \ + | jq -r '.token') + +if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then + echo "❌ Error: No se pudo obtener el token. Verifica las credenciales." + echo " Credenciales usadas: $EMAIL / $PASSWORD" + exit 1 +fi + +echo "✅ Token obtenido: ${TOKEN:0:30}..." +echo "" + +# Step 2: Import metadata first (courses and plans) +echo "📋 Importando metadatos de cursos y planes..." +METADATA_RESULT=$(curl -s -X POST "$CMS_API_URL/question-bank/import-mysql-all" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"import_metadata_only": true}') + +echo "📊 Resultado:" +echo "$METADATA_RESULT" | jq '.metadata' 2>/dev/null || echo "$METADATA_RESULT" + +STUDY_PLANS=$(echo "$METADATA_RESULT" | jq -r '.metadata.study_plans_imported // 0') +COURSES=$(echo "$METADATA_RESULT" | jq -r '.metadata.courses_imported // 0') + +echo "" +echo "✅ Metadatos importados:" +echo " - Planes de estudio: $STUDY_PLANS" +echo " - Cursos: $COURSES" +echo "" + +# Step 3: Show available courses +echo "📚 Cursos disponibles para importar:" +echo "-------------------------------------" +COURSE_LIST=$(curl -s -X GET "$CMS_API_URL/question-bank/mysql-courses" \ + -H "Authorization: Bearer $TOKEN") + +echo "$COURSE_LIST" | jq -r '.[] | " ID: \(.id_cursos) | \(.nombre_curso) | Plan: \(.nombre_plan) | Duración: \(.duracion)h"' + +echo "" + +# Step 4: Ask user which course to import +read -p "Ingresa el ID del curso a importar (o 'all' para importar todos, o 'q' para salir): " COURSE_ID + +if [ "$COURSE_ID" = "q" ]; then + echo "👋 Operación cancelada" + exit 0 +fi + +if [ "$COURSE_ID" = "all" ]; then + echo "" + echo "📦 Importando TODOS los cursos..." + + # Get all course IDs + COURSE_IDS=$(echo "$COURSE_LIST" | jq -r '.[].id_cursos') + + TOTAL=0 + SUCCESS=0 + FAILED=0 + + for CID in $COURSE_IDS; do + TOTAL=$((TOTAL + 1)) + echo "" + echo "📦 Importando curso $CID ($TOTAL/$COURSES)..." + + RESULT=$(curl -s -X POST "$CMS_API_URL/question-bank/import-course-mysql" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d "{\"mysql_course_id\": $CID}") + + if echo "$RESULT" | jq -e '.course_id' > /dev/null 2>&1; then + MESSAGE=$(echo "$RESULT" | jq -r '.message') + echo "✅ $MESSAGE" + SUCCESS=$((SUCCESS + 1)) + else + ERROR=$(echo "$RESULT" | jq -r '.error // "Error desconocido"') + echo "❌ Error al importar curso $CID: $ERROR" + FAILED=$((FAILED + 1)) + fi + done + + echo "" + echo "=== Resumen ===" + echo "✅ Cursos importados exitosamente: $SUCCESS" + echo "❌ Cursos fallidos: $FAILED" + echo "📊 Total procesados: $TOTAL" + +else + # Import single course + echo "" + echo "📦 Importando curso ID: $COURSE_ID..." + + RESULT=$(curl -s -X POST "$CMS_API_URL/question-bank/import-course-mysql" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d "{\"mysql_course_id\": $COURSE_ID}") + + echo "📋 Resultado:" + echo "$RESULT" | jq . + + if echo "$RESULT" | jq -e '.course_id' > /dev/null 2>&1; then + COURSE_TITLE=$(echo "$RESULT" | jq -r '.course_title') + MODULES=$(echo "$RESULT" | jq -r '.modules_created') + LESSONS=$(echo "$RESULT" | jq -r '.lessons_created') + + echo "" + echo "✅ ¡Curso importado exitosamente!" + echo " - Título: $COURSE_TITLE" + echo " - Módulos creados: $MODULES" + echo " - Lecciones creadas: $LESSONS" + echo "" + echo "💡 Puedes ver el curso en: http://localhost:3000/courses" + else + ERROR=$(echo "$RESULT" | jq -r '.error // .message // "Error desconocido"') + echo "" + echo "❌ Error al importar el curso: $ERROR" + exit 1 + fi +fi + +echo "" +echo "=== Comandos Útiles ===" +echo "" +echo "📋 Ver cursos disponibles:" +echo " curl -X GET http://localhost:3001/question-bank/mysql-courses \\" +echo " -H \"Authorization: Bearer \$TOKEN\"" +echo "" +echo "📦 Importar un curso específico:" +echo " curl -X POST http://localhost:3001/question-bank/import-course-mysql \\" +echo " -H \"Content-Type: application/json\" \\" +echo " -H \"Authorization: Bearer \$TOKEN\" \\" +echo " -d '{\"mysql_course_id\": }'" +echo "" diff --git a/scripts/import_mysql.sh b/scripts/import_mysql.sh index 3e401bf..6e3d252 100755 --- a/scripts/import_mysql.sh +++ b/scripts/import_mysql.sh @@ -30,8 +30,20 @@ fi echo "✅ Token obtenido: ${TOKEN:0:20}..." -# Step 2: Import all from MySQL -echo "📊 Importando cursos, planes y preguntas desde MySQL..." +# Step 3: Get list of courses from MySQL +echo "" +echo "📋 Obteniendo lista de cursos desde MySQL..." +COURSES_RESULT=$(curl -s -X POST "$CMS_API_URL/question-bank/import-mysql-all" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"import_metadata_only": true}') + +echo "📊 Metadatos de cursos importados:" +echo "$COURSES_RESULT" | jq '.metadata // empty' 2>/dev/null || echo "$COURSES_RESULT" + +# Step 4: Import all questions from MySQL +echo "" +echo "📊 Importando preguntas desde MySQL..." RESULT=$(curl -s -X POST "$CMS_API_URL/question-bank/import-mysql-all" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TOKEN") @@ -46,3 +58,46 @@ if [ "$IMPORTED" != "null" ] && [ "$IMPORTED" -gt 0 ]; then else echo "⚠️ Revisa el resultado para más detalles" fi + +# Step 5: Import courses with structure (optional - uncomment to enable) +# echo "" +# echo "📚 Importando cursos con estructura básica..." +# +# # Get course list +# COURSE_LIST=$(curl -s -X GET "$CMS_API_URL/question-bank/mysql-courses" \ +# -H "Authorization: Bearer $TOKEN") +# +# echo "📋 Cursos disponibles:" +# echo "$COURSE_LIST" | jq '.[] | {id: .id_cursos, name: .nombre_curso, plan: .nombre_plan}' +# +# # Import first course as example +# FIRST_COURSE_ID=$(echo "$COURSE_LIST" | jq -r '.[0].id_cursos') +# if [ "$FIRST_COURSE_ID" != "null" ] && [ -n "$FIRST_COURSE_ID" ]; then +# echo "" +# echo "📦 Importando curso de ejemplo (ID: $FIRST_COURSE_ID)..." +# COURSE_IMPORT=$(curl -s -X POST "$CMS_API_URL/question-bank/import-course-mysql" \ +# -H "Content-Type: application/json" \ +# -H "Authorization: Bearer $TOKEN" \ +# -d "{\"mysql_course_id\": $FIRST_COURSE_ID}") +# +# echo "📋 Resultado:" +# echo "$COURSE_IMPORT" | jq . +# +# MESSAGE=$(echo "$COURSE_IMPORT" | jq -r '.message // "Error al importar"') +# echo "✅ $MESSAGE" +# fi + +echo "" +echo "=== Resumen ===" +echo "✅ Metadatos de cursos y planes importados" +echo "✅ Preguntas importadas desde MySQL" +echo "" +echo "💡 Para importar un curso completo con estructura básica, usa:" +echo " curl -X POST http://localhost:3001/question-bank/import-course-mysql \\" +echo " -H \"Content-Type: application/json\" \\" +echo " -H \"Authorization: Bearer \$TOKEN\" \\" +echo " -d '{\"mysql_course_id\": }'" +echo "" +echo "📋 Para ver los cursos disponibles, usa:" +echo " curl -X GET http://localhost:3001/question-bank/mysql-courses \\" +echo " -H \"Authorization: Bearer \$TOKEN\"" diff --git a/services/cms-service/src/handlers_question_bank.rs b/services/cms-service/src/handlers_question_bank.rs index 479a932..811dcd5 100644 --- a/services/cms-service/src/handlers_question_bank.rs +++ b/services/cms-service/src/handlers_question_bank.rs @@ -836,7 +836,7 @@ pub struct MySqlCoursesFilters { pub plan_id: i32, } -#[derive(Debug, sqlx::FromRow, Serialize)] +#[derive(Debug, Clone, sqlx::FromRow, Serialize)] pub struct MySqlPlanInfo { #[sqlx(rename = "idPlanDeEstudios")] #[serde(rename = "idPlanDeEstudios")] @@ -846,12 +846,42 @@ pub struct MySqlPlanInfo { pub nombre_plan: String, } +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct MySqlCourseInfo { + #[sqlx(rename = "idCursos")] + #[serde(rename = "idCursos")] + pub id_cursos: i32, + #[sqlx(rename = "NombreCurso")] + #[serde(rename = "NombreCurso")] + pub nombre_curso: String, + #[sqlx(rename = "NivelCurso")] + #[serde(rename = "NivelCurso", skip_serializing_if = "Option::is_none")] + pub nivel_curso: Option, + #[sqlx(rename = "idPlanDeEstudios")] + #[serde(rename = "idPlanDeEstudios")] + pub id_plan_de_estudios: i32, + #[sqlx(rename = "NombrePlan")] + #[serde(rename = "NombrePlan")] + pub nombre_plan: String, + #[sqlx(rename = "Duracion")] + #[serde(rename = "Duracion", skip_serializing_if = "Option::is_none")] + pub duracion: Option, // Duration in hours (40=regular, 80=intensive) +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ImportAllFromMySQLPayload { + pub import_metadata_only: Option, // Only import metadata (courses/plans), not questions +} + /// POST /api/question-bank/import-mysql-all - Import ALL questions from MySQL (bulk import) pub async fn import_all_from_mysql( Org(org_ctx): Org, claims: Claims, State(pool): State, -) -> Result, (StatusCode, String)> { + Json(payload): Json, +) -> Result, (StatusCode, String)> { + use serde_json::json; + // Connect to MySQL let mysql_url = std::env::var("MYSQL_DATABASE_URL") .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "MYSQL_DATABASE_URL not configured".to_string()))?; @@ -901,10 +931,29 @@ pub async fn import_all_from_mysql( // Save plans and courses to PostgreSQL tracing::info!("Saving plans and courses to PostgreSQL..."); - save_mysql_courses_and_plans(&pool, org_ctx.id, mysql_plans, mysql_courses) + save_mysql_courses_and_plans(&pool, org_ctx.id, mysql_plans.clone(), mysql_courses.clone()) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to save courses/plans: {}", e)))?; + // If only metadata import is requested, return early + if payload.import_metadata_only.unwrap_or(false) { + return Ok(Json(json!({ + "success": true, + "message": "Metadatos importados exitosamente", + "metadata": { + "study_plans_imported": mysql_plans.len(), + "courses_imported": mysql_courses.len(), + "courses": mysql_courses.iter().map(|c| json!({ + "id_cursos": c.id_cursos, + "nombre_curso": c.nombre_curso, + "nombre_plan": c.nombre_plan, + "duracion": c.duracion, + "nivel_curso": c.nivel_curso + })).collect::>() + } + }))); + } + // Fetch ALL questions from MySQL with answers (using JSON aggregation for answers) let mysql_questions: Vec = sqlx::query_as( r#" @@ -948,14 +997,15 @@ pub async fn import_all_from_mysql( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch questions: {}", e)))?; mysql_pool.close().await; - + if mysql_questions.is_empty() { - return Ok(Json(ImportResult { - imported: 0, - skipped: 0, - updated: 0, - error: Some("No questions found in MySQL".to_string()), - })); + return Ok(Json(json!({ + "success": false, + "imported": 0, + "skipped": 0, + "updated": 0, + "error": "No questions found in MySQL" + }))); } // Import questions into PostgreSQL @@ -1051,13 +1101,17 @@ pub async fn import_all_from_mysql( skipped_count, updated_count ); - - Ok(Json(ImportResult { - imported: imported_count, - skipped: skipped_count, - updated: updated_count, - error: None, - })) + + Ok(Json(json!({ + "success": true, + "imported": imported_count, + "skipped": skipped_count, + "updated": updated_count, + "metadata": { + "study_plans_imported": mysql_plans.len(), + "courses_imported": mysql_courses.len() + } + }))) } #[derive(Debug, Serialize)] @@ -1068,28 +1122,6 @@ pub struct ImportResult { pub error: Option, } -#[derive(Debug, sqlx::FromRow, Serialize, Deserialize)] -pub struct MySqlCourseInfo { - #[sqlx(rename = "idCursos")] - #[serde(rename = "idCursos")] - pub id_cursos: i32, - #[sqlx(rename = "NombreCurso")] - #[serde(rename = "NombreCurso")] - pub nombre_curso: String, - #[sqlx(rename = "NivelCurso")] - #[serde(rename = "NivelCurso", skip_serializing_if = "Option::is_none")] - pub nivel_curso: Option, - #[sqlx(rename = "idPlanDeEstudios")] - #[serde(rename = "idPlanDeEstudios")] - pub id_plan_de_estudios: i32, - #[sqlx(rename = "NombrePlan")] - #[serde(rename = "NombrePlan")] - pub nombre_plan: String, - #[sqlx(rename = "Duracion")] - #[serde(rename = "Duracion", skip_serializing_if = "Option::is_none")] - pub duracion: Option, // Duration in hours (40=regular, 80=intensive) -} - // Excel import - pendiente de fix // /// POST /api/question-bank/import-excel - Import questions from Excel file // pub async fn import_from_excel( @@ -1226,3 +1258,233 @@ pub async fn ai_generate_question( Ok(Json(ai_question)) } + +// ==================== Import Courses from MySQL ==================== + +#[derive(Debug, Serialize, Deserialize)] +pub struct ImportCourseFromMySQLPayload { + pub mysql_course_id: i32, + pub title: Option, // Optional custom title (defaults to MySQL course name) + pub description: Option, // Optional description + pub pacing_mode: Option, // self_paced or instructor_led +} + +#[derive(Debug, Serialize)] +pub struct ImportCourseResult { + pub course_id: Uuid, + pub course_title: String, + pub mysql_course_id: i32, + pub modules_created: i32, + pub lessons_created: i32, + pub message: String, +} + +/// POST /api/question-bank/import-course-mysql - Import a course from MySQL with basic structure +pub async fn import_course_from_mysql( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + use common::models::Course; + + // Connect to MySQL + let mysql_url = std::env::var("MYSQL_DATABASE_URL") + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "MYSQL_DATABASE_URL not configured".to_string()))?; + + let mysql_pool = sqlx::MySqlPool::connect(&mysql_url) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to connect to MySQL: {}", e)))?; + + // Fetch course info from MySQL + let mysql_course: MySqlCourseInfo = sqlx::query_as( + r#" + SELECT + c.idCursos AS id_cursos, + c.NombreCurso AS nombre_curso, + c.NivelCurso AS nivel_curso, + pe.idPlanDeEstudios AS id_plan_de_estudios, + pe.Nombre AS nombre_plan, + CAST(c.Duracion AS SIGNED INTEGER) AS duracion + FROM curso c + JOIN plandeestudios pe ON c.idPlanDeEstudios = pe.idPlanDeEstudios + WHERE c.idCursos = ? AND c.Activo = 1 AND pe.Activo = 1 + "# + ) + .bind(payload.mysql_course_id) + .fetch_one(&mysql_pool) + .await + .map_err(|e| { + if let sqlx::Error::RowNotFound = e { + (StatusCode::NOT_FOUND, format!("Course with ID {} not found in MySQL", payload.mysql_course_id)) + } else { + (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch course from MySQL: {}", e)) + } + })?; + + tracing::info!("Importing course from MySQL: {} (ID: {})", mysql_course.nombre_curso, mysql_course.id_cursos); + + // Start transaction + let mut tx = pool.begin().await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to start transaction: {}", e)))?; + + // Determine course type and level for structure generation + let course_type = calculate_course_type_from_duration(mysql_course.duracion); + let level = calculate_course_level(mysql_course.nivel_curso); + + tracing::info!("Course type: {}, Level: {}", course_type, level); + + // Create course in PostgreSQL + let course_title = payload.title.unwrap_or_else(|| format!("{} ({})", mysql_course.nombre_curso, mysql_course.nombre_plan)); + let pacing_mode = payload.pacing_mode.unwrap_or_else(|| "self_paced".to_string()); + let description = payload.description.unwrap_or_else(|| format!("Curso importado desde MySQL - Plan: {}", mysql_course.nombre_plan)); + + let new_course: Course = sqlx::query_as( + r#" + INSERT INTO courses ( + organization_id, instructor_id, title, pacing_mode, description, + passing_percentage, certificate_template, imported_mysql_course_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING * + "# + ) + .bind(org_ctx.id) + .bind(claims.sub) + .bind(&course_title) + .bind(&pacing_mode) + .bind(&description) + .bind(60.0) // Default passing percentage + .bind(serde_json::json!({ + "template": "default", + "show_logo": true, + "show_instructor": true + })) + .bind(mysql_course.id_cursos) + .fetch_one(&mut *tx) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create course: {}", e)))?; + + tracing::info!("Created course in PostgreSQL: {}", new_course.id); + + // Generate basic course structure based on course type and level + let (modules_count, lessons_count) = generate_course_structure( + &mut tx, + new_course.id, + org_ctx.id, + &course_type, + &level, + &mysql_course.nombre_curso, + ) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + + // Commit transaction + tx.commit().await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to commit transaction: {}", e)))?; + + tracing::info!( + "Successfully imported course {} with {} modules and {} lessons", + new_course.id, + modules_count, + lessons_count + ); + + Ok(Json(ImportCourseResult { + course_id: new_course.id, + course_title: new_course.title.clone(), + mysql_course_id: mysql_course.id_cursos, + modules_created: modules_count, + lessons_created: lessons_count, + message: format!("Curso '{}' importado exitosamente con {} módulos y {} lecciones", + new_course.title, modules_count, lessons_count), + })) +} + +/// Generate basic course structure based on type and level +async fn generate_course_structure<'a>( + tx: &mut sqlx::Transaction<'a, sqlx::Postgres>, + course_id: Uuid, + org_id: Uuid, + course_type: &str, + level: &str, + course_name: &str, +) -> Result<(i32, i32), String> { + // Define module structure based on course type + // Regular (40h): 4 modules + // Intensive (80h): 8 modules + let modules_config = match course_type { + "intensive" => vec![ + ("Fundamentos Básicos", 6), + ("Gramática Esencial", 6), + ("Vocabulario Intermedio", 6), + ("Comprensión Auditiva", 6), + ("Expresión Oral", 6), + ("Lectura y Escritura", 6), + ("Práctica Avanzada", 6), + ("Proyecto Final", 4), + ], + _ => vec![ + ("Introducción y Fundamentos", 5), + ("Gramática Básica", 5), + ("Vocabulario Esencial", 5), + ("Práctica Integradora", 5), + ], + }; + + let mut total_modules = 0; + let mut total_lessons = 0; + + for (module_idx, (module_name, lessons_count)) in modules_config.iter().enumerate() { + // Create module + let module: common::models::Module = sqlx::query_as( + r#" + INSERT INTO modules (course_id, organization_id, title, position) + VALUES ($1, $2, $3, $4) + RETURNING * + "# + ) + .bind(course_id) + .bind(org_id) + .bind(format!("Módulo {}: {}", module_idx + 1, module_name)) + .bind(module_idx as i32) + .fetch_one(&mut **tx) + .await + .map_err(|e| format!("Failed to create module {}: {}", module_idx + 1, e))?; + + total_modules += 1; + + // Create lessons for this module + for lesson_idx in 0..*lessons_count { + let lesson_position = (module_idx * lessons_count + lesson_idx) as i32; + let lesson_title = format!("Lección {}.{}", module_idx + 1, lesson_idx + 1); + + // Determine content type based on position (rotate through types) + let content_types = ["video", "document", "interactive", "quiz"]; + let content_type = content_types[lesson_idx % content_types.len()]; + + sqlx::query( + r#" + INSERT INTO lessons ( + module_id, organization_id, title, content_type, + content_url, position, is_graded, summary + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + "# + ) + .bind(module.id) + .bind(org_id) + .bind(&lesson_title) + .bind(content_type) + .bind("") // Empty content URL (to be filled by instructor) + .bind(lesson_position) + .bind(lesson_idx % 4 == 3) // Every 4th lesson is graded (quiz) + .bind(format!("Contenido de la lección: {}", lesson_title)) + .execute(&mut **tx) + .await + .map_err(|e| format!("Failed to create lesson {}: {}", lesson_position, e))?; + + total_lessons += 1; + } + } + + Ok((total_modules, total_lessons)) +} diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index ed1c189..40c7193 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -356,6 +356,10 @@ async fn main() { "/question-bank/import-mysql-all", post(handlers_question_bank::import_all_from_mysql), ) + .route( + "/question-bank/import-course-mysql", + post(handlers_question_bank::import_course_from_mysql), + ) .route( "/question-bank/ai-generate", post(handlers_question_bank::ai_generate_question),