Skip to main content

headless_lms_credit_registration/use_cases/enrolment_discovery/
listing.rs

1//! Sending one listing request, and recording what its enrolment lists, or its failure, say about
2//! each code.
3
4use headless_lms_models::course_module_suotar_configurations::mark_listing_failed;
5use headless_lms_models::credit_registration_roster_schedules::{
6    mark_alone_failed, mark_attempted, mark_batch_failed, mark_fetched,
7};
8use headless_lms_models::credit_registrations::CreditRegistrationErrorCode;
9use headless_lms_models::library::credit_registration::outcomes::request_level_code;
10use headless_lms_models::library::credit_registration::study_registry::RosterPerson;
11use headless_lms_utils::prelude::{DateTime, Utc};
12use sqlx::{PgConnection, PgPool};
13
14use super::CodeListing;
15use super::reconcile::reconcile_roster;
16use crate::error::CreditRegistrationResult;
17use crate::registry::{RegistryError, RosterCode, StudyRegistry};
18use crate::workflow::Counts;
19
20/// The code Suotar answers for a code it holds no realisation of, which it stops holding two months
21/// after the last one ends.
22const NO_REALISATION_CODE: CreditRegistrationErrorCode =
23    CreditRegistrationErrorCode::CourseCodeNotFound;
24
25/// What one listing request did.
26pub(super) struct FetchedRosters {
27    pub counts: Counts,
28    /// Whether it claimed linking mail the `link-emails` phase still has to send.
29    pub claimed_mail: bool,
30}
31
32/// Sends one listing request and reconciles what came back.
33pub(super) async fn fetch_course_roster<R: StudyRegistry>(
34    pool: &PgPool,
35    registry: &mut R,
36    request: &[CodeListing],
37    account_linking_since: Option<DateTime<Utc>>,
38) -> CreditRegistrationResult<FetchedRosters> {
39    let codes: Vec<String> = request
40        .iter()
41        .map(|listing| listing.code.course_code.as_str().to_string())
42        .collect();
43    let module_count: usize = request.iter().map(|listing| listing.modules.len()).sum();
44    let attempted = i32::try_from(module_count).unwrap_or(i32::MAX);
45    let started_at = {
46        let mut conn = pool.acquire().await?;
47        mark_attempted(&mut conn, &codes).await?
48    };
49    let could_claim_mail = account_linking_since.is_some();
50    let roster_codes: Vec<RosterCode> =
51        request.iter().map(|listing| listing.code.clone()).collect();
52    let listed = registry.list_course_roster(&roster_codes).await;
53    let mut conn = pool.acquire().await?;
54    let roster_listing = match listed {
55        Ok(roster_listing) => roster_listing,
56        Err(error) => {
57            record_roster_failure(&mut conn, request, &codes, &error).await?;
58            return Ok(FetchedRosters {
59                counts: Counts::all_failed(attempted),
60                claimed_mail: false,
61            });
62        }
63    };
64
65    let duration_ms = i32::try_from(roster_listing.duration.as_millis()).unwrap_or(i32::MAX);
66    let mut items_failed = 0;
67    let mut enrolments = 0;
68    let mut new_mails = 0;
69    // The registry answers one roster per requested code, in request order.
70    for (listing, roster) in request.iter().zip(&roster_listing.rosters) {
71        let course_code = listing.code.course_code.as_str();
72        match roster {
73            Ok(people) => {
74                new_mails +=
75                    reconcile_roster(&mut conn, listing, people, account_linking_since).await?;
76                let person_count = i32::try_from(people.len()).unwrap_or(i32::MAX);
77                enrolments += person_count;
78                let previous_fetch_at = mark_fetched(
79                    &mut conn,
80                    course_code,
81                    started_at,
82                    could_claim_mail,
83                    person_count,
84                    duration_ms,
85                )
86                .await?;
87                log_surfaced_enrolments(course_code, people, previous_fetch_at);
88            }
89            Err(error) => {
90                let error = *error;
91                items_failed += i32::try_from(listing.modules.len()).unwrap_or(i32::MAX);
92                for module in &listing.modules {
93                    mark_listing_failed(&mut conn, module.course_module_id, error).await?;
94                }
95                // Not one to back off from: a new realisation can open at any time.
96                if error == NO_REALISATION_CODE {
97                    mark_fetched(
98                        &mut conn,
99                        course_code,
100                        started_at,
101                        could_claim_mail,
102                        0,
103                        duration_ms,
104                    )
105                    .await?;
106                } else {
107                    mark_alone_failed(&mut conn, course_code, error).await?;
108                }
109            }
110        }
111    }
112    let codes = request.len();
113    info!(
114        codes,
115        enrolments,
116        new_mails,
117        duration_ms,
118        "fetched roster: {enrolments} enrolments for {codes} codes, {new_mails} new"
119    );
120    Ok(FetchedRosters {
121        counts: Counts::processed_with_failures(attempted, items_failed),
122        claimed_mail: new_mails > 0,
123    })
124}
125
126/// Logs each enrolment made since the code's previous fetch with the fetches it surfaced between,
127/// which brackets how long Suotar's copy of Sisu takes to show an enrolment.
128fn log_surfaced_enrolments(
129    course_code: &str,
130    people: &[RosterPerson],
131    previous_fetch_at: Option<DateTime<Utc>>,
132) {
133    let Some(previous_fetch_at) = previous_fetch_at else {
134        return;
135    };
136    let fetched_at = Utc::now();
137    for enrolled_at in people
138        .iter()
139        .filter_map(RosterPerson::enrolled_at)
140        .filter(|enrolled_at| *enrolled_at > previous_fetch_at)
141    {
142        info!(
143            course_code,
144            %enrolled_at,
145            %previous_fetch_at,
146            %fetched_at,
147            surfaced_within_secs = (fetched_at - enrolled_at).num_seconds(),
148            "enrolment surfaced on the enrolment list"
149        );
150    }
151}
152
153/// A request Suotar failed as a whole. Suotar fails every code of a request when one fails, so the
154/// codes of a failed batch are listed on their own from now on, and a code that fails alone backs
155/// off.
156async fn record_roster_failure(
157    conn: &mut PgConnection,
158    request: &[CodeListing],
159    codes: &[String],
160    error: &RegistryError,
161) -> CreditRegistrationResult<()> {
162    let code = request_level_code(error.kind);
163    for module in request.iter().flat_map(|listing| &listing.modules) {
164        mark_listing_failed(conn, module.course_module_id, code).await?;
165    }
166    if !error.blames_request_items() {
167        return Ok(());
168    }
169    match request {
170        [only] => mark_alone_failed(conn, only.code.course_code.as_str(), code).await?,
171        _ => mark_batch_failed(conn, codes, code).await?,
172    }
173    Ok(())
174}