Skip to main content

headless_lms_models/
peer_or_self_review_configs.rs

1use futures::future::BoxFuture;
2use url::Url;
3use utoipa::ToSchema;
4
5use crate::error::{ensure_version, missing_model_error};
6use crate::{
7    exercise_service_info::ExerciseServiceInfoApi,
8    exercises::{self, Exercise},
9    library::{self, peer_or_self_reviewing::CourseMaterialPeerOrSelfReviewData},
10    peer_or_self_review_questions::{
11        CmsPeerOrSelfReviewQuestion,
12        delete_peer_or_self_review_questions_by_peer_or_self_review_config_ids,
13        upsert_multiple_peer_or_self_review_questions,
14    },
15    prelude::*,
16    user_exercise_states::{self, ReviewingStage},
17};
18
19#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
20
21pub struct PeerOrSelfReviewConfig {
22    pub id: Uuid,
23    pub created_at: DateTime<Utc>,
24    pub updated_at: DateTime<Utc>,
25    pub deleted_at: Option<DateTime<Utc>>,
26    pub course_id: Uuid,
27    pub exercise_id: Option<Uuid>,
28    pub peer_reviews_to_give: i32,
29    pub peer_reviews_to_receive: i32,
30    pub accepting_threshold: f32,
31    pub processing_strategy: PeerReviewProcessingStrategy,
32    pub manual_review_cutoff_in_days: i32,
33    pub points_are_all_or_nothing: bool,
34    pub reset_answer_if_zero_points_from_review: bool,
35    pub review_instructions: Option<serde_json::Value>,
36}
37
38/// Like `PeerOrSelfReviewConfig` but only the fields it's fine to show to all users.
39#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
40
41pub struct CourseMaterialPeerOrSelfReviewConfig {
42    pub id: Uuid,
43    pub course_id: Uuid,
44    pub exercise_id: Option<Uuid>,
45    pub peer_reviews_to_give: i32,
46    pub peer_reviews_to_receive: i32,
47}
48
49#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
50
51pub struct CmsPeerOrSelfReviewConfig {
52    pub id: Uuid,
53    pub course_id: Uuid,
54    pub exercise_id: Option<Uuid>,
55    pub peer_reviews_to_give: i32,
56    pub peer_reviews_to_receive: i32,
57    pub accepting_threshold: f32,
58    pub processing_strategy: PeerReviewProcessingStrategy,
59    pub points_are_all_or_nothing: bool,
60    pub reset_answer_if_zero_points_from_review: bool,
61    pub review_instructions: Option<serde_json::Value>,
62}
63
64impl CmsPeerOrSelfReviewConfig {
65    /// The names of the review settings that differ from `other`'s: every field but the ids, the
66    /// course and the review instructions.
67    pub fn settings_differing_from(&self, other: &CmsPeerOrSelfReviewConfig) -> Vec<&'static str> {
68        let CmsPeerOrSelfReviewConfig {
69            id: _,
70            course_id: _,
71            exercise_id: _,
72            peer_reviews_to_give,
73            peer_reviews_to_receive,
74            accepting_threshold,
75            processing_strategy,
76            points_are_all_or_nothing,
77            reset_answer_if_zero_points_from_review,
78            review_instructions: _,
79        } = self;
80        [
81            (
82                "peer_reviews_to_give",
83                *peer_reviews_to_give != other.peer_reviews_to_give,
84            ),
85            (
86                "peer_reviews_to_receive",
87                *peer_reviews_to_receive != other.peer_reviews_to_receive,
88            ),
89            (
90                "accepting_threshold",
91                *accepting_threshold != other.accepting_threshold,
92            ),
93            (
94                "processing_strategy",
95                *processing_strategy != other.processing_strategy,
96            ),
97            (
98                "points_are_all_or_nothing",
99                *points_are_all_or_nothing != other.points_are_all_or_nothing,
100            ),
101            (
102                "reset_answer_if_zero_points_from_review",
103                *reset_answer_if_zero_points_from_review
104                    != other.reset_answer_if_zero_points_from_review,
105            ),
106        ]
107        .into_iter()
108        .filter(|(_, differs)| *differs)
109        .map(|(name, _)| name)
110        .collect()
111    }
112}
113
114#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
115
116pub struct CmsPeerOrSelfReviewConfiguration {
117    pub peer_or_self_review_config: CmsPeerOrSelfReviewConfig,
118    pub peer_or_self_review_questions: Vec<CmsPeerOrSelfReviewQuestion>,
119}
120
121/// A course's default configuration as the CMS loaded it. `updated_at` is the version the editor
122/// sends back on save.
123#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
124pub struct VersionedCmsPeerOrSelfReviewConfiguration {
125    #[serde(flatten)]
126    pub configuration: CmsPeerOrSelfReviewConfiguration,
127    pub updated_at: DateTime<Utc>,
128}
129
130/// Save of a course's default configuration. A save is refused as stale when `expected_updated_at`
131/// is set and the configuration has changed since that version; leave it unset to overwrite.
132#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
133pub struct CmsPeerOrSelfReviewConfigurationSave {
134    #[serde(flatten)]
135    pub configuration: CmsPeerOrSelfReviewConfiguration,
136    pub expected_updated_at: Option<DateTime<Utc>>,
137}
138
139/**
140Determines how we will treat the answer being peer reviewed once it has received enough reviews and the student has given enough peer reviews.
141
142Some strategies compare the overall received peer review likert answer (1-5) average to peer_reviews.accepting threshold.
143*/
144#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, sqlx::Type, ToSchema)]
145#[sqlx(
146    type_name = "peer_review_processing_strategy",
147    rename_all = "snake_case"
148)]
149pub enum PeerReviewProcessingStrategy {
150    /// If the average of the peer review likert answers is greater than the threshold, the peer review is accepted, otherwise it is rejected.
151    AutomaticallyGradeByAverage,
152    /// If the average of the peer review likert answers is greater than the threshold, the peer review is accepted, otherwise it is sent to be manually reviewed by the teacher.
153    AutomaticallyGradeOrManualReviewByAverage,
154    /// All answers will be sent to be manually reviewed by the teacher once they have received and given enough peer reviews.
155    ManualReviewEverything,
156}
157
158pub async fn insert(
159    conn: &mut PgConnection,
160    pkey_policy: PKeyPolicy<Uuid>,
161    course_id: Uuid,
162    exercise_id: Option<Uuid>,
163) -> ModelResult<Uuid> {
164    let res = sqlx::query!(
165        "
166INSERT INTO peer_or_self_review_configs (id, course_id, exercise_id)
167VALUES ($1, $2, $3)
168RETURNING *
169        ",
170        pkey_policy.into_uuid(),
171        course_id,
172        exercise_id,
173    )
174    .fetch_one(conn)
175    .await?;
176    Ok(res.id)
177}
178
179pub async fn upsert_with_id(
180    conn: &mut PgConnection,
181    pkey_policy: PKeyPolicy<Uuid>,
182    cms_peer_review: &CmsPeerOrSelfReviewConfig,
183) -> ModelResult<CmsPeerOrSelfReviewConfig> {
184    let res = sqlx::query_as!(
185        CmsPeerOrSelfReviewConfig,
186        r#"
187    INSERT INTO peer_or_self_review_configs (
188    id,
189    course_id,
190    exercise_id,
191    peer_reviews_to_give,
192    peer_reviews_to_receive,
193    accepting_threshold,
194    processing_strategy,
195    points_are_all_or_nothing,
196    review_instructions,
197    reset_answer_if_zero_points_from_review
198  )
199VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (id) DO
200UPDATE
201SET course_id = excluded.course_id,
202  exercise_id = excluded.exercise_id,
203  peer_reviews_to_give = excluded.peer_reviews_to_give,
204  peer_reviews_to_receive = excluded.peer_reviews_to_receive,
205  accepting_threshold = excluded.accepting_threshold,
206  processing_strategy = excluded.processing_strategy,
207  points_are_all_or_nothing = excluded.points_are_all_or_nothing,
208  reset_answer_if_zero_points_from_review = excluded.reset_answer_if_zero_points_from_review,
209  review_instructions = excluded.review_instructions
210RETURNING id,
211  course_id,
212  exercise_id,
213  peer_reviews_to_give,
214  peer_reviews_to_receive,
215  accepting_threshold,
216  processing_strategy,
217  points_are_all_or_nothing,
218  review_instructions,
219  reset_answer_if_zero_points_from_review
220"#,
221        pkey_policy.into_uuid(),
222        cms_peer_review.course_id,
223        cms_peer_review.exercise_id,
224        cms_peer_review.peer_reviews_to_give,
225        cms_peer_review.peer_reviews_to_receive,
226        cms_peer_review.accepting_threshold,
227        cms_peer_review.processing_strategy as _,
228        cms_peer_review.points_are_all_or_nothing,
229        cms_peer_review.review_instructions,
230        cms_peer_review.reset_answer_if_zero_points_from_review,
231    )
232    .fetch_one(conn)
233    .await?;
234    Ok(res)
235}
236
237pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<PeerOrSelfReviewConfig> {
238    let res = sqlx::query_as!(
239        PeerOrSelfReviewConfig,
240        r#"
241SELECT *
242FROM peer_or_self_review_configs
243WHERE id = $1
244  AND deleted_at IS NULL
245        "#,
246        id
247    )
248    .fetch_one(conn)
249    .await?;
250    Ok(res)
251}
252
253/// The configs with the ids that aren't deleted, in no particular order.
254pub async fn get_by_ids(
255    conn: &mut PgConnection,
256    ids: &[Uuid],
257) -> ModelResult<Vec<PeerOrSelfReviewConfig>> {
258    let res = sqlx::query_as!(
259        PeerOrSelfReviewConfig,
260        r#"
261SELECT *
262FROM peer_or_self_review_configs
263WHERE id = ANY($1)
264  AND deleted_at IS NULL
265        "#,
266        ids
267    )
268    .fetch_all(conn)
269    .await?;
270    Ok(res)
271}
272
273/// Usually you want to use `get_by_exercise_or_course_id` instead of this one.
274pub async fn get_by_exercise_id(
275    conn: &mut PgConnection,
276    exercise_id: Uuid,
277) -> ModelResult<PeerOrSelfReviewConfig> {
278    let res = sqlx::query_as!(
279        PeerOrSelfReviewConfig,
280        r#"
281SELECT *
282FROM peer_or_self_review_configs
283WHERE exercise_id = $1
284  AND deleted_at IS NULL
285        "#,
286        exercise_id
287    )
288    .fetch_one(conn)
289    .await?;
290    Ok(res)
291}
292
293/// Returns the correct peer review config depending on `exercise.use_course_default_peer_or_self_review_config`.
294pub async fn get_by_exercise_or_course_id(
295    conn: &mut PgConnection,
296    exercise: &Exercise,
297    course_id: Uuid,
298) -> ModelResult<PeerOrSelfReviewConfig> {
299    if exercise.use_course_default_peer_or_self_review_config {
300        get_default_for_course_by_course_id(conn, course_id).await
301    } else {
302        get_by_exercise_id(conn, exercise.id).await
303    }
304}
305
306pub async fn get_default_for_course_by_course_id(
307    conn: &mut PgConnection,
308    course_id: Uuid,
309) -> ModelResult<PeerOrSelfReviewConfig> {
310    let res = sqlx::query_as!(
311        PeerOrSelfReviewConfig,
312        r#"
313SELECT *
314FROM peer_or_self_review_configs
315WHERE course_id = $1
316  AND exercise_id IS NULL
317  AND deleted_at IS NULL;
318        "#,
319        course_id
320    )
321    .fetch_one(conn)
322    .await?;
323    Ok(res)
324}
325
326pub async fn delete(conn: &mut PgConnection, id: Uuid) -> ModelResult<Uuid> {
327    let res = sqlx::query!(
328        "
329UPDATE peer_or_self_review_configs
330SET deleted_at = now()
331WHERE id = $1
332AND deleted_at IS NULL
333RETURNING *
334    ",
335        id
336    )
337    .fetch_one(conn)
338    .await?;
339    Ok(res.id)
340}
341
342pub async fn get_course_material_peer_or_self_review_data(
343    conn: &mut PgConnection,
344    user_id: Uuid,
345    exercise_id: Uuid,
346    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
347    file_store: &dyn FileStore,
348    app_conf: &ApplicationConfiguration,
349) -> ModelResult<CourseMaterialPeerOrSelfReviewData> {
350    let exercise = exercises::get_by_id(conn, exercise_id).await?;
351    let (_current_exercise_slide, instance_or_exam_id) = exercises::get_or_select_exercise_slide(
352        &mut *conn,
353        Some(user_id),
354        &exercise,
355        &fetch_service_info,
356        file_store,
357        app_conf,
358    )
359    .await?;
360
361    let user_exercise_state = match instance_or_exam_id {
362        Some(course_or_exam_id) => {
363            user_exercise_states::get_user_exercise_state_if_exists(
364                conn,
365                user_id,
366                exercise.id,
367                course_or_exam_id,
368            )
369            .await?
370        }
371        _ => None,
372    };
373
374    match user_exercise_state {
375        Some(ref user_exercise_state) => {
376            if matches!(
377                user_exercise_state.reviewing_stage,
378                ReviewingStage::PeerReview | ReviewingStage::WaitingForPeerReviews
379            ) {
380                // Calling library inside a model function. Maybe should be refactored by moving
381                // complicated logic to own library file?
382                let res = library::peer_or_self_reviewing::try_to_select_exercise_slide_submission_for_peer_review(
383                    conn,
384                    &exercise,
385                    user_exercise_state,
386                    &fetch_service_info,
387                    file_store,
388                    app_conf,
389                )
390                .await?;
391                Ok(res)
392            } else if user_exercise_state.reviewing_stage == ReviewingStage::SelfReview {
393                let res = library::peer_or_self_reviewing::select_own_submission_for_self_review(
394                    conn,
395                    &exercise,
396                    user_exercise_state,
397                    &fetch_service_info,
398                    file_store,
399                    app_conf,
400                )
401                .await?;
402                Ok(res)
403            } else {
404                Err(ModelError::new(
405                    ModelErrorType::PreconditionFailed,
406                    "You cannot peer review yet".to_string(),
407                    None,
408                ))
409            }
410        }
411        None => Err(ModelError::new(
412            ModelErrorType::InvalidRequest,
413            "You haven't answered this exercise".to_string(),
414            None,
415        )),
416    }
417}
418
419/// The review configs of the undeleted exercises on the pages.
420pub async fn get_peer_reviews_by_page_ids(
421    conn: &mut PgConnection,
422    page_ids: &[Uuid],
423) -> ModelResult<Vec<CmsPeerOrSelfReviewConfig>> {
424    let res = sqlx::query_as!(
425        CmsPeerOrSelfReviewConfig,
426        r#"
427SELECT pr.id as id,
428  pr.course_id as course_id,
429  pr.exercise_id as exercise_id,
430  pr.peer_reviews_to_give as peer_reviews_to_give,
431  pr.peer_reviews_to_receive as peer_reviews_to_receive,
432  pr.accepting_threshold as accepting_threshold,
433  pr.processing_strategy,
434  points_are_all_or_nothing,
435  pr.reset_answer_if_zero_points_from_review,
436  pr.review_instructions
437from pages p
438  join exercises e on p.id = e.page_id
439  join peer_or_self_review_configs pr on e.id = pr.exercise_id
440where p.id = ANY($1)
441  AND p.deleted_at IS NULL
442  AND e.deleted_at IS NULL
443  AND pr.deleted_at IS NULL;
444    "#,
445        page_ids,
446    )
447    .fetch_all(conn)
448    .await?;
449
450    Ok(res)
451}
452
453pub async fn delete_peer_reviews_by_exrcise_ids(
454    conn: &mut PgConnection,
455    exercise_ids: &[Uuid],
456) -> ModelResult<Vec<Uuid>> {
457    let res = sqlx::query!(
458        "
459UPDATE peer_or_self_review_configs
460SET deleted_at = now()
461WHERE exercise_id = ANY ($1)
462AND deleted_at IS NULL
463RETURNING *;
464    ",
465        exercise_ids
466    )
467    .fetch_all(conn)
468    .await?
469    .into_iter()
470    .map(|x| x.id)
471    .collect();
472    Ok(res)
473}
474
475pub async fn get_course_default_cms_peer_review(
476    conn: &mut PgConnection,
477    course_id: Uuid,
478) -> ModelResult<CmsPeerOrSelfReviewConfig> {
479    let res = sqlx::query_as!(
480        CmsPeerOrSelfReviewConfig,
481        r#"
482SELECT id,
483  course_id,
484  exercise_id,
485  peer_reviews_to_give,
486  peer_reviews_to_receive,
487  accepting_threshold,
488  processing_strategy,
489  points_are_all_or_nothing,
490  reset_answer_if_zero_points_from_review,
491  review_instructions
492FROM peer_or_self_review_configs
493WHERE course_id = $1
494  AND exercise_id IS NULL
495  AND deleted_at IS NULL;
496"#,
497        course_id
498    )
499    .fetch_one(conn)
500    .await?;
501    Ok(res)
502}
503
504/// Sets the review instructions of the course's default config, leaving its other settings as
505/// they are. Errors with `PreconditionFailed` if `config_id` isn't the course's default config.
506pub async fn set_course_default_review_instructions(
507    conn: &mut PgConnection,
508    config_id: Uuid,
509    course_id: Uuid,
510    review_instructions: Option<&serde_json::Value>,
511) -> ModelResult<()> {
512    let updated = sqlx::query!(
513        r#"
514UPDATE peer_or_self_review_configs
515SET review_instructions = $3
516WHERE id = $1
517  AND course_id = $2
518  AND exercise_id IS NULL
519  AND deleted_at IS NULL
520        "#,
521        config_id,
522        course_id,
523        review_instructions,
524    )
525    .execute(conn)
526    .await?;
527    if updated.rows_affected() == 0 {
528        return Err(model_err!(
529            PreconditionFailed,
530            "The course default review config doesn't exist".to_string()
531        ));
532    }
533    Ok(())
534}
535
536pub async fn get_cms_peer_review_by_id(
537    conn: &mut PgConnection,
538    peer_or_self_review_config_id: Uuid,
539) -> ModelResult<CmsPeerOrSelfReviewConfig> {
540    let res = sqlx::query_as!(
541        CmsPeerOrSelfReviewConfig,
542        r#"
543SELECT id,
544  course_id,
545  exercise_id,
546  peer_reviews_to_give,
547  peer_reviews_to_receive,
548  accepting_threshold,
549  processing_strategy,
550  points_are_all_or_nothing,
551  reset_answer_if_zero_points_from_review,
552  review_instructions
553FROM peer_or_self_review_configs
554WHERE id = $1;
555    "#,
556        peer_or_self_review_config_id
557    )
558    .fetch_one(conn)
559    .await?;
560    Ok(res)
561}
562
563pub async fn upsert_course_default_cms_peer_review_and_questions(
564    conn: &mut PgConnection,
565    peer_or_self_review_configuration: &CmsPeerOrSelfReviewConfiguration,
566) -> ModelResult<CmsPeerOrSelfReviewConfiguration> {
567    // Upsert peer review
568    let peer_or_self_review_config = upsert_with_id(
569        conn,
570        PKeyPolicy::Fixed(
571            peer_or_self_review_configuration
572                .peer_or_self_review_config
573                .id,
574        ),
575        &peer_or_self_review_configuration.peer_or_self_review_config,
576    )
577    .await?;
578
579    // Upsert peer review questions
580    let previous_peer_or_self_review_question_ids =
581        delete_peer_or_self_review_questions_by_peer_or_self_review_config_ids(
582            conn,
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        conn,
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    Ok(CmsPeerOrSelfReviewConfiguration {
604        peer_or_self_review_config,
605        peer_or_self_review_questions,
606    })
607}
608
609/// With `expected_updated_at` set, refuses with [ModelErrorType::StaleVersion] unless the config
610/// row still has that `updated_at`. The check runs under the row lock.
611pub async fn upsert_for_course_id(
612    conn: &mut PgConnection,
613    course_id: Uuid,
614    peer_or_self_review_configuration: &CmsPeerOrSelfReviewConfiguration,
615    expected_updated_at: Option<DateTime<Utc>>,
616) -> ModelResult<VersionedCmsPeerOrSelfReviewConfiguration> {
617    let input = &peer_or_self_review_configuration.peer_or_self_review_config;
618    if input.course_id != course_id {
619        return Err(model_err!(
620            PreconditionFailed,
621            "Peer review config course does not match expected course".to_string()
622        ));
623    }
624    if peer_or_self_review_configuration
625        .peer_or_self_review_questions
626        .iter()
627        .any(|q| q.peer_or_self_review_config_id != input.id)
628    {
629        return Err(model_err!(
630            PreconditionFailed,
631            "Peer review questions do not belong to the peer review config".to_string()
632        ));
633    }
634
635    let mut tx = conn.begin().await?;
636    if expected_updated_at.is_some() {
637        let current_updated_at = sqlx::query_scalar!(
638            "
639SELECT updated_at
640FROM peer_or_self_review_configs
641WHERE id = $1
642  AND course_id = $2
643  AND deleted_at IS NULL
644FOR UPDATE
645",
646            input.id,
647            course_id
648        )
649        .fetch_optional(&mut *tx)
650        .await?;
651        ensure_version(
652            current_updated_at,
653            expected_updated_at,
654            "The default peer review settings were changed after they were opened.",
655        )?;
656    }
657    let peer_or_self_review_config = sqlx::query_as!(
658        CmsPeerOrSelfReviewConfig,
659        r#"
660INSERT INTO peer_or_self_review_configs (
661    id,
662    course_id,
663    exercise_id,
664    peer_reviews_to_give,
665    peer_reviews_to_receive,
666    accepting_threshold,
667    processing_strategy,
668    points_are_all_or_nothing,
669    review_instructions,
670    reset_answer_if_zero_points_from_review
671)
672SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10
673WHERE (
674    $3::uuid IS NULL
675    OR EXISTS (
676      SELECT 1
677      FROM exercises
678      WHERE id = $3
679        AND course_id = $2
680        AND deleted_at IS NULL
681    )
682)
683ON CONFLICT (id) DO UPDATE
684SET course_id = excluded.course_id,
685  exercise_id = excluded.exercise_id,
686  peer_reviews_to_give = excluded.peer_reviews_to_give,
687  peer_reviews_to_receive = excluded.peer_reviews_to_receive,
688  accepting_threshold = excluded.accepting_threshold,
689  processing_strategy = excluded.processing_strategy,
690  points_are_all_or_nothing = excluded.points_are_all_or_nothing,
691  reset_answer_if_zero_points_from_review = excluded.reset_answer_if_zero_points_from_review,
692  review_instructions = excluded.review_instructions,
693  deleted_at = NULL
694WHERE peer_or_self_review_configs.course_id = $2
695RETURNING id,
696  course_id,
697  exercise_id,
698  peer_reviews_to_give,
699  peer_reviews_to_receive,
700  accepting_threshold,
701  processing_strategy,
702  points_are_all_or_nothing,
703  review_instructions,
704  reset_answer_if_zero_points_from_review
705        "#,
706        input.id,
707        course_id,
708        input.exercise_id,
709        input.peer_reviews_to_give,
710        input.peer_reviews_to_receive,
711        input.accepting_threshold,
712        input.processing_strategy as _,
713        input.points_are_all_or_nothing,
714        input.review_instructions,
715        input.reset_answer_if_zero_points_from_review,
716    )
717    .fetch_optional(&mut *tx)
718    .await?;
719    let Some(peer_or_self_review_config) = peer_or_self_review_config else {
720        return Err(model_err!(
721            PreconditionFailed,
722            "Peer review config exercise does not belong to the expected course".to_string()
723        ));
724    };
725
726    let previous_peer_or_self_review_question_ids =
727        delete_peer_or_self_review_questions_by_peer_or_self_review_config_ids(
728            &mut tx,
729            &[peer_or_self_review_config.id],
730        )
731        .await?;
732    let peer_or_self_review_questions = upsert_multiple_peer_or_self_review_questions(
733        &mut tx,
734        &peer_or_self_review_configuration
735            .peer_or_self_review_questions
736            .iter()
737            .map(|prq| {
738                let id = if previous_peer_or_self_review_question_ids.contains(&prq.id) {
739                    prq.id
740                } else {
741                    Uuid::new_v4()
742                };
743                CmsPeerOrSelfReviewQuestion { id, ..prq.clone() }
744            })
745            .collect::<Vec<_>>(),
746    )
747    .await?;
748
749    let updated_at = get_by_id(&mut tx, peer_or_self_review_config.id)
750        .await?
751        .updated_at;
752    tx.commit().await?;
753
754    Ok(VersionedCmsPeerOrSelfReviewConfiguration {
755        configuration: CmsPeerOrSelfReviewConfiguration {
756            peer_or_self_review_config,
757            peer_or_self_review_questions,
758        },
759        updated_at,
760    })
761}
762
763/// The course's default configuration with its questions; `None` if the course has none.
764pub async fn get_course_default_cms_peer_review_configuration(
765    conn: &mut PgConnection,
766    course_id: Uuid,
767) -> ModelResult<Option<CmsPeerOrSelfReviewConfiguration>> {
768    let mut tx = conn.begin().await?;
769    let Some(peer_or_self_review_config) = get_course_default_cms_peer_review(&mut tx, course_id)
770        .await
771        .optional()?
772    else {
773        tx.rollback().await?;
774        return Ok(None);
775    };
776    let peer_or_self_review_questions =
777        crate::peer_or_self_review_questions::get_course_default_cms_peer_or_self_review_questions(
778            &mut tx,
779            peer_or_self_review_config.id,
780        )
781        .await?;
782    tx.commit().await?;
783    Ok(Some(CmsPeerOrSelfReviewConfiguration {
784        peer_or_self_review_config,
785        peer_or_self_review_questions,
786    }))
787}
788
789/// The course default configuration with its questions, as the CMS edits it.
790pub async fn get_course_default_versioned_cms_peer_review(
791    conn: &mut PgConnection,
792    course_id: Uuid,
793) -> ModelResult<VersionedCmsPeerOrSelfReviewConfiguration> {
794    let mut tx = conn.begin().await?;
795    let configuration = get_course_default_cms_peer_review_configuration(&mut tx, course_id)
796        .await?
797        .ok_or_else(missing_model_error(
798            ModelErrorType::RecordNotFound,
799            "The course has no default peer review config",
800        ))?;
801    let updated_at = get_by_id(&mut tx, configuration.peer_or_self_review_config.id)
802        .await?
803        .updated_at;
804    tx.commit().await?;
805    Ok(VersionedCmsPeerOrSelfReviewConfiguration {
806        configuration,
807        updated_at,
808    })
809}
810
811#[cfg(test)]
812mod tests {
813    use super::*;
814    use crate::test_helper::*;
815
816    #[tokio::test]
817    async fn only_one_default_peer_review_per_course() {
818        insert_data!(:tx, :user, :org, :course);
819
820        let peer_review_1 = insert(tx.as_mut(), PKeyPolicy::Generate, course, None).await;
821        assert!(peer_review_1.is_err());
822    }
823}