Skip to main content

headless_lms_models/library/credit_registration/
legacy_mirror.rs

1//! Mirroring our successes into the legacy study-registry ledger. The mirror row makes the
2//! registry's own `?exclude_already_registered=true` skip the completion, keeps the teacher views'
3//! existing `registered` flag working, and gives support the real student number.
4//!
5//! The row's `study_registry_registrar_id` is null, which is what marks it as ours: we registered the
6//! attainment ourselves rather than handing it to a third party holding an API key.
7
8use crate::credit_registrations::{CreditRegistrationState, RegistrationScope};
9use crate::prelude::*;
10
11/// How many rows one iteration may mirror.
12pub const LEGACY_MIRROR_LIMIT: i64 = 500;
13
14/// Writes a legacy ledger row for every successful registration that has none; returns the count.
15pub async fn mirror_successes_to_legacy_ledger(
16    conn: &mut PgConnection,
17    scope: &RegistrationScope,
18    limit: i64,
19) -> ModelResult<i64> {
20    let mirrored = sqlx::query_scalar!(
21        r#"
22WITH unmirrored AS (
23  SELECT cr.course_id,
24    cr.course_module_completion_id,
25    cr.course_module_id,
26    cr.user_id,
27    cr.student_number
28  FROM credit_registrations cr
29  WHERE cr.deleted_at IS NULL
30    AND cr.state = ANY($5::credit_registration_state [])
31    AND cr.student_number IS NOT NULL
32    -- Only the completion's latest successful attempt mirrors, or a regraded completion gets two
33    -- ledger rows and the teacher's completions list shows it twice. A later attempt in flight or
34    -- failed leaves the earlier one the credit.
35    AND NOT EXISTS (
36      SELECT 1
37      FROM credit_registrations later
38      WHERE later.course_module_completion_id = cr.course_module_completion_id
39        AND later.attempt_number > cr.attempt_number
40        AND later.state = ANY($5::credit_registration_state [])
41        AND later.deleted_at IS NULL
42    )
43    AND NOT EXISTS (
44      SELECT 1
45      FROM course_module_completion_registered_to_study_registries r
46      WHERE r.course_module_completion_id = cr.course_module_completion_id
47        AND r.study_registry_registrar_id IS NULL
48        AND r.deleted_at IS NULL
49    )
50    AND ($2::uuid IS NULL OR cr.course_id = $2)
51    AND ($3::uuid IS NULL OR cr.user_id = $3)
52    AND (
53      cardinality($4::uuid []) = 0
54      OR cr.id = ANY($4::uuid [])
55    )
56  ORDER BY cr.terminal_at
57  LIMIT $1
58),
59inserted AS (
60  INSERT INTO course_module_completion_registered_to_study_registries (
61      course_id,
62      course_module_completion_id,
63      course_module_id,
64      user_id,
65      real_student_number
66    )
67  SELECT course_id,
68    course_module_completion_id,
69    course_module_id,
70    user_id,
71    student_number
72  FROM unmirrored
73  -- Matches cmc_registered_to_study_registries_completion_registrar_idx, so a concurrent iteration
74  -- that mirrored the same row first is not an error.
75  ON CONFLICT (course_module_completion_id, study_registry_registrar_id) WHERE deleted_at IS NULL DO NOTHING
76  RETURNING id
77)
78SELECT COUNT(*) AS "mirrored!"
79FROM inserted
80        "#,
81        limit,
82        scope.course_id,
83        scope.user_id,
84        &scope.credit_registration_ids,
85        &CreditRegistrationState::SUCCESS_STATES as &[CreditRegistrationState],
86    )
87    .fetch_one(conn)
88    .await?;
89    Ok(mirrored)
90}
91
92/// One ledger row whose standing in the legacy ledger disagrees with ours.
93#[derive(Debug, Clone, PartialEq)]
94pub struct LegacyLedgerDivergence {
95    pub credit_registration_id: Uuid,
96    pub course_module_completion_id: Uuid,
97    pub user_id: Uuid,
98    pub first_name: Option<String>,
99    pub last_name: Option<String>,
100    pub email: Option<String>,
101    pub course_id: Uuid,
102    pub course_name: String,
103    pub course_module_id: Uuid,
104    pub state: CreditRegistrationState,
105    pub state_entered_at: DateTime<Utc>,
106    /// We registered it and the legacy ledger has no row of ours, so the teacher views and the pull
107    /// stream still call the completion unregistered.
108    pub mirror_missing: bool,
109    /// A registrar took the completion through the pull path while our pipeline had not finished
110    /// with it, which is how the same attainment gets submitted twice.
111    pub registered_by_a_registrar: bool,
112}
113
114/// Ledger rows the legacy ledger contradicts, in either direction.
115///
116/// Not "every completion the pull path registered": `materialize` skips those on purpose, so
117/// listing them would report the coexistence design as a fault. Only rows where one side has moved
118/// and the other has not are here.
119pub async fn get_legacy_ledger_divergences(
120    conn: &mut PgConnection,
121    limit: i64,
122) -> ModelResult<Vec<LegacyLedgerDivergence>> {
123    let res = sqlx::query_as!(
124        LegacyLedgerDivergence,
125        r#"
126SELECT cr.id AS credit_registration_id,
127  cr.course_module_completion_id,
128  cr.user_id,
129  ud.first_name AS "first_name?",
130  ud.last_name AS "last_name?",
131  ud.email AS "email?",
132  cr.course_id,
133  c.name AS course_name,
134  cr.course_module_id,
135  cr.state,
136  cr.state_entered_at,
137  d.mirror_missing AS "mirror_missing!",
138  d.registered_by_a_registrar AS "registered_by_a_registrar!"
139FROM credit_registrations cr
140  JOIN courses c ON c.id = cr.course_id
141  LEFT JOIN user_details ud ON ud.user_id = cr.user_id
142  CROSS JOIN LATERAL (
143    SELECT cr.state = ANY($2::credit_registration_state [])
144      AND cr.student_number IS NOT NULL
145      AND NOT EXISTS (
146        SELECT 1
147        FROM course_module_completion_registered_to_study_registries r
148        WHERE r.course_module_completion_id = cr.course_module_completion_id
149          AND r.study_registry_registrar_id IS NULL
150          AND r.deleted_at IS NULL
151      ) AS mirror_missing,
152      NOT (cr.state = ANY($2::credit_registration_state []))
153      AND EXISTS (
154        SELECT 1
155        FROM course_module_completion_registered_to_study_registries r
156        WHERE r.course_module_completion_id = cr.course_module_completion_id
157          AND r.study_registry_registrar_id IS NOT NULL
158          AND r.deleted_at IS NULL
159      ) AS registered_by_a_registrar
160  ) d
161WHERE cr.superseded_by_id IS NULL
162  AND cr.deleted_at IS NULL
163  AND (
164    d.mirror_missing
165    OR d.registered_by_a_registrar
166  )
167ORDER BY cr.state_entered_at DESC
168LIMIT $1
169        "#,
170        limit,
171        &CreditRegistrationState::SUCCESS_STATES as &[CreditRegistrationState],
172    )
173    .fetch_all(conn)
174    .await?;
175    Ok(res)
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::course_module_completions::{
182        CourseModuleCompletionGranter, NewCourseModuleCompletion,
183    };
184    use crate::credit_registrations::{
185        CreditRegistrationState, NewCreditRegistration, PayloadSnapshot, Transition,
186    };
187    use crate::test_helper::*;
188
189    async fn registered_row(
190        conn: &mut PgConnection,
191        user: Uuid,
192        course: Uuid,
193        instance: Uuid,
194        course_module: Uuid,
195        state: CreditRegistrationState,
196        student_number: &str,
197    ) -> Uuid {
198        let completion = crate::course_module_completions::insert(
199            conn,
200            PKeyPolicy::Generate,
201            &NewCourseModuleCompletion {
202                course_id: course,
203                course_module_id: course_module,
204                user_id: user,
205                completion_date: Utc::now(),
206                completion_registration_attempt_date: None,
207                completion_language: "en".to_string(),
208                eligible_for_ects: true,
209                email: "student@example.com".to_string(),
210                grade: Some(4),
211                passed: true,
212            },
213            CourseModuleCompletionGranter::Automatic,
214        )
215        .await
216        .unwrap();
217        let id = crate::credit_registrations::insert(
218            conn,
219            PKeyPolicy::Generate,
220            &NewCreditRegistration {
221                course_module_completion_id: completion.id,
222                user_id: user,
223                course_id: course,
224                course_module_id: course_module,
225                course_instance_id: instance,
226                attempt_number: 1,
227            },
228            None,
229        )
230        .await
231        .unwrap();
232        crate::credit_registrations::set_payload_snapshot(
233            conn,
234            id,
235            &PayloadSnapshot {
236                student_number: DbSecret::new(student_number),
237                sisu_person_id: Some(DbSecret::new(format!("hy-hlo-{student_number}"))),
238                uh_course_code: "CRS-101".to_string(),
239                selected_enrolment_id: Some("otm-900000101-degree".to_string()),
240                selected_enrolment_kind: Some("degree".to_string()),
241                selected_enrolment_realisation_id: Some("hy-opt-cur-1".to_string()),
242                selected_enrolment_realisation_name: None,
243                attained_at: Utc::now(),
244                attainment_language: "en".to_string(),
245                grade_scale_id: "sis-0-5".to_string(),
246                grade_id: "4".to_string(),
247                credits: 5.0,
248            },
249        )
250        .await
251        .unwrap();
252        crate::credit_registrations::transition(conn, id, &Transition::planted(state))
253            .await
254            .unwrap();
255        id
256    }
257
258    #[tokio::test]
259    async fn every_success_state_is_mirrored_once() {
260        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
261        for (index, state) in [
262            CreditRegistrationState::Registered,
263            CreditRegistrationState::Duplicate,
264            CreditRegistrationState::NotImproved,
265        ]
266        .into_iter()
267        .enumerate()
268        {
269            insert_data!(tx: tx; user: student);
270            registered_row(
271                tx.as_mut(),
272                student,
273                course,
274                instance.id,
275                course_module.id,
276                state,
277                &format!("90000010{index}"),
278            )
279            .await;
280        }
281
282        let scope = RegistrationScope::for_course(course);
283        assert_eq!(
284            mirror_successes_to_legacy_ledger(tx.as_mut(), &scope, LEGACY_MIRROR_LIMIT)
285                .await
286                .unwrap(),
287            3
288        );
289        assert_eq!(
290            mirror_successes_to_legacy_ledger(tx.as_mut(), &scope, LEGACY_MIRROR_LIMIT)
291                .await
292                .unwrap(),
293            0
294        );
295    }
296
297    #[tokio::test]
298    async fn the_mirror_row_carries_the_real_student_number() {
299        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
300        let id = registered_row(
301            tx.as_mut(),
302            user,
303            course,
304            instance.id,
305            course_module.id,
306            CreditRegistrationState::Registered,
307            "900000101",
308        )
309        .await;
310        let registration = crate::credit_registrations::get_by_id(tx.as_mut(), id)
311            .await
312            .unwrap();
313
314        mirror_successes_to_legacy_ledger(
315            tx.as_mut(),
316            &RegistrationScope::for_course(course),
317            LEGACY_MIRROR_LIMIT,
318        )
319        .await
320        .unwrap();
321
322        let mirrored = crate::course_module_completion_registered_to_study_registries::get_platform_registered_row_for_completion(
323            tx.as_mut(),
324            registration.course_module_completion_id,
325        )
326        .await
327        .unwrap()
328        .unwrap();
329        assert_eq!(mirrored.user_id, user);
330        assert_eq!(mirrored.real_student_number, "900000101");
331    }
332
333    #[tokio::test]
334    async fn a_registration_that_has_not_succeeded_is_not_mirrored() {
335        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
336        registered_row(
337            tx.as_mut(),
338            user,
339            course,
340            instance.id,
341            course_module.id,
342            CreditRegistrationState::FailedPermanent,
343            "900000102",
344        )
345        .await;
346        insert_data!(tx: tx; user: cancelled_student);
347        registered_row(
348            tx.as_mut(),
349            cancelled_student,
350            course,
351            instance.id,
352            course_module.id,
353            CreditRegistrationState::Cancelled,
354            "900000103",
355        )
356        .await;
357
358        assert_eq!(
359            mirror_successes_to_legacy_ledger(
360                tx.as_mut(),
361                &RegistrationScope::for_course(course),
362                LEGACY_MIRROR_LIMIT
363            )
364            .await
365            .unwrap(),
366            0
367        );
368    }
369}