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