1use crate::credit_registrations::{
7 BatchMove, CreditRegistrationState, NewCreditRegistration, RegistrationScope, Transition,
8 mark_improvement_checked, transition_batch,
9};
10use crate::prelude::*;
11
12use super::grade_mapping::{GradeComparison, GradeSource, MappedGrade, compare_grades, map_grade};
13
14pub const MATERIALIZE_LIMIT: i64 = 500;
16
17pub const GRADE_IMPROVEMENT_LIMIT: i64 = 200;
20
21pub async fn ensure_registration_rows_for_eligible_completions(
24 conn: &mut PgConnection,
25 scope: &RegistrationScope,
26 limit: i64,
27) -> ModelResult<i64> {
28 let created = sqlx::query_scalar!(
29 r#"
30WITH registrable_completion AS (
31 SELECT uuid_generate_v4() AS id,
32 c.course_module_completion_id,
33 c.user_id,
34 c.course_id,
35 c.course_module_id,
36 enrolment.course_instance_id
37 FROM credit_registration_registrable_completions c
38 -- The completion does not name an instance, and the ledger row must. An inner join, so a
39 -- completion whose enrolment was removed is skipped rather than guessed at.
40 JOIN LATERAL (
41 SELECT cie.course_instance_id
42 FROM course_instance_enrollments cie
43 WHERE cie.user_id = c.user_id
44 AND cie.course_id = c.course_id
45 AND cie.deleted_at IS NULL
46 ORDER BY cie.created_at DESC
47 LIMIT 1
48 ) enrolment ON TRUE
49 WHERE ($2::uuid IS NULL OR c.course_id = $2)
50 AND ($3::uuid IS NULL OR c.user_id = $3)
51 ORDER BY c.created_at
52 LIMIT $1
53),
54inserted AS (
55 INSERT INTO credit_registrations (
56 id,
57 course_module_completion_id,
58 user_id,
59 course_id,
60 course_module_id,
61 course_instance_id
62 )
63 SELECT id,
64 course_module_completion_id,
65 user_id,
66 course_id,
67 course_module_id,
68 course_instance_id
69 FROM registrable_completion ON CONFLICT DO NOTHING
70 RETURNING id
71),
72events AS (
73 INSERT INTO credit_registration_events (credit_registration_id, kind, to_state, message)
74 SELECT id,
75 'created',
76 'pending',
77 'Created for an eligible completion.'
78 FROM inserted
79 RETURNING credit_registration_id
80)
81SELECT COUNT(*) AS "created!"
82FROM events
83 "#,
84 limit,
85 scope.course_id,
86 scope.user_id,
87 )
88 .fetch_one(conn)
89 .await?;
90 Ok(created)
91}
92
93#[derive(Debug, Clone, PartialEq)]
95pub struct UnmaterialisedCompletion {
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 course_module_name: Option<String>,
105 pub completion_date: DateTime<Utc>,
106 pub created_at: DateTime<Utc>,
107 pub missing_enrolment: bool,
110}
111
112pub async fn get_unmaterialised_eligible_completions(
120 conn: &mut PgConnection,
121 min_age_secs: i64,
122 limit: i64,
123) -> ModelResult<Vec<UnmaterialisedCompletion>> {
124 let res = sqlx::query_as!(
125 UnmaterialisedCompletion,
126 r#"
127SELECT rc.course_module_completion_id AS "course_module_completion_id!",
128 rc.user_id AS "user_id!",
129 ud.first_name AS "first_name?",
130 ud.last_name AS "last_name?",
131 ud.email AS "email?",
132 rc.course_id AS "course_id!",
133 c.name AS course_name,
134 rc.course_module_id AS "course_module_id!",
135 cm.name AS course_module_name,
136 rc.completion_date AS "completion_date!",
137 rc.created_at AS "created_at!",
138 NOT EXISTS (
139 SELECT 1
140 FROM course_instance_enrollments cie
141 WHERE cie.user_id = rc.user_id
142 AND cie.course_id = rc.course_id
143 AND cie.deleted_at IS NULL
144 ) AS "missing_enrolment!"
145FROM credit_registration_registrable_completions rc
146 JOIN course_modules cm ON cm.id = rc.course_module_id AND cm.deleted_at IS NULL
147 JOIN courses c ON c.id = rc.course_id AND c.deleted_at IS NULL
148 LEFT JOIN user_details ud ON ud.user_id = rc.user_id
149WHERE rc.created_at < now() - MAKE_INTERVAL(secs => $1::double precision)
150ORDER BY rc.created_at
151LIMIT $2
152 "#,
153 min_age_secs as f64,
154 limit,
155 )
156 .fetch_all(conn)
157 .await?;
158 Ok(res)
159}
160
161pub async fn start_re_attempts_for_improved_grades(
174 conn: &mut PgConnection,
175 scope: &RegistrationScope,
176 limit: i64,
177) -> ModelResult<i64> {
178 let mut tx = conn.begin().await?;
179 let candidates = sqlx::query!(
180 r#"
181SELECT cr.id,
182 cr.attempt_number,
183 cr.course_module_completion_id,
184 cr.user_id,
185 cr.course_id,
186 cr.course_module_id,
187 cr.course_instance_id,
188 cr.grade_scale_id AS "registered_grade_scale_id!",
189 cr.grade_id AS "registered_grade_id!",
190 cmc.passed,
191 cmc.grade,
192 cmc.updated_at AS completion_updated_at
193FROM credit_registrations cr
194 JOIN course_module_completions cmc ON cmc.id = cr.course_module_completion_id
195 -- Membership is the whole eligibility check: the view is the module opt-in and the completion
196 -- being live, passed and ECTS-eligible, and fully_eligible the prerequisites and the review.
197 JOIN credit_registration_eligible_completions e ON e.course_module_completion_id = cr.course_module_completion_id
198 AND e.fully_eligible
199WHERE cr.deleted_at IS NULL
200 AND cr.superseded_by_id IS NULL
201 AND cr.pending_superseded_by_id IS NULL
202 -- The success set only: a row whose outcome we do not know must not gain a successor.
203 AND cr.state = ANY($4::credit_registration_state [])
204 AND cr.grade_scale_id IS NOT NULL
205 AND cr.grade_id IS NOT NULL
206 -- Only the latest attempt: a newer one, even one that stopped short, reads the completion's grade
207 -- afresh whenever it resolves again.
208 AND NOT EXISTS (
209 SELECT 1
210 FROM credit_registrations later
211 WHERE later.course_module_completion_id = cr.course_module_completion_id
212 AND later.attempt_number > cr.attempt_number
213 AND later.deleted_at IS NULL
214 )
215 -- Two halves of one cheap pre-filter. A completion untouched since the attempt was created cannot
216 -- have been regraded after that attempt froze its grade; but one touched for any other reason
217 -- passes that test forever, and only the grade comparison below can tell the two apart, which is
218 -- why the loop stamps the watermark on every candidate it declines. Without the second half the
219 -- limit keeps spending itself on the same rows while a real regrade waits behind them.
220 AND cmc.updated_at > cr.created_at
221 AND (
222 cr.improvement_checked_completion_updated_at IS NULL
223 OR cmc.updated_at > cr.improvement_checked_completion_updated_at
224 )
225 AND ($2::uuid IS NULL OR cr.course_id = $2)
226 AND ($3::uuid IS NULL OR cr.user_id = $3)
227ORDER BY cmc.updated_at FOR
228UPDATE OF cr SKIP LOCKED
229LIMIT $1
230 "#,
231 limit,
232 scope.course_id,
233 scope.user_id,
234 &CreditRegistrationState::SUCCESS_STATES as &[CreditRegistrationState],
235 )
236 .fetch_all(&mut *tx)
237 .await?;
238
239 let mut starts = Vec::new();
240 for candidate in candidates {
241 let looked_at = candidate.completion_updated_at;
245 let Ok(mapped) = map_grade(GradeSource {
246 passed: candidate.passed,
247 grade: candidate.grade,
248 enrolment_grade_scale_id: None,
251 }) else {
252 mark_improvement_checked(&mut tx, candidate.id, looked_at).await?;
253 continue;
254 };
255 let registered = MappedGrade {
256 grade_scale_id: candidate.registered_grade_scale_id,
257 grade_id: candidate.registered_grade_id,
258 };
259 if compare_grades(®istered, &mapped) != GradeComparison::Better {
260 mark_improvement_checked(&mut tx, candidate.id, looked_at).await?;
261 continue;
262 }
263 let next = crate::credit_registrations::insert(
264 &mut tx,
265 PKeyPolicy::Generate,
266 &NewCreditRegistration {
267 course_module_completion_id: candidate.course_module_completion_id,
268 user_id: candidate.user_id,
269 course_id: candidate.course_id,
270 course_module_id: candidate.course_module_id,
271 course_instance_id: candidate.course_instance_id,
272 attempt_number: candidate.attempt_number + 1,
273 },
274 Some(&format!(
275 "The completion's grade rose from {} to {}. The registered attempt stays the credit \
276 until this one is registered in its place.",
277 registered.grade_id, mapped.grade_id
278 )),
279 )
280 .await?;
281 starts.push(BatchMove {
285 id: next,
286 transition: Transition::to(CreditRegistrationState::ReadyToSubmit),
287 });
288 }
289 let started = transition_batch(&mut tx, &starts).await?;
290 tx.commit().await?;
291 Ok(started)
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297 use crate::course_module_completion_registered_to_study_registries::NewCourseModuleCompletionRegisteredToStudyRegistry;
298 use crate::course_module_completions::{
299 CourseModuleCompletionGranter, NewCourseModuleCompletion,
300 };
301 use crate::credit_registrations::CreditRegistrationState;
302 use crate::test_helper::*;
303
304 async fn enable_suotar(
305 conn: &mut PgConnection,
306 course_module: &crate::course_modules::CourseModule,
307 ) {
308 crate::course_modules::update(
309 conn,
310 course_module.id,
311 &crate::course_modules::NewCourseModule::new(
312 course_module.course_id,
313 course_module.name.clone(),
314 course_module.order_number,
315 )
316 .set_enable_credit_registration_via_suotar(true),
317 )
318 .await
319 .unwrap();
320 crate::course_modules::set_register_eligible_new_completions_via_suotar(
321 conn,
322 course_module.id,
323 true,
324 )
325 .await
326 .unwrap();
327 }
328
329 async fn add_completion(
330 conn: &mut PgConnection,
331 course: Uuid,
332 course_module: Uuid,
333 course_instance: Uuid,
334 user: Uuid,
335 passed: bool,
336 eligible_for_ects: bool,
337 ) -> Uuid {
338 crate::course_instance_enrollments::insert(conn, user, course, course_instance)
339 .await
340 .unwrap();
341 if crate::verified_student_numbers::get_by_user_id(conn, user)
343 .await
344 .unwrap()
345 .is_none()
346 {
347 let student_number = format!("{:09}", user.as_u128() % 1_000_000_000);
348 crate::verified_student_numbers::insert(
349 conn,
350 PKeyPolicy::Generate,
351 &crate::verified_student_numbers::NewVerifiedStudentNumber {
352 user_id: user,
353 sisu_person_id: DbSecret::new(format!("hy-hlo-{student_number}")),
354 student_number: DbSecret::new(student_number),
355 first_names: None,
356 last_name: None,
357 verified_via:
358 crate::verified_student_numbers::StudentNumberVerificationMethod::EmailedLink,
359 verified_via_email: Some(DbSecret::new("student@example.com")),
360 linked_by_user_id: None,
361 link_reason: None,
362 verified_from_course_id: None,
363 },
364 )
365 .await
366 .unwrap();
367 }
368 crate::course_module_completions::insert(
369 conn,
370 PKeyPolicy::Generate,
371 &NewCourseModuleCompletion {
372 course_id: course,
373 course_module_id: course_module,
374 user_id: user,
375 completion_date: Utc::now(),
376 completion_registration_attempt_date: None,
377 completion_language: "en".to_string(),
378 eligible_for_ects,
379 email: "student@example.com".to_string(),
380 grade: Some(4),
381 passed,
382 },
383 CourseModuleCompletionGranter::Automatic,
384 )
385 .await
386 .unwrap()
387 .id
388 }
389
390 #[tokio::test]
391 async fn a_completion_on_an_enabled_module_gets_a_row_addressed_by_its_own_id() {
392 insert_data!(:tx, :user, :org, :course, :instance, :course_module);
393 enable_suotar(tx.as_mut(), &course_module).await;
394 add_completion(
395 tx.as_mut(),
396 course,
397 course_module.id,
398 instance.id,
399 user,
400 true,
401 true,
402 )
403 .await;
404
405 let created = ensure_registration_rows_for_eligible_completions(
406 tx.as_mut(),
407 &RegistrationScope::default(),
408 MATERIALIZE_LIMIT,
409 )
410 .await
411 .unwrap();
412 assert_eq!(created, 1);
413
414 let rows = crate::credit_registrations::get_by_course_id(tx.as_mut(), course)
415 .await
416 .unwrap();
417 assert_eq!(rows.len(), 1);
418 assert_eq!(rows[0].state, CreditRegistrationState::Pending);
419
420 let events =
421 crate::credit_registration_events::get_by_registration_id(tx.as_mut(), rows[0].id)
422 .await
423 .unwrap();
424 assert_eq!(events.len(), 1);
425 }
426
427 #[tokio::test]
428 async fn running_twice_creates_nothing_the_second_time() {
429 insert_data!(:tx, :user, :org, :course, :instance, :course_module);
430 enable_suotar(tx.as_mut(), &course_module).await;
431 add_completion(
432 tx.as_mut(),
433 course,
434 course_module.id,
435 instance.id,
436 user,
437 true,
438 true,
439 )
440 .await;
441
442 let scope = RegistrationScope::default();
443 assert_eq!(
444 ensure_registration_rows_for_eligible_completions(
445 tx.as_mut(),
446 &scope,
447 MATERIALIZE_LIMIT
448 )
449 .await
450 .unwrap(),
451 1
452 );
453 assert_eq!(
454 ensure_registration_rows_for_eligible_completions(
455 tx.as_mut(),
456 &scope,
457 MATERIALIZE_LIMIT
458 )
459 .await
460 .unwrap(),
461 0
462 );
463 }
464
465 #[tokio::test]
466 async fn a_module_that_was_never_opted_in_materialises_nothing() {
467 insert_data!(:tx, :user, :org, :course, :instance, :course_module);
468 add_completion(
469 tx.as_mut(),
470 course,
471 course_module.id,
472 instance.id,
473 user,
474 true,
475 true,
476 )
477 .await;
478 assert_eq!(
479 ensure_registration_rows_for_eligible_completions(
480 tx.as_mut(),
481 &RegistrationScope::default(),
482 MATERIALIZE_LIMIT
483 )
484 .await
485 .unwrap(),
486 0
487 );
488 }
489
490 #[tokio::test]
491 async fn a_failed_or_ects_ineligible_completion_never_gets_a_row() {
492 insert_data!(:tx, :user, :org, :course, :instance, :course_module);
493 enable_suotar(tx.as_mut(), &course_module).await;
494 add_completion(
495 tx.as_mut(),
496 course,
497 course_module.id,
498 instance.id,
499 user,
500 false,
501 true,
502 )
503 .await;
504
505 insert_data!(tx: tx; user: other_user);
506 add_completion(
507 tx.as_mut(),
508 course,
509 course_module.id,
510 instance.id,
511 other_user,
512 true,
513 false,
514 )
515 .await;
516
517 assert_eq!(
518 ensure_registration_rows_for_eligible_completions(
519 tx.as_mut(),
520 &RegistrationScope::default(),
521 MATERIALIZE_LIMIT
522 )
523 .await
524 .unwrap(),
525 0
526 );
527 }
528
529 #[tokio::test]
530 async fn a_completion_the_pull_path_already_registered_is_skipped() {
531 insert_data!(:tx, :user, :org, :course, :instance, :course_module);
532 enable_suotar(tx.as_mut(), &course_module).await;
533 let completion = add_completion(
534 tx.as_mut(),
535 course,
536 course_module.id,
537 instance.id,
538 user,
539 true,
540 true,
541 )
542 .await;
543 let registrar = crate::study_registry_registrars::insert(
544 tx.as_mut(),
545 PKeyPolicy::Generate,
546 "Test registrar",
547 "test-registrar-secret-key",
548 )
549 .await
550 .unwrap();
551 crate::course_module_completion_registered_to_study_registries::insert(
552 tx.as_mut(),
553 PKeyPolicy::Generate,
554 &NewCourseModuleCompletionRegisteredToStudyRegistry {
555 course_id: course,
556 course_module_completion_id: completion,
557 course_module_id: course_module.id,
558 study_registry_registrar_id: registrar,
559 user_id: user,
560 real_student_number: "012345678".to_string(),
561 },
562 )
563 .await
564 .unwrap();
565
566 assert_eq!(
567 ensure_registration_rows_for_eligible_completions(
568 tx.as_mut(),
569 &RegistrationScope::default(),
570 MATERIALIZE_LIMIT
571 )
572 .await
573 .unwrap(),
574 0
575 );
576 }
577
578 #[tokio::test]
579 async fn a_scoped_run_leaves_another_courses_completions_alone() {
580 insert_data!(:tx, :user, :org, :course, :instance, :course_module);
581 enable_suotar(tx.as_mut(), &course_module).await;
582 add_completion(
583 tx.as_mut(),
584 course,
585 course_module.id,
586 instance.id,
587 user,
588 true,
589 true,
590 )
591 .await;
592
593 let elsewhere = Uuid::new_v4();
594 assert_eq!(
595 ensure_registration_rows_for_eligible_completions(
596 tx.as_mut(),
597 &RegistrationScope::for_course(elsewhere),
598 MATERIALIZE_LIMIT
599 )
600 .await
601 .unwrap(),
602 0
603 );
604 assert_eq!(
605 ensure_registration_rows_for_eligible_completions(
606 tx.as_mut(),
607 &RegistrationScope::for_course(course),
608 MATERIALIZE_LIMIT
609 )
610 .await
611 .unwrap(),
612 1
613 );
614 }
615
616 #[tokio::test]
617 async fn the_limit_bounds_one_iteration() {
618 insert_data!(:tx, :user, :org, :course, :instance, :course_module);
619 enable_suotar(tx.as_mut(), &course_module).await;
620 add_completion(
621 tx.as_mut(),
622 course,
623 course_module.id,
624 instance.id,
625 user,
626 true,
627 true,
628 )
629 .await;
630 insert_data!(tx: tx; user: second_user);
631 add_completion(
632 tx.as_mut(),
633 course,
634 course_module.id,
635 instance.id,
636 second_user,
637 true,
638 true,
639 )
640 .await;
641
642 let scope = RegistrationScope::default();
643 assert_eq!(
644 ensure_registration_rows_for_eligible_completions(tx.as_mut(), &scope, 1)
645 .await
646 .unwrap(),
647 1
648 );
649 assert_eq!(
650 ensure_registration_rows_for_eligible_completions(tx.as_mut(), &scope, 1)
651 .await
652 .unwrap(),
653 1
654 );
655 }
656}