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 course_module_id: Option<Uuid>,
57 pub created_at: DateTime<Utc>,
58 pub deleted_at: Option<DateTime<Utc>>,
59 pub updated_at: Option<DateTime<Utc>>,
60 pub total_duration_seconds: Option<i32>,
61 pub total_points: i32,
62 pub status: SuspectedCheaterStatus,
63}
64
65#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
68pub struct UserSuspectedCheaterInfo {
69 pub course_id: Uuid,
70 pub status: SuspectedCheaterStatus,
71 pub total_duration_seconds: Option<i32>,
72 pub total_points: i32,
73 pub first_flagged_at: DateTime<Utc>,
75 pub threshold_seconds: i32,
77}
78
79#[derive(Debug, Serialize, Deserialize, ToSchema)]
80
81pub struct ThresholdData {
82 pub duration_seconds: i32,
83}
84
85#[derive(Debug, Serialize, Deserialize)]
86
87pub struct DeletedSuspectedCheater {
88 pub id: i32,
89 pub count: i32,
90}
91
92#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
93
94pub struct Threshold {
95 pub id: Uuid,
96 pub course_module_id: Uuid,
97 pub created_at: DateTime<Utc>,
98 pub updated_at: DateTime<Utc>,
99 pub deleted_at: Option<DateTime<Utc>>,
100 pub duration_seconds: i32,
101}
102
103#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
107pub struct CourseModuleThresholdInfo {
108 pub course_module_id: Uuid,
109 pub configured_duration_seconds: Option<i32>,
112 pub minimum_duration_seconds: i32,
115 pub default_duration_seconds: i32,
117}
118
119pub async fn insert(
120 conn: &mut PgConnection,
121 user_id: Uuid,
122 course_id: Uuid,
123 course_module_id: Uuid,
124 total_duration_seconds: Option<i32>,
125 total_points: i32,
126) -> ModelResult<bool> {
127 let res = sqlx::query!(
128 "
129 INSERT INTO suspected_cheaters (
130 user_id,
131 total_duration_seconds,
132 total_points,
133 course_id,
134 course_module_id
135 )
136 VALUES ($1, $2, $3, $4, $5)
137 ON CONFLICT (user_id, course_id) WHERE deleted_at IS NULL
138 DO UPDATE SET
139 total_duration_seconds = EXCLUDED.total_duration_seconds,
140 total_points = EXCLUDED.total_points,
141 course_module_id = EXCLUDED.course_module_id
142 RETURNING *
143 ",
144 user_id,
145 total_duration_seconds,
146 total_points,
147 course_id,
148 course_module_id
149 )
150 .fetch_one(&mut *conn)
151 .await?;
152 Ok(res.status != SuspectedCheaterStatus::Dismissed)
155}
156
157pub async fn insert_thresholds(
158 conn: &mut PgConnection,
159 course_id: Uuid,
160 duration_seconds: i32,
161) -> ModelResult<Threshold> {
162 validate_threshold_duration(duration_seconds)?;
163 let default_module = course_modules::get_default_by_course_id(conn, course_id).await?;
164
165 let threshold = sqlx::query_as!(
166 Threshold,
167 "
168 INSERT INTO cheater_thresholds (
169 course_module_id,
170 duration_seconds
171 )
172 VALUES ($1, $2)
173 ON CONFLICT (course_module_id)
174 DO UPDATE SET
175 duration_seconds = EXCLUDED.duration_seconds,
176 deleted_at = NULL
177 RETURNING *
178 ",
179 default_module.id,
180 duration_seconds,
181 )
182 .fetch_one(conn)
183 .await?;
184
185 Ok(threshold)
186}
187
188pub async fn get_thresholds_by_id(
189 conn: &mut PgConnection,
190 course_id: Uuid,
191) -> ModelResult<Threshold> {
192 let default_module = course_modules::get_default_by_course_id(conn, course_id).await?;
193
194 let thresholds = sqlx::query_as!(
195 Threshold,
196 "
197 SELECT *
198 FROM cheater_thresholds
199 WHERE course_module_id = $1
200 AND deleted_at IS NULL;
201 ",
202 default_module.id
203 )
204 .fetch_one(conn)
205 .await?;
206 Ok(thresholds)
207}
208
209pub async fn get_by_user_id_and_course_id(
210 conn: &mut PgConnection,
211 user_id: Uuid,
212 course_id: Uuid,
213) -> ModelResult<SuspectedCheaters> {
214 let cheater = sqlx::query_as!(
215 SuspectedCheaters,
216 "
217SELECT *
218FROM suspected_cheaters
219WHERE user_id = $1
220 AND course_id = $2
221 AND deleted_at IS NULL;
222 ",
223 user_id,
224 course_id
225 )
226 .fetch_one(conn)
227 .await?;
228 Ok(cheater)
229}
230
231pub async fn dismiss_by_user_id_and_course_id(
235 conn: &mut PgConnection,
236 user_id: Uuid,
237 course_id: Uuid,
238) -> ModelResult<SuspectedCheaters> {
239 let mut tx = conn.begin().await?;
240 let cheater = sqlx::query_as!(
241 SuspectedCheaters,
242 r#"
243UPDATE suspected_cheaters
244SET status = 'dismissed'
245WHERE user_id = $1
246 AND course_id = $2
247 AND deleted_at IS NULL
248RETURNING *
249 "#,
250 user_id,
251 course_id
252 )
253 .fetch_one(&mut *tx)
254 .await?;
255 course_module_completions::update_needs_to_be_reviewed_by_course_and_user_ids(
256 &mut tx, course_id, user_id, false,
257 )
258 .await?;
259 cheating_confirmation_grade_snapshots::restore_and_clear_for_user_course(
260 &mut tx, course_id, user_id,
261 )
262 .await?;
263 tx.commit().await?;
264 Ok(cheater)
265}
266
267pub async fn confirm_cheater_by_user_id_and_course_id(
271 conn: &mut PgConnection,
272 user_id: Uuid,
273 course_id: Uuid,
274) -> ModelResult<SuspectedCheaters> {
275 let mut tx = conn.begin().await?;
276 let cheater = sqlx::query_as!(
277 SuspectedCheaters,
278 r#"
279UPDATE suspected_cheaters
280SET status = 'confirmed-cheating'
281WHERE user_id = $1
282 AND course_id = $2
283 AND deleted_at IS NULL
284RETURNING *
285 "#,
286 user_id,
287 course_id
288 )
289 .fetch_one(&mut *tx)
290 .await?;
291 cheating_confirmation_grade_snapshots::snapshot_and_fail_completions(
292 &mut tx, course_id, user_id,
293 )
294 .await?;
295 tx.commit().await?;
296 Ok(cheater)
297}
298
299pub async fn get_all_by_user_id(
302 conn: &mut PgConnection,
303 user_id: Uuid,
304) -> ModelResult<Vec<SuspectedCheaters>> {
305 let cheaters = sqlx::query_as!(
306 SuspectedCheaters,
307 r#"
308SELECT *
309FROM suspected_cheaters
310WHERE user_id = $1
311 AND deleted_at IS NULL
312 "#,
313 user_id
314 )
315 .fetch_all(conn)
316 .await?;
317 Ok(cheaters)
318}
319
320pub async fn get_applicable_threshold_seconds(
325 conn: &mut PgConnection,
326 course_id: Uuid,
327) -> ModelResult<i32> {
328 let default_module = course_modules::get_default_by_course_id(conn, course_id).await?;
329 let threshold = get_thresholds_by_module_id(conn, default_module.id)
330 .await?
331 .map(|t| t.duration_seconds)
332 .unwrap_or(DEFAULT_CHEATER_THRESHOLD_SECONDS);
333 Ok(threshold)
334}
335
336pub async fn get_suspected_cheater_info_for_user(
340 conn: &mut PgConnection,
341 user_id: Uuid,
342) -> ModelResult<Vec<UserSuspectedCheaterInfo>> {
343 let rows = get_all_by_user_id(conn, user_id).await?;
344 let mut info = Vec::with_capacity(rows.len());
345 for row in rows {
346 let threshold_seconds = match row.course_module_id {
347 Some(course_module_id) => get_thresholds_by_module_id(conn, course_module_id)
348 .await?
349 .map(|t| t.duration_seconds)
350 .unwrap_or(DEFAULT_CHEATER_THRESHOLD_SECONDS),
351 None => get_applicable_threshold_seconds(conn, row.course_id).await?,
352 };
353 info.push(UserSuspectedCheaterInfo {
354 course_id: row.course_id,
355 status: row.status,
356 total_duration_seconds: row.total_duration_seconds,
357 total_points: row.total_points,
358 first_flagged_at: row.created_at,
359 threshold_seconds,
360 });
361 }
362 Ok(info)
363}
364
365pub async fn get_all_suspected_cheaters_in_course(
366 conn: &mut PgConnection,
367 course_id: Uuid,
368 status: SuspectedCheaterStatus,
369) -> ModelResult<Vec<SuspectedCheaters>> {
370 let cheaters = sqlx::query_as!(
371 SuspectedCheaters,
372 r#"
373SELECT *
374FROM suspected_cheaters
375WHERE course_id = $1
376 AND status = $2
377 AND deleted_at IS NULL;
378 "#,
379 course_id,
380 status as SuspectedCheaterStatus
381 )
382 .fetch_all(conn)
383 .await?;
384 Ok(cheaters)
385}
386
387pub async fn get_count_in_course_by_status(
389 conn: &mut PgConnection,
390 course_id: Uuid,
391 status: SuspectedCheaterStatus,
392) -> ModelResult<i64> {
393 let count = sqlx::query_scalar!(
394 r#"
395SELECT COUNT(*) AS "count!"
396FROM suspected_cheaters
397WHERE course_id = $1
398 AND status = $2
399 AND deleted_at IS NULL
400 "#,
401 course_id,
402 status as SuspectedCheaterStatus
403 )
404 .fetch_one(conn)
405 .await?;
406 Ok(count)
407}
408
409fn validate_threshold_duration(duration_seconds: i32) -> ModelResult<()> {
413 if duration_seconds < 0 {
414 return Err(ModelError::new(
415 ModelErrorType::InvalidRequest,
416 "Cheater threshold duration cannot be negative.".to_string(),
417 None,
418 ));
419 }
420 Ok(())
421}
422
423pub async fn insert_thresholds_by_module_id(
424 conn: &mut PgConnection,
425 course_module_id: Uuid,
426 duration_seconds: i32,
427) -> ModelResult<Threshold> {
428 validate_threshold_duration(duration_seconds)?;
429 let threshold = sqlx::query_as!(
430 Threshold,
431 "
432 INSERT INTO cheater_thresholds (
433 course_module_id,
434 duration_seconds
435 )
436 VALUES ($1, $2)
437 ON CONFLICT (course_module_id)
438 DO UPDATE SET
439 duration_seconds = EXCLUDED.duration_seconds,
440 deleted_at = NULL
441 RETURNING *
442 ",
443 course_module_id,
444 duration_seconds,
445 )
446 .fetch_one(conn)
447 .await?;
448
449 Ok(threshold)
450}
451
452pub async fn get_thresholds_by_module_id(
453 conn: &mut PgConnection,
454 course_module_id: Uuid,
455) -> ModelResult<Option<Threshold>> {
456 let threshold = sqlx::query_as!(
457 Threshold,
458 "
459 SELECT *
460 FROM cheater_thresholds
461 WHERE course_module_id = $1
462 AND deleted_at IS NULL;
463 ",
464 course_module_id
465 )
466 .fetch_optional(conn)
467 .await?;
468 Ok(threshold)
469}
470
471pub async fn get_threshold_info_for_course(
475 conn: &mut PgConnection,
476 course_id: Uuid,
477) -> ModelResult<Vec<CourseModuleThresholdInfo>> {
478 let rows = sqlx::query!(
479 r#"
480SELECT cm.id AS "course_module_id!",
481 ct.duration_seconds AS "configured_duration_seconds?",
482 COUNT(DISTINCT c.id) AS "chapters!",
483 COUNT(e.id) AS "exercises!"
484FROM course_modules cm
485 LEFT JOIN cheater_thresholds ct ON ct.course_module_id = cm.id
486 AND ct.deleted_at IS NULL
487 LEFT JOIN chapters c ON c.course_module_id = cm.id
488 AND c.deleted_at IS NULL
489 LEFT JOIN exercises e ON e.chapter_id = c.id
490 AND e.deleted_at IS NULL
491WHERE cm.course_id = $1
492 AND cm.deleted_at IS NULL
493GROUP BY cm.id, ct.duration_seconds
494 "#,
495 course_id
496 )
497 .fetch_all(conn)
498 .await?;
499 let info = rows
500 .into_iter()
501 .map(|row| CourseModuleThresholdInfo {
502 course_module_id: row.course_module_id,
503 configured_duration_seconds: row.configured_duration_seconds,
504 minimum_duration_seconds: minimum_threshold_seconds(row.chapters, row.exercises),
505 default_duration_seconds: DEFAULT_CHEATER_THRESHOLD_SECONDS,
506 })
507 .collect();
508 Ok(info)
509}
510
511pub async fn delete_threshold_for_module(
512 conn: &mut PgConnection,
513 course_module_id: Uuid,
514) -> ModelResult<()> {
515 sqlx::query!(
516 "
517 UPDATE cheater_thresholds
518 SET deleted_at = NOW()
519 WHERE course_module_id = $1
520 AND deleted_at IS NULL
521 ",
522 course_module_id
523 )
524 .execute(conn)
525 .await?;
526 Ok(())
527}