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 *
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
184/// Usually you want to use `get_by_exercise_or_course_id` instead of this one.
185pub 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
204/// Returns the correct peer review config depending on `exercise.use_course_default_peer_or_self_review_config`.
205pub 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                // Calling library inside a model function. Maybe should be refactored by moving
288                // complicated logic to own library file?
289                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    // Upsert peer review
438    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    // Upsert peer review questions
450    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}