Skip to main content

headless_lms_server/controllers/mock_suotar/
world.rs

1//! The simulated Sisu world: entities, the submission lifecycle, the world-shaped behaviours, and
2//! the 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;
9use headless_lms_utils::services::suotar::SuotarEndpoint;
10
11use crate::prelude::*;
12
13pub type StudentNumber = String;
14pub type CourseCode = String;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
18pub enum EnrolmentState {
19    Enrolled,
20    Processing,
21    Rejected,
22    Aborted,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
27pub enum AttainmentState {
28    Attained,
29    Misregistered,
30    Failed,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "camelCase")]
35pub enum RealisationKind {
36    Degree,
37    OpenUniversity,
38}
39
40impl RealisationKind {
41    pub fn as_str(self) -> &'static str {
42        match self {
43            Self::Degree => "degree",
44            Self::OpenUniversity => "openUniversity",
45        }
46    }
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
51pub enum ProductTokenState {
52    Enabled,
53    Disabled,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
58pub enum ProductDocumentState {
59    Active,
60    Draft,
61    Deleted,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "camelCase")]
66pub enum DuplicateDetection {
67    Detect,
68    AllowDoubles,
69}
70
71/// When a submission becomes a real Sisu attainment. There is no clock: something has to transition
72/// it, and which mechanism does is per-submission data rather than a mode the mock runs in.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "camelCase")]
75pub enum Ripeness {
76    /// Registers inside the `import` that creates it, so import answers `registered`.
77    AtImport,
78    /// Only an explicit control transition registers it. What every installed world sets.
79    Manual,
80    /// Registers once more than `calls` verify calls have named it. Unsafe for a spec: every unscoped
81    /// tick's verify sweep burns the count.
82    AutoAfterVerifyCalls { calls: u32 },
83}
84
85#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86#[serde(rename_all = "camelCase")]
87pub enum SubmissionLifecycle {
88    Pending {
89        ripeness: Ripeness,
90    },
91    Registered {
92        attainment_id: String,
93        registered_at: DateTime<Utc>,
94    },
95    Misregistered {
96        attainment_id: String,
97        misregistered_at: DateTime<Utc>,
98    },
99    TimedOutNothingLanded,
100    TimedOutButLanded {
101        ripeness: Ripeness,
102    },
103}
104
105#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
106#[serde(rename_all = "camelCase")]
107pub struct PersonBehaviour {
108    pub ripeness: Option<Ripeness>,
109    /// Per person rather than global: switching it off globally would hide real double submissions
110    /// from every concurrent spec.
111    pub duplicate_detection: Option<DuplicateDetection>,
112}
113
114#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
115#[serde(rename_all = "camelCase")]
116pub struct CourseBehaviour {
117    pub import_allowed: bool,
118}
119
120impl Default for CourseBehaviour {
121    fn default() -> Self {
122        Self {
123            import_allowed: true,
124        }
125    }
126}
127
128/// The wire's own shapes, so a world dump can be edited and pushed straight back.
129pub use super::wire::{CreditRange, DatePeriod, LocalizedName};
130
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132#[serde(rename_all = "camelCase")]
133pub struct MockPerson {
134    pub student_number: StudentNumber,
135    pub person_id: String,
136    pub first_names: String,
137    pub last_name: String,
138    pub primary_email: String,
139    pub secondary_email: Option<String>,
140    #[serde(default)]
141    pub behaviour: PersonBehaviour,
142    /// The account this person belongs to, so a fault can be addressed by user rather than by raw
143    /// student number.
144    pub owner_user_email: Option<String>,
145}
146
147#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
148#[serde(rename_all = "camelCase")]
149pub struct MockRealisation {
150    pub id: String,
151    pub name: LocalizedName,
152    pub assessment_item_id: String,
153    pub kind: RealisationKind,
154    pub activity_period: DatePeriod,
155    pub grade_scale_id: String,
156    pub credits: CreditRange,
157    pub acceptor_person_id: Option<String>,
158    pub open_university_product_id: Option<String>,
159}
160
161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
162#[serde(rename_all = "camelCase")]
163pub struct MockCourseUnit {
164    pub course_code: CourseCode,
165    pub course_unit_id: String,
166    pub name: LocalizedName,
167    pub realisations: Vec<MockRealisation>,
168    #[serde(default)]
169    pub behaviour: CourseBehaviour,
170    /// The courses.mooc.fi course this unit is a module of; a slug spans every module, which is the
171    /// granularity a tick scope has.
172    pub owner_course_slug: Option<String>,
173}
174
175impl MockCourseUnit {
176    pub fn realisation(&self, id: &str) -> Option<&MockRealisation> {
177        self.realisations.iter().find(|r| r.id == id)
178    }
179}
180
181#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
182#[serde(rename_all = "camelCase")]
183pub struct MockEnrolment {
184    pub id: String,
185    pub student_number: StudentNumber,
186    pub course_code: CourseCode,
187    pub realisation_id: String,
188    pub state: EnrolmentState,
189    pub study_right_id: String,
190    pub study_right_validity_period: DatePeriod,
191    pub enrolment_date_time: DateTime<Utc>,
192}
193
194#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
195#[serde(rename_all = "camelCase")]
196pub struct MockAttainment {
197    pub id: String,
198    #[serde(rename = "type")]
199    pub attainment_type: String,
200    pub state: AttainmentState,
201    pub person_id: String,
202    pub student_number: StudentNumber,
203    pub course_code: CourseCode,
204    pub course_unit_id: String,
205    pub assessment_item_id: String,
206    pub course_unit_realisation_id: String,
207    pub attainment_date: NaiveDate,
208    pub registration_date: NaiveDate,
209    pub grade_scale_id: String,
210    pub grade_id: String,
211    pub passed: bool,
212    /// Set when the attainment came from a submission of ours rather than from pushed fixture data.
213    pub from_submission: Option<String>,
214}
215
216impl MockAttainment {
217    /// Shared by `logic::register` (auto/manual ripening) and `commands::transition` (test-forced
218    /// transitions): both mint an attainment from a submission and differ only in the state they land in.
219    pub fn from_submission(
220        submission: &MockSubmission,
221        attainment_id: &str,
222        state: AttainmentState,
223        defaults: &WorldDefaults,
224        now: DateTime<Utc>,
225    ) -> Self {
226        Self {
227            id: attainment_id.to_string(),
228            attainment_type: "CourseUnitAttainment".to_string(),
229            state,
230            person_id: submission.person_id.clone(),
231            student_number: submission.student_number.clone(),
232            course_code: submission.course_code.clone(),
233            course_unit_id: submission.course_unit_id.clone(),
234            assessment_item_id: submission.assessment_item_id.clone(),
235            course_unit_realisation_id: submission.realisation_id.clone(),
236            attainment_date: submission.attainment_date,
237            registration_date: now.date_naive(),
238            grade_scale_id: submission.grade_scale_id.clone(),
239            grade_id: submission.grade_id.clone(),
240            passed: defaults
241                .scale(&submission.grade_scale_id)
242                .and_then(|scale| scale.grade(&submission.grade_id))
243                .is_some_and(|grade| grade.passed),
244            from_submission: Some(submission.submitted_attainment_id.clone()),
245        }
246    }
247}
248
249#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
250#[serde(rename_all = "camelCase")]
251pub struct MockSubmission {
252    pub submitted_attainment_id: String,
253    pub submitted_attainment_type: String,
254    pub student_number: StudentNumber,
255    pub course_code: CourseCode,
256    pub enrolment_id: String,
257    pub realisation_id: String,
258    /// Denormalised from the course unit at import time, so ripening needs only the submission.
259    pub person_id: String,
260    pub course_unit_id: String,
261    pub assessment_item_id: String,
262    pub attainment_date: NaiveDate,
263    pub attainment_language: String,
264    pub grade_scale_id: String,
265    pub grade_id: String,
266    pub credits: f64,
267    pub lifecycle: SubmissionLifecycle,
268    pub verify_calls: u32,
269    pub id_disclosed_to_client: bool,
270    pub created_at: DateTime<Utc>,
271}
272
273#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
274#[serde(rename_all = "camelCase")]
275pub struct MockProductAccessToken {
276    pub open_university_product_id: String,
277    pub id: String,
278    pub access_token: String,
279    pub state: ProductTokenState,
280    pub document_state: ProductDocumentState,
281}
282
283#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
284#[serde(rename_all = "camelCase")]
285pub struct Grade {
286    pub id: String,
287    pub rank: i32,
288    pub passed: bool,
289}
290
291#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
292#[serde(rename_all = "camelCase")]
293pub struct GradeScale {
294    pub id: String,
295    /// Other spellings that resolve to this scale. Responses echo the spelling the world stores.
296    #[serde(default)]
297    pub aliases: Vec<String>,
298    pub grades: Vec<Grade>,
299}
300
301impl GradeScale {
302    pub fn answers_to(&self, id: &str) -> bool {
303        self.id == id || self.aliases.iter().any(|alias| alias == id)
304    }
305
306    pub fn grade(&self, grade_id: &str) -> Option<&Grade> {
307        self.grades.iter().find(|grade| grade.id == grade_id)
308    }
309}
310
311/// Every field defaults, so a partial `defaults` push cannot install an empty accepted token and 401
312/// the whole suite.
313#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
314#[serde(rename_all = "camelCase", default)]
315pub struct WorldDefaults {
316    pub accepted_token: String,
317    pub ripeness: Ripeness,
318    pub duplicate_detection: DuplicateDetection,
319    pub grade_scales: Vec<GradeScale>,
320    pub call_log_capacity: usize,
321    pub include_non_enrolled_in_result: bool,
322    pub realisation_id_required: bool,
323    /// Suotar has not said what code a statically unknown grade id gets; this tries the alternative
324    /// reading without a code change.
325    pub static_grade_error_code: Option<String>,
326}
327
328impl Default for WorldDefaults {
329    fn default() -> Self {
330        Self {
331            accepted_token: headless_lms_base::config::MOCK_SUOTAR_TOKEN.to_string(),
332            ripeness: Ripeness::Manual,
333            duplicate_detection: DuplicateDetection::Detect,
334            grade_scales: default_grade_scales(),
335            call_log_capacity: 2000,
336            include_non_enrolled_in_result: false,
337            realisation_id_required: false,
338            static_grade_error_code: None,
339        }
340    }
341}
342
343impl WorldDefaults {
344    pub fn scale(&self, id: &str) -> Option<&GradeScale> {
345        self.grade_scales.iter().find(|scale| scale.answers_to(id))
346    }
347
348    /// An unknown grade id is a request-level rejection rather than a per-item error.
349    pub fn any_scale_has_grade(&self, grade_id: &str) -> bool {
350        self.grade_scales
351            .iter()
352            .any(|scale| scale.grade(grade_id).is_some())
353    }
354}
355
356/// TODO: Suotar has not confirmed whether the pass/fail scale id is `sis-hyv-hyl` or `sis-hyl-hyv`,
357/// so both spellings resolve to one scale.
358pub fn default_grade_scales() -> Vec<GradeScale> {
359    vec![
360        GradeScale {
361            id: "sis-hyl-hyv".to_string(),
362            aliases: vec!["sis-hyv-hyl".to_string()],
363            grades: vec![
364                Grade {
365                    id: "0".to_string(),
366                    rank: 0,
367                    passed: false,
368                },
369                Grade {
370                    id: "1".to_string(),
371                    rank: 1,
372                    passed: true,
373                },
374            ],
375        },
376        GradeScale {
377            id: "sis-0-5".to_string(),
378            aliases: Vec::new(),
379            grades: (0..=5)
380                .map(|value| Grade {
381                    id: value.to_string(),
382                    rank: value,
383                    passed: value >= 1,
384                })
385                .collect(),
386        },
387    ]
388}
389
390#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
391#[serde(rename_all = "camelCase")]
392pub struct MissedFault {
393    pub fault_id: String,
394    /// The one predicate that failed. Best-effort: recorded only when everything else matched.
395    pub predicate: String,
396}
397
398#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
399#[serde(rename_all = "camelCase")]
400pub struct RecordedFaults {
401    pub applied: Vec<String>,
402    /// Faults an earlier match beat on the same request, stage and blast radius.
403    pub shadowed: Vec<String>,
404    pub missed: Vec<MissedFault>,
405}
406
407#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
408#[serde(rename_all = "camelCase")]
409pub struct RecordedItem {
410    pub request_item_id: String,
411    pub student_number: Option<String>,
412    pub course_code: Option<String>,
413    pub submitted_attainment_id: Option<String>,
414    pub product_id: Option<String>,
415    pub status: String,
416    pub code: String,
417}
418
419/// One entry of the mock's own call log: unscrubbed fake data, capped, never fed to the audited
420/// tables.
421#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
422#[serde(rename_all = "camelCase")]
423pub struct RecordedCall {
424    pub seq: u64,
425    pub received_at: DateTime<Utc>,
426    pub endpoint: SuotarEndpoint,
427    pub correlation_id: Option<String>,
428    pub authorized: bool,
429    pub http_status: u16,
430    pub request_level_code: Option<String>,
431    pub effect: Option<String>,
432    pub raw_body_truncated: String,
433    pub faults: RecordedFaults,
434    pub items: Vec<RecordedItem>,
435}
436
437/// One change the resolution logic wants persisted, one Redis command each.
438#[derive(Debug, Clone, PartialEq)]
439pub enum WorldWrite {
440    UpsertSubmission(String),
441    UpsertAttainment(String),
442    IndexSubmission {
443        student_number: StudentNumber,
444        course_code: CourseCode,
445        id: String,
446    },
447    IndexAttainment {
448        student_number: StudentNumber,
449        course_code: CourseCode,
450        id: String,
451    },
452}
453
454/// The slice of the world one request needs, read once and written back once.
455#[derive(Debug, Clone, Default)]
456pub struct WorkingSet {
457    pub defaults: WorldDefaults,
458    pub persons: BTreeMap<StudentNumber, MockPerson>,
459    pub course_units: BTreeMap<CourseCode, MockCourseUnit>,
460    pub enrolments: BTreeMap<String, MockEnrolment>,
461    pub attainments: BTreeMap<String, MockAttainment>,
462    pub submissions: BTreeMap<String, MockSubmission>,
463    pub product_tokens: BTreeMap<String, MockProductAccessToken>,
464    /// Existing attainment ids per `{studentNumber}|{courseCode}`.
465    pub attainments_by_person_course: BTreeMap<String, Vec<String>>,
466    /// Submitted attainment ids per `{studentNumber}|{courseCode}`.
467    pub submissions_by_person_course: BTreeMap<String, Vec<String>>,
468    pub enrolments_by_person: BTreeMap<StudentNumber, Vec<String>>,
469    pub enrolments_by_realisation: BTreeMap<String, Vec<String>>,
470    pub writes: Vec<WorldWrite>,
471}
472
473pub fn person_course_key(student_number: &str, course_code: &str) -> String {
474    format!("{student_number}|{course_code}")
475}
476
477impl WorkingSet {
478    pub fn ripeness_for(&self, student_number: &str) -> Ripeness {
479        self.persons
480            .get(student_number)
481            .and_then(|person| person.behaviour.ripeness)
482            .unwrap_or(self.defaults.ripeness)
483    }
484
485    pub fn duplicate_detection_for(&self, student_number: &str) -> DuplicateDetection {
486        self.persons
487            .get(student_number)
488            .and_then(|person| person.behaviour.duplicate_detection)
489            .unwrap_or(self.defaults.duplicate_detection)
490    }
491}