1use std::borrow::Borrow;
2use std::collections::{HashMap, HashSet};
3
4use futures::Stream;
5use utoipa::ToSchema;
6
7use crate::{
8 error::missing_model_error, prelude::*, study_registry_registrars::StudyRegistryRegistrar,
9};
10
11#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, ToSchema)]
12
13pub struct CourseModuleCompletion {
14 pub id: Uuid,
15 pub created_at: DateTime<Utc>,
16 pub updated_at: DateTime<Utc>,
17 pub deleted_at: Option<DateTime<Utc>>,
18 pub course_id: Uuid,
19 pub course_module_id: Uuid,
20 pub user_id: Uuid,
21 pub completion_date: DateTime<Utc>,
22 pub completion_registration_attempt_date: Option<DateTime<Utc>>,
23 pub completion_language: String,
24 pub eligible_for_ects: bool,
25 pub email: String,
26 pub grade: Option<i32>,
27 pub passed: bool,
28 pub prerequisite_modules_completed: bool,
29 pub completion_granter_user_id: Option<Uuid>,
30 pub needs_to_be_reviewed: bool,
31 pub register_credits_via_suotar: bool,
33}
34
35#[derive(Clone, PartialEq, Deserialize, Serialize)]
36pub enum CourseModuleCompletionGranter {
37 Automatic,
38 User(Uuid),
39}
40
41impl CourseModuleCompletionGranter {
42 fn to_database_field(&self) -> Option<Uuid> {
43 match self {
44 CourseModuleCompletionGranter::Automatic => None,
45 CourseModuleCompletionGranter::User(user_id) => Some(*user_id),
46 }
47 }
48}
49
50#[derive(Clone, PartialEq, Deserialize, Serialize)]
51
52pub struct NewCourseModuleCompletion {
53 pub course_id: Uuid,
54 pub course_module_id: Uuid,
55 pub user_id: Uuid,
56 pub completion_date: DateTime<Utc>,
57 pub completion_registration_attempt_date: Option<DateTime<Utc>>,
58 pub completion_language: String,
59 pub eligible_for_ects: bool,
60 pub email: String,
61 pub grade: Option<i32>,
62 pub passed: bool,
63}
64
65pub async fn insert(
66 conn: &mut PgConnection,
67 pkey_policy: PKeyPolicy<Uuid>,
68 new_course_module_completion: &NewCourseModuleCompletion,
69 completion_granter: CourseModuleCompletionGranter,
70) -> ModelResult<CourseModuleCompletion> {
71 let res = sqlx::query_as!(
72 CourseModuleCompletion,
73 "
74INSERT INTO course_module_completions (
75 id,
76 course_id,
77 course_module_id,
78 user_id,
79 completion_date,
80 completion_registration_attempt_date,
81 completion_language,
82 eligible_for_ects,
83 email,
84 grade,
85 passed,
86 completion_granter_user_id
87 )
88VALUES (
89 $1,
90 $2,
91 $3,
92 $4,
93 $5,
94 $6,
95 $7,
96 $8,
97 $9,
98 $10,
99 $11,
100 $12
101 )
102RETURNING *
103 ",
104 pkey_policy.into_uuid(),
105 new_course_module_completion.course_id,
106 new_course_module_completion.course_module_id,
107 new_course_module_completion.user_id,
108 new_course_module_completion.completion_date,
109 new_course_module_completion.completion_registration_attempt_date,
110 new_course_module_completion.completion_language,
111 new_course_module_completion.eligible_for_ects,
112 new_course_module_completion.email,
113 new_course_module_completion.grade,
114 new_course_module_completion.passed,
115 completion_granter.to_database_field(),
116 )
117 .fetch_one(conn)
118 .await?;
119 Ok(res)
120}
121
122#[derive(Debug, Clone)]
123pub struct NewCourseModuleCompletionSeed {
124 pub course_id: Uuid,
125 pub course_module_id: Uuid,
126 pub user_id: Uuid,
127 pub completion_date: Option<DateTime<Utc>>,
128 pub completion_language: Option<String>,
129 pub eligible_for_ects: Option<bool>,
130 pub email: Option<String>,
131 pub grade: Option<i32>,
132 pub passed: Option<bool>,
133 pub prerequisite_modules_completed: Option<bool>,
134 pub needs_to_be_reviewed: Option<bool>,
135 pub register_credits_via_suotar: bool,
137}
138
139pub async fn insert_seed_row(
140 conn: &mut PgConnection,
141 seed: &NewCourseModuleCompletionSeed,
142) -> ModelResult<Uuid> {
143 let res = sqlx::query!(
144 r#"
145 INSERT INTO course_module_completions (
146 course_id,
147 course_module_id,
148 user_id,
149 completion_date,
150 completion_language,
151 eligible_for_ects,
152 email,
153 grade,
154 passed,
155 prerequisite_modules_completed,
156 needs_to_be_reviewed,
157 register_credits_via_suotar
158 )
159 VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
160 RETURNING id
161 "#,
162 seed.course_id,
163 seed.course_module_id,
164 seed.user_id,
165 seed.completion_date,
166 seed.completion_language.as_deref(),
167 seed.eligible_for_ects,
168 seed.email.as_deref(),
169 seed.grade,
170 seed.passed,
171 seed.prerequisite_modules_completed,
172 seed.needs_to_be_reviewed,
173 seed.register_credits_via_suotar,
174 )
175 .fetch_one(conn)
176 .await?;
177
178 Ok(res.id)
179}
180
181pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<CourseModuleCompletion> {
182 let res = sqlx::query_as!(
183 CourseModuleCompletion,
184 r#"
185SELECT *
186FROM course_module_completions
187WHERE id = $1
188 AND deleted_at IS NULL
189 "#,
190 id,
191 )
192 .fetch_one(conn)
193 .await?;
194 Ok(res)
195}
196
197pub async fn get_by_ids(
199 conn: &mut PgConnection,
200 ids: &[Uuid],
201) -> ModelResult<Vec<CourseModuleCompletion>> {
202 let res = sqlx::query_as!(
203 CourseModuleCompletion,
204 "
205SELECT *
206FROM course_module_completions
207WHERE id = ANY($1)
208 ",
209 ids,
210 )
211 .fetch_all(conn)
212 .await?;
213 Ok(res)
214}
215
216pub async fn get_by_ids_as_map(
217 conn: &mut PgConnection,
218 ids: &[Uuid],
219) -> ModelResult<HashMap<Uuid, CourseModuleCompletion>> {
220 let res = get_by_ids(conn, ids)
221 .await?
222 .into_iter()
223 .map(|x| (x.id, x))
224 .collect();
225 Ok(res)
226}
227
228#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
229
230pub struct CourseModuleCompletionWithRegistrationInfo {
231 pub completion_registration_attempt_date: Option<DateTime<Utc>>,
233 pub course_module_id: Uuid,
235 pub created_at: DateTime<Utc>,
237 pub grade: Option<i32>,
239 pub passed: bool,
241 pub prerequisite_modules_completed: bool,
243 pub registered: bool,
245 pub needs_to_be_reviewed: bool,
247 pub user_id: Uuid,
249 pub completion_date: DateTime<Utc>,
251}
252
253pub async fn get_all_with_registration_information_by_course_instance_id(
255 conn: &mut PgConnection,
256 course_instance_id: Uuid,
257 course_id: Uuid,
258) -> ModelResult<Vec<CourseModuleCompletionWithRegistrationInfo>> {
259 let res = sqlx::query_as!(
260 CourseModuleCompletionWithRegistrationInfo,
261 r#"
262SELECT completions.completion_registration_attempt_date,
263 completions.course_module_id AS "course_module_id!",
264 completions.created_at AS "created_at!",
265 completions.grade,
266 completions.passed AS "passed!",
267 completions.prerequisite_modules_completed AS "prerequisite_modules_completed!",
268 (registered.id IS NOT NULL) AS "registered!",
269 completions.needs_to_be_reviewed AS "needs_to_be_reviewed!",
270 completions.user_id AS "user_id!",
271 completions.completion_date AS "completion_date!"
272FROM course_module_completions completions
273 LEFT JOIN course_module_completion_registered_to_study_registries registered ON (
274 completions.id = registered.course_module_completion_id
275 )
276 JOIN user_course_settings settings ON (
277 completions.user_id = settings.user_id
278 AND settings.current_course_id = completions.course_id
279 )
280WHERE settings.current_course_instance_id = $1
281 AND completions.deleted_at IS NULL
282 AND registered.deleted_at IS NULL
283 AND settings.deleted_at IS NULL
284 AND settings.current_course_id = $2
285 "#,
286 course_instance_id,
287 course_id
288 )
289 .fetch_all(conn)
290 .await?;
291 Ok(res)
292}
293
294pub async fn get_all_by_course_id_and_user_id(
297 conn: &mut PgConnection,
298 course_id: Uuid,
299 user_id: Uuid,
300) -> ModelResult<Vec<CourseModuleCompletion>> {
301 let res = sqlx::query_as!(
302 CourseModuleCompletion,
303 "
304SELECT *
305FROM course_module_completions
306WHERE course_id = $1
307 AND user_id = $2
308 AND deleted_at IS NULL
309 ",
310 course_id,
311 user_id,
312 )
313 .fetch_all(conn)
314 .await?;
315 Ok(res)
316}
317
318pub async fn get_all_by_user_id(
319 conn: &mut PgConnection,
320 user_id: Uuid,
321) -> ModelResult<Vec<CourseModuleCompletion>> {
322 let res = sqlx::query_as!(
323 CourseModuleCompletion,
324 "
325SELECT *
326FROM course_module_completions
327WHERE user_id = $1
328 AND deleted_at IS NULL
329 ",
330 user_id,
331 )
332 .fetch_all(conn)
333 .await?;
334 Ok(res)
335}
336
337pub async fn get_all_by_user_id_and_course_module_id(
338 conn: &mut PgConnection,
339 user_id: Uuid,
340 course_module_id: Uuid,
341) -> ModelResult<Vec<CourseModuleCompletion>> {
342 let res = sqlx::query_as!(
343 CourseModuleCompletion,
344 "
345SELECT *
346FROM course_module_completions
347WHERE user_id = $1
348 AND course_module_id = $2
349 AND deleted_at IS NULL
350 ",
351 user_id,
352 course_module_id,
353 )
354 .fetch_all(conn)
355 .await?;
356 Ok(res)
357}
358
359pub async fn get_all_by_course_module_and_user_ids(
360 conn: &mut PgConnection,
361 course_module_id: Uuid,
362 user_id: Uuid,
363) -> ModelResult<Vec<CourseModuleCompletion>> {
364 let res = sqlx::query_as!(
365 CourseModuleCompletion,
366 "
367SELECT *
368FROM course_module_completions
369WHERE course_module_id = $1
370 AND user_id = $2
371 AND deleted_at IS NULL
372 ",
373 course_module_id,
374 user_id,
375 )
376 .fetch_all(conn)
377 .await?;
378 Ok(res)
379}
380
381pub async fn get_latest_by_course_and_user_ids(
383 conn: &mut PgConnection,
384 course_module_id: Uuid,
385 user_id: Uuid,
386) -> ModelResult<CourseModuleCompletion> {
387 let res = sqlx::query_as!(
388 CourseModuleCompletion,
389 "
390SELECT *
391FROM course_module_completions
392WHERE course_module_id = $1
393 AND user_id = $2
394 AND deleted_at IS NULL
395ORDER BY created_at DESC
396LIMIT 1
397 ",
398 course_module_id,
399 user_id,
400 )
401 .fetch_one(conn)
402 .await?;
403 Ok(res)
404}
405
406pub async fn get_best_completion_by_user_and_course_module_id(
407 conn: &mut PgConnection,
408 user_id: Uuid,
409 course_module_id: Uuid,
410) -> ModelResult<Option<CourseModuleCompletion>> {
411 let completions = sqlx::query_as!(
412 CourseModuleCompletion,
413 r#"
414SELECT *
415FROM course_module_completions
416WHERE user_id = $1
417 AND course_module_id = $2
418 AND deleted_at IS NULL
419 "#,
420 user_id,
421 course_module_id,
422 )
423 .fetch_all(conn)
424 .await?;
425
426 Ok(select_best_completion(completions))
427}
428
429pub fn select_best_completion<C: Borrow<CourseModuleCompletion>>(
431 completions: impl IntoIterator<Item = C>,
432) -> Option<C> {
433 completions.into_iter().max_by_key(|completion| {
438 let completion = completion.borrow();
439 (
440 completion.passed,
441 completion.grade.unwrap_or(0),
442 completion.created_at,
443 completion.id,
444 )
445 })
446}
447
448pub async fn get_credit_registration_expected_ids(
451 conn: &mut PgConnection,
452 ids: &[Uuid],
453) -> ModelResult<HashSet<Uuid>> {
454 let res = sqlx::query_scalar!(
455 r#"
456SELECT course_module_completion_id AS "course_module_completion_id!"
457FROM credit_registration_eligible_completions
458WHERE course_module_completion_id = ANY($1)
459 "#,
460 ids,
461 )
462 .fetch_all(conn)
463 .await?;
464 Ok(res.into_iter().collect())
465}
466
467pub fn select_registration_completion<C: Borrow<CourseModuleCompletion>>(
474 completions: impl IntoIterator<Item = C>,
475) -> Option<C> {
476 completions.into_iter().max_by_key(|completion| {
477 let completion = completion.borrow();
478 (
479 completion.register_credits_via_suotar,
480 completion.created_at,
481 completion.id,
482 )
483 })
484}
485
486pub async fn get_registration_completion_by_user_and_course_module_id(
489 conn: &mut PgConnection,
490 user_id: Uuid,
491 course_module_id: Uuid,
492) -> ModelResult<CourseModuleCompletion> {
493 let completions =
494 get_all_by_course_module_and_user_ids(conn, course_module_id, user_id).await?;
495 select_registration_completion(completions).ok_or_else(missing_model_error(
496 ModelErrorType::RecordNotFound,
497 "The user has no completion for this course module.".to_string(),
498 ))
499}
500
501pub async fn get_count_of_distinct_completors_by_course_id(
503 conn: &mut PgConnection,
504 course_id: Uuid,
505) -> ModelResult<i64> {
506 let res = sqlx::query!(
507 "
508SELECT COUNT(DISTINCT user_id) as count
509FROM course_module_completions
510WHERE course_id = $1
511 AND deleted_at IS NULL
512",
513 course_id,
514 )
515 .fetch_one(conn)
516 .await?;
517 Ok(res.count.unwrap_or(0))
518}
519
520pub async fn get_completed_module_ids_by_course_id(
522 conn: &mut PgConnection,
523 course_id: Uuid,
524) -> ModelResult<Vec<Uuid>> {
525 let res = sqlx::query_scalar!(
526 "
527SELECT m.id
528FROM course_modules m
529WHERE m.course_id = $1
530 AND EXISTS (
531 SELECT 1
532 FROM course_module_completions c
533 WHERE c.course_module_id = m.id
534 AND c.course_id = $1
535 AND c.deleted_at IS NULL
536 )
537",
538 course_id,
539 )
540 .fetch_all(conn)
541 .await?;
542 Ok(res)
543}
544
545pub async fn get_automatic_completion_by_course_module_course_and_user_ids(
549 conn: &mut PgConnection,
550 course_module_id: Uuid,
551 course_id: Uuid,
552 user_id: Uuid,
553) -> ModelResult<CourseModuleCompletion> {
554 let res = sqlx::query_as!(
555 CourseModuleCompletion,
556 "
557SELECT *
558FROM course_module_completions
559WHERE course_module_id = $1
560 AND course_id = $2
561 AND user_id = $3
562 AND completion_granter_user_id IS NULL
563 AND deleted_at IS NULL
564 ",
565 course_module_id,
566 course_id,
567 user_id,
568 )
569 .fetch_one(conn)
570 .await?;
571 Ok(res)
572}
573
574pub async fn user_has_manual_completion_in_course(
578 conn: &mut PgConnection,
579 user_id: Uuid,
580 course_id: Uuid,
581) -> ModelResult<bool> {
582 let res = sqlx::query!(
583 r#"
584SELECT EXISTS (
585 SELECT 1
586 FROM course_module_completions
587 WHERE user_id = $1
588 AND course_id = $2
589 AND completion_granter_user_id IS NOT NULL
590 AND deleted_at IS NULL
591) AS "exists!"
592 "#,
593 user_id,
594 course_id,
595 )
596 .fetch_one(conn)
597 .await?;
598 Ok(res.exists)
599}
600
601pub async fn update_completion_registration_attempt_date(
602 conn: &mut PgConnection,
603 id: Uuid,
604 completion_registration_attempt_date: DateTime<Utc>,
605) -> ModelResult<bool> {
606 let res = sqlx::query!(
607 "
608UPDATE course_module_completions
609SET completion_registration_attempt_date = $1
610WHERE id = $2
611 AND deleted_at IS NULL
612 ",
613 Some(completion_registration_attempt_date),
614 id,
615 )
616 .execute(conn)
617 .await?;
618 Ok(res.rows_affected() > 0)
619}
620
621pub async fn set_grade_for_testing(
627 conn: &mut PgConnection,
628 id: Uuid,
629 grade: Option<i32>,
630 passed: Option<bool>,
631) -> ModelResult<()> {
632 sqlx::query!(
633 "
634UPDATE course_module_completions
635SET grade = $2,
636 passed = COALESCE($3, passed)
637WHERE id = $1
638 AND deleted_at IS NULL
639 ",
640 id,
641 grade,
642 passed,
643 )
644 .execute(conn)
645 .await?;
646 Ok(())
647}
648
649pub async fn update_prerequisite_modules_completed(
650 conn: &mut PgConnection,
651 id: Uuid,
652 prerequisite_modules_completed: bool,
653) -> ModelResult<bool> {
654 let res = sqlx::query!(
655 "
656UPDATE course_module_completions SET prerequisite_modules_completed = $1
657WHERE id = $2 AND deleted_at IS NULL
658 ",
659 prerequisite_modules_completed,
660 id
661 )
662 .execute(conn)
663 .await?;
664 Ok(res.rows_affected() > 0)
665}
666
667pub async fn update_needs_to_be_reviewed(
668 conn: &mut PgConnection,
669 id: Uuid,
670 needs_to_be_reviewed: bool,
671) -> ModelResult<bool> {
672 let res = sqlx::query!(
673 "
674UPDATE course_module_completions SET needs_to_be_reviewed = $1
675WHERE id = $2 AND deleted_at IS NULL
676 ",
677 needs_to_be_reviewed,
678 id
679 )
680 .execute(conn)
681 .await?;
682 Ok(res.rows_affected() > 0)
683}
684
685pub async fn update_needs_to_be_reviewed_by_course_and_user_ids(
686 conn: &mut PgConnection,
687 course_id: Uuid,
688 user_id: Uuid,
689 needs_to_be_reviewed: bool,
690) -> ModelResult<bool> {
691 let res = sqlx::query!(
692 "
693UPDATE course_module_completions SET needs_to_be_reviewed = $1
694WHERE course_id = $2 AND user_id = $3 AND deleted_at IS NULL
695 ",
696 needs_to_be_reviewed,
697 course_id,
698 user_id,
699 )
700 .execute(conn)
701 .await?;
702 Ok(res.rows_affected() > 0)
703}
704
705pub async fn user_has_completed_course_module(
708 conn: &mut PgConnection,
709 user_id: Uuid,
710 course_module_id: Uuid,
711) -> ModelResult<bool> {
712 let res = get_all_by_course_module_and_user_ids(conn, course_module_id, user_id).await?;
713 Ok(!res.is_empty())
714}
715
716#[derive(Clone, PartialEq, Deserialize, Serialize)]
718
719pub struct StudyRegistryCompletion {
720 pub completion_date: DateTime<Utc>,
726 pub completion_language: String,
728 pub completion_registration_attempt_date: Option<DateTime<Utc>>,
730 pub email: String,
734 pub grade: StudyRegistryGrade,
736 pub id: Uuid,
738 pub user_id: Uuid,
740 pub tier: Option<i32>,
743}
744
745impl From<CourseModuleCompletion> for StudyRegistryCompletion {
746 fn from(completion: CourseModuleCompletion) -> Self {
747 Self {
748 completion_date: completion.completion_date,
749 completion_language: completion.completion_language,
750 completion_registration_attempt_date: completion.completion_registration_attempt_date,
751 email: completion.email,
752 grade: StudyRegistryGrade::new(completion.passed, completion.grade),
753 id: completion.id,
754 user_id: completion.user_id,
755 tier: None,
756 }
757 }
758}
759
760impl StudyRegistryCompletion {
761 pub fn normalize_language_code(&mut self) {
762 match self.completion_language.as_str() {
763 "en" => self.completion_language = "en-GB".to_string(),
764 "fi" => self.completion_language = "fi-FI".to_string(),
765 "sv" => self.completion_language = "sv-SE".to_string(),
766 _ => {}
767 }
768 }
769}
770
771#[derive(Clone, PartialEq, Deserialize, Serialize)]
810
811pub struct StudyRegistryGrade {
812 pub scale: String,
813 pub grade: String,
814}
815
816impl StudyRegistryGrade {
817 pub fn new(passed: bool, grade: Option<i32>) -> Self {
818 match grade {
819 Some(grade) => Self {
820 scale: "sis-0-5".to_string(),
821 grade: grade.to_string(),
822 },
823 None => Self {
824 scale: "sis-hyv-hyl".to_string(),
825 grade: if passed {
826 "1".to_string()
827 } else {
828 "0".to_string()
829 },
830 },
831 }
832 }
833}
834pub fn stream_by_course_module_id<'a>(
838 conn: &'a mut PgConnection,
839 course_module_ids: &'a [Uuid],
840 no_completions_registered_by_this_study_registry_registrar: &'a Option<StudyRegistryRegistrar>,
841) -> impl Stream<Item = sqlx::Result<StudyRegistryCompletion>> + Send + 'a {
842 let study_module_registrar_id = no_completions_registered_by_this_study_registry_registrar
844 .clone()
845 .map(|o| o.id)
846 .unwrap_or(Uuid::nil());
847
848 sqlx::query_as!(
849 CourseModuleCompletion,
850 r#"
851SELECT *
852FROM course_module_completions
853WHERE course_module_id IN (
854 SELECT id
855 FROM course_modules
856 WHERE id = ANY($1)
857 AND completions_can_be_registered_to_sisu
858 )
859 AND prerequisite_modules_completed
860 AND eligible_for_ects IS TRUE
861 -- Completions still awaiting suspected-cheater review are withheld from study-registry
862 -- registration until a teacher dismisses or confirms them.
863 AND needs_to_be_reviewed = FALSE
864 AND deleted_at IS NULL
865 -- Completions on the push path are registered by us; letting the registry pull them too would put
866 -- a second attainment on the student's transcript. Per student and module: a completion from
867 -- before the push path stays the pull path's until the student gets a flagged one, which takes
868 -- its siblings along, since any of them registers the same credit.
869 AND NOT EXISTS (
870 SELECT 1
871 FROM course_module_completions sibling
872 WHERE sibling.user_id = course_module_completions.user_id
873 AND sibling.course_module_id = course_module_completions.course_module_id
874 AND sibling.register_credits_via_suotar
875 AND sibling.deleted_at IS NULL
876 )
877 -- Belt and braces behind the flag above: once the push path has sent anything for the student and
878 -- module, it stays out for good, since re-registering would double the attainment on a real
879 -- transcript.
880 AND NOT EXISTS (
881 SELECT 1
882 FROM credit_registrations cr
883 WHERE cr.user_id = course_module_completions.user_id
884 AND cr.course_module_id = course_module_completions.course_module_id
885 AND cr.submitted_at IS NOT NULL
886 AND cr.deleted_at IS NULL
887 )
888 AND id NOT IN (
889 SELECT course_module_completion_id
890 FROM course_module_completion_registered_to_study_registries
891 WHERE course_module_id = ANY($1)
892 AND (
893 study_registry_registrar_id = $2
894 -- Our own rows count as already registered too: what the push path put in the registry is
895 -- in it whichever registrar's export this is.
896 OR study_registry_registrar_id IS NULL
897 )
898 AND deleted_at IS NULL
899 )
900 "#,
901 course_module_ids,
902 study_module_registrar_id,
903 )
904 .map(StudyRegistryCompletion::from)
905 .fetch(conn)
906}
907
908pub async fn delete(conn: &mut PgConnection, id: Uuid) -> ModelResult<()> {
909 sqlx::query!(
910 "
911
912UPDATE course_module_completions
913SET deleted_at = now()
914WHERE id = $1
915AND deleted_at IS NULL
916 ",
917 id,
918 )
919 .execute(conn)
920 .await?;
921 Ok(())
922}
923
924pub async fn find_existing(
925 conn: &mut PgConnection,
926 course_id: Uuid,
927 course_module_id: Uuid,
928 user_id: Uuid,
929) -> ModelResult<Uuid> {
930 let row = sqlx::query!(
931 r#"
932 SELECT id
933 FROM course_module_completions
934 WHERE course_id = $1
935 AND course_module_id = $2
936 AND user_id = $3
937 AND completion_granter_user_id IS NULL
938 AND deleted_at IS NULL
939 "#,
940 course_id,
941 course_module_id,
942 user_id,
943 )
944 .fetch_one(conn)
945 .await?;
946
947 Ok(row.id)
948}