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:
@@ -31,9 +31,9 @@ pub async fn create_course_external(
|
||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||
let org_id = validate_api_key(&headers, &pool).await?;
|
||||
|
||||
// We reuse the internal logic but with the org_id from the API key
|
||||
// We need to provide a mock claims for handlers::create_course or refactor it.
|
||||
// Simplifying for now: direct DB call or calling handlers with constructed context.
|
||||
// Reutilizamos la lógica interna pero con el org_id de la clave API
|
||||
// Necesitamos proporcionar un reclamo ficticio (mock claims) para handlers::create_course o refactorizarlo.
|
||||
// Simplificando por ahora: llamada directa a la BD o llamando a manejadores con contexto construido.
|
||||
|
||||
let title = payload.title.trim();
|
||||
if title.is_empty() {
|
||||
@@ -52,7 +52,7 @@ pub async fn create_course_external(
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("External course creation failed: {}", e);
|
||||
tracing::error!("La creación del curso externo falló: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
@@ -94,9 +94,9 @@ pub struct ExternalCreateCoursePayload {
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub pacing_mode: Option<String>,
|
||||
// Optional direct template selection
|
||||
// Selección directa de plantilla opcional
|
||||
pub template_id: Option<Uuid>,
|
||||
// Optional fallback selection by level/course type/test type
|
||||
// Selección de respaldo (fallback) opcional por nivel/tipo de curso/tipo de test
|
||||
pub template_level: Option<String>,
|
||||
pub template_course_type: Option<String>,
|
||||
pub template_test_type: Option<String>,
|
||||
@@ -298,7 +298,7 @@ pub async fn trigger_transcription_external(
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
let org_id = validate_api_key(&headers, &pool).await?;
|
||||
|
||||
// Verify lesson belongs to org
|
||||
// Verificar que la lección pertenece a la organización
|
||||
let _ = sqlx::query("SELECT 1 FROM lessons WHERE id = $1 AND organization_id = $2")
|
||||
.bind(id)
|
||||
.bind(org_id)
|
||||
@@ -306,7 +306,7 @@ pub async fn trigger_transcription_external(
|
||||
.await
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
|
||||
// Queue transcription
|
||||
// Encolar transcripción
|
||||
sqlx::query("UPDATE lessons SET transcription_status = 'queued' WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&pool)
|
||||
|
||||
@@ -54,12 +54,12 @@ fn get_ai_url(var_base: &str, default: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple token counter (approximate: 1 token ≈ 4 characters in English)
|
||||
/// Contador de tokens simple (aproximado: 1 token ≈ 4 caracteres en inglés)
|
||||
fn count_tokens(text: &str) -> i32 {
|
||||
// More accurate for English: split by whitespace and count words * 1.3
|
||||
// For Spanish/other languages, character-based is more reliable
|
||||
// Más preciso para inglés: dividir por espacios en blanco y contar palabras * 1.3
|
||||
// Para el español y otros idiomas, el conteo basado en caracteres es más fiable
|
||||
let char_count = text.len();
|
||||
// OpenAI estimate: ~4 chars per token
|
||||
// Estimación de OpenAI: ~4 caracteres por token
|
||||
((char_count as f64) / 4.0).ceil() as i32
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ pub async fn publish_course(
|
||||
let is_super_admin = claims.role == "admin"
|
||||
&& claims.org == Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
|
||||
|
||||
// 1. Fetch Course (Super admin can publish any course, others only their org's)
|
||||
// 1. Obtener curso (El superadministrador puede publicar cualquier curso, otros solo los de su organización)
|
||||
let course = if is_super_admin {
|
||||
sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1")
|
||||
.bind(id)
|
||||
@@ -88,14 +88,14 @@ pub async fn publish_course(
|
||||
}
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
|
||||
// Determine target organization
|
||||
// Determinar la organización de destino
|
||||
let target_org_id = if is_super_admin && payload_params.target_organization_id.is_some() {
|
||||
payload_params.target_organization_id.unwrap()
|
||||
} else {
|
||||
course.organization_id
|
||||
};
|
||||
|
||||
// 2. Fetch Modules
|
||||
// 2. Obtener módulos
|
||||
let modules =
|
||||
sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE course_id = $1 ORDER BY position")
|
||||
.bind(id)
|
||||
@@ -105,7 +105,7 @@ pub async fn publish_course(
|
||||
|
||||
let mut pub_modules = Vec::new();
|
||||
|
||||
// 3. Fetch Grading Categories
|
||||
// 3. Obtener categorías de calificación
|
||||
let grading_categories = sqlx::query_as::<_, common::models::GradingCategory>(
|
||||
"SELECT * FROM grading_categories WHERE course_id = $1",
|
||||
)
|
||||
@@ -114,7 +114,7 @@ pub async fn publish_course(
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
// 4. Fetch Target Organization
|
||||
// 4. Obtener organización de destino
|
||||
let organization = sqlx::query_as::<_, common::models::Organization>(
|
||||
"SELECT * FROM organizations WHERE id = $1",
|
||||
)
|
||||
@@ -123,7 +123,7 @@ pub async fn publish_course(
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
// 5. Fetch Lessons for each Module
|
||||
// 5. Obtener lecciones de cada módulo
|
||||
for module in modules {
|
||||
let lessons = sqlx::query_as::<_, Lesson>(
|
||||
"SELECT * FROM lessons WHERE module_id = $1 ORDER BY position",
|
||||
@@ -136,11 +136,11 @@ pub async fn publish_course(
|
||||
pub_modules.push(PublishedModule { module, lessons });
|
||||
}
|
||||
|
||||
// Overwrite the course's organization_id in the payload if publishing to a different org
|
||||
// Sobrescribir el organization_id del curso en la carga útil si se publica en una organización diferente
|
||||
let mut course_for_pub = course.clone();
|
||||
course_for_pub.organization_id = target_org_id;
|
||||
|
||||
// 5. Fetch Course Team
|
||||
// 5. Obtener equipo del curso
|
||||
let instructors = sqlx::query_as::<_, CourseInstructor>(
|
||||
"SELECT ci.*, u.email, u.full_name FROM course_instructors ci
|
||||
JOIN users u ON ci.user_id = u.id
|
||||
@@ -160,7 +160,7 @@ pub async fn publish_course(
|
||||
dependencies: None,
|
||||
};
|
||||
|
||||
// 4. Send to LMS
|
||||
// 4. Enviar al LMS
|
||||
let lms_url =
|
||||
env::var("LMS_INTERNAL_URL").unwrap_or_else(|_| "http://experience:3002".to_string());
|
||||
let client = reqwest::Client::new();
|
||||
@@ -170,12 +170,12 @@ pub async fn publish_course(
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to reach LMS service: {}", e);
|
||||
tracing::error!("Error al contactar con el servicio LMS: {}", e);
|
||||
StatusCode::BAD_GATEWAY
|
||||
})?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
tracing::error!("LMS ingestion failed with status: {}", res.status());
|
||||
tracing::error!("La ingesta del LMS falló con estado: {}", res.status());
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ pub async fn publish_course(
|
||||
)
|
||||
.await;
|
||||
|
||||
// 5. Trigger Webhook
|
||||
// 5. Ejecutar webhook
|
||||
let webhook_service = WebhookService::new(pool.clone());
|
||||
webhook_service
|
||||
.dispatch(
|
||||
@@ -225,7 +225,7 @@ pub struct GradingPayload {
|
||||
pub name: String,
|
||||
pub weight: i32,
|
||||
pub drop_count: i32,
|
||||
pub tipo_nota_id: Option<i32>, // idTipoNota from tiponota table
|
||||
pub tipo_nota_id: Option<i32>, // idTipoNota de la tabla tiponota
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -258,11 +258,11 @@ pub async fn create_course(
|
||||
.unwrap_or("self_paced");
|
||||
|
||||
let mut tx = pool.begin().await.map_err(|e| {
|
||||
tracing::error!("Failed to start transaction: {}", e);
|
||||
tracing::error!("Error al iniciar la transacción: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
// Set session context for the DB trigger to find
|
||||
// Establecer el contexto de la sesión para que el disparador de la BD lo encuentre
|
||||
let ip = headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
@@ -284,7 +284,7 @@ pub async fn create_course(
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to set session context: {}", e);
|
||||
tracing::error!("Error al establecer el contexto de la sesión: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
@@ -320,12 +320,12 @@ pub async fn create_course(
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Create course failed: {}", e);
|
||||
tracing::error!("Error al crear el curso: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
tx.commit().await.map_err(|e| {
|
||||
tracing::error!("Failed to commit transaction: {}", e);
|
||||
tracing::error!("Error al confirmar la transacción: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
@@ -339,7 +339,7 @@ pub async fn get_courses(
|
||||
let is_super_admin = claims.role == "admin"
|
||||
&& claims.org == Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
|
||||
|
||||
// Check if user is a SAM student
|
||||
// Comprobar si el usuario es un estudiante de SAM
|
||||
let is_sam_student: bool = sqlx::query_scalar(
|
||||
"SELECT COALESCE(is_sam_student, false) FROM users WHERE id = $1"
|
||||
)
|
||||
@@ -349,18 +349,18 @@ pub async fn get_courses(
|
||||
.unwrap_or(false);
|
||||
|
||||
let courses: Vec<Course> = if is_super_admin {
|
||||
// Super admin sees all courses
|
||||
// El superadministrador ve todos los cursos
|
||||
sqlx::query_as::<_, Course>("SELECT * FROM courses")
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
} else if claims.role == "admin" || claims.role == "instructor" {
|
||||
// Admins and instructors see all courses in their organization
|
||||
// Los administradores e instructores ven todos los cursos de su organización
|
||||
sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE organization_id = $1")
|
||||
.bind(org_ctx.id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
} else if is_sam_student {
|
||||
// SAM students only see courses they are enrolled in via SAM
|
||||
// Los estudiantes de SAM solo ven los cursos en los que están matriculados a través de SAM
|
||||
sqlx::query_as::<_, Course>(
|
||||
"SELECT c.* FROM courses c
|
||||
INNER JOIN sam_course_assignments sca ON c.id = sca.course_id
|
||||
@@ -373,7 +373,7 @@ pub async fn get_courses(
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
} else {
|
||||
// Non-SAM students see NO courses (empty list)
|
||||
// Los estudiantes que no son de SAM no ven NINGÚN curso (lista vacía)
|
||||
return Ok(Json(Vec::new()));
|
||||
}
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
@@ -464,7 +464,7 @@ pub async fn update_course(
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Set auditing context
|
||||
// Establecer contexto de auditoría
|
||||
sqlx::query(
|
||||
"SELECT set_config('app.current_user_id', $1, true), set_config('app.org_id', $2, true)",
|
||||
)
|
||||
@@ -562,7 +562,7 @@ pub async fn create_module(
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Create module failed: {}", e);
|
||||
tracing::error!("Error al crear el módulo: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
@@ -678,7 +678,7 @@ pub async fn create_lesson(
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Create lesson failed: {}", e);
|
||||
tracing::error!("Error al crear la lección: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
@@ -686,7 +686,7 @@ pub async fn create_lesson(
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
// Trigger auto-transcription if it's a video or audio lesson with a URL
|
||||
// Activar la autotranscripción si es una lección de vídeo o audio con una URL
|
||||
if (content_type == "video" || content_type == "audio") && content_url.is_some() {
|
||||
trigger_transcription(pool, lesson.id).await;
|
||||
}
|
||||
@@ -695,16 +695,16 @@ pub async fn create_lesson(
|
||||
}
|
||||
|
||||
async fn trigger_transcription(pool: PgPool, lesson_id: Uuid) {
|
||||
// Set status to queued
|
||||
// Establecer estado como en cola
|
||||
let _ = sqlx::query("UPDATE lessons SET transcription_status = 'queued' WHERE id = $1")
|
||||
.bind(lesson_id)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
|
||||
// Spawn background task
|
||||
// Iniciar tarea en segundo plano
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = run_transcription_task(pool, lesson_id).await {
|
||||
tracing::error!("Auto-transcription task failed for lesson {}: {}", lesson_id, e);
|
||||
tracing::error!("La tarea de autotranscripción falló para la lección {}: {}", lesson_id, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -715,8 +715,8 @@ pub async fn process_transcription(
|
||||
State(pool): State<PgPool>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<Lesson>, StatusCode> {
|
||||
tracing::info!("Received transcription request for lesson: {}", id);
|
||||
// 1. Fetch lesson
|
||||
tracing::info!("Recibida solicitud de transcripción para la lección: {}", id);
|
||||
// 1. Obtener lección
|
||||
let lesson =
|
||||
sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2")
|
||||
.bind(id)
|
||||
@@ -724,7 +724,7 @@ pub async fn process_transcription(
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Lesson fetch failed: {}", e);
|
||||
tracing::error!("Error al obtener la lección: {}", e);
|
||||
StatusCode::NOT_FOUND
|
||||
})?;
|
||||
|
||||
@@ -734,13 +734,13 @@ pub async fn process_transcription(
|
||||
|
||||
let url = lesson.content_url.ok_or(StatusCode::BAD_REQUEST)?;
|
||||
|
||||
// 2. Validate media is reachable (local /assets or absolute URL)
|
||||
// 2. Validar que el medio sea accesible (assets locales o URL absoluta)
|
||||
if read_lesson_media_bytes(&url).await.is_err() {
|
||||
tracing::error!("Media not accessible for transcription: {}", url);
|
||||
tracing::error!("Medio no accesible para transcripción: {}", url);
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
// 3. Set status to queued
|
||||
// 3. Establecer estado como en cola
|
||||
let updated_lesson = sqlx::query_as::<_, Lesson>(
|
||||
"UPDATE lessons SET transcription_status = 'queued' WHERE id = $1 RETURNING *",
|
||||
)
|
||||
@@ -748,7 +748,7 @@ pub async fn process_transcription(
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database update failed (queued): {}", e);
|
||||
tracing::error!("Error en la actualización de la base de datos (en cola): {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
@@ -763,11 +763,11 @@ pub async fn process_transcription(
|
||||
)
|
||||
.await;
|
||||
|
||||
// 4. Spawn background task
|
||||
// 4. Iniciar tarea en segundo plano
|
||||
let pool_clone = pool.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = run_transcription_task(pool_clone, id).await {
|
||||
tracing::error!("Transcription task failed for lesson {}: {}", id, e);
|
||||
tracing::error!("La tarea de transcripción falló para la lección {}: {}", id, e);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -9,15 +9,15 @@ use sqlx::PgPool;
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
// ==================== Token Usage Tracking ====================
|
||||
// ==================== Seguimiento del Uso de Tokens ====================
|
||||
|
||||
/// GET /api/admin/token-usage - Get token usage statistics for all users
|
||||
/// GET /api/admin/token-usage - Obtener estadísticas de uso de tokens para todos los usuarios
|
||||
pub async fn get_token_usage(
|
||||
_org_ctx: Org,
|
||||
_claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<Json<TokenUsageResponse>, (StatusCode, String)> {
|
||||
// Get user token usage from database
|
||||
// Obtener el uso de tokens de usuario de la base de datos
|
||||
let usage: Vec<TokenUsageRecord> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT
|
||||
@@ -38,9 +38,9 @@ pub async fn get_token_usage(
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch usage: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener el uso: {}", e)))?;
|
||||
|
||||
// Calculate stats
|
||||
// Calcular estadísticas
|
||||
let total_tokens: i64 = usage.iter().map(|u| u.total_tokens).sum();
|
||||
let total_input: i64 = usage.iter().map(|u| u.input_tokens).sum();
|
||||
let total_output: i64 = usage.iter().map(|u| u.output_tokens).sum();
|
||||
@@ -48,7 +48,7 @@ pub async fn get_token_usage(
|
||||
let top_user_tokens = usage.first().map(|u| u.total_tokens).unwrap_or(0);
|
||||
let avg_tokens = if !usage.is_empty() { total_tokens / usage.len() as i64 } else { 0 };
|
||||
|
||||
// Estimate cost (using approximate OpenAI pricing: $0.001/1K input, $0.003/1K output)
|
||||
// Estimación de coste (usando precios aproximados de OpenAI: $0.001/1K entrada, $0.003/1K salida)
|
||||
let estimated_cost = (total_input as f64 * 0.000001) + (total_output as f64 * 0.000003);
|
||||
|
||||
let stats = TokenUsageStats {
|
||||
@@ -61,7 +61,7 @@ pub async fn get_token_usage(
|
||||
avg_tokens_per_user: avg_tokens,
|
||||
};
|
||||
|
||||
// Convert to response format with USD estimation per user
|
||||
// Convertir al formato de respuesta con estimación en USD por usuario
|
||||
let usage_with_cost: Vec<TokenUsage> = usage
|
||||
.into_iter()
|
||||
.map(|u| {
|
||||
@@ -78,7 +78,7 @@ pub async fn get_token_usage(
|
||||
last_used: u
|
||||
.last_used
|
||||
.map(|ts| ts.to_rfc3339())
|
||||
.unwrap_or_else(|| "Never".to_string()),
|
||||
.unwrap_or_else(|| "Nunca".to_string()),
|
||||
estimated_cost_usd: user_cost,
|
||||
}
|
||||
})
|
||||
@@ -90,14 +90,14 @@ pub async fn get_token_usage(
|
||||
}))
|
||||
}
|
||||
|
||||
/// GET /api/admin/ai-usage-dashboard - Comprehensive AI usage dashboard data
|
||||
/// GET /api/admin/ai-usage-dashboard - Datos exhaustivos del panel de uso de IA
|
||||
pub async fn get_ai_usage_dashboard(
|
||||
Org(org_ctx): Org,
|
||||
_claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Query(filters): Query<DashboardFilters>,
|
||||
) -> Result<Json<DashboardResponse>, (StatusCode, String)> {
|
||||
// Get daily usage for charts
|
||||
// Obtener el uso diario para gráficos
|
||||
let daily_usage: Vec<DailyUsage> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT
|
||||
@@ -121,9 +121,9 @@ pub async fn get_ai_usage_dashboard(
|
||||
.bind(filters.end_date.clone())
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch daily usage: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener el uso diario: {}", e)))?;
|
||||
|
||||
// Get usage by endpoint/feature
|
||||
// Obtener uso por punto de conexión/función
|
||||
let by_endpoint: Vec<UsageByEndpoint> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT
|
||||
@@ -147,9 +147,9 @@ pub async fn get_ai_usage_dashboard(
|
||||
.bind(filters.end_date.clone())
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch endpoint usage: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener el uso del punto de conexión: {}", e)))?;
|
||||
|
||||
// Get top users
|
||||
// Obtener usuarios principales
|
||||
let top_users: Vec<TopUserUsage> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT
|
||||
@@ -177,18 +177,18 @@ pub async fn get_ai_usage_dashboard(
|
||||
.bind(filters.end_date.clone())
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch top users: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los usuarios principales: {}", e)))?;
|
||||
|
||||
// Calculate summary stats
|
||||
// Calcular estadísticas de resumen
|
||||
let total_tokens: i64 = daily_usage.iter().map(|d| d.total_tokens).sum();
|
||||
let total_input: i64 = daily_usage.iter().map(|d| d.input_tokens).sum();
|
||||
let total_output: i64 = daily_usage.iter().map(|d| d.output_tokens).sum();
|
||||
let total_cost: f64 = daily_usage.iter().map(|d| d.cost_usd).sum();
|
||||
let total_requests: i64 = daily_usage.iter().map(|d| d.requests).sum();
|
||||
|
||||
// Calculate savings estimate (vs OpenAI GPT-4 pricing)
|
||||
// Calcular estimación de ahorros (frente a precios de OpenAI GPT-4)
|
||||
// GPT-4: ~$0.03/1K input, ~$0.06/1K output
|
||||
// Our local AI: ~$0.001/1K input, ~$0.003/1K output
|
||||
// Nuestra IA local: ~$0.001/1K entrada, ~$0.003/1K salida
|
||||
let openai_equivalent_cost = (total_input as f64 * 0.00003) + (total_output as f64 * 0.00006);
|
||||
let savings_vs_openai = openai_equivalent_cost - total_cost;
|
||||
|
||||
@@ -208,7 +208,7 @@ pub async fn get_ai_usage_dashboard(
|
||||
}))
|
||||
}
|
||||
|
||||
/// GET /api/admin/ai-usage/logs - Get detailed AI usage logs with pagination
|
||||
/// GET /api/admin/ai-usage/logs - Obtener logs detallados de uso de IA con paginación
|
||||
pub async fn get_ai_usage_logs(
|
||||
Org(org_ctx): Org,
|
||||
_claims: Claims,
|
||||
@@ -254,9 +254,9 @@ pub async fn get_ai_usage_logs(
|
||||
.bind(offset as i64)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch logs: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los logs: {}", e)))?;
|
||||
|
||||
// Get total count for pagination
|
||||
// Obtener el conteo total para la paginación
|
||||
let count: (i64,) = sqlx::query_as(
|
||||
r#"
|
||||
SELECT COUNT(*) FROM ai_usage_logs
|
||||
@@ -272,7 +272,7 @@ pub async fn get_ai_usage_logs(
|
||||
.bind(filters.user_id.clone())
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to count logs: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al contar los logs: {}", e)))?;
|
||||
|
||||
Ok(Json(UsageLogsResponse {
|
||||
logs,
|
||||
@@ -427,15 +427,15 @@ pub struct TokenUsageResponse {
|
||||
pub stats: TokenUsageStats,
|
||||
}
|
||||
|
||||
// ==================== Global AI Usage Dashboard (Root Only) ====================
|
||||
// ==================== Panel de Uso Global de IA (Solo Root) ====================
|
||||
|
||||
/// GET /api/admin/ai-usage/global - Global AI usage dashboard for root users
|
||||
/// GET /api/admin/ai-usage/global - Panel de uso global de IA para usuarios root
|
||||
pub async fn get_ai_usage_global(
|
||||
_claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Query(filters): Query<DashboardFilters>,
|
||||
) -> Result<Json<GlobalAiUsageResponse>, (StatusCode, String)> {
|
||||
// Get daily usage for charts (all organizations)
|
||||
// Obtener el uso diario para gráficos (todas las organizaciones)
|
||||
let daily_usage: Vec<DailyUsage> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT
|
||||
@@ -457,9 +457,9 @@ pub async fn get_ai_usage_global(
|
||||
.bind(filters.end_date.clone())
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch daily usage: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener el uso diario: {}", e)))?;
|
||||
|
||||
// Get usage by endpoint/feature
|
||||
// Obtener uso por punto de conexión/función
|
||||
let by_endpoint: Vec<UsageByEndpoint> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT
|
||||
@@ -481,9 +481,9 @@ pub async fn get_ai_usage_global(
|
||||
.bind(filters.end_date.clone())
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch endpoint usage: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener el uso del punto de conexión: {}", e)))?;
|
||||
|
||||
// Get usage by organization
|
||||
// Obtener uso por organización
|
||||
let by_organization: Vec<UsageByOrganization> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT
|
||||
@@ -507,9 +507,9 @@ pub async fn get_ai_usage_global(
|
||||
.bind(filters.end_date.clone())
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch org usage: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener el uso de la organización: {}", e)))?;
|
||||
|
||||
// Get top users across all organizations
|
||||
// Obtener usuarios principales en todas las organizaciones
|
||||
let top_users: Vec<TopUserUsage> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT
|
||||
@@ -537,9 +537,9 @@ pub async fn get_ai_usage_global(
|
||||
.bind(filters.end_date.clone())
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch top users: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los usuarios principales: {}", e)))?;
|
||||
|
||||
// Get usage by request type (for pie chart)
|
||||
// Obtener uso por tipo de solicitud (para gráfico circular)
|
||||
let by_request_type: Vec<UsageByRequestType> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT
|
||||
@@ -558,18 +558,18 @@ pub async fn get_ai_usage_global(
|
||||
.bind(filters.end_date.clone())
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch request type usage: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener el uso del tipo de solicitud: {}", e)))?;
|
||||
|
||||
// Calculate summary stats
|
||||
// Calcular estadísticas de resumen
|
||||
let total_tokens: i64 = daily_usage.iter().map(|d| d.total_tokens).sum();
|
||||
let total_input: i64 = daily_usage.iter().map(|d| d.input_tokens).sum();
|
||||
let total_output: i64 = daily_usage.iter().map(|d| d.output_tokens).sum();
|
||||
let total_cost: f64 = daily_usage.iter().map(|d| d.cost_usd).sum();
|
||||
let total_requests: i64 = daily_usage.iter().map(|d| d.requests).sum();
|
||||
|
||||
// Calculate savings estimate (vs OpenAI GPT-4 pricing)
|
||||
// Calcular estimación de ahorros (frente a precios de OpenAI GPT-4)
|
||||
// GPT-4: ~$0.03/1K input, ~$0.06/1K output
|
||||
// Our local AI: ~$0.001/1K input, ~$0.003/1K output
|
||||
// Nuestra IA local: ~$0.001/1K entrada, ~$0.003/1K salida
|
||||
let openai_equivalent_cost = (total_input as f64 * 0.00003) + (total_output as f64 * 0.00006);
|
||||
let savings_vs_openai = openai_equivalent_cost - total_cost;
|
||||
let savings_percentage = if openai_equivalent_cost > 0.0 {
|
||||
@@ -578,7 +578,7 @@ pub async fn get_ai_usage_global(
|
||||
0.0
|
||||
};
|
||||
|
||||
// Get total active users count
|
||||
// Obtener el conteo total de usuarios activos
|
||||
let total_active_users: i64 = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT COUNT(DISTINCT au.user_id)
|
||||
@@ -591,9 +591,9 @@ pub async fn get_ai_usage_global(
|
||||
.bind(filters.end_date.clone())
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to count users: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al contar los usuarios: {}", e)))?;
|
||||
|
||||
// Calculate student-specific usage (chat interactions)
|
||||
// Calcular uso específico por estudiante (interacciones de chat)
|
||||
let student_chat_usage: Vec<StudentChatUsage> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT
|
||||
@@ -621,9 +621,9 @@ pub async fn get_ai_usage_global(
|
||||
.bind(filters.end_date.clone())
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch student chat usage: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener el uso de chat de estudiantes: {}", e)))?;
|
||||
|
||||
// Calculate student chat totals
|
||||
// Calcular totales de chat de estudiantes
|
||||
let total_student_chat_tokens: i64 = student_chat_usage.iter().map(|s| s.total_tokens).sum();
|
||||
let total_student_chat_cost: f64 = student_chat_usage.iter().map(|s| s.cost_usd).sum();
|
||||
let total_student_chat_requests: i64 = student_chat_usage.iter().map(|s| s.chat_requests).sum();
|
||||
@@ -722,7 +722,7 @@ pub struct GlobalAiUsageResponse {
|
||||
pub student_chat_usage: Vec<StudentChatUsage>,
|
||||
}
|
||||
|
||||
// ==================== User Token Limits ====================
|
||||
// ==================== Límites de Tokens de Usuario ====================
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetTokenLimitPayload {
|
||||
@@ -730,7 +730,7 @@ pub struct SetTokenLimitPayload {
|
||||
pub token_limit_reset_day: Option<i32>,
|
||||
}
|
||||
|
||||
/// PUT /admin/users/{user_id}/token-limit - Set monthly token limit for user
|
||||
/// PUT /admin/users/{user_id}/token-limit - Establecer límite mensual de tokens para el usuario
|
||||
pub async fn set_user_token_limit(
|
||||
_org: Org,
|
||||
_claims: Claims,
|
||||
@@ -738,7 +738,7 @@ pub async fn set_user_token_limit(
|
||||
Path(user_id): Path<Uuid>,
|
||||
Json(payload): Json<SetTokenLimitPayload>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
// Validate reset day (1-28 to avoid month-end issues)
|
||||
// Validar día de reinicio (1-28 para evitar problemas a fin de mes)
|
||||
let reset_day = payload.token_limit_reset_day.unwrap_or(1).clamp(1, 28);
|
||||
|
||||
sqlx::query(
|
||||
@@ -755,19 +755,19 @@ pub async fn set_user_token_limit(
|
||||
.bind(user_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to set limit: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al establecer el límite: {}", e)))?;
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
/// GET /admin/users/{user_id}/token-usage - Get token usage for specific user
|
||||
/// GET /admin/users/{user_id}/token-usage - Obtener uso de tokens para un usuario específico
|
||||
pub async fn get_user_token_usage(
|
||||
_org: Org,
|
||||
_claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path(user_id): Path<Uuid>,
|
||||
) -> Result<Json<UserTokenUsageDetail>, (StatusCode, String)> {
|
||||
// Get current month usage
|
||||
// Obtener uso del mes actual
|
||||
let usage: UserTokenUsageDetail = sqlx::query_as(
|
||||
r#"
|
||||
SELECT
|
||||
@@ -790,12 +790,12 @@ pub async fn get_user_token_usage(
|
||||
.bind(user_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch usage: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener el uso: {}", e)))?;
|
||||
|
||||
Ok(Json(usage))
|
||||
}
|
||||
|
||||
/// GET /admin/users/{user_id}/token-limit/check - Check if user has available tokens
|
||||
/// GET /admin/users/{user_id}/token-limit/check - Comprobar si el usuario tiene tokens disponibles
|
||||
pub async fn check_user_token_limit(
|
||||
_org: Org,
|
||||
_claims: Claims,
|
||||
|
||||
@@ -158,7 +158,7 @@ async fn maybe_push_local_file_to_s3(
|
||||
|
||||
let bytes = tokio::fs::read(local_path)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Read local file failed: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al leer el archivo local: {}", e)))?;
|
||||
|
||||
let client = build_s3_client(&settings).await?;
|
||||
let key = build_s3_object_key(org_id, course_id, storage_filename);
|
||||
@@ -189,7 +189,7 @@ async fn push_bytes_to_s3(
|
||||
.body(bytes.into())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("S3 upload failed: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("Error al subir a S3: {}", e)))?;
|
||||
|
||||
let storage_path = format!("s3://{}/{}", settings.bucket, key);
|
||||
let public_url = build_s3_public_url(settings, key);
|
||||
@@ -200,7 +200,7 @@ async fn delete_storage_path(storage_path: &str) -> Result<(), (StatusCode, Stri
|
||||
if let Some((bucket, key)) = parse_s3_storage_path(storage_path) {
|
||||
let settings = get_s3_settings().ok_or((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"S3 storage path found but S3 is not configured".to_string(),
|
||||
"Se encontró una ruta de almacenamiento S3 pero S3 no está configurado".to_string(),
|
||||
))?;
|
||||
let client = build_s3_client(&settings).await?;
|
||||
client
|
||||
@@ -209,7 +209,7 @@ async fn delete_storage_path(storage_path: &str) -> Result<(), (StatusCode, Stri
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("S3 delete failed: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("Error al eliminar de S3: {}", e)))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -227,26 +227,26 @@ fn parse_s3_storage_path(path: &str) -> Option<(&str, &str)> {
|
||||
}
|
||||
|
||||
/// GET /api/assets/s3-proxy/{bucket}/{*key}
|
||||
/// Proxies private S3 objects through CMS so frontend URLs do not depend on public-read ACLs.
|
||||
/// Realiza un proxy de objetos privados de S3 a través del CMS para que las URLs del frontend no dependan de ACLs de lectura pública.
|
||||
pub async fn public_s3_proxy(
|
||||
Path(params): Path<HashMap<String, String>>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let bucket = params
|
||||
.get("bucket")
|
||||
.cloned()
|
||||
.ok_or((StatusCode::BAD_REQUEST, "Missing bucket".to_string()))?;
|
||||
.ok_or((StatusCode::BAD_REQUEST, "Falta el cubo (bucket)".to_string()))?;
|
||||
let key = params
|
||||
.get("key")
|
||||
.cloned()
|
||||
.ok_or((StatusCode::BAD_REQUEST, "Missing key".to_string()))?;
|
||||
.ok_or((StatusCode::BAD_REQUEST, "Falta la clave (key)".to_string()))?;
|
||||
|
||||
let settings = get_s3_settings().ok_or((
|
||||
StatusCode::NOT_FOUND,
|
||||
"S3 storage is not configured".to_string(),
|
||||
"El almacenamiento S3 no está configurado".to_string(),
|
||||
))?;
|
||||
|
||||
if bucket != settings.bucket {
|
||||
return Err((StatusCode::FORBIDDEN, "Bucket not allowed".to_string()));
|
||||
return Err((StatusCode::FORBIDDEN, "Cubo (bucket) no permitido".to_string()));
|
||||
}
|
||||
|
||||
let storage_path = format!("s3://{}/{}", bucket, key);
|
||||
@@ -257,13 +257,13 @@ pub async fn public_s3_proxy(
|
||||
header::CONTENT_TYPE,
|
||||
"application/octet-stream"
|
||||
.parse()
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Invalid header: {}", e)))?,
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Cabecera inválida: {}", e)))?,
|
||||
);
|
||||
headers.insert(
|
||||
header::CACHE_CONTROL,
|
||||
"public, max-age=3600"
|
||||
.parse()
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Invalid header: {}", e)))?,
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Cabecera inválida: {}", e)))?,
|
||||
);
|
||||
|
||||
Ok((headers, bytes))
|
||||
@@ -282,18 +282,18 @@ async fn read_storage_bytes(storage_path: &str) -> Result<Vec<u8>, (StatusCode,
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("S3 read failed: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("Error al leer de S3: {}", e)))?;
|
||||
let data = output
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("S3 stream read failed: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("Error al leer el flujo de S3: {}", e)))?;
|
||||
return Ok(data.into_bytes().to_vec());
|
||||
}
|
||||
|
||||
tokio::fs::read(storage_path)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Read failed: {}", e)))
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error de lectura: {}", e)))
|
||||
}
|
||||
|
||||
/// POST /api/assets/upload - Subir un archivo a la biblioteca global
|
||||
@@ -357,12 +357,12 @@ pub async fn upload_asset(
|
||||
}
|
||||
|
||||
if data.is_empty() {
|
||||
return Err((StatusCode::BAD_REQUEST, "No file uploaded".to_string()));
|
||||
return Err((StatusCode::BAD_REQUEST, "No se subió ningún archivo".to_string()));
|
||||
}
|
||||
|
||||
let asset_id = Uuid::new_v4();
|
||||
|
||||
// Ensure uploads directory exists
|
||||
// Asegurar que el directorio de subidas existe
|
||||
tokio::fs::create_dir_all("uploads")
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
@@ -543,7 +543,7 @@ pub async fn delete_asset(
|
||||
State(pool): State<PgPool>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
// 1. Get asset metadata to find file path
|
||||
// 1. Obtener metadatos del activo para encontrar la ruta del archivo
|
||||
let asset: Asset = sqlx::query_as(
|
||||
"SELECT * FROM assets WHERE id = $1 AND organization_id = $2"
|
||||
)
|
||||
@@ -552,16 +552,16 @@ pub async fn delete_asset(
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Asset not found".to_string()))?;
|
||||
.ok_or((StatusCode::NOT_FOUND, "Activo no encontrado".to_string()))?;
|
||||
|
||||
// 2. Delete from DB
|
||||
// 2. Eliminar de la base de datos
|
||||
sqlx::query("DELETE FROM assets WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// 3. Delete physical file or S3 object
|
||||
// 3. Eliminar archivo físico u objeto de S3
|
||||
let _ = delete_storage_path(&asset.storage_path).await;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
@@ -582,7 +582,7 @@ pub async fn ingest_asset_for_rag(
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Asset not found".to_string()))?;
|
||||
.ok_or((StatusCode::NOT_FOUND, "Activo no encontrado".to_string()))?;
|
||||
|
||||
let extracted = extract_asset_text(&asset).await?;
|
||||
let content = extracted.trim();
|
||||
@@ -668,7 +668,7 @@ pub async fn ingest_asset_for_rag(
|
||||
/// - file: ZIP requerido
|
||||
/// - course_id: UUID opcional
|
||||
/// - ingest_rag: true/false opcional (default false)
|
||||
/// Extracts a unit number from a ZIP entry path using the top-level folder name.
|
||||
/// Extrae un número de unidad de la ruta de una entrada ZIP usando el nombre de la carpeta de nivel superior.
|
||||
/// Supports: "Unit 1/...", "Unidad 1/...", "unit-01/...", "01/...", "1/..."
|
||||
fn extract_unit_number(entry_name: &str) -> Option<i32> {
|
||||
let parts: Vec<&str> = entry_name.splitn(2, '/').collect();
|
||||
@@ -680,7 +680,7 @@ fn extract_unit_number(entry_name: &str) -> Option<i32> {
|
||||
return None;
|
||||
}
|
||||
let lower = folder.to_lowercase();
|
||||
// Strip common textual prefixes, then parse leading digits
|
||||
// Eliminar prefijos textuales comunes, luego analizar los dígitos iniciales
|
||||
let stripped = lower
|
||||
.trim_start_matches("unidad")
|
||||
.trim_start_matches("unit")
|
||||
@@ -739,13 +739,13 @@ async fn process_zip_entry_without_rag(
|
||||
let temp_storage_path = format!("uploads/{}", temp_storage_filename);
|
||||
tokio::fs::write(&temp_storage_path, &content)
|
||||
.await
|
||||
.map_err(|e| format!("{}: local write failed ({})", entry_name, e))?;
|
||||
.map_err(|e| format!("{}: Error en la escritura local ({})", entry_name, e))?;
|
||||
|
||||
let final_storage_filename = format!("{}.mp4", asset_id);
|
||||
let final_storage_path = format!("uploads/{}", final_storage_filename);
|
||||
if let Err((_, msg)) = transcode_flv_to_mp4(&temp_storage_path, &final_storage_path).await {
|
||||
let _ = tokio::fs::remove_file(&temp_storage_path).await;
|
||||
return Err(format!("{}: flv transcode failed ({})", entry_name, msg));
|
||||
return Err(format!("{}: la transcodificación de flv falló ({})", entry_name, msg));
|
||||
}
|
||||
let _ = tokio::fs::remove_file(&temp_storage_path).await;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Organization Branding Handlers
|
||||
// Manejadores de Marca de la Organización
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -32,84 +32,84 @@ pub struct BrandingResponse {
|
||||
pub secondary_color: String,
|
||||
}
|
||||
|
||||
// Upload organization logo
|
||||
// Cargar logo de la organización
|
||||
pub async fn upload_organization_logo(
|
||||
claims: common::auth::Claims,
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
mut multipart: axum::extract::Multipart,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
// Only admins can upload logos
|
||||
// Solo los administradores pueden cargar logos
|
||||
if claims.role != "admin" {
|
||||
return Err((StatusCode::FORBIDDEN, "Admin access required".into()));
|
||||
return Err((StatusCode::FORBIDDEN, "Se requiere acceso de administrador".into()));
|
||||
}
|
||||
|
||||
// Verify organization exists and user has access
|
||||
// Verificar que la organización existe y el usuario tiene acceso
|
||||
let _ = sqlx::query_as::<_, Organization>("SELECT * FROM organizations WHERE id = $1")
|
||||
.bind(org_ctx.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "Organization not found".into()))?;
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "Organización no encontrada".into()))?;
|
||||
|
||||
// Process multipart form
|
||||
// Procesar formulario multipart
|
||||
while let Some(field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Multipart error: {}", e)))?
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Error en multipart: {}", e)))?
|
||||
{
|
||||
let name = field.name().unwrap_or("").to_string();
|
||||
|
||||
if name == "file" {
|
||||
let filename = field
|
||||
.file_name()
|
||||
.ok_or((StatusCode::BAD_REQUEST, "Missing filename".into()))?
|
||||
.ok_or((StatusCode::BAD_REQUEST, "Faltan datos del nombre del archivo".into()))?
|
||||
.to_string();
|
||||
|
||||
// Validate file extension
|
||||
// Validar extensión del archivo
|
||||
let ext = filename.split('.').last().unwrap_or("");
|
||||
if !["png", "jpg", "jpeg", "svg"].contains(&ext.to_lowercase().as_str()) {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Invalid file type. Only PNG, JPG, and SVG allowed".into(),
|
||||
"Tipo de archivo inválido. Solo se permiten PNG, JPG y SVG".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let data = field.bytes().await.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to read file: {}", e),
|
||||
format!("Error al leer el archivo: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Validate file size (max 2MB)
|
||||
// Validar tamaño del archivo (máx. 2MB)
|
||||
if data.len() > 2 * 1024 * 1024 {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"File too large. Maximum 2MB allowed".into(),
|
||||
"Archivo demasiado grande. Se permite un máximo de 2MB".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Create uploads directory if it doesn't exist
|
||||
// Crear el directorio de subidas si no existe
|
||||
std::fs::create_dir_all("uploads/org-logos").map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to create directory: {}", e),
|
||||
format!("Error al crear el directorio: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Generate unique filename
|
||||
// Generar nombre de archivo único
|
||||
let unique_filename = format!("{}_{}.{}", org_ctx.id, uuid::Uuid::new_v4(), ext);
|
||||
let filepath = format!("uploads/org-logos/{}", unique_filename);
|
||||
|
||||
// Save file
|
||||
// Guardar archivo
|
||||
std::fs::write(&filepath, &data).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to save file: {}", e),
|
||||
format!("Error al guardar el archivo: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Update organization in database
|
||||
// Actualizar organización en la base de datos
|
||||
let logo_url = format!("/{}", filepath);
|
||||
sqlx::query("UPDATE organizations SET logo_url = $1 WHERE id = $2")
|
||||
.bind(&logo_url)
|
||||
@@ -119,7 +119,7 @@ pub async fn upload_organization_logo(
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Database error: {}", e),
|
||||
format!("Error de base de datos: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -136,92 +136,92 @@ pub async fn upload_organization_logo(
|
||||
|
||||
return Ok(Json(json!({
|
||||
"logo_url": logo_url,
|
||||
"message": "Logo uploaded successfully"
|
||||
"message": "Logo cargado con éxito"
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
Err((StatusCode::BAD_REQUEST, "No file provided".into()))
|
||||
Err((StatusCode::BAD_REQUEST, "No se proporcionó ningún archivo".into()))
|
||||
}
|
||||
|
||||
// Upload organization favicon
|
||||
// Cargar favicon de la organización
|
||||
pub async fn upload_organization_favicon(
|
||||
claims: common::auth::Claims,
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
mut multipart: axum::extract::Multipart,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
// Only admins can upload favicons
|
||||
// Solo los administradores pueden cargar favicons
|
||||
if claims.role != "admin" {
|
||||
return Err((StatusCode::FORBIDDEN, "Admin access required".into()));
|
||||
return Err((StatusCode::FORBIDDEN, "Se requiere acceso de administrador".into()));
|
||||
}
|
||||
|
||||
// Verify organization exists and user has access
|
||||
// Verificar que la organización existe y el usuario tiene acceso
|
||||
let _ = sqlx::query_as::<_, Organization>("SELECT * FROM organizations WHERE id = $1")
|
||||
.bind(org_ctx.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "Organization not found".into()))?;
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "Organización no encontrada".into()))?;
|
||||
|
||||
// Process multipart form
|
||||
// Procesar formulario multipart
|
||||
while let Some(field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Multipart error: {}", e)))?
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Error en multipart: {}", e)))?
|
||||
{
|
||||
let name = field.name().unwrap_or("").to_string();
|
||||
|
||||
if name == "file" {
|
||||
let filename = field
|
||||
.file_name()
|
||||
.ok_or((StatusCode::BAD_REQUEST, "Missing filename".into()))?
|
||||
.ok_or((StatusCode::BAD_REQUEST, "Falta el nombre del archivo".into()))?
|
||||
.to_string();
|
||||
|
||||
// Validate file extension
|
||||
// Validar extensión del archivo
|
||||
let ext = filename.split('.').last().unwrap_or("");
|
||||
if !["png", "jpg", "jpeg", "svg", "ico"].contains(&ext.to_lowercase().as_str()) {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Invalid file type. Only PNG, JPG, SVG, and ICO allowed".into(),
|
||||
"Tipo de archivo inválido. Solo se permiten PNG, JPG, SVG e ICO".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let data = field.bytes().await.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to read file: {}", e),
|
||||
format!("Error al leer el archivo: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Validate file size (max 512KB for favicons seems reasonable, but sticking to 1MB to be safe)
|
||||
// Validar tamaño del archivo (el máx. razonable para favicons es 512KB, pero se mantiene en 1MB por seguridad)
|
||||
if data.len() > 1024 * 1024 {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"File too large. Maximum 1MB allowed".into(),
|
||||
"Archivo demasiado grande. Se permite un máximo de 1MB".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Create uploads directory if it doesn't exist
|
||||
// Crear el directorio de subidas si no existe
|
||||
std::fs::create_dir_all("uploads/org-favicons").map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to create directory: {}", e),
|
||||
format!("Error al crear el directorio: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Generate unique filename
|
||||
// Generar nombre de archivo único
|
||||
let unique_filename = format!("{}_{}.{}", org_ctx.id, uuid::Uuid::new_v4(), ext);
|
||||
let filepath = format!("uploads/org-favicons/{}", unique_filename);
|
||||
|
||||
// Save file
|
||||
// Guardar archivo
|
||||
std::fs::write(&filepath, &data).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to save file: {}", e),
|
||||
format!("Error al guardar el archivo: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Update organization in database
|
||||
// Actualizar organización en la base de datos
|
||||
let favicon_url = format!("/{}", filepath);
|
||||
sqlx::query("UPDATE organizations SET favicon_url = $1 WHERE id = $2")
|
||||
.bind(&favicon_url)
|
||||
@@ -231,7 +231,7 @@ pub async fn upload_organization_favicon(
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Database error: {}", e),
|
||||
format!("Error de base de datos: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -248,27 +248,27 @@ pub async fn upload_organization_favicon(
|
||||
|
||||
return Ok(Json(json!({
|
||||
"favicon_url": favicon_url,
|
||||
"message": "Favicon uploaded successfully"
|
||||
"message": "Favicon cargado con éxito"
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
Err((StatusCode::BAD_REQUEST, "No file provided".into()))
|
||||
Err((StatusCode::BAD_REQUEST, "No se proporcionó ningún archivo".into()))
|
||||
}
|
||||
|
||||
// Update organization branding colors
|
||||
// Actualizar colores de marca de la organización
|
||||
pub async fn update_organization_branding(
|
||||
claims: common::auth::Claims,
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<BrandingPayload>,
|
||||
) -> Result<Json<Organization>, (StatusCode, String)> {
|
||||
// Only admins can update branding
|
||||
// Solo los administradores pueden actualizar la marca
|
||||
if claims.role != "admin" {
|
||||
return Err((StatusCode::FORBIDDEN, "Admin access required".into()));
|
||||
return Err((StatusCode::FORBIDDEN, "Se requiere acceso de administrador".into()));
|
||||
}
|
||||
|
||||
// Validate hex color format
|
||||
// Validar formato de color hexadecimal
|
||||
let validate_color = |color: &str| -> bool {
|
||||
color.len() == 7
|
||||
&& color.starts_with('#')
|
||||
@@ -279,7 +279,7 @@ pub async fn update_organization_branding(
|
||||
if !validate_color(primary) {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Invalid primary_color format. Use #RRGGBB".into(),
|
||||
"Formato de primary_color inválido. Use #RRGGBB".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -288,12 +288,12 @@ pub async fn update_organization_branding(
|
||||
if !validate_color(secondary) {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Invalid secondary_color format. Use #RRGGBB".into(),
|
||||
"Formato de secondary_color inválido. Use #RRGGBB".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Update organization
|
||||
// Actualizar organización
|
||||
let org = sqlx::query_as::<_, Organization>(
|
||||
"UPDATE organizations
|
||||
SET name = COALESCE($1, name),
|
||||
@@ -316,7 +316,7 @@ pub async fn update_organization_branding(
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Database error: {}", e),
|
||||
format!("Error de base de datos: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -334,7 +334,7 @@ pub async fn update_organization_branding(
|
||||
Ok(Json(org))
|
||||
}
|
||||
|
||||
// Get organization branding (public endpoint)
|
||||
// Obtener marca de la organización (punto de conexión público)
|
||||
pub async fn get_organization_branding(
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<Json<BrandingResponse>, StatusCode> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Handlers for PGVector embeddings in Question Bank
|
||||
//! Enables semantic search and RAG with AI-powered embeddings
|
||||
//! Manejadores para incrustaciones (embeddings) de PGVector en el Banco de Preguntas
|
||||
//! Habilita la búsqueda semántica y RAG con incrustaciones impulsadas por IA
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ==================== Query Parameters ====================
|
||||
// ==================== Parámetros de Consulta ====================
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SemanticSearchFilters {
|
||||
@@ -43,26 +43,26 @@ pub struct GenerateEmbeddingsResult {
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
// ==================== Generate Embeddings ====================
|
||||
// ==================== Generar Incrustaciones (Embeddings) ====================
|
||||
|
||||
/// POST /api/question-bank/embeddings/generate - Generate embeddings for all questions without them
|
||||
/// POST /api/question-bank/embeddings/generate - Generar incrustaciones para todas las preguntas que no las tengan
|
||||
pub async fn generate_question_embeddings(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<Json<GenerateEmbeddingsResult>, (StatusCode, String)> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
// Create client that accepts invalid certificates (for dev with self-signed certs)
|
||||
// Crear cliente que acepte certificados inválidos (para desarrollo con certificados autofirmados)
|
||||
let client = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_hostnames(true)
|
||||
.build()
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("HTTP client error: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error del cliente HTTP: {}", e)))?;
|
||||
|
||||
let ollama_url = ai::get_ollama_url();
|
||||
let model = ai::get_embedding_model();
|
||||
|
||||
// Get questions without embeddings
|
||||
// Obtener preguntas sin incrustaciones
|
||||
let questions: Vec<QuestionBank> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT * FROM question_bank
|
||||
@@ -82,7 +82,7 @@ pub async fn generate_question_embeddings(
|
||||
let mut failed = 0;
|
||||
|
||||
for question in questions {
|
||||
// Generate embedding text (combine question + options + explanation)
|
||||
// Generar el texto de la incrustación (combinar pregunta + opciones + explicación)
|
||||
let mut embedding_text = question.question_text.clone();
|
||||
|
||||
if let Some(options) = &question.options {
|
||||
@@ -104,12 +104,12 @@ pub async fn generate_question_embeddings(
|
||||
embedding_text.push_str(explanation);
|
||||
}
|
||||
|
||||
// Generate embedding
|
||||
// Generar incrustación
|
||||
match generate_embedding(&client, &ollama_url, &model, &embedding_text).await {
|
||||
Ok(response) => {
|
||||
let pgvector = ai::embedding_to_pgvector(&response.embedding);
|
||||
|
||||
// Update question with embedding
|
||||
// Actualizar pregunta con la incrustación
|
||||
let result: Result<(i64,), sqlx::Error> = sqlx::query_as(
|
||||
r#"
|
||||
UPDATE question_bank
|
||||
@@ -127,16 +127,16 @@ pub async fn generate_question_embeddings(
|
||||
match result {
|
||||
Ok(_) => {
|
||||
processed += 1;
|
||||
tracing::debug!("Generated embedding for question {}", question.id);
|
||||
tracing::debug!("Incrustación generada para la pregunta {}", question.id);
|
||||
}
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
tracing::error!("Failed to update embedding for question {}: {}", question.id, e);
|
||||
tracing::error!("Error al actualizar la incrustación para la pregunta {}: {}", question.id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to generate embedding for question {}: {}", question.id, e);
|
||||
tracing::error!("Error al generar la incrustación para la pregunta {}: {}", question.id, e);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
@@ -145,7 +145,7 @@ pub async fn generate_question_embeddings(
|
||||
let duration_ms = start.elapsed().as_millis() as u64;
|
||||
|
||||
tracing::info!(
|
||||
"Generated embeddings: {} processed, {} failed in {}ms",
|
||||
"Incrustaciones generadas: {} procesadas, {} fallidas en {}ms",
|
||||
processed,
|
||||
failed,
|
||||
duration_ms
|
||||
@@ -158,23 +158,23 @@ pub async fn generate_question_embeddings(
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/question-bank/:id/embedding/regenerate - Regenerate embedding for a specific question
|
||||
/// POST /api/question-bank/:id/embedding/regenerate - Regenerar incrustación para una pregunta específica
|
||||
pub async fn regenerate_question_embedding(
|
||||
Org(org_ctx): Org,
|
||||
Path(question_id): Path<Uuid>,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
// Create client that accepts invalid certificates
|
||||
// Crear cliente que acepte certificados inválidos
|
||||
let client = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_hostnames(true)
|
||||
.build()
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("HTTP client error: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error del cliente HTTP: {}", e)))?;
|
||||
|
||||
let ollama_url = ai::get_ollama_url();
|
||||
let model = ai::get_embedding_model();
|
||||
|
||||
// Get question
|
||||
// Obtener pregunta
|
||||
let question: QuestionBank = sqlx::query_as(
|
||||
"SELECT * FROM question_bank WHERE id = $1 AND organization_id = $2"
|
||||
)
|
||||
@@ -183,9 +183,9 @@ pub async fn regenerate_question_embedding(
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Question not found".to_string()))?;
|
||||
.ok_or((StatusCode::NOT_FOUND, "Pregunta no encontrada".to_string()))?;
|
||||
|
||||
// Generate embedding text
|
||||
// Generar texto de la incrustación
|
||||
let mut embedding_text = question.question_text.clone();
|
||||
|
||||
if let Some(options) = &question.options {
|
||||
@@ -207,14 +207,14 @@ pub async fn regenerate_question_embedding(
|
||||
embedding_text.push_str(explanation);
|
||||
}
|
||||
|
||||
// Generate embedding
|
||||
// Generar incrustación
|
||||
let response = generate_embedding(&client, &ollama_url, &model, &embedding_text)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("AI error: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error de IA: {}", e)))?;
|
||||
|
||||
let pgvector = ai::embedding_to_pgvector(&response.embedding);
|
||||
|
||||
// Update question
|
||||
// Actualizar pregunta
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE question_bank
|
||||
@@ -232,35 +232,35 @@ pub async fn regenerate_question_embedding(
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
// ==================== Semantic Search ====================
|
||||
// ==================== Búsqueda Semántica ====================
|
||||
|
||||
/// GET /api/question-bank/semantic-search - Search questions by semantic similarity
|
||||
/// GET /api/question-bank/semantic-search - Buscar preguntas por similitud semántica
|
||||
pub async fn semantic_search(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Query(filters): Query<SemanticSearchFilters>,
|
||||
) -> Result<Json<Vec<SemanticSearchResult>>, (StatusCode, String)> {
|
||||
// Create client that accepts invalid certificates
|
||||
// Crear cliente que acepte certificados inválidos
|
||||
let client = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_hostnames(true)
|
||||
.build()
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("HTTP client error: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error del cliente HTTP: {}", e)))?;
|
||||
|
||||
let ollama_url = ai::get_ollama_url();
|
||||
let model = ai::get_embedding_model();
|
||||
|
||||
// Generate embedding for query
|
||||
// Generar incrustación para la consulta
|
||||
let embedding_response = generate_embedding(&client, &ollama_url, &model, &filters.query)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("AI error: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error de IA: {}", e)))?;
|
||||
|
||||
let pgvector = ai::embedding_to_pgvector(&embedding_response.embedding);
|
||||
|
||||
let limit = filters.limit.unwrap_or(20);
|
||||
let threshold = filters.threshold.unwrap_or(0.5);
|
||||
|
||||
// Build query with optional filters
|
||||
// Construir consulta con filtros opcionales
|
||||
let mut query = String::from(
|
||||
r#"
|
||||
SELECT
|
||||
@@ -316,7 +316,7 @@ pub async fn semantic_search(
|
||||
Ok(Json(results))
|
||||
}
|
||||
|
||||
/// GET /api/question-bank/similar/:id - Find questions similar to a given question
|
||||
/// GET /api/question-bank/similar/:id - Encontrar preguntas similares a una pregunta dada
|
||||
pub async fn find_similar_questions(
|
||||
Org(org_ctx): Org,
|
||||
Path(question_id): Path<Uuid>,
|
||||
|
||||
@@ -13,7 +13,7 @@ use uuid::Uuid;
|
||||
pub struct LibraryBlockFilters {
|
||||
#[serde(rename = "type")]
|
||||
pub block_type: Option<String>,
|
||||
pub tags: Option<String>, // Comma-separated list
|
||||
pub tags: Option<String>, // Lista separada por comas
|
||||
pub search: Option<String>,
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ pub async fn list_library_blocks(
|
||||
State(pool): State<PgPool>,
|
||||
Query(filters): Query<LibraryBlockFilters>,
|
||||
) -> Result<Json<Vec<LibraryBlock>>, (StatusCode, String)> {
|
||||
// Base query
|
||||
// Consulta base
|
||||
let mut query = String::from("SELECT * FROM library_blocks WHERE organization_id = $1");
|
||||
let mut param_count = 1;
|
||||
|
||||
@@ -78,7 +78,7 @@ pub async fn list_library_blocks(
|
||||
|
||||
query.push_str(" ORDER BY created_at DESC");
|
||||
|
||||
// Build query con bind dinámico
|
||||
// Construir consulta con bind dinámico
|
||||
let mut sql_query = sqlx::query_as::<_, LibraryBlock>(&query).bind(org_ctx.id);
|
||||
|
||||
if let Some(block_type) = &filters.block_type {
|
||||
@@ -120,7 +120,7 @@ pub async fn get_library_block(
|
||||
|
||||
match block {
|
||||
Some(b) => Ok(Json(b)),
|
||||
None => Err((StatusCode::NOT_FOUND, "Block not found".to_string())),
|
||||
None => Err((StatusCode::NOT_FOUND, "Bloque no encontrado".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ pub async fn update_library_block(
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if existing.is_none() {
|
||||
return Err((StatusCode::NOT_FOUND, "Block not found".to_string()));
|
||||
return Err((StatusCode::NOT_FOUND, "Bloque no encontrado".to_string()));
|
||||
}
|
||||
|
||||
// Update dinámico basado en campos provistos
|
||||
@@ -201,7 +201,7 @@ pub async fn delete_library_block(
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err((StatusCode::NOT_FOUND, "Block not found".to_string()));
|
||||
return Err((StatusCode::NOT_FOUND, "Bloque no encontrado".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
@@ -221,7 +221,7 @@ pub async fn increment_block_usage(
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err((StatusCode::NOT_FOUND, "Block not found".to_string()));
|
||||
return Err((StatusCode::NOT_FOUND, "Bloque no encontrado".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
|
||||
use sqlx::{PgPool, Row};
|
||||
use uuid::Uuid;
|
||||
|
||||
// ==================== Payload Structs ====================
|
||||
// ==================== Estructuras de Carga Útil (Payload) ====================
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateRubricPayload {
|
||||
@@ -97,9 +97,9 @@ pub struct CriterionWithLevels {
|
||||
pub levels: Vec<RubricLevel>,
|
||||
}
|
||||
|
||||
// ==================== Rubric Management ====================
|
||||
// ==================== Gestión de Rúbricas ====================
|
||||
|
||||
/// Create a new rubric
|
||||
/// Crear una nueva rúbrica
|
||||
pub async fn create_rubric(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
@@ -126,7 +126,7 @@ pub async fn create_rubric(
|
||||
Ok(Json(rubric))
|
||||
}
|
||||
|
||||
/// List all rubrics for a course
|
||||
/// Listar todas las rúbricas de un curso
|
||||
pub async fn list_course_rubrics(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
@@ -149,7 +149,7 @@ pub async fn list_course_rubrics(
|
||||
Ok(Json(rubrics))
|
||||
}
|
||||
|
||||
/// Get a rubric with all criteria and levels
|
||||
/// Obtener una rúbrica con todos los criterios y niveles
|
||||
pub async fn get_rubric_with_details(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
@@ -168,7 +168,7 @@ pub async fn get_rubric_with_details(
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Rubric not found".to_string()))?;
|
||||
.ok_or((StatusCode::NOT_FOUND, "Rúbrica no encontrada".to_string()))?;
|
||||
|
||||
// Get criteria
|
||||
let criteria: Vec<RubricCriterion> = sqlx::query_as(
|
||||
@@ -209,7 +209,7 @@ pub async fn get_rubric_with_details(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Update a rubric
|
||||
/// Actualizar una rúbrica
|
||||
pub async fn update_rubric(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
@@ -233,12 +233,12 @@ pub async fn update_rubric(
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Rubric not found".to_string()))?;
|
||||
.ok_or((StatusCode::NOT_FOUND, "Rúbrica no encontrada".to_string()))?;
|
||||
|
||||
Ok(Json(rubric))
|
||||
}
|
||||
|
||||
/// Delete a rubric
|
||||
/// Eliminar una rúbrica
|
||||
pub async fn delete_rubric(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
@@ -252,15 +252,15 @@ pub async fn delete_rubric(
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err((StatusCode::NOT_FOUND, "Rubric not found".to_string()));
|
||||
return Err((StatusCode::NOT_FOUND, "Rúbrica no encontrada".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ==================== Criterion Management ====================
|
||||
// ==================== Gestión de Criterios ====================
|
||||
|
||||
/// Add a criterion to a rubric
|
||||
/// Añadir un criterio a una rúbrica
|
||||
pub async fn create_criterion(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
@@ -274,7 +274,7 @@ pub async fn create_criterion(
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Rubric not found".to_string()))?;
|
||||
.ok_or((StatusCode::NOT_FOUND, "Rúbrica no encontrada".to_string()))?;
|
||||
|
||||
let position = payload.position.unwrap_or(0);
|
||||
|
||||
@@ -311,7 +311,7 @@ pub async fn create_criterion(
|
||||
Ok(Json(criterion))
|
||||
}
|
||||
|
||||
/// Update a criterion
|
||||
/// Actualizar un criterio
|
||||
pub async fn update_criterion(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
@@ -339,7 +339,7 @@ pub async fn update_criterion(
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Criterion not found".to_string()))?;
|
||||
.ok_or((StatusCode::NOT_FOUND, "Criterio no encontrado".to_string()))?;
|
||||
|
||||
// Update rubric total_points if max_points changed
|
||||
if payload.max_points.is_some() {
|
||||
@@ -360,7 +360,7 @@ pub async fn update_criterion(
|
||||
Ok(Json(criterion))
|
||||
}
|
||||
|
||||
/// Delete a criterion
|
||||
/// Eliminar un criterio
|
||||
pub async fn delete_criterion(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
@@ -372,7 +372,7 @@ pub async fn delete_criterion(
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Criterion not found".to_string()))?;
|
||||
.ok_or((StatusCode::NOT_FOUND, "Criterio no encontrado".to_string()))?;
|
||||
|
||||
let rubric_id: Uuid = criterion_row.get("rubric_id");
|
||||
|
||||
@@ -390,7 +390,7 @@ pub async fn delete_criterion(
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err((StatusCode::NOT_FOUND, "Criterion not found".to_string()));
|
||||
return Err((StatusCode::NOT_FOUND, "Criterio no encontrado".to_string()));
|
||||
}
|
||||
|
||||
// Update rubric total_points
|
||||
@@ -410,9 +410,9 @@ pub async fn delete_criterion(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ==================== Performance Level Management ====================
|
||||
// ==================== Gestión de Niveles de Desempeño ====================
|
||||
|
||||
/// Add a performance level to a criterion
|
||||
/// Añadir un nivel de desempeño a un criterio
|
||||
pub async fn create_level(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
@@ -426,7 +426,7 @@ pub async fn create_level(
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Criterion not found".to_string()))?;
|
||||
.ok_or((StatusCode::NOT_FOUND, "Criterio no encontrado".to_string()))?;
|
||||
|
||||
let position = payload.position.unwrap_or(0);
|
||||
|
||||
@@ -449,7 +449,7 @@ pub async fn create_level(
|
||||
Ok(Json(level))
|
||||
}
|
||||
|
||||
/// Update a performance level
|
||||
/// Actualizar un nivel de desempeño
|
||||
pub async fn update_level(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
@@ -480,12 +480,12 @@ pub async fn update_level(
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Level not found".to_string()))?;
|
||||
.ok_or((StatusCode::NOT_FOUND, "Nivel no encontrado".to_string()))?;
|
||||
|
||||
Ok(Json(level))
|
||||
}
|
||||
|
||||
/// Delete a performance level
|
||||
/// Eliminar un nivel de desempeño
|
||||
pub async fn delete_level(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
@@ -508,15 +508,15 @@ pub async fn delete_level(
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err((StatusCode::NOT_FOUND, "Level not found".to_string()));
|
||||
return Err((StatusCode::NOT_FOUND, "Nivel no encontrado".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ==================== Lesson-Rubric Association ====================
|
||||
// ==================== Asociación Lección-Rúbrica ====================
|
||||
|
||||
/// Assign a rubric to a lesson
|
||||
/// Asignar una rúbrica a una lección
|
||||
pub async fn assign_rubric_to_lesson(
|
||||
Org(_org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
@@ -539,7 +539,7 @@ pub async fn assign_rubric_to_lesson(
|
||||
Ok(Json(lesson_rubric))
|
||||
}
|
||||
|
||||
/// Unassign a rubric from a lesson
|
||||
/// Desasignar una rúbrica de una lección
|
||||
pub async fn unassign_rubric_from_lesson(
|
||||
Org(_org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
@@ -553,13 +553,13 @@ pub async fn unassign_rubric_from_lesson(
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err((StatusCode::NOT_FOUND, "Lesson rubric not found".to_string()));
|
||||
return Err((StatusCode::NOT_FOUND, "Vínculo lección-rúbrica no encontrado".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// Get rubrics assigned to a lesson
|
||||
/// Obtener rúbricas asignadas a una lección
|
||||
pub async fn get_lesson_rubrics(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/// SAM (Sistema de Administración Académica) Integration Handlers
|
||||
/// Manejadores de Integración con SAM (Sistema de Administración Académica)
|
||||
///
|
||||
/// This module handles synchronization between OpenCCB and the external SAM system.
|
||||
/// Este módulo maneja la sincronización entre OpenCCB y el sistema externo SAM.
|
||||
/// SAM tables: sige_sam_v3.alumnos, sige_sam_v3.detalle_contrato
|
||||
|
||||
use axum::{
|
||||
@@ -50,27 +50,27 @@ pub struct SamStudentFilters {
|
||||
pub nombre: Option<String>,
|
||||
}
|
||||
|
||||
/// ==================== SAM Sync Handlers ====================
|
||||
/// ==================== Manejadores de Sincronización SAM ====================
|
||||
|
||||
/// POST /api/sam/sync-students
|
||||
/// Sync students from sige_sam_v3.alumnos to OpenCCB users table
|
||||
/// Sincronizar estudiantes de sige_sam_v3.alumnos a la tabla de usuarios de OpenCCB
|
||||
pub async fn sync_sam_students(
|
||||
_org: Org,
|
||||
_claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<Json<SamSyncResponse>, (StatusCode, String)> {
|
||||
// Connect to external SAM database
|
||||
// Conectar a la base de datos externa de SAM
|
||||
let sam_url = std::env::var("SAM_DATABASE_URL")
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "SAM_DATABASE_URL not configured".to_string()))?;
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "SAM_DATABASE_URL no configurada".to_string()))?;
|
||||
|
||||
let sam_pool = sqlx::PgPool::connect(&sam_url)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to connect to SAM DB: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al conectar con la base de datos SAM: {}", e)))?;
|
||||
|
||||
let mut errors = Vec::new();
|
||||
let mut students_synced = 0;
|
||||
|
||||
// Fetch active students from SAM using generic query
|
||||
// Obtener estudiantes activos de SAM usando una consulta genérica
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
@@ -85,9 +85,9 @@ pub async fn sync_sam_students(
|
||||
)
|
||||
.fetch_all(&sam_pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch SAM students: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener estudiantes de SAM: {}", e)))?;
|
||||
|
||||
// Convert to SamStudentInfo
|
||||
// Convertir a SamStudentInfo
|
||||
let sam_students: Vec<SamStudentInfo> = rows.iter().map(|row| {
|
||||
SamStudentInfo {
|
||||
id_alumno: row.get("id_alumno"),
|
||||
@@ -98,9 +98,9 @@ pub async fn sync_sam_students(
|
||||
}
|
||||
}).collect();
|
||||
|
||||
// Sync each student to OpenCCB
|
||||
// Sincronizar cada estudiante con OpenCCB
|
||||
for sam_student in sam_students {
|
||||
// Check if user exists by email
|
||||
// Comprobar si el usuario existe por correo electrónico
|
||||
let existing_user: Option<(Uuid, Option<String>)> = sqlx::query_as(
|
||||
"SELECT id, sam_student_id FROM users WHERE email = $1"
|
||||
)
|
||||
@@ -108,7 +108,7 @@ pub async fn sync_sam_students(
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
errors.push(format!("Error checking user {}: {}", sam_student.email, e));
|
||||
errors.push(format!("Error al comprobar el usuario {}: {}", sam_student.email, e));
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
|
||||
})
|
||||
.ok()
|
||||
@@ -116,7 +116,7 @@ pub async fn sync_sam_students(
|
||||
|
||||
match existing_user {
|
||||
Some((user_id, existing_sam_id)) => {
|
||||
// Update existing user with SAM info
|
||||
// Actualizar usuario existente con información de SAM
|
||||
let update_result = sqlx::query(
|
||||
r#"
|
||||
UPDATE users
|
||||
@@ -136,11 +136,11 @@ pub async fn sync_sam_students(
|
||||
if update_result.is_ok() {
|
||||
students_synced += 1;
|
||||
} else {
|
||||
errors.push(format!("Failed to update user {}", sam_student.email));
|
||||
errors.push(format!("Error al actualizar el usuario {}", sam_student.email));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Create new user for SAM student
|
||||
// Crear nuevo usuario para el estudiante de SAM
|
||||
let insert_result = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO users (
|
||||
@@ -157,18 +157,18 @@ pub async fn sync_sam_students(
|
||||
"#
|
||||
)
|
||||
.bind(&sam_student.email)
|
||||
.bind(format!("sam_managed_{}", sam_student.id_alumno)) // Placeholder password
|
||||
.bind(format!("sam_managed_{}", sam_student.id_alumno)) // Contraseña provisional
|
||||
.bind(&sam_student.nombre)
|
||||
.bind("student")
|
||||
.bind(Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap()) // Default org
|
||||
.bind(Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap()) // Organización por defecto
|
||||
.bind(&sam_student.id_alumno)
|
||||
.fetch_optional(&pool)
|
||||
.await;
|
||||
|
||||
match insert_result {
|
||||
Ok(Some(_)) => students_synced += 1,
|
||||
Ok(None) => errors.push(format!("Failed to create user for {}", sam_student.email)),
|
||||
Err(e) => errors.push(format!("Error creating user {}: {}", sam_student.email, e)),
|
||||
Ok(None) => errors.push(format!("Error al crear el usuario para {}", sam_student.email)),
|
||||
Err(e) => errors.push(format!("Error al crear el usuario {}: {}", sam_student.email, e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,24 +184,24 @@ pub async fn sync_sam_students(
|
||||
}
|
||||
|
||||
/// POST /api/sam/sync-assignments
|
||||
/// Sync course assignments from sige_sam_v3.detalle_contrato
|
||||
/// Sincronizar asignaciones de cursos desde sige_sam_v3.detalle_contrato
|
||||
pub async fn sync_sam_assignments(
|
||||
_org: Org,
|
||||
_claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<Json<SamSyncResponse>, (StatusCode, String)> {
|
||||
// Connect to external SAM database
|
||||
// Conectar a la base de datos externa de SAM
|
||||
let sam_url = std::env::var("SAM_DATABASE_URL")
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "SAM_DATABASE_URL not configured".to_string()))?;
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "SAM_DATABASE_URL no configurada".to_string()))?;
|
||||
|
||||
let sam_pool = sqlx::PgPool::connect(&sam_url)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to connect to SAM DB: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al conectar con la base de datos SAM: {}", e)))?;
|
||||
|
||||
let mut errors = Vec::new();
|
||||
let mut assignments_synced = 0;
|
||||
|
||||
// Fetch active course assignments from SAM
|
||||
// Obtener asignaciones de cursos activas de SAM
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
@@ -215,9 +215,9 @@ pub async fn sync_sam_assignments(
|
||||
)
|
||||
.fetch_all(&sam_pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch assignments: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener asignaciones: {}", e)))?;
|
||||
|
||||
// Convert to SamAssignmentInfo
|
||||
// Convertir a SamAssignmentInfo
|
||||
let sam_assignments: Vec<SamAssignmentInfo> = rows.iter().map(|row| {
|
||||
SamAssignmentInfo {
|
||||
id_contrato: row.get("id_contrato"),
|
||||
@@ -227,10 +227,10 @@ pub async fn sync_sam_assignments(
|
||||
}
|
||||
}).collect();
|
||||
|
||||
// Sync each assignment
|
||||
// Sincronizar cada asignación
|
||||
for assignment in sam_assignments {
|
||||
// Get the OpenCCB course ID from SAM course ID
|
||||
// This assumes you have a mapping table or the SAM course ID matches OpenCCB course external_id
|
||||
// Obtener el ID del curso de OpenCCB a partir del ID del curso SAM
|
||||
// Esto asume que tienes una tabla de mapeo o que el ID del curso SAM coincide con el external_id del curso en OpenCCB
|
||||
let course_result = sqlx::query_as::<_, (Uuid,)>(
|
||||
"SELECT id FROM courses WHERE external_sam_id = $1 OR id = $2"
|
||||
)
|
||||
@@ -243,13 +243,13 @@ pub async fn sync_sam_assignments(
|
||||
Ok(Some((id,))) => Some(id),
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
errors.push(format!("Error finding course {}: {}", assignment.id_curso_abierto, e));
|
||||
errors.push(format!("Error al encontrar el curso {}: {}", assignment.id_curso_abierto, e));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(course_id) = course_id {
|
||||
// Upsert assignment
|
||||
// Actualizar o insertar asignación
|
||||
let upsert_result = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO sam_course_assignments (sam_student_id, sam_contrato_id, course_id, is_active, synced_at)
|
||||
@@ -270,7 +270,7 @@ pub async fn sync_sam_assignments(
|
||||
if upsert_result.is_ok() {
|
||||
assignments_synced += 1;
|
||||
} else {
|
||||
errors.push(format!("Failed to sync assignment for student {}", assignment.id_alumno));
|
||||
errors.push(format!("Error al sincronizar la asignación para el estudiante {}", assignment.id_alumno));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -285,7 +285,7 @@ pub async fn sync_sam_assignments(
|
||||
}
|
||||
|
||||
/// GET /api/sam/students
|
||||
/// List SAM students with optional filters
|
||||
/// Listar estudiantes de SAM con filtros opcionales
|
||||
pub async fn list_sam_students(
|
||||
_org: Org,
|
||||
_claims: Claims,
|
||||
@@ -318,7 +318,7 @@ pub async fn list_sam_students(
|
||||
let rows = sqlx::query(&query)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch students: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los estudiantes: {}", e)))?;
|
||||
|
||||
let students: Vec<serde_json::Value> = rows.iter().map(|row| {
|
||||
serde_json::json!({
|
||||
@@ -336,7 +336,7 @@ pub async fn list_sam_students(
|
||||
}
|
||||
|
||||
/// GET /api/sam/students/{student_id}/courses
|
||||
/// Get courses assigned to a specific SAM student
|
||||
/// Obtener cursos asignados a un estudiante SAM específico
|
||||
pub async fn get_sam_student_courses(
|
||||
_org: Org,
|
||||
_claims: Claims,
|
||||
@@ -355,7 +355,7 @@ pub async fn get_sam_student_courses(
|
||||
.bind(&sam_student_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)))?;
|
||||
|
||||
let courses: Vec<serde_json::Value> = rows.iter().map(|row| {
|
||||
serde_json::json!({
|
||||
@@ -371,7 +371,7 @@ pub async fn get_sam_student_courses(
|
||||
}
|
||||
|
||||
/// POST /api/sam/sync-all
|
||||
/// Full synchronization: students + assignments
|
||||
/// Sincronización completa: estudiantes + asignaciones
|
||||
pub async fn sync_all_sam(
|
||||
org: Org,
|
||||
claims: Claims,
|
||||
@@ -381,17 +381,17 @@ pub async fn sync_all_sam(
|
||||
let mut students_synced = 0;
|
||||
let mut assignments_synced = 0;
|
||||
|
||||
// Connect to external SAM database
|
||||
// Conectar a la base de datos externa de SAM
|
||||
let sam_url = std::env::var("SAM_DATABASE_URL")
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "SAM_DATABASE_URL not configured".to_string()))?;
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "SAM_DATABASE_URL no configurada".to_string()))?;
|
||||
|
||||
let sam_pool = sqlx::PgPool::connect(&sam_url)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to connect to SAM DB: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al conectar con la base de datos SAM: {}", e)))?;
|
||||
|
||||
// Sync students first
|
||||
// Sincronizar estudiantes primero
|
||||
{
|
||||
// Fetch active students from SAM
|
||||
// Obtener estudiantes activos de SAM
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT id_alumno, nombre, email, telefono, activo
|
||||
@@ -401,7 +401,7 @@ pub async fn sync_all_sam(
|
||||
)
|
||||
.fetch_all(&sam_pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch SAM students: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener estudiantes de SAM: {}", e)))?;
|
||||
|
||||
let sam_students: Vec<SamStudentInfo> = rows.iter().map(|row| {
|
||||
SamStudentInfo {
|
||||
@@ -413,7 +413,7 @@ pub async fn sync_all_sam(
|
||||
}
|
||||
}).collect();
|
||||
|
||||
// Sync each student
|
||||
// Sincronizar cada estudiante
|
||||
for sam_student in sam_students {
|
||||
let existing_user: Option<(Uuid, Option<String>)> = sqlx::query_as(
|
||||
"SELECT id, sam_student_id FROM users WHERE email = $1"
|
||||
@@ -422,7 +422,7 @@ pub async fn sync_all_sam(
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
errors.push(format!("Error checking user {}: {}", sam_student.email, e));
|
||||
errors.push(format!("Error al comprobar el usuario {}: {}", sam_student.email, e));
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
|
||||
})
|
||||
.ok()
|
||||
@@ -439,7 +439,7 @@ pub async fn sync_all_sam(
|
||||
.execute(&pool)
|
||||
.await;
|
||||
|
||||
if update_result.is_ok() { students_synced += 1; } else { errors.push(format!("Failed to update user {}", sam_student.email)); }
|
||||
if update_result.is_ok() { students_synced += 1; } else { errors.push(format!("Error al actualizar el usuario {}", sam_student.email)); }
|
||||
}
|
||||
None => {
|
||||
let insert_result = sqlx::query(
|
||||
@@ -456,22 +456,22 @@ pub async fn sync_all_sam(
|
||||
|
||||
match insert_result {
|
||||
Ok(Some(_)) => students_synced += 1,
|
||||
Ok(None) => errors.push(format!("Failed to create user for {}", sam_student.email)),
|
||||
Err(e) => errors.push(format!("Error creating user {}: {}", sam_student.email, e)),
|
||||
Ok(None) => errors.push(format!("Error al crear el usuario para {}", sam_student.email)),
|
||||
Err(e) => errors.push(format!("Error al crear el usuario {}: {}", sam_student.email, e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync assignments
|
||||
// Sincronizar asignaciones
|
||||
{
|
||||
let rows = sqlx::query(
|
||||
"SELECT id_contrato, id_alumno, id_curso_abierto, estado FROM sige_sam_v3.detalle_contrato WHERE estado = 'activo' OR estado = 'vigente'"
|
||||
)
|
||||
.fetch_all(&sam_pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch assignments: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener asignaciones: {}", e)))?;
|
||||
|
||||
let sam_assignments: Vec<SamAssignmentInfo> = rows.iter().map(|row| {
|
||||
SamAssignmentInfo {
|
||||
@@ -494,7 +494,7 @@ pub async fn sync_all_sam(
|
||||
let course_id = match course_result {
|
||||
Ok(Some((id,))) => Some(id),
|
||||
Ok(None) => continue,
|
||||
Err(e) => { errors.push(format!("Error finding course {}: {}", assignment.id_curso_abierto, e)); continue; }
|
||||
Err(e) => { errors.push(format!("Error al encontrar el curso {}: {}", assignment.id_curso_abierto, e)); continue; }
|
||||
};
|
||||
|
||||
if let Some(course_id) = course_id {
|
||||
@@ -507,7 +507,7 @@ pub async fn sync_all_sam(
|
||||
.execute(&pool)
|
||||
.await;
|
||||
|
||||
if upsert_result.is_ok() { assignments_synced += 1; } else { errors.push(format!("Failed to sync assignment for student {}", assignment.id_alumno)); }
|
||||
if upsert_result.is_ok() { assignments_synced += 1; } else { errors.push(format!("Error al sincronizar la asignación para el estudiante {}", assignment.id_alumno)); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,17 +58,17 @@ fn normalize_question_bank_payload_values(
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TestTemplateFilters {
|
||||
pub mysql_course_id: Option<i32>, // Filter by MySQL course ID
|
||||
pub mysql_course_id: Option<i32>, // Filtrar por ID de curso MySQL
|
||||
pub level: Option<CourseLevel>,
|
||||
pub course_type: Option<CourseType>,
|
||||
pub test_type: Option<TestType>,
|
||||
pub tags: Option<String>, // Comma-separated list
|
||||
pub tags: Option<String>, // Lista separada por comas
|
||||
pub search: Option<String>,
|
||||
}
|
||||
|
||||
// ==================== Create ====================
|
||||
|
||||
/// POST /api/test-templates - Create a new test template
|
||||
/// POST /api/test-templates - Crear una nueva plantilla de test
|
||||
pub async fn create_test_template(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
@@ -115,47 +115,47 @@ pub async fn create_test_template(
|
||||
|
||||
// ==================== Read ====================
|
||||
|
||||
/// GET /api/test-templates - List test templates with filters
|
||||
/// GET /api/test-templates - Listar plantillas de test con filtros
|
||||
pub async fn list_test_templates(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Query(filters): Query<TestTemplateFilters>,
|
||||
) -> Result<Json<Vec<TestTemplate>>, (StatusCode, String)> {
|
||||
// Base query
|
||||
// Consulta base
|
||||
let mut query = String::from("SELECT * FROM test_templates WHERE organization_id = $1");
|
||||
let mut param_count = 1;
|
||||
|
||||
// Filter by mysql_course_id
|
||||
// Filtrar por mysql_course_id
|
||||
if filters.mysql_course_id.is_some() {
|
||||
param_count += 1;
|
||||
query.push_str(&format!(" AND mysql_course_id = ${}", param_count));
|
||||
}
|
||||
|
||||
// Filter by level
|
||||
// Filtrar por nivel
|
||||
if filters.level.is_some() {
|
||||
param_count += 1;
|
||||
query.push_str(&format!(" AND level = ${}", param_count));
|
||||
}
|
||||
|
||||
// Filter by course type
|
||||
// Filtrar por tipo de curso
|
||||
if filters.course_type.is_some() {
|
||||
param_count += 1;
|
||||
query.push_str(&format!(" AND course_type = ${}", param_count));
|
||||
}
|
||||
|
||||
// Filter by test type
|
||||
// Filtrar por tipo de test
|
||||
if filters.test_type.is_some() {
|
||||
param_count += 1;
|
||||
query.push_str(&format!(" AND test_type = ${}", param_count));
|
||||
}
|
||||
|
||||
// Filter by tags (array overlap)
|
||||
// Filtrar por etiquetas (solapamiento de array)
|
||||
if filters.tags.is_some() {
|
||||
param_count += 1;
|
||||
query.push_str(&format!(" AND tags && ${}", param_count));
|
||||
}
|
||||
|
||||
// Search in name and description
|
||||
// Buscar en nombre y descripción
|
||||
if filters.search.is_some() {
|
||||
param_count += 1;
|
||||
query.push_str(&format!(
|
||||
@@ -166,7 +166,7 @@ pub async fn list_test_templates(
|
||||
|
||||
query.push_str(" ORDER BY created_at DESC");
|
||||
|
||||
// Build query with dynamic binds
|
||||
// Construir consulta con binds dinámicos
|
||||
let mut sql_query = sqlx::query_as::<_, TestTemplate>(&query).bind(org_ctx.id);
|
||||
|
||||
if let Some(mysql_course_id) = &filters.mysql_course_id {
|
||||
@@ -203,13 +203,13 @@ pub async fn list_test_templates(
|
||||
Ok(Json(templates))
|
||||
}
|
||||
|
||||
/// GET /api/test-templates/:id - Get a specific test template with questions
|
||||
/// GET /api/test-templates/:id - Obtener una plantilla de test específica con preguntas
|
||||
pub async fn get_test_template(
|
||||
Org(org_ctx): Org,
|
||||
Path(template_id): Path<Uuid>,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<Json<TestTemplateWithQuestions>, (StatusCode, String)> {
|
||||
// Get template
|
||||
// Obtener plantilla
|
||||
let template: TestTemplate = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, organization_id, mysql_course_id, created_by, name, description, level, course_type,
|
||||
@@ -224,11 +224,11 @@ pub async fn get_test_template(
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Template not found".to_string()),
|
||||
sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Plantilla no encontrada".to_string()),
|
||||
_ => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
||||
})?;
|
||||
|
||||
// Get sections
|
||||
// Obtener secciones
|
||||
let sections: Vec<TestTemplateSection> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, template_id, title, description, section_order, points, instructions, section_data, created_at
|
||||
@@ -242,7 +242,7 @@ pub async fn get_test_template(
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Get questions
|
||||
// Obtener preguntas
|
||||
let questions: Vec<TestTemplateQuestion> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, template_id, section_id, question_order, question_type, question_text,
|
||||
@@ -266,7 +266,7 @@ pub async fn get_test_template(
|
||||
|
||||
// ==================== Update ====================
|
||||
|
||||
/// PUT /api/test-templates/:id - Update a test template
|
||||
/// PUT /api/test-templates/:id - Actualizar una plantilla de test
|
||||
pub async fn update_test_template(
|
||||
Org(org_ctx): Org,
|
||||
Path(template_id): Path<Uuid>,
|
||||
@@ -316,7 +316,7 @@ pub async fn update_test_template(
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Template not found".to_string()),
|
||||
sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Plantilla no encontrada".to_string()),
|
||||
_ => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
||||
})?;
|
||||
|
||||
@@ -325,7 +325,7 @@ pub async fn update_test_template(
|
||||
|
||||
// ==================== Delete ====================
|
||||
|
||||
/// DELETE /api/test-templates/:id - Delete a test template
|
||||
/// DELETE /api/test-templates/:id - Eliminar una plantilla de test
|
||||
pub async fn delete_test_template(
|
||||
Org(org_ctx): Org,
|
||||
Path(template_id): Path<Uuid>,
|
||||
@@ -344,22 +344,22 @@ pub async fn delete_test_template(
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err((StatusCode::NOT_FOUND, "Template not found".to_string()));
|
||||
return Err((StatusCode::NOT_FOUND, "Plantilla no encontrada".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ==================== Template Questions Management ====================
|
||||
// ==================== Gestión de Preguntas de la Plantilla ====================
|
||||
|
||||
/// POST /api/test-templates/:id/questions - Add a question to a template
|
||||
/// POST /api/test-templates/:id/questions - Añadir una pregunta a una plantilla
|
||||
pub async fn create_template_question(
|
||||
Org(org_ctx): Org,
|
||||
Path(template_id): Path<Uuid>,
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<CreateQuestionPayload>,
|
||||
) -> Result<Json<TestTemplateQuestion>, (StatusCode, String)> {
|
||||
// Verify template exists and belongs to organization
|
||||
// Verificar que la plantilla existe y pertenece a la organización
|
||||
let exists: (bool,) = sqlx::query_as(
|
||||
r#"SELECT EXISTS(SELECT 1 FROM test_templates WHERE id = $1 AND organization_id = $2)"#
|
||||
)
|
||||
@@ -370,7 +370,7 @@ pub async fn create_template_question(
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if !exists.0 {
|
||||
return Err((StatusCode::NOT_FOUND, "Template not found".to_string()));
|
||||
return Err((StatusCode::NOT_FOUND, "Plantilla no encontrada".to_string()));
|
||||
}
|
||||
|
||||
let question: TestTemplateQuestion = sqlx::query_as(
|
||||
@@ -414,13 +414,13 @@ pub struct CreateQuestionPayload {
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// DELETE /api/test-templates/:template_id/questions/:question_id - Delete a question
|
||||
/// DELETE /api/test-templates/:template_id/questions/:question_id - Eliminar una pregunta
|
||||
pub async fn delete_template_question(
|
||||
Org(org_ctx): Org,
|
||||
Path((template_id, question_id)): Path<(Uuid, Uuid)>,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
// Verify template exists and belongs to organization
|
||||
// Verificar que la plantilla existe y pertenece a la organización
|
||||
let exists: (bool,) = sqlx::query_as(
|
||||
r#"SELECT EXISTS(SELECT 1 FROM test_templates WHERE id = $1 AND organization_id = $2)"#
|
||||
)
|
||||
@@ -431,7 +431,7 @@ pub async fn delete_template_question(
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if !exists.0 {
|
||||
return Err((StatusCode::NOT_FOUND, "Template not found".to_string()));
|
||||
return Err((StatusCode::NOT_FOUND, "Plantilla no encontrada".to_string()));
|
||||
}
|
||||
|
||||
let result = sqlx::query(
|
||||
@@ -447,22 +447,22 @@ pub async fn delete_template_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)
|
||||
}
|
||||
|
||||
// ==================== Template Sections Management ====================
|
||||
// ==================== Gestión de Secciones de la Plantilla ====================
|
||||
|
||||
/// POST /api/test-templates/:id/sections - Add a section to a template
|
||||
/// POST /api/test-templates/:id/sections - Añadir una sección a una plantilla
|
||||
pub async fn create_template_section(
|
||||
Org(org_ctx): Org,
|
||||
Path(template_id): Path<Uuid>,
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<CreateSectionPayload>,
|
||||
) -> Result<Json<TestTemplateSection>, (StatusCode, String)> {
|
||||
// Verify template exists
|
||||
// Verificar que la plantilla existe
|
||||
let exists: (bool,) = sqlx::query_as(
|
||||
r#"SELECT EXISTS(SELECT 1 FROM test_templates WHERE id = $1 AND organization_id = $2)"#
|
||||
)
|
||||
@@ -473,7 +473,7 @@ pub async fn create_template_section(
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if !exists.0 {
|
||||
return Err((StatusCode::NOT_FOUND, "Template not found".to_string()));
|
||||
return Err((StatusCode::NOT_FOUND, "Plantilla no encontrada".to_string()));
|
||||
}
|
||||
|
||||
let section: TestTemplateSection = sqlx::query_as(
|
||||
@@ -509,7 +509,7 @@ pub struct CreateSectionPayload {
|
||||
pub section_data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// DELETE /api/test-templates/:template_id/sections/:section_id - Delete a section
|
||||
/// DELETE /api/test-templates/:template_id/sections/:section_id - Eliminar una sección
|
||||
pub async fn delete_template_section(
|
||||
Org(org_ctx): Org,
|
||||
Path((template_id, section_id)): Path<(Uuid, Uuid)>,
|
||||
@@ -528,15 +528,15 @@ pub async fn delete_template_section(
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err((StatusCode::NOT_FOUND, "Section not found".to_string()));
|
||||
return Err((StatusCode::NOT_FOUND, "Sección no encontrada".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ==================== Apply Template to Lesson ====================
|
||||
// ==================== Aplicar Plantilla a la Lección ====================
|
||||
|
||||
/// POST /api/test-templates/:id/apply - Apply a template to a lesson
|
||||
/// POST /api/test-templates/:id/apply - Aplicar una plantilla a una lección
|
||||
pub async fn apply_template_to_lesson(
|
||||
Org(org_ctx): Org,
|
||||
Path(template_id): Path<Uuid>,
|
||||
@@ -544,7 +544,7 @@ pub async fn apply_template_to_lesson(
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<ApplyTemplatePayload>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
// Verify template exists and belongs to organization
|
||||
// Verificar que la plantilla existe y pertenece a la organización
|
||||
let template: TestTemplate = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, organization_id, mysql_course_id, created_by, name, description, level, course_type,
|
||||
@@ -559,11 +559,11 @@ pub async fn apply_template_to_lesson(
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Template not found".to_string()),
|
||||
sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Plantilla no encontrada".to_string()),
|
||||
_ => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
||||
})?;
|
||||
|
||||
// Verify lesson exists and belongs to organization
|
||||
// Verificar que la lección existe y pertenece a la organización
|
||||
let lesson_exists: (bool,) = sqlx::query_as(
|
||||
r#"SELECT EXISTS(SELECT 1 FROM lessons WHERE id = $1 AND organization_id = $2)"#
|
||||
)
|
||||
@@ -574,10 +574,10 @@ pub async fn apply_template_to_lesson(
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if !lesson_exists.0 {
|
||||
return Err((StatusCode::NOT_FOUND, "Lesson not found".to_string()));
|
||||
return Err((StatusCode::NOT_FOUND, "Lección no encontrada".to_string()));
|
||||
}
|
||||
|
||||
// Get template questions with their sections
|
||||
// Obtener las preguntas de la plantilla con sus secciones
|
||||
let template_questions: Vec<TestTemplateQuestion> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, template_id, section_id, question_order, question_type, question_text,
|
||||
@@ -593,10 +593,10 @@ pub async fn apply_template_to_lesson(
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if template_questions.is_empty() {
|
||||
return Err((StatusCode::BAD_REQUEST, "Template has no questions".to_string()));
|
||||
return Err((StatusCode::BAD_REQUEST, "La plantilla no tiene preguntas".to_string()));
|
||||
}
|
||||
|
||||
// Build quiz_data JSON from template questions
|
||||
// Construir el JSON quiz_data a partir de las preguntas de la plantilla
|
||||
let questions_json: Vec<serde_json::Value> = template_questions
|
||||
.iter()
|
||||
.map(|q| {
|
||||
@@ -621,12 +621,12 @@ pub async fn apply_template_to_lesson(
|
||||
"passing_score": template.passing_score,
|
||||
"total_points": template.total_points,
|
||||
"instructions": template.instructions,
|
||||
"max_attempts": 1, // Single attempt as requested
|
||||
"max_attempts": 1, // Intento único según lo solicitado
|
||||
"show_feedback": true, // Show explanations after answering
|
||||
"permanent_history": true, // Student can always view their responses
|
||||
"permanent_history": true, // El estudiante siempre puede ver sus respuestas
|
||||
});
|
||||
|
||||
// Update lesson with quiz data and configuration
|
||||
// Actualizar lección con datos del cuestionario y configuración
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE lessons
|
||||
@@ -649,7 +649,7 @@ pub async fn apply_template_to_lesson(
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Increment template usage count
|
||||
// Incrementar el contador de uso de la plantilla
|
||||
sqlx::query("SELECT increment_template_usage($1)")
|
||||
.bind(template_id)
|
||||
.execute(&pool)
|
||||
@@ -657,7 +657,7 @@ pub async fn apply_template_to_lesson(
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
tracing::info!(
|
||||
"Applied template '{}' to lesson '{}' with {} questions",
|
||||
"Plantilla '{}' aplicada a la lección '{}' con {} preguntas",
|
||||
template.name,
|
||||
payload.lesson_id,
|
||||
template_questions.len()
|
||||
@@ -672,9 +672,9 @@ pub struct ApplyTemplatePayload {
|
||||
pub grading_category_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
// ==================== RAG Question Generation ====================
|
||||
// ==================== Generación de Preguntas RAG ====================
|
||||
|
||||
// Helper function to generate system prompt based on question type
|
||||
// Función auxiliar para generar el system prompt basado en el tipo de pregunta
|
||||
fn get_system_prompt_for_question_type(
|
||||
question_type: &str,
|
||||
num_questions: i32,
|
||||
@@ -686,12 +686,12 @@ fn get_system_prompt_for_question_type(
|
||||
format!(
|
||||
r#"You are an English Teacher creating quiz questions.
|
||||
|
||||
Use these examples as inspiration (do NOT copy):
|
||||
Usa estos ejemplos como inspiración (NO los copies):
|
||||
{}
|
||||
|
||||
Create {} ORIGINAL true-false questions about: {}
|
||||
Crea {} preguntas ORIGINALES de verdadero-falso sobre: {}
|
||||
|
||||
IMPORTANT - Return ONLY a JSON array with this EXACT structure:
|
||||
IMPORTANTE - Devuelve SOLO un array JSON con esta estructura EXACTA:
|
||||
[
|
||||
{{
|
||||
"question_text": "The capital of France is Paris.",
|
||||
@@ -702,10 +702,10 @@ IMPORTANT - Return ONLY a JSON array with this EXACT structure:
|
||||
}}
|
||||
]
|
||||
|
||||
Rules:
|
||||
- Each question must be a clear statement
|
||||
- correct_answer must be true or false
|
||||
- Explanations must be concise"#,
|
||||
Reglas:
|
||||
- Cada pregunta debe ser una afirmación clara
|
||||
- correct_answer debe ser true o false
|
||||
- Las explicaciones deben ser concisas"#,
|
||||
rag_context, num_questions, topic
|
||||
)
|
||||
}
|
||||
@@ -713,12 +713,12 @@ Rules:
|
||||
format!(
|
||||
r#"You are an English Teacher creating quiz questions.
|
||||
|
||||
Use these examples as inspiration (do NOT copy):
|
||||
Usa estos ejemplos como inspiración (NO los copies):
|
||||
{}
|
||||
|
||||
Create {} ORIGINAL short-answer questions about: {}
|
||||
Crea {} preguntas ORIGINALES de respuesta corta sobre: {}
|
||||
|
||||
IMPORTANT - Return ONLY a JSON array with this EXACT structure:
|
||||
IMPORTANTE - Devuelve SOLO un array JSON con esta estructura EXACTA:
|
||||
[
|
||||
{{
|
||||
"question_text": "What is the past tense of 'go'?",
|
||||
@@ -730,10 +730,10 @@ IMPORTANT - Return ONLY a JSON array with this EXACT structure:
|
||||
}}
|
||||
]
|
||||
|
||||
Rules:
|
||||
- correct_answer should be the expected response
|
||||
- keywords array contains acceptable variations or key concepts to check
|
||||
- Questions should accept brief responses"#,
|
||||
Reglas:
|
||||
- correct_answer debe ser la respuesta esperada
|
||||
- el array de palabras clave (keywords) contiene variaciones aceptables o conceptos clave para verificar
|
||||
- Las preguntas deben aceptar respuestas breves"#,
|
||||
rag_context, num_questions, topic
|
||||
)
|
||||
}
|
||||
@@ -741,12 +741,12 @@ Rules:
|
||||
format!(
|
||||
r#"You are an English Teacher creating essay questions.
|
||||
|
||||
Use these examples as inspiration (do NOT copy):
|
||||
Usa estos ejemplos como inspiración (NO los copies):
|
||||
{}
|
||||
|
||||
Create {} ORIGINAL essay questions about: {}
|
||||
Crea {} preguntas ORIGINALES de ensayo sobre: {}
|
||||
|
||||
IMPORTANT - Return ONLY a JSON array with this EXACT structure:
|
||||
IMPORTANTE - Devuelve SOLO un array JSON con esta estructura EXACTA:
|
||||
[
|
||||
{{
|
||||
"question_text": "Explain how setting influences the mood of a short story.",
|
||||
@@ -757,10 +757,10 @@ IMPORTANT - Return ONLY a JSON array with this EXACT structure:
|
||||
}}
|
||||
]
|
||||
|
||||
Rules:
|
||||
- Questions must require extended written responses
|
||||
- correct_answer should contain rubric guidance or expected criteria
|
||||
- No options array is needed"#,
|
||||
Reglas:
|
||||
- Las preguntas deben requerir respuestas escritas extensas
|
||||
- correct_answer debe contener una guía de rúbrica o criterios esperados
|
||||
- No se necesita un array de opciones (options)"#,
|
||||
rag_context, num_questions, topic
|
||||
)
|
||||
}
|
||||
@@ -768,15 +768,15 @@ Rules:
|
||||
format!(
|
||||
r#"You are an English Teacher creating quiz questions.
|
||||
|
||||
Use these examples as inspiration (do NOT copy):
|
||||
Usa estos ejemplos como inspiración (NO los copies):
|
||||
{}
|
||||
|
||||
Create {} ORIGINAL matching question sets about: {}
|
||||
Crea {} conjuntos de preguntas ORIGINALES de emparejamiento sobre: {}
|
||||
|
||||
IMPORTANT - Return ONLY a JSON array with matching questions. Each matching question should have this EXACT structure:
|
||||
IMPORTANTE - Devuelve SOLO un array JSON con preguntas de emparejamiento. Cada pregunta de emparejamiento debe tener esta estructura EXACTA:
|
||||
[
|
||||
{{
|
||||
"question_text": "Match each vocabulary term with its definition:",
|
||||
"question_text": "Empareje cada término de vocabulario con su definición:",
|
||||
"question_type": "matching",
|
||||
"pairs": [
|
||||
{{"left": "Verb", "right": "A word that describes an action"}},
|
||||
@@ -788,128 +788,128 @@ IMPORTANT - Return ONLY a JSON array with matching questions. Each matching ques
|
||||
}}
|
||||
]
|
||||
|
||||
Rules:
|
||||
- Create 3-5 matching pairs per question
|
||||
- left/right items must be clear and distinct
|
||||
- All items in pairs array must follow the same structure
|
||||
- One question per array element"#,
|
||||
Reglas:
|
||||
- Crear 3-5 pares de emparejamiento por pregunta
|
||||
- los elementos izquierda/derecha (left/right) deben ser claros y distintos
|
||||
- Todos los elementos del array de pares deben seguir la misma estructura
|
||||
- Una pregunta por elemento del array"#,
|
||||
rag_context, num_questions, topic
|
||||
)
|
||||
}
|
||||
"ordering" => {
|
||||
format!(
|
||||
r#"You are an English Teacher creating quiz questions.
|
||||
r#"Eres un profesor de inglés creando preguntas para un cuestionario.
|
||||
|
||||
Use these examples as inspiration (do NOT copy):
|
||||
Usa estos ejemplos como inspiración (NO los copies):
|
||||
{}
|
||||
|
||||
Create {} ORIGINAL ordering questions about: {}
|
||||
Crea {} preguntas ORIGINALES de ordenación sobre: {}
|
||||
|
||||
IMPORTANT - Return ONLY a JSON array with this EXACT structure:
|
||||
IMPORTANTE - Devuelve SOLO un array JSON con esta estructura EXACTA:
|
||||
[
|
||||
{{
|
||||
"question_text": "Arrange these steps of the writing process in correct order:",
|
||||
"question_text": "Organice estos pasos del proceso de escritura en el orden correcto:",
|
||||
"question_type": "ordering",
|
||||
"items": ["Revise", "Draft", "Prewrite", "Publish", "Edit"],
|
||||
"correct_order": [2, 1, 3, 4, 0],
|
||||
"explanation": "The writing process starts with prewriting, then drafting, revising, editing, and finally publishing.",
|
||||
"explanation": "El proceso de escritura comienza con la pre-escritura, luego el borrador, la revisión, la edición y finalmente la publicación.",
|
||||
"points": 3
|
||||
}}
|
||||
]
|
||||
|
||||
Rules:
|
||||
- items array contains the items to order
|
||||
- correct_order is an array of indices showing the proper sequence (0-based)
|
||||
- Must have at least 4 items to order
|
||||
- Questions should have a clear logical sequence"#,
|
||||
Reglas:
|
||||
- el array de elementos (items) contiene los elementos a ordenar
|
||||
- correct_order es un array de índices que muestran la secuencia correcta (basado en 0)
|
||||
- Debe tener al menos 4 elementos para ordenar
|
||||
- Las preguntas deben tener una secuencia lógica clara"#,
|
||||
rag_context, num_questions, topic
|
||||
)
|
||||
}
|
||||
"fill-in-the-blanks" => {
|
||||
format!(
|
||||
r#"You are an English Teacher creating quiz questions.
|
||||
r#"Eres un profesor de inglés creando preguntas para un cuestionario.
|
||||
|
||||
Use these examples as inspiration (do NOT copy):
|
||||
Usa estos ejemplos como inspiración (NO los copies):
|
||||
{}
|
||||
|
||||
Create {} ORIGINAL fill-in-the-blanks questions about: {}
|
||||
Crea {} preguntas ORIGINALES de completar espacios en blanco sobre: {}
|
||||
|
||||
IMPORTANT - Return ONLY a JSON array with this EXACT structure:
|
||||
IMPORTANTE - Devuelve SOLO un array JSON con esta estructura EXACTA:
|
||||
[
|
||||
{{
|
||||
"question_text": "The ________ is the main character in a story, while the ________ opposes them.",
|
||||
"question_text": "El ________ es el personaje principal de una historia, mientras que el ________ se le opone.",
|
||||
"question_type": "fill-in-the-blanks",
|
||||
"blanks": [
|
||||
{{"answer": "protagonist", "keywords": ["protagonist", "hero", "main character"]}},
|
||||
{{"answer": "antagonist", "keywords": ["antagonist", "villain", "opponent"]}}
|
||||
],
|
||||
"explanation": "These are key literary terms describing characters in stories.",
|
||||
"explanation": "Estos son términos literarios clave que describen personajes en las historias.",
|
||||
"points": 2
|
||||
}}
|
||||
]
|
||||
|
||||
Rules:
|
||||
- question_text should have ________ for each blank
|
||||
- blanks array has one object per blank
|
||||
- Each blank object must have 'answer' and 'keywords' array
|
||||
- keywords should include the main answer plus acceptable variations
|
||||
- Questions can have 1-3 blanks"#,
|
||||
Reglas:
|
||||
- question_text debe tener ________ para cada espacio en blanco
|
||||
- el array de espacios (blanks) tiene un objeto por espacio en blanco
|
||||
- Cada objeto de espacio en blanco debe tener 'answer' y un array 'keywords'
|
||||
- keywords debe incluir la respuesta principal más variaciones aceptables
|
||||
- Las preguntas pueden tener de 1 a 3 espacios en blanco"#,
|
||||
rag_context, num_questions, topic
|
||||
)
|
||||
}
|
||||
"audio-response" => {
|
||||
format!(
|
||||
r#"You are an English Teacher creating speaking exercises.
|
||||
r#"Eres un profesor de inglés creando ejercicios de expresión oral.
|
||||
|
||||
Use these examples as inspiration (do NOT copy):
|
||||
Usa estos ejemplos como inspiración (NO los copies):
|
||||
{}
|
||||
|
||||
Create {} ORIGINAL audio-response speaking prompts about: {}
|
||||
Crea {} consignas ORIGINALES de respuesta de audio sobre: {}
|
||||
|
||||
IMPORTANT - Return ONLY a JSON array with this EXACT structure:
|
||||
IMPORTANTE - Devuelve SOLO un array JSON con esta estructura EXACTA:
|
||||
[
|
||||
{{
|
||||
"question_text": "Describe a memorable trip using at least three past tense verbs.",
|
||||
"question_text": "Describa un viaje memorable utilizando al menos tres verbos en pasado.",
|
||||
"question_type": "audio-response",
|
||||
"correct_answer": "Use clear past tense forms and relevant travel vocabulary in a coherent answer.",
|
||||
"explanation": "This prompt checks fluency and grammatical control in spoken production.",
|
||||
"correct_answer": "Utilice formas claras en tiempo pasado y vocabulario de viaje relevante en una respuesta coherente.",
|
||||
"explanation": "Esta consigna comprueba la fluidez y el control gramatical en la producción oral.",
|
||||
"points": 2
|
||||
}}
|
||||
]
|
||||
|
||||
Rules:
|
||||
- Questions must require spoken production
|
||||
- correct_answer should contain rubric guidance or expected response criteria
|
||||
- No options array is needed"#,
|
||||
Reglas:
|
||||
- Las preguntas deben requerir producción oral
|
||||
- correct_answer debe contener una guía de rúbrica o criterios de respuesta esperados
|
||||
- No se necesita un array de opciones (options)"#,
|
||||
rag_context, num_questions, topic
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
// Default to multiple-choice
|
||||
format!(
|
||||
r#"You are an English Teacher creating quiz questions.
|
||||
r#"Eres un profesor de inglés creando preguntas para un cuestionario.
|
||||
|
||||
Use these examples as inspiration (do NOT copy):
|
||||
Usa estos ejemplos como inspiración (NO los copies):
|
||||
{}
|
||||
|
||||
Create {} ORIGINAL multiple-choice questions about: {}
|
||||
Crea {} preguntas ORIGINALES de opción múltiple sobre: {}
|
||||
|
||||
IMPORTANT - Return ONLY a JSON array with this EXACT structure:
|
||||
IMPORTANTE - Devuelve SOLO un array JSON con esta estructura EXACTA:
|
||||
[
|
||||
{{
|
||||
"question_text": "The tourist got lost in the ______ of the city.",
|
||||
"question_text": "El turista se perdió en el ______ de la ciudad.",
|
||||
"question_type": "multiple-choice",
|
||||
"options": ["downtown", "countryside", "mountains", "desert"],
|
||||
"correct_answer": 0,
|
||||
"explanation": "Downtown is the main area of a city where tourists typically visit.",
|
||||
"explanation": "El centro (downtown) es la zona principal de una ciudad que los turistas suelen visitar.",
|
||||
"points": 1,
|
||||
"skill_assessed": "reading"
|
||||
}}
|
||||
]
|
||||
|
||||
Rules:
|
||||
- Option text only, no prefixes like A. or 1)
|
||||
- Skills must be one of: reading, listening, speaking, writing"#,
|
||||
Reglas:
|
||||
- Solo texto de la opción, sin prefijos como A. o 1)
|
||||
- Las habilidades (skills) deben ser una de: lectura, escucha, habla, escritura"#,
|
||||
rag_context, num_questions, topic
|
||||
)
|
||||
}
|
||||
@@ -997,8 +997,8 @@ fn parse_ai_response_for_question_type(
|
||||
flatten_into_questions(questions_data)
|
||||
}
|
||||
|
||||
/// POST /test-templates/generate-with-rag - Generate questions using RAG from imported MySQL question bank
|
||||
/// Uses semantic search with pgvector embeddings when available, falls back to course_id filtering
|
||||
/// POST /test-templates/generate-with-rag - Generar preguntas usando RAG del banco de preguntas MySQL importado
|
||||
/// Usa búsqueda semántica con embeddings de pgvector cuando están disponibles, de lo contrario recurre al filtrado por course_id
|
||||
pub async fn generate_questions_with_rag(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
@@ -1019,7 +1019,7 @@ pub async fn generate_questions_with_rag(
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_hostnames(true)
|
||||
.build()
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("HTTP client error: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error del cliente HTTP: {}", e)))?;
|
||||
|
||||
let ollama_url = ai::get_ollama_url();
|
||||
let model = ai::get_embedding_model();
|
||||
@@ -1070,13 +1070,11 @@ pub async fn generate_questions_with_rag(
|
||||
.bind(requested_num_questions * 3) // Get more for diversity
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Semantic search failed: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("La búsqueda semántica falló: {}", e)))?;
|
||||
|
||||
tracing::info!("Semantic search found {} similar questions", mysql_questions.len());
|
||||
tracing::info!("La búsqueda semántica encontró {} preguntas similares", mysql_questions.len());
|
||||
|
||||
if mysql_questions.is_empty() {
|
||||
tracing::info!(
|
||||
"Semantic search returned no rows; falling back to keyword search for topic {:?} and course {:?}",
|
||||
topic,
|
||||
payload.course_id
|
||||
);
|
||||
@@ -1123,11 +1121,9 @@ pub async fn generate_questions_with_rag(
|
||||
.bind(requested_num_questions * 3)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Keyword fallback failed: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("El recurso a palabras clave falló: {}", e)))?;
|
||||
|
||||
if mysql_questions.is_empty() {
|
||||
tracing::info!(
|
||||
"Keyword fallback returned no rows; falling back to imported MySQL questions for course {:?}",
|
||||
payload.course_id
|
||||
);
|
||||
|
||||
@@ -1168,7 +1164,7 @@ pub async fn generate_questions_with_rag(
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Course fallback failed: {}", e),
|
||||
format!("El recurso por curso falló: {}", e),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
@@ -1176,7 +1172,7 @@ pub async fn generate_questions_with_rag(
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Semantic search failed, falling back to keyword search: {}", e);
|
||||
tracing::warn!("La búsqueda semántica falló, recurriendo a búsqueda por palabras clave: {}", e);
|
||||
// Fall back to text search
|
||||
mysql_questions = sqlx::query_as(
|
||||
r#"
|
||||
@@ -1220,11 +1216,9 @@ pub async fn generate_questions_with_rag(
|
||||
.bind(requested_num_questions * 3)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Keyword search failed: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("La búsqueda por palabras clave falló: {}", e)))?;
|
||||
|
||||
if mysql_questions.is_empty() {
|
||||
tracing::info!(
|
||||
"No semantic or keyword matches for topic; falling back to imported MySQL questions for course {:?}",
|
||||
payload.course_id
|
||||
);
|
||||
|
||||
@@ -1265,7 +1259,7 @@ pub async fn generate_questions_with_rag(
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Course fallback failed: {}", e),
|
||||
format!("El recurso por curso falló: {}", e),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
@@ -1309,7 +1303,7 @@ pub async fn generate_questions_with_rag(
|
||||
.bind(course_id)
|
||||
.fetch_all(&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 {
|
||||
// Fetch all imported MySQL questions for this organization
|
||||
// NO LIMIT - fetch all questions for better RAG context
|
||||
@@ -1339,12 +1333,10 @@ pub async fn generate_questions_with_rag(
|
||||
.bind(org_ctx.id)
|
||||
.fetch_all(&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)))?;
|
||||
}
|
||||
|
||||
if mysql_questions.is_empty() {
|
||||
tracing::warn!(
|
||||
"No imported MySQL questions found for org={} course={:?} topic={:?}; falling back to organization-wide imported questions",
|
||||
org_ctx.id,
|
||||
payload.course_id,
|
||||
payload.topic
|
||||
@@ -1379,7 +1371,7 @@ pub async fn generate_questions_with_rag(
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to fetch organization-wide fallback questions: {}", e),
|
||||
format!("Error al obtener preguntas de recurso de toda la organización: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -1405,7 +1397,7 @@ pub async fn generate_questions_with_rag(
|
||||
.map(|q| get_course_level_from_mysql(q.nivel_curso, &q.plan_nombre, ""))
|
||||
.unwrap_or(CourseLevel::Intermediate);
|
||||
|
||||
tracing::info!("Determined course_type: {:?}, level: {:?} from imported data", course_type, level);
|
||||
tracing::info!("Tipo de curso determinado: {:?}, nivel: {:?} a partir de los datos importados", course_type, level);
|
||||
|
||||
// 2. Build RAG context from MySQL questions (lightweight format)
|
||||
let rag_context: String = mysql_questions
|
||||
@@ -1415,7 +1407,7 @@ pub async fn generate_questions_with_rag(
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
tracing::info!("RAG context built with {} questions", mysql_questions.len().min(8));
|
||||
tracing::info!("Contexto RAG construido con {} preguntas", mysql_questions.len().min(8));
|
||||
|
||||
// 3. Call AI to generate new questions based on RAG context (Ollama only)
|
||||
let base_url = std::env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
|
||||
@@ -1428,7 +1420,7 @@ pub async fn generate_questions_with_rag(
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new());
|
||||
|
||||
tracing::info!("Calling Ollama at {} with model {}", url, model);
|
||||
tracing::info!("Llamando a Ollama en {} con el modelo {}", url, model);
|
||||
|
||||
// Save topic for later use
|
||||
let topic = payload.topic.clone().unwrap_or_else(|| "English grammar".to_string());
|
||||
@@ -1459,7 +1451,7 @@ pub async fn generate_questions_with_rag(
|
||||
&rag_context,
|
||||
);
|
||||
|
||||
tracing::debug!("System prompt length: {} chars", system_prompt.len());
|
||||
tracing::debug!("Longitud del prompt del sistema: {} caracteres", system_prompt.len());
|
||||
|
||||
let request = client
|
||||
.post(&url)
|
||||
@@ -1483,19 +1475,19 @@ pub async fn generate_questions_with_rag(
|
||||
}
|
||||
}));
|
||||
|
||||
tracing::info!("Sending request to Ollama (model: {}, prompt length: {} chars)", model, system_prompt.len());
|
||||
tracing::info!("Enviando solicitud a Ollama (modelo: {}, longitud del prompt: {} caracteres)", model, system_prompt.len());
|
||||
|
||||
let response = request.send().await.map_err(|e| {
|
||||
tracing::error!("AI request failed after timeout: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, format!("Ollama timeout - el equipo t-800 está tardando en responder. Intenta nuevamente: {}", e))
|
||||
})?;
|
||||
|
||||
tracing::info!("Ollama response status: {}", response.status());
|
||||
tracing::info!("Estado de la respuesta de Ollama: {}", response.status());
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
tracing::error!("Ollama returned non-success status {}: {}", status, body);
|
||||
tracing::error!("Ollama devolvió un estado de error {}: {}", status, body);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Ollama returned {}. El proxy de IA agotó el tiempo de espera.", status),
|
||||
@@ -1503,8 +1495,8 @@ pub async fn generate_questions_with_rag(
|
||||
}
|
||||
|
||||
let response_text = response.text().await.map_err(|e| {
|
||||
tracing::error!("Failed to read AI stream response: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Invalid AI response".to_string())
|
||||
tracing::error!("Error al leer la respuesta del flujo de la IA: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Respuesta de IA inválida".to_string())
|
||||
})?;
|
||||
|
||||
let aggregated_content = response_text
|
||||
@@ -1532,7 +1524,7 @@ pub async fn generate_questions_with_rag(
|
||||
}
|
||||
});
|
||||
|
||||
tracing::debug!("Ollama response: {:?}", response_json);
|
||||
tracing::debug!("Respuesta de Ollama: {:?}", response_json);
|
||||
|
||||
// Parse questions from Ollama response
|
||||
let ai_payload = response_json
|
||||
@@ -1619,7 +1611,7 @@ pub async fn generate_questions_with_rag(
|
||||
.to_string();
|
||||
|
||||
if question_text.is_empty() || question_text.eq_ignore_ascii_case("question") {
|
||||
tracing::warn!("Skipping invalid generated question with empty placeholder text: {:?}", q);
|
||||
tracing::warn!("Omitiendo pregunta generada inválida con texto de marcador vacío: {:?}", q);
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -1644,7 +1636,7 @@ pub async fn generate_questions_with_rag(
|
||||
let (options, correct_answer, options_shuffled) = match question_type_value.as_str() {
|
||||
"multiple-choice" => {
|
||||
if original_options.len() < 2 {
|
||||
tracing::warn!("Skipping invalid multiple-choice question without enough options: {:?}", q);
|
||||
tracing::warn!("Omitiendo pregunta de opción múltiple inválida sin suficientes opciones: {:?}", q);
|
||||
return None;
|
||||
}
|
||||
if !original_options.is_empty() && original_correct_idx.is_some() {
|
||||
@@ -1675,7 +1667,7 @@ pub async fn generate_questions_with_rag(
|
||||
.and_then(|v| v.as_bool())
|
||||
.map(|v| if v { json!(0) } else { json!(1) });
|
||||
if bool_answer.is_none() {
|
||||
tracing::warn!("Skipping invalid true-false question without boolean correct answer: {:?}", q);
|
||||
tracing::warn!("Omitiendo pregunta de verdadero-falso inválida sin respuesta correcta booleana: {:?}", q);
|
||||
return None;
|
||||
}
|
||||
(Some(json!(["True", "False"])), bool_answer, false)
|
||||
@@ -1688,7 +1680,7 @@ pub async fn generate_questions_with_rag(
|
||||
.map(|arr| !arr.is_empty())
|
||||
.unwrap_or(false);
|
||||
if !is_valid {
|
||||
tracing::warn!("Skipping invalid matching question without pairs: {:?}", q);
|
||||
tracing::warn!("Omitiendo pregunta de emparejamiento inválida sin pares: {:?}", q);
|
||||
return None;
|
||||
}
|
||||
(pairs.clone(), pairs, false)
|
||||
@@ -1711,7 +1703,7 @@ pub async fn generate_questions_with_rag(
|
||||
.map(|arr| !arr.is_empty())
|
||||
.unwrap_or(false);
|
||||
if !has_items || !has_order {
|
||||
tracing::warn!("Skipping invalid ordering question without items/order: {:?}", q);
|
||||
tracing::warn!("Omitiendo pregunta de ordenación inválida sin elementos/orden: {:?}", q);
|
||||
return None;
|
||||
}
|
||||
(items, order, false)
|
||||
@@ -1724,7 +1716,7 @@ pub async fn generate_questions_with_rag(
|
||||
.map(|arr| !arr.is_empty())
|
||||
.unwrap_or(false);
|
||||
if !has_blanks {
|
||||
tracing::warn!("Skipping invalid fill-in-the-blanks question without blanks array: {:?}", q);
|
||||
tracing::warn!("Omitiendo pregunta de completar espacios en blanco inválida sin array de espacios: {:?}", q);
|
||||
return None;
|
||||
}
|
||||
(blanks.clone(), blanks, false)
|
||||
@@ -1760,7 +1752,7 @@ pub async fn generate_questions_with_rag(
|
||||
.collect();
|
||||
|
||||
if generated_questions.is_empty() {
|
||||
return Err((StatusCode::INTERNAL_SERVER_ERROR, "AI failed to generate questions".to_string()));
|
||||
return Err((StatusCode::INTERNAL_SERVER_ERROR, "La IA no pudo generar las preguntas".to_string()));
|
||||
}
|
||||
|
||||
// Save generated questions to question bank
|
||||
@@ -1819,7 +1811,7 @@ pub async fn generate_questions_with_rag(
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"Generated {} questions using RAG from MySQL bank, saved {} to question bank",
|
||||
"Generadas {} preguntas usando RAG del banco MySQL, guardadas {} al banco de preguntas",
|
||||
generated_questions.len(),
|
||||
saved_count
|
||||
);
|
||||
@@ -1892,7 +1884,7 @@ async fn ensure_mysql_course_metadata(
|
||||
let mysql_url = std::env::var("MYSQL_DATABASE_URL").map_err(|_| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"MYSQL_DATABASE_URL not configured".to_string(),
|
||||
"MYSQL_DATABASE_URL no configurada".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -1907,7 +1899,7 @@ async fn ensure_mysql_course_metadata(
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to connect to external MySQL: {}", e),
|
||||
format!("Error al conectar con MySQL externo: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -1962,7 +1954,7 @@ async fn ensure_mysql_course_metadata(
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to upsert MySQL study plan metadata: {}", e),
|
||||
format!("Error al actualizar/insertar los metadatos del plan de estudios MySQL: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -1976,7 +1968,7 @@ async fn ensure_mysql_course_metadata(
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to resolve MySQL study plan metadata: {}", e),
|
||||
format!("Error al resolver los metadatos del plan de estudios MySQL: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -2013,7 +2005,7 @@ async fn ensure_mysql_course_metadata(
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to upsert MySQL course metadata: {}", e),
|
||||
format!("Error al actualizar/insertar los metadatos del curso MySQL: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
|
||||
@@ -36,32 +36,32 @@ async fn main() {
|
||||
dotenv().ok();
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let db_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||
let db_url = env::var("DATABASE_URL").expect("DATABASE_URL debe estar configurada");
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(10)
|
||||
.min_connections(2)
|
||||
.acquire_timeout(Duration::from_secs(30))
|
||||
.connect(&db_url)
|
||||
.await
|
||||
.expect("Failed to connect to database");
|
||||
.expect("Error al conectar con la base de datos");
|
||||
|
||||
// Initialize health state
|
||||
// Inicializar el estado de salud
|
||||
let health_state = HealthState::default();
|
||||
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("Failed to run migrations");
|
||||
.expect("Error al ejecutar las migraciones");
|
||||
|
||||
// Sync default organization branding from environment
|
||||
// Sincronizar la marca de la organización por defecto desde el entorno
|
||||
sync_default_organization(&pool).await;
|
||||
|
||||
// Start AI Background Worker
|
||||
// Iniciar el trabajador de IA en segundo plano
|
||||
let worker_pool = pool.clone();
|
||||
tokio::spawn(async move {
|
||||
tracing::info!("AI Background Worker started");
|
||||
tracing::info!("Trabajador de IA en segundo plano iniciado");
|
||||
loop {
|
||||
// Check for queued transcriptions
|
||||
// Buscar transcripciones en cola
|
||||
let queued_lessons: Vec<sqlx::types::Uuid> = match sqlx::query_scalar(
|
||||
"SELECT id FROM lessons WHERE transcription_status = 'queued' LIMIT 5",
|
||||
)
|
||||
@@ -70,18 +70,18 @@ async fn main() {
|
||||
{
|
||||
Ok(ids) => ids,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to fetch queued lessons: {}", e);
|
||||
tracing::error!("Error al obtener lecciones en cola: {}", e);
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
for lesson_id in queued_lessons {
|
||||
tracing::info!("Processing transcription for lesson: {}", lesson_id);
|
||||
tracing::info!("Procesando transcripción para la lección: {}", lesson_id);
|
||||
if let Err(e) =
|
||||
handlers::run_transcription_task(worker_pool.clone(), lesson_id).await
|
||||
{
|
||||
tracing::error!("Transcription task failed for lesson {}: {}", lesson_id, e);
|
||||
tracing::error!("La tarea de transcripción falló para la lección {}: {}", lesson_id, e);
|
||||
let _ = sqlx::query(
|
||||
"UPDATE lessons SET transcription_status = 'failed' WHERE id = $1",
|
||||
)
|
||||
@@ -97,15 +97,15 @@ async fn main() {
|
||||
}
|
||||
});
|
||||
|
||||
// CORS configuration - Allow multiple origins for development and production
|
||||
// Using a predicate closure to support wildcard subdomains for norteamericano.cl
|
||||
// Configuración de CORS - Permitir múltiples orígenes para desarrollo y producción
|
||||
// Uso de un cierre de predicado para soportar subdominios comodín para norteamericano.cl
|
||||
use tower_http::cors::AllowOrigin;
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(AllowOrigin::predicate(|origin: &http::HeaderValue, _request: &http::request::Parts| -> bool {
|
||||
let origin_str = origin.to_str().unwrap_or("");
|
||||
|
||||
// Development origins
|
||||
// Orígenes de desarrollo
|
||||
let allowed_origins = [
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3003",
|
||||
@@ -114,7 +114,7 @@ async fn main() {
|
||||
"http://192.168.0.254:3000",
|
||||
"http://192.168.0.254:3003",
|
||||
"http://192.168.0.254",
|
||||
// Production - Norteamericano domains (.cl and .com)
|
||||
// Producción - Dominios de Norteamericano (.cl y .com)
|
||||
"http://studio.norteamericano.com",
|
||||
"https://studio.norteamericano.com",
|
||||
"http://learning.norteamericano.com",
|
||||
@@ -125,12 +125,12 @@ async fn main() {
|
||||
"https://learning.norteamericano.cl",
|
||||
];
|
||||
|
||||
// Check exact matches
|
||||
// Comprobar coincidencias exactas
|
||||
if allowed_origins.contains(&origin_str) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check wildcard for subdomains in norteamericano.cl/.com over HTTP(S)
|
||||
// Comprobar comodín para subdominios en norteamericano.cl/.com sobre HTTP(S)
|
||||
for scheme in ["http://", "https://"] {
|
||||
for domain in [".norteamericano.cl", ".norteamericano.com"] {
|
||||
if origin_str.starts_with(scheme) && origin_str.ends_with(domain) {
|
||||
@@ -140,7 +140,7 @@ async fn main() {
|
||||
.strip_suffix(domain)
|
||||
.unwrap_or("");
|
||||
|
||||
// Allow any subdomain (e.g., api., cdn., admin., etc.)
|
||||
// Permitir cualquier subdominio (ej., api., cdn., admin., etc.)
|
||||
if !subdomain.is_empty() && !subdomain.contains('/') {
|
||||
return true;
|
||||
}
|
||||
@@ -296,7 +296,7 @@ async fn main() {
|
||||
"/organization/branding",
|
||||
axum::routing::put(handlers_branding::update_organization_branding),
|
||||
)
|
||||
// Content Libraries routes
|
||||
// Rutas de librerías de contenido
|
||||
.route(
|
||||
"/library/blocks",
|
||||
get(handlers_library::list_library_blocks).post(handlers_library::create_library_block),
|
||||
@@ -311,7 +311,7 @@ async fn main() {
|
||||
"/library/blocks/{id}/increment-usage",
|
||||
post(handlers_library::increment_block_usage),
|
||||
)
|
||||
// Advanced Grading (Rubrics) routes
|
||||
// Rutas de calificación avanzada (rúbricas)
|
||||
.route(
|
||||
"/courses/{id}/rubrics",
|
||||
get(handlers_rubrics::list_course_rubrics).post(handlers_rubrics::create_rubric),
|
||||
@@ -347,7 +347,7 @@ async fn main() {
|
||||
"/lessons/{id}/rubrics",
|
||||
get(handlers_rubrics::get_lesson_rubrics),
|
||||
)
|
||||
// Learning Sequences (Dependencies) routes
|
||||
// Rutas de secuencias de aprendizaje (dependencias)
|
||||
.route(
|
||||
"/lessons/{id}/dependencies",
|
||||
get(handlers_dependencies::list_lesson_dependencies)
|
||||
@@ -357,7 +357,7 @@ async fn main() {
|
||||
"/lessons/{id}/dependencies/{prerequisite_id}",
|
||||
delete(handlers_dependencies::remove_dependency),
|
||||
)
|
||||
// Test Templates routes
|
||||
// Rutas de plantillas de pruebas
|
||||
.route(
|
||||
"/test-templates",
|
||||
get(handlers_test_templates::list_test_templates)
|
||||
@@ -393,7 +393,7 @@ async fn main() {
|
||||
"/test-templates/generate-with-rag",
|
||||
post(handlers_test_templates::generate_questions_with_rag),
|
||||
)
|
||||
// Question Bank routes
|
||||
// Rutas del banco de preguntas
|
||||
.route(
|
||||
"/question-bank",
|
||||
get(handlers_question_bank::list_questions)
|
||||
@@ -433,7 +433,7 @@ async fn main() {
|
||||
"/question-bank/ai-generate",
|
||||
post(handlers_question_bank::ai_generate_question),
|
||||
)
|
||||
// Embedding routes for semantic search
|
||||
// Rutas de embeddings para búsqueda semántica
|
||||
.route(
|
||||
"/question-bank/embeddings/generate",
|
||||
post(handlers_embeddings::generate_question_embeddings),
|
||||
@@ -450,7 +450,7 @@ async fn main() {
|
||||
"/question-bank/{id}/embedding/regenerate",
|
||||
post(handlers_embeddings::regenerate_question_embedding),
|
||||
)
|
||||
// SAM Integration routes
|
||||
// Rutas de integración con SAM
|
||||
.route(
|
||||
"/sam/sync-all",
|
||||
post(handlers_sam::sync_all_sam),
|
||||
@@ -471,7 +471,7 @@ async fn main() {
|
||||
"/sam/students/{student_id}/courses",
|
||||
get(handlers_sam::get_sam_student_courses),
|
||||
)
|
||||
// Admin routes
|
||||
// Rutas de administración
|
||||
.route(
|
||||
"/admin/token-usage",
|
||||
get(handlers_admin::get_token_usage),
|
||||
@@ -527,12 +527,12 @@ async fn main() {
|
||||
"/api/assets/s3-proxy/{bucket}/{*key}",
|
||||
get(handlers_assets::public_s3_proxy),
|
||||
)
|
||||
// Health check routes
|
||||
// Rutas de verificación de salud
|
||||
.merge(health::health_routes(pool.clone()).with_state(health_state))
|
||||
.nest_service("/assets", tower_http::services::ServeDir::new("uploads"))
|
||||
.merge(auth_routes)
|
||||
.merge(protected_routes)
|
||||
// Security headers
|
||||
// Cabeceras de seguridad
|
||||
.layer(SetResponseHeaderLayer::overriding(
|
||||
http::header::STRICT_TRANSPORT_SECURITY,
|
||||
http::HeaderValue::from_static("max-age=31536000; includeSubDomains"),
|
||||
@@ -553,14 +553,14 @@ async fn main() {
|
||||
http::header::REFERRER_POLICY,
|
||||
http::HeaderValue::from_static("strict-origin-when-cross-origin"),
|
||||
))
|
||||
// CORS layer - MUST be last to execute first on response
|
||||
// Capa CORS - DEBE ser la última para ejecutarse primero en la respuesta
|
||||
.layer(cors)
|
||||
// Trace layer for logging requests/responses
|
||||
// Capa de trazado para registrar solicitudes/respuestas
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(pool);
|
||||
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], 3001));
|
||||
tracing::info!("CMS Service listening on {} with rate limiting and security headers", addr);
|
||||
tracing::info!("Servicio CMS escuchando en {} con limitación de tasa y cabeceras de seguridad", addr);
|
||||
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||
axum::serve(listener, public_routes).await.unwrap();
|
||||
}
|
||||
@@ -597,8 +597,8 @@ async fn sync_default_organization(pool: &sqlx::PgPool) {
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => tracing::info!("Default organization branding synced from .env"),
|
||||
Err(e) => tracing::error!("Failed to sync default organization branding: {}", e),
|
||||
Ok(_) => tracing::info!("Marca de la organización por defecto sincronizada desde .env"),
|
||||
Err(e) => tracing::error!("Error al sincronizar la marca de la organización por defecto: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ impl WebhookService {
|
||||
}
|
||||
|
||||
pub async fn dispatch(&self, org_id: Uuid, event_type: &str, payload: &serde_json::Value) {
|
||||
// 1. Fetch active webhooks for this org that are interested in this event
|
||||
// 1. Obtener webhooks activos para esta org que estén interesados en este evento
|
||||
let webhooks = match sqlx::query_as::<_, Webhook>(
|
||||
"SELECT * FROM webhooks WHERE organization_id = $1 AND is_active = TRUE AND $2 = ANY(events)"
|
||||
)
|
||||
@@ -25,7 +25,7 @@ impl WebhookService {
|
||||
.await {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to fetch webhooks for org {}: {}", org_id, e);
|
||||
tracing::error!("Error al obtener los webhooks para la org {}: {}", org_id, e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -44,7 +44,7 @@ impl WebhookService {
|
||||
.header("X-OpenCCB-Event", event_type)
|
||||
.header("X-OpenCCB-Delivery", Uuid::new_v4().to_string());
|
||||
|
||||
// Add signature if secret exists
|
||||
// Añadir firma si existe el secreto
|
||||
if let Some(secret) = &webhook.secret {
|
||||
let signature = generate_signature(secret, &payload_str);
|
||||
request = request.header("X-OpenCCB-Signature", signature);
|
||||
@@ -56,15 +56,14 @@ impl WebhookService {
|
||||
match res {
|
||||
Ok(response) => {
|
||||
if !response.status().is_success() {
|
||||
tracing::warn!(
|
||||
"Webhook delivery to {} (event: {}) failed with status {}",
|
||||
"El envío del webhook a {} (evento: {}) falló con estado {}",
|
||||
url,
|
||||
event_type,
|
||||
response.status()
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
"Successfully delivered webhook to {} (event: {})",
|
||||
"Webhook enviado con éxito a {} (evento: {})",
|
||||
url,
|
||||
event_type
|
||||
);
|
||||
@@ -72,7 +71,7 @@ impl WebhookService {
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Failed to deliver webhook to {} (event: {}): {}",
|
||||
"Error al enviar el webhook a {} (evento: {}): {}",
|
||||
url,
|
||||
event_type,
|
||||
e
|
||||
|
||||
@@ -24,7 +24,7 @@ pub async fn set_session_context(
|
||||
.await?;
|
||||
}
|
||||
if let Some(user_agent) = ua {
|
||||
// Use set_config for potentially long strings to avoid SQL injection/formatting issues
|
||||
// Usar set_config para cadenas potencialmente largas para evitar inyección SQL o problemas de formato
|
||||
sqlx::query("SELECT set_config('app.user_agent', $1, true)")
|
||||
.bind(user_agent)
|
||||
.execute(&mut **tx)
|
||||
|
||||
@@ -11,16 +11,16 @@ pub async fn init_mysql_pool() -> Option<MySqlPool> {
|
||||
.await
|
||||
{
|
||||
Ok(pool) => {
|
||||
tracing::info!("Connected to external MySQL database");
|
||||
tracing::info!("Conectado a la base de datos MySQL externa");
|
||||
Some(pool)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to connect to external MySQL database: {}", e);
|
||||
tracing::error!("Error al conectar a la base de datos MySQL externa: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::info!("MYSQL_DATABASE_URL not set, skipping external database integration");
|
||||
tracing::info!("MYSQL_DATABASE_URL no establecida, omitiendo la integración con la base de datos externa");
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ use serde::Deserialize;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ========== Request/Response DTOs ==========
|
||||
// ========== DTOs de Solicitud/Respuesta ==========
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateAnnouncementPayload {
|
||||
@@ -27,7 +27,7 @@ pub struct UpdateAnnouncementPayload {
|
||||
pub is_pinned: Option<bool>,
|
||||
}
|
||||
|
||||
// ========== HANDLERS ==========
|
||||
// ========== MANEJADORES ==========
|
||||
|
||||
pub async fn list_announcements(
|
||||
Org(org_ctx): Org,
|
||||
@@ -50,7 +50,7 @@ pub async fn list_announcements(
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Attach cohort_ids to each announcement
|
||||
// Adjuntar cohort_ids a cada anuncio
|
||||
for a in &mut announcements {
|
||||
let cohorts: Vec<(Uuid,)> = sqlx::query_as(
|
||||
"SELECT cohort_id FROM announcement_cohorts WHERE announcement_id = $1"
|
||||
@@ -75,17 +75,17 @@ pub async fn create_announcement(
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<CreateAnnouncementPayload>,
|
||||
) -> Result<Json<CourseAnnouncement>, (StatusCode, String)> {
|
||||
// Check if user is instructor or admin
|
||||
// Verificar si el usuario es instructor o administrador
|
||||
let user = sqlx::query_as::<_, (String,)>("SELECT role FROM users WHERE id = $1")
|
||||
.bind(claims.sub)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?;
|
||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "Usuario no encontrado".to_string()))?;
|
||||
|
||||
if user.0 != "instructor" && user.0 != "admin" {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"Only instructors can create announcements".to_string(),
|
||||
"Solo los instructores pueden crear anuncios".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ pub async fn create_announcement(
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// 1. Create announcement
|
||||
// 1. Crear anuncio
|
||||
let mut announcement = sqlx::query_as::<_, CourseAnnouncement>(
|
||||
"INSERT INTO course_announcements (organization_id, course_id, author_id, title, content, is_pinned)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
@@ -110,7 +110,7 @@ pub async fn create_announcement(
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// 2. Link cohorts if provided
|
||||
// 2. Vincular cohortes si se proporcionan
|
||||
if let Some(ref cohort_ids) = payload.cohort_ids {
|
||||
for cohort_id in cohort_ids {
|
||||
sqlx::query(
|
||||
@@ -129,7 +129,7 @@ pub async fn create_announcement(
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// 3. Get target students for notifications
|
||||
// 3. Obtener estudiantes objetivo para notificaciones
|
||||
let enrolled_students = if let Some(ref cohort_ids) = payload.cohort_ids {
|
||||
if !cohort_ids.is_empty() {
|
||||
sqlx::query_as::<_, (Uuid,)>(
|
||||
@@ -144,7 +144,7 @@ pub async fn create_announcement(
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
} else {
|
||||
// Fallback to everyone if empty list provided (though UI should prevent)
|
||||
// Recurrir a todos si se proporciona una lista vacía (aunque la interfaz debería evitarlo)
|
||||
sqlx::query_as::<_, (Uuid,)>(
|
||||
"SELECT user_id FROM enrollments WHERE course_id = $1 AND user_id != $2",
|
||||
)
|
||||
@@ -154,7 +154,7 @@ pub async fn create_announcement(
|
||||
.await
|
||||
}
|
||||
} else {
|
||||
// No segment provided -> everyone in the course
|
||||
// No se proporcionó segmento -> todos en el curso
|
||||
sqlx::query_as::<_, (Uuid,)>(
|
||||
"SELECT user_id FROM enrollments WHERE course_id = $1 AND user_id != $2",
|
||||
)
|
||||
@@ -165,7 +165,7 @@ pub async fn create_announcement(
|
||||
}
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Create notification for each enrolled student
|
||||
// Crear notificación para cada estudiante inscrito
|
||||
for (student_id,) in enrolled_students {
|
||||
let notification_title = format!("Nuevo Anuncio: {}", payload.title);
|
||||
let notification_message = if payload.content.len() > 100 {
|
||||
@@ -198,21 +198,21 @@ pub async fn update_announcement(
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<UpdateAnnouncementPayload>,
|
||||
) -> Result<Json<CourseAnnouncement>, (StatusCode, String)> {
|
||||
// Check if user is instructor or admin
|
||||
// Verificar si el usuario es instructor o administrador
|
||||
let user = sqlx::query_as::<_, (String,)>("SELECT role FROM users WHERE id = $1")
|
||||
.bind(claims.sub)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?;
|
||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "Usuario no encontrado".to_string()))?;
|
||||
|
||||
if user.0 != "instructor" && user.0 != "admin" {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"Only instructors can update announcements".to_string(),
|
||||
"Solo los instructores pueden actualizar anuncios".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Get current announcement to verify ownership
|
||||
// Obtener el anuncio actual para verificar la propiedad
|
||||
let current = sqlx::query_as::<_, CourseAnnouncement>(
|
||||
"SELECT * FROM course_announcements WHERE id = $1 AND organization_id = $2",
|
||||
)
|
||||
@@ -220,7 +220,7 @@ pub async fn update_announcement(
|
||||
.bind(org_ctx.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "Announcement not found".to_string()))?;
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "Anuncio no encontrado".to_string()))?;
|
||||
|
||||
let title = payload.title.unwrap_or(current.title);
|
||||
let content = payload.content.unwrap_or(current.content);
|
||||
@@ -250,17 +250,17 @@ pub async fn delete_announcement(
|
||||
Path(announcement_id): Path<Uuid>,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
// Check if user is instructor or admin
|
||||
// Verificar si el usuario es instructor o administrador
|
||||
let user = sqlx::query_as::<_, (String,)>("SELECT role FROM users WHERE id = $1")
|
||||
.bind(claims.sub)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?;
|
||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "Usuario no encontrado".to_string()))?;
|
||||
|
||||
if user.0 != "instructor" && user.0 != "admin" {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"Only instructors can delete announcements".to_string(),
|
||||
"Solo los instructores pueden eliminar anuncios".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ pub async fn add_cohort_member(
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<AddMemberPayload>,
|
||||
) -> Result<Json<UserCohort>, (StatusCode, String)> {
|
||||
// Verify cohort belongs to org
|
||||
// Verificar que la cohorte pertenece a la organización
|
||||
let exists: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM cohorts WHERE id = $1 AND organization_id = $2)",
|
||||
)
|
||||
@@ -66,7 +66,7 @@ pub async fn add_cohort_member(
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if !exists {
|
||||
return Err((StatusCode::NOT_FOUND, "Cohort not found".to_string()));
|
||||
return Err((StatusCode::NOT_FOUND, "Cohorte no encontrada".to_string()));
|
||||
}
|
||||
|
||||
let member = sqlx::query_as::<_, UserCohort>(
|
||||
@@ -92,7 +92,7 @@ pub async fn remove_cohort_member(
|
||||
Path((cohort_id, user_id)): Path<(Uuid, Uuid)>,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
// Verify cohort belongs to org
|
||||
// Verificar que la cohorte pertenece a la organización
|
||||
let exists: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM cohorts WHERE id = $1 AND organization_id = $2)",
|
||||
)
|
||||
@@ -103,7 +103,7 @@ pub async fn remove_cohort_member(
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if !exists {
|
||||
return Err((StatusCode::NOT_FOUND, "Cohort not found".to_string()));
|
||||
return Err((StatusCode::NOT_FOUND, "Cohorte no encontrada".to_string()));
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM user_cohorts WHERE cohort_id = $1 AND user_id = $2")
|
||||
@@ -122,7 +122,7 @@ pub async fn get_cohort_members(
|
||||
Path(cohort_id): Path<Uuid>,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<Json<Vec<Uuid>>, (StatusCode, String)> {
|
||||
// Verify cohort belongs to org
|
||||
// Verificar que la cohorte pertenece a la organización
|
||||
let exists: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM cohorts WHERE id = $1 AND organization_id = $2)",
|
||||
)
|
||||
@@ -133,7 +133,7 @@ pub async fn get_cohort_members(
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if !exists {
|
||||
return Err((StatusCode::NOT_FOUND, "Cohort not found".to_string()));
|
||||
return Err((StatusCode::NOT_FOUND, "Cohorte no encontrada".to_string()));
|
||||
}
|
||||
|
||||
let members = sqlx::query_scalar("SELECT user_id FROM user_cohorts WHERE cohort_id = $1")
|
||||
|
||||
@@ -10,7 +10,7 @@ use serde::Deserialize;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ========== Request/Response DTOs ==========
|
||||
// ========== DTOs de Solicitud/Respuesta ==========
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateThreadPayload {
|
||||
@@ -37,7 +37,7 @@ pub struct ThreadListQuery {
|
||||
pub page: Option<i64>,
|
||||
}
|
||||
|
||||
// ========== THREAD HANDLERS ==========
|
||||
// ========== MANEJADORES DE HILOS ==========
|
||||
|
||||
pub async fn list_threads(
|
||||
Org(org_ctx): Org,
|
||||
@@ -141,7 +141,7 @@ pub async fn create_thread(
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Auto-subscribe author to thread
|
||||
// Suscribir automáticamente al autor al hilo
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO discussion_subscriptions (organization_id, thread_id, user_id)
|
||||
VALUES ($1, $2, $3)
|
||||
@@ -162,13 +162,13 @@ pub async fn get_thread_detail(
|
||||
Path(thread_id): Path<Uuid>,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
// Increment view count
|
||||
// Incrementar el conteo de visualizaciones
|
||||
let _ = sqlx::query("UPDATE discussion_threads SET view_count = view_count + 1 WHERE id = $1")
|
||||
.bind(thread_id)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
|
||||
// Get thread with author info
|
||||
// Obtener el hilo con información del autor
|
||||
let thread = sqlx::query_as::<_, ThreadWithAuthor>(
|
||||
"SELECT
|
||||
t.*,
|
||||
@@ -186,9 +186,9 @@ pub async fn get_thread_detail(
|
||||
.bind(org_ctx.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "Thread not found".to_string()))?;
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "Hilo no encontrado".to_string()))?;
|
||||
|
||||
// Get all posts with author info and user votes
|
||||
// Obtener todos los mensajes con información del autor y votos del usuario
|
||||
let posts = get_thread_posts_recursive(&pool, thread_id, None, claims.sub, org_ctx.id).await?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
@@ -197,7 +197,7 @@ pub async fn get_thread_detail(
|
||||
})))
|
||||
}
|
||||
|
||||
// Recursive function to build nested post tree
|
||||
// Función recursiva para construir el árbol de mensajes anidados
|
||||
fn get_thread_posts_recursive<'a>(
|
||||
pool: &'a PgPool,
|
||||
thread_id: Uuid,
|
||||
@@ -244,7 +244,7 @@ fn get_thread_posts_recursive<'a>(
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Recursively fetch replies for each post
|
||||
// Obtener respuestas recursivamente para cada mensaje
|
||||
for post in &mut posts {
|
||||
post.replies =
|
||||
get_thread_posts_recursive(pool, thread_id, Some(post.id), user_id, org_id).await?;
|
||||
@@ -260,17 +260,17 @@ pub async fn pin_thread(
|
||||
Path(thread_id): Path<Uuid>,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
// Check if user is instructor
|
||||
// Verificar si el usuario es instructor o administrador
|
||||
let user = sqlx::query_as::<_, (String,)>("SELECT role FROM users WHERE id = $1")
|
||||
.bind(claims.sub)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?;
|
||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "Usuario no encontrado".to_string()))?;
|
||||
|
||||
if user.0 != "instructor" && user.0 != "admin" {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"Only instructors can pin threads".to_string(),
|
||||
"Solo los instructores pueden fijar hilos".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -290,17 +290,17 @@ pub async fn lock_thread(
|
||||
Path(thread_id): Path<Uuid>,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
// Check if user is instructor
|
||||
// Verificar si el usuario es instructor o administrador
|
||||
let user = sqlx::query_as::<_, (String,)>("SELECT role FROM users WHERE id = $1")
|
||||
.bind(claims.sub)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?;
|
||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "Usuario no encontrado".to_string()))?;
|
||||
|
||||
if user.0 != "instructor" && user.0 != "admin" {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"Only instructors can lock threads".to_string(),
|
||||
"Solo los instructores pueden bloquear hilos".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -314,7 +314,7 @@ pub async fn lock_thread(
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
// ========== POST HANDLERS ==========
|
||||
// ========== MANEJADORES DE MENSAJES ==========
|
||||
|
||||
pub async fn create_post(
|
||||
Org(org_ctx): Org,
|
||||
@@ -323,13 +323,13 @@ pub async fn create_post(
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<CreatePostPayload>,
|
||||
) -> Result<Json<DiscussionPost>, (StatusCode, String)> {
|
||||
// Check if thread is locked
|
||||
// Verificar si el hilo está bloqueado
|
||||
let thread =
|
||||
sqlx::query_as::<_, (bool,)>("SELECT is_locked FROM discussion_threads WHERE id = $1")
|
||||
.bind(thread_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "Cohorte no encontrada".to_string()))?;
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "Hilo no encontrado".to_string()))?;
|
||||
|
||||
if thread.0 {
|
||||
return Err((StatusCode::FORBIDDEN, "El hilo está bloqueado".to_string()));
|
||||
@@ -349,7 +349,7 @@ pub async fn create_post(
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// TODO: Send notifications to subscribed users
|
||||
// TODO: Enviar notificaciones a los usuarios suscritos
|
||||
|
||||
Ok(Json(post))
|
||||
}
|
||||
@@ -360,17 +360,17 @@ pub async fn endorse_post(
|
||||
Path(post_id): Path<Uuid>,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
// Check if user is instructor
|
||||
// Verificar si el usuario es instructor o administrador
|
||||
let user = sqlx::query_as::<_, (String,)>("SELECT role FROM users WHERE id = $1")
|
||||
.bind(claims.sub)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?;
|
||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "Usuario no encontrado".to_string()))?;
|
||||
|
||||
if user.0 != "instructor" && user.0 != "admin" {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"Only instructors can endorse posts".to_string(),
|
||||
"Solo los instructores pueden recomendar mensajes".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -395,7 +395,7 @@ pub async fn vote_post(
|
||||
return Err((StatusCode::BAD_REQUEST, "Tipo de voto inválido".to_string()));
|
||||
}
|
||||
|
||||
// Upsert vote
|
||||
// Upsert de voto
|
||||
sqlx::query(
|
||||
"INSERT INTO discussion_votes (organization_id, post_id, user_id, vote_type)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
@@ -410,7 +410,7 @@ pub async fn vote_post(
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Recalculate upvotes
|
||||
// Recalcular votos positivos
|
||||
let upvote_count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM discussion_votes WHERE post_id = $1 AND vote_type = 'upvote'",
|
||||
)
|
||||
@@ -429,7 +429,7 @@ pub async fn vote_post(
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
// ========== SUBSCRIPTION HANDLERS ==========
|
||||
// ========== MANEJADORES DE SUSCRIPCIONES ==========
|
||||
|
||||
pub async fn subscribe_thread(
|
||||
Org(org_ctx): Org,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Handlers for PGVector embeddings in Knowledge Base (LMS)
|
||||
//! Enables semantic search for AI tutor chat with RAG
|
||||
//! Manejadores para embeddings de PGVector en la Base de Conocimientos (LMS)
|
||||
//! Habilita la búsqueda semántica para el chat del tutor de IA con RAG
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ==================== Query Parameters ====================
|
||||
// ==================== Parámetros de Consulta ====================
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct KnowledgeSearchFilters {
|
||||
@@ -41,7 +41,7 @@ pub struct GenerateKnowledgeEmbeddingsResult {
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
// ==================== Generate Embeddings ====================
|
||||
// ==================== Generar Embeddings ====================
|
||||
|
||||
/// POST /api/knowledge-base/embeddings/generate - Generate embeddings for all knowledge base entries
|
||||
pub async fn generate_knowledge_embeddings(
|
||||
@@ -50,7 +50,7 @@ pub async fn generate_knowledge_embeddings(
|
||||
) -> Result<Json<GenerateKnowledgeEmbeddingsResult>, (StatusCode, String)> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
// Create client that accepts invalid certificates (for dev with self-signed certs)
|
||||
// Crear cliente que acepte certificados inválidos (para desarrollo con certificados autofirmados)
|
||||
let client = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_hostnames(true)
|
||||
@@ -60,7 +60,7 @@ pub async fn generate_knowledge_embeddings(
|
||||
let ollama_url = ai::get_ollama_url();
|
||||
let model = ai::get_embedding_model();
|
||||
|
||||
// Get knowledge base entries without embeddings
|
||||
// Obtener entradas de la base de conocimientos sin embeddings
|
||||
let entries: Vec<KnowledgeBaseEntry> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT * FROM knowledge_base
|
||||
@@ -80,12 +80,12 @@ pub async fn generate_knowledge_embeddings(
|
||||
let mut failed = 0;
|
||||
|
||||
for entry in entries {
|
||||
// Generate embedding from content chunk
|
||||
// Generar embedding desde el fragmento de contenido
|
||||
match generate_embedding(&client, &ollama_url, &model, &entry.content_chunk).await {
|
||||
Ok(response) => {
|
||||
let pgvector = ai::embedding_to_pgvector(&response.embedding);
|
||||
|
||||
// Update entry with embedding
|
||||
// Actualizar entrada con embedding
|
||||
let result: Result<(i64,), sqlx::Error> = sqlx::query_as(
|
||||
r#"
|
||||
UPDATE knowledge_base
|
||||
@@ -108,7 +108,7 @@ pub async fn generate_knowledge_embeddings(
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Failed to generate embedding for knowledge entry {}: {}",
|
||||
"Error al generar el embedding para la entrada de conocimiento {}: {}",
|
||||
entry.id,
|
||||
e
|
||||
);
|
||||
@@ -133,13 +133,13 @@ pub async fn generate_knowledge_embeddings(
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/knowledge-base/{id}/embedding/regenerate - Regenerate embedding for a specific entry
|
||||
/// POST /api/knowledge-base/{id}/embedding/regenerate - Regenerar embedding para una entrada específica
|
||||
pub async fn regenerate_knowledge_embedding(
|
||||
Org(org_ctx): Org,
|
||||
Path(entry_id): Path<Uuid>,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
// Create client that accepts invalid certificates
|
||||
// Crear cliente que acepte certificados inválidos
|
||||
let client = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_hostnames(true)
|
||||
@@ -149,7 +149,7 @@ pub async fn regenerate_knowledge_embedding(
|
||||
let ollama_url = ai::get_ollama_url();
|
||||
let model = ai::get_embedding_model();
|
||||
|
||||
// Get entry
|
||||
// Obtener entrada
|
||||
let entry: KnowledgeBaseEntry = sqlx::query_as(
|
||||
"SELECT * FROM knowledge_base WHERE id = $1 AND organization_id = $2"
|
||||
)
|
||||
@@ -158,16 +158,16 @@ pub async fn regenerate_knowledge_embedding(
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Knowledge base entry not found".to_string()))?;
|
||||
.ok_or((StatusCode::NOT_FOUND, "Entrada de la base de conocimientos no encontrada".to_string()))?;
|
||||
|
||||
// Generate embedding
|
||||
// Generar embedding
|
||||
let response = generate_embedding(&client, &ollama_url, &model, &entry.content_chunk)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("AI error: {}", e)))?;
|
||||
|
||||
let pgvector = ai::embedding_to_pgvector(&response.embedding);
|
||||
|
||||
// Update entry
|
||||
// Actualizar entrada
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE knowledge_base
|
||||
@@ -185,7 +185,7 @@ pub async fn regenerate_knowledge_embedding(
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
// ==================== Semantic Search ====================
|
||||
// ==================== Búsqueda Semántica ====================
|
||||
|
||||
/// GET /api/knowledge-base/semantic-search - Search knowledge base by semantic similarity
|
||||
pub async fn semantic_search_knowledge(
|
||||
@@ -193,7 +193,7 @@ pub async fn semantic_search_knowledge(
|
||||
State(pool): State<PgPool>,
|
||||
Query(filters): Query<KnowledgeSearchFilters>,
|
||||
) -> Result<Json<Vec<KnowledgeSearchResult>>, (StatusCode, String)> {
|
||||
// Create client that accepts invalid certificates
|
||||
// Crear cliente que acepte certificados inválidos
|
||||
let client = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_hostnames(true)
|
||||
@@ -203,7 +203,7 @@ pub async fn semantic_search_knowledge(
|
||||
let ollama_url = ai::get_ollama_url();
|
||||
let model = ai::get_embedding_model();
|
||||
|
||||
// Generate embedding for query
|
||||
// Generar embedding para la consulta
|
||||
let embedding_response = generate_embedding(&client, &ollama_url, &model, &filters.query)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("AI error: {}", e)))?;
|
||||
@@ -213,7 +213,7 @@ pub async fn semantic_search_knowledge(
|
||||
let limit = filters.limit.unwrap_or(10);
|
||||
let threshold = filters.threshold.unwrap_or(0.5);
|
||||
|
||||
// Build query with optional filters
|
||||
// Construir consulta con filtros opcionales
|
||||
let mut query = String::from(
|
||||
r#"
|
||||
SELECT
|
||||
@@ -269,7 +269,7 @@ pub async fn semantic_search_knowledge(
|
||||
Ok(Json(results))
|
||||
}
|
||||
|
||||
// ==================== Helper Structs ====================
|
||||
// ==================== Estructuras de Ayuda ====================
|
||||
|
||||
#[derive(Debug, sqlx::FromRow, Clone)]
|
||||
struct KnowledgeBaseEntry {
|
||||
|
||||
@@ -26,18 +26,18 @@ pub async fn create_payment_preference(
|
||||
let user_id = claims.sub;
|
||||
let course_id = payload.course_id;
|
||||
|
||||
// 1. Get Course details
|
||||
// 1. Obtener detalles del curso
|
||||
let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1")
|
||||
.bind(course_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "Course not found".into()))?;
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "Curso no encontrado".into()))?;
|
||||
|
||||
if course.price <= 0.0 {
|
||||
return Err((StatusCode::BAD_REQUEST, "Course is free".into()));
|
||||
return Err((StatusCode::BAD_REQUEST, "El curso es gratuito".into()));
|
||||
}
|
||||
|
||||
// 2. Create a pending transaction
|
||||
// 2. Crear una transacción pendiente
|
||||
let transaction_id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO transactions (id, organization_id, user_id, course_id, amount, currency, status)
|
||||
@@ -53,7 +53,7 @@ pub async fn create_payment_preference(
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// 3. Call Mercado Pago API
|
||||
// 3. Llamar a la API de Mercado Pago
|
||||
let mp_access_token = std::env::var("MP_ACCESS_TOKEN").unwrap_or_default();
|
||||
let back_url_success = std::env::var("MP_BACK_URL_SUCCESS").unwrap_or_default();
|
||||
let back_url_failure = std::env::var("MP_BACK_URL_FAILURE").unwrap_or_default();
|
||||
@@ -94,7 +94,7 @@ pub async fn create_payment_preference(
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("MP Error: {}", e),
|
||||
format!("Error de MP: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -102,14 +102,14 @@ pub async fn create_payment_preference(
|
||||
let err_text = mp_response.text().await.unwrap_or_default();
|
||||
return Err((
|
||||
StatusCode::BAD_GATEWAY,
|
||||
format!("MP API Error: {}", err_text),
|
||||
format!("Error de la API de MP: {}", err_text),
|
||||
));
|
||||
}
|
||||
|
||||
let mp_data: serde_json::Value = mp_response.json().await.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to parse MP response: {}", e),
|
||||
format!("Error al analizar la respuesta de MP: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -119,7 +119,7 @@ pub async fn create_payment_preference(
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
// Update transaction with provider reference
|
||||
// Actualizar transacción con la referencia del proveedor
|
||||
sqlx::query("UPDATE transactions SET provider_reference = $1 WHERE id = $2")
|
||||
.bind(&preference_id)
|
||||
.bind(transaction_id)
|
||||
@@ -155,7 +155,7 @@ pub async fn mercadopago_webhook(
|
||||
let payment_id = payload.data.id;
|
||||
let mp_access_token = std::env::var("MP_ACCESS_TOKEN").unwrap_or_default();
|
||||
|
||||
// 1. Fetch payment details from Mercado Pago to verify status
|
||||
// 1. Obtener detalles del pago de Mercado Pago para verificar el estado
|
||||
let client = reqwest::Client::new();
|
||||
let mp_response = client
|
||||
.get(format!(
|
||||
@@ -181,27 +181,27 @@ pub async fn mercadopago_webhook(
|
||||
.unwrap_or_default();
|
||||
|
||||
if status != "approved" {
|
||||
return Ok(StatusCode::OK); // Payment not yet approved, wait for next notification
|
||||
return Ok(StatusCode::OK); // El pago aún no ha sido aprobado, esperar a la siguiente notificación
|
||||
}
|
||||
|
||||
// 2. Find transaction by external reference (transaction_id)
|
||||
// 2. Buscar transacción por referencia externa (transaction_id)
|
||||
let transaction: Option<(Uuid, Uuid, Uuid)> = sqlx::query_as(
|
||||
"SELECT id, user_id, course_id FROM transactions WHERE id = $1 OR provider_reference = $1",
|
||||
)
|
||||
.bind(&external_reference) // Try by external reference first
|
||||
.bind(&external_reference) // Intentar por referencia externa primero
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
|
||||
if let Some((trans_id, user_id, course_id)) = transaction {
|
||||
// Mark transaction as success
|
||||
// Marcar transacción como exitosa
|
||||
sqlx::query("UPDATE transactions SET status = 'success', updated_at = NOW() WHERE id = $1")
|
||||
.bind(trans_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
// Auto-enroll the user
|
||||
// Inscribir automáticamente al usuario
|
||||
sqlx::query("INSERT INTO enrollments (id, user_id, course_id) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING")
|
||||
.bind(Uuid::new_v4())
|
||||
.bind(user_id)
|
||||
|
||||
@@ -18,7 +18,7 @@ pub async fn submit_assignment(
|
||||
Path((course_id, lesson_id)): Path<(Uuid, Uuid)>,
|
||||
Json(payload): Json<SubmitAssignmentPayload>,
|
||||
) -> Result<Json<CourseSubmission>, (StatusCode, String)> {
|
||||
// Check if submission already exists
|
||||
// Verificar si la entrega ya existe
|
||||
let existing: Option<CourseSubmission> = sqlx::query_as(
|
||||
"SELECT * FROM course_submissions WHERE user_id = $1 AND lesson_id = $2"
|
||||
)
|
||||
@@ -29,7 +29,7 @@ pub async fn submit_assignment(
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if let Some(_) = existing {
|
||||
// Update existing submission
|
||||
// Actualizar entrega existente
|
||||
let updated: CourseSubmission = sqlx::query_as(
|
||||
r#"
|
||||
UPDATE course_submissions
|
||||
@@ -48,7 +48,7 @@ pub async fn submit_assignment(
|
||||
return Ok(Json(updated));
|
||||
}
|
||||
|
||||
// Create new submission
|
||||
// Crear nueva entrega
|
||||
let submission: CourseSubmission = sqlx::query_as(
|
||||
r#"
|
||||
INSERT INTO course_submissions (user_id, course_id, lesson_id, organization_id, content)
|
||||
@@ -74,10 +74,10 @@ pub async fn get_peer_review_assignment(
|
||||
State(pool): State<PgPool>,
|
||||
Path((course_id, lesson_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<Json<Option<CourseSubmission>>, (StatusCode, String)> {
|
||||
// Find a submission that:
|
||||
// 1. Is not my own
|
||||
// 2. Has fewer than 2 reviews (configurable, but hardcoded for now)
|
||||
// 3. I haven't reviewed yet
|
||||
// Buscar una entrega que:
|
||||
// 1. No sea la mía propia
|
||||
// 2. Tenga menos de 2 revisiones (configurable, pero hardcoded por ahora)
|
||||
// 3. Yo no haya revisado aún
|
||||
let submission: Option<CourseSubmission> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT s.*
|
||||
@@ -115,7 +115,7 @@ pub async fn submit_peer_review(
|
||||
Path((_course_id, _lesson_id)): Path<(Uuid, Uuid)>,
|
||||
Json(payload): Json<SubmitPeerReviewPayload>,
|
||||
) -> Result<Json<PeerReview>, (StatusCode, String)> {
|
||||
// Verify valid submission
|
||||
// Verificar entrega válida
|
||||
let submission_row = sqlx::query(
|
||||
"SELECT user_id FROM course_submissions WHERE id = $1"
|
||||
)
|
||||
@@ -126,17 +126,17 @@ pub async fn submit_peer_review(
|
||||
|
||||
let submission_user_id = match submission_row {
|
||||
Some(row) => row.get::<Uuid, _>("user_id"),
|
||||
None => return Err((StatusCode::NOT_FOUND, "Submission not found".to_string())),
|
||||
None => return Err((StatusCode::NOT_FOUND, "Entrega no encontrada".to_string())),
|
||||
};
|
||||
|
||||
if submission_user_id == claims.sub {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Cannot review your own submission".to_string(),
|
||||
"No puedes revisar tu propia entrega".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Check if already reviewed
|
||||
// Verificar si ya fue revisada
|
||||
let existing = sqlx::query(
|
||||
"SELECT id FROM peer_reviews WHERE submission_id = $1 AND reviewer_id = $2"
|
||||
)
|
||||
@@ -149,11 +149,11 @@ pub async fn submit_peer_review(
|
||||
if existing.is_some() {
|
||||
return Err((
|
||||
StatusCode::CONFLICT,
|
||||
"You have already reviewed this submission".to_string(),
|
||||
"Ya has revisado esta entrega".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Create review
|
||||
// Crear revisión
|
||||
let review: PeerReview = sqlx::query_as(
|
||||
r#"
|
||||
INSERT INTO peer_reviews (submission_id, reviewer_id, score, feedback, organization_id)
|
||||
@@ -179,7 +179,7 @@ pub async fn get_my_submission_feedback(
|
||||
State(pool): State<PgPool>,
|
||||
Path((_course_id, lesson_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<Json<Vec<PeerReview>>, (StatusCode, String)> {
|
||||
// Get reviews for my submission on this lesson
|
||||
// Obtener revisiones para mi entrega en esta lección
|
||||
let reviews: Vec<PeerReview> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT pr.*
|
||||
|
||||
@@ -6,23 +6,23 @@ pub fn get_lti_private_key() -> jsonwebtoken::EncodingKey {
|
||||
let key_str = env::var("LTI_PRIVATE_KEY").unwrap_or_else(|_| {
|
||||
let dev_key_path = "services/lms-service/dev_keys/lti_private.pem";
|
||||
std::fs::read_to_string(dev_key_path).unwrap_or_else(|_| {
|
||||
// Return a dummy key or handle error gracefully in production
|
||||
// For now, we'll return a string that will likely fail decoding if used,
|
||||
// but allows the service to start if LTI is not used.
|
||||
tracing::warn!("LTI private key not found at {} and LTI_PRIVATE_KEY is not set.", dev_key_path);
|
||||
// Devolver una clave ficticia o manejar el error de forma adecuada en producción
|
||||
// Por ahora, devolveremos una cadena que probablemente fallará al decodificarse si se utiliza,
|
||||
// pero permite que el servicio se inicie si no se utiliza LTI.
|
||||
tracing::warn!("Clave privada LTI no encontrada en {} y LTI_PRIVATE_KEY no está establecida.", dev_key_path);
|
||||
String::new()
|
||||
})
|
||||
});
|
||||
|
||||
if key_str.is_empty() {
|
||||
// Handle the empty key case - maybe return a specialized error or a dummy key
|
||||
// that fails later. jsonwebtoken::EncodingKey::from_rsa_pem usually expects valid PEM.
|
||||
// We'll use a dummy valid-looking but useless PEM if it's empty to avoid panic on startup
|
||||
// but it will fail on actual LTI usage.
|
||||
// Manejar el caso de clave vacía; tal vez devolver un error especializado o una clave ficticia
|
||||
// que falle más tarde. jsonwebtoken::EncodingKey::from_rsa_pem suele esperar un PEM válido.
|
||||
// Utilizaremos un PEM ficticio con apariencia válida pero inútil si está vacío para evitar pánico al inicio,
|
||||
// pero fallará en el uso real de LTI.
|
||||
return jsonwebtoken::EncodingKey::from_rsa_pem(b"-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA7f...dummy...\n-----END RSA PRIVATE KEY-----").expect("Dummy key failed");
|
||||
}
|
||||
|
||||
jsonwebtoken::EncodingKey::from_rsa_pem(key_str.as_bytes()).expect("Invalid LTI private key format")
|
||||
jsonwebtoken::EncodingKey::from_rsa_pem(key_str.as_bytes()).expect("Formato de clave privada LTI inválido")
|
||||
}
|
||||
|
||||
pub fn get_lti_jwks() -> JwkSet {
|
||||
|
||||
@@ -42,7 +42,7 @@ pub async fn create_meeting(
|
||||
Json(payload): Json<CreateMeetingPayload>,
|
||||
) -> Result<Json<Meeting>, (StatusCode, String)> {
|
||||
if claims.role == "student" {
|
||||
return Err((StatusCode::FORBIDDEN, "Only instructors can create meetings".to_string()));
|
||||
return Err((StatusCode::FORBIDDEN, "Solo los instructores pueden crear reuniones".to_string()));
|
||||
}
|
||||
|
||||
let meeting_id = format!("openccb-{}", Uuid::new_v4());
|
||||
@@ -76,7 +76,7 @@ pub async fn delete_meeting(
|
||||
claims: Claims,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
if claims.role == "student" {
|
||||
return Err((StatusCode::FORBIDDEN, "Only instructors can delete meetings".to_string()));
|
||||
return Err((StatusCode::FORBIDDEN, "Solo los instructores pueden eliminar reuniones".to_string()));
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM meetings WHERE id = $1 AND organization_id = $2")
|
||||
|
||||
@@ -25,7 +25,7 @@ pub async fn lti_login_initiation(
|
||||
State(pool): State<PgPool>,
|
||||
Query(params): Query<LtiLoginParams>,
|
||||
) -> Result<Redirect, (StatusCode, String)> {
|
||||
// 1. Find registration
|
||||
// 1. Buscar registro
|
||||
let registration = sqlx::query_as::<_, LtiRegistration>(
|
||||
"SELECT * FROM lti_registrations WHERE issuer = $1 AND ($2::text IS NULL OR client_id = $2)"
|
||||
)
|
||||
@@ -34,20 +34,20 @@ pub async fn lti_login_initiation(
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::BAD_REQUEST, "LTI Registration not found".to_string()))?;
|
||||
.ok_or((StatusCode::BAD_REQUEST, "Registro LTI no encontrado".to_string()))?;
|
||||
|
||||
// 2. Generate state and nonce
|
||||
// 2. Generar estado y nonce
|
||||
let state = Uuid::new_v4().to_string();
|
||||
let nonce = Uuid::new_v4().to_string();
|
||||
|
||||
// 3. Store nonce
|
||||
// 3. Almacenar nonce
|
||||
sqlx::query("INSERT INTO lti_nonces (nonce) VALUES ($1)")
|
||||
.bind(&nonce)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// 4. Construct redirect URL
|
||||
// 4. Construir URL de redirección
|
||||
let mut url = format!(
|
||||
"{}?scope=openid&response_type=id_token&client_id={}&redirect_uri={}&login_hint={}&state={}&nonce={}&response_mode=form_post",
|
||||
registration.auth_login_url,
|
||||
@@ -76,9 +76,9 @@ pub async fn validate_lti_jwt(
|
||||
client_id: &str,
|
||||
) -> Result<LtiLaunchClaims, String> {
|
||||
let header = decode_header(id_token).map_err(|e| e.to_string())?;
|
||||
let kid = header.kid.ok_or("Missing kid in JWT header")?;
|
||||
let kid = header.kid.ok_or("Falta kid en el encabezado JWT")?;
|
||||
|
||||
// Fetch JWKS
|
||||
// Obtener JWKS
|
||||
let jwks: JwkSet = reqwest::get(jwks_url)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
@@ -86,7 +86,7 @@ pub async fn validate_lti_jwt(
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let jwk = jwks.find(&kid).ok_or("JWK not found for kid")?;
|
||||
let jwk = jwks.find(&kid).ok_or("JWK no encontrado para kid")?;
|
||||
let decoding_key = DecodingKey::from_jwk(jwk).map_err(|e| e.to_string())?;
|
||||
|
||||
let mut validation = Validation::new(jsonwebtoken::Algorithm::RS256);
|
||||
@@ -102,27 +102,27 @@ pub async fn lti_launch(
|
||||
State(pool): State<PgPool>,
|
||||
Form(payload): Form<LtiLaunchParams>,
|
||||
) -> Result<Redirect, (StatusCode, String)> {
|
||||
// 1. Decode claims manually to find registration (since we don't have the key yet)
|
||||
// 1. Decodificar claims manualmente para encontrar el registro (ya que aún no tenemos la clave)
|
||||
let parts: Vec<&str> = payload.id_token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err((StatusCode::BAD_REQUEST, "Invalid JWT format".to_string()));
|
||||
return Err((StatusCode::BAD_REQUEST, "Formato JWT inválido".to_string()));
|
||||
}
|
||||
|
||||
let decoded_claims = URL_SAFE_NO_PAD.decode(parts[1])
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid base64 in JWT payload: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Base64 inválido en el payload del JWT: {}", e)))?;
|
||||
|
||||
let claims: serde_json::Value = serde_json::from_slice(&decoded_claims)
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid JSON in JWT payload: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, format!("JSON inválido en el payload del JWT: {}", e)))?;
|
||||
|
||||
let iss = claims["iss"].as_str().ok_or((StatusCode::BAD_REQUEST, "Missing iss claim".to_string()))?;
|
||||
let iss = claims["iss"].as_str().ok_or((StatusCode::BAD_REQUEST, "Falta el claim iss".to_string()))?;
|
||||
let aud_val = &claims["aud"];
|
||||
let aud = match aud_val {
|
||||
serde_json::Value::String(s) => s.as_str(),
|
||||
serde_json::Value::Array(arr) => arr[0].as_str().ok_or((StatusCode::BAD_REQUEST, "Invalid aud in array".to_string()))?,
|
||||
_ => return Err((StatusCode::BAD_REQUEST, "Invalid aud claim".to_string())),
|
||||
serde_json::Value::Array(arr) => arr[0].as_str().ok_or((StatusCode::BAD_REQUEST, "aud inválido en el array".to_string()))?,
|
||||
_ => return Err((StatusCode::BAD_REQUEST, "Claim aud inválido".to_string())),
|
||||
};
|
||||
|
||||
// 2. Find registration
|
||||
// 2. Buscar registro
|
||||
let registration = sqlx::query_as::<_, LtiRegistration>(
|
||||
"SELECT * FROM lti_registrations WHERE issuer = $1 AND client_id = $2"
|
||||
)
|
||||
@@ -131,14 +131,14 @@ pub async fn lti_launch(
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "LTI Registration not found for issuer/aud".to_string()))?;
|
||||
.ok_or((StatusCode::NOT_FOUND, "Registro LTI no encontrado para emisor/audiencia".to_string()))?;
|
||||
|
||||
// 3. Validate JWT
|
||||
// 3. Validar JWT
|
||||
let lti_claims = validate_lti_jwt(&payload.id_token, ®istration.jwks_url, ®istration.client_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::UNAUTHORIZED, format!("JWT validation failed: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::UNAUTHORIZED, format!("Validación de JWT fallida: {}", e)))?;
|
||||
|
||||
// 4. Verify nonce
|
||||
// 4. Verificar nonce
|
||||
let nonce_exists = sqlx::query("DELETE FROM lti_nonces WHERE nonce = $1")
|
||||
.bind(<i_claims.nonce)
|
||||
.execute(&pool)
|
||||
@@ -147,10 +147,10 @@ pub async fn lti_launch(
|
||||
.rows_affected() > 0;
|
||||
|
||||
if !nonce_exists {
|
||||
return Err((StatusCode::BAD_REQUEST, "Invalid or expired nonce".to_string()));
|
||||
return Err((StatusCode::BAD_REQUEST, "Nonce inválido o expirado".to_string()));
|
||||
}
|
||||
|
||||
// 5. Find or create user
|
||||
// 5. Buscar o crear usuario
|
||||
let email = lti_claims.email.clone().unwrap_or_else(|| format!("lti_{}@{}", lti_claims.subject, iss.replace("http://", "").replace("https://", "")));
|
||||
let full_name = lti_claims.name.clone().unwrap_or_else(|| "LTI User".to_string());
|
||||
|
||||
@@ -206,16 +206,16 @@ pub async fn lti_launch(
|
||||
|
||||
let user = user.unwrap();
|
||||
|
||||
// 8. Redirect based on message type
|
||||
// 8. Redirigir según el tipo de mensaje
|
||||
let experience_url = std::env::var("NEXT_PUBLIC_EXPERIENCE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
|
||||
let studio_url = std::env::var("NEXT_PUBLIC_STUDIO_URL").unwrap_or_else(|_| "http://localhost:3001".to_string());
|
||||
|
||||
let token = common::auth::create_jwt(user.id, user.organization_id, &user.role)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create token: {}", e)))?;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al crear el token: {}", e)))?;
|
||||
let redirect_target = lti_claims.resource_link.as_ref().map(|rl| rl.id.clone()).unwrap_or_default();
|
||||
|
||||
if lti_claims.message_type == "LtiDeepLinkingRequest" {
|
||||
let settings = lti_claims.deep_linking_settings.ok_or((StatusCode::BAD_REQUEST, "Missing deep_linking_settings".to_string()))?;
|
||||
let settings = lti_claims.deep_linking_settings.ok_or((StatusCode::BAD_REQUEST, "Faltan deep_linking_settings".to_string()))?;
|
||||
|
||||
let dl_request_id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
@@ -249,8 +249,8 @@ pub async fn lti_deep_linking_response(
|
||||
claims: Claims,
|
||||
Json(payload): Json<LtiDeepLinkingResponsePayload>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
// 1. Retrieve and delete DL request
|
||||
let dl_id = Uuid::parse_str(&payload.dl_token).map_err(|_| (StatusCode::BAD_REQUEST, "Invalid DL token".to_string()))?;
|
||||
// 1. Recuperar y eliminar la solicitud de DL
|
||||
let dl_id = Uuid::parse_str(&payload.dl_token).map_err(|_| (StatusCode::BAD_REQUEST, "Token de DL inválido".to_string()))?;
|
||||
|
||||
let dl_request = sqlx::query(
|
||||
"DELETE FROM lti_deep_linking_requests WHERE id = $1 RETURNING registration_id, deployment_id, return_url, data"
|
||||
@@ -259,15 +259,15 @@ pub async fn lti_deep_linking_response(
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Invalid or expired DL request".to_string()))?;
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Solicitud de DL inválida o expirada".to_string()))?;
|
||||
|
||||
// Manual mapping since we can't use query!/query_as! easily for RETURNING without a struct
|
||||
// Mapeo manual ya que no podemos usar query!/query_as! fácilmente para RETURNING sin una estructura
|
||||
let registration_id: Uuid = dl_request.get("registration_id");
|
||||
let deployment_id: String = dl_request.get("deployment_id");
|
||||
let _return_url: String = dl_request.get::<String, _>("return_url");
|
||||
let dl_data: Option<String> = dl_request.get("data");
|
||||
|
||||
// 2. Find registration
|
||||
// 2. Buscar registro
|
||||
let registration = sqlx::query_as::<_, LtiRegistration>(
|
||||
"SELECT * FROM lti_registrations WHERE id = $1",
|
||||
)
|
||||
|
||||
@@ -36,44 +36,44 @@ async fn main() {
|
||||
dotenv().ok();
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let db_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||
let db_url = env::var("DATABASE_URL").expect("DATABASE_URL debe estar configurada");
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(10)
|
||||
.min_connections(2)
|
||||
.acquire_timeout(Duration::from_secs(30))
|
||||
.connect(&db_url)
|
||||
.await
|
||||
.expect("Failed to connect to database");
|
||||
.expect("Error al conectar con la base de datos");
|
||||
|
||||
// Initialize health state
|
||||
// Inicializar estado de salud
|
||||
let health_state = HealthState::default();
|
||||
|
||||
let mysql_pool = external_db::init_mysql_pool().await;
|
||||
|
||||
// Run migrations automatically
|
||||
// Ejecutar migraciones automáticamente
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("Failed to run migrations");
|
||||
.expect("Error al ejecutar las migraciones");
|
||||
|
||||
// Start background task for deadline notifications
|
||||
// Iniciar tarea en segundo plano para notificaciones de fechas límite
|
||||
let pool_clone = pool.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
handlers::check_deadlines_and_notify(pool_clone.clone()).await;
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3600)).await; // Every hour
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3600)).await; // Cada hora
|
||||
}
|
||||
});
|
||||
|
||||
// CORS configuration - Allow multiple origins for development and production
|
||||
// Using a predicate closure to support wildcard subdomains for norteamericano.cl
|
||||
// Configuración de CORS - Permitir múltiples orígenes para desarrollo y producción
|
||||
// Usando un cierre de predicado para soportar subdominios comodín para norteamericano.cl
|
||||
use tower_http::cors::AllowOrigin;
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(AllowOrigin::predicate(|origin: &http::HeaderValue, _request: &http::request::Parts| -> bool {
|
||||
let origin_str = origin.to_str().unwrap_or("");
|
||||
|
||||
// Development origins
|
||||
// Orígenes de desarrollo
|
||||
let allowed_origins = [
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3003",
|
||||
@@ -82,17 +82,17 @@ async fn main() {
|
||||
"http://192.168.0.254:3000",
|
||||
"http://192.168.0.254:3003",
|
||||
"http://192.168.0.254",
|
||||
// Production - Norteamericano domains (HTTPS)
|
||||
// Producción - Dominios de Norteamericano (HTTPS)
|
||||
"https://studio.norteamericano.cl",
|
||||
"https://learning.norteamericano.cl",
|
||||
];
|
||||
|
||||
// Check exact matches
|
||||
// Comprobar coincidencias exactas
|
||||
if allowed_origins.contains(&origin_str) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check wildcard for subdomains: https://*.norteamericano.cl
|
||||
// Comprobar comodín para subdominios: https://*.norteamericano.cl
|
||||
if origin_str.starts_with("https://") && origin_str.ends_with(".norteamericano.cl") {
|
||||
let subdomain = origin_str
|
||||
.strip_prefix("https://")
|
||||
@@ -100,7 +100,7 @@ async fn main() {
|
||||
.strip_suffix(".norteamericano.cl")
|
||||
.unwrap_or("");
|
||||
|
||||
// Allow any subdomain (e.g., api., cdn., admin., etc.)
|
||||
// Permitir cualquier subdominio (p. ej., api., cdn., admin., etc.)
|
||||
if !subdomain.is_empty() && !subdomain.contains('/') {
|
||||
return true;
|
||||
}
|
||||
@@ -169,10 +169,10 @@ async fn main() {
|
||||
"/courses/{id}/dropout-risks",
|
||||
get(predictive::get_course_dropout_risks),
|
||||
)
|
||||
// Live Learning
|
||||
// Aprendizaje en Vivo (Live Learning)
|
||||
.route("/courses/{id}/meetings", get(live::get_course_meetings).post(live::create_meeting))
|
||||
.route("/courses/{id}/meetings/{meeting_id}", delete(live::delete_meeting))
|
||||
// Portfolio & Badges
|
||||
// Portafolio e insignias (Badges)
|
||||
.route("/profile/{user_id}", get(portfolio::get_public_profile))
|
||||
.route("/my/badges", get(portfolio::get_my_badges))
|
||||
.route("/badges/award", post(portfolio::award_badge))
|
||||
@@ -189,7 +189,7 @@ async fn main() {
|
||||
.route("/lessons/{id}/heatmap", get(handlers::get_lesson_heatmap))
|
||||
.route("/audio/evaluate", post(handlers::evaluate_audio_response))
|
||||
.route("/audio/evaluate-file", post(handlers::evaluate_audio_file))
|
||||
// Audio Response Teacher Routes
|
||||
// Rutas de Profesor para Respuesta de Audio
|
||||
.route("/audio-responses", get(handlers::get_audio_responses))
|
||||
.route("/audio-responses/{id}", get(handlers::get_audio_response_detail))
|
||||
.route("/audio-responses/{id}/audio", get(handlers::get_audio_response_audio))
|
||||
@@ -204,7 +204,7 @@ async fn main() {
|
||||
"/notifications/{id}/read",
|
||||
post(handlers::mark_notification_as_read),
|
||||
)
|
||||
// Knowledge Base Embedding Routes for Semantic RAG
|
||||
// Rutas de Embeddings de Base de Conocimientos para RAG Semántico
|
||||
.route(
|
||||
"/knowledge-base/embeddings/generate",
|
||||
post(handlers_embeddings::generate_knowledge_embeddings),
|
||||
@@ -217,7 +217,7 @@ async fn main() {
|
||||
"/knowledge-base/{id}/embedding/regenerate",
|
||||
post(handlers_embeddings::regenerate_knowledge_embedding),
|
||||
)
|
||||
// Discussion Forums Routes
|
||||
// Rutas de Foros de Discusión
|
||||
.route(
|
||||
"/courses/{id}/discussions",
|
||||
get(handlers_discussions::list_threads),
|
||||
@@ -255,7 +255,7 @@ async fn main() {
|
||||
"/discussions/{id}/unsubscribe",
|
||||
post(handlers_discussions::unsubscribe_thread),
|
||||
)
|
||||
// Announcements
|
||||
// Anuncios
|
||||
.route(
|
||||
"/courses/{id}/announcements",
|
||||
get(handlers_announcements::list_announcements),
|
||||
@@ -274,7 +274,7 @@ async fn main() {
|
||||
)
|
||||
.route("/lessons/{id}/notes", get(handlers_notes::get_note))
|
||||
.route("/lessons/{id}/notes", put(handlers_notes::save_note))
|
||||
// Cohorts
|
||||
// Cohortes (Cohorts)
|
||||
.route("/cohorts", get(handlers_cohorts::list_cohorts))
|
||||
.route("/cohorts", post(handlers_cohorts::create_cohort))
|
||||
.route(
|
||||
@@ -289,7 +289,7 @@ async fn main() {
|
||||
"/cohorts/{id}/members",
|
||||
get(handlers_cohorts::get_cohort_members),
|
||||
)
|
||||
// Peer Assessment
|
||||
// Evaluación por Pares (Peer Assessment)
|
||||
.route(
|
||||
"/courses/{id}/lessons/{lesson_id}/submit",
|
||||
post(handlers_peer_review::submit_assignment),
|
||||
@@ -338,7 +338,7 @@ async fn main() {
|
||||
</html>
|
||||
"#)
|
||||
}))
|
||||
// Health check routes
|
||||
// Rutas de comprobación de salud (Health check)
|
||||
.merge(health::health_routes(pool.clone()).with_state(health_state))
|
||||
.route("/catalog", get(handlers::get_course_catalog))
|
||||
.route("/ingest", post(handlers::ingest_course))
|
||||
@@ -353,7 +353,7 @@ async fn main() {
|
||||
.route("/lti/jwks", get(jwks::lti_jwks_handler))
|
||||
.route("/lti/deep-linking/response", post(lti::lti_deep_linking_response))
|
||||
.merge(protected_routes)
|
||||
// Security headers
|
||||
// Encabezados de seguridad (Security headers)
|
||||
.layer(SetResponseHeaderLayer::overriding(
|
||||
http::header::STRICT_TRANSPORT_SECURITY,
|
||||
http::HeaderValue::from_static("max-age=31536000; includeSubDomains"),
|
||||
@@ -379,7 +379,7 @@ async fn main() {
|
||||
.layer(axum::Extension(mysql_pool));
|
||||
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], 3002));
|
||||
tracing::info!("LMS Service listening on {} with rate limiting and security headers", addr);
|
||||
tracing::info!("LMS Service escuchando en {} con limitación de tasa y encabezados de seguridad", addr);
|
||||
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||
axum::serve(listener, public_routes).await.unwrap();
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ pub struct ApiDoc;
|
||||
tag = "Cursos",
|
||||
request_body = IngestCourseRequest,
|
||||
responses(
|
||||
(status = 200, description = "Curso ingestada exitosamente"),
|
||||
(status = 200, description = "Curso ingestado exitosamente"),
|
||||
(status = 400, description = "Payload inválido o JSON mal formado"),
|
||||
(status = 500, description = "Error interno del servidor"),
|
||||
)
|
||||
@@ -227,11 +227,11 @@ pub fn get_course_outline() {}
|
||||
///
|
||||
/// | id | nombre | descripcion |
|
||||
/// |----|--------|-------------|
|
||||
/// | 1 | CA | Continuous Assessment |
|
||||
/// | 2 | MWT | Midterm Written Test |
|
||||
/// | 3 | MOT | Midterm Oral Test |
|
||||
/// | 5 | FOT | Final Oral Test |
|
||||
/// | 6 | FWT | Final written test |
|
||||
/// | 1 | CA | Evaluación Continua (Continuous Assessment) |
|
||||
/// | 2 | MWT | Examen Escrito de Mitad de Ciclo (Midterm Written Test) |
|
||||
/// | 3 | MOT | Examen Oral de Mitad de Ciclo (Midterm Oral Test) |
|
||||
/// | 5 | FOT | Examen Oral Final (Final Oral Test) |
|
||||
/// | 6 | FWT | Examen Escrito Final (Final Written Test) |
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/tipo-nota",
|
||||
|
||||
@@ -19,11 +19,11 @@ pub async fn get_public_profile(
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
|
||||
.ok_or((StatusCode::NOT_FOUND, "Usuario no encontrado".to_string()))?;
|
||||
|
||||
let is_public: bool = user.get("is_public_profile");
|
||||
if !is_public {
|
||||
return Err((StatusCode::FORBIDDEN, "This profile is private".to_string()));
|
||||
return Err((StatusCode::FORBIDDEN, "Este perfil es privado".to_string()));
|
||||
}
|
||||
|
||||
let badges = sqlx::query_as::<sqlx::Postgres, Badge>(
|
||||
@@ -98,7 +98,7 @@ pub async fn award_badge(
|
||||
Json(payload): Json<UserBadge>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
if claims.role == "student" {
|
||||
return Err((StatusCode::FORBIDDEN, "Only admins can award badges manually".to_string()));
|
||||
return Err((StatusCode::FORBIDDEN, "Solo los administradores pueden otorgar insignias manualmente".to_string()));
|
||||
}
|
||||
|
||||
sqlx::query("INSERT INTO user_badges (user_id, badge_id, awarded_at) VALUES ($1, $2, NOW()) ON CONFLICT DO NOTHING")
|
||||
|
||||
@@ -16,7 +16,7 @@ pub async fn get_course_dropout_risks(
|
||||
claims: Claims,
|
||||
) -> Result<Json<Vec<DropoutRisk>>, (StatusCode, String)> {
|
||||
if claims.role == "student" {
|
||||
return Err((StatusCode::FORBIDDEN, "Only instructors can view risk reports".to_string()));
|
||||
return Err((StatusCode::FORBIDDEN, "Solo los instructores pueden ver informes de riesgo".to_string()));
|
||||
}
|
||||
|
||||
calculate_risks_for_course(&pool, course_id, claims.org).await
|
||||
@@ -34,7 +34,7 @@ pub async fn get_course_dropout_risks(
|
||||
.bind(claims.org)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch risks: {}", e)))?;
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los riesgos: {}", e)))?;
|
||||
|
||||
let risks: Vec<DropoutRisk> = rows.into_iter().map(|row| {
|
||||
DropoutRisk {
|
||||
@@ -106,8 +106,8 @@ pub async fn calculate_risks_for_course(
|
||||
};
|
||||
|
||||
let reasons = vec![
|
||||
DropoutRiskReason { metric: "performance".to_string(), value: avg_grade, description: format!("Grade: {:.0}%", avg_grade * 100.0) },
|
||||
DropoutRiskReason { metric: "activity".to_string(), value: last_activity_count as f32, description: format!("{} actions in last week", last_activity_count) },
|
||||
DropoutRiskReason { metric: "performance".to_string(), value: avg_grade, description: format!("Calificación: {:.0}%", avg_grade * 100.0) },
|
||||
DropoutRiskReason { metric: "activity".to_string(), value: last_activity_count as f32, description: format!("{} acciones en la última semana", last_activity_count) },
|
||||
];
|
||||
|
||||
sqlx::query(
|
||||
|
||||
+41
-41
@@ -1,33 +1,33 @@
|
||||
//! AI Utilities for OpenCCB
|
||||
//! Provides embedding generation and other AI helper functions
|
||||
//! Utilidades de IA para OpenCCB
|
||||
//! Proporciona generación de embeddings y otras funciones de ayuda de IA
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Default embedding model for Ollama
|
||||
/// Modelo de embedding por defecto para Ollama
|
||||
pub const DEFAULT_EMBEDDING_MODEL: &str = "nomic-embed-text";
|
||||
|
||||
/// Default Ollama URL
|
||||
/// URL de Ollama por defecto
|
||||
pub const DEFAULT_OLLAMA_URL: &str = "http://localhost:11434";
|
||||
|
||||
/// Embedding dimensions for nomic-embed-text
|
||||
/// Dimensiones del embedding para nomic-embed-text
|
||||
pub const EMBEDDING_DIMENSIONS: usize = 768;
|
||||
|
||||
/// Model selection for different use cases
|
||||
/// Selección de modelo para diferentes casos de uso
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ModelType {
|
||||
/// Fast conversational AI (chat, tutor, Q&A)
|
||||
/// IA conversacional rápida (chat, tutor, preguntas y respuestas)
|
||||
Chat,
|
||||
/// Complex reasoning (analysis, recommendations, feedback)
|
||||
/// Razonamiento complejo (análisis, recomendaciones, retroalimentación)
|
||||
Complex,
|
||||
/// Advanced tasks (course generation, detailed analysis)
|
||||
/// Tareas avanzadas (generación de cursos, análisis detallado)
|
||||
Advanced,
|
||||
/// Embedding generation
|
||||
/// Generación de embeddings
|
||||
Embedding,
|
||||
}
|
||||
|
||||
impl ModelType {
|
||||
/// Get the model name for this type from environment
|
||||
/// Obtener el nombre del modelo para este tipo desde el entorno
|
||||
pub fn get_model(&self) -> String {
|
||||
match self {
|
||||
ModelType::Chat => {
|
||||
@@ -45,34 +45,34 @@ impl ModelType {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get recommended temperature for this model type
|
||||
/// Obtener la temperatura recomendada para este tipo de modelo
|
||||
pub fn get_temperature(&self) -> f32 {
|
||||
match self {
|
||||
ModelType::Chat => 0.7, // Balanced creativity/accuracy
|
||||
ModelType::Complex => 0.5, // More focused reasoning
|
||||
ModelType::Advanced => 0.6, // Balanced for analysis
|
||||
ModelType::Embedding => 0.0, // Deterministic for embeddings
|
||||
ModelType::Chat => 0.7, // Equilibrio entre creatividad y precisión
|
||||
ModelType::Complex => 0.5, // Razonamiento más enfocado
|
||||
ModelType::Advanced => 0.6, // Equilibrado para análisis
|
||||
ModelType::Embedding => 0.0, // Determinista para embeddings
|
||||
}
|
||||
}
|
||||
|
||||
/// Get max tokens for this model type
|
||||
/// Obtener los tokens máximos para este tipo de modelo
|
||||
pub fn get_max_tokens(&self) -> u32 {
|
||||
match self {
|
||||
ModelType::Chat => 1024,
|
||||
ModelType::Complex => 2048,
|
||||
ModelType::Advanced => 4096,
|
||||
ModelType::Embedding => 0, // Not applicable
|
||||
ModelType::Embedding => 0, // No aplicable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AiError {
|
||||
#[error("Ollama request failed: {0}")]
|
||||
#[error("Solicitud a Ollama fallida: {0}")]
|
||||
OllamaRequest(String),
|
||||
#[error("Invalid embedding response: {0}")]
|
||||
#[error("Respuesta de embedding inválida: {0}")]
|
||||
InvalidResponse(String),
|
||||
#[error("Model not available: {0}")]
|
||||
#[error("Modelo no disponible: {0}")]
|
||||
ModelNotAvailable(String),
|
||||
}
|
||||
|
||||
@@ -83,19 +83,19 @@ pub struct EmbeddingResponse {
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
/// Get Ollama URL from environment or default
|
||||
/// Obtener la URL de Ollama desde el entorno o por defecto
|
||||
pub fn get_ollama_url() -> String {
|
||||
std::env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| DEFAULT_OLLAMA_URL.to_string())
|
||||
}
|
||||
|
||||
/// Get embedding model from environment or default
|
||||
/// Obtener el modelo de embedding desde el entorno o por defecto
|
||||
pub fn get_embedding_model() -> String {
|
||||
std::env::var("EMBEDDING_MODEL").unwrap_or_else(|_| DEFAULT_EMBEDDING_MODEL.to_string())
|
||||
}
|
||||
|
||||
/// Get the best model for a specific task
|
||||
/// Obtener el mejor modelo para una tarea específica
|
||||
pub fn get_model_for_task(task: &str) -> String {
|
||||
// Task-based model selection
|
||||
// Selección de modelo basada en la tarea
|
||||
match task.to_lowercase().as_str() {
|
||||
t if t.contains("chat") || t.contains("tutor") || t.contains("conversation") => {
|
||||
ModelType::Chat.get_model()
|
||||
@@ -116,22 +116,22 @@ pub fn get_model_for_task(task: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a reqwest client that accepts invalid certificates (for dev with self-signed certs)
|
||||
/// Crear un cliente reqwest que acepte certificados inválidos (para desarrollo con certificados autofirmados)
|
||||
fn create_insecure_client() -> Result<reqwest::Client, AiError> {
|
||||
reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_hostnames(true)
|
||||
.build()
|
||||
.map_err(|e| AiError::OllamaRequest(format!("Failed to create HTTP client: {}", e)))
|
||||
.map_err(|e| AiError::OllamaRequest(format!("Error al crear el cliente HTTP: {}", e)))
|
||||
}
|
||||
|
||||
/// Generate embedding for text using Ollama
|
||||
/// Generar embedding para texto usando Ollama
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `client` - reqwest::Client instance
|
||||
/// * `ollama_url` - Base URL for Ollama (e.g., "http://localhost:11434")
|
||||
/// * `model` - Embedding model name (default: "nomic-embed-text")
|
||||
/// * `text` - Text to embed
|
||||
/// # Argumentos
|
||||
/// * `client` - instancia de reqwest::Client
|
||||
/// * `ollama_url` - URL base para Ollama (e.g., "http://localhost:11434")
|
||||
/// * `model` - Nombre del modelo de embedding (por defecto: "nomic-embed-text")
|
||||
/// * `text` - Texto a incrustar
|
||||
pub async fn generate_embedding(
|
||||
client: &reqwest::Client,
|
||||
ollama_url: &str,
|
||||
@@ -148,25 +148,25 @@ pub async fn generate_embedding(
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AiError::OllamaRequest(format!("Request failed: {}", e)))?;
|
||||
.map_err(|e| AiError::OllamaRequest(format!("Solicitud fallida: {}", e)))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
return Err(AiError::OllamaRequest(
|
||||
format!("Ollama API error ({}): {}", status, error_text)
|
||||
format!("Error de la API de Ollama ({}): {}", status, error_text)
|
||||
));
|
||||
}
|
||||
|
||||
let embedding_response: EmbeddingResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AiError::InvalidResponse(format!("Failed to parse response: {}", e)))?;
|
||||
.map_err(|e| AiError::InvalidResponse(format!("Error al analizar la respuesta: {}", e)))?;
|
||||
|
||||
Ok(embedding_response)
|
||||
}
|
||||
|
||||
/// Generate embeddings for multiple texts in batch
|
||||
/// Generar embeddings para múltiples textos en lote
|
||||
pub async fn generate_embeddings_batch(
|
||||
client: &reqwest::Client,
|
||||
ollama_url: &str,
|
||||
@@ -183,8 +183,8 @@ pub async fn generate_embeddings_batch(
|
||||
Ok(embeddings)
|
||||
}
|
||||
|
||||
/// Convert a vector of f32 to pgvector-compatible format
|
||||
/// PostgreSQL vector format: "[0.1,0.2,0.3,...]"
|
||||
/// Convertir un vector de f32 a un formato compatible con pgvector
|
||||
/// Formato de vector de PostgreSQL: "[0.1,0.2,0.3,...]"
|
||||
pub fn embedding_to_pgvector(embedding: &[f32]) -> String {
|
||||
let formatted: Vec<String> = embedding
|
||||
.iter()
|
||||
@@ -193,12 +193,12 @@ pub fn embedding_to_pgvector(embedding: &[f32]) -> String {
|
||||
format!("[{}]", formatted.join(","))
|
||||
}
|
||||
|
||||
/// Parse pgvector format back to Vec<f32>
|
||||
/// Analizar el formato pgvector de vuelta a Vec<f32>
|
||||
pub fn pgvector_to_embedding(pgvector: &str) -> Result<Vec<f32>, String> {
|
||||
let trimmed = pgvector.trim().trim_start_matches('[').trim_end_matches(']');
|
||||
trimmed
|
||||
.split(',')
|
||||
.map(|s| s.trim().parse::<f32>().map_err(|e| format!("Parse error: {}", e)))
|
||||
.map(|s| s.trim().parse::<f32>().map_err(|e| format!("Error de análisis: {}", e)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
//! Health check endpoints for monitoring and observability
|
||||
//! Endpoints de verificación de salud para monitoreo y observabilidad
|
||||
|
||||
use axum::{Json, Router, routing::get, extract::State};
|
||||
use serde_json::json;
|
||||
use sqlx::PgPool;
|
||||
use std::time::Instant;
|
||||
|
||||
/// Health check state shared across requests
|
||||
/// Estado de verificación de salud compartido entre peticiones
|
||||
#[derive(Clone)]
|
||||
pub struct HealthState {
|
||||
pub start_time: Instant,
|
||||
@@ -21,7 +21,7 @@ impl Default for HealthState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Basic health check endpoint
|
||||
/// Endpoint básico de verificación de salud
|
||||
pub async fn health_check() -> Json<serde_json::Value> {
|
||||
Json(json!({
|
||||
"status": "healthy",
|
||||
@@ -29,7 +29,7 @@ pub async fn health_check() -> Json<serde_json::Value> {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Detailed readiness check including database connectivity
|
||||
/// Verificación detallada de disponibilidad que incluye la conectividad con la base de datos
|
||||
pub async fn readiness_check(pool: PgPool) -> Json<serde_json::Value> {
|
||||
let db_status = match pool.acquire().await {
|
||||
Ok(_) => "connected",
|
||||
@@ -45,7 +45,7 @@ pub async fn readiness_check(pool: PgPool) -> Json<serde_json::Value> {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Liveness check with uptime information
|
||||
/// Verificación de vitalidad (liveness) con información de tiempo de actividad (uptime)
|
||||
pub async fn liveness_check(state: State<HealthState>) -> Json<serde_json::Value> {
|
||||
let uptime = state.start_time.elapsed();
|
||||
|
||||
@@ -57,7 +57,7 @@ pub async fn liveness_check(state: State<HealthState>) -> Json<serde_json::Value
|
||||
}))
|
||||
}
|
||||
|
||||
/// Create health routes
|
||||
/// Crear rutas de salud
|
||||
pub fn health_routes(pool: PgPool) -> Router<HealthState> {
|
||||
Router::new()
|
||||
.route("/health", get(health_check))
|
||||
|
||||
@@ -28,7 +28,7 @@ pub async fn org_extractor_middleware(
|
||||
let token = if let Some(token_str) = auth_header.and_then(|s: &str| s.strip_prefix("Bearer ")) {
|
||||
token_str.to_string()
|
||||
} else {
|
||||
// Check for preview_token in query string
|
||||
// Verificar si hay preview_token en la cadena de consulta
|
||||
let query = req.uri().query().unwrap_or_default();
|
||||
let preview_token = query
|
||||
.split('&')
|
||||
|
||||
+56
-56
@@ -11,7 +11,7 @@ pub struct Course {
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub instructor_id: Uuid,
|
||||
pub pacing_mode: String, // "self_paced" or "instructor_led"
|
||||
pub pacing_mode: String, // "self_paced" o "instructor_led"
|
||||
pub start_date: Option<DateTime<Utc>>,
|
||||
pub end_date: Option<DateTime<Utc>>,
|
||||
pub passing_percentage: i32,
|
||||
@@ -121,7 +121,7 @@ pub struct GradingCategory {
|
||||
pub name: String,
|
||||
pub weight: i32, // 0-100
|
||||
pub drop_count: i32,
|
||||
pub tipo_nota_id: Option<i32>, // Maps to idTipoNota in external MySQL system
|
||||
pub tipo_nota_id: Option<i32>, // Se mapea con idTipoNota en el sistema MySQL externo
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ pub struct UserGrade {
|
||||
pub user_id: Uuid,
|
||||
pub course_id: Uuid,
|
||||
pub lesson_id: Uuid,
|
||||
pub score: f32, // 0.0 to 1.0
|
||||
pub score: f32, // 0.0 a 1.0
|
||||
pub attempts_count: i32,
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
@@ -187,7 +187,7 @@ pub struct Enrollment {
|
||||
pub user_id: Uuid,
|
||||
pub organization_id: Uuid,
|
||||
pub course_id: Uuid,
|
||||
pub external_id: Option<i32>, // idDetalleContrato from the external system
|
||||
pub external_id: Option<i32>, // idDetalleContrato del sistema externo
|
||||
pub enrolled_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -242,7 +242,7 @@ pub struct LtiLaunchClaims {
|
||||
#[serde(rename = "sub")]
|
||||
pub subject: String,
|
||||
#[serde(rename = "aud")]
|
||||
pub audience: serde_json::Value, // Can be string or array
|
||||
pub audience: serde_json::Value, // Puede ser una cadena o un array
|
||||
#[serde(rename = "exp")]
|
||||
pub expires_at: i64,
|
||||
#[serde(rename = "iat")]
|
||||
@@ -363,7 +363,7 @@ pub struct Transaction {
|
||||
pub course_id: Uuid,
|
||||
pub amount: f64,
|
||||
pub currency: String,
|
||||
pub status: String, // "pending", "success", "failure"
|
||||
pub status: String, // "pending", "success", "failure" (pendiente, éxito, falla)
|
||||
pub provider_reference: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
@@ -377,7 +377,7 @@ pub struct User {
|
||||
pub email: String,
|
||||
pub password_hash: String,
|
||||
pub full_name: String,
|
||||
pub role: String, // admin, instructor, student
|
||||
pub role: String, // admin (administrador), instructor, student (estudiante)
|
||||
pub xp: i32,
|
||||
pub level: i32,
|
||||
pub avatar_url: Option<String>,
|
||||
@@ -538,7 +538,7 @@ pub struct Recommendation {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub lesson_id: Option<Uuid>,
|
||||
pub priority: String, // "high", "medium", "low"
|
||||
pub priority: String, // "high", "medium", "low" (alta, media, baja)
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
@@ -596,7 +596,7 @@ pub struct AuditLog {
|
||||
}
|
||||
|
||||
|
||||
// Discussion Forums Models
|
||||
// Modelos de Foros de Discusión
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||
pub struct DiscussionThread {
|
||||
pub id: Uuid,
|
||||
@@ -633,7 +633,7 @@ pub struct DiscussionVote {
|
||||
pub organization_id: Uuid,
|
||||
pub post_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub vote_type: String, // 'upvote' or 'downvote'
|
||||
pub vote_type: String, // 'upvote' o 'downvote'
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -646,10 +646,10 @@ pub struct DiscussionSubscription {
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// Response DTOs for Discussion APIs
|
||||
// DTOs de respuesta para las APIs de Discusión
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||
pub struct ThreadWithAuthor {
|
||||
// Thread fields
|
||||
// Campos del hilo
|
||||
pub id: Uuid,
|
||||
pub organization_id: Uuid,
|
||||
pub course_id: Uuid,
|
||||
@@ -662,17 +662,17 @@ pub struct ThreadWithAuthor {
|
||||
pub view_count: i32,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
// Author info
|
||||
// Información del autor
|
||||
pub author_name: String,
|
||||
pub author_avatar: Option<String>,
|
||||
// Aggregated data
|
||||
// Datos agregados
|
||||
pub post_count: i64,
|
||||
pub has_endorsed_answer: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||
pub struct PostWithAuthor {
|
||||
// Post fields
|
||||
// Campos de la publicación
|
||||
pub id: Uuid,
|
||||
pub organization_id: Uuid,
|
||||
pub thread_id: Uuid,
|
||||
@@ -686,14 +686,14 @@ pub struct PostWithAuthor {
|
||||
// Author info
|
||||
pub author_name: String,
|
||||
pub author_avatar: Option<String>,
|
||||
// User interaction
|
||||
pub user_vote: Option<String>, // 'upvote', 'downvote', or null
|
||||
// Nested replies (not from DB, populated manually)
|
||||
// Interacción del usuario
|
||||
pub user_vote: Option<String>, // 'upvote', 'downvote' o nulo
|
||||
// Respuestas anidadas (no desde la BD, pobladas manualmente)
|
||||
#[sqlx(skip)]
|
||||
pub replies: Vec<PostWithAuthor>,
|
||||
}
|
||||
|
||||
// Course Announcements
|
||||
// Anuncios del Curso
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||
pub struct CourseAnnouncement {
|
||||
pub id: Uuid,
|
||||
@@ -711,7 +711,7 @@ pub struct CourseAnnouncement {
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||
pub struct AnnouncementWithAuthor {
|
||||
// Announcement fields
|
||||
// Campos del anuncio
|
||||
pub id: Uuid,
|
||||
pub organization_id: Uuid,
|
||||
pub course_id: Uuid,
|
||||
@@ -728,7 +728,7 @@ pub struct AnnouncementWithAuthor {
|
||||
pub cohort_ids: Option<Vec<Uuid>>,
|
||||
}
|
||||
|
||||
// Student Notes
|
||||
// Notas del Estudiante
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||
pub struct StudentNote {
|
||||
pub id: Uuid,
|
||||
@@ -744,7 +744,7 @@ pub struct SaveNotePayload {
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
// Cohorts & Groups
|
||||
// Cohortes y Grupos
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||
pub struct Cohort {
|
||||
pub id: Uuid,
|
||||
@@ -784,7 +784,7 @@ pub struct StudentGradeReport {
|
||||
pub last_active_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
// Peer Assessment
|
||||
// Evaluación por Pares
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||
pub struct CourseSubmission {
|
||||
pub id: Uuid,
|
||||
@@ -832,7 +832,7 @@ pub struct SubmissionWithReviews {
|
||||
pub average_score: Option<f64>,
|
||||
}
|
||||
|
||||
// Content Libraries
|
||||
// Librerías de Contenido
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||
pub struct LibraryBlock {
|
||||
pub id: Uuid,
|
||||
@@ -894,8 +894,8 @@ pub struct DropoutRisk {
|
||||
pub course_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub risk_level: DropoutRiskLevel,
|
||||
pub score: f32, // 0.0 to 1.0 (Higher means higher risk)
|
||||
pub reasons: Option<serde_json::Value>, // e.g., ["low_grades", "inactivity"]
|
||||
pub score: f32, // 0.0 a 1.0 (Más alto significa mayor riesgo)
|
||||
pub reasons: Option<serde_json::Value>, // ej., ["low_grades", "inactivity"]
|
||||
pub last_calculated_at: DateTime<Utc>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
@@ -921,7 +921,7 @@ mod tests {
|
||||
|
||||
let lesson = Lesson {
|
||||
id: lesson_id,
|
||||
organization_id: course_id, // Use course_id as proxy for org_id in test
|
||||
organization_id: course_id, // Usar course_id como proxi para org_id en la prueba
|
||||
module_id,
|
||||
title: "Test Lesson".to_string(),
|
||||
content_type: "activity".to_string(),
|
||||
@@ -1040,7 +1040,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Advanced Grading / Rubrics ====================
|
||||
// ==================== Calificación Avanzada / Rúbricas ====================
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||
pub struct Rubric {
|
||||
@@ -1112,7 +1112,7 @@ pub struct AssessmentScore {
|
||||
pub feedback: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
// ==================== Learning Sequences / Dependencies ====================
|
||||
// ==================== Secuencias de Aprendizaje / Dependencias ====================
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||
pub struct LessonDependency {
|
||||
@@ -1124,7 +1124,7 @@ pub struct LessonDependency {
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// ==================== Live Learning (Meetings) ====================
|
||||
// ==================== Aprendizaje en Vivo (Reuniones) ====================
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||
pub struct Meeting {
|
||||
@@ -1134,7 +1134,7 @@ pub struct Meeting {
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub provider: String, // "jitsi" | "bbb"
|
||||
pub meeting_id: String, // Room name or external ID
|
||||
pub meeting_id: String, // Nombre de la sala o ID externo
|
||||
pub start_at: DateTime<Utc>,
|
||||
pub duration_minutes: i32,
|
||||
pub join_url: Option<String>,
|
||||
@@ -1143,7 +1143,7 @@ pub struct Meeting {
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// ==================== Student portfolio & Badges ====================
|
||||
// ==================== Portafolio del Estudiante e Insignias ====================
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||
pub struct Badge {
|
||||
@@ -1177,7 +1177,7 @@ pub struct PublicProfile {
|
||||
pub completed_courses_count: i64,
|
||||
}
|
||||
|
||||
// ==================== Test Templates ====================
|
||||
// ==================== Plantillas de Examen ====================
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::Type, Clone, PartialEq)]
|
||||
#[sqlx(type_name = "course_level", rename_all = "lowercase")]
|
||||
@@ -1206,15 +1206,15 @@ pub enum CourseType {
|
||||
#[sqlx(type_name = "test_type")]
|
||||
pub enum TestType {
|
||||
#[sqlx(rename = "CA")]
|
||||
CA, // Continuous Assessment
|
||||
CA, // Evaluación Continua
|
||||
#[sqlx(rename = "MWT")]
|
||||
MWT, // Midterm Written Test
|
||||
MWT, // Examen Escrito de Mitad de Ciclo
|
||||
#[sqlx(rename = "MOT")]
|
||||
MOT, // Midterm Oral Test
|
||||
MOT, // Examen Oral de Mitad de Ciclo
|
||||
#[sqlx(rename = "FOT")]
|
||||
FOT, // Final Oral Test
|
||||
FOT, // Examen Oral Final
|
||||
#[sqlx(rename = "FWT")]
|
||||
FWT, // Final Written Test
|
||||
FWT, // Examen Escrito Final
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TestType {
|
||||
@@ -1233,17 +1233,17 @@ impl std::fmt::Display for TestType {
|
||||
pub struct TestTemplate {
|
||||
pub id: Uuid,
|
||||
pub organization_id: Uuid,
|
||||
pub mysql_course_id: Option<i32>, // Reference to imported MySQL course
|
||||
pub mysql_course_id: Option<i32>, // Referencia al curso de MySQL importado
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub level: Option<CourseLevel>, // Deprecated: use mysql_course_id instead
|
||||
pub course_type: Option<CourseType>, // Deprecated: use mysql_course_id instead
|
||||
pub level: Option<CourseLevel>, // Depreciado: use mysql_course_id en su lugar
|
||||
pub course_type: Option<CourseType>, // Depreciado: use mysql_course_id en su lugar
|
||||
pub test_type: TestType,
|
||||
pub duration_minutes: i32,
|
||||
pub passing_score: i32, // 0-100 percentage
|
||||
pub passing_score: i32, // Porcentaje 0-100
|
||||
pub total_points: i32,
|
||||
pub instructions: Option<String>,
|
||||
pub template_data: serde_json::Value, // Complete test structure with sections and questions
|
||||
pub template_data: serde_json::Value, // Estructura completa del examen con secciones y preguntas
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub is_active: bool,
|
||||
pub usage_count: i32,
|
||||
@@ -1261,7 +1261,7 @@ pub struct TestTemplateSection {
|
||||
pub section_order: i32,
|
||||
pub points: i32,
|
||||
pub instructions: Option<String>,
|
||||
pub section_data: Option<serde_json::Value>, // Section-specific configuration
|
||||
pub section_data: Option<serde_json::Value>, // Configuración específica de la sección
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -1277,7 +1277,7 @@ pub struct TestTemplateQuestion {
|
||||
pub correct_answer: Option<serde_json::Value>, // Can be index, array of indices, or text
|
||||
pub explanation: Option<String>,
|
||||
pub points: i32,
|
||||
pub metadata: Option<serde_json::Value>, // Additional question metadata
|
||||
pub metadata: Option<serde_json::Value>, // Metadatos adicionales de la pregunta
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -1285,9 +1285,9 @@ pub struct TestTemplateQuestion {
|
||||
pub struct CreateTestTemplatePayload {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub mysql_course_id: Option<i32>, // Reference to imported MySQL course (preferred)
|
||||
pub level: Option<CourseLevel>, // Fallback if mysql_course_id not provided
|
||||
pub course_type: Option<CourseType>, // Fallback if mysql_course_id not provided
|
||||
pub mysql_course_id: Option<i32>, // Referencia al curso de MySQL importado (preferido)
|
||||
pub level: Option<CourseLevel>, // Alternativa si no se proporciona mysql_course_id
|
||||
pub course_type: Option<CourseType>, // Alternativa si no se proporciona mysql_course_id
|
||||
pub test_type: TestType,
|
||||
pub duration_minutes: i32,
|
||||
pub passing_score: i32,
|
||||
@@ -1327,7 +1327,7 @@ pub struct ApplyTemplatePayload {
|
||||
pub grading_category_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
// ==================== Question Bank ====================
|
||||
// ==================== Banco de Preguntas ====================
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Copy, sqlx::Type, PartialEq)]
|
||||
#[sqlx(type_name = "question_bank_type")]
|
||||
@@ -1389,7 +1389,7 @@ pub struct QuestionBank {
|
||||
pub points: i32,
|
||||
pub difficulty: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub skill_assessed: Option<String>, // reading, listening, speaking, writing
|
||||
pub skill_assessed: Option<String>, // lectura (reading), escucha (listening), habla (speaking), escritura (writing)
|
||||
pub source: Option<String>,
|
||||
pub source_metadata: Option<serde_json::Value>,
|
||||
pub imported_mysql_id: Option<i32>,
|
||||
@@ -1401,10 +1401,10 @@ pub struct QuestionBank {
|
||||
pub created_by: Option<Uuid>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
pub embedding: Option<String>, // PGVector embedding for semantic search
|
||||
pub embedding: Option<String>, // Embedding de PGVector para búsqueda semántica
|
||||
pub embedding_updated_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub source_asset_id: Option<Uuid>, // audio/video asset that originated this RAG chunk
|
||||
pub unit_number: Option<i32>, // syllabus unit number from ZIP folder structure
|
||||
pub source_asset_id: Option<Uuid>, // Activo de audio/video que originó este fragmento RAG
|
||||
pub unit_number: Option<i32>, // Número de unidad del sílabo desde la estructura de carpetas ZIP
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
@@ -1419,7 +1419,7 @@ pub struct CreateQuestionBankPayload {
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub media_url: Option<String>,
|
||||
pub media_type: Option<String>,
|
||||
pub skill_assessed: Option<String>, // reading, listening, speaking, writing
|
||||
pub skill_assessed: Option<String>, // lectura (reading), escucha (listening), habla (speaking), escritura (writing)
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
@@ -1453,8 +1453,8 @@ pub struct QuestionBankFilters {
|
||||
pub has_audio: Option<bool>,
|
||||
}
|
||||
|
||||
// ==================== AUDIO RESPONSE MODELS ====================
|
||||
// For speaking practice exercises with AI + Teacher evaluation
|
||||
// ==================== MODELOS DE RESPUESTA DE AUDIO ====================
|
||||
// Para ejercicios de práctica de habla con evaluación de IA + Profesor
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! AI Token Limit Utilities
|
||||
//! Provides functions to check and enforce monthly token limits
|
||||
//! Utilidades de Límite de Tokens de IA
|
||||
//! Proporciona funciones para verificar y hacer cumplir los límites mensuales de tokens
|
||||
|
||||
use sqlx::{PgPool, FromRow};
|
||||
use uuid::Uuid;
|
||||
@@ -23,16 +23,16 @@ pub struct TokenLimitError {
|
||||
pub reset_date: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Verify if user has available tokens for AI operations
|
||||
/// Verificar si el usuario tiene tokens disponibles para operaciones de IA
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `pool` - Database connection pool
|
||||
/// * `user_id` - User UUID
|
||||
/// * `estimated_tokens` - Estimated tokens for this operation (default: 1000)
|
||||
/// # Argumentos
|
||||
/// * `pool` - Pool de conexión a la base de datos
|
||||
/// * `user_id` - UUID del usuario
|
||||
/// * `estimated_tokens` - Tokens estimados para esta operación (por defecto: 1000)
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(TokenLimitCheck)` - User has available tokens
|
||||
/// * `Err(TokenLimitError)` - User exceeded limit
|
||||
/// # Retornos
|
||||
/// * `Ok(TokenLimitCheck)` - El usuario tiene tokens disponibles
|
||||
/// * `Err(TokenLimitError)` - El usuario excedió el límite
|
||||
pub async fn check_ai_token_limit(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
@@ -46,9 +46,9 @@ pub async fn check_ai_token_limit(
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to check token limit: {}", e);
|
||||
tracing::error!("Error al verificar el límite de tokens: {}", e);
|
||||
TokenLimitError {
|
||||
error: "Failed to verify token limit".to_string(),
|
||||
error: "Error al verificar el límite de tokens".to_string(),
|
||||
monthly_limit: 0,
|
||||
used_tokens: 0,
|
||||
remaining_tokens: 0,
|
||||
@@ -58,7 +58,7 @@ pub async fn check_ai_token_limit(
|
||||
|
||||
if !result.has_available_tokens {
|
||||
tracing::warn!(
|
||||
"User {} exceeded token limit: {}/{} (reset: {})",
|
||||
"El usuario {} excedió el límite de tokens: {}/{} (reinicio: {})",
|
||||
user_id,
|
||||
result.used_tokens,
|
||||
result.monthly_limit,
|
||||
@@ -67,7 +67,7 @@ pub async fn check_ai_token_limit(
|
||||
|
||||
return Err(TokenLimitError {
|
||||
error: format!(
|
||||
"Monthly AI token limit exceeded. Used: {} / Limit: {}. Reset date: {}",
|
||||
"Límite mensual de tokens de IA excedido. Usados: {} / Límite: {}. Fecha de reinicio: {}",
|
||||
result.used_tokens,
|
||||
result.monthly_limit,
|
||||
result.reset_date.format("%Y-%m-%d %H:%M UTC")
|
||||
@@ -82,7 +82,7 @@ pub async fn check_ai_token_limit(
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Get formatted token usage message for API responses
|
||||
/// Obtener el mensaje formateado de uso de tokens para las respuestas de la API
|
||||
pub fn format_token_limit_message(used: i64, limit: i32, reset: DateTime<Utc>) -> String {
|
||||
let percentage = (used as f64 / limit as f64 * 100.0).round();
|
||||
format!(
|
||||
|
||||
@@ -23,7 +23,7 @@ impl WebhookService {
|
||||
.await {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to fetch webhooks for org {}: {}", org_id, e);
|
||||
tracing::error!("Error al obtener los webhooks para la org {}: {}", org_id, e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -54,7 +54,7 @@ impl WebhookService {
|
||||
Ok(response) => {
|
||||
if !response.status().is_success() {
|
||||
tracing::warn!(
|
||||
"Webhook delivery to {} (event: {}) failed with status {}",
|
||||
"La entrega del webhook a {} (evento: {}) falló con el estado {}",
|
||||
url,
|
||||
event_type,
|
||||
response.status()
|
||||
@@ -63,7 +63,7 @@ impl WebhookService {
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Failed to deliver webhook to {} (event: {}): {}",
|
||||
"Error al entregar el webhook a {} (evento: {}): {}",
|
||||
url,
|
||||
event_type,
|
||||
e
|
||||
|
||||
Reference in New Issue
Block a user