Skip to main content

headless_lms_models/
course_module_completions.rs

1use std::borrow::Borrow;
2use std::collections::{HashMap, HashSet};
3
4use futures::Stream;
5use utoipa::ToSchema;
6
7use crate::{
8    error::missing_model_error, prelude::*, study_registry_registrars::StudyRegistryRegistrar,
9};
10
11#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, ToSchema)]
12
13pub struct CourseModuleCompletion {
14    pub id: Uuid,
15    pub created_at: DateTime<Utc>,
16    pub updated_at: DateTime<Utc>,
17    pub deleted_at: Option<DateTime<Utc>>,
18    pub course_id: Uuid,
19    pub course_module_id: Uuid,
20    pub user_id: Uuid,
21    pub completion_date: DateTime<Utc>,
22    pub completion_registration_attempt_date: Option<DateTime<Utc>>,
23    pub completion_language: String,
24    pub eligible_for_ects: bool,
25    pub email: String,
26    pub grade: Option<i32>,
27    pub passed: bool,
28    pub prerequisite_modules_completed: bool,
29    pub completion_granter_user_id: Option<Uuid>,
30    pub needs_to_be_reviewed: bool,
31    /// Whether the push path owns this completion. See the column comment.
32    pub register_credits_via_suotar: bool,
33}
34
35#[derive(Clone, PartialEq, Deserialize, Serialize)]
36pub enum CourseModuleCompletionGranter {
37    Automatic,
38    User(Uuid),
39}
40
41impl CourseModuleCompletionGranter {
42    fn to_database_field(&self) -> Option<Uuid> {
43        match self {
44            CourseModuleCompletionGranter::Automatic => None,
45            CourseModuleCompletionGranter::User(user_id) => Some(*user_id),
46        }
47    }
48}
49
50#[derive(Clone, PartialEq, Deserialize, Serialize)]
51
52pub struct NewCourseModuleCompletion {
53    pub course_id: Uuid,
54    pub course_module_id: Uuid,
55    pub user_id: Uuid,
56    pub completion_date: DateTime<Utc>,
57    pub completion_registration_attempt_date: Option<DateTime<Utc>>,
58    pub completion_language: String,
59    pub eligible_for_ects: bool,
60    pub email: String,
61    pub grade: Option<i32>,
62    pub passed: bool,
63}
64
65pub async fn insert(
66    conn: &mut PgConnection,
67    pkey_policy: PKeyPolicy<Uuid>,
68    new_course_module_completion: &NewCourseModuleCompletion,
69    completion_granter: CourseModuleCompletionGranter,
70) -> ModelResult<CourseModuleCompletion> {
71    let res = sqlx::query_as!(
72        CourseModuleCompletion,
73        "
74INSERT INTO course_module_completions (
75    id,
76    course_id,
77    course_module_id,
78    user_id,
79    completion_date,
80    completion_registration_attempt_date,
81    completion_language,
82    eligible_for_ects,
83    email,
84    grade,
85    passed,
86    completion_granter_user_id,
87    register_credits_via_suotar
88  )
89VALUES (
90    $1,
91    $2,
92    $3,
93    $4,
94    $5,
95    $6,
96    $7,
97    $8,
98    $9,
99    $10,
100    $11,
101    $12,
102    -- Decided here rather than by the caller: the flag is what keeps the two registration paths
103    -- from both claiming a completion, and a caller that forgot it would hand the row to neither.
104    (
105      SELECT cm.deleted_at IS NULL
106        AND cm.enable_credit_registration_via_suotar
107        AND cm.register_eligible_new_completions_via_suotar
108        AND EXISTS (
109          SELECT 1
110          FROM verified_student_numbers vsn
111          WHERE vsn.user_id = $4
112            AND vsn.deleted_at IS NULL
113        )
114      FROM course_modules cm
115      WHERE cm.id = $3
116    )
117  )
118RETURNING *
119        ",
120        pkey_policy.into_uuid(),
121        new_course_module_completion.course_id,
122        new_course_module_completion.course_module_id,
123        new_course_module_completion.user_id,
124        new_course_module_completion.completion_date,
125        new_course_module_completion.completion_registration_attempt_date,
126        new_course_module_completion.completion_language,
127        new_course_module_completion.eligible_for_ects,
128        new_course_module_completion.email,
129        new_course_module_completion.grade,
130        new_course_module_completion.passed,
131        completion_granter.to_database_field(),
132    )
133    .fetch_one(conn)
134    .await?;
135    Ok(res)
136}
137
138#[derive(Debug, Clone)]
139pub struct NewCourseModuleCompletionSeed {
140    pub course_id: Uuid,
141    pub course_module_id: Uuid,
142    pub user_id: Uuid,
143    pub completion_date: Option<DateTime<Utc>>,
144    pub completion_language: Option<String>,
145    pub eligible_for_ects: Option<bool>,
146    pub email: Option<String>,
147    pub grade: Option<i32>,
148    pub passed: Option<bool>,
149    pub prerequisite_modules_completed: Option<bool>,
150    pub needs_to_be_reviewed: Option<bool>,
151}
152
153pub async fn insert_seed_row(
154    conn: &mut PgConnection,
155    seed: &NewCourseModuleCompletionSeed,
156) -> ModelResult<Uuid> {
157    let res = sqlx::query!(
158        r#"
159        INSERT INTO course_module_completions (
160            course_id,
161            course_module_id,
162            user_id,
163            completion_date,
164            completion_language,
165            eligible_for_ects,
166            email,
167            grade,
168            passed,
169            prerequisite_modules_completed,
170            needs_to_be_reviewed,
171            register_credits_via_suotar
172        )
173        VALUES (
174            $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,
175            (
176              SELECT cm.deleted_at IS NULL
177                AND cm.enable_credit_registration_via_suotar
178                AND cm.register_eligible_new_completions_via_suotar
179                AND EXISTS (
180                  SELECT 1
181                  FROM verified_student_numbers vsn
182                  WHERE vsn.user_id = $3
183                    AND vsn.deleted_at IS NULL
184                )
185              FROM course_modules cm
186              WHERE cm.id = $2
187            )
188        )
189        RETURNING id
190        "#,
191        seed.course_id,
192        seed.course_module_id,
193        seed.user_id,
194        seed.completion_date,
195        seed.completion_language.as_deref(),
196        seed.eligible_for_ects,
197        seed.email.as_deref(),
198        seed.grade,
199        seed.passed,
200        seed.prerequisite_modules_completed,
201        seed.needs_to_be_reviewed,
202    )
203    .fetch_one(conn)
204    .await?;
205
206    Ok(res.id)
207}
208
209pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<CourseModuleCompletion> {
210    let res = sqlx::query_as!(
211        CourseModuleCompletion,
212        r#"
213SELECT *
214FROM course_module_completions
215WHERE id = $1
216  AND deleted_at IS NULL
217        "#,
218        id,
219    )
220    .fetch_one(conn)
221    .await?;
222    Ok(res)
223}
224
225/// 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.
226pub async fn get_by_ids(
227    conn: &mut PgConnection,
228    ids: &[Uuid],
229) -> ModelResult<Vec<CourseModuleCompletion>> {
230    let res = sqlx::query_as!(
231        CourseModuleCompletion,
232        "
233SELECT *
234FROM course_module_completions
235WHERE id = ANY($1)
236        ",
237        ids,
238    )
239    .fetch_all(conn)
240    .await?;
241    Ok(res)
242}
243
244pub async fn get_by_ids_as_map(
245    conn: &mut PgConnection,
246    ids: &[Uuid],
247) -> ModelResult<HashMap<Uuid, CourseModuleCompletion>> {
248    let res = get_by_ids(conn, ids)
249        .await?
250        .into_iter()
251        .map(|x| (x.id, x))
252        .collect();
253    Ok(res)
254}
255
256#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
257
258pub struct CourseModuleCompletionWithRegistrationInfo {
259    /// When the student has attempted to register the completion.
260    pub completion_registration_attempt_date: Option<DateTime<Utc>>,
261    /// ID of the course module.
262    pub course_module_id: Uuid,
263    /// When the record was created
264    pub created_at: DateTime<Utc>,
265    /// Grade that the student received for the completion.
266    pub grade: Option<i32>,
267    /// Whether or not the student is eligible for credit for the completion.
268    pub passed: bool,
269    /// Whether or not the student is qualified for credit based on other modules in the course.
270    pub prerequisite_modules_completed: bool,
271    /// Whether or not the completion has been registered to a study registry.
272    pub registered: bool,
273    /// Whether or not the completion needs to be reviewed by the teacher.
274    pub needs_to_be_reviewed: bool,
275    /// ID of the user for the completion.
276    pub user_id: Uuid,
277    // When the user completed the course
278    pub completion_date: DateTime<Utc>,
279}
280
281/// Gets summaries for all completions on the given course instance.
282pub async fn get_all_with_registration_information_by_course_instance_id(
283    conn: &mut PgConnection,
284    course_instance_id: Uuid,
285    course_id: Uuid,
286) -> ModelResult<Vec<CourseModuleCompletionWithRegistrationInfo>> {
287    let res = sqlx::query_as!(
288        CourseModuleCompletionWithRegistrationInfo,
289        r#"
290SELECT completions.completion_registration_attempt_date,
291  completions.course_module_id,
292  completions.created_at,
293  completions.grade,
294  completions.passed,
295  completions.prerequisite_modules_completed,
296  (registered.id IS NOT NULL) AS "registered!",
297  completions.needs_to_be_reviewed,
298  completions.user_id,
299  completions.completion_date
300FROM course_module_completions completions
301  LEFT JOIN course_module_completion_registered_to_study_registries registered ON (
302    completions.id = registered.course_module_completion_id
303  )
304  JOIN user_course_settings settings ON (
305    completions.user_id = settings.user_id
306    AND settings.current_course_id = completions.course_id
307  )
308WHERE settings.current_course_instance_id = $1
309  AND completions.deleted_at IS NULL
310  AND registered.deleted_at IS NULL
311  AND settings.deleted_at IS NULL
312  AND settings.current_course_id = $2
313        "#,
314        course_instance_id,
315        course_id
316    )
317    .fetch_all(conn)
318    .await?;
319    Ok(res)
320}
321
322/// Gets all module completions for the user on a course. There can be multiple modules
323/// in a single course, so the result is a `Vec`.
324pub async fn get_all_by_course_id_and_user_id(
325    conn: &mut PgConnection,
326    course_id: Uuid,
327    user_id: Uuid,
328) -> ModelResult<Vec<CourseModuleCompletion>> {
329    let res = sqlx::query_as!(
330        CourseModuleCompletion,
331        "
332SELECT *
333FROM course_module_completions
334WHERE course_id = $1
335  AND user_id = $2
336  AND deleted_at IS NULL
337        ",
338        course_id,
339        user_id,
340    )
341    .fetch_all(conn)
342    .await?;
343    Ok(res)
344}
345
346pub async fn get_all_by_user_id(
347    conn: &mut PgConnection,
348    user_id: Uuid,
349) -> ModelResult<Vec<CourseModuleCompletion>> {
350    let res = sqlx::query_as!(
351        CourseModuleCompletion,
352        "
353SELECT *
354FROM course_module_completions
355WHERE user_id = $1
356  AND deleted_at IS NULL
357        ",
358        user_id,
359    )
360    .fetch_all(conn)
361    .await?;
362    Ok(res)
363}
364
365pub async fn get_all_by_user_id_and_course_module_id(
366    conn: &mut PgConnection,
367    user_id: Uuid,
368    course_module_id: Uuid,
369) -> ModelResult<Vec<CourseModuleCompletion>> {
370    let res = sqlx::query_as!(
371        CourseModuleCompletion,
372        "
373SELECT *
374FROM course_module_completions
375WHERE user_id = $1
376  AND course_module_id = $2
377  AND deleted_at IS NULL
378        ",
379        user_id,
380        course_module_id,
381    )
382    .fetch_all(conn)
383    .await?;
384    Ok(res)
385}
386
387pub async fn get_all_by_course_module_and_user_ids(
388    conn: &mut PgConnection,
389    course_module_id: Uuid,
390    user_id: Uuid,
391) -> ModelResult<Vec<CourseModuleCompletion>> {
392    let res = sqlx::query_as!(
393        CourseModuleCompletion,
394        "
395SELECT *
396FROM course_module_completions
397WHERE course_module_id = $1
398  AND user_id = $2
399  AND deleted_at IS NULL
400        ",
401        course_module_id,
402        user_id,
403    )
404    .fetch_all(conn)
405    .await?;
406    Ok(res)
407}
408
409/// Gets latest created completion for the given user on the specified course module.
410pub async fn get_latest_by_course_and_user_ids(
411    conn: &mut PgConnection,
412    course_module_id: Uuid,
413    user_id: Uuid,
414) -> ModelResult<CourseModuleCompletion> {
415    let res = sqlx::query_as!(
416        CourseModuleCompletion,
417        "
418SELECT *
419FROM course_module_completions
420WHERE course_module_id = $1
421  AND user_id = $2
422  AND deleted_at IS NULL
423ORDER BY created_at DESC
424LIMIT 1
425        ",
426        course_module_id,
427        user_id,
428    )
429    .fetch_one(conn)
430    .await?;
431    Ok(res)
432}
433
434pub async fn get_best_completion_by_user_and_course_module_id(
435    conn: &mut PgConnection,
436    user_id: Uuid,
437    course_module_id: Uuid,
438) -> ModelResult<Option<CourseModuleCompletion>> {
439    let completions = sqlx::query_as!(
440        CourseModuleCompletion,
441        r#"
442SELECT *
443FROM course_module_completions
444WHERE user_id = $1
445  AND course_module_id = $2
446  AND deleted_at IS NULL
447        "#,
448        user_id,
449        course_module_id,
450    )
451    .fetch_all(conn)
452    .await?;
453
454    Ok(select_best_completion(completions))
455}
456
457/// Finds the best grade
458pub fn select_best_completion<C: Borrow<CourseModuleCompletion>>(
459    completions: impl IntoIterator<Item = C>,
460) -> Option<C> {
461    // Passed outranks not passed before grades are compared: ranking by grade alone let a failed
462    // graded completion beat a passed pass/fail one, so a failure was reported as the best result.
463    // `created_at` and `id` only break ties, so two equally good completions resolve to the newest
464    // one instead of to whichever order the caller's query happened to return.
465    completions.into_iter().max_by_key(|completion| {
466        let completion = completion.borrow();
467        (
468            completion.passed,
469            completion.grade.unwrap_or(0),
470            completion.created_at,
471            completion.id,
472        )
473    })
474}
475
476/// Which of `ids` the push path will create a credit registration for, now or on its next
477/// materialise tick: those in `credit_registration_eligible_completions`.
478pub async fn get_credit_registration_expected_ids(
479    conn: &mut PgConnection,
480    ids: &[Uuid],
481) -> ModelResult<HashSet<Uuid>> {
482    let res = sqlx::query_scalar!(
483        r#"
484SELECT course_module_completion_id AS "course_module_completion_id!"
485FROM credit_registration_eligible_completions
486WHERE course_module_completion_id = ANY($1)
487        "#,
488        ids,
489    )
490    .fetch_all(conn)
491    .await?;
492    Ok(res.into_iter().collect())
493}
494
495/// The completion that decides which credit registration flow a student gets for one module: one
496/// opted in to `register_credits_via_suotar` outranks the rest, then the newest wins. While the push
497/// path owns a completion, the student must not be sent to the old flow for another.
498///
499/// Pass completions awaiting review too: leaving them out could switch the flow and so reveal the
500/// flag. Not [`select_best_completion`], which picks the result shown to the student.
501pub fn select_registration_completion<C: Borrow<CourseModuleCompletion>>(
502    completions: impl IntoIterator<Item = C>,
503) -> Option<C> {
504    completions.into_iter().max_by_key(|completion| {
505        let completion = completion.borrow();
506        (
507            completion.register_credits_via_suotar,
508            completion.created_at,
509            completion.id,
510        )
511    })
512}
513
514/// [`select_registration_completion`] over the user's completions of the module. Errors with
515/// `RecordNotFound` if there are none.
516pub async fn get_registration_completion_by_user_and_course_module_id(
517    conn: &mut PgConnection,
518    user_id: Uuid,
519    course_module_id: Uuid,
520) -> ModelResult<CourseModuleCompletion> {
521    let completions =
522        get_all_by_course_module_and_user_ids(conn, course_module_id, user_id).await?;
523    select_registration_completion(completions).ok_or_else(missing_model_error(
524        ModelErrorType::RecordNotFound,
525        "The user has no completion for this course module.".to_string(),
526    ))
527}
528
529/// Get the number of students that have completed the course
530pub async fn get_count_of_distinct_completors_by_course_id(
531    conn: &mut PgConnection,
532    course_id: Uuid,
533) -> ModelResult<i64> {
534    let res = sqlx::query!(
535        "
536SELECT COUNT(DISTINCT user_id) as count
537FROM course_module_completions
538WHERE course_id = $1
539  AND deleted_at IS NULL
540",
541        course_id,
542    )
543    .fetch_one(conn)
544    .await?;
545    Ok(res.count.unwrap_or(0))
546}
547
548/// Gets automatically granted course module completion for the given user on the specified course.
549/// This entry is quaranteed to be unique in database by the index
550/// `course_module_automatic_completion_uniqueness`.
551pub async fn get_automatic_completion_by_course_module_course_and_user_ids(
552    conn: &mut PgConnection,
553    course_module_id: Uuid,
554    course_id: Uuid,
555    user_id: Uuid,
556) -> ModelResult<CourseModuleCompletion> {
557    let res = sqlx::query_as!(
558        CourseModuleCompletion,
559        "
560SELECT *
561FROM course_module_completions
562WHERE course_module_id = $1
563  AND course_id = $2
564  AND user_id = $3
565  AND completion_granter_user_id IS NULL
566  AND deleted_at IS NULL
567        ",
568        course_module_id,
569        course_id,
570        user_id,
571    )
572    .fetch_one(conn)
573    .await?;
574    Ok(res)
575}
576
577/// True if the user has at least one non-deleted, teacher-granted (manual) completion in the
578/// course. A manual completion means a teacher vouched for the student, which exempts them from
579/// automatic cheating suspicion for the whole course.
580pub async fn user_has_manual_completion_in_course(
581    conn: &mut PgConnection,
582    user_id: Uuid,
583    course_id: Uuid,
584) -> ModelResult<bool> {
585    let res = sqlx::query!(
586        r#"
587SELECT EXISTS (
588  SELECT 1
589  FROM course_module_completions
590  WHERE user_id = $1
591    AND course_id = $2
592    AND completion_granter_user_id IS NOT NULL
593    AND deleted_at IS NULL
594) AS "exists!"
595        "#,
596        user_id,
597        course_id,
598    )
599    .fetch_one(conn)
600    .await?;
601    Ok(res.exists)
602}
603
604pub async fn update_completion_registration_attempt_date(
605    conn: &mut PgConnection,
606    id: Uuid,
607    completion_registration_attempt_date: DateTime<Utc>,
608) -> ModelResult<bool> {
609    let res = sqlx::query!(
610        "
611UPDATE course_module_completions
612SET completion_registration_attempt_date = $1
613WHERE id = $2
614  AND deleted_at IS NULL
615        ",
616        Some(completion_registration_attempt_date),
617        id,
618    )
619    .execute(conn)
620    .await?;
621    Ok(res.rows_affected() > 0)
622}
623
624/// Rewrites a completion's grade in place, so a test can regrade one.
625///
626/// Exists only for test setup, behind the mock study registry's control surface: a teacher regrade
627/// goes through the manual completion flow, which writes a *new* completion row rather than editing
628/// this one, and there is no product path that edits a completion's grade.
629pub async fn set_grade_for_testing(
630    conn: &mut PgConnection,
631    id: Uuid,
632    grade: Option<i32>,
633    passed: Option<bool>,
634) -> ModelResult<()> {
635    sqlx::query!(
636        "
637UPDATE course_module_completions
638SET grade = $2,
639  passed = COALESCE($3, passed)
640WHERE id = $1
641  AND deleted_at IS NULL
642        ",
643        id,
644        grade,
645        passed,
646    )
647    .execute(conn)
648    .await?;
649    Ok(())
650}
651
652pub async fn update_prerequisite_modules_completed(
653    conn: &mut PgConnection,
654    id: Uuid,
655    prerequisite_modules_completed: bool,
656) -> ModelResult<bool> {
657    let res = sqlx::query!(
658        "
659UPDATE course_module_completions SET prerequisite_modules_completed = $1
660WHERE id = $2 AND deleted_at IS NULL
661    ",
662        prerequisite_modules_completed,
663        id
664    )
665    .execute(conn)
666    .await?;
667    Ok(res.rows_affected() > 0)
668}
669
670pub async fn update_needs_to_be_reviewed(
671    conn: &mut PgConnection,
672    id: Uuid,
673    needs_to_be_reviewed: bool,
674) -> ModelResult<bool> {
675    let res = sqlx::query!(
676        "
677UPDATE course_module_completions SET needs_to_be_reviewed = $1
678WHERE id = $2 AND deleted_at IS NULL
679        ",
680        needs_to_be_reviewed,
681        id
682    )
683    .execute(conn)
684    .await?;
685    Ok(res.rows_affected() > 0)
686}
687
688pub async fn update_needs_to_be_reviewed_by_course_and_user_ids(
689    conn: &mut PgConnection,
690    course_id: Uuid,
691    user_id: Uuid,
692    needs_to_be_reviewed: bool,
693) -> ModelResult<bool> {
694    let res = sqlx::query!(
695        "
696UPDATE course_module_completions SET needs_to_be_reviewed = $1
697WHERE course_id = $2 AND user_id = $3 AND deleted_at IS NULL
698        ",
699        needs_to_be_reviewed,
700        course_id,
701        user_id,
702    )
703    .execute(conn)
704    .await?;
705    Ok(res.rows_affected() > 0)
706}
707
708/// Checks whether the user has any completions for the given course module on the specified
709/// course module.
710pub async fn user_has_completed_course_module(
711    conn: &mut PgConnection,
712    user_id: Uuid,
713    course_module_id: Uuid,
714) -> ModelResult<bool> {
715    let res = get_all_by_course_module_and_user_ids(conn, course_module_id, user_id).await?;
716    Ok(!res.is_empty())
717}
718
719/// Completion in the form that is recognized by authorized third party study registry registrars.
720#[derive(Clone, PartialEq, Deserialize, Serialize)]
721
722pub struct StudyRegistryCompletion {
723    /// The date when the student completed the course. The value of this field is the date that will
724    /// end up in the user's study registry as the completion date. If the completion is created
725    /// automatically, it is the date when the student passed the completion thresholds. If the teacher
726    /// creates these completions manually, the teacher inputs this value. Usually the teacher would in
727    /// this case input the date of the exam.
728    pub completion_date: DateTime<Utc>,
729    /// The language used in the completion of the course.
730    pub completion_language: String,
731    /// Date when the student opened the form to register their credits to the open university.
732    pub completion_registration_attempt_date: Option<DateTime<Utc>>,
733    /// Email at the time of completing the course. Used to match the student to the data that they will
734    /// fill to the open university and it will remain unchanged in the event of email change because
735    /// changing this would break the matching.
736    pub email: String,
737    /// The grade to be passed to the study registry. Uses the sisu format. See the struct documentation for details.
738    pub grade: StudyRegistryGrade,
739    /// ID of the completion.
740    pub id: Uuid,
741    /// User id in courses.mooc.fi for received registered completions.
742    pub user_id: Uuid,
743    /// Tier of the completion. Currently always null. Historically used for example to distinguish between
744    /// intermediate and advanced versions of the Building AI course.
745    pub tier: Option<i32>,
746}
747
748impl From<CourseModuleCompletion> for StudyRegistryCompletion {
749    fn from(completion: CourseModuleCompletion) -> Self {
750        Self {
751            completion_date: completion.completion_date,
752            completion_language: completion.completion_language,
753            completion_registration_attempt_date: completion.completion_registration_attempt_date,
754            email: completion.email,
755            grade: StudyRegistryGrade::new(completion.passed, completion.grade),
756            id: completion.id,
757            user_id: completion.user_id,
758            tier: None,
759        }
760    }
761}
762
763impl StudyRegistryCompletion {
764    pub fn normalize_language_code(&mut self) {
765        match self.completion_language.as_str() {
766            "en" => self.completion_language = "en-GB".to_string(),
767            "fi" => self.completion_language = "fi-FI".to_string(),
768            "sv" => self.completion_language = "sv-SE".to_string(),
769            _ => {}
770        }
771    }
772}
773
774/// Grading object that maps the system grading information to Sisu's grading scales.
775///
776/// Currently only `sis-0-5` and `sis-hyv-hyl` scales are supported in the system.
777///
778/// All grading scales can be found from <https://sis-helsinki-test.funidata.fi/api/graphql> using
779/// the following query:
780///
781/// ```graphql
782/// query {
783///   grade_scales {
784///     id
785///     name {
786///       fi
787///       en
788///       sv
789///     }
790///     grades {
791///       name {
792///         fi
793///         en
794///         sv
795///       }
796///       passed
797///       localId
798///       abbreviation {
799///         fi
800///         en
801///         sv
802///       }
803///     }
804///     abbreviation {
805///       fi
806///       en
807///       sv
808///     }
809///   }
810/// }
811/// ```
812#[derive(Clone, PartialEq, Deserialize, Serialize)]
813
814pub struct StudyRegistryGrade {
815    pub scale: String,
816    pub grade: String,
817}
818
819impl StudyRegistryGrade {
820    pub fn new(passed: bool, grade: Option<i32>) -> Self {
821        match grade {
822            Some(grade) => Self {
823                scale: "sis-0-5".to_string(),
824                grade: grade.to_string(),
825            },
826            None => Self {
827                scale: "sis-hyv-hyl".to_string(),
828                grade: if passed {
829                    "1".to_string()
830                } else {
831                    "0".to_string()
832                },
833            },
834        }
835    }
836}
837/// Streams completions.
838///
839/// If no_completions_registered_by_this_study_registry_registrar is None, then all completions are streamed.
840pub fn stream_by_course_module_id<'a>(
841    conn: &'a mut PgConnection,
842    course_module_ids: &'a [Uuid],
843    no_completions_registered_by_this_study_registry_registrar: &'a Option<StudyRegistryRegistrar>,
844) -> impl Stream<Item = sqlx::Result<StudyRegistryCompletion>> + Send + 'a {
845    // If this is none, we're using a null uuid, which will never match anything. Therefore, no completions will be filtered out.
846    let study_module_registrar_id = no_completions_registered_by_this_study_registry_registrar
847        .clone()
848        .map(|o| o.id)
849        .unwrap_or(Uuid::nil());
850
851    sqlx::query_as!(
852        CourseModuleCompletion,
853        r#"
854SELECT *
855FROM course_module_completions
856WHERE course_module_id = ANY($1)
857  AND prerequisite_modules_completed
858  AND eligible_for_ects IS TRUE
859  -- Completions still awaiting suspected-cheater review are withheld from study-registry
860  -- registration until a teacher dismisses or confirms them.
861  AND needs_to_be_reviewed = FALSE
862  AND deleted_at IS NULL
863  -- Completions on the push path are registered by us; letting the registry pull them too would put
864  -- a second attainment on the student's transcript. Per student and module, not per module: a
865  -- module can be switched on while students with no flagged completion stay the pull path's, and
866  -- a flagged completion takes its siblings along, since any of them registers the same credit.
867  AND NOT EXISTS (
868    SELECT 1
869    FROM course_module_completions sibling
870    WHERE sibling.user_id = course_module_completions.user_id
871      AND sibling.course_module_id = course_module_completions.course_module_id
872      AND sibling.register_credits_via_suotar
873      AND sibling.deleted_at IS NULL
874  )
875  -- Belt and braces behind the flag above: once the push path has sent anything for the student and
876  -- module, it stays out for good, since re-registering would double the attainment on a real
877  -- transcript.
878  AND NOT EXISTS (
879    SELECT 1
880    FROM credit_registrations cr
881    WHERE cr.user_id = course_module_completions.user_id
882      AND cr.course_module_id = course_module_completions.course_module_id
883      AND cr.submitted_at IS NOT NULL
884      AND cr.deleted_at IS NULL
885  )
886  AND id NOT IN (
887    SELECT course_module_completion_id
888    FROM course_module_completion_registered_to_study_registries
889    WHERE course_module_id = ANY($1)
890      AND (
891        study_registry_registrar_id = $2
892        -- Our own rows count as already registered too: what the push path put in the registry is
893        -- in it whichever registrar's export this is.
894        OR study_registry_registrar_id IS NULL
895      )
896      AND deleted_at IS NULL
897  )
898        "#,
899        course_module_ids,
900        study_module_registrar_id,
901    )
902    .map(StudyRegistryCompletion::from)
903    .fetch(conn)
904}
905
906pub async fn delete(conn: &mut PgConnection, id: Uuid) -> ModelResult<()> {
907    sqlx::query!(
908        "
909
910UPDATE course_module_completions
911SET deleted_at = now()
912WHERE id = $1
913AND deleted_at IS NULL
914        ",
915        id,
916    )
917    .execute(conn)
918    .await?;
919    Ok(())
920}
921
922pub async fn find_existing(
923    conn: &mut PgConnection,
924    course_id: Uuid,
925    course_module_id: Uuid,
926    user_id: Uuid,
927) -> ModelResult<Uuid> {
928    let row = sqlx::query!(
929        r#"
930        SELECT id
931        FROM course_module_completions
932        WHERE course_id = $1
933          AND course_module_id = $2
934          AND user_id = $3
935          AND completion_granter_user_id IS NULL
936          AND deleted_at IS NULL
937        "#,
938        course_id,
939        course_module_id,
940        user_id,
941    )
942    .fetch_one(conn)
943    .await?;
944
945    Ok(row.id)
946}