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 let flagged_submission_data =
69 exercise_slide_submissions::get_by_id(&mut tx, flagged_answer.submission_id).await?;
70
71 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 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 let insert_result = insert_flagged_answer(&mut tx, new_flagged_answer).await?;
93
94 let updated_flag_count = increment_flag_count(&mut tx, flagged_answer.submission_id).await?;
96
97 let course = courses::get_course(&mut tx, course_id).await?;
99
100 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 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 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 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 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}