headless_lms_credit_registration/runtime/suotar/
decode.rs1use 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
39pub(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
90pub(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 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 _ => 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
219pub(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
236pub(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}