Skip to main content

headless_lms_models/library/
progressing.rs

1use chrono::{DateTime, Utc};
2use itertools::Itertools;
3use std::collections::HashMap;
4use utoipa::ToSchema;
5
6use crate::{
7    course_exams,
8    course_instance_enrollments::{self, NewCourseInstanceEnrollment},
9    course_instances::{self, CourseInstance},
10    course_module_completions::{
11        self, CourseModuleCompletion, CourseModuleCompletionGranter,
12        CourseModuleCompletionWithRegistrationInfo, NewCourseModuleCompletion,
13    },
14    course_modules::{self, AutomaticCompletionRequirements, CompletionPolicy, CourseModule},
15    courses, exams, open_university_registration_links,
16    prelude::*,
17    suspected_cheaters, user_course_settings,
18    user_details::UserDetail,
19    user_exercise_states,
20    users::{self, User},
21};
22
23/// Checks whether the course module can be completed automatically and creates an entry for completion
24/// if the user meets the criteria. Also re-checks module completion prerequisites if the module is
25/// completed.
26pub async fn update_automatic_completion_status_and_grant_if_eligible(
27    conn: &mut PgConnection,
28    course_module: &CourseModule,
29    user_id: Uuid,
30) -> ModelResult<()> {
31    let mut tx = conn.begin().await?;
32    let completion =
33        create_automatic_course_module_completion_if_eligible(&mut tx, course_module, user_id)
34            .await?;
35    if let Some(completion) = completion {
36        let course = courses::get_course(&mut tx, course_module.course_id).await?;
37        let submodule_completions_required = course
38            .base_module_completion_requires_n_submodule_completions
39            .try_into()?;
40        update_module_completion_prerequisite_statuses_for_user(
41            &mut tx,
42            user_id,
43            course_module.course_id,
44            submodule_completions_required,
45        )
46        .await?;
47
48        if course.cheater_detection_enabled {
49            // Detection is on by default. When the course has no explicit threshold configured,
50            // fall back to the default (3 hours).
51            let threshold_seconds = suspected_cheaters::get_thresholds_by_module_id(
52                &mut tx,
53                completion.course_module_id,
54            )
55            .await?
56            .map(|t| t.duration_seconds)
57            .unwrap_or(suspected_cheaters::DEFAULT_CHEATER_THRESHOLD_SECONDS);
58            // A threshold of 0 (or less) is the documented per-module off-switch for the duration
59            // check. Only an explicitly configured 0 disables it -- the default fallback above is
60            // always positive.
61            if threshold_seconds > 0 {
62                check_and_insert_suspected_cheaters(
63                    &mut tx,
64                    user_id,
65                    course.id,
66                    threshold_seconds,
67                    completion,
68                )
69                .await?;
70            }
71        }
72    }
73    tx.commit().await?;
74    Ok(())
75}
76
77pub async fn check_and_insert_suspected_cheaters(
78    conn: &mut PgConnection,
79    user_id: Uuid,
80    course_id: Uuid,
81    threshold_seconds: i32,
82    completion: CourseModuleCompletion,
83) -> ModelResult<()> {
84    // A teacher-granted (manual) completion means the teacher has vouched for the student, so they
85    // are not subject to automatic cheating suspicion for this course.
86    if course_module_completions::user_has_manual_completion_in_course(conn, user_id, course_id)
87        .await?
88    {
89        return Ok(());
90    }
91
92    let total_points = user_exercise_states::get_user_total_course_points(conn, user_id, course_id)
93        .await?
94        .unwrap_or(0.0);
95
96    let completed_module = course_modules::get_by_id(conn, completion.course_module_id).await?;
97    let is_default_module = completed_module.is_default_module();
98
99    let student_duration_seconds = if is_default_module {
100        course_instances::get_student_duration(conn, completion.user_id, course_id)
101            .await?
102            .unwrap_or(0)
103    } else {
104        let default_module = course_modules::get_default_by_course_id(conn, course_id).await?;
105        let default_completion = course_module_completions::get_all_by_course_module_and_user_ids(
106            conn,
107            default_module.id,
108            completion.user_id,
109        )
110        .await?
111        .into_iter()
112        .max_by_key(|c| c.completion_date);
113
114        if let Some(default_completion) = default_completion {
115            let duration =
116                (completion.completion_date - default_completion.completion_date).num_seconds();
117            duration.max(0)
118        } else {
119            // No default completion exists yet, fall back to calculating duration from enrollment time.
120            course_instances::get_student_duration(conn, completion.user_id, course_id)
121                .await?
122                .unwrap_or(0)
123        }
124    };
125
126    if (student_duration_seconds as i32) < threshold_seconds {
127        let suspicion_is_active = suspected_cheaters::insert(
128            conn,
129            completion.user_id,
130            course_id,
131            Some(student_duration_seconds as i32),
132            total_points as i32,
133        )
134        .await?;
135
136        if suspicion_is_active {
137            course_module_completions::update_needs_to_be_reviewed(conn, completion.id, true)
138                .await?;
139        }
140    }
141
142    Ok(())
143}
144
145/// Creates completion for the user if eligible and previous one doesn't exist. Returns an Option containing
146/// the completion if one exists after calling this function.
147#[instrument(skip(conn))]
148async fn create_automatic_course_module_completion_if_eligible(
149    conn: &mut PgConnection,
150    course_module: &CourseModule,
151    user_id: Uuid,
152) -> ModelResult<Option<CourseModuleCompletion>> {
153    let existing_completion =
154        course_module_completions::get_automatic_completion_by_course_module_course_and_user_ids(
155            conn,
156            course_module.id,
157            course_module.course_id,
158            user_id,
159        )
160        .await
161        .optional()?;
162    if let Some(existing_completion) = existing_completion {
163        // If user already has a completion, do not attempt to create a new one.
164        Ok(Some(existing_completion))
165    } else {
166        let eligible =
167            user_is_eligible_for_automatic_completion(conn, course_module, user_id).await?;
168        if eligible {
169            let course = courses::get_course(conn, course_module.course_id).await?;
170            let user = users::get_by_id(conn, user_id).await?;
171            if user.deleted_at.is_some() {
172                warn!("Cannot create a completion for a deleted user");
173                return Ok(None);
174            }
175            let user_details =
176                crate::user_details::get_user_details_by_user_id(conn, user.id).await?;
177            let completion = course_module_completions::insert(
178                conn,
179                PKeyPolicy::Generate,
180                &NewCourseModuleCompletion {
181                    course_id: course_module.course_id,
182                    course_module_id: course_module.id,
183                    user_id,
184                    completion_date: Utc::now(),
185                    completion_registration_attempt_date: None,
186                    completion_language: course.language_code,
187                    eligible_for_ects: true,
188                    email: user_details.email,
189                    grade: None,
190                    passed: true,
191                },
192                CourseModuleCompletionGranter::Automatic,
193            )
194            .await?;
195            info!("Created a completion");
196            Ok(Some(completion))
197        } else {
198            // Can't grant automatic completion; no-op.
199            Ok(None)
200        }
201    }
202}
203
204#[instrument(skip(conn))]
205async fn user_is_eligible_for_automatic_completion(
206    conn: &mut PgConnection,
207    course_module: &CourseModule,
208    user_id: Uuid,
209) -> ModelResult<bool> {
210    match &course_module.completion_policy {
211        CompletionPolicy::Automatic(requirements) => {
212            let eligible = user_passes_automatic_completion_exercise_tresholds(
213                conn,
214                user_id,
215                requirements,
216                course_module.course_id,
217            )
218            .await?;
219            if eligible {
220                if requirements.requires_exam {
221                    info!("To complete this module automatically, the user must pass an exam.");
222                    user_has_passed_exam_for_the_course_based_on_points(
223                        conn,
224                        user_id,
225                        course_module.course_id,
226                    )
227                    .await
228                } else {
229                    Ok(true)
230                }
231            } else {
232                Ok(false)
233            }
234        }
235        CompletionPolicy::Manual => Ok(false),
236    }
237}
238
239/// Checks whether the student can partake in an exam.
240///
241/// The result of this process depends on the configuration for the exam. If the exam is not linked
242/// to any course, the user will always be able to take it by default. Otherwise the student
243/// progress in their current selected instances is compared against any of the linked courses, and
244/// checked whether any pass the exercise completion tresholds. Finally, if none of the courses have
245/// automatic completion configuration, the exam is once again allowed to be taken by default.
246#[instrument(skip(conn))]
247pub async fn user_can_take_exam(
248    conn: &mut PgConnection,
249    exam_id: Uuid,
250    user_id: Uuid,
251) -> ModelResult<bool> {
252    let course_ids = course_exams::get_course_ids_by_exam_id(conn, exam_id).await?;
253    let settings = user_course_settings::get_all_by_user_and_multiple_current_courses(
254        conn,
255        &course_ids,
256        user_id,
257    )
258    .await?;
259    // User can take the exam by default if course_ids is an empty array.
260    let mut can_take_exam = true;
261    for course_id in course_ids {
262        let default_module = course_modules::get_default_by_course_id(conn, course_id).await?;
263        if let CompletionPolicy::Automatic(requirements) = &default_module.completion_policy {
264            if let Some(s) = settings.iter().find(|x| x.current_course_id == course_id) {
265                let eligible = user_passes_automatic_completion_exercise_tresholds(
266                    conn,
267                    s.user_id,
268                    requirements,
269                    s.current_course_id,
270                )
271                .await?;
272                if eligible {
273                    // Only one current instance needs to pass the tresholds.
274                    can_take_exam = true;
275                    break;
276                }
277            }
278            // If there is at least one associated course with requirements, make sure that the user
279            // passes one of them.
280            can_take_exam = false;
281        }
282    }
283    Ok(can_take_exam)
284}
285
286/// Returns true if there is at least one exam associated with the course, that has ended and the
287/// user has received enough points from it.
288async fn user_has_passed_exam_for_the_course_based_on_points(
289    conn: &mut PgConnection,
290    user_id: Uuid,
291    course_id: Uuid,
292) -> ModelResult<bool> {
293    let now = Utc::now();
294    let exam_ids = course_exams::get_exam_ids_by_course_id(conn, course_id).await?;
295    for exam_id in exam_ids {
296        let exam = exams::get(conn, exam_id).await?;
297        // A minimum points threshold of 0 indicates that the "Related courses can be completed automatically" option has not been enabled by the teacher. If you wish to remove this condition, please first store this information in a separate column in the exams table.
298        if exam.minimum_points_treshold == 0 || exam.grade_manually {
299            continue;
300        }
301        if exam.ended_at_or(now, false) {
302            let points =
303                user_exercise_states::get_user_total_exam_points(conn, user_id, exam_id).await?;
304            if let Some(points) = points
305                && points >= exam.minimum_points_treshold as f32
306            {
307                return Ok(true);
308            }
309        }
310    }
311    Ok(false)
312}
313
314async fn user_passes_automatic_completion_exercise_tresholds(
315    conn: &mut PgConnection,
316    user_id: Uuid,
317    requirements: &AutomaticCompletionRequirements,
318    course_id: Uuid,
319) -> ModelResult<bool> {
320    let user_metrics = user_exercise_states::get_single_module_metrics(
321        conn,
322        course_id,
323        requirements.course_module_id,
324        user_id,
325    )
326    .await?;
327    let attempted_exercises: i32 = user_metrics.attempted_exercises.unwrap_or(0) as i32;
328    let exercise_points = user_metrics.score_given.unwrap_or(0.0) as i32;
329    let eligible = requirements.passes_exercise_tresholds(attempted_exercises, exercise_points);
330    Ok(eligible)
331}
332
333/// Fetches all course module completions for the given user on the given course and updates the
334/// prerequisite module completion statuses for any completions that are missing them.
335#[instrument(skip(conn))]
336async fn update_module_completion_prerequisite_statuses_for_user(
337    conn: &mut PgConnection,
338    user_id: Uuid,
339    course_id: Uuid,
340    base_module_completion_requires_n_submodule_completions: u32,
341) -> ModelResult<()> {
342    let default_course_module = course_modules::get_default_by_course_id(conn, course_id).await?;
343    let course_module_completions =
344        course_module_completions::get_all_by_course_id_and_user_id(conn, course_id, user_id)
345            .await?;
346    let default_module_is_completed = course_module_completions
347        .iter()
348        .any(|x| x.course_module_id == default_course_module.id);
349    let submodule_completions = course_module_completions
350        .iter()
351        .filter(|x| x.course_module_id != default_course_module.id)
352        .unique_by(|x| x.course_module_id)
353        .count();
354    let enough_submodule_completions = submodule_completions
355        >= base_module_completion_requires_n_submodule_completions.try_into()?;
356    let completions_needing_processing: Vec<_> = course_module_completions
357        .into_iter()
358        .filter(|x| !x.prerequisite_modules_completed)
359        .collect();
360    for completion in completions_needing_processing {
361        if completion.course_module_id == default_course_module.id {
362            if enough_submodule_completions {
363                course_module_completions::update_prerequisite_modules_completed(
364                    conn,
365                    completion.id,
366                    true,
367                )
368                .await?;
369            }
370        } else if default_module_is_completed {
371            course_module_completions::update_prerequisite_modules_completed(
372                conn,
373                completion.id,
374                true,
375            )
376            .await?;
377        }
378    }
379    Ok(())
380}
381
382/// Goes through all user on a course and grants completions where eligible.
383#[instrument(skip(conn))]
384pub async fn process_all_course_completions(
385    conn: &mut PgConnection,
386    course_id: Uuid,
387) -> ModelResult<()> {
388    info!("Reprocessing course completions");
389    let course = courses::get_course(conn, course_id).await?;
390    let submodule_completions_required = course
391        .base_module_completion_requires_n_submodule_completions
392        .try_into()?;
393    let course_modules = course_modules::get_by_course_id(conn, course_id).await?;
394    // If user has an user exercise state, they might have returned an exercise so we need to check whether they have completed modules.
395    let users =
396        crate::users::get_all_user_ids_with_user_exercise_states_on_course(conn, course_id).await?;
397    info!(users = ?users.len(), course_modules = ?course_modules.len(), ?submodule_completions_required, "Completion reprocessing info");
398    for course_module in course_modules.iter() {
399        info!(?course_module, "Course module information");
400    }
401    let mut tx = conn.begin().await?;
402    for user_id in users {
403        let mut num_completions = 0;
404        for course_module in course_modules.iter() {
405            let completion = create_automatic_course_module_completion_if_eligible(
406                &mut tx,
407                course_module,
408                user_id,
409            )
410            .await?;
411            if completion.is_some() {
412                num_completions += 1;
413            }
414        }
415        if num_completions > 0 {
416            update_module_completion_prerequisite_statuses_for_user(
417                &mut tx,
418                user_id,
419                course_id,
420                submodule_completions_required,
421            )
422            .await?;
423        }
424    }
425    tx.commit().await?;
426    info!("Reprocessing course module completions complete");
427    Ok(())
428}
429
430#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
431
432pub struct CourseInstanceCompletionSummary {
433    pub course_modules: Vec<CourseModule>,
434    pub users_with_course_module_completions: Vec<UserWithModuleCompletions>,
435}
436
437#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
438
439pub struct UserWithModuleCompletions {
440    pub completed_modules: Vec<CourseModuleCompletionWithRegistrationInfo>,
441    pub email: String,
442    pub first_name: Option<String>,
443    pub last_name: Option<String>,
444    pub user_id: Uuid,
445}
446
447#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
448
449pub struct UserCourseModuleCompletion {
450    pub course_module_id: Uuid,
451    pub grade: Option<i32>,
452    pub passed: bool,
453}
454
455impl From<CourseModuleCompletion> for UserCourseModuleCompletion {
456    fn from(course_module_completion: CourseModuleCompletion) -> Self {
457        Self {
458            course_module_id: course_module_completion.course_module_id,
459            grade: course_module_completion.grade,
460            passed: course_module_completion.passed,
461        }
462    }
463}
464
465impl UserWithModuleCompletions {
466    fn from_user_and_details(user: User, user_details: UserDetail) -> Self {
467        Self {
468            user_id: user.id,
469            first_name: user_details.first_name,
470            last_name: user_details.last_name,
471            email: user_details.email,
472            completed_modules: vec![],
473        }
474    }
475}
476
477pub async fn get_course_instance_completion_summary(
478    conn: &mut PgConnection,
479    course_instance: &CourseInstance,
480) -> ModelResult<CourseInstanceCompletionSummary> {
481    let course_modules = course_modules::get_by_course_id(conn, course_instance.course_id).await?;
482    let users_with_course_module_completions_list =
483        users::get_users_by_course_instance_enrollment(conn, course_instance.id).await?;
484    let user_id_to_details_map = crate::user_details::get_users_details_by_user_id_map(
485        conn,
486        &users_with_course_module_completions_list,
487    )
488    .await?;
489    let mut users_with_course_module_completions: HashMap<Uuid, UserWithModuleCompletions> =
490        users_with_course_module_completions_list
491            .into_iter()
492            .filter_map(|o| {
493                let details = user_id_to_details_map.get(&o.id);
494                details.map(|details| (o, details))
495            })
496            .map(|u| {
497                (
498                    u.0.id,
499                    UserWithModuleCompletions::from_user_and_details(u.0, u.1.clone()),
500                )
501            })
502            .collect();
503    let completions =
504        course_module_completions::get_all_with_registration_information_by_course_instance_id(
505            conn,
506            course_instance.id,
507            course_instance.course_id,
508        )
509        .await?;
510    completions.into_iter().for_each(|x| {
511        let user_with_completions = users_with_course_module_completions.get_mut(&x.user_id);
512        if let Some(completion) = user_with_completions {
513            completion.completed_modules.push(x);
514        }
515    });
516    Ok(CourseInstanceCompletionSummary {
517        course_modules,
518        users_with_course_module_completions: users_with_course_module_completions
519            .into_values()
520            .collect(),
521    })
522}
523
524#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
525
526pub struct TeacherManualCompletionRequest {
527    pub course_module_id: Uuid,
528    pub new_completions: Vec<TeacherManualCompletion>,
529    pub skip_duplicate_completions: bool,
530}
531
532#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
533
534pub struct TeacherManualCompletion {
535    pub user_id: Uuid,
536    pub grade: Option<i32>,
537    pub passed: bool,
538    pub completion_date: Option<DateTime<Utc>>,
539}
540
541pub async fn add_manual_completions(
542    conn: &mut PgConnection,
543    completion_giver_user_id: Uuid,
544    course_instance: &CourseInstance,
545    manual_completion_request: &TeacherManualCompletionRequest,
546) -> ModelResult<()> {
547    let course_module =
548        course_modules::get_by_id(conn, manual_completion_request.course_module_id).await?;
549    if course_module.course_id != course_instance.course_id {
550        return Err(ModelError::new(
551            ModelErrorType::PreconditionFailed,
552            "Course module not part of the course.".to_string(),
553            None,
554        ));
555    }
556    let course = courses::get_course(conn, course_instance.course_id).await?;
557    let mut tx = conn.begin().await?;
558    for completion in manual_completion_request.new_completions.iter() {
559        let completion_receiver = users::get_by_id(&mut tx, completion.user_id).await?;
560        let completion_receiver_user_details =
561            crate::user_details::get_user_details_by_user_id(&mut tx, completion_receiver.id)
562                .await?;
563        let module_completed = course_module_completions::user_has_completed_course_module(
564            &mut tx,
565            completion.user_id,
566            manual_completion_request.course_module_id,
567        )
568        .await?;
569        if !module_completed || !manual_completion_request.skip_duplicate_completions {
570            course_instance_enrollments::insert_enrollment_if_it_doesnt_exist(
571                &mut tx,
572                NewCourseInstanceEnrollment {
573                    user_id: completion_receiver.id,
574                    course_id: course.id,
575                    course_instance_id: course_instance.id,
576                },
577            )
578            .await?;
579
580            if completion.grade.is_some()
581                && (completion.grade > Some(5) || completion.grade < Some(0))
582            {
583                return Err(ModelError::new(
584                    ModelErrorType::PreconditionFailed,
585                    "Invalid grade".to_string(),
586                    None,
587                ));
588            }
589            course_module_completions::insert(
590                &mut tx,
591                PKeyPolicy::Generate,
592                &NewCourseModuleCompletion {
593                    course_id: course_instance.course_id,
594                    course_module_id: manual_completion_request.course_module_id,
595                    user_id: completion.user_id,
596                    completion_date: completion.completion_date.unwrap_or_else(Utc::now),
597                    completion_registration_attempt_date: None,
598                    completion_language: course.language_code.clone(),
599                    eligible_for_ects: true,
600                    email: completion_receiver_user_details.email,
601                    grade: completion.grade,
602                    passed: if completion.grade == Some(0) {
603                        false
604                    } else {
605                        completion.passed
606                    },
607                },
608                CourseModuleCompletionGranter::User(completion_giver_user_id),
609            )
610            .await?;
611
612            // User may not have enrolled to the course at all, or they may have enrolled to a different instance. By inserting the enrollment
613            crate::course_instance_enrollments::insert_enrollment_and_set_as_current(
614                &mut tx,
615                NewCourseInstanceEnrollment {
616                    user_id: completion_receiver.id,
617                    course_id: course.id,
618                    course_instance_id: course_instance.id,
619                },
620            )
621            .await?;
622
623            update_module_completion_prerequisite_statuses_for_user(
624                &mut tx,
625                completion_receiver.id,
626                course.id,
627                course
628                    .base_module_completion_requires_n_submodule_completions
629                    .try_into()?,
630            )
631            .await?;
632        }
633
634        // Adding a manual completion vouches for the student, so any cheating suspicion for this
635        // course (flagged or confirmed) is cleared and any grade a confirmation had failed is
636        // restored. This runs regardless of `skip_duplicate_completions` (a teacher vouching for an
637        // already-completed student should still clear the flag) and regardless of the completion's
638        // grade (recording any manual completion defers the decision to the teacher). The existence
639        // check is needed because `dismiss_...` errors when no suspicion row exists.
640        if suspected_cheaters::get_by_user_id_and_course_id(&mut tx, completion.user_id, course.id)
641            .await
642            .optional()?
643            .is_some()
644        {
645            suspected_cheaters::dismiss_by_user_id_and_course_id(
646                &mut tx,
647                completion.user_id,
648                course.id,
649            )
650            .await?;
651        }
652    }
653    tx.commit().await?;
654    Ok(())
655}
656
657#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
658
659pub struct ManualCompletionPreview {
660    pub already_completed_users: Vec<ManualCompletionPreviewUser>,
661    pub first_time_completing_users: Vec<ManualCompletionPreviewUser>,
662    pub non_enrolled_users: Vec<ManualCompletionPreviewUser>,
663}
664
665#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
666
667pub struct ManualCompletionPreviewUser {
668    pub user_id: Uuid,
669    pub first_name: Option<String>,
670    pub last_name: Option<String>,
671    pub grade: Option<i32>,
672    pub passed: bool,
673    pub previous_best_grade: Option<i32>,
674}
675
676/// Gets a preview of changes that will occur to completions with the given manual completion data.
677pub async fn get_manual_completion_result_preview(
678    conn: &mut PgConnection,
679    course_instance: &CourseInstance,
680    manual_completion_request: &TeacherManualCompletionRequest,
681) -> ModelResult<ManualCompletionPreview> {
682    let course_module =
683        course_modules::get_by_id(conn, manual_completion_request.course_module_id).await?;
684    if course_module.course_id != course_instance.course_id {
685        return Err(ModelError::new(
686            ModelErrorType::PreconditionFailed,
687            "Course module not part of the course.".to_string(),
688            None,
689        ));
690    }
691    let mut already_completed_users = vec![];
692    let mut first_time_completing_users = vec![];
693    let mut non_enrolled_users = vec![];
694    for completion in manual_completion_request.new_completions.iter() {
695        let user = users::get_by_id(conn, completion.user_id).await?;
696        let user_details = crate::user_details::get_user_details_by_user_id(conn, user.id).await?;
697        let user = ManualCompletionPreviewUser {
698            user_id: user.id,
699            first_name: user_details.first_name,
700            last_name: user_details.last_name,
701            grade: completion.grade,
702            passed: completion.passed,
703            previous_best_grade: None,
704        };
705        let enrollment = course_instance_enrollments::get_by_user_and_course_instance_id(
706            conn,
707            completion.user_id,
708            course_instance.id,
709        )
710        .await
711        .optional()?;
712        if enrollment.is_none() {
713            non_enrolled_users.push(user.clone());
714        }
715        let module_completed = course_module_completions::user_has_completed_course_module(
716            conn,
717            completion.user_id,
718            manual_completion_request.course_module_id,
719        )
720        .await?;
721        if module_completed {
722            already_completed_users.push(user);
723        } else {
724            first_time_completing_users.push(user);
725        }
726    }
727    Ok(ManualCompletionPreview {
728        already_completed_users,
729        first_time_completing_users,
730        non_enrolled_users,
731    })
732}
733
734#[derive(Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)]
735
736pub struct UserCompletionInformation {
737    pub course_module_completion_id: Uuid,
738    pub course_name: String,
739    pub uh_course_code: String,
740    pub email: String,
741    pub ects_credits: Option<f32>,
742    pub enable_registering_completion_to_uh_open_university: bool,
743}
744
745pub async fn get_user_completion_information(
746    conn: &mut PgConnection,
747    user_id: Uuid,
748    course_module: &CourseModule,
749) -> ModelResult<UserCompletionInformation> {
750    let user = users::get_by_id(conn, user_id).await?;
751    let course = courses::get_course(conn, course_module.course_id).await?;
752    let course_module_completion = course_module_completions::get_latest_by_course_and_user_ids(
753        conn,
754        course_module.id,
755        user.id,
756    )
757    .await?;
758    // Course code is required only so that fetching the link later works.
759    let uh_course_code = course_module.uh_course_code.clone().ok_or_else(|| {
760        ModelError::new(
761            ModelErrorType::InvalidRequest,
762            "Course module is missing uh_course_code.".to_string(),
763            None,
764        )
765    })?;
766    Ok(UserCompletionInformation {
767        course_module_completion_id: course_module_completion.id,
768        course_name: course_module
769            .name
770            .clone()
771            .unwrap_or_else(|| course.name.clone()),
772        uh_course_code,
773        ects_credits: course_module.ects_credits,
774        email: course_module_completion.email,
775        enable_registering_completion_to_uh_open_university: course_module
776            .enable_registering_completion_to_uh_open_university,
777    })
778}
779
780#[derive(Clone, PartialEq, Deserialize, Serialize, ToSchema)]
781
782pub struct UserModuleCompletionStatus {
783    pub completed: bool,
784    pub default: bool,
785    pub module_id: Uuid,
786    pub name: String,
787    pub order_number: i32,
788    pub prerequisite_modules_completed: bool,
789    pub grade: Option<i32>,
790    pub passed: Option<bool>,
791    pub enable_registering_completion_to_uh_open_university: bool,
792    pub certification_enabled: bool,
793    pub certificate_configuration_id: Option<Uuid>,
794}
795
796/// Gets course modules with user's completion status for the given instance.
797pub async fn get_user_module_completion_statuses_for_course(
798    conn: &mut PgConnection,
799    user_id: Uuid,
800    course_id: Uuid,
801) -> ModelResult<Vec<UserModuleCompletionStatus>> {
802    let course = courses::get_course(conn, course_id).await?;
803    let course_modules = course_modules::get_by_course_id(conn, course_id).await?;
804
805    let course_module_completions_raw =
806        course_module_completions::get_all_by_course_id_and_user_id(conn, course_id, user_id)
807            .await?;
808
809    let course_module_completions: HashMap<Uuid, CourseModuleCompletion> =
810        course_module_completions_raw
811            .into_iter()
812            .sorted_by_key(|c| c.course_module_id)
813            .chunk_by(|c| c.course_module_id)
814            .into_iter()
815            .filter_map(|(module_id, group)| {
816                crate::course_module_completions::select_best_completion(group.collect())
817                    .map(|best| (module_id, best))
818            })
819            .collect();
820
821    let all_default_certificate_configurations = crate::certificate_configurations::get_default_certificate_configurations_and_requirements_by_course(conn, course_id).await?;
822
823    let course_module_completion_statuses = course_modules
824        .into_iter()
825        .map(|module| {
826            let mut certificate_configuration_id = None;
827
828            // A completion that still needs review (e.g. because the student was auto-flagged
829            // as a suspected cheater) is hidden from the student: the module is reported as if
830            // it simply has not been completed yet. This way a flagged student cannot infer
831            // from the API that they are under suspicion.
832            let completion = course_module_completions
833                .get(&module.id)
834                .filter(|c| !c.needs_to_be_reviewed);
835            let passed = completion.map(|x| x.passed);
836            if module.certification_enabled && passed == Some(true) {
837                // If passed, show the user the default certificate configuration id so that they can generate their certificate.
838                let default_certificate_configuration = all_default_certificate_configurations
839                    .iter()
840                    .find(|x| x.requirements.course_module_ids.contains(&module.id));
841                if let Some(default_certificate_configuration) = default_certificate_configuration {
842                    certificate_configuration_id = Some(
843                        default_certificate_configuration
844                            .certificate_configuration
845                            .id,
846                    );
847                }
848            }
849            UserModuleCompletionStatus {
850                completed: completion.is_some(),
851                default: module.is_default_module(),
852                module_id: module.id,
853                name: module.name.unwrap_or_else(|| course.name.clone()),
854                order_number: module.order_number,
855                passed,
856                grade: completion.and_then(|x| x.grade),
857                prerequisite_modules_completed: completion
858                    .is_some_and(|x| x.prerequisite_modules_completed),
859                enable_registering_completion_to_uh_open_university: module
860                    .enable_registering_completion_to_uh_open_university,
861                certification_enabled: module.certification_enabled,
862                certificate_configuration_id,
863            }
864        })
865        .collect();
866    Ok(course_module_completion_statuses)
867}
868
869#[derive(Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)]
870
871pub struct CompletionRegistrationLink {
872    pub url: String,
873}
874
875pub async fn get_completion_registration_link_and_save_attempt(
876    conn: &mut PgConnection,
877    user_id: Uuid,
878    course_module: &CourseModule,
879) -> ModelResult<CompletionRegistrationLink> {
880    if !course_module.enable_registering_completion_to_uh_open_university {
881        return Err(ModelError::new(
882            ModelErrorType::InvalidRequest,
883            "Completion registration is not enabled for this course module.".to_string(),
884            None,
885        ));
886    }
887    let user = users::get_by_id(conn, user_id).await?;
888
889    let course_module_completion = course_module_completions::get_latest_by_course_and_user_ids(
890        conn,
891        course_module.id,
892        user.id,
893    )
894    .await?;
895    course_module_completions::update_completion_registration_attempt_date(
896        conn,
897        course_module_completion.id,
898        Utc::now(),
899    )
900    .await?;
901    let registration_link = if let Some(link_override) =
902        course_module.completion_registration_link_override.as_ref()
903    {
904        link_override.clone()
905    } else {
906        let uh_course_code = course_module.uh_course_code.clone().ok_or_else(|| {
907            ModelError::new(
908                ModelErrorType::PreconditionFailed,
909                "Course module doesn't have an assossiated University of Helsinki course code."
910                    .to_string(),
911                None,
912            )
913        })?;
914        open_university_registration_links::get_link_by_course_code(conn, &uh_course_code).await?
915    };
916
917    Ok(CompletionRegistrationLink {
918        url: registration_link,
919    })
920}
921
922#[cfg(test)]
923mod tests {
924    use chrono::Duration;
925    use user_exercise_states::{ReviewingStage, UserExerciseStateUpdate};
926
927    use super::*;
928
929    use crate::{
930        exercises::{ActivityProgress, GradingProgress},
931        suspected_cheaters::SuspectedCheaterStatus,
932        test_helper::*,
933    };
934
935    mod grant_automatic_completion_if_eligible {
936        use super::*;
937        use crate::{
938            chapters::NewChapter,
939            course_modules::{
940                self, AutomaticCompletionRequirements, CompletionPolicy, NewCourseModule,
941            },
942            exercises::{self, ActivityProgress, GradingProgress},
943            library::content_management,
944            user_exercise_states::{self, ReviewingStage, UserExerciseStateUpdate},
945        };
946
947        #[tokio::test]
948        async fn grants_automatic_completion_but_no_prerequisite_for_default_module() {
949            insert_data!(:tx);
950            let (mut tx, user, course, _instance, default_module, _submodule_1, _submodule_2) =
951                create_test_data(tx).await;
952            update_automatic_completion_status_and_grant_if_eligible(
953                tx.as_mut(),
954                &default_module,
955                user,
956            )
957            .await
958            .unwrap();
959            let statuses =
960                get_user_module_completion_statuses_for_course(tx.as_mut(), user, course)
961                    .await
962                    .unwrap();
963            let status = statuses
964                .iter()
965                .find(|x| x.module_id == default_module.id)
966                .unwrap();
967            assert!(status.completed);
968            assert!(!status.prerequisite_modules_completed);
969        }
970
971        #[tokio::test]
972        async fn grants_automatic_completion_but_no_prerequisite_for_submodule() {
973            insert_data!(:tx);
974            let (mut tx, user, course, _instance, _default_module, submodule_1, _submodule_2) =
975                create_test_data(tx).await;
976            update_automatic_completion_status_and_grant_if_eligible(
977                tx.as_mut(),
978                &submodule_1,
979                user,
980            )
981            .await
982            .unwrap();
983            let statuses =
984                get_user_module_completion_statuses_for_course(tx.as_mut(), user, course)
985                    .await
986                    .unwrap();
987            let status = statuses
988                .iter()
989                .find(|x| x.module_id == submodule_1.id)
990                .unwrap();
991            assert!(status.completed);
992            assert!(!status.prerequisite_modules_completed);
993        }
994
995        #[tokio::test]
996        async fn grants_automatic_completion_for_eligible_submodule_when_completing_default_module()
997        {
998            insert_data!(:tx);
999            let (mut tx, user, course, _instance, default_module, submodule_1, submodule_2) =
1000                create_test_data(tx).await;
1001            update_automatic_completion_status_and_grant_if_eligible(
1002                tx.as_mut(),
1003                &default_module,
1004                user,
1005            )
1006            .await
1007            .unwrap();
1008            update_automatic_completion_status_and_grant_if_eligible(
1009                tx.as_mut(),
1010                &submodule_1,
1011                user,
1012            )
1013            .await
1014            .unwrap();
1015            update_automatic_completion_status_and_grant_if_eligible(
1016                tx.as_mut(),
1017                &submodule_2,
1018                user,
1019            )
1020            .await
1021            .unwrap();
1022            let statuses =
1023                get_user_module_completion_statuses_for_course(tx.as_mut(), user, course)
1024                    .await
1025                    .unwrap();
1026            statuses.iter().for_each(|x| {
1027                assert!(x.completed);
1028                assert!(x.prerequisite_modules_completed);
1029            });
1030        }
1031
1032        async fn create_test_data(
1033            mut tx: Tx<'_>,
1034        ) -> (
1035            Tx<'_>,
1036            Uuid,
1037            Uuid,
1038            Uuid,
1039            CourseModule,
1040            CourseModule,
1041            CourseModule,
1042        ) {
1043            insert_data!(tx: tx; :user, :org, :course, :instance, :course_module, :chapter, :page, :exercise);
1044            // These tests complete modules instantly, which would trip suspected-cheater detection
1045            // (on by default) and hide the completion. Detection is exercised by its own tests, so
1046            // disable it here to test automatic-completion granting in isolation.
1047            courses::set_cheater_detection_enabled(tx.as_mut(), course, false)
1048                .await
1049                .unwrap();
1050            let automatic_completion_policy =
1051                CompletionPolicy::Automatic(AutomaticCompletionRequirements {
1052                    course_module_id: course_module.id,
1053                    number_of_exercises_attempted_treshold: Some(0),
1054                    number_of_points_treshold: Some(0),
1055                    requires_exam: false,
1056                });
1057            courses::update_course_base_module_completion_count_requirement(tx.as_mut(), course, 1)
1058                .await
1059                .unwrap();
1060            let course_module_2 = course_modules::insert(
1061                tx.as_mut(),
1062                PKeyPolicy::Generate,
1063                &NewCourseModule::new(course, Some("Module 2".to_string()), 1),
1064            )
1065            .await
1066            .unwrap();
1067            let (chapter_2, page2) = content_management::create_new_chapter(
1068                tx.as_mut(),
1069                PKeyPolicy::Generate,
1070                &NewChapter {
1071                    name: "chapter 2".to_string(),
1072                    color: None,
1073                    course_id: course,
1074                    chapter_number: 2,
1075                    front_page_id: None,
1076                    opens_at: None,
1077                    deadline: None,
1078                    course_module_id: Some(course_module_2.id),
1079                },
1080                user,
1081                |_, _, _| unimplemented!(),
1082                |_| unimplemented!(),
1083            )
1084            .await
1085            .unwrap();
1086
1087            let exercise_2 = exercises::insert(
1088                tx.as_mut(),
1089                PKeyPolicy::Generate,
1090                course,
1091                "",
1092                page2.id,
1093                chapter_2.id,
1094                0,
1095            )
1096            .await
1097            .unwrap();
1098            let user_exercise_state = user_exercise_states::get_or_create_user_exercise_state(
1099                tx.as_mut(),
1100                user,
1101                exercise,
1102                Some(course),
1103                None,
1104            )
1105            .await
1106            .unwrap();
1107            user_exercise_states::update(
1108                tx.as_mut(),
1109                UserExerciseStateUpdate {
1110                    id: user_exercise_state.id,
1111                    score_given: Some(0.0),
1112                    activity_progress: ActivityProgress::Completed,
1113                    reviewing_stage: ReviewingStage::NotStarted,
1114                    grading_progress: GradingProgress::FullyGraded,
1115                },
1116            )
1117            .await
1118            .unwrap();
1119            let user_exercise_state_2 = user_exercise_states::get_or_create_user_exercise_state(
1120                tx.as_mut(),
1121                user,
1122                exercise_2,
1123                Some(course),
1124                None,
1125            )
1126            .await
1127            .unwrap();
1128            user_exercise_states::update(
1129                tx.as_mut(),
1130                UserExerciseStateUpdate {
1131                    id: user_exercise_state_2.id,
1132                    score_given: Some(0.0),
1133                    activity_progress: ActivityProgress::Completed,
1134                    reviewing_stage: ReviewingStage::NotStarted,
1135                    grading_progress: GradingProgress::FullyGraded,
1136                },
1137            )
1138            .await
1139            .unwrap();
1140            let default_module = course_modules::get_default_by_course_id(tx.as_mut(), course)
1141                .await
1142                .unwrap();
1143            let default_module = course_modules::update_automatic_completion_status(
1144                tx.as_mut(),
1145                default_module.id,
1146                &automatic_completion_policy,
1147            )
1148            .await
1149            .unwrap();
1150            let course_module = course_modules::update_automatic_completion_status(
1151                tx.as_mut(),
1152                course_module.id,
1153                &automatic_completion_policy,
1154            )
1155            .await
1156            .unwrap();
1157            let course_module_2 = course_modules::update_automatic_completion_status(
1158                tx.as_mut(),
1159                course_module_2.id,
1160                &automatic_completion_policy,
1161            )
1162            .await
1163            .unwrap();
1164            (
1165                tx,
1166                user,
1167                course,
1168                instance.id,
1169                default_module,
1170                course_module,
1171                course_module_2,
1172            )
1173        }
1174    }
1175
1176    #[tokio::test]
1177    async fn tags_suspected_cheater() {
1178        insert_data!(:tx, user:user, :org, course:course, instance:instance, course_module:course_module, :chapter, :page, :exercise);
1179
1180        crate::library::course_instances::enroll(tx.as_mut(), user, instance.id, &[])
1181            .await
1182            .unwrap();
1183        let state = user_exercise_states::get_or_create_user_exercise_state(
1184            tx.as_mut(),
1185            user,
1186            exercise,
1187            Some(course),
1188            None,
1189        )
1190        .await
1191        .unwrap();
1192        user_exercise_states::update(
1193            tx.as_mut(),
1194            UserExerciseStateUpdate {
1195                id: state.id,
1196                score_given: Some(10.0),
1197                activity_progress: ActivityProgress::Completed,
1198                reviewing_stage: ReviewingStage::NotStarted,
1199                grading_progress: GradingProgress::FullyGraded,
1200            },
1201        )
1202        .await
1203        .unwrap();
1204
1205        let completion = course_module_completions::insert(
1206            tx.as_mut(),
1207            PKeyPolicy::Generate,
1208            &NewCourseModuleCompletion {
1209                course_id: course,
1210                course_module_id: course_module.id,
1211                user_id: user,
1212                completion_date: Utc::now() + Duration::days(1),
1213                completion_registration_attempt_date: None,
1214                completion_language: "en-US".to_string(),
1215                eligible_for_ects: false,
1216                email: "email".to_string(),
1217                grade: None,
1218                passed: true,
1219            },
1220            CourseModuleCompletionGranter::Automatic,
1221        )
1222        .await
1223        .unwrap();
1224        let thresholds = suspected_cheaters::insert_thresholds_by_module_id(
1225            tx.as_mut(),
1226            course_module.id,
1227            259200,
1228        )
1229        .await
1230        .unwrap();
1231        check_and_insert_suspected_cheaters(
1232            tx.as_mut(),
1233            user,
1234            course,
1235            thresholds.duration_seconds,
1236            completion,
1237        )
1238        .await
1239        .unwrap();
1240
1241        let cheaters = suspected_cheaters::get_all_suspected_cheaters_in_course(
1242            tx.as_mut(),
1243            course,
1244            SuspectedCheaterStatus::Flagged,
1245        )
1246        .await
1247        .unwrap();
1248        assert_eq!(cheaters.len(), 1);
1249        assert_eq!(cheaters[0].user_id, user);
1250    }
1251
1252    #[tokio::test]
1253    async fn tagging_suspected_cheater_is_idempotent() {
1254        insert_data!(:tx, user:user, :org, course:course, instance:instance, course_module:course_module, :chapter, :page, :exercise);
1255
1256        crate::library::course_instances::enroll(tx.as_mut(), user, instance.id, &[])
1257            .await
1258            .unwrap();
1259        let state = user_exercise_states::get_or_create_user_exercise_state(
1260            tx.as_mut(),
1261            user,
1262            exercise,
1263            Some(course),
1264            None,
1265        )
1266        .await
1267        .unwrap();
1268        user_exercise_states::update(
1269            tx.as_mut(),
1270            UserExerciseStateUpdate {
1271                id: state.id,
1272                score_given: Some(10.0),
1273                activity_progress: ActivityProgress::Completed,
1274                reviewing_stage: ReviewingStage::NotStarted,
1275                grading_progress: GradingProgress::FullyGraded,
1276            },
1277        )
1278        .await
1279        .unwrap();
1280
1281        let completion = course_module_completions::insert(
1282            tx.as_mut(),
1283            PKeyPolicy::Generate,
1284            &NewCourseModuleCompletion {
1285                course_id: course,
1286                course_module_id: course_module.id,
1287                user_id: user,
1288                completion_date: Utc::now() + Duration::days(1),
1289                completion_registration_attempt_date: None,
1290                completion_language: "en-US".to_string(),
1291                eligible_for_ects: false,
1292                email: "email".to_string(),
1293                grade: None,
1294                passed: true,
1295            },
1296            CourseModuleCompletionGranter::Automatic,
1297        )
1298        .await
1299        .unwrap();
1300        let thresholds = suspected_cheaters::insert_thresholds_by_module_id(
1301            tx.as_mut(),
1302            course_module.id,
1303            259200,
1304        )
1305        .await
1306        .unwrap();
1307        check_and_insert_suspected_cheaters(
1308            tx.as_mut(),
1309            user,
1310            course,
1311            thresholds.duration_seconds,
1312            completion.clone(),
1313        )
1314        .await
1315        .unwrap();
1316        check_and_insert_suspected_cheaters(
1317            tx.as_mut(),
1318            user,
1319            course,
1320            thresholds.duration_seconds,
1321            completion,
1322        )
1323        .await
1324        .unwrap();
1325
1326        let cheaters = suspected_cheaters::get_all_suspected_cheaters_in_course(
1327            tx.as_mut(),
1328            course,
1329            SuspectedCheaterStatus::Flagged,
1330        )
1331        .await
1332        .unwrap();
1333        assert_eq!(cheaters.len(), 1);
1334        let completion_needing_review =
1335            course_module_completions::get_latest_by_course_and_user_ids(
1336                tx.as_mut(),
1337                course_module.id,
1338                user,
1339            )
1340            .await
1341            .unwrap();
1342        assert!(completion_needing_review.needs_to_be_reviewed);
1343
1344        suspected_cheaters::dismiss_by_user_id_and_course_id(tx.as_mut(), user, course)
1345            .await
1346            .unwrap();
1347        let archived_completion = course_module_completions::get_latest_by_course_and_user_ids(
1348            tx.as_mut(),
1349            course_module.id,
1350            user,
1351        )
1352        .await
1353        .unwrap();
1354        assert!(!archived_completion.needs_to_be_reviewed);
1355        check_and_insert_suspected_cheaters(
1356            tx.as_mut(),
1357            user,
1358            course,
1359            thresholds.duration_seconds,
1360            archived_completion,
1361        )
1362        .await
1363        .unwrap();
1364
1365        let visible_cheaters = suspected_cheaters::get_all_suspected_cheaters_in_course(
1366            tx.as_mut(),
1367            course,
1368            SuspectedCheaterStatus::Flagged,
1369        )
1370        .await
1371        .unwrap();
1372        let archived_cheaters = suspected_cheaters::get_all_suspected_cheaters_in_course(
1373            tx.as_mut(),
1374            course,
1375            SuspectedCheaterStatus::Dismissed,
1376        )
1377        .await
1378        .unwrap();
1379        assert!(visible_cheaters.is_empty());
1380        assert_eq!(archived_cheaters.len(), 1);
1381        let rechecked_completion = course_module_completions::get_latest_by_course_and_user_ids(
1382            tx.as_mut(),
1383            course_module.id,
1384            user,
1385        )
1386        .await
1387        .unwrap();
1388        assert!(!rechecked_completion.needs_to_be_reviewed);
1389    }
1390
1391    #[tokio::test]
1392    async fn confirming_then_dismissing_restores_grade() {
1393        insert_data!(:tx, user:user, :org, course:course, instance:instance, course_module:course_module, :chapter, :page, :exercise);
1394
1395        crate::library::course_instances::enroll(tx.as_mut(), user, instance.id, &[])
1396            .await
1397            .unwrap();
1398        let state = user_exercise_states::get_or_create_user_exercise_state(
1399            tx.as_mut(),
1400            user,
1401            exercise,
1402            Some(course),
1403            None,
1404        )
1405        .await
1406        .unwrap();
1407        user_exercise_states::update(
1408            tx.as_mut(),
1409            UserExerciseStateUpdate {
1410                id: state.id,
1411                score_given: Some(10.0),
1412                activity_progress: ActivityProgress::Completed,
1413                reviewing_stage: ReviewingStage::NotStarted,
1414                grading_progress: GradingProgress::FullyGraded,
1415            },
1416        )
1417        .await
1418        .unwrap();
1419
1420        // A graded, passing completion so we can prove the exact grade is restored, not just pass/fail.
1421        let completion = course_module_completions::insert(
1422            tx.as_mut(),
1423            PKeyPolicy::Generate,
1424            &NewCourseModuleCompletion {
1425                course_id: course,
1426                course_module_id: course_module.id,
1427                user_id: user,
1428                completion_date: Utc::now() + Duration::days(1),
1429                completion_registration_attempt_date: None,
1430                completion_language: "en-US".to_string(),
1431                eligible_for_ects: false,
1432                email: "email".to_string(),
1433                grade: Some(4),
1434                passed: true,
1435            },
1436            CourseModuleCompletionGranter::Automatic,
1437        )
1438        .await
1439        .unwrap();
1440        let thresholds = suspected_cheaters::insert_thresholds_by_module_id(
1441            tx.as_mut(),
1442            course_module.id,
1443            259200,
1444        )
1445        .await
1446        .unwrap();
1447        check_and_insert_suspected_cheaters(
1448            tx.as_mut(),
1449            user,
1450            course,
1451            thresholds.duration_seconds,
1452            completion,
1453        )
1454        .await
1455        .unwrap();
1456
1457        // Confirm: the student is failed and the previous grade is snapshotted.
1458        suspected_cheaters::confirm_cheater_by_user_id_and_course_id(tx.as_mut(), user, course)
1459            .await
1460            .unwrap();
1461        let failed = course_module_completions::get_latest_by_course_and_user_ids(
1462            tx.as_mut(),
1463            course_module.id,
1464            user,
1465        )
1466        .await
1467        .unwrap();
1468        assert!(!failed.passed);
1469        assert_eq!(failed.grade, Some(0));
1470        let confirmed = suspected_cheaters::get_all_suspected_cheaters_in_course(
1471            tx.as_mut(),
1472            course,
1473            SuspectedCheaterStatus::ConfirmedCheating,
1474        )
1475        .await
1476        .unwrap();
1477        assert_eq!(confirmed.len(), 1);
1478
1479        // Dismiss: the confirmation is undone and the exact previous grade is restored.
1480        suspected_cheaters::dismiss_by_user_id_and_course_id(tx.as_mut(), user, course)
1481            .await
1482            .unwrap();
1483        let restored = course_module_completions::get_latest_by_course_and_user_ids(
1484            tx.as_mut(),
1485            course_module.id,
1486            user,
1487        )
1488        .await
1489        .unwrap();
1490        assert!(restored.passed);
1491        assert_eq!(restored.grade, Some(4));
1492        assert!(!restored.needs_to_be_reviewed);
1493        let dismissed = suspected_cheaters::get_all_suspected_cheaters_in_course(
1494            tx.as_mut(),
1495            course,
1496            SuspectedCheaterStatus::Dismissed,
1497        )
1498        .await
1499        .unwrap();
1500        assert_eq!(dismissed.len(), 1);
1501    }
1502
1503    #[tokio::test]
1504    async fn manual_completion_prevents_flagging() {
1505        insert_data!(:tx, user:user, :org, course:course, instance:instance, course_module:course_module);
1506
1507        crate::library::course_instances::enroll(tx.as_mut(), user, instance.id, &[])
1508            .await
1509            .unwrap();
1510
1511        // A teacher has manually granted a completion for this student.
1512        let teacher = users::insert(
1513            tx.as_mut(),
1514            PKeyPolicy::Generate,
1515            "teacher-vouching@example.com",
1516            Some("Teacher"),
1517            Some("McVouch"),
1518        )
1519        .await
1520        .unwrap();
1521        course_module_completions::insert(
1522            tx.as_mut(),
1523            PKeyPolicy::Generate,
1524            &NewCourseModuleCompletion {
1525                course_id: course,
1526                course_module_id: course_module.id,
1527                user_id: user,
1528                completion_date: Utc::now(),
1529                completion_registration_attempt_date: None,
1530                completion_language: "en-US".to_string(),
1531                eligible_for_ects: false,
1532                email: "email".to_string(),
1533                grade: Some(5),
1534                passed: true,
1535            },
1536            CourseModuleCompletionGranter::User(teacher),
1537        )
1538        .await
1539        .unwrap();
1540
1541        // An automatic completion well inside the threshold would normally flag the student.
1542        let automatic_completion = course_module_completions::insert(
1543            tx.as_mut(),
1544            PKeyPolicy::Generate,
1545            &NewCourseModuleCompletion {
1546                course_id: course,
1547                course_module_id: course_module.id,
1548                user_id: user,
1549                completion_date: Utc::now() + Duration::days(1),
1550                completion_registration_attempt_date: None,
1551                completion_language: "en-US".to_string(),
1552                eligible_for_ects: false,
1553                email: "email".to_string(),
1554                grade: None,
1555                passed: true,
1556            },
1557            CourseModuleCompletionGranter::Automatic,
1558        )
1559        .await
1560        .unwrap();
1561        let thresholds = suspected_cheaters::insert_thresholds_by_module_id(
1562            tx.as_mut(),
1563            course_module.id,
1564            259200,
1565        )
1566        .await
1567        .unwrap();
1568        check_and_insert_suspected_cheaters(
1569            tx.as_mut(),
1570            user,
1571            course,
1572            thresholds.duration_seconds,
1573            automatic_completion,
1574        )
1575        .await
1576        .unwrap();
1577
1578        // The teacher's manual completion exempts the student, so no suspicion is created.
1579        let cheaters = suspected_cheaters::get_all_suspected_cheaters_in_course(
1580            tx.as_mut(),
1581            course,
1582            SuspectedCheaterStatus::Flagged,
1583        )
1584        .await
1585        .unwrap();
1586        assert!(cheaters.is_empty());
1587    }
1588
1589    #[tokio::test]
1590    async fn adding_manual_completion_dismisses_confirmed_suspicion_and_restores_grade() {
1591        insert_data!(:tx, user:user, :org, course:course, instance:instance, course_module:course_module, :chapter, :page, :exercise);
1592
1593        crate::library::course_instances::enroll(tx.as_mut(), user, instance.id, &[])
1594            .await
1595            .unwrap();
1596        let state = user_exercise_states::get_or_create_user_exercise_state(
1597            tx.as_mut(),
1598            user,
1599            exercise,
1600            Some(course),
1601            None,
1602        )
1603        .await
1604        .unwrap();
1605        user_exercise_states::update(
1606            tx.as_mut(),
1607            UserExerciseStateUpdate {
1608                id: state.id,
1609                score_given: Some(10.0),
1610                activity_progress: ActivityProgress::Completed,
1611                reviewing_stage: ReviewingStage::NotStarted,
1612                grading_progress: GradingProgress::FullyGraded,
1613            },
1614        )
1615        .await
1616        .unwrap();
1617
1618        // Flag the student via a fast automatic completion, then confirm cheating (fails the grade).
1619        let completion = course_module_completions::insert(
1620            tx.as_mut(),
1621            PKeyPolicy::Generate,
1622            &NewCourseModuleCompletion {
1623                course_id: course,
1624                course_module_id: course_module.id,
1625                user_id: user,
1626                completion_date: Utc::now() + Duration::days(1),
1627                completion_registration_attempt_date: None,
1628                completion_language: "en-US".to_string(),
1629                eligible_for_ects: false,
1630                email: "email".to_string(),
1631                grade: Some(4),
1632                passed: true,
1633            },
1634            CourseModuleCompletionGranter::Automatic,
1635        )
1636        .await
1637        .unwrap();
1638        let thresholds = suspected_cheaters::insert_thresholds_by_module_id(
1639            tx.as_mut(),
1640            course_module.id,
1641            259200,
1642        )
1643        .await
1644        .unwrap();
1645        check_and_insert_suspected_cheaters(
1646            tx.as_mut(),
1647            user,
1648            course,
1649            thresholds.duration_seconds,
1650            completion,
1651        )
1652        .await
1653        .unwrap();
1654        suspected_cheaters::confirm_cheater_by_user_id_and_course_id(tx.as_mut(), user, course)
1655            .await
1656            .unwrap();
1657
1658        // A teacher manually adds a completion for the same student. `skip_duplicate_completions` is
1659        // false so the completion is inserted even though the module is already completed.
1660        let teacher = users::insert(
1661            tx.as_mut(),
1662            PKeyPolicy::Generate,
1663            "teacher-vouching@example.com",
1664            Some("Teacher"),
1665            Some("McVouch"),
1666        )
1667        .await
1668        .unwrap();
1669        add_manual_completions(
1670            tx.as_mut(),
1671            teacher,
1672            &instance,
1673            &TeacherManualCompletionRequest {
1674                course_module_id: course_module.id,
1675                new_completions: vec![TeacherManualCompletion {
1676                    user_id: user,
1677                    grade: Some(5),
1678                    passed: true,
1679                    completion_date: None,
1680                }],
1681                skip_duplicate_completions: false,
1682            },
1683        )
1684        .await
1685        .unwrap();
1686
1687        // The suspicion is dismissed and the confirmed-cheating grade failure is undone.
1688        let flagged = suspected_cheaters::get_all_suspected_cheaters_in_course(
1689            tx.as_mut(),
1690            course,
1691            SuspectedCheaterStatus::Flagged,
1692        )
1693        .await
1694        .unwrap();
1695        let confirmed = suspected_cheaters::get_all_suspected_cheaters_in_course(
1696            tx.as_mut(),
1697            course,
1698            SuspectedCheaterStatus::ConfirmedCheating,
1699        )
1700        .await
1701        .unwrap();
1702        let dismissed = suspected_cheaters::get_all_suspected_cheaters_in_course(
1703            tx.as_mut(),
1704            course,
1705            SuspectedCheaterStatus::Dismissed,
1706        )
1707        .await
1708        .unwrap();
1709        assert!(flagged.is_empty());
1710        assert!(confirmed.is_empty());
1711        assert_eq!(dismissed.len(), 1);
1712
1713        // The automatic completion's failed grade is restored and its review flag is cleared.
1714        let restored =
1715            course_module_completions::get_automatic_completion_by_course_module_course_and_user_ids(
1716                tx.as_mut(),
1717                course_module.id,
1718                course,
1719                user,
1720            )
1721            .await
1722            .unwrap();
1723        assert!(restored.passed);
1724        assert_eq!(restored.grade, Some(4));
1725        assert!(!restored.needs_to_be_reviewed);
1726    }
1727
1728    #[tokio::test]
1729    async fn doesnt_tag_suspected_cheater() {
1730        insert_data!(:tx, user:user, :org, :course, instance:instance, course_module:course_module, :chapter, :page, :exercise);
1731
1732        crate::library::course_instances::enroll(tx.as_mut(), user, instance.id, &[])
1733            .await
1734            .unwrap();
1735        let state = user_exercise_states::get_or_create_user_exercise_state(
1736            tx.as_mut(),
1737            user,
1738            exercise,
1739            Some(course),
1740            None,
1741        )
1742        .await
1743        .unwrap();
1744        user_exercise_states::update(
1745            tx.as_mut(),
1746            UserExerciseStateUpdate {
1747                id: state.id,
1748                score_given: Some(9.0),
1749                activity_progress: ActivityProgress::Completed,
1750                reviewing_stage: ReviewingStage::NotStarted,
1751                grading_progress: GradingProgress::FullyGraded,
1752            },
1753        )
1754        .await
1755        .unwrap();
1756
1757        course_module_completions::insert(
1758            tx.as_mut(),
1759            PKeyPolicy::Generate,
1760            &NewCourseModuleCompletion {
1761                course_id: course,
1762                course_module_id: course_module.id,
1763                user_id: user,
1764                completion_date: Utc::now() + Duration::days(3),
1765                completion_registration_attempt_date: None,
1766                completion_language: "en-US".to_string(),
1767                eligible_for_ects: false,
1768                email: "email".to_string(),
1769                grade: Some(9),
1770                passed: true,
1771            },
1772            CourseModuleCompletionGranter::Automatic,
1773        )
1774        .await
1775        .unwrap();
1776        suspected_cheaters::insert_thresholds_by_module_id(tx.as_mut(), course_module.id, 172800)
1777            .await
1778            .unwrap();
1779        update_automatic_completion_status_and_grant_if_eligible(tx.as_mut(), &course_module, user)
1780            .await
1781            .unwrap();
1782
1783        let cheaters = suspected_cheaters::get_all_suspected_cheaters_in_course(
1784            tx.as_mut(),
1785            course,
1786            SuspectedCheaterStatus::Flagged,
1787        )
1788        .await
1789        .unwrap();
1790        assert!(cheaters.is_empty());
1791    }
1792
1793    // TODO: New automatic completion tests?
1794}