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
475 .into_iter()
476 .max_by_key(|completion| (completion.passed, completion.grade.unwrap_or(0)))
477}
478
479pub async fn get_count_of_distinct_completors_by_course_id(
481 conn: &mut PgConnection,
482 course_id: Uuid,
483) -> ModelResult<i64> {
484 let res = sqlx::query!(
485 "
486SELECT COUNT(DISTINCT user_id) as count
487FROM course_module_completions
488WHERE course_id = $1
489 AND deleted_at IS NULL
490",
491 course_id,
492 )
493 .fetch_one(conn)
494 .await?;
495 Ok(res.count.unwrap_or(0))
496}
497
498pub async fn get_automatic_completion_by_course_module_course_and_user_ids(
502 conn: &mut PgConnection,
503 course_module_id: Uuid,
504 course_id: Uuid,
505 user_id: Uuid,
506) -> ModelResult<CourseModuleCompletion> {
507 let res = sqlx::query_as!(
508 CourseModuleCompletion,
509 "
510SELECT *
511FROM course_module_completions
512WHERE course_module_id = $1
513 AND course_id = $2
514 AND user_id = $3
515 AND completion_granter_user_id IS NULL
516 AND deleted_at IS NULL
517 ",
518 course_module_id,
519 course_id,
520 user_id,
521 )
522 .fetch_one(conn)
523 .await?;
524 Ok(res)
525}
526
527pub async fn user_has_manual_completion_in_course(
531 conn: &mut PgConnection,
532 user_id: Uuid,
533 course_id: Uuid,
534) -> ModelResult<bool> {
535 let res = sqlx::query!(
536 r#"
537SELECT EXISTS (
538 SELECT 1
539 FROM course_module_completions
540 WHERE user_id = $1
541 AND course_id = $2
542 AND completion_granter_user_id IS NOT NULL
543 AND deleted_at IS NULL
544) AS "exists!"
545 "#,
546 user_id,
547 course_id,
548 )
549 .fetch_one(conn)
550 .await?;
551 Ok(res.exists)
552}
553
554pub async fn update_completion_registration_attempt_date(
555 conn: &mut PgConnection,
556 id: Uuid,
557 completion_registration_attempt_date: DateTime<Utc>,
558) -> ModelResult<bool> {
559 let res = sqlx::query!(
560 "
561UPDATE course_module_completions
562SET completion_registration_attempt_date = $1
563WHERE id = $2
564 AND deleted_at IS NULL
565 ",
566 Some(completion_registration_attempt_date),
567 id,
568 )
569 .execute(conn)
570 .await?;
571 Ok(res.rows_affected() > 0)
572}
573
574pub async fn update_prerequisite_modules_completed(
575 conn: &mut PgConnection,
576 id: Uuid,
577 prerequisite_modules_completed: bool,
578) -> ModelResult<bool> {
579 let res = sqlx::query!(
580 "
581UPDATE course_module_completions SET prerequisite_modules_completed = $1
582WHERE id = $2 AND deleted_at IS NULL
583 ",
584 prerequisite_modules_completed,
585 id
586 )
587 .execute(conn)
588 .await?;
589 Ok(res.rows_affected() > 0)
590}
591
592pub async fn update_needs_to_be_reviewed(
593 conn: &mut PgConnection,
594 id: Uuid,
595 needs_to_be_reviewed: bool,
596) -> ModelResult<bool> {
597 let res = sqlx::query!(
598 "
599UPDATE course_module_completions SET needs_to_be_reviewed = $1
600WHERE id = $2 AND deleted_at IS NULL
601 ",
602 needs_to_be_reviewed,
603 id
604 )
605 .execute(conn)
606 .await?;
607 Ok(res.rows_affected() > 0)
608}
609
610pub async fn update_needs_to_be_reviewed_by_course_and_user_ids(
611 conn: &mut PgConnection,
612 course_id: Uuid,
613 user_id: Uuid,
614 needs_to_be_reviewed: bool,
615) -> ModelResult<bool> {
616 let res = sqlx::query!(
617 "
618UPDATE course_module_completions SET needs_to_be_reviewed = $1
619WHERE course_id = $2 AND user_id = $3 AND deleted_at IS NULL
620 ",
621 needs_to_be_reviewed,
622 course_id,
623 user_id,
624 )
625 .execute(conn)
626 .await?;
627 Ok(res.rows_affected() > 0)
628}
629
630pub async fn user_has_completed_course_module(
633 conn: &mut PgConnection,
634 user_id: Uuid,
635 course_module_id: Uuid,
636) -> ModelResult<bool> {
637 let res = get_all_by_course_module_and_user_ids(conn, course_module_id, user_id).await?;
638 Ok(!res.is_empty())
639}
640
641#[derive(Clone, PartialEq, Deserialize, Serialize)]
643
644pub struct StudyRegistryCompletion {
645 pub completion_date: DateTime<Utc>,
651 pub completion_language: String,
653 pub completion_registration_attempt_date: Option<DateTime<Utc>>,
655 pub email: String,
659 pub grade: StudyRegistryGrade,
661 pub id: Uuid,
663 pub user_id: Uuid,
665 pub tier: Option<i32>,
668}
669
670impl From<CourseModuleCompletion> for StudyRegistryCompletion {
671 fn from(completion: CourseModuleCompletion) -> Self {
672 Self {
673 completion_date: completion.completion_date,
674 completion_language: completion.completion_language,
675 completion_registration_attempt_date: completion.completion_registration_attempt_date,
676 email: completion.email,
677 grade: StudyRegistryGrade::new(completion.passed, completion.grade),
678 id: completion.id,
679 user_id: completion.user_id,
680 tier: None,
681 }
682 }
683}
684
685impl StudyRegistryCompletion {
686 pub fn normalize_language_code(&mut self) {
687 match self.completion_language.as_str() {
688 "en" => self.completion_language = "en-GB".to_string(),
689 "fi" => self.completion_language = "fi-FI".to_string(),
690 "sv" => self.completion_language = "sv-SE".to_string(),
691 _ => {}
692 }
693 }
694}
695
696#[derive(Clone, PartialEq, Deserialize, Serialize)]
735
736pub struct StudyRegistryGrade {
737 pub scale: String,
738 pub grade: String,
739}
740
741impl StudyRegistryGrade {
742 pub fn new(passed: bool, grade: Option<i32>) -> Self {
743 match grade {
744 Some(grade) => Self {
745 scale: "sis-0-5".to_string(),
746 grade: grade.to_string(),
747 },
748 None => Self {
749 scale: "sis-hyv-hyl".to_string(),
750 grade: if passed {
751 "1".to_string()
752 } else {
753 "0".to_string()
754 },
755 },
756 }
757 }
758}
759pub fn stream_by_course_module_id<'a>(
763 conn: &'a mut PgConnection,
764 course_module_ids: &'a [Uuid],
765 no_completions_registered_by_this_study_registry_registrar: &'a Option<StudyRegistryRegistrar>,
766) -> impl Stream<Item = sqlx::Result<StudyRegistryCompletion>> + Send + 'a {
767 let study_module_registrar_id = no_completions_registered_by_this_study_registry_registrar
769 .clone()
770 .map(|o| o.id)
771 .unwrap_or(Uuid::nil());
772
773 sqlx::query_as!(
774 CourseModuleCompletion,
775 r#"
776SELECT *
777FROM course_module_completions
778WHERE course_module_id = ANY($1)
779 AND prerequisite_modules_completed
780 AND eligible_for_ects IS TRUE
781 -- Completions still awaiting suspected-cheater review are withheld from study-registry
782 -- registration until a teacher dismisses or confirms them.
783 AND needs_to_be_reviewed = FALSE
784 AND deleted_at IS NULL
785 AND id NOT IN (
786 SELECT course_module_completion_id
787 FROM course_module_completion_registered_to_study_registries
788 WHERE course_module_id = ANY($1)
789 AND study_registry_registrar_id = $2
790 AND deleted_at IS NULL
791 )
792 "#,
793 course_module_ids,
794 study_module_registrar_id,
795 )
796 .map(StudyRegistryCompletion::from)
797 .fetch(conn)
798}
799
800pub async fn delete(conn: &mut PgConnection, id: Uuid) -> ModelResult<()> {
801 sqlx::query!(
802 "
803
804UPDATE course_module_completions
805SET deleted_at = now()
806WHERE id = $1
807AND deleted_at IS NULL
808 ",
809 id,
810 )
811 .execute(conn)
812 .await?;
813 Ok(())
814}
815
816pub async fn find_existing(
817 conn: &mut PgConnection,
818 course_id: Uuid,
819 course_module_id: Uuid,
820 user_id: Uuid,
821) -> ModelResult<Uuid> {
822 let row = sqlx::query!(
823 r#"
824 SELECT id
825 FROM course_module_completions
826 WHERE course_id = $1
827 AND course_module_id = $2
828 AND user_id = $3
829 AND completion_granter_user_id IS NULL
830 AND deleted_at IS NULL
831 "#,
832 course_id,
833 course_module_id,
834 user_id,
835 )
836 .fetch_one(conn)
837 .await?;
838
839 Ok(row.id)
840}
841
842pub async fn update_registration_attempt(
843 conn: &mut PgConnection,
844 completion_id: Uuid,
845) -> ModelResult<()> {
846 sqlx::query!(
847 r#"
848 UPDATE course_module_completions
849 SET completion_registration_attempt_date = now()
850 WHERE id = $1
851 "#,
852 completion_id
853 )
854 .execute(conn)
855 .await?;
856
857 Ok(())
858}