Skip to main content

headless_lms_server/controllers/main_frontend/courses/
students.rs

1//! Controllers for requests starting with `/api/v0/main-frontend/courses/{course_id}/students`.
2use crate::prelude::*;
3
4use headless_lms_models::chapter_lock_action_logs;
5use headless_lms_models::library::credit_registration::StudentFacingCreditRegistrationStatus;
6use headless_lms_models::library::students_view::{
7    CertificateGridRow, CompletionGridRow, CourseStudentsProgressStructure,
8    CourseStudentsProgressUsers, GRADE_FILTER_FAILED, GRADE_FILTER_NOT_COMPLETED,
9    GRADE_FILTER_PASSED, StudentsListPage,
10};
11use headless_lms_models::user_chapter_locking_statuses::{
12    ChapterLockingStatus, UserChapterLockingStatus,
13};
14use serde::Deserialize;
15use utoipa::OpenApi;
16use utoipa::ToSchema;
17
18#[derive(OpenApi)]
19#[openapi(paths(
20    get_progress_structure,
21    get_progress,
22    get_user_chapter_locking_statuses,
23    get_course_users,
24    get_completions,
25    get_certificates,
26    teacher_lock_student_chapter,
27    teacher_unlock_student_chapter,
28    teacher_set_student_chapter_status
29))]
30pub(crate) struct MainFrontendCourseStudentsApiDoc;
31
32#[derive(Debug, Deserialize, ToSchema)]
33struct ChapterLockStatusActionPayload {
34    status: ChapterLockingStatus,
35}
36
37/// Body for the batch detail endpoints: the users of the current identity-list page.
38#[derive(Debug, Deserialize, ToSchema)]
39struct UserIdsPayload {
40    user_ids: Vec<Uuid>,
41}
42
43/// Query parameters for the paginated student identity list.
44#[derive(Debug, Deserialize)]
45struct GetStudentsQuery {
46    page: Option<u32>,
47    limit: Option<u32>,
48    search: Option<String>,
49    sort_column: Option<String>,
50    sort_direction: Option<String>,
51    course_instance_id: Option<Uuid>,
52    /// Scopes `grade` to one of this module's completions. Ignored (no filtering) when `grade` is
53    /// absent.
54    module_id: Option<Uuid>,
55    /// A numeric grade (the sis-0-5 scale, `"0"`..`"5"`), or `"passed"`/`"failed"` (the sis-hyv-hyl
56    /// scale), or `"not_completed"`. Requires `module_id`.
57    grade: Option<String>,
58    /// Credit registration stages to narrow the roster to. Repeatable, so a filter naming several
59    /// stages ("needs attention") sends each of them rather than a name only the server understands.
60    registration_status: Option<Vec<StudentFacingCreditRegistrationStatus>>,
61}
62
63const VALID_GRADE_FILTERS: [&str; 3] = [
64    GRADE_FILTER_NOT_COMPLETED,
65    GRADE_FILTER_PASSED,
66    GRADE_FILTER_FAILED,
67];
68
69fn validate_grade_filter(grade: &str) -> Result<(), ControllerError> {
70    if VALID_GRADE_FILTERS.contains(&grade) || matches!(grade, "0" | "1" | "2" | "3" | "4" | "5") {
71        return Ok(());
72    }
73    Err(controller_err!(
74        BadRequest,
75        format!("Invalid grade filter: {grade}")
76    ))
77}
78
79/// GET `/api/v0/main-frontend/courses/{course_id}/students/progress-structure`
80#[utoipa::path(
81    get,
82    path = "/progress-structure",
83    operation_id = "getCourseStudentsProgressStructure",
84    tag = "course-students",
85    params(
86        ("course_id" = Uuid, Path, description = "Course id")
87    ),
88    responses(
89        (status = 200, description = "Course-level progress structure", body = CourseStudentsProgressStructure)
90    )
91)]
92#[instrument(skip(pool))]
93async fn get_progress_structure(
94    course_id: web::Path<Uuid>,
95    pool: web::Data<PgPool>,
96    user: AuthUser,
97) -> ControllerResult<web::Json<CourseStudentsProgressStructure>> {
98    let mut conn = pool.acquire().await?;
99    let token = authorize(
100        &mut conn,
101        Act::Teach,
102        Some(user.id),
103        Res::Course(*course_id),
104    )
105    .await?;
106    let res =
107        headless_lms_models::library::students_view::get_progress_structure(&mut conn, *course_id)
108            .await?;
109
110    token.authorized_ok(web::Json(res))
111}
112
113/// POST `/api/v0/main-frontend/courses/{course_id}/students/progress`
114#[utoipa::path(
115    post,
116    path = "/progress",
117    operation_id = "getCourseStudentsProgress",
118    tag = "course-students",
119    params(
120        ("course_id" = Uuid, Path, description = "Course id")
121    ),
122    request_body = UserIdsPayload,
123    responses(
124        (status = 200, description = "Per-user course progress for the given users", body = CourseStudentsProgressUsers)
125    )
126)]
127#[instrument(skip(pool))]
128async fn get_progress(
129    course_id: web::Path<Uuid>,
130    payload: web::Json<UserIdsPayload>,
131    pool: web::Data<PgPool>,
132    user: AuthUser,
133) -> ControllerResult<web::Json<CourseStudentsProgressUsers>> {
134    let mut conn = pool.acquire().await?;
135    let token = authorize(
136        &mut conn,
137        Act::Teach,
138        Some(user.id),
139        Res::Course(*course_id),
140    )
141    .await?;
142    let res = headless_lms_models::library::students_view::get_progress_for_users(
143        &mut conn,
144        *course_id,
145        &payload.user_ids,
146    )
147    .await?;
148
149    token.authorized_ok(web::Json(res))
150}
151
152/// GET `/api/v0/main-frontend/courses/{course_id}/students/{user_id}/chapter-locking-statuses`
153#[utoipa::path(
154    get,
155    path = "/{user_id}/chapter-locking-statuses",
156    operation_id = "getCourseStudentChapterLockingStatuses",
157    tag = "course-students",
158    params(
159        ("course_id" = Uuid, Path, description = "Course id"),
160        ("user_id" = Uuid, Path, description = "Target student id")
161    ),
162    responses(
163        (status = 200, description = "Student chapter locking statuses", body = [UserChapterLockingStatus])
164    )
165)]
166#[instrument(skip(pool))]
167async fn get_user_chapter_locking_statuses(
168    path: web::Path<(Uuid, Uuid)>,
169    pool: web::Data<PgPool>,
170    user: AuthUser,
171) -> ControllerResult<web::Json<Vec<UserChapterLockingStatus>>> {
172    let (course_id, target_user_id) = path.into_inner();
173    let mut conn = pool.acquire().await?;
174    let token = authorize(
175        &mut conn,
176        Act::ViewUserProgressOrDetails,
177        Some(user.id),
178        Res::Course(course_id),
179    )
180    .await?;
181
182    models::user_details::get_user_details_by_user_id_for_course(
183        &mut conn,
184        target_user_id,
185        course_id,
186    )
187    .await?;
188
189    let statuses = models::user_chapter_locking_statuses::get_or_init_all_for_course(
190        &mut conn,
191        target_user_id,
192        course_id,
193    )
194    .await?;
195
196    token.authorized_ok(web::Json(statuses))
197}
198
199/// GET `/api/v0/main-frontend/courses/{course_id}/students/users`
200#[utoipa::path(
201    get,
202    path = "/users",
203    operation_id = "getCourseStudentsUsers",
204    tag = "course-students",
205    params(
206        ("course_id" = Uuid, Path, description = "Course id"),
207        ("page" = Option<u32>, Query, description = "Page number (1-based)"),
208        ("limit" = Option<u32>, Query, description = "Page size (1-10000)"),
209        ("search" = Option<String>, Query, description = "Filter by name/email substring or exact user id"),
210        ("sort_column" = Option<String>, Query, description = "last_name | first_name | email | total_points"),
211        ("sort_direction" = Option<String>, Query, description = "asc | desc"),
212        ("course_instance_id" = Option<Uuid>, Query, description = "Filter to a single course instance"),
213        ("module_id" = Option<Uuid>, Query, description = "Scopes `grade` to this module's completions"),
214        ("grade" = Option<String>, Query, description = "A sis-0-5 grade (\"0\"..\"5\"), \"passed\"/\"failed\", or \"not_completed\"; requires module_id"),
215        ("registration_status" = Option<Vec<StudentFacingCreditRegistrationStatus>>, Query, description = "Only students holding a live credit registration at one of these stages; repeat the parameter for several")
216    ),
217    responses(
218        (status = 200, description = "A page of enrolled students", body = StudentsListPage)
219    )
220)]
221#[instrument(skip(pool))]
222async fn get_course_users(
223    course_id: web::Path<Uuid>,
224    query: MultiQuery<GetStudentsQuery>,
225    pool: web::Data<PgPool>,
226    user: AuthUser,
227) -> ControllerResult<web::Json<StudentsListPage>> {
228    let mut conn = pool.acquire().await?;
229    let token = authorize(
230        &mut conn,
231        Act::Teach,
232        Some(user.id),
233        Res::Course(*course_id),
234    )
235    .await?;
236    let pagination = Pagination::new(query.page.unwrap_or(1), query.limit.unwrap_or(100))
237        .map_err(|e| controller_err!(BadRequest, e.to_string()))?;
238
239    if let Some(module_id) = query.module_id {
240        let module = models::course_modules::get_by_id(&mut conn, module_id).await?;
241        if module.course_id != *course_id {
242            return Err(controller_err!(
243                BadRequest,
244                "Module does not belong to the course."
245            ));
246        }
247    }
248    if let Some(grade) = query.grade.as_deref() {
249        if query.module_id.is_none() {
250            return Err(controller_err!(BadRequest, "`grade` requires `module_id`."));
251        }
252        validate_grade_filter(grade)?;
253    }
254
255    let res = headless_lms_models::library::students_view::get_course_students_page(
256        &mut conn,
257        *course_id,
258        pagination,
259        query.search.as_deref(),
260        query.sort_column.as_deref(),
261        query.sort_direction.as_deref(),
262        query.course_instance_id,
263        query.module_id,
264        query.grade.as_deref(),
265        query.registration_status.as_deref().unwrap_or_default(),
266    )
267    .await?;
268
269    token.authorized_ok(web::Json(res))
270}
271
272/// POST `/api/v0/main-frontend/courses/{course_id}/students/completions`
273#[utoipa::path(
274    post,
275    path = "/completions",
276    operation_id = "getCourseStudentsCompletions",
277    tag = "course-students",
278    params(
279        ("course_id" = Uuid, Path, description = "Course id")
280    ),
281    request_body = UserIdsPayload,
282    responses(
283        (status = 200, description = "Course completions for the given users", body = [CompletionGridRow])
284    )
285)]
286#[instrument(skip(pool))]
287async fn get_completions(
288    course_id: web::Path<Uuid>,
289    payload: web::Json<UserIdsPayload>,
290    pool: web::Data<PgPool>,
291    user: AuthUser,
292) -> ControllerResult<web::Json<Vec<CompletionGridRow>>> {
293    let mut conn = pool.acquire().await?;
294    let token = authorize(
295        &mut conn,
296        Act::Teach,
297        Some(user.id),
298        Res::Course(*course_id),
299    )
300    .await?;
301    let rows = headless_lms_models::library::students_view::get_completions_grid_for_users(
302        &mut conn,
303        *course_id,
304        &payload.user_ids,
305    )
306    .await?;
307
308    token.authorized_ok(web::Json(rows))
309}
310
311/// POST `/api/v0/main-frontend/courses/{course_id}/students/certificates`
312#[utoipa::path(
313    post,
314    path = "/certificates",
315    operation_id = "getCourseStudentsCertificates",
316    tag = "course-students",
317    params(
318        ("course_id" = Uuid, Path, description = "Course id")
319    ),
320    request_body = UserIdsPayload,
321    responses(
322        (status = 200, description = "Course certificates for the given users", body = [CertificateGridRow])
323    )
324)]
325#[instrument(skip(pool))]
326async fn get_certificates(
327    course_id: web::Path<Uuid>,
328    payload: web::Json<UserIdsPayload>,
329    pool: web::Data<PgPool>,
330    user: AuthUser,
331) -> ControllerResult<web::Json<Vec<CertificateGridRow>>> {
332    let mut conn = pool.acquire().await?;
333    let token = authorize(
334        &mut conn,
335        Act::Teach,
336        Some(user.id),
337        Res::Course(*course_id),
338    )
339    .await?;
340    let rows = headless_lms_models::library::students_view::get_certificates_grid_for_users(
341        &mut conn,
342        *course_id,
343        &payload.user_ids,
344    )
345    .await?;
346
347    token.authorized_ok(web::Json(rows))
348}
349
350/// POST `/api/v0/main-frontend/courses/{course_id}/students/{user_id}/chapters/{chapter_id}/lock`
351#[utoipa::path(
352    post,
353    path = "/{user_id}/chapters/{chapter_id}/lock",
354    operation_id = "teacherLockStudentChapter",
355    tag = "course-students",
356    params(
357        ("course_id" = Uuid, Path, description = "Course id"),
358        ("user_id" = Uuid, Path, description = "Target student id"),
359        ("chapter_id" = Uuid, Path, description = "Chapter id")
360    ),
361    responses(
362        (status = 200, description = "Updated chapter locking status", body = UserChapterLockingStatus)
363    )
364)]
365#[instrument(skip(pool))]
366async fn teacher_lock_student_chapter(
367    path: web::Path<(Uuid, Uuid, Uuid)>,
368    pool: web::Data<PgPool>,
369    user: AuthUser,
370) -> ControllerResult<web::Json<UserChapterLockingStatus>> {
371    let (course_id, target_user_id, chapter_id) = path.into_inner();
372    let mut conn = pool.acquire().await?;
373    let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Course(course_id)).await?;
374
375    let chapter = models::chapters::get_chapter(&mut conn, chapter_id).await?;
376    if chapter.course_id != course_id {
377        return Err(ControllerError::new(
378            ControllerErrorType::BadRequest,
379            "Chapter does not belong to the course.".to_string(),
380            None,
381        ));
382    }
383    let course = models::courses::get_course(&mut conn, course_id).await?;
384    if !course.chapter_locking_enabled {
385        return Err(ControllerError::new(
386            ControllerErrorType::BadRequest,
387            "Chapter locking is not enabled for this course.".to_string(),
388            None,
389        ));
390    }
391
392    models::user_details::get_user_details_by_user_id_for_course(
393        &mut conn,
394        target_user_id,
395        course_id,
396    )
397    .await?;
398
399    let mut tx = conn.begin().await?;
400    let status = models::user_chapter_locking_statuses::complete_and_lock_chapter(
401        &mut tx,
402        target_user_id,
403        chapter_id,
404        course_id,
405    )
406    .await?;
407    chapter_lock_action_logs::insert(
408        &mut tx,
409        Some(user.id),
410        target_user_id,
411        course_id,
412        chapter_id,
413        status.status,
414    )
415    .await?;
416    tx.commit().await?;
417
418    token.authorized_ok(web::Json(status))
419}
420
421/// POST `/api/v0/main-frontend/courses/{course_id}/students/{user_id}/chapters/{chapter_id}/unlock`
422#[utoipa::path(
423    post,
424    path = "/{user_id}/chapters/{chapter_id}/unlock",
425    operation_id = "teacherUnlockStudentChapter",
426    tag = "course-students",
427    params(
428        ("course_id" = Uuid, Path, description = "Course id"),
429        ("user_id" = Uuid, Path, description = "Target student id"),
430        ("chapter_id" = Uuid, Path, description = "Chapter id")
431    ),
432    responses(
433        (status = 200, description = "Updated chapter locking status", body = UserChapterLockingStatus)
434    )
435)]
436#[instrument(skip(pool))]
437async fn teacher_unlock_student_chapter(
438    path: web::Path<(Uuid, Uuid, Uuid)>,
439    pool: web::Data<PgPool>,
440    user: AuthUser,
441) -> ControllerResult<web::Json<UserChapterLockingStatus>> {
442    let (course_id, target_user_id, chapter_id) = path.into_inner();
443    let mut conn = pool.acquire().await?;
444    let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Course(course_id)).await?;
445
446    let chapter = models::chapters::get_chapter(&mut conn, chapter_id).await?;
447    if chapter.course_id != course_id {
448        return Err(ControllerError::new(
449            ControllerErrorType::BadRequest,
450            "Chapter does not belong to the course.".to_string(),
451            None,
452        ));
453    }
454    let course = models::courses::get_course(&mut conn, course_id).await?;
455    if !course.chapter_locking_enabled {
456        return Err(ControllerError::new(
457            ControllerErrorType::BadRequest,
458            "Chapter locking is not enabled for this course.".to_string(),
459            None,
460        ));
461    }
462
463    models::user_details::get_user_details_by_user_id_for_course(
464        &mut conn,
465        target_user_id,
466        course_id,
467    )
468    .await?;
469
470    let mut tx = conn.begin().await?;
471    let status = models::user_chapter_locking_statuses::unlock_chapter(
472        &mut tx,
473        target_user_id,
474        chapter_id,
475        course_id,
476    )
477    .await?;
478    chapter_lock_action_logs::insert(
479        &mut tx,
480        Some(user.id),
481        target_user_id,
482        course_id,
483        chapter_id,
484        status.status,
485    )
486    .await?;
487    tx.commit().await?;
488
489    token.authorized_ok(web::Json(status))
490}
491
492/// POST `/api/v0/main-frontend/courses/{course_id}/students/{user_id}/chapters/{chapter_id}/status`
493#[utoipa::path(
494    post,
495    path = "/{user_id}/chapters/{chapter_id}/status",
496    operation_id = "teacherSetStudentChapterStatus",
497    tag = "course-students",
498    params(
499        ("course_id" = Uuid, Path, description = "Course id"),
500        ("user_id" = Uuid, Path, description = "Target student id"),
501        ("chapter_id" = Uuid, Path, description = "Chapter id")
502    ),
503    request_body = ChapterLockStatusActionPayload,
504    responses(
505        (status = 200, description = "Updated chapter locking status", body = UserChapterLockingStatus)
506    )
507)]
508#[instrument(skip(pool))]
509async fn teacher_set_student_chapter_status(
510    path: web::Path<(Uuid, Uuid, Uuid)>,
511    payload: web::Json<ChapterLockStatusActionPayload>,
512    pool: web::Data<PgPool>,
513    user: AuthUser,
514) -> ControllerResult<web::Json<UserChapterLockingStatus>> {
515    let (course_id, target_user_id, chapter_id) = path.into_inner();
516    let mut conn = pool.acquire().await?;
517    let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Course(course_id)).await?;
518
519    let chapter = models::chapters::get_chapter(&mut conn, chapter_id).await?;
520    if chapter.course_id != course_id {
521        return Err(ControllerError::new(
522            ControllerErrorType::BadRequest,
523            "Chapter does not belong to the course.".to_string(),
524            None,
525        ));
526    }
527    let course = models::courses::get_course(&mut conn, course_id).await?;
528    if !course.chapter_locking_enabled {
529        return Err(ControllerError::new(
530            ControllerErrorType::BadRequest,
531            "Chapter locking is not enabled for this course.".to_string(),
532            None,
533        ));
534    }
535
536    models::user_details::get_user_details_by_user_id_for_course(
537        &mut conn,
538        target_user_id,
539        course_id,
540    )
541    .await?;
542
543    let mut tx = conn.begin().await?;
544    let status = models::user_chapter_locking_statuses::set_chapter_status(
545        &mut tx,
546        target_user_id,
547        chapter_id,
548        course_id,
549        payload.status,
550    )
551    .await?;
552    chapter_lock_action_logs::insert(
553        &mut tx,
554        Some(user.id),
555        target_user_id,
556        course_id,
557        chapter_id,
558        status.status,
559    )
560    .await?;
561    tx.commit().await?;
562
563    token.authorized_ok(web::Json(status))
564}
565
566pub fn _add_routes(cfg: &mut web::ServiceConfig) {
567    cfg.route("/progress-structure", web::get().to(get_progress_structure));
568    cfg.route("/progress", web::post().to(get_progress));
569    cfg.route(
570        "/{user_id}/chapter-locking-statuses",
571        web::get().to(get_user_chapter_locking_statuses),
572    );
573    cfg.route("/users", web::get().to(get_course_users));
574    cfg.route("/completions", web::post().to(get_completions));
575    cfg.route("/certificates", web::post().to(get_certificates));
576    cfg.route(
577        "/{user_id}/chapters/{chapter_id}/lock",
578        web::post().to(teacher_lock_student_chapter),
579    );
580    cfg.route(
581        "/{user_id}/chapters/{chapter_id}/unlock",
582        web::post().to(teacher_unlock_student_chapter),
583    );
584    cfg.route(
585        "/{user_id}/chapters/{chapter_id}/status",
586        web::post().to(teacher_set_student_chapter_status),
587    );
588}