1use std::collections::HashMap;
2
3use futures::Stream;
4
5use crate::{prelude::*, study_registry_registrars::StudyRegistryRegistrar};
6
7#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
8#[cfg_attr(feature = "ts_rs", derive(TS))]
9pub struct CourseModuleCompletion {
10 pub id: Uuid,
11 pub created_at: DateTime<Utc>,
12 pub updated_at: DateTime<Utc>,
13 pub deleted_at: Option<DateTime<Utc>>,
14 pub course_id: Uuid,
15 pub course_module_id: Uuid,
16 pub user_id: Uuid,
17 pub completion_date: DateTime<Utc>,
18 pub completion_registration_attempt_date: Option<DateTime<Utc>>,
19 pub completion_language: String,
20 pub eligible_for_ects: bool,
21 pub email: String,
22 pub grade: Option<i32>,
23 pub passed: bool,
24 pub prerequisite_modules_completed: bool,
25 pub completion_granter_user_id: Option<Uuid>,
26 pub needs_to_be_reviewed: bool,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
30#[cfg_attr(feature = "ts_rs", derive(TS))]
31pub struct CourseModuleAverage {
32 pub id: Uuid,
33 pub course_id: Uuid,
34 pub created_at: DateTime<Utc>,
35 pub updated_at: DateTime<Utc>,
36 pub deleted_at: Option<DateTime<Utc>>,
37 pub average_duration: Option<u64>,
38 pub average_points: i32,
39 pub total_points: i32,
40 pub total_student: i32,
41}
42
43#[derive(Debug, Serialize, Deserialize)]
45#[cfg_attr(feature = "ts_rs", derive(TS))]
46pub struct CourseModulePointsAverage {
47 pub course_id: Uuid,
48 pub average_points: Option<f32>,
49 pub total_points: Option<i32>,
50 pub total_student: Option<i32>,
51}
52
53#[derive(Clone, PartialEq, Deserialize, Serialize)]
54pub enum CourseModuleCompletionGranter {
55 Automatic,
56 User(Uuid),
57}
58
59impl CourseModuleCompletionGranter {
60 fn to_database_field(&self) -> Option<Uuid> {
61 match self {
62 CourseModuleCompletionGranter::Automatic => None,
63 CourseModuleCompletionGranter::User(user_id) => Some(*user_id),
64 }
65 }
66}
67
68#[derive(Clone, PartialEq, Deserialize, Serialize)]
69#[cfg_attr(feature = "ts_rs", derive(TS))]
70pub struct NewCourseModuleCompletion {
71 pub course_id: Uuid,
72 pub course_module_id: Uuid,
73 pub user_id: Uuid,
74 pub completion_date: DateTime<Utc>,
75 pub completion_registration_attempt_date: Option<DateTime<Utc>>,
76 pub completion_language: String,
77 pub eligible_for_ects: bool,
78 pub email: String,
79 pub grade: Option<i32>,
80 pub passed: bool,
81}
82
83pub async fn insert(
84 conn: &mut PgConnection,
85 pkey_policy: PKeyPolicy<Uuid>,
86 new_course_module_completion: &NewCourseModuleCompletion,
87 completion_granter: CourseModuleCompletionGranter,
88) -> ModelResult<CourseModuleCompletion> {
89 let res = sqlx::query_as!(
90 CourseModuleCompletion,
91 "
92INSERT INTO course_module_completions (
93 id,
94 course_id,
95 course_module_id,
96 user_id,
97 completion_date,
98 completion_registration_attempt_date,
99 completion_language,
100 eligible_for_ects,
101 email,
102 grade,
103 passed,
104 completion_granter_user_id
105 )
106VALUES (
107 $1,
108 $2,
109 $3,
110 $4,
111 $5,
112 $6,
113 $7,
114 $8,
115 $9,
116 $10,
117 $11,
118 $12
119 )
120RETURNING *
121 ",
122 pkey_policy.into_uuid(),
123 new_course_module_completion.course_id,
124 new_course_module_completion.course_module_id,
125 new_course_module_completion.user_id,
126 new_course_module_completion.completion_date,
127 new_course_module_completion.completion_registration_attempt_date,
128 new_course_module_completion.completion_language,
129 new_course_module_completion.eligible_for_ects,
130 new_course_module_completion.email,
131 new_course_module_completion.grade,
132 new_course_module_completion.passed,
133 completion_granter.to_database_field(),
134 )
135 .fetch_one(conn)
136 .await?;
137 Ok(res)
138}
139
140#[derive(Debug, Clone)]
141pub struct NewCourseModuleCompletionSeed {
142 pub course_id: Uuid,
143 pub course_module_id: Uuid,
144 pub user_id: Uuid,
145 pub completion_date: Option<DateTime<Utc>>,
146 pub completion_language: Option<String>,
147 pub eligible_for_ects: Option<bool>,
148 pub email: Option<String>,
149 pub grade: Option<i32>,
150 pub passed: Option<bool>,
151 pub prerequisite_modules_completed: Option<bool>,
152 pub needs_to_be_reviewed: Option<bool>,
153}
154
155pub async fn insert_seed_row(
156 conn: &mut PgConnection,
157 seed: &NewCourseModuleCompletionSeed,
158) -> ModelResult<Uuid> {
159 let res = sqlx::query!(
160 r#"
161 INSERT INTO course_module_completions (
162 course_id,
163 course_module_id,
164 user_id,
165 completion_date,
166 completion_language,
167 eligible_for_ects,
168 email,
169 grade,
170 passed,
171 prerequisite_modules_completed,
172 needs_to_be_reviewed
173 )
174 VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
175 RETURNING id
176 "#,
177 seed.course_id,
178 seed.course_module_id,
179 seed.user_id,
180 seed.completion_date,
181 seed.completion_language.as_deref(),
182 seed.eligible_for_ects,
183 seed.email.as_deref(),
184 seed.grade,
185 seed.passed,
186 seed.prerequisite_modules_completed,
187 seed.needs_to_be_reviewed,
188 )
189 .fetch_one(conn)
190 .await?;
191
192 Ok(res.id)
193}
194
195pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<CourseModuleCompletion> {
196 let res = sqlx::query_as!(
197 CourseModuleCompletion,
198 r#"
199SELECT *
200FROM course_module_completions
201WHERE id = $1
202 AND deleted_at IS NULL
203 "#,
204 id,
205 )
206 .fetch_one(conn)
207 .await?;
208 Ok(res)
209}
210
211pub async fn get_by_ids(
213 conn: &mut PgConnection,
214 ids: &[Uuid],
215) -> ModelResult<Vec<CourseModuleCompletion>> {
216 let res = sqlx::query_as!(
217 CourseModuleCompletion,
218 "
219SELECT *
220FROM course_module_completions
221WHERE id = ANY($1)
222 ",
223 ids,
224 )
225 .fetch_all(conn)
226 .await?;
227 Ok(res)
228}
229
230pub async fn get_by_ids_as_map(
231 conn: &mut PgConnection,
232 ids: &[Uuid],
233) -> ModelResult<HashMap<Uuid, CourseModuleCompletion>> {
234 let res = get_by_ids(conn, ids)
235 .await?
236 .into_iter()
237 .map(|x| (x.id, x))
238 .collect();
239 Ok(res)
240}
241
242#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
243#[cfg_attr(feature = "ts_rs", derive(TS))]
244pub struct CourseModuleCompletionWithRegistrationInfo {
245 pub completion_registration_attempt_date: Option<DateTime<Utc>>,
247 pub course_module_id: Uuid,
249 pub created_at: DateTime<Utc>,
251 pub grade: Option<i32>,
253 pub passed: bool,
255 pub prerequisite_modules_completed: bool,
257 pub registered: bool,
259 pub user_id: Uuid,
261 pub completion_date: DateTime<Utc>,
263}
264
265pub async fn get_all_with_registration_information_by_course_instance_id(
267 conn: &mut PgConnection,
268 course_instance_id: Uuid,
269 course_id: Uuid,
270) -> ModelResult<Vec<CourseModuleCompletionWithRegistrationInfo>> {
271 let res = sqlx::query_as!(
272 CourseModuleCompletionWithRegistrationInfo,
273 r#"
274SELECT completions.completion_registration_attempt_date,
275 completions.course_module_id,
276 completions.created_at,
277 completions.grade,
278 completions.passed,
279 completions.prerequisite_modules_completed,
280 (registered.id IS NOT NULL) AS "registered!",
281 completions.user_id,
282 completions.completion_date
283FROM course_module_completions completions
284 LEFT JOIN course_module_completion_registered_to_study_registries registered ON (
285 completions.id = registered.course_module_completion_id
286 )
287 JOIN user_course_settings settings ON (
288 completions.user_id = settings.user_id
289 AND settings.current_course_id = completions.course_id
290 )
291WHERE settings.current_course_instance_id = $1
292 AND completions.deleted_at IS NULL
293 AND registered.deleted_at IS NULL
294 AND settings.deleted_at IS NULL
295 AND settings.current_course_id = $2
296 "#,
297 course_instance_id,
298 course_id
299 )
300 .fetch_all(conn)
301 .await?;
302 Ok(res)
303}
304
305pub async fn get_all_by_course_id_and_user_id(
308 conn: &mut PgConnection,
309 course_id: Uuid,
310 user_id: Uuid,
311) -> ModelResult<Vec<CourseModuleCompletion>> {
312 let res = sqlx::query_as!(
313 CourseModuleCompletion,
314 "
315SELECT *
316FROM course_module_completions
317WHERE course_id = $1
318 AND user_id = $2
319 AND deleted_at IS NULL
320 ",
321 course_id,
322 user_id,
323 )
324 .fetch_all(conn)
325 .await?;
326 Ok(res)
327}
328
329pub async fn get_all_by_user_id(
330 conn: &mut PgConnection,
331 user_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 deleted_at IS NULL
340 ",
341 user_id,
342 )
343 .fetch_all(conn)
344 .await?;
345 Ok(res)
346}
347
348pub async fn get_all_by_user_id_and_course_module_id(
349 conn: &mut PgConnection,
350 user_id: Uuid,
351 course_module_id: Uuid,
352) -> ModelResult<Vec<CourseModuleCompletion>> {
353 let res = sqlx::query_as!(
354 CourseModuleCompletion,
355 "
356SELECT *
357FROM course_module_completions
358WHERE user_id = $1
359 AND course_module_id = $2
360 AND deleted_at IS NULL
361 ",
362 user_id,
363 course_module_id,
364 )
365 .fetch_all(conn)
366 .await?;
367 Ok(res)
368}
369
370pub async fn get_all_by_course_module_and_user_ids(
371 conn: &mut PgConnection,
372 course_module_id: Uuid,
373 user_id: Uuid,
374) -> ModelResult<Vec<CourseModuleCompletion>> {
375 let res = sqlx::query_as!(
376 CourseModuleCompletion,
377 "
378SELECT *
379FROM course_module_completions
380WHERE course_module_id = $1
381 AND user_id = $2
382 AND deleted_at IS NULL
383 ",
384 course_module_id,
385 user_id,
386 )
387 .fetch_all(conn)
388 .await?;
389 Ok(res)
390}
391
392pub async fn get_latest_by_course_and_user_ids(
394 conn: &mut PgConnection,
395 course_module_id: Uuid,
396 user_id: Uuid,
397) -> ModelResult<CourseModuleCompletion> {
398 let res = sqlx::query_as!(
399 CourseModuleCompletion,
400 "
401SELECT *
402FROM course_module_completions
403WHERE course_module_id = $1
404 AND user_id = $2
405 AND deleted_at IS NULL
406ORDER BY created_at DESC
407LIMIT 1
408 ",
409 course_module_id,
410 user_id,
411 )
412 .fetch_one(conn)
413 .await?;
414 Ok(res)
415}
416
417pub async fn get_best_completion_by_user_and_course_module_id(
418 conn: &mut PgConnection,
419 user_id: Uuid,
420 course_module_id: Uuid,
421) -> ModelResult<Option<CourseModuleCompletion>> {
422 let completions = sqlx::query_as!(
423 CourseModuleCompletion,
424 r#"
425SELECT *
426FROM course_module_completions
427WHERE user_id = $1
428 AND course_module_id = $2
429 AND deleted_at IS NULL
430 "#,
431 user_id,
432 course_module_id,
433 )
434 .fetch_all(conn)
435 .await?;
436
437 let best_grade = completions
438 .into_iter()
439 .max_by(|completion_a, completion_b| {
440 let score_a = match completion_a.grade {
441 Some(grade) => grade as f32,
442 None => match completion_a.passed {
443 true => 0.5,
444 false => -1.0,
445 },
446 };
447
448 let score_b = match completion_b.grade {
449 Some(grade) => grade as f32,
450 None => match completion_b.passed {
451 true => 0.5,
452 false => -1.0,
453 },
454 };
455
456 score_a
457 .partial_cmp(&score_b)
458 .unwrap_or(std::cmp::Ordering::Equal)
459 });
460
461 Ok(best_grade)
462}
463
464pub fn select_best_completion(
466 completions: Vec<CourseModuleCompletion>,
467) -> Option<CourseModuleCompletion> {
468 completions.into_iter().max_by(|a, b| {
469 let score_a = match a.grade {
470 Some(grade) => grade as f32,
471 None => {
472 if a.passed {
473 0.5
474 } else {
475 -1.0
476 }
477 }
478 };
479 let score_b = match b.grade {
480 Some(grade) => grade as f32,
481 None => {
482 if b.passed {
483 0.5
484 } else {
485 -1.0
486 }
487 }
488 };
489 score_a
490 .partial_cmp(&score_b)
491 .unwrap_or(std::cmp::Ordering::Equal)
492 })
493}
494
495pub async fn get_count_of_distinct_completors_by_course_id(
497 conn: &mut PgConnection,
498 course_id: Uuid,
499) -> ModelResult<i64> {
500 let res = sqlx::query!(
501 "
502SELECT COUNT(DISTINCT user_id) as count
503FROM course_module_completions
504WHERE course_id = $1
505 AND deleted_at IS NULL
506",
507 course_id,
508 )
509 .fetch_one(conn)
510 .await?;
511 Ok(res.count.unwrap_or(0))
512}
513
514pub async fn get_automatic_completion_by_course_module_course_and_user_ids(
518 conn: &mut PgConnection,
519 course_module_id: Uuid,
520 course_id: Uuid,
521 user_id: Uuid,
522) -> ModelResult<CourseModuleCompletion> {
523 let res = sqlx::query_as!(
524 CourseModuleCompletion,
525 "
526SELECT *
527FROM course_module_completions
528WHERE course_module_id = $1
529 AND course_id = $2
530 AND user_id = $3
531 AND completion_granter_user_id IS NULL
532 AND deleted_at IS NULL
533 ",
534 course_module_id,
535 course_id,
536 user_id,
537 )
538 .fetch_one(conn)
539 .await?;
540 Ok(res)
541}
542
543pub async fn update_completion_registration_attempt_date(
544 conn: &mut PgConnection,
545 id: Uuid,
546 completion_registration_attempt_date: DateTime<Utc>,
547) -> ModelResult<bool> {
548 let res = sqlx::query!(
549 "
550UPDATE course_module_completions
551SET completion_registration_attempt_date = $1
552WHERE id = $2
553 AND deleted_at IS NULL
554 ",
555 Some(completion_registration_attempt_date),
556 id,
557 )
558 .execute(conn)
559 .await?;
560 Ok(res.rows_affected() > 0)
561}
562
563pub async fn update_prerequisite_modules_completed(
564 conn: &mut PgConnection,
565 id: Uuid,
566 prerequisite_modules_completed: bool,
567) -> ModelResult<bool> {
568 let res = sqlx::query!(
569 "
570UPDATE course_module_completions SET prerequisite_modules_completed = $1
571WHERE id = $2 AND deleted_at IS NULL
572 ",
573 prerequisite_modules_completed,
574 id
575 )
576 .execute(conn)
577 .await?;
578 Ok(res.rows_affected() > 0)
579}
580
581pub async fn update_passed_and_grade_status(
582 conn: &mut PgConnection,
583 course_id: Uuid,
584 user_id: Uuid,
585 passed: bool,
586 grade: i32,
587) -> ModelResult<bool> {
588 let res = sqlx::query!(
589 "
590UPDATE course_module_completions SET passed = $1, grade = $2
591WHERE user_id = $3 AND course_id = $4 AND deleted_at IS NULL
592 ",
593 passed,
594 grade,
595 user_id,
596 course_id
597 )
598 .execute(conn)
599 .await?;
600 Ok(res.rows_affected() > 0)
601}
602
603pub async fn update_needs_to_be_reviewed(
604 conn: &mut PgConnection,
605 id: Uuid,
606 needs_to_be_reviewed: bool,
607) -> ModelResult<bool> {
608 let res = sqlx::query!(
609 "
610UPDATE course_module_completions SET needs_to_be_reviewed = $1
611WHERE id = $2 AND deleted_at IS NULL
612 ",
613 needs_to_be_reviewed,
614 id
615 )
616 .execute(conn)
617 .await?;
618 Ok(res.rows_affected() > 0)
619}
620
621pub async fn user_has_completed_course_module(
624 conn: &mut PgConnection,
625 user_id: Uuid,
626 course_module_id: Uuid,
627) -> ModelResult<bool> {
628 let res = get_all_by_course_module_and_user_ids(conn, course_module_id, user_id).await?;
629 Ok(!res.is_empty())
630}
631
632#[derive(Clone, PartialEq, Deserialize, Serialize)]
634#[cfg_attr(feature = "ts_rs", derive(TS))]
635pub struct StudyRegistryCompletion {
636 pub completion_date: DateTime<Utc>,
642 pub completion_language: String,
644 pub completion_registration_attempt_date: Option<DateTime<Utc>>,
646 pub email: String,
650 pub grade: StudyRegistryGrade,
652 pub id: Uuid,
654 pub user_id: Uuid,
656 pub tier: Option<i32>,
659}
660
661impl From<CourseModuleCompletion> for StudyRegistryCompletion {
662 fn from(completion: CourseModuleCompletion) -> Self {
663 Self {
664 completion_date: completion.completion_date,
665 completion_language: completion.completion_language,
666 completion_registration_attempt_date: completion.completion_registration_attempt_date,
667 email: completion.email,
668 grade: StudyRegistryGrade::new(completion.passed, completion.grade),
669 id: completion.id,
670 user_id: completion.user_id,
671 tier: None,
672 }
673 }
674}
675
676impl StudyRegistryCompletion {
677 pub fn normalize_language_code(&mut self) {
678 match self.completion_language.as_str() {
679 "en" => self.completion_language = "en-GB".to_string(),
680 "fi" => self.completion_language = "fi-FI".to_string(),
681 "sv" => self.completion_language = "sv-SE".to_string(),
682 _ => {}
683 }
684 }
685}
686
687#[derive(Clone, PartialEq, Deserialize, Serialize)]
726#[cfg_attr(feature = "ts_rs", derive(TS))]
727pub struct StudyRegistryGrade {
728 pub scale: String,
729 pub grade: String,
730}
731
732impl StudyRegistryGrade {
733 pub fn new(passed: bool, grade: Option<i32>) -> Self {
734 match grade {
735 Some(grade) => Self {
736 scale: "sis-0-5".to_string(),
737 grade: grade.to_string(),
738 },
739 None => Self {
740 scale: "sis-hyv-hyl".to_string(),
741 grade: if passed {
742 "1".to_string()
743 } else {
744 "0".to_string()
745 },
746 },
747 }
748 }
749}
750pub fn stream_by_course_module_id<'a>(
754 conn: &'a mut PgConnection,
755 course_module_ids: &'a [Uuid],
756 no_completions_registered_by_this_study_registry_registrar: &'a Option<StudyRegistryRegistrar>,
757) -> impl Stream<Item = sqlx::Result<StudyRegistryCompletion>> + Send + 'a {
758 let study_module_registrar_id = no_completions_registered_by_this_study_registry_registrar
760 .clone()
761 .map(|o| o.id)
762 .unwrap_or(Uuid::nil());
763
764 sqlx::query_as!(
765 CourseModuleCompletion,
766 r#"
767SELECT *
768FROM course_module_completions
769WHERE course_module_id = ANY($1)
770 AND prerequisite_modules_completed
771 AND eligible_for_ects IS TRUE
772 AND deleted_at IS NULL
773 AND id NOT IN (
774 SELECT course_module_completion_id
775 FROM course_module_completion_registered_to_study_registries
776 WHERE course_module_id = ANY($1)
777 AND study_registry_registrar_id = $2
778 AND deleted_at IS NULL
779 )
780 "#,
781 course_module_ids,
782 study_module_registrar_id,
783 )
784 .map(StudyRegistryCompletion::from)
785 .fetch(conn)
786}
787
788pub async fn delete(conn: &mut PgConnection, id: Uuid) -> ModelResult<()> {
789 sqlx::query!(
790 "
791
792UPDATE course_module_completions
793SET deleted_at = now()
794WHERE id = $1
795AND deleted_at IS NULL
796 ",
797 id,
798 )
799 .execute(conn)
800 .await?;
801 Ok(())
802}
803
804pub async fn find_existing(
805 conn: &mut PgConnection,
806 course_id: Uuid,
807 course_module_id: Uuid,
808 user_id: Uuid,
809) -> ModelResult<Uuid> {
810 let row = sqlx::query!(
811 r#"
812 SELECT id
813 FROM course_module_completions
814 WHERE course_id = $1
815 AND course_module_id = $2
816 AND user_id = $3
817 AND completion_granter_user_id IS NULL
818 AND deleted_at IS NULL
819 "#,
820 course_id,
821 course_module_id,
822 user_id,
823 )
824 .fetch_one(conn)
825 .await?;
826
827 Ok(row.id)
828}
829
830pub async fn update_registration_attempt(
831 conn: &mut PgConnection,
832 completion_id: Uuid,
833) -> ModelResult<()> {
834 sqlx::query!(
835 r#"
836 UPDATE course_module_completions
837 SET completion_registration_attempt_date = now()
838 WHERE id = $1
839 "#,
840 completion_id
841 )
842 .execute(conn)
843 .await?;
844
845 Ok(())
846}