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 file_store: &dyn FileStore,
259 app_conf: &ApplicationConfiguration,
260) -> ModelResult<CourseMaterialPeerOrSelfReviewData> {
261 let exercise = exercises::get_by_id(conn, exercise_id).await?;
262 let (_current_exercise_slide, instance_or_exam_id) = exercises::get_or_select_exercise_slide(
263 &mut *conn,
264 Some(user_id),
265 &exercise,
266 &fetch_service_info,
267 file_store,
268 app_conf,
269 )
270 .await?;
271
272 let user_exercise_state = match instance_or_exam_id {
273 Some(course_or_exam_id) => {
274 user_exercise_states::get_user_exercise_state_if_exists(
275 conn,
276 user_id,
277 exercise.id,
278 course_or_exam_id,
279 )
280 .await?
281 }
282 _ => None,
283 };
284
285 match user_exercise_state {
286 Some(ref user_exercise_state) => {
287 if matches!(
288 user_exercise_state.reviewing_stage,
289 ReviewingStage::PeerReview | ReviewingStage::WaitingForPeerReviews
290 ) {
291 let res = library::peer_or_self_reviewing::try_to_select_exercise_slide_submission_for_peer_review(
294 conn,
295 &exercise,
296 user_exercise_state,
297 &fetch_service_info,
298 file_store,
299 app_conf,
300 )
301 .await?;
302 Ok(res)
303 } else if user_exercise_state.reviewing_stage == ReviewingStage::SelfReview {
304 let res = library::peer_or_self_reviewing::select_own_submission_for_self_review(
305 conn,
306 &exercise,
307 user_exercise_state,
308 &fetch_service_info,
309 file_store,
310 app_conf,
311 )
312 .await?;
313 Ok(res)
314 } else {
315 Err(ModelError::new(
316 ModelErrorType::PreconditionFailed,
317 "You cannot peer review yet".to_string(),
318 None,
319 ))
320 }
321 }
322 None => Err(ModelError::new(
323 ModelErrorType::InvalidRequest,
324 "You haven't answered this exercise".to_string(),
325 None,
326 )),
327 }
328}
329
330pub async fn get_peer_reviews_by_page_id(
331 conn: &mut PgConnection,
332 page_id: Uuid,
333) -> ModelResult<Vec<CmsPeerOrSelfReviewConfig>> {
334 let res = sqlx::query_as!(
335 CmsPeerOrSelfReviewConfig,
336 r#"
337SELECT pr.id as id,
338 pr.course_id as course_id,
339 pr.exercise_id as exercise_id,
340 pr.peer_reviews_to_give as peer_reviews_to_give,
341 pr.peer_reviews_to_receive as peer_reviews_to_receive,
342 pr.accepting_threshold as accepting_threshold,
343 pr.processing_strategy,
344 points_are_all_or_nothing,
345 pr.reset_answer_if_zero_points_from_review,
346 pr.review_instructions
347from pages p
348 join exercises e on p.id = e.page_id
349 join peer_or_self_review_configs pr on e.id = pr.exercise_id
350where p.id = $1
351 AND p.deleted_at IS NULL
352 AND e.deleted_at IS NULL
353 AND pr.deleted_at IS NULL;
354 "#,
355 page_id,
356 )
357 .fetch_all(conn)
358 .await?;
359
360 Ok(res)
361}
362
363pub async fn delete_peer_reviews_by_exrcise_ids(
364 conn: &mut PgConnection,
365 exercise_ids: &[Uuid],
366) -> ModelResult<Vec<Uuid>> {
367 let res = sqlx::query!(
368 "
369UPDATE peer_or_self_review_configs
370SET deleted_at = now()
371WHERE exercise_id = ANY ($1)
372AND deleted_at IS NULL
373RETURNING *;
374 ",
375 exercise_ids
376 )
377 .fetch_all(conn)
378 .await?
379 .into_iter()
380 .map(|x| x.id)
381 .collect();
382 Ok(res)
383}
384
385pub async fn get_course_default_cms_peer_review(
386 conn: &mut PgConnection,
387 course_id: Uuid,
388) -> ModelResult<CmsPeerOrSelfReviewConfig> {
389 let res = sqlx::query_as!(
390 CmsPeerOrSelfReviewConfig,
391 r#"
392SELECT id,
393 course_id,
394 exercise_id,
395 peer_reviews_to_give,
396 peer_reviews_to_receive,
397 accepting_threshold,
398 processing_strategy,
399 points_are_all_or_nothing,
400 reset_answer_if_zero_points_from_review,
401 review_instructions
402FROM peer_or_self_review_configs
403WHERE course_id = $1
404 AND exercise_id IS NULL
405 AND deleted_at IS NULL;
406"#,
407 course_id
408 )
409 .fetch_one(conn)
410 .await?;
411 Ok(res)
412}
413
414pub async fn get_cms_peer_review_by_id(
415 conn: &mut PgConnection,
416 peer_or_self_review_config_id: Uuid,
417) -> ModelResult<CmsPeerOrSelfReviewConfig> {
418 let res = sqlx::query_as!(
419 CmsPeerOrSelfReviewConfig,
420 r#"
421SELECT id,
422 course_id,
423 exercise_id,
424 peer_reviews_to_give,
425 peer_reviews_to_receive,
426 accepting_threshold,
427 processing_strategy,
428 points_are_all_or_nothing,
429 reset_answer_if_zero_points_from_review,
430 review_instructions
431FROM peer_or_self_review_configs
432WHERE id = $1;
433 "#,
434 peer_or_self_review_config_id
435 )
436 .fetch_one(conn)
437 .await?;
438 Ok(res)
439}
440
441pub async fn upsert_course_default_cms_peer_review_and_questions(
442 conn: &mut PgConnection,
443 peer_or_self_review_configuration: &CmsPeerOrSelfReviewConfiguration,
444) -> ModelResult<CmsPeerOrSelfReviewConfiguration> {
445 let peer_or_self_review_config = upsert_with_id(
447 conn,
448 PKeyPolicy::Fixed(
449 peer_or_self_review_configuration
450 .peer_or_self_review_config
451 .id,
452 ),
453 &peer_or_self_review_configuration.peer_or_self_review_config,
454 )
455 .await?;
456
457 let previous_peer_or_self_review_question_ids =
459 delete_peer_or_self_review_questions_by_peer_or_self_review_config_ids(
460 conn,
461 &[peer_or_self_review_config.id],
462 )
463 .await?;
464 let peer_or_self_review_questions = upsert_multiple_peer_or_self_review_questions(
465 conn,
466 &peer_or_self_review_configuration
467 .peer_or_self_review_questions
468 .iter()
469 .map(|prq| {
470 let id = if previous_peer_or_self_review_question_ids.contains(&prq.id) {
471 prq.id
472 } else {
473 Uuid::new_v4()
474 };
475 CmsPeerOrSelfReviewQuestion { id, ..prq.clone() }
476 })
477 .collect::<Vec<_>>(),
478 )
479 .await?;
480
481 Ok(CmsPeerOrSelfReviewConfiguration {
482 peer_or_self_review_config,
483 peer_or_self_review_questions,
484 })
485}
486
487pub async fn upsert_for_course_id(
488 conn: &mut PgConnection,
489 course_id: Uuid,
490 peer_or_self_review_configuration: &CmsPeerOrSelfReviewConfiguration,
491) -> ModelResult<CmsPeerOrSelfReviewConfiguration> {
492 let input = &peer_or_self_review_configuration.peer_or_self_review_config;
493 if input.course_id != course_id {
494 return Err(model_err!(
495 PreconditionFailed,
496 "Peer review config course does not match expected course".to_string()
497 ));
498 }
499 if peer_or_self_review_configuration
500 .peer_or_self_review_questions
501 .iter()
502 .any(|q| q.peer_or_self_review_config_id != input.id)
503 {
504 return Err(model_err!(
505 PreconditionFailed,
506 "Peer review questions do not belong to the peer review config".to_string()
507 ));
508 }
509
510 let mut tx = conn.begin().await?;
511 let peer_or_self_review_config = sqlx::query_as!(
512 CmsPeerOrSelfReviewConfig,
513 r#"
514INSERT INTO peer_or_self_review_configs (
515 id,
516 course_id,
517 exercise_id,
518 peer_reviews_to_give,
519 peer_reviews_to_receive,
520 accepting_threshold,
521 processing_strategy,
522 points_are_all_or_nothing,
523 review_instructions,
524 reset_answer_if_zero_points_from_review
525)
526SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10
527WHERE (
528 $3::uuid IS NULL
529 OR EXISTS (
530 SELECT 1
531 FROM exercises
532 WHERE id = $3
533 AND course_id = $2
534 AND deleted_at IS NULL
535 )
536)
537ON CONFLICT (id) DO UPDATE
538SET course_id = excluded.course_id,
539 exercise_id = excluded.exercise_id,
540 peer_reviews_to_give = excluded.peer_reviews_to_give,
541 peer_reviews_to_receive = excluded.peer_reviews_to_receive,
542 accepting_threshold = excluded.accepting_threshold,
543 processing_strategy = excluded.processing_strategy,
544 points_are_all_or_nothing = excluded.points_are_all_or_nothing,
545 reset_answer_if_zero_points_from_review = excluded.reset_answer_if_zero_points_from_review,
546 review_instructions = excluded.review_instructions,
547 deleted_at = NULL
548WHERE peer_or_self_review_configs.course_id = $2
549RETURNING id,
550 course_id,
551 exercise_id,
552 peer_reviews_to_give,
553 peer_reviews_to_receive,
554 accepting_threshold,
555 processing_strategy,
556 points_are_all_or_nothing,
557 review_instructions,
558 reset_answer_if_zero_points_from_review
559 "#,
560 input.id,
561 course_id,
562 input.exercise_id,
563 input.peer_reviews_to_give,
564 input.peer_reviews_to_receive,
565 input.accepting_threshold,
566 input.processing_strategy as _,
567 input.points_are_all_or_nothing,
568 input.review_instructions,
569 input.reset_answer_if_zero_points_from_review,
570 )
571 .fetch_optional(&mut *tx)
572 .await?;
573 let Some(peer_or_self_review_config) = peer_or_self_review_config else {
574 return Err(model_err!(
575 PreconditionFailed,
576 "Peer review config exercise does not belong to the expected course".to_string()
577 ));
578 };
579
580 let previous_peer_or_self_review_question_ids =
581 delete_peer_or_self_review_questions_by_peer_or_self_review_config_ids(
582 &mut tx,
583 &[peer_or_self_review_config.id],
584 )
585 .await?;
586 let peer_or_self_review_questions = upsert_multiple_peer_or_self_review_questions(
587 &mut tx,
588 &peer_or_self_review_configuration
589 .peer_or_self_review_questions
590 .iter()
591 .map(|prq| {
592 let id = if previous_peer_or_self_review_question_ids.contains(&prq.id) {
593 prq.id
594 } else {
595 Uuid::new_v4()
596 };
597 CmsPeerOrSelfReviewQuestion { id, ..prq.clone() }
598 })
599 .collect::<Vec<_>>(),
600 )
601 .await?;
602
603 tx.commit().await?;
604
605 Ok(CmsPeerOrSelfReviewConfiguration {
606 peer_or_self_review_config,
607 peer_or_self_review_questions,
608 })
609}
610
611#[cfg(test)]
612mod tests {
613 use super::*;
614 use crate::test_helper::*;
615
616 #[tokio::test]
617 async fn only_one_default_peer_review_per_course() {
618 insert_data!(:tx, :user, :org, :course);
619
620 let peer_review_1 = insert(tx.as_mut(), PKeyPolicy::Generate, course, None).await;
621 assert!(peer_review_1.is_err());
622 }
623}