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 -- 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#[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 pub mirror_missing: bool,
109 pub registered_by_a_registrar: bool,
112}
113
114pub 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}