Skip to main content

headless_lms_models/credit_registrations/
registration.rs

1//! The ledger row itself: creating it and reading it back whole.
2
3use super::state::{CreditRegistrationErrorCode, CreditRegistrationState, ResubmissionFacts};
4use crate::credit_registration_events::{CreditRegistrationEventKind, NewCreditRegistrationEvent};
5use crate::library::credit_registration::enrolment_check_schedule::{
6    EnrolmentCheckGroup, EnrolmentCheckSource,
7};
8use crate::library::credit_registration::grade_mapping::MappedGrade;
9use crate::prelude::*;
10
11#[derive(Debug, Deserialize, Clone)]
12pub struct CreditRegistration {
13    pub id: Uuid,
14    pub created_at: DateTime<Utc>,
15    pub updated_at: DateTime<Utc>,
16    pub deleted_at: Option<DateTime<Utc>>,
17    pub course_module_completion_id: Uuid,
18    pub user_id: Uuid,
19    pub course_id: Uuid,
20    pub course_module_id: Uuid,
21    pub course_instance_id: Uuid,
22    pub state: CreditRegistrationState,
23    pub state_entered_at: DateTime<Utc>,
24    pub error_code: Option<CreditRegistrationErrorCode>,
25    pub error_message: Option<String>,
26    pub needs_admin_attention: bool,
27    pub enrolment_banner_dismissed_at: Option<DateTime<Utc>>,
28    pub student_number: Option<DbSecret>,
29    pub sisu_person_id: Option<DbSecret>,
30    pub uh_course_code: Option<String>,
31    pub selected_enrolment_id: Option<String>,
32    pub selected_enrolment_kind: Option<String>,
33    pub selected_enrolment_realisation_id: Option<String>,
34    pub attained_at: Option<DateTime<Utc>>,
35    pub attainment_language: Option<String>,
36    pub grade_scale_id: Option<String>,
37    pub grade_id: Option<String>,
38    pub credits: Option<f32>,
39    pub submitted_attainment_id: Option<String>,
40    pub submitted_attainment_type: Option<String>,
41    pub sisu_attainment_id: Option<String>,
42    pub sisu_attainment_type: Option<String>,
43    pub submit_retry_count: i32,
44    pub verify_attempt_count: i32,
45    pub next_attempt_at: DateTime<Utc>,
46    pub first_failed_at: Option<DateTime<Utc>>,
47    pub last_attempt_at: Option<DateTime<Utc>>,
48    pub attempt_number: i32,
49    pub superseded_by_id: Option<Uuid>,
50    pub superseded_at: Option<DateTime<Utc>>,
51    pub enrolment_checked_at: Option<DateTime<Utc>>,
52    pub submitted_at: Option<DateTime<Utc>>,
53    pub registered_at: Option<DateTime<Utc>>,
54    pub terminal_at: Option<DateTime<Utc>>,
55    /// Set once the student mail for that outcome is queued, and never cleared: these two are the
56    /// idempotency guard for the `student-notifications` phase.
57    pub action_needed_email_delivery_id: Option<Uuid>,
58    pub registered_email_delivery_id: Option<Uuid>,
59    /// The completion revision the grade-improvement scan last found no improvement against. See
60    /// [`mark_improvement_checked`](super::mark_improvement_checked).
61    pub improvement_checked_completion_updated_at: Option<DateTime<Utc>>,
62    /// Set while verify sees only an assessment item attainment for the submission.
63    pub partially_registered_at: Option<DateTime<Utc>>,
64    pub not_registered_reimport_count: i32,
65    /// Localized `{fi, sv, en}` name of the chosen enrolment's realisation, as Suotar reported it.
66    pub selected_enrolment_realisation_name: Option<serde_json::Value>,
67    /// Suotar's `retryAfter` for a pending submission; resubmitting earlier may duplicate it.
68    pub resubmit_not_before: Option<DateTime<Utc>>,
69    /// A later attempt on its way to replace this registered row; see
70    /// [`mark_pending_superseded`](super::mark_pending_superseded). Until it lands this row is
71    /// still the credit Sisu holds.
72    pub pending_superseded_by_id: Option<Uuid>,
73    /// When the row started waiting for an enrolment, held across the rechecks that keep finding
74    /// none.
75    pub no_usable_enrolment_since: Option<DateTime<Utc>>,
76    /// Kept for the row's whole life; see [`EnrolmentCheckGroup`].
77    pub enrolment_check_group: EnrolmentCheckGroup,
78    /// What the ladder is counted from. `None` outside the wait for an enrolment, which is how the
79    /// phases tell a scheduled check from any other resolve.
80    pub enrolment_check_anchor_at: Option<DateTime<Utc>>,
81    pub enrolment_check_step: Option<i32>,
82    /// The ladder time of `enrolment_check_step`, which `next_attempt_at` does not keep.
83    pub enrolment_check_due_at: Option<DateTime<Utc>>,
84    pub is_enrolment_check_batched: bool,
85    pub enrolment_check_source: EnrolmentCheckSource,
86    pub enrolment_checks_stopped_at: Option<DateTime<Utc>>,
87    pub enrolment_check_requested_at: Option<DateTime<Utc>>,
88    pub enrolment_check_restart_window_started_at: Option<DateTime<Utc>>,
89    pub enrolment_check_restart_count: i32,
90    /// `None` until the first check, which is what lets any roster listing wake a row never
91    /// checked.
92    pub seen_enrolment_ids: Option<Vec<String>>,
93    /// Set while a lookup is out for a row parked in `no_usable_enrolment`; see
94    /// [`claim_enrolment_checks`](super::claim_enrolment_checks).
95    pub enrolment_check_claimed_until: Option<DateTime<Utc>>,
96    /// When the row last entered a different state. Not `state_entered_at`, which every write that
97    /// keeps the state (each verify poll, each enrolment check) moves too.
98    pub state_changed_at: DateTime<Utc>,
99    /// When a state change last moved the row into another timeline phase; see
100    /// [`TimelinePhase::entered_with`](crate::library::credit_registration::timeline::TimelinePhase::entered_with).
101    pub phase_started_at: DateTime<Utc>,
102}
103
104impl CreditRegistration {
105    /// What decides whether a human may move this row; see [`ResubmissionFacts`].
106    pub fn resubmission_facts(&self) -> ResubmissionFacts {
107        ResubmissionFacts {
108            state: self.state,
109            is_superseded: self.superseded_by_id.is_some(),
110            error_code: self.error_code,
111            resubmit_not_before: self.resubmit_not_before,
112            submitted_at: self.submitted_at,
113        }
114    }
115
116    /// The grade the frozen payload carries; `None` before one is frozen.
117    pub fn frozen_grade(&self) -> Option<MappedGrade> {
118        MappedGrade::from_columns(self.grade_scale_id.as_deref(), self.grade_id.as_deref())
119    }
120
121    /// See [`is_waiting_for_enrolment`].
122    pub fn is_waiting_for_enrolment(&self) -> bool {
123        is_waiting_for_enrolment(
124            self.state,
125            self.enrolment_check_anchor_at,
126            self.no_usable_enrolment_since,
127        )
128    }
129}
130
131/// Whether a row is waiting for an enrolment: parked without a usable one, check schedule started
132/// or not, or on its first check or a retry on its way there. Only such a row is moved by a visit
133/// or a check request, and kept waiting through a lookup that fails in transit.
134pub fn is_waiting_for_enrolment(
135    state: CreditRegistrationState,
136    enrolment_check_anchor_at: Option<DateTime<Utc>>,
137    no_usable_enrolment_since: Option<DateTime<Utc>>,
138) -> bool {
139    state.keeps_enrolment_check_schedule()
140        && (enrolment_check_anchor_at.is_some() || no_usable_enrolment_since.is_some())
141}
142
143#[derive(Debug, Clone, PartialEq)]
144pub struct NewCreditRegistration {
145    pub course_module_completion_id: Uuid,
146    pub user_id: Uuid,
147    pub course_id: Uuid,
148    pub course_module_id: Uuid,
149    pub course_instance_id: Uuid,
150    pub attempt_number: i32,
151}
152
153/// Creates a ledger row at `pending` with a `created` event.
154pub async fn insert(
155    conn: &mut PgConnection,
156    pkey_policy: PKeyPolicy<Uuid>,
157    new: &NewCreditRegistration,
158    event_message: Option<&str>,
159) -> ModelResult<Uuid> {
160    let id = pkey_policy.into_uuid();
161    let mut tx = conn.begin().await?;
162    sqlx::query!(
163        r#"
164INSERT INTO credit_registrations (
165    id,
166    course_module_completion_id,
167    user_id,
168    course_id,
169    course_module_id,
170    course_instance_id,
171    attempt_number
172  )
173VALUES ($1, $2, $3, $4, $5, $6, $7)
174        "#,
175        id,
176        new.course_module_completion_id,
177        new.user_id,
178        new.course_id,
179        new.course_module_id,
180        new.course_instance_id,
181        new.attempt_number,
182    )
183    .execute(&mut *tx)
184    .await?;
185
186    crate::credit_registration_events::insert(
187        &mut tx,
188        &NewCreditRegistrationEvent {
189            message: event_message.map(str::to_string),
190            ..NewCreditRegistrationEvent::new(id, CreditRegistrationEventKind::Created)
191        },
192    )
193    .await?;
194
195    tx.commit().await?;
196    Ok(id)
197}
198
199pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<CreditRegistration> {
200    let res = sqlx::query_as!(
201        CreditRegistration,
202        r#"
203SELECT *
204FROM credit_registrations
205WHERE id = $1
206  AND deleted_at IS NULL
207        "#,
208        id
209    )
210    .fetch_one(conn)
211    .await?;
212    Ok(res)
213}
214
215/// The named rows, locked until the caller's transaction ends. Must be called inside one.
216///
217/// For a caller that judges each row and then transitions it: holding the lock is what keeps the
218/// judgement true, so [`transition`](super::transition::transition)'s `expected_from_state` cannot fail halfway
219/// and roll the whole batch back. Locks in id order, which every batch caller shares, so two of
220/// them cannot deadlock.
221pub async fn get_by_ids_for_update(
222    conn: &mut PgConnection,
223    ids: &[Uuid],
224) -> ModelResult<Vec<CreditRegistration>> {
225    let res = sqlx::query_as!(
226        CreditRegistration,
227        r#"
228SELECT *
229FROM credit_registrations
230WHERE id = ANY($1::uuid [])
231  AND deleted_at IS NULL
232ORDER BY id FOR UPDATE
233        "#,
234        ids
235    )
236    .fetch_all(conn)
237    .await?;
238    Ok(res)
239}
240
241pub async fn get_by_user_id(
242    conn: &mut PgConnection,
243    user_id: Uuid,
244) -> ModelResult<Vec<CreditRegistration>> {
245    let res = sqlx::query_as!(
246        CreditRegistration,
247        r#"
248SELECT *
249FROM credit_registrations
250WHERE user_id = $1
251  AND deleted_at IS NULL
252ORDER BY created_at DESC
253        "#,
254        user_id
255    )
256    .fetch_all(conn)
257    .await?;
258    Ok(res)
259}
260
261pub async fn get_by_course_id(
262    conn: &mut PgConnection,
263    course_id: Uuid,
264) -> ModelResult<Vec<CreditRegistration>> {
265    let res = sqlx::query_as!(
266        CreditRegistration,
267        r#"
268SELECT *
269FROM credit_registrations
270WHERE course_id = $1
271  AND deleted_at IS NULL
272ORDER BY created_at DESC
273        "#,
274        course_id
275    )
276    .fetch_all(conn)
277    .await?;
278    Ok(res)
279}
280
281/// Whether this account has any attempt, live or replaced, on this course.
282///
283/// For course-scoped handlers that take a user id from a request body: without it, holding one
284/// course lets a teacher ask questions about accounts that have nothing to do with it.
285pub async fn exists_for_user_and_course(
286    conn: &mut PgConnection,
287    user_id: Uuid,
288    course_id: Uuid,
289) -> ModelResult<bool> {
290    let exists = sqlx::query_scalar!(
291        r#"
292SELECT EXISTS (
293    SELECT 1
294    FROM credit_registrations
295    WHERE user_id = $1
296      AND course_id = $2
297      AND deleted_at IS NULL
298  ) AS "exists!"
299        "#,
300        user_id,
301        course_id,
302    )
303    .fetch_one(conn)
304    .await?;
305    Ok(exists)
306}