Skip to main content

headless_lms_server/controllers/course_material/
course_instances.rs

1//! Controllers for requests starting with `/api/v0/course-material/course-instances`.
2
3use headless_lms_utils::numbers::option_f32_to_f32_two_decimals_with_none_as_zero;
4use models::{
5    chapters::UserCourseInstanceChapterProgress,
6    course_background_question_answers::NewCourseBackgroundQuestionAnswer,
7    course_background_questions::CourseBackgroundQuestionsAndAnswers,
8    course_instance_enrollments::CourseInstanceEnrollment,
9    course_module_completions::CourseModuleCompletion,
10    library::progressing::UserModuleCompletionStatus,
11    points_breakdowns::ChapterPointsBreakdown,
12    user_exercise_states::{UserCourseChapterExerciseProgress, UserCourseProgress},
13};
14use utoipa::{OpenApi, ToSchema};
15
16use crate::{
17    domain::authorization::{authorize_access_to_course_material, skip_authorize},
18    prelude::*,
19};
20
21#[derive(OpenApi)]
22#[openapi(paths(
23    get_user_progress_for_course_instance,
24    get_user_progress_for_course_instance_chapter,
25    get_user_progress_for_course_instance_chapter_exercises,
26    get_user_points_breakdown_for_course_module,
27    get_module_completions_for_course_instance,
28    save_course_settings,
29    get_all_get_all_course_module_completions_for_user_by_course_instance_id,
30    get_background_questions_and_answers
31))]
32pub(crate) struct CourseMaterialCourseInstancesApiDoc;
33
34/**
35 GET /api/v0/course-material/course-instance/:course_intance_id/progress - returns user progress information.
36*/
37#[utoipa::path(
38    get,
39    path = "/{course_instance_id}/progress",
40    operation_id = "getCourseMaterialUserCourseProgress",
41    tag = "course-material-course-instances",
42    params(
43        ("course_instance_id" = Uuid, Path, description = "Course instance id")
44    ),
45    responses(
46        (status = 200, description = "User course progress", body = Vec<UserCourseProgress>)
47    )
48)]
49#[instrument(skip(pool))]
50async fn get_user_progress_for_course_instance(
51    user: AuthUser,
52    course_instance_id: web::Path<Uuid>,
53    pool: web::Data<PgPool>,
54) -> ControllerResult<web::Json<Vec<UserCourseProgress>>> {
55    let mut conn = pool.acquire().await?;
56    let course_instance =
57        models::course_instances::get_course_instance(&mut conn, *course_instance_id).await?;
58    let user_course_progress = models::user_exercise_states::get_user_course_progress(
59        &mut conn,
60        course_instance.course_id,
61        user.id,
62        false,
63    )
64    .await?;
65    let token = skip_authorize();
66    token.authorized_ok(web::Json(user_course_progress))
67}
68
69/**
70GET `/api/v0/course-material/course-instance/:course_instance_id/chapters/:chapter_id/progress - Returns user progress for chapter in course instance.
71*/
72#[utoipa::path(
73    get,
74    path = "/{course_instance_id}/chapters/{chapter_id}/progress",
75    operation_id = "getCourseMaterialChapterProgress",
76    tag = "course-material-course-instances",
77    params(
78        ("course_instance_id" = Uuid, Path, description = "Course instance id"),
79        ("chapter_id" = Uuid, Path, description = "Chapter id")
80    ),
81    responses(
82        (status = 200, description = "Course instance chapter progress", body = UserCourseInstanceChapterProgress)
83    )
84)]
85#[instrument(skip(pool))]
86async fn get_user_progress_for_course_instance_chapter(
87    user: AuthUser,
88    params: web::Path<(Uuid, Uuid)>,
89    pool: web::Data<PgPool>,
90) -> ControllerResult<web::Json<UserCourseInstanceChapterProgress>> {
91    let mut conn = pool.acquire().await?;
92    let (course_instance_id, chapter_id) = params.into_inner();
93    let course_instance =
94        models::course_instances::get_course_instance(&mut conn, course_instance_id).await?;
95    let chapter = models::chapters::get_chapter(&mut conn, chapter_id).await?;
96    if chapter.course_id != course_instance.course_id {
97        return Err(controller_err!(
98            Forbidden,
99            "Chapter does not belong to the requested course instance".to_string()
100        ));
101    }
102    let token =
103        authorize_access_to_course_material(&mut conn, Some(user.id), course_instance.course_id)
104            .await?;
105    let user_course_instance_chapter_progress =
106        models::chapters::get_user_course_instance_chapter_progress(
107            &mut conn,
108            course_instance_id,
109            chapter_id,
110            user.id,
111        )
112        .await?;
113    token.authorized_ok(web::Json(user_course_instance_chapter_progress))
114}
115
116/**
117GET /api/v0/course-material/course-instance/:course_instance_id/chapters/:chapter_id/exercises/progress - Returns user progress for an exercise in given course instance.
118*/
119#[utoipa::path(
120    get,
121    path = "/{course_instance_id}/chapters/{chapter_id}/exercises/progress",
122    operation_id = "getCourseMaterialChapterExerciseProgress",
123    tag = "course-material-course-instances",
124    params(
125        ("course_instance_id" = Uuid, Path, description = "Course instance id"),
126        ("chapter_id" = Uuid, Path, description = "Chapter id")
127    ),
128    responses(
129        (status = 200, description = "Course instance chapter exercise progress", body = Vec<UserCourseChapterExerciseProgress>)
130    )
131)]
132#[instrument(skip(pool))]
133async fn get_user_progress_for_course_instance_chapter_exercises(
134    user: AuthUser,
135    params: web::Path<(Uuid, Uuid)>,
136    pool: web::Data<PgPool>,
137) -> ControllerResult<web::Json<Vec<UserCourseChapterExerciseProgress>>> {
138    let mut conn = pool.acquire().await?;
139    let (course_instance_id, chapter_id) = params.into_inner();
140    let course_instance =
141        models::course_instances::get_course_instance(&mut conn, course_instance_id).await?;
142    let chapter = models::chapters::get_chapter(&mut conn, chapter_id).await?;
143    if chapter.course_id != course_instance.course_id {
144        return Err(controller_err!(
145            Forbidden,
146            "Chapter does not belong to the requested course instance".to_string()
147        ));
148    }
149    let token =
150        authorize_access_to_course_material(&mut conn, Some(user.id), course_instance.course_id)
151            .await?;
152    let chapter_exercises =
153        models::exercises::get_exercises_by_chapter_id(&mut conn, chapter_id).await?;
154    let exercise_ids: Vec<Uuid> = chapter_exercises.into_iter().map(|e| e.id).collect();
155    let user_course_instance_exercise_progress =
156        models::user_exercise_states::get_user_course_chapter_exercises_progress(
157            &mut conn,
158            course_instance.course_id,
159            &exercise_ids,
160            user.id,
161        )
162        .await?;
163    let rounded_score_given_instances: Vec<UserCourseChapterExerciseProgress> =
164        user_course_instance_exercise_progress
165            .into_iter()
166            .map(|i| UserCourseChapterExerciseProgress {
167                score_given: option_f32_to_f32_two_decimals_with_none_as_zero(i.score_given),
168                exercise_id: i.exercise_id,
169            })
170            .collect();
171    token.authorized_ok(web::Json(rounded_score_given_instances))
172}
173
174/**
175GET `/api/v0/course-material/course-instances/:course_instance_id/course-modules/:course_module_id/points-breakdown` - Returns the user's points in the module's opened chapters, exercise by exercise.
176*/
177#[utoipa::path(
178    get,
179    path = "/{course_instance_id}/course-modules/{course_module_id}/points-breakdown",
180    operation_id = "getCourseMaterialCourseModulePointsBreakdown",
181    tag = "course-material-course-instances",
182    params(
183        ("course_instance_id" = Uuid, Path, description = "Course instance id"),
184        ("course_module_id" = Uuid, Path, description = "Course module id")
185    ),
186    responses(
187        (status = 200, description = "The user's points by chapter, page and exercise", body = Vec<ChapterPointsBreakdown>)
188    )
189)]
190#[instrument(skip(pool))]
191async fn get_user_points_breakdown_for_course_module(
192    user: AuthUser,
193    params: web::Path<(Uuid, Uuid)>,
194    pool: web::Data<PgPool>,
195) -> ControllerResult<web::Json<Vec<ChapterPointsBreakdown>>> {
196    let mut conn = pool.acquire().await?;
197    let (course_instance_id, course_module_id) = params.into_inner();
198    let course_instance =
199        models::course_instances::get_course_instance(&mut conn, course_instance_id).await?;
200    let course_module = models::course_modules::get_by_id(&mut conn, course_module_id).await?;
201    if course_module.course_id != course_instance.course_id {
202        return Err(controller_err!(
203            Forbidden,
204            "Course module does not belong to the requested course instance".to_string()
205        ));
206    }
207    let token =
208        authorize_access_to_course_material(&mut conn, Some(user.id), course_instance.course_id)
209            .await?;
210    let breakdown = models::points_breakdowns::get_user_course_module_points_breakdown(
211        &mut conn,
212        course_module_id,
213        user.id,
214    )
215    .await?;
216    token.authorized_ok(web::Json(breakdown))
217}
218
219/**
220GET `/api/v0/course-material/course-instance/{course_instance_id}/module-completions`
221 */
222#[utoipa::path(
223    get,
224    path = "/{course_instance_id}/module-completions",
225    operation_id = "getCourseMaterialUserModuleCompletions",
226    tag = "course-material-course-instances",
227    params(
228        ("course_instance_id" = Uuid, Path, description = "Course instance id")
229    ),
230    responses(
231        (status = 200, description = "User module completion statuses", body = Vec<UserModuleCompletionStatus>)
232    )
233)]
234#[instrument(skip(pool))]
235async fn get_module_completions_for_course_instance(
236    user: AuthUser,
237    course_instance_id: web::Path<Uuid>,
238    pool: web::Data<PgPool>,
239) -> ControllerResult<web::Json<Vec<UserModuleCompletionStatus>>> {
240    let mut conn = pool.acquire().await?;
241    let token = skip_authorize();
242
243    let course_instance =
244        models::course_instances::get_course_instance(&mut conn, *course_instance_id).await?;
245    let mut module_completion_statuses =
246        models::library::progressing::get_user_module_completion_statuses_for_course(
247            &mut conn,
248            user.id,
249            course_instance.course_id,
250        )
251        .await?;
252    // Override individual completions in modules with insufficient prerequisites
253    module_completion_statuses.iter_mut().for_each(|module| {
254        if !module.prerequisite_modules_completed {
255            module.completed = false;
256            module.certificate_configuration_id = None;
257        }
258    });
259    token.authorized_ok(web::Json(module_completion_statuses))
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
263
264pub struct SaveCourseSettingsPayload {
265    pub background_question_answers: Vec<NewCourseBackgroundQuestionAnswer>,
266}
267
268/**
269POST /api/v0/course-material/course-instance/:course_instance_id/save-course-settings - enrolls user to the course instance and save background questions.
270*/
271#[utoipa::path(
272    post,
273    path = "/{course_instance_id}/save-course-settings",
274    operation_id = "saveCourseMaterialCourseSettings",
275    tag = "course-material-course-instances",
276    params(
277        ("course_instance_id" = Uuid, Path, description = "Course instance id")
278    ),
279    request_body = SaveCourseSettingsPayload,
280    responses(
281        (status = 200, description = "Course instance enrollment", body = CourseInstanceEnrollment)
282    )
283)]
284#[instrument(skip(pool))]
285async fn save_course_settings(
286    pool: web::Data<PgPool>,
287    course_instance_id: web::Path<Uuid>,
288    payload: web::Json<SaveCourseSettingsPayload>,
289    user: AuthUser,
290) -> ControllerResult<web::Json<CourseInstanceEnrollment>> {
291    let mut conn = pool.acquire().await?;
292
293    let enrollment = models::library::course_instances::enroll(
294        &mut conn,
295        user.id,
296        *course_instance_id,
297        payload.background_question_answers.as_slice(),
298    )
299    .await?;
300    let token = skip_authorize();
301    token.authorized_ok(web::Json(enrollment))
302}
303
304/**
305GET /course-instances/:id/course-module-completions/:user_id - Returns a list of all course module completions for a given user for this course instance.
306*/
307#[utoipa::path(
308    get,
309    path = "/{course_instance_id}/course-module-completions/{user_id}",
310    operation_id = "getCourseMaterialCourseModuleCompletionsForUser",
311    tag = "course-material-course-instances",
312    params(
313        ("course_instance_id" = Uuid, Path, description = "Course instance id"),
314        ("user_id" = Uuid, Path, description = "User id")
315    ),
316    responses(
317        (status = 200, description = "Course module completions for user", body = Vec<CourseModuleCompletion>)
318    )
319)]
320#[instrument(skip(pool))]
321
322async fn get_all_get_all_course_module_completions_for_user_by_course_instance_id(
323    params: web::Path<(Uuid, Uuid)>,
324    pool: web::Data<PgPool>,
325    user: AuthUser,
326) -> ControllerResult<web::Json<Vec<CourseModuleCompletion>>> {
327    let (course_instance_id, user_id) = params.into_inner();
328    let mut conn = pool.acquire().await?;
329    let token = authorize(
330        &mut conn,
331        Act::ViewUserProgressOrDetails,
332        Some(user.id),
333        Res::CourseInstance(course_instance_id),
334    )
335    .await?;
336
337    let course_instance =
338        models::course_instances::get_course_instance(&mut conn, course_instance_id).await?;
339
340    let res = models::course_module_completions::get_all_by_course_id_and_user_id(
341        &mut conn,
342        course_instance.course_id,
343        user_id,
344    )
345    .await?;
346
347    token.authorized_ok(web::Json(res))
348}
349
350/**
351GET /api/v0/course-material/course-instance/:course_instance_id/background-questions-and-answers - Gets background questions and answers for an course instance.
352*/
353#[utoipa::path(
354    get,
355    path = "/{course_instance_id}/background-questions-and-answers",
356    operation_id = "getCourseMaterialBackgroundQuestionsAndAnswers",
357    tag = "course-material-course-instances",
358    params(
359        ("course_instance_id" = Uuid, Path, description = "Course instance id")
360    ),
361    responses(
362        (status = 200, description = "Background questions and answers", body = CourseBackgroundQuestionsAndAnswers)
363    )
364)]
365#[instrument(skip(pool))]
366async fn get_background_questions_and_answers(
367    pool: web::Data<PgPool>,
368    course_instance_id: web::Path<Uuid>,
369    user: AuthUser,
370) -> ControllerResult<web::Json<CourseBackgroundQuestionsAndAnswers>> {
371    let mut conn = pool.acquire().await?;
372
373    let instance =
374        models::course_instances::get_course_instance(&mut conn, *course_instance_id).await?;
375    let res = models::course_background_questions::get_background_questions_and_answers(
376        &mut conn, &instance, user.id,
377    )
378    .await?;
379    let token = skip_authorize();
380    token.authorized_ok(web::Json(res))
381}
382
383pub fn _add_routes(cfg: &mut ServiceConfig) {
384    cfg.route(
385        "/{course_instance_id}/save-course-settings",
386        web::post().to(save_course_settings),
387    )
388    .route(
389        "/{course_instance_id}/progress",
390        web::get().to(get_user_progress_for_course_instance),
391    )
392    .route(
393        "/{course_instance_id}/chapters/{chapter_id}/exercises/progress",
394        web::get().to(get_user_progress_for_course_instance_chapter_exercises),
395    )
396    .route(
397        "/{course_instance_id}/chapters/{chapter_id}/progress",
398        web::get().to(get_user_progress_for_course_instance_chapter),
399    )
400    .route(
401        "/{course_instance_id}/course-modules/{course_module_id}/points-breakdown",
402        web::get().to(get_user_points_breakdown_for_course_module),
403    )
404    .route(
405        "/{course_instance_id}/module-completions",
406        web::get().to(get_module_completions_for_course_instance),
407    )
408    .route(
409        "/{course_instance_id}/course-module-completions/{user_id}",
410        web::get().to(get_all_get_all_course_module_completions_for_user_by_course_instance_id),
411    )
412    .route(
413        "/{course_instance_id}/background-questions-and-answers",
414        web::get().to(get_background_questions_and_answers),
415    );
416}