1use 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#[derive(Debug, Deserialize, ToSchema)]
38struct UserIdsPayload {
39 user_ids: Vec<Uuid>,
40}
41
42#[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 module_id: Option<Uuid>,
54 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#[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#[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#[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#[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#[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#[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#[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#[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#[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}