headless_lms_credit_registration/use_cases/enrolment_discovery/
listing.rs1use 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
20const NO_REALISATION_CODE: CreditRegistrationErrorCode =
23 CreditRegistrationErrorCode::CourseCodeNotFound;
24
25pub(super) struct FetchedRosters {
27 pub counts: Counts,
28 pub claimed_mail: bool,
30}
31
32pub(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 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 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
126fn 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
153async 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}