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