From c76125c96ad12b8ec6c79ac54c8e58e49e179b49 Mon Sep 17 00:00:00 2001 From: Nurfog Date: Tue, 24 Feb 2026 12:43:58 -0300 Subject: [PATCH] feat: enhance LMS retention data with completion rates, improve LTI key handling, and refine dev setup scripts --- .gitignore | 4 ++- docker-compose.yml | 2 ++ install.sh | 22 ++++++++++--- services/cms-service/src/handlers_rubrics.rs | 7 ++-- services/lms-service/generate_dev_keys.sh | 31 ++++++++++++++++++ ...24000001_fix_enrollments_and_retention.sql | 32 +++++++++++++++++++ services/lms-service/src/handlers.rs | 4 +-- services/lms-service/src/jwks.rs | 20 ++++++++++-- services/lms-service/src/live.rs | 2 +- services/lms-service/src/lti.rs | 2 +- services/lms-service/src/main.rs | 1 - shared/common/src/models.rs | 1 + .../src/components/CourseEditorLayout.tsx | 8 ++--- 13 files changed, 117 insertions(+), 19 deletions(-) create mode 100755 services/lms-service/generate_dev_keys.sh create mode 100644 services/lms-service/migrations/20260224000001_fix_enrollments_and_retention.sql diff --git a/.gitignore b/.gitignore index 6788fbb..29516d6 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,6 @@ Thumbs.db *.log npm-debug.log* yarn-debug.log* -yarn-error.log* \ No newline at end of file +yarn-error.log* +# --- Project-specific Development Keys --- +services/lms-service/dev_keys/ diff --git a/docker-compose.yml b/docker-compose.yml index 89fd51c..49dd5fb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,6 +33,7 @@ services: env_file: .env extra_hosts: - "host.docker.internal:host-gateway" + - "t-800:192.168.0.5" depends_on: - db @@ -58,6 +59,7 @@ services: env_file: .env extra_hosts: - "host.docker.internal:host-gateway" + - "t-800:192.168.0.5" depends_on: - db diff --git a/install.sh b/install.sh index 4833504..6fcec7a 100755 --- a/install.sh +++ b/install.sh @@ -223,7 +223,6 @@ docker compose up -d --build if [ "$ADMIN_EXISTS" != "t" ]; then echo "⏳ Esperando a que el API CMS esté listo..." API_URL="http://localhost:3001" - START_WAIT=$SECONDS PAYLOAD=$(cat </dev/null; do + echo -n "." + sleep 2 + count=$((count+1)) + if [ $count -ge $MAX_RETRIES ]; then + echo "" + echo "⚠️ Tiempo de espera agotado. El API no respondió." + break + fi + done + echo "" + RESPONSE=$(curl -s -X POST "$API_URL/auth/register" -H "Content-Type: application/json" -d "$PAYLOAD") if echo "$RESPONSE" | grep -q "token"; then echo "✅ ¡Éxito! Administrador creado." - # Generate and show initial API Key - API_KEY=$(docker exec openccb-db-1 psql -U user -d openccb_cms -t -c "SELECT api_key FROM organizations WHERE name = 'Organización por Defecto' LIMIT 1;" | xargs) + API_KEY=$(docker exec openccb-db-1 psql -U user -d openccb_cms -t -c "SELECT api_key FROM organizations LIMIT 1;" | xargs) echo "🔑 API Key Inicial: $API_KEY" else - echo "⚠️ Fallo al crear el administrador." + echo "⚠️ Fallo al crear el administrador. Respuesta: $RESPONSE" fi else echo "✅ El administrador ya existe. Saltando registro." diff --git a/services/cms-service/src/handlers_rubrics.rs b/services/cms-service/src/handlers_rubrics.rs index 2ec3891..679f34f 100644 --- a/services/cms-service/src/handlers_rubrics.rs +++ b/services/cms-service/src/handlers_rubrics.rs @@ -57,6 +57,7 @@ pub struct UpdateLevelPayload { pub position: Option, } +#[allow(dead_code)] #[derive(Debug, Deserialize)] pub struct CreateAssessmentPayload { pub lesson_id: Uuid, @@ -65,6 +66,7 @@ pub struct CreateAssessmentPayload { pub submission_id: Option, } +#[allow(dead_code)] #[derive(Debug, Deserialize)] pub struct UpdateAssessmentPayload { pub total_score: f32, @@ -72,6 +74,7 @@ pub struct UpdateAssessmentPayload { pub scores: Vec, } +#[allow(dead_code)] #[derive(Debug, Deserialize)] pub struct CriterionScorePayload { pub criterion_id: Uuid, @@ -531,7 +534,7 @@ pub async fn delete_level( /// Assign a rubric to a lesson pub async fn assign_rubric_to_lesson( - Org(org_ctx): Org, + Org(_org_ctx): Org, State(pool): State, Path((lesson_id, rubric_id)): Path<(Uuid, Uuid)>, ) -> Result, (StatusCode, String)> { @@ -555,7 +558,7 @@ pub async fn assign_rubric_to_lesson( /// Unassign a rubric from a lesson pub async fn unassign_rubric_from_lesson( - Org(org_ctx): Org, + Org(_org_ctx): Org, State(pool): State, Path((lesson_id, rubric_id)): Path<(Uuid, Uuid)>, ) -> Result { diff --git a/services/lms-service/generate_dev_keys.sh b/services/lms-service/generate_dev_keys.sh new file mode 100755 index 0000000..b10ef0f --- /dev/null +++ b/services/lms-service/generate_dev_keys.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +# Configuration +# Path is relative to the project root where the script is located +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +KEY_DIR="$SCRIPT_DIR/dev_keys" +mkdir -p "$KEY_DIR" + +echo "🔐 Generando llaves de desarrollo para LTI..." + +# 1. Private Key +openssl genrsa -out "$KEY_DIR/lti_private.pem" 2048 + +# 2. Public Key +openssl rsa -in "$KEY_DIR/lti_private.pem" -pubout -out "$KEY_DIR/lti_public.pem" + +# 3. Extract Modulus (needed for JWKS 'n' parameter) +# Hex format +openssl rsa -in "$KEY_DIR/lti_private.pem" -noout -modulus | cut -d'=' -f2 > "$KEY_DIR/modulus_hex.txt" + +# Base64URL format (standard for JWKS) +# We convert the hex modulus to binary and then to base64url +MODULUS_HEX=$(cat "$KEY_DIR/modulus_hex.txt") +python3 -c "import base64; print(base64.urlsafe_b64encode(bytes.fromhex('$MODULUS_HEX')).decode().rstrip('='))" > "$KEY_DIR/modulus_b64.txt" + +echo "✅ Llaves generadas en $KEY_DIR" +echo "------------------------------------------------" +echo "Para producción, define las variables de entorno:" +echo "- LTI_PRIVATE_KEY: Contenido completo de lti_private.pem" +echo "- LTI_JWK_N: Contenido de modulus_b64.txt" +echo "------------------------------------------------" diff --git a/services/lms-service/migrations/20260224000001_fix_enrollments_and_retention.sql b/services/lms-service/migrations/20260224000001_fix_enrollments_and_retention.sql new file mode 100644 index 0000000..645b7b9 --- /dev/null +++ b/services/lms-service/migrations/20260224000001_fix_enrollments_and_retention.sql @@ -0,0 +1,32 @@ +-- Fix 1: Add missing `progress` column to enrollments +ALTER TABLE enrollments ADD COLUMN IF NOT EXISTS progress FLOAT4 NOT NULL DEFAULT 0; +ALTER TABLE enrollments ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); + +-- Fix 2: Update fn_get_retention_data to match what the Rust handler queries +-- Drop all existing overloads +DROP FUNCTION IF EXISTS fn_get_retention_data(uuid); +DROP FUNCTION IF EXISTS fn_get_retention_data(uuid, uuid); + +-- Recreate with consistent signature matching the Rust query_as struct +CREATE OR REPLACE FUNCTION fn_get_retention_data(p_course_id uuid, p_organization_id uuid DEFAULT NULL) +RETURNS TABLE( + lesson_id uuid, + lesson_title varchar, + student_count bigint, + completion_rate float4 +) AS $$ +BEGIN + RETURN QUERY + SELECT + l.id AS lesson_id, + l.title::varchar AS lesson_title, + COUNT(DISTINCT e.user_id)::bigint AS student_count, + COALESCE(AVG(e.progress), 0)::float4 AS completion_rate + FROM lessons l + JOIN modules m ON l.module_id = m.id + LEFT JOIN enrollments e ON e.course_id = m.course_id + WHERE m.course_id = p_course_id + GROUP BY l.id, l.title + ORDER BY l.position; +END; +$$ LANGUAGE plpgsql; diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index 68b119a..5c65fa5 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -1240,7 +1240,7 @@ pub async fn get_user_gamification( .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let badges = sqlx::query_as::<_, BadgeResponse>( - "SELECT b.id, b.name, b.description, b.icon_url, ub.earned_at + "SELECT b.id, b.name, b.description, b.icon_url, ub.awarded_at AS earned_at FROM user_badges ub JOIN badges b ON ub.badge_id = b.id WHERE ub.user_id = $1 AND ub.organization_id = $2", @@ -1550,7 +1550,7 @@ pub async fn get_advanced_analytics( // 2. Retention Analysis using DB function let retention_data = sqlx::query_as::<_, common::models::RetentionData>( - "SELECT lesson_id, lesson_title, student_count FROM fn_get_retention_data($1, $2)", + "SELECT lesson_id, lesson_title, student_count, completion_rate FROM fn_get_retention_data($1, $2)", ) .bind(course_id) .bind(org_ctx.id) diff --git a/services/lms-service/src/jwks.rs b/services/lms-service/src/jwks.rs index ad50051..30890ef 100644 --- a/services/lms-service/src/jwks.rs +++ b/services/lms-service/src/jwks.rs @@ -4,11 +4,25 @@ use std::env; pub fn get_lti_private_key() -> jsonwebtoken::EncodingKey { let key_str = env::var("LTI_PRIVATE_KEY").unwrap_or_else(|_| { - // Fallback for development (DO NOT USE IN PRODUCTION) - include_str!("../dev_keys/lti_private.pem").to_string() + let dev_key_path = "services/lms-service/dev_keys/lti_private.pem"; + std::fs::read_to_string(dev_key_path).unwrap_or_else(|_| { + // Return a dummy key or handle error gracefully in production + // For now, we'll return a string that will likely fail decoding if used, + // but allows the service to start if LTI is not used. + tracing::warn!("LTI private key not found at {} and LTI_PRIVATE_KEY is not set.", dev_key_path); + String::new() + }) }); - jsonwebtoken::EncodingKey::from_rsa_pem(key_str.as_bytes()).expect("Invalid LTI private key") + if key_str.is_empty() { + // Handle the empty key case - maybe return a specialized error or a dummy key + // that fails later. jsonwebtoken::EncodingKey::from_rsa_pem usually expects valid PEM. + // We'll use a dummy valid-looking but useless PEM if it's empty to avoid panic on startup + // but it will fail on actual LTI usage. + return jsonwebtoken::EncodingKey::from_rsa_pem(b"-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA7f...dummy...\n-----END RSA PRIVATE KEY-----").expect("Dummy key failed"); + } + + jsonwebtoken::EncodingKey::from_rsa_pem(key_str.as_bytes()).expect("Invalid LTI private key format") } pub fn get_lti_jwks() -> JwkSet { diff --git a/services/lms-service/src/live.rs b/services/lms-service/src/live.rs index 492b04f..121d729 100644 --- a/services/lms-service/src/live.rs +++ b/services/lms-service/src/live.rs @@ -6,7 +6,7 @@ use axum::{ use chrono::Utc; use common::auth::Claims; use common::models::Meeting; -use sqlx::{PgPool, Row}; +use sqlx::PgPool; use uuid::Uuid; use serde::Deserialize; diff --git a/services/lms-service/src/lti.rs b/services/lms-service/src/lti.rs index 871a706..f39b7cb 100644 --- a/services/lms-service/src/lti.rs +++ b/services/lms-service/src/lti.rs @@ -8,7 +8,7 @@ use jsonwebtoken::{decode, decode_header, jwk::JwkSet, DecodingKey, Validation}; use serde::{Deserialize}; use sqlx::{PgPool}; use uuid::Uuid; -use common::models::{LtiLaunchClaims, LtiRegistration, LtiResourceLink, User}; +use common::models::{LtiLaunchClaims, LtiRegistration, User}; use common::auth::Claims; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; diff --git a/services/lms-service/src/main.rs b/services/lms-service/src/main.rs index b1dfa8e..b1f0483 100644 --- a/services/lms-service/src/main.rs +++ b/services/lms-service/src/main.rs @@ -16,7 +16,6 @@ use axum::{ Router, middleware, routing::{delete, get, post, put}, }; -use axum::Json; // Added based on instruction use dotenvy::dotenv; use sqlx::postgres::PgPoolOptions; use std::env; diff --git a/shared/common/src/models.rs b/shared/common/src/models.rs index 52bf5c2..ac66c99 100644 --- a/shared/common/src/models.rs +++ b/shared/common/src/models.rs @@ -399,6 +399,7 @@ pub struct RetentionData { pub lesson_id: Uuid, pub lesson_title: String, pub student_count: i64, + pub completion_rate: f32, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/web/studio/src/components/CourseEditorLayout.tsx b/web/studio/src/components/CourseEditorLayout.tsx index f0756f7..96e0907 100644 --- a/web/studio/src/components/CourseEditorLayout.tsx +++ b/web/studio/src/components/CourseEditorLayout.tsx @@ -30,7 +30,7 @@ export default function CourseEditorLayout({ children, activeTab }: CourseEditor
{/* Tabs Navigation */}
-
+
{tabs.map((tab) => { const Icon = tab.icon; const isActive = tab.key === activeTab; @@ -38,12 +38,12 @@ export default function CourseEditorLayout({ children, activeTab }: CourseEditor - + {tab.label} );