feat: Translate various strings and comments to Spanish for better localization

- Updated error messages and comments in main.rs, openapi.rs, portfolio.rs, predictive.rs, ai.rs, health.rs, middleware.rs, models.rs, token_limits.rs, and webhooks.rs to Spanish.
- Enhanced user experience by providing localized content for Spanish-speaking users.
This commit is contained in:
2026-04-10 10:26:26 -04:00
parent 7c48b3b1a9
commit 53e5ef4d0b
35 changed files with 1135 additions and 1144 deletions
+172 -172
View File
@@ -92,7 +92,7 @@ async fn connect_mysql_pool(env_var: &str) -> Result<sqlx::MySqlPool, (StatusCod
use sqlx::mysql::MySqlPoolOptions;
let mysql_url = std::env::var(env_var)
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, format!("{} not configured", env_var)))?;
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, format!("{} no configurada", env_var)))?;
let mut last_error = String::new();
@@ -112,7 +112,7 @@ async fn connect_mysql_pool(env_var: &str) -> Result<sqlx::MySqlPool, (StatusCod
Err(e) => {
last_error = e.to_string();
tracing::warn!(
"MySQL connection attempt {}/3 failed for {}: {}",
"Intento de conexión a MySQL {}/3 fallido para {}: {}",
attempt,
env_var,
last_error
@@ -128,13 +128,13 @@ async fn connect_mysql_pool(env_var: &str) -> Result<sqlx::MySqlPool, (StatusCod
Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!(
"Failed to connect to MySQL after 3 attempts: {}",
"Error al conectar a MySQL tras 3 intentos: {}",
last_error
),
))
}
// ==================== MySQL Study Plans & Courses ====================
// ==================== Planes de Estudio y Cursos de MySQL ====================
#[derive(Debug, sqlx::FromRow, Serialize, Deserialize)]
pub struct MySqlStudyPlan {
@@ -163,7 +163,7 @@ pub struct MySqlCourse {
pub updated_at: chrono::DateTime<chrono::Utc>,
}
/// Save or update study plans and courses from MySQL during import
/// Guardar o actualizar planes de estudio y cursos de MySQL durante la importación
pub async fn save_mysql_courses_and_plans(
pool: &PgPool,
org_id: Uuid,
@@ -172,14 +172,14 @@ pub async fn save_mysql_courses_and_plans(
) -> Result<(), String> {
let plans_count = plans.len();
let courses_count = courses.len();
tracing::info!("Saving {} study plans and {} courses from MySQL", plans_count, courses_count);
tracing::info!("Guardando {} planes de estudio y {} cursos de MySQL", plans_count, courses_count);
// Save study plans first
// Guardar planes de estudio primero
for plan in plans {
let course_type = calculate_course_type(&plan.nombre_plan);
tracing::debug!("Saving study plan: {} (ID: {})", plan.nombre_plan, plan.id_plan_de_estudios);
tracing::debug!("Guardando plan de estudios: {} (ID: {})", plan.nombre_plan, plan.id_plan_de_estudios);
// Mirror SAM structure in PostgreSQL using SAM-native column names.
// Reflejar la estructura SAM en PostgreSQL usando nombres de columna nativos de SAM.
sqlx::query(
r#"
INSERT INTO sam_study_plans (organization_id, idPlanDeEstudios, Nombre, Activo)
@@ -195,7 +195,7 @@ pub async fn save_mysql_courses_and_plans(
.bind(&plan.nombre_plan)
.execute(pool)
.await
.map_err(|e| format!("Failed to save SAM study plan mirror: {}", e))?;
.map_err(|e| format!("Error al guardar el reflejo del plan de estudios SAM: {}", e))?;
sqlx::query(
r#"
@@ -213,15 +213,15 @@ pub async fn save_mysql_courses_and_plans(
.bind(&course_type)
.execute(pool)
.await
.map_err(|e| format!("Failed to save study plan: {}", e))?;
.map_err(|e| format!("Error al guardar el plan de estudios: {}", e))?;
}
// Save courses
// Guardar cursos
for course in courses {
// Determine course_type from duration (40h = regular, 80h = intensive)
// Determinar el course_type a partir de la duración (40h = regular, 80h = intensivo)
let course_type = calculate_course_type_from_duration(course.duracion);
let level_calculated = calculate_course_level(course.nivel_curso);
tracing::debug!("Saving course: {} (ID: {}, Plan ID: {})", course.nombre_curso, course.id_cursos, course.id_plan_de_estudios);
tracing::debug!("Guardando curso: {} (ID: {}, ID de Plan: {})", course.nombre_curso, course.id_cursos, course.id_plan_de_estudios);
sqlx::query(
r#"
@@ -246,9 +246,9 @@ pub async fn save_mysql_courses_and_plans(
.bind(course.duracion)
.execute(pool)
.await
.map_err(|e| format!("Failed to save SAM course mirror: {}", e))?;
.map_err(|e| format!("Error al guardar el reflejo del curso SAM: {}", e))?;
// Get study_plan_id from mysql_study_plans
// Obtener study_plan_id de mysql_study_plans
let study_plan_id: i32 = sqlx::query_scalar(
"SELECT id FROM mysql_study_plans WHERE mysql_id = $1 AND organization_id = $2"
)
@@ -256,7 +256,7 @@ pub async fn save_mysql_courses_and_plans(
.bind(org_id)
.fetch_one(pool)
.await
.map_err(|e| format!("Failed to find study plan: {}", e))?;
.map_err(|e| format!("Error al encontrar el plan de estudios: {}", e))?;
sqlx::query(
r#"
@@ -284,10 +284,10 @@ pub async fn save_mysql_courses_and_plans(
.bind(&level_calculated)
.execute(pool)
.await
.map_err(|e| format!("Failed to save course: {}", e))?;
.map_err(|e| format!("Error al guardar el curso: {}", e))?;
}
tracing::info!("Successfully saved {} study plans and {} courses", plans_count, courses_count);
tracing::info!("Se guardaron con éxito {} planes de estudio y {} cursos", plans_count, courses_count);
Ok(())
}
@@ -320,9 +320,9 @@ fn calculate_course_level(nivel: Option<i32>) -> String {
}
}
// ==================== Create ====================
// ==================== Crear ====================
/// POST /api/question-bank - Create a new question in the bank
/// POST /api/question-bank - Crear una nueva pregunta en el banco
pub async fn create_question(
Org(org_ctx): Org,
claims: Claims,
@@ -382,9 +382,9 @@ pub async fn create_question(
Ok(Json(question))
}
// ==================== List ====================
// ==================== Listar ====================
/// GET /api/question-bank - List questions with filters
/// GET /api/question-bank - Listar preguntas con filtros
pub async fn list_questions(
Org(org_ctx): Org,
State(pool): State<PgPool>,
@@ -396,7 +396,7 @@ pub async fn list_questions(
&& filters.search.is_none()
&& filters.has_audio.is_none()
{
// No filters - simple query
// Sin filtros - consulta simple
sqlx::query_as::<_, QuestionBank>(
&format!(
"SELECT {} FROM question_bank WHERE organization_id = $1 AND is_archived = false ORDER BY created_at DESC",
@@ -504,9 +504,9 @@ pub async fn list_questions(
Ok(Json(questions))
}
// ==================== Get ====================
// ==================== Obtener ====================
/// GET /api/question-bank/{id} - Get a single question
/// GET /api/question-bank/{id} - Obtener una sola pregunta
pub async fn get_question(
Org(org_ctx): Org,
Path(id): Path<Uuid>,
@@ -528,16 +528,16 @@ pub async fn get_question(
.fetch_one(&pool)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Question not found".to_string()),
sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Pregunta no encontrada".to_string()),
_ => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
})?;
Ok(Json(question))
}
// ==================== Update ====================
// ==================== Actualizar ====================
/// PUT /api/question-bank/{id} - Update a question
/// PUT /api/question-bank/{id} - Actualizar una pregunta
pub async fn update_question(
Org(org_ctx): Org,
Path(id): Path<Uuid>,
@@ -599,16 +599,16 @@ pub async fn update_question(
.fetch_one(&pool)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Question not found".to_string()),
sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Pregunta no encontrada".to_string()),
_ => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
})?;
Ok(Json(question))
}
// ==================== Delete ====================
// ==================== Eliminar ====================
/// DELETE /api/question-bank/{id} - Delete (archive) a question
/// DELETE /api/question-bank/{id} - Eliminar (archivar) una pregunta
pub async fn delete_question(
Org(org_ctx): Org,
Path(id): Path<Uuid>,
@@ -628,15 +628,15 @@ pub async fn delete_question(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if result.rows_affected() == 0 {
return Err((StatusCode::NOT_FOUND, "Question not found".to_string()));
return Err((StatusCode::NOT_FOUND, "Pregunta no encontrada".to_string()));
}
Ok(StatusCode::NO_CONTENT)
}
// ==================== Import from MySQL ====================
// ==================== Importar desde MySQL ====================
/// POST /api/question-bank/import-mysql - Import questions from MySQL question bank
/// POST /api/question-bank/import-mysql - Importar preguntas desde el banco de preguntas de MySQL
pub async fn import_from_mysql(
Org(org_ctx): Org,
claims: Claims,
@@ -645,10 +645,10 @@ pub async fn import_from_mysql(
) -> Result<Json<Vec<QuestionBank>>, (StatusCode, String)> {
use serde_json::json;
// Connect to MySQL
// Conectar a MySQL
let mysql_pool = connect_mysql_pool("MYSQL_DATABASE_URL").await?;
// Fetch all study plans and courses from MySQL to sync them
// Obtener todos los planes de estudio y cursos de MySQL para sincronizarlos
let mysql_plans: Vec<MySqlPlanInfo> = sqlx::query_as(
r#"
SELECT DISTINCT
@@ -662,13 +662,13 @@ pub async fn import_from_mysql(
.fetch_all(&mysql_pool)
.await
.map_err(|e| {
let error_msg = format!("Failed to fetch: {}", e);
tracing::error!("MySQL Error: {}", error_msg);
tracing::error!("Check column names in your MySQL database (plandeestudios, curso tables)");
let error_msg = format!("Error al obtener: {}", e);
tracing::error!("Error de MySQL: {}", error_msg);
tracing::error!("Verifique los nombres de las columnas en su base de datos MySQL (tablas plandeestudios, curso)");
(StatusCode::INTERNAL_SERVER_ERROR, error_msg)
})?;
tracing::info!("Fetched {} study plans from MySQL", mysql_plans.len());
tracing::info!("Se obtuvieron {} planes de estudio de MySQL", mysql_plans.len());
let mysql_courses: Vec<MySqlCourseInfo> = sqlx::query_as(
r#"
@@ -688,19 +688,19 @@ pub async fn import_from_mysql(
)
.fetch_all(&mysql_pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch courses: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los cursos: {}", e)))?;
tracing::info!("Fetched {} courses from MySQL", mysql_courses.len());
tracing::info!("Se obtuvieron {} cursos de MySQL", mysql_courses.len());
// Save plans and courses to PostgreSQL
tracing::info!("Saving plans and courses to PostgreSQL...");
// Guardar planes y cursos en PostgreSQL
tracing::info!("Guardando planes y cursos en PostgreSQL...");
save_mysql_courses_and_plans(&pool, org_ctx.id, mysql_plans, mysql_courses)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to save courses/plans: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al guardar cursos/planes: {}", e)))?;
// Fetch questions from MySQL
// Obtener preguntas de MySQL
let mysql_questions: Vec<MySqlQuestion> = if payload.import_all.unwrap_or(false) {
// Import ALL questions (no limit)
// Importar TODAS las preguntas (sin límite)
sqlx::query_as(
r#"
SELECT bp.idPregunta, bp.descripcion, bp.idTipoPregunta, bp.activo,
@@ -716,9 +716,9 @@ pub async fn import_from_mysql(
)
.fetch_all(&mysql_pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch questions: {}", e)))?
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener las preguntas: {}", e)))?
} else if let Some(course_id) = payload.mysql_course_id {
// Import all questions for a specific course (no limit)
// Importar todas las preguntas para un curso específico (sin límite)
sqlx::query_as(
r#"
SELECT bp.idPregunta, bp.descripcion, bp.idTipoPregunta, bp.activo,
@@ -735,9 +735,9 @@ pub async fn import_from_mysql(
.bind(course_id)
.fetch_all(&mysql_pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch questions: {}", e)))?
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener las preguntas: {}", e)))?
} else if let Some(question_ids) = payload.question_ids {
// Fetch specific question IDs - use simple approach
// Obtener IDs de preguntas específicas - usar enfoque simple
let mut imported_questions: Vec<QuestionBank> = vec![];
for q_id in question_ids {
@@ -756,10 +756,10 @@ pub async fn import_from_mysql(
.bind(q_id)
.fetch_optional(&mysql_pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch question {}: {}", q_id, e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener la pregunta {}: {}", q_id, e)))?;
if let Some(question) = mq {
// Map MySQL question type to platform question type
// Mapear el tipo de pregunta de MySQL al tipo de pregunta de la plataforma
let question_type = map_mysql_question_type(question.id_tipo_pregunta, None);
let options = if question_type == QuestionBankType::MultipleChoice {
@@ -804,7 +804,7 @@ pub async fn import_from_mysql(
.bind(&source_metadata)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to import question {}: {}", question.id_pregunta, e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al importar la pregunta {}: {}", question.id_pregunta, e)))?;
imported_questions.push(qb);
}
@@ -813,21 +813,21 @@ pub async fn import_from_mysql(
mysql_pool.close().await;
return Ok(Json(imported_questions));
} else {
return Err((StatusCode::BAD_REQUEST, "Must provide course_id, question_ids, or import_all".to_string()));
return Err((StatusCode::BAD_REQUEST, "Debe proporcionar course_id, question_ids o import_all".to_string()));
};
mysql_pool.close().await;
if mysql_questions.is_empty() {
return Err((StatusCode::NOT_FOUND, "No questions found in MySQL to import".to_string()));
return Err((StatusCode::NOT_FOUND, "No se encontraron preguntas en MySQL para importar".to_string()));
}
// Import questions into PostgreSQL
// Importar preguntas a PostgreSQL
let mut imported_questions: Vec<QuestionBank> = vec![];
let mut skipped_count = 0;
for mq in mysql_questions {
// Check if question already imported
// Comprobar si la pregunta ya ha sido importada
let exists: (bool,) = sqlx::query_as(
"SELECT EXISTS(SELECT 1 FROM question_bank WHERE imported_mysql_id = $1 AND organization_id = $2)"
)
@@ -835,17 +835,17 @@ pub async fn import_from_mysql(
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to check existing: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al comprobar existencia: {}", e)))?;
if exists.0 {
skipped_count += 1;
continue; // Skip already imported question
continue; // Omitir pregunta ya importada
}
// Map MySQL question type to platform question type
// Mapear el tipo de pregunta de MySQL al tipo de pregunta de la plataforma
let question_type = map_mysql_question_type(mq.id_tipo_pregunta, None);
// Create options for multiple choice (if applicable)
// Crear opciones para opción múltiple (si corresponde)
let options = if question_type == QuestionBankType::MultipleChoice {
Some(json!(["Opción A", "Opción B", "Opción C", "Opción D"]))
} else if question_type == QuestionBankType::TrueFalse {
@@ -885,27 +885,27 @@ pub async fn import_from_mysql(
.bind(&mq.descripcion)
.bind(&question_type)
.bind(&options)
.bind(&serde_json::Value::Null) // Correct answer to be filled by user
.bind(&serde_json::Value::Null) // Respuesta correcta para que el usuario la complete
.bind(&source_metadata)
.bind(mq.id_pregunta)
.bind(mq.id_cursos)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to import question {}: {}", mq.id_pregunta, e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al importar la pregunta {}: {}", mq.id_pregunta, e)))?;
imported_questions.push(question);
}
tracing::info!("Imported {} questions from MySQL (skipped {} already imported)", imported_questions.len(), skipped_count);
tracing::info!("Se importaron {} preguntas de MySQL (se omitieron {} ya importadas)", imported_questions.len(), skipped_count);
Ok(Json(imported_questions))
}
// ==================== Helpers ====================
// ==================== Auxiliares ====================
fn map_mysql_question_type(mysql_type: i32, tipo_nombre: Option<&str>) -> QuestionBankType {
// Map MySQL question types to platform types
// First try by name, then by ID as fallback
// Mapear tipos de preguntas de MySQL a tipos de plataforma
// Primero intentar por nombre, luego por ID como respaldo
if let Some(nombre) = tipo_nombre {
let nombre_lower = nombre.to_lowercase();
if nombre_lower.contains("selecc") || nombre_lower.contains("múltiple") || nombre_lower.contains("multiple") || nombre_lower.contains("alternativa") {
@@ -929,13 +929,13 @@ fn map_mysql_question_type(mysql_type: i32, tipo_nombre: Option<&str>) -> Questi
if nombre_lower.contains("corta") || nombre_lower.contains("short") || nombre_lower.contains("texto") {
return QuestionBankType::ShortAnswer;
}
// Audio type in MySQL is typically listening comprehension with multiple choice answers
// El tipo audio en MySQL suele ser comprensión auditiva con respuestas de opción múltiple
if nombre_lower.contains("audio") || nombre_lower.contains("listening") {
return QuestionBankType::MultipleChoice;
}
}
// Fallback to ID mapping
// Respaldo al mapeo por ID
match mysql_type {
1 => QuestionBankType::MultipleChoice, // Alternativa
2 => QuestionBankType::MultipleChoice, // Audio (listening comprehension)
@@ -948,7 +948,7 @@ fn map_mysql_question_type(mysql_type: i32, tipo_nombre: Option<&str>) -> Questi
}
}
/// Parse MySQL answers JSON and extract options and correct answer
/// Analizar el JSON de respuestas de MySQL y extraer opciones y respuesta correcta
fn parse_mysql_answers(
answers_json: Option<&str>,
question_type: QuestionBankType,
@@ -956,13 +956,13 @@ fn parse_mysql_answers(
if let Some(json_str) = answers_json {
if let Ok(answers) = serde_json::from_str::<Vec<serde_json::Value>>(json_str) {
if !answers.is_empty() {
// Extract options (all answer texts)
// Extraer opciones (todos los textos de respuesta)
let options: Vec<String> = answers
.iter()
.filter_map(|a| a.get("texto").and_then(|t| t.as_str()).map(String::from))
.collect();
// Extract correct answer(s)
// Extraer respuesta(s) correcta(s)
let correct_indices: Vec<usize> = answers
.iter()
.enumerate()
@@ -978,16 +978,16 @@ fn parse_mysql_answers(
let correct_answer = if question_type == QuestionBankType::TrueFalse ||
question_type == QuestionBankType::MultipleChoice {
// For multiple choice, store index/indices
// Para opción múltiple, guardar índice/índices
if correct_indices.len() == 1 {
Some(serde_json::json!(correct_indices[0]))
} else if correct_indices.len() > 1 {
Some(serde_json::json!(correct_indices))
} else {
Some(serde_json::json!(0)) // Default to first
Some(serde_json::json!(0)) // Por defecto a la primera
}
} else {
// For other types, store the text
// Para otros tipos, guardar el texto
correct_indices.first()
.and_then(|&i| answers.get(i))
.and_then(|a| a.get("texto").and_then(|t| t.as_str()))
@@ -999,7 +999,7 @@ fn parse_mysql_answers(
}
}
// Default values if no answers provided
// Valores por defecto si no se proporcionan respuestas
let default_options = match question_type {
QuestionBankType::MultipleChoice => Some(serde_json::json!(["Opción A", "Opción B", "Opción C", "Opción D"])),
QuestionBankType::TrueFalse => Some(serde_json::json!(["Verdadero", "Falso"])),
@@ -1009,17 +1009,17 @@ fn parse_mysql_answers(
(default_options, Some(serde_json::json!(0)))
}
// ==================== MySQL Integration ====================
// ==================== Integración con MySQL ====================
/// GET /api/question-bank/mysql-courses - List courses from MySQL for import
/// GET /api/question-bank/mysql-courses - Listar cursos de MySQL para importación
pub async fn list_mysql_courses(
Org(_org_ctx): Org,
State(_pool): State<PgPool>,
) -> Result<Json<Vec<MySqlCourseInfo>>, (StatusCode, String)> {
// Connect to MySQL
// Conectar a MySQL
let mysql_pool = connect_mysql_pool("MYSQL_DATABASE_URL").await?;
// Fetch courses with their plan names
// Obtener cursos con sus nombres de plan
let courses: Vec<MySqlCourseInfo> = sqlx::query_as(
r#"
SELECT DISTINCT
@@ -1038,19 +1038,19 @@ pub async fn list_mysql_courses(
)
.fetch_all(&mysql_pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch courses: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los cursos: {}", e)))?;
mysql_pool.close().await;
Ok(Json(courses))
}
/// GET /api/question-bank/mysql-plans - Get all study plans from PostgreSQL (imported from MySQL)
/// GET /api/question-bank/mysql-plans - Obtener todos los planes de estudio de PostgreSQL (importados de MySQL)
pub async fn get_mysql_plans(
Org(org_ctx): Org,
State(pool): State<PgPool>,
) -> Result<Json<Vec<MySqlPlanInfo>>, (StatusCode, String)> {
// Read from SAM mirror in PostgreSQL with SAM-native fields.
// Leer del reflejo SAM en PostgreSQL con campos nativos de SAM.
let mut plans: Vec<MySqlPlanInfo> = sqlx::query_as(
r#"
SELECT
@@ -1064,9 +1064,9 @@ pub async fn get_mysql_plans(
.bind(org_ctx.id)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch plans: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los planes: {}", e)))?;
// Backward-compatible fallback: if SAM mirror is empty, use legacy metadata mirror.
// Respaldo compatible con versiones anteriores: si el reflejo SAM está vacío, usar el reflejo de metadatos heredado.
if plans.is_empty() {
plans = sqlx::query_as(
r#"
@@ -1081,10 +1081,10 @@ pub async fn get_mysql_plans(
.bind(org_ctx.id)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch legacy plans: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los planes heredados: {}", e)))?;
}
// Last-resort auto-sync: if still empty, pull metadata from MySQL and persist it.
// Auto-sincronización de último recurso: si sigue vacío, obtener metadatos de MySQL y persistirlos.
if plans.is_empty() {
match connect_mysql_pool("MYSQL_DATABASE_URL").await {
Ok(mysql_pool) => {
@@ -1123,21 +1123,21 @@ pub async fn get_mysql_plans(
match (mysql_plans, mysql_courses) {
(Ok(p), Ok(c)) => {
if let Err(err) = save_mysql_courses_and_plans(&pool, org_ctx.id, p, c).await {
tracing::warn!("Auto-sync MySQL metadata failed: {}", err);
tracing::warn!("Fallo la auto-sincronización de metadatos de MySQL: {}", err);
}
}
(Err(e), _) => tracing::warn!("Auto-sync plans query failed: {}", e),
(_, Err(e)) => tracing::warn!("Auto-sync courses query failed: {}", e),
(Err(e), _) => tracing::warn!("Fallo la consulta de planes de auto-sincronización: {}", e),
(_, Err(e)) => tracing::warn!("Fallo la consulta de cursos de auto-sincronización: {}", e),
}
mysql_pool.close().await;
}
Err(e) => {
tracing::warn!("Auto-sync could not connect to MySQL: {:?}", e);
tracing::warn!("La auto-sincronización no pudo conectarse a MySQL: {:?}", e);
}
}
// Reload plans after auto-sync attempt.
// Recargar planes tras el intento de auto-sincronización.
plans = sqlx::query_as(
r#"
SELECT
@@ -1174,13 +1174,13 @@ pub async fn get_mysql_plans(
Ok(Json(plans))
}
/// GET /api/question-bank/mysql-courses - Get courses filtered by plan from PostgreSQL
/// GET /api/question-bank/mysql-courses - Obtener cursos filtrados por plan de PostgreSQL
pub async fn get_mysql_courses_by_plan(
Org(org_ctx): Org,
State(pool): State<PgPool>,
Query(filters): Query<MySqlCoursesFilters>,
) -> Result<Json<Vec<MySqlCourseInfo>>, (StatusCode, String)> {
// Read from SAM mirror in PostgreSQL with SAM-native fields.
// Leer del reflejo SAM en PostgreSQL con campos nativos de SAM.
let mut courses: Vec<MySqlCourseInfo> = sqlx::query_as(
r#"
SELECT
@@ -1205,9 +1205,9 @@ pub async fn get_mysql_courses_by_plan(
.bind(filters.plan_id)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch courses: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los cursos: {}", e)))?;
// Backward-compatible fallback: if SAM mirror is empty, use legacy metadata mirror.
// Respaldo compatible con versiones anteriores: si el reflejo SAM está vacío, usar el reflejo de metadatos heredado.
if courses.is_empty() {
courses = sqlx::query_as(
r#"
@@ -1231,7 +1231,7 @@ pub async fn get_mysql_courses_by_plan(
.bind(filters.plan_id)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch legacy courses: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los cursos heredados: {}", e)))?;
}
Ok(Json(courses))
@@ -1255,15 +1255,15 @@ pub struct MySqlCourseInfo {
pub nivel_curso: Option<i32>,
pub id_plan_de_estudios: i32,
pub nombre_plan: String,
pub duracion: Option<f64>, // Duration in hours (40=regular, 80=intensive) - MySQL float type
pub duracion: Option<f64>, // Duración en horas (40=regular, 80=intensivo) - Tipo float de MySQL
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ImportAllFromMySQLPayload {
pub import_metadata_only: Option<bool>, // Only import metadata (courses/plans), not questions
pub import_metadata_only: Option<bool>, // Solo importar metadatos (cursos/planes), no preguntas
}
/// POST /api/question-bank/import-mysql-all - Import ALL questions from MySQL (bulk import)
/// POST /api/question-bank/import-mysql-all - Importar TODAS las preguntas de MySQL (importación masiva)
pub async fn import_all_from_mysql(
Org(org_ctx): Org,
claims: Claims,
@@ -1272,7 +1272,7 @@ pub async fn import_all_from_mysql(
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
use serde_json::json;
// Parse optional JSON body
// Analizar el cuerpo JSON opcional
let import_metadata_only = if body.trim().is_empty() {
false
} else {
@@ -1281,10 +1281,10 @@ pub async fn import_all_from_mysql(
.unwrap_or(false)
};
// Connect to MySQL
// Conectar a MySQL
let mysql_pool = connect_mysql_pool("MYSQL_DATABASE_URL").await?;
// Fetch all study plans and courses from MySQL to sync them
// Obtener todos los planes de estudio y cursos de MySQL para sincronizarlos
let mysql_plans: Vec<MySqlPlanInfo> = sqlx::query_as(
r#"
SELECT DISTINCT
@@ -1298,13 +1298,13 @@ pub async fn import_all_from_mysql(
.fetch_all(&mysql_pool)
.await
.map_err(|e| {
let error_msg = format!("Failed to fetch: {}", e);
tracing::error!("MySQL Error: {}", error_msg);
tracing::error!("Check column names in your MySQL database (plandeestudios, curso tables)");
let error_msg = format!("Error al obtener: {}", e);
tracing::error!("Error de MySQL: {}", error_msg);
tracing::error!("Verifique los nombres de las columnas en su base de datos MySQL (tablas plandeestudios, curso)");
(StatusCode::INTERNAL_SERVER_ERROR, error_msg)
})?;
tracing::info!("Fetched {} study plans from MySQL", mysql_plans.len());
tracing::info!("Se obtuvieron {} planes de estudio de MySQL", mysql_plans.len());
let mysql_courses: Vec<MySqlCourseInfo> = sqlx::query_as(
r#"
@@ -1324,17 +1324,17 @@ pub async fn import_all_from_mysql(
)
.fetch_all(&mysql_pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch courses: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los cursos: {}", e)))?;
tracing::info!("Fetched {} courses from MySQL", mysql_courses.len());
tracing::info!("Se obtuvieron {} cursos de MySQL", mysql_courses.len());
// Save plans and courses to PostgreSQL
tracing::info!("Saving plans and courses to PostgreSQL...");
// Guardar planes y cursos en PostgreSQL
tracing::info!("Guardando planes y cursos en PostgreSQL...");
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)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al guardar cursos/planes: {}", e)))?;
// If only metadata import is requested, return early
// Si solo se solicita la importación de metadatos, salir antes
if import_metadata_only {
return Ok(Json(json!({
"success": true,
@@ -1353,7 +1353,7 @@ pub async fn import_all_from_mysql(
})));
}
// Fetch ALL questions from MySQL with answers (using JSON aggregation for answers)
// Obtener TODAS las preguntas de MySQL con respuestas (usando agregación JSON para las respuestas)
let mysql_questions: Vec<MySqlQuestionFull> = sqlx::query_as(
r#"
SELECT
@@ -1393,7 +1393,7 @@ pub async fn import_all_from_mysql(
)
.fetch_all(&mysql_pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch questions: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener las preguntas: {}", e)))?;
mysql_pool.close().await;
@@ -1403,17 +1403,17 @@ pub async fn import_all_from_mysql(
"imported": 0,
"skipped": 0,
"updated": 0,
"error": "No questions found in MySQL"
"error": "No se encontraron preguntas en MySQL"
})));
}
// Import questions into PostgreSQL
// Importar preguntas a PostgreSQL
let mut imported_count = 0;
let mut skipped_count = 0;
let mut updated_count = 0;
for mq in mysql_questions {
// Check if question already exists
// Comprobar si la pregunta ya existe
let existing: Option<(Uuid, bool)> = sqlx::query_as(
"SELECT id, is_active FROM question_bank WHERE imported_mysql_id = $1 AND organization_id = $2"
)
@@ -1421,13 +1421,13 @@ pub async fn import_all_from_mysql(
.bind(org_ctx.id)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Check failed: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Fallo en la comprobación: {}", e)))?;
match existing {
Some((id, is_active)) => {
// Question exists - update if it was inactive or has new data
// La pregunta existe - actualizar si estaba inactiva o tiene nuevos datos
if !is_active {
// Reactivate and update
// Reactivar y actualizar
let _ = sqlx::query(
"UPDATE question_bank SET is_active = true, question_text = $3, updated_at = NOW() WHERE id = $1 AND organization_id = $2"
)
@@ -1438,14 +1438,14 @@ pub async fn import_all_from_mysql(
.await;
updated_count += 1;
} else {
skipped_count += 1; // Already exists and active, skip
skipped_count += 1; // Ya existe y está activa, omitir
}
}
None => {
// New question - insert with answers from MySQL
// Nueva pregunta - insertar con respuestas de MySQL
let question_type = map_mysql_question_type(mq.id_tipo_pregunta, mq.tipo_pregunta_nombre.as_deref());
// Parse answers from MySQL
// Analizar respuestas de MySQL
let (options, correct_answer) = parse_mysql_answers(mq.respuestas_json.as_deref(), question_type);
let has_answers = mq.respuestas_json.is_some();
@@ -1495,7 +1495,7 @@ pub async fn import_all_from_mysql(
}
tracing::info!(
"Bulk import from MySQL: {} imported, {} skipped, {} updated",
"Importación masiva desde MySQL: {} importadas, {} omitidas, {} actualizadas",
imported_count,
skipped_count,
updated_count
@@ -1544,7 +1544,7 @@ pub struct MySqlQuestionFull {
pub nivel_curso: Option<i32>,
pub id_plan_de_estudios: i32,
pub plan_nombre: String,
pub respuestas_json: Option<String>, // JSON array de respuestas
pub respuestas_json: Option<String>, // Array JSON de respuestas
pub tipo_pregunta_nombre: Option<String>, // Nombre del tipo de pregunta
}
@@ -1560,7 +1560,7 @@ struct MySqlQuestion {
plan_nombre: String,
}
// ==================== AI Generation ====================
// ==================== Generación por IA ====================
#[derive(Debug, Deserialize)]
pub struct AIGenerateQuestionPayload {
@@ -1578,7 +1578,7 @@ pub struct AIQuestionResponse {
pub explanation: String,
}
/// POST /question-bank/ai-generate - Generate question options/answers using AI (Ollama only)
/// POST /question-bank/ai-generate - Generar opciones/respuestas de preguntas usando IA (solo Ollama)
pub async fn ai_generate_question(
_org_ctx: Org,
_claims: Claims,
@@ -1587,11 +1587,11 @@ pub async fn ai_generate_question(
use std::env;
use std::time::Duration;
let question_text = payload.question_text.unwrap_or_else(|| "English grammar question".to_string());
let question_text = payload.question_text.unwrap_or_else(|| "Pregunta de gramática inglesa".to_string());
let difficulty = payload.difficulty.unwrap_or_else(|| "medium".to_string());
let skill = payload.skill.unwrap_or_else(|| "grammar".to_string());
// Build prompt for AI
// Construir prompt para la IA
let system_prompt = format!(
r#"You are an expert English Teacher creating quiz questions.
@@ -1610,7 +1610,7 @@ pub async fn ai_generate_question(
question_text, difficulty, skill
);
// Call Ollama AI with extended timeout
// Llamar a Ollama AI con tiempo de espera extendido
let base_url = env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string());
let url = format!("{}/api/chat", base_url);
@@ -1628,37 +1628,37 @@ pub async fn ai_generate_question(
"model": model,
"messages": [
{ "role": "system", "content": system_prompt },
{ "role": "user", "content": "Generate the question in JSON format" }
{ "role": "user", "content": "Generar la pregunta en formato JSON" }
],
"stream": false,
"format": "json"
}))
.send()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("AI request failed: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("La solicitud de IA falló: {}", e)))?;
if !response.status().is_success() {
return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("AI API error: {}", response.status())));
return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Error de la API de IA: {}", response.status())));
}
let result: serde_json::Value = response.json().await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to parse AI response: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al analizar la respuesta de la IA: {}", e)))?;
// Extract content from Ollama response
// Extraer contenido de la respuesta de Ollama
let content = result
.get("message")
.and_then(|m| m.get("content"))
.and_then(|c| c.as_str())
.ok_or((StatusCode::INTERNAL_SERVER_ERROR, "Invalid AI response format".to_string()))?;
.ok_or((StatusCode::INTERNAL_SERVER_ERROR, "Formato de respuesta de IA no válido".to_string()))?;
// Parse AI response as JSON
// Analizar la respuesta de la IA como JSON
let ai_question: AIQuestionResponse = serde_json::from_str(content)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to parse question JSON: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al analizar el JSON de la pregunta: {}", e)))?;
Ok(Json(ai_question))
}
// ==================== Import Courses from MySQL ====================
// ==================== Importar Cursos de MySQL ====================
#[derive(Debug, Serialize, Deserialize)]
pub struct ImportCourseFromMySQLPayload {
@@ -1678,7 +1678,7 @@ pub struct ImportCourseResult {
pub message: String,
}
/// POST /api/question-bank/import-course-mysql - Import a course from MySQL with basic structure
/// POST /api/question-bank/import-course-mysql - Importar un curso de MySQL con estructura básica
pub async fn import_course_from_mysql(
Org(org_ctx): Org,
claims: Claims,
@@ -1687,10 +1687,10 @@ pub async fn import_course_from_mysql(
) -> Result<Json<ImportCourseResult>, (StatusCode, String)> {
use common::models::Course;
// Connect to MySQL
// Conectar a MySQL
let mysql_pool = connect_mysql_pool("MYSQL_DATABASE_URL").await?;
// Fetch course info from MySQL
// Obtener información del curso de MySQL
let mysql_course: MySqlCourseInfo = sqlx::query_as(
r#"
SELECT
@@ -1710,25 +1710,25 @@ pub async fn import_course_from_mysql(
.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))
(StatusCode::NOT_FOUND, format!("Curso con ID {} no encontrado en MySQL", payload.mysql_course_id))
} else {
(StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch course from MySQL: {}", e))
(StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener el curso de MySQL: {}", e))
}
})?;
tracing::info!("Importing course from MySQL: {} (ID: {})", mysql_course.nombre_curso, mysql_course.id_cursos);
tracing::info!("Importando curso de MySQL: {} (ID: {})", mysql_course.nombre_curso, mysql_course.id_cursos);
// Start transaction
// Iniciar transacción
let mut tx = pool.begin().await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to start transaction: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al iniciar la transacción: {}", e)))?;
// Determine course type and level for structure generation
// Determinar el tipo y nivel del curso para la generación de la estructura
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
// Crear el curso en 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));
@@ -1756,11 +1756,11 @@ pub async fn import_course_from_mysql(
.bind(mysql_course.id_cursos)
.fetch_one(&mut *tx)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create course: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al crear el curso: {}", e)))?;
tracing::info!("Created course in PostgreSQL: {}", new_course.id);
tracing::info!("Curso creado en PostgreSQL: {}", new_course.id);
// Generate basic course structure based on course type and level
// Generar estructura básica del curso basada en su tipo y nivel
let (modules_count, lessons_count) = generate_course_structure(
&mut tx,
new_course.id,
@@ -1772,12 +1772,12 @@ pub async fn import_course_from_mysql(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
// Commit transaction
// Confirmar transacción
tx.commit().await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to commit transaction: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al confirmar la transacción: {}", e)))?;
tracing::info!(
"Successfully imported course {} with {} modules and {} lessons",
"Curso {} importado con éxito con {} módulos y {} lecciones",
new_course.id,
modules_count,
lessons_count
@@ -1794,7 +1794,7 @@ pub async fn import_course_from_mysql(
}))
}
/// Generate basic course structure based on type and level
/// Generar estructura básica del curso según tipo y nivel
async fn generate_course_structure<'a>(
tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
course_id: Uuid,
@@ -1803,9 +1803,9 @@ async fn generate_course_structure<'a>(
level: &str,
course_name: &str,
) -> Result<(i32, i32), String> {
// Define module structure based on course type
// Regular (40h): 4 modules
// Intensive (80h): 8 modules
// Definir la estructura de módulos basada en el tipo de curso
// Regular (40h): 4 módulos
// Intensivo (80h): 8 módulos
let modules_config = match course_type {
"intensive" => vec![
("Fundamentos Básicos", 6),
@@ -1829,7 +1829,7 @@ async fn generate_course_structure<'a>(
let mut total_lessons = 0;
for (module_idx, (module_name, lessons_count)) in modules_config.iter().enumerate() {
// Create module
// Crear módulo
let module: common::models::Module = sqlx::query_as(
r#"
INSERT INTO modules (course_id, organization_id, title, position)
@@ -1843,16 +1843,16 @@ async fn generate_course_structure<'a>(
.bind(module_idx as i32)
.fetch_one(&mut **tx)
.await
.map_err(|e| format!("Failed to create module {}: {}", module_idx + 1, e))?;
.map_err(|e| format!("Error al crear el módulo {}: {}", module_idx + 1, e))?;
total_modules += 1;
// Create lessons for this module
// Crear lecciones para este módulo
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)
// Determinar el tipo de contenido basado en la posición (rotar por tipos)
let content_types = ["video", "document", "interactive", "quiz"];
let content_type = content_types[lesson_idx % content_types.len()];
@@ -1868,13 +1868,13 @@ async fn generate_course_structure<'a>(
.bind(org_id)
.bind(&lesson_title)
.bind(content_type)
.bind("") // Empty content URL (to be filled by instructor)
.bind("") // URL de contenido vacía (para que el instructor la complete)
.bind(lesson_position)
.bind(lesson_idx % 4 == 3) // Every 4th lesson is graded (quiz)
.bind(lesson_idx % 4 == 3) // Cada 4ª lección se califica (cuestionario)
.bind(format!("Contenido de la lección: {}", lesson_title))
.execute(&mut **tx)
.await
.map_err(|e| format!("Failed to create lesson {}: {}", lesson_position, e))?;
.map_err(|e| format!("Error al crear la lección {}: {}", lesson_position, e))?;
total_lessons += 1;
}
@@ -1883,7 +1883,7 @@ async fn generate_course_structure<'a>(
Ok((total_modules, total_lessons))
}
// ==================== Import from SAM Diagnostico ====================
// ==================== Importar desde SAM Diagnóstico ====================
/// Row retornada por GROUP BY sobre las tablas de SAM_diagnostico
#[derive(Debug, sqlx::FromRow)]
@@ -2011,7 +2011,7 @@ pub async fn import_from_sam_diagnostico(
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to check duplicate: {}", e),
format!("Error al comprobar duplicado: {}", e),
)
})?;
@@ -2083,7 +2083,7 @@ pub async fn import_from_sam_diagnostico(
mysql_pool.close().await;
tracing::info!(
"SAM_diagnostico import done: imported={} skipped={} errors={}",
"Importación de SAM_diagnostico finalizada: imported={} skipped={} errors={}",
total_imported, total_skipped, errors.len()
);