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(
173        &mut conn,
174        course_module_id,
175        enabled,
176        user.id,
177    )
178    .await?;
179
180    token.authorized_ok(web::Json(true))
181}
182
183/**
184GET `/api/v0/main-frontend/course-modules/{course_module_id}/course-module-completion`
185
186Gets users's best completion for the course.
187*/
188#[utoipa::path(
189    get,
190    path = "/{course_module_id}/course-module-completion",
191    operation_id = "getCourseModuleCompletion",
192    tag = "course_modules",
193    params(
194        ("course_module_id" = Uuid, Path, description = "Course module id")
195    ),
196    responses(
197        (status = 200, description = "Best course module completion for the current user", body = Option<CourseModuleCompletion>)
198    )
199)]
200#[instrument(skip(pool))]
201async fn get_best_course_module_completion_for_user(
202    course_module_id: web::Path<Uuid>,
203    pool: web::Data<PgPool>,
204    user: AuthUser,
205) -> ControllerResult<web::Json<Option<CourseModuleCompletion>>> {
206    let mut conn = pool.acquire().await?;
207    let course_module = course_modules::get_by_id(&mut conn, *course_module_id).await?;
208
209    let token = authorize(
210        &mut conn,
211        Act::View,
212        Some(user.id),
213        Res::Course(course_module.course_id),
214    )
215    .await?;
216
217    let information =
218        models::course_module_completions::get_best_completion_by_user_and_course_module_id(
219            &mut conn,
220            user.id,
221            *course_module_id,
222        )
223        .await?;
224    token.authorized_ok(web::Json(information))
225}
226
227/**
228 POST /api/v0/main-frontend/course-modules/${course_module_id}/threshold - post threshold for a specific course module.
229*/
230#[utoipa::path(
231    post,
232    path = "/{course_module_id}/threshold",
233    operation_id = "createCourseModuleThreshold",
234    tag = "course_modules",
235    params(
236        ("course_module_id" = Uuid, Path, description = "Course module id")
237    ),
238    request_body = ThresholdData,
239    responses(
240        (status = 200, description = "Threshold created")
241    )
242)]
243#[instrument(skip(pool))]
244async fn insert_threshold_for_module(
245    pool: web::Data<PgPool>,
246    params: web::Path<Uuid>,
247    payload: web::Json<ThresholdData>,
248    user: AuthUser,
249) -> ControllerResult<web::Json<()>> {
250    let mut conn = pool.acquire().await?;
251
252    let course_module_id = params.into_inner();
253    let new_threshold = payload.0;
254
255    let course_module = course_modules::get_by_id(&mut conn, course_module_id).await?;
256    let token = authorize(
257        &mut conn,
258        Act::Edit,
259        Some(user.id),
260        Res::Course(course_module.course_id),
261    )
262    .await?;
263
264    // Small modules are exempt from the minimum threshold: they can legitimately be completed
265    // fast, so any duration >= 0 is allowed for them, where 0 turns the duration check off. The
266    // exemption rule lives in suspected_cheaters::minimum_threshold_seconds so the save-time check
267    // and the configuration UI cannot drift. The exemption is checked at save time only -- a module
268    // that later grows past these limits keeps its existing below-minimum threshold until the next
269    // save.
270    let counts =
271        course_modules::get_chapter_and_exercise_counts(&mut conn, course_module_id).await?;
272    let minimum_seconds =
273        suspected_cheaters::minimum_threshold_seconds(counts.chapters, counts.exercises);
274    if new_threshold.duration_seconds < minimum_seconds {
275        let message = if minimum_seconds > 0 {
276            format!(
277                "The threshold must be at least {} hours.",
278                minimum_seconds / 3600
279            )
280        } else {
281            "The threshold cannot be negative.".to_string()
282        };
283        return Err(ControllerError::new(
284            ControllerErrorType::BadRequest,
285            message,
286            None,
287        ));
288    }
289
290    suspected_cheaters::insert_thresholds_by_module_id(
291        &mut conn,
292        course_module_id,
293        new_threshold.duration_seconds,
294    )
295    .await?;
296
297    token.authorized_ok(web::Json(()))
298}
299
300/**
301 DELETE /api/v0/main-frontend/course-modules/${course_module_id}/threshold - delete threshold for a specific course module.
302*/
303#[utoipa::path(
304    delete,
305    path = "/{course_module_id}/threshold",
306    operation_id = "deleteCourseModuleThreshold",
307    tag = "course_modules",
308    params(
309        ("course_module_id" = Uuid, Path, description = "Course module id")
310    ),
311    responses(
312        (status = 200, description = "Threshold deleted")
313    )
314)]
315#[instrument(skip(pool))]
316async fn delete_threshold_for_module(
317    pool: web::Data<PgPool>,
318    params: web::Path<Uuid>,
319    user: AuthUser,
320) -> ControllerResult<web::Json<()>> {
321    let mut conn = pool.acquire().await?;
322
323    let course_module_id = params.into_inner();
324
325    let course_module = course_modules::get_by_id(&mut conn, course_module_id).await?;
326    let token = authorize(
327        &mut conn,
328        Act::Edit,
329        Some(user.id),
330        Res::Course(course_module.course_id),
331    )
332    .await?;
333
334    suspected_cheaters::delete_threshold_for_module(&mut conn, course_module_id).await?;
335
336    token.authorized_ok(web::Json(()))
337}
338
339/**
340Add a route for each controller in this module.
341
342The name starts with an underline in order to appear before other functions in the module documentation.
343
344We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
345*/
346pub fn _add_routes(cfg: &mut ServiceConfig) {
347    cfg.route("/{course_module_id}", web::get().to(get_course_module))
348        .route(
349            "/{course_module_id}/user-completion",
350            web::get().to(get_course_module_completion_information_for_user),
351        )
352        .route(
353            "/{course_module_id}/completion-registration-link",
354            web::get().to(get_course_module_completion_registration_link),
355        )
356        .route(
357            "/{course_module_id}/set-certificate-generation/{enabled}",
358            web::post().to(enable_or_disable_certificate_generation),
359        )
360        .route(
361            "/{course_module_id}/course-module-completion",
362            web::get().to(get_best_course_module_completion_for_user),
363        )
364        .route(
365            "/{course_module_id}/threshold",
366            web::post().to(insert_threshold_for_module),
367        )
368        .route(
369            "/{course_module_id}/threshold",
370            web::delete().to(delete_threshold_for_module),
371        );
372}