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