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    /// ID of the user for the completion.
261    pub user_id: Uuid,
262    // When the user completed the course
263    pub completion_date: DateTime<Utc>,
264}
265
266/// Gets summaries for all completions on the given course instance.
267pub async fn get_all_with_registration_information_by_course_instance_id(
268    conn: &mut PgConnection,
269    course_instance_id: Uuid,
270    course_id: Uuid,
271) -> ModelResult<Vec<CourseModuleCompletionWithRegistrationInfo>> {
272    let res = sqlx::query_as!(
273        CourseModuleCompletionWithRegistrationInfo,
274        r#"
275SELECT completions.completion_registration_attempt_date,
276  completions.course_module_id,
277  completions.created_at,
278  completions.grade,
279  completions.passed,
280  completions.prerequisite_modules_completed,
281  (registered.id IS NOT NULL) AS "registered!",
282  completions.user_id,
283  completions.completion_date
284FROM course_module_completions completions
285  LEFT JOIN course_module_completion_registered_to_study_registries registered ON (
286    completions.id = registered.course_module_completion_id
287  )
288  JOIN user_course_settings settings ON (
289    completions.user_id = settings.user_id
290    AND settings.current_course_id = completions.course_id
291  )
292WHERE settings.current_course_instance_id = $1
293  AND completions.deleted_at IS NULL
294  AND registered.deleted_at IS NULL
295  AND settings.deleted_at IS NULL
296  AND settings.current_course_id = $2
297        "#,
298        course_instance_id,
299        course_id
300    )
301    .fetch_all(conn)
302    .await?;
303    Ok(res)
304}
305
306/// Gets all module completions for the user on a course. There can be multiple modules
307/// in a single course, so the result is a `Vec`.
308pub async fn get_all_by_course_id_and_user_id(
309    conn: &mut PgConnection,
310    course_id: Uuid,
311    user_id: Uuid,
312) -> ModelResult<Vec<CourseModuleCompletion>> {
313    let res = sqlx::query_as!(
314        CourseModuleCompletion,
315        "
316SELECT *
317FROM course_module_completions
318WHERE course_id = $1
319  AND user_id = $2
320  AND deleted_at IS NULL
321        ",
322        course_id,
323        user_id,
324    )
325    .fetch_all(conn)
326    .await?;
327    Ok(res)
328}
329
330pub async fn get_all_by_user_id(
331    conn: &mut PgConnection,
332    user_id: Uuid,
333) -> ModelResult<Vec<CourseModuleCompletion>> {
334    let res = sqlx::query_as!(
335        CourseModuleCompletion,
336        "
337SELECT *
338FROM course_module_completions
339WHERE user_id = $1
340  AND deleted_at IS NULL
341        ",
342        user_id,
343    )
344    .fetch_all(conn)
345    .await?;
346    Ok(res)
347}
348
349pub async fn get_all_by_user_id_and_course_module_id(
350    conn: &mut PgConnection,
351    user_id: Uuid,
352    course_module_id: Uuid,
353) -> ModelResult<Vec<CourseModuleCompletion>> {
354    let res = sqlx::query_as!(
355        CourseModuleCompletion,
356        "
357SELECT *
358FROM course_module_completions
359WHERE user_id = $1
360  AND course_module_id = $2
361  AND deleted_at IS NULL
362        ",
363        user_id,
364        course_module_id,
365    )
366    .fetch_all(conn)
367    .await?;
368    Ok(res)
369}
370
371pub async fn get_all_by_course_module_and_user_ids(
372    conn: &mut PgConnection,
373    course_module_id: Uuid,
374    user_id: Uuid,
375) -> ModelResult<Vec<CourseModuleCompletion>> {
376    let res = sqlx::query_as!(
377        CourseModuleCompletion,
378        "
379SELECT *
380FROM course_module_completions
381WHERE course_module_id = $1
382  AND user_id = $2
383  AND deleted_at IS NULL
384        ",
385        course_module_id,
386        user_id,
387    )
388    .fetch_all(conn)
389    .await?;
390    Ok(res)
391}
392
393/// Gets latest created completion for the given user on the specified course module.
394pub async fn get_latest_by_course_and_user_ids(
395    conn: &mut PgConnection,
396    course_module_id: Uuid,
397    user_id: Uuid,
398) -> ModelResult<CourseModuleCompletion> {
399    let res = sqlx::query_as!(
400        CourseModuleCompletion,
401        "
402SELECT *
403FROM course_module_completions
404WHERE course_module_id = $1
405  AND user_id = $2
406  AND deleted_at IS NULL
407ORDER BY created_at DESC
408LIMIT 1
409        ",
410        course_module_id,
411        user_id,
412    )
413    .fetch_one(conn)
414    .await?;
415    Ok(res)
416}
417
418pub async fn get_best_completion_by_user_and_course_module_id(
419    conn: &mut PgConnection,
420    user_id: Uuid,
421    course_module_id: Uuid,
422) -> ModelResult<Option<CourseModuleCompletion>> {
423    let completions = sqlx::query_as!(
424        CourseModuleCompletion,
425        r#"
426SELECT *
427FROM course_module_completions
428WHERE user_id = $1
429  AND course_module_id = $2
430  AND deleted_at IS NULL
431        "#,
432        user_id,
433        course_module_id,
434    )
435    .fetch_all(conn)
436    .await?;
437
438    let best_grade = completions
439        .into_iter()
440        .max_by(|completion_a, completion_b| {
441            let score_a = match completion_a.grade {
442                Some(grade) => grade as f32,
443                None => match completion_a.passed {
444                    true => 0.5,
445                    false => -1.0,
446                },
447            };
448
449            let score_b = match completion_b.grade {
450                Some(grade) => grade as f32,
451                None => match completion_b.passed {
452                    true => 0.5,
453                    false => -1.0,
454                },
455            };
456
457            score_a
458                .partial_cmp(&score_b)
459                .unwrap_or(std::cmp::Ordering::Equal)
460        });
461
462    Ok(best_grade)
463}
464
465/// Finds the best grade
466pub fn select_best_completion(
467    completions: Vec<CourseModuleCompletion>,
468) -> Option<CourseModuleCompletion> {
469    completions.into_iter().max_by(|a, b| {
470        let score_a = match a.grade {
471            Some(grade) => grade as f32,
472            None => {
473                if a.passed {
474                    0.5
475                } else {
476                    -1.0
477                }
478            }
479        };
480        let score_b = match b.grade {
481            Some(grade) => grade as f32,
482            None => {
483                if b.passed {
484                    0.5
485                } else {
486                    -1.0
487                }
488            }
489        };
490        score_a
491            .partial_cmp(&score_b)
492            .unwrap_or(std::cmp::Ordering::Equal)
493    })
494}
495
496/// Get the number of students that have completed the course
497pub async fn get_count_of_distinct_completors_by_course_id(
498    conn: &mut PgConnection,
499    course_id: Uuid,
500) -> ModelResult<i64> {
501    let res = sqlx::query!(
502        "
503SELECT COUNT(DISTINCT user_id) as count
504FROM course_module_completions
505WHERE course_id = $1
506  AND deleted_at IS NULL
507",
508        course_id,
509    )
510    .fetch_one(conn)
511    .await?;
512    Ok(res.count.unwrap_or(0))
513}
514
515/// Gets automatically granted course module completion for the given user on the specified course.
516/// This entry is quaranteed to be unique in database by the index
517/// `course_module_automatic_completion_uniqueness`.
518pub async fn get_automatic_completion_by_course_module_course_and_user_ids(
519    conn: &mut PgConnection,
520    course_module_id: Uuid,
521    course_id: Uuid,
522    user_id: Uuid,
523) -> ModelResult<CourseModuleCompletion> {
524    let res = sqlx::query_as!(
525        CourseModuleCompletion,
526        "
527SELECT *
528FROM course_module_completions
529WHERE course_module_id = $1
530  AND course_id = $2
531  AND user_id = $3
532  AND completion_granter_user_id IS NULL
533  AND deleted_at IS NULL
534        ",
535        course_module_id,
536        course_id,
537        user_id,
538    )
539    .fetch_one(conn)
540    .await?;
541    Ok(res)
542}
543
544pub async fn update_completion_registration_attempt_date(
545    conn: &mut PgConnection,
546    id: Uuid,
547    completion_registration_attempt_date: DateTime<Utc>,
548) -> ModelResult<bool> {
549    let res = sqlx::query!(
550        "
551UPDATE course_module_completions
552SET completion_registration_attempt_date = $1
553WHERE id = $2
554  AND deleted_at IS NULL
555        ",
556        Some(completion_registration_attempt_date),
557        id,
558    )
559    .execute(conn)
560    .await?;
561    Ok(res.rows_affected() > 0)
562}
563
564pub async fn update_prerequisite_modules_completed(
565    conn: &mut PgConnection,
566    id: Uuid,
567    prerequisite_modules_completed: bool,
568) -> ModelResult<bool> {
569    let res = sqlx::query!(
570        "
571UPDATE course_module_completions SET prerequisite_modules_completed = $1
572WHERE id = $2 AND deleted_at IS NULL
573    ",
574        prerequisite_modules_completed,
575        id
576    )
577    .execute(conn)
578    .await?;
579    Ok(res.rows_affected() > 0)
580}
581
582pub async fn update_passed_and_grade_status(
583    conn: &mut PgConnection,
584    course_id: Uuid,
585    user_id: Uuid,
586    passed: bool,
587    grade: i32,
588) -> ModelResult<bool> {
589    let res = sqlx::query!(
590        "
591UPDATE course_module_completions SET passed = $1, grade = $2
592WHERE user_id = $3 AND course_id = $4 AND deleted_at IS NULL
593    ",
594        passed,
595        grade,
596        user_id,
597        course_id
598    )
599    .execute(conn)
600    .await?;
601    Ok(res.rows_affected() > 0)
602}
603
604pub async fn update_needs_to_be_reviewed(
605    conn: &mut PgConnection,
606    id: Uuid,
607    needs_to_be_reviewed: bool,
608) -> ModelResult<bool> {
609    let res = sqlx::query!(
610        "
611UPDATE course_module_completions SET needs_to_be_reviewed = $1
612WHERE id = $2 AND deleted_at IS NULL
613        ",
614        needs_to_be_reviewed,
615        id
616    )
617    .execute(conn)
618    .await?;
619    Ok(res.rows_affected() > 0)
620}
621
622/// Checks whether the user has any completions for the given course module on the specified
623/// course module.
624pub async fn user_has_completed_course_module(
625    conn: &mut PgConnection,
626    user_id: Uuid,
627    course_module_id: Uuid,
628) -> ModelResult<bool> {
629    let res = get_all_by_course_module_and_user_ids(conn, course_module_id, user_id).await?;
630    Ok(!res.is_empty())
631}
632
633/// Completion in the form that is recognized by authorized third party study registry registrars.
634#[derive(Clone, PartialEq, Deserialize, Serialize)]
635
636pub struct StudyRegistryCompletion {
637    /// The date when the student completed the course. The value of this field is the date that will
638    /// end up in the user's study registry as the completion date. If the completion is created
639    /// automatically, it is the date when the student passed the completion thresholds. If the teacher
640    /// creates these completions manually, the teacher inputs this value. Usually the teacher would in
641    /// this case input the date of the exam.
642    pub completion_date: DateTime<Utc>,
643    /// The language used in the completion of the course.
644    pub completion_language: String,
645    /// Date when the student opened the form to register their credits to the open university.
646    pub completion_registration_attempt_date: Option<DateTime<Utc>>,
647    /// Email at the time of completing the course. Used to match the student to the data that they will
648    /// fill to the open university and it will remain unchanged in the event of email change because
649    /// changing this would break the matching.
650    pub email: String,
651    /// The grade to be passed to the study registry. Uses the sisu format. See the struct documentation for details.
652    pub grade: StudyRegistryGrade,
653    /// ID of the completion.
654    pub id: Uuid,
655    /// User id in courses.mooc.fi for received registered completions.
656    pub user_id: Uuid,
657    /// Tier of the completion. Currently always null. Historically used for example to distinguish between
658    /// intermediate and advanced versions of the Building AI course.
659    pub tier: Option<i32>,
660}
661
662impl From<CourseModuleCompletion> for StudyRegistryCompletion {
663    fn from(completion: CourseModuleCompletion) -> Self {
664        Self {
665            completion_date: completion.completion_date,
666            completion_language: completion.completion_language,
667            completion_registration_attempt_date: completion.completion_registration_attempt_date,
668            email: completion.email,
669            grade: StudyRegistryGrade::new(completion.passed, completion.grade),
670            id: completion.id,
671            user_id: completion.user_id,
672            tier: None,
673        }
674    }
675}
676
677impl StudyRegistryCompletion {
678    pub fn normalize_language_code(&mut self) {
679        match self.completion_language.as_str() {
680            "en" => self.completion_language = "en-GB".to_string(),
681            "fi" => self.completion_language = "fi-FI".to_string(),
682            "sv" => self.completion_language = "sv-SE".to_string(),
683            _ => {}
684        }
685    }
686}
687
688/// Grading object that maps the system grading information to Sisu's grading scales.
689///
690/// Currently only `sis-0-5` and `sis-hyv-hyl` scales are supported in the system.
691///
692/// All grading scales can be found from <https://sis-helsinki-test.funidata.fi/api/graphql> using
693/// the following query:
694///
695/// ```graphql
696/// query {
697///   grade_scales {
698///     id
699///     name {
700///       fi
701///       en
702///       sv
703///     }
704///     grades {
705///       name {
706///         fi
707///         en
708///         sv
709///       }
710///       passed
711///       localId
712///       abbreviation {
713///         fi
714///         en
715///         sv
716///       }
717///     }
718///     abbreviation {
719///       fi
720///       en
721///       sv
722///     }
723///   }
724/// }
725/// ```
726#[derive(Clone, PartialEq, Deserialize, Serialize)]
727
728pub struct StudyRegistryGrade {
729    pub scale: String,
730    pub grade: String,
731}
732
733impl StudyRegistryGrade {
734    pub fn new(passed: bool, grade: Option<i32>) -> Self {
735        match grade {
736            Some(grade) => Self {
737                scale: "sis-0-5".to_string(),
738                grade: grade.to_string(),
739            },
740            None => Self {
741                scale: "sis-hyv-hyl".to_string(),
742                grade: if passed {
743                    "1".to_string()
744                } else {
745                    "0".to_string()
746                },
747            },
748        }
749    }
750}
751/// Streams completions.
752///
753/// If no_completions_registered_by_this_study_registry_registrar is None, then all completions are streamed.
754pub fn stream_by_course_module_id<'a>(
755    conn: &'a mut PgConnection,
756    course_module_ids: &'a [Uuid],
757    no_completions_registered_by_this_study_registry_registrar: &'a Option<StudyRegistryRegistrar>,
758) -> impl Stream<Item = sqlx::Result<StudyRegistryCompletion>> + Send + 'a {
759    // If this is none, we're using a null uuid, which will never match anything. Therefore, no completions will be filtered out.
760    let study_module_registrar_id = no_completions_registered_by_this_study_registry_registrar
761        .clone()
762        .map(|o| o.id)
763        .unwrap_or(Uuid::nil());
764
765    sqlx::query_as!(
766        CourseModuleCompletion,
767        r#"
768SELECT *
769FROM course_module_completions
770WHERE course_module_id = ANY($1)
771  AND prerequisite_modules_completed
772  AND eligible_for_ects IS TRUE
773  AND deleted_at IS NULL
774  AND id NOT IN (
775    SELECT course_module_completion_id
776    FROM course_module_completion_registered_to_study_registries
777    WHERE course_module_id = ANY($1)
778      AND study_registry_registrar_id = $2
779      AND deleted_at IS NULL
780  )
781        "#,
782        course_module_ids,
783        study_module_registrar_id,
784    )
785    .map(StudyRegistryCompletion::from)
786    .fetch(conn)
787}
788
789pub async fn delete(conn: &mut PgConnection, id: Uuid) -> ModelResult<()> {
790    sqlx::query!(
791        "
792
793UPDATE course_module_completions
794SET deleted_at = now()
795WHERE id = $1
796AND deleted_at IS NULL
797        ",
798        id,
799    )
800    .execute(conn)
801    .await?;
802    Ok(())
803}
804
805pub async fn find_existing(
806    conn: &mut PgConnection,
807    course_id: Uuid,
808    course_module_id: Uuid,
809    user_id: Uuid,
810) -> ModelResult<Uuid> {
811    let row = sqlx::query!(
812        r#"
813        SELECT id
814        FROM course_module_completions
815        WHERE course_id = $1
816          AND course_module_id = $2
817          AND user_id = $3
818          AND completion_granter_user_id IS NULL
819          AND deleted_at IS NULL
820        "#,
821        course_id,
822        course_module_id,
823        user_id,
824    )
825    .fetch_one(conn)
826    .await?;
827
828    Ok(row.id)
829}
830
831pub async fn update_registration_attempt(
832    conn: &mut PgConnection,
833    completion_id: Uuid,
834) -> ModelResult<()> {
835    sqlx::query!(
836        r#"
837        UPDATE course_module_completions
838        SET completion_registration_attempt_date = now()
839        WHERE id = $1
840        "#,
841        completion_id
842    )
843    .execute(conn)
844    .await?;
845
846    Ok(())
847}