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