Skip to main content

headless_lms_models/
course_module_completions.rs

1use std::collections::HashMap;
2
3use futures::Stream;
4use utoipa::ToSchema;
5
6use crate::{prelude::*, study_registry_registrars::StudyRegistryRegistrar};
7
8#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, ToSchema)]
9
10pub struct CourseModuleCompletion {
11    pub id: Uuid,
12    pub created_at: DateTime<Utc>,
13    pub updated_at: DateTime<Utc>,
14    pub deleted_at: Option<DateTime<Utc>>,
15    pub course_id: Uuid,
16    pub course_module_id: Uuid,
17    pub user_id: Uuid,
18    pub completion_date: DateTime<Utc>,
19    pub completion_registration_attempt_date: Option<DateTime<Utc>>,
20    pub completion_language: String,
21    pub eligible_for_ects: bool,
22    pub email: String,
23    pub grade: Option<i32>,
24    pub passed: bool,
25    pub prerequisite_modules_completed: bool,
26    pub completion_granter_user_id: Option<Uuid>,
27    pub needs_to_be_reviewed: bool,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
31
32pub struct CourseModuleAverage {
33    pub id: Uuid,
34    pub course_id: Uuid,
35    pub created_at: DateTime<Utc>,
36    pub updated_at: DateTime<Utc>,
37    pub deleted_at: Option<DateTime<Utc>>,
38    pub average_duration: Option<u64>,
39    pub average_points: i32,
40    pub total_points: i32,
41    pub total_student: i32,
42}
43
44// Define the CourseModulePointsAverage struct to match the result of the SQL query
45#[derive(Debug, Serialize, Deserialize)]
46
47pub struct CourseModulePointsAverage {
48    pub course_id: Uuid,
49    pub average_points: Option<f32>,
50    pub total_points: Option<i32>,
51    pub total_student: Option<i32>,
52}
53
54#[derive(Clone, PartialEq, Deserialize, Serialize)]
55pub enum CourseModuleCompletionGranter {
56    Automatic,
57    User(Uuid),
58}
59
60impl CourseModuleCompletionGranter {
61    fn to_database_field(&self) -> Option<Uuid> {
62        match self {
63            CourseModuleCompletionGranter::Automatic => None,
64            CourseModuleCompletionGranter::User(user_id) => Some(*user_id),
65        }
66    }
67}
68
69#[derive(Clone, PartialEq, Deserialize, Serialize)]
70
71pub struct NewCourseModuleCompletion {
72    pub course_id: Uuid,
73    pub course_module_id: Uuid,
74    pub user_id: Uuid,
75    pub completion_date: DateTime<Utc>,
76    pub completion_registration_attempt_date: Option<DateTime<Utc>>,
77    pub completion_language: String,
78    pub eligible_for_ects: bool,
79    pub email: String,
80    pub grade: Option<i32>,
81    pub passed: bool,
82}
83
84pub async fn insert(
85    conn: &mut PgConnection,
86    pkey_policy: PKeyPolicy<Uuid>,
87    new_course_module_completion: &NewCourseModuleCompletion,
88    completion_granter: CourseModuleCompletionGranter,
89) -> ModelResult<CourseModuleCompletion> {
90    let res = sqlx::query_as!(
91        CourseModuleCompletion,
92        "
93INSERT INTO course_module_completions (
94    id,
95    course_id,
96    course_module_id,
97    user_id,
98    completion_date,
99    completion_registration_attempt_date,
100    completion_language,
101    eligible_for_ects,
102    email,
103    grade,
104    passed,
105    completion_granter_user_id
106  )
107VALUES (
108    $1,
109    $2,
110    $3,
111    $4,
112    $5,
113    $6,
114    $7,
115    $8,
116    $9,
117    $10,
118    $11,
119    $12
120  )
121RETURNING *
122        ",
123        pkey_policy.into_uuid(),
124        new_course_module_completion.course_id,
125        new_course_module_completion.course_module_id,
126        new_course_module_completion.user_id,
127        new_course_module_completion.completion_date,
128        new_course_module_completion.completion_registration_attempt_date,
129        new_course_module_completion.completion_language,
130        new_course_module_completion.eligible_for_ects,
131        new_course_module_completion.email,
132        new_course_module_completion.grade,
133        new_course_module_completion.passed,
134        completion_granter.to_database_field(),
135    )
136    .fetch_one(conn)
137    .await?;
138    Ok(res)
139}
140
141#[derive(Debug, Clone)]
142pub struct NewCourseModuleCompletionSeed {
143    pub course_id: Uuid,
144    pub course_module_id: Uuid,
145    pub user_id: Uuid,
146    pub completion_date: Option<DateTime<Utc>>,
147    pub completion_language: Option<String>,
148    pub eligible_for_ects: Option<bool>,
149    pub email: Option<String>,
150    pub grade: Option<i32>,
151    pub passed: Option<bool>,
152    pub prerequisite_modules_completed: Option<bool>,
153    pub needs_to_be_reviewed: Option<bool>,
154}
155
156pub async fn insert_seed_row(
157    conn: &mut PgConnection,
158    seed: &NewCourseModuleCompletionSeed,
159) -> ModelResult<Uuid> {
160    let res = sqlx::query!(
161        r#"
162        INSERT INTO course_module_completions (
163            course_id,
164            course_module_id,
165            user_id,
166            completion_date,
167            completion_language,
168            eligible_for_ects,
169            email,
170            grade,
171            passed,
172            prerequisite_modules_completed,
173            needs_to_be_reviewed
174        )
175        VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
176        RETURNING id
177        "#,
178        seed.course_id,
179        seed.course_module_id,
180        seed.user_id,
181        seed.completion_date,
182        seed.completion_language.as_deref(),
183        seed.eligible_for_ects,
184        seed.email.as_deref(),
185        seed.grade,
186        seed.passed,
187        seed.prerequisite_modules_completed,
188        seed.needs_to_be_reviewed,
189    )
190    .fetch_one(conn)
191    .await?;
192
193    Ok(res.id)
194}
195
196pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<CourseModuleCompletion> {
197    let res = sqlx::query_as!(
198        CourseModuleCompletion,
199        r#"
200SELECT *
201FROM course_module_completions
202WHERE id = $1
203  AND deleted_at IS NULL
204        "#,
205        id,
206    )
207    .fetch_one(conn)
208    .await?;
209    Ok(res)
210}
211
212/// Also returns soft deleted completions so that we can make sure the process does not crash if a completion is deleted before we get it back from the study registry.
213pub async fn get_by_ids(
214    conn: &mut PgConnection,
215    ids: &[Uuid],
216) -> ModelResult<Vec<CourseModuleCompletion>> {
217    let res = sqlx::query_as!(
218        CourseModuleCompletion,
219        "
220SELECT *
221FROM course_module_completions
222WHERE id = ANY($1)
223        ",
224        ids,
225    )
226    .fetch_all(conn)
227    .await?;
228    Ok(res)
229}
230
231pub async fn get_by_ids_as_map(
232    conn: &mut PgConnection,
233    ids: &[Uuid],
234) -> ModelResult<HashMap<Uuid, CourseModuleCompletion>> {
235    let res = get_by_ids(conn, ids)
236        .await?
237        .into_iter()
238        .map(|x| (x.id, x))
239        .collect();
240    Ok(res)
241}
242
243#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
244
245pub struct CourseModuleCompletionWithRegistrationInfo {
246    /// When the student has attempted to register the completion.
247    pub completion_registration_attempt_date: Option<DateTime<Utc>>,
248    /// ID of the course module.
249    pub course_module_id: Uuid,
250    /// When the record was created
251    pub created_at: DateTime<Utc>,
252    /// Grade that the student received for the completion.
253    pub grade: Option<i32>,
254    /// Whether or not the student is eligible for credit for the completion.
255    pub passed: bool,
256    /// Whether or not the student is qualified for credit based on other modules in the course.
257    pub prerequisite_modules_completed: bool,
258    /// Whether or not the completion has been registered to a study registry.
259    pub registered: bool,
260    /// Whether or not the completion needs to be reviewed by the teacher.
261    pub needs_to_be_reviewed: bool,
262    /// ID of the user for the completion.
263    pub user_id: Uuid,
264    // When the user completed the course
265    pub completion_date: DateTime<Utc>,
266}
267
268/// Gets summaries for all completions on the given course instance.
269pub async fn get_all_with_registration_information_by_course_instance_id(
270    conn: &mut PgConnection,
271    course_instance_id: Uuid,
272    course_id: Uuid,
273) -> ModelResult<Vec<CourseModuleCompletionWithRegistrationInfo>> {
274    let res = sqlx::query_as!(
275        CourseModuleCompletionWithRegistrationInfo,
276        r#"
277SELECT completions.completion_registration_attempt_date,
278  completions.course_module_id,
279  completions.created_at,
280  completions.grade,
281  completions.passed,
282  completions.prerequisite_modules_completed,
283  (registered.id IS NOT NULL) AS "registered!",
284  completions.needs_to_be_reviewed,
285  completions.user_id,
286  completions.completion_date
287FROM course_module_completions completions
288  LEFT JOIN course_module_completion_registered_to_study_registries registered ON (
289    completions.id = registered.course_module_completion_id
290  )
291  JOIN user_course_settings settings ON (
292    completions.user_id = settings.user_id
293    AND settings.current_course_id = completions.course_id
294  )
295WHERE settings.current_course_instance_id = $1
296  AND completions.deleted_at IS NULL
297  AND registered.deleted_at IS NULL
298  AND settings.deleted_at IS NULL
299  AND settings.current_course_id = $2
300        "#,
301        course_instance_id,
302        course_id
303    )
304    .fetch_all(conn)
305    .await?;
306    Ok(res)
307}
308
309/// Gets all module completions for the user on a course. There can be multiple modules
310/// in a single course, so the result is a `Vec`.
311pub async fn get_all_by_course_id_and_user_id(
312    conn: &mut PgConnection,
313    course_id: Uuid,
314    user_id: Uuid,
315) -> ModelResult<Vec<CourseModuleCompletion>> {
316    let res = sqlx::query_as!(
317        CourseModuleCompletion,
318        "
319SELECT *
320FROM course_module_completions
321WHERE course_id = $1
322  AND user_id = $2
323  AND deleted_at IS NULL
324        ",
325        course_id,
326        user_id,
327    )
328    .fetch_all(conn)
329    .await?;
330    Ok(res)
331}
332
333pub async fn get_all_by_user_id(
334    conn: &mut PgConnection,
335    user_id: Uuid,
336) -> ModelResult<Vec<CourseModuleCompletion>> {
337    let res = sqlx::query_as!(
338        CourseModuleCompletion,
339        "
340SELECT *
341FROM course_module_completions
342WHERE user_id = $1
343  AND deleted_at IS NULL
344        ",
345        user_id,
346    )
347    .fetch_all(conn)
348    .await?;
349    Ok(res)
350}
351
352pub async fn get_all_by_user_id_and_course_module_id(
353    conn: &mut PgConnection,
354    user_id: Uuid,
355    course_module_id: Uuid,
356) -> ModelResult<Vec<CourseModuleCompletion>> {
357    let res = sqlx::query_as!(
358        CourseModuleCompletion,
359        "
360SELECT *
361FROM course_module_completions
362WHERE user_id = $1
363  AND course_module_id = $2
364  AND deleted_at IS NULL
365        ",
366        user_id,
367        course_module_id,
368    )
369    .fetch_all(conn)
370    .await?;
371    Ok(res)
372}
373
374pub async fn get_all_by_course_module_and_user_ids(
375    conn: &mut PgConnection,
376    course_module_id: Uuid,
377    user_id: Uuid,
378) -> ModelResult<Vec<CourseModuleCompletion>> {
379    let res = sqlx::query_as!(
380        CourseModuleCompletion,
381        "
382SELECT *
383FROM course_module_completions
384WHERE course_module_id = $1
385  AND user_id = $2
386  AND deleted_at IS NULL
387        ",
388        course_module_id,
389        user_id,
390    )
391    .fetch_all(conn)
392    .await?;
393    Ok(res)
394}
395
396/// Gets latest created completion for the given user on the specified course module.
397pub async fn get_latest_by_course_and_user_ids(
398    conn: &mut PgConnection,
399    course_module_id: Uuid,
400    user_id: Uuid,
401) -> ModelResult<CourseModuleCompletion> {
402    let res = sqlx::query_as!(
403        CourseModuleCompletion,
404        "
405SELECT *
406FROM course_module_completions
407WHERE course_module_id = $1
408  AND user_id = $2
409  AND deleted_at IS NULL
410ORDER BY created_at DESC
411LIMIT 1
412        ",
413        course_module_id,
414        user_id,
415    )
416    .fetch_one(conn)
417    .await?;
418    Ok(res)
419}
420
421pub async fn get_best_completion_by_user_and_course_module_id(
422    conn: &mut PgConnection,
423    user_id: Uuid,
424    course_module_id: Uuid,
425) -> ModelResult<Option<CourseModuleCompletion>> {
426    let completions = sqlx::query_as!(
427        CourseModuleCompletion,
428        r#"
429SELECT *
430FROM course_module_completions
431WHERE user_id = $1
432  AND course_module_id = $2
433  AND deleted_at IS NULL
434        "#,
435        user_id,
436        course_module_id,
437    )
438    .fetch_all(conn)
439    .await?;
440
441    let best_grade = completions
442        .into_iter()
443        .max_by(|completion_a, completion_b| {
444            let score_a = match completion_a.grade {
445                Some(grade) => grade as f32,
446                None => match completion_a.passed {
447                    true => 0.5,
448                    false => -1.0,
449                },
450            };
451
452            let score_b = match completion_b.grade {
453                Some(grade) => grade as f32,
454                None => match completion_b.passed {
455                    true => 0.5,
456                    false => -1.0,
457                },
458            };
459
460            score_a
461                .partial_cmp(&score_b)
462                .unwrap_or(std::cmp::Ordering::Equal)
463        });
464
465    Ok(best_grade)
466}
467
468/// Finds the best grade
469pub fn select_best_completion(
470    completions: Vec<CourseModuleCompletion>,
471) -> Option<CourseModuleCompletion> {
472    // Passed outranks not passed before grades are compared: ranking by grade alone let a failed
473    // graded completion beat a passed pass/fail one, so a failure was reported as the best result.
474    completions
475        .into_iter()
476        .max_by_key(|completion| (completion.passed, completion.grade.unwrap_or(0)))
477}
478
479/// Get the number of students that have completed the course
480pub async fn get_count_of_distinct_completors_by_course_id(
481    conn: &mut PgConnection,
482    course_id: Uuid,
483) -> ModelResult<i64> {
484    let res = sqlx::query!(
485        "
486SELECT COUNT(DISTINCT user_id) as count
487FROM course_module_completions
488WHERE course_id = $1
489  AND deleted_at IS NULL
490",
491        course_id,
492    )
493    .fetch_one(conn)
494    .await?;
495    Ok(res.count.unwrap_or(0))
496}
497
498/// Gets automatically granted course module completion for the given user on the specified course.
499/// This entry is quaranteed to be unique in database by the index
500/// `course_module_automatic_completion_uniqueness`.
501pub async fn get_automatic_completion_by_course_module_course_and_user_ids(
502    conn: &mut PgConnection,
503    course_module_id: Uuid,
504    course_id: Uuid,
505    user_id: Uuid,
506) -> ModelResult<CourseModuleCompletion> {
507    let res = sqlx::query_as!(
508        CourseModuleCompletion,
509        "
510SELECT *
511FROM course_module_completions
512WHERE course_module_id = $1
513  AND course_id = $2
514  AND user_id = $3
515  AND completion_granter_user_id IS NULL
516  AND deleted_at IS NULL
517        ",
518        course_module_id,
519        course_id,
520        user_id,
521    )
522    .fetch_one(conn)
523    .await?;
524    Ok(res)
525}
526
527/// True if the user has at least one non-deleted, teacher-granted (manual) completion in the
528/// course. A manual completion means a teacher vouched for the student, which exempts them from
529/// automatic cheating suspicion for the whole course.
530pub async fn user_has_manual_completion_in_course(
531    conn: &mut PgConnection,
532    user_id: Uuid,
533    course_id: Uuid,
534) -> ModelResult<bool> {
535    let res = sqlx::query!(
536        r#"
537SELECT EXISTS (
538  SELECT 1
539  FROM course_module_completions
540  WHERE user_id = $1
541    AND course_id = $2
542    AND completion_granter_user_id IS NOT NULL
543    AND deleted_at IS NULL
544) AS "exists!"
545        "#,
546        user_id,
547        course_id,
548    )
549    .fetch_one(conn)
550    .await?;
551    Ok(res.exists)
552}
553
554pub async fn update_completion_registration_attempt_date(
555    conn: &mut PgConnection,
556    id: Uuid,
557    completion_registration_attempt_date: DateTime<Utc>,
558) -> ModelResult<bool> {
559    let res = sqlx::query!(
560        "
561UPDATE course_module_completions
562SET completion_registration_attempt_date = $1
563WHERE id = $2
564  AND deleted_at IS NULL
565        ",
566        Some(completion_registration_attempt_date),
567        id,
568    )
569    .execute(conn)
570    .await?;
571    Ok(res.rows_affected() > 0)
572}
573
574pub async fn update_prerequisite_modules_completed(
575    conn: &mut PgConnection,
576    id: Uuid,
577    prerequisite_modules_completed: bool,
578) -> ModelResult<bool> {
579    let res = sqlx::query!(
580        "
581UPDATE course_module_completions SET prerequisite_modules_completed = $1
582WHERE id = $2 AND deleted_at IS NULL
583    ",
584        prerequisite_modules_completed,
585        id
586    )
587    .execute(conn)
588    .await?;
589    Ok(res.rows_affected() > 0)
590}
591
592pub async fn update_needs_to_be_reviewed(
593    conn: &mut PgConnection,
594    id: Uuid,
595    needs_to_be_reviewed: bool,
596) -> ModelResult<bool> {
597    let res = sqlx::query!(
598        "
599UPDATE course_module_completions SET needs_to_be_reviewed = $1
600WHERE id = $2 AND deleted_at IS NULL
601        ",
602        needs_to_be_reviewed,
603        id
604    )
605    .execute(conn)
606    .await?;
607    Ok(res.rows_affected() > 0)
608}
609
610pub async fn update_needs_to_be_reviewed_by_course_and_user_ids(
611    conn: &mut PgConnection,
612    course_id: Uuid,
613    user_id: Uuid,
614    needs_to_be_reviewed: bool,
615) -> ModelResult<bool> {
616    let res = sqlx::query!(
617        "
618UPDATE course_module_completions SET needs_to_be_reviewed = $1
619WHERE course_id = $2 AND user_id = $3 AND deleted_at IS NULL
620        ",
621        needs_to_be_reviewed,
622        course_id,
623        user_id,
624    )
625    .execute(conn)
626    .await?;
627    Ok(res.rows_affected() > 0)
628}
629
630/// Checks whether the user has any completions for the given course module on the specified
631/// course module.
632pub async fn user_has_completed_course_module(
633    conn: &mut PgConnection,
634    user_id: Uuid,
635    course_module_id: Uuid,
636) -> ModelResult<bool> {
637    let res = get_all_by_course_module_and_user_ids(conn, course_module_id, user_id).await?;
638    Ok(!res.is_empty())
639}
640
641/// Completion in the form that is recognized by authorized third party study registry registrars.
642#[derive(Clone, PartialEq, Deserialize, Serialize)]
643
644pub struct StudyRegistryCompletion {
645    /// The date when the student completed the course. The value of this field is the date that will
646    /// end up in the user's study registry as the completion date. If the completion is created
647    /// automatically, it is the date when the student passed the completion thresholds. If the teacher
648    /// creates these completions manually, the teacher inputs this value. Usually the teacher would in
649    /// this case input the date of the exam.
650    pub completion_date: DateTime<Utc>,
651    /// The language used in the completion of the course.
652    pub completion_language: String,
653    /// Date when the student opened the form to register their credits to the open university.
654    pub completion_registration_attempt_date: Option<DateTime<Utc>>,
655    /// Email at the time of completing the course. Used to match the student to the data that they will
656    /// fill to the open university and it will remain unchanged in the event of email change because
657    /// changing this would break the matching.
658    pub email: String,
659    /// The grade to be passed to the study registry. Uses the sisu format. See the struct documentation for details.
660    pub grade: StudyRegistryGrade,
661    /// ID of the completion.
662    pub id: Uuid,
663    /// User id in courses.mooc.fi for received registered completions.
664    pub user_id: Uuid,
665    /// Tier of the completion. Currently always null. Historically used for example to distinguish between
666    /// intermediate and advanced versions of the Building AI course.
667    pub tier: Option<i32>,
668}
669
670impl From<CourseModuleCompletion> for StudyRegistryCompletion {
671    fn from(completion: CourseModuleCompletion) -> Self {
672        Self {
673            completion_date: completion.completion_date,
674            completion_language: completion.completion_language,
675            completion_registration_attempt_date: completion.completion_registration_attempt_date,
676            email: completion.email,
677            grade: StudyRegistryGrade::new(completion.passed, completion.grade),
678            id: completion.id,
679            user_id: completion.user_id,
680            tier: None,
681        }
682    }
683}
684
685impl StudyRegistryCompletion {
686    pub fn normalize_language_code(&mut self) {
687        match self.completion_language.as_str() {
688            "en" => self.completion_language = "en-GB".to_string(),
689            "fi" => self.completion_language = "fi-FI".to_string(),
690            "sv" => self.completion_language = "sv-SE".to_string(),
691            _ => {}
692        }
693    }
694}
695
696/// Grading object that maps the system grading information to Sisu's grading scales.
697///
698/// Currently only `sis-0-5` and `sis-hyv-hyl` scales are supported in the system.
699///
700/// All grading scales can be found from <https://sis-helsinki-test.funidata.fi/api/graphql> using
701/// the following query:
702///
703/// ```graphql
704/// query {
705///   grade_scales {
706///     id
707///     name {
708///       fi
709///       en
710///       sv
711///     }
712///     grades {
713///       name {
714///         fi
715///         en
716///         sv
717///       }
718///       passed
719///       localId
720///       abbreviation {
721///         fi
722///         en
723///         sv
724///       }
725///     }
726///     abbreviation {
727///       fi
728///       en
729///       sv
730///     }
731///   }
732/// }
733/// ```
734#[derive(Clone, PartialEq, Deserialize, Serialize)]
735
736pub struct StudyRegistryGrade {
737    pub scale: String,
738    pub grade: String,
739}
740
741impl StudyRegistryGrade {
742    pub fn new(passed: bool, grade: Option<i32>) -> Self {
743        match grade {
744            Some(grade) => Self {
745                scale: "sis-0-5".to_string(),
746                grade: grade.to_string(),
747            },
748            None => Self {
749                scale: "sis-hyv-hyl".to_string(),
750                grade: if passed {
751                    "1".to_string()
752                } else {
753                    "0".to_string()
754                },
755            },
756        }
757    }
758}
759/// Streams completions.
760///
761/// If no_completions_registered_by_this_study_registry_registrar is None, then all completions are streamed.
762pub fn stream_by_course_module_id<'a>(
763    conn: &'a mut PgConnection,
764    course_module_ids: &'a [Uuid],
765    no_completions_registered_by_this_study_registry_registrar: &'a Option<StudyRegistryRegistrar>,
766) -> impl Stream<Item = sqlx::Result<StudyRegistryCompletion>> + Send + 'a {
767    // If this is none, we're using a null uuid, which will never match anything. Therefore, no completions will be filtered out.
768    let study_module_registrar_id = no_completions_registered_by_this_study_registry_registrar
769        .clone()
770        .map(|o| o.id)
771        .unwrap_or(Uuid::nil());
772
773    sqlx::query_as!(
774        CourseModuleCompletion,
775        r#"
776SELECT *
777FROM course_module_completions
778WHERE course_module_id = ANY($1)
779  AND prerequisite_modules_completed
780  AND eligible_for_ects IS TRUE
781  -- Completions still awaiting suspected-cheater review are withheld from study-registry
782  -- registration until a teacher dismisses or confirms them.
783  AND needs_to_be_reviewed = FALSE
784  AND deleted_at IS NULL
785  AND id NOT IN (
786    SELECT course_module_completion_id
787    FROM course_module_completion_registered_to_study_registries
788    WHERE course_module_id = ANY($1)
789      AND study_registry_registrar_id = $2
790      AND deleted_at IS NULL
791  )
792        "#,
793        course_module_ids,
794        study_module_registrar_id,
795    )
796    .map(StudyRegistryCompletion::from)
797    .fetch(conn)
798}
799
800pub async fn delete(conn: &mut PgConnection, id: Uuid) -> ModelResult<()> {
801    sqlx::query!(
802        "
803
804UPDATE course_module_completions
805SET deleted_at = now()
806WHERE id = $1
807AND deleted_at IS NULL
808        ",
809        id,
810    )
811    .execute(conn)
812    .await?;
813    Ok(())
814}
815
816pub async fn find_existing(
817    conn: &mut PgConnection,
818    course_id: Uuid,
819    course_module_id: Uuid,
820    user_id: Uuid,
821) -> ModelResult<Uuid> {
822    let row = sqlx::query!(
823        r#"
824        SELECT id
825        FROM course_module_completions
826        WHERE course_id = $1
827          AND course_module_id = $2
828          AND user_id = $3
829          AND completion_granter_user_id IS NULL
830          AND deleted_at IS NULL
831        "#,
832        course_id,
833        course_module_id,
834        user_id,
835    )
836    .fetch_one(conn)
837    .await?;
838
839    Ok(row.id)
840}
841
842pub async fn update_registration_attempt(
843    conn: &mut PgConnection,
844    completion_id: Uuid,
845) -> ModelResult<()> {
846    sqlx::query!(
847        r#"
848        UPDATE course_module_completions
849        SET completion_registration_attempt_date = now()
850        WHERE id = $1
851        "#,
852        completion_id
853    )
854    .execute(conn)
855    .await?;
856
857    Ok(())
858}