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