feat: Implement advanced grading (rubrics) and lesson dependencies across CMS service, API, and Studio UI.
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
-- Migration: Advanced Grading System (Rubrics)
|
||||
-- Description: Creates tables for rubric-based assessment and grading workflows
|
||||
-- Created: 2026-02-17
|
||||
|
||||
-- Rubrics table - Reusable evaluation templates
|
||||
CREATE TABLE IF NOT EXISTS rubrics (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
course_id UUID REFERENCES courses(id) ON DELETE CASCADE, -- NULL = reusable across courses
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
total_points INTEGER NOT NULL DEFAULT 100,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_rubrics_org ON rubrics(organization_id);
|
||||
CREATE INDEX idx_rubrics_course ON rubrics(course_id);
|
||||
|
||||
-- Rubric Criteria - Evaluation dimensions
|
||||
CREATE TABLE IF NOT EXISTS rubric_criteria (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
rubric_id UUID NOT NULL REFERENCES rubrics(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
max_points INTEGER NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_criteria_rubric ON rubric_criteria(rubric_id);
|
||||
|
||||
-- Rubric Levels - Performance levels per criterion
|
||||
CREATE TABLE IF NOT EXISTS rubric_levels (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
criterion_id UUID NOT NULL REFERENCES rubric_criteria(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL, -- e.g., "Excellent", "Proficient", "Developing"
|
||||
description TEXT,
|
||||
points INTEGER NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_levels_criterion ON rubric_levels(criterion_id);
|
||||
|
||||
-- Lesson-Rubric Association
|
||||
CREATE TABLE IF NOT EXISTS lesson_rubrics (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
lesson_id UUID NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
|
||||
rubric_id UUID NOT NULL REFERENCES rubrics(id) ON DELETE CASCADE,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
assigned_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
UNIQUE(lesson_id, rubric_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_lesson_rubrics_lesson ON lesson_rubrics(lesson_id);
|
||||
CREATE INDEX idx_lesson_rubrics_rubric ON lesson_rubrics(rubric_id);
|
||||
|
||||
-- Rubric Assessments - Student evaluation results
|
||||
CREATE TABLE IF NOT EXISTS rubric_assessments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
lesson_id UUID NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
|
||||
rubric_id UUID NOT NULL REFERENCES rubrics(id),
|
||||
user_id UUID NOT NULL REFERENCES users(id), -- student being assessed
|
||||
graded_by UUID REFERENCES users(id), -- instructor or peer reviewer
|
||||
submission_id UUID, -- if linked to peer_submissions
|
||||
total_score DECIMAL(5,2) NOT NULL,
|
||||
max_score INTEGER NOT NULL,
|
||||
feedback TEXT,
|
||||
status VARCHAR(50) DEFAULT 'draft', -- draft, submitted, published
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_assessments_lesson ON rubric_assessments(lesson_id);
|
||||
CREATE INDEX idx_assessments_user ON rubric_assessments(user_id);
|
||||
CREATE INDEX idx_assessments_grader ON rubric_assessments(graded_by);
|
||||
CREATE INDEX idx_assessments_status ON rubric_assessments(status);
|
||||
|
||||
-- Assessment Scores - Individual criterion scores
|
||||
CREATE TABLE IF NOT EXISTS assessment_scores (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
assessment_id UUID NOT NULL REFERENCES rubric_assessments(id) ON DELETE CASCADE,
|
||||
criterion_id UUID NOT NULL REFERENCES rubric_criteria(id),
|
||||
level_id UUID REFERENCES rubric_levels(id), -- selected performance level
|
||||
points DECIMAL(5,2) NOT NULL,
|
||||
feedback TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_scores_assessment ON assessment_scores(assessment_id);
|
||||
CREATE INDEX idx_scores_criterion ON assessment_scores(criterion_id);
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Migración para crear la tabla de dependencias de lecciones (Learning Sequences)
|
||||
|
||||
CREATE TABLE lesson_dependencies (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
lesson_id UUID NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
|
||||
prerequisite_lesson_id UUID NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
|
||||
min_score_percentage DOUBLE PRECISION,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(lesson_id, prerequisite_lesson_id),
|
||||
CHECK (lesson_id != prerequisite_lesson_id)
|
||||
);
|
||||
|
||||
-- Índices para mejorar el rendimiento de las consultas
|
||||
CREATE INDEX idx_lesson_dependencies_lesson_id ON lesson_dependencies(lesson_id);
|
||||
CREATE INDEX idx_lesson_dependencies_prerequisite_id ON lesson_dependencies(prerequisite_lesson_id);
|
||||
CREATE INDEX idx_lesson_dependencies_org_id ON lesson_dependencies(organization_id);
|
||||
@@ -0,0 +1,102 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use common::middleware::Org;
|
||||
use common::models::LessonDependency;
|
||||
use serde::Deserialize;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AssignDependencyPayload {
|
||||
pub prerequisite_lesson_id: Uuid,
|
||||
pub min_score_percentage: Option<f64>,
|
||||
}
|
||||
|
||||
pub async fn assign_dependency(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(lesson_id): Path<Uuid>,
|
||||
Json(payload): Json<AssignDependencyPayload>,
|
||||
) -> Result<Json<LessonDependency>, StatusCode> {
|
||||
// 1. Validar que ambas lecciones pertenecen a la organización
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM lessons WHERE id IN ($1, $2) AND organization_id = $3",
|
||||
)
|
||||
.bind(lesson_id)
|
||||
.bind(payload.prerequisite_lesson_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
if count != 2 {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// 2. Insertar la dependencia
|
||||
let dependency = sqlx::query_as!(
|
||||
LessonDependency,
|
||||
r#"
|
||||
INSERT INTO lesson_dependencies (organization_id, lesson_id, prerequisite_lesson_id, min_score_percentage)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (lesson_id, prerequisite_lesson_id)
|
||||
DO UPDATE SET min_score_percentage = EXCLUDED.min_score_percentage
|
||||
RETURNING id, organization_id, lesson_id, prerequisite_lesson_id, min_score_percentage, created_at
|
||||
"#,
|
||||
org_ctx.id,
|
||||
lesson_id,
|
||||
payload.prerequisite_lesson_id,
|
||||
payload.min_score_percentage
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to assign dependency: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
Ok(Json(dependency))
|
||||
}
|
||||
|
||||
pub async fn remove_dependency(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path((lesson_id, prerequisite_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
let result = sqlx::query!(
|
||||
"DELETE FROM lesson_dependencies WHERE lesson_id = $1 AND prerequisite_lesson_id = $2 AND organization_id = $3",
|
||||
lesson_id,
|
||||
prerequisite_id,
|
||||
org_ctx.id
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn list_lesson_dependencies(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(lesson_id): Path<Uuid>,
|
||||
) -> Result<Json<Vec<LessonDependency>>, StatusCode> {
|
||||
let dependencies = sqlx::query_as!(
|
||||
LessonDependency,
|
||||
"SELECT * FROM lesson_dependencies WHERE lesson_id = $1 AND organization_id = $2",
|
||||
lesson_id,
|
||||
org_ctx.id
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(dependencies))
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use common::auth::Claims;
|
||||
use common::middleware::Org;
|
||||
use common::models::{LessonRubric, Rubric, RubricCriterion, RubricLevel};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ==================== Payload Structs ====================
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateRubricPayload {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub course_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateRubricPayload {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateCriterionPayload {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub max_points: i32,
|
||||
pub position: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateCriterionPayload {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub max_points: Option<i32>,
|
||||
pub position: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateLevelPayload {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub points: i32,
|
||||
pub position: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateLevelPayload {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub points: Option<i32>,
|
||||
pub position: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateAssessmentPayload {
|
||||
pub lesson_id: Uuid,
|
||||
pub rubric_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub submission_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateAssessmentPayload {
|
||||
pub total_score: f32,
|
||||
pub feedback: Option<String>,
|
||||
pub scores: Vec<CriterionScorePayload>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CriterionScorePayload {
|
||||
pub criterion_id: Uuid,
|
||||
pub level_id: Option<Uuid>,
|
||||
pub points: f32,
|
||||
pub feedback: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RubricWithDetails {
|
||||
#[serde(flatten)]
|
||||
pub rubric: Rubric,
|
||||
pub criteria: Vec<CriterionWithLevels>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CriterionWithLevels {
|
||||
#[serde(flatten)]
|
||||
pub criterion: RubricCriterion,
|
||||
pub levels: Vec<RubricLevel>,
|
||||
}
|
||||
|
||||
// ==================== Rubric Management ====================
|
||||
|
||||
/// Create a new rubric
|
||||
pub async fn create_rubric(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path(course_id): Path<Uuid>,
|
||||
Json(payload): Json<CreateRubricPayload>,
|
||||
) -> Result<Json<Rubric>, (StatusCode, String)> {
|
||||
let rubric = sqlx::query_as!(
|
||||
Rubric,
|
||||
r#"
|
||||
INSERT INTO rubrics (organization_id, course_id, created_by, name, description)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, organization_id, course_id, created_by, name, description, total_points, created_at, updated_at
|
||||
"#,
|
||||
org_ctx.id,
|
||||
payload.course_id.or(Some(course_id)),
|
||||
claims.sub,
|
||||
payload.name,
|
||||
payload.description
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(rubric))
|
||||
}
|
||||
|
||||
/// List all rubrics for a course
|
||||
pub async fn list_course_rubrics(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(course_id): Path<Uuid>,
|
||||
) -> Result<Json<Vec<Rubric>>, (StatusCode, String)> {
|
||||
let rubrics = sqlx::query_as!(
|
||||
Rubric,
|
||||
r#"
|
||||
SELECT id, organization_id, course_id, created_by, name, description, total_points, created_at, updated_at
|
||||
FROM rubrics
|
||||
WHERE organization_id = $1 AND (course_id = $2 OR course_id IS NULL)
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
org_ctx.id,
|
||||
course_id
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(rubrics))
|
||||
}
|
||||
|
||||
/// Get a rubric with all criteria and levels
|
||||
pub async fn get_rubric_with_details(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(rubric_id): Path<Uuid>,
|
||||
) -> Result<Json<RubricWithDetails>, (StatusCode, String)> {
|
||||
// Get rubric
|
||||
let rubric = sqlx::query_as!(
|
||||
Rubric,
|
||||
r#"
|
||||
SELECT id, organization_id, course_id, created_by, name, description, total_points, created_at, updated_at
|
||||
FROM rubrics
|
||||
WHERE id = $1 AND organization_id = $2
|
||||
"#,
|
||||
rubric_id,
|
||||
org_ctx.id
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Rubric not found".to_string()))?;
|
||||
|
||||
// Get criteria
|
||||
let criteria = sqlx::query_as!(
|
||||
RubricCriterion,
|
||||
r#"
|
||||
SELECT id, rubric_id, name, description, max_points, position, created_at
|
||||
FROM rubric_criteria
|
||||
WHERE rubric_id = $1
|
||||
ORDER BY position ASC
|
||||
"#,
|
||||
rubric_id
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Get levels for each criterion
|
||||
let mut criteria_with_levels = Vec::new();
|
||||
for criterion in criteria {
|
||||
let levels = sqlx::query_as!(
|
||||
RubricLevel,
|
||||
r#"
|
||||
SELECT id, criterion_id, name, description, points, position, created_at
|
||||
FROM rubric_levels
|
||||
WHERE criterion_id = $1
|
||||
ORDER BY position ASC
|
||||
"#,
|
||||
criterion.id
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
criteria_with_levels.push(CriterionWithLevels { criterion, levels });
|
||||
}
|
||||
|
||||
Ok(Json(RubricWithDetails {
|
||||
rubric,
|
||||
criteria: criteria_with_levels,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Update a rubric
|
||||
pub async fn update_rubric(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(rubric_id): Path<Uuid>,
|
||||
Json(payload): Json<UpdateRubricPayload>,
|
||||
) -> Result<Json<Rubric>, (StatusCode, String)> {
|
||||
let rubric = sqlx::query_as!(
|
||||
Rubric,
|
||||
r#"
|
||||
UPDATE rubrics
|
||||
SET name = COALESCE($1, name),
|
||||
description = COALESCE($2, description),
|
||||
updated_at = NOW()
|
||||
WHERE id = $3 AND organization_id = $4
|
||||
RETURNING id, organization_id, course_id, created_by, name, description, total_points, created_at, updated_at
|
||||
"#,
|
||||
payload.name,
|
||||
payload.description,
|
||||
rubric_id,
|
||||
org_ctx.id
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Rubric not found".to_string()))?;
|
||||
|
||||
Ok(Json(rubric))
|
||||
}
|
||||
|
||||
/// Delete a rubric
|
||||
pub async fn delete_rubric(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(rubric_id): Path<Uuid>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
let result = sqlx::query!(
|
||||
"DELETE FROM rubrics WHERE id = $1 AND organization_id = $2",
|
||||
rubric_id,
|
||||
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, "Rubric not found".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ==================== Criterion Management ====================
|
||||
|
||||
/// Add a criterion to a rubric
|
||||
pub async fn create_criterion(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(rubric_id): Path<Uuid>,
|
||||
Json(payload): Json<CreateCriterionPayload>,
|
||||
) -> Result<Json<RubricCriterion>, (StatusCode, String)> {
|
||||
// Verify rubric exists and belongs to org
|
||||
let _rubric = sqlx::query!(
|
||||
"SELECT id FROM rubrics WHERE id = $1 AND organization_id = $2",
|
||||
rubric_id,
|
||||
org_ctx.id
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Rubric not found".to_string()))?;
|
||||
|
||||
let position = payload.position.unwrap_or(0);
|
||||
|
||||
let criterion = sqlx::query_as!(
|
||||
RubricCriterion,
|
||||
r#"
|
||||
INSERT INTO rubric_criteria (rubric_id, name, description, max_points, position)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, rubric_id, name, description, max_points, position, created_at
|
||||
"#,
|
||||
rubric_id,
|
||||
payload.name,
|
||||
payload.description,
|
||||
payload.max_points,
|
||||
position
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Update rubric total_points
|
||||
let _= sqlx::query!(
|
||||
r#"
|
||||
UPDATE rubrics
|
||||
SET total_points = (SELECT COALESCE(SUM(max_points), 0) FROM rubric_criteria WHERE rubric_id = $1),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
"#,
|
||||
rubric_id
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(criterion))
|
||||
}
|
||||
|
||||
/// Update a criterion
|
||||
pub async fn update_criterion(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(criterion_id): Path<Uuid>,
|
||||
Json(payload): Json<UpdateCriterionPayload>,
|
||||
) -> Result<Json<RubricCriterion>, (StatusCode, String)> {
|
||||
let criterion = sqlx::query_as!(
|
||||
RubricCriterion,
|
||||
r#"
|
||||
UPDATE rubric_criteria
|
||||
SET name = COALESCE($1, name),
|
||||
description = COALESCE($2, description),
|
||||
max_points = COALESCE($3, max_points),
|
||||
position = COALESCE($4, position)
|
||||
WHERE id = $5
|
||||
AND rubric_id IN (SELECT id FROM rubrics WHERE organization_id = $6)
|
||||
RETURNING id, rubric_id, name, description, max_points, position, created_at
|
||||
"#,
|
||||
payload.name,
|
||||
payload.description,
|
||||
payload.max_points,
|
||||
payload.position,
|
||||
criterion_id,
|
||||
org_ctx.id
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Criterion not found".to_string()))?;
|
||||
|
||||
// Update rubric total_points if max_points changed
|
||||
if payload.max_points.is_some() {
|
||||
let _ = sqlx::query!(
|
||||
r#"
|
||||
UPDATE rubrics
|
||||
SET total_points = (SELECT COALESCE(SUM(max_points), 0) FROM rubric_criteria WHERE rubric_id = $1),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
"#,
|
||||
criterion.rubric_id
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
}
|
||||
|
||||
Ok(Json(criterion))
|
||||
}
|
||||
|
||||
/// Delete a criterion
|
||||
pub async fn delete_criterion(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(criterion_id): Path<Uuid>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
// Get rubric_id before deleting
|
||||
let criterion = sqlx::query!(
|
||||
"SELECT rubric_id FROM rubric_criteria WHERE id = $1",
|
||||
criterion_id
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Criterion not found".to_string()))?;
|
||||
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
DELETE FROM rubric_criteria
|
||||
WHERE id = $1
|
||||
AND rubric_id IN (SELECT id FROM rubrics WHERE organization_id = $2)
|
||||
"#,
|
||||
criterion_id,
|
||||
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, "Criterion not found".to_string()));
|
||||
}
|
||||
|
||||
// Update rubric total_points
|
||||
let _ = sqlx::query!(
|
||||
r#"
|
||||
UPDATE rubrics
|
||||
SET total_points = (SELECT COALESCE(SUM(max_points), 0) FROM rubric_criteria WHERE rubric_id = $1),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
"#,
|
||||
criterion.rubric_id
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ==================== Performance Level Management ====================
|
||||
|
||||
/// Add a performance level to a criterion
|
||||
pub async fn create_level(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(criterion_id): Path<Uuid>,
|
||||
Json(payload): Json<CreateLevelPayload>,
|
||||
) -> Result<Json<RubricLevel>, (StatusCode, String)> {
|
||||
// Verify criterion exists and belongs to org
|
||||
let _criterion = sqlx::query!(
|
||||
"SELECT id FROM rubric_criteria WHERE id = $1 AND rubric_id IN (SELECT id FROM rubrics WHERE organization_id = $2)",
|
||||
criterion_id,
|
||||
org_ctx.id
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Criterion not found".to_string()))?;
|
||||
|
||||
let position = payload.position.unwrap_or(0);
|
||||
|
||||
let level = sqlx::query_as!(
|
||||
RubricLevel,
|
||||
r#"
|
||||
INSERT INTO rubric_levels (criterion_id, name, description, points, position)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, criterion_id, name, description, points, position, created_at
|
||||
"#,
|
||||
criterion_id,
|
||||
payload.name,
|
||||
payload.description,
|
||||
payload.points,
|
||||
position
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(level))
|
||||
}
|
||||
|
||||
/// Update a performance level
|
||||
pub async fn update_level(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(level_id): Path<Uuid>,
|
||||
Json(payload): Json<UpdateLevelPayload>,
|
||||
) -> Result<Json<RubricLevel>, (StatusCode, String)> {
|
||||
let level = sqlx::query_as!(
|
||||
RubricLevel,
|
||||
r#"
|
||||
UPDATE rubric_levels
|
||||
SET name = COALESCE($1, name),
|
||||
description = COALESCE($2, description),
|
||||
points = COALESCE($3, points),
|
||||
position = COALESCE($4, position)
|
||||
WHERE id = $5
|
||||
AND criterion_id IN (
|
||||
SELECT id FROM rubric_criteria
|
||||
WHERE rubric_id IN (SELECT id FROM rubrics WHERE organization_id = $6)
|
||||
)
|
||||
RETURNING id, criterion_id, name, description, points, position, created_at
|
||||
"#,
|
||||
payload.name,
|
||||
payload.description,
|
||||
payload.points,
|
||||
payload.position,
|
||||
level_id,
|
||||
org_ctx.id
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Level not found".to_string()))?;
|
||||
|
||||
Ok(Json(level))
|
||||
}
|
||||
|
||||
/// Delete a performance level
|
||||
pub async fn delete_level(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(level_id): Path<Uuid>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
DELETE FROM rubric_levels
|
||||
WHERE id = $1
|
||||
AND criterion_id IN (
|
||||
SELECT id FROM rubric_criteria
|
||||
WHERE rubric_id IN (SELECT id FROM rubrics WHERE organization_id = $2)
|
||||
)
|
||||
"#,
|
||||
level_id,
|
||||
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, "Level not found".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ==================== Lesson-Rubric Association ====================
|
||||
|
||||
/// Assign a rubric to a lesson
|
||||
pub async fn assign_rubric_to_lesson(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path((lesson_id, rubric_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<Json<LessonRubric>, (StatusCode, String)> {
|
||||
let lesson_rubric = sqlx::query_as!(
|
||||
LessonRubric,
|
||||
r#"
|
||||
INSERT INTO lesson_rubrics (lesson_id, rubric_id, is_active)
|
||||
VALUES ($1, $2, true)
|
||||
ON CONFLICT (lesson_id, rubric_id) DO UPDATE SET is_active = true
|
||||
RETURNING id, lesson_id, rubric_id, is_active, assigned_at
|
||||
"#,
|
||||
lesson_id,
|
||||
rubric_id
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(lesson_rubric))
|
||||
}
|
||||
|
||||
/// Unassign a rubric from a lesson
|
||||
pub async fn unassign_rubric_from_lesson(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path((lesson_id, rubric_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
let result = sqlx::query!(
|
||||
"DELETE FROM lesson_rubrics WHERE lesson_id = $1 AND rubric_id = $2",
|
||||
lesson_id,
|
||||
rubric_id
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err((StatusCode::NOT_FOUND, "Lesson rubric not found".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// Get rubrics assigned to a lesson
|
||||
pub async fn get_lesson_rubrics(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(lesson_id): Path<Uuid>,
|
||||
) -> Result<Json<Vec<Rubric>>, (StatusCode, String)> {
|
||||
let rubrics = sqlx::query_as!(
|
||||
Rubric,
|
||||
r#"
|
||||
SELECT r.id, r.organization_id, r.course_id, r.created_by, r.name, r.description, r.total_points, r.created_at, r.updated_at
|
||||
FROM rubrics r
|
||||
INNER JOIN lesson_rubrics lr ON lr.rubric_id = r.id
|
||||
WHERE lr.lesson_id = $1 AND lr.is_active = true AND r.organization_id = $2
|
||||
ORDER BY lr.assigned_at DESC
|
||||
"#,
|
||||
lesson_id,
|
||||
org_ctx.id
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(rubrics))
|
||||
}
|
||||
@@ -3,14 +3,16 @@ pub mod exporter;
|
||||
mod external_handlers;
|
||||
mod handlers;
|
||||
mod handlers_branding;
|
||||
mod handlers_dependencies;
|
||||
mod handlers_library;
|
||||
mod handlers_rubrics;
|
||||
mod webhooks;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::DefaultBodyLimit,
|
||||
middleware,
|
||||
routing::{delete, get, post},
|
||||
routing::{delete, get, post, put},
|
||||
};
|
||||
use dotenvy::dotenv;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
@@ -196,6 +198,52 @@ async fn main() {
|
||||
"/library/blocks/{id}/increment-usage",
|
||||
post(handlers_library::increment_block_usage),
|
||||
)
|
||||
// Advanced Grading (Rubrics) routes
|
||||
.route(
|
||||
"/courses/{id}/rubrics",
|
||||
get(handlers_rubrics::list_course_rubrics).post(handlers_rubrics::create_rubric),
|
||||
)
|
||||
.route(
|
||||
"/rubrics/{id}",
|
||||
get(handlers_rubrics::get_rubric_with_details)
|
||||
.put(handlers_rubrics::update_rubric)
|
||||
.delete(handlers_rubrics::delete_rubric),
|
||||
)
|
||||
.route(
|
||||
"/rubrics/{id}/criteria",
|
||||
post(handlers_rubrics::create_criterion),
|
||||
)
|
||||
.route(
|
||||
"/criteria/{id}",
|
||||
put(handlers_rubrics::update_criterion).delete(handlers_rubrics::delete_criterion),
|
||||
)
|
||||
.route(
|
||||
"/criteria/{id}/levels",
|
||||
post(handlers_rubrics::create_level),
|
||||
)
|
||||
.route(
|
||||
"/levels/{id}",
|
||||
put(handlers_rubrics::update_level).delete(handlers_rubrics::delete_level),
|
||||
)
|
||||
.route(
|
||||
"/lessons/{lesson_id}/rubrics/{rubric_id}",
|
||||
post(handlers_rubrics::assign_rubric_to_lesson)
|
||||
.delete(handlers_rubrics::unassign_rubric_from_lesson),
|
||||
)
|
||||
.route(
|
||||
"/lessons/{id}/rubrics",
|
||||
get(handlers_rubrics::get_lesson_rubrics),
|
||||
)
|
||||
// Learning Sequences (Dependencies) routes
|
||||
.route(
|
||||
"/lessons/{id}/dependencies",
|
||||
get(handlers_dependencies::list_lesson_dependencies)
|
||||
.post(handlers_dependencies::assign_dependency),
|
||||
)
|
||||
.route(
|
||||
"/lessons/{id}/dependencies/{prerequisite_id}",
|
||||
delete(handlers_dependencies::remove_dependency),
|
||||
)
|
||||
.route_layer(middleware::from_fn(
|
||||
common::middleware::org_extractor_middleware,
|
||||
));
|
||||
|
||||
@@ -600,11 +600,31 @@ pub async fn get_course_outline(
|
||||
pub_modules.push(common::models::PublishedModule { module, lessons });
|
||||
}
|
||||
|
||||
// 6. Fetch all dependencies for this course
|
||||
let dependencies = sqlx::query_as!(
|
||||
LessonDependency,
|
||||
r#"
|
||||
SELECT ld.*
|
||||
FROM lesson_dependencies ld
|
||||
JOIN lessons l ON ld.lesson_id = l.id
|
||||
JOIN modules m ON l.module_id = m.id
|
||||
WHERE m.course_id = $1
|
||||
"#,
|
||||
id
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("get_course_outline: dependencies fetch failed: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
Ok(Json(common::models::PublishedCourse {
|
||||
course,
|
||||
organization,
|
||||
grading_categories,
|
||||
modules: pub_modules,
|
||||
dependencies: Some(dependencies),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -620,7 +640,7 @@ pub async fn get_lesson_content(
|
||||
claims.sub
|
||||
);
|
||||
|
||||
// Check if user is enrolled in the course this lesson belongs to
|
||||
// 1. Check if user is enrolled in the course this lesson belongs to
|
||||
let lesson = sqlx::query_as::<_, Lesson>(
|
||||
"SELECT l.* FROM lessons l
|
||||
JOIN modules m ON l.module_id = m.id
|
||||
@@ -636,17 +656,64 @@ pub async fn get_lesson_content(
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
match lesson {
|
||||
Some(l) => Ok(Json(l)),
|
||||
let lesson = match lesson {
|
||||
Some(l) => l,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
"get_lesson_content: User {} not enrolled or lesson {} not found",
|
||||
claims.sub,
|
||||
id
|
||||
);
|
||||
Err(StatusCode::FORBIDDEN)
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Enforce Prerequisites
|
||||
// We check if there are any prerequisites that the user hasn't completed yet.
|
||||
// A prerequisite is completed if:
|
||||
// a) It's graded and the user has a grade >= min_score_percentage (default 0)
|
||||
// b) It's not graded and the user has a 'complete' interaction
|
||||
let unmet_dependencies = sqlx::query!(
|
||||
r#"
|
||||
SELECT ld.prerequisite_lesson_id, p.title as prereq_title, ld.min_score_percentage
|
||||
FROM lesson_dependencies ld
|
||||
JOIN lessons p ON ld.prerequisite_lesson_id = p.id
|
||||
LEFT JOIN user_grades ug ON ld.prerequisite_lesson_id = ug.lesson_id AND ug.user_id = $2
|
||||
LEFT JOIN lesson_interactions li ON ld.prerequisite_lesson_id = li.lesson_id
|
||||
AND li.user_id = $2 AND li.event_type = 'complete'
|
||||
WHERE ld.lesson_id = $1
|
||||
AND (
|
||||
(p.is_graded = true AND (ug.score IS NULL OR (ug.score * 100.0) < COALESCE(ld.min_score_percentage, 0.0)))
|
||||
OR
|
||||
(p.is_graded = false AND li.id IS NULL)
|
||||
)
|
||||
"#,
|
||||
id,
|
||||
claims.sub
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("get_lesson_content: failed to check dependencies: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
if !unmet_dependencies.is_empty() {
|
||||
let names: Vec<String> = unmet_dependencies
|
||||
.iter()
|
||||
.map(|d| d.prereq_title.clone())
|
||||
.collect();
|
||||
tracing::warn!(
|
||||
"get_lesson_content: User {} blocked for lesson {} by prerequisites: {:?}",
|
||||
claims.sub,
|
||||
id,
|
||||
names
|
||||
);
|
||||
// We could return a custom error body here, but for now 403 Forbidden is consistent.
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
Ok(Json(lesson))
|
||||
}
|
||||
|
||||
pub async fn get_user_enrollments(
|
||||
|
||||
Reference in New Issue
Block a user