Skip to main content

headless_lms_credit_registration/runtime/suotar/
decode.rs

1//! Suotar's answers and failures read into the registry's terms.
2
3use headless_lms_models::credit_registrations::{
4    CreditRegistrationErrorCode, CreditRegistrationState,
5};
6use headless_lms_models::library::credit_registration::config_validation::CourseCodeVerdict;
7use headless_lms_models::library::credit_registration::study_registry::{
8    ATTAINMENT_TYPE_COURSE_UNIT, CreditRange, DatePeriod, LocalizedName, RegistryAttainment,
9    RegistryEnrolment, RegistryErrorKind, RosterEnrolment, RosterPerson,
10};
11use headless_lms_utils::services::suotar::{
12    self as wire, EnrolmentResolutionResult, EnrolmentsListedResult, ImportAttainmentResult,
13    PersonResult, SuotarBatchResponse, SuotarEndpoint, SuotarError, SuotarErrorVariant,
14    SuotarItemStatus, SuotarResponseItem, ValidateCourseCodeResult, VerifyAttainmentResult,
15};
16
17use super::codes::{
18    DUPLICATE_REQUEST_ITEM_CODE, PERSON_NOT_FOUND_CODE, WireOutcome, map_code, outcome_of,
19    settled_state,
20};
21use crate::registry::{
22    AttainmentId, CourseCode, EnrolmentAnswer, EnrolmentReading, FoundPerson, HeldCredit,
23    ImportAnswer, PersonAnswer, PersonLookupError, PersonReading, RegistryError, RegistryPerson,
24    SubmittedAttainmentRef, VerificationAnswer, VerificationReading,
25};
26
27fn registry_attainment(attainment: &wire::SuotarAttainment) -> RegistryAttainment {
28    RegistryAttainment {
29        id: attainment.id.clone(),
30        attainment_type: attainment.attainment_type.clone(),
31        state: attainment.state.clone(),
32        attainment_date: attainment.attainment_date,
33        registration_date: attainment.registration_date,
34        grade_scale_id: attainment.grade_scale_id.clone(),
35        grade_id: attainment.grade_id.clone(),
36    }
37}
38
39/// Keeps [`SuotarError::message`], not its `Display`, which prefixes the variant.
40pub(super) fn registry_error(error: &SuotarError) -> RegistryError {
41    let kind = match error.variant {
42        SuotarErrorVariant::Unauthorized => RegistryErrorKind::AuthenticationFailure,
43        SuotarErrorVariant::MalformedRequest => RegistryErrorKind::MalformedRequest,
44        SuotarErrorVariant::RequestLevelError => RegistryErrorKind::RejectedRequest,
45        SuotarErrorVariant::ServiceTemporarilyUnavailable => {
46            RegistryErrorKind::TemporarilyUnavailable
47        }
48        SuotarErrorVariant::ServerError => RegistryErrorKind::ServerError,
49        SuotarErrorVariant::TransportNotDelivered => RegistryErrorKind::NotDelivered,
50        SuotarErrorVariant::TransportUnknown => RegistryErrorKind::NoAnswer,
51        SuotarErrorVariant::Deserialization => RegistryErrorKind::ProtocolViolation,
52    };
53    RegistryError::new(kind, error.message())
54}
55
56fn error_message<R>(item: &SuotarResponseItem<R>) -> Option<String> {
57    item.error.as_ref().map(|error| error.message.clone())
58}
59
60fn found_person(person: &PersonResult) -> FoundPerson {
61    FoundPerson {
62        person_id: person.person_id.clone(),
63        first_names: person.first_names.clone(),
64        last_name: person.last_name.clone(),
65    }
66}
67
68pub(super) fn person_answer(item: &SuotarResponseItem<PersonResult>) -> PersonAnswer {
69    let found = item
70        .result
71        .as_ref()
72        .filter(|_| item.status == SuotarItemStatus::Ok);
73    let reading = match found {
74        Some(person) => PersonReading::Found(found_person(person)),
75        None => PersonReading::Refused {
76            code: if item.status == SuotarItemStatus::Ok {
77                CreditRegistrationErrorCode::UnexpectedResponse
78            } else {
79                map_code(SuotarEndpoint::ResolvePersons, &item.code)
80                    .unwrap_or(CreditRegistrationErrorCode::Unknown)
81            },
82        },
83    };
84    PersonAnswer {
85        reading,
86        error_message: error_message(item),
87    }
88}
89
90/// An interactive lookup's item: `personNotFound` is read before the status, whatever the status
91/// says, and every other code is kept as Suotar gave it.
92pub(super) fn person_lookup(
93    item: &SuotarResponseItem<PersonResult>,
94) -> Result<Option<RegistryPerson>, PersonLookupError> {
95    if item.code == PERSON_NOT_FOUND_CODE {
96        return Ok(None);
97    }
98    match item
99        .result
100        .as_ref()
101        .filter(|_| item.status == SuotarItemStatus::Ok)
102    {
103        Some(person) => Ok(Some(RegistryPerson {
104            sisu_person_id: person.person_id.clone(),
105            first_names: person.first_names.clone(),
106            last_name: person.last_name.clone(),
107            code: item.code.clone(),
108        })),
109        None => Err(PersonLookupError::UnexpectedAnswer {
110            code: item.code.clone(),
111        }),
112    }
113}
114
115pub(super) fn enrolment_answer(
116    item: &SuotarResponseItem<EnrolmentResolutionResult>,
117) -> EnrolmentAnswer {
118    let reading = if item.status == SuotarItemStatus::Error {
119        EnrolmentReading::Refused {
120            code: map_code(SuotarEndpoint::ResolveEnrolments, &item.code)
121                .unwrap_or(CreditRegistrationErrorCode::Unknown),
122            error_message: error_message(item),
123        }
124    } else {
125        EnrolmentReading::Listed
126    };
127    let result = item.result.as_ref();
128    EnrolmentAnswer {
129        reading,
130        enrolments: result
131            .map(|result| result.enrolments.iter().map(enrolment).collect())
132            .unwrap_or_default(),
133        existing_attainments: result
134            .map(|result| {
135                result
136                    .existing_attainments
137                    .iter()
138                    .map(registry_attainment)
139                    .collect()
140            })
141            .unwrap_or_default(),
142    }
143}
144
145pub(super) fn import_answer(item: &SuotarResponseItem<ImportAttainmentResult>) -> ImportAnswer {
146    let result = item.result.as_ref();
147    let submission = result.and_then(|result| {
148        result
149            .submitted_attainment_id
150            .as_ref()
151            .map(|id| SubmittedAttainmentRef {
152                id: AttainmentId::new(id.clone()),
153                attainment_type: result.submitted_attainment_type.clone(),
154            })
155    });
156    let settled = |held| ImportAnswer::Settled {
157        held,
158        attainment: result
159            .and_then(|result| {
160                result
161                    .attainment
162                    .as_ref()
163                    .or(result.previous_attainment.as_ref())
164            })
165            .map(registry_attainment),
166    };
167    match settled_state(SuotarEndpoint::ImportAttainments, &item.code) {
168        Some(CreditRegistrationState::AwaitingVerification) => ImportAnswer::Submitted {
169            submission,
170            is_repeat_in_batch: item.code == DUPLICATE_REQUEST_ITEM_CODE,
171        },
172        Some(CreditRegistrationState::Duplicate) => settled(HeldCredit::Duplicate),
173        Some(CreditRegistrationState::NotImproved) => settled(HeldCredit::NotImproved),
174        None if item.status == SuotarItemStatus::Error => ImportAnswer::Refused {
175            code: map_code(SuotarEndpoint::ImportAttainments, &item.code)
176                .unwrap_or(CreditRegistrationErrorCode::Unknown),
177            submission,
178            error_message: error_message(item),
179        },
180        // No other state settles an import.
181        Some(_) | None => ImportAnswer::UnknownSuccessCode,
182    }
183}
184
185pub(super) fn verification_answer(
186    item: &SuotarResponseItem<VerifyAttainmentResult>,
187) -> VerificationAnswer {
188    let result = item.result.as_ref();
189    let reading = match outcome_of(SuotarEndpoint::VerifyAttainments, &item.code) {
190        WireOutcome::Settled(CreditRegistrationState::Registered)
191            if item.status == SuotarItemStatus::Ok =>
192        {
193            match result.and_then(|result| result.attainment.as_ref()) {
194                Some(registered) if registered.attainment_type == ATTAINMENT_TYPE_COURSE_UNIT => {
195                    VerificationReading::Registered {
196                        attainment: registry_attainment(registered),
197                    }
198                }
199                // The assessment item attainment's id can equal the submitted one, and verify
200                // records only the final course unit attainment.
201                _ => VerificationReading::PartiallyRegistered,
202            }
203        }
204        WireOutcome::Unsettled => VerificationReading::Pending {
205            resubmit_not_before: result.and_then(|result| result.retry_after),
206        },
207        WireOutcome::Failure(CreditRegistrationErrorCode::NotRegistered) => {
208            VerificationReading::NotRegistered
209        }
210        WireOutcome::Failure(code) => VerificationReading::Failed { code },
211        WireOutcome::Settled(_) => VerificationReading::Inconclusive,
212    };
213    VerificationAnswer {
214        reading,
215        error_message: error_message(item),
216    }
217}
218
219/// `None` for any answer that is neither verdict.
220pub(super) fn course_code_verdict(
221    item: &SuotarResponseItem<ValidateCourseCodeResult>,
222) -> Option<CourseCodeVerdict> {
223    match outcome_of(SuotarEndpoint::ValidateCourseCodes, &item.code) {
224        WireOutcome::Unsettled if item.status == SuotarItemStatus::Ok => {
225            Some(CourseCodeVerdict::Allowed)
226        }
227        WireOutcome::Failure(CreditRegistrationErrorCode::CourseNotAllowed) => {
228            Some(CourseCodeVerdict::NotAllowed {
229                reason: error_message(item).unwrap_or_default(),
230            })
231        }
232        _ => None,
233    }
234}
235
236/// The people one code's answer lists, or why there is no roster for it.
237pub(super) fn roster(
238    response: &SuotarBatchResponse<EnrolmentsListedResult>,
239    request_item_id: &str,
240    course_code: &CourseCode,
241) -> Result<Vec<RosterPerson>, CreditRegistrationErrorCode> {
242    match response.item(request_item_id) {
243        Some(item) if item.status == SuotarItemStatus::Ok => Ok(item
244            .result
245            .as_ref()
246            .map(|result| result.people.iter().map(roster_person).collect())
247            .unwrap_or_default()),
248        Some(item) => {
249            warn!(
250                course_code = %course_code.as_str(),
251                code = %item.code,
252                "Listing course code failed"
253            );
254            Err(map_code(SuotarEndpoint::ListByCourse, &item.code)
255                .unwrap_or(CreditRegistrationErrorCode::Unknown))
256        }
257        None => {
258            warn!(
259                course_code = %course_code.as_str(),
260                "The study registry did not answer for a course code"
261            );
262            Err(CreditRegistrationErrorCode::UnexpectedResponse)
263        }
264    }
265}
266
267pub(super) fn roster_person(person: &wire::ListedPerson) -> RosterPerson {
268    RosterPerson {
269        student_number: person.student_number.clone(),
270        person_id: person.person_id.clone(),
271        first_names: person.first_names.clone(),
272        last_name: person.last_name.clone(),
273        primary_email: person.primary_email.clone(),
274        secondary_email: person.secondary_email.clone(),
275        enrolment: person.enrolment.as_ref().map(|enrolment| RosterEnrolment {
276            id: enrolment.id.clone(),
277            course_unit_realisation_id: enrolment.course_unit_realisation_id.clone(),
278            state: enrolment.state.clone(),
279            enrolment_date_time: enrolment.enrolment_date_time,
280        }),
281    }
282}
283
284fn enrolment(enrolment: &wire::SuotarEnrolment) -> RegistryEnrolment {
285    RegistryEnrolment {
286        id: enrolment.id.clone(),
287        state: enrolment.state.clone(),
288        kind: enrolment.kind.clone(),
289        course_unit_realisation_id: enrolment.course_unit_realisation_id.clone(),
290        course_unit_realisation_name: enrolment.course_unit_realisation_name.as_ref().map(|name| {
291            LocalizedName {
292                fi: name.fi.clone(),
293                sv: name.sv.clone(),
294                en: name.en.clone(),
295            }
296        }),
297        activity_period: enrolment.activity_period.as_ref().map(date_period),
298        grade_scale_id: enrolment.grade_scale_id.clone(),
299        credits: enrolment.credits.as_ref().map(|range| CreditRange {
300            min: range.min,
301            max: range.max,
302        }),
303        study_right_validity_period: enrolment
304            .study_right_validity_period
305            .as_ref()
306            .map(date_period),
307        enrolment_date_time: enrolment.enrolment_date_time,
308    }
309}
310
311fn date_period(period: &wire::DatePeriod) -> DatePeriod {
312    DatePeriod {
313        start_date: period.start_date,
314        end_date: period.end_date,
315    }
316}