headless_lms_server/controllers/main_frontend/
exams.rs

1use std::collections::HashMap;
2
3use futures::future;
4
5use chrono::Utc;
6use headless_lms_models::user_exercise_states::UserExerciseState;
7use models::{
8    course_exams,
9    exams::{self, Exam, NewExam},
10    exercise_slide_submissions::{
11        ExerciseSlideSubmissionAndUserExerciseState,
12        ExerciseSlideSubmissionAndUserExerciseStateList,
13    },
14    exercises::Exercise,
15    library::user_exercise_state_updater,
16    teacher_grading_decisions,
17};
18use utoipa::{OpenApi, ToSchema};
19
20use crate::{
21    domain::csv_export::{
22        general_export, points::ExamPointExportOperation,
23        submissions::ExamSubmissionExportOperation,
24    },
25    prelude::*,
26};
27
28#[derive(OpenApi)]
29#[openapi(paths(
30    get_exam,
31    set_course,
32    unset_course,
33    export_points,
34    export_submissions,
35    edit_exam,
36    duplicate_exam,
37    get_exercise_slide_submissions_and_user_exercise_states_with_exam_id,
38    get_exercise_slide_submissions_and_user_exercise_states_with_exercise_id,
39    release_grades,
40    get_exercises_with_exam_id
41))]
42pub(crate) struct MainFrontendExamsApiDoc;
43
44/**
45GET `/api/v0/main-frontend/exams/:id
46*/
47#[utoipa::path(
48    get,
49    path = "/{id}",
50    operation_id = "getExam",
51    tag = "exams",
52    params(
53        ("id" = Uuid, Path, description = "Exam id")
54    ),
55    responses(
56        (status = 200, description = "Exam", body = Exam)
57    )
58)]
59#[instrument(skip(pool))]
60pub async fn get_exam(
61    pool: web::Data<PgPool>,
62    exam_id: web::Path<Uuid>,
63    user: AuthUser,
64) -> ControllerResult<web::Json<Exam>> {
65    let mut conn = pool.acquire().await?;
66    let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
67
68    let exam = exams::get(&mut conn, *exam_id).await?;
69
70    token.authorized_ok(web::Json(exam))
71}
72
73#[derive(Debug, Deserialize, ToSchema)]
74
75pub struct ExamCourseInfo {
76    course_id: Uuid,
77}
78
79/**
80POST `/api/v0/main-frontend/exams/:id/set`
81*/
82#[utoipa::path(
83    post,
84    path = "/{id}/set",
85    operation_id = "setExamCourse",
86    tag = "exams",
87    params(
88        ("id" = Uuid, Path, description = "Exam id")
89    ),
90    request_body = ExamCourseInfo,
91    responses(
92        (status = 200, description = "Course set for exam")
93    )
94)]
95#[instrument(skip(pool))]
96pub async fn set_course(
97    pool: web::Data<PgPool>,
98    exam_id: web::Path<Uuid>,
99    exam: web::Json<ExamCourseInfo>,
100    user: AuthUser,
101) -> ControllerResult<web::Json<()>> {
102    let mut conn = pool.acquire().await?;
103    let token = authorize(&mut conn, Act::Edit, Some(user.id), Res::Exam(*exam_id)).await?;
104
105    course_exams::upsert(&mut conn, *exam_id, exam.course_id).await?;
106
107    token.authorized_ok(web::Json(()))
108}
109
110/**
111POST `/api/v0/main-frontend/exams/:id/unset`
112*/
113#[utoipa::path(
114    post,
115    path = "/{id}/unset",
116    operation_id = "unsetExamCourse",
117    tag = "exams",
118    params(
119        ("id" = Uuid, Path, description = "Exam id")
120    ),
121    request_body = ExamCourseInfo,
122    responses(
123        (status = 200, description = "Course unset from exam")
124    )
125)]
126#[instrument(skip(pool))]
127pub async fn unset_course(
128    pool: web::Data<PgPool>,
129    exam_id: web::Path<Uuid>,
130    exam: web::Json<ExamCourseInfo>,
131    user: AuthUser,
132) -> ControllerResult<web::Json<()>> {
133    let mut conn = pool.acquire().await?;
134    let token = authorize(&mut conn, Act::Edit, Some(user.id), Res::Exam(*exam_id)).await?;
135
136    course_exams::delete(&mut conn, *exam_id, exam.course_id).await?;
137
138    token.authorized_ok(web::Json(()))
139}
140
141/**
142GET `/api/v0/main-frontend/exams/:id/export-points`
143*/
144#[utoipa::path(
145    get,
146    path = "/{id}/export-points",
147    operation_id = "exportExamPointsCsv",
148    tag = "exams",
149    params(
150        ("id" = Uuid, Path, description = "Exam id")
151    ),
152    responses(
153        (status = 200, description = "Exam points CSV export", body = String, content_type = "text/csv")
154    )
155)]
156#[instrument(skip(pool))]
157pub async fn export_points(
158    exam_id: web::Path<Uuid>,
159    pool: web::Data<PgPool>,
160    user: AuthUser,
161) -> ControllerResult<HttpResponse> {
162    let mut conn = pool.acquire().await?;
163    let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
164
165    let exam = exams::get(&mut conn, *exam_id).await?;
166
167    general_export(
168        pool,
169        &format!(
170            "attachment; filename=\"Exam: {} - Point export {}.csv\"",
171            exam.name,
172            Utc::now().format("%Y-%m-%d")
173        ),
174        ExamPointExportOperation { exam_id: *exam_id },
175        token,
176    )
177    .await
178}
179
180/**
181GET `/api/v0/main-frontend/exams/:id/export-submissions`
182*/
183#[utoipa::path(
184    get,
185    path = "/{id}/export-submissions",
186    operation_id = "exportExamSubmissionsCsv",
187    tag = "exams",
188    params(
189        ("id" = Uuid, Path, description = "Exam id")
190    ),
191    responses(
192        (status = 200, description = "Exam submissions CSV export", body = String, content_type = "text/csv")
193    )
194)]
195#[instrument(skip(pool))]
196pub async fn export_submissions(
197    exam_id: web::Path<Uuid>,
198    pool: web::Data<PgPool>,
199    user: AuthUser,
200) -> ControllerResult<HttpResponse> {
201    let mut conn = pool.acquire().await?;
202    let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
203
204    let exam = exams::get(&mut conn, *exam_id).await?;
205
206    general_export(
207        pool,
208        &format!(
209            "attachment; filename=\"Exam: {} - Submissions {}.csv\"",
210            exam.name,
211            Utc::now().format("%Y-%m-%d")
212        ),
213        ExamSubmissionExportOperation { exam_id: *exam_id },
214        token,
215    )
216    .await
217}
218
219/**
220 * POST `/api/v0/cms/exams/:exam_id/duplicate` - duplicates existing exam.
221 */
222#[utoipa::path(
223    post,
224    path = "/{id}/duplicate",
225    operation_id = "duplicateExam",
226    tag = "exams",
227    params(
228        ("id" = Uuid, Path, description = "Exam id")
229    ),
230    request_body = NewExam,
231    responses(
232        (status = 200, description = "Exam duplicated", body = bool)
233    )
234)]
235#[instrument(skip(pool))]
236async fn duplicate_exam(
237    pool: web::Data<PgPool>,
238    exam_id: web::Path<Uuid>,
239    new_exam: web::Json<NewExam>,
240    user: AuthUser,
241) -> ControllerResult<web::Json<bool>> {
242    let mut conn = pool.acquire().await?;
243    let organization_id = models::exams::get_organization_id(&mut conn, *exam_id).await?;
244    let token = authorize(
245        &mut conn,
246        Act::CreateCoursesOrExams,
247        Some(user.id),
248        Res::Organization(organization_id),
249    )
250    .await?;
251
252    let mut tx = conn.begin().await?;
253    let new_exam = models::library::copying::copy_exam(&mut tx, &exam_id, &new_exam).await?;
254
255    models::roles::insert(
256        &mut tx,
257        user.id,
258        models::roles::UserRole::Teacher,
259        models::roles::RoleDomain::Exam(new_exam.id),
260    )
261    .await?;
262    tx.commit().await?;
263
264    token.authorized_ok(web::Json(true))
265}
266
267/**
268POST `/api/v0/main-frontend/organizations/{organization_id}/edit-exam` - edits an exam.
269*/
270#[utoipa::path(
271    post,
272    path = "/{id}/edit-exam",
273    operation_id = "editExam",
274    tag = "exams",
275    params(
276        ("id" = Uuid, Path, description = "Exam id")
277    ),
278    request_body = NewExam,
279    responses(
280        (status = 200, description = "Exam edited")
281    )
282)]
283#[instrument(skip(pool))]
284async fn edit_exam(
285    pool: web::Data<PgPool>,
286    exam_id: web::Path<Uuid>,
287    payload: web::Json<NewExam>,
288    user: AuthUser,
289) -> ControllerResult<web::Json<()>> {
290    let mut conn = pool.acquire().await?;
291    let mut tx = conn.begin().await?;
292
293    let exam = payload.0;
294    let token = authorize(&mut tx, Act::Edit, Some(user.id), Res::Exam(*exam_id)).await?;
295
296    models::exams::edit(&mut tx, *exam_id, exam).await?;
297
298    tx.commit().await?;
299
300    token.authorized_ok(web::Json(()))
301}
302
303/**
304GET `/api/v0/main-frontend/exam/:exercise_id/submissions-with-exercise_id` - Returns all the exercise submissions and user exercise states with exercise_id.
305 */
306#[utoipa::path(
307    get,
308    path = "/{exercise_id}/submissions-with-exercise-id",
309    operation_id = "getExamSubmissionsWithExerciseId",
310    tag = "exams",
311    params(
312        ("exercise_id" = Uuid, Path, description = "Exercise id"),
313        ("page" = Option<u32>, Query, description = "Page number"),
314        ("limit" = Option<u32>, Query, description = "Page size")
315    ),
316    responses(
317        (status = 200, description = "Exercise submissions with exercise id", body = ExerciseSlideSubmissionAndUserExerciseStateList)
318    )
319)]
320#[instrument(skip(pool))]
321async fn get_exercise_slide_submissions_and_user_exercise_states_with_exercise_id(
322    pool: web::Data<PgPool>,
323    exercise_id: web::Path<Uuid>,
324    pagination: web::Query<Pagination>,
325    user: AuthUser,
326) -> ControllerResult<web::Json<ExerciseSlideSubmissionAndUserExerciseStateList>> {
327    let mut conn = pool.acquire().await?;
328
329    let token = authorize(
330        &mut conn,
331        Act::Teach,
332        Some(user.id),
333        Res::Exercise(*exercise_id),
334    )
335    .await?;
336
337    let submission_count =
338        models::exercise_slide_submissions::exercise_slide_submission_count_with_exercise_id(
339            &mut conn,
340            *exercise_id,
341        );
342    let mut conn = pool.acquire().await?;
343    let submissions = models::exercise_slide_submissions::get_latest_exercise_slide_submissions_and_user_exercise_state_list_with_exercise_id(
344        &mut conn,
345        *exercise_id,
346        *pagination,
347    );
348    let (submission_count, submissions) = future::try_join(submission_count, submissions).await?;
349    let total_pages = pagination.total_pages(submission_count);
350
351    token.authorized_ok(web::Json(ExerciseSlideSubmissionAndUserExerciseStateList {
352        data: submissions,
353        total_pages,
354    }))
355}
356
357/**
358GET `/api/v0/main-frontend/exam/:exam_id/submissions-with-exam-id` - Returns all the exercise submissions and user exercise states with exam_id.
359 */
360#[utoipa::path(
361    get,
362    path = "/{exam_id}/submissions-with-exam-id",
363    operation_id = "getExamSubmissionsWithExamId",
364    tag = "exams",
365    params(
366        ("exam_id" = Uuid, Path, description = "Exam id"),
367        ("page" = Option<u32>, Query, description = "Page number"),
368        ("limit" = Option<u32>, Query, description = "Page size")
369    ),
370    responses(
371        (status = 200, description = "Exercise submissions with exam id", body = Vec<Vec<ExerciseSlideSubmissionAndUserExerciseState>>)
372    )
373)]
374#[instrument(skip(pool))]
375async fn get_exercise_slide_submissions_and_user_exercise_states_with_exam_id(
376    pool: web::Data<PgPool>,
377    exam_id: web::Path<Uuid>,
378    pagination: web::Query<Pagination>,
379    user: AuthUser,
380) -> ControllerResult<web::Json<Vec<Vec<ExerciseSlideSubmissionAndUserExerciseState>>>> {
381    let mut conn = pool.acquire().await?;
382
383    let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
384
385    let mut submissions_and_user_exercise_states: Vec<
386        Vec<ExerciseSlideSubmissionAndUserExerciseState>,
387    > = Vec::new();
388
389    let exercises = models::exercises::get_exercises_by_exam_id(&mut conn, *exam_id).await?;
390
391    let mut conn = pool.acquire().await?;
392    for exercise in exercises.iter() {
393        let submissions = models::exercise_slide_submissions::get_latest_exercise_slide_submissions_and_user_exercise_state_list_with_exercise_id(
394        &mut conn,
395        exercise.id,
396        *pagination,
397    ).await?;
398        submissions_and_user_exercise_states.push(submissions)
399    }
400
401    token.authorized_ok(web::Json(submissions_and_user_exercise_states))
402}
403
404/**
405GET `/api/v0/main-frontend/exam/:exam_id/exam-exercises` - Returns all the exercises with exam_id.
406 */
407#[utoipa::path(
408    get,
409    path = "/{exam_id}/exam-exercises",
410    operation_id = "getExamExercises",
411    tag = "exams",
412    params(
413        ("exam_id" = Uuid, Path, description = "Exam id")
414    ),
415    responses(
416        (status = 200, description = "Exam exercises", body = Vec<Exercise>)
417    )
418)]
419#[instrument(skip(pool))]
420async fn get_exercises_with_exam_id(
421    pool: web::Data<PgPool>,
422    exam_id: web::Path<Uuid>,
423    user: AuthUser,
424) -> ControllerResult<web::Json<Vec<Exercise>>> {
425    let mut conn = pool.acquire().await?;
426    let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
427
428    let exercises = models::exercises::get_exercises_by_exam_id(&mut conn, *exam_id).await?;
429
430    token.authorized_ok(web::Json(exercises))
431}
432
433/**
434POST `/api/v0/main-frontend/exam/:exam_id/release-grades` - Publishes grading results of an exam by updating user_exercise_states according to teacher_grading_decisons and changes teacher_grading_decisions hidden field to false. Takes teacher grading decision ids as input.
435 */
436#[utoipa::path(
437    post,
438    path = "/{exam_id}/release-grades",
439    operation_id = "releaseExamGrades",
440    tag = "exams",
441    params(
442        ("exam_id" = Uuid, Path, description = "Exam id")
443    ),
444    request_body = Vec<Uuid>,
445    responses(
446        (status = 200, description = "Exam grades released")
447    )
448)]
449#[instrument(skip(pool))]
450async fn release_grades(
451    pool: web::Data<PgPool>,
452    exam_id: web::Path<Uuid>,
453    user: AuthUser,
454    payload: web::Json<Vec<Uuid>>,
455) -> ControllerResult<web::Json<()>> {
456    let mut conn = pool.acquire().await?;
457    let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
458
459    let teacher_grading_decision_ids = payload.0;
460
461    let teacher_grading_decisions =
462        models::teacher_grading_decisions::get_by_ids(&mut conn, &teacher_grading_decision_ids)
463            .await?;
464
465    let user_exercise_state_mapping = models::user_exercise_states::get_by_ids(
466        &mut conn,
467        &teacher_grading_decisions
468            .iter()
469            .map(|x| x.user_exercise_state_id)
470            .collect::<Vec<Uuid>>(),
471    )
472    .await?
473    .into_iter()
474    .map(|x| (x.id, x))
475    .collect::<HashMap<Uuid, UserExerciseState>>();
476
477    let mut tx = conn.begin().await?;
478    for teacher_grading_decision in teacher_grading_decisions.iter() {
479        let user_exercise_state = user_exercise_state_mapping
480            .get(&teacher_grading_decision.user_exercise_state_id)
481            .ok_or_else(|| {
482                ControllerError::new(
483                    ControllerErrorType::InternalServerError,
484                    "User exercise state not found for a teacher grading decision",
485                    None,
486                )
487            })?;
488
489        if user_exercise_state.exam_id != Some(*exam_id) {
490            return Err(ControllerError::new(
491                ControllerErrorType::BadRequest,
492                "Teacher grading decision does not belong to the specified exam.",
493                None,
494            ));
495        }
496
497        teacher_grading_decisions::update_teacher_grading_decision_hidden_field(
498            &mut tx,
499            teacher_grading_decision.id,
500            false,
501        )
502        .await?;
503        user_exercise_state_updater::update_user_exercise_state(&mut tx, user_exercise_state.id)
504            .await?;
505    }
506
507    tx.commit().await?;
508
509    token.authorized_ok(web::Json(()))
510}
511
512/**
513Add a route for each controller in this module.
514
515The name starts with an underline in order to appear before other functions in the module documentation.
516
517We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
518*/
519pub fn _add_routes(cfg: &mut ServiceConfig) {
520    cfg.route("/{id}", web::get().to(get_exam))
521        .route("/{id}/set", web::post().to(set_course))
522        .route("/{id}/unset", web::post().to(unset_course))
523        .route("/{id}/export-points", web::get().to(export_points))
524        .route(
525            "/{id}/export-submissions",
526            web::get().to(export_submissions),
527        )
528        .route("/{id}/edit-exam", web::post().to(edit_exam))
529        .route("/{id}/duplicate", web::post().to(duplicate_exam))
530        .route(
531            "/{exercise_id}/submissions-with-exercise-id",
532            web::get().to(get_exercise_slide_submissions_and_user_exercise_states_with_exercise_id),
533        )
534        .route(
535            "/{exam_id}/submissions-with-exam-id",
536            web::get().to(get_exercise_slide_submissions_and_user_exercise_states_with_exam_id),
537        )
538        .route("/{exam_id}/release-grades", web::post().to(release_grades))
539        .route(
540            "/{exam_id}/exam-exercises",
541            web::get().to(get_exercises_with_exam_id),
542        );
543}