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    completions.into_iter().max_by(|a, b| {
473        let score_a = match a.grade {
474            Some(grade) => grade as f32,
475            None => {
476                if a.passed {
477                    0.5
478                } else {
479                    -1.0
480                }
481            }
482        };
483        let score_b = match b.grade {
484            Some(grade) => grade as f32,
485            None => {
486                if b.passed {
487                    0.5
488                } else {
489                    -1.0
490                }
491            }
492        };
493        score_a
494            .partial_cmp(&score_b)
495            .unwrap_or(std::cmp::Ordering::Equal)
496    })
497}
498
499/// Get the number of students that have completed the course
500pub async fn get_count_of_distinct_completors_by_course_id(
501    conn: &mut PgConnection,
502    course_id: Uuid,
503) -> ModelResult<i64> {
504    let res = sqlx::query!(
505        "
506SELECT COUNT(DISTINCT user_id) as count
507FROM course_module_completions
508WHERE course_id = $1
509  AND deleted_at IS NULL
510",
511        course_id,
512    )
513    .fetch_one(conn)
514    .await?;
515    Ok(res.count.unwrap_or(0))
516}
517
518/// Gets automatically granted course module completion for the given user on the specified course.
519/// This entry is quaranteed to be unique in database by the index
520/// `course_module_automatic_completion_uniqueness`.
521pub async fn get_automatic_completion_by_course_module_course_and_user_ids(
522    conn: &mut PgConnection,
523    course_module_id: Uuid,
524    course_id: Uuid,
525    user_id: Uuid,
526) -> ModelResult<CourseModuleCompletion> {
527    let res = sqlx::query_as!(
528        CourseModuleCompletion,
529        "
530SELECT *
531FROM course_module_completions
532WHERE course_module_id = $1
533  AND course_id = $2
534  AND user_id = $3
535  AND completion_granter_user_id IS NULL
536  AND deleted_at IS NULL
537        ",
538        course_module_id,
539        course_id,
540        user_id,
541    )
542    .fetch_one(conn)
543    .await?;
544    Ok(res)
545}
546
547/// True if the user has at least one non-deleted, teacher-granted (manual) completion in the
548/// course. A manual completion means a teacher vouched for the student, which exempts them from
549/// automatic cheating suspicion for the whole course.
550pub async fn user_has_manual_completion_in_course(
551    conn: &mut PgConnection,
552    user_id: Uuid,
553    course_id: Uuid,
554) -> ModelResult<bool> {
555    let res = sqlx::query!(
556        r#"
557SELECT EXISTS (
558  SELECT 1
559  FROM course_module_completions
560  WHERE user_id = $1
561    AND course_id = $2
562    AND completion_granter_user_id IS NOT NULL
563    AND deleted_at IS NULL
564) AS "exists!"
565        "#,
566        user_id,
567        course_id,
568    )
569    .fetch_one(conn)
570    .await?;
571    Ok(res.exists)
572}
573
574pub async fn update_completion_registration_attempt_date(
575    conn: &mut PgConnection,
576    id: Uuid,
577    completion_registration_attempt_date: DateTime<Utc>,
578) -> ModelResult<bool> {
579    let res = sqlx::query!(
580        "
581UPDATE course_module_completions
582SET completion_registration_attempt_date = $1
583WHERE id = $2
584  AND deleted_at IS NULL
585        ",
586        Some(completion_registration_attempt_date),
587        id,
588    )
589    .execute(conn)
590    .await?;
591    Ok(res.rows_affected() > 0)
592}
593
594pub async fn update_prerequisite_modules_completed(
595    conn: &mut PgConnection,
596    id: Uuid,
597    prerequisite_modules_completed: bool,
598) -> ModelResult<bool> {
599    let res = sqlx::query!(
600        "
601UPDATE course_module_completions SET prerequisite_modules_completed = $1
602WHERE id = $2 AND deleted_at IS NULL
603    ",
604        prerequisite_modules_completed,
605        id
606    )
607    .execute(conn)
608    .await?;
609    Ok(res.rows_affected() > 0)
610}
611
612pub async fn update_needs_to_be_reviewed(
613    conn: &mut PgConnection,
614    id: Uuid,
615    needs_to_be_reviewed: bool,
616) -> ModelResult<bool> {
617    let res = sqlx::query!(
618        "
619UPDATE course_module_completions SET needs_to_be_reviewed = $1
620WHERE id = $2 AND deleted_at IS NULL
621        ",
622        needs_to_be_reviewed,
623        id
624    )
625    .execute(conn)
626    .await?;
627    Ok(res.rows_affected() > 0)
628}
629
630pub async fn update_needs_to_be_reviewed_by_course_and_user_ids(
631    conn: &mut PgConnection,
632    course_id: Uuid,
633    user_id: Uuid,
634    needs_to_be_reviewed: bool,
635) -> ModelResult<bool> {
636    let res = sqlx::query!(
637        "
638UPDATE course_module_completions SET needs_to_be_reviewed = $1
639WHERE course_id = $2 AND user_id = $3 AND deleted_at IS NULL
640        ",
641        needs_to_be_reviewed,
642        course_id,
643        user_id,
644    )
645    .execute(conn)
646    .await?;
647    Ok(res.rows_affected() > 0)
648}
649
650/// Checks whether the user has any completions for the given course module on the specified
651/// course module.
652pub async fn user_has_completed_course_module(
653    conn: &mut PgConnection,
654    user_id: Uuid,
655    course_module_id: Uuid,
656) -> ModelResult<bool> {
657    let res = get_all_by_course_module_and_user_ids(conn, course_module_id, user_id).await?;
658    Ok(!res.is_empty())
659}
660
661/// Completion in the form that is recognized by authorized third party study registry registrars.
662#[derive(Clone, PartialEq, Deserialize, Serialize)]
663
664pub struct StudyRegistryCompletion {
665    /// The date when the student completed the course. The value of this field is the date that will
666    /// end up in the user's study registry as the completion date. If the completion is created
667    /// automatically, it is the date when the student passed the completion thresholds. If the teacher
668    /// creates these completions manually, the teacher inputs this value. Usually the teacher would in
669    /// this case input the date of the exam.
670    pub completion_date: DateTime<Utc>,
671    /// The language used in the completion of the course.
672    pub completion_language: String,
673    /// Date when the student opened the form to register their credits to the open university.
674    pub completion_registration_attempt_date: Option<DateTime<Utc>>,
675    /// Email at the time of completing the course. Used to match the student to the data that they will
676    /// fill to the open university and it will remain unchanged in the event of email change because
677    /// changing this would break the matching.
678    pub email: String,
679    /// The grade to be passed to the study registry. Uses the sisu format. See the struct documentation for details.
680    pub grade: StudyRegistryGrade,
681    /// ID of the completion.
682    pub id: Uuid,
683    /// User id in courses.mooc.fi for received registered completions.
684    pub user_id: Uuid,
685    /// Tier of the completion. Currently always null. Historically used for example to distinguish between
686    /// intermediate and advanced versions of the Building AI course.
687    pub tier: Option<i32>,
688}
689
690impl From<CourseModuleCompletion> for StudyRegistryCompletion {
691    fn from(completion: CourseModuleCompletion) -> Self {
692        Self {
693            completion_date: completion.completion_date,
694            completion_language: completion.completion_language,
695            completion_registration_attempt_date: completion.completion_registration_attempt_date,
696            email: completion.email,
697            grade: StudyRegistryGrade::new(completion.passed, completion.grade),
698            id: completion.id,
699            user_id: completion.user_id,
700            tier: None,
701        }
702    }
703}
704
705impl StudyRegistryCompletion {
706    pub fn normalize_language_code(&mut self) {
707        match self.completion_language.as_str() {
708            "en" => self.completion_language = "en-GB".to_string(),
709            "fi" => self.completion_language = "fi-FI".to_string(),
710            "sv" => self.completion_language = "sv-SE".to_string(),
711            _ => {}
712        }
713    }
714}
715
716/// Grading object that maps the system grading information to Sisu's grading scales.
717///
718/// Currently only `sis-0-5` and `sis-hyv-hyl` scales are supported in the system.
719///
720/// All grading scales can be found from <https://sis-helsinki-test.funidata.fi/api/graphql> using
721/// the following query:
722///
723/// ```graphql
724/// query {
725///   grade_scales {
726///     id
727///     name {
728///       fi
729///       en
730///       sv
731///     }
732///     grades {
733///       name {
734///         fi
735///         en
736///         sv
737///       }
738///       passed
739///       localId
740///       abbreviation {
741///         fi
742///         en
743///         sv
744///       }
745///     }
746///     abbreviation {
747///       fi
748///       en
749///       sv
750///     }
751///   }
752/// }
753/// ```
754#[derive(Clone, PartialEq, Deserialize, Serialize)]
755
756pub struct StudyRegistryGrade {
757    pub scale: String,
758    pub grade: String,
759}
760
761impl StudyRegistryGrade {
762    pub fn new(passed: bool, grade: Option<i32>) -> Self {
763        match grade {
764            Some(grade) => Self {
765                scale: "sis-0-5".to_string(),
766                grade: grade.to_string(),
767            },
768            None => Self {
769                scale: "sis-hyv-hyl".to_string(),
770                grade: if passed {
771                    "1".to_string()
772                } else {
773                    "0".to_string()
774                },
775            },
776        }
777    }
778}
779/// Streams completions.
780///
781/// If no_completions_registered_by_this_study_registry_registrar is None, then all completions are streamed.
782pub fn stream_by_course_module_id<'a>(
783    conn: &'a mut PgConnection,
784    course_module_ids: &'a [Uuid],
785    no_completions_registered_by_this_study_registry_registrar: &'a Option<StudyRegistryRegistrar>,
786) -> impl Stream<Item = sqlx::Result<StudyRegistryCompletion>> + Send + 'a {
787    // If this is none, we're using a null uuid, which will never match anything. Therefore, no completions will be filtered out.
788    let study_module_registrar_id = no_completions_registered_by_this_study_registry_registrar
789        .clone()
790        .map(|o| o.id)
791        .unwrap_or(Uuid::nil());
792
793    sqlx::query_as!(
794        CourseModuleCompletion,
795        r#"
796SELECT *
797FROM course_module_completions
798WHERE course_module_id = ANY($1)
799  AND prerequisite_modules_completed
800  AND eligible_for_ects IS TRUE
801  -- Completions still awaiting suspected-cheater review are withheld from study-registry
802  -- registration until a teacher dismisses or confirms them.
803  AND needs_to_be_reviewed = FALSE
804  AND deleted_at IS NULL
805  AND id NOT IN (
806    SELECT course_module_completion_id
807    FROM course_module_completion_registered_to_study_registries
808    WHERE course_module_id = ANY($1)
809      AND study_registry_registrar_id = $2
810      AND deleted_at IS NULL
811  )
812        "#,
813        course_module_ids,
814        study_module_registrar_id,
815    )
816    .map(StudyRegistryCompletion::from)
817    .fetch(conn)
818}
819
820pub async fn delete(conn: &mut PgConnection, id: Uuid) -> ModelResult<()> {
821    sqlx::query!(
822        "
823
824UPDATE course_module_completions
825SET deleted_at = now()
826WHERE id = $1
827AND deleted_at IS NULL
828        ",
829        id,
830    )
831    .execute(conn)
832    .await?;
833    Ok(())
834}
835
836pub async fn find_existing(
837    conn: &mut PgConnection,
838    course_id: Uuid,
839    course_module_id: Uuid,
840    user_id: Uuid,
841) -> ModelResult<Uuid> {
842    let row = sqlx::query!(
843        r#"
844        SELECT id
845        FROM course_module_completions
846        WHERE course_id = $1
847          AND course_module_id = $2
848          AND user_id = $3
849          AND completion_granter_user_id IS NULL
850          AND deleted_at IS NULL
851        "#,
852        course_id,
853        course_module_id,
854        user_id,
855    )
856    .fetch_one(conn)
857    .await?;
858
859    Ok(row.id)
860}
861
862pub async fn update_registration_attempt(
863    conn: &mut PgConnection,
864    completion_id: Uuid,
865) -> ModelResult<()> {
866    sqlx::query!(
867        r#"
868        UPDATE course_module_completions
869        SET completion_registration_attempt_date = now()
870        WHERE id = $1
871        "#,
872        completion_id
873    )
874    .execute(conn)
875    .await?;
876
877    Ok(())
878}