headless_lms_models/
course_module_completions.rs

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