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 exercise = models::exercises::get_by_id(&mut conn, *exercise_id).await?;
442
443 if let Some(chapter_id) = exercise.chapter_id {
444 let course_id = models::chapters::get_course_id(&mut conn, chapter_id).await?;
445 let is_accessible = user_chapter_locking_statuses::is_chapter_accessible(
446 &mut conn, user.id, chapter_id, course_id,
447 )
448 .await?;
449 if !is_accessible {
450 return Err(ControllerError::new(
451 ControllerErrorType::Forbidden,
452 "Complete and lock the previous chapter to unlock exercises in this chapter."
453 .to_string(),
454 None,
455 ));
456 }
457 let exercises_locked = user_chapter_locking_statuses::is_chapter_exercises_locked(
458 &mut conn, user.id, chapter_id, course_id,
459 )
460 .await?;
461 if exercises_locked {
462 return Err(ControllerError::new(
463 ControllerErrorType::Forbidden,
464 "The current chapter is locked, and you can no longer submit exercises."
465 .to_string(),
466 None,
467 ));
468 }
469 }
470
471 let claim = GivePeerReviewClaim::validate(&payload.token, &jwt_key)?;
474 if claim.exercise_slide_submission_id != payload.exercise_slide_submission_id
475 || claim.peer_or_self_review_config_id != payload.peer_or_self_review_config_id
476 {
477 return Err(ControllerError::new(
478 ControllerErrorType::BadRequest,
479 "You are not allowed to review this answer.".to_string(),
480 None,
481 ));
482 }
483
484 let giver_user_exercise_state =
485 user_exercise_states::get_users_current_by_exercise(&mut conn, user.id, &exercise).await?;
486 let exercise_slide_submission: models::exercise_slide_submissions::ExerciseSlideSubmission =
487 models::exercise_slide_submissions::get_by_id(
488 &mut conn,
489 payload.exercise_slide_submission_id,
490 )
491 .await?;
492
493 if let Some(receiver_course_id) = exercise_slide_submission.course_id {
494 let receiver_user_exercise_state = user_exercise_states::get_user_exercise_state_if_exists(
495 &mut conn,
496 exercise_slide_submission.user_id,
497 exercise.id,
498 CourseOrExamId::Course(receiver_course_id),
499 )
500 .await?;
501 if let Some(receiver_user_exercise_state) = receiver_user_exercise_state {
502 let mut tx = conn.begin().await?;
503
504 models::library::peer_or_self_reviewing::create_peer_or_self_review_submission_for_user(
505 &mut tx,
506 &exercise,
507 giver_user_exercise_state,
508 receiver_user_exercise_state,
509 payload.0,
510 )
511 .await?;
512
513 let updated_receiver_state = user_exercise_states::get_user_exercise_state_if_exists(
515 &mut tx,
516 exercise_slide_submission.user_id,
517 exercise.id,
518 CourseOrExamId::Course(receiver_course_id),
519 )
520 .await?
521 .ok_or_else(|| {
522 ModelError::new(
523 ModelErrorType::Generic,
524 "Receiver exercise state not found".to_string(),
525 None,
526 )
527 })?;
528
529 let peer_or_self_review_config =
530 peer_or_self_review_configs::get_by_exercise_or_course_id(
531 &mut tx,
532 &exercise,
533 exercise.get_course_id()?,
534 )
535 .await?;
536
537 let _ = models::library::peer_or_self_reviewing::reset_exercise_if_needed_if_zero_points_from_review(
538 &mut tx,
539 &peer_or_self_review_config,
540 &updated_receiver_state,
541 ).await?;
542
543 tx.commit().await?;
544 } else {
545 warn!(
546 "No user exercise state found for receiver's exercise slide submission id: {}",
547 exercise_slide_submission.id
548 );
549 return Err(ControllerError::new(
550 ControllerErrorType::BadRequest,
551 "No user exercise state found for receiver's exercise slide submission."
552 .to_string(),
553 None,
554 ));
555 }
556 } else {
557 warn!(
558 "No course instance id found for receiver's exercise slide submission id: {}",
559 exercise_slide_submission.id
560 );
561 return Err(ControllerError::new(
562 ControllerErrorType::BadRequest,
563 "No course instance id found for receiver's exercise slide submission.".to_string(),
564 None,
565 ));
566 }
567 let token = skip_authorize();
568 token.authorized_ok(web::Json(true))
569}
570
571#[utoipa::path(
575 post,
576 path = "/{exercise_id}/flag-peer-review-answer",
577 operation_id = "postFlagAnswerInPeerReview",
578 tag = "course-material-exercises",
579 params(
580 ("exercise_id" = Uuid, Path, description = "Exercise id")
581 ),
582 request_body = NewFlaggedAnswerWithToken,
583 responses(
584 (status = 200, description = "Created flagged answer", body = FlaggedAnswer)
585 )
586)]
587#[instrument(skip(pool))]
588async fn post_flag_answer_in_peer_review(
589 pool: web::Data<PgPool>,
590 payload: web::Json<NewFlaggedAnswerWithToken>,
591 user: AuthUser,
592 jwt_key: web::Data<JwtKey>,
593) -> ControllerResult<web::Json<FlaggedAnswer>> {
594 let mut conn = pool.acquire().await?;
595
596 let claim = GivePeerReviewClaim::validate(&payload.token, &jwt_key)?;
597 if claim.exercise_slide_submission_id != payload.submission_id
598 || claim.peer_or_self_review_config_id != payload.peer_or_self_review_config_id
599 {
600 return Err(ControllerError::new(
601 ControllerErrorType::BadRequest,
602 "You are not allowed to report this answer.".to_string(),
603 None,
604 ));
605 }
606
607 let insert_result =
608 models::flagged_answers::insert_flagged_answer_and_move_to_manual_review_if_needed(
609 &mut conn,
610 payload.into_inner(),
611 user.id,
612 )
613 .await?;
614
615 let token = skip_authorize();
616 token.authorized_ok(web::Json(insert_result))
617}
618
619pub fn _add_routes(cfg: &mut ServiceConfig) {
627 cfg.route("/{exercise_id}", web::get().to(get_exercise))
628 .route(
629 "/{exercise_id}/peer-or-self-reviews",
630 web::post().to(submit_peer_or_self_review),
631 )
632 .route(
633 "/{exercise_id}/peer-or-self-reviews/start",
634 web::post().to(start_peer_or_self_review),
635 )
636 .route(
637 "/{exercise_id}/peer-review",
638 web::get().to(get_peer_review_for_exercise),
639 )
640 .route(
641 "/{exercise_id}/exercise-slide-submission/{exercise_slide_submission_id}/peer-or-self-reviews-received",
642 web::get().to(get_peer_reviews_received),
643 )
644 .route(
645 "/{exercise_id}/submissions",
646 web::post().to(post_submission),
647 ).route(
648 "/{exercise_id}/flag-peer-review-answer",
649 web::post().to(post_flag_answer_in_peer_review),
650 );
651}