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