Skip to main content

headless_lms_credit_registration/use_cases/
config_validation.rs

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