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    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/// Per-module threshold configuration plus the policy-derived limits the configuration UI needs to
87/// render and validate the threshold form. Computed server-side so the exemption rule and the
88/// minimum/default values live in one place instead of being duplicated in the frontend.
89#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
90pub struct CourseModuleThresholdInfo {
91    pub course_module_id: Uuid,
92    /// The explicitly configured threshold in seconds, or `None` when the module has no threshold
93    /// row and [`Self::default_duration_seconds`] applies.
94    pub configured_duration_seconds: Option<i32>,
95    /// The smallest threshold a teacher may save for this module: `0` for small (exempt) modules,
96    /// otherwise [`MINIMUM_CHEATER_THRESHOLD_SECONDS`].
97    pub minimum_duration_seconds: i32,
98    /// The threshold applied when none is configured.
99    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    // A suspicion is "active" (still needs review) unless it has been dismissed as a false
132    // alarm. A new row defaults to Flagged; an existing row keeps its status on conflict.
133    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
210/// Dismisses the suspicion against a student (marks it a false alarm), clears the
211/// "needs to be reviewed" flag on their completions, and restores any grade that a prior cheating
212/// confirmation had failed (a no-op if the student was never confirmed). Atomic.
213pub 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
246/// Confirms that a student cheated and applies the consequence: their completions in the course are
247/// failed (passed = false, grade = 0), with the previous values snapshotted so the confirmation can
248/// be undone by [`dismiss_by_user_id_and_course_id`]. Atomic.
249pub 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
319/// Counts the suspected cheaters in a given review state for a course.
320pub 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
341/// Guards the invariant that a stored threshold is never negative. `progressing.rs` treats a
342/// stored value of `<= 0` as "duration check disabled", so a stray negative write would silently
343/// turn off cheater detection; rejecting it here protects every writer, not just the HTTP handler.
344fn 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
403/// Returns the configured threshold (if any) and the policy-derived minimum/default for every
404/// non-deleted module in the course. The exemption rule is applied here so the configuration UI
405/// does not have to recompute module sizes or duplicate the threshold constants.
406pub 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}