feat: creacion de plantillas para pruebas, prototipo
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
-- Add course level and type enums
|
||||
CREATE TYPE course_level AS ENUM (
|
||||
'beginner',
|
||||
'beginner_1',
|
||||
'beginner_2',
|
||||
'intermediate',
|
||||
'intermediate_1',
|
||||
'intermediate_2',
|
||||
'advanced',
|
||||
'advanced_1',
|
||||
'advanced_2'
|
||||
);
|
||||
|
||||
CREATE TYPE course_type AS ENUM (
|
||||
'intensive',
|
||||
'regular'
|
||||
);
|
||||
|
||||
-- Add level and course_type to courses table
|
||||
ALTER TABLE courses
|
||||
ADD COLUMN level course_level,
|
||||
ADD COLUMN course_type course_type;
|
||||
|
||||
-- Add test type enum for assessment templates
|
||||
CREATE TYPE test_type AS ENUM (
|
||||
'CA', -- Continuous Assessment
|
||||
'MWT', -- Midterm Written Test
|
||||
'MOT', -- Midterm Oral Test
|
||||
'FOT', -- Final Oral Test
|
||||
'FWT' -- Final Written Test
|
||||
);
|
||||
|
||||
-- Test Templates table
|
||||
-- Stores reusable test/quiz templates that can be applied to courses based on level and type
|
||||
CREATE TABLE test_templates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
level course_level NOT NULL,
|
||||
course_type course_type NOT NULL,
|
||||
test_type test_type NOT NULL,
|
||||
duration_minutes INTEGER NOT NULL DEFAULT 60,
|
||||
passing_score INTEGER NOT NULL DEFAULT 70, -- 0-100 percentage
|
||||
total_points INTEGER NOT NULL DEFAULT 100,
|
||||
instructions TEXT,
|
||||
template_data JSONB NOT NULL, -- Complete test structure with sections and questions
|
||||
tags TEXT[], -- For easier searching and categorization
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0, -- Track how many times template has been used
|
||||
created_by UUID NOT NULL REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT test_templates_passing_score_range CHECK (passing_score >= 0 AND passing_score <= 100)
|
||||
);
|
||||
|
||||
-- Index for filtering templates by level, type, and test type
|
||||
CREATE INDEX idx_test_templates_filters ON test_templates(organization_id, level, course_type, test_type, is_active);
|
||||
|
||||
-- Index for searching templates by name
|
||||
CREATE INDEX idx_test_templates_name ON test_templates USING gin(to_tsvector('english', name));
|
||||
|
||||
-- Index for tags (array)
|
||||
CREATE INDEX idx_test_templates_tags ON test_templates USING GIN(tags);
|
||||
|
||||
-- Test Template Sections table
|
||||
-- Optional: Allows organizing questions into sections (e.g., "Reading Comprehension", "Grammar", "Listening")
|
||||
CREATE TABLE test_template_sections (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
template_id UUID NOT NULL REFERENCES test_templates(id) ON DELETE CASCADE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
section_order INTEGER NOT NULL DEFAULT 0,
|
||||
points INTEGER NOT NULL DEFAULT 0,
|
||||
instructions TEXT,
|
||||
section_data JSONB, -- Section-specific configuration (e.g., time limit, question count)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
UNIQUE(template_id, section_order)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_test_template_sections_template ON test_template_sections(template_id, section_order);
|
||||
|
||||
-- Test Template Questions table
|
||||
-- Stores individual questions for each template
|
||||
CREATE TABLE test_template_questions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
template_id UUID NOT NULL REFERENCES test_templates(id) ON DELETE CASCADE,
|
||||
section_id UUID REFERENCES test_template_sections(id) ON DELETE SET NULL,
|
||||
question_order INTEGER NOT NULL DEFAULT 0,
|
||||
question_type VARCHAR(50) NOT NULL, -- "multiple-choice", "true-false", "short-answer", "essay", "matching", "ordering"
|
||||
question_text TEXT NOT NULL,
|
||||
options JSONB, -- Array of options for multiple choice/select questions
|
||||
correct_answer JSONB, -- Can be index, array of indices, or text depending on question type
|
||||
explanation TEXT, -- Optional explanation shown after answering
|
||||
points INTEGER NOT NULL DEFAULT 1,
|
||||
metadata JSONB, -- Additional question metadata (e.g., difficulty, tags, media references)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT test_template_questions_points_positive CHECK (points > 0)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_test_template_questions_template ON test_template_questions(template_id, section_id, question_order);
|
||||
CREATE INDEX idx_test_template_questions_type ON test_template_questions(question_type);
|
||||
|
||||
-- Trigger to update updated_at timestamp for test_templates
|
||||
CREATE OR REPLACE FUNCTION update_test_templates_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_update_test_templates_updated_at
|
||||
BEFORE UPDATE ON test_templates
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_test_templates_updated_at();
|
||||
|
||||
-- Function to get templates filtered by course level and type
|
||||
CREATE OR REPLACE FUNCTION get_test_templates_by_filters(
|
||||
p_organization_id UUID,
|
||||
p_level course_level DEFAULT NULL,
|
||||
p_course_type course_type DEFAULT NULL,
|
||||
p_test_type test_type DEFAULT NULL,
|
||||
p_search_query TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS TABLE (
|
||||
id UUID,
|
||||
organization_id UUID,
|
||||
name VARCHAR,
|
||||
description TEXT,
|
||||
level course_level,
|
||||
course_type course_type,
|
||||
test_type test_type,
|
||||
duration_minutes INTEGER,
|
||||
passing_score INTEGER,
|
||||
total_points INTEGER,
|
||||
instructions TEXT,
|
||||
template_data JSONB,
|
||||
tags TEXT[],
|
||||
is_active BOOLEAN,
|
||||
usage_count INTEGER,
|
||||
created_by UUID,
|
||||
created_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ
|
||||
) AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
tt.id,
|
||||
tt.organization_id,
|
||||
tt.name,
|
||||
tt.description,
|
||||
tt.level,
|
||||
tt.course_type,
|
||||
tt.test_type,
|
||||
tt.duration_minutes,
|
||||
tt.passing_score,
|
||||
tt.total_points,
|
||||
tt.instructions,
|
||||
tt.template_data,
|
||||
tt.tags,
|
||||
tt.is_active,
|
||||
tt.usage_count,
|
||||
tt.created_by,
|
||||
tt.created_at,
|
||||
tt.updated_at
|
||||
FROM test_templates tt
|
||||
WHERE tt.organization_id = p_organization_id
|
||||
AND tt.is_active = true
|
||||
AND (p_level IS NULL OR tt.level = p_level)
|
||||
AND (p_course_type IS NULL OR tt.course_type = p_course_type)
|
||||
AND (p_test_type IS NULL OR tt.test_type = p_test_type)
|
||||
AND (p_search_query IS NULL OR tt.name ILIKE '%' || p_search_query || '%');
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Function to increment usage count when a template is applied
|
||||
CREATE OR REPLACE FUNCTION increment_template_usage(p_template_id UUID)
|
||||
RETURNS VOID AS $$
|
||||
BEGIN
|
||||
UPDATE test_templates
|
||||
SET usage_count = usage_count + 1
|
||||
WHERE id = p_template_id;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Comments for documentation
|
||||
COMMENT ON TABLE test_templates IS 'Reusable test/quiz templates organized by course level, type, and assessment type';
|
||||
COMMENT ON COLUMN test_templates.level IS 'Course level this template is designed for (beginner, intermediate, advanced)';
|
||||
COMMENT ON COLUMN test_templates.course_type IS 'Type of course (intensive or regular)';
|
||||
COMMENT ON COLUMN test_templates.test_type IS 'Assessment type (CA, MWT, MOT, FOT, FWT)';
|
||||
COMMENT ON COLUMN test_templates.template_data IS 'Complete test structure including sections, timing, and question references';
|
||||
COMMENT ON COLUMN test_templates.usage_count IS 'Number of times this template has been applied to courses';
|
||||
|
||||
COMMENT ON TABLE test_template_sections IS 'Optional sections within a test template (e.g., Reading, Grammar, Listening)';
|
||||
COMMENT ON COLUMN test_template_sections.section_data IS 'Section-specific configuration like time limits or special instructions';
|
||||
|
||||
COMMENT ON TABLE test_template_questions IS 'Individual questions belonging to test templates';
|
||||
COMMENT ON COLUMN test_template_questions.question_type IS 'Type of question: multiple-choice, true-false, short-answer, essay, matching, ordering';
|
||||
COMMENT ON COLUMN test_template_questions.options IS 'JSON array of answer options for multiple choice questions';
|
||||
COMMENT ON COLUMN test_template_questions.correct_answer IS 'Correct answer(s) - format depends on question type';
|
||||
COMMENT ON COLUMN test_template_questions.metadata IS 'Additional metadata like difficulty level, tags, or media references';
|
||||
@@ -0,0 +1,541 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use common::models::{
|
||||
CourseLevel, CourseType, CreateTestTemplatePayload, TestTemplate, TestTemplateQuestion,
|
||||
TestTemplateSection, TestTemplateWithQuestions, TestType, UpdateTestTemplatePayload,
|
||||
};
|
||||
use common::{auth::Claims, middleware::Org};
|
||||
use serde::Deserialize;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ==================== Query Parameters ====================
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TestTemplateFilters {
|
||||
pub level: Option<CourseLevel>,
|
||||
pub course_type: Option<CourseType>,
|
||||
pub test_type: Option<TestType>,
|
||||
pub tags: Option<String>, // Comma-separated list
|
||||
pub search: Option<String>,
|
||||
}
|
||||
|
||||
// ==================== Create ====================
|
||||
|
||||
/// POST /api/test-templates - Create a new test template
|
||||
pub async fn create_test_template(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<CreateTestTemplatePayload>,
|
||||
) -> Result<Json<TestTemplate>, (StatusCode, String)> {
|
||||
let template: TestTemplate = sqlx::query_as(
|
||||
r#"
|
||||
INSERT INTO test_templates (
|
||||
organization_id, created_by, name, description, level, course_type,
|
||||
test_type, duration_minutes, passing_score, total_points,
|
||||
instructions, template_data, tags
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING id, organization_id, created_by, name, description, level, course_type,
|
||||
test_type, duration_minutes, passing_score, total_points, instructions,
|
||||
template_data, tags, is_active, usage_count, created_at, updated_at
|
||||
"#
|
||||
)
|
||||
.bind(org_ctx.id)
|
||||
.bind(claims.sub)
|
||||
.bind(&payload.name)
|
||||
.bind(&payload.description)
|
||||
.bind(&payload.level)
|
||||
.bind(&payload.course_type)
|
||||
.bind(&payload.test_type)
|
||||
.bind(payload.duration_minutes)
|
||||
.bind(payload.passing_score)
|
||||
.bind(payload.total_points)
|
||||
.bind(&payload.instructions)
|
||||
.bind(&payload.template_data)
|
||||
.bind(payload.tags.as_deref())
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(template))
|
||||
}
|
||||
|
||||
// ==================== Read ====================
|
||||
|
||||
/// GET /api/test-templates - List test templates with filters
|
||||
pub async fn list_test_templates(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Query(filters): Query<TestTemplateFilters>,
|
||||
) -> Result<Json<Vec<TestTemplate>>, (StatusCode, String)> {
|
||||
// Base query
|
||||
let mut query = String::from("SELECT * FROM test_templates WHERE organization_id = $1");
|
||||
let mut param_count = 1;
|
||||
|
||||
// Filter by level
|
||||
if filters.level.is_some() {
|
||||
param_count += 1;
|
||||
query.push_str(&format!(" AND level = ${}", param_count));
|
||||
}
|
||||
|
||||
// Filter by course type
|
||||
if filters.course_type.is_some() {
|
||||
param_count += 1;
|
||||
query.push_str(&format!(" AND course_type = ${}", param_count));
|
||||
}
|
||||
|
||||
// Filter by test type
|
||||
if filters.test_type.is_some() {
|
||||
param_count += 1;
|
||||
query.push_str(&format!(" AND test_type = ${}", param_count));
|
||||
}
|
||||
|
||||
// Filter by tags (array overlap)
|
||||
if filters.tags.is_some() {
|
||||
param_count += 1;
|
||||
query.push_str(&format!(" AND tags && ${}", param_count));
|
||||
}
|
||||
|
||||
// Search in name and description
|
||||
if filters.search.is_some() {
|
||||
param_count += 1;
|
||||
query.push_str(&format!(
|
||||
" AND (name ILIKE ${0} OR description ILIKE ${0})",
|
||||
param_count
|
||||
));
|
||||
}
|
||||
|
||||
query.push_str(" ORDER BY created_at DESC");
|
||||
|
||||
// Build query with dynamic binds
|
||||
let mut sql_query = sqlx::query_as::<_, TestTemplate>(&query).bind(org_ctx.id);
|
||||
|
||||
if let Some(level) = &filters.level {
|
||||
sql_query = sql_query.bind(level);
|
||||
}
|
||||
|
||||
if let Some(course_type) = &filters.course_type {
|
||||
sql_query = sql_query.bind(course_type);
|
||||
}
|
||||
|
||||
if let Some(test_type) = &filters.test_type {
|
||||
sql_query = sql_query.bind(test_type);
|
||||
}
|
||||
|
||||
if let Some(tags_str) = &filters.tags {
|
||||
let tags: Vec<String> = tags_str.split(',').map(|s| s.trim().to_string()).collect();
|
||||
sql_query = sql_query.bind(tags);
|
||||
}
|
||||
|
||||
let search_pattern = filters.search.as_ref().map(|s| format!("%{}%", s));
|
||||
if let Some(ref pattern) = search_pattern {
|
||||
sql_query = sql_query.bind(pattern);
|
||||
}
|
||||
|
||||
let templates = sql_query
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(templates))
|
||||
}
|
||||
|
||||
/// GET /api/test-templates/:id - Get a specific test template with questions
|
||||
pub async fn get_test_template(
|
||||
Org(org_ctx): Org,
|
||||
Path(template_id): Path<Uuid>,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<Json<TestTemplateWithQuestions>, (StatusCode, String)> {
|
||||
// Get template
|
||||
let template: TestTemplate = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, organization_id, created_by, name, description, level, course_type,
|
||||
test_type, duration_minutes, passing_score, total_points, instructions,
|
||||
template_data, tags, is_active, usage_count, created_at, updated_at
|
||||
FROM test_templates
|
||||
WHERE id = $1 AND organization_id = $2
|
||||
"#
|
||||
)
|
||||
.bind(template_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Template not found".to_string()),
|
||||
_ => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
||||
})?;
|
||||
|
||||
// Get sections
|
||||
let sections: Vec<TestTemplateSection> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, template_id, title, description, section_order, points, instructions, section_data, created_at
|
||||
FROM test_template_sections
|
||||
WHERE template_id = $1
|
||||
ORDER BY section_order
|
||||
"#
|
||||
)
|
||||
.bind(template_id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Get questions
|
||||
let questions: Vec<TestTemplateQuestion> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, template_id, section_id, question_order, question_type, question_text,
|
||||
options, correct_answer, explanation, points, metadata, created_at
|
||||
FROM test_template_questions
|
||||
WHERE template_id = $1
|
||||
ORDER BY section_id, question_order
|
||||
"#
|
||||
)
|
||||
.bind(template_id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(TestTemplateWithQuestions {
|
||||
template,
|
||||
sections,
|
||||
questions,
|
||||
}))
|
||||
}
|
||||
|
||||
// ==================== Update ====================
|
||||
|
||||
/// PUT /api/test-templates/:id - Update a test template
|
||||
pub async fn update_test_template(
|
||||
Org(org_ctx): Org,
|
||||
Path(template_id): Path<Uuid>,
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<UpdateTestTemplatePayload>,
|
||||
) -> Result<Json<TestTemplate>, (StatusCode, String)> {
|
||||
let template: TestTemplate = sqlx::query_as(
|
||||
r#"
|
||||
UPDATE test_templates
|
||||
SET
|
||||
name = COALESCE($3, name),
|
||||
description = COALESCE($4, description),
|
||||
level = COALESCE($5, level),
|
||||
course_type = COALESCE($6, course_type),
|
||||
test_type = COALESCE($7, test_type),
|
||||
duration_minutes = COALESCE($8, duration_minutes),
|
||||
passing_score = COALESCE($9, passing_score),
|
||||
total_points = COALESCE($10, total_points),
|
||||
instructions = COALESCE($11, instructions),
|
||||
template_data = COALESCE($12, template_data),
|
||||
tags = COALESCE($13, tags),
|
||||
is_active = COALESCE($14, is_active),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1 AND organization_id = $2
|
||||
RETURNING id, organization_id, created_by, name, description, level, course_type,
|
||||
test_type, duration_minutes, passing_score, total_points, instructions,
|
||||
template_data, tags, is_active, usage_count, created_at, updated_at
|
||||
"#
|
||||
)
|
||||
.bind(template_id)
|
||||
.bind(org_ctx.id)
|
||||
.bind(payload.name)
|
||||
.bind(payload.description)
|
||||
.bind(payload.level)
|
||||
.bind(payload.course_type)
|
||||
.bind(payload.test_type)
|
||||
.bind(payload.duration_minutes)
|
||||
.bind(payload.passing_score)
|
||||
.bind(payload.total_points)
|
||||
.bind(payload.instructions)
|
||||
.bind(payload.template_data)
|
||||
.bind(payload.tags)
|
||||
.bind(payload.is_active)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Template not found".to_string()),
|
||||
_ => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
||||
})?;
|
||||
|
||||
Ok(Json(template))
|
||||
}
|
||||
|
||||
// ==================== Delete ====================
|
||||
|
||||
/// DELETE /api/test-templates/:id - Delete a test template
|
||||
pub async fn delete_test_template(
|
||||
Org(org_ctx): Org,
|
||||
Path(template_id): Path<Uuid>,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM test_templates
|
||||
WHERE id = $1 AND organization_id = $2
|
||||
"#
|
||||
)
|
||||
.bind(template_id)
|
||||
.bind(org_ctx.id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err((StatusCode::NOT_FOUND, "Template not found".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ==================== Template Questions Management ====================
|
||||
|
||||
/// POST /api/test-templates/:id/questions - Add a question to a template
|
||||
pub async fn create_template_question(
|
||||
Org(org_ctx): Org,
|
||||
Path(template_id): Path<Uuid>,
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<CreateQuestionPayload>,
|
||||
) -> Result<Json<TestTemplateQuestion>, (StatusCode, String)> {
|
||||
// Verify template exists and belongs to organization
|
||||
let exists: (bool,) = sqlx::query_as(
|
||||
r#"SELECT EXISTS(SELECT 1 FROM test_templates WHERE id = $1 AND organization_id = $2)"#
|
||||
)
|
||||
.bind(template_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if !exists.0 {
|
||||
return Err((StatusCode::NOT_FOUND, "Template not found".to_string()));
|
||||
}
|
||||
|
||||
let question: TestTemplateQuestion = sqlx::query_as(
|
||||
r#"
|
||||
INSERT INTO test_template_questions (
|
||||
template_id, section_id, question_order, question_type, question_text,
|
||||
options, correct_answer, explanation, points, metadata
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id, template_id, section_id, question_order, question_type, question_text,
|
||||
options, correct_answer, explanation, points, metadata, created_at
|
||||
"#
|
||||
)
|
||||
.bind(template_id)
|
||||
.bind(payload.section_id)
|
||||
.bind(payload.question_order)
|
||||
.bind(&payload.question_type)
|
||||
.bind(&payload.question_text)
|
||||
.bind(payload.options.as_ref())
|
||||
.bind(payload.correct_answer.as_ref())
|
||||
.bind(&payload.explanation)
|
||||
.bind(payload.points)
|
||||
.bind(payload.metadata.as_ref())
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(question))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateQuestionPayload {
|
||||
pub section_id: Option<Uuid>,
|
||||
pub question_order: i32,
|
||||
pub question_type: String,
|
||||
pub question_text: String,
|
||||
pub options: Option<serde_json::Value>,
|
||||
pub correct_answer: Option<serde_json::Value>,
|
||||
pub explanation: Option<String>,
|
||||
pub points: i32,
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// DELETE /api/test-templates/:template_id/questions/:question_id - Delete a question
|
||||
pub async fn delete_template_question(
|
||||
Org(org_ctx): Org,
|
||||
Path((template_id, question_id)): Path<(Uuid, Uuid)>,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
// Verify template exists and belongs to organization
|
||||
let exists: (bool,) = sqlx::query_as(
|
||||
r#"SELECT EXISTS(SELECT 1 FROM test_templates WHERE id = $1 AND organization_id = $2)"#
|
||||
)
|
||||
.bind(template_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if !exists.0 {
|
||||
return Err((StatusCode::NOT_FOUND, "Template not found".to_string()));
|
||||
}
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM test_template_questions
|
||||
WHERE id = $1 AND template_id = $2
|
||||
"#
|
||||
)
|
||||
.bind(question_id)
|
||||
.bind(template_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err((StatusCode::NOT_FOUND, "Question not found".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ==================== Template Sections Management ====================
|
||||
|
||||
/// POST /api/test-templates/:id/sections - Add a section to a template
|
||||
pub async fn create_template_section(
|
||||
Org(org_ctx): Org,
|
||||
Path(template_id): Path<Uuid>,
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<CreateSectionPayload>,
|
||||
) -> Result<Json<TestTemplateSection>, (StatusCode, String)> {
|
||||
// Verify template exists
|
||||
let exists: (bool,) = sqlx::query_as(
|
||||
r#"SELECT EXISTS(SELECT 1 FROM test_templates WHERE id = $1 AND organization_id = $2)"#
|
||||
)
|
||||
.bind(template_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if !exists.0 {
|
||||
return Err((StatusCode::NOT_FOUND, "Template not found".to_string()));
|
||||
}
|
||||
|
||||
let section: TestTemplateSection = sqlx::query_as(
|
||||
r#"
|
||||
INSERT INTO test_template_sections (
|
||||
template_id, title, description, section_order, points, instructions, section_data
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, template_id, title, description, section_order, points, instructions, section_data, created_at
|
||||
"#
|
||||
)
|
||||
.bind(template_id)
|
||||
.bind(&payload.title)
|
||||
.bind(&payload.description)
|
||||
.bind(payload.section_order)
|
||||
.bind(payload.points)
|
||||
.bind(&payload.instructions)
|
||||
.bind(payload.section_data.as_ref())
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(section))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateSectionPayload {
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub section_order: i32,
|
||||
pub points: i32,
|
||||
pub instructions: Option<String>,
|
||||
pub section_data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// DELETE /api/test-templates/:template_id/sections/:section_id - Delete a section
|
||||
pub async fn delete_template_section(
|
||||
Org(org_ctx): Org,
|
||||
Path((template_id, section_id)): Path<(Uuid, Uuid)>,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM test_template_sections
|
||||
WHERE id = $1 AND template_id = $2
|
||||
"#
|
||||
)
|
||||
.bind(section_id)
|
||||
.bind(template_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err((StatusCode::NOT_FOUND, "Section not found".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ==================== Apply Template to Lesson ====================
|
||||
|
||||
/// POST /api/test-templates/:id/apply - Apply a template to a lesson
|
||||
pub async fn apply_template_to_lesson(
|
||||
Org(org_ctx): Org,
|
||||
Path(template_id): Path<Uuid>,
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<ApplyTemplatePayload>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
use common::models::ApplyTemplatePayload;
|
||||
|
||||
// Verify template exists and belongs to organization
|
||||
let template: TestTemplate = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, organization_id, created_by, name, description, level, course_type,
|
||||
test_type, duration_minutes, passing_score, total_points, instructions,
|
||||
template_data, tags, is_active, usage_count, created_at, updated_at
|
||||
FROM test_templates
|
||||
WHERE id = $1 AND organization_id = $2
|
||||
"#
|
||||
)
|
||||
.bind(template_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Template not found".to_string()),
|
||||
_ => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
||||
})?;
|
||||
|
||||
// Verify lesson exists and belongs to organization
|
||||
let lesson_exists: (bool,) = sqlx::query_as(
|
||||
r#"SELECT EXISTS(SELECT 1 FROM lessons WHERE id = $1 AND organization_id = $2)"#
|
||||
)
|
||||
.bind(payload.lesson_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if !lesson_exists.0 {
|
||||
return Err((StatusCode::NOT_FOUND, "Lesson not found".to_string()));
|
||||
}
|
||||
|
||||
// Update lesson with template data
|
||||
// This would typically involve:
|
||||
// 1. Setting lesson content_type to "quiz" or "test"
|
||||
// 2. Setting lesson metadata with template_data
|
||||
// 3. Optionally linking to a grading category
|
||||
// For now, we just increment the usage count
|
||||
sqlx::query("SELECT increment_template_usage($1)")
|
||||
.bind(template_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ApplyTemplatePayload {
|
||||
pub lesson_id: Uuid,
|
||||
pub grading_category_id: Option<Uuid>,
|
||||
}
|
||||
@@ -7,6 +7,7 @@ mod handlers_assets;
|
||||
mod handlers_dependencies;
|
||||
mod handlers_library;
|
||||
mod handlers_rubrics;
|
||||
mod handlers_test_templates;
|
||||
mod webhooks;
|
||||
|
||||
use axum::{
|
||||
@@ -17,6 +18,7 @@ use axum::{
|
||||
};
|
||||
use common::health::{self, HealthState};
|
||||
use dotenvy::dotenv;
|
||||
use http::{Method, header};
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use std::env;
|
||||
use std::net::SocketAddr;
|
||||
@@ -93,8 +95,14 @@ async fn main() {
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS, Method::PATCH])
|
||||
.allow_headers([
|
||||
header::CONTENT_TYPE,
|
||||
header::AUTHORIZATION,
|
||||
header::HeaderName::from_static("x-requested-with"),
|
||||
header::HeaderName::from_static("x-organization-id"),
|
||||
])
|
||||
.expose_headers([header::CONTENT_LENGTH]);
|
||||
|
||||
// Rate limiting: Deshabilitado temporalmente por problemas de compatibilidad con tower-governor
|
||||
// Para habilitar en producción, configurar con GovernorLayer y ajustar los límites apropiadamente
|
||||
@@ -281,6 +289,38 @@ async fn main() {
|
||||
"/lessons/{id}/dependencies/{prerequisite_id}",
|
||||
delete(handlers_dependencies::remove_dependency),
|
||||
)
|
||||
// Test Templates routes
|
||||
.route(
|
||||
"/test-templates",
|
||||
get(handlers_test_templates::list_test_templates)
|
||||
.post(handlers_test_templates::create_test_template),
|
||||
)
|
||||
.route(
|
||||
"/test-templates/{id}",
|
||||
get(handlers_test_templates::get_test_template)
|
||||
.put(handlers_test_templates::update_test_template)
|
||||
.delete(handlers_test_templates::delete_test_template),
|
||||
)
|
||||
.route(
|
||||
"/test-templates/{id}/questions",
|
||||
post(handlers_test_templates::create_template_question),
|
||||
)
|
||||
.route(
|
||||
"/test-templates/{template_id}/questions/{question_id}",
|
||||
delete(handlers_test_templates::delete_template_question),
|
||||
)
|
||||
.route(
|
||||
"/test-templates/{id}/sections",
|
||||
post(handlers_test_templates::create_template_section),
|
||||
)
|
||||
.route(
|
||||
"/test-templates/{template_id}/sections/{section_id}",
|
||||
delete(handlers_test_templates::delete_template_section),
|
||||
)
|
||||
.route(
|
||||
"/test-templates/{id}/apply",
|
||||
post(handlers_test_templates::apply_template_to_lesson),
|
||||
)
|
||||
.route_layer(middleware::from_fn(
|
||||
common::middleware::org_extractor_middleware,
|
||||
));
|
||||
|
||||
Reference in New Issue
Block a user