Skip to main content

headless_lms_models/
suspected_cheaters.rs

1use crate::prelude::*;
2use crate::{cheating_confirmation_grade_snapshots, course_module_completions, course_modules};
3use utoipa::ToSchema;
4
5/// 3 hours, in seconds. Default threshold used when a course has no explicit threshold
6/// configured but cheater detection is enabled.
7pub const DEFAULT_CHEATER_THRESHOLD_SECONDS: i32 = 3 * 60 * 60;
8/// Teachers cannot configure a threshold below this (3 hours).
9pub const MINIMUM_CHEATER_THRESHOLD_SECONDS: i32 = 3 * 60 * 60;
10/// Modules with at most this many exercises are exempt from the minimum threshold; for them any
11/// duration >= 0 is allowed, where 0 turns the duration check off.
12pub const SMALL_MODULE_MAX_EXERCISES: i64 = 5;
13/// Modules with at most this many chapters are exempt from the minimum threshold; for them any
14/// duration >= 0 is allowed, where 0 turns the duration check off.
15pub const SMALL_MODULE_MAX_CHAPTERS: i64 = 1;
16
17/// Whether a module of the given size is exempt from the minimum cheater threshold. Small modules
18/// can legitimately be completed fast, so for them any duration >= 0 is allowed (0 disables the
19/// duration check). This is the single source of truth for the exemption rule -- both the save-time
20/// validation and the configuration UI derive their behaviour from it (the latter via
21/// [`get_threshold_info_for_course`]).
22pub fn module_exempt_from_minimum(chapters: i64, exercises: i64) -> bool {
23    exercises <= SMALL_MODULE_MAX_EXERCISES || chapters <= SMALL_MODULE_MAX_CHAPTERS
24}
25
26/// The smallest threshold (in seconds) a teacher may configure for a module of the given size:
27/// `0` for small (exempt) modules, otherwise [`MINIMUM_CHEATER_THRESHOLD_SECONDS`].
28pub 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/// Review state of a suspected cheater.
37#[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    /// Auto-flagged by the system (completed faster than the threshold), awaiting teacher review.
41    Flagged,
42    /// A teacher confirmed the student cheated. The student is failed as a consequence.
43    ConfirmedCheating,
44    /// A teacher decided the suspicion was a false alarm.
45    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    /// The module completion that triggered the flag. `None` only for legacy rows the backfill
55    /// couldn't map to a default module.
56    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/// A user's suspected-cheater record in one course, paired with that course's duration threshold.
66/// Read-only, for the cross-course "Completion review" list on the user-details page.
67#[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    /// When first flagged in this course (record `created_at`, unchanged on re-flag).
74    pub first_flagged_at: DateTime<Utc>,
75    /// Threshold (seconds) of the module that triggered the flag; the student completed faster.
76    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/// Per-module threshold configuration plus the policy-derived limits the configuration UI needs to
104/// render and validate the threshold form. Computed server-side so the exemption rule and the
105/// minimum/default values live in one place instead of being duplicated in the frontend.
106#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
107pub struct CourseModuleThresholdInfo {
108    pub course_module_id: Uuid,
109    /// The explicitly configured threshold in seconds, or `None` when the module has no threshold
110    /// row and [`Self::default_duration_seconds`] applies.
111    pub configured_duration_seconds: Option<i32>,
112    /// The smallest threshold a teacher may save for this module: `0` for small (exempt) modules,
113    /// otherwise [`MINIMUM_CHEATER_THRESHOLD_SECONDS`].
114    pub minimum_duration_seconds: i32,
115    /// The threshold applied when none is configured.
116    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    // A suspicion is "active" (still needs review) unless it has been dismissed as a false
153    // alarm. A new row defaults to Flagged; an existing row keeps its status on conflict.
154    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
231/// Dismisses the suspicion against a student (marks it a false alarm), clears the
232/// "needs to be reviewed" flag on their completions, and restores any grade that a prior cheating
233/// confirmation had failed (a no-op if the student was never confirmed). Atomic.
234pub 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
267/// Confirms that a student cheated and applies the consequence: their completions in the course are
268/// failed (passed = false, grade = 0), with the previous values snapshotted so the confirmation can
269/// be undone by [`dismiss_by_user_id_and_course_id`]. Atomic.
270pub 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
299/// All non-deleted suspected-cheater records for a user across courses. A user can be flagged in
300/// more than one course, hence a list.
301pub 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
320/// Fallback duration threshold (seconds) for a course: the DEFAULT module's configured threshold, or
321/// [`DEFAULT_CHEATER_THRESHOLD_SECONDS`] if unset. Only used for legacy flag rows without a recorded
322/// triggering module; otherwise the threshold is resolved from that module (see
323/// [`get_suspected_cheater_info_for_user`]).
324pub 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
336/// Each course where the user has a non-deleted suspected-cheater record, paired with the threshold
337/// that flagged them. The threshold is resolved from the triggering module (matching the flagging
338/// logic in `library/progressing.rs`), falling back to the default-module threshold for legacy rows.
339pub 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
387/// Counts the suspected cheaters in a given review state for a course.
388pub 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
409/// Guards the invariant that a stored threshold is never negative. `progressing.rs` treats a
410/// stored value of `<= 0` as "duration check disabled", so a stray negative write would silently
411/// turn off cheater detection; rejecting it here protects every writer, not just the HTTP handler.
412fn 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
471/// Returns the configured threshold (if any) and the policy-derived minimum/default for every
472/// non-deleted module in the course. The exemption rule is applied here so the configuration UI
473/// does not have to recompute module sizes or duplicate the threshold constants.
474pub 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}