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