headless_lms_models/credit_registrations/
row_writes.rs1use 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#[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 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
95pub 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
116pub 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
137pub 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
156pub 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
177pub 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
200pub 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
229pub 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 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
279pub 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
303pub 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
350pub 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
375pub 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 ×,
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
417pub 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
446pub 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
474pub 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
499pub 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}