Skip to main content

headless_lms_credit_registration/use_cases/enrolment_discovery/
mod.rs

1//! The `enrolment-discovery` phase: who the study registry says is on the course.
2//!
3//! Each iteration lists the course codes that are due, triggered listings first, in batches as
4//! large as the registry takes; every module on a code shares its listing. One listing wakes the
5//! registrations of people we already have a link for and, while account linking is switched on,
6//! claims an account-linking mail for everybody else. When to list a code is
7//! [`headless_lms_models::credit_registration_roster_schedules`].
8
9mod 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
29/// One code of a listing request, with the modules that share its roster.
30struct CodeListing {
31    code: RosterCode,
32    modules: Vec<ModuleToList>,
33}
34
35/// Books a listing of the module's course code for a student we hold no number for, since the
36/// listing is what mails them the link. `is_visit` also books the follow-up listing a visit gets.
37/// Does nothing for a linked student, or a module with no course code. The caller checks that
38/// account linking is switched on.
39pub 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
63/// With account linking off, a roster still wakes linked students' registrations, and only the
64/// mails are left out.
65pub(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    // The limiter counts roster requests, so the limit is how many may go out.
72    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
89/// The codes whose rosters are due, triggered listings first, then by when each fell due.
90async 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
120/// Groups due codes into requests in the order given: a code fetched alone in one of its own, the
121/// rest in batches of `request_size`. Keeps the first `request_limit`.
122fn 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
146/// Each planned request's codes, with the modules that share each code's roster.
147async 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}