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