Skip to main content

headless_lms_models/library/credit_registration/
study_registry.rs

1//! What the study registry answers, in the pipeline's own terms. The credit registration worker's
2//! Suotar adapter reads Suotar's wire records into these; nothing here knows the wire format.
3
4use chrono::NaiveDate;
5use secrecy::SecretString;
6
7use super::grade_mapping::MappedGrade;
8use crate::prelude::*;
9
10/// The final attainment type in Sisu; any other type on a registered submission is partial
11/// evidence.
12pub const ATTAINMENT_TYPE_COURSE_UNIT: &str = "CourseUnitAttainment";
13
14/// Serialized into the frozen payload as `{fi, sv, en}`.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
16pub struct LocalizedName {
17    pub fi: Option<String>,
18    pub sv: Option<String>,
19    pub en: Option<String>,
20}
21
22/// Sisu's `LocalDateRange`: start inclusive, end exclusive, either end possibly open.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct DatePeriod {
25    pub start_date: Option<NaiveDate>,
26    pub end_date: Option<NaiveDate>,
27}
28
29impl DatePeriod {
30    /// Whether `date` falls in the range; the end date itself is already outside it, and an open
31    /// end contains every date on that side.
32    pub fn contains(&self, date: NaiveDate) -> bool {
33        self.start_date.is_none_or(|start| start <= date)
34            && self.end_date.is_none_or(|end| date < end)
35    }
36}
37
38/// Sisu's credit range. An import against one missing either bound is refused.
39#[derive(Debug, Clone, PartialEq)]
40pub struct CreditRange {
41    pub min: Option<f64>,
42    pub max: Option<f64>,
43}
44
45/// One of a student's enrolments on a course code. Only the id is certain: every other field may
46/// be missing or unreadable in the registry's data.
47#[derive(Debug, Clone, PartialEq)]
48pub struct RegistryEnrolment {
49    pub id: String,
50    pub state: Option<String>,
51    pub kind: Option<String>,
52    pub course_unit_realisation_id: Option<String>,
53    pub course_unit_realisation_name: Option<LocalizedName>,
54    pub activity_period: Option<DatePeriod>,
55    /// The assessment item's scale, else the course unit's.
56    pub grade_scale_id: Option<String>,
57    /// The course unit's range; `None` when Sisu gives none, which no import can go against.
58    pub credits: Option<CreditRange>,
59    /// `None` when the study right could not be resolved, which is no proof it is invalid.
60    pub study_right_validity_period: Option<DatePeriod>,
61    pub enrolment_date_time: Option<DateTime<Utc>>,
62}
63
64/// An attainment the registry holds, or one a submission of ours created. Every field but the id
65/// and the type may be missing.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct RegistryAttainment {
68    pub id: String,
69    pub attainment_type: String,
70    pub state: Option<String>,
71    pub attainment_date: Option<NaiveDate>,
72    pub registration_date: Option<NaiveDate>,
73    pub grade_scale_id: Option<String>,
74    pub grade_id: Option<String>,
75}
76
77impl RegistryAttainment {
78    /// The grade the registry holds, or `None` when it gave no scale or no grade.
79    pub fn held_grade(&self) -> Option<MappedGrade> {
80        MappedGrade::from_columns(self.grade_scale_id.as_deref(), self.grade_id.as_deref())
81    }
82
83    /// The grade as a timeline line names it, with its scale: "1" is a pass on one scale and a one
84    /// out of five on the other. `None` when the registry gave no grade.
85    pub fn display_grade(&self) -> Option<String> {
86        let grade_id = self.grade_id.as_deref()?;
87        Some(match self.grade_scale_id.as_deref() {
88            Some(scale) => format!("{grade_id} on {scale}"),
89            None => grade_id.to_string(),
90        })
91    }
92}
93
94/// One person a course roster lists, once per realisation they are enrolled on.
95#[derive(Debug, Clone)]
96pub struct RosterPerson {
97    pub student_number: SecretString,
98    pub person_id: SecretString,
99    pub first_names: Option<SecretString>,
100    pub last_name: Option<SecretString>,
101    pub primary_email: Option<SecretString>,
102    pub secondary_email: Option<SecretString>,
103    pub enrolment: Option<RosterEnrolment>,
104}
105
106/// The enrolment a roster lists a person under; every field may be absent.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct RosterEnrolment {
109    pub id: Option<String>,
110    pub course_unit_realisation_id: Option<String>,
111    pub state: Option<String>,
112    pub enrolment_date_time: Option<DateTime<Utc>>,
113}
114
115/// What the pipeline asks of the study registry, one variant per kind of request. The Suotar
116/// adapter maps each to its endpoint.
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
118pub enum RegistryOperation {
119    ResolvePersons,
120    ResolveEnrolments,
121    ImportAttainments,
122    VerifyAttainments,
123    ListCourseRoster,
124    ValidateCourseCodes,
125}
126
127impl RegistryOperation {
128    /// An item this operation never answered is uncertain, not retryable: re-sending it can put a
129    /// second attainment on a real transcript.
130    pub fn creates_attainments(self) -> bool {
131        matches!(self, Self::ImportAttainments)
132    }
133}
134
135/// How a whole request to the study registry failed. Failures of single items are answers, not
136/// these.
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum RegistryErrorKind {
139    /// Our credentials.
140    AuthenticationFailure,
141    /// Our request, including one refused before it left.
142    MalformedRequest,
143    /// Another refusal of the request as a whole.
144    RejectedRequest,
145    /// The registry's own lookups failed before anything was written or sent.
146    TemporarilyUnavailable,
147    ServerError,
148    /// The connection itself failed, so the request provably never arrived.
149    NotDelivered,
150    /// The request left and no answer arrived, as on a timeout.
151    NoAnswer,
152    /// An answer arrived that was not a batch answer.
153    ProtocolViolation,
154}
155
156impl RegistryErrorKind {
157    /// Whether the registry may have acted on the request. An import that may have landed must be
158    /// verified rather than re-sent, or a transcript gets a second attainment.
159    pub fn may_have_been_acted_on(self) -> bool {
160        !matches!(
161            self,
162            Self::AuthenticationFailure
163                | Self::MalformedRequest
164                | Self::RejectedRequest
165                | Self::TemporarilyUnavailable
166                | Self::NotDelivered
167        )
168    }
169
170    /// Whether the registry or the network is down rather than anything being wrong with the
171    /// request, so the same request may succeed once they are back.
172    pub fn is_outage(self) -> bool {
173        matches!(
174            self,
175            Self::TemporarilyUnavailable | Self::ServerError | Self::NotDelivered | Self::NoAnswer
176        )
177    }
178}