feat: add progress tracking for course completion metrics
- Introduced a new module for progress tracking in the LMS service. - Implemented `calculate_course_completion` function to compute total lessons, completed lessons, and progress percentage for a user in a specific course. - Updated the main.rs file to include the new progress tracking module. - Enhanced the Excel import functionality in the Question Bank to support various question types and improved error handling. - Added a new dependency on the `xlsx` library for handling Excel files in the frontend. - Modified the course settings page to include a branded certificate template with additional organization details. - Updated the package.json and package-lock.json files to include the new `xlsx` dependency. - Changed the default state for ingestRag in the Admin Shared Materials page to true.
This commit is contained in:
@@ -22,6 +22,7 @@ use common::models::{
|
||||
LessonDependency,
|
||||
};
|
||||
use crate::external_db::MySqlPool;
|
||||
use crate::progress_tracking::{CourseCompletionMetrics, calculate_course_completion};
|
||||
use serde_json::json;
|
||||
use base64::Engine;
|
||||
use tokio::time::{Duration, timeout};
|
||||
@@ -1340,6 +1341,7 @@ pub async fn get_user_enrollments(
|
||||
WHERE ug.user_id = e.user_id
|
||||
AND ug.course_id = e.course_id
|
||||
AND l.is_graded = true
|
||||
AND (ug.score * 100.0) >= COALESCE(l.passing_percentage::double precision, 60.0)
|
||||
) graded ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(DISTINCT li.lesson_id)::int AS ungraded_done
|
||||
@@ -1530,31 +1532,26 @@ pub async fn submit_lesson_score(
|
||||
.await;
|
||||
|
||||
// Lógica de detección de finalización de curso
|
||||
let total_lessons: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM lessons WHERE module_id IN (SELECT id FROM modules WHERE course_id = $1)")
|
||||
.bind(payload.course_id)
|
||||
.fetch_one(&pool).await.unwrap_or(0);
|
||||
|
||||
let completed_lessons: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM user_grades WHERE organization_id = $1 AND user_id = $2 AND course_id = $3",
|
||||
)
|
||||
.bind(org_ctx.id)
|
||||
.bind(payload.user_id)
|
||||
.bind(payload.course_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
if total_lessons > 0 && completed_lessons >= total_lessons {
|
||||
webhook_service
|
||||
.dispatch(
|
||||
org_ctx.id,
|
||||
"course.completed",
|
||||
&serde_json::json!({
|
||||
"user_id": payload.user_id,
|
||||
"course_id": payload.course_id
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
if let Ok(course_completion) = calculate_course_completion(&pool, payload.user_id, payload.course_id).await {
|
||||
if course_completion.completed {
|
||||
webhook_service
|
||||
.dispatch(
|
||||
org_ctx.id,
|
||||
"course.completed",
|
||||
&serde_json::json!({
|
||||
"user_id": payload.user_id,
|
||||
"course_id": payload.course_id,
|
||||
"progress_percentage": course_completion.progress_percentage
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"No se pudo calcular la completitud real del curso {} para el usuario {}",
|
||||
payload.course_id,
|
||||
payload.user_id
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Json(grade))
|
||||
@@ -1799,49 +1796,25 @@ pub async fn get_student_progress_stats(
|
||||
) -> Result<Json<common::models::ProgressStats>, (StatusCode, String)> {
|
||||
let user_id = claims.sub;
|
||||
|
||||
// 1. Lecciones totales
|
||||
let total_lessons: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM lessons WHERE organization_id = $1 AND module_id IN (SELECT id FROM modules WHERE course_id = $2)"
|
||||
)
|
||||
.bind(org_ctx.id)
|
||||
.bind(course_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let course_completion = calculate_course_completion(&pool, user_id, course_id)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!(
|
||||
"No se pudo calcular el progreso del curso {} para el usuario {}: {}",
|
||||
course_id,
|
||||
user_id,
|
||||
e
|
||||
);
|
||||
CourseCompletionMetrics {
|
||||
total_lessons: 0,
|
||||
completed_lessons: 0,
|
||||
progress_percentage: 0.0,
|
||||
completed: false,
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Lecciones completadas
|
||||
let completed_lessons: i64 = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT COUNT(*)
|
||||
FROM lessons l
|
||||
JOIN modules m ON m.id = l.module_id
|
||||
WHERE m.course_id = $2
|
||||
AND l.organization_id = $3
|
||||
AND (
|
||||
(l.is_graded = true AND EXISTS (
|
||||
SELECT 1
|
||||
FROM user_grades ug
|
||||
WHERE ug.user_id = $1
|
||||
AND ug.course_id = $2
|
||||
AND ug.lesson_id = l.id
|
||||
))
|
||||
OR
|
||||
(l.is_graded = false AND EXISTS (
|
||||
SELECT 1
|
||||
FROM lesson_interactions li
|
||||
WHERE li.user_id = $1
|
||||
AND li.lesson_id = l.id
|
||||
AND li.event_type = 'complete'
|
||||
))
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(course_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let total_lessons = course_completion.total_lessons;
|
||||
let completed_lessons = course_completion.completed_lessons;
|
||||
|
||||
// 3. Progreso diario (Últimos 30 días)
|
||||
let daily_completions = sqlx::query_as::<_, common::models::DailyProgress>(
|
||||
@@ -1888,11 +1861,7 @@ pub async fn get_student_progress_stats(
|
||||
None
|
||||
};
|
||||
|
||||
let progress_percentage = if total_lessons > 0 {
|
||||
(completed_lessons as f32 / total_lessons as f32) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let progress_percentage = course_completion.progress_percentage as f32;
|
||||
|
||||
Ok(Json(common::models::ProgressStats {
|
||||
total_lessons,
|
||||
|
||||
@@ -27,6 +27,55 @@ pub struct UpdateAnnouncementPayload {
|
||||
pub is_pinned: Option<bool>,
|
||||
}
|
||||
|
||||
fn is_instructor_or_admin(role: &str) -> bool {
|
||||
role == "instructor" || role == "admin"
|
||||
}
|
||||
|
||||
fn normalize_lms_role(role: &str) -> &'static str {
|
||||
match role {
|
||||
"admin" => "admin",
|
||||
"instructor" => "instructor",
|
||||
_ => "student",
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_announcement_author_exists(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
organization_id: Uuid,
|
||||
role: &str,
|
||||
) -> Result<(), (StatusCode, String)> {
|
||||
let exists = sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)")
|
||||
.bind(user_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if exists {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let synthetic_email = format!("user-{}@openccb.local", user_id);
|
||||
let synthetic_name = format!("Usuario {}", &user_id.to_string()[..8]);
|
||||
let normalized_role = normalize_lms_role(role);
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO users (id, email, password_hash, full_name, organization_id, role)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(&synthetic_email)
|
||||
.bind("external-auth")
|
||||
.bind(&synthetic_name)
|
||||
.bind(organization_id)
|
||||
.bind(normalized_role)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("No se pudo provisionar usuario LMS: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ========== MANEJADORES ==========
|
||||
|
||||
pub async fn list_announcements(
|
||||
@@ -75,20 +124,15 @@ pub async fn create_announcement(
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<CreateAnnouncementPayload>,
|
||||
) -> Result<Json<CourseAnnouncement>, (StatusCode, String)> {
|
||||
// Verificar si el usuario es instructor o administrador
|
||||
let user = sqlx::query_as::<_, (String,)>("SELECT role FROM users WHERE id = $1")
|
||||
.bind(claims.sub)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "Usuario no encontrado".to_string()))?;
|
||||
|
||||
if user.0 != "instructor" && user.0 != "admin" {
|
||||
if !is_instructor_or_admin(&claims.role) {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"Solo los instructores pueden crear anuncios".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
ensure_announcement_author_exists(&pool, claims.sub, org_ctx.id, &claims.role).await?;
|
||||
|
||||
let mut tx = pool
|
||||
.begin()
|
||||
.await
|
||||
@@ -198,14 +242,7 @@ pub async fn update_announcement(
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<UpdateAnnouncementPayload>,
|
||||
) -> Result<Json<CourseAnnouncement>, (StatusCode, String)> {
|
||||
// Verificar si el usuario es instructor o administrador
|
||||
let user = sqlx::query_as::<_, (String,)>("SELECT role FROM users WHERE id = $1")
|
||||
.bind(claims.sub)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "Usuario no encontrado".to_string()))?;
|
||||
|
||||
if user.0 != "instructor" && user.0 != "admin" {
|
||||
if !is_instructor_or_admin(&claims.role) {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"Solo los instructores pueden actualizar anuncios".to_string(),
|
||||
@@ -250,14 +287,7 @@ pub async fn delete_announcement(
|
||||
Path(announcement_id): Path<Uuid>,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
// Verificar si el usuario es instructor o administrador
|
||||
let user = sqlx::query_as::<_, (String,)>("SELECT role FROM users WHERE id = $1")
|
||||
.bind(claims.sub)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "Usuario no encontrado".to_string()))?;
|
||||
|
||||
if user.0 != "instructor" && user.0 != "admin" {
|
||||
if !is_instructor_or_admin(&claims.role) {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"Solo los instructores pueden eliminar anuncios".to_string(),
|
||||
|
||||
@@ -3,10 +3,12 @@ use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use crate::progress_tracking::{CourseCompletionMetrics, calculate_course_completion};
|
||||
use common::auth::Claims;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Sha256, Digest};
|
||||
use sqlx::{PgPool, Row};
|
||||
use std::env;
|
||||
use uuid::Uuid;
|
||||
|
||||
// Macro para usar json!() sin importar serde_json
|
||||
@@ -50,6 +52,45 @@ struct CertificateRecord {
|
||||
metadata: serde_json::Value,
|
||||
}
|
||||
|
||||
fn resolve_certificate_asset_url(path: &str) -> String {
|
||||
if path.starts_with("http://") || path.starts_with("https://") {
|
||||
return path.to_string();
|
||||
}
|
||||
|
||||
let cms_base_url = env::var("NEXT_PUBLIC_CMS_API_URL")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| "/cms-api".to_string());
|
||||
let cms_base_url = cms_base_url.trim_end_matches('/');
|
||||
|
||||
if let Some(without_scheme) = path.strip_prefix("s3://") {
|
||||
if let Some((bucket, key)) = without_scheme.split_once('/') {
|
||||
return format!(
|
||||
"{}/api/assets/s3-proxy/{}/{}",
|
||||
cms_base_url,
|
||||
bucket,
|
||||
key
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let normalized = if let Some(stripped) = path.strip_prefix("uploads/") {
|
||||
format!("/assets/{}", stripped)
|
||||
} else if let Some(stripped) = path.strip_prefix("/uploads/") {
|
||||
format!("/assets/{}", stripped)
|
||||
} else if path.starts_with("/assets/") {
|
||||
path.to_string()
|
||||
} else if path.starts_with("assets/") {
|
||||
format!("/{}", path)
|
||||
} else if path.starts_with('/') {
|
||||
path.to_string()
|
||||
} else {
|
||||
format!("/{}", path)
|
||||
};
|
||||
|
||||
format!("{}{}", cms_base_url, normalized)
|
||||
}
|
||||
|
||||
// ============= Handlers =============
|
||||
|
||||
/// GET /courses/{id}/certificate
|
||||
@@ -170,7 +211,7 @@ pub async fn get_certificate(
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "No has completado este curso aún",
|
||||
"progress": course_completion.progress,
|
||||
"progress": course_completion.progress_percentage,
|
||||
"required": 100.0
|
||||
})),
|
||||
));
|
||||
@@ -257,7 +298,7 @@ pub async fn issue_certificate(
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "No has completado este curso aún",
|
||||
"progress": course_completion.progress,
|
||||
"progress": course_completion.progress_percentage,
|
||||
"required": 100.0
|
||||
})),
|
||||
));
|
||||
@@ -326,98 +367,19 @@ pub async fn verify_certificate(
|
||||
|
||||
// ============= Funciones Internas =============
|
||||
|
||||
struct CourseCompletion {
|
||||
completed: bool,
|
||||
progress: f64,
|
||||
}
|
||||
|
||||
async fn check_course_completion(
|
||||
user_id: Uuid,
|
||||
course_id: Uuid,
|
||||
pool: &PgPool,
|
||||
) -> Result<CourseCompletion, (StatusCode, Json<serde_json::Value>)> {
|
||||
// Obtener todas las lecciones graduables del curso
|
||||
let gradable_lessons = sqlx::query(
|
||||
r#"
|
||||
SELECT l.id, l.is_graded, l.passing_percentage
|
||||
FROM lessons l
|
||||
JOIN modules m ON m.id = l.module_id
|
||||
WHERE m.course_id = $1 AND l.is_graded = true
|
||||
"#
|
||||
)
|
||||
.bind(course_id)
|
||||
.fetch_all(pool)
|
||||
) -> Result<CourseCompletionMetrics, (StatusCode, Json<serde_json::Value>)> {
|
||||
calculate_course_completion(pool, user_id, course_id)
|
||||
.await
|
||||
.map_err(|e: sqlx::Error| {
|
||||
tracing::error!("Error al obtener lecciones graduables: {}", e);
|
||||
tracing::error!("Error al calcular completitud del curso: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "Error interno del servidor"})),
|
||||
)
|
||||
})?;
|
||||
|
||||
if gradable_lessons.is_empty() {
|
||||
// Si no hay lecciones graduables, considerar completado si está inscrito
|
||||
let enrolled = sqlx::query(
|
||||
"SELECT id FROM enrollments WHERE user_id = $1 AND course_id = $2"
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(course_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e: sqlx::Error| {
|
||||
tracing::error!("Error al verificar inscripción: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "Error interno del servidor"})),
|
||||
)
|
||||
})?;
|
||||
|
||||
return Ok(CourseCompletion {
|
||||
completed: enrolled.is_some(),
|
||||
progress: if enrolled.is_some() { 100.0 } else { 0.0 },
|
||||
});
|
||||
}
|
||||
|
||||
// Contar lecciones aprobadas
|
||||
let total_lessons = gradable_lessons.len() as f64;
|
||||
let mut passed_lessons = 0.0;
|
||||
|
||||
for lesson in gradable_lessons {
|
||||
let passing_pct = lesson.get::<Option<i32>, _>("passing_percentage").unwrap_or(60) as f64;
|
||||
let lesson_id: Uuid = lesson.get("id");
|
||||
|
||||
let best_score = sqlx::query(
|
||||
r#"
|
||||
SELECT MAX(score_percentage) as max_score
|
||||
FROM grades
|
||||
WHERE user_id = $1 AND lesson_id = $2
|
||||
"#
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(lesson_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e: sqlx::Error| {
|
||||
tracing::error!("Error al obtener calificaciones: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "Error interno del servidor"})),
|
||||
)
|
||||
})?;
|
||||
|
||||
if let Some(row) = best_score {
|
||||
if row.get::<Option<f64>, _>("max_score").unwrap_or(0.0) >= passing_pct {
|
||||
passed_lessons += 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let progress = (passed_lessons / total_lessons) * 100.0;
|
||||
|
||||
Ok(CourseCompletion {
|
||||
completed: progress >= 100.0,
|
||||
progress,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -469,7 +431,7 @@ async fn issue_certificate_internal(
|
||||
let organization_id: Uuid = course_row.get("organization_id");
|
||||
let org_data = sqlx::query(
|
||||
r#"
|
||||
SELECT name, certificate_template
|
||||
SELECT name, platform_name, logo_url, primary_color, secondary_color, certificate_template
|
||||
FROM organizations
|
||||
WHERE id = $1
|
||||
"#
|
||||
@@ -492,7 +454,27 @@ async fn issue_certificate_internal(
|
||||
.or_else(|| org_data.as_ref().and_then(|o| o.get::<Option<String>, _>("certificate_template")))
|
||||
.unwrap_or_else(|| get_default_certificate_template());
|
||||
|
||||
let _org_name = org_data.as_ref().map(|o| o.get::<String, _>("name")).unwrap_or_else(|| "OpenCCB".to_string());
|
||||
let organization_name = org_data
|
||||
.as_ref()
|
||||
.map(|o| o.get::<String, _>("name"))
|
||||
.unwrap_or_else(|| "OpenCCB".to_string());
|
||||
let platform_name = org_data
|
||||
.as_ref()
|
||||
.and_then(|o| o.get::<Option<String>, _>("platform_name"))
|
||||
.unwrap_or_else(|| organization_name.clone());
|
||||
let logo_url = org_data
|
||||
.as_ref()
|
||||
.and_then(|o| o.get::<Option<String>, _>("logo_url"))
|
||||
.map(|value| resolve_certificate_asset_url(&value))
|
||||
.unwrap_or_default();
|
||||
let primary_color = org_data
|
||||
.as_ref()
|
||||
.and_then(|o| o.get::<Option<String>, _>("primary_color"))
|
||||
.unwrap_or_else(|| "#2563eb".to_string());
|
||||
let secondary_color = org_data
|
||||
.as_ref()
|
||||
.and_then(|o| o.get::<Option<String>, _>("secondary_color"))
|
||||
.unwrap_or_else(|| "#7c3aed".to_string());
|
||||
|
||||
// Reemplazar variables en el template
|
||||
let now = chrono::Utc::now();
|
||||
@@ -501,6 +483,11 @@ async fn issue_certificate_internal(
|
||||
.replace("{{course_title}}", &course_title)
|
||||
.replace("{{date}}", &now.format("%d/%m/%Y").to_string())
|
||||
.replace("{{score}}", "Aprobado")
|
||||
.replace("{{organization_name}}", &organization_name)
|
||||
.replace("{{platform_name}}", &platform_name)
|
||||
.replace("{{logo_url}}", &logo_url)
|
||||
.replace("{{primary_color}}", &primary_color)
|
||||
.replace("{{secondary_color}}", &secondary_color)
|
||||
.replace("{{verification_code}}", "VER-PLACEHOLDER");
|
||||
|
||||
// Generar código de verificación único
|
||||
@@ -519,16 +506,12 @@ async fn issue_certificate_internal(
|
||||
let certificate_hash = format!("{:x}", hasher.finalize());
|
||||
|
||||
// Obtener progreso final para metadata
|
||||
let course_completion = check_course_completion(user_id, course_id, pool).await
|
||||
.unwrap_or(CourseCompletion {
|
||||
completed: true,
|
||||
progress: 100.0,
|
||||
});
|
||||
let course_completion = check_course_completion(user_id, course_id, pool).await?;
|
||||
|
||||
// Insertar certificado en BD
|
||||
let metadata = serde_json::json!({
|
||||
"completion_date": now.to_rfc3339(),
|
||||
"final_score": course_completion.progress,
|
||||
"final_score": course_completion.progress_percentage,
|
||||
"organization_id": organization_id.to_string(),
|
||||
});
|
||||
|
||||
|
||||
@@ -57,6 +57,42 @@ fn parse_bool(value: &str) -> bool {
|
||||
normalized == "1" || normalized == "true" || normalized == "yes"
|
||||
}
|
||||
|
||||
fn build_learning_base_url() -> String {
|
||||
let domain = env::var("NEXT_PUBLIC_LEARNING_DOMAIN")
|
||||
.or_else(|_| env::var("LEARNING_DOMAIN"))
|
||||
.unwrap_or_else(|_| "localhost:3003".to_string())
|
||||
.trim()
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
|
||||
if domain.starts_with("http://") || domain.starts_with("https://") {
|
||||
return domain;
|
||||
}
|
||||
|
||||
let scheme = if domain.contains("localhost") || domain.contains("127.0.0.1") {
|
||||
"http"
|
||||
} else {
|
||||
"https"
|
||||
};
|
||||
|
||||
format!("{}://{}", scheme, domain)
|
||||
}
|
||||
|
||||
fn build_discussion_thread_url(course_id: Uuid) -> String {
|
||||
format!("{}/courses/{}#discussions", build_learning_base_url(), course_id)
|
||||
}
|
||||
|
||||
async fn load_organization_name(pool: &PgPool, organization_id: Uuid) -> String {
|
||||
sqlx::query_scalar::<_, String>("SELECT name FROM organizations WHERE id = $1")
|
||||
.bind(organization_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| "OpenCCB".to_string())
|
||||
}
|
||||
|
||||
fn load_env_smtp_config() -> Result<SmtpConfig, String> {
|
||||
let enabled = env::var("SMTP_ENABLED").map(|v| parse_bool(&v)).unwrap_or(false);
|
||||
let host = env::var("SMTP_HOST").map_err(|_| "SMTP_HOST no está configurado".to_string())?;
|
||||
@@ -429,6 +465,8 @@ pub async fn create_thread(
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<CreateThreadPayload>,
|
||||
) -> Result<Json<DiscussionThread>, (StatusCode, String)> {
|
||||
let organization_name = load_organization_name(&pool, org_ctx.id).await;
|
||||
let thread_url = build_discussion_thread_url(course_id);
|
||||
let author_name: Option<String> = sqlx::query_scalar("SELECT full_name FROM users WHERE id = $1")
|
||||
.bind(claims.sub)
|
||||
.fetch_optional(&pool)
|
||||
@@ -502,7 +540,7 @@ pub async fn create_thread(
|
||||
thread.content.clone()
|
||||
})
|
||||
.bind("forum_thread")
|
||||
.bind(format!("/courses/{}#discussions", course_id))
|
||||
.bind(thread_url.clone())
|
||||
.execute(&pool)
|
||||
.await;
|
||||
}
|
||||
@@ -512,8 +550,8 @@ pub async fn create_thread(
|
||||
variables.insert("thread_title", thread.title.clone());
|
||||
variables.insert("author_name", author_display.clone());
|
||||
variables.insert("message_content", thread.content.clone());
|
||||
variables.insert("thread_url", format!("/courses/{}#discussions", course_id));
|
||||
variables.insert("organization_name", "OpenCCB".to_string()); // TODO: obtener de org
|
||||
variables.insert("thread_url", thread_url);
|
||||
variables.insert("organization_name", organization_name);
|
||||
|
||||
send_forum_email_notifications(&pool, org_ctx.id, &instructor_recipients, "forum_thread", &variables).await;
|
||||
|
||||
@@ -687,6 +725,7 @@ pub async fn create_post(
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<CreatePostPayload>,
|
||||
) -> Result<Json<DiscussionPost>, (StatusCode, String)> {
|
||||
let organization_name = load_organization_name(&pool, org_ctx.id).await;
|
||||
// Verificar si el hilo está bloqueado
|
||||
let thread =
|
||||
sqlx::query_as::<_, (bool, String, Uuid, Uuid)>(
|
||||
@@ -702,6 +741,8 @@ pub async fn create_post(
|
||||
return Err((StatusCode::FORBIDDEN, "El hilo está bloqueado".to_string()));
|
||||
}
|
||||
|
||||
let thread_url = build_discussion_thread_url(thread.2);
|
||||
|
||||
let author_name: Option<String> = sqlx::query_scalar("SELECT full_name FROM users WHERE id = $1")
|
||||
.bind(claims.sub)
|
||||
.fetch_optional(&pool)
|
||||
@@ -782,7 +823,7 @@ pub async fn create_post(
|
||||
post.content.clone()
|
||||
})
|
||||
.bind("forum_reply")
|
||||
.bind(format!("/courses/{}#discussions", thread.2))
|
||||
.bind(thread_url.clone())
|
||||
.execute(&pool)
|
||||
.await;
|
||||
}
|
||||
@@ -792,8 +833,8 @@ pub async fn create_post(
|
||||
variables.insert("thread_title", thread.1.clone());
|
||||
variables.insert("author_name", author_display.clone());
|
||||
variables.insert("message_content", post.content.clone());
|
||||
variables.insert("thread_url", format!("/courses/{}#discussions", thread.2));
|
||||
variables.insert("organization_name", "OpenCCB".to_string()); // TODO: obtener de org
|
||||
variables.insert("thread_url", thread_url);
|
||||
variables.insert("organization_name", organization_name);
|
||||
|
||||
send_forum_email_notifications(&pool, org_ctx.id, &recipients, "forum_reply", &variables).await;
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ mod handlers_peer_review;
|
||||
mod handlers_embeddings;
|
||||
mod handlers_faq;
|
||||
mod handlers_certificates;
|
||||
mod progress_tracking;
|
||||
mod lti;
|
||||
mod jwks;
|
||||
mod predictive;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
use sqlx::{PgPool, Row};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CourseCompletionMetrics {
|
||||
pub total_lessons: i64,
|
||||
pub completed_lessons: i64,
|
||||
pub progress_percentage: f64,
|
||||
pub completed: bool,
|
||||
}
|
||||
|
||||
pub async fn calculate_course_completion(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
course_id: Uuid,
|
||||
) -> Result<CourseCompletionMetrics, sqlx::Error> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
COALESCE(totals.total_lessons, 0)::bigint AS total_lessons,
|
||||
LEAST(
|
||||
COALESCE(totals.total_lessons, 0)::bigint,
|
||||
COALESCE(graded.graded_done, 0)::bigint + COALESCE(ungraded.ungraded_done, 0)::bigint
|
||||
) AS completed_lessons
|
||||
FROM (SELECT 1) seed
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*)::bigint AS total_lessons
|
||||
FROM lessons l
|
||||
JOIN modules m ON m.id = l.module_id
|
||||
WHERE m.course_id = $2
|
||||
) totals ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(DISTINCT ug.lesson_id)::bigint AS graded_done
|
||||
FROM user_grades ug
|
||||
JOIN lessons l ON l.id = ug.lesson_id
|
||||
JOIN modules m ON m.id = l.module_id
|
||||
WHERE ug.user_id = $1
|
||||
AND m.course_id = $2
|
||||
AND l.is_graded = true
|
||||
AND (ug.score * 100.0) >= COALESCE(l.passing_percentage::double precision, 60.0)
|
||||
) graded ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(DISTINCT li.lesson_id)::bigint AS ungraded_done
|
||||
FROM lesson_interactions li
|
||||
JOIN lessons l ON l.id = li.lesson_id
|
||||
JOIN modules m ON m.id = l.module_id
|
||||
WHERE li.user_id = $1
|
||||
AND li.event_type = 'complete'
|
||||
AND m.course_id = $2
|
||||
AND l.is_graded = false
|
||||
) ungraded ON TRUE
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(course_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
let total_lessons = row.get::<i64, _>("total_lessons");
|
||||
let completed_lessons = row.get::<i64, _>("completed_lessons");
|
||||
let progress_percentage = if total_lessons > 0 {
|
||||
(completed_lessons as f64 / total_lessons as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
Ok(CourseCompletionMetrics {
|
||||
total_lessons,
|
||||
completed_lessons,
|
||||
progress_percentage,
|
||||
completed: total_lessons > 0 && completed_lessons >= total_lessons,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user