1use 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
20const DETECTOR_LIMIT: i64 = 200;
23const NEVER_ENTERED_MIN_AGE_SECS: i64 = 60 * 60;
25
26#[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 pub missing_enrolment: bool,
43}
44
45#[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#[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 pub mirror_missing: bool,
86 pub registered_by_a_registrar: bool,
89}
90
91#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
92pub struct CreditRegistrationReconciliation {
93 pub never_entered: Vec<NeverEnteredCompletion>,
95 pub outcome_uncertain: Vec<ReconciliationRegistration>,
97 pub several_submitted_attainments: Vec<ReconciliationRegistration>,
100 pub misregistered: Vec<ReconciliationRegistration>,
102 pub legacy_divergences: Vec<LegacyLedgerDivergenceRow>,
103 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 pub finding_count: i64,
111 pub max_rows_per_detector: i64,
112}
113
114#[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#[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
208async 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
244async 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}