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