1use crate::credit_registrations::{CreditRegistrationState, RegistrationScope};
9use crate::prelude::*;
10
11pub const LEGACY_MIRROR_LIMIT: i64 = 500;
13
14pub 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#[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 pub mirror_missing: bool,
101 pub registered_by_a_registrar: bool,
104}
105
106pub 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}