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};
6
7#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
8#[cfg_attr(feature = "ts_rs", derive(TS))]
9pub struct FlaggedAnswer {
10    pub id: Uuid,
11    pub submission_id: Uuid,
12    pub flagged_user: Uuid,
13    pub flagged_by: Uuid,
14    pub reason: ReportReason,
15    pub description: Option<String>,
16    pub created_at: DateTime<Utc>,
17    pub updated_at: DateTime<Utc>,
18    pub deleted_at: Option<DateTime<Utc>>,
19}
20
21#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, sqlx::Type)]
22#[cfg_attr(feature = "ts_rs", derive(TS))]
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#[cfg_attr(feature = "ts_rs", derive(TS))]
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)]
44#[cfg_attr(feature = "ts_rs", derive(TS))]
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 id,
193  submission_id,
194  flagged_user,
195  flagged_by,
196  reason AS "reason: _",
197  description,
198  created_at,
199  updated_at,
200  deleted_at
201        "#,
202        flagged_answer.submission_id,
203        flagged_answer.flagged_user,
204        flagged_answer.flagged_by,
205        flagged_answer.reason as ReportReason,
206        flagged_answer.description,
207    )
208    .fetch_one(conn)
209    .await?;
210    Ok(res)
211}
212
213pub async fn increment_flag_count(
214    conn: &mut PgConnection,
215    submission_id: Uuid,
216) -> ModelResult<i32> {
217    let result = sqlx::query!(
218        r#"
219        UPDATE exercise_slide_submissions
220        SET flag_count = COALESCE(flag_count, 0) + 1
221        WHERE id = $1
222        RETURNING flag_count
223        "#,
224        submission_id
225    )
226    .fetch_one(conn)
227    .await?;
228
229    Ok(result.flag_count)
230}
231
232pub async fn get_flagged_answers_by_submission_id(
233    conn: &mut PgConnection,
234    exercise_slide_submission_id: Uuid,
235) -> ModelResult<Vec<FlaggedAnswer>> {
236    let results = sqlx::query_as!(
237        FlaggedAnswer,
238        r#"
239        SELECT
240            id,
241            submission_id,
242            flagged_user,
243            flagged_by,
244            reason AS "reason: _",
245            description,
246            created_at,
247            updated_at,
248            deleted_at
249        FROM flagged_answers
250        WHERE submission_id = $1
251          AND deleted_at IS NULL
252        "#,
253        exercise_slide_submission_id
254    )
255    .fetch_all(conn)
256    .await?;
257
258    Ok(results)
259}
260
261pub async fn get_flagged_answers_submission_ids_by_flaggers_id(
262    conn: &mut PgConnection,
263    flagged_by: Uuid,
264) -> ModelResult<Vec<Uuid>> {
265    let flagged_submissions = sqlx::query_as!(
266        FlaggedAnswer,
267        r#"
268        SELECT
269            id,
270            submission_id,
271            flagged_user,
272            flagged_by,
273            reason AS "reason: _",
274            description,
275            created_at,
276            updated_at,
277            deleted_at
278        FROM flagged_answers
279        WHERE flagged_by = $1
280          AND deleted_at IS NULL
281        "#,
282        flagged_by
283    )
284    .fetch_all(conn)
285    .await?;
286
287    Ok(flagged_submissions
288        .into_iter()
289        .map(|row| row.submission_id)
290        .collect())
291}