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