1use crate::prelude::*;
2use crate::{cheating_confirmation_grade_snapshots, course_module_completions, course_modules};
3use utoipa::ToSchema;
4
5pub const DEFAULT_CHEATER_THRESHOLD_SECONDS: i32 = 3 * 60 * 60;
8pub const MINIMUM_CHEATER_THRESHOLD_SECONDS: i32 = 3 * 60 * 60;
10pub const SMALL_MODULE_MAX_EXERCISES: i64 = 5;
13pub const SMALL_MODULE_MAX_CHAPTERS: i64 = 1;
16
17pub fn module_exempt_from_minimum(chapters: i64, exercises: i64) -> bool {
23 exercises <= SMALL_MODULE_MAX_EXERCISES || chapters <= SMALL_MODULE_MAX_CHAPTERS
24}
25
26pub fn minimum_threshold_seconds(chapters: i64, exercises: i64) -> i32 {
29 if module_exempt_from_minimum(chapters, exercises) {
30 0
31 } else {
32 MINIMUM_CHEATER_THRESHOLD_SECONDS
33 }
34}
35
36#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, sqlx::Type, ToSchema)]
38#[sqlx(type_name = "suspected_cheater_status", rename_all = "kebab-case")]
39pub enum SuspectedCheaterStatus {
40 Flagged,
42 ConfirmedCheating,
44 Dismissed,
46}
47
48#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
49
50pub struct SuspectedCheaters {
51 pub id: Uuid,
52 pub user_id: Uuid,
53 pub course_id: Uuid,
54 pub created_at: DateTime<Utc>,
55 pub deleted_at: Option<DateTime<Utc>>,
56 pub updated_at: Option<DateTime<Utc>>,
57 pub total_duration_seconds: Option<i32>,
58 pub total_points: i32,
59 pub status: SuspectedCheaterStatus,
60}
61
62#[derive(Debug, Serialize, Deserialize, ToSchema)]
63
64pub struct ThresholdData {
65 pub duration_seconds: i32,
66}
67
68#[derive(Debug, Serialize, Deserialize)]
69
70pub struct DeletedSuspectedCheater {
71 pub id: i32,
72 pub count: i32,
73}
74
75#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
76
77pub struct Threshold {
78 pub id: Uuid,
79 pub course_module_id: Uuid,
80 pub created_at: DateTime<Utc>,
81 pub updated_at: DateTime<Utc>,
82 pub deleted_at: Option<DateTime<Utc>>,
83 pub duration_seconds: i32,
84}
85
86#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
90pub struct CourseModuleThresholdInfo {
91 pub course_module_id: Uuid,
92 pub configured_duration_seconds: Option<i32>,
95 pub minimum_duration_seconds: i32,
98 pub default_duration_seconds: i32,
100}
101
102pub async fn insert(
103 conn: &mut PgConnection,
104 user_id: Uuid,
105 course_id: Uuid,
106 total_duration_seconds: Option<i32>,
107 total_points: i32,
108) -> ModelResult<bool> {
109 let res = sqlx::query!(
110 "
111 INSERT INTO suspected_cheaters (
112 user_id,
113 total_duration_seconds,
114 total_points,
115 course_id
116 )
117 VALUES ($1, $2, $3, $4)
118 ON CONFLICT (user_id, course_id) WHERE deleted_at IS NULL
119 DO UPDATE SET
120 total_duration_seconds = EXCLUDED.total_duration_seconds,
121 total_points = EXCLUDED.total_points
122 RETURNING *
123 ",
124 user_id,
125 total_duration_seconds,
126 total_points,
127 course_id
128 )
129 .fetch_one(&mut *conn)
130 .await?;
131 Ok(res.status != SuspectedCheaterStatus::Dismissed)
134}
135
136pub async fn insert_thresholds(
137 conn: &mut PgConnection,
138 course_id: Uuid,
139 duration_seconds: i32,
140) -> ModelResult<Threshold> {
141 validate_threshold_duration(duration_seconds)?;
142 let default_module = course_modules::get_default_by_course_id(conn, course_id).await?;
143
144 let threshold = sqlx::query_as!(
145 Threshold,
146 "
147 INSERT INTO cheater_thresholds (
148 course_module_id,
149 duration_seconds
150 )
151 VALUES ($1, $2)
152 ON CONFLICT (course_module_id)
153 DO UPDATE SET
154 duration_seconds = EXCLUDED.duration_seconds,
155 deleted_at = NULL
156 RETURNING *
157 ",
158 default_module.id,
159 duration_seconds,
160 )
161 .fetch_one(conn)
162 .await?;
163
164 Ok(threshold)
165}
166
167pub async fn get_thresholds_by_id(
168 conn: &mut PgConnection,
169 course_id: Uuid,
170) -> ModelResult<Threshold> {
171 let default_module = course_modules::get_default_by_course_id(conn, course_id).await?;
172
173 let thresholds = sqlx::query_as!(
174 Threshold,
175 "
176 SELECT *
177 FROM cheater_thresholds
178 WHERE course_module_id = $1
179 AND deleted_at IS NULL;
180 ",
181 default_module.id
182 )
183 .fetch_one(conn)
184 .await?;
185 Ok(thresholds)
186}
187
188pub async fn get_by_user_id_and_course_id(
189 conn: &mut PgConnection,
190 user_id: Uuid,
191 course_id: Uuid,
192) -> ModelResult<SuspectedCheaters> {
193 let cheater = sqlx::query_as!(
194 SuspectedCheaters,
195 "
196SELECT *
197FROM suspected_cheaters
198WHERE user_id = $1
199 AND course_id = $2
200 AND deleted_at IS NULL;
201 ",
202 user_id,
203 course_id
204 )
205 .fetch_one(conn)
206 .await?;
207 Ok(cheater)
208}
209
210pub async fn dismiss_by_user_id_and_course_id(
214 conn: &mut PgConnection,
215 user_id: Uuid,
216 course_id: Uuid,
217) -> ModelResult<SuspectedCheaters> {
218 let mut tx = conn.begin().await?;
219 let cheater = sqlx::query_as!(
220 SuspectedCheaters,
221 r#"
222UPDATE suspected_cheaters
223SET status = 'dismissed'
224WHERE user_id = $1
225 AND course_id = $2
226 AND deleted_at IS NULL
227RETURNING *
228 "#,
229 user_id,
230 course_id
231 )
232 .fetch_one(&mut *tx)
233 .await?;
234 course_module_completions::update_needs_to_be_reviewed_by_course_and_user_ids(
235 &mut tx, course_id, user_id, false,
236 )
237 .await?;
238 cheating_confirmation_grade_snapshots::restore_and_clear_for_user_course(
239 &mut tx, course_id, user_id,
240 )
241 .await?;
242 tx.commit().await?;
243 Ok(cheater)
244}
245
246pub async fn confirm_cheater_by_user_id_and_course_id(
250 conn: &mut PgConnection,
251 user_id: Uuid,
252 course_id: Uuid,
253) -> ModelResult<SuspectedCheaters> {
254 let mut tx = conn.begin().await?;
255 let cheater = sqlx::query_as!(
256 SuspectedCheaters,
257 r#"
258UPDATE suspected_cheaters
259SET status = 'confirmed-cheating'
260WHERE user_id = $1
261 AND course_id = $2
262 AND deleted_at IS NULL
263RETURNING *
264 "#,
265 user_id,
266 course_id
267 )
268 .fetch_one(&mut *tx)
269 .await?;
270 cheating_confirmation_grade_snapshots::snapshot_and_fail_completions(
271 &mut tx, course_id, user_id,
272 )
273 .await?;
274 tx.commit().await?;
275 Ok(cheater)
276}
277
278pub async fn get_suspected_cheaters_by_id(
279 conn: &mut PgConnection,
280 id: Uuid,
281) -> ModelResult<SuspectedCheaters> {
282 let cheaters = sqlx::query_as!(
283 SuspectedCheaters,
284 "
285 SELECT *
286 FROM suspected_cheaters
287 WHERE user_id = $1
288 AND deleted_at IS NULL;
289 ",
290 id
291 )
292 .fetch_one(conn)
293 .await?;
294 Ok(cheaters)
295}
296
297pub async fn get_all_suspected_cheaters_in_course(
298 conn: &mut PgConnection,
299 course_id: Uuid,
300 status: SuspectedCheaterStatus,
301) -> ModelResult<Vec<SuspectedCheaters>> {
302 let cheaters = sqlx::query_as!(
303 SuspectedCheaters,
304 r#"
305SELECT *
306FROM suspected_cheaters
307WHERE course_id = $1
308 AND status = $2
309 AND deleted_at IS NULL;
310 "#,
311 course_id,
312 status as SuspectedCheaterStatus
313 )
314 .fetch_all(conn)
315 .await?;
316 Ok(cheaters)
317}
318
319pub async fn get_count_in_course_by_status(
321 conn: &mut PgConnection,
322 course_id: Uuid,
323 status: SuspectedCheaterStatus,
324) -> ModelResult<i64> {
325 let count = sqlx::query_scalar!(
326 r#"
327SELECT COUNT(*) AS "count!"
328FROM suspected_cheaters
329WHERE course_id = $1
330 AND status = $2
331 AND deleted_at IS NULL
332 "#,
333 course_id,
334 status as SuspectedCheaterStatus
335 )
336 .fetch_one(conn)
337 .await?;
338 Ok(count)
339}
340
341fn validate_threshold_duration(duration_seconds: i32) -> ModelResult<()> {
345 if duration_seconds < 0 {
346 return Err(ModelError::new(
347 ModelErrorType::InvalidRequest,
348 "Cheater threshold duration cannot be negative.".to_string(),
349 None,
350 ));
351 }
352 Ok(())
353}
354
355pub async fn insert_thresholds_by_module_id(
356 conn: &mut PgConnection,
357 course_module_id: Uuid,
358 duration_seconds: i32,
359) -> ModelResult<Threshold> {
360 validate_threshold_duration(duration_seconds)?;
361 let threshold = sqlx::query_as!(
362 Threshold,
363 "
364 INSERT INTO cheater_thresholds (
365 course_module_id,
366 duration_seconds
367 )
368 VALUES ($1, $2)
369 ON CONFLICT (course_module_id)
370 DO UPDATE SET
371 duration_seconds = EXCLUDED.duration_seconds,
372 deleted_at = NULL
373 RETURNING *
374 ",
375 course_module_id,
376 duration_seconds,
377 )
378 .fetch_one(conn)
379 .await?;
380
381 Ok(threshold)
382}
383
384pub async fn get_thresholds_by_module_id(
385 conn: &mut PgConnection,
386 course_module_id: Uuid,
387) -> ModelResult<Option<Threshold>> {
388 let threshold = sqlx::query_as!(
389 Threshold,
390 "
391 SELECT *
392 FROM cheater_thresholds
393 WHERE course_module_id = $1
394 AND deleted_at IS NULL;
395 ",
396 course_module_id
397 )
398 .fetch_optional(conn)
399 .await?;
400 Ok(threshold)
401}
402
403pub async fn get_threshold_info_for_course(
407 conn: &mut PgConnection,
408 course_id: Uuid,
409) -> ModelResult<Vec<CourseModuleThresholdInfo>> {
410 let rows = sqlx::query!(
411 r#"
412SELECT cm.id AS "course_module_id!",
413 ct.duration_seconds AS "configured_duration_seconds?",
414 COUNT(DISTINCT c.id) AS "chapters!",
415 COUNT(e.id) AS "exercises!"
416FROM course_modules cm
417 LEFT JOIN cheater_thresholds ct ON ct.course_module_id = cm.id
418 AND ct.deleted_at IS NULL
419 LEFT JOIN chapters c ON c.course_module_id = cm.id
420 AND c.deleted_at IS NULL
421 LEFT JOIN exercises e ON e.chapter_id = c.id
422 AND e.deleted_at IS NULL
423WHERE cm.course_id = $1
424 AND cm.deleted_at IS NULL
425GROUP BY cm.id, ct.duration_seconds
426 "#,
427 course_id
428 )
429 .fetch_all(conn)
430 .await?;
431 let info = rows
432 .into_iter()
433 .map(|row| CourseModuleThresholdInfo {
434 course_module_id: row.course_module_id,
435 configured_duration_seconds: row.configured_duration_seconds,
436 minimum_duration_seconds: minimum_threshold_seconds(row.chapters, row.exercises),
437 default_duration_seconds: DEFAULT_CHEATER_THRESHOLD_SECONDS,
438 })
439 .collect();
440 Ok(info)
441}
442
443pub async fn delete_threshold_for_module(
444 conn: &mut PgConnection,
445 course_module_id: Uuid,
446) -> ModelResult<()> {
447 sqlx::query!(
448 "
449 UPDATE cheater_thresholds
450 SET deleted_at = NOW()
451 WHERE course_module_id = $1
452 AND deleted_at IS NULL
453 ",
454 course_module_id
455 )
456 .execute(conn)
457 .await?;
458 Ok(())
459}