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::{
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/// Like `PeerOrSelfReviewConfig` but only the fields it's fine to show to all users.
38#[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/**
71Determines how we will treat the answer being peer reviewed once it has received enough reviews and the student has given enough peer reviews.
72
73Some strategies compare the overall received peer review likert answer (1-5) average to peer_reviews.accepting threshold.
74*/
75#[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    /// If the average of the peer review likert answers is greater than the threshold, the peer review is accepted, otherwise it is rejected.
82    AutomaticallyGradeByAverage,
83    /// 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.
84    AutomaticallyGradeOrManualReviewByAverage,
85    /// All answers will be sent to be manually reviewed by the teacher once they have received and given enough peer reviews.
86    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 id
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 AS "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 id,
173  created_at,
174  updated_at,
175  deleted_at,
176  course_id,
177  exercise_id,
178  peer_reviews_to_give,
179  peer_reviews_to_receive,
180  accepting_threshold,
181  processing_strategy AS "processing_strategy: _",
182  manual_review_cutoff_in_days,
183  points_are_all_or_nothing,
184  review_instructions,
185  reset_answer_if_zero_points_from_review
186FROM peer_or_self_review_configs
187WHERE id = $1
188  AND deleted_at IS NULL
189        "#,
190        id
191    )
192    .fetch_one(conn)
193    .await?;
194    Ok(res)
195}
196
197/// Usually you want to use `get_by_exercise_or_course_id` instead of this one.
198pub async fn get_by_exercise_id(
199    conn: &mut PgConnection,
200    exercise_id: Uuid,
201) -> ModelResult<PeerOrSelfReviewConfig> {
202    let res = sqlx::query_as!(
203        PeerOrSelfReviewConfig,
204        r#"
205SELECT id,
206    created_at,
207    updated_at,
208    deleted_at,
209    course_id,
210    exercise_id,
211    peer_reviews_to_give,
212    peer_reviews_to_receive,
213    accepting_threshold,
214    processing_strategy AS "processing_strategy: _",
215    manual_review_cutoff_in_days,
216    points_are_all_or_nothing,
217    review_instructions,
218    reset_answer_if_zero_points_from_review
219FROM peer_or_self_review_configs
220WHERE exercise_id = $1
221  AND deleted_at IS NULL
222        "#,
223        exercise_id
224    )
225    .fetch_one(conn)
226    .await?;
227    Ok(res)
228}
229
230/// Returns the correct peer review config depending on `exercise.use_course_default_peer_or_self_review_config`.
231pub async fn get_by_exercise_or_course_id(
232    conn: &mut PgConnection,
233    exercise: &Exercise,
234    course_id: Uuid,
235) -> ModelResult<PeerOrSelfReviewConfig> {
236    if exercise.use_course_default_peer_or_self_review_config {
237        get_default_for_course_by_course_id(conn, course_id).await
238    } else {
239        get_by_exercise_id(conn, exercise.id).await
240    }
241}
242
243pub async fn get_default_for_course_by_course_id(
244    conn: &mut PgConnection,
245    course_id: Uuid,
246) -> ModelResult<PeerOrSelfReviewConfig> {
247    let res = sqlx::query_as!(
248        PeerOrSelfReviewConfig,
249        r#"
250SELECT id,
251  created_at,
252  updated_at,
253  deleted_at,
254  course_id,
255  exercise_id,
256  peer_reviews_to_give,
257  peer_reviews_to_receive,
258  accepting_threshold,
259  processing_strategy AS "processing_strategy: _",
260  manual_review_cutoff_in_days,
261  points_are_all_or_nothing,
262  review_instructions,
263  reset_answer_if_zero_points_from_review
264FROM peer_or_self_review_configs
265WHERE course_id = $1
266  AND exercise_id IS NULL
267  AND deleted_at IS NULL;
268        "#,
269        course_id
270    )
271    .fetch_one(conn)
272    .await?;
273    Ok(res)
274}
275
276pub async fn delete(conn: &mut PgConnection, id: Uuid) -> ModelResult<Uuid> {
277    let res = sqlx::query!(
278        "
279UPDATE peer_or_self_review_configs
280SET deleted_at = now()
281WHERE id = $1
282AND deleted_at IS NULL
283RETURNING id
284    ",
285        id
286    )
287    .fetch_one(conn)
288    .await?;
289    Ok(res.id)
290}
291
292pub async fn get_course_material_peer_or_self_review_data(
293    conn: &mut PgConnection,
294    user_id: Uuid,
295    exercise_id: Uuid,
296    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
297) -> ModelResult<CourseMaterialPeerOrSelfReviewData> {
298    let exercise = exercises::get_by_id(conn, exercise_id).await?;
299    let (_current_exercise_slide, instance_or_exam_id) = exercises::get_or_select_exercise_slide(
300        &mut *conn,
301        Some(user_id),
302        &exercise,
303        &fetch_service_info,
304    )
305    .await?;
306
307    let user_exercise_state = match instance_or_exam_id {
308        Some(course_or_exam_id) => {
309            user_exercise_states::get_user_exercise_state_if_exists(
310                conn,
311                user_id,
312                exercise.id,
313                course_or_exam_id,
314            )
315            .await?
316        }
317        _ => None,
318    };
319
320    match user_exercise_state {
321        Some(ref user_exercise_state) => {
322            if matches!(
323                user_exercise_state.reviewing_stage,
324                ReviewingStage::PeerReview | ReviewingStage::WaitingForPeerReviews
325            ) {
326                // Calling library inside a model function. Maybe should be refactored by moving
327                // complicated logic to own library file?
328                let res = library::peer_or_self_reviewing::try_to_select_exercise_slide_submission_for_peer_review(
329                    conn,
330                    &exercise,
331                    user_exercise_state,
332                    &fetch_service_info
333                )
334                .await?;
335                Ok(res)
336            } else if user_exercise_state.reviewing_stage == ReviewingStage::SelfReview {
337                let res = library::peer_or_self_reviewing::select_own_submission_for_self_review(
338                    conn,
339                    &exercise,
340                    user_exercise_state,
341                    &fetch_service_info,
342                )
343                .await?;
344                Ok(res)
345            } else {
346                Err(ModelError::new(
347                    ModelErrorType::PreconditionFailed,
348                    "You cannot peer review yet".to_string(),
349                    None,
350                ))
351            }
352        }
353        None => Err(ModelError::new(
354            ModelErrorType::InvalidRequest,
355            "You haven't answered this exercise".to_string(),
356            None,
357        )),
358    }
359}
360
361pub async fn get_peer_reviews_by_page_id(
362    conn: &mut PgConnection,
363    page_id: Uuid,
364) -> ModelResult<Vec<CmsPeerOrSelfReviewConfig>> {
365    let res = sqlx::query_as!(
366        CmsPeerOrSelfReviewConfig,
367        r#"
368SELECT pr.id as id,
369  pr.course_id as course_id,
370  pr.exercise_id as exercise_id,
371  pr.peer_reviews_to_give as peer_reviews_to_give,
372  pr.peer_reviews_to_receive as peer_reviews_to_receive,
373  pr.accepting_threshold as accepting_threshold,
374  pr.processing_strategy AS "processing_strategy: _",
375  points_are_all_or_nothing,
376  pr.reset_answer_if_zero_points_from_review,
377  pr.review_instructions
378from pages p
379  join exercises e on p.id = e.page_id
380  join peer_or_self_review_configs pr on e.id = pr.exercise_id
381where p.id = $1
382  AND p.deleted_at IS NULL
383  AND e.deleted_at IS NULL
384  AND pr.deleted_at IS NULL;
385    "#,
386        page_id,
387    )
388    .fetch_all(conn)
389    .await?;
390
391    Ok(res)
392}
393
394pub async fn delete_peer_reviews_by_exrcise_ids(
395    conn: &mut PgConnection,
396    exercise_ids: &[Uuid],
397) -> ModelResult<Vec<Uuid>> {
398    let res = sqlx::query!(
399        "
400UPDATE peer_or_self_review_configs
401SET deleted_at = now()
402WHERE exercise_id = ANY ($1)
403AND deleted_at IS NULL
404RETURNING id;
405    ",
406        exercise_ids
407    )
408    .fetch_all(conn)
409    .await?
410    .into_iter()
411    .map(|x| x.id)
412    .collect();
413    Ok(res)
414}
415
416pub async fn get_course_default_cms_peer_review(
417    conn: &mut PgConnection,
418    course_id: Uuid,
419) -> ModelResult<CmsPeerOrSelfReviewConfig> {
420    let res = sqlx::query_as!(
421        CmsPeerOrSelfReviewConfig,
422        r#"
423SELECT id,
424  course_id,
425  exercise_id,
426  peer_reviews_to_give,
427  peer_reviews_to_receive,
428  accepting_threshold,
429  processing_strategy AS "processing_strategy: _",
430  points_are_all_or_nothing,
431  reset_answer_if_zero_points_from_review,
432  review_instructions
433FROM peer_or_self_review_configs
434WHERE course_id = $1
435  AND exercise_id IS NULL
436  AND deleted_at IS NULL;
437"#,
438        course_id
439    )
440    .fetch_one(conn)
441    .await?;
442    Ok(res)
443}
444
445pub async fn get_cms_peer_review_by_id(
446    conn: &mut PgConnection,
447    peer_or_self_review_config_id: Uuid,
448) -> ModelResult<CmsPeerOrSelfReviewConfig> {
449    let res = sqlx::query_as!(
450        CmsPeerOrSelfReviewConfig,
451        r#"
452SELECT id,
453  course_id,
454  exercise_id,
455  peer_reviews_to_give,
456  peer_reviews_to_receive,
457  accepting_threshold,
458  processing_strategy AS "processing_strategy:_",
459  points_are_all_or_nothing,
460  reset_answer_if_zero_points_from_review,
461  review_instructions
462FROM peer_or_self_review_configs
463WHERE id = $1;
464    "#,
465        peer_or_self_review_config_id
466    )
467    .fetch_one(conn)
468    .await?;
469    Ok(res)
470}
471
472pub async fn upsert_course_default_cms_peer_review_and_questions(
473    conn: &mut PgConnection,
474    peer_or_self_review_configuration: &CmsPeerOrSelfReviewConfiguration,
475) -> ModelResult<CmsPeerOrSelfReviewConfiguration> {
476    // Upsert peer review
477    let peer_or_self_review_config = upsert_with_id(
478        conn,
479        PKeyPolicy::Fixed(
480            peer_or_self_review_configuration
481                .peer_or_self_review_config
482                .id,
483        ),
484        &peer_or_self_review_configuration.peer_or_self_review_config,
485    )
486    .await?;
487
488    // Upsert peer review questions
489    let previous_peer_or_self_review_question_ids =
490        delete_peer_or_self_review_questions_by_peer_or_self_review_config_ids(
491            conn,
492            &[peer_or_self_review_config.id],
493        )
494        .await?;
495    let peer_or_self_review_questions = upsert_multiple_peer_or_self_review_questions(
496        conn,
497        &peer_or_self_review_configuration
498            .peer_or_self_review_questions
499            .iter()
500            .map(|prq| {
501                let id = if previous_peer_or_self_review_question_ids.contains(&prq.id) {
502                    prq.id
503                } else {
504                    Uuid::new_v4()
505                };
506                CmsPeerOrSelfReviewQuestion { id, ..prq.clone() }
507            })
508            .collect::<Vec<_>>(),
509    )
510    .await?;
511
512    Ok(CmsPeerOrSelfReviewConfiguration {
513        peer_or_self_review_config,
514        peer_or_self_review_questions,
515    })
516}
517
518pub async fn upsert_for_course_id(
519    conn: &mut PgConnection,
520    course_id: Uuid,
521    peer_or_self_review_configuration: &CmsPeerOrSelfReviewConfiguration,
522) -> ModelResult<CmsPeerOrSelfReviewConfiguration> {
523    let input = &peer_or_self_review_configuration.peer_or_self_review_config;
524    if input.course_id != course_id {
525        return Err(model_err!(
526            PreconditionFailed,
527            "Peer review config course does not match expected course".to_string()
528        ));
529    }
530    if peer_or_self_review_configuration
531        .peer_or_self_review_questions
532        .iter()
533        .any(|q| q.peer_or_self_review_config_id != input.id)
534    {
535        return Err(model_err!(
536            PreconditionFailed,
537            "Peer review questions do not belong to the peer review config".to_string()
538        ));
539    }
540
541    let mut tx = conn.begin().await?;
542    let peer_or_self_review_config = sqlx::query_as!(
543        CmsPeerOrSelfReviewConfig,
544        r#"
545INSERT INTO peer_or_self_review_configs (
546    id,
547    course_id,
548    exercise_id,
549    peer_reviews_to_give,
550    peer_reviews_to_receive,
551    accepting_threshold,
552    processing_strategy,
553    points_are_all_or_nothing,
554    review_instructions,
555    reset_answer_if_zero_points_from_review
556)
557SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10
558WHERE (
559    $3::uuid IS NULL
560    OR EXISTS (
561      SELECT 1
562      FROM exercises
563      WHERE id = $3
564        AND course_id = $2
565        AND deleted_at IS NULL
566    )
567)
568ON CONFLICT (id) DO UPDATE
569SET course_id = excluded.course_id,
570  exercise_id = excluded.exercise_id,
571  peer_reviews_to_give = excluded.peer_reviews_to_give,
572  peer_reviews_to_receive = excluded.peer_reviews_to_receive,
573  accepting_threshold = excluded.accepting_threshold,
574  processing_strategy = excluded.processing_strategy,
575  points_are_all_or_nothing = excluded.points_are_all_or_nothing,
576  reset_answer_if_zero_points_from_review = excluded.reset_answer_if_zero_points_from_review,
577  review_instructions = excluded.review_instructions,
578  deleted_at = NULL
579WHERE peer_or_self_review_configs.course_id = $2
580RETURNING id,
581  course_id,
582  exercise_id,
583  peer_reviews_to_give,
584  peer_reviews_to_receive,
585  accepting_threshold,
586  processing_strategy AS "processing_strategy:_",
587  points_are_all_or_nothing,
588  review_instructions,
589  reset_answer_if_zero_points_from_review
590        "#,
591        input.id,
592        course_id,
593        input.exercise_id,
594        input.peer_reviews_to_give,
595        input.peer_reviews_to_receive,
596        input.accepting_threshold,
597        input.processing_strategy as _,
598        input.points_are_all_or_nothing,
599        input.review_instructions,
600        input.reset_answer_if_zero_points_from_review,
601    )
602    .fetch_optional(&mut *tx)
603    .await?;
604    let Some(peer_or_self_review_config) = peer_or_self_review_config else {
605        return Err(model_err!(
606            PreconditionFailed,
607            "Peer review config exercise does not belong to the expected course".to_string()
608        ));
609    };
610
611    let previous_peer_or_self_review_question_ids =
612        delete_peer_or_self_review_questions_by_peer_or_self_review_config_ids(
613            &mut tx,
614            &[peer_or_self_review_config.id],
615        )
616        .await?;
617    let peer_or_self_review_questions = upsert_multiple_peer_or_self_review_questions(
618        &mut tx,
619        &peer_or_self_review_configuration
620            .peer_or_self_review_questions
621            .iter()
622            .map(|prq| {
623                let id = if previous_peer_or_self_review_question_ids.contains(&prq.id) {
624                    prq.id
625                } else {
626                    Uuid::new_v4()
627                };
628                CmsPeerOrSelfReviewQuestion { id, ..prq.clone() }
629            })
630            .collect::<Vec<_>>(),
631    )
632    .await?;
633
634    tx.commit().await?;
635
636    Ok(CmsPeerOrSelfReviewConfiguration {
637        peer_or_self_review_config,
638        peer_or_self_review_questions,
639    })
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645    use crate::test_helper::*;
646
647    #[tokio::test]
648    async fn only_one_default_peer_review_per_course() {
649        insert_data!(:tx, :user, :org, :course);
650
651        let peer_review_1 = insert(tx.as_mut(), PKeyPolicy::Generate, course, None).await;
652        assert!(peer_review_1.is_err());
653    }
654}