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#[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
212pub 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 pub completion_registration_attempt_date: Option<DateTime<Utc>>,
248 pub course_module_id: Uuid,
250 pub created_at: DateTime<Utc>,
252 pub grade: Option<i32>,
254 pub passed: bool,
256 pub prerequisite_modules_completed: bool,
258 pub registered: bool,
260 pub needs_to_be_reviewed: bool,
262 pub user_id: Uuid,
264 pub completion_date: DateTime<Utc>,
266}
267
268pub 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
309pub 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
396pub 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
468pub 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
499pub 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
518pub 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
547pub 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
650pub 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#[derive(Clone, PartialEq, Deserialize, Serialize)]
663
664pub struct StudyRegistryCompletion {
665 pub completion_date: DateTime<Utc>,
671 pub completion_language: String,
673 pub completion_registration_attempt_date: Option<DateTime<Utc>>,
675 pub email: String,
679 pub grade: StudyRegistryGrade,
681 pub id: Uuid,
683 pub user_id: Uuid,
685 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#[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}
779pub 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 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}