Skip to main content

headless_lms_credit_registration/use_cases/
config_validation.rs

1//! The `config-validation` phase: the daily pass over every Suotar-enabled module's configuration.
2//!
3//! Asks Suotar's `course-codes/validate` about every distinct course code; the rest of the check
4//! reads the database.
5
6use headless_lms_models::course_module_suotar_configurations::{
7    SuotarConfigCheck, SuotarModuleConfigFacts, get_config_facts_for_enabled_modules,
8    record_config_check,
9};
10use headless_lms_models::library::credit_registration::config_validation::{
11    CourseCodeVerdict, check_module_config,
12};
13use itertools::Itertools;
14use sqlx::PgPool;
15
16use crate::error::CreditRegistrationResult;
17use crate::registry::{CourseCode, CourseCodeVerdicts, StudyRegistry};
18use crate::workflow::Counts;
19use headless_lms_models::credit_registrations::RegistrationScope;
20
21pub(crate) async fn run<R: StudyRegistry>(
22    pool: &PgPool,
23    scope: &RegistrationScope,
24    registry: &mut R,
25) -> CreditRegistrationResult<Counts> {
26    let modules = {
27        let mut conn = pool.acquire().await?;
28        get_config_facts_for_enabled_modules(&mut conn, scope.course_id).await?
29    };
30    let course_codes = distinct_course_codes(&modules);
31
32    // After a refusal nothing is recorded, so the previous verdicts stand until a check gets through.
33    let Ok(verdicts) = registry.validate_course_codes(&course_codes).await else {
34        return Ok(Counts::default());
35    };
36
37    let checks = check_modules(&modules, &verdicts);
38    record_checks(pool, &modules, &checks).await?;
39
40    let with_problems = checks
41        .iter()
42        .filter(|check| check.message.is_some())
43        .count();
44    if with_problems > 0 {
45        warn!(
46            modules_with_problems = with_problems,
47            "Suotar-enabled course modules have configuration problems"
48        );
49    }
50    let modules_checked = modules.len();
51    let codes_checked = verdicts.len();
52    if modules_checked > 0 {
53        info!(
54            modules_checked,
55            codes_checked,
56            with_problems,
57            "checked {modules_checked} modules, {codes_checked} course codes"
58        );
59    }
60
61    // A misconfigured module is a finding, not a failed item: the phase did its job.
62    Ok(Counts::processed(
63        i64::try_from(modules.len()).unwrap_or(i64::MAX),
64    ))
65}
66
67/// The modules' course codes as the registry is asked about them: trimmed, sorted, each once.
68fn distinct_course_codes(modules: &[SuotarModuleConfigFacts]) -> Vec<CourseCode> {
69    modules
70        .iter()
71        .filter_map(|module| module.uh_course_code.as_deref())
72        .filter_map(CourseCode::parse)
73        .sorted()
74        .dedup()
75        .collect()
76}
77
78/// Each module's check, in module order.
79fn check_modules(
80    modules: &[SuotarModuleConfigFacts],
81    verdicts: &CourseCodeVerdicts,
82) -> Vec<SuotarConfigCheck> {
83    modules
84        .iter()
85        .map(|module| {
86            // A code Suotar gave no verdict for, or left unchecked, keeps its stored one: clearing
87            // it would read as allowed and release a blocked module's rows.
88            let verdict = module
89                .uh_course_code
90                .as_deref()
91                .and_then(CourseCode::parse)
92                .and_then(|code| verdicts.get(&code).cloned())
93                .or_else(|| CourseCodeVerdict::stored(module));
94            check_module_config(module, verdict.as_ref())
95        })
96        .collect()
97}
98
99/// Writes each module's check, in module order.
100async fn record_checks(
101    pool: &PgPool,
102    modules: &[SuotarModuleConfigFacts],
103    checks: &[SuotarConfigCheck],
104) -> CreditRegistrationResult<()> {
105    let mut conn = pool.acquire().await?;
106    for (module, check) in modules.iter().zip(checks) {
107        record_config_check(&mut conn, module.course_module_id, check).await?;
108    }
109    Ok(())
110}
111
112#[cfg(test)]
113mod tests {
114    use uuid::Uuid;
115
116    use super::*;
117
118    fn module(
119        code: Option<&str>,
120        stored_course_code_allowed: Option<bool>,
121    ) -> SuotarModuleConfigFacts {
122        SuotarModuleConfigFacts {
123            course_module_id: Uuid::new_v4(),
124            course_id: Uuid::new_v4(),
125            uh_course_code: code.map(str::to_string),
126            ects_credits: Some(5.0),
127            stored_course_code_allowed,
128            stored_course_code_rejection: stored_course_code_allowed
129                .filter(|allowed| !allowed)
130                .map(|_| "stored reason".to_string()),
131        }
132    }
133
134    fn verdicts(entries: &[(&str, CourseCodeVerdict)]) -> CourseCodeVerdicts {
135        entries
136            .iter()
137            .map(|(code, verdict)| (CourseCode::parse(code).unwrap(), verdict.clone()))
138            .collect()
139    }
140
141    #[test]
142    fn the_registry_is_asked_about_each_trimmed_code_once_in_order() {
143        let modules = [
144            module(Some(" TKT2 "), None),
145            module(Some("TKT1"), None),
146            module(Some("TKT2"), None),
147            module(Some("   "), None),
148            module(None, None),
149        ];
150        let codes: Vec<_> = distinct_course_codes(&modules)
151            .iter()
152            .map(|code| code.as_str().to_string())
153            .collect();
154        assert_eq!(codes, ["TKT1", "TKT2"]);
155    }
156
157    #[test]
158    fn each_module_takes_the_verdict_for_its_trimmed_code() {
159        let modules = [
160            module(Some(" TKT1 "), Some(true)),
161            module(Some("TKT2"), None),
162        ];
163        let verdicts = verdicts(&[
164            (
165                "TKT1",
166                CourseCodeVerdict::NotAllowed {
167                    reason: "closed".to_string(),
168                },
169            ),
170            ("TKT2", CourseCodeVerdict::Allowed),
171        ]);
172        let checks = check_modules(&modules, &verdicts);
173        assert_eq!(checks[0].course_code_allowed, Some(false));
174        assert_eq!(checks[0].course_code_rejection.as_deref(), Some("closed"));
175        assert!(checks[0].message.is_some());
176        assert_eq!(checks[1].course_code_allowed, Some(true));
177        assert_eq!(checks[1].checked_course_code.as_deref(), Some("TKT2"));
178        assert_eq!(checks[1].message, None);
179    }
180
181    #[test]
182    fn a_code_left_without_a_verdict_keeps_its_stored_one() {
183        let modules = [
184            module(Some("TKT1"), Some(false)),
185            module(Some("TKT2"), Some(true)),
186        ];
187        let checks = check_modules(&modules, &verdicts(&[]));
188        assert_eq!(checks[0].course_code_allowed, Some(false));
189        assert_eq!(
190            checks[0].course_code_rejection.as_deref(),
191            Some("stored reason")
192        );
193        assert_eq!(checks[1].course_code_allowed, Some(true));
194    }
195
196    #[test]
197    fn a_code_never_checked_stays_unknown_rather_than_allowed() {
198        let checks = check_modules(&[module(Some("TKT1"), None)], &verdicts(&[]));
199        assert_eq!(checks[0].course_code_allowed, None);
200        assert_eq!(checks[0].checked_course_code, None);
201    }
202
203    #[test]
204    fn a_module_without_a_code_is_a_problem_whatever_the_registry_says() {
205        let checks = check_modules(
206            &[module(None, Some(true))],
207            &verdicts(&[("TKT1", CourseCodeVerdict::Allowed)]),
208        );
209        assert_eq!(checks[0].course_code_allowed, Some(false));
210        assert!(checks[0].message.is_some());
211    }
212}