Skip to main content

headless_lms_server/mock_suotar/
logic.rs

1//! Per-item world-state resolution: pure functions over the working set, with `now` passed in.
2//!
3//! The caller resolves faults ahead of this, so per item the order is first matching fault, then here.
4
5use chrono::{Duration, Months, NaiveDate};
6
7use crate::prelude::*;
8
9use super::ids;
10use super::wire::{
11    self, AttainmentReference, AttainmentSummary, COURSE_NOT_CARRIED, CourseAllowedResult,
12    DuplicateAttainmentResult, Endpoint, EnrolmentResolutionResult, EnrolmentsListedResult,
13    ExistingAttainment, ExistingAttainmentsResult, ListedEnrolment, ListedPerson,
14    NotImprovedAttainmentResult, RecentlySentAttainment, RecentlySentDuplicateResult,
15    RegisteredResult, ResponseItem, SubmissionPendingResult, SubmittedAttainment, iso_millis,
16    sisu_midnight,
17};
18use super::world::{
19    AttainmentState, EnrolmentState, ImporterVisibility, MockAttainment, MockCourseUnit,
20    MockEnrolment, MockRealisation, MockStudyRight, MockSubmission, PENDING_WINDOW_HOURS,
21    REFUSED_COURSE_CODES, SendState, WorkingSet, WorldWrite, person_course_key,
22};
23
24/// Suotar's approver violation, as its translation table words it.
25const NO_ACCEPTORS_VIOLATION: &str = "Approver is required.";
26
27pub fn resolve_person_item(
28    item: &wire::ResolvePersonRequestItem,
29    working: &WorkingSet,
30) -> ResponseItem {
31    match working.persons.get(&item.student_number) {
32        Some(person) => ResponseItem::ok(
33            &item.request_item_id,
34            "personFound",
35            wire::PersonResult {
36                student_number: person.student_number.clone(),
37                person_id: person.person_id.clone(),
38                first_names: person.first_names.clone(),
39                last_name: person.last_name.clone(),
40            },
41        ),
42        None => ResponseItem::error(
43            Endpoint::ResolvePersons,
44            &item.request_item_id,
45            "personNotFound",
46        ),
47    }
48}
49
50pub fn resolve_enrolments_item(
51    item: &wire::ResolveEnrolmentRequestItem,
52    working: &WorkingSet,
53) -> ResponseItem {
54    let endpoint = Endpoint::ResolveEnrolments;
55    let id = &item.request_item_id;
56    if !working.persons.contains_key(&item.student_number) {
57        return ResponseItem::error(endpoint, id, "personNotFound");
58    }
59    let Some(course_unit) = working.course_units.get(&item.course_code) else {
60        return ResponseItem::error(endpoint, id, "courseCodeNotFound");
61    };
62
63    // The importer leaves misregistered attainments out of this listing.
64    let mut existing: Vec<&MockAttainment> =
65        attainments_for(working, &item.student_number, &item.course_code)
66            .into_iter()
67            .filter(|attainment| attainment.state != AttainmentState::Misregistered)
68            .collect();
69    existing.sort_by(|a, b| {
70        a.attainment_date
71            .cmp(&b.attainment_date)
72            .then_with(|| a.id.cmp(&b.id))
73    });
74    let existing_attainments: Vec<ExistingAttainment> =
75        existing.into_iter().map(existing_attainment).collect();
76
77    // The importer hands over ENROLLED enrolments only, so `enrolmentNotAccepted` is unreachable.
78    let mut enrolled: Vec<&MockEnrolment> =
79        enrolments_for(working, &item.student_number, &item.course_code)
80            .into_iter()
81            .filter(|enrolment| enrolment.state == EnrolmentState::Enrolled)
82            .collect();
83    enrolled.sort_by_key(|enrolment| enrolment.enrolment_date_time);
84    let enrolments: Vec<wire::Enrolment> = enrolled
85        .into_iter()
86        .filter_map(|enrolment| {
87            course_unit
88                .realisation(&enrolment.realisation_id)
89                .map(|realisation| enrolment_dto(course_unit, enrolment, realisation))
90        })
91        .collect();
92    if enrolments.is_empty() {
93        return ResponseItem::error(endpoint, id, "enrolmentNotFound").with_result(
94            ExistingAttainmentsResult {
95                existing_attainments,
96            },
97        );
98    }
99
100    ResponseItem::ok(
101        id,
102        "enrolmentFound",
103        EnrolmentResolutionResult {
104            enrolments,
105            existing_attainments,
106        },
107    )
108}
109
110/// What resolving one import item comes to before anything is written.
111pub enum ImportResolution {
112    Answered(ResponseItem),
113    /// Survived every check; the submission to write, not yet sent.
114    Write(Box<MockSubmission>),
115}
116
117/// Suotar's checks in its own order, first failure wins.
118pub fn resolve_import_item(
119    item: &wire::ImportAttainmentRequestItem,
120    working: &WorkingSet,
121    now: DateTime<Utc>,
122) -> ImportResolution {
123    let endpoint = Endpoint::ImportAttainments;
124    let id = &item.request_item_id;
125    let error = |code: &str| ImportResolution::Answered(ResponseItem::error(endpoint, id, code));
126    let error_with_message = |code: &str, message: String| {
127        ImportResolution::Answered(ResponseItem::error_with_message(id, code, message))
128    };
129
130    let course_unit = working.course_units.get(&item.course_code);
131    if let Some(reason) = course_not_allowed_reason(&item.course_code, course_unit) {
132        return error_with_message("courseNotAllowed", reason);
133    }
134    let Some(course_unit) = course_unit else {
135        return error_with_message("courseNotAllowed", COURSE_NOT_CARRIED.to_string());
136    };
137    let Some(person) = working.persons.get(&item.student_number) else {
138        return error("personNotFound");
139    };
140    let enrolment = working
141        .enrolments
142        .get(&item.enrolment_id)
143        .filter(|enrolment| {
144            enrolment.student_number == item.student_number
145                && enrolment.course_code == item.course_code
146                && enrolment.state == EnrolmentState::Enrolled
147        });
148    let Some((enrolment, realisation)) = enrolment.and_then(|enrolment| {
149        course_unit
150            .realisation(&enrolment.realisation_id)
151            .map(|realisation| (enrolment, realisation))
152    }) else {
153        return error("enrolmentNotFound");
154    };
155
156    let Some(credits) = &course_unit.credits else {
157        return error_with_message(
158            "invalidCredits",
159            format!(
160                "Sisu gives no credit range for course {}.",
161                item.course_code
162            ),
163        );
164    };
165    // An open maximum refuses every positive amount, as JavaScript's `credits <= null` does.
166    if item.credits < credits.min || credits.max.is_none_or(|max| item.credits > max) {
167        return error_with_message(
168            "invalidCredits",
169            format!(
170                "Credits must be between {} and {} for course {}.",
171                credits.min,
172                credits
173                    .max
174                    .map_or_else(|| "null".to_string(), |max| max.to_string()),
175                item.course_code
176            ),
177        );
178    }
179
180    let grade_scale_id = course_unit.grade_scale_for(realisation);
181    if let Some(expected) = &grade_scale_id
182        && &item.grade_scale_id != expected
183    {
184        return error_with_message(
185            "gradeScaleMismatch",
186            format!(
187                "Grade scale {} was sent, but the enrolment is graded on {expected}.",
188                item.grade_scale_id
189            ),
190        );
191    }
192    let is_known_grade = grade_scale_id
193        .as_deref()
194        .and_then(|scale_id| working.defaults.scale(scale_id))
195        .is_some_and(|scale| scale.grade(&item.grade_id).is_some());
196    if !is_known_grade {
197        return error("invalidGradeForGradeScale");
198    }
199
200    let incoming = IncomingGrade::of(&item.grade_scale_id, &item.grade_id);
201    let mut earlier: Vec<&MockAttainment> =
202        attainments_for(working, &item.student_number, &item.course_code)
203            .into_iter()
204            .filter(|attainment| attainment.state != AttainmentState::Misregistered)
205            .collect();
206    earlier.sort_by_key(|attainment| std::cmp::Reverse(attainment.attainment_date));
207    // Suotar reports the newest attainment on the course, not necessarily the one that decided.
208    if let Some(newest) = earlier.first()
209        && earlier
210            .iter()
211            .any(|attainment| is_identical(attainment, incoming, item, working))
212    {
213        return ImportResolution::Answered(ResponseItem::ok(
214            id,
215            "duplicateAttainment",
216            DuplicateAttainmentResult {
217                attainment: attainment_summary(newest),
218            },
219        ));
220    }
221    if let Some(recent) = recently_accepted(working, item, now) {
222        return ImportResolution::Answered(ResponseItem::ok(
223            id,
224            "duplicateAttainment",
225            RecentlySentDuplicateResult {
226                attainment: RecentlySentAttainment {
227                    id: recent.submitted_attainment_id.clone(),
228                    attainment_type: wire::ASSESSMENT_ITEM_ATTAINMENT.to_string(),
229                    attainment_date: recent.adjusted_attainment_date,
230                    grade_scale_id: recent.grade_scale_id.clone(),
231                    grade_id: recent.grade_id.clone(),
232                },
233            },
234        ));
235    }
236    if let Some(newest) = earlier.first()
237        && !earlier
238            .iter()
239            .all(|attainment| beats(incoming, attainment, item, working))
240    {
241        return ImportResolution::Answered(ResponseItem::ok(
242            id,
243            "notImprovedAttainment",
244            NotImprovedAttainmentResult {
245                previous_attainment: attainment_summary(newest),
246            },
247        ));
248    }
249
250    let adjusted_attainment_date = match &enrolment.study_right {
251        Some(study_right) => clamp_into_study_right(item.attainment_date(), study_right),
252        None if person.behaviour.study_right_unresolvable => return error("studyRightNotValid"),
253        None => item.attainment_date(),
254    };
255
256    ImportResolution::Write(Box::new(MockSubmission {
257        submitted_attainment_id: ids::submitted_attainment_id(),
258        request_item_id: id.clone(),
259        student_number: item.student_number.clone(),
260        course_code: item.course_code.clone(),
261        enrolment_id: enrolment.id.clone(),
262        realisation_id: realisation.id.clone(),
263        person_id: person.person_id.clone(),
264        course_unit_id: course_unit.course_unit_id.clone(),
265        assessment_item_id: realisation.assessment_item_id.clone(),
266        attainment_date: item.attainment_date(),
267        adjusted_attainment_date,
268        attainment_language: item.attainment_language.clone(),
269        grade_scale_id: item.grade_scale_id.clone(),
270        grade_id: item.grade_id.clone(),
271        credits: item.credits,
272        send_state: SendState::NotSent,
273        violations: Vec::new(),
274        importer: ImporterVisibility::None,
275        created_at: now,
276    }))
277}
278
279/// How far back Suotar checks its own accepted sends, which the importer's copy of Sisu may not show
280/// yet.
281pub(super) const RECENTLY_ACCEPTED_HOURS: i64 = 24;
282
283/// A send Sisu accepted within [`RECENTLY_ACCEPTED_HOURS`] of the same completion. Not matched on the
284/// date, which Suotar's cron runs vary for one completion.
285fn recently_accepted<'a>(
286    working: &'a WorkingSet,
287    item: &wire::ImportAttainmentRequestItem,
288    now: DateTime<Utc>,
289) -> Option<&'a MockSubmission> {
290    working
291        .submissions_by_person_course
292        .get(&person_course_key(&item.student_number, &item.course_code))
293        .into_iter()
294        .flatten()
295        .filter_map(|id| working.submissions.get(id))
296        .find(|submission| {
297            submission.send_state == SendState::Accepted
298                && submission.created_at >= now - Duration::hours(RECENTLY_ACCEPTED_HOURS)
299                && submission.credits == item.credits
300                && submission.grade_scale_id == item.grade_scale_id
301                && submission.grade_id == item.grade_id
302        })
303}
304
305/// Whether the batch's acceptor lookup would fail for any of these submissions' course units.
306pub fn acceptor_lookup_fails(working: &WorkingSet, submissions: &[MockSubmission]) -> bool {
307    submissions.iter().any(|submission| {
308        working
309            .course_units
310            .get(&submission.course_code)
311            .is_some_and(|unit| unit.behaviour.acceptor_lookup_fails)
312    })
313}
314
315/// Writes the submission and sends it: refused by Sisu when the world holds violations for it, accepted
316/// otherwise.
317pub fn write_and_send(working: &mut WorkingSet, mut submission: MockSubmission) -> ResponseItem {
318    let mut violations = working
319        .sisu_violations
320        .get(&person_course_key(
321            &submission.student_number,
322            &submission.course_code,
323        ))
324        .cloned()
325        .unwrap_or_default();
326    if working
327        .course_units
328        .get(&submission.course_code)
329        .is_some_and(|unit| unit.behaviour.no_acceptors)
330    {
331        violations.push(NO_ACCEPTORS_VIOLATION.to_string());
332    }
333    submission.send_state = if violations.is_empty() {
334        SendState::Accepted
335    } else {
336        SendState::Rejected
337    };
338    submission.violations = violations;
339    let outcome = import_outcome(&submission);
340    record_submission(working, submission);
341    outcome
342}
343
344/// Read back from the submission, the way Suotar answers after its send.
345pub fn import_outcome(submission: &MockSubmission) -> ResponseItem {
346    let id = &submission.request_item_id;
347    match submission.send_state {
348        SendState::Rejected => {
349            let message = if submission.violations.is_empty() {
350                "Sisu rejected the attainment.".to_string()
351            } else {
352                format!(
353                    "Sisu rejected the attainment: {}",
354                    submission.violations.join("; ")
355                )
356            };
357            ResponseItem::error_with_message(id, "sisuValidationFailed", message)
358        }
359        SendState::Accepted => ResponseItem::ok(
360            id,
361            "sent",
362            SubmittedAttainment::new(&submission.submitted_attainment_id),
363        ),
364        SendState::Attempted | SendState::NotSent => {
365            ResponseItem::error(Endpoint::ImportAttainments, id, "sisuTimeout").with_result(
366                SubmittedAttainment::new(&submission.submitted_attainment_id),
367            )
368        }
369    }
370}
371
372pub fn verify_item(
373    item: &wire::VerifyAttainmentRequestItem,
374    working: &WorkingSet,
375    now: DateTime<Utc>,
376) -> ResponseItem {
377    let endpoint = Endpoint::VerifyAttainments;
378    let id = &item.request_item_id;
379    let registered = |attainment_id: &str, attainment_type: &str| {
380        ResponseItem::ok(
381            id,
382            "registered",
383            RegisteredResult {
384                attainment: AttainmentReference {
385                    id: attainment_id.to_string(),
386                    attainment_type: attainment_type.to_string(),
387                },
388            },
389        )
390    };
391
392    if let Some(submission) = working.submissions.get(&item.submitted_attainment_id) {
393        return match &submission.importer {
394            ImporterVisibility::Misregistered { .. } => {
395                ResponseItem::error(endpoint, id, "misregistered")
396            }
397            ImporterVisibility::Partial { attainment_id } => {
398                registered(attainment_id, wire::ASSESSMENT_ITEM_ATTAINMENT)
399            }
400            ImporterVisibility::Final { attainment_id } => {
401                registered(attainment_id, wire::COURSE_UNIT_ATTAINMENT)
402            }
403            ImporterVisibility::None if is_pending(submission, now) => {
404                ResponseItem::error(endpoint, id, "submissionPending").with_result(
405                    SubmissionPendingResult {
406                        submitted_attainment_id: submission.submitted_attainment_id.clone(),
407                        submitted_attainment_type: wire::ASSESSMENT_ITEM_ATTAINMENT.to_string(),
408                        retry_after: iso_millis(retry_after(submission, now)),
409                    },
410                )
411            }
412            ImporterVisibility::None => ResponseItem::error(endpoint, id, "notRegistered"),
413        };
414    }
415    match working.attainments.get(&item.submitted_attainment_id) {
416        Some(attainment) if attainment.state == AttainmentState::Misregistered => {
417            ResponseItem::error(endpoint, id, "misregistered")
418        }
419        Some(attainment) => registered(&attainment.id, &attainment.attainment_type),
420        None => ResponseItem::error(endpoint, id, "notRegistered"),
421    }
422}
423
424/// An accepted send stays pending for good: Sisu took it, so `notRegistered` would invite a second.
425fn is_pending(submission: &MockSubmission, now: DateTime<Utc>) -> bool {
426    match submission.send_state {
427        SendState::Accepted => true,
428        SendState::Attempted => submission.created_at > now - Duration::hours(PENDING_WINDOW_HOURS),
429        SendState::NotSent | SendState::Rejected => false,
430    }
431}
432
433fn retry_after(submission: &MockSubmission, now: DateTime<Utc>) -> DateTime<Utc> {
434    let scheduled = submission.created_at + Duration::hours(PENDING_WINDOW_HOURS);
435    if scheduled > now {
436        scheduled
437    } else {
438        now + Duration::hours(PENDING_WINDOW_HOURS)
439    }
440}
441
442/// Only realisations whose activity period ends after two months ago are listed; one with no end
443/// date never is.
444pub fn list_by_course_item(
445    item: &wire::CourseCodeRequestItem,
446    working: &WorkingSet,
447    now: DateTime<Utc>,
448) -> ResponseItem {
449    let id = &item.request_item_id;
450    let not_found = || ResponseItem::error(Endpoint::ListByCourse, id, "courseCodeNotFound");
451    let Some(course_unit) = working.course_units.get(&item.course_code) else {
452        return not_found();
453    };
454    let cutoff = (now - Months::new(2)).date_naive();
455    let current: Vec<&MockRealisation> = course_unit
456        .realisations
457        .iter()
458        .filter(|realisation| {
459            realisation
460                .activity_period
461                .as_ref()
462                .is_some_and(|period| period.end_date.is_some_and(|end| end > cutoff))
463        })
464        .collect();
465    if current.is_empty() {
466        return not_found();
467    }
468
469    let people = current
470        .into_iter()
471        .flat_map(|realisation| {
472            let mut enrolments: Vec<&MockEnrolment> = working
473                .enrolments_by_realisation
474                .get(&realisation.id)
475                .into_iter()
476                .flatten()
477                .filter_map(|enrolment_id| working.enrolments.get(enrolment_id))
478                .filter(|enrolment| enrolment.state == EnrolmentState::Enrolled)
479                .collect();
480            enrolments.sort_by(|a, b| a.student_number.cmp(&b.student_number));
481            enrolments
482        })
483        .filter_map(|enrolment| {
484            let person = working.persons.get(&enrolment.student_number)?;
485            Some(ListedPerson {
486                student_number: person.student_number.clone(),
487                person_id: person.person_id.clone(),
488                first_names: person.first_names.clone(),
489                last_name: person.last_name.clone(),
490                primary_email: person.primary_email.clone(),
491                secondary_email: person.secondary_email.clone(),
492                enrolment: ListedEnrolment {
493                    id: enrolment.id.clone(),
494                    course_unit_realisation_id: enrolment.realisation_id.clone(),
495                    state: "ENROLLED".to_string(),
496                    enrolment_date_time: enrolment.enrolment_date_time.map(iso_millis),
497                },
498            })
499        })
500        .collect();
501
502    ResponseItem::ok(id, "enrolmentsListed", EnrolmentsListedResult { people })
503}
504
505pub fn validate_course_code_item(
506    item: &wire::CourseCodeRequestItem,
507    working: &WorkingSet,
508) -> ResponseItem {
509    let course_unit = working.course_units.get(&item.course_code);
510    match (
511        course_not_allowed_reason(&item.course_code, course_unit),
512        course_unit.and_then(|unit| unit.suotar_course.as_ref()),
513    ) {
514        (None, Some(course)) => ResponseItem::ok(
515            &item.request_item_id,
516            "courseAllowed",
517            CourseAllowedResult {
518                course_code: item.course_code.clone(),
519                name: course.name.clone(),
520            },
521        ),
522        (reason, _) => ResponseItem::error_with_message(
523            &item.request_item_id,
524            "courseNotAllowed",
525            reason.unwrap_or_else(|| COURSE_NOT_CARRIED.to_string()),
526        ),
527    }
528}
529
530fn course_not_allowed_reason(
531    course_code: &str,
532    course_unit: Option<&MockCourseUnit>,
533) -> Option<String> {
534    if REFUSED_COURSE_CODES.contains(&course_code) {
535        return Some(format!(
536            "{course_code} cannot be registered through this API yet."
537        ));
538    }
539    if course_unit
540        .and_then(|unit| unit.suotar_course.as_ref())
541        .is_none()
542    {
543        return Some(COURSE_NOT_CARRIED.to_string());
544    }
545    None
546}
547
548pub fn record_submission(working: &mut WorkingSet, submission: MockSubmission) {
549    let id = submission.submitted_attainment_id.clone();
550    let key = person_course_key(&submission.student_number, &submission.course_code);
551    let write = WorldWrite::IndexSubmission {
552        student_number: submission.student_number.clone(),
553        course_code: submission.course_code.clone(),
554    };
555    working.submissions.insert(id.clone(), submission);
556    let index = working.submissions_by_person_course.entry(key).or_default();
557    if !index.contains(&id) {
558        index.push(id.clone());
559    }
560    working.writes.push(WorldWrite::UpsertSubmission(id));
561    working.writes.push(write);
562}
563
564/// A date on or before the start moves to the start, one on or after the end to the day before it,
565/// and nothing may precede a grant date that falls inside the validity.
566fn clamp_into_study_right(date: NaiveDate, study_right: &MockStudyRight) -> NaiveDate {
567    let validity = &study_right.validity;
568    let mut adjusted = match validity.end_date {
569        _ if date <= validity.start_date => validity.start_date,
570        Some(end_date) if date >= end_date => end_date - Duration::days(1),
571        _ => date,
572    };
573    if let Some(grant_date) = study_right.grant_date
574        && grant_date > validity.start_date
575        && validity
576            .end_date
577            .is_none_or(|end_date| grant_date < end_date)
578        && adjusted < grant_date
579    {
580        adjusted = grant_date;
581    }
582    adjusted
583}
584
585/// The Finnish grade string Suotar ranks by: 1–5, "Hyv." or a fail.
586#[derive(Debug, Clone, Copy, PartialEq, Eq)]
587enum IncomingGrade {
588    Numeric(i32),
589    Pass,
590    Fail,
591}
592
593impl IncomingGrade {
594    fn of(grade_scale_id: &str, grade_id: &str) -> Self {
595        match (grade_scale_id, grade_id.parse::<i32>().ok()) {
596            ("sis-0-5", Some(value @ 1..=5)) => Self::Numeric(value),
597            ("sis-hyl-hyv", Some(1)) => Self::Pass,
598            _ => Self::Fail,
599        }
600    }
601}
602
603/// A pass/fail attainment has no numeric correspondence, which Suotar compares as zero.
604fn numeric_grade(attainment: &MockAttainment) -> i32 {
605    if attainment.grade_scale_id == "sis-0-5" {
606        attainment.grade_id.parse().unwrap_or(0)
607    } else {
608        0
609    }
610}
611
612fn is_passed(attainment: &MockAttainment, working: &WorkingSet) -> bool {
613    attainment.passed.unwrap_or_else(|| {
614        working
615            .defaults
616            .scale(&attainment.grade_scale_id)
617            .and_then(|scale| scale.grade(&attainment.grade_id))
618            .is_some_and(|grade| grade.passed)
619    })
620}
621
622fn credits_of(attainment: &MockAttainment, item: &wire::ImportAttainmentRequestItem) -> f64 {
623    attainment.credits.unwrap_or(item.credits)
624}
625
626fn is_identical(
627    attainment: &MockAttainment,
628    incoming: IncomingGrade,
629    item: &wire::ImportAttainmentRequestItem,
630    working: &WorkingSet,
631) -> bool {
632    let same_grade = match incoming {
633        IncomingGrade::Numeric(value) => {
634            attainment.grade_scale_id == "sis-0-5" && numeric_grade(attainment) == value
635        }
636        IncomingGrade::Pass => {
637            attainment.grade_scale_id == "sis-hyl-hyv" && attainment.grade_id == "1"
638        }
639        IncomingGrade::Fail => !is_passed(attainment, working),
640    };
641    same_grade
642        && attainment.attainment_date == item.attainment_date()
643        && credits_of(attainment, item) == item.credits
644}
645
646/// Higher grade wins; with equal grades more credits win; with those equal too a later date wins,
647/// by more than a day on the 0–5 scale. A fail never beats a pass.
648fn beats(
649    incoming: IncomingGrade,
650    attainment: &MockAttainment,
651    item: &wire::ImportAttainmentRequestItem,
652    working: &WorkingSet,
653) -> bool {
654    let credits = credits_of(attainment, item);
655    let later_than =
656        |days: i64| item.attainment_date() - Duration::days(days) > attainment.attainment_date;
657    let tie_break = |days: i64| {
658        if credits != item.credits {
659            credits < item.credits
660        } else {
661            later_than(days)
662        }
663    };
664    match incoming {
665        IncomingGrade::Numeric(value) => {
666            let existing = numeric_grade(attainment);
667            if existing != value {
668                existing < value
669            } else {
670                tie_break(1)
671            }
672        }
673        IncomingGrade::Pass => !is_passed(attainment, working) || tie_break(0),
674        IncomingGrade::Fail => !is_passed(attainment, working) && tie_break(0),
675    }
676}
677
678fn attainments_for<'a>(
679    working: &'a WorkingSet,
680    student_number: &str,
681    course_code: &str,
682) -> Vec<&'a MockAttainment> {
683    working
684        .attainments_by_person_course
685        .get(&person_course_key(student_number, course_code))
686        .into_iter()
687        .flatten()
688        .filter_map(|id| working.attainments.get(id))
689        .collect()
690}
691
692fn existing_attainment(attainment: &MockAttainment) -> ExistingAttainment {
693    ExistingAttainment {
694        id: attainment.id.clone(),
695        attainment_type: attainment.attainment_type.clone(),
696        state: attainment.state.wire_state().to_string(),
697        person_id: attainment.person_id.clone(),
698        course_unit_id: attainment.course_unit_id.clone(),
699        assessment_item_id: attainment.assessment_item_id.clone(),
700        course_unit_realisation_id: attainment.course_unit_realisation_id.clone(),
701        attainment_date: sisu_midnight(attainment.attainment_date),
702        registration_date: sisu_midnight(attainment.registration_date),
703        grade_scale_id: attainment.grade_scale_id.clone(),
704        grade_id: attainment.grade_id.clone(),
705        passed: attainment.passed,
706    }
707}
708
709fn attainment_summary(attainment: &MockAttainment) -> AttainmentSummary {
710    AttainmentSummary {
711        id: attainment.id.clone(),
712        attainment_type: attainment.attainment_type.clone(),
713        state: attainment.state.wire_state().to_string(),
714        attainment_date: sisu_midnight(attainment.attainment_date),
715        registration_date: sisu_midnight(attainment.registration_date),
716        grade_scale_id: attainment.grade_scale_id.clone(),
717        grade_id: attainment.grade_id.clone(),
718    }
719}
720
721fn enrolments_for<'a>(
722    working: &'a WorkingSet,
723    student_number: &str,
724    course_code: &str,
725) -> Vec<&'a MockEnrolment> {
726    working
727        .enrolments_by_person
728        .get(student_number)
729        .into_iter()
730        .flatten()
731        .filter_map(|id| working.enrolments.get(id))
732        .filter(|enrolment| enrolment.course_code == course_code)
733        .collect()
734}
735
736fn enrolment_dto(
737    course_unit: &MockCourseUnit,
738    enrolment: &MockEnrolment,
739    realisation: &MockRealisation,
740) -> wire::Enrolment {
741    wire::Enrolment {
742        id: enrolment.id.clone(),
743        state: "ENROLLED".to_string(),
744        kind: enrolment.kind().to_string(),
745        course_unit_id: course_unit.course_unit_id.clone(),
746        assessment_item_id: realisation.assessment_item_id.clone(),
747        course_unit_realisation_id: realisation.id.clone(),
748        course_unit_realisation_name: realisation.name.clone(),
749        activity_period: realisation.activity_period.clone(),
750        grade_scale_id: course_unit.grade_scale_for(realisation),
751        credits: course_unit.credits.clone(),
752        study_right_id: enrolment.study_right_id.clone(),
753        study_right_validity_period: enrolment
754            .study_right
755            .as_ref()
756            .map(|study_right| study_right.validity.clone()),
757        enrolment_date_time: enrolment.enrolment_date_time.map(iso_millis),
758    }
759}
760
761#[cfg(test)]
762mod tests {
763    use super::super::world::{
764        CourseBehaviour, CreditRange, DatePeriod, LocalizedName, MockPerson, PersonBehaviour,
765        RealisationKind, SuotarCourse,
766    };
767    use super::*;
768
769    const STUDENT_NUMBER: &str = "090001019";
770    const COURSE_CODE: &str = "CRS-101";
771
772    fn world() -> WorkingSet {
773        let now = Utc::now();
774        let period = DatePeriod {
775            start_date: (now - Duration::days(30)).date_naive(),
776            end_date: Some((now + Duration::days(30)).date_naive()),
777        };
778        let name = LocalizedName {
779            fi: COURSE_CODE.to_string(),
780            sv: COURSE_CODE.to_string(),
781            en: COURSE_CODE.to_string(),
782        };
783        let realisation = MockRealisation {
784            id: ids::realisation_id(COURSE_CODE, RealisationKind::Degree),
785            name: Some(name.clone()),
786            assessment_item_id: ids::assessment_item_id(COURSE_CODE, RealisationKind::Degree),
787            kind: RealisationKind::Degree,
788            activity_period: Some(period.clone()),
789            grade_scale_id: None,
790        };
791        let enrolment = MockEnrolment {
792            id: ids::enrolment_id(STUDENT_NUMBER, COURSE_CODE, RealisationKind::Degree),
793            student_number: STUDENT_NUMBER.to_string(),
794            course_code: COURSE_CODE.to_string(),
795            realisation_id: realisation.id.clone(),
796            state: EnrolmentState::Enrolled,
797            study_right_id: Some(ids::study_right_id(STUDENT_NUMBER, RealisationKind::Degree)),
798            study_right: Some(MockStudyRight {
799                validity: period,
800                grant_date: None,
801            }),
802            enrolment_date_time: Some(now),
803        };
804        WorkingSet {
805            persons: [(
806                STUDENT_NUMBER.to_string(),
807                MockPerson {
808                    student_number: STUDENT_NUMBER.to_string(),
809                    person_id: ids::person_id(STUDENT_NUMBER),
810                    first_names: Some("Zzyzx".to_string()),
811                    last_name: Some("Happypath".to_string()),
812                    primary_email: Some("zzyzx.happypath@helsinki.example.com".to_string()),
813                    secondary_email: None,
814                    behaviour: PersonBehaviour::default(),
815                    owner_user_email: None,
816                },
817            )]
818            .into(),
819            course_units: [(
820                COURSE_CODE.to_string(),
821                MockCourseUnit {
822                    course_code: COURSE_CODE.to_string(),
823                    course_unit_id: ids::course_unit_id(COURSE_CODE),
824                    name,
825                    credits: Some(CreditRange {
826                        min: 5.0,
827                        max: Some(5.0),
828                    }),
829                    grade_scale_id: Some("sis-hyl-hyv".to_string()),
830                    realisations: vec![realisation],
831                    suotar_course: Some(SuotarCourse {
832                        name: COURSE_CODE.to_string(),
833                    }),
834                    behaviour: CourseBehaviour::default(),
835                    owner_course_slug: None,
836                },
837            )]
838            .into(),
839            enrolments: [(enrolment.id.clone(), enrolment)].into(),
840            ..Default::default()
841        }
842    }
843
844    /// The write is queued before the response is shaped, which is what makes "timed out, but it
845    /// landed" different from "timed out, nothing landed".
846    #[test]
847    fn an_import_queues_its_submission_before_any_response_shaping() {
848        let mut working = world();
849        let item = wire::ImportAttainmentRequestItem {
850            request_item_id: "item-1".to_string(),
851            student_number: STUDENT_NUMBER.to_string(),
852            course_code: COURSE_CODE.to_string(),
853            enrolment_id: ids::enrolment_id(STUDENT_NUMBER, COURSE_CODE, RealisationKind::Degree),
854            attained_at: Utc::now(),
855            attainment_language: "en".to_string(),
856            grade_scale_id: "sis-hyl-hyv".to_string(),
857            grade_id: "1".to_string(),
858            credits: 5.0,
859        };
860        let ImportResolution::Write(submission) = resolve_import_item(&item, &working, Utc::now())
861        else {
862            panic!("a well-formed import resolves to a write");
863        };
864        let id = submission.submitted_attainment_id.clone();
865        let response = write_and_send(&mut working, *submission);
866        assert_eq!(response.code, "sent");
867        assert!(working.submissions.contains_key(&id));
868        assert!(
869            working
870                .writes
871                .contains(&WorldWrite::UpsertSubmission(id.clone()))
872        );
873    }
874}