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