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 reset_answer_if_zero_points_from_review: bool,
33    pub review_instructions: Option<serde_json::Value>,
34}
35
36/// Like `PeerOrSelfReviewConfig` but only the fields it's fine to show to all users.
37#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
38#[cfg_attr(feature = "ts_rs", derive(TS))]
39pub struct CourseMaterialPeerOrSelfReviewConfig {
40    pub id: Uuid,
41    pub course_id: Uuid,
42    pub exercise_id: Option<Uuid>,
43    pub peer_reviews_to_give: i32,
44    pub peer_reviews_to_receive: i32,
45}
46
47#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
48#[cfg_attr(feature = "ts_rs", derive(TS))]
49pub struct CmsPeerOrSelfReviewConfig {
50    pub id: Uuid,
51    pub course_id: Uuid,
52    pub exercise_id: Option<Uuid>,
53    pub peer_reviews_to_give: i32,
54    pub peer_reviews_to_receive: i32,
55    pub accepting_threshold: f32,
56    pub processing_strategy: PeerReviewProcessingStrategy,
57    pub points_are_all_or_nothing: bool,
58    pub reset_answer_if_zero_points_from_review: bool,
59    pub review_instructions: Option<serde_json::Value>,
60}
61
62#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
63#[cfg_attr(feature = "ts_rs", derive(TS))]
64pub struct CmsPeerOrSelfReviewConfiguration {
65    pub peer_or_self_review_config: CmsPeerOrSelfReviewConfig,
66    pub peer_or_self_review_questions: Vec<CmsPeerOrSelfReviewQuestion>,
67}
68
69/**
70Determines how we will treat the answer being peer reviewed once it has received enough reviews and the student has given enough peer reviews.
71
72Some strategies compare the overall received peer review likert answer (1-5) average to peer_reviews.accepting threshold.
73*/
74#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, sqlx::Type)]
75#[cfg_attr(feature = "ts_rs", derive(TS))]
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
518#[cfg(test)]
519mod tests {
520    use super::*;
521    use crate::test_helper::*;
522
523    #[tokio::test]
524    async fn only_one_default_peer_review_per_course() {
525        insert_data!(:tx, :user, :org, :course);
526
527        let peer_review_1 = insert(tx.as_mut(), PKeyPolicy::Generate, course, None).await;
528        assert!(peer_review_1.is_err());
529    }
530}