1use 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#[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 NotAnswered,
29 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#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
71pub struct ExercisePointsBreakdown {
72 pub exercise_id: Uuid,
73 pub name: String,
74 pub status: ExercisePointsStatus,
75 pub score_given: f32,
77 pub score_maximum: i32,
78 pub attempts: i64,
80 pub attempts_limit: Option<i32>,
82}
83
84#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
86pub struct PagePointsBreakdown {
87 pub page_id: Uuid,
88 pub title: String,
89 pub url_path: String,
91 pub score_given: f32,
92 pub score_maximum: i32,
93 pub exercises: Vec<ExercisePointsBreakdown>,
94}
95
96#[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
107pub 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}