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
30pub 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 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 peer_reviews_given > suspicious_amount_of_peer_reviews {
157 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 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 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 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 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
273pub 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
345fn 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 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 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 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 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 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 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 pub course_material_exercise_tasks: Vec<CourseMaterialExerciseTask>,
451}
452
453pub 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 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 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
549pub 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 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 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 let peer_review_queue_entry = peer_review_queue_entries::get_by_receiving_peer_reviews_exercise_slide_submission_id(conn, ess_id).await?;
620 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 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 return Ok(None);
649 }
650 }
651
652 warn!("Maximum attempts ({MAX_ATTEMPTS}) reached without finding a valid submission");
653 Ok(None)
654}
655
656async 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 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 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}