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