Skip to main content

headless_lms_models/credit_registrations/
row_writes.rs

1//! Writes to a row's fields other than its state, which only [`transition`](super::transition::transition)
2//! writes: the frozen payload, what the study registry answered, the schedule, the counters and the flags.
3
4use super::transition::AdminAttention;
5use crate::library::credit_registration::enrolment_check_schedule::EnrolmentCheckSource;
6use crate::prelude::*;
7use headless_lms_utils::secret_string::expose_option;
8use secrecy::ExposeSecret;
9use std::collections::HashMap;
10
11/// Frozen copy of what we are about to submit. Written once, before the row leaves
12/// `checking_enrolment`: a later regrade must not alter a submitted row.
13#[derive(Debug, Clone)]
14pub struct PayloadSnapshot {
15    pub student_number: DbSecret,
16    pub sisu_person_id: Option<DbSecret>,
17    pub uh_course_code: String,
18    pub selected_enrolment_id: Option<String>,
19    pub selected_enrolment_kind: Option<String>,
20    pub selected_enrolment_realisation_id: Option<String>,
21    /// Localized `{fi, sv, en}` realisation name, as Suotar reported it.
22    pub selected_enrolment_realisation_name: Option<serde_json::Value>,
23    pub attained_at: DateTime<Utc>,
24    pub attainment_language: String,
25    pub grade_scale_id: String,
26    pub grade_id: String,
27    pub credits: f32,
28}
29
30pub async fn set_payload_snapshot(
31    conn: &mut PgConnection,
32    id: Uuid,
33    snapshot: &PayloadSnapshot,
34) -> ModelResult<()> {
35    sqlx::query!(
36        r#"
37UPDATE credit_registrations
38SET student_number = $2,
39  sisu_person_id = $3,
40  uh_course_code = $4,
41  selected_enrolment_id = $5,
42  selected_enrolment_kind = $6,
43  selected_enrolment_realisation_id = $7,
44  attained_at = $8,
45  attainment_language = $9,
46  grade_scale_id = $10,
47  grade_id = $11,
48  credits = $12,
49  selected_enrolment_realisation_name = $13
50WHERE id = $1
51  AND deleted_at IS NULL
52        "#,
53        id,
54        snapshot.student_number.expose_secret(),
55        expose_option(&snapshot.sisu_person_id),
56        snapshot.uh_course_code,
57        snapshot.selected_enrolment_id,
58        snapshot.selected_enrolment_kind,
59        snapshot.selected_enrolment_realisation_id,
60        snapshot.attained_at,
61        snapshot.attainment_language,
62        snapshot.grade_scale_id,
63        snapshot.grade_id,
64        snapshot.credits,
65        snapshot.selected_enrolment_realisation_name,
66    )
67    .execute(conn)
68    .await?;
69    Ok(())
70}
71
72pub async fn set_submitted_attainment(
73    conn: &mut PgConnection,
74    id: Uuid,
75    submitted_attainment_id: &str,
76    submitted_attainment_type: Option<&str>,
77) -> ModelResult<()> {
78    sqlx::query!(
79        r#"
80UPDATE credit_registrations
81SET submitted_attainment_id = $2,
82  submitted_attainment_type = $3
83WHERE id = $1
84  AND deleted_at IS NULL
85        "#,
86        id,
87        submitted_attainment_id,
88        submitted_attainment_type,
89    )
90    .execute(conn)
91    .await?;
92    Ok(())
93}
94
95/// Notes that verify saw only the assessment item attainment, keeping the first sighting, and
96/// returns when that was.
97pub async fn mark_partially_registered(
98    conn: &mut PgConnection,
99    id: Uuid,
100) -> ModelResult<DateTime<Utc>> {
101    let partially_registered_at = sqlx::query_scalar!(
102        r#"
103UPDATE credit_registrations
104SET partially_registered_at = COALESCE(partially_registered_at, now())
105WHERE id = $1
106  AND deleted_at IS NULL
107RETURNING partially_registered_at AS "partially_registered_at!"
108        "#,
109        id,
110    )
111    .fetch_one(conn)
112    .await?;
113    Ok(partially_registered_at)
114}
115
116/// Sets when a registration was submitted to Sisu.
117pub async fn set_submitted_at(
118    conn: &mut PgConnection,
119    id: Uuid,
120    submitted_at: DateTime<Utc>,
121) -> ModelResult<()> {
122    sqlx::query!(
123        r#"
124UPDATE credit_registrations
125SET submitted_at = $2
126WHERE id = $1
127  AND deleted_at IS NULL
128        "#,
129        id,
130        submitted_at,
131    )
132    .execute(conn)
133    .await?;
134    Ok(())
135}
136
137/// Restamps `submitted_at` on rows still `submitting`, for an import that sends them again after
138/// splitting a refused batch: the precondition sweep times a lost submission from this stamp, and
139/// must not condemn a row still waiting its turn in the same iteration.
140pub async fn restamp_submitting(conn: &mut PgConnection, ids: &[Uuid]) -> ModelResult<()> {
141    sqlx::query!(
142        r#"
143UPDATE credit_registrations
144SET submitted_at = now()
145WHERE id = ANY($1)
146  AND state = 'submitting'
147  AND deleted_at IS NULL
148        "#,
149        ids,
150    )
151    .execute(conn)
152    .await?;
153    Ok(())
154}
155
156/// Restarts the recovery grace of rows waiting out an enrolment lookup in `resolving_enrolment`,
157/// which runs from `state_entered_at`, for a split batch whose later halves are still to be sent.
158pub async fn restamp_resolving_enrolment(conn: &mut PgConnection, ids: &[Uuid]) -> ModelResult<()> {
159    if ids.is_empty() {
160        return Ok(());
161    }
162    sqlx::query!(
163        r#"
164UPDATE credit_registrations
165SET state_entered_at = now()
166WHERE id = ANY($1)
167  AND state = 'resolving_enrolment'
168  AND deleted_at IS NULL
169        "#,
170        ids,
171    )
172    .execute(conn)
173    .await?;
174    Ok(())
175}
176
177/// Records Suotar's `retryAfter` for the pending submission, before which a resubmission may
178/// register the credits twice. See
179/// [`ResubmissionFacts::resubmission_refusal`](super::ResubmissionFacts::resubmission_refusal).
180pub async fn set_resubmit_not_before(
181    conn: &mut PgConnection,
182    id: Uuid,
183    resubmit_not_before: DateTime<Utc>,
184) -> ModelResult<()> {
185    sqlx::query!(
186        r#"
187UPDATE credit_registrations
188SET resubmit_not_before = $2
189WHERE id = $1
190  AND deleted_at IS NULL
191        "#,
192        id,
193        resubmit_not_before,
194    )
195    .execute(conn)
196    .await?;
197    Ok(())
198}
199
200/// Forgets a submission Suotar says never landed, so the row resolves its enrolment and imports
201/// again from scratch, and returns how many times that has now happened.
202///
203/// Also restarts the retry window and the verify count: the resend is new work, and the old
204/// submission's history would expire it or flag it at once.
205pub async fn reset_for_resubmission(conn: &mut PgConnection, id: Uuid) -> ModelResult<i32> {
206    let reimport_count = sqlx::query_scalar!(
207        r#"
208UPDATE credit_registrations
209SET submitted_attainment_id = NULL,
210  submitted_attainment_type = NULL,
211  partially_registered_at = NULL,
212  resubmit_not_before = NULL,
213  selected_enrolment_id = NULL,
214  grade_id = NULL,
215  first_failed_at = NULL,
216  verify_attempt_count = 0,
217  not_registered_reimport_count = not_registered_reimport_count + 1
218WHERE id = $1
219  AND deleted_at IS NULL
220RETURNING not_registered_reimport_count
221        "#,
222        id,
223    )
224    .fetch_one(conn)
225    .await?;
226    Ok(reimport_count)
227}
228
229/// Records the attainment the study registry holds, unless another live row already claims it.
230///
231/// Two rows may legitimately be told about one attainment — a grade improvement Sisu declines names
232/// the attainment the first attempt registered — so this returns `false` instead of failing.
233pub async fn set_sisu_attainment_if_unclaimed(
234    conn: &mut PgConnection,
235    id: Uuid,
236    sisu_attainment_id: &str,
237    sisu_attainment_type: Option<&str>,
238) -> ModelResult<bool> {
239    let updated = sqlx::query_scalar!(
240        r#"
241UPDATE credit_registrations
242SET sisu_attainment_id = $2,
243  sisu_attainment_type = $3
244WHERE id = $1
245  AND deleted_at IS NULL
246  AND NOT EXISTS (
247    SELECT 1
248    FROM credit_registrations other
249    WHERE other.sisu_attainment_id = $2
250      AND other.deleted_at IS NULL
251      AND other.id <> $1
252  )
253RETURNING id
254        "#,
255        id,
256        sisu_attainment_id,
257        sisu_attainment_type,
258    )
259    .fetch_optional(conn)
260    .await;
261    match updated {
262        Ok(updated) => Ok(updated.is_some()),
263        // The NOT EXISTS guard above isn't atomic against a concurrent caller claiming the
264        // same sisu_attainment_id for a different row; the loser hits this unique index instead.
265        Err(err) => {
266            let err: ModelError = err.into();
267            match err.error_type() {
268                ModelErrorType::DatabaseConstraint { constraint, .. }
269                    if constraint == "uq_credit_registrations_sisu_attainment" =>
270                {
271                    Ok(false)
272                }
273                _ => Err(err),
274            }
275        }
276    }
277}
278
279/// Defers when the pipeline may next claim this row; the delay is the caller's policy.
280///
281/// Deliberately leaves `first_failed_at` alone: a state that waits on a human defers too, and
282/// anchoring the retry window here would expire it.
283pub async fn schedule_next_attempt(
284    conn: &mut PgConnection,
285    id: Uuid,
286    next_attempt_at: DateTime<Utc>,
287) -> ModelResult<()> {
288    sqlx::query!(
289        r#"
290UPDATE credit_registrations
291SET next_attempt_at = $2
292WHERE id = $1
293  AND deleted_at IS NULL
294        "#,
295        id,
296        next_attempt_at,
297    )
298    .execute(conn)
299    .await?;
300    Ok(())
301}
302
303/// Makes rows claimable again now, whatever backoff parked them. A row waiting for an enrolment
304/// check has its check marked as `enrolment_check_source`: who asked for it.
305///
306/// Uses the database clock: an app-clock value sampled after `BEGIN` is still in the future when
307/// the same transaction compares it against `now()`.
308pub async fn make_due_now_batch(
309    conn: &mut PgConnection,
310    ids: &[Uuid],
311    enrolment_check_source: EnrolmentCheckSource,
312) -> ModelResult<()> {
313    sqlx::query!(
314        r#"
315UPDATE credit_registrations
316SET next_attempt_at = now(),
317  enrolment_check_source = CASE
318    WHEN state = 'no_usable_enrolment' THEN $2
319    ELSE enrolment_check_source
320  END
321WHERE id = ANY($1)
322  AND next_attempt_at > now()
323  AND superseded_by_id IS NULL
324  AND deleted_at IS NULL
325        "#,
326        ids,
327        enrolment_check_source as EnrolmentCheckSource,
328    )
329    .execute(conn)
330    .await?;
331    Ok(())
332}
333
334pub async fn increment_submit_retry_count(conn: &mut PgConnection, id: Uuid) -> ModelResult<i32> {
335    let res = sqlx::query!(
336        r#"
337UPDATE credit_registrations
338SET submit_retry_count = submit_retry_count + 1
339WHERE id = $1
340  AND deleted_at IS NULL
341RETURNING submit_retry_count
342        "#,
343        id
344    )
345    .fetch_one(conn)
346    .await?;
347    Ok(res.submit_retry_count)
348}
349
350/// Counts one verify poll for every row of a batch and returns each row's new count, which sets the
351/// backoff the poll's answer is scheduled by.
352pub async fn increment_verify_attempt_counts(
353    conn: &mut PgConnection,
354    ids: &[Uuid],
355) -> ModelResult<HashMap<Uuid, i32>> {
356    let rows = sqlx::query!(
357        r#"
358UPDATE credit_registrations
359SET verify_attempt_count = verify_attempt_count + 1
360WHERE id = ANY($1)
361  AND deleted_at IS NULL
362RETURNING id,
363  verify_attempt_count
364        "#,
365        ids
366    )
367    .fetch_all(conn)
368    .await?;
369    Ok(rows
370        .into_iter()
371        .map(|row| (row.id, row.verify_attempt_count))
372        .collect())
373}
374
375/// [`schedule_next_attempt`] for a whole batch, each row with its own time.
376pub async fn schedule_next_attempts(
377    conn: &mut PgConnection,
378    scheduled: &[(Uuid, DateTime<Utc>)],
379) -> ModelResult<()> {
380    let (ids, times): (Vec<Uuid>, Vec<DateTime<Utc>>) = scheduled.iter().copied().unzip();
381    sqlx::query!(
382        r#"
383UPDATE credit_registrations cr
384SET next_attempt_at = scheduled.at
385FROM UNNEST($1::uuid [], $2::timestamptz []) AS scheduled(id, at)
386WHERE cr.id = scheduled.id
387  AND cr.deleted_at IS NULL
388        "#,
389        &ids,
390        &times,
391    )
392    .execute(conn)
393    .await?;
394    Ok(())
395}
396
397pub async fn set_needs_admin_attention(
398    conn: &mut PgConnection,
399    id: Uuid,
400    attention: AdminAttention,
401) -> ModelResult<()> {
402    sqlx::query!(
403        r#"
404UPDATE credit_registrations
405SET needs_admin_attention = $2
406WHERE id = $1
407  AND deleted_at IS NULL
408        "#,
409        id,
410        attention.is_raised(),
411    )
412    .execute(conn)
413    .await?;
414    Ok(())
415}
416
417/// Points an old attempt at the newer one that replaced it. The old row keeps its state and
418/// `terminal_at`: it really was registered.
419///
420/// For fixtures planting a finished replacement. The pipeline goes through
421/// [`mark_pending_superseded`], which supersedes the row only once the new attempt is registered.
422///
423/// `superseded_by_id` may name a row that does not exist yet, as long as it is inserted before the
424/// caller's transaction commits: the foreign key is deferred.
425pub async fn mark_superseded(
426    conn: &mut PgConnection,
427    id: Uuid,
428    superseded_by_id: Uuid,
429) -> ModelResult<()> {
430    sqlx::query!(
431        r#"
432UPDATE credit_registrations
433SET superseded_by_id = $2,
434  superseded_at = now()
435WHERE id = $1
436  AND deleted_at IS NULL
437        "#,
438        id,
439        superseded_by_id,
440    )
441    .execute(conn)
442    .await?;
443    Ok(())
444}
445
446/// Marks a registered row as being replaced by `superseded_by_id`, a later attempt with a better
447/// grade, of the same completion or another, which takes over the row's slot in
448/// `uq_credit_registrations_person_module`.
449///
450/// Not [`mark_superseded`]: the row stays the live credit until the new attempt is registered,
451/// since Sisu may accept the better grade without ever making it the course unit's attainment.
452/// [`transition`](super::transition::transition) then supersedes the row, or clears the mark if
453/// the new attempt stops short.
454pub async fn mark_pending_superseded(
455    conn: &mut PgConnection,
456    id: Uuid,
457    superseded_by_id: Uuid,
458) -> ModelResult<()> {
459    sqlx::query!(
460        r#"
461UPDATE credit_registrations
462SET pending_superseded_by_id = $2
463WHERE id = $1
464  AND deleted_at IS NULL
465        "#,
466        id,
467        superseded_by_id,
468    )
469    .execute(conn)
470    .await?;
471    Ok(())
472}
473
474/// Records that the grade-improvement scan looked at this accepted attempt against a completion in
475/// the given revision and found nothing better.
476///
477/// `completion_updated_at` must be the `updated_at` the scan actually read, not `now()`: the point
478/// is that the row stops being a candidate until the completion changes again.
479pub async fn mark_improvement_checked(
480    conn: &mut PgConnection,
481    id: Uuid,
482    completion_updated_at: DateTime<Utc>,
483) -> ModelResult<()> {
484    sqlx::query!(
485        r#"
486UPDATE credit_registrations
487SET improvement_checked_completion_updated_at = $2
488WHERE id = $1
489  AND deleted_at IS NULL
490        "#,
491        id,
492        completion_updated_at,
493    )
494    .execute(conn)
495    .await?;
496    Ok(())
497}
498
499/// Makes every due-later `failed_retryable` row due now; returns how many. The button pressed once
500/// the study registry says an outage is over.
501///
502/// Only `failed_retryable`: no other state's backoff means "waiting out an outage", and
503/// `submission_uncertain` must never be swept forward in bulk.
504pub async fn requeue_retryable_now(
505    conn: &mut PgConnection,
506    course_id: Option<Uuid>,
507    course_module_id: Option<Uuid>,
508    limit: i64,
509) -> ModelResult<i64> {
510    let count = sqlx::query_scalar!(
511        r#"
512WITH due AS (
513  SELECT id
514  FROM credit_registrations
515  WHERE state = 'failed_retryable'
516    AND next_attempt_at > now()
517    AND superseded_by_id IS NULL
518    AND deleted_at IS NULL
519    AND ($2::uuid IS NULL OR course_id = $2)
520    AND ($3::uuid IS NULL OR course_module_id = $3)
521  ORDER BY next_attempt_at
522  LIMIT $1
523),
524updated AS (
525  UPDATE credit_registrations cr
526  SET next_attempt_at = now()
527  FROM due
528  WHERE cr.id = due.id
529  RETURNING cr.id
530)
531SELECT COUNT(*) AS "count!"
532FROM updated
533        "#,
534        limit,
535        course_id,
536        course_module_id,
537    )
538    .fetch_one(conn)
539    .await?;
540    Ok(count)
541}