trading-platform-database-v2/ddl/schemas/education/tables/009_quiz_attempts.sql
rckrdmrd cd6590ec25 [DDL] feat: Sprint 2 - Add education schema with 11 tables
## Schema: education (Complete Learning Management System)

### Course Structure (3 tables):
- categories: Hierarchical course categories with materialized paths
- courses: Full course catalog with pricing, access models, gamification
- modules: Course sections/modules with sequencing

### Content (3 tables):
- lessons: Individual lessons (video, article, interactive)
- quizzes: Assessments with configurable rules
- quiz_questions: Question bank with multiple types

### Progress Tracking (3 tables):
- enrollments: User enrollments with progress tracking
- lesson_progress: Detailed per-lesson progress
- quiz_attempts: Quiz attempt history with grading

### Completion (2 tables):
- course_reviews: Student reviews with moderation
- certificates: Verifiable completion certificates

## Features:
- 8 custom ENUMs for education domain
- Multi-tenancy with RLS policies
- Automatic progress calculation triggers
- Quiz grading and statistics
- Certificate generation with verification codes
- Rating aggregation for courses
- Gamification support (XP, badges)

Total: 11 tables, ~95KB of DDL

Roadmap: orchestration/planes/ROADMAP-IMPLEMENTACION-DDL-2026-Q1.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 19:48:39 -06:00

378 lines
13 KiB
PL/PgSQL

-- ============================================================================
-- SCHEMA: education
-- TABLE: quiz_attempts
-- DESCRIPTION: Intentos de quizzes por usuarios
-- VERSION: 1.0.0
-- CREATED: 2026-01-16
-- SPRINT: Sprint 2 - DDL Implementation Roadmap Q1-2026
-- ============================================================================
-- Enum para estado del intento
DO $$ BEGIN
CREATE TYPE education.attempt_status AS ENUM (
'in_progress', -- En progreso
'submitted', -- Enviado, pendiente calificacion
'graded', -- Calificado
'abandoned', -- Abandonado (tiempo expirado)
'voided' -- Anulado por admin
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Tabla de Intentos de Quiz
CREATE TABLE IF NOT EXISTS education.quiz_attempts (
-- Identificadores
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
quiz_id UUID NOT NULL REFERENCES education.quizzes(id) ON DELETE CASCADE,
enrollment_id UUID NOT NULL REFERENCES education.enrollments(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
-- Numero de intento
attempt_number INTEGER NOT NULL DEFAULT 1,
-- Estado
status education.attempt_status NOT NULL DEFAULT 'in_progress',
-- Tiempo
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
submitted_at TIMESTAMPTZ,
graded_at TIMESTAMPTZ,
time_limit_at TIMESTAMPTZ, -- Cuando expira el tiempo
time_spent_seconds INTEGER,
-- Preguntas
questions_shown JSONB NOT NULL DEFAULT '[]'::JSONB, -- IDs de preguntas mostradas (si shuffle)
questions_total INTEGER NOT NULL DEFAULT 0,
questions_answered INTEGER NOT NULL DEFAULT 0,
-- Respuestas
-- [{ "question_id": "...", "answer": "...", "is_correct": true, "points": 1, "time_spent": 30 }]
answers JSONB NOT NULL DEFAULT '[]'::JSONB,
-- Calificacion
score DECIMAL(5, 2), -- Puntos obtenidos
max_score DECIMAL(5, 2), -- Puntos maximos posibles
score_percent DECIMAL(5, 2), -- Porcentaje (score/max_score * 100)
passed BOOLEAN,
grade VARCHAR(2), -- 'A', 'B', 'C', 'D', 'F'
-- Desglose
correct_count INTEGER NOT NULL DEFAULT 0,
incorrect_count INTEGER NOT NULL DEFAULT 0,
partial_count INTEGER NOT NULL DEFAULT 0, -- Respuestas parcialmente correctas
unanswered_count INTEGER NOT NULL DEFAULT 0,
-- Feedback
feedback_shown BOOLEAN NOT NULL DEFAULT FALSE,
instructor_feedback TEXT,
-- XP y gamificacion
xp_earned INTEGER NOT NULL DEFAULT 0,
badge_earned UUID,
-- Metadata
ip_address INET,
user_agent TEXT,
metadata JSONB DEFAULT '{}'::JSONB,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE education.quiz_attempts IS
'Intentos de quiz con respuestas y calificaciones';
COMMENT ON COLUMN education.quiz_attempts.questions_shown IS
'Array de IDs de preguntas en el orden mostrado (puede variar por shuffle)';
-- Indices
CREATE INDEX IF NOT EXISTS idx_quiz_attempts_quiz
ON education.quiz_attempts(quiz_id);
CREATE INDEX IF NOT EXISTS idx_quiz_attempts_enrollment
ON education.quiz_attempts(enrollment_id);
CREATE INDEX IF NOT EXISTS idx_quiz_attempts_user
ON education.quiz_attempts(user_id);
CREATE INDEX IF NOT EXISTS idx_quiz_attempts_tenant
ON education.quiz_attempts(tenant_id);
CREATE INDEX IF NOT EXISTS idx_quiz_attempts_status
ON education.quiz_attempts(status);
CREATE INDEX IF NOT EXISTS idx_quiz_attempts_in_progress
ON education.quiz_attempts(user_id, quiz_id)
WHERE status = 'in_progress';
CREATE INDEX IF NOT EXISTS idx_quiz_attempts_passed
ON education.quiz_attempts(quiz_id, passed)
WHERE passed = TRUE;
CREATE INDEX IF NOT EXISTS idx_quiz_attempts_score
ON education.quiz_attempts(quiz_id, score_percent DESC);
-- GIN index para respuestas
CREATE INDEX IF NOT EXISTS idx_quiz_attempts_answers_gin
ON education.quiz_attempts USING GIN (answers);
-- Trigger para updated_at
DROP TRIGGER IF EXISTS quiz_attempt_updated_at ON education.quiz_attempts;
CREATE TRIGGER quiz_attempt_updated_at
BEFORE UPDATE ON education.quiz_attempts
FOR EACH ROW
EXECUTE FUNCTION education.update_education_timestamp();
-- Trigger para calcular calificacion al enviar
CREATE OR REPLACE FUNCTION education.grade_quiz_attempt()
RETURNS TRIGGER AS $$
DECLARE
v_quiz RECORD;
v_answer RECORD;
v_total_points DECIMAL := 0;
v_earned_points DECIMAL := 0;
v_correct INTEGER := 0;
v_incorrect INTEGER := 0;
v_partial INTEGER := 0;
BEGIN
-- Solo procesar cuando cambia a submitted
IF NEW.status = 'submitted' AND OLD.status = 'in_progress' THEN
-- Obtener info del quiz
SELECT * INTO v_quiz FROM education.quizzes WHERE id = NEW.quiz_id;
-- Calcular puntos
FOR v_answer IN SELECT * FROM jsonb_array_elements(NEW.answers)
LOOP
v_total_points := v_total_points + COALESCE((v_answer.value->>'max_points')::DECIMAL, 1);
IF (v_answer.value->>'is_correct')::BOOLEAN = TRUE THEN
v_earned_points := v_earned_points + COALESCE((v_answer.value->>'points')::DECIMAL, 1);
v_correct := v_correct + 1;
ELSIF (v_answer.value->>'points')::DECIMAL > 0 THEN
v_earned_points := v_earned_points + (v_answer.value->>'points')::DECIMAL;
v_partial := v_partial + 1;
ELSE
v_incorrect := v_incorrect + 1;
END IF;
END LOOP;
-- Calcular porcentaje
NEW.score := v_earned_points;
NEW.max_score := v_total_points;
IF v_total_points > 0 THEN
NEW.score_percent := (v_earned_points / v_total_points * 100);
END IF;
-- Determinar si paso
NEW.passed := NEW.score_percent >= v_quiz.passing_score;
-- Calcular grade
NEW.grade := CASE
WHEN NEW.score_percent >= 90 THEN 'A'
WHEN NEW.score_percent >= 80 THEN 'B'
WHEN NEW.score_percent >= 70 THEN 'C'
WHEN NEW.score_percent >= 60 THEN 'D'
ELSE 'F'
END;
-- Contadores
NEW.correct_count := v_correct;
NEW.incorrect_count := v_incorrect;
NEW.partial_count := v_partial;
NEW.unanswered_count := NEW.questions_total - jsonb_array_length(NEW.answers);
-- Tiempo
NEW.submitted_at := NOW();
NEW.time_spent_seconds := EXTRACT(EPOCH FROM (NOW() - NEW.started_at))::INTEGER;
-- XP
IF NEW.passed THEN
NEW.xp_earned := v_quiz.xp_reward;
ELSE
NEW.xp_earned := v_quiz.xp_reward / 4; -- XP parcial por intentar
END IF;
-- Marcar como graded
NEW.status := 'graded';
NEW.graded_at := NOW();
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS quiz_attempt_grade ON education.quiz_attempts;
CREATE TRIGGER quiz_attempt_grade
BEFORE UPDATE OF status ON education.quiz_attempts
FOR EACH ROW
EXECUTE FUNCTION education.grade_quiz_attempt();
-- Trigger para actualizar estadisticas del quiz
CREATE OR REPLACE FUNCTION education.update_quiz_stats_on_attempt()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.status = 'graded' THEN
UPDATE education.quizzes
SET attempt_count = attempt_count + 1,
average_score = (
SELECT AVG(score_percent)
FROM education.quiz_attempts
WHERE quiz_id = NEW.quiz_id AND status = 'graded'
),
pass_rate = (
SELECT (COUNT(*) FILTER (WHERE passed = TRUE)::DECIMAL / COUNT(*) * 100)
FROM education.quiz_attempts
WHERE quiz_id = NEW.quiz_id AND status = 'graded'
)
WHERE id = NEW.quiz_id;
-- Actualizar enrollment
UPDATE education.enrollments
SET quizzes_completed = (
SELECT COUNT(DISTINCT quiz_id)
FROM education.quiz_attempts
WHERE enrollment_id = NEW.enrollment_id AND status = 'graded'
),
quizzes_passed = (
SELECT COUNT(DISTINCT quiz_id)
FROM education.quiz_attempts
WHERE enrollment_id = NEW.enrollment_id AND status = 'graded' AND passed = TRUE
),
xp_earned = xp_earned + NEW.xp_earned
WHERE id = NEW.enrollment_id;
-- Actualizar lesson_progress si el quiz esta asociado a una leccion
UPDATE education.lesson_progress
SET quiz_attempted = TRUE,
quiz_passed = NEW.passed,
quiz_score = NEW.score_percent,
is_completed = NEW.passed -- Completar leccion si paso el quiz
WHERE enrollment_id = NEW.enrollment_id
AND lesson_id = (SELECT lesson_id FROM education.quizzes WHERE id = NEW.quiz_id);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS quiz_attempt_stats ON education.quiz_attempts;
CREATE TRIGGER quiz_attempt_stats
AFTER UPDATE OF status ON education.quiz_attempts
FOR EACH ROW
EXECUTE FUNCTION education.update_quiz_stats_on_attempt();
-- Funcion para iniciar un intento
CREATE OR REPLACE FUNCTION education.start_quiz_attempt(
p_quiz_id UUID,
p_enrollment_id UUID
)
RETURNS UUID AS $$
DECLARE
v_quiz RECORD;
v_enrollment RECORD;
v_attempt_count INTEGER;
v_questions UUID[];
v_attempt_id UUID;
BEGIN
-- Obtener quiz
SELECT * INTO v_quiz FROM education.quizzes WHERE id = p_quiz_id;
IF v_quiz IS NULL THEN
RAISE EXCEPTION 'Quiz not found';
END IF;
-- Obtener enrollment
SELECT * INTO v_enrollment FROM education.enrollments WHERE id = p_enrollment_id;
-- Verificar intentos previos
SELECT COUNT(*) INTO v_attempt_count
FROM education.quiz_attempts
WHERE quiz_id = p_quiz_id
AND enrollment_id = p_enrollment_id
AND status NOT IN ('abandoned', 'voided');
IF v_quiz.max_attempts IS NOT NULL AND v_attempt_count >= v_quiz.max_attempts THEN
RAISE EXCEPTION 'Maximum attempts reached';
END IF;
-- Verificar si hay intento en progreso
IF EXISTS (
SELECT 1 FROM education.quiz_attempts
WHERE quiz_id = p_quiz_id
AND enrollment_id = p_enrollment_id
AND status = 'in_progress'
) THEN
RAISE EXCEPTION 'Already have an attempt in progress';
END IF;
-- Seleccionar preguntas
SELECT ARRAY_AGG(id ORDER BY
CASE WHEN v_quiz.shuffle_questions THEN RANDOM() ELSE sequence_number END
) INTO v_questions
FROM education.quiz_questions
WHERE quiz_id = p_quiz_id AND is_active = TRUE
LIMIT COALESCE(v_quiz.questions_to_show, v_quiz.question_count);
-- Crear intento
INSERT INTO education.quiz_attempts (
quiz_id, enrollment_id, user_id, tenant_id,
attempt_number, questions_shown, questions_total,
time_limit_at
) VALUES (
p_quiz_id, p_enrollment_id, v_enrollment.user_id, v_enrollment.tenant_id,
v_attempt_count + 1,
to_jsonb(v_questions),
array_length(v_questions, 1),
CASE WHEN v_quiz.time_limit_minutes IS NOT NULL
THEN NOW() + (v_quiz.time_limit_minutes || ' minutes')::INTERVAL
ELSE NULL
END
)
RETURNING id INTO v_attempt_id;
RETURN v_attempt_id;
END;
$$ LANGUAGE plpgsql;
-- Vista de intentos de un usuario
CREATE OR REPLACE VIEW education.v_my_quiz_attempts AS
SELECT
qa.id,
qa.quiz_id,
q.title AS quiz_title,
q.type AS quiz_type,
qa.enrollment_id,
qa.attempt_number,
qa.status,
qa.score_percent,
qa.passed,
qa.grade,
qa.correct_count,
qa.questions_total,
qa.time_spent_seconds,
qa.started_at,
qa.submitted_at
FROM education.quiz_attempts qa
JOIN education.quizzes q ON qa.quiz_id = q.id
ORDER BY qa.started_at DESC;
-- RLS Policy para multi-tenancy
ALTER TABLE education.quiz_attempts ENABLE ROW LEVEL SECURITY;
CREATE POLICY quiz_attempts_tenant_isolation ON education.quiz_attempts
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY quiz_attempts_user_isolation ON education.quiz_attempts
FOR SELECT
USING (user_id = current_setting('app.current_user_id', true)::UUID);
-- Grants
GRANT SELECT, INSERT, UPDATE ON education.quiz_attempts TO trading_app;
GRANT SELECT ON education.quiz_attempts TO trading_readonly;
GRANT SELECT ON education.v_my_quiz_attempts TO trading_app;
GRANT EXECUTE ON FUNCTION education.start_quiz_attempt TO trading_app;