feat: Implement external MySQL integration for LMS enrollments and grade synchronization, including external_id and tipo_nota support.

This commit is contained in:
2026-02-27 09:20:35 -03:00
parent e5373919c9
commit bbef932776
13 changed files with 485 additions and 5 deletions
+26
View File
@@ -0,0 +1,26 @@
use sqlx::{MySql, Pool};
use std::env;
pub type MySqlPool = Pool<MySql>;
pub async fn init_mysql_pool() -> Option<MySqlPool> {
if let Ok(url) = env::var("MYSQL_DATABASE_URL") {
match sqlx::mysql::MySqlPoolOptions::new()
.max_connections(5)
.connect(&url)
.await
{
Ok(pool) => {
tracing::info!("Connected to external MySQL database");
Some(pool)
}
Err(e) => {
tracing::error!("Failed to connect to external MySQL database: {}", e);
None
}
}
} else {
tracing::info!("MYSQL_DATABASE_URL not set, skipping external database integration");
None
}
}
+86 -1
View File
@@ -3,6 +3,7 @@ use axum::{
extract::{Multipart, Path, Query, State},
http::StatusCode,
response::IntoResponse,
Extension,
};
use bcrypt::{DEFAULT_COST, hash, verify};
use common::auth::{Claims, create_jwt};
@@ -12,6 +13,7 @@ use common::models::{
Module, Notification, Organization, RecommendationResponse, User, UserResponse,
LessonDependency,
};
use crate::external_db::MySqlPool;
pub async fn get_me(
claims: common::auth::Claims,
@@ -295,6 +297,9 @@ pub async fn enroll_user(
let course_id = Uuid::parse_str(course_id_str).map_err(|_| StatusCode::BAD_REQUEST)?;
let user_id = claims.sub;
// Optional: ID from the external system (idDetalleContrato)
let external_id: Option<i32> = payload.get("external_id").and_then(|v| v.as_i64()).map(|v| v as i32);
// 1. Check if course exists and get its price
let course_info: (f64, String) =
sqlx::query_as("SELECT price, currency FROM courses WHERE id = $1")
@@ -357,6 +362,19 @@ pub async fn enroll_user(
StatusCode::INTERNAL_SERVER_ERROR
})?;
// If an external_id was provided, persist it on the enrollment now
if let Some(ext_id) = external_id {
sqlx::query("UPDATE enrollments SET external_id = $1 WHERE id = $2")
.bind(ext_id)
.bind(enrollment.id)
.execute(&mut *tx)
.await
.map_err(|e: sqlx::Error| {
tracing::error!("Failed to set external_id on enrollment: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
}
tx.commit()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@@ -370,7 +388,8 @@ pub async fn enroll_user(
&serde_json::json!({
"user_id": user_id,
"course_id": course_id,
"enrollment_id": enrollment.id
"enrollment_id": enrollment.id,
"external_id": external_id
}),
)
.await;
@@ -1092,6 +1111,7 @@ pub async fn submit_lesson_score(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Extension(mysql_pool): Extension<Option<MySqlPool>>,
headers: axum::http::HeaderMap,
Json(payload): Json<GradeSubmissionPayload>,
) -> Result<Json<common::models::UserGrade>, (StatusCode, String)> {
@@ -1169,6 +1189,71 @@ pub async fn submit_lesson_score(
.await
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// 3.1 Synchronize with external MySQL if available
if let Some(mysql_pool) = mysql_pool {
// Fetch the external_id (idDetalleContrato) from the enrollment record
let external_id: Option<i32> = sqlx::query_scalar(
"SELECT external_id FROM enrollments WHERE user_id = $1 AND course_id = $2"
)
.bind(payload.user_id)
.bind(payload.course_id)
.fetch_optional(&pool)
.await
.unwrap_or(None)
.flatten();
if let Some(id_detalle_contrato) = external_id {
let table = env::var("EXTERNAL_TABLE_GRADES").unwrap_or_else(|_| "notas".to_string());
// Convert score from 0.0-1.0 to integer scale (1-7 Chilean grades by default)
let scale_max: f32 = env::var("EXTERNAL_GRADE_SCALE_MAX")
.ok().and_then(|v| v.parse().ok()).unwrap_or(7.0);
let scale_min: f32 = env::var("EXTERNAL_GRADE_SCALE_MIN")
.ok().and_then(|v| v.parse().ok()).unwrap_or(1.0);
let nota = (scale_min + (payload.score * (scale_max - scale_min))).round() as i32;
// Resolve idTipoNota from the lesson's grading category (tipo_nota_id),
// falling back to the EXTERNAL_ID_TIPO_NOTA env var.
let tipo_nota_from_category: Option<i32> = sqlx::query_scalar(
"SELECT gc.tipo_nota_id FROM grading_categories gc \
JOIN lessons l ON l.grading_category_id = gc.id \
WHERE l.id = $1"
)
.bind(payload.lesson_id)
.fetch_optional(&pool)
.await
.unwrap_or(None)
.flatten();
let id_tipo_nota: i32 = tipo_nota_from_category
.or_else(|| {
env::var("EXTERNAL_ID_TIPO_NOTA").ok().and_then(|v| v.parse().ok())
})
.unwrap_or(1); // Default: CA (Continuous Assessment)
let query = format!(
"INSERT INTO {} (idDetalleContrato, FechaIngresoNota, idTipoNota, Nota, Activo) VALUES (?, NOW(), ?, ?, 1)",
table
);
let _ = sqlx::query(&query)
.bind(id_detalle_contrato)
.bind(id_tipo_nota)
.bind(nota)
.execute(&mysql_pool)
.await
.map_err(|e| {
tracing::error!("Failed to sync grade to external MySQL (notas): {}", e);
});
} else {
tracing::warn!(
"No external_id found for enrollment (user_id={}, course_id={}). Grade not synced to MySQL.",
payload.user_id,
payload.course_id
);
}
}
tx.commit()
.await
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+27 -1
View File
@@ -11,16 +11,20 @@ mod jwks;
mod predictive;
mod live;
mod portfolio;
mod external_db;
mod openapi;
use axum::{
Router, middleware,
routing::{delete, get, post, put},
response::Html,
};
use dotenvy::dotenv;
use sqlx::postgres::PgPoolOptions;
use std::env;
use std::net::SocketAddr;
use tower_http::cors::{Any, CorsLayer};
use utoipa::OpenApi;
#[tokio::main]
async fn main() {
@@ -34,6 +38,8 @@ async fn main() {
.await
.expect("Failed to connect to database");
let mysql_pool = external_db::init_mysql_pool().await;
// Run migrations automatically
sqlx::migrate!("./migrations")
.run(&pool)
@@ -223,6 +229,25 @@ async fn main() {
));
let public_routes = Router::new()
.route("/api-docs/openapi.json", get(|| async {
axum::Json(openapi::ApiDoc::openapi())
}))
.route("/scalar", get(|| async {
Html(r#"
<!doctype html>
<html>
<head>
<title>OpenCCB LMS API</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<script id="api-reference" data-url="/api-docs/openapi.json"></script>
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
</body>
</html>
"#)
}))
.route("/catalog", get(handlers::get_course_catalog))
.route("/ingest", post(handlers::ingest_course))
.route("/auth/register", post(handlers::register))
@@ -237,7 +262,8 @@ async fn main() {
.route("/lti/deep-linking/response", post(lti::lti_deep_linking_response))
.merge(protected_routes)
.layer(cors)
.with_state(pool);
.with_state(pool)
.layer(axum::Extension(mysql_pool));
let addr = SocketAddr::from(([0, 0, 0, 0], 3002));
tracing::info!("LMS Service listening on {}", addr);
+243
View File
@@ -0,0 +1,243 @@
#![allow(dead_code)]
use utoipa::OpenApi;
// ─── Modelos de Esquema ───────────────────────────────────────────────────────
/// Petición de inscripción externa. Se usa cuando la otra plataforma inscribe a un estudiante.
#[derive(utoipa::ToSchema, serde::Deserialize, serde::Serialize)]
pub struct EnrollRequest {
/// UUID del curso al cual inscribirse
pub course_id: String,
/// idDetalleContrato del sistema externo — requerido para sincronizar notas a MySQL
pub external_id: Option<i32>,
}
/// Payload de envío de nota
#[derive(utoipa::ToSchema, serde::Deserialize, serde::Serialize)]
pub struct GradeSubmissionRequest {
pub user_id: String,
pub course_id: String,
pub lesson_id: String,
/// Puntaje entre 0.0 y 1.0 — se convertirá a la escala local (ej: 1-7)
pub score: f32,
pub metadata: Option<serde_json::Value>,
}
/// Categoría de Evaluación (Ponderación)
#[derive(utoipa::ToSchema, serde::Serialize)]
pub struct GradingCategorySchema {
pub id: String,
pub course_id: String,
pub name: String,
/// Ponderación como porcentaje (0-100)
pub weight: i32,
pub drop_count: i32,
/// idTipoNota del catálogo tiponota (ej. 1=CA, 2=MWT, 6=FWT)
pub tipo_nota_id: Option<i32>,
pub created_at: String,
}
/// Lección dentro de un módulo de curso
#[derive(utoipa::ToSchema, serde::Serialize)]
pub struct LessonSchema {
pub id: String,
pub module_id: String,
pub title: String,
pub content_type: String,
pub position: i32,
pub is_graded: bool,
pub grading_category_id: Option<String>,
pub created_at: String,
}
/// Módulo de curso
#[derive(utoipa::ToSchema, serde::Serialize)]
pub struct ModuleSchema {
pub id: String,
pub title: String,
pub position: i32,
pub created_at: String,
}
/// Organización
#[derive(utoipa::ToSchema, serde::Serialize)]
pub struct OrgSchema {
pub id: String,
pub name: String,
pub domain: String,
}
/// Curso
#[derive(utoipa::ToSchema, serde::Serialize)]
pub struct CourseSchema {
pub id: String,
pub title: String,
pub description: Option<String>,
pub price: f64,
pub currency: String,
pub status: String,
pub created_at: String,
}
/// Payload completo de ingesta de curso — usado por el sistema externo para crear/actualizar cursos
#[derive(utoipa::ToSchema, serde::Serialize)]
pub struct IngestCourseRequest {
pub organization: OrgSchema,
pub course: CourseSchema,
pub grading_categories: Vec<GradingCategorySchema>,
pub modules: Vec<ModuleSchema>,
pub lessons: Vec<LessonSchema>,
pub instructors: Vec<String>,
}
/// Entrada del catálogo Tipo Nota
#[derive(utoipa::ToSchema, serde::Serialize)]
pub struct TipoNotaSchema {
pub id_tipo_nota: i32,
pub nombre: String,
pub descripcion: Option<String>,
/// 1 = activo, 0 = inactivo
pub activo: i16,
}
// ─── Definición de la API ─────────────────────────────────────────────────────
#[derive(OpenApi)]
#[openapi(
info(
title = "OpenCCB LMS — API de Integración",
version = "1.0.0",
description = "API para integrar plataformas externas: creación de cursos, inscripción de alumnos y sincronización de notas."
),
paths(
ingest_course,
enroll_user,
submit_lesson_score,
get_tipo_nota,
get_course_outline,
),
components(
schemas(
IngestCourseRequest,
OrgSchema,
CourseSchema,
ModuleSchema,
LessonSchema,
GradingCategorySchema,
EnrollRequest,
GradeSubmissionRequest,
TipoNotaSchema,
)
),
tags(
(name = "Cursos", description = "Creación y lectura de cursos"),
(name = "Inscripciones", description = "Inscripción de alumnos desde plataforma externa"),
(name = "Notas", description = "Envío de notas y sincronización a MySQL"),
(name = "Catálogos", description = "Catálogos de datos de referencia"),
)
)]
pub struct ApiDoc;
// ─── Stubs de Rutas — proveen documentación para handlers definidos en handlers.rs ─
/// **Crear o actualizar un curso (ingesta externa)**
///
/// Llama a este endpoint cuando un curso es creado o actualizado en la plataforma externa.
/// Creará o actualizará el curso, sus módulos, lecciones, ponderaciones e instructores en OpenCCB.
#[utoipa::path(
post,
path = "/ingest",
tag = "Cursos",
request_body = IngestCourseRequest,
responses(
(status = 200, description = "Curso ingestada exitosamente"),
(status = 400, description = "Payload inválido o JSON mal formado"),
(status = 500, description = "Error interno del servidor"),
)
)]
pub fn ingest_course() {}
/// **Inscribir un alumno en un curso**
///
/// Inscribe un estudiante en un curso específico. Debes incluir `external_id` (idDetalleContrato)
/// para habilitar la sincronización automática de notas a la base de datos externa MySQL (tabla `notas`).
///
/// Requiere un token JWT válido en la cabecera Authorization (correspondiente al alumno).
#[utoipa::path(
post,
path = "/enroll",
tag = "Inscripciones",
security(("Bearer" = [])),
request_body = EnrollRequest,
responses(
(status = 200, description = "Inscripción exitosa"),
(status = 400, description = "Falta el course_id o es inválido"),
(status = 402, description = "Se requiere pago para cursos de pago"),
(status = 500, description = "Error interno del servidor"),
)
)]
pub fn enroll_user() {}
/// **Enviar nota de una lección**
///
/// Envía el puntaje de un alumno para una lección calificada. La nota se guarda
/// localmente en PostgreSQL y se sincroniza automáticamente a MySQL en la tabla `notas`
/// usando el `idDetalleContrato` guardado al momento de la inscripción.
///
/// El campo `score` debe estar entre 0.0 y 1.0 — se convertirá a la escala
/// entera configurada (por defecto a escala chilena 17).
#[utoipa::path(
post,
path = "/grades",
tag = "Notas",
security(("Bearer" = [])),
request_body = GradeSubmissionRequest,
responses(
(status = 200, description = "Nota ingresada y sincronizada exitosamente"),
(status = 403, description = "Cantidad máxima de intentos alcanzada"),
(status = 500, description = "Error interno del servidor"),
)
)]
pub fn submit_lesson_score() {}
/// **Obtener estructura del curso (outline)**
///
/// Devuelve el contenido completo del curso incluyendo módulos, lecciones y
/// las categorías de evaluación (ponderaciones). Útil para verificar la
/// estructura luego de llamar al endpoint de ingesta.
#[utoipa::path(
get,
path = "/courses/{id}/outline",
tag = "Cursos",
security(("Bearer" = [])),
params(
("id" = String, Path, description = "UUID del Curso")
),
responses(
(status = 200, description = "Estructura del curso obtenida exitosamente"),
(status = 404, description = "Curso no encontrado"),
)
)]
pub fn get_course_outline() {}
/// **Obtener catálogo Tipo Nota**
///
/// Devuelve la lista de tipos de evaluación activos (`tiponota`).
/// Utiliza el valor `id_tipo_nota` al crear categorías de evaluación mediante el endpoint `/ingest`.
///
/// | 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 |
#[utoipa::path(
get,
path = "/tipo-nota",
tag = "Catálogos",
responses(
(status = 200, description = "Lista de tipos de evaluación activos", body = Vec<TipoNotaSchema>),
)
)]
pub fn get_tipo_nota() {}