feat: creacion de plantillas para pruebas, prototipo
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
# Test Templates System - Implementation Summary
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the implementation of a comprehensive **Test Template System** for OpenCCB, allowing instructors to create and reuse test/quiz templates based on:
|
||||
|
||||
1. **Course Level**: `beginner`, `beginner_1`, `beginner_2`, `intermediate`, `advanced`, etc.
|
||||
2. **Course Type**: `intensive` or `regular`
|
||||
3. **Assessment Type**: `CA`, `MWT`, `MOT`, `FOT`, `FWT`
|
||||
|
||||
## Database Changes
|
||||
|
||||
### New Enums
|
||||
|
||||
```sql
|
||||
-- Course levels
|
||||
CREATE TYPE course_level AS ENUM (
|
||||
'beginner', 'beginner_1', 'beginner_2',
|
||||
'intermediate', 'intermediate_1', 'intermediate_2',
|
||||
'advanced', 'advanced_1', 'advanced_2'
|
||||
);
|
||||
|
||||
-- Course types
|
||||
CREATE TYPE course_type AS ENUM ('intensive', 'regular');
|
||||
|
||||
-- Test types (assessment types)
|
||||
CREATE TYPE test_type AS ENUM ('CA', 'MWT', 'MOT', 'FOT', 'FWT');
|
||||
```
|
||||
|
||||
### New Tables
|
||||
|
||||
#### `test_templates`
|
||||
Main table for storing test templates with metadata:
|
||||
- `id`: UUID primary key
|
||||
- `organization_id`: Multi-tenancy support
|
||||
- `name`, `description`: Template identification
|
||||
- `level`, `course_type`, `test_type`: Classification enums
|
||||
- `duration_minutes`, `passing_score`, `total_points`: Configuration
|
||||
- `instructions`: General test instructions
|
||||
- `template_data`: JSONB with complete test structure
|
||||
- `tags`: Text array for categorization
|
||||
- `usage_count`: Tracks template popularity
|
||||
- `is_active`: Soft delete support
|
||||
|
||||
#### `test_template_sections`
|
||||
Optional sections within a test (e.g., "Reading", "Grammar", "Listening"):
|
||||
- `template_id`: Foreign key to `test_templates`
|
||||
- `section_order`: Ordering within template
|
||||
- `points`, `instructions`: Section-specific config
|
||||
- `section_data`: JSONB for advanced configuration
|
||||
|
||||
#### `test_template_questions`
|
||||
Individual questions for each template:
|
||||
- `template_id`, `section_id`: Foreign keys
|
||||
- `question_order`: Ordering
|
||||
- `question_type`: "multiple-choice", "true-false", "short-answer", "essay", "matching", "ordering"
|
||||
- `question_text`, `options`, `correct_answer`, `explanation`: Question content
|
||||
- `points`, `metadata`: Scoring and additional data
|
||||
|
||||
### Database Functions
|
||||
|
||||
- `get_test_templates_by_filters()`: Filter templates by level, type, test type, and search
|
||||
- `increment_template_usage()`: Track template usage statistics
|
||||
|
||||
### Course Model Updates
|
||||
|
||||
Added optional fields to `courses` table:
|
||||
- `level`: Course level enum
|
||||
- `course_type`: Course type enum
|
||||
|
||||
## Backend Implementation
|
||||
|
||||
### Models (`shared/common/src/models.rs`)
|
||||
|
||||
New Rust structs:
|
||||
- `CourseLevel`, `CourseType`, `TestType`: Enum types
|
||||
- `TestTemplate`, `TestTemplateSection`, `TestTemplateQuestion`: Main models
|
||||
- `CreateTestTemplatePayload`, `UpdateTestTemplatePayload`: Request/Response DTOs
|
||||
- `TestTemplateWithQuestions`: Composite response type
|
||||
|
||||
### API Handlers (`services/cms-service/src/handlers_test_templates.rs`)
|
||||
|
||||
RESTful endpoints:
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/test-templates` | List templates with filters |
|
||||
| POST | `/test-templates` | Create new template |
|
||||
| GET | `/test-templates/{id}` | Get template with questions |
|
||||
| PUT | `/test-templates/{id}` | Update template |
|
||||
| DELETE | `/test-templates/{id}` | Delete template |
|
||||
| POST | `/test-templates/{id}/questions` | Add question |
|
||||
| DELETE | `/test-templates/{id}/questions/{qid}` | Delete question |
|
||||
| POST | `/test-templates/{id}/sections` | Add section |
|
||||
| DELETE | `/test-templates/{id}/sections/{sid}` | Delete section |
|
||||
| POST | `/test-templates/{id}/apply` | Apply template to lesson |
|
||||
|
||||
### Routes (`services/cms-service/src/main.rs`)
|
||||
|
||||
All routes are protected and require organization context.
|
||||
|
||||
## Frontend Implementation
|
||||
|
||||
### TypeScript Types (`web/studio/src/lib/api.ts`)
|
||||
|
||||
```typescript
|
||||
type CourseLevel = 'beginner' | 'beginner_1' | ... | 'advanced_2';
|
||||
type CourseType = 'intensive' | 'regular';
|
||||
type TestType = 'CA' | 'MWT' | 'MOT' | 'FOT' | 'FWT';
|
||||
type QuestionType = 'multiple-choice' | 'true-false' | ...;
|
||||
|
||||
interface TestTemplate { ... }
|
||||
interface TestTemplateSection { ... }
|
||||
interface TestTemplateQuestion { ... }
|
||||
interface CreateTestTemplatePayload { ... }
|
||||
```
|
||||
|
||||
### API Functions (`cmsApi` object)
|
||||
|
||||
- `listTestTemplates(filters)`
|
||||
- `getTestTemplate(templateId)`
|
||||
- `createTestTemplate(payload)`
|
||||
- `updateTestTemplate(templateId, payload)`
|
||||
- `deleteTestTemplate(templateId)`
|
||||
- `createTemplateQuestion(templateId, payload)`
|
||||
- `deleteTemplateQuestion(templateId, questionId)`
|
||||
- `createTemplateSection(templateId, payload)`
|
||||
- `deleteTemplateSection(templateId, sectionId)`
|
||||
- `applyTemplateToLesson(templateId, lessonId, gradingCategoryId)`
|
||||
|
||||
### UI Components
|
||||
|
||||
#### `TestTemplateManager` (`web/studio/src/components/TestTemplates/TestTemplateManager.tsx`)
|
||||
|
||||
Main management interface with:
|
||||
- Grid view of templates
|
||||
- Search functionality
|
||||
- Advanced filters (level, course type, test type)
|
||||
- Template cards showing:
|
||||
- Name, description
|
||||
- Level, type badges with color coding
|
||||
- Duration, passing score, total points
|
||||
- Tags
|
||||
- Usage statistics
|
||||
- Action buttons (view, edit, delete, apply)
|
||||
|
||||
#### `TestTemplateForm` (`web/studio/src/components/TestTemplates/TestTemplateForm.tsx`)
|
||||
|
||||
Modal form for creating/editing templates with:
|
||||
- Basic info (name, description)
|
||||
- Classification (level, course type, test type)
|
||||
- Configuration (duration, passing score, total points)
|
||||
- Instructions
|
||||
- Tag management
|
||||
|
||||
#### Page (`web/studio/src/app/test-templates/page.tsx`)
|
||||
|
||||
Dedicated page at `/test-templates` route.
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Creating a Template via API
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3001/test-templates \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||
-H "X-Organization-Id: YOUR_ORG_ID" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Final Exam - Beginner 1",
|
||||
"description": "Comprehensive final exam for beginner level",
|
||||
"level": "beginner_1",
|
||||
"course_type": "regular",
|
||||
"test_type": "FWT",
|
||||
"duration_minutes": 90,
|
||||
"passing_score": 70,
|
||||
"total_points": 100,
|
||||
"instructions": "Answer all questions. You have 90 minutes.",
|
||||
"tags": ["final", "beginner", "written"]
|
||||
}'
|
||||
```
|
||||
|
||||
### Filtering Templates
|
||||
|
||||
```bash
|
||||
# Get all FOT templates for beginner intensive courses
|
||||
curl "http://localhost:3001/test-templates?level=beginner_1&course_type=intensive&test_type=FOT" \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
||||
```
|
||||
|
||||
### Applying Template to a Lesson
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3001/test-templates/TEMPLATE_ID/apply \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||
-H "X-Organization-Id: YOUR_ORG_ID" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"lesson_id": "LESSON_ID",
|
||||
"grading_category_id": "CATEGORY_ID"
|
||||
}'
|
||||
```
|
||||
|
||||
## Migration
|
||||
|
||||
Run the migration to set up the database schema:
|
||||
|
||||
```bash
|
||||
cd services/cms-service
|
||||
sqlx migrate run --source migrations
|
||||
```
|
||||
|
||||
The migration file is: `20260316000000_test_templates.sql`
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Template Cloning**: Allow duplicating existing templates
|
||||
2. **Question Bank**: Centralized repository of questions that can be mixed and matched
|
||||
3. **Template Sharing**: Share templates across organizations
|
||||
4. **Analytics**: Track template effectiveness and student performance
|
||||
5. **AI Generation**: Auto-generate templates based on course content
|
||||
6. **Version Control**: Track template revisions
|
||||
7. **Bulk Operations**: Apply templates to multiple lessons at once
|
||||
8. **Preview Mode**: Preview template before applying
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
openccb/
|
||||
├── shared/common/src/models.rs # Rust models
|
||||
├── services/cms-service/
|
||||
│ ├── migrations/20260316000000_test_templates.sql
|
||||
│ ├── src/handlers_test_templates.rs # API handlers
|
||||
│ └── src/main.rs # Routes
|
||||
└── web/studio/
|
||||
├── src/lib/api.ts # TypeScript types & API functions
|
||||
├── src/components/TestTemplates/
|
||||
│ ├── TestTemplateManager.tsx
|
||||
│ ├── TestTemplateForm.tsx
|
||||
│ └── index.ts
|
||||
└── src/app/test-templates/
|
||||
└── page.tsx
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Backend Tests
|
||||
|
||||
```bash
|
||||
cargo test -p common
|
||||
cargo test -p cms-service
|
||||
```
|
||||
|
||||
### Frontend Type Check
|
||||
|
||||
```bash
|
||||
cd web/studio
|
||||
npm run type-check
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- All templates are organization-scoped for multi-tenancy
|
||||
- Soft delete via `is_active` flag preserves historical data
|
||||
- Usage count helps identify popular templates
|
||||
- JSONB fields provide flexibility for evolving question types
|
||||
- The system integrates with existing grading categories via `tipo_nota` catalog
|
||||
@@ -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,
|
||||
));
|
||||
|
||||
@@ -20,6 +20,8 @@ pub struct Course {
|
||||
pub currency: String,
|
||||
pub marketing_metadata: Option<serde_json::Value>,
|
||||
pub course_image_url: Option<String>,
|
||||
pub level: Option<CourseLevel>,
|
||||
pub course_type: Option<CourseType>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -41,6 +43,8 @@ impl Default for Course {
|
||||
currency: "USD".to_string(),
|
||||
marketing_metadata: None,
|
||||
course_image_url: None,
|
||||
level: None,
|
||||
course_type: None,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
@@ -975,6 +979,8 @@ mod tests {
|
||||
currency: "USD".to_string(),
|
||||
marketing_metadata: None,
|
||||
course_image_url: None,
|
||||
level: None,
|
||||
course_type: None,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
},
|
||||
@@ -1013,6 +1019,8 @@ mod tests {
|
||||
currency: "USD".to_string(),
|
||||
marketing_metadata: None,
|
||||
course_image_url: None,
|
||||
level: None,
|
||||
course_type: None,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
@@ -1164,3 +1172,138 @@ pub struct PublicProfile {
|
||||
pub xp: i32,
|
||||
pub completed_courses_count: i64,
|
||||
}
|
||||
|
||||
// ==================== Test Templates ====================
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::Type, Clone, PartialEq)]
|
||||
#[sqlx(type_name = "course_level", rename_all = "snake_case")]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CourseLevel {
|
||||
Beginner,
|
||||
Beginner1,
|
||||
Beginner2,
|
||||
Intermediate,
|
||||
Intermediate1,
|
||||
Intermediate2,
|
||||
Advanced,
|
||||
Advanced1,
|
||||
Advanced2,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::Type, Clone, PartialEq)]
|
||||
#[sqlx(type_name = "course_type", rename_all = "lowercase")]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CourseType {
|
||||
Intensive,
|
||||
Regular,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::Type, Clone, PartialEq)]
|
||||
#[sqlx(type_name = "test_type")]
|
||||
pub enum TestType {
|
||||
#[sqlx(rename = "CA")]
|
||||
CA, // Continuous Assessment
|
||||
#[sqlx(rename = "MWT")]
|
||||
MWT, // Midterm Written Test
|
||||
#[sqlx(rename = "MOT")]
|
||||
MOT, // Midterm Oral Test
|
||||
#[sqlx(rename = "FOT")]
|
||||
FOT, // Final Oral Test
|
||||
#[sqlx(rename = "FWT")]
|
||||
FWT, // Final Written Test
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||
pub struct TestTemplate {
|
||||
pub id: Uuid,
|
||||
pub organization_id: Uuid,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub level: CourseLevel,
|
||||
pub course_type: CourseType,
|
||||
pub test_type: TestType,
|
||||
pub duration_minutes: i32,
|
||||
pub passing_score: i32, // 0-100 percentage
|
||||
pub total_points: i32,
|
||||
pub instructions: Option<String>,
|
||||
pub template_data: serde_json::Value, // Complete test structure with sections and questions
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub is_active: bool,
|
||||
pub usage_count: i32,
|
||||
pub created_by: Uuid,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||
pub struct TestTemplateSection {
|
||||
pub id: Uuid,
|
||||
pub template_id: Uuid,
|
||||
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>, // Section-specific configuration
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||
pub struct TestTemplateQuestion {
|
||||
pub id: Uuid,
|
||||
pub template_id: Uuid,
|
||||
pub section_id: Option<Uuid>,
|
||||
pub question_order: i32,
|
||||
pub question_type: String, // "multiple-choice", "true-false", "short-answer", "essay", "matching", "ordering"
|
||||
pub question_text: String,
|
||||
pub options: Option<serde_json::Value>, // Array of options for multiple choice
|
||||
pub correct_answer: Option<serde_json::Value>, // Can be index, array of indices, or text
|
||||
pub explanation: Option<String>,
|
||||
pub points: i32,
|
||||
pub metadata: Option<serde_json::Value>, // Additional question metadata
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct CreateTestTemplatePayload {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub level: CourseLevel,
|
||||
pub course_type: CourseType,
|
||||
pub test_type: TestType,
|
||||
pub duration_minutes: i32,
|
||||
pub passing_score: i32,
|
||||
pub total_points: i32,
|
||||
pub instructions: Option<String>,
|
||||
pub template_data: serde_json::Value,
|
||||
pub tags: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct UpdateTestTemplatePayload {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub level: Option<CourseLevel>,
|
||||
pub course_type: Option<CourseType>,
|
||||
pub test_type: Option<TestType>,
|
||||
pub duration_minutes: Option<i32>,
|
||||
pub passing_score: Option<i32>,
|
||||
pub total_points: Option<i32>,
|
||||
pub instructions: Option<String>,
|
||||
pub template_data: Option<serde_json::Value>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub is_active: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct TestTemplateWithQuestions {
|
||||
pub template: TestTemplate,
|
||||
pub sections: Vec<TestTemplateSection>,
|
||||
pub questions: Vec<TestTemplateQuestion>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ApplyTemplatePayload {
|
||||
pub lesson_id: Uuid,
|
||||
pub grading_category_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
import { TestTemplateManager, TestTemplateForm } from '@/components/TestTemplates';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
export default function TestTemplatesPage() {
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const handleSuccess = () => {
|
||||
setShowCreateForm(false);
|
||||
setRefreshKey(prev => prev + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout title="Plantillas de Pruebas">
|
||||
<div key={refreshKey}>
|
||||
<TestTemplateManager
|
||||
onSelectTemplate={() => setShowCreateForm(true)}
|
||||
onCreateTemplate={() => setShowCreateForm(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showCreateForm && (
|
||||
<TestTemplateForm
|
||||
onSuccess={handleSuccess}
|
||||
onCancel={() => setShowCreateForm(false)}
|
||||
/>
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { cmsApi, CreateTestTemplatePayload, CourseLevel, CourseType, TestType } from '@/lib/api';
|
||||
import { X, Save, Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
interface TestTemplateFormProps {
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export default function TestTemplateForm({ onSuccess, onCancel }: TestTemplateFormProps) {
|
||||
const [formData, setFormData] = useState<CreateTestTemplatePayload>({
|
||||
name: '',
|
||||
description: '',
|
||||
level: 'beginner',
|
||||
course_type: 'regular',
|
||||
test_type: 'CA',
|
||||
duration_minutes: 60,
|
||||
passing_score: 70,
|
||||
total_points: 100,
|
||||
instructions: '',
|
||||
template_data: { sections: [], questions: [] },
|
||||
tags: [],
|
||||
});
|
||||
|
||||
const [newTag, setNewTag] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
alert('El nombre es obligatorio');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
await cmsApi.createTestTemplate(formData);
|
||||
alert('Plantilla creada exitosamente');
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
console.error('Failed to create template:', error);
|
||||
alert('Error al crear la plantilla');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddTag = () => {
|
||||
if (newTag.trim() && !formData.tags?.includes(newTag.trim())) {
|
||||
setFormData({
|
||||
...formData,
|
||||
tags: [...(formData.tags || []), newTag.trim()],
|
||||
});
|
||||
setNewTag('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveTag = (tagToRemove: string) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
tags: formData.tags?.filter(tag => tag !== tagToRemove) || [],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg max-w-3xl w-full max-h-[90vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<h2 className="text-xl font-bold text-gray-900">Nueva Plantilla de Prueba</h2>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
{/* Basic Info */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold text-gray-900">Información Básica</h3>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Nombre *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Ej: Final Exam - Beginner 1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Descripción
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Descripción de la plantilla..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Classification */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold text-gray-900">Clasificación</h3>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Nivel *
|
||||
</label>
|
||||
<select
|
||||
value={formData.level}
|
||||
onChange={(e) => setFormData({ ...formData, level: e.target.value as CourseLevel })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="beginner">Beginner</option>
|
||||
<option value="beginner_1">Beginner 1</option>
|
||||
<option value="beginner_2">Beginner 2</option>
|
||||
<option value="intermediate">Intermediate</option>
|
||||
<option value="intermediate_1">Intermediate 1</option>
|
||||
<option value="intermediate_2">Intermediate 2</option>
|
||||
<option value="advanced">Advanced</option>
|
||||
<option value="advanced_1">Advanced 1</option>
|
||||
<option value="advanced_2">Advanced 2</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Tipo de Curso *
|
||||
</label>
|
||||
<select
|
||||
value={formData.course_type}
|
||||
onChange={(e) => setFormData({ ...formData, course_type: e.target.value as CourseType })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="regular">Regular</option>
|
||||
<option value="intensive">Intensivo</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Tipo de Prueba *
|
||||
</label>
|
||||
<select
|
||||
value={formData.test_type}
|
||||
onChange={(e) => setFormData({ ...formData, test_type: e.target.value as TestType })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="CA">Continuous Assessment (CA)</option>
|
||||
<option value="MWT">Midterm Written Test (MWT)</option>
|
||||
<option value="MOT">Midterm Oral Test (MOT)</option>
|
||||
<option value="FOT">Final Oral Test (FOT)</option>
|
||||
<option value="FWT">Final Written Test (FWT)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold text-gray-900">Configuración</h3>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Duración (minutos) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.duration_minutes}
|
||||
onChange={(e) => setFormData({ ...formData, duration_minutes: parseInt(e.target.value) || 0 })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Puntuación Mínima (%) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.passing_score}
|
||||
onChange={(e) => setFormData({ ...formData, passing_score: parseInt(e.target.value) || 0 })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Puntos Totales *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.total_points}
|
||||
onChange={(e) => setFormData({ ...formData, total_points: parseInt(e.target.value) || 0 })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Instrucciones
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.instructions}
|
||||
onChange={(e) => setFormData({ ...formData, instructions: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Instrucciones generales para la prueba..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold text-gray-900">Etiquetas</h3>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newTag}
|
||||
onChange={(e) => setNewTag(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddTag())}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Agregar etiqueta..."
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddTag}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors flex items-center gap-1"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Agregar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{formData.tags && formData.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{formData.tags.map((tag, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm flex items-center gap-1"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveTag(tag)}
|
||||
className="hover:text-blue-600"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3 pt-6 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? 'Guardando...' : 'Guardar Plantilla'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { cmsApi, TestTemplate, TestTemplateFilters, CourseLevel, CourseType, TestType } from '@/lib/api';
|
||||
import { Plus, Search, Filter, Edit2, Trash2, Eye, Copy, BookOpen, Clock, Target, Tag } from 'lucide-react';
|
||||
|
||||
interface TestTemplateManagerProps {
|
||||
onSelectTemplate?: (template: TestTemplate) => void;
|
||||
onCreateTemplate?: () => void;
|
||||
}
|
||||
|
||||
export default function TestTemplateManager({ onSelectTemplate, onCreateTemplate }: TestTemplateManagerProps) {
|
||||
const [templates, setTemplates] = useState<TestTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filters, setFilters] = useState<TestTemplateFilters>({});
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const loadTemplates = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await cmsApi.listTestTemplates(filters);
|
||||
setTemplates(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load test templates:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplates();
|
||||
}, [filters]);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('¿Estás seguro de que deseas eliminar esta plantilla?')) return;
|
||||
|
||||
try {
|
||||
await cmsApi.deleteTestTemplate(id);
|
||||
await loadTemplates();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete template:', error);
|
||||
alert('Error al eliminar la plantilla');
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyTemplate = async (template: TestTemplate) => {
|
||||
if (onSelectTemplate) {
|
||||
onSelectTemplate(template);
|
||||
} else {
|
||||
alert(`Plantilla "${template.name}" seleccionada. (Implementar lógica de aplicación)`);
|
||||
}
|
||||
};
|
||||
|
||||
const getLevelLabel = (level: CourseLevel) => {
|
||||
const labels: Record<CourseLevel, string> = {
|
||||
beginner: 'Beginner',
|
||||
beginner_1: 'Beginner 1',
|
||||
beginner_2: 'Beginner 2',
|
||||
intermediate: 'Intermediate',
|
||||
intermediate_1: 'Intermediate 1',
|
||||
intermediate_2: 'Intermediate 2',
|
||||
advanced: 'Advanced',
|
||||
advanced_1: 'Advanced 1',
|
||||
advanced_2: 'Advanced 2',
|
||||
};
|
||||
return labels[level] || level;
|
||||
};
|
||||
|
||||
const getCourseTypeLabel = (type: CourseType) => {
|
||||
return type === 'intensive' ? 'Intensivo' : 'Regular';
|
||||
};
|
||||
|
||||
const getTestTypeLabel = (type: TestType) => {
|
||||
const labels: Record<TestType, string> = {
|
||||
CA: 'Evaluación Continua',
|
||||
MWT: 'Examen Escrito Parcial',
|
||||
MOT: 'Examen Oral Parcial',
|
||||
FOT: 'Examen Oral Final',
|
||||
FWT: 'Examen Escrito Final',
|
||||
};
|
||||
return labels[type] || type;
|
||||
};
|
||||
|
||||
const getLevelColor = (level: CourseLevel) => {
|
||||
if (level.includes('beginner')) return 'bg-green-100 text-green-800';
|
||||
if (level.includes('intermediate')) return 'bg-yellow-100 text-yellow-800';
|
||||
if (level.includes('advanced')) return 'bg-red-100 text-red-800';
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
};
|
||||
|
||||
const getTestTypeColor = (type: TestType) => {
|
||||
if (type === 'CA') return 'bg-blue-100 text-blue-800';
|
||||
if (type.includes('MWT') || type.includes('MOT')) return 'bg-purple-100 text-purple-800';
|
||||
if (type.includes('FWT') || type.includes('FOT')) return 'bg-orange-100 text-orange-800';
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Plantillas de Pruebas</h1>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Gestiona plantillas de evaluaciones por nivel y tipo de curso
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCreateTemplate}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Nueva Plantilla
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="mb-6 flex gap-3">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar plantillas..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className={`flex items-center gap-2 px-4 py-2 border rounded-lg transition-colors ${
|
||||
showFilters ? 'bg-blue-50 border-blue-500' : 'border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
Filtros
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter Panel */}
|
||||
{showFilters && (
|
||||
<div className="mb-6 p-4 bg-gray-50 rounded-lg border border-gray-200 grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Nivel</label>
|
||||
<select
|
||||
value={filters.level || ''}
|
||||
onChange={(e) => setFilters({ ...filters, level: e.target.value as CourseLevel || undefined })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Todos los niveles</option>
|
||||
<option value="beginner">Beginner</option>
|
||||
<option value="beginner_1">Beginner 1</option>
|
||||
<option value="beginner_2">Beginner 2</option>
|
||||
<option value="intermediate">Intermediate</option>
|
||||
<option value="intermediate_1">Intermediate 1</option>
|
||||
<option value="intermediate_2">Intermediate 2</option>
|
||||
<option value="advanced">Advanced</option>
|
||||
<option value="advanced_1">Advanced 1</option>
|
||||
<option value="advanced_2">Advanced 2</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Tipo de Curso</label>
|
||||
<select
|
||||
value={filters.course_type || ''}
|
||||
onChange={(e) => setFilters({ ...filters, course_type: e.target.value as CourseType || undefined })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Todos los tipos</option>
|
||||
<option value="intensive">Intensivo</option>
|
||||
<option value="regular">Regular</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Tipo de Prueba</label>
|
||||
<select
|
||||
value={filters.test_type || ''}
|
||||
onChange={(e) => setFilters({ ...filters, test_type: e.target.value as TestType || undefined })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Todos los tipos</option>
|
||||
<option value="CA">Continuous Assessment (CA)</option>
|
||||
<option value="MWT">Midterm Written Test (MWT)</option>
|
||||
<option value="MOT">Midterm Oral Test (MOT)</option>
|
||||
<option value="FOT">Final Oral Test (FOT)</option>
|
||||
<option value="FWT">Final Written Test (FWT)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Templates Grid */}
|
||||
{loading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Cargando plantillas...</p>
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="text-center py-12 bg-gray-50 rounded-lg">
|
||||
<BookOpen className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900">No hay plantillas</h3>
|
||||
<p className="text-gray-600 mt-1">Crea tu primera plantilla de prueba</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{templates.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className="bg-white border border-gray-200 rounded-lg p-4 hover:shadow-lg transition-shadow"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h3 className="font-semibold text-gray-900 flex-1">{template.name}</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => alert(`Implementar: Ver plantilla ${template.id}`)}
|
||||
className="p-1 text-gray-400 hover:text-blue-600 transition-colors"
|
||||
title="Ver detalles"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => alert(`Implementar: Editar plantilla ${template.id}`)}
|
||||
className="p-1 text-gray-400 hover:text-green-600 transition-colors"
|
||||
title="Editar"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(template.id)}
|
||||
className="p-1 text-gray-400 hover:text-red-600 transition-colors"
|
||||
title="Eliminar"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{template.description && (
|
||||
<p className="text-sm text-gray-600 mb-3 line-clamp-2">{template.description}</p>
|
||||
)}
|
||||
|
||||
{/* Badges */}
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${getLevelColor(template.level)}`}>
|
||||
{getLevelLabel(template.level)}
|
||||
</span>
|
||||
<span className="px-2 py-1 rounded text-xs font-medium bg-gray-100 text-gray-800">
|
||||
{getCourseTypeLabel(template.course_type)}
|
||||
</span>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${getTestTypeColor(template.test_type)}`}>
|
||||
{getTestTypeLabel(template.test_type)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="space-y-2 mb-3">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>{template.duration_minutes} minutos</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Target className="w-4 h-4" />
|
||||
<span>Puntuación mínima: {template.passing_score}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Tag className="w-4 h-4" />
|
||||
<span>Puntos totales: {template.total_points}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{template.tags && template.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-3">
|
||||
{template.tags.map((tag, idx) => (
|
||||
<span key={idx} className="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs rounded">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Usage Stats */}
|
||||
<div className="flex items-center justify-between pt-3 border-t border-gray-200">
|
||||
<span className="text-xs text-gray-500">
|
||||
Usos: {template.usage_count}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleApplyTemplate(template)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
Aplicar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as TestTemplateManager } from './TestTemplateManager';
|
||||
export { default as TestTemplateForm } from './TestTemplateForm';
|
||||
+163
-2
@@ -590,7 +590,11 @@ export interface CourseInstructor {
|
||||
const getToken = () => typeof window !== 'undefined' ? localStorage.getItem('studio_token') : null;
|
||||
const getSelectedOrgId = () => typeof window !== 'undefined' ? localStorage.getItem('studio_selected_org_id') : null;
|
||||
|
||||
const apiFetch = (url: string, options: RequestInit = {}, isLms: boolean = false) => {
|
||||
interface ApiFetchOptions extends RequestInit {
|
||||
query?: Record<string, string | number | boolean | undefined | null>;
|
||||
}
|
||||
|
||||
const apiFetch = (url: string, options: ApiFetchOptions = {}, isLms: boolean = false) => {
|
||||
const token = getToken();
|
||||
const selectedOrgId = getSelectedOrgId();
|
||||
const baseUrl = isLms ? LMS_API_BASE_URL : API_BASE_URL;
|
||||
@@ -602,7 +606,23 @@ const apiFetch = (url: string, options: RequestInit = {}, isLms: boolean = false
|
||||
...(selectedOrgId ? { 'X-Organization-Id': selectedOrgId } : {})
|
||||
};
|
||||
|
||||
return fetch(`${baseUrl}${url}`, { ...options, headers }).then(async res => {
|
||||
// Build query string
|
||||
const queryParams = options.query;
|
||||
let finalUrl = `${baseUrl}${url}`;
|
||||
if (queryParams) {
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(queryParams).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
const queryString = searchParams.toString();
|
||||
if (queryString) {
|
||||
finalUrl += `${url.includes('?') ? '&' : '?'}${queryString}`;
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(finalUrl, { ...options, headers }).then(async res => {
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
try {
|
||||
@@ -881,6 +901,28 @@ export const cmsApi = {
|
||||
apiFetch(`/lessons/${lessonId}/dependencies`, { method: 'POST', body: JSON.stringify(payload) }),
|
||||
removeDependency: (lessonId: string, prerequisiteId: string): Promise<void> =>
|
||||
apiFetch(`/lessons/${lessonId}/dependencies/${prerequisiteId}`, { method: 'DELETE' }),
|
||||
|
||||
// Test Templates
|
||||
listTestTemplates: (filters?: TestTemplateFilters): Promise<TestTemplate[]> =>
|
||||
apiFetch('/test-templates', { method: 'GET', query: filters as any }, false),
|
||||
getTestTemplate: (templateId: string): Promise<TestTemplateWithQuestions> =>
|
||||
apiFetch(`/test-templates/${templateId}`, {}, false),
|
||||
createTestTemplate: (payload: CreateTestTemplatePayload): Promise<TestTemplate> =>
|
||||
apiFetch('/test-templates', { method: 'POST', body: JSON.stringify(payload) }, false),
|
||||
updateTestTemplate: (templateId: string, payload: UpdateTestTemplatePayload): Promise<TestTemplate> =>
|
||||
apiFetch(`/test-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) }, false),
|
||||
deleteTestTemplate: (templateId: string): Promise<void> =>
|
||||
apiFetch(`/test-templates/${templateId}`, { method: 'DELETE' }, false),
|
||||
createTemplateQuestion: (templateId: string, payload: CreateQuestionPayload): Promise<TestTemplateQuestion> =>
|
||||
apiFetch(`/test-templates/${templateId}/questions`, { method: 'POST', body: JSON.stringify(payload) }, false),
|
||||
deleteTemplateQuestion: (templateId: string, questionId: string): Promise<void> =>
|
||||
apiFetch(`/test-templates/${templateId}/questions/${questionId}`, { method: 'DELETE' }, false),
|
||||
createTemplateSection: (templateId: string, payload: CreateSectionPayload): Promise<TestTemplateSection> =>
|
||||
apiFetch(`/test-templates/${templateId}/sections`, { method: 'POST', body: JSON.stringify(payload) }, false),
|
||||
deleteTemplateSection: (templateId: string, sectionId: string): Promise<void> =>
|
||||
apiFetch(`/test-templates/${templateId}/sections/${sectionId}`, { method: 'DELETE' }, false),
|
||||
applyTemplateToLesson: (templateId: string, lessonId: string, gradingCategoryId?: string): Promise<void> =>
|
||||
apiFetch(`/test-templates/${templateId}/apply`, { method: 'POST', body: JSON.stringify({ lesson_id: lessonId, grading_category_id: gradingCategoryId }) }, false),
|
||||
};
|
||||
|
||||
export const lmsApi = {
|
||||
@@ -997,4 +1039,123 @@ export interface BackgroundTask {
|
||||
status: 'idle' | 'queued' | 'processing' | 'failed' | 'completed' | 'error';
|
||||
progress: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ==================== Test Templates ====================
|
||||
|
||||
export type CourseLevel = 'beginner' | 'beginner_1' | 'beginner_2' | 'intermediate' | 'intermediate_1' | 'intermediate_2' | 'advanced' | 'advanced_1' | 'advanced_2';
|
||||
export type CourseType = 'intensive' | 'regular';
|
||||
export type TestType = 'CA' | 'MWT' | 'MOT' | 'FOT' | 'FWT';
|
||||
export type QuestionType = 'multiple-choice' | 'true-false' | 'short-answer' | 'essay' | 'matching' | 'ordering';
|
||||
|
||||
export interface TestTemplate {
|
||||
id: string;
|
||||
organization_id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
level: CourseLevel;
|
||||
course_type: CourseType;
|
||||
test_type: TestType;
|
||||
duration_minutes: number;
|
||||
passing_score: number;
|
||||
total_points: number;
|
||||
instructions?: string;
|
||||
template_data: any;
|
||||
tags?: string[];
|
||||
is_active: boolean;
|
||||
usage_count: number;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TestTemplateSection {
|
||||
id: string;
|
||||
template_id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
section_order: number;
|
||||
points: number;
|
||||
instructions?: string;
|
||||
section_data?: any;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TestTemplateQuestion {
|
||||
id: string;
|
||||
template_id: string;
|
||||
section_id?: string;
|
||||
question_order: number;
|
||||
question_type: QuestionType;
|
||||
question_text: string;
|
||||
options?: any;
|
||||
correct_answer?: any;
|
||||
explanation?: string;
|
||||
points: number;
|
||||
metadata?: any;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TestTemplateWithQuestions {
|
||||
template: TestTemplate;
|
||||
sections: TestTemplateSection[];
|
||||
questions: TestTemplateQuestion[];
|
||||
}
|
||||
|
||||
export interface CreateTestTemplatePayload {
|
||||
name: string;
|
||||
description?: string;
|
||||
level: CourseLevel;
|
||||
course_type: CourseType;
|
||||
test_type: TestType;
|
||||
duration_minutes: number;
|
||||
passing_score: number;
|
||||
total_points: number;
|
||||
instructions?: string;
|
||||
template_data: any;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateTestTemplatePayload {
|
||||
name?: string;
|
||||
description?: string;
|
||||
level?: CourseLevel;
|
||||
course_type?: CourseType;
|
||||
test_type?: TestType;
|
||||
duration_minutes?: number;
|
||||
passing_score?: number;
|
||||
total_points?: number;
|
||||
instructions?: string;
|
||||
template_data?: any;
|
||||
tags?: string[];
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateQuestionPayload {
|
||||
section_id?: string;
|
||||
question_order: number;
|
||||
question_type: string;
|
||||
question_text: string;
|
||||
options?: any;
|
||||
correct_answer?: any;
|
||||
explanation?: string;
|
||||
points: number;
|
||||
metadata?: any;
|
||||
}
|
||||
|
||||
export interface CreateSectionPayload {
|
||||
title: string;
|
||||
description?: string;
|
||||
section_order: number;
|
||||
points: number;
|
||||
instructions?: string;
|
||||
section_data?: any;
|
||||
}
|
||||
|
||||
export interface TestTemplateFilters {
|
||||
level?: CourseLevel;
|
||||
course_type?: CourseType;
|
||||
test_type?: TestType;
|
||||
tags?: string;
|
||||
search?: string;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user