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