Skip to main content

headless_lms_models/library/credit_registration/
materialize.rs

1//! Creating ledger rows for completions that are allowed to be registered. It catches up as well as
2//! keeps up: any completion carrying the push-path flag and still missing a row gets one, stopping
3//! at `pending`, because historical completions belong to students nobody ever asked. Turning a
4//! module on reaches none made before it: `register_credits_via_suotar` is set at creation or by hand.
5
6use 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
14/// How many rows one iteration may create, and so the rate at which a catch-up drains.
15pub const MATERIALIZE_LIMIT: i64 = 500;
16
17/// How many re-attempts one iteration may start. Bounded apart from [`MATERIALIZE_LIMIT`] because
18/// each of these costs a round trip to the study registry for a credit the student already has.
19pub const GRADE_IMPROVEMENT_LIMIT: i64 = 200;
20
21/// Creates a `pending` row and its `created` event for every registrable completion that has none;
22/// returns the count.
23pub 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/// A completion that should have a ledger row and has none.
94#[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    /// No enrolment to hang a ledger row on, which is the one cause `materialize` cannot fix by
108    /// running again.
109    pub missing_enrolment: bool,
110}
111
112/// Completions [`ensure_registration_rows_for_eligible_completions`] should have picked up at least
113/// `min_age_secs` ago and did not.
114///
115/// The materialise statement's own view, minus its enrolment join, which is reported per row
116/// instead: a completion whose enrolment was removed is invisible to materialise and would
117/// otherwise look like a lost row forever. Returns one row over the limit where there are more, so
118/// a caller can say so without a second count.
119pub 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
161/// Starts a new attempt at `ready_to_submit` for every accepted attempt whose completion has since
162/// been graded higher; returns how many were started.
163///
164/// Only a strictly better grade on the same scale qualifies, so a downward correction and a
165/// cross-scale change both do nothing at all. Rows in `submission_uncertain` are deliberately not
166/// candidates: whether their import landed is unknown, and a successor would risk a second
167/// attainment.
168///
169/// The accepted attempt stays the live credit: resolve-enrolments weighs the new one against it
170/// through [`crate::credit_registrations::lock_live_successes_for_same_module`], exactly as it does a
171/// better grade that arrives as a new completion, and marks it for replacement only if the new one
172/// goes out.
173pub 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        // Stamped rather than merely skipped, in both refusals below: the query's pre-filter cannot
242        // tell a regrade from any other touch of the completion, so a candidate left unstamped comes
243        // back on every iteration and eventually fills the batch.
244        let looked_at = candidate.completion_updated_at;
245        let Ok(mapped) = map_grade(GradeSource {
246            passed: candidate.passed,
247            grade: candidate.grade,
248            // No enrolment has been resolved for the next attempt yet, so the scale is the one the
249            // completion itself implies.
250            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(&registered, &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        // Not `pending`: the preconditions were cleared before the first attempt was accepted, the
282        // query above rechecks eligibility, and a student number unlinked since sends the row back
283        // by itself when the submitter finds none.
284        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        // Eligibility for the push path at creation.
342        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}