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