Skip to main content

headless_lms_models/library/credit_registration/
materialize.rs

1//! Creating ledger rows for completions that are allowed to be registered. The same statement is
2//! the backfill: flipping a module on makes every pre-existing eligible completion match, and they
3//! stop at `pending`, because historical completions belong to students nobody ever asked.
4
5use crate::credit_registrations::{
6    BatchMove, CreditRegistrationState, NewCreditRegistration, RegistrationScope, Transition,
7    mark_improvement_checked, mark_superseded, transition_batch,
8};
9use crate::prelude::*;
10
11use super::grade_mapping::{GradeComparison, GradeSource, compare_grades, map_grade};
12
13/// How many rows one iteration may create. Also the backfill's rate limit.
14pub const MATERIALIZE_LIMIT: i64 = 500;
15
16/// How many re-attempts one iteration may start. Bounded apart from [`MATERIALIZE_LIMIT`] because
17/// each of these costs a round trip to the study registry for a credit the student already has.
18pub const GRADE_IMPROVEMENT_LIMIT: i64 = 200;
19
20/// Creates a `pending` row and its `created` event for every registrable completion that has none;
21/// returns the count.
22pub async fn ensure_registration_rows_for_eligible_completions(
23    conn: &mut PgConnection,
24    scope: &RegistrationScope,
25    limit: i64,
26) -> ModelResult<i64> {
27    // The ids are generated in the CTE so request_item_id stays derivable from the row id in both
28    // directions: it is the only handle Suotar's log and ours share on one registration.
29    let created = sqlx::query_scalar!(
30        r#"
31WITH registrable_completion AS (
32  SELECT uuid_generate_v4() AS id,
33    c.course_module_completion_id,
34    c.user_id,
35    c.course_id,
36    c.course_module_id,
37    enrolment.course_instance_id
38  FROM credit_registration_registrable_completions c
39    -- The completion does not name an instance, and the ledger row must. An inner join, so a
40    -- completion whose enrolment was removed is skipped rather than guessed at.
41    JOIN LATERAL (
42      SELECT cie.course_instance_id
43      FROM course_instance_enrollments cie
44      WHERE cie.user_id = c.user_id
45        AND cie.course_id = c.course_id
46        AND cie.deleted_at IS NULL
47      ORDER BY cie.created_at DESC
48      LIMIT 1
49    ) enrolment ON TRUE
50  WHERE ($2::uuid IS NULL OR c.course_id = $2)
51    AND ($3::uuid IS NULL OR c.user_id = $3)
52  ORDER BY c.created_at
53  LIMIT $1
54),
55inserted AS (
56  INSERT INTO credit_registrations (
57      id,
58      course_module_completion_id,
59      user_id,
60      course_id,
61      course_module_id,
62      course_instance_id,
63      request_item_id
64    )
65  SELECT id,
66    course_module_completion_id,
67    user_id,
68    course_id,
69    course_module_id,
70    course_instance_id,
71    'cr-' || id
72  FROM registrable_completion ON CONFLICT DO NOTHING
73  RETURNING id
74),
75events AS (
76  INSERT INTO credit_registration_events (credit_registration_id, kind, to_state, message)
77  SELECT id,
78    'created',
79    'pending',
80    'Created for an eligible completion.'
81  FROM inserted
82  RETURNING credit_registration_id
83)
84SELECT COUNT(*) AS "created!"
85FROM events
86        "#,
87        limit,
88        scope.course_id,
89        scope.user_id,
90    )
91    .fetch_one(conn)
92    .await?;
93    Ok(created)
94}
95
96/// A completion that should have a ledger row and has none.
97#[derive(Debug, Clone, PartialEq)]
98pub struct UnmaterialisedCompletion {
99    pub course_module_completion_id: Uuid,
100    pub user_id: Uuid,
101    pub first_name: Option<String>,
102    pub last_name: Option<String>,
103    pub email: Option<String>,
104    pub course_id: Uuid,
105    pub course_name: String,
106    pub course_module_id: Uuid,
107    pub course_module_name: Option<String>,
108    pub completion_date: DateTime<Utc>,
109    pub created_at: DateTime<Utc>,
110    /// No enrolment to hang a ledger row on, which is the one cause `materialize` cannot fix by
111    /// running again.
112    pub missing_enrolment: bool,
113}
114
115/// Completions [`ensure_registration_rows_for_eligible_completions`] should have picked up at least
116/// `min_age_secs` ago and did not.
117///
118/// The materialise statement's own view, minus its enrolment join, which is reported per row
119/// instead: a completion whose enrolment was removed is invisible to materialise and would
120/// otherwise look like a lost row forever. Returns one row over the limit where there are more, so
121/// a caller can say so without a second count.
122pub async fn get_unmaterialised_eligible_completions(
123    conn: &mut PgConnection,
124    min_age_secs: i64,
125    limit: i64,
126) -> ModelResult<Vec<UnmaterialisedCompletion>> {
127    let res = sqlx::query_as!(
128        UnmaterialisedCompletion,
129        r#"
130SELECT rc.course_module_completion_id AS "course_module_completion_id!",
131  rc.user_id AS "user_id!",
132  ud.first_name AS "first_name?",
133  ud.last_name AS "last_name?",
134  ud.email AS "email?",
135  rc.course_id AS "course_id!",
136  c.name AS course_name,
137  rc.course_module_id AS "course_module_id!",
138  cm.name AS course_module_name,
139  rc.completion_date AS "completion_date!",
140  rc.created_at AS "created_at!",
141  NOT EXISTS (
142    SELECT 1
143    FROM course_instance_enrollments cie
144    WHERE cie.user_id = rc.user_id
145      AND cie.course_id = rc.course_id
146      AND cie.deleted_at IS NULL
147  ) AS "missing_enrolment!"
148FROM credit_registration_registrable_completions rc
149  JOIN course_modules cm ON cm.id = rc.course_module_id
150  JOIN courses c ON c.id = rc.course_id
151  LEFT JOIN user_details ud ON ud.user_id = rc.user_id
152WHERE rc.created_at < now() - MAKE_INTERVAL(secs => $1::double precision)
153ORDER BY rc.created_at
154LIMIT $2
155        "#,
156        min_age_secs as f64,
157        limit,
158    )
159    .fetch_all(conn)
160    .await?;
161    Ok(res)
162}
163
164/// Supersedes accepted attempts whose completion has since been graded higher, and starts the next
165/// attempt at `ready_to_submit`; returns how many were started.
166///
167/// Only a strictly better grade on the same scale qualifies, so a downward correction and a
168/// cross-scale change both do nothing at all. Rows in `submission_uncertain` are deliberately not
169/// candidates: whether their import landed is unknown, and a successor would risk a second
170/// attainment. The new attempt is an ordinary `ready_to_submit` row from here on.
171pub async fn start_re_attempts_for_improved_grades(
172    conn: &mut PgConnection,
173    scope: &RegistrationScope,
174    limit: i64,
175) -> ModelResult<i64> {
176    let mut tx = conn.begin().await?;
177    let candidates = sqlx::query!(
178        r#"
179SELECT cr.id,
180  cr.attempt_number,
181  cr.course_module_completion_id,
182  cr.user_id,
183  cr.course_id,
184  cr.course_module_id,
185  cr.course_instance_id,
186  cr.grade_scale_id AS "registered_grade_scale_id!",
187  cr.grade_id AS "registered_grade_id!",
188  cmc.passed,
189  cmc.grade,
190  cmc.updated_at AS completion_updated_at,
191  conf.grade_scale_id AS "configured_grade_scale_id?"
192FROM credit_registrations cr
193  JOIN course_module_completions cmc ON cmc.id = cr.course_module_completion_id
194  -- Membership is the whole eligibility check: the view is the module opt-in and the completion
195  -- being live, passed and ECTS-eligible, and fully_eligible the prerequisites and the review.
196  JOIN credit_registration_eligible_completions e ON e.course_module_completion_id = cr.course_module_completion_id
197  AND e.fully_eligible
198  LEFT JOIN course_module_suotar_configurations conf ON conf.course_module_id = cr.course_module_id
199  AND conf.deleted_at IS NULL
200WHERE cr.deleted_at IS NULL
201  AND cr.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  -- Two halves of one cheap pre-filter. A completion untouched since the attempt was created cannot
207  -- have been regraded after that attempt froze its grade; but one touched for any other reason
208  -- passes that test forever, and only the grade comparison below can tell the two apart, which is
209  -- why the loop stamps the watermark on every candidate it declines. Without the second half the
210  -- limit keeps spending itself on the same rows while a real regrade waits behind them.
211  AND cmc.updated_at > cr.created_at
212  AND (
213    cr.improvement_checked_completion_updated_at IS NULL
214    OR cmc.updated_at > cr.improvement_checked_completion_updated_at
215  )
216  AND ($2::uuid IS NULL OR cr.course_id = $2)
217  AND ($3::uuid IS NULL OR cr.user_id = $3)
218ORDER BY cmc.updated_at FOR
219UPDATE OF cr SKIP LOCKED
220LIMIT $1
221        "#,
222        limit,
223        scope.course_id,
224        scope.user_id,
225        &CreditRegistrationState::SUCCESS_STATES as &[CreditRegistrationState],
226    )
227    .fetch_all(&mut *tx)
228    .await?;
229
230    let mut starts = Vec::new();
231    for candidate in candidates {
232        // Stamped rather than merely skipped, in both refusals below: the query's pre-filter cannot
233        // tell a regrade from any other touch of the completion, so a candidate left unstamped comes
234        // back on every iteration and eventually fills the batch.
235        let looked_at = candidate.completion_updated_at;
236        let Ok(mapped) = map_grade(GradeSource {
237            passed: candidate.passed,
238            grade: candidate.grade,
239            configured_grade_scale_id: candidate.configured_grade_scale_id.as_deref(),
240            // No enrolment has been resolved for the next attempt yet, so the scale is the module's
241            // override or the one the completion itself implies.
242            enrolment_grade_scale_id: None,
243        }) else {
244            mark_improvement_checked(&mut tx, candidate.id, looked_at).await?;
245            continue;
246        };
247        if compare_grades(
248            &candidate.registered_grade_scale_id,
249            &candidate.registered_grade_id,
250            &mapped,
251        ) != GradeComparison::Better
252        {
253            mark_improvement_checked(&mut tx, candidate.id, looked_at).await?;
254            continue;
255        }
256        // `uq_credit_registrations_completion` allows one live attempt per completion, so the old
257        // one has to point away before the successor is inserted. The successor's id is allocated
258        // here rather than by the database because of that order; the deferred foreign key is what
259        // lets the pointer name a row this transaction has not written yet.
260        let next = Uuid::new_v4();
261        mark_superseded(&mut tx, candidate.id, next).await?;
262        crate::credit_registrations::insert(
263            &mut tx,
264            PKeyPolicy::Fixed(next),
265            &NewCreditRegistration {
266                course_module_completion_id: candidate.course_module_completion_id,
267                user_id: candidate.user_id,
268                course_id: candidate.course_id,
269                course_module_id: candidate.course_module_id,
270                course_instance_id: candidate.course_instance_id,
271                attempt_number: candidate.attempt_number + 1,
272            },
273            Some(&format!(
274                "The completion's grade rose from {} to {}, so the registered attempt was \
275                 superseded.",
276                candidate.registered_grade_id, mapped.grade_id
277            )),
278        )
279        .await?;
280        // Not `pending`: the preconditions were cleared before the first attempt was accepted, the
281        // query above rechecks eligibility, and a student number unlinked since sends the row back
282        // by itself when the submitter finds none.
283        starts.push(BatchMove {
284            id: next,
285            transition: Transition::to(CreditRegistrationState::ReadyToSubmit),
286        });
287    }
288    let started = transition_batch(&mut tx, &starts).await?;
289    tx.commit().await?;
290    Ok(started)
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use crate::course_module_completion_registered_to_study_registries::NewCourseModuleCompletionRegisteredToStudyRegistry;
297    use crate::course_module_completions::{
298        CourseModuleCompletionGranter, NewCourseModuleCompletion,
299    };
300    use crate::credit_registrations::{CreditRegistrationState, import_request_item_id};
301    use crate::test_helper::*;
302
303    async fn enable_suotar(
304        conn: &mut PgConnection,
305        course_module: &crate::course_modules::CourseModule,
306    ) {
307        crate::course_modules::update(
308            conn,
309            course_module.id,
310            &crate::course_modules::NewCourseModule::new(
311                course_module.course_id,
312                course_module.name.clone(),
313                course_module.order_number,
314            )
315            .set_enable_credit_registration_via_suotar(true),
316        )
317        .await
318        .unwrap();
319    }
320
321    async fn add_completion(
322        conn: &mut PgConnection,
323        course: Uuid,
324        course_module: Uuid,
325        course_instance: Uuid,
326        user: Uuid,
327        passed: bool,
328        eligible_for_ects: bool,
329    ) -> Uuid {
330        crate::course_instance_enrollments::insert(conn, user, course, course_instance)
331            .await
332            .unwrap();
333        crate::course_module_completions::insert(
334            conn,
335            PKeyPolicy::Generate,
336            &NewCourseModuleCompletion {
337                course_id: course,
338                course_module_id: course_module,
339                user_id: user,
340                completion_date: Utc::now(),
341                completion_registration_attempt_date: None,
342                completion_language: "en".to_string(),
343                eligible_for_ects,
344                email: "student@example.com".to_string(),
345                grade: Some(4),
346                passed,
347            },
348            CourseModuleCompletionGranter::Automatic,
349        )
350        .await
351        .unwrap()
352        .id
353    }
354
355    #[tokio::test]
356    async fn a_completion_on_an_enabled_module_gets_a_row_addressed_by_its_own_id() {
357        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
358        enable_suotar(tx.as_mut(), &course_module).await;
359        add_completion(
360            tx.as_mut(),
361            course,
362            course_module.id,
363            instance.id,
364            user,
365            true,
366            true,
367        )
368        .await;
369
370        let created = ensure_registration_rows_for_eligible_completions(
371            tx.as_mut(),
372            &RegistrationScope::default(),
373            MATERIALIZE_LIMIT,
374        )
375        .await
376        .unwrap();
377        assert_eq!(created, 1);
378
379        let rows = crate::credit_registrations::get_by_course_id(tx.as_mut(), course)
380            .await
381            .unwrap();
382        assert_eq!(rows.len(), 1);
383        assert_eq!(rows[0].state, CreditRegistrationState::Pending);
384        assert_eq!(rows[0].request_item_id, import_request_item_id(rows[0].id));
385
386        let events =
387            crate::credit_registration_events::get_by_registration_id(tx.as_mut(), rows[0].id)
388                .await
389                .unwrap();
390        assert_eq!(events.len(), 1);
391    }
392
393    #[tokio::test]
394    async fn running_twice_creates_nothing_the_second_time() {
395        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
396        enable_suotar(tx.as_mut(), &course_module).await;
397        add_completion(
398            tx.as_mut(),
399            course,
400            course_module.id,
401            instance.id,
402            user,
403            true,
404            true,
405        )
406        .await;
407
408        let scope = RegistrationScope::default();
409        assert_eq!(
410            ensure_registration_rows_for_eligible_completions(
411                tx.as_mut(),
412                &scope,
413                MATERIALIZE_LIMIT
414            )
415            .await
416            .unwrap(),
417            1
418        );
419        assert_eq!(
420            ensure_registration_rows_for_eligible_completions(
421                tx.as_mut(),
422                &scope,
423                MATERIALIZE_LIMIT
424            )
425            .await
426            .unwrap(),
427            0
428        );
429    }
430
431    #[tokio::test]
432    async fn a_module_that_was_never_opted_in_materialises_nothing() {
433        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
434        add_completion(
435            tx.as_mut(),
436            course,
437            course_module.id,
438            instance.id,
439            user,
440            true,
441            true,
442        )
443        .await;
444        assert_eq!(
445            ensure_registration_rows_for_eligible_completions(
446                tx.as_mut(),
447                &RegistrationScope::default(),
448                MATERIALIZE_LIMIT
449            )
450            .await
451            .unwrap(),
452            0
453        );
454    }
455
456    #[tokio::test]
457    async fn a_failed_or_ects_ineligible_completion_never_gets_a_row() {
458        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
459        enable_suotar(tx.as_mut(), &course_module).await;
460        add_completion(
461            tx.as_mut(),
462            course,
463            course_module.id,
464            instance.id,
465            user,
466            false,
467            true,
468        )
469        .await;
470
471        insert_data!(tx: tx; user: other_user);
472        add_completion(
473            tx.as_mut(),
474            course,
475            course_module.id,
476            instance.id,
477            other_user,
478            true,
479            false,
480        )
481        .await;
482
483        assert_eq!(
484            ensure_registration_rows_for_eligible_completions(
485                tx.as_mut(),
486                &RegistrationScope::default(),
487                MATERIALIZE_LIMIT
488            )
489            .await
490            .unwrap(),
491            0
492        );
493    }
494
495    #[tokio::test]
496    async fn a_completion_the_pull_path_already_registered_is_skipped() {
497        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
498        enable_suotar(tx.as_mut(), &course_module).await;
499        let completion = add_completion(
500            tx.as_mut(),
501            course,
502            course_module.id,
503            instance.id,
504            user,
505            true,
506            true,
507        )
508        .await;
509        let registrar = crate::study_registry_registrars::insert(
510            tx.as_mut(),
511            PKeyPolicy::Generate,
512            "Test registrar",
513            "test-registrar-secret-key",
514        )
515        .await
516        .unwrap();
517        crate::course_module_completion_registered_to_study_registries::insert(
518            tx.as_mut(),
519            PKeyPolicy::Generate,
520            &NewCourseModuleCompletionRegisteredToStudyRegistry {
521                course_id: course,
522                course_module_completion_id: completion,
523                course_module_id: course_module.id,
524                study_registry_registrar_id: registrar,
525                user_id: user,
526                real_student_number: "012345678".to_string(),
527            },
528        )
529        .await
530        .unwrap();
531
532        assert_eq!(
533            ensure_registration_rows_for_eligible_completions(
534                tx.as_mut(),
535                &RegistrationScope::default(),
536                MATERIALIZE_LIMIT
537            )
538            .await
539            .unwrap(),
540            0
541        );
542    }
543
544    #[tokio::test]
545    async fn a_scoped_run_leaves_another_courses_completions_alone() {
546        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
547        enable_suotar(tx.as_mut(), &course_module).await;
548        add_completion(
549            tx.as_mut(),
550            course,
551            course_module.id,
552            instance.id,
553            user,
554            true,
555            true,
556        )
557        .await;
558
559        let elsewhere = Uuid::new_v4();
560        assert_eq!(
561            ensure_registration_rows_for_eligible_completions(
562                tx.as_mut(),
563                &RegistrationScope::for_course(elsewhere),
564                MATERIALIZE_LIMIT
565            )
566            .await
567            .unwrap(),
568            0
569        );
570        assert_eq!(
571            ensure_registration_rows_for_eligible_completions(
572                tx.as_mut(),
573                &RegistrationScope::for_course(course),
574                MATERIALIZE_LIMIT
575            )
576            .await
577            .unwrap(),
578            1
579        );
580    }
581
582    #[tokio::test]
583    async fn the_limit_bounds_one_iteration() {
584        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
585        enable_suotar(tx.as_mut(), &course_module).await;
586        add_completion(
587            tx.as_mut(),
588            course,
589            course_module.id,
590            instance.id,
591            user,
592            true,
593            true,
594        )
595        .await;
596        insert_data!(tx: tx; user: second_user);
597        add_completion(
598            tx.as_mut(),
599            course,
600            course_module.id,
601            instance.id,
602            second_user,
603            true,
604            true,
605        )
606        .await;
607
608        let scope = RegistrationScope::default();
609        assert_eq!(
610            ensure_registration_rows_for_eligible_completions(tx.as_mut(), &scope, 1)
611                .await
612                .unwrap(),
613            1
614        );
615        assert_eq!(
616            ensure_registration_rows_for_eligible_completions(tx.as_mut(), &scope, 1)
617                .await
618                .unwrap(),
619            1
620        );
621    }
622}