1use crate::{
4 domain::{
5 authorization::skip_authorize,
6 exercises::process_submission,
7 models_requests::{self, GivePeerReviewClaim, JwtKey},
8 },
9 prelude::*,
10};
11use headless_lms_models::{
12 ModelError, ModelErrorType, flagged_answers::FlaggedAnswer, peer_or_self_review_configs,
13};
14use models::{
15 exercise_task_submissions::PeerOrSelfReviewsReceived,
16 exercises::CourseMaterialExercise,
17 flagged_answers::NewFlaggedAnswerWithToken,
18 library::{
19 grading::{StudentExerciseSlideSubmission, StudentExerciseSlideSubmissionResult},
20 peer_or_self_reviewing::{
21 CourseMaterialPeerOrSelfReviewData, CourseMaterialPeerOrSelfReviewSubmission,
22 },
23 },
24 user_chapter_locking_statuses, user_exercise_states,
25};
26use utoipa::OpenApi;
27
28#[derive(OpenApi)]
29#[openapi(paths(
30 get_exercise,
31 get_peer_review_for_exercise,
32 get_peer_reviews_received,
33 post_submission,
34 start_peer_or_self_review,
35 submit_peer_or_self_review,
36 post_flag_answer_in_peer_review
37))]
38pub(crate) struct CourseMaterialExercisesApiDoc;
39
40#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, utoipa::ToSchema)]
41
42pub struct CourseMaterialPeerOrSelfReviewDataWithToken {
43 pub course_material_peer_or_self_review_data: CourseMaterialPeerOrSelfReviewData,
44 pub token: Option<String>,
45}
46
47#[utoipa::path(
55 get,
56 path = "/{exercise_id}",
57 operation_id = "getCourseMaterialExercise",
58 tag = "course-material-exercises",
59 params(
60 ("exercise_id" = Uuid, Path, description = "Exercise id")
61 ),
62 responses(
63 (status = 200, description = "Course material exercise", body = CourseMaterialExercise)
64 )
65)]
66#[instrument(skip(pool, file_store, app_conf))]
67async fn get_exercise(
68 pool: web::Data<PgPool>,
69 exercise_id: web::Path<Uuid>,
70 user: Option<AuthUser>,
71 file_store: web::Data<dyn FileStore>,
72 app_conf: web::Data<ApplicationConfiguration>,
73) -> ControllerResult<web::Json<CourseMaterialExercise>> {
74 let mut conn = pool.acquire().await?;
75 let user_id = user.map(|u| u.id);
76 let mut course_material_exercise = models::exercises::get_course_material_exercise(
77 &mut conn,
78 user_id,
79 *exercise_id,
80 models_requests::fetch_service_info,
81 file_store.as_ref(),
82 app_conf.as_ref(),
83 )
84 .await?;
85
86 let mut should_clear_grading_information = true;
87 if let Some(exam_id) = course_material_exercise.exercise.exam_id {
89 let user_id_for_exam = user_id.ok_or_else(|| {
90 ControllerError::new(
91 ControllerErrorType::UnauthorizedWithReason(
92 crate::domain::error::UnauthorizedReason::AuthenticationRequiredForExamExercise,
93 ),
94 "User must be authenticated to view exam exercises".to_string(),
95 None,
96 )
97 })?;
98 let user_enrollment =
99 models::exams::get_enrollment(&mut conn, exam_id, user_id_for_exam).await?;
100
101 if let Some(enrollment) = user_enrollment
102 && let Some(show_answers) = enrollment.show_exercise_answers
103 && enrollment.is_teacher_testing
104 && show_answers
105 {
106 should_clear_grading_information = false;
107 }
108 }
109
110 if course_material_exercise.can_post_submission
111 && course_material_exercise.exercise.exam_id.is_some()
112 && should_clear_grading_information
113 {
114 course_material_exercise.clear_grading_information();
116 }
117
118 let score_given: f32 = if let Some(status) = &course_material_exercise.exercise_status {
119 status.score_given.unwrap_or(0.0)
120 } else {
121 0.0
122 };
123
124 let submission_count = course_material_exercise
125 .exercise_slide_submission_counts
126 .get(&course_material_exercise.current_exercise_slide.id)
127 .unwrap_or(&0);
128
129 let out_of_tries = course_material_exercise.exercise.limit_number_of_tries
130 && *submission_count as i32
131 >= course_material_exercise
132 .exercise
133 .max_tries_per_slide
134 .unwrap_or(i32::MAX);
135
136 let has_received_full_points = score_given
139 >= course_material_exercise.exercise.score_maximum as f32
140 || (score_given - course_material_exercise.exercise.score_maximum as f32).abs() < 0.0001;
141 if !has_received_full_points && !out_of_tries {
142 course_material_exercise.clear_model_solution_specs();
143 }
144 let token = skip_authorize();
145 token.authorized_ok(web::Json(course_material_exercise))
146}
147
148#[utoipa::path(
154 get,
155 path = "/{exercise_id}/peer-review",
156 operation_id = "fetchPeerOrSelfReviewDataByExerciseId",
157 tag = "course-material-exercises",
158 params(
159 ("exercise_id" = Uuid, Path, description = "Exercise id")
160 ),
161 responses(
162 (
163 status = 200,
164 description = "Peer or self review data",
165 body = CourseMaterialPeerOrSelfReviewDataWithToken
166 )
167 )
168)]
169#[instrument(skip(pool, file_store, app_conf))]
170async fn get_peer_review_for_exercise(
171 pool: web::Data<PgPool>,
172 exercise_id: web::Path<Uuid>,
173 user: AuthUser,
174 jwt_key: web::Data<JwtKey>,
175 file_store: web::Data<dyn FileStore>,
176 app_conf: web::Data<ApplicationConfiguration>,
177) -> ControllerResult<web::Json<CourseMaterialPeerOrSelfReviewDataWithToken>> {
178 let mut conn = pool.acquire().await?;
179 let course_material_peer_or_self_review_data =
180 models::peer_or_self_review_configs::get_course_material_peer_or_self_review_data(
181 &mut conn,
182 user.id,
183 *exercise_id,
184 models_requests::fetch_service_info,
185 file_store.as_ref(),
186 app_conf.as_ref(),
187 )
188 .await?;
189 let token = authorize(
190 &mut conn,
191 Act::View,
192 Some(user.id),
193 Res::Exercise(*exercise_id),
194 )
195 .await?;
196 let give_peer_review_claim =
197 if let Some(to_review) = &course_material_peer_or_self_review_data.answer_to_review {
198 Some(
199 GivePeerReviewClaim::expiring_in_1_day(
200 to_review.exercise_slide_submission_id,
201 course_material_peer_or_self_review_data
202 .peer_or_self_review_config
203 .id,
204 )
205 .sign(&jwt_key)?,
206 )
207 } else {
208 None
209 };
210
211 let res = CourseMaterialPeerOrSelfReviewDataWithToken {
212 course_material_peer_or_self_review_data,
213 token: give_peer_review_claim,
214 };
215 token.authorized_ok(web::Json(res))
216}
217
218#[utoipa::path(
222 get,
223 path = "/{exercise_id}/exercise-slide-submission/{exercise_slide_submission_id}/peer-or-self-reviews-received",
224 operation_id = "fetchPeerReviewDataReceivedByExerciseId",
225 tag = "course-material-exercises",
226 params(
227 ("exercise_id" = Uuid, Path, description = "Exercise id"),
228 ("exercise_slide_submission_id" = Uuid, Path, description = "Exercise slide submission id")
229 ),
230 responses(
231 (status = 200, description = "Peer reviews received", body = PeerOrSelfReviewsReceived)
232 )
233)]
234#[instrument(skip(pool))]
235async fn get_peer_reviews_received(
236 pool: web::Data<PgPool>,
237 params: web::Path<(Uuid, Uuid)>,
238 user: AuthUser,
239) -> ControllerResult<web::Json<PeerOrSelfReviewsReceived>> {
240 let mut conn = pool.acquire().await?;
241 let (exercise_id, exercise_slide_submission_id) = params.into_inner();
242 let peer_review_data = models::exercise_task_submissions::get_peer_reviews_received(
243 &mut conn,
244 exercise_id,
245 exercise_slide_submission_id,
246 user.id,
247 )
248 .await?;
249 let token = skip_authorize();
250 token.authorized_ok(web::Json(peer_review_data))
251}
252
253#[utoipa::path(
277 post,
278 path = "/{exercise_id}/submissions",
279 operation_id = "postSubmission",
280 tag = "course-material-exercises",
281 params(
282 ("exercise_id" = Uuid, Path, description = "Exercise id")
283 ),
284 request_body = StudentExerciseSlideSubmission,
285 responses(
286 (
287 status = 200,
288 description = "Submission result",
289 body = StudentExerciseSlideSubmissionResult
290 )
291 )
292)]
293#[instrument(skip(pool, file_store, jwt_key, app_conf))]
294async fn post_submission(
295 pool: web::Data<PgPool>,
296 file_store: web::Data<dyn FileStore>,
297 jwt_key: web::Data<JwtKey>,
298 exercise_id: web::Path<Uuid>,
299 payload: web::Json<StudentExerciseSlideSubmission>,
300 user: AuthUser,
301 app_conf: web::Data<ApplicationConfiguration>,
302) -> ControllerResult<web::Json<StudentExerciseSlideSubmissionResult>> {
303 let submission = payload.0;
304 let mut conn = pool.acquire().await?;
305 let exercise = models::exercises::get_by_id(&mut conn, *exercise_id).await?;
306
307 if let Some(chapter_id) = exercise.chapter_id {
308 let course_id = models::chapters::get_course_id(&mut conn, chapter_id).await?;
309 let is_accessible = user_chapter_locking_statuses::is_chapter_accessible(
310 &mut conn, user.id, chapter_id, course_id,
311 )
312 .await?;
313 if !is_accessible {
314 return Err(ControllerError::new(
315 ControllerErrorType::Forbidden,
316 "Complete and lock the previous chapter to unlock exercises in this chapter."
317 .to_string(),
318 None,
319 ));
320 }
321 let exercises_locked = user_chapter_locking_statuses::is_chapter_exercises_locked(
322 &mut conn, user.id, chapter_id, course_id,
323 )
324 .await?;
325 if exercises_locked {
326 return Err(ControllerError::new(
327 ControllerErrorType::Forbidden,
328 "The current chapter is locked, and you can no longer submit exercises."
329 .to_string(),
330 None,
331 ));
332 }
333 }
334
335 let token = authorize(
336 &mut conn,
337 Act::View,
338 Some(user.id),
339 Res::Exercise(exercise.id),
340 )
341 .await?;
342 let result = process_submission(
343 &mut conn,
344 user.id,
345 exercise,
346 &submission,
347 jwt_key.into_inner(),
348 file_store.as_ref(),
349 app_conf.as_ref(),
350 )
351 .await?;
352 token.authorized_ok(web::Json(result))
353}
354
355#[utoipa::path(
363 post,
364 path = "/{exercise_id}/peer-or-self-reviews/start",
365 operation_id = "postStartPeerOrSelfReview",
366 tag = "course-material-exercises",
367 params(
368 ("exercise_id" = Uuid, Path, description = "Exercise id")
369 ),
370 responses(
371 (status = 200, description = "Peer or self review started", body = bool)
372 )
373)]
374#[instrument(skip(pool))]
375async fn start_peer_or_self_review(
376 pool: web::Data<PgPool>,
377 exercise_id: web::Path<Uuid>,
378 user: AuthUser,
379) -> ControllerResult<web::Json<bool>> {
380 let mut conn = pool.acquire().await?;
381
382 let exercise = models::exercises::get_by_id(&mut conn, *exercise_id).await?;
383
384 if let Some(chapter_id) = exercise.chapter_id {
385 let course_id = models::chapters::get_course_id(&mut conn, chapter_id).await?;
386 let is_accessible = user_chapter_locking_statuses::is_chapter_accessible(
387 &mut conn, user.id, chapter_id, course_id,
388 )
389 .await?;
390 if !is_accessible {
391 return Err(ControllerError::new(
392 ControllerErrorType::Forbidden,
393 "Complete and lock the previous chapter to unlock exercises in this chapter."
394 .to_string(),
395 None,
396 ));
397 }
398 let exercises_locked = user_chapter_locking_statuses::is_chapter_exercises_locked(
399 &mut conn, user.id, chapter_id, course_id,
400 )
401 .await?;
402 if exercises_locked {
403 return Err(ControllerError::new(
404 ControllerErrorType::Forbidden,
405 "The current chapter is locked, and you can no longer submit exercises."
406 .to_string(),
407 None,
408 ));
409 }
410 }
411
412 let user_exercise_state =
413 user_exercise_states::get_users_current_by_exercise(&mut conn, user.id, &exercise).await?;
414 let token = authorize(
415 &mut conn,
416 Act::View,
417 Some(user.id),
418 Res::Exercise(*exercise_id),
419 )
420 .await?;
421 models::library::peer_or_self_reviewing::start_peer_or_self_review_for_user(
422 &mut conn,
423 user_exercise_state,
424 &exercise,
425 )
426 .await?;
427
428 token.authorized_ok(web::Json(true))
429}
430
431#[utoipa::path(
436 post,
437 path = "/{exercise_id}/peer-or-self-reviews",
438 operation_id = "postPeerOrSelfReviewSubmission",
439 tag = "course-material-exercises",
440 params(
441 ("exercise_id" = Uuid, Path, description = "Exercise id")
442 ),
443 request_body = CourseMaterialPeerOrSelfReviewSubmission,
444 responses(
445 (status = 200, description = "Peer or self review submitted", body = bool)
446 )
447)]
448#[instrument(skip(pool))]
449async fn submit_peer_or_self_review(
450 pool: web::Data<PgPool>,
451 exercise_id: web::Path<Uuid>,
452 payload: web::Json<CourseMaterialPeerOrSelfReviewSubmission>,
453 user: AuthUser,
454 jwt_key: web::Data<JwtKey>,
455) -> ControllerResult<web::Json<bool>> {
456 let mut conn = pool.acquire().await?;
457 let payload = payload.into_inner();
458 let exercise = models::exercises::get_non_deleted_by_id(&mut conn, *exercise_id).await?;
459
460 if let Some(chapter_id) = exercise.chapter_id {
461 let course_id = models::chapters::get_course_id(&mut conn, chapter_id).await?;
462 let is_accessible = user_chapter_locking_statuses::is_chapter_accessible(
463 &mut conn, user.id, chapter_id, course_id,
464 )
465 .await?;
466 if !is_accessible {
467 return Err(ControllerError::new(
468 ControllerErrorType::Forbidden,
469 "Complete and lock the previous chapter to unlock exercises in this chapter."
470 .to_string(),
471 None,
472 ));
473 }
474 let exercises_locked = user_chapter_locking_statuses::is_chapter_exercises_locked(
475 &mut conn, user.id, chapter_id, course_id,
476 )
477 .await?;
478 if exercises_locked {
479 return Err(ControllerError::new(
480 ControllerErrorType::Forbidden,
481 "The current chapter is locked, and you can no longer submit exercises."
482 .to_string(),
483 None,
484 ));
485 }
486 }
487
488 let claim = GivePeerReviewClaim::validate(&payload.token, &jwt_key)?;
491 if claim.exercise_slide_submission_id != payload.exercise_slide_submission_id
492 || claim.peer_or_self_review_config_id != payload.peer_or_self_review_config_id
493 {
494 return Err(ControllerError::new(
495 ControllerErrorType::BadRequest,
496 "You are not allowed to review this answer.".to_string(),
497 None,
498 ));
499 }
500
501 let giver_user_exercise_state =
502 user_exercise_states::get_users_current_by_exercise(&mut conn, user.id, &exercise).await?;
503 let exercise_slide_submission: models::exercise_slide_submissions::ExerciseSlideSubmission =
504 models::exercise_slide_submissions::get_by_id(
505 &mut conn,
506 payload.exercise_slide_submission_id,
507 )
508 .await?;
509 if exercise_slide_submission.exercise_id != exercise.id
510 || exercise_slide_submission.course_id != exercise.course_id
511 {
512 return Err(controller_err!(
513 Forbidden,
514 "Reviewed submission does not belong to the requested exercise".to_string()
515 ));
516 }
517
518 let peer_or_self_review_config = peer_or_self_review_configs::get_by_exercise_or_course_id(
519 &mut conn,
520 &exercise,
521 exercise.get_course_id()?,
522 )
523 .await?;
524 if peer_or_self_review_config.id != payload.peer_or_self_review_config_id {
525 return Err(controller_err!(
526 Forbidden,
527 "Peer review configuration does not belong to the requested exercise".to_string()
528 ));
529 }
530
531 if let Some(receiver_course_id) = exercise_slide_submission.course_id {
532 let receiver_user_exercise_state = user_exercise_states::get_user_exercise_state_if_exists(
533 &mut conn,
534 exercise_slide_submission.user_id,
535 exercise.id,
536 CourseOrExamId::Course(receiver_course_id),
537 )
538 .await?;
539 if let Some(receiver_user_exercise_state) = receiver_user_exercise_state {
540 let mut tx = conn.begin().await?;
541
542 models::library::peer_or_self_reviewing::create_peer_or_self_review_submission_for_user(
543 &mut tx,
544 &exercise,
545 giver_user_exercise_state,
546 receiver_user_exercise_state,
547 payload,
548 )
549 .await?;
550
551 let updated_receiver_state = user_exercise_states::get_user_exercise_state_if_exists(
553 &mut tx,
554 exercise_slide_submission.user_id,
555 exercise.id,
556 CourseOrExamId::Course(receiver_course_id),
557 )
558 .await?
559 .ok_or_else(|| {
560 ModelError::new(
561 ModelErrorType::Generic,
562 "Receiver exercise state not found".to_string(),
563 None,
564 )
565 })?;
566
567 let _ = models::library::peer_or_self_reviewing::reset_exercise_if_needed_if_zero_points_from_review(
568 &mut tx,
569 &peer_or_self_review_config,
570 &updated_receiver_state,
571 ).await?;
572
573 tx.commit().await?;
574 } else {
575 warn!(
576 "No user exercise state found for receiver's exercise slide submission id: {}",
577 exercise_slide_submission.id
578 );
579 return Err(ControllerError::new(
580 ControllerErrorType::BadRequest,
581 "No user exercise state found for receiver's exercise slide submission."
582 .to_string(),
583 None,
584 ));
585 }
586 } else {
587 warn!(
588 "No course instance id found for receiver's exercise slide submission id: {}",
589 exercise_slide_submission.id
590 );
591 return Err(ControllerError::new(
592 ControllerErrorType::BadRequest,
593 "No course instance id found for receiver's exercise slide submission.".to_string(),
594 None,
595 ));
596 }
597 let token = skip_authorize();
598 token.authorized_ok(web::Json(true))
599}
600
601#[utoipa::path(
605 post,
606 path = "/{exercise_id}/flag-peer-review-answer",
607 operation_id = "postFlagAnswerInPeerReview",
608 tag = "course-material-exercises",
609 params(
610 ("exercise_id" = Uuid, Path, description = "Exercise id")
611 ),
612 request_body = NewFlaggedAnswerWithToken,
613 responses(
614 (status = 200, description = "Created flagged answer", body = FlaggedAnswer)
615 )
616)]
617#[instrument(skip(pool))]
618async fn post_flag_answer_in_peer_review(
619 pool: web::Data<PgPool>,
620 payload: web::Json<NewFlaggedAnswerWithToken>,
621 user: AuthUser,
622 jwt_key: web::Data<JwtKey>,
623) -> ControllerResult<web::Json<FlaggedAnswer>> {
624 let mut conn = pool.acquire().await?;
625
626 let claim = GivePeerReviewClaim::validate(&payload.token, &jwt_key)?;
627 if claim.exercise_slide_submission_id != payload.submission_id
628 || claim.peer_or_self_review_config_id != payload.peer_or_self_review_config_id
629 {
630 return Err(ControllerError::new(
631 ControllerErrorType::BadRequest,
632 "You are not allowed to report this answer.".to_string(),
633 None,
634 ));
635 }
636
637 let insert_result =
638 models::flagged_answers::insert_flagged_answer_and_move_to_manual_review_if_needed(
639 &mut conn,
640 payload.into_inner(),
641 user.id,
642 )
643 .await?;
644
645 let token = skip_authorize();
646 token.authorized_ok(web::Json(insert_result))
647}
648
649pub fn _add_routes(cfg: &mut ServiceConfig) {
657 cfg.route("/{exercise_id}", web::get().to(get_exercise))
658 .route(
659 "/{exercise_id}/peer-or-self-reviews",
660 web::post().to(submit_peer_or_self_review),
661 )
662 .route(
663 "/{exercise_id}/peer-or-self-reviews/start",
664 web::post().to(start_peer_or_self_review),
665 )
666 .route(
667 "/{exercise_id}/peer-review",
668 web::get().to(get_peer_review_for_exercise),
669 )
670 .route(
671 "/{exercise_id}/exercise-slide-submission/{exercise_slide_submission_id}/peer-or-self-reviews-received",
672 web::get().to(get_peer_reviews_received),
673 )
674 .route(
675 "/{exercise_id}/submissions",
676 web::post().to(post_submission),
677 ).route(
678 "/{exercise_id}/flag-peer-review-answer",
679 web::post().to(post_flag_answer_in_peer_review),
680 );
681}