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
+7
View File
@@ -29,3 +29,10 @@ MP_WEBHOOK_SECRET=
MP_BACK_URL_SUCCESS=http://localhost:3003/payments/success
MP_BACK_URL_FAILURE=http://localhost:3003/payments/failure
MP_NOTIFICATION_URL=
# External MySQL Integration
MYSQL_DATABASE_URL=mysql://db_user:db_password@localhost:3306/external_database_name
EXTERNAL_TABLE_GRADES=notas
EXTERNAL_GRADE_SCALE_MIN=1
EXTERNAL_GRADE_SCALE_MAX=7
EXTERNAL_ID_TIPO_NOTA=1
Generated
+38
View File
@@ -1489,6 +1489,7 @@ dependencies = [
"tracing",
"tracing-subscriber",
"urlencoding",
"utoipa",
"uuid",
]
@@ -2056,6 +2057,18 @@ dependencies = [
"syn",
]
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.13"
@@ -3334,6 +3347,31 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utoipa"
version = "5.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993"
dependencies = [
"indexmap 2.12.1",
"serde",
"serde_json",
"utoipa-gen",
]
[[package]]
name = "utoipa-gen"
version = "5.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b"
dependencies = [
"proc-macro2",
"quote",
"regex",
"syn",
"uuid",
]
[[package]]
name = "uuid"
version = "1.19.0"
+2 -1
View File
@@ -16,7 +16,7 @@ axum = { version = "0.8", features = ["multipart"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid"] }
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "mysql", "chrono", "uuid"] }
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4", "serde"] }
tracing = "0.1"
@@ -31,3 +31,4 @@ sha2 = "0.10"
hex = "0.4"
openidconnect = { version = "3.5", features = ["reqwest"] }
anyhow = "1.0"
utoipa = { version = "5", features = ["axum_extras", "chrono", "uuid"] }
+26 -2
View File
@@ -213,6 +213,7 @@ pub struct GradingPayload {
pub name: String,
pub weight: i32,
pub drop_count: i32,
pub tipo_nota_id: Option<i32>, // idTipoNota from tiponota table
}
#[derive(Deserialize)]
@@ -1441,8 +1442,8 @@ pub async fn create_grading_category(
Json(payload): Json<GradingPayload>,
) -> Result<Json<common::models::GradingCategory>, (StatusCode, String)> {
let category = sqlx::query_as::<_, common::models::GradingCategory>(
"INSERT INTO grading_categories (organization_id, course_id, name, weight, drop_count)
VALUES ($1, $2, $3, $4, $5)
"INSERT INTO grading_categories (organization_id, course_id, name, weight, drop_count, tipo_nota_id)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *",
)
.bind(org_ctx.id)
@@ -1450,6 +1451,7 @@ pub async fn create_grading_category(
.bind(payload.name)
.bind(payload.weight)
.bind(payload.drop_count)
.bind(payload.tipo_nota_id)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
@@ -1457,6 +1459,28 @@ pub async fn create_grading_category(
Ok(Json(category))
}
// Tipo Nota (Assessment type catalog)
#[derive(Debug, serde::Serialize, sqlx::FromRow)]
pub struct TipoNota {
pub id_tipo_nota: i32,
pub nombre: String,
pub descripcion: Option<String>,
pub activo: i16,
}
pub async fn get_tipo_nota(
State(pool): State<PgPool>,
) -> Result<Json<Vec<TipoNota>>, StatusCode> {
let tipos = sqlx::query_as::<_, TipoNota>(
"SELECT * FROM tipo_nota WHERE activo = 1 ORDER BY id_tipo_nota"
)
.fetch_all(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(tipos))
}
pub async fn delete_grading_category(
Org(org_ctx): Org,
State(pool): State<PgPool>,
+1
View File
@@ -154,6 +154,7 @@ async fn main() {
"/courses/{id}/grading",
get(handlers::get_grading_categories),
)
.route("/tipo-nota", get(handlers::get_tipo_nota))
.route("/auth/me", get(handlers::get_me))
.route(
"/users",
+1
View File
@@ -22,3 +22,4 @@ jsonwebtoken.workspace = true
reqwest = { version = "0.12", features = ["json"] }
urlencoding = "2.1"
base64 = "0.22"
utoipa.workspace = true
@@ -0,0 +1,2 @@
-- Add external_id to enrollments to map idDetalleContrato from the external system
ALTER TABLE enrollments ADD COLUMN IF NOT EXISTS external_id INTEGER;
@@ -0,0 +1,24 @@
-- Mirror the external tiponota table in Postgres for consistency
CREATE TABLE IF NOT EXISTS tipo_nota (
id_tipo_nota INTEGER PRIMARY KEY,
nombre VARCHAR(60) NOT NULL,
descripcion VARCHAR(60),
activo SMALLINT NOT NULL DEFAULT 1
);
-- Seed with the same values as the external MySQL database
INSERT INTO tipo_nota (id_tipo_nota, nombre, descripcion, activo) VALUES
(1, 'CA', 'Continuous Assessment', 1),
(2, 'MWT', 'Midterm Written Test', 1),
(3, 'MOT', 'Midterm Oral Test', 1),
(4, 'SAS', 'Self Assessment Student', 0),
(5, 'FOT', 'Final Oral Test', 1),
(6, 'FWT', 'Final written test', 1)
ON CONFLICT (id_tipo_nota) DO UPDATE SET
nombre = EXCLUDED.nombre,
descripcion = EXCLUDED.descripcion,
activo = EXCLUDED.activo;
-- Add tipo_nota_id to grading_categories so each category maps to an assessment type
ALTER TABLE grading_categories
ADD COLUMN IF NOT EXISTS tipo_nota_id INTEGER REFERENCES tipo_nota(id_tipo_nota);
+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() {}
+2
View File
@@ -62,6 +62,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 created_at: DateTime<Utc>,
}
@@ -126,6 +127,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 enrolled_at: DateTime<Utc>,
}