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) -> ModelResult<CourseMaterialPeerOrSelfReviewData> {
466    let peer_or_self_review_config = peer_or_self_review_configs::get_by_exercise_or_course_id(
467        conn,
468        exercise,
469        exercise.get_course_id()?,
470    )
471    .await?;
472
473    let course_id = exercise.get_course_id()?;
474
475    // If an answer has been given within 1 hour to be reviewed and it still needs peer review, return the same one
476    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? {
477        let data = get_course_material_peer_or_self_review_data(
478            conn,
479            &peer_or_self_review_config,
480            &Some(saved_exercise_slide_submission_to_review),
481            reviewer_user_exercise_state.user_id,
482            exercise.id,
483            fetch_service_info,
484        )
485        .await?;
486
487        return Ok(data)
488    }
489
490    let mut excluded_exercise_slide_submission_ids =
491        peer_or_self_review_submissions::get_users_submission_ids_for_exercise_and_course_instance(
492            conn,
493            reviewer_user_exercise_state.user_id,
494            reviewer_user_exercise_state.exercise_id,
495            course_id,
496        )
497        .await?;
498    let reported_submissions =
499        crate::flagged_answers::get_flagged_answers_submission_ids_by_flaggers_id(
500            conn,
501            reviewer_user_exercise_state.user_id,
502        )
503        .await?;
504    excluded_exercise_slide_submission_ids.extend(reported_submissions);
505
506    let candidate_submission_id = try_to_select_peer_review_candidate_from_queue(
507        conn,
508        reviewer_user_exercise_state.exercise_id,
509        reviewer_user_exercise_state.user_id,
510        &excluded_exercise_slide_submission_ids,
511    )
512    .await?;
513    let exercise_slide_submission_to_review = match candidate_submission_id {
514        Some(exercise_slide_submission) => {
515            crate::offered_answers_to_peer_review_temporary::save_given_exercise_slide_submission(
516                &mut *conn,
517                exercise_slide_submission.id,
518                exercise.id,
519                reviewer_user_exercise_state.user_id,
520                course_id,
521            )
522            .await?;
523            Some(exercise_slide_submission)
524        }
525        None => {
526            // At the start of a course there can be a short period when there aren't any peer reviews.
527            // In that case just get a random one.
528            exercise_slide_submissions::try_to_get_random_filtered_by_user_and_submissions(
529                conn,
530                reviewer_user_exercise_state.exercise_id,
531                reviewer_user_exercise_state.user_id,
532                &excluded_exercise_slide_submission_ids,
533            )
534            .await?
535        }
536    };
537    let data = get_course_material_peer_or_self_review_data(
538        conn,
539        &peer_or_self_review_config,
540        &exercise_slide_submission_to_review,
541        reviewer_user_exercise_state.user_id,
542        exercise.id,
543        fetch_service_info,
544    )
545    .await?;
546
547    Ok(data)
548}
549
550/// 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.
551pub async fn select_own_submission_for_self_review(
552    conn: &mut PgConnection,
553    exercise: &Exercise,
554    reviewer_user_exercise_state: &UserExerciseState,
555    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
556) -> ModelResult<CourseMaterialPeerOrSelfReviewData> {
557    let peer_or_self_review_config = peer_or_self_review_configs::get_by_exercise_or_course_id(
558        conn,
559        exercise,
560        exercise.get_course_id()?,
561    )
562    .await?;
563    let exercise_slide_submission =
564        exercise_slide_submissions::get_users_latest_exercise_slide_submission(
565            conn,
566            reviewer_user_exercise_state.get_selected_exercise_slide_id()?,
567            reviewer_user_exercise_state.user_id,
568        )
569        .await?;
570    let data = get_course_material_peer_or_self_review_data(
571        conn,
572        &peer_or_self_review_config,
573        &Some(exercise_slide_submission),
574        reviewer_user_exercise_state.user_id,
575        exercise.id,
576        fetch_service_info,
577    )
578    .await?;
579
580    Ok(data)
581}
582
583async fn try_to_select_peer_review_candidate_from_queue(
584    conn: &mut PgConnection,
585    exercise_id: Uuid,
586    excluded_user_id: Uuid,
587    excluded_exercise_slide_submission_ids: &[Uuid],
588) -> ModelResult<Option<ExerciseSlideSubmission>> {
589    const MAX_ATTEMPTS: u32 = 10;
590    let mut attempts = 0;
591
592    // Loop until we either find a non deleted submission or we find no submission at all
593    while attempts < MAX_ATTEMPTS {
594        attempts += 1;
595        let maybe_submission = try_to_select_peer_review_candidate_from_queue_impl(
596            conn,
597            exercise_id,
598            excluded_user_id,
599            excluded_exercise_slide_submission_ids,
600        )
601        .await?;
602
603        if let Some((ess_id, selected_submission_needs_peer_review)) = maybe_submission {
604            if excluded_exercise_slide_submission_ids.contains(&ess_id) {
605                warn!(exercise_slide_submission_id = %ess_id, "Selected exercise slide submission that should have been excluded from the selection process. Trying again.");
606                continue;
607            }
608
609            let ess = exercise_slide_submissions::get_by_id(conn, ess_id)
610                .await
611                .optional()?;
612            if let Some(ess) = ess {
613                // Peer reviewing only works if there is a course_id in it.
614                if ess.course_id.is_none() {
615                    warn!(exercise_slide_submission_id = %ess_id, "Selected exercise slide submission that doesn't have a course_id. Skipping it.");
616                    continue;
617                };
618                if ess.deleted_at.is_none() {
619                    // Double check that the submission has not been removed from the queue.
620                    let peer_review_queue_entry = peer_review_queue_entries::get_by_receiving_peer_reviews_exercise_slide_submission_id(conn, ess_id).await?;
621                    // If we have selected a submission outside of the peer review queue, there is no need for double checking.
622                    if !selected_submission_needs_peer_review {
623                        return Ok(Some(ess));
624                    }
625                    if peer_review_queue_entry.deleted_at.is_none()
626                        && !peer_review_queue_entry.removed_from_queue_for_unusual_reason
627                    {
628                        return Ok(Some(ess));
629                    } else {
630                        if attempts == MAX_ATTEMPTS {
631                            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");
632                            return Ok(Some(ess));
633                        }
634                        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.");
635                        continue;
636                    }
637                }
638            } else {
639                // We found a submission from the peer reveiw queue but the submission was deleted. This is unfortunate since if
640                // the submission was deleted the peer review queue entry should have been deleted too. We can try to fix the situation somehow.
641                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.");
642                peer_review_queue_entries::delete_by_receiving_peer_reviews_exercise_slide_submission_id(
643                    conn, ess_id,
644                ).await?;
645                info!("Deleting done, trying to select a new peer review candidate");
646            }
647        } else {
648            // We didn't manage to select a candidate from the queue
649            return Ok(None);
650        }
651    }
652
653    warn!("Maximum attempts ({MAX_ATTEMPTS}) reached without finding a valid submission");
654    Ok(None)
655}
656
657/// Returns a tuple of the exercise slide submission id and a boolean indicating if the submission needs peer review.
658async fn try_to_select_peer_review_candidate_from_queue_impl(
659    conn: &mut PgConnection,
660    exercise_id: Uuid,
661    excluded_user_id: Uuid,
662    excluded_exercise_slide_submission_ids: &[Uuid],
663) -> ModelResult<Option<(Uuid, bool)>> {
664    let mut rng = rng();
665    // Try to get a candidate that needs reviews from queue.
666    let mut candidates = peer_review_queue_entries::get_many_that_need_peer_reviews_by_exercise_id_and_review_priority(conn,
667        exercise_id,
668        excluded_user_id,
669        excluded_exercise_slide_submission_ids,
670        MAX_PEER_REVIEW_CANDIDATES,
671    ).await?;
672    candidates.shuffle(&mut rng);
673    match candidates.into_iter().next() {
674        Some(candidate) => Ok(Some((
675            candidate.receiving_peer_reviews_exercise_slide_submission_id,
676            true,
677        ))),
678        None => {
679            // Try again for any queue entry.
680            let mut candidates = peer_review_queue_entries::get_any_including_not_needing_review(
681                conn,
682                exercise_id,
683                excluded_user_id,
684                excluded_exercise_slide_submission_ids,
685                MAX_PEER_REVIEW_CANDIDATES,
686            )
687            .await?;
688            candidates.shuffle(&mut rng);
689            Ok(candidates.into_iter().next().map(|entry| {
690                (
691                    entry.receiving_peer_reviews_exercise_slide_submission_id,
692                    false,
693                )
694            }))
695        }
696    }
697}
698
699async fn get_course_material_peer_or_self_review_data(
700    conn: &mut PgConnection,
701    peer_or_self_review_config: &PeerOrSelfReviewConfig,
702    exercise_slide_submission: &Option<ExerciseSlideSubmission>,
703    reviewer_user_id: Uuid,
704    exercise_id: Uuid,
705    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
706) -> ModelResult<CourseMaterialPeerOrSelfReviewData> {
707    let peer_or_self_review_questions =
708        peer_or_self_review_questions::get_all_by_peer_or_self_review_config_id(
709            conn,
710            peer_or_self_review_config.id,
711        )
712        .await?;
713    let num_peer_reviews_given =
714        peer_or_self_review_submissions::get_num_peer_reviews_given_by_user_and_course_instance_and_exercise(
715            conn,
716            reviewer_user_id,
717            peer_or_self_review_config.course_id,
718            exercise_id,
719        )
720        .await?;
721
722    let answer_to_review = match exercise_slide_submission {
723        Some(exercise_slide_submission) => {
724            let exercise_slide_submission_id = exercise_slide_submission.id;
725            let course_material_exercise_tasks = exercise_task_submissions::get_exercise_task_submission_info_by_exercise_slide_submission_id(
726                conn,
727                exercise_slide_submission_id,
728                reviewer_user_id,
729                 fetch_service_info,
730                 false
731            ).await?;
732            Some(CourseMaterialPeerOrSelfReviewDataAnswerToReview {
733                exercise_slide_submission_id,
734                course_material_exercise_tasks,
735            })
736        }
737        None => None,
738    };
739
740    Ok(CourseMaterialPeerOrSelfReviewData {
741        answer_to_review,
742        peer_or_self_review_config: peer_or_self_review_config.clone(),
743        peer_or_self_review_questions,
744        num_peer_reviews_given,
745    })
746}
747
748#[instrument(skip(conn))]
749pub async fn update_peer_review_queue_reviews_received(
750    conn: &mut PgConnection,
751    course_id: Uuid,
752) -> ModelResult<()> {
753    let mut tx = conn.begin().await?;
754    info!("Updating peer review queue reviews received");
755    let exercises = crate::exercises::get_exercises_by_course_id(&mut tx, course_id)
756        .await?
757        .into_iter()
758        .filter(|e| e.needs_peer_review)
759        .collect::<Vec<_>>();
760    for exercise in exercises {
761        info!("Processing exercise {:?}", exercise.id);
762        let peer_or_self_review_config = peer_or_self_review_configs::get_by_exercise_or_course_id(
763            &mut tx, &exercise, course_id,
764        )
765        .await?;
766        let peer_review_queue_entries =
767            crate::peer_review_queue_entries::get_all_that_need_peer_reviews_by_exercise_id(
768                &mut tx,
769                exercise.id,
770            )
771            .await?;
772        info!(
773            "Processing {:?} peer review queue entries",
774            peer_review_queue_entries.len()
775        );
776        for peer_review_queue_entry in peer_review_queue_entries {
777            update_peer_review_receiver_exercise_status(
778                &mut tx,
779                &exercise,
780                &peer_or_self_review_config,
781                peer_review_queue_entry,
782            )
783            .await?;
784        }
785    }
786    info!("Done");
787    tx.commit().await?;
788    Ok(())
789}
790
791#[cfg(test)]
792mod tests {
793    use super::*;
794
795    mod validate_peer_or_self_review_submissions_answers {
796        use chrono::TimeZone;
797
798        use crate::peer_or_self_review_questions::PeerOrSelfReviewQuestionType;
799
800        use super::*;
801
802        #[test]
803        fn accepts_valid_answers() {
804            let peer_or_self_review_config_id =
805                Uuid::parse_str("5f464818-1e68-4839-ae86-850b310f508c").unwrap();
806            let question_id = Uuid::parse_str("68d5cda3-6ad8-464b-9af1-bd1692fcbee1").unwrap();
807            let questions = HashMap::from([(
808                question_id,
809                create_peer_review_question(question_id, peer_or_self_review_config_id, true)
810                    .unwrap(),
811            )]);
812            let answers = vec![create_peer_review_answer(question_id)];
813            assert_eq!(
814                validate_and_sanitize_peer_review_submission_answers(questions, answers)
815                    .unwrap()
816                    .len(),
817                1
818            );
819        }
820
821        #[test]
822        fn filters_illegal_answers() {
823            let peer_or_self_review_config_id =
824                Uuid::parse_str("5f464818-1e68-4839-ae86-850b310f508c").unwrap();
825            let questions = HashMap::new();
826            let answers = vec![create_peer_review_answer(peer_or_self_review_config_id)];
827            assert_eq!(
828                validate_and_sanitize_peer_review_submission_answers(questions, answers)
829                    .unwrap()
830                    .len(),
831                0
832            );
833        }
834
835        #[test]
836        fn errors_on_missing_required_answers() {
837            let peer_or_self_review_config_id =
838                Uuid::parse_str("5f464818-1e68-4839-ae86-850b310f508c").unwrap();
839            let question_id = Uuid::parse_str("68d5cda3-6ad8-464b-9af1-bd1692fcbee1").unwrap();
840            let questions = HashMap::from([(
841                question_id,
842                create_peer_review_question(question_id, peer_or_self_review_config_id, true)
843                    .unwrap(),
844            )]);
845            assert!(
846                validate_and_sanitize_peer_review_submission_answers(questions, vec![]).is_err()
847            )
848        }
849
850        fn create_peer_review_question(
851            id: Uuid,
852            peer_or_self_review_config_id: Uuid,
853            answer_required: bool,
854        ) -> ModelResult<PeerOrSelfReviewQuestion> {
855            Ok(PeerOrSelfReviewQuestion {
856                id,
857                created_at: Utc.with_ymd_and_hms(2022, 1, 1, 0, 0, 0).unwrap(),
858                updated_at: Utc.with_ymd_and_hms(2022, 1, 1, 0, 0, 0).unwrap(),
859                deleted_at: None,
860                peer_or_self_review_config_id,
861                order_number: 0,
862                question: "".to_string(),
863                question_type: PeerOrSelfReviewQuestionType::Essay,
864                answer_required,
865                weight: 0.0,
866            })
867        }
868
869        fn create_peer_review_answer(
870            peer_or_self_review_question_id: Uuid,
871        ) -> CourseMaterialPeerOrSelfReviewQuestionAnswer {
872            CourseMaterialPeerOrSelfReviewQuestionAnswer {
873                peer_or_self_review_question_id,
874                text_data: Some("".to_string()),
875                number_data: None,
876            }
877        }
878    }
879}