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 user_id: Uuid,
262 pub completion_date: DateTime<Utc>,
264}
265
266pub async fn get_all_with_registration_information_by_course_instance_id(
268 conn: &mut PgConnection,
269 course_instance_id: Uuid,
270 course_id: Uuid,
271) -> ModelResult<Vec<CourseModuleCompletionWithRegistrationInfo>> {
272 let res = sqlx::query_as!(
273 CourseModuleCompletionWithRegistrationInfo,
274 r#"
275SELECT completions.completion_registration_attempt_date,
276 completions.course_module_id,
277 completions.created_at,
278 completions.grade,
279 completions.passed,
280 completions.prerequisite_modules_completed,
281 (registered.id IS NOT NULL) AS "registered!",
282 completions.user_id,
283 completions.completion_date
284FROM course_module_completions completions
285 LEFT JOIN course_module_completion_registered_to_study_registries registered ON (
286 completions.id = registered.course_module_completion_id
287 )
288 JOIN user_course_settings settings ON (
289 completions.user_id = settings.user_id
290 AND settings.current_course_id = completions.course_id
291 )
292WHERE settings.current_course_instance_id = $1
293 AND completions.deleted_at IS NULL
294 AND registered.deleted_at IS NULL
295 AND settings.deleted_at IS NULL
296 AND settings.current_course_id = $2
297 "#,
298 course_instance_id,
299 course_id
300 )
301 .fetch_all(conn)
302 .await?;
303 Ok(res)
304}
305
306pub async fn get_all_by_course_id_and_user_id(
309 conn: &mut PgConnection,
310 course_id: Uuid,
311 user_id: Uuid,
312) -> ModelResult<Vec<CourseModuleCompletion>> {
313 let res = sqlx::query_as!(
314 CourseModuleCompletion,
315 "
316SELECT *
317FROM course_module_completions
318WHERE course_id = $1
319 AND user_id = $2
320 AND deleted_at IS NULL
321 ",
322 course_id,
323 user_id,
324 )
325 .fetch_all(conn)
326 .await?;
327 Ok(res)
328}
329
330pub async fn get_all_by_user_id(
331 conn: &mut PgConnection,
332 user_id: Uuid,
333) -> ModelResult<Vec<CourseModuleCompletion>> {
334 let res = sqlx::query_as!(
335 CourseModuleCompletion,
336 "
337SELECT *
338FROM course_module_completions
339WHERE user_id = $1
340 AND deleted_at IS NULL
341 ",
342 user_id,
343 )
344 .fetch_all(conn)
345 .await?;
346 Ok(res)
347}
348
349pub async fn get_all_by_user_id_and_course_module_id(
350 conn: &mut PgConnection,
351 user_id: Uuid,
352 course_module_id: Uuid,
353) -> ModelResult<Vec<CourseModuleCompletion>> {
354 let res = sqlx::query_as!(
355 CourseModuleCompletion,
356 "
357SELECT *
358FROM course_module_completions
359WHERE user_id = $1
360 AND course_module_id = $2
361 AND deleted_at IS NULL
362 ",
363 user_id,
364 course_module_id,
365 )
366 .fetch_all(conn)
367 .await?;
368 Ok(res)
369}
370
371pub async fn get_all_by_course_module_and_user_ids(
372 conn: &mut PgConnection,
373 course_module_id: Uuid,
374 user_id: Uuid,
375) -> ModelResult<Vec<CourseModuleCompletion>> {
376 let res = sqlx::query_as!(
377 CourseModuleCompletion,
378 "
379SELECT *
380FROM course_module_completions
381WHERE course_module_id = $1
382 AND user_id = $2
383 AND deleted_at IS NULL
384 ",
385 course_module_id,
386 user_id,
387 )
388 .fetch_all(conn)
389 .await?;
390 Ok(res)
391}
392
393pub async fn get_latest_by_course_and_user_ids(
395 conn: &mut PgConnection,
396 course_module_id: Uuid,
397 user_id: Uuid,
398) -> ModelResult<CourseModuleCompletion> {
399 let res = sqlx::query_as!(
400 CourseModuleCompletion,
401 "
402SELECT *
403FROM course_module_completions
404WHERE course_module_id = $1
405 AND user_id = $2
406 AND deleted_at IS NULL
407ORDER BY created_at DESC
408LIMIT 1
409 ",
410 course_module_id,
411 user_id,
412 )
413 .fetch_one(conn)
414 .await?;
415 Ok(res)
416}
417
418pub async fn get_best_completion_by_user_and_course_module_id(
419 conn: &mut PgConnection,
420 user_id: Uuid,
421 course_module_id: Uuid,
422) -> ModelResult<Option<CourseModuleCompletion>> {
423 let completions = sqlx::query_as!(
424 CourseModuleCompletion,
425 r#"
426SELECT *
427FROM course_module_completions
428WHERE user_id = $1
429 AND course_module_id = $2
430 AND deleted_at IS NULL
431 "#,
432 user_id,
433 course_module_id,
434 )
435 .fetch_all(conn)
436 .await?;
437
438 let best_grade = completions
439 .into_iter()
440 .max_by(|completion_a, completion_b| {
441 let score_a = match completion_a.grade {
442 Some(grade) => grade as f32,
443 None => match completion_a.passed {
444 true => 0.5,
445 false => -1.0,
446 },
447 };
448
449 let score_b = match completion_b.grade {
450 Some(grade) => grade as f32,
451 None => match completion_b.passed {
452 true => 0.5,
453 false => -1.0,
454 },
455 };
456
457 score_a
458 .partial_cmp(&score_b)
459 .unwrap_or(std::cmp::Ordering::Equal)
460 });
461
462 Ok(best_grade)
463}
464
465pub fn select_best_completion(
467 completions: Vec<CourseModuleCompletion>,
468) -> Option<CourseModuleCompletion> {
469 completions.into_iter().max_by(|a, b| {
470 let score_a = match a.grade {
471 Some(grade) => grade as f32,
472 None => {
473 if a.passed {
474 0.5
475 } else {
476 -1.0
477 }
478 }
479 };
480 let score_b = match b.grade {
481 Some(grade) => grade as f32,
482 None => {
483 if b.passed {
484 0.5
485 } else {
486 -1.0
487 }
488 }
489 };
490 score_a
491 .partial_cmp(&score_b)
492 .unwrap_or(std::cmp::Ordering::Equal)
493 })
494}
495
496pub async fn get_count_of_distinct_completors_by_course_id(
498 conn: &mut PgConnection,
499 course_id: Uuid,
500) -> ModelResult<i64> {
501 let res = sqlx::query!(
502 "
503SELECT COUNT(DISTINCT user_id) as count
504FROM course_module_completions
505WHERE course_id = $1
506 AND deleted_at IS NULL
507",
508 course_id,
509 )
510 .fetch_one(conn)
511 .await?;
512 Ok(res.count.unwrap_or(0))
513}
514
515pub async fn get_automatic_completion_by_course_module_course_and_user_ids(
519 conn: &mut PgConnection,
520 course_module_id: Uuid,
521 course_id: Uuid,
522 user_id: Uuid,
523) -> ModelResult<CourseModuleCompletion> {
524 let res = sqlx::query_as!(
525 CourseModuleCompletion,
526 "
527SELECT *
528FROM course_module_completions
529WHERE course_module_id = $1
530 AND course_id = $2
531 AND user_id = $3
532 AND completion_granter_user_id IS NULL
533 AND deleted_at IS NULL
534 ",
535 course_module_id,
536 course_id,
537 user_id,
538 )
539 .fetch_one(conn)
540 .await?;
541 Ok(res)
542}
543
544pub async fn update_completion_registration_attempt_date(
545 conn: &mut PgConnection,
546 id: Uuid,
547 completion_registration_attempt_date: DateTime<Utc>,
548) -> ModelResult<bool> {
549 let res = sqlx::query!(
550 "
551UPDATE course_module_completions
552SET completion_registration_attempt_date = $1
553WHERE id = $2
554 AND deleted_at IS NULL
555 ",
556 Some(completion_registration_attempt_date),
557 id,
558 )
559 .execute(conn)
560 .await?;
561 Ok(res.rows_affected() > 0)
562}
563
564pub async fn update_prerequisite_modules_completed(
565 conn: &mut PgConnection,
566 id: Uuid,
567 prerequisite_modules_completed: bool,
568) -> ModelResult<bool> {
569 let res = sqlx::query!(
570 "
571UPDATE course_module_completions SET prerequisite_modules_completed = $1
572WHERE id = $2 AND deleted_at IS NULL
573 ",
574 prerequisite_modules_completed,
575 id
576 )
577 .execute(conn)
578 .await?;
579 Ok(res.rows_affected() > 0)
580}
581
582pub async fn update_passed_and_grade_status(
583 conn: &mut PgConnection,
584 course_id: Uuid,
585 user_id: Uuid,
586 passed: bool,
587 grade: i32,
588) -> ModelResult<bool> {
589 let res = sqlx::query!(
590 "
591UPDATE course_module_completions SET passed = $1, grade = $2
592WHERE user_id = $3 AND course_id = $4 AND deleted_at IS NULL
593 ",
594 passed,
595 grade,
596 user_id,
597 course_id
598 )
599 .execute(conn)
600 .await?;
601 Ok(res.rows_affected() > 0)
602}
603
604pub async fn update_needs_to_be_reviewed(
605 conn: &mut PgConnection,
606 id: Uuid,
607 needs_to_be_reviewed: bool,
608) -> ModelResult<bool> {
609 let res = sqlx::query!(
610 "
611UPDATE course_module_completions SET needs_to_be_reviewed = $1
612WHERE id = $2 AND deleted_at IS NULL
613 ",
614 needs_to_be_reviewed,
615 id
616 )
617 .execute(conn)
618 .await?;
619 Ok(res.rows_affected() > 0)
620}
621
622pub async fn user_has_completed_course_module(
625 conn: &mut PgConnection,
626 user_id: Uuid,
627 course_module_id: Uuid,
628) -> ModelResult<bool> {
629 let res = get_all_by_course_module_and_user_ids(conn, course_module_id, user_id).await?;
630 Ok(!res.is_empty())
631}
632
633#[derive(Clone, PartialEq, Deserialize, Serialize)]
635
636pub struct StudyRegistryCompletion {
637 pub completion_date: DateTime<Utc>,
643 pub completion_language: String,
645 pub completion_registration_attempt_date: Option<DateTime<Utc>>,
647 pub email: String,
651 pub grade: StudyRegistryGrade,
653 pub id: Uuid,
655 pub user_id: Uuid,
657 pub tier: Option<i32>,
660}
661
662impl From<CourseModuleCompletion> for StudyRegistryCompletion {
663 fn from(completion: CourseModuleCompletion) -> Self {
664 Self {
665 completion_date: completion.completion_date,
666 completion_language: completion.completion_language,
667 completion_registration_attempt_date: completion.completion_registration_attempt_date,
668 email: completion.email,
669 grade: StudyRegistryGrade::new(completion.passed, completion.grade),
670 id: completion.id,
671 user_id: completion.user_id,
672 tier: None,
673 }
674 }
675}
676
677impl StudyRegistryCompletion {
678 pub fn normalize_language_code(&mut self) {
679 match self.completion_language.as_str() {
680 "en" => self.completion_language = "en-GB".to_string(),
681 "fi" => self.completion_language = "fi-FI".to_string(),
682 "sv" => self.completion_language = "sv-SE".to_string(),
683 _ => {}
684 }
685 }
686}
687
688#[derive(Clone, PartialEq, Deserialize, Serialize)]
727
728pub struct StudyRegistryGrade {
729 pub scale: String,
730 pub grade: String,
731}
732
733impl StudyRegistryGrade {
734 pub fn new(passed: bool, grade: Option<i32>) -> Self {
735 match grade {
736 Some(grade) => Self {
737 scale: "sis-0-5".to_string(),
738 grade: grade.to_string(),
739 },
740 None => Self {
741 scale: "sis-hyv-hyl".to_string(),
742 grade: if passed {
743 "1".to_string()
744 } else {
745 "0".to_string()
746 },
747 },
748 }
749 }
750}
751pub fn stream_by_course_module_id<'a>(
755 conn: &'a mut PgConnection,
756 course_module_ids: &'a [Uuid],
757 no_completions_registered_by_this_study_registry_registrar: &'a Option<StudyRegistryRegistrar>,
758) -> impl Stream<Item = sqlx::Result<StudyRegistryCompletion>> + Send + 'a {
759 let study_module_registrar_id = no_completions_registered_by_this_study_registry_registrar
761 .clone()
762 .map(|o| o.id)
763 .unwrap_or(Uuid::nil());
764
765 sqlx::query_as!(
766 CourseModuleCompletion,
767 r#"
768SELECT *
769FROM course_module_completions
770WHERE course_module_id = ANY($1)
771 AND prerequisite_modules_completed
772 AND eligible_for_ects IS TRUE
773 AND deleted_at IS NULL
774 AND id NOT IN (
775 SELECT course_module_completion_id
776 FROM course_module_completion_registered_to_study_registries
777 WHERE course_module_id = ANY($1)
778 AND study_registry_registrar_id = $2
779 AND deleted_at IS NULL
780 )
781 "#,
782 course_module_ids,
783 study_module_registrar_id,
784 )
785 .map(StudyRegistryCompletion::from)
786 .fetch(conn)
787}
788
789pub async fn delete(conn: &mut PgConnection, id: Uuid) -> ModelResult<()> {
790 sqlx::query!(
791 "
792
793UPDATE course_module_completions
794SET deleted_at = now()
795WHERE id = $1
796AND deleted_at IS NULL
797 ",
798 id,
799 )
800 .execute(conn)
801 .await?;
802 Ok(())
803}
804
805pub async fn find_existing(
806 conn: &mut PgConnection,
807 course_id: Uuid,
808 course_module_id: Uuid,
809 user_id: Uuid,
810) -> ModelResult<Uuid> {
811 let row = sqlx::query!(
812 r#"
813 SELECT id
814 FROM course_module_completions
815 WHERE course_id = $1
816 AND course_module_id = $2
817 AND user_id = $3
818 AND completion_granter_user_id IS NULL
819 AND deleted_at IS NULL
820 "#,
821 course_id,
822 course_module_id,
823 user_id,
824 )
825 .fetch_one(conn)
826 .await?;
827
828 Ok(row.id)
829}
830
831pub async fn update_registration_attempt(
832 conn: &mut PgConnection,
833 completion_id: Uuid,
834) -> ModelResult<()> {
835 sqlx::query!(
836 r#"
837 UPDATE course_module_completions
838 SET completion_registration_attempt_date = now()
839 WHERE id = $1
840 "#,
841 completion_id
842 )
843 .execute(conn)
844 .await?;
845
846 Ok(())
847}