Skip to main content

headless_lms_models/library/
peer_or_self_reviewing.rs

1use std::collections::HashMap;
2
3use chrono::Duration;
4use futures::future::BoxFuture;
5use rand::{rng, seq::SliceRandom};
6use url::Url;
7use utoipa::ToSchema;
8
9use crate::{
10    exercise_service_info::ExerciseServiceInfoApi,
11    exercise_slide_submissions::{self, ExerciseSlideSubmission},
12    exercise_task_submissions,
13    exercise_tasks::CourseMaterialExerciseTask,
14    exercises::Exercise,
15    peer_or_self_review_configs::{self, PeerOrSelfReviewConfig, PeerReviewProcessingStrategy},
16    peer_or_self_review_question_submissions,
17    peer_or_self_review_questions::{self, PeerOrSelfReviewQuestion},
18    peer_or_self_review_submissions,
19    peer_review_queue_entries::{self, PeerReviewQueueEntry},
20    prelude::*,
21    user_exercise_states::{self, ReviewingStage, UserExerciseState},
22};
23
24use super::user_exercise_state_updater::{
25    self, UserExerciseStateUpdateAlreadyLoadedRequiredData,
26    UserExerciseStateUpdateAlreadyLoadedRequiredDataPeerReviewInformation,
27};
28
29const MAX_PEER_REVIEW_CANDIDATES: i64 = 10;
30
31/// Starts peer review state for the student for this exercise.
32pub async fn start_peer_or_self_review_for_user(
33    conn: &mut PgConnection,
34    user_exercise_state: UserExerciseState,
35    exercise: &Exercise,
36) -> ModelResult<()> {
37    if user_exercise_state.reviewing_stage != ReviewingStage::NotStarted {
38        return Err(ModelError::new(
39            ModelErrorType::PreconditionFailed,
40            "Cannot start peer or self review anymore.".to_string(),
41            None,
42        ));
43    }
44    if !exercise.needs_peer_review && !exercise.needs_self_review {
45        return Err(ModelError::new(
46            ModelErrorType::PreconditionFailed,
47            "Exercise does not need peer or self review.".to_string(),
48            None,
49        ));
50    }
51    let new_reviewing_stage = if exercise.needs_peer_review {
52        ReviewingStage::PeerReview
53    } else {
54        ReviewingStage::SelfReview
55    };
56
57    let _user_exercise_state = user_exercise_states::update_exercise_progress(
58        conn,
59        user_exercise_state.id,
60        new_reviewing_stage,
61    )
62    .await?;
63    Ok(())
64}
65
66#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
67
68pub struct CourseMaterialPeerOrSelfReviewSubmission {
69    pub exercise_slide_submission_id: Uuid,
70    pub peer_or_self_review_config_id: Uuid,
71    pub peer_review_question_answers: Vec<CourseMaterialPeerOrSelfReviewQuestionAnswer>,
72    pub token: String,
73}
74
75#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
76
77pub struct CourseMaterialPeerOrSelfReviewQuestionAnswer {
78    pub peer_or_self_review_question_id: Uuid,
79    pub text_data: Option<String>,
80    pub number_data: Option<f32>,
81}
82
83pub async fn create_peer_or_self_review_submission_for_user(
84    conn: &mut PgConnection,
85    exercise: &Exercise,
86    giver_exercise_state: UserExerciseState,
87    receiver_exercise_state: UserExerciseState,
88    peer_review_submission: CourseMaterialPeerOrSelfReviewSubmission,
89) -> ModelResult<UserExerciseState> {
90    let is_self_review = giver_exercise_state.user_id == receiver_exercise_state.user_id;
91
92    if is_self_review
93        && (!exercise.needs_self_review
94            || giver_exercise_state.reviewing_stage != ReviewingStage::SelfReview)
95    {
96        return Err(ModelError::new(
97            ModelErrorType::PreconditionFailed,
98            "Self review not allowed.".to_string(),
99            None,
100        ));
101    }
102    if !is_self_review
103        && (!exercise.needs_peer_review
104            || giver_exercise_state.reviewing_stage == ReviewingStage::NotStarted)
105    {
106        return Err(ModelError::new(
107            ModelErrorType::PreconditionFailed,
108            "Peer review not allowed.".to_string(),
109            None,
110        ));
111    }
112
113    let peer_or_self_review_config = peer_or_self_review_configs::get_by_exercise_or_course_id(
114        conn,
115        exercise,
116        exercise.get_course_id()?,
117    )
118    .await?;
119    let sanitized_answers = validate_and_sanitize_peer_review_submission_answers(
120        peer_or_self_review_questions::get_all_by_peer_or_self_review_config_id_as_map(
121            conn,
122            peer_or_self_review_config.id,
123        )
124        .await?,
125        peer_review_submission.peer_review_question_answers,
126    )?;
127
128    let mut tx = conn.begin().await?;
129
130    let peer_reviews_given_before_this_review: i32 =
131        peer_or_self_review_submissions::get_users_submission_count_for_exercise_and_course_instance(
132            &mut tx,
133            giver_exercise_state.user_id,
134            giver_exercise_state.exercise_id,
135            giver_exercise_state.get_course_id()?,
136        )
137        .await?
138        .try_into()?;
139    let peer_reviews_given = peer_reviews_given_before_this_review + 1;
140
141    if !is_self_review {
142        let unacceptable_amount_of_peer_reviews =
143            std::cmp::max(peer_or_self_review_config.peer_reviews_to_give, 1) * 15;
144        let suspicious_amount_of_peer_reviews = std::cmp::max(
145            std::cmp::max(peer_or_self_review_config.peer_reviews_to_give, 1) * 2,
146            4,
147        );
148        // To prevent someone from spamming peer reviews
149        if peer_reviews_given > unacceptable_amount_of_peer_reviews {
150            return Err(ModelError::new(
151                ModelErrorType::PreconditionFailed,
152                "You have given too many peer reviews to this exercise".to_string(),
153                None,
154            ));
155        }
156        // If someone has created more peer reviews than usual, apply rate limiting
157        if peer_reviews_given > suspicious_amount_of_peer_reviews {
158            // This is purposefully getting submission time to any peer reviewed exercise to prevent the user from spamming multiple exercises at the same time.
159            let last_submission_time =
160                peer_or_self_review_submissions::get_last_time_user_submitted_peer_review(
161                    &mut tx,
162                    giver_exercise_state.user_id,
163                    giver_exercise_state.exercise_id,
164                    giver_exercise_state.get_course_id()?,
165                )
166                .await?;
167
168            if let Some(last_submission_time) = last_submission_time {
169                let diff = peer_reviews_given - suspicious_amount_of_peer_reviews;
170                let coefficient = diff.clamp(1, 10);
171                // Between 30 seconds and 5 minutes
172                if Utc::now() - Duration::seconds(30 * coefficient as i64) < last_submission_time {
173                    return Err(ModelError::new(
174                        ModelErrorType::InvalidRequest,
175                        "You are submitting too fast. Try again later.".to_string(),
176                        None,
177                    ));
178                }
179            }
180        }
181    }
182    let peer_or_self_review_submission_id = peer_or_self_review_submissions::insert(
183        &mut tx,
184        PKeyPolicy::Generate,
185        giver_exercise_state.user_id,
186        giver_exercise_state.exercise_id,
187        giver_exercise_state.get_course_id()?,
188        peer_or_self_review_config.id,
189        peer_review_submission.exercise_slide_submission_id,
190    )
191    .await?;
192    for answer in sanitized_answers {
193        peer_or_self_review_question_submissions::insert(
194            &mut tx,
195            PKeyPolicy::Generate,
196            answer.peer_or_self_review_question_id,
197            peer_or_self_review_submission_id,
198            answer.text_data,
199            answer.number_data,
200        )
201        .await?;
202    }
203
204    if !is_self_review && peer_reviews_given >= peer_or_self_review_config.peer_reviews_to_give {
205        // Update peer review queue entry
206        let users_latest_submission =
207            exercise_slide_submissions::get_users_latest_exercise_slide_submission(
208                &mut tx,
209                giver_exercise_state.get_selected_exercise_slide_id()?,
210                giver_exercise_state.user_id,
211            )
212            .await?;
213        let peer_reviews_received: i32 =
214        peer_or_self_review_submissions::count_peer_or_self_review_submissions_for_exercise_slide_submission(
215            &mut tx,
216            users_latest_submission.id,
217            &[giver_exercise_state.user_id],
218        )
219        .await?
220        .try_into()?;
221        let _peer_review_queue_entry = peer_review_queue_entries::upsert_peer_review_priority(
222            &mut tx,
223            giver_exercise_state.user_id,
224            giver_exercise_state.exercise_id,
225            giver_exercise_state.get_course_id()?,
226            peer_reviews_given,
227            users_latest_submission.id,
228            peer_reviews_received >= peer_or_self_review_config.peer_reviews_to_receive,
229        )
230        .await?;
231    }
232
233    let giver_exercise_state =
234        user_exercise_state_updater::update_user_exercise_state(&mut tx, giver_exercise_state.id)
235            .await?;
236
237    let exercise_slide_submission = exercise_slide_submissions::get_by_id(
238        &mut tx,
239        peer_review_submission.exercise_slide_submission_id,
240    )
241    .await?;
242    let receiver_peer_review_queue_entry =
243        peer_review_queue_entries::get_by_receiving_peer_reviews_exercise_slide_submission_id(
244            &mut tx,
245            exercise_slide_submission.id,
246        )
247        .await
248        .optional()?;
249    if let Some(entry) = receiver_peer_review_queue_entry {
250        // No need to update the user exercise state again if this is a self review
251        if entry.user_id != giver_exercise_state.user_id {
252            update_peer_review_receiver_exercise_status(
253                &mut tx,
254                exercise,
255                &peer_or_self_review_config,
256                entry,
257            )
258            .await?;
259        }
260    }
261    // Make it possible for the user to receive a new submission to review
262    crate::offered_answers_to_peer_review_temporary::delete_saved_submissions_for_user(
263        &mut tx,
264        exercise.id,
265        giver_exercise_state.user_id,
266    )
267    .await?;
268
269    tx.commit().await?;
270
271    Ok(giver_exercise_state)
272}
273
274/// Checks whether the exercise should be reset after peer or self review
275/// and performs the reset if needed.
276/// Called after the user's state has been updated post-review.
277///
278/// Returns true if reset was performed, otherwise false.
279pub async fn reset_exercise_if_needed_if_zero_points_from_review(
280    conn: &mut PgConnection,
281    peer_review_config: &PeerOrSelfReviewConfig,
282    user_exercise_state: &UserExerciseState,
283) -> ModelResult<bool> {
284    if peer_review_config.reset_answer_if_zero_points_from_review
285        && peer_review_config.processing_strategy
286            == PeerReviewProcessingStrategy::AutomaticallyGradeByAverage
287        && user_exercise_state.reviewing_stage
288            == crate::user_exercise_states::ReviewingStage::ReviewedAndLocked
289        && user_exercise_state
290            .score_given
291            .is_some_and(|score| score == 0.0)
292    {
293        let latest_submission =
294            crate::exercise_slide_submissions::try_to_get_users_latest_exercise_slide_submission(
295                conn,
296                user_exercise_state
297                    .selected_exercise_slide_id
298                    .ok_or_else(|| {
299                        ModelError::new(
300                            ModelErrorType::PreconditionFailed,
301                            "No selected exercise slide id found".to_string(),
302                            None,
303                        )
304                    })?,
305                user_exercise_state.user_id,
306            )
307            .await?;
308
309        if let Some(latest_submission) = latest_submission {
310            let mut tx = conn.begin().await?;
311
312            crate::exercises::reset_exercises_for_selected_users(
313                &mut tx,
314                &[(
315                    user_exercise_state.user_id,
316                    vec![latest_submission.exercise_id],
317                )],
318                None,
319                latest_submission.course_id.ok_or_else(|| {
320                    ModelError::new(
321                        ModelErrorType::Generic,
322                        "No course id for submission".to_string(),
323                        None,
324                    )
325                })?,
326                Some("automatic-reset-due-to-failed-review".to_string()),
327            )
328            .await?;
329
330            tx.commit().await?;
331
332            tracing::info!(
333                "Reset exercise {} for user {} due to 0 points from {:?}.",
334                latest_submission.exercise_id,
335                user_exercise_state.user_id,
336                peer_review_config.processing_strategy
337            );
338
339            return Ok(true);
340        }
341    }
342
343    Ok(false)
344}
345
346/// Filters submitted peer review answers to those that are part of the peer review.
347fn validate_and_sanitize_peer_review_submission_answers(
348    peer_or_self_review_questions: HashMap<Uuid, PeerOrSelfReviewQuestion>,
349    peer_review_submission_question_answers: Vec<CourseMaterialPeerOrSelfReviewQuestionAnswer>,
350) -> ModelResult<Vec<CourseMaterialPeerOrSelfReviewQuestionAnswer>> {
351    // Filter to valid answers (those with a matching question ID)
352    let valid_peer_review_question_answers: Vec<_> = peer_review_submission_question_answers
353        .into_iter()
354        .filter(|answer| {
355            peer_or_self_review_questions.contains_key(&answer.peer_or_self_review_question_id)
356        })
357        .collect();
358
359    // Get IDs of questions that have been answered
360    let answered_question_ids: std::collections::HashSet<_> = valid_peer_review_question_answers
361        .iter()
362        .map(|answer| answer.peer_or_self_review_question_id)
363        .collect();
364
365    // Check if any required question is unanswered
366    let has_unanswered_required_questions = peer_or_self_review_questions
367        .iter()
368        .any(|(id, question)| question.answer_required && !answered_question_ids.contains(id));
369
370    if has_unanswered_required_questions {
371        Err(ModelError::new(
372            ModelErrorType::PreconditionFailed,
373            "All required questions need to be answered.".to_string(),
374            None,
375        ))
376    } else {
377        // All required questions are answered
378        Ok(valid_peer_review_question_answers)
379    }
380}
381
382async fn update_peer_review_receiver_exercise_status(
383    conn: &mut PgConnection,
384    exercise: &Exercise,
385    peer_review: &PeerOrSelfReviewConfig,
386    peer_review_queue_entry: PeerReviewQueueEntry,
387) -> ModelResult<()> {
388    let peer_reviews_received =
389        peer_or_self_review_submissions::count_peer_or_self_review_submissions_for_exercise_slide_submission(
390            conn,
391            peer_review_queue_entry.receiving_peer_reviews_exercise_slide_submission_id,
392            &[peer_review_queue_entry.user_id],
393        )
394        .await?;
395    if peer_reviews_received >= peer_review.peer_reviews_to_receive.try_into()? {
396        // Only ever set this to true
397        let peer_review_queue_entry =
398            peer_review_queue_entries::update_received_enough_peer_reviews(
399                conn,
400                peer_review_queue_entry.id,
401                true,
402            )
403            .await?;
404        let user_exercise_state = user_exercise_states::get_user_exercise_state_if_exists(
405            conn,
406            peer_review_queue_entry.user_id,
407            peer_review_queue_entry.exercise_id,
408            CourseOrExamId::Course(peer_review_queue_entry.course_id),
409        )
410        .await?;
411        if let Some(user_exercise_state) = user_exercise_state {
412            let received_peer_or_self_review_question_submissions = crate::peer_or_self_review_question_submissions::get_received_question_submissions_for_exercise_slide_submission(conn, peer_review_queue_entry.receiving_peer_reviews_exercise_slide_submission_id).await?;
413            let _updated_user_exercise_state =
414            user_exercise_state_updater::update_user_exercise_state_with_some_already_loaded_data(
415                conn,
416                user_exercise_state.id,
417                UserExerciseStateUpdateAlreadyLoadedRequiredData {
418                    current_user_exercise_state: Some(user_exercise_state),
419                    exercise: Some(exercise.clone()),
420                    peer_or_self_review_information: Some(UserExerciseStateUpdateAlreadyLoadedRequiredDataPeerReviewInformation {
421                        peer_review_queue_entry: Some(Some(peer_review_queue_entry)),
422                        latest_exercise_slide_submission_received_peer_or_self_review_question_submissions:
423                            Some(received_peer_or_self_review_question_submissions),
424                        ..Default::default()
425                    }),
426                    ..Default::default()
427                },
428            )
429            .await?;
430        }
431    }
432    Ok(())
433}
434
435#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
436
437pub struct CourseMaterialPeerOrSelfReviewData {
438    /// If none, no answer was available for review.
439    pub answer_to_review: Option<CourseMaterialPeerOrSelfReviewDataAnswerToReview>,
440    pub peer_or_self_review_config: PeerOrSelfReviewConfig,
441    pub peer_or_self_review_questions: Vec<PeerOrSelfReviewQuestion>,
442
443    pub num_peer_reviews_given: i64,
444}
445
446#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
447
448pub struct CourseMaterialPeerOrSelfReviewDataAnswerToReview {
449    pub exercise_slide_submission_id: Uuid,
450    /// Uses the same type as we use when we render and exercise in course material. Allows us to reuse existing logic for getting all the necessary information for rendering the submission.
451    pub course_material_exercise_tasks: Vec<CourseMaterialExerciseTask>,
452}
453
454/// Tries to select a submission for user to peer review.
455///
456/// The selection process prioritizes peer review queue when selecting a submission for peer review.
457/// In the event where the queue is empty - in practice only when a course has just started - a random
458/// submission is selected instead. This function will only return `None` if no other user has made
459/// submissions for the specified exercise.
460pub async fn try_to_select_exercise_slide_submission_for_peer_review(
461    conn: &mut PgConnection,
462    exercise: &Exercise,
463    reviewer_user_exercise_state: &UserExerciseState,
464    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
465    file_store: &dyn FileStore,
466    app_conf: &ApplicationConfiguration,
467) -> ModelResult<CourseMaterialPeerOrSelfReviewData> {
468    let peer_or_self_review_config = peer_or_self_review_configs::get_by_exercise_or_course_id(
469        conn,
470        exercise,
471        exercise.get_course_id()?,
472    )
473    .await?;
474
475    let course_id = exercise.get_course_id()?;
476
477    // If an answer has been given within 1 hour to be reviewed and it still needs peer review, return the same one
478    if let Some(saved_exercise_slide_submission_to_review) = crate::offered_answers_to_peer_review_temporary::try_to_restore_previously_given_exercise_slide_submission(&mut *conn, exercise.id, reviewer_user_exercise_state.user_id, course_id).await? {
479        let data = get_course_material_peer_or_self_review_data(
480            conn,
481            &peer_or_self_review_config,
482            &Some(saved_exercise_slide_submission_to_review),
483            reviewer_user_exercise_state.user_id,
484            exercise.id,
485            fetch_service_info,
486            file_store,
487            app_conf,
488        )
489        .await?;
490
491        return Ok(data)
492    }
493
494    let mut excluded_exercise_slide_submission_ids =
495        peer_or_self_review_submissions::get_users_submission_ids_for_exercise_and_course_instance(
496            conn,
497            reviewer_user_exercise_state.user_id,
498            reviewer_user_exercise_state.exercise_id,
499            course_id,
500        )
501        .await?;
502    let reported_submissions =
503        crate::flagged_answers::get_flagged_answers_submission_ids_by_flaggers_id(
504            conn,
505            reviewer_user_exercise_state.user_id,
506        )
507        .await?;
508    excluded_exercise_slide_submission_ids.extend(reported_submissions);
509
510    let candidate_submission_id = try_to_select_peer_review_candidate_from_queue(
511        conn,
512        reviewer_user_exercise_state.exercise_id,
513        reviewer_user_exercise_state.user_id,
514        &excluded_exercise_slide_submission_ids,
515    )
516    .await?;
517    let exercise_slide_submission_to_review = match candidate_submission_id {
518        Some(exercise_slide_submission) => {
519            crate::offered_answers_to_peer_review_temporary::save_given_exercise_slide_submission(
520                &mut *conn,
521                exercise_slide_submission.id,
522                exercise.id,
523                reviewer_user_exercise_state.user_id,
524                course_id,
525            )
526            .await?;
527            Some(exercise_slide_submission)
528        }
529        None => {
530            // At the start of a course there can be a short period when there aren't any peer reviews.
531            // In that case just get a random one.
532            exercise_slide_submissions::try_to_get_random_filtered_by_user_and_submissions(
533                conn,
534                reviewer_user_exercise_state.exercise_id,
535                reviewer_user_exercise_state.user_id,
536                &excluded_exercise_slide_submission_ids,
537            )
538            .await?
539        }
540    };
541    let data = get_course_material_peer_or_self_review_data(
542        conn,
543        &peer_or_self_review_config,
544        &exercise_slide_submission_to_review,
545        reviewer_user_exercise_state.user_id,
546        exercise.id,
547        fetch_service_info,
548        file_store,
549        app_conf,
550    )
551    .await?;
552
553    Ok(data)
554}
555
556/// Selects a user's own submission to be self-reviewed. Works similarly to `try_to_select_exercise_slide_submission_for_peer_review` but selects the user's latest submission.
557pub async fn select_own_submission_for_self_review(
558    conn: &mut PgConnection,
559    exercise: &Exercise,
560    reviewer_user_exercise_state: &UserExerciseState,
561    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
562    file_store: &dyn FileStore,
563    app_conf: &ApplicationConfiguration,
564) -> ModelResult<CourseMaterialPeerOrSelfReviewData> {
565    let peer_or_self_review_config = peer_or_self_review_configs::get_by_exercise_or_course_id(
566        conn,
567        exercise,
568        exercise.get_course_id()?,
569    )
570    .await?;
571    let exercise_slide_submission =
572        exercise_slide_submissions::get_users_latest_exercise_slide_submission(
573            conn,
574            reviewer_user_exercise_state.get_selected_exercise_slide_id()?,
575            reviewer_user_exercise_state.user_id,
576        )
577        .await?;
578    let data = get_course_material_peer_or_self_review_data(
579        conn,
580        &peer_or_self_review_config,
581        &Some(exercise_slide_submission),
582        reviewer_user_exercise_state.user_id,
583        exercise.id,
584        fetch_service_info,
585        file_store,
586        app_conf,
587    )
588    .await?;
589
590    Ok(data)
591}
592
593async fn try_to_select_peer_review_candidate_from_queue(
594    conn: &mut PgConnection,
595    exercise_id: Uuid,
596    excluded_user_id: Uuid,
597    excluded_exercise_slide_submission_ids: &[Uuid],
598) -> ModelResult<Option<ExerciseSlideSubmission>> {
599    const MAX_ATTEMPTS: u32 = 10;
600    let mut attempts = 0;
601
602    // Loop until we either find a non deleted submission or we find no submission at all
603    while attempts < MAX_ATTEMPTS {
604        attempts += 1;
605        let maybe_submission = try_to_select_peer_review_candidate_from_queue_impl(
606            conn,
607            exercise_id,
608            excluded_user_id,
609            excluded_exercise_slide_submission_ids,
610        )
611        .await?;
612
613        if let Some((ess_id, selected_submission_needs_peer_review)) = maybe_submission {
614            if excluded_exercise_slide_submission_ids.contains(&ess_id) {
615                warn!(exercise_slide_submission_id = %ess_id, "Selected exercise slide submission that should have been excluded from the selection process. Trying again.");
616                continue;
617            }
618
619            let ess = exercise_slide_submissions::get_by_id(conn, ess_id)
620                .await
621                .optional()?;
622            if let Some(ess) = ess {
623                // Peer reviewing only works if there is a course_id in it.
624                if ess.course_id.is_none() {
625                    warn!(exercise_slide_submission_id = %ess_id, "Selected exercise slide submission that doesn't have a course_id. Skipping it.");
626                    continue;
627                };
628                if ess.deleted_at.is_none() {
629                    // Double check that the submission has not been removed from the queue.
630                    let peer_review_queue_entry = peer_review_queue_entries::get_by_receiving_peer_reviews_exercise_slide_submission_id(conn, ess_id).await?;
631                    // If we have selected a submission outside of the peer review queue, there is no need for double checking.
632                    if !selected_submission_needs_peer_review {
633                        return Ok(Some(ess));
634                    }
635                    if peer_review_queue_entry.deleted_at.is_none()
636                        && !peer_review_queue_entry.removed_from_queue_for_unusual_reason
637                    {
638                        return Ok(Some(ess));
639                    } else {
640                        if attempts == MAX_ATTEMPTS {
641                            warn!(exercise_slide_submission_id = %ess_id, deleted_at = ?peer_review_queue_entry.deleted_at, removed_from_queue = %peer_review_queue_entry.removed_from_queue_for_unusual_reason, "Max attempts reached, returning submission despite being removed from queue");
642                            return Ok(Some(ess));
643                        }
644                        warn!(exercise_slide_submission_id = %ess_id, deleted_at = ?peer_review_queue_entry.deleted_at, removed_from_queue = %peer_review_queue_entry.removed_from_queue_for_unusual_reason, "Selected exercise slide submission that was removed from the peer review queue. Trying again.");
645                        continue;
646                    }
647                }
648            } else {
649                // We found a submission from the peer reveiw queue but the submission was deleted. This is unfortunate since if
650                // the submission was deleted the peer review queue entry should have been deleted too. We can try to fix the situation somehow.
651                warn!(exercise_slide_submission_id = %ess_id, "Selected exercise slide submission that was deleted. The peer review queue entry should've been deleted too! Deleting it now.");
652                peer_review_queue_entries::delete_by_receiving_peer_reviews_exercise_slide_submission_id(
653                    conn, ess_id,
654                ).await?;
655                info!("Deleting done, trying to select a new peer review candidate");
656            }
657        } else {
658            // We didn't manage to select a candidate from the queue
659            return Ok(None);
660        }
661    }
662
663    warn!("Maximum attempts ({MAX_ATTEMPTS}) reached without finding a valid submission");
664    Ok(None)
665}
666
667/// Returns a tuple of the exercise slide submission id and a boolean indicating if the submission needs peer review.
668async fn try_to_select_peer_review_candidate_from_queue_impl(
669    conn: &mut PgConnection,
670    exercise_id: Uuid,
671    excluded_user_id: Uuid,
672    excluded_exercise_slide_submission_ids: &[Uuid],
673) -> ModelResult<Option<(Uuid, bool)>> {
674    let mut rng = rng();
675    // Try to get a candidate that needs reviews from queue.
676    let mut candidates = peer_review_queue_entries::get_many_that_need_peer_reviews_by_exercise_id_and_review_priority(conn,
677        exercise_id,
678        excluded_user_id,
679        excluded_exercise_slide_submission_ids,
680        MAX_PEER_REVIEW_CANDIDATES,
681    ).await?;
682    candidates.shuffle(&mut rng);
683    match candidates.into_iter().next() {
684        Some(candidate) => Ok(Some((
685            candidate.receiving_peer_reviews_exercise_slide_submission_id,
686            true,
687        ))),
688        None => {
689            // Try again for any queue entry.
690            let mut candidates = peer_review_queue_entries::get_any_including_not_needing_review(
691                conn,
692                exercise_id,
693                excluded_user_id,
694                excluded_exercise_slide_submission_ids,
695                MAX_PEER_REVIEW_CANDIDATES,
696            )
697            .await?;
698            candidates.shuffle(&mut rng);
699            Ok(candidates.into_iter().next().map(|entry| {
700                (
701                    entry.receiving_peer_reviews_exercise_slide_submission_id,
702                    false,
703                )
704            }))
705        }
706    }
707}
708
709#[allow(clippy::too_many_arguments)]
710async fn get_course_material_peer_or_self_review_data(
711    conn: &mut PgConnection,
712    peer_or_self_review_config: &PeerOrSelfReviewConfig,
713    exercise_slide_submission: &Option<ExerciseSlideSubmission>,
714    reviewer_user_id: Uuid,
715    exercise_id: Uuid,
716    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
717    file_store: &dyn FileStore,
718    app_conf: &ApplicationConfiguration,
719) -> ModelResult<CourseMaterialPeerOrSelfReviewData> {
720    let peer_or_self_review_questions =
721        peer_or_self_review_questions::get_all_by_peer_or_self_review_config_id(
722            conn,
723            peer_or_self_review_config.id,
724        )
725        .await?;
726    let num_peer_reviews_given =
727        peer_or_self_review_submissions::get_num_peer_reviews_given_by_user_and_course_instance_and_exercise(
728            conn,
729            reviewer_user_id,
730            peer_or_self_review_config.course_id,
731            exercise_id,
732        )
733        .await?;
734
735    let answer_to_review = match exercise_slide_submission {
736        Some(exercise_slide_submission) => {
737            let exercise_slide_submission_id = exercise_slide_submission.id;
738            let course_material_exercise_tasks = exercise_task_submissions::get_exercise_task_submission_info_by_exercise_slide_submission_id(
739                conn,
740                exercise_slide_submission_id,
741                reviewer_user_id,
742                 fetch_service_info,
743                 false,
744                 file_store,
745                 app_conf,
746            ).await?;
747            Some(CourseMaterialPeerOrSelfReviewDataAnswerToReview {
748                exercise_slide_submission_id,
749                course_material_exercise_tasks,
750            })
751        }
752        None => None,
753    };
754
755    Ok(CourseMaterialPeerOrSelfReviewData {
756        answer_to_review,
757        peer_or_self_review_config: peer_or_self_review_config.clone(),
758        peer_or_self_review_questions,
759        num_peer_reviews_given,
760    })
761}
762
763#[instrument(skip(conn))]
764pub async fn update_peer_review_queue_reviews_received(
765    conn: &mut PgConnection,
766    course_id: Uuid,
767) -> ModelResult<()> {
768    let mut tx = conn.begin().await?;
769    info!("Updating peer review queue reviews received");
770    let exercises = crate::exercises::get_exercises_by_course_id(&mut tx, course_id)
771        .await?
772        .into_iter()
773        .filter(|e| e.needs_peer_review)
774        .collect::<Vec<_>>();
775    for exercise in exercises {
776        info!("Processing exercise {:?}", exercise.id);
777        let peer_or_self_review_config = peer_or_self_review_configs::get_by_exercise_or_course_id(
778            &mut tx, &exercise, course_id,
779        )
780        .await?;
781        let peer_review_queue_entries =
782            crate::peer_review_queue_entries::get_all_that_need_peer_reviews_by_exercise_id(
783                &mut tx,
784                exercise.id,
785            )
786            .await?;
787        info!(
788            "Processing {:?} peer review queue entries",
789            peer_review_queue_entries.len()
790        );
791        for peer_review_queue_entry in peer_review_queue_entries {
792            update_peer_review_receiver_exercise_status(
793                &mut tx,
794                &exercise,
795                &peer_or_self_review_config,
796                peer_review_queue_entry,
797            )
798            .await?;
799        }
800    }
801    info!("Done");
802    tx.commit().await?;
803    Ok(())
804}
805
806#[cfg(test)]
807mod tests {
808    use super::*;
809
810    mod validate_peer_or_self_review_submissions_answers {
811        use chrono::TimeZone;
812
813        use crate::peer_or_self_review_questions::PeerOrSelfReviewQuestionType;
814
815        use super::*;
816
817        #[test]
818        fn accepts_valid_answers() {
819            let peer_or_self_review_config_id =
820                Uuid::parse_str("5f464818-1e68-4839-ae86-850b310f508c").unwrap();
821            let question_id = Uuid::parse_str("68d5cda3-6ad8-464b-9af1-bd1692fcbee1").unwrap();
822            let questions = HashMap::from([(
823                question_id,
824                create_peer_review_question(question_id, peer_or_self_review_config_id, true)
825                    .unwrap(),
826            )]);
827            let answers = vec![create_peer_review_answer(question_id)];
828            assert_eq!(
829                validate_and_sanitize_peer_review_submission_answers(questions, answers)
830                    .unwrap()
831                    .len(),
832                1
833            );
834        }
835
836        #[test]
837        fn filters_illegal_answers() {
838            let peer_or_self_review_config_id =
839                Uuid::parse_str("5f464818-1e68-4839-ae86-850b310f508c").unwrap();
840            let questions = HashMap::new();
841            let answers = vec![create_peer_review_answer(peer_or_self_review_config_id)];
842            assert_eq!(
843                validate_and_sanitize_peer_review_submission_answers(questions, answers)
844                    .unwrap()
845                    .len(),
846                0
847            );
848        }
849
850        #[test]
851        fn errors_on_missing_required_answers() {
852            let peer_or_self_review_config_id =
853                Uuid::parse_str("5f464818-1e68-4839-ae86-850b310f508c").unwrap();
854            let question_id = Uuid::parse_str("68d5cda3-6ad8-464b-9af1-bd1692fcbee1").unwrap();
855            let questions = HashMap::from([(
856                question_id,
857                create_peer_review_question(question_id, peer_or_self_review_config_id, true)
858                    .unwrap(),
859            )]);
860            assert!(
861                validate_and_sanitize_peer_review_submission_answers(questions, vec![]).is_err()
862            )
863        }
864
865        fn create_peer_review_question(
866            id: Uuid,
867            peer_or_self_review_config_id: Uuid,
868            answer_required: bool,
869        ) -> ModelResult<PeerOrSelfReviewQuestion> {
870            Ok(PeerOrSelfReviewQuestion {
871                id,
872                created_at: Utc.with_ymd_and_hms(2022, 1, 1, 0, 0, 0).unwrap(),
873                updated_at: Utc.with_ymd_and_hms(2022, 1, 1, 0, 0, 0).unwrap(),
874                deleted_at: None,
875                peer_or_self_review_config_id,
876                order_number: 0,
877                question: "".to_string(),
878                question_type: PeerOrSelfReviewQuestionType::Essay,
879                answer_required,
880                weight: 0.0,
881            })
882        }
883
884        fn create_peer_review_answer(
885            peer_or_self_review_question_id: Uuid,
886        ) -> CourseMaterialPeerOrSelfReviewQuestionAnswer {
887            CourseMaterialPeerOrSelfReviewQuestionAnswer {
888                peer_or_self_review_question_id,
889                text_data: Some("".to_string()),
890                number_data: None,
891            }
892        }
893    }
894}