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. Completions
4//! from before the push path never carry the flag, so they are never caught up.
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 course code 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 give_course_code(
305        conn: &mut PgConnection,
306        course_module: &crate::course_modules::CourseModule,
307    ) {
308        crate::course_modules::update_uh_course_code(
309            conn,
310            course_module.id,
311            Some("TKT10001".to_string()),
312        )
313        .await
314        .unwrap();
315    }
316
317    async fn add_completion(
318        conn: &mut PgConnection,
319        course: Uuid,
320        course_module: Uuid,
321        course_instance: Uuid,
322        user: Uuid,
323        passed: bool,
324        eligible_for_ects: bool,
325    ) -> Uuid {
326        crate::course_instance_enrollments::insert(conn, user, course, course_instance)
327            .await
328            .unwrap();
329        crate::course_module_completions::insert(
330            conn,
331            PKeyPolicy::Generate,
332            &NewCourseModuleCompletion {
333                course_id: course,
334                course_module_id: course_module,
335                user_id: user,
336                completion_date: Utc::now(),
337                completion_registration_attempt_date: None,
338                completion_language: "en".to_string(),
339                eligible_for_ects,
340                email: "student@example.com".to_string(),
341                grade: Some(4),
342                passed,
343            },
344            CourseModuleCompletionGranter::Automatic,
345        )
346        .await
347        .unwrap()
348        .id
349    }
350
351    #[tokio::test]
352    async fn a_completion_on_a_module_with_a_course_code_gets_a_row_addressed_by_its_own_id() {
353        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
354        give_course_code(tx.as_mut(), &course_module).await;
355        add_completion(
356            tx.as_mut(),
357            course,
358            course_module.id,
359            instance.id,
360            user,
361            true,
362            true,
363        )
364        .await;
365
366        let created = ensure_registration_rows_for_eligible_completions(
367            tx.as_mut(),
368            &RegistrationScope::default(),
369            MATERIALIZE_LIMIT,
370        )
371        .await
372        .unwrap();
373        assert_eq!(created, 1);
374
375        let rows = crate::credit_registrations::get_by_course_id(tx.as_mut(), course)
376            .await
377            .unwrap();
378        assert_eq!(rows.len(), 1);
379        assert_eq!(rows[0].state, CreditRegistrationState::Pending);
380
381        let events =
382            crate::credit_registration_events::get_by_registration_id(tx.as_mut(), rows[0].id)
383                .await
384                .unwrap();
385        assert_eq!(events.len(), 1);
386    }
387
388    #[tokio::test]
389    async fn running_twice_creates_nothing_the_second_time() {
390        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
391        give_course_code(tx.as_mut(), &course_module).await;
392        add_completion(
393            tx.as_mut(),
394            course,
395            course_module.id,
396            instance.id,
397            user,
398            true,
399            true,
400        )
401        .await;
402
403        let scope = RegistrationScope::default();
404        assert_eq!(
405            ensure_registration_rows_for_eligible_completions(
406                tx.as_mut(),
407                &scope,
408                MATERIALIZE_LIMIT
409            )
410            .await
411            .unwrap(),
412            1
413        );
414        assert_eq!(
415            ensure_registration_rows_for_eligible_completions(
416                tx.as_mut(),
417                &scope,
418                MATERIALIZE_LIMIT
419            )
420            .await
421            .unwrap(),
422            0
423        );
424    }
425
426    #[tokio::test]
427    async fn a_module_without_a_course_code_materialises_nothing() {
428        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
429        add_completion(
430            tx.as_mut(),
431            course,
432            course_module.id,
433            instance.id,
434            user,
435            true,
436            true,
437        )
438        .await;
439        assert_eq!(
440            ensure_registration_rows_for_eligible_completions(
441                tx.as_mut(),
442                &RegistrationScope::default(),
443                MATERIALIZE_LIMIT
444            )
445            .await
446            .unwrap(),
447            0
448        );
449    }
450
451    #[tokio::test]
452    async fn a_failed_or_ects_ineligible_completion_never_gets_a_row() {
453        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
454        give_course_code(tx.as_mut(), &course_module).await;
455        add_completion(
456            tx.as_mut(),
457            course,
458            course_module.id,
459            instance.id,
460            user,
461            false,
462            true,
463        )
464        .await;
465
466        insert_data!(tx: tx; user: other_user);
467        add_completion(
468            tx.as_mut(),
469            course,
470            course_module.id,
471            instance.id,
472            other_user,
473            true,
474            false,
475        )
476        .await;
477
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_completion_the_pull_path_already_registered_is_skipped() {
492        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
493        give_course_code(tx.as_mut(), &course_module).await;
494        let completion = add_completion(
495            tx.as_mut(),
496            course,
497            course_module.id,
498            instance.id,
499            user,
500            true,
501            true,
502        )
503        .await;
504        let registrar = crate::study_registry_registrars::insert(
505            tx.as_mut(),
506            PKeyPolicy::Generate,
507            "Test registrar",
508            "test-registrar-secret-key",
509        )
510        .await
511        .unwrap();
512        crate::course_module_completion_registered_to_study_registries::insert(
513            tx.as_mut(),
514            PKeyPolicy::Generate,
515            &NewCourseModuleCompletionRegisteredToStudyRegistry {
516                course_id: course,
517                course_module_completion_id: completion,
518                course_module_id: course_module.id,
519                study_registry_registrar_id: registrar,
520                user_id: user,
521                real_student_number: "012345678".to_string(),
522            },
523        )
524        .await
525        .unwrap();
526
527        assert_eq!(
528            ensure_registration_rows_for_eligible_completions(
529                tx.as_mut(),
530                &RegistrationScope::default(),
531                MATERIALIZE_LIMIT
532            )
533            .await
534            .unwrap(),
535            0
536        );
537    }
538
539    #[tokio::test]
540    async fn a_scoped_run_leaves_another_courses_completions_alone() {
541        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
542        give_course_code(tx.as_mut(), &course_module).await;
543        add_completion(
544            tx.as_mut(),
545            course,
546            course_module.id,
547            instance.id,
548            user,
549            true,
550            true,
551        )
552        .await;
553
554        let elsewhere = Uuid::new_v4();
555        assert_eq!(
556            ensure_registration_rows_for_eligible_completions(
557                tx.as_mut(),
558                &RegistrationScope::for_course(elsewhere),
559                MATERIALIZE_LIMIT
560            )
561            .await
562            .unwrap(),
563            0
564        );
565        assert_eq!(
566            ensure_registration_rows_for_eligible_completions(
567                tx.as_mut(),
568                &RegistrationScope::for_course(course),
569                MATERIALIZE_LIMIT
570            )
571            .await
572            .unwrap(),
573            1
574        );
575    }
576
577    #[tokio::test]
578    async fn the_limit_bounds_one_iteration() {
579        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
580        give_course_code(tx.as_mut(), &course_module).await;
581        add_completion(
582            tx.as_mut(),
583            course,
584            course_module.id,
585            instance.id,
586            user,
587            true,
588            true,
589        )
590        .await;
591        insert_data!(tx: tx; user: second_user);
592        add_completion(
593            tx.as_mut(),
594            course,
595            course_module.id,
596            instance.id,
597            second_user,
598            true,
599            true,
600        )
601        .await;
602
603        let scope = RegistrationScope::default();
604        assert_eq!(
605            ensure_registration_rows_for_eligible_completions(tx.as_mut(), &scope, 1)
606                .await
607                .unwrap(),
608            1
609        );
610        assert_eq!(
611            ensure_registration_rows_for_eligible_completions(tx.as_mut(), &scope, 1)
612                .await
613                .unwrap(),
614            1
615        );
616    }
617}