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