Skip to main content

headless_lms_models/
points_breakdowns.rs

1//! One user's points in a course module, exercise by exercise, grouped by chapter and page.
2
3use headless_lms_utils::numbers::{
4    f32_to_two_decimals, option_f32_to_f32_two_decimals_with_none_as_zero,
5};
6use utoipa::ToSchema;
7
8use crate::{
9    exercises::{ActivityProgress, GradingProgress},
10    prelude::*,
11    user_exercise_states::ReviewingStage,
12};
13
14/// Where a user's answer to an exercise stands, as far as the student may know.
15///
16/// Collapses the exercise state into what the exercise block itself tells the student, so the
17/// underlying reviewing and grading stages never reach the client.
18#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
19pub enum ExercisePointsStatus {
20    NotStarted,
21    GradingInProgress,
22    GradingFailed,
23    PeerReviewToGive,
24    SelfReviewToGive,
25    WaitingForPeerReviews,
26    WaitingForTeacherGrading,
27    /// The exercise was locked before the user answered it.
28    NotAnswered,
29    /// Graded; the score may still be below the maximum.
30    Done,
31}
32
33impl ExercisePointsStatus {
34    fn from_exercise_state(
35        activity_progress: Option<ActivityProgress>,
36        grading_progress: Option<GradingProgress>,
37        reviewing_stage: Option<ReviewingStage>,
38    ) -> Self {
39        let (Some(activity_progress), Some(grading_progress), Some(reviewing_stage)) =
40            (activity_progress, grading_progress, reviewing_stage)
41        else {
42            return Self::NotStarted;
43        };
44        match reviewing_stage {
45            ReviewingStage::PeerReview => Self::PeerReviewToGive,
46            ReviewingStage::SelfReview => Self::SelfReviewToGive,
47            ReviewingStage::WaitingForPeerReviews => Self::WaitingForPeerReviews,
48            ReviewingStage::WaitingForManualGrading => Self::WaitingForTeacherGrading,
49            ReviewingStage::ReviewedAndLocked | ReviewingStage::Locked => Self::Done,
50            ReviewingStage::NotAnsweredAndLocked => Self::NotAnswered,
51            ReviewingStage::NotStarted => match grading_progress {
52                GradingProgress::Pending => Self::GradingInProgress,
53                GradingProgress::PendingManual => Self::WaitingForTeacherGrading,
54                GradingProgress::Failed => Self::GradingFailed,
55                GradingProgress::FullyGraded => Self::Done,
56                GradingProgress::NotReady => match activity_progress {
57                    ActivityProgress::Submitted | ActivityProgress::Completed => {
58                        Self::GradingInProgress
59                    }
60                    ActivityProgress::Initialized
61                    | ActivityProgress::Started
62                    | ActivityProgress::InProgress => Self::NotStarted,
63                },
64            },
65        }
66    }
67}
68
69/// One exercise's row in a points breakdown.
70#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
71pub struct ExercisePointsBreakdown {
72    pub exercise_id: Uuid,
73    pub name: String,
74    pub status: ExercisePointsStatus,
75    /// Rounded to two decimals; 0 until the answer counts towards the module's points.
76    pub score_given: f32,
77    pub score_maximum: i32,
78    /// Submissions to the slide the user was given, which is what the attempt limit counts.
79    pub attempts: i64,
80    /// `None` when the exercise allows unlimited attempts.
81    pub attempts_limit: Option<i32>,
82}
83
84/// A page's exercises in a points breakdown, with their subtotal.
85#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
86pub struct PagePointsBreakdown {
87    pub page_id: Uuid,
88    pub title: String,
89    /// Path within the course; the exercise's block is anchored on the page by its exercise id.
90    pub url_path: String,
91    pub score_given: f32,
92    pub score_maximum: i32,
93    pub exercises: Vec<ExercisePointsBreakdown>,
94}
95
96/// A chapter's pages in a points breakdown, with their subtotal.
97#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
98pub struct ChapterPointsBreakdown {
99    pub chapter_id: Uuid,
100    pub chapter_number: i32,
101    pub name: String,
102    pub score_given: f32,
103    pub score_maximum: i32,
104    pub pages: Vec<PagePointsBreakdown>,
105}
106
107/// The user's points in one course module, in chapter, page and exercise order.
108///
109/// Counts exercises as `user_exercise_states::get_user_course_module_progress` does, except that
110/// chapters that have not opened yet are left out entirely, so the sum can fall below the module's
111/// total. Chapters and pages without exercises are left out too.
112pub async fn get_user_course_module_points_breakdown(
113    conn: &mut PgConnection,
114    course_module_id: Uuid,
115    user_id: Uuid,
116) -> ModelResult<Vec<ChapterPointsBreakdown>> {
117    let rows = sqlx::query!(
118        r#"
119SELECT chapters.id AS "chapter_id!",
120  chapters.chapter_number AS "chapter_number!",
121  chapters.name AS "chapter_name!",
122  pages.id AS "page_id!",
123  pages.title AS "page_title!",
124  pages.url_path AS "page_url_path!",
125  exercises.id AS "exercise_id!",
126  exercises.name AS "exercise_name!",
127  exercises.score_maximum AS "score_maximum!",
128  exercises.limit_number_of_tries AS "limit_number_of_tries!",
129  exercises.max_tries_per_slide AS "max_tries_per_slide?",
130  CASE
131    WHEN ues.activity_progress IN ('completed', 'submitted') THEN ues.score_given
132  END AS "score_given?",
133  ues.activity_progress AS "activity_progress?: ActivityProgress",
134  ues.grading_progress AS "grading_progress?: GradingProgress",
135  ues.reviewing_stage AS "reviewing_stage?: ReviewingStage",
136  (
137    SELECT COUNT(*)
138    FROM exercise_slide_submissions AS ess
139    WHERE ess.exercise_slide_id = ues.selected_exercise_slide_id
140      AND ess.course_id = chapters.course_id
141      AND ess.user_id = $2
142      AND ess.deleted_at IS NULL
143  ) AS "attempts!"
144FROM exercises
145  JOIN chapters ON (exercises.chapter_id = chapters.id)
146  JOIN pages ON (exercises.page_id = pages.id)
147  LEFT JOIN user_exercise_states AS ues ON (
148    ues.exercise_id = exercises.id
149    AND ues.course_id = chapters.course_id
150    AND ues.user_id = $2
151    AND ues.deleted_at IS NULL
152  )
153WHERE chapters.course_module_id = $1
154  AND exercises.deleted_at IS NULL
155  AND chapters.deleted_at IS NULL
156  AND pages.deleted_at IS NULL
157  AND (
158    chapters.opens_at < now()
159    OR chapters.opens_at IS NULL
160  )
161ORDER BY chapters.chapter_number,
162  pages.order_number,
163  pages.id,
164  exercises.order_number,
165  exercises.id
166        "#,
167        course_module_id,
168        user_id,
169    )
170    .fetch_all(conn)
171    .await?;
172
173    let mut chapters: Vec<ChapterPointsBreakdown> = Vec::new();
174    for row in rows {
175        let exercise = ExercisePointsBreakdown {
176            exercise_id: row.exercise_id,
177            name: row.exercise_name,
178            status: ExercisePointsStatus::from_exercise_state(
179                row.activity_progress,
180                row.grading_progress,
181                row.reviewing_stage,
182            ),
183            score_given: option_f32_to_f32_two_decimals_with_none_as_zero(row.score_given),
184            score_maximum: row.score_maximum,
185            attempts: row.attempts,
186            attempts_limit: row
187                .max_tries_per_slide
188                .filter(|_| row.limit_number_of_tries),
189        };
190        if chapters
191            .last()
192            .is_none_or(|c| c.chapter_id != row.chapter_id)
193        {
194            chapters.push(ChapterPointsBreakdown {
195                chapter_id: row.chapter_id,
196                chapter_number: row.chapter_number,
197                name: row.chapter_name,
198                score_given: 0.0,
199                score_maximum: 0,
200                pages: Vec::new(),
201            });
202        }
203        let Some(chapter) = chapters.last_mut() else {
204            continue;
205        };
206        if chapter
207            .pages
208            .last()
209            .is_none_or(|p| p.page_id != row.page_id)
210        {
211            chapter.pages.push(PagePointsBreakdown {
212                page_id: row.page_id,
213                title: row.page_title,
214                url_path: row.page_url_path,
215                score_given: 0.0,
216                score_maximum: 0,
217                exercises: Vec::new(),
218            });
219        }
220        let Some(page) = chapter.pages.last_mut() else {
221            continue;
222        };
223        page.score_given = f32_to_two_decimals(page.score_given + exercise.score_given);
224        page.score_maximum += exercise.score_maximum;
225        chapter.score_given = f32_to_two_decimals(chapter.score_given + exercise.score_given);
226        chapter.score_maximum += exercise.score_maximum;
227        page.exercises.push(exercise);
228    }
229    Ok(chapters)
230}