Skip to main content

headless_lms_server/mock_suotar/
world.rs

1//! The simulated Sisu and Suotar world: entities, the per-submission send and importer state, and the
2//! per-request working set the endpoints resolve over.
3//!
4//! Plain values only, so the resolution logic stays a pure function over an in-memory slice.
5
6use std::collections::BTreeMap;
7
8use chrono::NaiveDate;
9
10use crate::prelude::*;
11
12use super::ids;
13use super::wire::{ASSESSMENT_ITEM_ATTAINMENT, COURSE_UNIT_ATTAINMENT, Endpoint};
14pub use super::wire::{CreditRange, DatePeriod, LocalizedName};
15
16pub type StudentNumber = String;
17pub type CourseCode = String;
18
19/// Elements of AI and Building AI, which Suotar refuses whatever its course table says.
20pub const REFUSED_COURSE_CODES: [&str; 9] = [
21    "TKT21018",
22    "AYTKT21018",
23    "AYTKT21018fi",
24    "AYTKT21018sv",
25    "TKT210281",
26    "TKT210282",
27    "AYTKT21028en",
28    "AYTKT210281en",
29    "AYTKT210282en",
30];
31
32/// How long an unconfirmed send stays `submissionPending` before verify calls it `notRegistered`.
33pub const PENDING_WINDOW_HOURS: i64 = 24;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
37pub enum EnrolmentState {
38    Enrolled,
39    Processing,
40    Rejected,
41    Aborted,
42}
43
44/// `Misregistered` stands for Sisu's separate misregistration flag, which the wire never shows: such
45/// an attainment reads as `ATTAINED` where it is listed at all, and resolve leaves it out.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
48pub enum AttainmentState {
49    Attained,
50    Misregistered,
51    Failed,
52}
53
54impl AttainmentState {
55    pub fn wire_state(self) -> &'static str {
56        match self {
57            Self::Attained | Self::Misregistered => "ATTAINED",
58            Self::Failed => "FAILED",
59        }
60    }
61}
62
63/// Which audience a fixture realisation serves; only picks the default ids. The wire's `kind` comes
64/// from the enrolment's study right id instead.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub enum RealisationKind {
68    Degree,
69    OpenUniversity,
70}
71
72impl RealisationKind {
73    pub fn as_str(self) -> &'static str {
74        match self {
75            Self::Degree => "degree",
76            Self::OpenUniversity => "openUniversity",
77        }
78    }
79}
80
81/// Suotar's `entries.sendState`.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
84pub enum SendState {
85    NotSent,
86    Attempted,
87    Accepted,
88    Rejected,
89}
90
91/// What the importer's copy of Sisu holds for a submission. Only a control transition changes it.
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(rename_all = "camelCase", tag = "kind")]
94pub enum ImporterVisibility {
95    None,
96    /// An assessment-item attainment under the submission's own id.
97    #[serde(rename_all = "camelCase")]
98    Partial {
99        attainment_id: String,
100    },
101    #[serde(rename_all = "camelCase")]
102    Final {
103        attainment_id: String,
104    },
105    #[serde(rename_all = "camelCase")]
106    Misregistered {
107        attainment_id: String,
108    },
109}
110
111#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
112#[serde(rename_all = "camelCase", default)]
113pub struct PersonBehaviour {
114    /// The per-person study-right fallback finds nothing, so an enrolment without a study right of
115    /// its own answers `studyRightNotValid`.
116    pub study_right_unresolvable: bool,
117}
118
119#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
120#[serde(rename_all = "camelCase", default)]
121pub struct CourseBehaviour {
122    /// The realisations have no responsible teachers, which Sisu refuses at send time.
123    pub no_acceptors: bool,
124    /// The importer's acceptor lookup fails, which is a request-level 503 before anything is written.
125    pub acceptor_lookup_fails: bool,
126}
127
128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
129#[serde(rename_all = "camelCase")]
130pub struct MockPerson {
131    pub student_number: StudentNumber,
132    pub person_id: String,
133    pub first_names: Option<String>,
134    pub last_name: Option<String>,
135    pub primary_email: Option<String>,
136    pub secondary_email: Option<String>,
137    #[serde(default)]
138    pub behaviour: PersonBehaviour,
139    /// The account this person belongs to, so a fault can be addressed by user rather than by raw
140    /// student number.
141    pub owner_user_email: Option<String>,
142}
143
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
145#[serde(rename_all = "camelCase")]
146pub struct MockRealisation {
147    pub id: String,
148    pub name: Option<LocalizedName>,
149    pub assessment_item_id: String,
150    pub kind: RealisationKind,
151    /// Without one the realisation is left out of list-by-course.
152    pub activity_period: Option<DatePeriod>,
153    /// The assessment item's own scale, which wins over the course unit's.
154    pub grade_scale_id: Option<String>,
155}
156
157#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
158#[serde(rename_all = "camelCase")]
159pub struct SuotarCourse {
160    pub name: String,
161}
162
163#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
164#[serde(rename_all = "camelCase")]
165pub struct MockCourseUnit {
166    pub course_code: CourseCode,
167    pub course_unit_id: String,
168    pub name: LocalizedName,
169    pub credits: Option<CreditRange>,
170    pub grade_scale_id: Option<String>,
171    pub realisations: Vec<MockRealisation>,
172    /// Suotar's own course row. Without it import and validation refuse the code.
173    pub suotar_course: Option<SuotarCourse>,
174    #[serde(default)]
175    pub behaviour: CourseBehaviour,
176    /// The courses.mooc.fi course this unit is a module of; a slug spans every module, which is the
177    /// granularity a tick scope has.
178    pub owner_course_slug: Option<String>,
179}
180
181impl MockCourseUnit {
182    pub fn realisation(&self, id: &str) -> Option<&MockRealisation> {
183        self.realisations.iter().find(|r| r.id == id)
184    }
185
186    /// The scale an enrolment on this realisation is graded on.
187    pub fn grade_scale_for(&self, realisation: &MockRealisation) -> Option<String> {
188        realisation
189            .grade_scale_id
190            .clone()
191            .or_else(|| self.grade_scale_id.clone())
192    }
193}
194
195#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
196#[serde(rename_all = "camelCase")]
197pub struct MockStudyRight {
198    pub validity: DatePeriod,
199    pub grant_date: Option<NaiveDate>,
200}
201
202#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
203#[serde(rename_all = "camelCase")]
204pub struct MockEnrolment {
205    pub id: String,
206    pub student_number: StudentNumber,
207    pub course_code: CourseCode,
208    pub realisation_id: String,
209    pub state: EnrolmentState,
210    pub study_right_id: Option<String>,
211    /// Absent when the study right did not come back from the importer.
212    pub study_right: Option<MockStudyRight>,
213    /// Absent when the importer hands over no enrolment time.
214    pub enrolment_date_time: Option<DateTime<Utc>>,
215}
216
217impl MockEnrolment {
218    /// An enrolment made at `now` on a study right valid for a year either side of it.
219    pub fn enrolled_now(
220        student_number: &str,
221        course_code: &str,
222        realisation: &MockRealisation,
223        now: DateTime<Utc>,
224    ) -> Self {
225        Self {
226            id: ids::enrolment_id(student_number, course_code, realisation.kind),
227            student_number: student_number.to_string(),
228            course_code: course_code.to_string(),
229            realisation_id: realisation.id.clone(),
230            state: EnrolmentState::Enrolled,
231            study_right_id: Some(ids::study_right_id(student_number, realisation.kind)),
232            study_right: Some(MockStudyRight {
233                validity: DatePeriod {
234                    start_date: (now - chrono::Duration::days(365)).date_naive(),
235                    end_date: Some((now + chrono::Duration::days(365)).date_naive()),
236                },
237                grant_date: None,
238            }),
239            enrolment_date_time: Some(now),
240        }
241    }
242
243    /// Sisu has no field for it; Suotar reads it off the study right id.
244    pub fn kind(&self) -> &'static str {
245        if self
246            .study_right_id
247            .as_deref()
248            .is_some_and(|id| id.contains("avoin"))
249        {
250            RealisationKind::OpenUniversity.as_str()
251        } else {
252            RealisationKind::Degree.as_str()
253        }
254    }
255}
256
257/// Which of Sisu's two attainments a submission produces.
258#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259pub enum AttainmentLevel {
260    /// The assessment item attainment.
261    Partial,
262    /// The course unit attainment built from it.
263    Final,
264}
265
266#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
267#[serde(rename_all = "camelCase")]
268pub struct MockAttainment {
269    pub id: String,
270    #[serde(rename = "type")]
271    pub attainment_type: String,
272    pub state: AttainmentState,
273    pub person_id: String,
274    pub student_number: StudentNumber,
275    pub course_code: CourseCode,
276    pub course_unit_id: String,
277    pub assessment_item_id: Option<String>,
278    pub course_unit_realisation_id: Option<String>,
279    pub attainment_date: NaiveDate,
280    pub registration_date: NaiveDate,
281    pub grade_scale_id: String,
282    pub grade_id: String,
283    pub passed: Option<bool>,
284    /// Never on the wire, but Suotar's duplicate and improvement checks compare it. Absent counts as
285    /// equal to whatever is being imported.
286    pub credits: Option<f64>,
287    /// Set when the attainment came from a submission of ours rather than from pushed fixture data.
288    pub from_submission: Option<String>,
289}
290
291impl MockAttainment {
292    /// A partial attainment is the assessment-item one under the submission's own id; a final one is
293    /// the course-unit attainment Sisu builds from it.
294    pub fn from_submission(
295        submission: &MockSubmission,
296        attainment_id: &str,
297        level: AttainmentLevel,
298        state: AttainmentState,
299        defaults: &WorldDefaults,
300        now: DateTime<Utc>,
301    ) -> Self {
302        let (attainment_type, assessment_item_id, course_unit_realisation_id) = match level {
303            AttainmentLevel::Final => (COURSE_UNIT_ATTAINMENT, None, None),
304            AttainmentLevel::Partial => (
305                ASSESSMENT_ITEM_ATTAINMENT,
306                Some(submission.assessment_item_id.clone()),
307                Some(submission.realisation_id.clone()),
308            ),
309        };
310        Self {
311            id: attainment_id.to_string(),
312            attainment_type: attainment_type.to_string(),
313            state,
314            person_id: submission.person_id.clone(),
315            student_number: submission.student_number.clone(),
316            course_code: submission.course_code.clone(),
317            course_unit_id: submission.course_unit_id.clone(),
318            assessment_item_id,
319            course_unit_realisation_id,
320            attainment_date: submission.adjusted_attainment_date,
321            registration_date: now.date_naive(),
322            grade_scale_id: submission.grade_scale_id.clone(),
323            grade_id: submission.grade_id.clone(),
324            passed: defaults
325                .scale(&submission.grade_scale_id)
326                .and_then(|scale| scale.grade(&submission.grade_id))
327                .map(|grade| grade.passed),
328            credits: Some(submission.credits),
329            from_submission: Some(submission.submitted_attainment_id.clone()),
330        }
331    }
332}
333
334/// One of Suotar's `entries` rows: a completion written by an import, with what became of its Sisu
335/// send and what the importer has seen of it since.
336#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
337#[serde(rename_all = "camelCase")]
338pub struct MockSubmission {
339    /// `hy-kur-<uuid>`, the id Sisu is given.
340    pub submitted_attainment_id: String,
341    pub request_item_id: String,
342    pub student_number: StudentNumber,
343    pub course_code: CourseCode,
344    pub enrolment_id: String,
345    pub realisation_id: String,
346    pub person_id: String,
347    pub course_unit_id: String,
348    pub assessment_item_id: String,
349    pub attainment_date: NaiveDate,
350    /// Clamped into the study right; never reported back to the client.
351    pub adjusted_attainment_date: NaiveDate,
352    pub attainment_language: String,
353    pub grade_scale_id: String,
354    pub grade_id: String,
355    pub credits: f64,
356    pub send_state: SendState,
357    pub violations: Vec<String>,
358    pub importer: ImporterVisibility,
359    pub created_at: DateTime<Utc>,
360}
361
362#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
363#[serde(rename_all = "camelCase")]
364pub struct Grade {
365    pub id: String,
366    pub passed: bool,
367}
368
369#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
370#[serde(rename_all = "camelCase")]
371pub struct GradeScale {
372    pub id: String,
373    pub grades: Vec<Grade>,
374}
375
376impl GradeScale {
377    pub fn grade(&self, grade_id: &str) -> Option<&Grade> {
378        self.grades.iter().find(|grade| grade.id == grade_id)
379    }
380}
381
382/// Every field defaults, so a partial `defaults` push cannot install an empty accepted token and 401
383/// the whole suite.
384#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
385#[serde(rename_all = "camelCase", default)]
386pub struct WorldDefaults {
387    pub accepted_token: String,
388    /// The scales Suotar can map a grade onto; any other scale answers `invalidGradeForGradeScale`.
389    pub grade_scales: Vec<GradeScale>,
390    pub call_log_capacity: usize,
391}
392
393impl Default for WorldDefaults {
394    fn default() -> Self {
395        Self {
396            accepted_token: headless_lms_base::config::MOCK_SUOTAR_TOKEN.to_string(),
397            grade_scales: default_grade_scales(),
398            call_log_capacity: 2000,
399        }
400    }
401}
402
403impl WorldDefaults {
404    pub fn scale(&self, id: &str) -> Option<&GradeScale> {
405        self.grade_scales.iter().find(|scale| scale.id == id)
406    }
407}
408
409pub fn default_grade_scales() -> Vec<GradeScale> {
410    vec![
411        GradeScale {
412            id: "sis-hyl-hyv".to_string(),
413            grades: vec![
414                Grade {
415                    id: "0".to_string(),
416                    passed: false,
417                },
418                Grade {
419                    id: "1".to_string(),
420                    passed: true,
421                },
422            ],
423        },
424        GradeScale {
425            id: "sis-0-5".to_string(),
426            grades: (0..=5)
427                .map(|value| Grade {
428                    id: value.to_string(),
429                    passed: value >= 1,
430                })
431                .collect(),
432        },
433    ]
434}
435
436#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
437#[serde(rename_all = "camelCase")]
438pub struct MissedFault {
439    pub fault_id: String,
440    /// The one predicate that failed. Best-effort: recorded only when everything else matched.
441    pub predicate: String,
442}
443
444#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
445#[serde(rename_all = "camelCase")]
446pub struct RecordedFaults {
447    pub applied: Vec<String>,
448    /// Faults an earlier match beat on the same request, stage and blast radius.
449    pub shadowed: Vec<String>,
450    pub missed: Vec<MissedFault>,
451}
452
453#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
454#[serde(rename_all = "camelCase")]
455pub struct RecordedItem {
456    pub request_item_id: String,
457    pub student_number: Option<String>,
458    pub course_code: Option<String>,
459    pub submitted_attainment_id: Option<String>,
460    /// `dropped` for an item a fault left out of the response; `code` is then what it would have said.
461    /// `unanswered`, with no `code`, when the whole request was refused before any item was answered.
462    pub status: String,
463    pub code: String,
464}
465
466/// One entry of the mock's own call log: unscrubbed fake data, capped, never fed to the audited
467/// tables.
468#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
469#[serde(rename_all = "camelCase")]
470pub struct RecordedCall {
471    pub seq: u64,
472    pub received_at: DateTime<Utc>,
473    pub endpoint: Endpoint,
474    pub correlation_id: Option<String>,
475    pub authorized: bool,
476    pub http_status: u16,
477    pub request_level_code: Option<String>,
478    pub effect: Option<String>,
479    pub raw_body_truncated: String,
480    pub faults: RecordedFaults,
481    pub items: Vec<RecordedItem>,
482}
483
484/// One change the resolution logic wants persisted, one Redis command each.
485#[derive(Debug, Clone, PartialEq)]
486pub enum WorldWrite {
487    UpsertSubmission(String),
488    IndexSubmission {
489        student_number: StudentNumber,
490        course_code: CourseCode,
491    },
492}
493
494/// The slice of the world one request needs, read once and written back once.
495#[derive(Debug, Clone, Default)]
496pub struct WorkingSet {
497    pub defaults: WorldDefaults,
498    pub persons: BTreeMap<StudentNumber, MockPerson>,
499    pub course_units: BTreeMap<CourseCode, MockCourseUnit>,
500    pub enrolments: BTreeMap<String, MockEnrolment>,
501    pub attainments: BTreeMap<String, MockAttainment>,
502    pub submissions: BTreeMap<String, MockSubmission>,
503    /// Sisu's refusals per `{studentNumber}|{courseCode}`.
504    pub sisu_violations: BTreeMap<String, Vec<String>>,
505    /// Existing attainment ids per `{studentNumber}|{courseCode}`.
506    pub attainments_by_person_course: BTreeMap<String, Vec<String>>,
507    /// Submitted attainment ids per `{studentNumber}|{courseCode}`.
508    pub submissions_by_person_course: BTreeMap<String, Vec<String>>,
509    pub enrolments_by_person: BTreeMap<StudentNumber, Vec<String>>,
510    pub enrolments_by_realisation: BTreeMap<String, Vec<String>>,
511    pub writes: Vec<WorldWrite>,
512}
513
514pub fn person_course_key(student_number: &str, course_code: &str) -> String {
515    format!("{student_number}|{course_code}")
516}