headless_lms_credit_registration/use_cases/
config_validation.rs1use 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 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 Ok(Counts::processed(
63 i64::try_from(modules.len()).unwrap_or(i64::MAX),
64 ))
65}
66
67fn 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
78fn check_modules(
80 modules: &[SuotarModuleConfigFacts],
81 verdicts: &CourseCodeVerdicts,
82) -> Vec<SuotarConfigCheck> {
83 modules
84 .iter()
85 .map(|module| {
86 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
99async 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}