Skip to main content

headless_lms_credit_registration/runtime/suotar/
rosters.rs

1//! Course rosters from `list-by-course`: one request of a worker iteration, and the interactive
2//! search behind a linking mail resend.
3
4use futures::future::join_all;
5use headless_lms_models::credit_registrations::CreditRegistrationErrorCode;
6use headless_lms_models::library::credit_registration::study_registry::RosterPerson;
7use headless_lms_utils::services::suotar::{
8    ListByCourseRequestItem, SuotarEndpoint, SuotarItemStatus, endpoints, new_request_item_id,
9};
10use secrecy::ExposeSecret;
11use tracing::Instrument;
12
13use super::decode::{registry_error, roster, roster_person};
14use super::encode::roster_item;
15use super::gate::Exchange;
16use super::{InteractiveSuotar, SuotarStudyRegistry};
17use crate::registry::{
18    CourseCode, RegistryError, RosterCode, RosterListing, RosterSearch, StudentNumber,
19};
20
21const ENDPOINT: SuotarEndpoint = SuotarEndpoint::ListByCourse;
22
23pub(super) async fn list(
24    registry: &mut SuotarStudyRegistry<'_>,
25    request: &[RosterCode],
26) -> Result<RosterListing, RegistryError> {
27    registry.gate.spend(ENDPOINT, 1);
28    let items: Vec<ListByCourseRequestItem> = request
29        .iter()
30        .map(|code| roster_item(&code.course_code, new_request_item_id()))
31        .collect();
32    let request_item_ids: Vec<String> = items
33        .iter()
34        .map(|item| item.request_item_id.clone())
35        .collect();
36    let span = super::request_span(ENDPOINT, items.len());
37    let response = registry
38        .client
39        .post::<endpoints::ListByCourse>(registry.call_context(Vec::new()), items)
40        .instrument(span)
41        .await;
42    let response = match response {
43        Ok(response) => response,
44        Err(error) => {
45            let refusal = registry_error(&error);
46            // An outage fails a code listed alone as surely as a batch, so it still counts as one.
47            let is_known_bad_code = matches!(request, [only] if only.is_fetched_alone)
48                && refusal.blames_request_items()
49                && !refusal.kind.is_outage();
50            registry.gate.record(
51                ENDPOINT,
52                if is_known_bad_code {
53                    Exchange::RefusedAlone(&error)
54                } else {
55                    Exchange::Refused(&error)
56                },
57            );
58            return Err(refusal);
59        }
60    };
61    registry.gate.record(
62        ENDPOINT,
63        Exchange::answered(
64            &response,
65            "Every course code of the batch came back unavailable.",
66        ),
67    );
68    Ok(RosterListing {
69        duration: response.duration,
70        rosters: request
71            .iter()
72            .zip(&request_item_ids)
73            .map(|(code, request_item_id)| roster(&response, request_item_id, &code.course_code))
74            .collect(),
75    })
76}
77
78pub(super) async fn fetch_one(
79    registry: &InteractiveSuotar<'_>,
80    course_code: &CourseCode,
81) -> Option<Vec<RosterPerson>> {
82    let request_item_id = new_request_item_id();
83    let span = super::request_span(ENDPOINT, 1);
84    let response = registry
85        .client
86        .post::<endpoints::ListByCourse>(
87            registry.call_context(),
88            vec![roster_item(course_code, request_item_id.clone())],
89        )
90        .instrument(span)
91        .await
92        .inspect_err(|error| {
93            warn!(error = %error, "Could not list a course code's roster for an admin");
94        })
95        .ok()?;
96    match roster(&response, &request_item_id, course_code) {
97        Ok(people) => Some(people),
98        Err(CreditRegistrationErrorCode::CourseCodeNotFound) => Some(Vec::new()),
99        Err(_) => None,
100    }
101}
102
103pub(super) async fn search(
104    registry: &InteractiveSuotar<'_>,
105    codes: &[CourseCode],
106    student_number: &StudentNumber,
107) -> RosterSearch {
108    // One code per call, all at once: Suotar looks the codes of one call up one after another, and
109    // the caller is waiting in the browser, so only a single code has a chance of answering within
110    // the interactive timeout.
111    let responses = join_all(codes.iter().map(|course_code| {
112        let span = super::request_span(ENDPOINT, 1);
113        registry
114            .client
115            .post::<endpoints::ListByCourse>(
116                registry.call_context(),
117                vec![roster_item(course_code, new_request_item_id())],
118            )
119            .instrument(span)
120    }))
121    .await;
122    let mut has_unanswered_code = false;
123    let mut person = None;
124    for response in responses {
125        let response = match response {
126            Ok(response) => response,
127            Err(error) => {
128                warn!(
129                    error = %error,
130                    "Could not list a course code's roster for a linking mail resend"
131                );
132                has_unanswered_code = true;
133                continue;
134            }
135        };
136        person = person.or_else(|| {
137            response
138                .items
139                .iter()
140                .filter(|item| item.status == SuotarItemStatus::Ok)
141                .filter_map(|item| item.result.as_ref())
142                .flat_map(|result| result.people.iter())
143                .find(|candidate| {
144                    candidate.student_number.expose_secret() == student_number.expose()
145                })
146                .map(roster_person)
147        });
148    }
149    RosterSearch {
150        person,
151        has_unanswered_code,
152    }
153}