headless_lms_credit_registration/use_cases/enrolment_discovery/
mod.rs1mod listing;
10mod reconcile;
11
12use headless_lms_models::course_module_suotar_configurations::ModuleToList;
13use headless_lms_models::credit_registration_roster_schedules::{
14 RosterSchedule, ScheduleSelection, book_triggered_fetch, ensure_rows, get_modules_by_code,
15 get_schedules,
16};
17use headless_lms_models::{course_modules, verified_student_numbers};
18use headless_lms_utils::prelude::Utc;
19use sqlx::{PgConnection, PgPool};
20use uuid::Uuid;
21
22use crate::error::CreditRegistrationResult;
23use crate::registry::{CourseCode, RegistryOperation, RosterCode, StudyRegistry};
24use crate::workflow::Counts;
25use headless_lms_models::credit_registrations::RegistrationScope;
26
27use listing::fetch_course_roster;
28
29struct CodeListing {
31 code: RosterCode,
32 modules: Vec<ModuleToList>,
33}
34
35pub async fn book_listing_for_unlinked_student(
40 conn: &mut PgConnection,
41 user_id: Uuid,
42 course_module_id: Uuid,
43 is_visit: bool,
44) -> CreditRegistrationResult<()> {
45 if verified_student_numbers::get_by_user_id(conn, user_id)
46 .await?
47 .is_some()
48 {
49 return Ok(());
50 }
51 let course_module = course_modules::get_by_id(conn, course_module_id).await?;
52 let Some(course_code) = course_module
53 .uh_course_code
54 .as_deref()
55 .and_then(CourseCode::parse)
56 else {
57 return Ok(());
58 };
59 book_triggered_fetch(conn, course_code.as_str(), is_visit).await?;
60 Ok(())
61}
62
63pub(crate) async fn run<R: StudyRegistry>(
66 pool: &PgPool,
67 scope: &RegistrationScope,
68 is_account_linking_enabled: bool,
69 registry: &mut R,
70) -> CreditRegistrationResult<Counts> {
71 let request_limit = registry.allowance(RegistryOperation::ListCourseRoster);
73 if request_limit == 0 {
74 return Ok(Counts::default());
75 }
76 let mut conn = pool.acquire().await?;
77 let due = load_due_roster_codes(&mut conn, scope.course_id, is_account_linking_enabled).await?;
78 let planned = plan_roster_requests(due, request_limit, registry.roster_request_size());
79 let requests = load_listing_modules(&mut conn, scope.course_id, planned).await?;
80 drop(conn);
81
82 let mut counts = Counts::default();
83 for request in requests {
84 counts += fetch_course_roster(pool, registry, &request, is_account_linking_enabled).await?;
85 }
86 Ok(counts)
87}
88
89async fn load_due_roster_codes(
91 conn: &mut PgConnection,
92 course_id: Option<Uuid>,
93 is_account_linking_enabled: bool,
94) -> CreditRegistrationResult<Vec<RosterCode>> {
95 let now = Utc::now();
96 ensure_rows(conn, course_id).await?;
97 let mut due: Vec<RosterSchedule> =
98 get_schedules(conn, course_id, ScheduleSelection::DueCandidates)
99 .await?
100 .into_iter()
101 .filter(|schedule| schedule.is_due(is_account_linking_enabled, now))
102 .collect();
103 due.sort_by_key(|schedule| {
104 (
105 !schedule.is_triggered_due(now),
106 schedule.next_fetch_at(is_account_linking_enabled, now),
107 )
108 });
109 Ok(due
110 .into_iter()
111 .filter_map(|schedule| {
112 Some(RosterCode {
113 course_code: CourseCode::parse(&schedule.course_code)?,
114 is_fetched_alone: schedule.is_fetched_alone,
115 })
116 })
117 .collect())
118}
119
120fn plan_roster_requests(
123 due: Vec<RosterCode>,
124 request_limit: usize,
125 request_size: usize,
126) -> Vec<Vec<RosterCode>> {
127 let mut requests: Vec<Vec<RosterCode>> = Vec::new();
128 let mut open_batch: Option<usize> = None;
129 for code in due {
130 if code.is_fetched_alone {
131 requests.push(vec![code]);
132 continue;
133 }
134 match open_batch {
135 Some(index) if requests[index].len() < request_size => requests[index].push(code),
136 _ => {
137 open_batch = Some(requests.len());
138 requests.push(vec![code]);
139 }
140 }
141 }
142 requests.truncate(request_limit);
143 requests
144}
145
146async fn load_listing_modules(
148 conn: &mut PgConnection,
149 course_id: Option<Uuid>,
150 planned: Vec<Vec<RosterCode>>,
151) -> CreditRegistrationResult<Vec<Vec<CodeListing>>> {
152 let codes: Vec<String> = planned
153 .iter()
154 .flatten()
155 .map(|code| code.course_code.as_str().to_string())
156 .collect();
157 let mut modules_by_code = get_modules_by_code(conn, course_id, &codes).await?;
158 Ok(planned
159 .into_iter()
160 .map(|request| {
161 request
162 .into_iter()
163 .map(|code| CodeListing {
164 modules: modules_by_code
165 .remove(code.course_code.as_str())
166 .unwrap_or_default(),
167 code,
168 })
169 .collect()
170 })
171 .collect())
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 fn roster_codes(alone: &[&str], batched: usize) -> Vec<RosterCode> {
179 let alone = alone.iter().map(|alone_code| RosterCode {
180 course_code: CourseCode::parse(alone_code).expect("a code"),
181 is_fetched_alone: true,
182 });
183 let batched = (0..batched).map(|index| RosterCode {
184 course_code: CourseCode::parse(&format!("B{index}")).expect("a code"),
185 is_fetched_alone: false,
186 });
187 alone.chain(batched).collect()
188 }
189
190 fn planned_sizes(requests: &[Vec<RosterCode>]) -> Vec<usize> {
191 requests.iter().map(Vec::len).collect()
192 }
193
194 #[test]
195 fn roster_codes_fetched_alone_go_in_requests_of_their_own_and_the_rest_in_full_batches() {
196 let due = roster_codes(&["A1", "A2"], 51);
197 let requests = plan_roster_requests(due, usize::MAX, 50);
198 assert_eq!(planned_sizes(&requests), [1, 1, 50, 1]);
199 assert_eq!(requests[1][0].course_code.as_str(), "A2");
200 assert_eq!(requests[3][0].course_code.as_str(), "B50");
201 }
202
203 #[test]
204 fn roster_planning_keeps_the_first_requests_up_to_the_limit() {
205 let requests = plan_roster_requests(roster_codes(&["A1"], 51), 2, 50);
206 assert_eq!(planned_sizes(&requests), [1, 50]);
207 }
208
209 #[test]
210 fn a_roster_probe_sends_one_code() {
211 let requests = plan_roster_requests(roster_codes(&[], 60), 1, 1);
212 assert_eq!(planned_sizes(&requests), [1]);
213 }
214}