## 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>
378 lines
13 KiB
PL/PgSQL
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;
|