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    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                // Calling library inside a model function. Maybe should be refactored by moving
292                // complicated logic to own library file?
293                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    // Upsert peer review
446    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    // Upsert peer review questions
458    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}