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