diff --git a/README.md b/README.md index 5437b70..43cbb26 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ El proyecto ha sido optimizado para reducir la complejidad de la infraestructura - **Interactive Gradebook**: Libro de calificaciones avanzado con filtrado por cohortes y exportación a CSV. - **Content Libraries**: Repositorio centralizado de bloques y lecciones reutilizables entre múltiples cursos. - **Advanced Grading (Rubrics)**: Sistema de evaluación basado en rúbricas detalladas con indicadores de desempeño por criterio. +- **Learning Sequences**: Gestión de prerrequisitos entre lecciones con cumplimiento forzado en el LMS. ## Requisitos del Sistema @@ -326,6 +327,22 @@ Genera un quiz basado en el contenido de la lección. } ``` +#### POST /lessons/{id}/dependencies +Asigna una lección como prerrequisito de otra. +- **Cuerpo ( LessonDependencyRequest ):** + ```json + { + "prerequisite_lesson_id": "uuid", + "min_score_percentage": "number (opcional)" + } + ``` + +#### GET /lessons/{id}/dependencies +Lista los prerrequisitos de una lección específica. + +#### DELETE /lessons/{lesson_id}/dependencies/{prereq_id} +Elimina un prerrequisito de una lección. + #### DELETE /courses/{id} Elimina un curso y todos sus contenidos relacionados (módulos, lecciones, assets). @@ -601,6 +618,7 @@ Obtiene una lista de todas las organizaciones registradas. - **Student Notes Panel**: Anotaciones personales con interfaz glassmorphism y autoguardado inteligente. - **Cohort Management**: Sistema de gestión de grupos con seguimiento de progreso por cohorte. - **Advanced Gradebook**: Seguimiento del desempeño estudiantil con analíticas y filtrado avanzado. +- **Learning Sequences UI**: Interfaz visual para gestionar dependencias y visualización de bloqueos con iconos de candado. ## 📄 Licencia Este proyecto es código abierto y está disponible bajo los términos de la licencia especificada en el repositorio. \ No newline at end of file diff --git a/roadmap.md b/roadmap.md index 2442647..02df9a3 100644 --- a/roadmap.md +++ b/roadmap.md @@ -185,7 +185,7 @@ - [x] **Cohorts & Groups**: Segmentación de estudiantes con contenido específico. - [x] **Content Libraries**: Repositorio reutilizable de bloques y lecciones. - [x] **Advanced Grading**: Rúbricas detalladas y workflows de calificación. -- [ ] **Learning Sequences**: Prerequisitos y rutas condicionales entre lecciones. +- [x] **Learning Sequences**: Prerequisitos y rutas condicionales entre lecciones. - [ ] **Bulk Operations**: Inscripción masiva, exportación de calificaciones, comunicación masiva. - [ ] **Course Teams**: Múltiples instructores con roles y permisos granulares. - [ ] **Course Preview**: Vista previa de lecciones sin inscripción. @@ -215,9 +215,9 @@ --- -**Estado Actual**: La plataforma cuenta con un motor de IA avanzado, gestión multi-tenant completa, tutoría inteligente con memoria histórica, una **interfaz 100% responsiva**, flujos de autenticación diferenciados, **sistema de foros de discusión funcional**, **gestión de anuncios**, **monetización integrada con Mercado Pago**, **Librerías de Contenido reutilizables** y **Sistema de Rúbricas Avanzado**. +**Estado Actual**: La plataforma cuenta con un motor de IA avanzado, gestión multi-tenant completa, tutoría inteligente con memoria histórica, una **interfaz 100% responsiva**, flujos de autenticación diferenciados, **sistema de foros de discusión funcional**, **gestión de anuncios**, **monetización integrada con Mercado Pago**, **Librerías de Contenido reutilizables**, **Sistema de Rúbricas Avanzado** y **Secuencias de Aprendizaje (Prerrequisitos)**. **Próximas Prioridades**: -1. **Learning Sequences**: Prerequisitos y rutas condicionales entre lecciones. -2. **Bulk Operations**: Inscripción masiva y exportación avanzada de datos. -3. **Course Teams**: Gestión de múltiples instructores por curso. +1. **Bulk Operations**: Inscripción masiva y exportación avanzada de datos. +2. **Course Teams**: Gestión de múltiples instructores por curso. +3. **Course Preview**: Vista previa de lecciones sin inscripción. diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index e81547c..a15d368 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -15,6 +15,105 @@ use sqlx::{PgPool, Row}; use std::env; use uuid::Uuid; +#[derive(Deserialize)] +pub struct BulkEnrollPayload { + pub course_id: Uuid, + pub emails: Vec, +} + +#[derive(Serialize)] +pub struct BulkEnrollResponse { + pub successful_emails: Vec, + pub failed_emails: Vec, + pub already_enrolled_emails: Vec, +} + +pub async fn bulk_enroll_users( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, + Json(payload): Json, +) -> Result, StatusCode> { + if claims.role != "admin" && claims.role != "instructor" { + return Err(StatusCode::FORBIDDEN); + } + + let mut successful_emails = Vec::new(); + let mut failed_emails = Vec::new(); + let mut already_enrolled_emails = Vec::new(); + + for email in payload.emails { + // 1. Find user by email in the organization + let user: Option = sqlx::query_as("SELECT * FROM users WHERE email = $1") + .bind(&email) + .fetch_optional(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + match user { + Some(user) => { + // 2. Check if already enrolled + let is_enrolled: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM enrollments WHERE user_id = $1 AND course_id = $2)" + ) + .bind(user.id) + .bind(payload.course_id) + .fetch_one(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if is_enrolled { + already_enrolled_emails.push(email); + continue; + } + + // 3. Enroll (Admin enrollment ignores payment) + let mut tx = pool + .begin() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Set session context for audit + crate::db_util::set_session_context( + &mut tx, + Some(claims.sub), + Some(org_ctx.id), + None, + None, + Some("BULK_ENROLL".to_string()), + ) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let res = sqlx::query("SELECT * FROM fn_enroll_student($1, $2, $3)") + .bind(org_ctx.id) + .bind(user.id) + .bind(payload.course_id) + .execute(&mut *tx) + .await; + + if res.is_ok() { + tx.commit() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + successful_emails.push(email); + } else { + failed_emails.push(email); + } + } + None => { + failed_emails.push(email); + } + } + } + + Ok(Json(BulkEnrollResponse { + successful_emails, + failed_emails, + already_enrolled_emails, + })) +} + pub async fn enroll_user( Org(org_ctx): Org, claims: Claims, diff --git a/services/lms-service/src/main.rs b/services/lms-service/src/main.rs index 1b2f7d7..93d62ea 100644 --- a/services/lms-service/src/main.rs +++ b/services/lms-service/src/main.rs @@ -51,6 +51,7 @@ async fn main() { let protected_routes = Router::new() .route("/enroll", post(handlers::enroll_user)) + .route("/bulk-enroll", post(handlers::bulk_enroll_users)) .route("/enrollments/{id}", get(handlers::get_user_enrollments)) .route( "/payments/preference", diff --git a/web/studio/src/app/courses/[id]/grades/page.tsx b/web/studio/src/app/courses/[id]/grades/page.tsx index 8b37636..0c39547 100644 --- a/web/studio/src/app/courses/[id]/grades/page.tsx +++ b/web/studio/src/app/courses/[id]/grades/page.tsx @@ -14,7 +14,11 @@ import { AlertCircle, User, Mail, - Clock + Clock, + Users, + X, + AlertTriangle, + Loader2 } from "lucide-react"; export default function GradebookPage() { @@ -28,6 +32,14 @@ export default function GradebookPage() { const [searchQuery, setSearchQuery] = useState(""); const [loading, setLoading] = useState(true); const [courseTitle, setCourseTitle] = useState(""); + const [showBulkEnroll, setShowBulkEnroll] = useState(false); + const [bulkEmails, setBulkEmails] = useState(""); + const [bulkResults, setBulkResults] = useState<{ + successful_emails: string[]; + failed_emails: string[]; + already_enrolled_emails: string[]; + } | null>(null); + const [bulkLoading, setBulkLoading] = useState(false); useEffect(() => { if (!user) return; @@ -94,6 +106,32 @@ export default function GradebookPage() { document.body.removeChild(link); }; + const handleBulkEnroll = async () => { + if (!bulkEmails.trim()) return; + + const emails = bulkEmails + .split(/[\n,]/) + .map(e => e.trim()) + .filter(e => e.length > 0 && e.includes("@")); + + if (emails.length === 0) return; + + try { + setBulkLoading(true); + const results = await lmsApi.bulkEnroll(id, emails); + setBulkResults(results); + // Refresh list if any were successful + if (results.successful_emails.length > 0) { + const refreshedGrades = await lmsApi.getCourseGrades(id, selectedCohortId || undefined); + setStudents(refreshedGrades); + } + } catch (err) { + console.error("Bulk enrollment failed", err); + } finally { + setBulkLoading(false); + } + }; + if (loading && students.length === 0) return (
@@ -120,6 +158,12 @@ export default function GradebookPage() {
+
+ {/* Bulk Enroll Modal */} + {showBulkEnroll && ( +
+
+
+

+ Bulk Student Enrollment +

+ +
+ +
+ {!bulkResults ? ( + <> +
+ +