Skip to main content

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    let exam_identity = exams::get_identity_by_id(&mut conn, *exam_id).await?;
105    let course = models::courses::get_course(&mut conn, exam.course_id).await?;
106    if exam_identity.organization_id != course.organization_id {
107        return Err(controller_err!(
108            Forbidden,
109            "Course does not belong to the same organization as the exam".to_string()
110        ));
111    }
112    authorize(
113        &mut conn,
114        Act::Edit,
115        Some(user.id),
116        Res::Course(exam.course_id),
117    )
118    .await?;
119
120    course_exams::upsert(&mut conn, *exam_id, exam.course_id).await?;
121
122    token.authorized_ok(web::Json(()))
123}
124
125/**
126POST `/api/v0/main-frontend/exams/:id/unset`
127*/
128#[utoipa::path(
129    post,
130    path = "/{id}/unset",
131    operation_id = "unsetExamCourse",
132    tag = "exams",
133    params(
134        ("id" = Uuid, Path, description = "Exam id")
135    ),
136    request_body = ExamCourseInfo,
137    responses(
138        (status = 200, description = "Course unset from exam")
139    )
140)]
141#[instrument(skip(pool))]
142pub async fn unset_course(
143    pool: web::Data<PgPool>,
144    exam_id: web::Path<Uuid>,
145    exam: web::Json<ExamCourseInfo>,
146    user: AuthUser,
147) -> ControllerResult<web::Json<()>> {
148    let mut conn = pool.acquire().await?;
149    let token = authorize(&mut conn, Act::Edit, Some(user.id), Res::Exam(*exam_id)).await?;
150    let exam_identity = exams::get_identity_by_id(&mut conn, *exam_id).await?;
151    let course = models::courses::get_course(&mut conn, exam.course_id).await?;
152    if exam_identity.organization_id != course.organization_id {
153        return Err(controller_err!(
154            Forbidden,
155            "Course does not belong to the same organization as the exam".to_string()
156        ));
157    }
158    authorize(
159        &mut conn,
160        Act::Edit,
161        Some(user.id),
162        Res::Course(exam.course_id),
163    )
164    .await?;
165
166    course_exams::delete(&mut conn, *exam_id, exam.course_id).await?;
167
168    token.authorized_ok(web::Json(()))
169}
170
171/**
172GET `/api/v0/main-frontend/exams/:id/export-points`
173*/
174#[utoipa::path(
175    get,
176    path = "/{id}/export-points",
177    operation_id = "exportExamPointsCsv",
178    tag = "exams",
179    params(
180        ("id" = Uuid, Path, description = "Exam id")
181    ),
182    responses(
183        (status = 200, description = "Exam points CSV export", body = String, content_type = "text/csv")
184    )
185)]
186#[instrument(skip(pool))]
187pub async fn export_points(
188    exam_id: web::Path<Uuid>,
189    pool: web::Data<PgPool>,
190    user: AuthUser,
191) -> ControllerResult<HttpResponse> {
192    let mut conn = pool.acquire().await?;
193    let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
194
195    let exam = exams::get(&mut conn, *exam_id).await?;
196
197    general_export(
198        pool,
199        &format!(
200            "attachment; filename=\"Exam: {} - Point export {}.csv\"",
201            exam.name,
202            Utc::now().format("%Y-%m-%d")
203        ),
204        ExamPointExportOperation { exam_id: *exam_id },
205        token,
206    )
207    .await
208}
209
210/**
211GET `/api/v0/main-frontend/exams/:id/export-submissions`
212*/
213#[utoipa::path(
214    get,
215    path = "/{id}/export-submissions",
216    operation_id = "exportExamSubmissionsCsv",
217    tag = "exams",
218    params(
219        ("id" = Uuid, Path, description = "Exam id")
220    ),
221    responses(
222        (status = 200, description = "Exam submissions CSV export", body = String, content_type = "text/csv")
223    )
224)]
225#[instrument(skip(pool, file_store, app_conf))]
226pub async fn export_submissions(
227    exam_id: web::Path<Uuid>,
228    pool: web::Data<PgPool>,
229    user: AuthUser,
230    file_store: web::Data<dyn FileStore>,
231    app_conf: web::Data<ApplicationConfiguration>,
232) -> ControllerResult<HttpResponse> {
233    let mut conn = pool.acquire().await?;
234    let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
235
236    let exam = exams::get(&mut conn, *exam_id).await?;
237
238    general_export(
239        pool,
240        &format!(
241            "attachment; filename=\"Exam: {} - Submissions {}.csv\"",
242            exam.name,
243            Utc::now().format("%Y-%m-%d")
244        ),
245        ExamSubmissionExportOperation {
246            exam_id: *exam_id,
247            file_store,
248            app_conf,
249        },
250        token,
251    )
252    .await
253}
254
255/**
256 * POST `/api/v0/cms/exams/:exam_id/duplicate` - duplicates existing exam.
257 */
258#[utoipa::path(
259    post,
260    path = "/{id}/duplicate",
261    operation_id = "duplicateExam",
262    tag = "exams",
263    params(
264        ("id" = Uuid, Path, description = "Exam id")
265    ),
266    request_body = NewExam,
267    responses(
268        (status = 200, description = "Exam duplicated", body = bool)
269    )
270)]
271#[instrument(skip(pool))]
272async fn duplicate_exam(
273    pool: web::Data<PgPool>,
274    exam_id: web::Path<Uuid>,
275    new_exam: web::Json<NewExam>,
276    user: AuthUser,
277) -> ControllerResult<web::Json<bool>> {
278    let mut conn = pool.acquire().await?;
279    let organization_id = models::exams::get_organization_id(&mut conn, *exam_id).await?;
280    let token = authorize(
281        &mut conn,
282        Act::CreateCoursesOrExams,
283        Some(user.id),
284        Res::Organization(organization_id),
285    )
286    .await?;
287
288    let mut tx = conn.begin().await?;
289    let new_exam = models::library::copying::copy_exam(&mut tx, &exam_id, &new_exam).await?;
290
291    models::roles::insert(
292        &mut tx,
293        user.id,
294        models::roles::UserRole::Teacher,
295        models::roles::RoleDomain::Exam(new_exam.id),
296    )
297    .await?;
298    tx.commit().await?;
299
300    token.authorized_ok(web::Json(true))
301}
302
303/**
304POST `/api/v0/main-frontend/organizations/{organization_id}/edit-exam` - edits an exam.
305*/
306#[utoipa::path(
307    post,
308    path = "/{id}/edit-exam",
309    operation_id = "editExam",
310    tag = "exams",
311    params(
312        ("id" = Uuid, Path, description = "Exam id")
313    ),
314    request_body = NewExam,
315    responses(
316        (status = 200, description = "Exam edited")
317    )
318)]
319#[instrument(skip(pool))]
320async fn edit_exam(
321    pool: web::Data<PgPool>,
322    exam_id: web::Path<Uuid>,
323    payload: web::Json<NewExam>,
324    user: AuthUser,
325) -> ControllerResult<web::Json<()>> {
326    let mut conn = pool.acquire().await?;
327    let mut tx = conn.begin().await?;
328
329    let exam = payload.0;
330    let token = authorize(&mut tx, Act::Edit, Some(user.id), Res::Exam(*exam_id)).await?;
331
332    models::exams::edit(&mut tx, *exam_id, exam).await?;
333
334    tx.commit().await?;
335
336    token.authorized_ok(web::Json(()))
337}
338
339/**
340GET `/api/v0/main-frontend/exam/:exercise_id/submissions-with-exercise_id` - Returns all the exercise submissions and user exercise states with exercise_id.
341 */
342#[utoipa::path(
343    get,
344    path = "/{exercise_id}/submissions-with-exercise-id",
345    operation_id = "getExamSubmissionsWithExerciseId",
346    tag = "exams",
347    params(
348        ("exercise_id" = Uuid, Path, description = "Exercise id"),
349        ("page" = Option<u32>, Query, description = "Page number"),
350        ("limit" = Option<u32>, Query, description = "Page size")
351    ),
352    responses(
353        (status = 200, description = "Exercise submissions with exercise id", body = ExerciseSlideSubmissionAndUserExerciseStateList)
354    )
355)]
356#[instrument(skip(pool))]
357async fn get_exercise_slide_submissions_and_user_exercise_states_with_exercise_id(
358    pool: web::Data<PgPool>,
359    exercise_id: web::Path<Uuid>,
360    pagination: web::Query<Pagination>,
361    user: AuthUser,
362) -> ControllerResult<web::Json<ExerciseSlideSubmissionAndUserExerciseStateList>> {
363    let mut conn = pool.acquire().await?;
364
365    let token = authorize(
366        &mut conn,
367        Act::Teach,
368        Some(user.id),
369        Res::Exercise(*exercise_id),
370    )
371    .await?;
372
373    let submission_count =
374        models::exercise_slide_submissions::exercise_slide_submission_count_with_exercise_id(
375            &mut conn,
376            *exercise_id,
377        );
378    let mut conn = pool.acquire().await?;
379    let submissions = models::exercise_slide_submissions::get_latest_exercise_slide_submissions_and_user_exercise_state_list_with_exercise_id(
380        &mut conn,
381        *exercise_id,
382        *pagination,
383    );
384    let (submission_count, submissions) = future::try_join(submission_count, submissions).await?;
385    let total_pages = pagination.total_pages(submission_count);
386
387    token.authorized_ok(web::Json(ExerciseSlideSubmissionAndUserExerciseStateList {
388        data: submissions,
389        total_pages,
390    }))
391}
392
393/**
394GET `/api/v0/main-frontend/exam/:exam_id/submissions-with-exam-id` - Returns all the exercise submissions and user exercise states with exam_id.
395 */
396#[utoipa::path(
397    get,
398    path = "/{exam_id}/submissions-with-exam-id",
399    operation_id = "getExamSubmissionsWithExamId",
400    tag = "exams",
401    params(
402        ("exam_id" = Uuid, Path, description = "Exam id"),
403        ("page" = Option<u32>, Query, description = "Page number"),
404        ("limit" = Option<u32>, Query, description = "Page size")
405    ),
406    responses(
407        (status = 200, description = "Exercise submissions with exam id", body = Vec<Vec<ExerciseSlideSubmissionAndUserExerciseState>>)
408    )
409)]
410#[instrument(skip(pool))]
411async fn get_exercise_slide_submissions_and_user_exercise_states_with_exam_id(
412    pool: web::Data<PgPool>,
413    exam_id: web::Path<Uuid>,
414    pagination: web::Query<Pagination>,
415    user: AuthUser,
416) -> ControllerResult<web::Json<Vec<Vec<ExerciseSlideSubmissionAndUserExerciseState>>>> {
417    let mut conn = pool.acquire().await?;
418
419    let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
420
421    let mut submissions_and_user_exercise_states: Vec<
422        Vec<ExerciseSlideSubmissionAndUserExerciseState>,
423    > = Vec::new();
424
425    let exercises = models::exercises::get_exercises_by_exam_id(&mut conn, *exam_id).await?;
426
427    let mut conn = pool.acquire().await?;
428    for exercise in exercises.iter() {
429        let submissions = models::exercise_slide_submissions::get_latest_exercise_slide_submissions_and_user_exercise_state_list_with_exercise_id(
430        &mut conn,
431        exercise.id,
432        *pagination,
433    ).await?;
434        submissions_and_user_exercise_states.push(submissions)
435    }
436
437    token.authorized_ok(web::Json(submissions_and_user_exercise_states))
438}
439
440/**
441GET `/api/v0/main-frontend/exam/:exam_id/exam-exercises` - Returns all the exercises with exam_id.
442 */
443#[utoipa::path(
444    get,
445    path = "/{exam_id}/exam-exercises",
446    operation_id = "getExamExercises",
447    tag = "exams",
448    params(
449        ("exam_id" = Uuid, Path, description = "Exam id")
450    ),
451    responses(
452        (status = 200, description = "Exam exercises", body = Vec<Exercise>)
453    )
454)]
455#[instrument(skip(pool))]
456async fn get_exercises_with_exam_id(
457    pool: web::Data<PgPool>,
458    exam_id: web::Path<Uuid>,
459    user: AuthUser,
460) -> ControllerResult<web::Json<Vec<Exercise>>> {
461    let mut conn = pool.acquire().await?;
462    let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
463
464    let exercises = models::exercises::get_exercises_by_exam_id(&mut conn, *exam_id).await?;
465
466    token.authorized_ok(web::Json(exercises))
467}
468
469/**
470POST `/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.
471 */
472#[utoipa::path(
473    post,
474    path = "/{exam_id}/release-grades",
475    operation_id = "releaseExamGrades",
476    tag = "exams",
477    params(
478        ("exam_id" = Uuid, Path, description = "Exam id")
479    ),
480    request_body = Vec<Uuid>,
481    responses(
482        (status = 200, description = "Exam grades released")
483    )
484)]
485#[instrument(skip(pool))]
486async fn release_grades(
487    pool: web::Data<PgPool>,
488    exam_id: web::Path<Uuid>,
489    user: AuthUser,
490    payload: web::Json<Vec<Uuid>>,
491) -> ControllerResult<web::Json<()>> {
492    let mut conn = pool.acquire().await?;
493    let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
494
495    let teacher_grading_decision_ids = payload.0;
496
497    let teacher_grading_decisions =
498        models::teacher_grading_decisions::get_by_ids(&mut conn, &teacher_grading_decision_ids)
499            .await?;
500
501    let user_exercise_state_mapping = models::user_exercise_states::get_by_ids(
502        &mut conn,
503        &teacher_grading_decisions
504            .iter()
505            .map(|x| x.user_exercise_state_id)
506            .collect::<Vec<Uuid>>(),
507    )
508    .await?
509    .into_iter()
510    .map(|x| (x.id, x))
511    .collect::<HashMap<Uuid, UserExerciseState>>();
512
513    let mut tx = conn.begin().await?;
514    for teacher_grading_decision in teacher_grading_decisions.iter() {
515        let user_exercise_state = user_exercise_state_mapping
516            .get(&teacher_grading_decision.user_exercise_state_id)
517            .ok_or_else(|| {
518                ControllerError::new(
519                    ControllerErrorType::InternalServerError,
520                    "User exercise state not found for a teacher grading decision",
521                    None,
522                )
523            })?;
524
525        if user_exercise_state.exam_id != Some(*exam_id) {
526            return Err(ControllerError::new(
527                ControllerErrorType::BadRequest,
528                "Teacher grading decision does not belong to the specified exam.",
529                None,
530            ));
531        }
532
533        teacher_grading_decisions::update_teacher_grading_decision_hidden_field(
534            &mut tx,
535            teacher_grading_decision.id,
536            false,
537        )
538        .await?;
539        user_exercise_state_updater::update_user_exercise_state(&mut tx, user_exercise_state.id)
540            .await?;
541    }
542
543    tx.commit().await?;
544
545    token.authorized_ok(web::Json(()))
546}
547
548/**
549Add a route for each controller in this module.
550
551The name starts with an underline in order to appear before other functions in the module documentation.
552
553We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
554*/
555pub fn _add_routes(cfg: &mut ServiceConfig) {
556    cfg.route("/{id}", web::get().to(get_exam))
557        .route("/{id}/set", web::post().to(set_course))
558        .route("/{id}/unset", web::post().to(unset_course))
559        .route("/{id}/export-points", web::get().to(export_points))
560        .route(
561            "/{id}/export-submissions",
562            web::get().to(export_submissions),
563        )
564        .route("/{id}/edit-exam", web::post().to(edit_exam))
565        .route("/{id}/duplicate", web::post().to(duplicate_exam))
566        .route(
567            "/{exercise_id}/submissions-with-exercise-id",
568            web::get().to(get_exercise_slide_submissions_and_user_exercise_states_with_exercise_id),
569        )
570        .route(
571            "/{exam_id}/submissions-with-exam-id",
572            web::get().to(get_exercise_slide_submissions_and_user_exercise_states_with_exam_id),
573        )
574        .route("/{exam_id}/release-grades", web::post().to(release_grades))
575        .route(
576            "/{exam_id}/exam-exercises",
577            web::get().to(get_exercises_with_exam_id),
578        );
579}