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}
97
98impl CreditRegistration {
99    /// What decides whether a human may move this row; see [`ResubmissionFacts`].
100    pub fn resubmission_facts(&self) -> ResubmissionFacts {
101        ResubmissionFacts {
102            state: self.state,
103            is_superseded: self.superseded_by_id.is_some(),
104            error_code: self.error_code,
105            resubmit_not_before: self.resubmit_not_before,
106            submitted_at: self.submitted_at,
107        }
108    }
109
110    /// The grade the frozen payload carries; `None` before one is frozen.
111    pub fn frozen_grade(&self) -> Option<MappedGrade> {
112        MappedGrade::from_columns(self.grade_scale_id.as_deref(), self.grade_id.as_deref())
113    }
114
115    /// See [`is_waiting_for_enrolment`].
116    pub fn is_waiting_for_enrolment(&self) -> bool {
117        is_waiting_for_enrolment(
118            self.state,
119            self.enrolment_check_anchor_at,
120            self.no_usable_enrolment_since,
121        )
122    }
123}
124
125/// Whether a row is waiting for an enrolment: parked without a usable one, check schedule started
126/// or not, or on its first check or a retry on its way there. Only such a row is moved by a visit
127/// or a check request, and kept waiting through a lookup that fails in transit.
128pub fn is_waiting_for_enrolment(
129    state: CreditRegistrationState,
130    enrolment_check_anchor_at: Option<DateTime<Utc>>,
131    no_usable_enrolment_since: Option<DateTime<Utc>>,
132) -> bool {
133    state.keeps_enrolment_check_schedule()
134        && (enrolment_check_anchor_at.is_some() || no_usable_enrolment_since.is_some())
135}
136
137#[derive(Debug, Clone, PartialEq)]
138pub struct NewCreditRegistration {
139    pub course_module_completion_id: Uuid,
140    pub user_id: Uuid,
141    pub course_id: Uuid,
142    pub course_module_id: Uuid,
143    pub course_instance_id: Uuid,
144    pub attempt_number: i32,
145}
146
147/// Creates a ledger row at `pending` with a `created` event.
148pub async fn insert(
149    conn: &mut PgConnection,
150    pkey_policy: PKeyPolicy<Uuid>,
151    new: &NewCreditRegistration,
152    event_message: Option<&str>,
153) -> ModelResult<Uuid> {
154    let id = pkey_policy.into_uuid();
155    let mut tx = conn.begin().await?;
156    sqlx::query!(
157        r#"
158INSERT INTO credit_registrations (
159    id,
160    course_module_completion_id,
161    user_id,
162    course_id,
163    course_module_id,
164    course_instance_id,
165    attempt_number
166  )
167VALUES ($1, $2, $3, $4, $5, $6, $7)
168        "#,
169        id,
170        new.course_module_completion_id,
171        new.user_id,
172        new.course_id,
173        new.course_module_id,
174        new.course_instance_id,
175        new.attempt_number,
176    )
177    .execute(&mut *tx)
178    .await?;
179
180    crate::credit_registration_events::insert(
181        &mut tx,
182        &NewCreditRegistrationEvent {
183            message: event_message.map(str::to_string),
184            ..NewCreditRegistrationEvent::new(id, CreditRegistrationEventKind::Created)
185        },
186    )
187    .await?;
188
189    tx.commit().await?;
190    Ok(id)
191}
192
193pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<CreditRegistration> {
194    let res = sqlx::query_as!(
195        CreditRegistration,
196        r#"
197SELECT *
198FROM credit_registrations
199WHERE id = $1
200  AND deleted_at IS NULL
201        "#,
202        id
203    )
204    .fetch_one(conn)
205    .await?;
206    Ok(res)
207}
208
209/// The named rows, locked until the caller's transaction ends. Must be called inside one.
210///
211/// For a caller that judges each row and then transitions it: holding the lock is what keeps the
212/// judgement true, so [`transition`](super::transition::transition)'s `expected_from_state` cannot fail halfway
213/// and roll the whole batch back. Locks in id order, which every batch caller shares, so two of
214/// them cannot deadlock.
215pub async fn get_by_ids_for_update(
216    conn: &mut PgConnection,
217    ids: &[Uuid],
218) -> ModelResult<Vec<CreditRegistration>> {
219    let res = sqlx::query_as!(
220        CreditRegistration,
221        r#"
222SELECT *
223FROM credit_registrations
224WHERE id = ANY($1::uuid [])
225  AND deleted_at IS NULL
226ORDER BY id FOR UPDATE
227        "#,
228        ids
229    )
230    .fetch_all(conn)
231    .await?;
232    Ok(res)
233}
234
235pub async fn get_by_user_id(
236    conn: &mut PgConnection,
237    user_id: Uuid,
238) -> ModelResult<Vec<CreditRegistration>> {
239    let res = sqlx::query_as!(
240        CreditRegistration,
241        r#"
242SELECT *
243FROM credit_registrations
244WHERE user_id = $1
245  AND deleted_at IS NULL
246ORDER BY created_at DESC
247        "#,
248        user_id
249    )
250    .fetch_all(conn)
251    .await?;
252    Ok(res)
253}
254
255pub async fn get_by_course_id(
256    conn: &mut PgConnection,
257    course_id: Uuid,
258) -> ModelResult<Vec<CreditRegistration>> {
259    let res = sqlx::query_as!(
260        CreditRegistration,
261        r#"
262SELECT *
263FROM credit_registrations
264WHERE course_id = $1
265  AND deleted_at IS NULL
266ORDER BY created_at DESC
267        "#,
268        course_id
269    )
270    .fetch_all(conn)
271    .await?;
272    Ok(res)
273}
274
275/// Whether this account has any attempt, live or replaced, on this course.
276///
277/// For course-scoped handlers that take a user id from a request body: without it, holding one
278/// course lets a teacher ask questions about accounts that have nothing to do with it.
279pub async fn exists_for_user_and_course(
280    conn: &mut PgConnection,
281    user_id: Uuid,
282    course_id: Uuid,
283) -> ModelResult<bool> {
284    let exists = sqlx::query_scalar!(
285        r#"
286SELECT EXISTS (
287    SELECT 1
288    FROM credit_registrations
289    WHERE user_id = $1
290      AND course_id = $2
291      AND deleted_at IS NULL
292  ) AS "exists!"
293        "#,
294        user_id,
295        course_id,
296    )
297    .fetch_one(conn)
298    .await?;
299    Ok(exists)
300}