feat: enhance LMS retention data with completion rates, improve LTI key handling, and refine dev setup scripts
This commit is contained in:
+3
-1
@@ -30,4 +30,6 @@ Thumbs.db
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
yarn-error.log*
|
||||
# --- Project-specific Development Keys ---
|
||||
services/lms-service/dev_keys/
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+18
-4
@@ -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 <<EOF
|
||||
{
|
||||
"email": "$ADMIN_EMAIL",
|
||||
@@ -235,15 +234,30 @@ if [ "$ADMIN_EXISTS" != "t" ]; then
|
||||
EOF
|
||||
)
|
||||
|
||||
# Wait until the API actually responds (not just the port being open)
|
||||
MAX_RETRIES=30
|
||||
count=0
|
||||
echo -n "Esperando API"
|
||||
until curl -s -o /dev/null "$API_URL/auth/login" -H "Content-Type: application/json" -d '{}' 2>/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."
|
||||
|
||||
@@ -57,6 +57,7 @@ pub struct UpdateLevelPayload {
|
||||
pub position: Option<i32>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateAssessmentPayload {
|
||||
pub lesson_id: Uuid,
|
||||
@@ -65,6 +66,7 @@ pub struct CreateAssessmentPayload {
|
||||
pub submission_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateAssessmentPayload {
|
||||
pub total_score: f32,
|
||||
@@ -72,6 +74,7 @@ pub struct UpdateAssessmentPayload {
|
||||
pub scores: Vec<CriterionScorePayload>,
|
||||
}
|
||||
|
||||
#[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<PgPool>,
|
||||
Path((lesson_id, rubric_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<Json<LessonRubric>, (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<PgPool>,
|
||||
Path((lesson_id, rubric_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
|
||||
Executable
+31
@@ -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 "------------------------------------------------"
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function CourseEditorLayout({ children, activeTab }: CourseEditor
|
||||
<div className="space-y-8">
|
||||
{/* Tabs Navigation */}
|
||||
<div className="glass p-1">
|
||||
<div className="flex border-b border-white/10">
|
||||
<div className="flex border-b border-white/10 overflow-x-auto scrollbar-thin scrollbar-thumb-white/10 scrollbar-track-transparent">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = tab.key === activeTab;
|
||||
@@ -38,12 +38,12 @@ export default function CourseEditorLayout({ children, activeTab }: CourseEditor
|
||||
<Link
|
||||
key={tab.key}
|
||||
href={tab.href}
|
||||
className={`flex items-center gap-2 px-6 py-4 text-sm font-medium transition-colors ${isActive
|
||||
? "border-b-2 border-blue-500 bg-white/5"
|
||||
className={`flex items-center gap-1.5 px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap flex-shrink-0 ${isActive
|
||||
? "border-b-2 border-blue-500 bg-white/5 text-white"
|
||||
: "text-gray-500 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
{tab.label}
|
||||
</Link>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user