feat: enhance asset import functionality and unit tracking

- Added WHISPER_URL environment variable to docker-compose for audio transcription service.
- Updated Nginx configuration to increase timeout settings for API requests.
- Enhanced asset ingestion process to extract unit numbers from ZIP entry paths, supporting various naming conventions.
- Implemented logic to split intensive courses into two regular courses during asset import.
- Added new fields to the Asset and QuestionBank models to track unit numbers and source asset links.
- Introduced backward-compatible fallbacks for fetching study plans and courses from legacy MySQL database.
- Improved error handling and progress tracking during ZIP file uploads in the frontend.
- Created a new SQL migration to add unit_number and source_asset_id columns to the assets and question_bank tables, along with necessary indexes for performance.
This commit is contained in:
2026-04-07 13:38:22 -04:00
parent 7f9b9d69ae
commit 024bd6e46d
11 changed files with 687 additions and 101 deletions
@@ -864,7 +864,7 @@ pub async fn get_mysql_plans(
State(pool): State<PgPool>,
) -> Result<Json<Vec<MySqlPlanInfo>>, (StatusCode, String)> {
// Read from SAM mirror in PostgreSQL with SAM-native fields.
let plans: Vec<MySqlPlanInfo> = sqlx::query_as(
let mut plans: Vec<MySqlPlanInfo> = sqlx::query_as(
r#"
SELECT
idPlanDeEstudios AS id_plan_de_estudios,
@@ -879,6 +879,111 @@ pub async fn get_mysql_plans(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch plans: {}", e)))?;
// Backward-compatible fallback: if SAM mirror is empty, use legacy metadata mirror.
if plans.is_empty() {
plans = sqlx::query_as(
r#"
SELECT
mysql_id AS id_plan_de_estudios,
name AS nombre_plan
FROM mysql_study_plans
WHERE organization_id = $1 AND is_active = TRUE
ORDER BY name
"#,
)
.bind(org_ctx.id)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch legacy plans: {}", e)))?;
}
// Last-resort auto-sync: if still empty, pull metadata from MySQL and persist it.
if plans.is_empty() {
match connect_mysql_pool("MYSQL_DATABASE_URL").await {
Ok(mysql_pool) => {
let mysql_plans: Result<Vec<MySqlPlanInfo>, sqlx::Error> = sqlx::query_as(
r#"
SELECT DISTINCT
pe.idPlanDeEstudios AS id_plan_de_estudios,
pe.Nombre AS nombre_plan
FROM plandeestudios pe
WHERE pe.Activo = 1
ORDER BY pe.Nombre
"#,
)
.fetch_all(&mysql_pool)
.await;
let mysql_courses: Result<Vec<MySqlCourseInfo>, sqlx::Error> = sqlx::query_as(
r#"
SELECT DISTINCT
c.idCursos AS id_cursos,
c.NombreCurso AS nombre_curso,
c.NivelCurso AS nivel_curso,
pe.idPlanDeEstudios AS id_plan_de_estudios,
pe.Nombre AS nombre_plan,
c.Duracion AS duracion
FROM curso c
JOIN plandeestudios pe ON c.idPlanDeEstudios = pe.idPlanDeEstudios
WHERE c.Activo = 1
AND pe.Activo = 1
ORDER BY pe.Nombre, c.NivelCurso
"#,
)
.fetch_all(&mysql_pool)
.await;
match (mysql_plans, mysql_courses) {
(Ok(p), Ok(c)) => {
if let Err(err) = save_mysql_courses_and_plans(&pool, org_ctx.id, p, c).await {
tracing::warn!("Auto-sync MySQL metadata failed: {}", err);
}
}
(Err(e), _) => tracing::warn!("Auto-sync plans query failed: {}", e),
(_, Err(e)) => tracing::warn!("Auto-sync courses query failed: {}", e),
}
mysql_pool.close().await;
}
Err(e) => {
tracing::warn!("Auto-sync could not connect to MySQL: {:?}", e);
}
}
// Reload plans after auto-sync attempt.
plans = sqlx::query_as(
r#"
SELECT
idPlanDeEstudios AS id_plan_de_estudios,
Nombre AS nombre_plan
FROM sam_study_plans
WHERE organization_id = $1 AND Activo = TRUE
ORDER BY Nombre
"#,
)
.bind(org_ctx.id)
.fetch_all(&pool)
.await
.unwrap_or_default();
if plans.is_empty() {
plans = sqlx::query_as(
r#"
SELECT
mysql_id AS id_plan_de_estudios,
name AS nombre_plan
FROM mysql_study_plans
WHERE organization_id = $1 AND is_active = TRUE
ORDER BY name
"#,
)
.bind(org_ctx.id)
.fetch_all(&pool)
.await
.unwrap_or_default();
}
}
Ok(Json(plans))
}
@@ -889,7 +994,7 @@ pub async fn get_mysql_courses_by_plan(
Query(filters): Query<MySqlCoursesFilters>,
) -> Result<Json<Vec<MySqlCourseInfo>>, (StatusCode, String)> {
// Read from SAM mirror in PostgreSQL with SAM-native fields.
let courses: Vec<MySqlCourseInfo> = sqlx::query_as(
let mut courses: Vec<MySqlCourseInfo> = sqlx::query_as(
r#"
SELECT
c.idCursos AS id_cursos,
@@ -915,6 +1020,33 @@ pub async fn get_mysql_courses_by_plan(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch courses: {}", e)))?;
// Backward-compatible fallback: if SAM mirror is empty, use legacy metadata mirror.
if courses.is_empty() {
courses = sqlx::query_as(
r#"
SELECT
c.mysql_id AS id_cursos,
c.name AS nombre_curso,
c.level AS nivel_curso,
sp.mysql_id AS id_plan_de_estudios,
sp.name AS nombre_plan,
c.duracion::double precision AS duracion
FROM mysql_courses c
JOIN mysql_study_plans sp ON c.study_plan_id = sp.id
WHERE c.organization_id = $1
AND c.is_active = TRUE
AND sp.is_active = TRUE
AND sp.mysql_id = $2
ORDER BY c.level
"#,
)
.bind(org_ctx.id)
.bind(filters.plan_id)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch legacy courses: {}", e)))?;
}
Ok(Json(courses))
}