From bbef932776a31658b7e82346b338c15a00a59008 Mon Sep 17 00:00:00 2001 From: Nurfog Date: Fri, 27 Feb 2026 09:20:35 -0300 Subject: [PATCH] feat: Implement external MySQL integration for LMS enrollments and grade synchronization, including `external_id` and `tipo_nota` support. --- .env.example | 7 + Cargo.lock | 38 +++ Cargo.toml | 3 +- services/cms-service/src/handlers.rs | 28 +- services/cms-service/src/main.rs | 1 + services/lms-service/Cargo.toml | 1 + ...6040000_add_external_id_to_enrollments.sql | 2 + .../20260226050000_add_tipo_nota.sql | 24 ++ services/lms-service/src/external_db.rs | 26 ++ services/lms-service/src/handlers.rs | 87 ++++++- services/lms-service/src/main.rs | 28 +- services/lms-service/src/openapi.rs | 243 ++++++++++++++++++ shared/common/src/models.rs | 2 + 13 files changed, 485 insertions(+), 5 deletions(-) create mode 100644 services/lms-service/migrations/20260226040000_add_external_id_to_enrollments.sql create mode 100644 services/lms-service/migrations/20260226050000_add_tipo_nota.sql create mode 100644 services/lms-service/src/external_db.rs create mode 100644 services/lms-service/src/openapi.rs diff --git a/.env.example b/.env.example index 63777c9..270a616 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 2216f94..0e71450 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index b5a3970..aa5367e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index fbfbc97..cf71ea7 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -213,6 +213,7 @@ pub struct GradingPayload { pub name: String, pub weight: i32, pub drop_count: i32, + pub tipo_nota_id: Option, // idTipoNota from tiponota table } #[derive(Deserialize)] @@ -1441,8 +1442,8 @@ pub async fn create_grading_category( Json(payload): Json, ) -> Result, (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, + pub activo: i16, +} + +pub async fn get_tipo_nota( + State(pool): State, +) -> Result>, 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, diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index b72586f..a46e855 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -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", diff --git a/services/lms-service/Cargo.toml b/services/lms-service/Cargo.toml index 46ea795..cc23529 100644 --- a/services/lms-service/Cargo.toml +++ b/services/lms-service/Cargo.toml @@ -22,3 +22,4 @@ jsonwebtoken.workspace = true reqwest = { version = "0.12", features = ["json"] } urlencoding = "2.1" base64 = "0.22" +utoipa.workspace = true diff --git a/services/lms-service/migrations/20260226040000_add_external_id_to_enrollments.sql b/services/lms-service/migrations/20260226040000_add_external_id_to_enrollments.sql new file mode 100644 index 0000000..5c4204b --- /dev/null +++ b/services/lms-service/migrations/20260226040000_add_external_id_to_enrollments.sql @@ -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; diff --git a/services/lms-service/migrations/20260226050000_add_tipo_nota.sql b/services/lms-service/migrations/20260226050000_add_tipo_nota.sql new file mode 100644 index 0000000..836b71e --- /dev/null +++ b/services/lms-service/migrations/20260226050000_add_tipo_nota.sql @@ -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); diff --git a/services/lms-service/src/external_db.rs b/services/lms-service/src/external_db.rs new file mode 100644 index 0000000..c4deecf --- /dev/null +++ b/services/lms-service/src/external_db.rs @@ -0,0 +1,26 @@ +use sqlx::{MySql, Pool}; +use std::env; + +pub type MySqlPool = Pool; + +pub async fn init_mysql_pool() -> Option { + 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 + } +} diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index 5b58e2a..cc06317 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -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 = 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, + Extension(mysql_pool): Extension>, headers: axum::http::HeaderMap, Json(payload): Json, ) -> Result, (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 = 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 = 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()))?; diff --git a/services/lms-service/src/main.rs b/services/lms-service/src/main.rs index 040751e..c1be7f4 100644 --- a/services/lms-service/src/main.rs +++ b/services/lms-service/src/main.rs @@ -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#" + + + + OpenCCB LMS API + + + + + + + + + "#) + })) .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); diff --git a/services/lms-service/src/openapi.rs b/services/lms-service/src/openapi.rs new file mode 100644 index 0000000..c7082bb --- /dev/null +++ b/services/lms-service/src/openapi.rs @@ -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, +} + +/// 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, +} + +/// 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, + 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, + 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, + 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, + pub modules: Vec, + pub lessons: Vec, + pub instructors: Vec, +} + +/// 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, + /// 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), + ) +)] +pub fn get_tipo_nota() {} diff --git a/shared/common/src/models.rs b/shared/common/src/models.rs index ef66ce0..571e21c 100644 --- a/shared/common/src/models.rs +++ b/shared/common/src/models.rs @@ -62,6 +62,7 @@ pub struct GradingCategory { pub name: String, pub weight: i32, // 0-100 pub drop_count: i32, + pub tipo_nota_id: Option, // Maps to idTipoNota in external MySQL system pub created_at: DateTime, } @@ -126,6 +127,7 @@ pub struct Enrollment { pub user_id: Uuid, pub organization_id: Uuid, pub course_id: Uuid, + pub external_id: Option, // idDetalleContrato from the external system pub enrolled_at: DateTime, }