headless_lms_models/
peer_or_self_review_configs.rs

1use futures::future::BoxFuture;
2use url::Url;
3
4use crate::{
5    exercise_service_info::ExerciseServiceInfoApi,
6    exercises::{self, Exercise},
7    library::{self, peer_or_self_reviewing::CourseMaterialPeerOrSelfReviewData},
8    peer_or_self_review_questions::{
9        CmsPeerOrSelfReviewQuestion,
10        delete_peer_or_self_review_questions_by_peer_or_self_review_config_ids,
11        upsert_multiple_peer_or_self_review_questions,
12    },
13    prelude::*,
14    user_exercise_states::{self, ReviewingStage},
15};
16
17#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
18#[cfg_attr(feature = "ts_rs", derive(TS))]
19pub struct PeerOrSelfReviewConfig {
20    pub id: Uuid,
21    pub created_at: DateTime<Utc>,
22    pub updated_at: DateTime<Utc>,
23    pub deleted_at: Option<DateTime<Utc>>,
24    pub course_id: Uuid,
25    pub exercise_id: Option<Uuid>,
26    pub peer_reviews_to_give: i32,
27    pub peer_reviews_to_receive: i32,
28    pub accepting_threshold: f32,
29    pub processing_strategy: PeerReviewProcessingStrategy,
30    pub manual_review_cutoff_in_days: i32,
31    pub points_are_all_or_nothing: bool,
32    pub review_instructions: Option<serde_json::Value>,
33}
34
35/// Like `PeerOrSelfReviewConfig` but only the fields it's fine to show to all users.
36#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
37#[cfg_attr(feature = "ts_rs", derive(TS))]
38pub struct CourseMaterialPeerOrSelfReviewConfig {
39    pub id: Uuid,
40    pub course_id: Uuid,
41    pub exercise_id: Option<Uuid>,
42    pub peer_reviews_to_give: i32,
43    pub peer_reviews_to_receive: i32,
44}
45
46#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
47#[cfg_attr(feature = "ts_rs", derive(TS))]
48pub struct CmsPeerOrSelfReviewConfig {
49    pub id: Uuid,
50    pub course_id: Uuid,
51    pub exercise_id: Option<Uuid>,
52    pub peer_reviews_to_give: i32,
53    pub peer_reviews_to_receive: i32,
54    pub accepting_threshold: f32,
55    pub processing_strategy: PeerReviewProcessingStrategy,
56    pub points_are_all_or_nothing: bool,
57    pub review_instructions: Option<serde_json::Value>,
58}
59
60#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
61#[cfg_attr(feature = "ts_rs", derive(TS))]
62pub struct CmsPeerOrSelfReviewConfiguration {
63    pub peer_or_self_review_config: CmsPeerOrSelfReviewConfig,
64    pub peer_or_self_review_questions: Vec<CmsPeerOrSelfReviewQuestion>,
65}
66
67/**
68Determines how we will treat the answer being peer reviewed once it has received enough reviews and the student has given enough peer reviews.
69
70Some strategies compare the overall received peer review likert answer (1-5) average to peer_reviews.accepting threshold.
71*/
72#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, sqlx::Type)]
73#[cfg_attr(feature = "ts_rs", derive(TS))]
74#[sqlx(
75    type_name = "peer_review_processing_strategy",
76    rename_all = "snake_case"
77)]
78pub enum PeerReviewProcessingStrategy {
79    /// If the average of the peer review likert answers is greater than the threshold, the peer review is accepted, otherwise it is rejected.
80    AutomaticallyGradeByAverage,
81    /// 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.
82    AutomaticallyGradeOrManualReviewByAverage,
83    /// All answers will be sent to be manually reviewed by the teacher once they have received and given enough peer reviews.
84    ManualReviewEverything,
85}
86
87pub async fn insert(
88    conn: &mut PgConnection,
89    pkey_policy: PKeyPolicy<Uuid>,
90    course_id: Uuid,
91    exercise_id: Option<Uuid>,
92) -> ModelResult<Uuid> {
93    let res = sqlx::query!(
94        "
95INSERT INTO peer_or_self_review_configs (id, course_id, exercise_id)
96VALUES ($1, $2, $3)
97RETURNING id
98        ",
99        pkey_policy.into_uuid(),
100        course_id,
101        exercise_id,
102    )
103    .fetch_one(conn)
104    .await?;
105    Ok(res.id)
106}
107
108pub async fn upsert_with_id(
109    conn: &mut PgConnection,
110    pkey_policy: PKeyPolicy<Uuid>,
111    cms_peer_review: &CmsPeerOrSelfReviewConfig,
112) -> ModelResult<CmsPeerOrSelfReviewConfig> {
113    let res = sqlx::query_as!(
114        CmsPeerOrSelfReviewConfig,
115        r#"
116    INSERT INTO peer_or_self_review_configs (
117    id,
118    course_id,
119    exercise_id,
120    peer_reviews_to_give,
121    peer_reviews_to_receive,
122    accepting_threshold,
123    processing_strategy,
124    points_are_all_or_nothing,
125    review_instructions
126  )
127VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (id) DO
128UPDATE
129SET course_id = excluded.course_id,
130  exercise_id = excluded.exercise_id,
131  peer_reviews_to_give = excluded.peer_reviews_to_give,
132  peer_reviews_to_receive = excluded.peer_reviews_to_receive,
133  accepting_threshold = excluded.accepting_threshold,
134  processing_strategy = excluded.processing_strategy,
135  points_are_all_or_nothing = excluded.points_are_all_or_nothing,
136  review_instructions = excluded.review_instructions
137RETURNING id,
138  course_id,
139  exercise_id,
140  peer_reviews_to_give,
141  peer_reviews_to_receive,
142  accepting_threshold,
143  processing_strategy AS "processing_strategy:_",
144  points_are_all_or_nothing,
145  review_instructions
146"#,
147        pkey_policy.into_uuid(),
148        cms_peer_review.course_id,
149        cms_peer_review.exercise_id,
150        cms_peer_review.peer_reviews_to_give,
151        cms_peer_review.peer_reviews_to_receive,
152        cms_peer_review.accepting_threshold,
153        cms_peer_review.processing_strategy as _,
154        cms_peer_review.points_are_all_or_nothing,
155        cms_peer_review.review_instructions,
156    )
157    .fetch_one(conn)
158    .await?;
159    Ok(res)
160}
161
162pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<PeerOrSelfReviewConfig> {
163    let res = sqlx::query_as!(
164        PeerOrSelfReviewConfig,
165        r#"
166SELECT id,
167  created_at,
168  updated_at,
169  deleted_at,
170  course_id,
171  exercise_id,
172  peer_reviews_to_give,
173  peer_reviews_to_receive,
174  accepting_threshold,
175  processing_strategy AS "processing_strategy: _",
176  manual_review_cutoff_in_days,
177  points_are_all_or_nothing,
178  review_instructions
179FROM peer_or_self_review_configs
180WHERE id = $1
181  AND deleted_at IS NULL
182        "#,
183        id
184    )
185    .fetch_one(conn)
186    .await?;
187    Ok(res)
188}
189
190/// Usually you want to use `get_by_exercise_or_course_id` instead of this one.
191pub async fn get_by_exercise_id(
192    conn: &mut PgConnection,
193    exercise_id: Uuid,
194) -> ModelResult<PeerOrSelfReviewConfig> {
195    let res = sqlx::query_as!(
196        PeerOrSelfReviewConfig,
197        r#"
198SELECT id,
199    created_at,
200    updated_at,
201    deleted_at,
202    course_id,
203    exercise_id,
204    peer_reviews_to_give,
205    peer_reviews_to_receive,
206    accepting_threshold,
207    processing_strategy AS "processing_strategy: _",
208    manual_review_cutoff_in_days,
209    points_are_all_or_nothing,
210    review_instructions
211FROM peer_or_self_review_configs
212WHERE exercise_id = $1
213  AND deleted_at IS NULL
214        "#,
215        exercise_id
216    )
217    .fetch_one(conn)
218    .await?;
219    Ok(res)
220}
221
222/// Returns the correct peer review config depending on `exercise.use_course_default_peer_or_self_review_config`.
223pub async fn get_by_exercise_or_course_id(
224    conn: &mut PgConnection,
225    exercise: &Exercise,
226    course_id: Uuid,
227) -> ModelResult<PeerOrSelfReviewConfig> {
228    if exercise.use_course_default_peer_or_self_review_config {
229        get_default_for_course_by_course_id(conn, course_id).await
230    } else {
231        get_by_exercise_id(conn, exercise.id).await
232    }
233}
234
235pub async fn get_default_for_course_by_course_id(
236    conn: &mut PgConnection,
237    course_id: Uuid,
238) -> ModelResult<PeerOrSelfReviewConfig> {
239    let res = sqlx::query_as!(
240        PeerOrSelfReviewConfig,
241        r#"
242SELECT id,
243  created_at,
244  updated_at,
245  deleted_at,
246  course_id,
247  exercise_id,
248  peer_reviews_to_give,
249  peer_reviews_to_receive,
250  accepting_threshold,
251  processing_strategy AS "processing_strategy: _",
252  manual_review_cutoff_in_days,
253  points_are_all_or_nothing,
254  review_instructions
255FROM peer_or_self_review_configs
256WHERE course_id = $1
257  AND exercise_id IS NULL
258  AND deleted_at IS NULL;
259        "#,
260        course_id
261    )
262    .fetch_one(conn)
263    .await?;
264    Ok(res)
265}
266
267pub async fn delete(conn: &mut PgConnection, id: Uuid) -> ModelResult<Uuid> {
268    let res = sqlx::query!(
269        "
270UPDATE peer_or_self_review_configs
271SET deleted_at = now()
272WHERE id = $1
273RETURNING id
274    ",
275        id
276    )
277    .fetch_one(conn)
278    .await?;
279    Ok(res.id)
280}
281
282pub async fn get_course_material_peer_or_self_review_data(
283    conn: &mut PgConnection,
284    user_id: Uuid,
285    exercise_id: Uuid,
286    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
287) -> ModelResult<CourseMaterialPeerOrSelfReviewData> {
288    let exercise = exercises::get_by_id(conn, exercise_id).await?;
289    let (_current_exercise_slide, instance_or_exam_id) = exercises::get_or_select_exercise_slide(
290        &mut *conn,
291        Some(user_id),
292        &exercise,
293        &fetch_service_info,
294    )
295    .await?;
296
297    let user_exercise_state = match instance_or_exam_id {
298        Some(course_instance_or_exam_id) => {
299            user_exercise_states::get_user_exercise_state_if_exists(
300                conn,
301                user_id,
302                exercise.id,
303                course_instance_or_exam_id,
304            )
305            .await?
306        }
307        _ => None,
308    };
309
310    match user_exercise_state {
311        Some(ref user_exercise_state) => {
312            if matches!(
313                user_exercise_state.reviewing_stage,
314                ReviewingStage::PeerReview | ReviewingStage::WaitingForPeerReviews
315            ) {
316                // Calling library inside a model function. Maybe should be refactored by moving
317                // complicated logic to own library file?
318                let res = library::peer_or_self_reviewing::try_to_select_exercise_slide_submission_for_peer_review(
319                    conn,
320                    &exercise,
321                    user_exercise_state,
322                    &fetch_service_info
323                )
324                .await?;
325                Ok(res)
326            } else if user_exercise_state.reviewing_stage == ReviewingStage::SelfReview {
327                let res = library::peer_or_self_reviewing::select_own_submission_for_self_review(
328                    conn,
329                    &exercise,
330                    user_exercise_state,
331                    &fetch_service_info,
332                )
333                .await?;
334                Ok(res)
335            } else {
336                Err(ModelError::new(
337                    ModelErrorType::PreconditionFailed,
338                    "You cannot peer review yet".to_string(),
339                    None,
340                ))
341            }
342        }
343        None => Err(ModelError::new(
344            ModelErrorType::InvalidRequest,
345            "You haven't answered this exercise".to_string(),
346            None,
347        )),
348    }
349}
350
351pub async fn get_peer_reviews_by_page_id(
352    conn: &mut PgConnection,
353    page_id: Uuid,
354) -> ModelResult<Vec<CmsPeerOrSelfReviewConfig>> {
355    let res = sqlx::query_as!(
356        CmsPeerOrSelfReviewConfig,
357        r#"
358SELECT pr.id as id,
359  pr.course_id as course_id,
360  pr.exercise_id as exercise_id,
361  pr.peer_reviews_to_give as peer_reviews_to_give,
362  pr.peer_reviews_to_receive as peer_reviews_to_receive,
363  pr.accepting_threshold as accepting_threshold,
364  pr.processing_strategy AS "processing_strategy: _",
365  points_are_all_or_nothing,
366  pr.review_instructions
367from pages p
368  join exercises e on p.id = e.page_id
369  join peer_or_self_review_configs pr on e.id = pr.exercise_id
370where p.id = $1
371  AND p.deleted_at IS NULL
372  AND e.deleted_at IS NULL
373  AND pr.deleted_at IS NULL;
374    "#,
375        page_id,
376    )
377    .fetch_all(conn)
378    .await?;
379
380    Ok(res)
381}
382
383pub async fn delete_peer_reviews_by_exrcise_ids(
384    conn: &mut PgConnection,
385    exercise_ids: &[Uuid],
386) -> ModelResult<Vec<Uuid>> {
387    let res = sqlx::query!(
388        "
389UPDATE peer_or_self_review_configs
390SET deleted_at = now()
391WHERE exercise_id = ANY ($1)
392AND deleted_at IS NULL
393RETURNING id;
394    ",
395        exercise_ids
396    )
397    .fetch_all(conn)
398    .await?
399    .into_iter()
400    .map(|x| x.id)
401    .collect();
402    Ok(res)
403}
404
405pub async fn get_course_default_cms_peer_review(
406    conn: &mut PgConnection,
407    course_id: Uuid,
408) -> ModelResult<CmsPeerOrSelfReviewConfig> {
409    let res = sqlx::query_as!(
410        CmsPeerOrSelfReviewConfig,
411        r#"
412SELECT id,
413  course_id,
414  exercise_id,
415  peer_reviews_to_give,
416  peer_reviews_to_receive,
417  accepting_threshold,
418  processing_strategy AS "processing_strategy: _",
419  points_are_all_or_nothing,
420  review_instructions
421FROM peer_or_self_review_configs
422WHERE course_id = $1
423  AND exercise_id IS NULL
424  AND deleted_at IS NULL;
425"#,
426        course_id
427    )
428    .fetch_one(conn)
429    .await?;
430    Ok(res)
431}
432
433pub async fn get_cms_peer_review_by_id(
434    conn: &mut PgConnection,
435    peer_or_self_review_config_id: Uuid,
436) -> ModelResult<CmsPeerOrSelfReviewConfig> {
437    let res = sqlx::query_as!(
438        CmsPeerOrSelfReviewConfig,
439        r#"
440SELECT id,
441  course_id,
442  exercise_id,
443  peer_reviews_to_give,
444  peer_reviews_to_receive,
445  accepting_threshold,
446  processing_strategy AS "processing_strategy:_",
447  points_are_all_or_nothing,
448  review_instructions
449FROM peer_or_self_review_configs
450WHERE id = $1;
451    "#,
452        peer_or_self_review_config_id
453    )
454    .fetch_one(conn)
455    .await?;
456    Ok(res)
457}
458
459pub async fn upsert_course_default_cms_peer_review_and_questions(
460    conn: &mut PgConnection,
461    peer_or_self_review_configuration: &CmsPeerOrSelfReviewConfiguration,
462) -> ModelResult<CmsPeerOrSelfReviewConfiguration> {
463    // Upsert peer review
464    let peer_or_self_review_config = upsert_with_id(
465        conn,
466        PKeyPolicy::Fixed(
467            peer_or_self_review_configuration
468                .peer_or_self_review_config
469                .id,
470        ),
471        &peer_or_self_review_configuration.peer_or_self_review_config,
472    )
473    .await?;
474
475    // Upsert peer review questions
476    let previous_peer_or_self_review_question_ids =
477        delete_peer_or_self_review_questions_by_peer_or_self_review_config_ids(
478            conn,
479            &[peer_or_self_review_config.id],
480        )
481        .await?;
482    let peer_or_self_review_questions = upsert_multiple_peer_or_self_review_questions(
483        conn,
484        &peer_or_self_review_configuration
485            .peer_or_self_review_questions
486            .iter()
487            .map(|prq| {
488                let id = if previous_peer_or_self_review_question_ids.contains(&prq.id) {
489                    prq.id
490                } else {
491                    Uuid::new_v4()
492                };
493                CmsPeerOrSelfReviewQuestion { id, ..prq.clone() }
494            })
495            .collect::<Vec<_>>(),
496    )
497    .await?;
498
499    Ok(CmsPeerOrSelfReviewConfiguration {
500        peer_or_self_review_config,
501        peer_or_self_review_questions,
502    })
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508    use crate::test_helper::*;
509
510    #[tokio::test]
511    async fn only_one_default_peer_review_per_course() {
512        insert_data!(:tx, :user, :org, :course);
513
514        let peer_review_1 = insert(tx.as_mut(), PKeyPolicy::Generate, course, None).await;
515        assert!(peer_review_1.is_err());
516    }
517}