diff --git a/TEST_TEMPLATES_IMPLEMENTATION.md b/TEST_TEMPLATES_IMPLEMENTATION.md new file mode 100644 index 0000000..ab57d71 --- /dev/null +++ b/TEST_TEMPLATES_IMPLEMENTATION.md @@ -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 diff --git a/services/cms-service/migrations/20260316000000_test_templates.sql b/services/cms-service/migrations/20260316000000_test_templates.sql new file mode 100644 index 0000000..b5a73e3 --- /dev/null +++ b/services/cms-service/migrations/20260316000000_test_templates.sql @@ -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'; diff --git a/services/cms-service/src/handlers_test_templates.rs b/services/cms-service/src/handlers_test_templates.rs new file mode 100644 index 0000000..481d9d0 --- /dev/null +++ b/services/cms-service/src/handlers_test_templates.rs @@ -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, + pub course_type: Option, + pub test_type: Option, + pub tags: Option, // Comma-separated list + pub search: Option, +} + +// ==================== 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, + Json(payload): Json, +) -> Result, (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, + Query(filters): Query, +) -> Result>, (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 = 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, + State(pool): State, +) -> Result, (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 = 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 = 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, + claims: Claims, + State(pool): State, + Json(payload): Json, +) -> Result, (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, + State(pool): State, +) -> Result { + 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, + State(pool): State, + Json(payload): Json, +) -> Result, (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, + pub question_order: i32, + pub question_type: String, + pub question_text: String, + pub options: Option, + pub correct_answer: Option, + pub explanation: Option, + pub points: i32, + pub metadata: Option, +} + +/// 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, +) -> Result { + // 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, + State(pool): State, + Json(payload): Json, +) -> Result, (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, + pub section_order: i32, + pub points: i32, + pub instructions: Option, + pub section_data: Option, +} + +/// 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, +) -> Result { + 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, + claims: Claims, + State(pool): State, + Json(payload): Json, +) -> Result { + 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, +} diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index 757dcb8..1684927 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -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, )); diff --git a/shared/common/src/models.rs b/shared/common/src/models.rs index 25e18d8..65fd354 100644 --- a/shared/common/src/models.rs +++ b/shared/common/src/models.rs @@ -20,6 +20,8 @@ pub struct Course { pub currency: String, pub marketing_metadata: Option, pub course_image_url: Option, + pub level: Option, + pub course_type: Option, pub created_at: DateTime, pub updated_at: DateTime, } @@ -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, + 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, + pub template_data: serde_json::Value, // Complete test structure with sections and questions + pub tags: Option>, + pub is_active: bool, + pub usage_count: i32, + pub created_by: Uuid, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] +pub struct TestTemplateSection { + pub id: Uuid, + pub template_id: Uuid, + pub title: String, + pub description: Option, + pub section_order: i32, + pub points: i32, + pub instructions: Option, + pub section_data: Option, // Section-specific configuration + pub created_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] +pub struct TestTemplateQuestion { + pub id: Uuid, + pub template_id: Uuid, + pub section_id: Option, + pub question_order: i32, + pub question_type: String, // "multiple-choice", "true-false", "short-answer", "essay", "matching", "ordering" + pub question_text: String, + pub options: Option, // Array of options for multiple choice + pub correct_answer: Option, // Can be index, array of indices, or text + pub explanation: Option, + pub points: i32, + pub metadata: Option, // Additional question metadata + pub created_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct CreateTestTemplatePayload { + pub name: String, + pub description: Option, + 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, + pub template_data: serde_json::Value, + pub tags: Option>, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct UpdateTestTemplatePayload { + pub name: Option, + pub description: Option, + pub level: Option, + pub course_type: Option, + pub test_type: Option, + pub duration_minutes: Option, + pub passing_score: Option, + pub total_points: Option, + pub instructions: Option, + pub template_data: Option, + pub tags: Option>, + pub is_active: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct TestTemplateWithQuestions { + pub template: TestTemplate, + pub sections: Vec, + pub questions: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ApplyTemplatePayload { + pub lesson_id: Uuid, + pub grading_category_id: Option, +} diff --git a/web/studio/src/app/test-templates/page.tsx b/web/studio/src/app/test-templates/page.tsx new file mode 100644 index 0000000..08d2c71 --- /dev/null +++ b/web/studio/src/app/test-templates/page.tsx @@ -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 ( + +
+ setShowCreateForm(true)} + onCreateTemplate={() => setShowCreateForm(true)} + /> +
+ + {showCreateForm && ( + setShowCreateForm(false)} + /> + )} +
+ ); +} diff --git a/web/studio/src/components/TestTemplates/TestTemplateForm.tsx b/web/studio/src/components/TestTemplates/TestTemplateForm.tsx new file mode 100644 index 0000000..236e2a1 --- /dev/null +++ b/web/studio/src/components/TestTemplates/TestTemplateForm.tsx @@ -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({ + 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 ( +
+
+ {/* Header */} +
+

Nueva Plantilla de Prueba

+ +
+ + {/* Form */} +
+ {/* Basic Info */} +
+

Información Básica

+ +
+ + 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 + /> +
+ +
+ +