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},
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, CourseInstanceOrExamId, 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_instance_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_instance_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_instance_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_instance_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    tx.commit().await?;
268
269    Ok(giver_exercise_state)
270}
271
272/// Filters submitted peer review answers to those that are part of the peer review.
273fn validate_and_sanitize_peer_review_submission_answers(
274    peer_or_self_review_questions: HashMap<Uuid, PeerOrSelfReviewQuestion>,
275    peer_review_submission_question_answers: Vec<CourseMaterialPeerOrSelfReviewQuestionAnswer>,
276) -> ModelResult<Vec<CourseMaterialPeerOrSelfReviewQuestionAnswer>> {
277    // Filter to valid answers (those with a matching question ID)
278    let valid_peer_review_question_answers: Vec<_> = peer_review_submission_question_answers
279        .into_iter()
280        .filter(|answer| {
281            peer_or_self_review_questions.contains_key(&answer.peer_or_self_review_question_id)
282        })
283        .collect();
284
285    // Get IDs of questions that have been answered
286    let answered_question_ids: std::collections::HashSet<_> = valid_peer_review_question_answers
287        .iter()
288        .map(|answer| answer.peer_or_self_review_question_id)
289        .collect();
290
291    // Check if any required question is unanswered
292    let has_unanswered_required_questions = peer_or_self_review_questions
293        .iter()
294        .any(|(id, question)| question.answer_required && !answered_question_ids.contains(id));
295
296    if has_unanswered_required_questions {
297        Err(ModelError::new(
298            ModelErrorType::PreconditionFailed,
299            "All required questions need to be answered.".to_string(),
300            None,
301        ))
302    } else {
303        // All required questions are answered
304        Ok(valid_peer_review_question_answers)
305    }
306}
307
308async fn update_peer_review_receiver_exercise_status(
309    conn: &mut PgConnection,
310    exercise: &Exercise,
311    peer_review: &PeerOrSelfReviewConfig,
312    peer_review_queue_entry: PeerReviewQueueEntry,
313) -> ModelResult<()> {
314    let peer_reviews_received =
315        peer_or_self_review_submissions::count_peer_or_self_review_submissions_for_exercise_slide_submission(
316            conn,
317            peer_review_queue_entry.receiving_peer_reviews_exercise_slide_submission_id,
318            &[peer_review_queue_entry.user_id],
319        )
320        .await?;
321    if peer_reviews_received >= peer_review.peer_reviews_to_receive.try_into()? {
322        // Only ever set this to true
323        let peer_review_queue_entry =
324            peer_review_queue_entries::update_received_enough_peer_reviews(
325                conn,
326                peer_review_queue_entry.id,
327                true,
328            )
329            .await?;
330        let user_exercise_state = user_exercise_states::get_user_exercise_state_if_exists(
331            conn,
332            peer_review_queue_entry.user_id,
333            peer_review_queue_entry.exercise_id,
334            CourseInstanceOrExamId::Instance(peer_review_queue_entry.course_instance_id),
335        )
336        .await?;
337        if let Some(user_exercise_state) = user_exercise_state {
338            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?;
339            let _updated_user_exercise_state =
340            user_exercise_state_updater::update_user_exercise_state_with_some_already_loaded_data(
341                conn,
342                user_exercise_state.id,
343                UserExerciseStateUpdateAlreadyLoadedRequiredData {
344                    current_user_exercise_state: Some(user_exercise_state),
345                    exercise: Some(exercise.clone()),
346                    peer_or_self_review_information: Some(UserExerciseStateUpdateAlreadyLoadedRequiredDataPeerReviewInformation {
347                        peer_review_queue_entry: Some(Some(peer_review_queue_entry)),
348                        latest_exercise_slide_submission_received_peer_or_self_review_question_submissions:
349                            Some(received_peer_or_self_review_question_submissions),
350                        ..Default::default()
351                    }),
352                    ..Default::default()
353                },
354            )
355            .await?;
356        }
357    }
358    Ok(())
359}
360
361#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
362#[cfg_attr(feature = "ts_rs", derive(TS))]
363pub struct CourseMaterialPeerOrSelfReviewData {
364    /// If none, no answer was available for review.
365    pub answer_to_review: Option<CourseMaterialPeerOrSelfReviewDataAnswerToReview>,
366    pub peer_or_self_review_config: PeerOrSelfReviewConfig,
367    pub peer_or_self_review_questions: Vec<PeerOrSelfReviewQuestion>,
368    #[cfg_attr(feature = "ts_rs", ts(type = "number"))]
369    pub num_peer_reviews_given: i64,
370}
371
372#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
373#[cfg_attr(feature = "ts_rs", derive(TS))]
374pub struct CourseMaterialPeerOrSelfReviewDataAnswerToReview {
375    pub exercise_slide_submission_id: Uuid,
376    /// 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.
377    pub course_material_exercise_tasks: Vec<CourseMaterialExerciseTask>,
378}
379
380/// Tries to select a submission for user to peer review.
381///
382/// The selection process prioritizes peer review queue when selecting a submission for peer review.
383/// In the event where the queue is empty - in practice only when a course has just started - a random
384/// submission is selected instead. This function will only return `None` if no other user has made
385/// submissions for the specified exercise.
386pub async fn try_to_select_exercise_slide_submission_for_peer_review(
387    conn: &mut PgConnection,
388    exercise: &Exercise,
389    reviewer_user_exercise_state: &UserExerciseState,
390    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
391) -> ModelResult<CourseMaterialPeerOrSelfReviewData> {
392    let peer_or_self_review_config = peer_or_self_review_configs::get_by_exercise_or_course_id(
393        conn,
394        exercise,
395        exercise.get_course_id()?,
396    )
397    .await?;
398    let course_instance_id = reviewer_user_exercise_state.get_course_instance_id()?;
399
400    // If an answer has been given within 1 hour to be reviewed and it still needs peer review, return the same one
401    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_instance_id).await? {
402        let data = get_course_material_peer_or_self_review_data(
403            conn,
404            &peer_or_self_review_config,
405            &Some(saved_exercise_slide_submission_to_review),
406            reviewer_user_exercise_state.user_id,
407            course_instance_id,
408            exercise.id,
409            fetch_service_info,
410        )
411        .await?;
412
413        return Ok(data)
414    }
415
416    let mut excluded_exercise_slide_submission_ids =
417        peer_or_self_review_submissions::get_users_submission_ids_for_exercise_and_course_instance(
418            conn,
419            reviewer_user_exercise_state.user_id,
420            reviewer_user_exercise_state.exercise_id,
421            course_instance_id,
422        )
423        .await?;
424    let reported_submissions =
425        crate::flagged_answers::get_flagged_answers_submission_ids_by_flaggers_id(
426            conn,
427            reviewer_user_exercise_state.user_id,
428        )
429        .await?;
430    excluded_exercise_slide_submission_ids.extend(reported_submissions);
431
432    let candidate_submission_id = try_to_select_peer_review_candidate_from_queue(
433        conn,
434        reviewer_user_exercise_state.exercise_id,
435        reviewer_user_exercise_state.user_id,
436        &excluded_exercise_slide_submission_ids,
437    )
438    .await?;
439    let exercise_slide_submission_to_review = match candidate_submission_id {
440        Some(exercise_slide_submission) => {
441            crate::offered_answers_to_peer_review_temporary::save_given_exercise_slide_submission(
442                &mut *conn,
443                exercise_slide_submission.id,
444                exercise.id,
445                reviewer_user_exercise_state.user_id,
446                course_instance_id,
447            )
448            .await?;
449            Some(exercise_slide_submission)
450        }
451        None => {
452            // At the start of a course there can be a short period when there aren't any peer reviews.
453            // In that case just get a random one.
454            exercise_slide_submissions::try_to_get_random_filtered_by_user_and_submissions(
455                conn,
456                reviewer_user_exercise_state.exercise_id,
457                reviewer_user_exercise_state.user_id,
458                &excluded_exercise_slide_submission_ids,
459            )
460            .await?
461        }
462    };
463    let data = get_course_material_peer_or_self_review_data(
464        conn,
465        &peer_or_self_review_config,
466        &exercise_slide_submission_to_review,
467        reviewer_user_exercise_state.user_id,
468        course_instance_id,
469        exercise.id,
470        fetch_service_info,
471    )
472    .await?;
473
474    Ok(data)
475}
476
477/// 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.
478pub async fn select_own_submission_for_self_review(
479    conn: &mut PgConnection,
480    exercise: &Exercise,
481    reviewer_user_exercise_state: &UserExerciseState,
482    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
483) -> ModelResult<CourseMaterialPeerOrSelfReviewData> {
484    let peer_or_self_review_config = peer_or_self_review_configs::get_by_exercise_or_course_id(
485        conn,
486        exercise,
487        exercise.get_course_id()?,
488    )
489    .await?;
490    let course_instance_id = reviewer_user_exercise_state.get_course_instance_id()?;
491    let exercise_slide_submission =
492        exercise_slide_submissions::get_users_latest_exercise_slide_submission(
493            conn,
494            reviewer_user_exercise_state.get_selected_exercise_slide_id()?,
495            reviewer_user_exercise_state.user_id,
496        )
497        .await?;
498    let data = get_course_material_peer_or_self_review_data(
499        conn,
500        &peer_or_self_review_config,
501        &Some(exercise_slide_submission),
502        reviewer_user_exercise_state.user_id,
503        course_instance_id,
504        exercise.id,
505        fetch_service_info,
506    )
507    .await?;
508
509    Ok(data)
510}
511
512async fn try_to_select_peer_review_candidate_from_queue(
513    conn: &mut PgConnection,
514    exercise_id: Uuid,
515    excluded_user_id: Uuid,
516    excluded_exercise_slide_submission_ids: &[Uuid],
517) -> ModelResult<Option<ExerciseSlideSubmission>> {
518    const MAX_ATTEMPTS: u32 = 10;
519    let mut attempts = 0;
520
521    // Loop until we either find a non deleted submission or we find no submission at all
522    while attempts < MAX_ATTEMPTS {
523        attempts += 1;
524        let maybe_submission = try_to_select_peer_review_candidate_from_queue_impl(
525            conn,
526            exercise_id,
527            excluded_user_id,
528            excluded_exercise_slide_submission_ids,
529        )
530        .await?;
531
532        if let Some((ess_id, selected_submission_needs_peer_review)) = maybe_submission {
533            if excluded_exercise_slide_submission_ids.contains(&ess_id) {
534                warn!(exercise_slide_submission_id = %ess_id, "Selected exercise slide submission that should have been excluded from the selection process. Trying again.");
535                continue;
536            }
537
538            let ess = exercise_slide_submissions::get_by_id(conn, ess_id)
539                .await
540                .optional()?;
541            if let Some(ess) = ess {
542                // Peer reviewing only works if there is a course_id and a course instance id in it.
543                if ess.course_id.is_none() || ess.course_instance_id.is_none() {
544                    warn!(exercise_slide_submission_id = %ess_id, "Selected exercise slide submission that doesn't have a course_id or course_instance_id. Skipping it.");
545                    continue;
546                };
547                if ess.deleted_at.is_none() {
548                    // Double check that the submission has not been removed from the queue.
549                    let peer_review_queue_entry = peer_review_queue_entries::get_by_receiving_peer_reviews_exercise_slide_submission_id(conn, ess_id).await?;
550                    // If we have selected a submission outside of the peer review queue, there is no need for double checking.
551                    if !selected_submission_needs_peer_review {
552                        return Ok(Some(ess));
553                    }
554                    if peer_review_queue_entry.deleted_at.is_none()
555                        && !peer_review_queue_entry.removed_from_queue_for_unusual_reason
556                    {
557                        return Ok(Some(ess));
558                    } else {
559                        if attempts == MAX_ATTEMPTS {
560                            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");
561                            return Ok(Some(ess));
562                        }
563                        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.");
564                        continue;
565                    }
566                }
567            } else {
568                // We found a submission from the peer reveiw queue but the submission was deleted. This is unfortunate since if
569                // the submission was deleted the peer review queue entry should have been deleted too. We can try to fix the situation somehow.
570                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.");
571                peer_review_queue_entries::delete_by_receiving_peer_reviews_exercise_slide_submission_id(
572                    conn, ess_id,
573                ).await?;
574                info!("Deleting done, trying to select a new peer review candidate");
575            }
576        } else {
577            // We didn't manage to select a candidate from the queue
578            return Ok(None);
579        }
580    }
581
582    warn!("Maximum attempts ({MAX_ATTEMPTS}) reached without finding a valid submission");
583    Ok(None)
584}
585
586/// Returns a tuple of the exercise slide submission id and a boolean indicating if the submission needs peer review.
587async fn try_to_select_peer_review_candidate_from_queue_impl(
588    conn: &mut PgConnection,
589    exercise_id: Uuid,
590    excluded_user_id: Uuid,
591    excluded_exercise_slide_submission_ids: &[Uuid],
592) -> ModelResult<Option<(Uuid, bool)>> {
593    let mut rng = rng();
594    // Try to get a candidate that needs reviews from queue.
595    let mut candidates = peer_review_queue_entries::get_many_that_need_peer_reviews_by_exercise_id_and_review_priority(conn,
596        exercise_id,
597        excluded_user_id,
598        excluded_exercise_slide_submission_ids,
599        MAX_PEER_REVIEW_CANDIDATES,
600    ).await?;
601    candidates.shuffle(&mut rng);
602    match candidates.into_iter().next() {
603        Some(candidate) => Ok(Some((
604            candidate.receiving_peer_reviews_exercise_slide_submission_id,
605            true,
606        ))),
607        None => {
608            // Try again for any queue entry.
609            let mut candidates = peer_review_queue_entries::get_any_including_not_needing_review(
610                conn,
611                exercise_id,
612                excluded_user_id,
613                excluded_exercise_slide_submission_ids,
614                MAX_PEER_REVIEW_CANDIDATES,
615            )
616            .await?;
617            candidates.shuffle(&mut rng);
618            Ok(candidates.into_iter().next().map(|entry| {
619                (
620                    entry.receiving_peer_reviews_exercise_slide_submission_id,
621                    false,
622                )
623            }))
624        }
625    }
626}
627
628async fn get_course_material_peer_or_self_review_data(
629    conn: &mut PgConnection,
630    peer_or_self_review_config: &PeerOrSelfReviewConfig,
631    exercise_slide_submission: &Option<ExerciseSlideSubmission>,
632    reviewer_user_id: Uuid,
633    reviewer_course_instance_id: Uuid,
634    exercise_id: Uuid,
635    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
636) -> ModelResult<CourseMaterialPeerOrSelfReviewData> {
637    let peer_or_self_review_questions =
638        peer_or_self_review_questions::get_all_by_peer_or_self_review_config_id(
639            conn,
640            peer_or_self_review_config.id,
641        )
642        .await?;
643    let num_peer_reviews_given =
644        peer_or_self_review_submissions::get_num_peer_reviews_given_by_user_and_course_instance_and_exercise(
645            conn,
646            reviewer_user_id,
647            reviewer_course_instance_id,
648            exercise_id,
649        )
650        .await?;
651
652    let answer_to_review = match exercise_slide_submission {
653        Some(exercise_slide_submission) => {
654            let exercise_slide_submission_id = exercise_slide_submission.id;
655            let course_material_exercise_tasks = exercise_task_submissions::get_exercise_task_submission_info_by_exercise_slide_submission_id(
656                conn,
657                exercise_slide_submission_id,
658                reviewer_user_id,
659                 fetch_service_info
660            ).await?;
661            Some(CourseMaterialPeerOrSelfReviewDataAnswerToReview {
662                exercise_slide_submission_id,
663                course_material_exercise_tasks,
664            })
665        }
666        None => None,
667    };
668
669    Ok(CourseMaterialPeerOrSelfReviewData {
670        answer_to_review,
671        peer_or_self_review_config: peer_or_self_review_config.clone(),
672        peer_or_self_review_questions,
673        num_peer_reviews_given,
674    })
675}
676
677#[instrument(skip(conn))]
678pub async fn update_peer_review_queue_reviews_received(
679    conn: &mut PgConnection,
680    course_id: Uuid,
681) -> ModelResult<()> {
682    let mut tx = conn.begin().await?;
683    info!("Updating peer review queue reviews received");
684    let exercises = crate::exercises::get_exercises_by_course_id(&mut tx, course_id)
685        .await?
686        .into_iter()
687        .filter(|e| e.needs_peer_review)
688        .collect::<Vec<_>>();
689    for exercise in exercises {
690        info!("Processing exercise {:?}", exercise.id);
691        let peer_or_self_review_config = peer_or_self_review_configs::get_by_exercise_or_course_id(
692            &mut tx, &exercise, course_id,
693        )
694        .await?;
695        let peer_review_queue_entries =
696            crate::peer_review_queue_entries::get_all_that_need_peer_reviews_by_exercise_id(
697                &mut tx,
698                exercise.id,
699            )
700            .await?;
701        info!(
702            "Processing {:?} peer review queue entries",
703            peer_review_queue_entries.len()
704        );
705        for peer_review_queue_entry in peer_review_queue_entries {
706            update_peer_review_receiver_exercise_status(
707                &mut tx,
708                &exercise,
709                &peer_or_self_review_config,
710                peer_review_queue_entry,
711            )
712            .await?;
713        }
714    }
715    info!("Done");
716    tx.commit().await?;
717    Ok(())
718}
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723
724    mod validate_peer_or_self_review_submissions_answers {
725        use chrono::TimeZone;
726
727        use crate::peer_or_self_review_questions::PeerOrSelfReviewQuestionType;
728
729        use super::*;
730
731        #[test]
732        fn accepts_valid_answers() {
733            let peer_or_self_review_config_id =
734                Uuid::parse_str("5f464818-1e68-4839-ae86-850b310f508c").unwrap();
735            let question_id = Uuid::parse_str("68d5cda3-6ad8-464b-9af1-bd1692fcbee1").unwrap();
736            let questions = HashMap::from([(
737                question_id,
738                create_peer_review_question(question_id, peer_or_self_review_config_id, true)
739                    .unwrap(),
740            )]);
741            let answers = vec![create_peer_review_answer(question_id)];
742            assert_eq!(
743                validate_and_sanitize_peer_review_submission_answers(questions, answers)
744                    .unwrap()
745                    .len(),
746                1
747            );
748        }
749
750        #[test]
751        fn filters_illegal_answers() {
752            let peer_or_self_review_config_id =
753                Uuid::parse_str("5f464818-1e68-4839-ae86-850b310f508c").unwrap();
754            let questions = HashMap::new();
755            let answers = vec![create_peer_review_answer(peer_or_self_review_config_id)];
756            assert_eq!(
757                validate_and_sanitize_peer_review_submission_answers(questions, answers)
758                    .unwrap()
759                    .len(),
760                0
761            );
762        }
763
764        #[test]
765        fn errors_on_missing_required_answers() {
766            let peer_or_self_review_config_id =
767                Uuid::parse_str("5f464818-1e68-4839-ae86-850b310f508c").unwrap();
768            let question_id = Uuid::parse_str("68d5cda3-6ad8-464b-9af1-bd1692fcbee1").unwrap();
769            let questions = HashMap::from([(
770                question_id,
771                create_peer_review_question(question_id, peer_or_self_review_config_id, true)
772                    .unwrap(),
773            )]);
774            assert!(
775                validate_and_sanitize_peer_review_submission_answers(questions, vec![]).is_err()
776            )
777        }
778
779        fn create_peer_review_question(
780            id: Uuid,
781            peer_or_self_review_config_id: Uuid,
782            answer_required: bool,
783        ) -> ModelResult<PeerOrSelfReviewQuestion> {
784            Ok(PeerOrSelfReviewQuestion {
785                id,
786                created_at: Utc.with_ymd_and_hms(2022, 1, 1, 0, 0, 0).unwrap(),
787                updated_at: Utc.with_ymd_and_hms(2022, 1, 1, 0, 0, 0).unwrap(),
788                deleted_at: None,
789                peer_or_self_review_config_id,
790                order_number: 0,
791                question: "".to_string(),
792                question_type: PeerOrSelfReviewQuestionType::Essay,
793                answer_required,
794                weight: 0.0,
795            })
796        }
797
798        fn create_peer_review_answer(
799            peer_or_self_review_question_id: Uuid,
800        ) -> CourseMaterialPeerOrSelfReviewQuestionAnswer {
801            CourseMaterialPeerOrSelfReviewQuestionAnswer {
802                peer_or_self_review_question_id,
803                text_data: Some("".to_string()),
804                number_data: None,
805            }
806        }
807    }
808}