1use chrono::{NaiveDate, SecondsFormat};
10
11use crate::prelude::*;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum Endpoint {
17 ResolvePersons,
18 ResolveEnrolments,
19 ImportAttainments,
20 VerifyAttainments,
21 ListByCourse,
22 ValidateCourseCodes,
23}
24
25impl Endpoint {
26 pub fn max_batch_size(self) -> usize {
27 match self {
28 Self::ImportAttainments => 100,
29 Self::ListByCourse => 50,
30 Self::ResolvePersons
31 | Self::ResolveEnrolments
32 | Self::VerifyAttainments
33 | Self::ValidateCourseCodes => 1000,
34 }
35 }
36}
37
38pub const ASSESSMENT_ITEM_ATTAINMENT: &str = "AssessmentItemAttainment";
39pub const COURSE_UNIT_ATTAINMENT: &str = "CourseUnitAttainment";
40
41#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
42#[serde(rename_all = "camelCase")]
43pub struct ResolvePersonRequestItem {
44 pub request_item_id: String,
45 pub student_number: String,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
49#[serde(rename_all = "camelCase")]
50pub struct ResolveEnrolmentRequestItem {
51 pub request_item_id: String,
52 pub student_number: String,
53 pub course_code: String,
54}
55
56#[derive(Debug, Clone, PartialEq, Deserialize)]
57#[serde(rename_all = "camelCase")]
58pub struct ImportAttainmentRequestItem {
59 pub request_item_id: String,
60 pub student_number: String,
61 pub course_code: String,
62 pub enrolment_id: String,
63 #[serde(rename = "attainmentDate")]
64 pub attained_at: DateTime<Utc>,
65 pub attainment_language: String,
66 pub grade_scale_id: String,
67 pub grade_id: String,
68 pub credits: f64,
69}
70
71impl ImportAttainmentRequestItem {
72 pub fn attainment_date(&self) -> NaiveDate {
74 self.attained_at.date_naive()
75 }
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
79#[serde(rename_all = "camelCase")]
80pub struct VerifyAttainmentRequestItem {
81 pub request_item_id: String,
82 pub submitted_attainment_id: String,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
87#[serde(rename_all = "camelCase")]
88pub struct CourseCodeRequestItem {
89 pub request_item_id: String,
90 pub course_code: String,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "camelCase")]
95pub struct LocalizedName {
96 pub fi: String,
97 pub sv: String,
98 pub en: String,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "camelCase")]
103pub struct DatePeriod {
104 pub start_date: NaiveDate,
105 pub end_date: Option<NaiveDate>,
107}
108
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
110#[serde(rename_all = "camelCase")]
111pub struct CreditRange {
112 pub min: f64,
113 pub max: Option<f64>,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
119#[serde(rename_all = "camelCase")]
120pub struct PersonResult {
121 pub student_number: String,
122 pub person_id: String,
123 pub first_names: Option<String>,
124 pub last_name: Option<String>,
125}
126
127#[derive(Debug, Clone, PartialEq, Serialize)]
128#[serde(rename_all = "camelCase")]
129pub struct Enrolment {
130 pub id: String,
131 pub state: String,
132 pub kind: String,
133 pub course_unit_id: String,
134 pub assessment_item_id: String,
135 pub course_unit_realisation_id: String,
136 #[serde(skip_serializing_if = "Option::is_none")]
137 pub course_unit_realisation_name: Option<LocalizedName>,
138 #[serde(skip_serializing_if = "Option::is_none")]
139 pub activity_period: Option<DatePeriod>,
140 #[serde(skip_serializing_if = "Option::is_none")]
141 pub grade_scale_id: Option<String>,
142 pub credits: Option<CreditRange>,
143 pub study_right_id: Option<String>,
144 #[serde(skip_serializing_if = "Option::is_none")]
145 pub study_right_validity_period: Option<DatePeriod>,
146 #[serde(skip_serializing_if = "Option::is_none")]
148 pub enrolment_date_time: Option<String>,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
154#[serde(rename_all = "camelCase")]
155pub struct ExistingAttainment {
156 pub id: String,
157 #[serde(rename = "type")]
158 pub attainment_type: String,
159 pub state: String,
160 pub person_id: String,
161 pub course_unit_id: String,
162 #[serde(skip_serializing_if = "Option::is_none")]
163 pub assessment_item_id: Option<String>,
164 #[serde(skip_serializing_if = "Option::is_none")]
165 pub course_unit_realisation_id: Option<String>,
166 pub attainment_date: String,
168 pub registration_date: String,
169 pub grade_scale_id: String,
170 #[serde(serialize_with = "number_when_numeric")]
172 pub grade_id: String,
173 #[serde(skip_serializing_if = "Option::is_none")]
174 pub passed: Option<bool>,
175}
176
177#[derive(Debug, Clone, PartialEq, Serialize)]
178#[serde(rename_all = "camelCase")]
179pub struct EnrolmentResolutionResult {
180 pub enrolments: Vec<Enrolment>,
181 pub existing_attainments: Vec<ExistingAttainment>,
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
186#[serde(rename_all = "camelCase")]
187pub struct ExistingAttainmentsResult {
188 pub existing_attainments: Vec<ExistingAttainment>,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
193#[serde(rename_all = "camelCase")]
194pub struct AttainmentSummary {
195 pub id: String,
196 #[serde(rename = "type")]
197 pub attainment_type: String,
198 pub state: String,
199 pub attainment_date: String,
201 pub registration_date: String,
202 pub grade_scale_id: String,
203 pub grade_id: String,
204}
205
206#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
209#[serde(rename_all = "camelCase")]
210pub struct RecentlySentAttainment {
211 pub id: String,
212 #[serde(rename = "type")]
213 pub attainment_type: String,
214 pub attainment_date: NaiveDate,
216 pub grade_scale_id: String,
217 pub grade_id: String,
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
221#[serde(rename_all = "camelCase")]
222pub struct SubmittedAttainment {
223 pub submitted_attainment_id: String,
224 pub submitted_attainment_type: String,
225}
226
227impl SubmittedAttainment {
228 pub fn new(submitted_attainment_id: &str) -> Self {
229 Self {
230 submitted_attainment_id: submitted_attainment_id.to_string(),
231 submitted_attainment_type: ASSESSMENT_ITEM_ATTAINMENT.to_string(),
232 }
233 }
234}
235
236#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
237#[serde(rename_all = "camelCase")]
238pub struct DuplicateAttainmentResult {
239 pub attainment: AttainmentSummary,
240}
241
242#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
243#[serde(rename_all = "camelCase")]
244pub struct RecentlySentDuplicateResult {
245 pub attainment: RecentlySentAttainment,
246}
247
248#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
249#[serde(rename_all = "camelCase")]
250pub struct NotImprovedAttainmentResult {
251 pub previous_attainment: AttainmentSummary,
252}
253
254#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
255#[serde(rename_all = "camelCase")]
256pub struct AttainmentReference {
257 pub id: String,
258 #[serde(rename = "type")]
259 pub attainment_type: String,
260}
261
262#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
263#[serde(rename_all = "camelCase")]
264pub struct RegisteredResult {
265 pub attainment: AttainmentReference,
266}
267
268#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
269#[serde(rename_all = "camelCase")]
270pub struct SubmissionPendingResult {
271 pub submitted_attainment_id: String,
272 pub submitted_attainment_type: String,
273 pub retry_after: String,
274}
275
276#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
277#[serde(rename_all = "camelCase")]
278pub struct ListedEnrolment {
279 pub id: String,
280 pub course_unit_realisation_id: String,
281 pub state: String,
282 #[serde(skip_serializing_if = "Option::is_none")]
283 pub enrolment_date_time: Option<String>,
284}
285
286#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
287#[serde(rename_all = "camelCase")]
288pub struct ListedPerson {
289 pub student_number: String,
290 pub person_id: String,
291 pub first_names: Option<String>,
292 pub last_name: Option<String>,
293 pub primary_email: Option<String>,
294 pub secondary_email: Option<String>,
295 pub enrolment: ListedEnrolment,
296}
297
298#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
299#[serde(rename_all = "camelCase")]
300pub struct EnrolmentsListedResult {
301 pub people: Vec<ListedPerson>,
302}
303
304#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
305#[serde(rename_all = "camelCase")]
306pub struct CourseAllowedResult {
307 pub course_code: String,
308 pub name: String,
309}
310
311#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
312#[serde(rename_all = "camelCase")]
313pub enum ItemStatus {
314 Ok,
315 Error,
316}
317
318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
319pub struct ItemError {
320 pub message: String,
321}
322
323#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
326#[serde(rename_all = "camelCase")]
327pub struct ResponseItem {
328 pub request_item_id: String,
329 pub status: ItemStatus,
330 pub code: String,
331 #[serde(skip_serializing_if = "Option::is_none")]
332 pub error: Option<ItemError>,
333 #[serde(skip_serializing_if = "Option::is_none")]
336 pub result: Option<serde_json::Value>,
337}
338
339impl ResponseItem {
340 pub fn ok<R: Serialize>(request_item_id: &str, code: &str, result: R) -> Self {
341 Self {
342 request_item_id: request_item_id.to_string(),
343 status: ItemStatus::Ok,
344 code: code.to_string(),
345 error: None,
346 result: None,
347 }
348 .with_result(result)
349 }
350
351 pub fn error(endpoint: Endpoint, request_item_id: &str, code: &str) -> Self {
353 Self::error_with_message(
354 request_item_id,
355 code,
356 canonical_message(Some(endpoint), code),
357 )
358 }
359
360 pub fn error_with_message(request_item_id: &str, code: &str, message: String) -> Self {
361 Self {
362 request_item_id: request_item_id.to_string(),
363 status: ItemStatus::Error,
364 code: code.to_string(),
365 error: Some(ItemError { message }),
366 result: None,
367 }
368 }
369
370 pub fn with_result<R: Serialize>(mut self, result: R) -> Self {
371 self.result = Some(serde_json::to_value(result).unwrap_or(serde_json::Value::Null));
372 self
373 }
374}
375
376#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
377pub struct RequestLevelErrorBody {
378 pub code: String,
379 pub message: String,
380}
381
382#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
383pub struct RequestLevelError {
384 pub error: RequestLevelErrorBody,
385}
386
387impl RequestLevelError {
388 pub fn new(code: &str) -> Self {
389 Self::with_message(code, canonical_message(None, code))
390 }
391
392 pub fn with_message(code: &str, message: String) -> Self {
393 Self {
394 error: RequestLevelErrorBody {
395 code: code.to_string(),
396 message,
397 },
398 }
399 }
400}
401
402fn canonical_message(endpoint: Option<Endpoint>, code: &str) -> String {
406 match code {
407 "requestTooLarge" => "Request body is too large.",
408 "unauthorized" => "Missing or invalid credentials.",
409 "internalError" => "Suotar failed to process the request.",
410 "serviceTemporarilyUnavailable" => "Failed to fetch Sisu data.",
411 "malformedRequest" => NOT_AN_ARRAY,
412 "personNotFound" => "No Sisu person was found for the supplied student number.",
413 "courseCodeNotFound" => "Course code could not be resolved in Sisu.",
414 "enrolmentNotFound" if endpoint == Some(Endpoint::ImportAttainments) => {
415 "No ENROLLED Sisu enrolment was found for this student and course code."
416 }
417 "enrolmentNotFound" => "No Sisu enrolment was found for this person and course.",
418 "enrolmentNotAccepted" => "The Sisu enrolment has not been accepted.",
419 "duplicateRequestItem" => {
420 "An earlier request item in this batch is the same completion, and it was registered once. Verify the attainment in `result` rather than submitting this completion again."
421 }
422 "invalidGradeForGradeScale" => {
423 "Grade id is not valid for the resolved enrolment's grade scale."
424 }
425 "studyRightNotValid" => "Study right cannot support the attainment.",
426 "sisuTimeout" => "Sisu operation timed out; outcome is uncertain.",
427 "notRegistered" => {
428 "No final or partial Sisu registration evidence was found for the submitted attainment id."
429 }
430 "submissionPending" => {
431 "This attainment was submitted too recently for Sisu to have shown it to Suotar yet. Keep polling; do not resubmit before retryAfter."
432 }
433 "misregistered" => {
434 "A previously registered attainment has been marked misregistered in Sisu."
435 }
436 "courseNotAllowed" => COURSE_NOT_CARRIED,
437 "sisuValidationFailed" => "Sisu rejected the attainment.",
438 _ => "Suotar returned an unspecified outcome.",
439 }
440 .to_string()
441}
442
443pub const COURSE_NOT_CARRIED: &str = "Suotar does not carry this course code.";
444
445pub const NOT_AN_ARRAY: &str = "Request body must be a JSON array of request items.";
446
447fn number_when_numeric<S: serde::Serializer>(
448 value: &str,
449 serializer: S,
450) -> Result<S::Ok, S::Error> {
451 match value.parse::<i64>() {
452 Ok(number) => serializer.serialize_i64(number),
453 Err(_) => serializer.serialize_str(value),
454 }
455}
456
457pub fn iso_millis(time: DateTime<Utc>) -> String {
459 time.to_rfc3339_opts(SecondsFormat::Millis, true)
460}
461
462pub fn sisu_midnight(date: NaiveDate) -> String {
464 format!("{date}T00:00:00.000Z")
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470
471 #[test]
472 fn a_per_item_error_serializes_to_the_documented_shape() {
473 let item = ResponseItem::error(Endpoint::ResolvePersons, "b2", "personNotFound");
474 assert_eq!(
475 serde_json::to_value(&item).expect("serializes"),
476 serde_json::json!({
477 "requestItemId": "b2",
478 "status": "error",
479 "code": "personNotFound",
480 "error": { "message": "No Sisu person was found for the supplied student number." }
481 })
482 );
483 }
484}