feat: Implement lesson attempt tracking, retry functionality, and max attempt limits for interactive blocks.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
-- Add attempt limits and retry configuration to lessons
|
||||
ALTER TABLE lessons ADD COLUMN max_attempts INTEGER;
|
||||
ALTER TABLE lessons ADD COLUMN allow_retry BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
@@ -171,9 +171,12 @@ pub async fn create_lesson(
|
||||
|
||||
let is_graded = payload.get("is_graded").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let grading_category_id = payload.get("grading_category_id").and_then(|v| v.as_str()).and_then(|s| Uuid::parse_str(s).ok());
|
||||
let max_attempts = payload.get("max_attempts").and_then(|v| v.as_i64()).map(|v| v as i32);
|
||||
let allow_retry = payload.get("allow_retry").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
|
||||
let lesson = sqlx::query_as::<_, Lesson>(
|
||||
"INSERT INTO lessons (module_id, title, content_type, content_url, position, transcription, metadata, is_graded, grading_category_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *"
|
||||
"INSERT INTO lessons (module_id, title, content_type, content_url, position, transcription, metadata, is_graded, grading_category_id, max_attempts, allow_retry)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *"
|
||||
)
|
||||
.bind(module_id)
|
||||
.bind(title)
|
||||
@@ -184,6 +187,8 @@ pub async fn create_lesson(
|
||||
.bind(metadata)
|
||||
.bind(is_graded)
|
||||
.bind(grading_category_id)
|
||||
.bind(max_attempts)
|
||||
.bind(allow_retry)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
@@ -261,6 +266,10 @@ pub async fn update_lesson(
|
||||
}
|
||||
});
|
||||
|
||||
let max_attempts = payload.get("max_attempts").and_then(|v| v.as_i64()).map(|v| v as i32);
|
||||
let allow_retry = payload.get("allow_retry").and_then(|v| v.as_bool());
|
||||
let metadata = payload.get("metadata");
|
||||
|
||||
let updated_lesson = sqlx::query_as::<_, Lesson>(
|
||||
"UPDATE lessons
|
||||
SET title = COALESCE($1, title),
|
||||
@@ -268,8 +277,11 @@ pub async fn update_lesson(
|
||||
content_url = COALESCE($3, content_url),
|
||||
position = COALESCE($4, position),
|
||||
is_graded = COALESCE($5, is_graded),
|
||||
grading_category_id = CASE WHEN $6 = 'SET_NULL' THEN NULL WHEN $7::UUID IS NOT NULL THEN $7 ELSE grading_category_id END
|
||||
WHERE id = $8 RETURNING *"
|
||||
grading_category_id = CASE WHEN $6 = 'SET_NULL' THEN NULL WHEN $7::UUID IS NOT NULL THEN $7 ELSE grading_category_id END,
|
||||
metadata = COALESCE($8, metadata),
|
||||
max_attempts = COALESCE($9, max_attempts),
|
||||
allow_retry = COALESCE($10, allow_retry)
|
||||
WHERE id = $11 RETURNING *"
|
||||
)
|
||||
.bind(title)
|
||||
.bind(content_type)
|
||||
@@ -278,6 +290,9 @@ pub async fn update_lesson(
|
||||
.bind(is_graded)
|
||||
.bind(if payload.get("grading_category_id").map(|v| v.is_null()).unwrap_or(false) { "SET_NULL" } else { "" })
|
||||
.bind(payload.get("grading_category_id").and_then(|v| v.as_str()).and_then(|s| Uuid::parse_str(s).ok()))
|
||||
.bind(metadata)
|
||||
.bind(max_attempts)
|
||||
.bind(allow_retry)
|
||||
.bind(id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Add attempt limits and retry configuration to lessons
|
||||
ALTER TABLE lessons ADD COLUMN max_attempts INTEGER;
|
||||
ALTER TABLE lessons ADD COLUMN allow_retry BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
|
||||
-- Track attempt count in user_grades
|
||||
ALTER TABLE user_grades ADD COLUMN attempts_count INTEGER NOT NULL DEFAULT 1;
|
||||
@@ -199,8 +199,8 @@ pub async fn ingest_course(
|
||||
|
||||
for lesson in pub_module.lessons {
|
||||
sqlx::query(
|
||||
"INSERT INTO lessons (id, module_id, title, content_type, content_url, transcription, metadata, position, created_at, is_graded, grading_category_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)"
|
||||
"INSERT INTO lessons (id, module_id, title, content_type, content_url, transcription, metadata, position, created_at, is_graded, grading_category_id, max_attempts, allow_retry)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)"
|
||||
)
|
||||
.bind(lesson.id)
|
||||
.bind(pub_module.module.id)
|
||||
@@ -213,6 +213,8 @@ pub async fn ingest_course(
|
||||
.bind(lesson.created_at)
|
||||
.bind(lesson.is_graded)
|
||||
.bind(lesson.grading_category_id)
|
||||
.bind(lesson.max_attempts)
|
||||
.bind(lesson.allow_retry)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -306,12 +308,43 @@ pub async fn submit_lesson_score(
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<GradeSubmissionPayload>,
|
||||
) -> Result<Json<common::models::UserGrade>, (StatusCode, String)> {
|
||||
// 1. Get lesson attempt rules
|
||||
let max_attempts: Option<Option<i32>> = sqlx::query_scalar("SELECT max_attempts FROM lessons WHERE id = $1")
|
||||
.bind(payload.lesson_id)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if max_attempts.is_none() {
|
||||
return Err((StatusCode::NOT_FOUND, "Lesson not found".into()));
|
||||
}
|
||||
let max_attempts = max_attempts.flatten();
|
||||
|
||||
// 2. Check existing grade/attempts
|
||||
let existing_attempts: Option<i32> = sqlx::query_scalar("SELECT attempts_count FROM user_grades WHERE user_id = $1 AND lesson_id = $2")
|
||||
.bind(payload.user_id)
|
||||
.bind(payload.lesson_id)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if let Some(count) = existing_attempts {
|
||||
if let Some(max) = max_attempts {
|
||||
if count >= max {
|
||||
tracing::warn!("User {} attempted to resubmit lesson {} but reached max_attempts ({})", payload.user_id, payload.lesson_id, max);
|
||||
return Err((StatusCode::FORBIDDEN, "Maximum attempts reached for this assessment".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Upsert with increment
|
||||
let grade = sqlx::query_as::<_, common::models::UserGrade>(
|
||||
"INSERT INTO user_grades (user_id, course_id, lesson_id, score, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
"INSERT INTO user_grades (user_id, course_id, lesson_id, score, metadata, attempts_count)
|
||||
VALUES ($1, $2, $3, $4, $5, 1)
|
||||
ON CONFLICT (user_id, lesson_id) DO UPDATE SET
|
||||
score = EXCLUDED.score,
|
||||
metadata = EXCLUDED.metadata,
|
||||
attempts_count = user_grades.attempts_count + 1,
|
||||
created_at = CURRENT_TIMESTAMP
|
||||
RETURNING *"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user