Skip to main content

headless_lms_server/controllers/main_frontend/
course_modules.rs

1use headless_lms_models::course_module_completions::CourseModuleCompletion;
2use headless_lms_models::suspected_cheaters::ThresholdData;
3use models::{
4    course_modules::{self, CourseModule},
5    library::progressing::{CompletionRegistrationLink, UserCompletionInformation},
6    suspected_cheaters,
7};
8use utoipa::OpenApi;
9
10use crate::prelude::*;
11
12#[derive(OpenApi)]
13#[openapi(paths(
14    get_course_module,
15    get_course_module_completion_information_for_user,
16    get_course_module_completion_registration_link,
17    enable_or_disable_certificate_generation,
18    get_best_course_module_completion_for_user,
19    insert_threshold_for_module,
20    delete_threshold_for_module
21))]
22pub(crate) struct MainFrontendCourseModulesApiDoc;
23
24/**
25GET `/api/v0/main-frontend/course-modules/{course_module_id}`
26
27Returns information about the course module.
28*/
29#[utoipa::path(
30    get,
31    path = "/{course_module_id}",
32    operation_id = "getCourseModule",
33    tag = "course_modules",
34    params(
35        ("course_module_id" = Uuid, Path, description = "Course module id")
36    ),
37    responses(
38        (status = 200, description = "Course module", body = CourseModule)
39    )
40)]
41#[instrument(skip(pool))]
42async fn get_course_module(
43    course_module_id: web::Path<Uuid>,
44    pool: web::Data<PgPool>,
45    user: AuthUser,
46) -> ControllerResult<web::Json<CourseModule>> {
47    let mut conn = pool.acquire().await?;
48    let course_module = course_modules::get_by_id(&mut conn, *course_module_id).await?;
49    let token = authorize(
50        &mut conn,
51        Act::View,
52        Some(user.id),
53        Res::Course(course_module.course_id),
54    )
55    .await?;
56    token.authorized_ok(web::Json(course_module))
57}
58
59/**
60GET `/api/v0/main-frontend/course-modules/{course_module_id}/user-completion`
61
62Gets active users's completion for the course, if it exists.
63*/
64#[utoipa::path(
65    get,
66    path = "/{course_module_id}/user-completion",
67    operation_id = "getCourseModuleUserCompletion",
68    tag = "course_modules",
69    params(
70        ("course_module_id" = Uuid, Path, description = "Course module id")
71    ),
72    responses(
73        (status = 200, description = "User completion information", body = UserCompletionInformation)
74    )
75)]
76#[instrument(skip(pool))]
77async fn get_course_module_completion_information_for_user(
78    course_module_id: web::Path<Uuid>,
79    pool: web::Data<PgPool>,
80    user: AuthUser,
81) -> ControllerResult<web::Json<UserCompletionInformation>> {
82    let mut conn = pool.acquire().await?;
83    let course_module = course_modules::get_by_id(&mut conn, *course_module_id).await?;
84    // Proper request validation is based on whether a completion exists for the user or not.
85    let token = authorize(
86        &mut conn,
87        Act::View,
88        Some(user.id),
89        Res::Course(course_module.course_id),
90    )
91    .await?;
92    let information = models::library::progressing::get_user_completion_information(
93        &mut conn,
94        user.id,
95        &course_module,
96    )
97    .await?;
98    token.authorized_ok(web::Json(information))
99}
100
101/**
102GET `/api/v0/main-frontend/course-modules/{course_slug}/completion-registration-link`
103*/
104#[utoipa::path(
105    get,
106    path = "/{course_module_id}/completion-registration-link",
107    operation_id = "getCourseModuleCompletionRegistrationLink",
108    tag = "course_modules",
109    params(
110        ("course_module_id" = Uuid, Path, description = "Course module id")
111    ),
112    responses(
113        (status = 200, description = "Completion registration link", body = CompletionRegistrationLink)
114    )
115)]
116#[instrument(skip(pool))]
117async fn get_course_module_completion_registration_link(
118    course_module_id: web::Path<Uuid>,
119    pool: web::Data<PgPool>,
120    user: AuthUser,
121) -> ControllerResult<web::Json<CompletionRegistrationLink>> {
122    let mut conn = pool.acquire().await?;
123    let course_module = course_modules::get_by_id(&mut conn, *course_module_id).await?;
124    // Proper request validation is based on whether a completion exists for the user or not.
125    let token = authorize(
126        &mut conn,
127        Act::View,
128        Some(user.id),
129        Res::Course(course_module.course_id),
130    )
131    .await?;
132    let completion_registration_link =
133        models::library::progressing::get_completion_registration_link_and_save_attempt(
134            &mut conn,
135            user.id,
136            &course_module,
137        )
138        .await?;
139    token.authorized_ok(web::Json(completion_registration_link))
140}
141
142#[utoipa::path(
143    post,
144    path = "/{course_module_id}/set-certificate-generation/{enabled}",
145    operation_id = "setCourseModuleCertificateGeneration",
146    tag = "course_modules",
147    params(
148        ("course_module_id" = Uuid, Path, description = "Course module id"),
149        ("enabled" = bool, Path, description = "Whether certificate generation should be enabled")
150    ),
151    responses(
152        (status = 200, description = "Certificate generation updated", body = bool)
153    )
154)]
155async fn enable_or_disable_certificate_generation(
156    params: web::Path<(Uuid, bool)>,
157    pool: web::Data<PgPool>,
158    user: AuthUser,
159) -> ControllerResult<web::Json<bool>> {
160    let mut conn = pool.acquire().await?;
161    let (course_module_id, enabled) = params.into_inner();
162
163    let course_module = course_modules::get_by_id(&mut conn, course_module_id).await?;
164    // Proper request validation is based on whether a completion exists for the user or not.
165    let token = authorize(
166        &mut conn,
167        Act::Edit,
168        Some(user.id),
169        Res::Course(course_module.course_id),
170    )
171    .await?;
172    models::course_modules::update_certification_enabled(&mut conn, course_module_id, enabled)
173        .await?;
174
175    token.authorized_ok(web::Json(true))
176}
177
178/**
179GET `/api/v0/main-frontend/course-modules/{course_module_id}/course-module-completion`
180
181Gets users's best completion for the course.
182*/
183#[utoipa::path(
184    get,
185    path = "/{course_module_id}/course-module-completion",
186    operation_id = "getCourseModuleCompletion",
187    tag = "course_modules",
188    params(
189        ("course_module_id" = Uuid, Path, description = "Course module id")
190    ),
191    responses(
192        (status = 200, description = "Best course module completion for the current user", body = Option<CourseModuleCompletion>)
193    )
194)]
195#[instrument(skip(pool))]
196async fn get_best_course_module_completion_for_user(
197    course_module_id: web::Path<Uuid>,
198    pool: web::Data<PgPool>,
199    user: AuthUser,
200) -> ControllerResult<web::Json<Option<CourseModuleCompletion>>> {
201    let mut conn = pool.acquire().await?;
202    let course_module = course_modules::get_by_id(&mut conn, *course_module_id).await?;
203
204    let token = authorize(
205        &mut conn,
206        Act::View,
207        Some(user.id),
208        Res::Course(course_module.course_id),
209    )
210    .await?;
211
212    let information =
213        models::course_module_completions::get_best_completion_by_user_and_course_module_id(
214            &mut conn,
215            user.id,
216            *course_module_id,
217        )
218        .await?;
219    token.authorized_ok(web::Json(information))
220}
221
222/**
223 POST /api/v0/main-frontend/course-modules/${course_module_id}/threshold - post threshold for a specific course module.
224*/
225#[utoipa::path(
226    post,
227    path = "/{course_module_id}/threshold",
228    operation_id = "createCourseModuleThreshold",
229    tag = "course_modules",
230    params(
231        ("course_module_id" = Uuid, Path, description = "Course module id")
232    ),
233    request_body = ThresholdData,
234    responses(
235        (status = 200, description = "Threshold created")
236    )
237)]
238#[instrument(skip(pool))]
239async fn insert_threshold_for_module(
240    pool: web::Data<PgPool>,
241    params: web::Path<Uuid>,
242    payload: web::Json<ThresholdData>,
243    user: AuthUser,
244) -> ControllerResult<web::Json<()>> {
245    let mut conn = pool.acquire().await?;
246
247    let course_module_id = params.into_inner();
248    let new_threshold = payload.0;
249
250    let course_module = course_modules::get_by_id(&mut conn, course_module_id).await?;
251    let token = authorize(
252        &mut conn,
253        Act::Edit,
254        Some(user.id),
255        Res::Course(course_module.course_id),
256    )
257    .await?;
258
259    // Small modules are exempt from the minimum threshold: they can legitimately be completed
260    // fast, so any duration >= 0 is allowed for them, where 0 turns the duration check off. The
261    // exemption rule lives in suspected_cheaters::minimum_threshold_seconds so the save-time check
262    // and the configuration UI cannot drift. The exemption is checked at save time only -- a module
263    // that later grows past these limits keeps its existing below-minimum threshold until the next
264    // save.
265    let counts =
266        course_modules::get_chapter_and_exercise_counts(&mut conn, course_module_id).await?;
267    let minimum_seconds =
268        suspected_cheaters::minimum_threshold_seconds(counts.chapters, counts.exercises);
269    if new_threshold.duration_seconds < minimum_seconds {
270        let message = if minimum_seconds > 0 {
271            format!(
272                "The threshold must be at least {} hours.",
273                minimum_seconds / 3600
274            )
275        } else {
276            "The threshold cannot be negative.".to_string()
277        };
278        return Err(ControllerError::new(
279            ControllerErrorType::BadRequest,
280            message,
281            None,
282        ));
283    }
284
285    suspected_cheaters::insert_thresholds_by_module_id(
286        &mut conn,
287        course_module_id,
288        new_threshold.duration_seconds,
289    )
290    .await?;
291
292    token.authorized_ok(web::Json(()))
293}
294
295/**
296 DELETE /api/v0/main-frontend/course-modules/${course_module_id}/threshold - delete threshold for a specific course module.
297*/
298#[utoipa::path(
299    delete,
300    path = "/{course_module_id}/threshold",
301    operation_id = "deleteCourseModuleThreshold",
302    tag = "course_modules",
303    params(
304        ("course_module_id" = Uuid, Path, description = "Course module id")
305    ),
306    responses(
307        (status = 200, description = "Threshold deleted")
308    )
309)]
310#[instrument(skip(pool))]
311async fn delete_threshold_for_module(
312    pool: web::Data<PgPool>,
313    params: web::Path<Uuid>,
314    user: AuthUser,
315) -> ControllerResult<web::Json<()>> {
316    let mut conn = pool.acquire().await?;
317
318    let course_module_id = params.into_inner();
319
320    let course_module = course_modules::get_by_id(&mut conn, course_module_id).await?;
321    let token = authorize(
322        &mut conn,
323        Act::Edit,
324        Some(user.id),
325        Res::Course(course_module.course_id),
326    )
327    .await?;
328
329    suspected_cheaters::delete_threshold_for_module(&mut conn, course_module_id).await?;
330
331    token.authorized_ok(web::Json(()))
332}
333
334/**
335Add a route for each controller in this module.
336
337The name starts with an underline in order to appear before other functions in the module documentation.
338
339We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
340*/
341pub fn _add_routes(cfg: &mut ServiceConfig) {
342    cfg.route("/{course_module_id}", web::get().to(get_course_module))
343        .route(
344            "/{course_module_id}/user-completion",
345            web::get().to(get_course_module_completion_information_for_user),
346        )
347        .route(
348            "/{course_module_id}/completion-registration-link",
349            web::get().to(get_course_module_completion_registration_link),
350        )
351        .route(
352            "/{course_module_id}/set-certificate-generation/{enabled}",
353            web::post().to(enable_or_disable_certificate_generation),
354        )
355        .route(
356            "/{course_module_id}/course-module-completion",
357            web::get().to(get_best_course_module_completion_for_user),
358        )
359        .route(
360            "/{course_module_id}/threshold",
361            web::post().to(insert_threshold_for_module),
362        )
363        .route(
364            "/{course_module_id}/threshold",
365            web::delete().to(delete_threshold_for_module),
366        );
367}