From 2b01d5d3f4b59c09f86f428b7164971af429d8e5 Mon Sep 17 00:00:00 2001 From: Nurfog Date: Thu, 2 Apr 2026 14:08:48 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20Mejorar=20la=20gesti=C3=B3n=20de=20plan?= =?UTF-8?q?tillas=20de=20prueba=20y=20agregar=20validaciones=20para=20la?= =?UTF-8?q?=20composici=C3=B3n=20de=20preguntas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/cms-service/src/external_handlers.rs | 239 +++++++++++++++++- .../src/handlers_test_templates.rs | 77 +++++- .../TestTemplates/TestTemplateForm.tsx | 217 ++++++++++++++-- .../TestTemplates/TestTemplateManager.tsx | 163 +++--------- 4 files changed, 527 insertions(+), 169 deletions(-) diff --git a/services/cms-service/src/external_handlers.rs b/services/cms-service/src/external_handlers.rs index acecc1a..e1ed0ac 100644 --- a/services/cms-service/src/external_handlers.rs +++ b/services/cms-service/src/external_handlers.rs @@ -4,6 +4,7 @@ use axum::{ http::{StatusCode, HeaderMap}, }; use common::models::Course; +use serde::Deserialize; use serde_json::json; use sqlx::PgPool; use uuid::Uuid; @@ -26,16 +27,19 @@ async fn validate_api_key(headers: &HeaderMap, pool: &PgPool) -> Result, headers: HeaderMap, - Json(payload): Json, -) -> Result, StatusCode> { + Json(payload): Json, +) -> Result, StatusCode> { let org_id = validate_api_key(&headers, &pool).await?; // We reuse the internal logic but with the org_id from the API key // We need to provide a mock claims for handlers::create_course or refactor it. // Simplifying for now: direct DB call or calling handlers with constructed context. - let title = payload.get("title").and_then(|t| t.as_str()).ok_or(StatusCode::BAD_REQUEST)?; - let description = payload.get("description").and_then(|d| d.as_str()); + let title = payload.title.trim(); + if title.is_empty() { + return Err(StatusCode::BAD_REQUEST); + } + let description = payload.description.as_deref(); let course = sqlx::query_as::<_, Course>( "INSERT INTO courses (organization_id, title, description, instructor_id, pacing_mode) @@ -44,7 +48,7 @@ pub async fn create_course_external( .bind(org_id) .bind(title) .bind(description) - .bind(payload.get("pacing_mode").and_then(|p| p.as_str()).unwrap_or("self_paced")) + .bind(payload.pacing_mode.as_deref().unwrap_or("self_paced")) .fetch_one(&pool) .await .map_err(|e| { @@ -52,7 +56,230 @@ pub async fn create_course_external( StatusCode::INTERNAL_SERVER_ERROR })?; - Ok(Json(course)) + let selected_template_id = resolve_template_id( + &pool, + org_id, + payload.template_id, + payload.template_level.as_deref(), + payload.template_course_type.as_deref(), + payload.template_test_type.as_deref(), + ) + .await?; + + let mut lesson_id: Option = None; + if let Some(template_id) = selected_template_id { + lesson_id = Some( + create_course_lesson_and_apply_template( + &pool, + org_id, + course.id, + template_id, + payload.module_title.as_deref().unwrap_or("Evaluaciones"), + payload.lesson_title.as_deref().unwrap_or("Evaluación inicial"), + ) + .await?, + ); + } + + Ok(Json(json!({ + "course": course, + "template_applied": selected_template_id.is_some(), + "template_id": selected_template_id, + "lesson_id": lesson_id, + }))) +} + +#[derive(Debug, Deserialize)] +pub struct ExternalCreateCoursePayload { + pub title: String, + pub description: Option, + pub pacing_mode: Option, + // Optional direct template selection + pub template_id: Option, + // Optional fallback selection by level/course type/test type + pub template_level: Option, + pub template_course_type: Option, + pub template_test_type: Option, + pub module_title: Option, + pub lesson_title: Option, +} + +async fn resolve_template_id( + pool: &PgPool, + org_id: Uuid, + direct_template_id: Option, + level: Option<&str>, + course_type: Option<&str>, + test_type: Option<&str>, +) -> Result, StatusCode> { + if let Some(template_id) = direct_template_id { + let exists: Option = sqlx::query_scalar( + "SELECT id FROM test_templates WHERE id = $1 AND organization_id = $2 AND is_active = true" + ) + .bind(template_id) + .bind(org_id) + .fetch_optional(pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if exists.is_none() { + return Err(StatusCode::BAD_REQUEST); + } + return Ok(Some(template_id)); + } + + if level.is_none() && course_type.is_none() && test_type.is_none() { + return Ok(None); + } + + let selected: Option = sqlx::query_scalar( + r#" + SELECT id + FROM test_templates + WHERE organization_id = $1 + AND is_active = true + AND ($2::course_level IS NULL OR level = $2::course_level) + AND ($3::course_type IS NULL OR course_type = $3::course_type) + AND ($4::test_type IS NULL OR test_type = $4::test_type) + ORDER BY updated_at DESC + LIMIT 1 + "#, + ) + .bind(org_id) + .bind(level) + .bind(course_type) + .bind(test_type) + .fetch_optional(pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(selected) +} + +async fn create_course_lesson_and_apply_template( + pool: &PgPool, + org_id: Uuid, + course_id: Uuid, + template_id: Uuid, + module_title: &str, + lesson_title: &str, +) -> Result { + let module_id: Uuid = sqlx::query_scalar( + r#" + INSERT INTO modules (course_id, organization_id, title, position) + VALUES ($1, $2, $3, 1) + RETURNING id + "#, + ) + .bind(course_id) + .bind(org_id) + .bind(module_title) + .fetch_one(pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let lesson_id: Uuid = sqlx::query_scalar( + r#" + INSERT INTO lessons (module_id, organization_id, title, content_type, position, is_graded, max_attempts, allow_retry) + VALUES ($1, $2, $3, 'quiz', 1, true, 1, false) + RETURNING id + "#, + ) + .bind(module_id) + .bind(org_id) + .bind(lesson_title) + .fetch_one(pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let template: (Uuid, String, String, i32, i32, i32, Option) = sqlx::query_as( + r#" + SELECT id, name, test_type::text, duration_minutes, passing_score, total_points, instructions + FROM test_templates + WHERE id = $1 AND organization_id = $2 + "#, + ) + .bind(template_id) + .bind(org_id) + .fetch_one(pool) + .await + .map_err(|_| StatusCode::BAD_REQUEST)?; + + let template_questions: Vec<(Uuid, String, String, Option, Option, Option, i32)> = sqlx::query_as( + r#" + SELECT id, question_type, question_text, options, correct_answer, explanation, points + FROM test_template_questions + WHERE template_id = $1 + ORDER BY section_id, question_order + "#, + ) + .bind(template_id) + .fetch_all(pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if template.2 == "CA" && template_questions.len() < 4 { + return Err(StatusCode::BAD_REQUEST); + } + + if template.2 != "CA" && template_questions.len() != 1 { + return Err(StatusCode::BAD_REQUEST); + } + + let questions_json: Vec = template_questions + .iter() + .map(|q| { + json!({ + "id": q.0.to_string(), + "type": q.1, + "question": q.2, + "options": q.3, + "correct": q.4, + "explanation": q.5, + "points": q.6, + }) + }) + .collect(); + + let quiz_data = json!({ + "questions": questions_json, + "template_id": template.0.to_string(), + "template_name": template.1, + "test_type": template.2, + "duration_minutes": template.3, + "passing_score": template.4, + "total_points": template.5, + "instructions": template.6, + "max_attempts": 1, + "show_feedback": true, + "permanent_history": true, + }); + + sqlx::query( + r#" + UPDATE lessons + SET content_type = 'quiz', + metadata = $1, + is_graded = true, + max_attempts = 1, + allow_retry = false, + updated_at = NOW() + WHERE id = $2 AND organization_id = $3 + "#, + ) + .bind(&quiz_data) + .bind(lesson_id) + .bind(org_id) + .execute(pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let _ = sqlx::query("SELECT increment_template_usage($1)") + .bind(template_id) + .execute(pool) + .await; + + Ok(lesson_id) } pub async fn get_course_external( diff --git a/services/cms-service/src/handlers_test_templates.rs b/services/cms-service/src/handlers_test_templates.rs index 9038981..8550e63 100644 --- a/services/cms-service/src/handlers_test_templates.rs +++ b/services/cms-service/src/handlers_test_templates.rs @@ -555,6 +555,21 @@ pub async fn apply_template_to_lesson( return Err((StatusCode::BAD_REQUEST, "Template has no questions".to_string())); } + // Business rules for template composition. + if matches!(template.test_type, TestType::CA) && template_questions.len() < 4 { + return Err(( + StatusCode::BAD_REQUEST, + "Las plantillas CA deben tener minimo 4 preguntas".to_string(), + )); + } + + if !matches!(template.test_type, TestType::CA) && template_questions.len() != 1 { + return Err(( + StatusCode::BAD_REQUEST, + "Las plantillas MWT, MOT, FOT y FWT deben tener exactamente 1 pregunta".to_string(), + )); + } + // Build quiz_data JSON from template questions let questions_json: Vec = template_questions .iter() @@ -1452,17 +1467,29 @@ pub async fn generate_questions_with_rag( (shuffled_options, new_correct_idx) }; - // Convert to TestTemplateQuestion format + // Convert to TestTemplateQuestion format and skip invalid LLM entries let generated_questions: Vec = questions_data .iter() .enumerate() - .map(|(idx, q)| { + .filter_map(|(idx, q)| { let question_type_value = q .get("question_type") .and_then(|v| v.as_str()) .unwrap_or(&requested_question_type) .to_string(); + let question_text = q + .get("question_text") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + + if question_text.is_empty() || question_text.eq_ignore_ascii_case("question") { + tracing::warn!("Skipping invalid generated question with empty placeholder text: {:?}", q); + return None; + } + // Get original options and correct answer let original_options: Vec = q .get("options") @@ -1483,6 +1510,10 @@ pub async fn generate_questions_with_rag( let (options, correct_answer, options_shuffled) = match question_type_value.as_str() { "multiple-choice" => { + if original_options.len() < 2 { + tracing::warn!("Skipping invalid multiple-choice question without enough options: {:?}", q); + return None; + } if !original_options.is_empty() && original_correct_idx.is_some() { let correct_idx = original_correct_idx.unwrap(); if correct_idx < original_options.len() { @@ -1510,10 +1541,23 @@ pub async fn generate_questions_with_rag( .or(q.get("correct")) .and_then(|v| v.as_bool()) .map(|v| if v { json!(0) } else { json!(1) }); + if bool_answer.is_none() { + tracing::warn!("Skipping invalid true-false question without boolean correct answer: {:?}", q); + return None; + } (Some(json!(["True", "False"])), bool_answer, false) } "matching" => { let pairs = q.get("pairs").cloned().or_else(|| q.get("options").cloned()); + let is_valid = pairs + .as_ref() + .and_then(|v| v.as_array()) + .map(|arr| !arr.is_empty()) + .unwrap_or(false); + if !is_valid { + tracing::warn!("Skipping invalid matching question without pairs: {:?}", q); + return None; + } (pairs.clone(), pairs, false) } "ordering" => { @@ -1523,10 +1567,33 @@ pub async fn generate_questions_with_rag( .cloned() .or_else(|| q.get("correct_answer").cloned()) .or_else(|| q.get("correct").cloned()); + let has_items = items + .as_ref() + .and_then(|v| v.as_array()) + .map(|arr| arr.len() >= 2) + .unwrap_or(false); + let has_order = order + .as_ref() + .and_then(|v| v.as_array()) + .map(|arr| !arr.is_empty()) + .unwrap_or(false); + if !has_items || !has_order { + tracing::warn!("Skipping invalid ordering question without items/order: {:?}", q); + return None; + } (items, order, false) } "fill-in-the-blanks" => { let blanks = q.get("blanks").cloned(); + let has_blanks = blanks + .as_ref() + .and_then(|v| v.as_array()) + .map(|arr| !arr.is_empty()) + .unwrap_or(false); + if !has_blanks { + tracing::warn!("Skipping invalid fill-in-the-blanks question without blanks array: {:?}", q); + return None; + } (blanks.clone(), blanks, false) } _ => ( @@ -1536,13 +1603,13 @@ pub async fn generate_questions_with_rag( ), }; - TestTemplateQuestion { + Some(TestTemplateQuestion { id: Uuid::new_v4(), template_id: Uuid::nil(), section_id: None, question_order: idx as i32, question_type: question_type_value, - question_text: q.get("question_text").and_then(|v| v.as_str()).unwrap_or("Question").to_string(), + question_text, options, correct_answer, explanation: q.get("explanation").and_then(|v| v.as_str()).map(String::from), @@ -1555,7 +1622,7 @@ pub async fn generate_questions_with_rag( "options_shuffled": options_shuffled, })), created_at: chrono::Utc::now(), - } + }) }) .collect(); diff --git a/web/studio/src/components/TestTemplates/TestTemplateForm.tsx b/web/studio/src/components/TestTemplates/TestTemplateForm.tsx index 2d472c4..13fd524 100644 --- a/web/studio/src/components/TestTemplates/TestTemplateForm.tsx +++ b/web/studio/src/components/TestTemplates/TestTemplateForm.tsx @@ -19,7 +19,7 @@ interface Question { question_order: number; question_type: QuestionType; question_text: string; - options?: string[]; + options?: unknown; correct_answer?: unknown; explanation?: string; points: number; @@ -27,11 +27,12 @@ interface Question { } interface TestTemplateFormProps { + templateId?: string; onSuccess?: () => void; onCancel?: () => void; } -export default function TestTemplateForm({ onSuccess, onCancel }: TestTemplateFormProps) { +export default function TestTemplateForm({ templateId, onSuccess, onCancel }: TestTemplateFormProps) { const [formData, setFormData] = useState({ name: '', description: '', @@ -62,6 +63,7 @@ export default function TestTemplateForm({ onSuccess, onCancel }: TestTemplateFo const [selectedCourseId, setSelectedCourseId] = useState(''); const [loadingPlans, setLoadingPlans] = useState(false); const [loadingCourses, setLoadingCourses] = useState(false); + const isEditing = Boolean(templateId); // Load MySQL plans on mount useEffect(() => { @@ -100,6 +102,86 @@ export default function TestTemplateForm({ onSuccess, onCancel }: TestTemplateFo loadCourses(); }, [selectedPlanId]); + useEffect(() => { + if (!templateId) return; + + const loadTemplateForEdit = async () => { + try { + setSaving(true); + const data = await cmsApi.getTestTemplate(templateId); + + setFormData({ + name: data.template.name, + description: data.template.description || '', + mysql_course_id: data.template.mysql_course_id, + level: data.template.level, + course_type: data.template.course_type, + test_type: data.template.test_type, + duration_minutes: data.template.duration_minutes, + passing_score: data.template.passing_score, + total_points: data.template.total_points, + instructions: data.template.instructions || '', + template_data: data.template.template_data || { sections: [], questions: [] }, + tags: data.template.tags || [], + }); + + setSections( + (data.sections || []).map((section) => ({ + id: section.id, + title: section.title, + description: section.description || '', + section_order: section.section_order, + points: section.points, + instructions: section.instructions || '', + })) + ); + + setQuestions( + (data.questions || []).map((question) => { + const allowedTypes: QuestionType[] = [ + 'multiple-choice', + 'true-false', + 'short-answer', + 'essay', + 'matching', + 'ordering', + 'fill-in-the-blanks', + 'audio-response', + ]; + const normalizedType = allowedTypes.includes(question.question_type) + ? question.question_type + : 'multiple-choice'; + + return { + id: question.id, + section_id: question.section_id, + question_order: question.question_order, + question_type: normalizedType, + question_text: question.question_text, + options: question.options, + correct_answer: question.correct_answer, + explanation: question.explanation || '', + points: question.points, + metadata: question.metadata, + }; + }) + ); + + if (data.template.mysql_course_id) { + setSelectedCourseId(data.template.mysql_course_id); + } + } catch (error) { + console.error('Failed to load template for edit:', error); + alert('No se pudo cargar la plantilla para editar'); + onCancel?.(); + } finally { + setSaving(false); + } + }; + + loadTemplateForEdit(); + }, [templateId, onCancel]); + // Handle course selection - store mysql_course_id (preferred approach) const handleCourseSelect = (courseId: number | '') => { setSelectedCourseId(courseId); @@ -123,6 +205,19 @@ export default function TestTemplateForm({ onSuccess, onCancel }: TestTemplateFo return; } + // Business rules by test type: + // - CA must have at least 4 questions. + // - MWT/MOT/FOT/FWT must have exactly 1 question. + if (formData.test_type === 'CA' && questions.length < 4) { + alert('Las plantillas CA deben tener minimo 4 preguntas.'); + return; + } + + if (formData.test_type !== 'CA' && questions.length !== 1) { + alert('Las plantillas MWT, MOT, FOT y FWT deben tener exactamente 1 pregunta.'); + return; + } + // Validate: either mysql_course_id OR level+course_type must be provided if (!formData.mysql_course_id && (!formData.level || !formData.course_type)) { alert('Debes seleccionar un curso de MySQL o especificar nivel y tipo de curso manualmente'); @@ -132,24 +227,58 @@ export default function TestTemplateForm({ onSuccess, onCancel }: TestTemplateFo try { setSaving(true); - // Primero crear la plantilla - const template = await cmsApi.createTestTemplate(formData); - - // Luego agregar secciones + let targetTemplateId = templateId; + + if (isEditing && templateId) { + await cmsApi.updateTestTemplate(templateId, { + name: formData.name, + description: formData.description, + mysql_course_id: formData.mysql_course_id, + level: formData.level, + course_type: formData.course_type, + test_type: formData.test_type, + duration_minutes: formData.duration_minutes, + passing_score: formData.passing_score, + total_points: formData.total_points, + instructions: formData.instructions, + template_data: formData.template_data, + tags: formData.tags, + }); + + const existingData = await cmsApi.getTestTemplate(templateId); + + for (const existingQuestion of existingData.questions) { + await cmsApi.deleteTemplateQuestion(templateId, existingQuestion.id); + } + + for (const existingSection of existingData.sections) { + await cmsApi.deleteTemplateSection(templateId, existingSection.id); + } + } else { + const template = await cmsApi.createTestTemplate(formData); + targetTemplateId = template.id; + } + + if (!targetTemplateId) { + throw new Error('No se pudo determinar la plantilla a guardar'); + } + + const sectionIdMap = new Map(); + for (const section of sections) { - await cmsApi.createTemplateSection(template.id, { + const createdSection = await cmsApi.createTemplateSection(targetTemplateId, { title: section.title, description: section.description, section_order: section.section_order, points: section.points, instructions: section.instructions, }); + sectionIdMap.set(section.id, createdSection.id); } - - // Finalmente agregar preguntas + for (const question of questions) { - await cmsApi.createTemplateQuestion(template.id, { - section_id: question.section_id, + await cmsApi.createTemplateQuestion(targetTemplateId, { + section_id: question.section_id ? sectionIdMap.get(question.section_id) : undefined, question_order: question.question_order, question_type: question.question_type, question_text: question.question_text, @@ -160,8 +289,8 @@ export default function TestTemplateForm({ onSuccess, onCancel }: TestTemplateFo metadata: question.metadata, }); } - - alert('Plantilla creada exitosamente'); + + alert(isEditing ? 'Plantilla actualizada exitosamente' : 'Plantilla creada exitosamente'); onSuccess?.(); } catch (error) { console.error('Failed to create template:', error); @@ -319,6 +448,8 @@ export default function TestTemplateForm({ onSuccess, onCancel }: TestTemplateFo 'essay': 'Ensayo', 'matching': 'Emparejamiento', 'ordering': 'Ordenar', + 'fill-in-the-blanks': 'Completar espacios', + 'audio-response': 'Respuesta de audio', }; return labels[type] || type; }; @@ -329,8 +460,12 @@ export default function TestTemplateForm({ onSuccess, onCancel }: TestTemplateFo {/* Header */}
-

Nueva Plantilla de Prueba

-

Crea preguntas y secciones para tu evaluación

+

+ {isEditing ? 'Editar Plantilla de Prueba' : 'Nueva Plantilla de Prueba'} +

+

+ {isEditing ? 'Modifica preguntas y secciones de tu evaluación' : 'Crea preguntas y secciones para tu evaluación'} +

@@ -810,7 +964,7 @@ export default function TestTemplateForm({ onSuccess, onCancel }: TestTemplateFo - {question.options?.map((option, oIdx) => ( + {(Array.isArray(question.options) ? question.options : []).map((option, oIdx) => (
{ - const newOptions = [...(question.options || [])]; - newOptions[oIdx] = e.target.value; - handleUpdateQuestion(question.id, { options: newOptions }); + const currentOptions = Array.isArray(question.options) + ? [...question.options] + : []; + currentOptions[oIdx] = e.target.value; + handleUpdateQuestion(question.id, { options: currentOptions }); }} className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm" placeholder={`Opción ${oIdx + 1}`} @@ -833,7 +989,10 @@ export default function TestTemplateForm({ onSuccess, onCancel }: TestTemplateFo
- +
- {applyPlanId && ( + {selectedTargetCourseId && (
- - - {applyCoursesError && ( -

{applyCoursesError}

- )} -
- )} - - {applyCourseId && ( -
- - {matchingPlatformCourses.length > 0 ? ( -
- {matchingPlatformCourses[0].title} -
- ) : ( -
- No se encontró un curso de plataforma compatible para este curso SAM. -
- )} -
- )} - - {selectedPlatformCourseId && ( -
- + @@ -573,7 +470,7 @@ function LessonSelector({ selectedLesson: string; onSelect: (lessonId: string) => void; }) { - const [modules, setModules] = useState([]); + const [modules, setModules] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { @@ -610,7 +507,7 @@ function LessonSelector({ {modules.map((mod) => ( - {(mod.lessons || []).map((lesson: any) => ( + {(mod.lessons || []).map((lesson: Lesson) => (