feat: add external SAM ID support to courses and update API documentation
This commit is contained in:
Generated
+1
@@ -840,6 +840,7 @@ dependencies = [
|
||||
"tower_governor",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"utoipa",
|
||||
"uuid",
|
||||
"zip",
|
||||
]
|
||||
|
||||
@@ -28,6 +28,7 @@ openidconnect.workspace = true
|
||||
anyhow.workspace = true
|
||||
thiserror.workspace = true
|
||||
http.workspace = true
|
||||
utoipa.workspace = true
|
||||
zip = "0.6"
|
||||
mime_guess = "2.0"
|
||||
base64 = "0.22.1"
|
||||
|
||||
@@ -47,4 +47,4 @@ BEGIN
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
CREATE TRIGGER update_courses_updated_at BEFORE UPDATE ON courses FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column();
|
||||
CREATE TRIGGER update_courses_updated_at BEFORE UPDATE ON courses FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column();
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE courses
|
||||
ADD COLUMN IF NOT EXISTS external_sam_id BIGINT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_courses_org_external_sam_id
|
||||
ON courses (organization_id, external_sam_id)
|
||||
WHERE external_sam_id IS NOT NULL;
|
||||
@@ -42,10 +42,11 @@ pub async fn create_course_external(
|
||||
let description = payload.description.as_deref();
|
||||
|
||||
let course = sqlx::query_as::<_, Course>(
|
||||
"INSERT INTO courses (organization_id, title, description, instructor_id, pacing_mode)
|
||||
VALUES ($1, $2, $3, '00000000-0000-0000-0000-000000000001', $4) RETURNING *"
|
||||
"INSERT INTO courses (organization_id, external_sam_id, title, description, instructor_id, pacing_mode)
|
||||
VALUES ($1, $2, $3, $4, '00000000-0000-0000-0000-000000000001', $5) RETURNING *"
|
||||
)
|
||||
.bind(org_id)
|
||||
.bind(payload.external_sam_id)
|
||||
.bind(title)
|
||||
.bind(description)
|
||||
.bind(payload.pacing_mode.as_deref().unwrap_or("self_paced"))
|
||||
@@ -94,6 +95,8 @@ pub struct ExternalCreateCoursePayload {
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub pacing_mode: Option<String>,
|
||||
#[serde(alias = "idcursoabierto", alias = "id_curso_abierto")]
|
||||
pub external_sam_id: Option<i64>,
|
||||
// Selección directa de plantilla opcional
|
||||
pub template_id: Option<Uuid>,
|
||||
// Selección de respaldo (fallback) opcional por nivel/tipo de curso/tipo de test
|
||||
|
||||
@@ -232,10 +232,9 @@ pub async fn sync_sam_assignments(
|
||||
// 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"
|
||||
"SELECT id FROM courses WHERE external_sam_id = $1"
|
||||
)
|
||||
.bind(assignment.id_curso_abierto as i64)
|
||||
.bind(assignment.id_curso_abierto)
|
||||
.fetch_optional(&pool)
|
||||
.await;
|
||||
|
||||
@@ -484,10 +483,9 @@ pub async fn sync_all_sam(
|
||||
|
||||
for assignment in sam_assignments {
|
||||
let course_result = sqlx::query_as::<_, (Uuid,)>(
|
||||
"SELECT id FROM courses WHERE external_sam_id = $1 OR id = $2"
|
||||
"SELECT id FROM courses WHERE external_sam_id = $1"
|
||||
)
|
||||
.bind(assignment.id_curso_abierto as i64)
|
||||
.bind(assignment.id_curso_abierto)
|
||||
.fetch_optional(&pool)
|
||||
.await;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ mod handlers_admin;
|
||||
mod handlers_embeddings;
|
||||
mod handlers_sam;
|
||||
mod handlers_plugins;
|
||||
mod openapi;
|
||||
mod webhooks;
|
||||
|
||||
use axum::{
|
||||
@@ -34,6 +35,7 @@ use std::time::Duration;
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tower_http::set_header::SetResponseHeaderLayer;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use utoipa::OpenApi;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
@@ -594,7 +596,26 @@ async fn main() {
|
||||
get(handlers_branding::get_organization_branding),
|
||||
);
|
||||
|
||||
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 {
|
||||
axum::response::Html(r#"
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>OpenCCB CMS 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>
|
||||
"#)
|
||||
}))
|
||||
.nest("/api/external", api_routes)
|
||||
.route(
|
||||
"/api/assets/s3-proxy/{bucket}/{*key}",
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use utoipa::OpenApi;
|
||||
|
||||
#[derive(utoipa::ToSchema, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ExternalCreateCoursePayloadSchema {
|
||||
/// Obligatorio. No debe ser vacío.
|
||||
pub title: String,
|
||||
/// Opcional: puede omitirse o enviarse como `null`.
|
||||
pub description: Option<String>,
|
||||
/// Opcional: puede omitirse o enviarse como `null`.
|
||||
pub pacing_mode: Option<String>,
|
||||
/// Opcional: idCursoAbierto del sistema SAM.
|
||||
/// También se acepta como `idcursoabierto` o `id_curso_abierto` en el payload real.
|
||||
pub external_sam_id: Option<i64>,
|
||||
/// Opcional: UUID de plantilla específica.
|
||||
pub template_id: Option<String>,
|
||||
/// Opcional: fallback de selección de plantilla por nivel.
|
||||
pub template_level: Option<String>,
|
||||
/// Opcional: fallback de selección de plantilla por tipo de curso.
|
||||
pub template_course_type: Option<String>,
|
||||
/// Opcional: fallback de selección de plantilla por tipo de test.
|
||||
pub template_test_type: Option<String>,
|
||||
/// Opcional: título del módulo creado al aplicar plantilla.
|
||||
pub module_title: Option<String>,
|
||||
/// Opcional: título de la lección creada al aplicar plantilla.
|
||||
pub lesson_title: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(utoipa::ToSchema, serde::Serialize)]
|
||||
pub struct ExternalCourseEnvelopeSchema {
|
||||
pub course: serde_json::Value,
|
||||
pub template_applied: bool,
|
||||
pub template_id: Option<String>,
|
||||
pub lesson_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
info(
|
||||
title = "OpenCCB CMS API",
|
||||
version = "1.0.0",
|
||||
description = "API del CMS para gestión interna y endpoints externos por API key.\n\nReglas de nulabilidad y arreglos (aplican a todos los endpoints de esta API):\n- Campos opcionales (Option): pueden omitirse o enviarse como null.\n- Campos de arreglo no opcionales (Vec): deben enviarse como arreglo; pueden ser [] pero no null.\n- Objetos requeridos del payload: no deben enviarse como null."
|
||||
),
|
||||
paths(
|
||||
create_course_external,
|
||||
get_course_external,
|
||||
trigger_transcription_external,
|
||||
),
|
||||
components(
|
||||
schemas(
|
||||
ExternalCreateCoursePayloadSchema,
|
||||
ExternalCourseEnvelopeSchema,
|
||||
)
|
||||
),
|
||||
tags(
|
||||
(name = "External", description = "Integración externa del CMS")
|
||||
)
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/external/v1/courses",
|
||||
tag = "External",
|
||||
request_body = ExternalCreateCoursePayloadSchema,
|
||||
responses(
|
||||
(status = 200, description = "Curso creado exitosamente"),
|
||||
(status = 400, description = "Payload inválido o plantilla no encontrada"),
|
||||
(status = 401, description = "X-API-Key inválida o ausente"),
|
||||
(status = 500, description = "Error interno del servidor")
|
||||
),
|
||||
security(("ApiKey" = []))
|
||||
)]
|
||||
pub fn create_course_external() {}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/external/v1/courses/{id}",
|
||||
tag = "External",
|
||||
params(
|
||||
("id" = String, Path, description = "UUID del curso")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Curso encontrado"),
|
||||
(status = 401, description = "X-API-Key inválida o ausente"),
|
||||
(status = 404, description = "Curso no encontrado")
|
||||
),
|
||||
security(("ApiKey" = []))
|
||||
)]
|
||||
pub fn get_course_external() {}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/external/v1/lessons/{id}/transcribe",
|
||||
tag = "External",
|
||||
params(
|
||||
("id" = String, Path, description = "UUID de la lección")
|
||||
),
|
||||
responses(
|
||||
(status = 202, description = "Transcripción encolada"),
|
||||
(status = 401, description = "X-API-Key inválida o ausente"),
|
||||
(status = 404, description = "Lección no encontrada")
|
||||
),
|
||||
security(("ApiKey" = []))
|
||||
)]
|
||||
pub fn trigger_transcription_external() {}
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE courses
|
||||
ADD COLUMN IF NOT EXISTS external_sam_id BIGINT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_courses_org_external_sam_id
|
||||
ON courses (organization_id, external_sam_id)
|
||||
WHERE external_sam_id IS NOT NULL;
|
||||
@@ -1009,8 +1009,8 @@ pub async fn ingest_course(
|
||||
|
||||
// 2. Insertar o actualizar (Upsert) Curso
|
||||
sqlx::query(
|
||||
"INSERT INTO courses (id, title, description, instructor_id, start_date, end_date, passing_percentage, certificate_template, updated_at, organization_id, pacing_mode, price, currency)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
"INSERT INTO courses (id, title, description, instructor_id, start_date, end_date, passing_percentage, certificate_template, updated_at, organization_id, pacing_mode, price, currency, external_sam_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
description = EXCLUDED.description,
|
||||
@@ -1023,7 +1023,8 @@ pub async fn ingest_course(
|
||||
organization_id = EXCLUDED.organization_id,
|
||||
pacing_mode = EXCLUDED.pacing_mode,
|
||||
price = EXCLUDED.price,
|
||||
currency = EXCLUDED.currency"
|
||||
currency = EXCLUDED.currency,
|
||||
external_sam_id = EXCLUDED.external_sam_id"
|
||||
)
|
||||
.bind(payload.course.id)
|
||||
.bind(&payload.course.title)
|
||||
@@ -1038,6 +1039,7 @@ pub async fn ingest_course(
|
||||
.bind(&payload.course.pacing_mode)
|
||||
.bind(payload.course.price)
|
||||
.bind(&payload.course.currency)
|
||||
.bind(payload.course.external_sam_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e: sqlx::Error| {
|
||||
|
||||
@@ -470,7 +470,7 @@ async fn main() {
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
<body>
|
||||
<script id="api-reference" data-url="/api-docs/openapi.json"></script>
|
||||
<script id="api-reference" data-url="./api-docs/openapi.json"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -50,13 +50,26 @@ pub struct LessonSchema {
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// Módulo de curso
|
||||
/// Módulo de curso dentro de la ingesta.
|
||||
///
|
||||
/// `lessons` puede venir como `[]` (vacío), pero no debe venir `null`.
|
||||
#[derive(utoipa::ToSchema, serde::Serialize)]
|
||||
pub struct ModuleSchema {
|
||||
pub struct IngestModuleSchema {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub position: i32,
|
||||
pub created_at: String,
|
||||
pub lessons: Vec<LessonSchema>,
|
||||
}
|
||||
|
||||
/// Instructor asignado al curso.
|
||||
#[derive(utoipa::ToSchema, serde::Serialize)]
|
||||
pub struct CourseInstructorSchema {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
/// Ej: "primary", "instructor", "assistant"
|
||||
pub role: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// Organización
|
||||
@@ -71,6 +84,7 @@ pub struct OrgSchema {
|
||||
#[derive(utoipa::ToSchema, serde::Serialize)]
|
||||
pub struct CourseSchema {
|
||||
pub id: String,
|
||||
pub external_sam_id: Option<i64>,
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub price: f64,
|
||||
@@ -82,12 +96,17 @@ pub struct CourseSchema {
|
||||
/// Payload completo de ingesta de curso — usado por el sistema externo para crear/actualizar cursos
|
||||
#[derive(utoipa::ToSchema, serde::Serialize)]
|
||||
pub struct IngestCourseRequest {
|
||||
/// Obligatorio. No debe ser `null`.
|
||||
pub organization: OrgSchema,
|
||||
/// Obligatorio. No debe ser `null`.
|
||||
pub course: CourseSchema,
|
||||
/// Puede venir como `[]` (vacío), pero no `null`.
|
||||
pub grading_categories: Vec<GradingCategorySchema>,
|
||||
pub modules: Vec<ModuleSchema>,
|
||||
pub lessons: Vec<LessonSchema>,
|
||||
pub instructors: Vec<String>,
|
||||
/// Puede venir como `[]` (vacío), pero no `null`.
|
||||
/// Las lecciones se envían dentro de cada módulo en `modules[].lessons`.
|
||||
pub modules: Vec<IngestModuleSchema>,
|
||||
/// Opcional. Puede omitirse o venir como `null` o `[]`.
|
||||
pub instructors: Option<Vec<CourseInstructorSchema>>,
|
||||
}
|
||||
|
||||
/// Entrada del catálogo Tipo Nota
|
||||
@@ -107,7 +126,7 @@ pub struct TipoNotaSchema {
|
||||
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."
|
||||
description = "API para integrar plataformas externas: creación de cursos, inscripción de alumnos y sincronización de notas.\n\nReglas de nulabilidad y arreglos (aplican a todos los endpoints de esta API):\n- Campos opcionales (Option): pueden omitirse o enviarse como null.\n- Campos de arreglo no opcionales (Vec): deben enviarse como arreglo; pueden ser [] pero no null.\n- Objetos requeridos del payload: no deben enviarse como null.\n- En /ingest, enviar modules: [] reemplaza la estructura del curso y elimina módulos/lecciones previos en LMS."
|
||||
),
|
||||
paths(
|
||||
ingest_course,
|
||||
@@ -121,8 +140,9 @@ pub struct TipoNotaSchema {
|
||||
IngestCourseRequest,
|
||||
OrgSchema,
|
||||
CourseSchema,
|
||||
ModuleSchema,
|
||||
IngestModuleSchema,
|
||||
LessonSchema,
|
||||
CourseInstructorSchema,
|
||||
GradingCategorySchema,
|
||||
EnrollRequest,
|
||||
GradeSubmissionRequest,
|
||||
|
||||
@@ -8,6 +8,8 @@ use uuid::Uuid;
|
||||
pub struct Course {
|
||||
pub id: Uuid,
|
||||
pub organization_id: Uuid,
|
||||
#[serde(default, alias = "idcursoabierto", alias = "id_curso_abierto")]
|
||||
pub external_sam_id: Option<i64>,
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub instructor_id: Uuid,
|
||||
@@ -31,6 +33,7 @@ impl Default for Course {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
organization_id: Uuid::new_v4(),
|
||||
external_sam_id: None,
|
||||
title: String::new(),
|
||||
description: None,
|
||||
instructor_id: Uuid::new_v4(),
|
||||
|
||||
Reference in New Issue
Block a user