headless_lms_models/
course_module_completions.rs

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_instance_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#[cfg_attr(feature = "ts_rs", derive(TS))]
32pub struct CourseModuleAverage {
33    pub id: Uuid,
34    pub course_instance_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// Define the CourseModulePointsAverage struct to match the result of the SQL query
45#[derive(Debug, Serialize, Deserialize)]
46#[cfg_attr(feature = "ts_rs", derive(TS))]
47pub struct CourseModulePointsAverage {
48    pub course_instance_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#[cfg_attr(feature = "ts_rs", derive(TS))]
71pub struct NewCourseModuleCompletion {
72    pub course_id: Uuid,
73    pub course_instance_id: Uuid,
74    pub course_module_id: Uuid,
75    pub user_id: Uuid,
76    pub completion_date: DateTime<Utc>,
77    pub completion_registration_attempt_date: Option<DateTime<Utc>>,
78    pub completion_language: String,
79    pub eligible_for_ects: bool,
80    pub email: String,
81    pub grade: Option<i32>,
82    pub passed: bool,
83}
84
85pub async fn insert(
86    conn: &mut PgConnection,
87    pkey_policy: PKeyPolicy<Uuid>,
88    new_course_module_completion: &NewCourseModuleCompletion,
89    completion_granter: CourseModuleCompletionGranter,
90) -> ModelResult<CourseModuleCompletion> {
91    let res = sqlx::query_as!(
92        CourseModuleCompletion,
93        "
94INSERT INTO course_module_completions (
95    id,
96    course_id,
97    course_instance_id,
98    course_module_id,
99    user_id,
100    completion_date,
101    completion_registration_attempt_date,
102    completion_language,
103    eligible_for_ects,
104    email,
105    grade,
106    passed,
107    completion_granter_user_id
108  )
109VALUES (
110    $1,
111    $2,
112    $3,
113    $4,
114    $5,
115    $6,
116    $7,
117    $8,
118    $9,
119    $10,
120    $11,
121    $12,
122    $13
123  )
124RETURNING *
125        ",
126        pkey_policy.into_uuid(),
127        new_course_module_completion.course_id,
128        new_course_module_completion.course_instance_id,
129        new_course_module_completion.course_module_id,
130        new_course_module_completion.user_id,
131        new_course_module_completion.completion_date,
132        new_course_module_completion.completion_registration_attempt_date,
133        new_course_module_completion.completion_language,
134        new_course_module_completion.eligible_for_ects,
135        new_course_module_completion.email,
136        new_course_module_completion.grade,
137        new_course_module_completion.passed,
138        completion_granter.to_database_field(),
139    )
140    .fetch_one(conn)
141    .await?;
142    Ok(res)
143}
144
145pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<CourseModuleCompletion> {
146    let res = sqlx::query_as!(
147        CourseModuleCompletion,
148        r#"
149SELECT *
150FROM course_module_completions
151WHERE id = $1
152  AND deleted_at IS NULL
153        "#,
154        id,
155    )
156    .fetch_one(conn)
157    .await?;
158    Ok(res)
159}
160
161/// Also returns soft deleted completions so that we can make sure the process does not crash if a completion is deleted before we get it back from the study registry.
162pub async fn get_by_ids(
163    conn: &mut PgConnection,
164    ids: &[Uuid],
165) -> ModelResult<Vec<CourseModuleCompletion>> {
166    let res = sqlx::query_as!(
167        CourseModuleCompletion,
168        "
169SELECT *
170FROM course_module_completions
171WHERE id = ANY($1)
172        ",
173        ids,
174    )
175    .fetch_all(conn)
176    .await?;
177    Ok(res)
178}
179
180pub async fn get_by_ids_as_map(
181    conn: &mut PgConnection,
182    ids: &[Uuid],
183) -> ModelResult<HashMap<Uuid, CourseModuleCompletion>> {
184    let res = get_by_ids(conn, ids)
185        .await?
186        .into_iter()
187        .map(|x| (x.id, x))
188        .collect();
189    Ok(res)
190}
191
192pub async fn get_all_by_course_instance_id(
193    conn: &mut PgConnection,
194    course_instance_id: Uuid,
195) -> ModelResult<Vec<CourseModuleCompletion>> {
196    let res = sqlx::query_as!(
197        CourseModuleCompletion,
198        "
199SELECT *
200FROM course_module_completions
201WHERE course_instance_id = $1
202  AND deleted_at IS NULL
203        ",
204        course_instance_id,
205    )
206    .fetch_all(conn)
207    .await?;
208    Ok(res)
209}
210
211#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
212#[cfg_attr(feature = "ts_rs", derive(TS))]
213pub struct CourseModuleCompletionWithRegistrationInfo {
214    /// When the student has attempted to register the completion.
215    pub completion_registration_attempt_date: Option<DateTime<Utc>>,
216    /// ID of the course module.
217    pub course_module_id: Uuid,
218    /// When the record was created
219    pub created_at: DateTime<Utc>,
220    /// Grade that the student received for the completion.
221    pub grade: Option<i32>,
222    /// Whether or not the student is eligible for credit for the completion.
223    pub passed: bool,
224    /// Whether or not the student is qualified for credit based on other modules in the course.
225    pub prerequisite_modules_completed: bool,
226    /// Whether or not the completion has been registered to a study registry.
227    pub registered: bool,
228    /// ID of the user for the completion.
229    pub user_id: Uuid,
230    // When the user completed the course
231    pub completion_date: DateTime<Utc>,
232}
233
234/// Gets summaries for all completions on the given course instance.
235pub async fn get_all_with_registration_information_by_course_instance_id(
236    conn: &mut PgConnection,
237    course_instance_id: Uuid,
238) -> ModelResult<Vec<CourseModuleCompletionWithRegistrationInfo>> {
239    let res = sqlx::query_as!(
240        CourseModuleCompletionWithRegistrationInfo,
241        r#"
242SELECT completions.completion_registration_attempt_date,
243  completions.course_module_id,
244  completions.created_at,
245  completions.grade,
246  completions.passed,
247  completions.prerequisite_modules_completed,
248  (registered.id IS NOT NULL) AS "registered!",
249  completions.user_id,
250  completions.completion_date
251FROM course_module_completions completions
252  LEFT JOIN course_module_completion_registered_to_study_registries registered ON (
253    completions.id = registered.course_module_completion_id
254  )
255WHERE completions.course_instance_id = $1
256  AND completions.deleted_at IS NULL
257  AND registered.deleted_at IS NULL
258        "#,
259        course_instance_id,
260    )
261    .fetch_all(conn)
262    .await?;
263    Ok(res)
264}
265
266/// Gets all module completions for the user on a single course instance. There can be multiple modules
267/// in a single course, so the result is a `Vec`.
268pub async fn get_all_by_course_instance_and_user_id(
269    conn: &mut PgConnection,
270    course_instance_id: Uuid,
271    user_id: Uuid,
272) -> ModelResult<Vec<CourseModuleCompletion>> {
273    let res = sqlx::query_as!(
274        CourseModuleCompletion,
275        "
276SELECT *
277FROM course_module_completions
278WHERE course_instance_id = $1
279  AND user_id = $2
280  AND deleted_at IS NULL
281        ",
282        course_instance_id,
283        user_id,
284    )
285    .fetch_all(conn)
286    .await?;
287    Ok(res)
288}
289
290pub async fn get_all_by_user_id(
291    conn: &mut PgConnection,
292    user_id: Uuid,
293) -> ModelResult<Vec<CourseModuleCompletion>> {
294    let res = sqlx::query_as!(
295        CourseModuleCompletion,
296        "
297SELECT *
298FROM course_module_completions
299WHERE user_id = $1
300  AND deleted_at IS NULL
301        ",
302        user_id,
303    )
304    .fetch_all(conn)
305    .await?;
306    Ok(res)
307}
308
309pub async fn get_all_by_user_id_and_course_module_id(
310    conn: &mut PgConnection,
311    user_id: Uuid,
312    course_module_id: Uuid,
313) -> ModelResult<Vec<CourseModuleCompletion>> {
314    let res = sqlx::query_as!(
315        CourseModuleCompletion,
316        "
317SELECT *
318FROM course_module_completions
319WHERE user_id = $1
320  AND course_module_id = $2
321  AND deleted_at IS NULL
322        ",
323        user_id,
324        course_module_id,
325    )
326    .fetch_all(conn)
327    .await?;
328    Ok(res)
329}
330
331pub async fn get_all_by_course_module_instance_and_user_ids(
332    conn: &mut PgConnection,
333    course_module_id: Uuid,
334    course_instance_id: Uuid,
335    user_id: Uuid,
336) -> ModelResult<Vec<CourseModuleCompletion>> {
337    let res = sqlx::query_as!(
338        CourseModuleCompletion,
339        "
340SELECT *
341FROM course_module_completions
342WHERE course_module_id = $1
343  AND course_instance_id = $2
344  AND user_id = $3
345  AND deleted_at IS NULL
346        ",
347        course_module_id,
348        course_instance_id,
349        user_id,
350    )
351    .fetch_all(conn)
352    .await?;
353    Ok(res)
354}
355
356/// Gets latest created completion for the given user on the specified course instance.
357pub async fn get_latest_by_course_module_instance_and_user_ids(
358    conn: &mut PgConnection,
359    course_module_id: Uuid,
360    course_instance_id: Uuid,
361    user_id: Uuid,
362) -> ModelResult<CourseModuleCompletion> {
363    let res = sqlx::query_as!(
364        CourseModuleCompletion,
365        "
366SELECT *
367FROM course_module_completions
368WHERE course_module_id = $1
369  AND course_instance_id = $2
370  AND user_id = $3
371  AND deleted_at IS NULL
372ORDER BY created_at DESC
373LIMIT 1
374        ",
375        course_module_id,
376        course_instance_id,
377        user_id,
378    )
379    .fetch_one(conn)
380    .await?;
381    Ok(res)
382}
383
384pub async fn get_best_completion_by_user_and_course_module_id(
385    conn: &mut PgConnection,
386    user_id: Uuid,
387    course_module_id: Uuid,
388) -> ModelResult<Option<CourseModuleCompletion>> {
389    let completions = sqlx::query_as!(
390        CourseModuleCompletion,
391        r#"
392SELECT *
393FROM course_module_completions
394WHERE user_id = $1
395  AND course_module_id = $2
396  AND deleted_at IS NULL
397        "#,
398        user_id,
399        course_module_id,
400    )
401    .fetch_all(conn)
402    .await?;
403
404    let best_grade = completions
405        .into_iter()
406        .max_by(|completion_a, completion_b| {
407            let score_a = match completion_a.grade {
408                Some(grade) => grade as f32,
409                None => match completion_a.passed {
410                    true => 0.5,
411                    false => -1.0,
412                },
413            };
414
415            let score_b = match completion_b.grade {
416                Some(grade) => grade as f32,
417                None => match completion_b.passed {
418                    true => 0.5,
419                    false => -1.0,
420                },
421            };
422
423            score_a
424                .partial_cmp(&score_b)
425                .unwrap_or(std::cmp::Ordering::Equal)
426        });
427
428    Ok(best_grade)
429}
430
431/// Finds the best grade
432pub fn select_best_completion(
433    completions: Vec<CourseModuleCompletion>,
434) -> Option<CourseModuleCompletion> {
435    completions.into_iter().max_by(|a, b| {
436        let score_a = match a.grade {
437            Some(grade) => grade as f32,
438            None => {
439                if a.passed {
440                    0.5
441                } else {
442                    -1.0
443                }
444            }
445        };
446        let score_b = match b.grade {
447            Some(grade) => grade as f32,
448            None => {
449                if b.passed {
450                    0.5
451                } else {
452                    -1.0
453                }
454            }
455        };
456        score_a
457            .partial_cmp(&score_b)
458            .unwrap_or(std::cmp::Ordering::Equal)
459    })
460}
461
462/// Get the number of students that have completed the course
463pub async fn get_count_of_distinct_completors_by_course_id(
464    conn: &mut PgConnection,
465    course_id: Uuid,
466) -> ModelResult<i64> {
467    let res = sqlx::query!(
468        "
469SELECT COUNT(DISTINCT user_id) as count
470FROM course_module_completions
471WHERE course_id = $1
472  AND deleted_at IS NULL
473",
474        course_id,
475    )
476    .fetch_one(conn)
477    .await?;
478    Ok(res.count.unwrap_or(0))
479}
480
481/// Gets automatically granted course module completion for the given user on the specified course
482/// instance. This entry is quaranteed to be unique in database by the index
483/// `course_module_automatic_completion_uniqueness`.
484pub async fn get_automatic_completion_by_course_module_instance_and_user_ids(
485    conn: &mut PgConnection,
486    course_module_id: Uuid,
487    course_instance_id: Uuid,
488    user_id: Uuid,
489) -> ModelResult<CourseModuleCompletion> {
490    let res = sqlx::query_as!(
491        CourseModuleCompletion,
492        "
493SELECT *
494FROM course_module_completions
495WHERE course_module_id = $1
496  AND course_instance_id = $2
497  AND user_id = $3
498  AND completion_granter_user_id IS NULL
499  AND deleted_at IS NULL
500        ",
501        course_module_id,
502        course_instance_id,
503        user_id,
504    )
505    .fetch_one(conn)
506    .await?;
507    Ok(res)
508}
509
510pub async fn update_completion_registration_attempt_date(
511    conn: &mut PgConnection,
512    id: Uuid,
513    completion_registration_attempt_date: DateTime<Utc>,
514) -> ModelResult<bool> {
515    let res = sqlx::query!(
516        "
517UPDATE course_module_completions
518SET completion_registration_attempt_date = $1
519WHERE id = $2
520  AND deleted_at IS NULL
521        ",
522        Some(completion_registration_attempt_date),
523        id,
524    )
525    .execute(conn)
526    .await?;
527    Ok(res.rows_affected() > 0)
528}
529
530pub async fn update_prerequisite_modules_completed(
531    conn: &mut PgConnection,
532    id: Uuid,
533    prerequisite_modules_completed: bool,
534) -> ModelResult<bool> {
535    let res = sqlx::query!(
536        "
537UPDATE course_module_completions SET prerequisite_modules_completed = $1
538WHERE id = $2 AND deleted_at IS NULL
539    ",
540        prerequisite_modules_completed,
541        id
542    )
543    .execute(conn)
544    .await?;
545    Ok(res.rows_affected() > 0)
546}
547
548pub async fn update_passed_and_grade_status(
549    conn: &mut PgConnection,
550    course_id: Uuid,
551    user_id: Uuid,
552    passed: bool,
553    grade: i32,
554) -> ModelResult<bool> {
555    let res = sqlx::query!(
556        "
557UPDATE course_module_completions SET passed = $1, grade = $2
558WHERE user_id = $3 AND course_id = $4 AND deleted_at IS NULL
559    ",
560        passed,
561        grade,
562        user_id,
563        course_id
564    )
565    .execute(conn)
566    .await?;
567    Ok(res.rows_affected() > 0)
568}
569
570pub async fn update_needs_to_be_reviewed(
571    conn: &mut PgConnection,
572    id: Uuid,
573    needs_to_be_reviewed: bool,
574) -> ModelResult<bool> {
575    let res = sqlx::query!(
576        "
577UPDATE course_module_completions SET needs_to_be_reviewed = $1
578WHERE id = $2 AND deleted_at IS NULL
579        ",
580        needs_to_be_reviewed,
581        id
582    )
583    .execute(conn)
584    .await?;
585    Ok(res.rows_affected() > 0)
586}
587
588/// Checks whether the user has any completions for the given course module on the specified
589/// course instance.
590pub async fn user_has_completed_course_module_on_instance(
591    conn: &mut PgConnection,
592    user_id: Uuid,
593    course_module_id: Uuid,
594    course_instance_id: Uuid,
595) -> ModelResult<bool> {
596    let res = get_all_by_course_module_instance_and_user_ids(
597        conn,
598        course_module_id,
599        course_instance_id,
600        user_id,
601    )
602    .await?;
603    Ok(!res.is_empty())
604}
605
606/// Completion in the form that is recognized by authorized third party study registry registrars.
607#[derive(Clone, PartialEq, Deserialize, Serialize)]
608#[cfg_attr(feature = "ts_rs", derive(TS))]
609pub struct StudyRegistryCompletion {
610    /// The date when the student completed the course. The value of this field is the date that will
611    /// end up in the user's study registry as the completion date. If the completion is created
612    /// automatically, it is the date when the student passed the completion thresholds. If the teacher
613    /// creates these completions manually, the teacher inputs this value. Usually the teacher would in
614    /// this case input the date of the exam.
615    pub completion_date: DateTime<Utc>,
616    /// The language used in the completion of the course.
617    pub completion_language: String,
618    /// Date when the student opened the form to register their credits to the open university.
619    pub completion_registration_attempt_date: Option<DateTime<Utc>>,
620    /// Email at the time of completing the course. Used to match the student to the data that they will
621    /// fill to the open university and it will remain unchanged in the event of email change because
622    /// changing this would break the matching.
623    pub email: String,
624    /// The grade to be passed to the study registry. Uses the sisu format. See the struct documentation for details.
625    pub grade: StudyRegistryGrade,
626    /// ID of the completion.
627    pub id: Uuid,
628    /// User id in courses.mooc.fi for received registered completions.
629    pub user_id: Uuid,
630    /// Tier of the completion. Currently always null. Historically used for example to distinguish between
631    /// intermediate and advanced versions of the Building AI course.
632    pub tier: Option<i32>,
633}
634
635impl From<CourseModuleCompletion> for StudyRegistryCompletion {
636    fn from(completion: CourseModuleCompletion) -> Self {
637        Self {
638            completion_date: completion.completion_date,
639            completion_language: completion.completion_language,
640            completion_registration_attempt_date: completion.completion_registration_attempt_date,
641            email: completion.email,
642            grade: StudyRegistryGrade::new(completion.passed, completion.grade),
643            id: completion.id,
644            user_id: completion.user_id,
645            tier: None,
646        }
647    }
648}
649
650/// Grading object that maps the system grading information to Sisu's grading scales.
651///
652/// Currently only `sis-0-5` and `sis-hyv-hyl` scales are supported in the system.
653///
654/// All grading scales can be found from <https://sis-helsinki-test.funidata.fi/api/graphql> using
655/// the following query:
656///
657/// ```graphql
658/// query {
659///   grade_scales {
660///     id
661///     name {
662///       fi
663///       en
664///       sv
665///     }
666///     grades {
667///       name {
668///         fi
669///         en
670///         sv
671///       }
672///       passed
673///       localId
674///       abbreviation {
675///         fi
676///         en
677///         sv
678///       }
679///     }
680///     abbreviation {
681///       fi
682///       en
683///       sv
684///     }
685///   }
686/// }
687/// ```
688#[derive(Clone, PartialEq, Deserialize, Serialize)]
689#[cfg_attr(feature = "ts_rs", derive(TS))]
690pub struct StudyRegistryGrade {
691    pub scale: String,
692    pub grade: String,
693}
694
695impl StudyRegistryGrade {
696    pub fn new(passed: bool, grade: Option<i32>) -> Self {
697        match grade {
698            Some(grade) => Self {
699                scale: "sis-0-5".to_string(),
700                grade: grade.to_string(),
701            },
702            None => Self {
703                scale: "sis-hyv-hyl".to_string(),
704                grade: if passed {
705                    "1".to_string()
706                } else {
707                    "0".to_string()
708                },
709            },
710        }
711    }
712}
713/// Streams completions.
714///
715/// If no_completions_registered_by_this_study_registry_registrar is None, then all completions are streamed.
716pub fn stream_by_course_module_id<'a>(
717    conn: &'a mut PgConnection,
718    course_module_ids: &'a [Uuid],
719    no_completions_registered_by_this_study_registry_registrar: &'a Option<StudyRegistryRegistrar>,
720) -> impl Stream<Item = sqlx::Result<StudyRegistryCompletion>> + Send + 'a {
721    // If this is none, we're using a null uuid, which will never match anything. Therefore, no completions will be filtered out.
722    let study_module_registrar_id = no_completions_registered_by_this_study_registry_registrar
723        .clone()
724        .map(|o| o.id)
725        .unwrap_or(Uuid::nil());
726
727    sqlx::query_as!(
728        CourseModuleCompletion,
729        r#"
730SELECT *
731FROM course_module_completions
732WHERE course_module_id = ANY($1)
733  AND prerequisite_modules_completed
734  AND eligible_for_ects IS TRUE
735  AND deleted_at IS NULL
736  AND id NOT IN (
737    SELECT course_module_completion_id
738    FROM course_module_completion_registered_to_study_registries
739    WHERE course_module_id = ANY($1)
740      AND study_registry_registrar_id = $2
741      AND deleted_at IS NULL
742  )
743        "#,
744        course_module_ids,
745        study_module_registrar_id,
746    )
747    .map(StudyRegistryCompletion::from)
748    .fetch(conn)
749}
750
751pub async fn delete(conn: &mut PgConnection, id: Uuid) -> ModelResult<()> {
752    sqlx::query!(
753        "
754
755UPDATE course_module_completions
756SET deleted_at = now()
757WHERE id = $1
758        ",
759        id,
760    )
761    .execute(conn)
762    .await?;
763    Ok(())
764}