Skip to main content

headless_lms_models/
flagged_answers.rs

1use crate::{
2    courses, exercise_slide_submissions, exercises, peer_review_queue_entries,
3    prelude::*,
4    user_exercise_states::{self, ReviewingStage},
5};
6use utoipa::ToSchema;
7
8#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
9
10pub struct FlaggedAnswer {
11    pub id: Uuid,
12    pub submission_id: Uuid,
13    pub flagged_user: Uuid,
14    pub flagged_by: Uuid,
15    pub reason: ReportReason,
16    pub description: Option<String>,
17    pub created_at: DateTime<Utc>,
18    pub updated_at: DateTime<Utc>,
19    pub deleted_at: Option<DateTime<Utc>>,
20}
21
22#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, sqlx::Type, ToSchema)]
23#[sqlx(type_name = "report_reason")]
24pub enum ReportReason {
25    #[sqlx(rename = "flagging-reason-spam")]
26    Spam,
27    #[sqlx(rename = "flagging-reason-harmful-content")]
28    HarmfulContent,
29    #[sqlx(rename = "flagging-reason-ai-generated")]
30    AiGenerated,
31}
32
33#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
34
35pub struct NewFlaggedAnswer {
36    pub submission_id: Uuid,
37    pub flagged_user: Option<Uuid>,
38    pub flagged_by: Option<Uuid>,
39    pub reason: ReportReason,
40    pub description: Option<String>,
41}
42
43#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
44
45pub struct NewFlaggedAnswerWithToken {
46    pub submission_id: Uuid,
47    pub flagged_user: Option<Uuid>,
48    pub flagged_by: Option<Uuid>,
49    pub reason: ReportReason,
50    pub description: Option<String>,
51    pub peer_or_self_review_config_id: Uuid,
52    pub token: String,
53}
54
55pub async fn insert_flagged_answer_and_move_to_manual_review_if_needed(
56    conn: &mut PgConnection,
57    flagged_answer: NewFlaggedAnswerWithToken,
58    giver_user_id: Uuid,
59) -> ModelResult<FlaggedAnswer> {
60    let mut tx = conn.begin().await.map_err(|_| {
61        ModelError::new(
62            ModelErrorType::Generic,
63            "Failed to start transaction".to_string(),
64            None,
65        )
66    })?;
67    // Fetch flagged submission data
68    let flagged_submission_data =
69        exercise_slide_submissions::get_by_id(&mut tx, flagged_answer.submission_id).await?;
70
71    // Ensure the submission is related to a course
72    let course_id = flagged_submission_data.course_id.ok_or_else(|| {
73        ModelError::new(
74            ModelErrorType::Generic,
75            "Course id not found for the submission.".to_string(),
76            None,
77        )
78    })?;
79
80    let flagged_user = flagged_submission_data.user_id;
81
82    // Create a new flagged answer
83    let new_flagged_answer = NewFlaggedAnswer {
84        submission_id: flagged_answer.submission_id,
85        flagged_user: Some(flagged_user),
86        flagged_by: Some(giver_user_id),
87        reason: flagged_answer.reason,
88        description: flagged_answer.description.clone(),
89    };
90
91    // Insert the flagged answer into the database
92    let insert_result = insert_flagged_answer(&mut tx, new_flagged_answer).await?;
93
94    // Increment the flag count
95    let updated_flag_count = increment_flag_count(&mut tx, flagged_answer.submission_id).await?;
96
97    // Fetch the course data
98    let course = courses::get_course(&mut tx, course_id).await?;
99
100    // Check if the flag count exceeds the courses flagged answers threshold.
101    // If it does the move to manual review and remove from the peer review queue.
102    if let Some(flagged_answers_threshold) = course.flagged_answers_threshold
103        && updated_flag_count >= flagged_answers_threshold
104    {
105        if course.flagged_answers_skip_manual_review_and_allow_retry {
106            let course_id = flagged_submission_data.course_id.ok_or_else(|| {
107                ModelError::new(
108                    ModelErrorType::Generic,
109                    "No course instance found for the submission.".to_string(),
110                    None,
111                )
112            })?;
113
114            let _ = exercises::reset_exercises_for_selected_users(
115                &mut tx,
116                &[(flagged_user, vec![flagged_submission_data.exercise_id])],
117                None,
118                course_id,
119                Some("flagged-answers-skip-manual-review-and-allow-retry".to_string()),
120            )
121            .await?;
122        } else {
123            // Ensure course id exists
124            let course_id = flagged_submission_data
125                .course_id
126                .map(CourseOrExamId::Course)
127                .ok_or_else(|| {
128                    ModelError::new(
129                        ModelErrorType::Generic,
130                        "No course instance found for the submission.".to_string(),
131                        None,
132                    )
133                })?;
134
135            // Move the answer to manual review
136            let update_result = user_exercise_states::update_reviewing_stage(
137                &mut tx,
138                flagged_user,
139                course_id,
140                flagged_submission_data.exercise_id,
141                ReviewingStage::WaitingForManualGrading,
142            )
143            .await?;
144
145            // Remove from peer review queue so other students can't review an answers that is already in manual review
146            // Remove the answer from the peer review queue
147            if let Some(course_id) = update_result.course_id {
148                peer_review_queue_entries::remove_queue_entries_for_unusual_reason(
149                    &mut tx,
150                    flagged_user,
151                    flagged_submission_data.exercise_id,
152                    course_id,
153                )
154                .await?;
155            }
156        }
157    }
158    // Make sure the user who flagged this is allowed to get a new answer to review
159    crate::offered_answers_to_peer_review_temporary::delete_saved_submissions_for_user(
160        &mut tx,
161        flagged_submission_data.exercise_id,
162        giver_user_id,
163    )
164    .await?;
165
166    tx.commit().await.map_err(|_| {
167        ModelError::new(
168            ModelErrorType::Generic,
169            "Failed to commit transaction".to_string(),
170            None,
171        )
172    })?;
173
174    Ok(insert_result)
175}
176
177pub async fn insert_flagged_answer(
178    conn: &mut PgConnection,
179    flagged_answer: NewFlaggedAnswer,
180) -> ModelResult<FlaggedAnswer> {
181    let res = sqlx::query_as!(
182        FlaggedAnswer,
183        r#"
184INSERT INTO flagged_answers (
185    submission_id,
186    flagged_user,
187    flagged_by,
188    reason,
189    description
190)
191VALUES ($1, $2, $3, $4, $5)
192RETURNING *
193        "#,
194        flagged_answer.submission_id,
195        flagged_answer.flagged_user,
196        flagged_answer.flagged_by,
197        flagged_answer.reason as ReportReason,
198        flagged_answer.description,
199    )
200    .fetch_one(conn)
201    .await?;
202    Ok(res)
203}
204
205pub async fn increment_flag_count(
206    conn: &mut PgConnection,
207    submission_id: Uuid,
208) -> ModelResult<i32> {
209    let result = sqlx::query!(
210        r#"
211        UPDATE exercise_slide_submissions
212        SET flag_count = COALESCE(flag_count, 0) + 1
213        WHERE id = $1
214        RETURNING *
215        "#,
216        submission_id
217    )
218    .fetch_one(conn)
219    .await?;
220
221    Ok(result.flag_count)
222}
223
224pub async fn get_flagged_answers_by_submission_id(
225    conn: &mut PgConnection,
226    exercise_slide_submission_id: Uuid,
227) -> ModelResult<Vec<FlaggedAnswer>> {
228    let results = sqlx::query_as!(
229        FlaggedAnswer,
230        r#"
231        SELECT *
232        FROM flagged_answers
233        WHERE submission_id = $1
234          AND deleted_at IS NULL
235        "#,
236        exercise_slide_submission_id
237    )
238    .fetch_all(conn)
239    .await?;
240
241    Ok(results)
242}
243
244pub async fn get_flagged_answers_submission_ids_by_flaggers_id(
245    conn: &mut PgConnection,
246    flagged_by: Uuid,
247) -> ModelResult<Vec<Uuid>> {
248    let flagged_submissions = sqlx::query_as!(
249        FlaggedAnswer,
250        r#"
251        SELECT *
252        FROM flagged_answers
253        WHERE flagged_by = $1
254          AND deleted_at IS NULL
255        "#,
256        flagged_by
257    )
258    .fetch_all(conn)
259    .await?;
260
261    Ok(flagged_submissions
262        .into_iter()
263        .map(|row| row.submission_id)
264        .collect())
265}