Skip to main content

headless_lms_models/library/
progressing.rs

1use chrono::{DateTime, Utc};
2use itertools::Itertools;
3use std::collections::{HashMap, HashSet};
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    /// `None` only on a module registering through credit registration.
756    pub uh_course_code: Option<String>,
757    pub email: String,
758    pub ects_credits: Option<f32>,
759    pub enable_registering_completion_to_uh_open_university: bool,
760    pub enable_credit_registration_via_suotar: bool,
761}
762
763pub async fn get_user_completion_information(
764    conn: &mut PgConnection,
765    user_id: Uuid,
766    course_module: &CourseModule,
767) -> ModelResult<UserCompletionInformation> {
768    let user = users::get_by_id(conn, user_id).await?;
769    let course = courses::get_course(conn, course_module.course_id).await?;
770    let course_module_completion = course_module_completions::get_latest_by_course_and_user_ids(
771        conn,
772        course_module.id,
773        user.id,
774    )
775    .await?;
776    let credit_registration_config =
777        course_modules::get_credit_registration_config(conn, course_module.id).await?;
778    // A Suotar module explains a missing course code on its own status page, so failing here would
779    // hide the error instead of showing it.
780    if course_module.uh_course_code.is_none()
781        && !credit_registration_config.enable_credit_registration_via_suotar
782    {
783        return Err(ModelError::new(
784            ModelErrorType::InvalidRequest,
785            "Course module is missing uh_course_code.".to_string(),
786            None,
787        ));
788    }
789    Ok(UserCompletionInformation {
790        course_module_completion_id: course_module_completion.id,
791        course_name: course_module
792            .name
793            .clone()
794            .unwrap_or_else(|| course.name.clone()),
795        uh_course_code: course_module.uh_course_code.clone(),
796        ects_credits: course_module.ects_credits,
797        email: course_module_completion.email,
798        enable_registering_completion_to_uh_open_university: course_module
799            .enable_registering_completion_to_uh_open_university,
800        enable_credit_registration_via_suotar: credit_registration_config
801            .enable_credit_registration_via_suotar,
802    })
803}
804
805#[derive(Clone, PartialEq, Deserialize, Serialize, ToSchema)]
806
807pub struct UserModuleCompletionStatus {
808    pub completed: bool,
809    pub default: bool,
810    pub module_id: Uuid,
811    pub name: String,
812    pub order_number: i32,
813    pub prerequisite_modules_completed: bool,
814    pub grade: Option<i32>,
815    pub passed: Option<bool>,
816    pub enable_registering_completion_to_uh_open_university: bool,
817    pub enable_credit_registration_via_suotar: bool,
818    pub certification_enabled: bool,
819    pub certificate_configuration_id: Option<Uuid>,
820}
821
822/// Gets course modules with user's completion status for the given instance.
823pub async fn get_user_module_completion_statuses_for_course(
824    conn: &mut PgConnection,
825    user_id: Uuid,
826    course_id: Uuid,
827) -> ModelResult<Vec<UserModuleCompletionStatus>> {
828    let course = courses::get_course(conn, course_id).await?;
829    let course_modules = course_modules::get_by_course_id(conn, course_id).await?;
830
831    let course_module_completions_raw =
832        course_module_completions::get_all_by_course_id_and_user_id(conn, course_id, user_id)
833            .await?;
834
835    let course_module_completions: HashMap<Uuid, CourseModuleCompletion> =
836        course_module_completions_raw
837            .into_iter()
838            .sorted_by_key(|c| c.course_module_id)
839            .chunk_by(|c| c.course_module_id)
840            .into_iter()
841            .filter_map(|(module_id, group)| {
842                crate::course_module_completions::select_best_completion(group.collect())
843                    .map(|best| (module_id, best))
844            })
845            .collect();
846
847    let all_default_certificate_configurations = crate::certificate_configurations::get_default_certificate_configurations_and_requirements_by_course(conn, course_id).await?;
848
849    let credit_registration_enabled_module_ids: HashSet<Uuid> =
850        course_modules::get_credit_registration_enabled_ids_for_course(conn, course_id)
851            .await?
852            .into_iter()
853            .collect();
854
855    let course_module_completion_statuses = course_modules
856        .into_iter()
857        .map(|module| {
858            let mut certificate_configuration_id = None;
859
860            // A completion that still needs review (e.g. because the student was auto-flagged
861            // as a suspected cheater) is hidden from the student: the module is reported as if
862            // it simply has not been completed yet. This way a flagged student cannot infer
863            // from the API that they are under suspicion.
864            let completion = course_module_completions
865                .get(&module.id)
866                .filter(|c| !c.needs_to_be_reviewed);
867            let passed = completion.map(|x| x.passed);
868            if module.certification_enabled && passed == Some(true) {
869                // If passed, show the user the default certificate configuration id so that they can generate their certificate.
870                let default_certificate_configuration = all_default_certificate_configurations
871                    .iter()
872                    .find(|x| x.requirements.course_module_ids.contains(&module.id));
873                if let Some(default_certificate_configuration) = default_certificate_configuration {
874                    certificate_configuration_id = Some(
875                        default_certificate_configuration
876                            .certificate_configuration
877                            .id,
878                    );
879                }
880            }
881            UserModuleCompletionStatus {
882                completed: completion.is_some(),
883                default: module.is_default_module(),
884                module_id: module.id,
885                name: module.name.unwrap_or_else(|| course.name.clone()),
886                order_number: module.order_number,
887                passed,
888                grade: completion.and_then(|x| x.grade),
889                prerequisite_modules_completed: completion
890                    .is_some_and(|x| x.prerequisite_modules_completed),
891                enable_registering_completion_to_uh_open_university: module
892                    .enable_registering_completion_to_uh_open_university,
893                enable_credit_registration_via_suotar: credit_registration_enabled_module_ids
894                    .contains(&module.id),
895                certification_enabled: module.certification_enabled,
896                certificate_configuration_id,
897            }
898        })
899        .collect();
900    Ok(course_module_completion_statuses)
901}
902
903#[derive(Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)]
904
905pub struct CompletionRegistrationLink {
906    pub url: String,
907}
908
909pub async fn get_completion_registration_link_and_save_attempt(
910    conn: &mut PgConnection,
911    user_id: Uuid,
912    course_module: &CourseModule,
913) -> ModelResult<CompletionRegistrationLink> {
914    if !course_module.enable_registering_completion_to_uh_open_university {
915        return Err(ModelError::new(
916            ModelErrorType::InvalidRequest,
917            "Completion registration is not enabled for this course module.".to_string(),
918            None,
919        ));
920    }
921    let user = users::get_by_id(conn, user_id).await?;
922
923    let course_module_completion = course_module_completions::get_latest_by_course_and_user_ids(
924        conn,
925        course_module.id,
926        user.id,
927    )
928    .await?;
929    course_module_completions::update_completion_registration_attempt_date(
930        conn,
931        course_module_completion.id,
932        Utc::now(),
933    )
934    .await?;
935    let registration_link = if let Some(link_override) =
936        course_module.completion_registration_link_override.as_ref()
937    {
938        link_override.clone()
939    } else {
940        let uh_course_code = course_module.uh_course_code.clone().ok_or_else(|| {
941            ModelError::new(
942                ModelErrorType::PreconditionFailed,
943                "Course module doesn't have an assossiated University of Helsinki course code."
944                    .to_string(),
945                None,
946            )
947        })?;
948        open_university_registration_links::get_link_by_course_code(conn, &uh_course_code).await?
949    };
950
951    Ok(CompletionRegistrationLink {
952        url: registration_link,
953    })
954}
955
956#[cfg(test)]
957mod tests {
958    use chrono::Duration;
959    use user_exercise_states::{ReviewingStage, UserExerciseStateUpdate};
960
961    use super::*;
962
963    use crate::{
964        exercises::{ActivityProgress, GradingProgress},
965        suspected_cheaters::SuspectedCheaterStatus,
966        test_helper::*,
967    };
968
969    mod grant_automatic_completion_if_eligible {
970        use super::*;
971        use crate::{
972            chapters::NewChapter,
973            course_modules::{
974                self, AutomaticCompletionRequirements, CompletionPolicy, NewCourseModule,
975            },
976            exercises::{self, ActivityProgress, GradingProgress},
977            library::content_management,
978            user_exercise_states::{self, ReviewingStage, UserExerciseStateUpdate},
979        };
980
981        #[tokio::test]
982        async fn grants_automatic_completion_but_no_prerequisite_for_default_module() {
983            insert_data!(:tx);
984            let (mut tx, user, course, _instance, default_module, _submodule_1, _submodule_2) =
985                create_test_data(tx).await;
986            update_automatic_completion_status_and_grant_if_eligible(
987                tx.as_mut(),
988                &default_module,
989                user,
990            )
991            .await
992            .unwrap();
993            let statuses =
994                get_user_module_completion_statuses_for_course(tx.as_mut(), user, course)
995                    .await
996                    .unwrap();
997            let status = statuses
998                .iter()
999                .find(|x| x.module_id == default_module.id)
1000                .unwrap();
1001            assert!(status.completed);
1002            assert!(!status.prerequisite_modules_completed);
1003        }
1004
1005        #[tokio::test]
1006        async fn grants_automatic_completion_but_no_prerequisite_for_submodule() {
1007            insert_data!(:tx);
1008            let (mut tx, user, course, _instance, _default_module, submodule_1, _submodule_2) =
1009                create_test_data(tx).await;
1010            update_automatic_completion_status_and_grant_if_eligible(
1011                tx.as_mut(),
1012                &submodule_1,
1013                user,
1014            )
1015            .await
1016            .unwrap();
1017            let statuses =
1018                get_user_module_completion_statuses_for_course(tx.as_mut(), user, course)
1019                    .await
1020                    .unwrap();
1021            let status = statuses
1022                .iter()
1023                .find(|x| x.module_id == submodule_1.id)
1024                .unwrap();
1025            assert!(status.completed);
1026            assert!(!status.prerequisite_modules_completed);
1027        }
1028
1029        #[tokio::test]
1030        async fn grants_automatic_completion_for_eligible_submodule_when_completing_default_module()
1031        {
1032            insert_data!(:tx);
1033            let (mut tx, user, course, _instance, default_module, submodule_1, submodule_2) =
1034                create_test_data(tx).await;
1035            update_automatic_completion_status_and_grant_if_eligible(
1036                tx.as_mut(),
1037                &default_module,
1038                user,
1039            )
1040            .await
1041            .unwrap();
1042            update_automatic_completion_status_and_grant_if_eligible(
1043                tx.as_mut(),
1044                &submodule_1,
1045                user,
1046            )
1047            .await
1048            .unwrap();
1049            update_automatic_completion_status_and_grant_if_eligible(
1050                tx.as_mut(),
1051                &submodule_2,
1052                user,
1053            )
1054            .await
1055            .unwrap();
1056            let statuses =
1057                get_user_module_completion_statuses_for_course(tx.as_mut(), user, course)
1058                    .await
1059                    .unwrap();
1060            statuses.iter().for_each(|x| {
1061                assert!(x.completed);
1062                assert!(x.prerequisite_modules_completed);
1063            });
1064        }
1065
1066        async fn create_test_data(
1067            mut tx: Tx<'_>,
1068        ) -> (
1069            Tx<'_>,
1070            Uuid,
1071            Uuid,
1072            Uuid,
1073            CourseModule,
1074            CourseModule,
1075            CourseModule,
1076        ) {
1077            insert_data!(tx: tx; :user, :org, :course, :instance, :course_module, :chapter, :page, :exercise);
1078            // These tests complete modules instantly, which would trip suspected-cheater detection
1079            // (on by default) and hide the completion. Detection is exercised by its own tests, so
1080            // disable it here to test automatic-completion granting in isolation.
1081            courses::set_cheater_detection_enabled(tx.as_mut(), course, false)
1082                .await
1083                .unwrap();
1084            let automatic_completion_policy =
1085                CompletionPolicy::Automatic(AutomaticCompletionRequirements {
1086                    course_module_id: course_module.id,
1087                    number_of_exercises_attempted_treshold: Some(0),
1088                    number_of_points_treshold: Some(0),
1089                    requires_exam: false,
1090                });
1091            courses::update_course_base_module_completion_count_requirement(tx.as_mut(), course, 1)
1092                .await
1093                .unwrap();
1094            let course_module_2 = course_modules::insert(
1095                tx.as_mut(),
1096                PKeyPolicy::Generate,
1097                &NewCourseModule::new(course, Some("Module 2".to_string()), 1),
1098            )
1099            .await
1100            .unwrap();
1101            let (chapter_2, page2) = content_management::create_new_chapter(
1102                tx.as_mut(),
1103                PKeyPolicy::Generate,
1104                &NewChapter {
1105                    name: "chapter 2".to_string(),
1106                    color: None,
1107                    course_id: course,
1108                    chapter_number: 2,
1109                    front_page_id: None,
1110                    opens_at: None,
1111                    deadline: None,
1112                    course_module_id: Some(course_module_2.id),
1113                },
1114                user,
1115                |_, _, _| unimplemented!(),
1116                |_| unimplemented!(),
1117            )
1118            .await
1119            .unwrap();
1120
1121            let exercise_2 = exercises::insert(
1122                tx.as_mut(),
1123                PKeyPolicy::Generate,
1124                course,
1125                "",
1126                page2.id,
1127                chapter_2.id,
1128                0,
1129            )
1130            .await
1131            .unwrap();
1132            let user_exercise_state = user_exercise_states::get_or_create_user_exercise_state(
1133                tx.as_mut(),
1134                user,
1135                exercise,
1136                Some(course),
1137                None,
1138            )
1139            .await
1140            .unwrap();
1141            user_exercise_states::update(
1142                tx.as_mut(),
1143                UserExerciseStateUpdate {
1144                    id: user_exercise_state.id,
1145                    score_given: Some(0.0),
1146                    activity_progress: ActivityProgress::Completed,
1147                    reviewing_stage: ReviewingStage::NotStarted,
1148                    grading_progress: GradingProgress::FullyGraded,
1149                },
1150            )
1151            .await
1152            .unwrap();
1153            let user_exercise_state_2 = user_exercise_states::get_or_create_user_exercise_state(
1154                tx.as_mut(),
1155                user,
1156                exercise_2,
1157                Some(course),
1158                None,
1159            )
1160            .await
1161            .unwrap();
1162            user_exercise_states::update(
1163                tx.as_mut(),
1164                UserExerciseStateUpdate {
1165                    id: user_exercise_state_2.id,
1166                    score_given: Some(0.0),
1167                    activity_progress: ActivityProgress::Completed,
1168                    reviewing_stage: ReviewingStage::NotStarted,
1169                    grading_progress: GradingProgress::FullyGraded,
1170                },
1171            )
1172            .await
1173            .unwrap();
1174            let default_module = course_modules::get_default_by_course_id(tx.as_mut(), course)
1175                .await
1176                .unwrap();
1177            let default_module = course_modules::update_automatic_completion_status(
1178                tx.as_mut(),
1179                default_module.id,
1180                &automatic_completion_policy,
1181            )
1182            .await
1183            .unwrap();
1184            let course_module = course_modules::update_automatic_completion_status(
1185                tx.as_mut(),
1186                course_module.id,
1187                &automatic_completion_policy,
1188            )
1189            .await
1190            .unwrap();
1191            let course_module_2 = course_modules::update_automatic_completion_status(
1192                tx.as_mut(),
1193                course_module_2.id,
1194                &automatic_completion_policy,
1195            )
1196            .await
1197            .unwrap();
1198            (
1199                tx,
1200                user,
1201                course,
1202                instance.id,
1203                default_module,
1204                course_module,
1205                course_module_2,
1206            )
1207        }
1208    }
1209
1210    #[tokio::test]
1211    async fn tags_suspected_cheater() {
1212        insert_data!(:tx, user:user, :org, course:course, instance:instance, course_module:course_module, :chapter, :page, :exercise);
1213
1214        crate::library::course_instances::enroll(tx.as_mut(), user, instance.id, &[])
1215            .await
1216            .unwrap();
1217        let state = user_exercise_states::get_or_create_user_exercise_state(
1218            tx.as_mut(),
1219            user,
1220            exercise,
1221            Some(course),
1222            None,
1223        )
1224        .await
1225        .unwrap();
1226        user_exercise_states::update(
1227            tx.as_mut(),
1228            UserExerciseStateUpdate {
1229                id: state.id,
1230                score_given: Some(10.0),
1231                activity_progress: ActivityProgress::Completed,
1232                reviewing_stage: ReviewingStage::NotStarted,
1233                grading_progress: GradingProgress::FullyGraded,
1234            },
1235        )
1236        .await
1237        .unwrap();
1238
1239        let completion = course_module_completions::insert(
1240            tx.as_mut(),
1241            PKeyPolicy::Generate,
1242            &NewCourseModuleCompletion {
1243                course_id: course,
1244                course_module_id: course_module.id,
1245                user_id: user,
1246                completion_date: Utc::now() + Duration::days(1),
1247                completion_registration_attempt_date: None,
1248                completion_language: "en-US".to_string(),
1249                eligible_for_ects: false,
1250                email: "email".to_string(),
1251                grade: None,
1252                passed: true,
1253            },
1254            CourseModuleCompletionGranter::Automatic,
1255        )
1256        .await
1257        .unwrap();
1258        let thresholds = suspected_cheaters::insert_thresholds_by_module_id(
1259            tx.as_mut(),
1260            course_module.id,
1261            259200,
1262        )
1263        .await
1264        .unwrap();
1265        check_and_insert_suspected_cheaters(
1266            tx.as_mut(),
1267            user,
1268            course,
1269            thresholds.duration_seconds,
1270            completion,
1271        )
1272        .await
1273        .unwrap();
1274
1275        let cheaters = suspected_cheaters::get_all_suspected_cheaters_in_course(
1276            tx.as_mut(),
1277            course,
1278            SuspectedCheaterStatus::Flagged,
1279        )
1280        .await
1281        .unwrap();
1282        assert_eq!(cheaters.len(), 1);
1283        assert_eq!(cheaters[0].user_id, user);
1284    }
1285
1286    #[tokio::test]
1287    async fn tagging_suspected_cheater_is_idempotent() {
1288        insert_data!(:tx, user:user, :org, course:course, instance:instance, course_module:course_module, :chapter, :page, :exercise);
1289
1290        crate::library::course_instances::enroll(tx.as_mut(), user, instance.id, &[])
1291            .await
1292            .unwrap();
1293        let state = user_exercise_states::get_or_create_user_exercise_state(
1294            tx.as_mut(),
1295            user,
1296            exercise,
1297            Some(course),
1298            None,
1299        )
1300        .await
1301        .unwrap();
1302        user_exercise_states::update(
1303            tx.as_mut(),
1304            UserExerciseStateUpdate {
1305                id: state.id,
1306                score_given: Some(10.0),
1307                activity_progress: ActivityProgress::Completed,
1308                reviewing_stage: ReviewingStage::NotStarted,
1309                grading_progress: GradingProgress::FullyGraded,
1310            },
1311        )
1312        .await
1313        .unwrap();
1314
1315        let completion = course_module_completions::insert(
1316            tx.as_mut(),
1317            PKeyPolicy::Generate,
1318            &NewCourseModuleCompletion {
1319                course_id: course,
1320                course_module_id: course_module.id,
1321                user_id: user,
1322                completion_date: Utc::now() + Duration::days(1),
1323                completion_registration_attempt_date: None,
1324                completion_language: "en-US".to_string(),
1325                eligible_for_ects: false,
1326                email: "email".to_string(),
1327                grade: None,
1328                passed: true,
1329            },
1330            CourseModuleCompletionGranter::Automatic,
1331        )
1332        .await
1333        .unwrap();
1334        let thresholds = suspected_cheaters::insert_thresholds_by_module_id(
1335            tx.as_mut(),
1336            course_module.id,
1337            259200,
1338        )
1339        .await
1340        .unwrap();
1341        check_and_insert_suspected_cheaters(
1342            tx.as_mut(),
1343            user,
1344            course,
1345            thresholds.duration_seconds,
1346            completion.clone(),
1347        )
1348        .await
1349        .unwrap();
1350        check_and_insert_suspected_cheaters(
1351            tx.as_mut(),
1352            user,
1353            course,
1354            thresholds.duration_seconds,
1355            completion,
1356        )
1357        .await
1358        .unwrap();
1359
1360        let cheaters = suspected_cheaters::get_all_suspected_cheaters_in_course(
1361            tx.as_mut(),
1362            course,
1363            SuspectedCheaterStatus::Flagged,
1364        )
1365        .await
1366        .unwrap();
1367        assert_eq!(cheaters.len(), 1);
1368        let completion_needing_review =
1369            course_module_completions::get_latest_by_course_and_user_ids(
1370                tx.as_mut(),
1371                course_module.id,
1372                user,
1373            )
1374            .await
1375            .unwrap();
1376        assert!(completion_needing_review.needs_to_be_reviewed);
1377
1378        suspected_cheaters::dismiss_by_user_id_and_course_id(tx.as_mut(), user, course)
1379            .await
1380            .unwrap();
1381        let archived_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!(!archived_completion.needs_to_be_reviewed);
1389        check_and_insert_suspected_cheaters(
1390            tx.as_mut(),
1391            user,
1392            course,
1393            thresholds.duration_seconds,
1394            archived_completion,
1395        )
1396        .await
1397        .unwrap();
1398
1399        let visible_cheaters = suspected_cheaters::get_all_suspected_cheaters_in_course(
1400            tx.as_mut(),
1401            course,
1402            SuspectedCheaterStatus::Flagged,
1403        )
1404        .await
1405        .unwrap();
1406        let archived_cheaters = suspected_cheaters::get_all_suspected_cheaters_in_course(
1407            tx.as_mut(),
1408            course,
1409            SuspectedCheaterStatus::Dismissed,
1410        )
1411        .await
1412        .unwrap();
1413        assert!(visible_cheaters.is_empty());
1414        assert_eq!(archived_cheaters.len(), 1);
1415        let rechecked_completion = course_module_completions::get_latest_by_course_and_user_ids(
1416            tx.as_mut(),
1417            course_module.id,
1418            user,
1419        )
1420        .await
1421        .unwrap();
1422        assert!(!rechecked_completion.needs_to_be_reviewed);
1423    }
1424
1425    #[tokio::test]
1426    async fn confirming_then_dismissing_restores_grade() {
1427        insert_data!(:tx, user:user, :org, course:course, instance:instance, course_module:course_module, :chapter, :page, :exercise);
1428
1429        crate::library::course_instances::enroll(tx.as_mut(), user, instance.id, &[])
1430            .await
1431            .unwrap();
1432        let state = user_exercise_states::get_or_create_user_exercise_state(
1433            tx.as_mut(),
1434            user,
1435            exercise,
1436            Some(course),
1437            None,
1438        )
1439        .await
1440        .unwrap();
1441        user_exercise_states::update(
1442            tx.as_mut(),
1443            UserExerciseStateUpdate {
1444                id: state.id,
1445                score_given: Some(10.0),
1446                activity_progress: ActivityProgress::Completed,
1447                reviewing_stage: ReviewingStage::NotStarted,
1448                grading_progress: GradingProgress::FullyGraded,
1449            },
1450        )
1451        .await
1452        .unwrap();
1453
1454        // A graded, passing completion so we can prove the exact grade is restored, not just pass/fail.
1455        let completion = course_module_completions::insert(
1456            tx.as_mut(),
1457            PKeyPolicy::Generate,
1458            &NewCourseModuleCompletion {
1459                course_id: course,
1460                course_module_id: course_module.id,
1461                user_id: user,
1462                completion_date: Utc::now() + Duration::days(1),
1463                completion_registration_attempt_date: None,
1464                completion_language: "en-US".to_string(),
1465                eligible_for_ects: false,
1466                email: "email".to_string(),
1467                grade: Some(4),
1468                passed: true,
1469            },
1470            CourseModuleCompletionGranter::Automatic,
1471        )
1472        .await
1473        .unwrap();
1474        let thresholds = suspected_cheaters::insert_thresholds_by_module_id(
1475            tx.as_mut(),
1476            course_module.id,
1477            259200,
1478        )
1479        .await
1480        .unwrap();
1481        check_and_insert_suspected_cheaters(
1482            tx.as_mut(),
1483            user,
1484            course,
1485            thresholds.duration_seconds,
1486            completion,
1487        )
1488        .await
1489        .unwrap();
1490
1491        // Confirm: the student is failed and the previous grade is snapshotted.
1492        suspected_cheaters::confirm_cheater_by_user_id_and_course_id(tx.as_mut(), user, course)
1493            .await
1494            .unwrap();
1495        let failed = course_module_completions::get_latest_by_course_and_user_ids(
1496            tx.as_mut(),
1497            course_module.id,
1498            user,
1499        )
1500        .await
1501        .unwrap();
1502        assert!(!failed.passed);
1503        assert_eq!(failed.grade, Some(0));
1504        let confirmed = suspected_cheaters::get_all_suspected_cheaters_in_course(
1505            tx.as_mut(),
1506            course,
1507            SuspectedCheaterStatus::ConfirmedCheating,
1508        )
1509        .await
1510        .unwrap();
1511        assert_eq!(confirmed.len(), 1);
1512
1513        // Dismiss: the confirmation is undone and the exact previous grade is restored.
1514        suspected_cheaters::dismiss_by_user_id_and_course_id(tx.as_mut(), user, course)
1515            .await
1516            .unwrap();
1517        let restored = course_module_completions::get_latest_by_course_and_user_ids(
1518            tx.as_mut(),
1519            course_module.id,
1520            user,
1521        )
1522        .await
1523        .unwrap();
1524        assert!(restored.passed);
1525        assert_eq!(restored.grade, Some(4));
1526        assert!(!restored.needs_to_be_reviewed);
1527        let dismissed = suspected_cheaters::get_all_suspected_cheaters_in_course(
1528            tx.as_mut(),
1529            course,
1530            SuspectedCheaterStatus::Dismissed,
1531        )
1532        .await
1533        .unwrap();
1534        assert_eq!(dismissed.len(), 1);
1535    }
1536
1537    #[tokio::test]
1538    async fn manual_completion_prevents_flagging() {
1539        insert_data!(:tx, user:user, :org, course:course, instance:instance, course_module:course_module);
1540
1541        crate::library::course_instances::enroll(tx.as_mut(), user, instance.id, &[])
1542            .await
1543            .unwrap();
1544
1545        // A teacher has manually granted a completion for this student.
1546        let teacher = users::insert(
1547            tx.as_mut(),
1548            PKeyPolicy::Generate,
1549            "teacher-vouching@example.com",
1550            Some("Teacher"),
1551            Some("McVouch"),
1552        )
1553        .await
1554        .unwrap();
1555        course_module_completions::insert(
1556            tx.as_mut(),
1557            PKeyPolicy::Generate,
1558            &NewCourseModuleCompletion {
1559                course_id: course,
1560                course_module_id: course_module.id,
1561                user_id: user,
1562                completion_date: Utc::now(),
1563                completion_registration_attempt_date: None,
1564                completion_language: "en-US".to_string(),
1565                eligible_for_ects: false,
1566                email: "email".to_string(),
1567                grade: Some(5),
1568                passed: true,
1569            },
1570            CourseModuleCompletionGranter::User(teacher),
1571        )
1572        .await
1573        .unwrap();
1574
1575        // An automatic completion well inside the threshold would normally flag the student.
1576        let automatic_completion = course_module_completions::insert(
1577            tx.as_mut(),
1578            PKeyPolicy::Generate,
1579            &NewCourseModuleCompletion {
1580                course_id: course,
1581                course_module_id: course_module.id,
1582                user_id: user,
1583                completion_date: Utc::now() + Duration::days(1),
1584                completion_registration_attempt_date: None,
1585                completion_language: "en-US".to_string(),
1586                eligible_for_ects: false,
1587                email: "email".to_string(),
1588                grade: None,
1589                passed: true,
1590            },
1591            CourseModuleCompletionGranter::Automatic,
1592        )
1593        .await
1594        .unwrap();
1595        let thresholds = suspected_cheaters::insert_thresholds_by_module_id(
1596            tx.as_mut(),
1597            course_module.id,
1598            259200,
1599        )
1600        .await
1601        .unwrap();
1602        check_and_insert_suspected_cheaters(
1603            tx.as_mut(),
1604            user,
1605            course,
1606            thresholds.duration_seconds,
1607            automatic_completion,
1608        )
1609        .await
1610        .unwrap();
1611
1612        // The teacher's manual completion exempts the student, so no suspicion is created.
1613        let cheaters = suspected_cheaters::get_all_suspected_cheaters_in_course(
1614            tx.as_mut(),
1615            course,
1616            SuspectedCheaterStatus::Flagged,
1617        )
1618        .await
1619        .unwrap();
1620        assert!(cheaters.is_empty());
1621    }
1622
1623    #[tokio::test]
1624    async fn adding_manual_completion_dismisses_confirmed_suspicion_and_restores_grade() {
1625        insert_data!(:tx, user:user, :org, course:course, instance:instance, course_module:course_module, :chapter, :page, :exercise);
1626
1627        crate::library::course_instances::enroll(tx.as_mut(), user, instance.id, &[])
1628            .await
1629            .unwrap();
1630        let state = user_exercise_states::get_or_create_user_exercise_state(
1631            tx.as_mut(),
1632            user,
1633            exercise,
1634            Some(course),
1635            None,
1636        )
1637        .await
1638        .unwrap();
1639        user_exercise_states::update(
1640            tx.as_mut(),
1641            UserExerciseStateUpdate {
1642                id: state.id,
1643                score_given: Some(10.0),
1644                activity_progress: ActivityProgress::Completed,
1645                reviewing_stage: ReviewingStage::NotStarted,
1646                grading_progress: GradingProgress::FullyGraded,
1647            },
1648        )
1649        .await
1650        .unwrap();
1651
1652        // Flag the student via a fast automatic completion, then confirm cheating (fails the grade).
1653        let completion = course_module_completions::insert(
1654            tx.as_mut(),
1655            PKeyPolicy::Generate,
1656            &NewCourseModuleCompletion {
1657                course_id: course,
1658                course_module_id: course_module.id,
1659                user_id: user,
1660                completion_date: Utc::now() + Duration::days(1),
1661                completion_registration_attempt_date: None,
1662                completion_language: "en-US".to_string(),
1663                eligible_for_ects: false,
1664                email: "email".to_string(),
1665                grade: Some(4),
1666                passed: true,
1667            },
1668            CourseModuleCompletionGranter::Automatic,
1669        )
1670        .await
1671        .unwrap();
1672        let thresholds = suspected_cheaters::insert_thresholds_by_module_id(
1673            tx.as_mut(),
1674            course_module.id,
1675            259200,
1676        )
1677        .await
1678        .unwrap();
1679        check_and_insert_suspected_cheaters(
1680            tx.as_mut(),
1681            user,
1682            course,
1683            thresholds.duration_seconds,
1684            completion,
1685        )
1686        .await
1687        .unwrap();
1688        suspected_cheaters::confirm_cheater_by_user_id_and_course_id(tx.as_mut(), user, course)
1689            .await
1690            .unwrap();
1691
1692        // A teacher manually adds a completion for the same student. `skip_duplicate_completions` is
1693        // false so the completion is inserted even though the module is already completed.
1694        let teacher = users::insert(
1695            tx.as_mut(),
1696            PKeyPolicy::Generate,
1697            "teacher-vouching@example.com",
1698            Some("Teacher"),
1699            Some("McVouch"),
1700        )
1701        .await
1702        .unwrap();
1703        add_manual_completions(
1704            tx.as_mut(),
1705            teacher,
1706            &instance,
1707            &TeacherManualCompletionRequest {
1708                course_module_id: course_module.id,
1709                new_completions: vec![TeacherManualCompletion {
1710                    user_id: user,
1711                    grade: Some(5),
1712                    passed: true,
1713                    completion_date: None,
1714                }],
1715                skip_duplicate_completions: false,
1716            },
1717        )
1718        .await
1719        .unwrap();
1720
1721        // The suspicion is dismissed and the confirmed-cheating grade failure is undone.
1722        let flagged = suspected_cheaters::get_all_suspected_cheaters_in_course(
1723            tx.as_mut(),
1724            course,
1725            SuspectedCheaterStatus::Flagged,
1726        )
1727        .await
1728        .unwrap();
1729        let confirmed = suspected_cheaters::get_all_suspected_cheaters_in_course(
1730            tx.as_mut(),
1731            course,
1732            SuspectedCheaterStatus::ConfirmedCheating,
1733        )
1734        .await
1735        .unwrap();
1736        let dismissed = suspected_cheaters::get_all_suspected_cheaters_in_course(
1737            tx.as_mut(),
1738            course,
1739            SuspectedCheaterStatus::Dismissed,
1740        )
1741        .await
1742        .unwrap();
1743        assert!(flagged.is_empty());
1744        assert!(confirmed.is_empty());
1745        assert_eq!(dismissed.len(), 1);
1746
1747        // The automatic completion's failed grade is restored and its review flag is cleared.
1748        let restored =
1749            course_module_completions::get_automatic_completion_by_course_module_course_and_user_ids(
1750                tx.as_mut(),
1751                course_module.id,
1752                course,
1753                user,
1754            )
1755            .await
1756            .unwrap();
1757        assert!(restored.passed);
1758        assert_eq!(restored.grade, Some(4));
1759        assert!(!restored.needs_to_be_reviewed);
1760    }
1761
1762    #[tokio::test]
1763    async fn doesnt_tag_suspected_cheater() {
1764        insert_data!(:tx, user:user, :org, :course, instance:instance, course_module:course_module, :chapter, :page, :exercise);
1765
1766        crate::library::course_instances::enroll(tx.as_mut(), user, instance.id, &[])
1767            .await
1768            .unwrap();
1769        let state = user_exercise_states::get_or_create_user_exercise_state(
1770            tx.as_mut(),
1771            user,
1772            exercise,
1773            Some(course),
1774            None,
1775        )
1776        .await
1777        .unwrap();
1778        user_exercise_states::update(
1779            tx.as_mut(),
1780            UserExerciseStateUpdate {
1781                id: state.id,
1782                score_given: Some(9.0),
1783                activity_progress: ActivityProgress::Completed,
1784                reviewing_stage: ReviewingStage::NotStarted,
1785                grading_progress: GradingProgress::FullyGraded,
1786            },
1787        )
1788        .await
1789        .unwrap();
1790
1791        course_module_completions::insert(
1792            tx.as_mut(),
1793            PKeyPolicy::Generate,
1794            &NewCourseModuleCompletion {
1795                course_id: course,
1796                course_module_id: course_module.id,
1797                user_id: user,
1798                completion_date: Utc::now() + Duration::days(3),
1799                completion_registration_attempt_date: None,
1800                completion_language: "en-US".to_string(),
1801                eligible_for_ects: false,
1802                email: "email".to_string(),
1803                grade: Some(9),
1804                passed: true,
1805            },
1806            CourseModuleCompletionGranter::Automatic,
1807        )
1808        .await
1809        .unwrap();
1810        suspected_cheaters::insert_thresholds_by_module_id(tx.as_mut(), course_module.id, 172800)
1811            .await
1812            .unwrap();
1813        update_automatic_completion_status_and_grant_if_eligible(tx.as_mut(), &course_module, user)
1814            .await
1815            .unwrap();
1816
1817        let cheaters = suspected_cheaters::get_all_suspected_cheaters_in_course(
1818            tx.as_mut(),
1819            course,
1820            SuspectedCheaterStatus::Flagged,
1821        )
1822        .await
1823        .unwrap();
1824        assert!(cheaters.is_empty());
1825    }
1826
1827    // TODO: New automatic completion tests?
1828}