feat: Implement external MySQL integration for LMS enrollments and grade synchronization, including external_id and tipo_nota support.
This commit is contained in:
@@ -29,3 +29,10 @@ MP_WEBHOOK_SECRET=
|
|||||||
MP_BACK_URL_SUCCESS=http://localhost:3003/payments/success
|
MP_BACK_URL_SUCCESS=http://localhost:3003/payments/success
|
||||||
MP_BACK_URL_FAILURE=http://localhost:3003/payments/failure
|
MP_BACK_URL_FAILURE=http://localhost:3003/payments/failure
|
||||||
MP_NOTIFICATION_URL=
|
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
@@ -1489,6 +1489,7 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
|
"utoipa",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2056,6 +2057,18 @@ dependencies = [
|
|||||||
"syn",
|
"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]]
|
[[package]]
|
||||||
name = "regex-automata"
|
name = "regex-automata"
|
||||||
version = "0.4.13"
|
version = "0.4.13"
|
||||||
@@ -3334,6 +3347,31 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
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]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.19.0"
|
version = "1.19.0"
|
||||||
|
|||||||
+2
-1
@@ -16,7 +16,7 @@ axum = { version = "0.8", features = ["multipart"] }
|
|||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
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"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
@@ -31,3 +31,4 @@ sha2 = "0.10"
|
|||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
openidconnect = { version = "3.5", features = ["reqwest"] }
|
openidconnect = { version = "3.5", features = ["reqwest"] }
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
|
utoipa = { version = "5", features = ["axum_extras", "chrono", "uuid"] }
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ pub struct GradingPayload {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub weight: i32,
|
pub weight: i32,
|
||||||
pub drop_count: i32,
|
pub drop_count: i32,
|
||||||
|
pub tipo_nota_id: Option<i32>, // idTipoNota from tiponota table
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -1441,8 +1442,8 @@ pub async fn create_grading_category(
|
|||||||
Json(payload): Json<GradingPayload>,
|
Json(payload): Json<GradingPayload>,
|
||||||
) -> Result<Json<common::models::GradingCategory>, (StatusCode, String)> {
|
) -> Result<Json<common::models::GradingCategory>, (StatusCode, String)> {
|
||||||
let category = sqlx::query_as::<_, common::models::GradingCategory>(
|
let category = sqlx::query_as::<_, common::models::GradingCategory>(
|
||||||
"INSERT INTO grading_categories (organization_id, course_id, name, weight, drop_count)
|
"INSERT INTO grading_categories (organization_id, course_id, name, weight, drop_count, tipo_nota_id)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING *",
|
RETURNING *",
|
||||||
)
|
)
|
||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
@@ -1450,6 +1451,7 @@ pub async fn create_grading_category(
|
|||||||
.bind(payload.name)
|
.bind(payload.name)
|
||||||
.bind(payload.weight)
|
.bind(payload.weight)
|
||||||
.bind(payload.drop_count)
|
.bind(payload.drop_count)
|
||||||
|
.bind(payload.tipo_nota_id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
@@ -1457,6 +1459,28 @@ pub async fn create_grading_category(
|
|||||||
Ok(Json(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(
|
pub async fn delete_grading_category(
|
||||||
Org(org_ctx): Org,
|
Org(org_ctx): Org,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ async fn main() {
|
|||||||
"/courses/{id}/grading",
|
"/courses/{id}/grading",
|
||||||
get(handlers::get_grading_categories),
|
get(handlers::get_grading_categories),
|
||||||
)
|
)
|
||||||
|
.route("/tipo-nota", get(handlers::get_tipo_nota))
|
||||||
.route("/auth/me", get(handlers::get_me))
|
.route("/auth/me", get(handlers::get_me))
|
||||||
.route(
|
.route(
|
||||||
"/users",
|
"/users",
|
||||||
|
|||||||
@@ -22,3 +22,4 @@ jsonwebtoken.workspace = true
|
|||||||
reqwest = { version = "0.12", features = ["json"] }
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
urlencoding = "2.1"
|
urlencoding = "2.1"
|
||||||
base64 = "0.22"
|
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);
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ use axum::{
|
|||||||
extract::{Multipart, Path, Query, State},
|
extract::{Multipart, Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
|
Extension,
|
||||||
};
|
};
|
||||||
use bcrypt::{DEFAULT_COST, hash, verify};
|
use bcrypt::{DEFAULT_COST, hash, verify};
|
||||||
use common::auth::{Claims, create_jwt};
|
use common::auth::{Claims, create_jwt};
|
||||||
@@ -12,6 +13,7 @@ use common::models::{
|
|||||||
Module, Notification, Organization, RecommendationResponse, User, UserResponse,
|
Module, Notification, Organization, RecommendationResponse, User, UserResponse,
|
||||||
LessonDependency,
|
LessonDependency,
|
||||||
};
|
};
|
||||||
|
use crate::external_db::MySqlPool;
|
||||||
|
|
||||||
pub async fn get_me(
|
pub async fn get_me(
|
||||||
claims: common::auth::Claims,
|
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 course_id = Uuid::parse_str(course_id_str).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||||
let user_id = claims.sub;
|
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
|
// 1. Check if course exists and get its price
|
||||||
let course_info: (f64, String) =
|
let course_info: (f64, String) =
|
||||||
sqlx::query_as("SELECT price, currency FROM courses WHERE id = $1")
|
sqlx::query_as("SELECT price, currency FROM courses WHERE id = $1")
|
||||||
@@ -357,6 +362,19 @@ pub async fn enroll_user(
|
|||||||
StatusCode::INTERNAL_SERVER_ERROR
|
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()
|
tx.commit()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
@@ -370,7 +388,8 @@ pub async fn enroll_user(
|
|||||||
&serde_json::json!({
|
&serde_json::json!({
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"course_id": course_id,
|
"course_id": course_id,
|
||||||
"enrollment_id": enrollment.id
|
"enrollment_id": enrollment.id,
|
||||||
|
"external_id": external_id
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -1092,6 +1111,7 @@ pub async fn submit_lesson_score(
|
|||||||
Org(org_ctx): Org,
|
Org(org_ctx): Org,
|
||||||
claims: Claims,
|
claims: Claims,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
|
Extension(mysql_pool): Extension<Option<MySqlPool>>,
|
||||||
headers: axum::http::HeaderMap,
|
headers: axum::http::HeaderMap,
|
||||||
Json(payload): Json<GradeSubmissionPayload>,
|
Json(payload): Json<GradeSubmissionPayload>,
|
||||||
) -> Result<Json<common::models::UserGrade>, (StatusCode, String)> {
|
) -> Result<Json<common::models::UserGrade>, (StatusCode, String)> {
|
||||||
@@ -1169,6 +1189,71 @@ pub async fn submit_lesson_score(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.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()
|
tx.commit()
|
||||||
.await
|
.await
|
||||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|||||||
@@ -11,16 +11,20 @@ mod jwks;
|
|||||||
mod predictive;
|
mod predictive;
|
||||||
mod live;
|
mod live;
|
||||||
mod portfolio;
|
mod portfolio;
|
||||||
|
mod external_db;
|
||||||
|
mod openapi;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Router, middleware,
|
Router, middleware,
|
||||||
routing::{delete, get, post, put},
|
routing::{delete, get, post, put},
|
||||||
|
response::Html,
|
||||||
};
|
};
|
||||||
use dotenvy::dotenv;
|
use dotenvy::dotenv;
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use tower_http::cors::{Any, CorsLayer};
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
@@ -34,6 +38,8 @@ async fn main() {
|
|||||||
.await
|
.await
|
||||||
.expect("Failed to connect to database");
|
.expect("Failed to connect to database");
|
||||||
|
|
||||||
|
let mysql_pool = external_db::init_mysql_pool().await;
|
||||||
|
|
||||||
// Run migrations automatically
|
// Run migrations automatically
|
||||||
sqlx::migrate!("./migrations")
|
sqlx::migrate!("./migrations")
|
||||||
.run(&pool)
|
.run(&pool)
|
||||||
@@ -223,6 +229,25 @@ async fn main() {
|
|||||||
));
|
));
|
||||||
|
|
||||||
let public_routes = Router::new()
|
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("/catalog", get(handlers::get_course_catalog))
|
||||||
.route("/ingest", post(handlers::ingest_course))
|
.route("/ingest", post(handlers::ingest_course))
|
||||||
.route("/auth/register", post(handlers::register))
|
.route("/auth/register", post(handlers::register))
|
||||||
@@ -237,7 +262,8 @@ async fn main() {
|
|||||||
.route("/lti/deep-linking/response", post(lti::lti_deep_linking_response))
|
.route("/lti/deep-linking/response", post(lti::lti_deep_linking_response))
|
||||||
.merge(protected_routes)
|
.merge(protected_routes)
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
.with_state(pool);
|
.with_state(pool)
|
||||||
|
.layer(axum::Extension(mysql_pool));
|
||||||
|
|
||||||
let addr = SocketAddr::from(([0, 0, 0, 0], 3002));
|
let addr = SocketAddr::from(([0, 0, 0, 0], 3002));
|
||||||
tracing::info!("LMS Service listening on {}", addr);
|
tracing::info!("LMS Service listening on {}", addr);
|
||||||
|
|||||||
@@ -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 1–7).
|
||||||
|
#[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() {}
|
||||||
@@ -62,6 +62,7 @@ pub struct GradingCategory {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub weight: i32, // 0-100
|
pub weight: i32, // 0-100
|
||||||
pub drop_count: i32,
|
pub drop_count: i32,
|
||||||
|
pub tipo_nota_id: Option<i32>, // Maps to idTipoNota in external MySQL system
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +127,7 @@ pub struct Enrollment {
|
|||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
pub organization_id: Uuid,
|
pub organization_id: Uuid,
|
||||||
pub course_id: Uuid,
|
pub course_id: Uuid,
|
||||||
|
pub external_id: Option<i32>, // idDetalleContrato from the external system
|
||||||
pub enrolled_at: DateTime<Utc>,
|
pub enrolled_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user