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
188pub 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 pub completion_registration_attempt_date: Option<DateTime<Utc>>,
224 pub course_module_id: Uuid,
226 pub created_at: DateTime<Utc>,
228 pub grade: Option<i32>,
230 pub passed: bool,
232 pub prerequisite_modules_completed: bool,
234 pub registered: bool,
236 pub needs_to_be_reviewed: bool,
238 pub user_id: Uuid,
240 pub completion_date: DateTime<Utc>,
242}
243
244pub 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
285pub 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
372pub 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
420pub fn select_best_completion(
422 completions: Vec<CourseModuleCompletion>,
423) -> Option<CourseModuleCompletion> {
424 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
438pub 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
457pub 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
486pub 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
533pub 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
617pub 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#[derive(Clone, PartialEq, Deserialize, Serialize)]
630
631pub struct StudyRegistryCompletion {
632 pub completion_date: DateTime<Utc>,
638 pub completion_language: String,
640 pub completion_registration_attempt_date: Option<DateTime<Utc>>,
642 pub email: String,
646 pub grade: StudyRegistryGrade,
648 pub id: Uuid,
650 pub user_id: Uuid,
652 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#[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}
746pub 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 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}