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