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))]
226pub async fn export_submissions(
227    exam_id: web::Path<Uuid>,
228    pool: web::Data<PgPool>,
229    user: AuthUser,
230) -> ControllerResult<HttpResponse> {
231    let mut conn = pool.acquire().await?;
232    let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
233
234    let exam = exams::get(&mut conn, *exam_id).await?;
235
236    general_export(
237        pool,
238        &format!(
239            "attachment; filename=\"Exam: {} - Submissions {}.csv\"",
240            exam.name,
241            Utc::now().format("%Y-%m-%d")
242        ),
243        ExamSubmissionExportOperation { exam_id: *exam_id },
244        token,
245    )
246    .await
247}
248
249/**
250 * POST `/api/v0/cms/exams/:exam_id/duplicate` - duplicates existing exam.
251 */
252#[utoipa::path(
253    post,
254    path = "/{id}/duplicate",
255    operation_id = "duplicateExam",
256    tag = "exams",
257    params(
258        ("id" = Uuid, Path, description = "Exam id")
259    ),
260    request_body = NewExam,
261    responses(
262        (status = 200, description = "Exam duplicated", body = bool)
263    )
264)]
265#[instrument(skip(pool))]
266async fn duplicate_exam(
267    pool: web::Data<PgPool>,
268    exam_id: web::Path<Uuid>,
269    new_exam: web::Json<NewExam>,
270    user: AuthUser,
271) -> ControllerResult<web::Json<bool>> {
272    let mut conn = pool.acquire().await?;
273    let organization_id = models::exams::get_organization_id(&mut conn, *exam_id).await?;
274    let token = authorize(
275        &mut conn,
276        Act::CreateCoursesOrExams,
277        Some(user.id),
278        Res::Organization(organization_id),
279    )
280    .await?;
281
282    let mut tx = conn.begin().await?;
283    let new_exam = models::library::copying::copy_exam(&mut tx, &exam_id, &new_exam).await?;
284
285    models::roles::insert(
286        &mut tx,
287        user.id,
288        models::roles::UserRole::Teacher,
289        models::roles::RoleDomain::Exam(new_exam.id),
290    )
291    .await?;
292    tx.commit().await?;
293
294    token.authorized_ok(web::Json(true))
295}
296
297/**
298POST `/api/v0/main-frontend/organizations/{organization_id}/edit-exam` - edits an exam.
299*/
300#[utoipa::path(
301    post,
302    path = "/{id}/edit-exam",
303    operation_id = "editExam",
304    tag = "exams",
305    params(
306        ("id" = Uuid, Path, description = "Exam id")
307    ),
308    request_body = NewExam,
309    responses(
310        (status = 200, description = "Exam edited")
311    )
312)]
313#[instrument(skip(pool))]
314async fn edit_exam(
315    pool: web::Data<PgPool>,
316    exam_id: web::Path<Uuid>,
317    payload: web::Json<NewExam>,
318    user: AuthUser,
319) -> ControllerResult<web::Json<()>> {
320    let mut conn = pool.acquire().await?;
321    let mut tx = conn.begin().await?;
322
323    let exam = payload.0;
324    let token = authorize(&mut tx, Act::Edit, Some(user.id), Res::Exam(*exam_id)).await?;
325
326    models::exams::edit(&mut tx, *exam_id, exam).await?;
327
328    tx.commit().await?;
329
330    token.authorized_ok(web::Json(()))
331}
332
333/**
334GET `/api/v0/main-frontend/exam/:exercise_id/submissions-with-exercise_id` - Returns all the exercise submissions and user exercise states with exercise_id.
335 */
336#[utoipa::path(
337    get,
338    path = "/{exercise_id}/submissions-with-exercise-id",
339    operation_id = "getExamSubmissionsWithExerciseId",
340    tag = "exams",
341    params(
342        ("exercise_id" = Uuid, Path, description = "Exercise id"),
343        ("page" = Option<u32>, Query, description = "Page number"),
344        ("limit" = Option<u32>, Query, description = "Page size")
345    ),
346    responses(
347        (status = 200, description = "Exercise submissions with exercise id", body = ExerciseSlideSubmissionAndUserExerciseStateList)
348    )
349)]
350#[instrument(skip(pool))]
351async fn get_exercise_slide_submissions_and_user_exercise_states_with_exercise_id(
352    pool: web::Data<PgPool>,
353    exercise_id: web::Path<Uuid>,
354    pagination: web::Query<Pagination>,
355    user: AuthUser,
356) -> ControllerResult<web::Json<ExerciseSlideSubmissionAndUserExerciseStateList>> {
357    let mut conn = pool.acquire().await?;
358
359    let token = authorize(
360        &mut conn,
361        Act::Teach,
362        Some(user.id),
363        Res::Exercise(*exercise_id),
364    )
365    .await?;
366
367    let submission_count =
368        models::exercise_slide_submissions::exercise_slide_submission_count_with_exercise_id(
369            &mut conn,
370            *exercise_id,
371        );
372    let mut conn = pool.acquire().await?;
373    let submissions = models::exercise_slide_submissions::get_latest_exercise_slide_submissions_and_user_exercise_state_list_with_exercise_id(
374        &mut conn,
375        *exercise_id,
376        *pagination,
377    );
378    let (submission_count, submissions) = future::try_join(submission_count, submissions).await?;
379    let total_pages = pagination.total_pages(submission_count);
380
381    token.authorized_ok(web::Json(ExerciseSlideSubmissionAndUserExerciseStateList {
382        data: submissions,
383        total_pages,
384    }))
385}
386
387/**
388GET `/api/v0/main-frontend/exam/:exam_id/submissions-with-exam-id` - Returns all the exercise submissions and user exercise states with exam_id.
389 */
390#[utoipa::path(
391    get,
392    path = "/{exam_id}/submissions-with-exam-id",
393    operation_id = "getExamSubmissionsWithExamId",
394    tag = "exams",
395    params(
396        ("exam_id" = Uuid, Path, description = "Exam id"),
397        ("page" = Option<u32>, Query, description = "Page number"),
398        ("limit" = Option<u32>, Query, description = "Page size")
399    ),
400    responses(
401        (status = 200, description = "Exercise submissions with exam id", body = Vec<Vec<ExerciseSlideSubmissionAndUserExerciseState>>)
402    )
403)]
404#[instrument(skip(pool))]
405async fn get_exercise_slide_submissions_and_user_exercise_states_with_exam_id(
406    pool: web::Data<PgPool>,
407    exam_id: web::Path<Uuid>,
408    pagination: web::Query<Pagination>,
409    user: AuthUser,
410) -> ControllerResult<web::Json<Vec<Vec<ExerciseSlideSubmissionAndUserExerciseState>>>> {
411    let mut conn = pool.acquire().await?;
412
413    let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
414
415    let mut submissions_and_user_exercise_states: Vec<
416        Vec<ExerciseSlideSubmissionAndUserExerciseState>,
417    > = Vec::new();
418
419    let exercises = models::exercises::get_exercises_by_exam_id(&mut conn, *exam_id).await?;
420
421    let mut conn = pool.acquire().await?;
422    for exercise in exercises.iter() {
423        let submissions = models::exercise_slide_submissions::get_latest_exercise_slide_submissions_and_user_exercise_state_list_with_exercise_id(
424        &mut conn,
425        exercise.id,
426        *pagination,
427    ).await?;
428        submissions_and_user_exercise_states.push(submissions)
429    }
430
431    token.authorized_ok(web::Json(submissions_and_user_exercise_states))
432}
433
434/**
435GET `/api/v0/main-frontend/exam/:exam_id/exam-exercises` - Returns all the exercises with exam_id.
436 */
437#[utoipa::path(
438    get,
439    path = "/{exam_id}/exam-exercises",
440    operation_id = "getExamExercises",
441    tag = "exams",
442    params(
443        ("exam_id" = Uuid, Path, description = "Exam id")
444    ),
445    responses(
446        (status = 200, description = "Exam exercises", body = Vec<Exercise>)
447    )
448)]
449#[instrument(skip(pool))]
450async fn get_exercises_with_exam_id(
451    pool: web::Data<PgPool>,
452    exam_id: web::Path<Uuid>,
453    user: AuthUser,
454) -> ControllerResult<web::Json<Vec<Exercise>>> {
455    let mut conn = pool.acquire().await?;
456    let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
457
458    let exercises = models::exercises::get_exercises_by_exam_id(&mut conn, *exam_id).await?;
459
460    token.authorized_ok(web::Json(exercises))
461}
462
463/**
464POST `/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.
465 */
466#[utoipa::path(
467    post,
468    path = "/{exam_id}/release-grades",
469    operation_id = "releaseExamGrades",
470    tag = "exams",
471    params(
472        ("exam_id" = Uuid, Path, description = "Exam id")
473    ),
474    request_body = Vec<Uuid>,
475    responses(
476        (status = 200, description = "Exam grades released")
477    )
478)]
479#[instrument(skip(pool))]
480async fn release_grades(
481    pool: web::Data<PgPool>,
482    exam_id: web::Path<Uuid>,
483    user: AuthUser,
484    payload: web::Json<Vec<Uuid>>,
485) -> ControllerResult<web::Json<()>> {
486    let mut conn = pool.acquire().await?;
487    let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
488
489    let teacher_grading_decision_ids = payload.0;
490
491    let teacher_grading_decisions =
492        models::teacher_grading_decisions::get_by_ids(&mut conn, &teacher_grading_decision_ids)
493            .await?;
494
495    let user_exercise_state_mapping = models::user_exercise_states::get_by_ids(
496        &mut conn,
497        &teacher_grading_decisions
498            .iter()
499            .map(|x| x.user_exercise_state_id)
500            .collect::<Vec<Uuid>>(),
501    )
502    .await?
503    .into_iter()
504    .map(|x| (x.id, x))
505    .collect::<HashMap<Uuid, UserExerciseState>>();
506
507    let mut tx = conn.begin().await?;
508    for teacher_grading_decision in teacher_grading_decisions.iter() {
509        let user_exercise_state = user_exercise_state_mapping
510            .get(&teacher_grading_decision.user_exercise_state_id)
511            .ok_or_else(|| {
512                ControllerError::new(
513                    ControllerErrorType::InternalServerError,
514                    "User exercise state not found for a teacher grading decision",
515                    None,
516                )
517            })?;
518
519        if user_exercise_state.exam_id != Some(*exam_id) {
520            return Err(ControllerError::new(
521                ControllerErrorType::BadRequest,
522                "Teacher grading decision does not belong to the specified exam.",
523                None,
524            ));
525        }
526
527        teacher_grading_decisions::update_teacher_grading_decision_hidden_field(
528            &mut tx,
529            teacher_grading_decision.id,
530            false,
531        )
532        .await?;
533        user_exercise_state_updater::update_user_exercise_state(&mut tx, user_exercise_state.id)
534            .await?;
535    }
536
537    tx.commit().await?;
538
539    token.authorized_ok(web::Json(()))
540}
541
542/**
543Add a route for each controller in this module.
544
545The name starts with an underline in order to appear before other functions in the module documentation.
546
547We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
548*/
549pub fn _add_routes(cfg: &mut ServiceConfig) {
550    cfg.route("/{id}", web::get().to(get_exam))
551        .route("/{id}/set", web::post().to(set_course))
552        .route("/{id}/unset", web::post().to(unset_course))
553        .route("/{id}/export-points", web::get().to(export_points))
554        .route(
555            "/{id}/export-submissions",
556            web::get().to(export_submissions),
557        )
558        .route("/{id}/edit-exam", web::post().to(edit_exam))
559        .route("/{id}/duplicate", web::post().to(duplicate_exam))
560        .route(
561            "/{exercise_id}/submissions-with-exercise-id",
562            web::get().to(get_exercise_slide_submissions_and_user_exercise_states_with_exercise_id),
563        )
564        .route(
565            "/{exam_id}/submissions-with-exam-id",
566            web::get().to(get_exercise_slide_submissions_and_user_exercise_states_with_exam_id),
567        )
568        .route("/{exam_id}/release-grades", web::post().to(release_grades))
569        .route(
570            "/{exam_id}/exam-exercises",
571            web::get().to(get_exercises_with_exam_id),
572        );
573}