Files
openccb/services/lms-service/src/predictive.rs
T
Nurfog 53e5ef4d0b feat: Translate various strings and comments to Spanish for better localization
- Updated error messages and comments in main.rs, openapi.rs, portfolio.rs, predictive.rs, ai.rs, health.rs, middleware.rs, models.rs, token_limits.rs, and webhooks.rs to Spanish.
- Enhanced user experience by providing localized content for Spanish-speaking users.
2026-04-10 10:26:26 -04:00

137 lines
4.7 KiB
Rust

use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use chrono::{Utc, Duration};
use serde_json::json;
use sqlx::{PgPool, Row};
use uuid::Uuid;
use common::auth::Claims;
use common::models::{DropoutRisk, DropoutRiskLevel, DropoutRiskReason};
pub async fn get_course_dropout_risks(
Path(course_id): Path<Uuid>,
State(pool): State<PgPool>,
claims: Claims,
) -> Result<Json<Vec<DropoutRisk>>, (StatusCode, String)> {
if claims.role == "student" {
return Err((StatusCode::FORBIDDEN, "Solo los instructores pueden ver informes de riesgo".to_string()));
}
calculate_risks_for_course(&pool, course_id, claims.org).await
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let rows = sqlx::query(
r#"
SELECT id, organization_id, course_id, user_id, risk_level, score, reasons, last_calculated_at, created_at, updated_at
FROM dropout_risks
WHERE course_id = $1 AND organization_id = $2
ORDER BY score DESC
"#,
)
.bind(course_id)
.bind(claims.org)
.fetch_all(&pool)
.await
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los riesgos: {}", e)))?;
let risks: Vec<DropoutRisk> = rows.into_iter().map(|row| {
DropoutRisk {
id: row.get("id"),
organization_id: row.get("organization_id"),
course_id: row.get("course_id"),
user_id: row.get("user_id"),
risk_level: row.get("risk_level"),
score: row.get("score"),
reasons: row.get("reasons"),
last_calculated_at: row.get("last_calculated_at"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
}
}).collect();
Ok(Json(risks))
}
pub async fn calculate_risks_for_course(
pool: &PgPool,
course_id: Uuid,
organization_id: Uuid,
) -> Result<(), sqlx::Error> {
let enrollments = sqlx::query("SELECT user_id FROM enrollments WHERE course_id = $1 AND organization_id = $2")
.bind(course_id)
.bind(organization_id)
.fetch_all(pool)
.await?;
for enrollment in enrollments {
let user_id: Uuid = enrollment.get("user_id");
let avg_grade: f32 = sqlx::query("SELECT COALESCE(AVG(score), 0.0) FROM user_grades WHERE user_id = $1 AND course_id = $2")
.bind(user_id)
.bind(course_id)
.fetch_one(pool)
.await?
.get::<f64, _>(0) as f32; // AVG returns f64 usually
let last_activity_count: i64 = sqlx::query("SELECT COUNT(*) FROM lesson_interactions WHERE user_id = $1 AND created_at > $2")
.bind(user_id)
.bind(Utc::now() - Duration::days(7))
.fetch_one(pool)
.await?
.get(0);
let forum_posts: i64 = sqlx::query("SELECT COUNT(*) FROM discussion_posts WHERE author_id = $1 AND organization_id = $2")
.bind(user_id)
.bind(organization_id)
.fetch_one(pool)
.await?
.get(0);
let perf_risk = (1.0 - avg_grade).max(0.0);
let activity_risk = (1.0 / (last_activity_count as f32 + 1.0)).min(1.0);
let social_risk = (1.0 / (forum_posts as f32 + 1.0)).min(1.0);
let total_score = (perf_risk * 0.5) + (activity_risk * 0.4) + (social_risk * 0.1);
let risk_level = if total_score > 0.8 {
DropoutRiskLevel::Critical
} else if total_score > 0.5 {
DropoutRiskLevel::High
} else if total_score > 0.3 {
DropoutRiskLevel::Medium
} else {
DropoutRiskLevel::Low
};
let reasons = vec![
DropoutRiskReason { metric: "performance".to_string(), value: avg_grade, description: format!("Calificación: {:.0}%", avg_grade * 100.0) },
DropoutRiskReason { metric: "activity".to_string(), value: last_activity_count as f32, description: format!("{} acciones en la última semana", last_activity_count) },
];
sqlx::query(
r#"
INSERT INTO dropout_risks (organization_id, course_id, user_id, risk_level, score, reasons, last_calculated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
ON CONFLICT (course_id, user_id) DO UPDATE SET
risk_level = EXCLUDED.risk_level,
score = EXCLUDED.score,
reasons = EXCLUDED.reasons,
last_calculated_at = EXCLUDED.last_calculated_at,
updated_at = NOW()
"#,
)
.bind(organization_id)
.bind(course_id)
.bind(user_id)
.bind(risk_level)
.bind(total_score)
.bind(json!(reasons))
.execute(pool)
.await?;
}
Ok(())
}