1use futures::future::BoxFuture;
2use url::Url;
3use utoipa::ToSchema;
4
5use crate::{
6 exercise_service_info::ExerciseServiceInfoApi,
7 exercises::{self, Exercise},
8 library::{self, peer_or_self_reviewing::CourseMaterialPeerOrSelfReviewData},
9 peer_or_self_review_questions::{
10 CmsPeerOrSelfReviewQuestion,
11 delete_peer_or_self_review_questions_by_peer_or_self_review_config_ids,
12 upsert_multiple_peer_or_self_review_questions,
13 },
14 prelude::*,
15 user_exercise_states::{self, ReviewingStage},
16};
17
18#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
19
20pub struct PeerOrSelfReviewConfig {
21 pub id: Uuid,
22 pub created_at: DateTime<Utc>,
23 pub updated_at: DateTime<Utc>,
24 pub deleted_at: Option<DateTime<Utc>>,
25 pub course_id: Uuid,
26 pub exercise_id: Option<Uuid>,
27 pub peer_reviews_to_give: i32,
28 pub peer_reviews_to_receive: i32,
29 pub accepting_threshold: f32,
30 pub processing_strategy: PeerReviewProcessingStrategy,
31 pub manual_review_cutoff_in_days: i32,
32 pub points_are_all_or_nothing: bool,
33 pub reset_answer_if_zero_points_from_review: bool,
34 pub review_instructions: Option<serde_json::Value>,
35}
36
37#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
39
40pub struct CourseMaterialPeerOrSelfReviewConfig {
41 pub id: Uuid,
42 pub course_id: Uuid,
43 pub exercise_id: Option<Uuid>,
44 pub peer_reviews_to_give: i32,
45 pub peer_reviews_to_receive: i32,
46}
47
48#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
49
50pub struct CmsPeerOrSelfReviewConfig {
51 pub id: Uuid,
52 pub course_id: Uuid,
53 pub exercise_id: Option<Uuid>,
54 pub peer_reviews_to_give: i32,
55 pub peer_reviews_to_receive: i32,
56 pub accepting_threshold: f32,
57 pub processing_strategy: PeerReviewProcessingStrategy,
58 pub points_are_all_or_nothing: bool,
59 pub reset_answer_if_zero_points_from_review: bool,
60 pub review_instructions: Option<serde_json::Value>,
61}
62
63#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
64
65pub struct CmsPeerOrSelfReviewConfiguration {
66 pub peer_or_self_review_config: CmsPeerOrSelfReviewConfig,
67 pub peer_or_self_review_questions: Vec<CmsPeerOrSelfReviewQuestion>,
68}
69
70#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, sqlx::Type, ToSchema)]
76#[sqlx(
77 type_name = "peer_review_processing_strategy",
78 rename_all = "snake_case"
79)]
80pub enum PeerReviewProcessingStrategy {
81 AutomaticallyGradeByAverage,
83 AutomaticallyGradeOrManualReviewByAverage,
85 ManualReviewEverything,
87}
88
89pub async fn insert(
90 conn: &mut PgConnection,
91 pkey_policy: PKeyPolicy<Uuid>,
92 course_id: Uuid,
93 exercise_id: Option<Uuid>,
94) -> ModelResult<Uuid> {
95 let res = sqlx::query!(
96 "
97INSERT INTO peer_or_self_review_configs (id, course_id, exercise_id)
98VALUES ($1, $2, $3)
99RETURNING *
100 ",
101 pkey_policy.into_uuid(),
102 course_id,
103 exercise_id,
104 )
105 .fetch_one(conn)
106 .await?;
107 Ok(res.id)
108}
109
110pub async fn upsert_with_id(
111 conn: &mut PgConnection,
112 pkey_policy: PKeyPolicy<Uuid>,
113 cms_peer_review: &CmsPeerOrSelfReviewConfig,
114) -> ModelResult<CmsPeerOrSelfReviewConfig> {
115 let res = sqlx::query_as!(
116 CmsPeerOrSelfReviewConfig,
117 r#"
118 INSERT INTO peer_or_self_review_configs (
119 id,
120 course_id,
121 exercise_id,
122 peer_reviews_to_give,
123 peer_reviews_to_receive,
124 accepting_threshold,
125 processing_strategy,
126 points_are_all_or_nothing,
127 review_instructions,
128 reset_answer_if_zero_points_from_review
129 )
130VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (id) DO
131UPDATE
132SET course_id = excluded.course_id,
133 exercise_id = excluded.exercise_id,
134 peer_reviews_to_give = excluded.peer_reviews_to_give,
135 peer_reviews_to_receive = excluded.peer_reviews_to_receive,
136 accepting_threshold = excluded.accepting_threshold,
137 processing_strategy = excluded.processing_strategy,
138 points_are_all_or_nothing = excluded.points_are_all_or_nothing,
139 reset_answer_if_zero_points_from_review = excluded.reset_answer_if_zero_points_from_review,
140 review_instructions = excluded.review_instructions
141RETURNING id,
142 course_id,
143 exercise_id,
144 peer_reviews_to_give,
145 peer_reviews_to_receive,
146 accepting_threshold,
147 processing_strategy,
148 points_are_all_or_nothing,
149 review_instructions,
150 reset_answer_if_zero_points_from_review
151"#,
152 pkey_policy.into_uuid(),
153 cms_peer_review.course_id,
154 cms_peer_review.exercise_id,
155 cms_peer_review.peer_reviews_to_give,
156 cms_peer_review.peer_reviews_to_receive,
157 cms_peer_review.accepting_threshold,
158 cms_peer_review.processing_strategy as _,
159 cms_peer_review.points_are_all_or_nothing,
160 cms_peer_review.review_instructions,
161 cms_peer_review.reset_answer_if_zero_points_from_review,
162 )
163 .fetch_one(conn)
164 .await?;
165 Ok(res)
166}
167
168pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<PeerOrSelfReviewConfig> {
169 let res = sqlx::query_as!(
170 PeerOrSelfReviewConfig,
171 r#"
172SELECT *
173FROM peer_or_self_review_configs
174WHERE id = $1
175 AND deleted_at IS NULL
176 "#,
177 id
178 )
179 .fetch_one(conn)
180 .await?;
181 Ok(res)
182}
183
184pub async fn get_by_exercise_id(
186 conn: &mut PgConnection,
187 exercise_id: Uuid,
188) -> ModelResult<PeerOrSelfReviewConfig> {
189 let res = sqlx::query_as!(
190 PeerOrSelfReviewConfig,
191 r#"
192SELECT *
193FROM peer_or_self_review_configs
194WHERE exercise_id = $1
195 AND deleted_at IS NULL
196 "#,
197 exercise_id
198 )
199 .fetch_one(conn)
200 .await?;
201 Ok(res)
202}
203
204pub async fn get_by_exercise_or_course_id(
206 conn: &mut PgConnection,
207 exercise: &Exercise,
208 course_id: Uuid,
209) -> ModelResult<PeerOrSelfReviewConfig> {
210 if exercise.use_course_default_peer_or_self_review_config {
211 get_default_for_course_by_course_id(conn, course_id).await
212 } else {
213 get_by_exercise_id(conn, exercise.id).await
214 }
215}
216
217pub async fn get_default_for_course_by_course_id(
218 conn: &mut PgConnection,
219 course_id: Uuid,
220) -> ModelResult<PeerOrSelfReviewConfig> {
221 let res = sqlx::query_as!(
222 PeerOrSelfReviewConfig,
223 r#"
224SELECT *
225FROM peer_or_self_review_configs
226WHERE course_id = $1
227 AND exercise_id IS NULL
228 AND deleted_at IS NULL;
229 "#,
230 course_id
231 )
232 .fetch_one(conn)
233 .await?;
234 Ok(res)
235}
236
237pub async fn delete(conn: &mut PgConnection, id: Uuid) -> ModelResult<Uuid> {
238 let res = sqlx::query!(
239 "
240UPDATE peer_or_self_review_configs
241SET deleted_at = now()
242WHERE id = $1
243AND deleted_at IS NULL
244RETURNING *
245 ",
246 id
247 )
248 .fetch_one(conn)
249 .await?;
250 Ok(res.id)
251}
252
253pub async fn get_course_material_peer_or_self_review_data(
254 conn: &mut PgConnection,
255 user_id: Uuid,
256 exercise_id: Uuid,
257 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
258) -> ModelResult<CourseMaterialPeerOrSelfReviewData> {
259 let exercise = exercises::get_by_id(conn, exercise_id).await?;
260 let (_current_exercise_slide, instance_or_exam_id) = exercises::get_or_select_exercise_slide(
261 &mut *conn,
262 Some(user_id),
263 &exercise,
264 &fetch_service_info,
265 )
266 .await?;
267
268 let user_exercise_state = match instance_or_exam_id {
269 Some(course_or_exam_id) => {
270 user_exercise_states::get_user_exercise_state_if_exists(
271 conn,
272 user_id,
273 exercise.id,
274 course_or_exam_id,
275 )
276 .await?
277 }
278 _ => None,
279 };
280
281 match user_exercise_state {
282 Some(ref user_exercise_state) => {
283 if matches!(
284 user_exercise_state.reviewing_stage,
285 ReviewingStage::PeerReview | ReviewingStage::WaitingForPeerReviews
286 ) {
287 let res = library::peer_or_self_reviewing::try_to_select_exercise_slide_submission_for_peer_review(
290 conn,
291 &exercise,
292 user_exercise_state,
293 &fetch_service_info
294 )
295 .await?;
296 Ok(res)
297 } else if user_exercise_state.reviewing_stage == ReviewingStage::SelfReview {
298 let res = library::peer_or_self_reviewing::select_own_submission_for_self_review(
299 conn,
300 &exercise,
301 user_exercise_state,
302 &fetch_service_info,
303 )
304 .await?;
305 Ok(res)
306 } else {
307 Err(ModelError::new(
308 ModelErrorType::PreconditionFailed,
309 "You cannot peer review yet".to_string(),
310 None,
311 ))
312 }
313 }
314 None => Err(ModelError::new(
315 ModelErrorType::InvalidRequest,
316 "You haven't answered this exercise".to_string(),
317 None,
318 )),
319 }
320}
321
322pub async fn get_peer_reviews_by_page_id(
323 conn: &mut PgConnection,
324 page_id: Uuid,
325) -> ModelResult<Vec<CmsPeerOrSelfReviewConfig>> {
326 let res = sqlx::query_as!(
327 CmsPeerOrSelfReviewConfig,
328 r#"
329SELECT pr.id as id,
330 pr.course_id as course_id,
331 pr.exercise_id as exercise_id,
332 pr.peer_reviews_to_give as peer_reviews_to_give,
333 pr.peer_reviews_to_receive as peer_reviews_to_receive,
334 pr.accepting_threshold as accepting_threshold,
335 pr.processing_strategy,
336 points_are_all_or_nothing,
337 pr.reset_answer_if_zero_points_from_review,
338 pr.review_instructions
339from pages p
340 join exercises e on p.id = e.page_id
341 join peer_or_self_review_configs pr on e.id = pr.exercise_id
342where p.id = $1
343 AND p.deleted_at IS NULL
344 AND e.deleted_at IS NULL
345 AND pr.deleted_at IS NULL;
346 "#,
347 page_id,
348 )
349 .fetch_all(conn)
350 .await?;
351
352 Ok(res)
353}
354
355pub async fn delete_peer_reviews_by_exrcise_ids(
356 conn: &mut PgConnection,
357 exercise_ids: &[Uuid],
358) -> ModelResult<Vec<Uuid>> {
359 let res = sqlx::query!(
360 "
361UPDATE peer_or_self_review_configs
362SET deleted_at = now()
363WHERE exercise_id = ANY ($1)
364AND deleted_at IS NULL
365RETURNING *;
366 ",
367 exercise_ids
368 )
369 .fetch_all(conn)
370 .await?
371 .into_iter()
372 .map(|x| x.id)
373 .collect();
374 Ok(res)
375}
376
377pub async fn get_course_default_cms_peer_review(
378 conn: &mut PgConnection,
379 course_id: Uuid,
380) -> ModelResult<CmsPeerOrSelfReviewConfig> {
381 let res = sqlx::query_as!(
382 CmsPeerOrSelfReviewConfig,
383 r#"
384SELECT id,
385 course_id,
386 exercise_id,
387 peer_reviews_to_give,
388 peer_reviews_to_receive,
389 accepting_threshold,
390 processing_strategy,
391 points_are_all_or_nothing,
392 reset_answer_if_zero_points_from_review,
393 review_instructions
394FROM peer_or_self_review_configs
395WHERE course_id = $1
396 AND exercise_id IS NULL
397 AND deleted_at IS NULL;
398"#,
399 course_id
400 )
401 .fetch_one(conn)
402 .await?;
403 Ok(res)
404}
405
406pub async fn get_cms_peer_review_by_id(
407 conn: &mut PgConnection,
408 peer_or_self_review_config_id: Uuid,
409) -> ModelResult<CmsPeerOrSelfReviewConfig> {
410 let res = sqlx::query_as!(
411 CmsPeerOrSelfReviewConfig,
412 r#"
413SELECT id,
414 course_id,
415 exercise_id,
416 peer_reviews_to_give,
417 peer_reviews_to_receive,
418 accepting_threshold,
419 processing_strategy,
420 points_are_all_or_nothing,
421 reset_answer_if_zero_points_from_review,
422 review_instructions
423FROM peer_or_self_review_configs
424WHERE id = $1;
425 "#,
426 peer_or_self_review_config_id
427 )
428 .fetch_one(conn)
429 .await?;
430 Ok(res)
431}
432
433pub async fn upsert_course_default_cms_peer_review_and_questions(
434 conn: &mut PgConnection,
435 peer_or_self_review_configuration: &CmsPeerOrSelfReviewConfiguration,
436) -> ModelResult<CmsPeerOrSelfReviewConfiguration> {
437 let peer_or_self_review_config = upsert_with_id(
439 conn,
440 PKeyPolicy::Fixed(
441 peer_or_self_review_configuration
442 .peer_or_self_review_config
443 .id,
444 ),
445 &peer_or_self_review_configuration.peer_or_self_review_config,
446 )
447 .await?;
448
449 let previous_peer_or_self_review_question_ids =
451 delete_peer_or_self_review_questions_by_peer_or_self_review_config_ids(
452 conn,
453 &[peer_or_self_review_config.id],
454 )
455 .await?;
456 let peer_or_self_review_questions = upsert_multiple_peer_or_self_review_questions(
457 conn,
458 &peer_or_self_review_configuration
459 .peer_or_self_review_questions
460 .iter()
461 .map(|prq| {
462 let id = if previous_peer_or_self_review_question_ids.contains(&prq.id) {
463 prq.id
464 } else {
465 Uuid::new_v4()
466 };
467 CmsPeerOrSelfReviewQuestion { id, ..prq.clone() }
468 })
469 .collect::<Vec<_>>(),
470 )
471 .await?;
472
473 Ok(CmsPeerOrSelfReviewConfiguration {
474 peer_or_self_review_config,
475 peer_or_self_review_questions,
476 })
477}
478
479pub async fn upsert_for_course_id(
480 conn: &mut PgConnection,
481 course_id: Uuid,
482 peer_or_self_review_configuration: &CmsPeerOrSelfReviewConfiguration,
483) -> ModelResult<CmsPeerOrSelfReviewConfiguration> {
484 let input = &peer_or_self_review_configuration.peer_or_self_review_config;
485 if input.course_id != course_id {
486 return Err(model_err!(
487 PreconditionFailed,
488 "Peer review config course does not match expected course".to_string()
489 ));
490 }
491 if peer_or_self_review_configuration
492 .peer_or_self_review_questions
493 .iter()
494 .any(|q| q.peer_or_self_review_config_id != input.id)
495 {
496 return Err(model_err!(
497 PreconditionFailed,
498 "Peer review questions do not belong to the peer review config".to_string()
499 ));
500 }
501
502 let mut tx = conn.begin().await?;
503 let peer_or_self_review_config = sqlx::query_as!(
504 CmsPeerOrSelfReviewConfig,
505 r#"
506INSERT INTO peer_or_self_review_configs (
507 id,
508 course_id,
509 exercise_id,
510 peer_reviews_to_give,
511 peer_reviews_to_receive,
512 accepting_threshold,
513 processing_strategy,
514 points_are_all_or_nothing,
515 review_instructions,
516 reset_answer_if_zero_points_from_review
517)
518SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10
519WHERE (
520 $3::uuid IS NULL
521 OR EXISTS (
522 SELECT 1
523 FROM exercises
524 WHERE id = $3
525 AND course_id = $2
526 AND deleted_at IS NULL
527 )
528)
529ON CONFLICT (id) DO UPDATE
530SET course_id = excluded.course_id,
531 exercise_id = excluded.exercise_id,
532 peer_reviews_to_give = excluded.peer_reviews_to_give,
533 peer_reviews_to_receive = excluded.peer_reviews_to_receive,
534 accepting_threshold = excluded.accepting_threshold,
535 processing_strategy = excluded.processing_strategy,
536 points_are_all_or_nothing = excluded.points_are_all_or_nothing,
537 reset_answer_if_zero_points_from_review = excluded.reset_answer_if_zero_points_from_review,
538 review_instructions = excluded.review_instructions,
539 deleted_at = NULL
540WHERE peer_or_self_review_configs.course_id = $2
541RETURNING id,
542 course_id,
543 exercise_id,
544 peer_reviews_to_give,
545 peer_reviews_to_receive,
546 accepting_threshold,
547 processing_strategy,
548 points_are_all_or_nothing,
549 review_instructions,
550 reset_answer_if_zero_points_from_review
551 "#,
552 input.id,
553 course_id,
554 input.exercise_id,
555 input.peer_reviews_to_give,
556 input.peer_reviews_to_receive,
557 input.accepting_threshold,
558 input.processing_strategy as _,
559 input.points_are_all_or_nothing,
560 input.review_instructions,
561 input.reset_answer_if_zero_points_from_review,
562 )
563 .fetch_optional(&mut *tx)
564 .await?;
565 let Some(peer_or_self_review_config) = peer_or_self_review_config else {
566 return Err(model_err!(
567 PreconditionFailed,
568 "Peer review config exercise does not belong to the expected course".to_string()
569 ));
570 };
571
572 let previous_peer_or_self_review_question_ids =
573 delete_peer_or_self_review_questions_by_peer_or_self_review_config_ids(
574 &mut tx,
575 &[peer_or_self_review_config.id],
576 )
577 .await?;
578 let peer_or_self_review_questions = upsert_multiple_peer_or_self_review_questions(
579 &mut tx,
580 &peer_or_self_review_configuration
581 .peer_or_self_review_questions
582 .iter()
583 .map(|prq| {
584 let id = if previous_peer_or_self_review_question_ids.contains(&prq.id) {
585 prq.id
586 } else {
587 Uuid::new_v4()
588 };
589 CmsPeerOrSelfReviewQuestion { id, ..prq.clone() }
590 })
591 .collect::<Vec<_>>(),
592 )
593 .await?;
594
595 tx.commit().await?;
596
597 Ok(CmsPeerOrSelfReviewConfiguration {
598 peer_or_self_review_config,
599 peer_or_self_review_questions,
600 })
601}
602
603#[cfg(test)]
604mod tests {
605 use super::*;
606 use crate::test_helper::*;
607
608 #[tokio::test]
609 async fn only_one_default_peer_review_per_course() {
610 insert_data!(:tx, :user, :org, :course);
611
612 let peer_review_1 = insert(tx.as_mut(), PKeyPolicy::Generate, course, None).await;
613 assert!(peer_review_1.is_err());
614 }
615}