Skip to main content

headless_lms_server/controllers/main_frontend/credit_registration_admin/
reconciliation.rs

1//! The Reconciliation tab: the failures defined by an absence, which no error count can catch.
2
3use headless_lms_models::credit_registration_events;
4use headless_lms_models::credit_registrations::{
5    self, AdminCreditRegistration, AdminCreditRegistrationFilters, AdminCreditRegistrationSort,
6    CreditRegistrationState,
7};
8use headless_lms_models::library::credit_registration::legacy_mirror::{
9    self, LegacyLedgerDivergence,
10};
11use headless_lms_models::library::credit_registration::materialize::{
12    UnmaterialisedCompletion, get_unmaterialised_eligible_completions,
13};
14use utoipa::ToSchema;
15
16use crate::prelude::*;
17
18use super::authorize_credit_registration_admin;
19
20/// Rows per detector. These are heavy queries and every list here is meant to be worked through,
21/// not scrolled.
22const DETECTOR_LIMIT: i64 = 200;
23/// A completion younger than this is simply waiting for the next `materialize` tick.
24const NEVER_ENTERED_MIN_AGE_SECS: i64 = 60 * 60;
25
26/// A completion that satisfies the materialise predicate and has no ledger row.
27#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
28pub struct NeverEnteredCompletion {
29    pub course_module_completion_id: Uuid,
30    pub user_id: Uuid,
31    pub first_name: Option<String>,
32    pub last_name: Option<String>,
33    pub email: Option<String>,
34    pub course_id: Uuid,
35    pub course_name: String,
36    pub course_module_id: Uuid,
37    pub course_module_name: Option<String>,
38    pub completion_date: DateTime<Utc>,
39    pub created_at: DateTime<Utc>,
40    /// The student has no enrolment on the course, so `materialize` has no course instance to put
41    /// on a ledger row. The one cause running the phase again will not fix.
42    pub missing_enrolment: bool,
43}
44
45/// One ledger row, flattened to what every reconciliation list needs to name a student and link
46/// onwards.
47#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
48pub struct ReconciliationRegistration {
49    pub credit_registration_id: Uuid,
50    pub user_id: Uuid,
51    pub first_name: Option<String>,
52    pub last_name: Option<String>,
53    pub email: Option<String>,
54    pub student_number: Option<String>,
55    pub course_id: Uuid,
56    pub course_name: String,
57    pub course_module_id: Uuid,
58    pub course_module_name: Option<String>,
59    pub uh_course_code: Option<String>,
60    pub state: CreditRegistrationState,
61    pub state_entered_at: DateTime<Utc>,
62    pub submitted_at: Option<DateTime<Utc>>,
63    pub submitted_attainment_id: Option<String>,
64    pub sisu_attainment_id: Option<String>,
65    pub registered_at: Option<DateTime<Utc>>,
66    pub terminal_at: Option<DateTime<Utc>>,
67}
68
69/// A ledger row the legacy study-registry ledger contradicts.
70#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
71pub struct LegacyLedgerDivergenceRow {
72    pub credit_registration_id: Uuid,
73    pub course_module_completion_id: Uuid,
74    pub user_id: Uuid,
75    pub first_name: Option<String>,
76    pub last_name: Option<String>,
77    pub email: Option<String>,
78    pub course_id: Uuid,
79    pub course_name: String,
80    pub course_module_id: Uuid,
81    pub state: CreditRegistrationState,
82    pub state_entered_at: DateTime<Utc>,
83    /// We registered it and the legacy ledger has no row of ours, so the teacher views and the pull
84    /// stream still call the completion unregistered.
85    pub mirror_missing: bool,
86    /// A registrar took the completion through the pull path while our pipeline had not finished
87    /// with it: the shape a double registration would have.
88    pub registered_by_a_registrar: bool,
89}
90
91#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
92pub struct CreditRegistrationReconciliation {
93    /// Eligible completions with no ledger row at all.
94    pub never_entered: Vec<NeverEnteredCompletion>,
95    /// `submission_uncertain`: the import may or may not have landed. Verify these, never resubmit.
96    pub outcome_uncertain: Vec<ReconciliationRegistration>,
97    /// Rows whose answers named more than one submitted attainment id, which is what a double
98    /// submission would look like.
99    pub several_submitted_attainments: Vec<ReconciliationRegistration>,
100    /// Attainments the study registry reversed after we had recorded them as registered.
101    pub misregistered: Vec<ReconciliationRegistration>,
102    pub legacy_divergences: Vec<LegacyLedgerDivergenceRow>,
103    /// The detectors' counts, in the same order as the lists, capped at `max_rows_per_detector`.
104    pub never_entered_count: i64,
105    pub outcome_uncertain_count: i64,
106    pub several_submitted_attainments_count: i64,
107    pub misregistered_count: i64,
108    pub legacy_divergence_count: i64,
109    /// The four detector counts, which is the tab badge.
110    pub finding_count: i64,
111    pub max_rows_per_detector: i64,
112}
113
114/**
115GET `/api/v0/main-frontend/credit-registration-admin/reconciliation` - The drift detectors: work the
116ledger should be doing and is not, and outcomes the study registry and the ledger disagree about.
117*/
118#[instrument(skip(pool))]
119#[utoipa::path(
120    get,
121    path = "/reconciliation",
122    operation_id = "getCreditRegistrationReconciliation",
123    tag = "credit-registration-admin",
124    responses(
125        (status = 200, description = "Every detector's findings and counts", body = CreditRegistrationReconciliation)
126    )
127)]
128pub async fn get_credit_registration_reconciliation(
129    user: AuthUser,
130    pool: web::Data<PgPool>,
131) -> ControllerResult<web::Json<CreditRegistrationReconciliation>> {
132    let mut conn = pool.acquire().await?;
133    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
134
135    let never_entered: Vec<NeverEnteredCompletion> = get_unmaterialised_eligible_completions(
136        &mut conn,
137        NEVER_ENTERED_MIN_AGE_SECS,
138        DETECTOR_LIMIT,
139    )
140    .await?
141    .into_iter()
142    .map(to_never_entered)
143    .collect();
144
145    let (outcome_uncertain, misregistered) = rows_in_states(&mut conn).await?;
146
147    let several_ids = credit_registration_events::get_ids_with_several_submitted_attainments(
148        &mut conn,
149        DETECTOR_LIMIT,
150    )
151    .await?;
152    let several_submitted_attainments =
153        rows_by_ids(&mut conn, &several_ids, DETECTOR_LIMIT).await?;
154
155    let legacy_divergences: Vec<LegacyLedgerDivergenceRow> =
156        legacy_mirror::get_legacy_ledger_divergences(&mut conn, DETECTOR_LIMIT)
157            .await?
158            .into_iter()
159            .map(to_legacy_divergence)
160            .collect();
161
162    let never_entered_count = never_entered.len() as i64;
163    let outcome_uncertain_count = outcome_uncertain.len() as i64;
164    let several_submitted_attainments_count = several_submitted_attainments.len() as i64;
165    let misregistered_count = misregistered.len() as i64;
166    let legacy_divergence_count = legacy_divergences.len() as i64;
167
168    token.authorized_ok(web::Json(CreditRegistrationReconciliation {
169        finding_count: never_entered_count
170            + outcome_uncertain_count
171            + several_submitted_attainments_count
172            + misregistered_count
173            + legacy_divergence_count,
174        never_entered,
175        outcome_uncertain,
176        several_submitted_attainments,
177        misregistered,
178        legacy_divergences,
179        never_entered_count,
180        outcome_uncertain_count,
181        several_submitted_attainments_count,
182        misregistered_count,
183        legacy_divergence_count,
184        max_rows_per_detector: DETECTOR_LIMIT,
185    }))
186}
187
188/// A detector that is just "rows currently in this one live state". The single definition of which
189/// states these are and which detector each belongs to — `rows_in_states` iterates it instead of
190/// hand-writing the state list once for the query and again per detector's filter.
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192enum LiveStateDetector {
193    OutcomeUncertain,
194    Misregistered,
195}
196
197impl LiveStateDetector {
198    const ALL: [Self; 2] = [Self::OutcomeUncertain, Self::Misregistered];
199
200    fn state(self) -> CreditRegistrationState {
201        match self {
202            Self::OutcomeUncertain => CreditRegistrationState::SubmissionUncertain,
203            Self::Misregistered => CreditRegistrationState::Misregistered,
204        }
205    }
206}
207
208/// The two detectors that each just read one live state, in one `IN`-list query and one
209/// admin-projection lookup shared across both.
210async fn rows_in_states(
211    conn: &mut PgConnection,
212) -> Result<
213    (
214        Vec<ReconciliationRegistration>,
215        Vec<ReconciliationRegistration>,
216    ),
217    ControllerError,
218> {
219    let states = LiveStateDetector::ALL.map(LiveStateDetector::state);
220    let ids: Vec<Uuid> = credit_registrations::get_live_by_states(conn, &states, DETECTOR_LIMIT)
221        .await?
222        .into_iter()
223        .map(|row| row.id)
224        .collect();
225    let limit = ids.len() as i64;
226    let rows = rows_by_ids(conn, &ids, limit).await?;
227    let mut by_detector = LiveStateDetector::ALL.map(|_| Vec::new());
228    for row in rows {
229        let index = LiveStateDetector::ALL
230            .iter()
231            .position(|detector| detector.state() == row.state)
232            .ok_or_else(|| {
233                controller_err!(
234                    InternalServerError,
235                    "A reconciliation row carries a state no detector owns.".to_string()
236                )
237            })?;
238        by_detector[index].push(row);
239    }
240    let [outcome_uncertain, misregistered] = by_detector;
241    Ok((outcome_uncertain, misregistered))
242}
243
244/// Reads the same admin projection the explorer uses, so a name, a course and a student number are
245/// spelled identically wherever the dashboard shows them.
246async fn rows_by_ids(
247    conn: &mut PgConnection,
248    ids: &[Uuid],
249    limit: i64,
250) -> Result<Vec<ReconciliationRegistration>, ControllerError> {
251    if ids.is_empty() {
252        return Ok(Vec::new());
253    }
254    Ok(credit_registrations::get_admin_facing(
255        conn,
256        &AdminCreditRegistrationFilters {
257            credit_registration_ids: Some(ids),
258            include_superseded: true,
259            ..AdminCreditRegistrationFilters::default()
260        },
261        AdminCreditRegistrationSort::TimeInState,
262        limit,
263        0,
264    )
265    .await?
266    .into_iter()
267    .map(to_reconciliation_row)
268    .collect())
269}
270
271fn to_never_entered(row: UnmaterialisedCompletion) -> NeverEnteredCompletion {
272    NeverEnteredCompletion {
273        course_module_completion_id: row.course_module_completion_id,
274        user_id: row.user_id,
275        first_name: row.first_name,
276        last_name: row.last_name,
277        email: row.email,
278        course_id: row.course_id,
279        course_name: row.course_name,
280        course_module_id: row.course_module_id,
281        course_module_name: row.course_module_name,
282        completion_date: row.completion_date,
283        created_at: row.created_at,
284        missing_enrolment: row.missing_enrolment,
285    }
286}
287
288fn to_reconciliation_row(row: AdminCreditRegistration) -> ReconciliationRegistration {
289    ReconciliationRegistration {
290        credit_registration_id: row.id,
291        user_id: row.user_id,
292        first_name: row.first_name,
293        last_name: row.last_name,
294        email: row.email,
295        student_number: row.student_number,
296        course_id: row.course_id,
297        course_name: row.course_name,
298        course_module_id: row.course_module_id,
299        course_module_name: row.course_module_name,
300        uh_course_code: row.uh_course_code,
301        state: row.state,
302        state_entered_at: row.state_entered_at,
303        submitted_at: row.submitted_at,
304        submitted_attainment_id: row.submitted_attainment_id,
305        sisu_attainment_id: row.sisu_attainment_id,
306        registered_at: row.registered_at,
307        terminal_at: row.terminal_at,
308    }
309}
310
311fn to_legacy_divergence(row: LegacyLedgerDivergence) -> LegacyLedgerDivergenceRow {
312    LegacyLedgerDivergenceRow {
313        credit_registration_id: row.credit_registration_id,
314        course_module_completion_id: row.course_module_completion_id,
315        user_id: row.user_id,
316        first_name: row.first_name,
317        last_name: row.last_name,
318        email: row.email,
319        course_id: row.course_id,
320        course_name: row.course_name,
321        course_module_id: row.course_module_id,
322        state: row.state,
323        state_entered_at: row.state_entered_at,
324        mirror_missing: row.mirror_missing,
325        registered_by_a_registrar: row.registered_by_a_registrar,
326    }
327}
328
329pub fn _add_routes(cfg: &mut ServiceConfig) {
330    cfg.route(
331        "/reconciliation",
332        web::get().to(get_credit_registration_reconciliation),
333    );
334}