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