1use std::collections::HashMap;
8
9use chrono::NaiveDate;
10use utoipa::ToSchema;
11
12use crate::credit_registration_events::{CreditRegistrationEventKind, NewCreditRegistrationEvent};
13use crate::library::credit_registration::{
14 CreditRegistrationPendingReason, PendingPreconditions, PendingReasonCounts,
15};
16use crate::library::students_view::escape_like_pattern;
17use crate::prelude::*;
18use crate::verified_student_numbers::StudentNumberVerificationMethod;
19
20#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, Type, ToSchema)]
22#[sqlx(type_name = "credit_registration_state", rename_all = "snake_case")]
23#[serde(rename_all = "snake_case")]
24pub enum CreditRegistrationState {
25 Pending,
28 ReadyToSubmit,
29 ResolvingEnrolment,
30 CheckingEnrolment,
31 NoUsableEnrolment,
32 Submitting,
33 SubmissionUncertain,
34 AwaitingVerification,
35 Registered,
36 Duplicate,
37 NotImproved,
38 Misregistered,
39 FailedRetryable,
40 FailedPermanent,
41 Blocked,
42 Cancelled,
43}
44
45impl CreditRegistrationState {
46 pub const ALL: [Self; 16] = [
48 Self::Pending,
49 Self::ReadyToSubmit,
50 Self::ResolvingEnrolment,
51 Self::CheckingEnrolment,
52 Self::NoUsableEnrolment,
53 Self::Submitting,
54 Self::SubmissionUncertain,
55 Self::AwaitingVerification,
56 Self::Registered,
57 Self::Duplicate,
58 Self::NotImproved,
59 Self::Misregistered,
60 Self::FailedRetryable,
61 Self::FailedPermanent,
62 Self::Blocked,
63 Self::Cancelled,
64 ];
65
66 pub fn is_terminal(self) -> bool {
69 matches!(
70 self,
71 Self::Registered
72 | Self::Duplicate
73 | Self::NotImproved
74 | Self::FailedPermanent
75 | Self::Cancelled
76 )
77 }
78
79 pub fn is_failure(self) -> bool {
81 matches!(self, Self::FailedRetryable | Self::FailedPermanent)
82 }
83
84 pub fn is_success(self) -> bool {
86 matches!(self, Self::Registered | Self::Duplicate | Self::NotImproved)
87 }
88
89 pub const SUCCESS_STATES: [Self; 3] = [Self::Registered, Self::Duplicate, Self::NotImproved];
93
94 pub const OTHER_SUCCESS_STATES: [Self; 2] = [Self::Duplicate, Self::NotImproved];
96
97 pub const HARD_FAILURE_STATES: [Self; 2] = [Self::FailedPermanent, Self::Misregistered];
100
101 pub fn allowed_targets(self) -> &'static [Self] {
109 use CreditRegistrationState as S;
110 match self {
111 S::Pending => &[S::ReadyToSubmit, S::Blocked, S::Cancelled],
114 S::ReadyToSubmit => &[
118 S::Pending,
119 S::ResolvingEnrolment,
120 S::FailedRetryable,
121 S::FailedPermanent,
122 S::Blocked,
123 S::Cancelled,
124 ],
125 S::ResolvingEnrolment => &[
128 S::Pending,
129 S::CheckingEnrolment,
130 S::NoUsableEnrolment,
131 S::Duplicate,
132 S::FailedRetryable,
133 S::FailedPermanent,
134 S::Blocked,
135 S::Cancelled,
136 ],
137 S::CheckingEnrolment => &[
139 S::Pending,
140 S::ReadyToSubmit,
141 S::Submitting,
142 S::Duplicate,
143 S::FailedPermanent,
144 S::Blocked,
145 S::Cancelled,
146 ],
147 S::NoUsableEnrolment => &[S::Pending, S::ReadyToSubmit, S::Blocked, S::Cancelled],
148 S::Submitting => &[
151 S::Pending,
152 S::NoUsableEnrolment,
153 S::AwaitingVerification,
154 S::SubmissionUncertain,
155 S::Registered,
156 S::Duplicate,
157 S::NotImproved,
158 S::FailedRetryable,
159 S::FailedPermanent,
160 ],
161 S::AwaitingVerification | S::SubmissionUncertain => {
164 &[S::Registered, S::Duplicate, S::Misregistered]
165 }
166 S::FailedRetryable => &[
168 S::Pending,
169 S::ReadyToSubmit,
170 S::CheckingEnrolment,
171 S::AwaitingVerification,
172 S::FailedPermanent,
173 S::Blocked,
174 S::Cancelled,
175 ],
176 S::Blocked => &[S::Pending, S::ReadyToSubmit, S::Cancelled],
177 S::Registered
180 | S::Duplicate
181 | S::NotImproved
182 | S::Misregistered
183 | S::FailedPermanent
184 | S::Cancelled => &[],
185 }
186 }
187
188 pub fn resubmission_refusal(
197 self,
198 superseded: bool,
199 strictness: ResubmissionStrictness,
200 ) -> Option<ResubmissionRefusal> {
201 if superseded {
202 return Some(ResubmissionRefusal::Superseded);
203 }
204 if self.is_success() {
205 return Some(ResubmissionRefusal::AlreadySucceeded);
206 }
207 if strictness != ResubmissionStrictness::Any && self == Self::SubmissionUncertain {
208 return Some(ResubmissionRefusal::SubmissionUncertain);
209 }
210 if strictness == ResubmissionStrictness::OnlyFailedPermanent
211 && self != Self::FailedPermanent
212 {
213 return Some(ResubmissionRefusal::NotFailedPermanent);
214 }
215 None
216 }
217
218 pub fn admin_transition_refusal(
226 self,
227 target: Self,
228 superseded: bool,
229 strictness: ResubmissionStrictness,
230 ) -> Option<ResubmissionRefusal> {
231 if superseded {
232 return Some(ResubmissionRefusal::Superseded);
233 }
234 if self.is_success() {
235 return Some(ResubmissionRefusal::AlreadySucceeded);
236 }
237 if target != Self::ReadyToSubmit {
238 return None;
239 }
240 self.resubmission_refusal(false, strictness)
241 }
242
243 fn default_attempt_delay_secs(self) -> i64 {
250 use crate::library::credit_registration::backoff::{
251 NO_USABLE_ENROLMENT_RECHECK_SECS, SUBMIT_BASE_BACKOFF_SECS, UNCERTAIN_RECHECK_SECS,
252 VERIFY_FIRST_DELAY_SECS,
253 };
254 match self {
255 Self::AwaitingVerification => VERIFY_FIRST_DELAY_SECS,
256 Self::SubmissionUncertain => UNCERTAIN_RECHECK_SECS,
257 Self::NoUsableEnrolment => NO_USABLE_ENROLMENT_RECHECK_SECS,
258 Self::FailedRetryable => SUBMIT_BASE_BACKOFF_SECS,
259 _ => 0,
260 }
261 }
262}
263
264pub const ADMIN_ONLY_TARGETS: [CreditRegistrationState; 2] = [
269 CreditRegistrationState::ReadyToSubmit,
270 CreditRegistrationState::Cancelled,
271];
272
273#[derive(Debug, Clone, Copy, PartialEq, Eq)]
276pub enum ResubmissionStrictness {
277 OnlyFailedPermanent,
280 AnyExceptSubmissionUncertain,
284 Any,
287}
288
289#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, ToSchema)]
294#[serde(rename_all = "snake_case")]
295pub enum ResubmissionRefusal {
296 Superseded,
298 AlreadySucceeded,
301 SubmissionUncertain,
303 NotFailedPermanent,
305}
306
307#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, Type, ToSchema)]
309#[sqlx(
310 type_name = "credit_registration_error_code",
311 rename_all = "snake_case"
312)]
313#[serde(rename_all = "snake_case")]
314pub enum CreditRegistrationErrorCode {
315 PersonNotFound,
316 CourseCodeNotFound,
317 EnrolmentNotFound,
318 EnrolmentNotAccepted,
319 InvalidGradeForGradeScale,
320 CourseNotAllowed,
321 InvalidCredits,
322 StudyRightNotValid,
323 AcceptorNotFound,
324 SisuValidationFailed,
325 SisuTimeout,
326 SisuTemporarilyUnavailable,
327 Misregistered,
328 Unauthorized,
329 MalformedRequest,
330 TransportError,
331 UnexpectedResponse,
332 NoGradeScaleMapping,
333 MissingUhCourseCode,
334 MissingEctsCredits,
335 RetryWindowExpired,
336 Unknown,
337}
338
339impl CreditRegistrationErrorCode {
340 pub const ALL: [Self; 22] = [
342 Self::PersonNotFound,
343 Self::CourseCodeNotFound,
344 Self::EnrolmentNotFound,
345 Self::EnrolmentNotAccepted,
346 Self::InvalidGradeForGradeScale,
347 Self::CourseNotAllowed,
348 Self::InvalidCredits,
349 Self::StudyRightNotValid,
350 Self::AcceptorNotFound,
351 Self::SisuValidationFailed,
352 Self::SisuTimeout,
353 Self::SisuTemporarilyUnavailable,
354 Self::Misregistered,
355 Self::Unauthorized,
356 Self::MalformedRequest,
357 Self::TransportError,
358 Self::UnexpectedResponse,
359 Self::NoGradeScaleMapping,
360 Self::MissingUhCourseCode,
361 Self::MissingEctsCredits,
362 Self::RetryWindowExpired,
363 Self::Unknown,
364 ];
365}
366
367#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
368pub struct CreditRegistration {
369 pub id: Uuid,
370 pub created_at: DateTime<Utc>,
371 pub updated_at: DateTime<Utc>,
372 pub deleted_at: Option<DateTime<Utc>>,
373 pub course_module_completion_id: Uuid,
374 pub user_id: Uuid,
375 pub course_id: Uuid,
376 pub course_module_id: Uuid,
377 pub course_instance_id: Uuid,
378 pub state: CreditRegistrationState,
379 pub state_entered_at: DateTime<Utc>,
380 pub error_code: Option<CreditRegistrationErrorCode>,
381 pub error_message: Option<String>,
382 pub needs_admin_attention: bool,
383 pub enrolment_banner_dismissed_at: Option<DateTime<Utc>>,
384 pub student_number: Option<String>,
385 pub sisu_person_id: Option<String>,
386 pub uh_course_code: Option<String>,
387 pub selected_enrolment_id: Option<String>,
388 pub selected_enrolment_kind: Option<String>,
389 pub selected_enrolment_realisation_id: Option<String>,
390 pub attainment_date: Option<NaiveDate>,
391 pub attainment_language: Option<String>,
392 pub grade_scale_id: Option<String>,
393 pub grade_id: Option<String>,
394 pub credits: Option<f32>,
395 pub request_item_id: String,
396 pub submitted_attainment_id: Option<String>,
397 pub submitted_attainment_type: Option<String>,
398 pub sisu_attainment_id: Option<String>,
399 pub sisu_attainment_type: Option<String>,
400 pub submit_retry_count: i32,
401 pub verify_attempt_count: i32,
402 pub next_attempt_at: DateTime<Utc>,
403 pub first_failed_at: Option<DateTime<Utc>>,
404 pub last_attempt_at: Option<DateTime<Utc>>,
405 pub attempt_number: i32,
406 pub superseded_by_id: Option<Uuid>,
407 pub superseded_at: Option<DateTime<Utc>>,
408 pub enrolment_checked_at: Option<DateTime<Utc>>,
409 pub submitted_at: Option<DateTime<Utc>>,
410 pub registered_at: Option<DateTime<Utc>>,
411 pub terminal_at: Option<DateTime<Utc>>,
412 pub action_needed_email_delivery_id: Option<Uuid>,
415 pub registered_email_delivery_id: Option<Uuid>,
416 pub improvement_checked_completion_updated_at: Option<DateTime<Utc>>,
419}
420
421#[derive(Debug, Clone, PartialEq)]
422pub struct NewCreditRegistration {
423 pub course_module_completion_id: Uuid,
424 pub user_id: Uuid,
425 pub course_id: Uuid,
426 pub course_module_id: Uuid,
427 pub course_instance_id: Uuid,
428 pub attempt_number: i32,
429}
430
431pub fn import_request_item_id(registration_id: Uuid) -> String {
434 format!("cr-{registration_id}")
435}
436
437pub fn verify_request_item_id(registration_id: Uuid, verify_attempt_count: i32) -> String {
439 format!("vf-{registration_id}-{verify_attempt_count}")
440}
441
442pub fn recovery_request_item_id(registration_id: Uuid, verify_attempt_count: i32) -> String {
445 format!("rc-{registration_id}-{verify_attempt_count}")
446}
447
448#[derive(Debug, Clone, Copy, PartialEq, Eq)]
450pub enum RequestPurpose {
451 Submission,
455 VerifyPoll(i32),
456 UncertainRecovery(i32),
459}
460
461pub fn request_item_id(row: &CreditRegistration, purpose: RequestPurpose) -> String {
464 match purpose {
465 RequestPurpose::Submission => row.request_item_id.clone(),
466 RequestPurpose::VerifyPoll(attempt) => verify_request_item_id(row.id, attempt),
467 RequestPurpose::UncertainRecovery(attempt) => recovery_request_item_id(row.id, attempt),
468 }
469}
470
471pub async fn insert(
474 conn: &mut PgConnection,
475 pkey_policy: PKeyPolicy<Uuid>,
476 new: &NewCreditRegistration,
477 event_message: Option<&str>,
478) -> ModelResult<Uuid> {
479 let id = pkey_policy.into_uuid();
480 let mut tx = conn.begin().await?;
481 sqlx::query!(
482 r#"
483INSERT INTO credit_registrations (
484 id,
485 course_module_completion_id,
486 user_id,
487 course_id,
488 course_module_id,
489 course_instance_id,
490 attempt_number,
491 request_item_id
492 )
493VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
494 "#,
495 id,
496 new.course_module_completion_id,
497 new.user_id,
498 new.course_id,
499 new.course_module_id,
500 new.course_instance_id,
501 new.attempt_number,
502 import_request_item_id(id),
503 )
504 .execute(&mut *tx)
505 .await?;
506
507 crate::credit_registration_events::insert(
508 &mut tx,
509 &NewCreditRegistrationEvent {
510 message: event_message.map(str::to_string),
511 ..NewCreditRegistrationEvent::new(id, CreditRegistrationEventKind::Created)
512 },
513 )
514 .await?;
515
516 tx.commit().await?;
517 Ok(id)
518}
519
520#[derive(Debug, Clone, PartialEq)]
521pub struct Transition {
522 pub to_state: CreditRegistrationState,
523 pub error_code: Option<CreditRegistrationErrorCode>,
524 pub error_message: Option<String>,
526 pub needs_admin_attention: Option<bool>,
527 pub event_kind: CreditRegistrationEventKind,
528 pub event_message: Option<String>,
529 pub actor_user_id: Option<Uuid>,
530 pub suotar_api_call_id: Option<Uuid>,
531 pub event_details: Option<serde_json::Value>,
533 pub expected_from_state: Option<CreditRegistrationState>,
538 pub policy: TransitionPolicy,
540 pub next_attempt_at: Option<DateTime<Utc>>,
543}
544
545#[derive(Debug, Clone, Copy, PartialEq, Eq)]
547pub enum TransitionPolicy {
548 Pipeline,
551 Admin,
554 Planted,
557}
558
559impl TransitionPolicy {
560 fn allows(self, from: CreditRegistrationState, to: CreditRegistrationState) -> bool {
561 match self {
562 Self::Pipeline => from.allowed_targets().contains(&to),
563 Self::Admin => from.allowed_targets().contains(&to) || ADMIN_ONLY_TARGETS.contains(&to),
564 Self::Planted => true,
565 }
566 }
567}
568
569impl Transition {
570 pub fn to(to_state: CreditRegistrationState) -> Self {
571 Self {
572 to_state,
573 error_code: None,
574 error_message: None,
575 needs_admin_attention: None,
576 event_kind: CreditRegistrationEventKind::StateChanged,
577 event_message: None,
578 actor_user_id: None,
579 suotar_api_call_id: None,
580 event_details: None,
581 expected_from_state: None,
582 policy: TransitionPolicy::Pipeline,
583 next_attempt_at: None,
584 }
585 }
586
587 pub fn by_hand(to_state: CreditRegistrationState) -> Self {
589 Self {
590 policy: TransitionPolicy::Admin,
591 ..Self::to(to_state)
592 }
593 }
594
595 pub fn planted(to_state: CreditRegistrationState) -> Self {
597 Self {
598 policy: TransitionPolicy::Planted,
599 ..Self::to(to_state)
600 }
601 }
602}
603
604pub async fn transition(
616 conn: &mut PgConnection,
617 id: Uuid,
618 transition: &Transition,
619) -> ModelResult<CreditRegistration> {
620 let mut tx = conn.begin().await?;
621
622 let before = sqlx::query_as!(
623 CreditRegistration,
624 r#"
625SELECT *
626FROM credit_registrations
627WHERE id = $1
628 AND deleted_at IS NULL
629FOR UPDATE
630 "#,
631 id
632 )
633 .fetch_one(&mut *tx)
634 .await?;
635
636 if let Some(expected) = transition.expected_from_state
637 && before.state != expected
638 {
639 return Err(model_err!(
640 PreconditionFailed,
641 format!(
642 "Credit registration {id} is in {:?}, not the expected {expected:?}: refusing to overwrite it.",
643 before.state
644 )
645 ));
646 }
647
648 let to_state = transition.to_state;
649 check_edge(id, before.state, to_state, transition.policy)?;
650
651 let after = sqlx::query_as!(
652 CreditRegistration,
653 r#"
654UPDATE credit_registrations
655SET state = $2::credit_registration_state,
656 -- clock_timestamp(), not now(): now() is the transaction timestamp, so several state changes in
657 -- one transaction would share an instant and the timeline would lose their order.
658 state_entered_at = clock_timestamp(),
659 error_code = $3,
660 error_message = $4,
661 needs_admin_attention = COALESCE($5, needs_admin_attention),
662 -- ELSE NULL: without it an admin retry stays invisible to every terminal_at IS NULL query.
663 terminal_at = CASE
664 WHEN $6 THEN COALESCE(terminal_at, now())
665 ELSE NULL
666 END,
667 first_failed_at = CASE
668 WHEN $7 THEN COALESCE(first_failed_at, now())
669 ELSE first_failed_at
670 END,
671 registered_at = CASE
672 WHEN $2::credit_registration_state = 'registered' THEN COALESCE(registered_at, now())
673 ELSE registered_at
674 END,
675 submitted_at = CASE
676 WHEN $2::credit_registration_state = 'submitting' THEN now()
677 ELSE submitted_at
678 END,
679 enrolment_checked_at = CASE
680 WHEN state = 'checking_enrolment'
681 AND $2::credit_registration_state <> 'checking_enrolment' THEN now()
682 ELSE enrolment_checked_at
683 END,
684 enrolment_banner_dismissed_at = CASE
685 WHEN $2::credit_registration_state = 'no_usable_enrolment' THEN NULL
686 ELSE enrolment_banner_dismissed_at
687 END,
688 next_attempt_at = COALESCE(
689 $8::timestamptz,
690 now() + ($9::bigint * INTERVAL '1 second')
691 )
692WHERE id = $1
693 AND deleted_at IS NULL
694RETURNING *
695 "#,
696 id,
697 to_state as CreditRegistrationState,
698 transition.error_code as Option<CreditRegistrationErrorCode>,
699 transition.error_message,
700 transition.needs_admin_attention,
701 to_state.is_terminal(),
702 to_state.is_failure(),
703 transition.next_attempt_at,
704 to_state.default_attempt_delay_secs(),
705 )
706 .fetch_one(&mut *tx)
707 .await?;
708
709 crate::credit_registration_events::insert(
710 &mut tx,
711 &NewCreditRegistrationEvent {
712 credit_registration_id: id,
713 kind: transition.event_kind,
714 from_state: Some(before.state),
715 to_state: Some(to_state),
716 error_code: transition.error_code,
717 message: transition.event_message.clone(),
718 suotar_api_call_id: transition.suotar_api_call_id,
719 actor_user_id: transition.actor_user_id,
720 details: transition.event_details.clone(),
721 },
722 )
723 .await?;
724
725 tx.commit().await?;
726 Ok(after)
727}
728
729fn check_edge(
732 id: Uuid,
733 from: CreditRegistrationState,
734 to: CreditRegistrationState,
735 policy: TransitionPolicy,
736) -> ModelResult<()> {
737 if from == to || policy.allows(from, to) {
739 return Ok(());
740 }
741 Err(model_err!(
742 InvalidRequest,
743 format!("Credit registration {id} may not move from {from:?} to {to:?} under {policy:?}.")
744 ))
745}
746
747#[derive(Debug, Clone, PartialEq)]
749pub struct BatchMove {
750 pub id: Uuid,
751 pub transition: Transition,
752}
753
754pub async fn transition_batch(conn: &mut PgConnection, moves: &[BatchMove]) -> ModelResult<i64> {
761 if moves.is_empty() {
762 return Ok(0);
763 }
764 let mut tx = conn.begin().await?;
765 let ids: Vec<Uuid> = moves.iter().map(|batch_move| batch_move.id).collect();
766 let locked = sqlx::query!(
767 r#"
768SELECT id,
769 state
770FROM credit_registrations
771WHERE id = ANY($1)
772 AND deleted_at IS NULL
773ORDER BY id FOR
774UPDATE
775 "#,
776 &ids
777 )
778 .fetch_all(&mut *tx)
779 .await?;
780 let states: HashMap<Uuid, CreditRegistrationState> =
781 locked.into_iter().map(|row| (row.id, row.state)).collect();
782
783 let mut writes = Vec::new();
784 let mut events = Vec::new();
785 for batch_move in moves {
786 let Some(&from) = states.get(&batch_move.id) else {
787 continue;
788 };
789 let to = batch_move.transition.to_state;
790 if batch_move
791 .transition
792 .expected_from_state
793 .is_some_and(|expected| expected != from)
794 {
795 continue;
796 }
797 check_edge(batch_move.id, from, to, batch_move.transition.policy)?;
798 writes.push(batch_move);
799 events.push(NewCreditRegistrationEvent {
800 credit_registration_id: batch_move.id,
801 kind: batch_move.transition.event_kind,
802 from_state: Some(from),
803 to_state: Some(to),
804 error_code: batch_move.transition.error_code,
805 message: batch_move.transition.event_message.clone(),
806 suotar_api_call_id: batch_move.transition.suotar_api_call_id,
807 actor_user_id: batch_move.transition.actor_user_id,
808 details: batch_move.transition.event_details.clone(),
809 });
810 }
811 if writes.is_empty() {
812 tx.commit().await?;
813 return Ok(0);
814 }
815
816 let ids: Vec<Uuid> = writes.iter().map(|write| write.id).collect();
817 let to_states: Vec<CreditRegistrationState> = writes
818 .iter()
819 .map(|write| write.transition.to_state)
820 .collect();
821 let error_codes: Vec<Option<CreditRegistrationErrorCode>> = writes
822 .iter()
823 .map(|write| write.transition.error_code)
824 .collect();
825 let error_messages: Vec<Option<String>> = writes
826 .iter()
827 .map(|write| write.transition.error_message.clone())
828 .collect();
829 let needs_admin: Vec<Option<bool>> = writes
830 .iter()
831 .map(|write| write.transition.needs_admin_attention)
832 .collect();
833 let terminal: Vec<bool> = to_states.iter().map(|state| state.is_terminal()).collect();
834 let failure: Vec<bool> = to_states.iter().map(|state| state.is_failure()).collect();
835 let next_attempts: Vec<Option<DateTime<Utc>>> = writes
836 .iter()
837 .map(|write| write.transition.next_attempt_at)
838 .collect();
839 let default_delays: Vec<i64> = to_states
840 .iter()
841 .map(|state| state.default_attempt_delay_secs())
842 .collect();
843 sqlx::query!(
844 r#"
845UPDATE credit_registrations cr
846SET state = move.to_state,
847 -- clock_timestamp(), not now(): now() is the transaction timestamp, so several state changes in
848 -- one transaction would share an instant and the timeline would lose their order.
849 state_entered_at = clock_timestamp(),
850 error_code = move.error_code,
851 error_message = move.error_message,
852 needs_admin_attention = COALESCE(move.needs_admin_attention, cr.needs_admin_attention),
853 -- ELSE NULL: without it an admin retry stays invisible to every terminal_at IS NULL query.
854 terminal_at = CASE
855 WHEN move.terminal THEN COALESCE(cr.terminal_at, now())
856 ELSE NULL
857 END,
858 first_failed_at = CASE
859 WHEN move.failure THEN COALESCE(cr.first_failed_at, now())
860 ELSE cr.first_failed_at
861 END,
862 registered_at = CASE
863 WHEN move.to_state = 'registered' THEN COALESCE(cr.registered_at, now())
864 ELSE cr.registered_at
865 END,
866 submitted_at = CASE
867 WHEN move.to_state = 'submitting' THEN now()
868 ELSE cr.submitted_at
869 END,
870 enrolment_checked_at = CASE
871 WHEN cr.state = 'checking_enrolment'
872 AND move.to_state <> 'checking_enrolment' THEN now()
873 ELSE cr.enrolment_checked_at
874 END,
875 enrolment_banner_dismissed_at = CASE
876 WHEN move.to_state = 'no_usable_enrolment' THEN NULL
877 ELSE cr.enrolment_banner_dismissed_at
878 END,
879 next_attempt_at = COALESCE(
880 move.next_attempt_at,
881 now() + (move.default_delay_secs * INTERVAL '1 second')
882 )
883FROM UNNEST(
884 $1::uuid [],
885 $2::credit_registration_state [],
886 $3::credit_registration_error_code [],
887 $4::text [],
888 $5::boolean [],
889 $6::boolean [],
890 $7::boolean [],
891 $8::timestamptz [],
892 $9::bigint []
893 ) AS move(
894 id,
895 to_state,
896 error_code,
897 error_message,
898 needs_admin_attention,
899 terminal,
900 failure,
901 next_attempt_at,
902 default_delay_secs
903 )
904WHERE cr.id = move.id
905 AND cr.deleted_at IS NULL
906 "#,
907 &ids,
908 &to_states as &[CreditRegistrationState],
909 &error_codes as &[Option<CreditRegistrationErrorCode>],
910 &error_messages as &[Option<String>],
911 &needs_admin as &[Option<bool>],
912 &terminal,
913 &failure,
914 &next_attempts as &[Option<DateTime<Utc>>],
915 &default_delays,
916 )
917 .execute(&mut *tx)
918 .await?;
919
920 crate::credit_registration_events::insert_batch(&mut tx, &events).await?;
921 tx.commit().await?;
922 Ok(i64::try_from(writes.len()).unwrap_or(i64::MAX))
923}
924
925pub async fn set_state_entered_at_for_testing(
931 conn: &mut PgConnection,
932 id: Uuid,
933 state_entered_at: DateTime<Utc>,
934) -> ModelResult<()> {
935 sqlx::query!(
936 "
937UPDATE credit_registrations
938SET state_entered_at = $2
939WHERE id = $1
940 ",
941 id,
942 state_entered_at,
943 )
944 .execute(conn)
945 .await?;
946 Ok(())
947}
948
949pub async fn set_first_failed_at_for_testing(
955 conn: &mut PgConnection,
956 id: Uuid,
957 first_failed_at: DateTime<Utc>,
958) -> ModelResult<()> {
959 sqlx::query!(
960 "
961UPDATE credit_registrations
962SET first_failed_at = $2
963WHERE id = $1
964 ",
965 id,
966 first_failed_at,
967 )
968 .execute(conn)
969 .await?;
970 Ok(())
971}
972
973pub async fn set_test_exclusive_hold_for_testing(
983 conn: &mut PgConnection,
984 user_id: Uuid,
985 course_id: Option<Uuid>,
986 held_until: DateTime<Utc>,
987) -> ModelResult<()> {
988 sqlx::query!(
989 "
990INSERT INTO credit_registration_test_exclusive_holds (user_id, course_id, held_until)
991VALUES ($1, $2, $3)
992 ",
993 user_id,
994 course_id,
995 held_until,
996 )
997 .execute(conn)
998 .await?;
999 Ok(())
1000}
1001
1002#[derive(Debug, Clone, Default, PartialEq)]
1005pub struct RegistrationScope {
1006 pub course_id: Option<Uuid>,
1007 pub user_id: Option<Uuid>,
1008 pub credit_registration_ids: Vec<Uuid>,
1010}
1011
1012impl RegistrationScope {
1013 pub fn is_unscoped(&self) -> bool {
1014 self.course_id.is_none()
1015 && self.user_id.is_none()
1016 && self.credit_registration_ids.is_empty()
1017 }
1018
1019 pub fn for_course(course_id: Uuid) -> Self {
1020 Self {
1021 course_id: Some(course_id),
1022 ..Self::default()
1023 }
1024 }
1025}
1026
1027pub async fn claim_due(
1039 conn: &mut PgConnection,
1040 states: &[CreditRegistrationState],
1041 scope: &RegistrationScope,
1042 limit: i64,
1043) -> ModelResult<Vec<CreditRegistration>> {
1044 let is_scoped_call = !scope.is_unscoped();
1045 let res = sqlx::query_as!(
1046 CreditRegistration,
1047 r#"
1048WITH due AS (
1049 SELECT cr.id
1050 FROM credit_registrations cr
1051 JOIN credit_registration_active_course_modules acm ON acm.course_module_id = cr.course_module_id
1052 WHERE cr.deleted_at IS NULL
1053 AND cr.superseded_by_id IS NULL
1054 AND cr.state = ANY($1::credit_registration_state [])
1055 AND cr.next_attempt_at <= now()
1056 AND ($3::uuid IS NULL OR cr.course_id = $3)
1057 AND ($4::uuid IS NULL OR cr.user_id = $4)
1058 AND (
1059 cardinality($5::uuid []) = 0
1060 OR cr.id = ANY($5::uuid [])
1061 )
1062 AND (
1063 $6::boolean
1064 OR NOT EXISTS (
1065 SELECT 1
1066 FROM credit_registration_test_exclusive_holds h
1067 WHERE h.user_id = cr.user_id
1068 AND (
1069 h.course_id IS NULL
1070 OR h.course_id = cr.course_id
1071 )
1072 AND h.held_until > now()
1073 )
1074 )
1075 ORDER BY cr.next_attempt_at
1076 FOR UPDATE OF cr SKIP LOCKED
1077 LIMIT $2
1078)
1079UPDATE credit_registrations cr
1080SET last_attempt_at = now()
1081FROM due
1082WHERE cr.id = due.id
1083RETURNING cr.*
1084 "#,
1085 states as &[CreditRegistrationState],
1086 limit,
1087 scope.course_id,
1088 scope.user_id,
1089 &scope.credit_registration_ids,
1090 is_scoped_call,
1091 )
1092 .fetch_all(conn)
1093 .await?;
1094 Ok(res)
1095}
1096
1097pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<CreditRegistration> {
1098 let res = sqlx::query_as!(
1099 CreditRegistration,
1100 r#"
1101SELECT *
1102FROM credit_registrations
1103WHERE id = $1
1104 AND deleted_at IS NULL
1105 "#,
1106 id
1107 )
1108 .fetch_one(conn)
1109 .await?;
1110 Ok(res)
1111}
1112
1113pub async fn get_by_ids_for_update(
1119 conn: &mut PgConnection,
1120 ids: &[Uuid],
1121) -> ModelResult<Vec<CreditRegistration>> {
1122 let res = sqlx::query_as!(
1123 CreditRegistration,
1124 r#"
1125SELECT *
1126FROM credit_registrations
1127WHERE id = ANY($1::uuid [])
1128 AND deleted_at IS NULL
1129ORDER BY id FOR UPDATE
1130 "#,
1131 ids
1132 )
1133 .fetch_all(conn)
1134 .await?;
1135 Ok(res)
1136}
1137
1138pub async fn get_by_user_id(
1139 conn: &mut PgConnection,
1140 user_id: Uuid,
1141) -> ModelResult<Vec<CreditRegistration>> {
1142 let res = sqlx::query_as!(
1143 CreditRegistration,
1144 r#"
1145SELECT *
1146FROM credit_registrations
1147WHERE user_id = $1
1148 AND deleted_at IS NULL
1149ORDER BY created_at DESC
1150 "#,
1151 user_id
1152 )
1153 .fetch_all(conn)
1154 .await?;
1155 Ok(res)
1156}
1157
1158pub async fn get_by_course_id(
1159 conn: &mut PgConnection,
1160 course_id: Uuid,
1161) -> ModelResult<Vec<CreditRegistration>> {
1162 let res = sqlx::query_as!(
1163 CreditRegistration,
1164 r#"
1165SELECT *
1166FROM credit_registrations
1167WHERE course_id = $1
1168 AND deleted_at IS NULL
1169ORDER BY created_at DESC
1170 "#,
1171 course_id
1172 )
1173 .fetch_all(conn)
1174 .await?;
1175 Ok(res)
1176}
1177
1178#[derive(Debug, Clone, PartialEq)]
1181pub struct StudentCreditRegistration {
1182 pub id: Uuid,
1183 pub course_id: Uuid,
1184 pub course_name: String,
1185 pub course_slug: String,
1186 pub course_module_id: Uuid,
1187 pub course_module_name: Option<String>,
1188 pub uh_course_code: Option<String>,
1189 pub ects_credits: Option<f32>,
1190 pub course_module_completion_id: Uuid,
1191 pub completion_date: DateTime<Utc>,
1192 pub state: CreditRegistrationState,
1193 pub error_code: Option<CreditRegistrationErrorCode>,
1194 pub next_attempt_at: DateTime<Utc>,
1195 pub registered_at: Option<DateTime<Utc>>,
1196 pub sisu_attainment_id: Option<String>,
1197 pub credits: Option<f32>,
1198 pub grade_id: Option<String>,
1199 pub grade_scale_id: Option<String>,
1202 pub attempt_number: i32,
1203 pub superseded_by_id: Option<Uuid>,
1204 pub superseded_at: Option<DateTime<Utc>>,
1205 pub enrolment_checked_at: Option<DateTime<Utc>>,
1206 pub enrolment_realisation_name: Option<String>,
1208 pub open_university_product_id: Option<String>,
1210 pub completion_eligible: bool,
1211 pub has_verified_student_number: bool,
1212}
1213
1214impl StudentCreditRegistration {
1215 pub fn preconditions(&self) -> PendingPreconditions {
1217 PendingPreconditions {
1218 completion_eligible: self.completion_eligible,
1219 has_verified_student_number: self.has_verified_student_number,
1220 }
1221 }
1222}
1223
1224#[derive(Debug, Default, Clone, Copy)]
1226pub struct StudentRegistrationFilter {
1227 pub course_module_id: Option<Uuid>,
1228 pub course_id: Option<Uuid>,
1229 pub enrolment_banner_due: bool,
1232}
1233
1234pub async fn get_student_facing_by_user_id(
1237 conn: &mut PgConnection,
1238 user_id: Uuid,
1239 filter: StudentRegistrationFilter,
1240) -> ModelResult<Vec<StudentCreditRegistration>> {
1241 let res = sqlx::query_as!(
1242 StudentCreditRegistration,
1243 r#"
1244SELECT cr.id,
1245 cr.course_id,
1246 c.name AS course_name,
1247 c.slug AS course_slug,
1248 cr.course_module_id,
1249 cm.name AS course_module_name,
1250 cm.uh_course_code,
1251 cm.ects_credits,
1252 cr.course_module_completion_id,
1253 cmc.completion_date,
1254 cr.state,
1255 cr.error_code AS "error_code?",
1256 cr.next_attempt_at,
1257 cr.registered_at,
1258 cr.sisu_attainment_id,
1259 cr.credits,
1260 cr.grade_id,
1261 cr.grade_scale_id,
1262 cr.attempt_number,
1263 cr.superseded_by_id,
1264 cr.superseded_at,
1265 cr.enrolment_checked_at,
1266 r.label AS "enrolment_realisation_name?",
1267 conf.open_university_product_id AS "open_university_product_id?",
1268 p.completion_eligible AS "completion_eligible!",
1269 p.has_verified_student_number AS "has_verified_student_number!"
1270FROM credit_registrations cr
1271 JOIN courses c ON c.id = cr.course_id
1272 JOIN course_modules cm ON cm.id = cr.course_module_id
1273 JOIN course_module_completions cmc ON cmc.id = cr.course_module_completion_id
1274 JOIN credit_registration_preconditions p ON p.credit_registration_id = cr.id
1275 LEFT JOIN course_module_suotar_configurations conf ON conf.course_module_id = cr.course_module_id
1276 AND conf.deleted_at IS NULL
1277 LEFT JOIN course_module_suotar_realisations r ON r.course_module_id = cr.course_module_id
1278 AND r.course_unit_realisation_id = cr.selected_enrolment_realisation_id
1279 AND r.deleted_at IS NULL
1280WHERE cr.user_id = $1
1281 AND cr.deleted_at IS NULL
1282 AND ($2::uuid IS NULL OR cr.course_module_id = $2)
1283 AND ($3::uuid IS NULL OR cr.course_id = $3)
1284 AND (
1285 NOT $4::boolean
1286 OR (
1287 cr.state = 'no_usable_enrolment'
1288 AND cr.enrolment_banner_dismissed_at IS NULL
1289 )
1290 )
1291ORDER BY cmc.completion_date DESC,
1292 cr.attempt_number DESC
1293 "#,
1294 user_id,
1295 filter.course_module_id,
1296 filter.course_id,
1297 filter.enrolment_banner_due,
1298 )
1299 .fetch_all(conn)
1300 .await?;
1301 Ok(res)
1302}
1303
1304#[derive(Debug, Clone, PartialEq)]
1307pub struct PayloadSnapshot {
1308 pub student_number: String,
1309 pub sisu_person_id: String,
1310 pub uh_course_code: String,
1311 pub selected_enrolment_id: Option<String>,
1312 pub selected_enrolment_kind: Option<String>,
1313 pub selected_enrolment_realisation_id: Option<String>,
1314 pub attainment_date: NaiveDate,
1315 pub attainment_language: String,
1316 pub grade_scale_id: String,
1317 pub grade_id: String,
1318 pub credits: f32,
1319}
1320
1321pub async fn set_payload_snapshot(
1322 conn: &mut PgConnection,
1323 id: Uuid,
1324 snapshot: &PayloadSnapshot,
1325) -> ModelResult<()> {
1326 sqlx::query!(
1327 r#"
1328UPDATE credit_registrations
1329SET student_number = $2,
1330 sisu_person_id = $3,
1331 uh_course_code = $4,
1332 selected_enrolment_id = $5,
1333 selected_enrolment_kind = $6,
1334 selected_enrolment_realisation_id = $7,
1335 attainment_date = $8,
1336 attainment_language = $9,
1337 grade_scale_id = $10,
1338 grade_id = $11,
1339 credits = $12
1340WHERE id = $1
1341 AND deleted_at IS NULL
1342 "#,
1343 id,
1344 snapshot.student_number,
1345 snapshot.sisu_person_id,
1346 snapshot.uh_course_code,
1347 snapshot.selected_enrolment_id,
1348 snapshot.selected_enrolment_kind,
1349 snapshot.selected_enrolment_realisation_id,
1350 snapshot.attainment_date,
1351 snapshot.attainment_language,
1352 snapshot.grade_scale_id,
1353 snapshot.grade_id,
1354 snapshot.credits,
1355 )
1356 .execute(conn)
1357 .await?;
1358 Ok(())
1359}
1360
1361pub async fn set_submitted_attainment(
1362 conn: &mut PgConnection,
1363 id: Uuid,
1364 submitted_attainment_id: &str,
1365 submitted_attainment_type: Option<&str>,
1366) -> ModelResult<()> {
1367 sqlx::query!(
1368 r#"
1369UPDATE credit_registrations
1370SET submitted_attainment_id = $2,
1371 submitted_attainment_type = $3
1372WHERE id = $1
1373 AND deleted_at IS NULL
1374 "#,
1375 id,
1376 submitted_attainment_id,
1377 submitted_attainment_type,
1378 )
1379 .execute(conn)
1380 .await?;
1381 Ok(())
1382}
1383
1384pub async fn set_sisu_attainment_if_unclaimed(
1389 conn: &mut PgConnection,
1390 id: Uuid,
1391 sisu_attainment_id: &str,
1392 sisu_attainment_type: Option<&str>,
1393) -> ModelResult<bool> {
1394 let updated = sqlx::query_scalar!(
1395 r#"
1396UPDATE credit_registrations
1397SET sisu_attainment_id = $2,
1398 sisu_attainment_type = $3
1399WHERE id = $1
1400 AND deleted_at IS NULL
1401 AND NOT EXISTS (
1402 SELECT 1
1403 FROM credit_registrations other
1404 WHERE other.sisu_attainment_id = $2
1405 AND other.deleted_at IS NULL
1406 AND other.id <> $1
1407 )
1408RETURNING id
1409 "#,
1410 id,
1411 sisu_attainment_id,
1412 sisu_attainment_type,
1413 )
1414 .fetch_optional(conn)
1415 .await;
1416 match updated {
1417 Ok(updated) => Ok(updated.is_some()),
1418 Err(err) => {
1421 let err: ModelError = err.into();
1422 match err.error_type() {
1423 ModelErrorType::DatabaseConstraint { constraint, .. }
1424 if constraint == "uq_credit_registrations_sisu_attainment" =>
1425 {
1426 Ok(false)
1427 }
1428 _ => Err(err),
1429 }
1430 }
1431 }
1432}
1433
1434pub async fn schedule_next_attempt(
1439 conn: &mut PgConnection,
1440 id: Uuid,
1441 next_attempt_at: DateTime<Utc>,
1442) -> ModelResult<()> {
1443 sqlx::query!(
1444 r#"
1445UPDATE credit_registrations
1446SET next_attempt_at = $2
1447WHERE id = $1
1448 AND deleted_at IS NULL
1449 "#,
1450 id,
1451 next_attempt_at,
1452 )
1453 .execute(conn)
1454 .await?;
1455 Ok(())
1456}
1457
1458pub async fn make_due_now_batch(conn: &mut PgConnection, ids: &[Uuid]) -> ModelResult<()> {
1463 sqlx::query!(
1464 r#"
1465UPDATE credit_registrations
1466SET next_attempt_at = now()
1467WHERE id = ANY($1)
1468 AND next_attempt_at > now()
1469 AND superseded_by_id IS NULL
1470 AND deleted_at IS NULL
1471 "#,
1472 ids,
1473 )
1474 .execute(conn)
1475 .await?;
1476 Ok(())
1477}
1478
1479pub async fn recheck_no_usable_enrolment_now(
1485 conn: &mut PgConnection,
1486 course_id: Uuid,
1487 user_ids: &[Uuid],
1488) -> ModelResult<u64> {
1489 let res = sqlx::query!(
1490 r#"
1491UPDATE credit_registrations
1492SET next_attempt_at = now()
1493WHERE course_id = $1
1494 AND user_id = ANY($2::uuid [])
1495 AND state = 'no_usable_enrolment'
1496 AND next_attempt_at > now()
1497 AND superseded_by_id IS NULL
1498 AND deleted_at IS NULL
1499 "#,
1500 course_id,
1501 user_ids,
1502 )
1503 .execute(conn)
1504 .await?;
1505 Ok(res.rows_affected())
1506}
1507
1508pub async fn increment_submit_retry_count(conn: &mut PgConnection, id: Uuid) -> ModelResult<i32> {
1509 let res = sqlx::query!(
1510 r#"
1511UPDATE credit_registrations
1512SET submit_retry_count = submit_retry_count + 1
1513WHERE id = $1
1514 AND deleted_at IS NULL
1515RETURNING submit_retry_count
1516 "#,
1517 id
1518 )
1519 .fetch_one(conn)
1520 .await?;
1521 Ok(res.submit_retry_count)
1522}
1523
1524pub async fn increment_verify_attempt_counts(
1527 conn: &mut PgConnection,
1528 ids: &[Uuid],
1529) -> ModelResult<HashMap<Uuid, i32>> {
1530 let rows = sqlx::query!(
1531 r#"
1532UPDATE credit_registrations
1533SET verify_attempt_count = verify_attempt_count + 1
1534WHERE id = ANY($1)
1535 AND deleted_at IS NULL
1536RETURNING id,
1537 verify_attempt_count
1538 "#,
1539 ids
1540 )
1541 .fetch_all(conn)
1542 .await?;
1543 Ok(rows
1544 .into_iter()
1545 .map(|row| (row.id, row.verify_attempt_count))
1546 .collect())
1547}
1548
1549pub async fn schedule_next_attempts(
1551 conn: &mut PgConnection,
1552 scheduled: &[(Uuid, DateTime<Utc>)],
1553) -> ModelResult<()> {
1554 let (ids, times): (Vec<Uuid>, Vec<DateTime<Utc>>) = scheduled.iter().copied().unzip();
1555 sqlx::query!(
1556 r#"
1557UPDATE credit_registrations cr
1558SET next_attempt_at = scheduled.at
1559FROM UNNEST($1::uuid [], $2::timestamptz []) AS scheduled(id, at)
1560WHERE cr.id = scheduled.id
1561 AND cr.deleted_at IS NULL
1562 "#,
1563 &ids,
1564 ×,
1565 )
1566 .execute(conn)
1567 .await?;
1568 Ok(())
1569}
1570
1571pub async fn set_needs_admin_attention(
1572 conn: &mut PgConnection,
1573 id: Uuid,
1574 needs_admin_attention: bool,
1575) -> ModelResult<()> {
1576 sqlx::query!(
1577 r#"
1578UPDATE credit_registrations
1579SET needs_admin_attention = $2
1580WHERE id = $1
1581 AND deleted_at IS NULL
1582 "#,
1583 id,
1584 needs_admin_attention,
1585 )
1586 .execute(conn)
1587 .await?;
1588 Ok(())
1589}
1590
1591pub async fn dismiss_enrolment_banner(
1593 conn: &mut PgConnection,
1594 id: Uuid,
1595 user_id: Uuid,
1596) -> ModelResult<()> {
1597 sqlx::query!(
1598 r#"
1599UPDATE credit_registrations
1600SET enrolment_banner_dismissed_at = now()
1601WHERE id = $1
1602 AND user_id = $2
1603 AND deleted_at IS NULL
1604 "#,
1605 id,
1606 user_id,
1607 )
1608 .execute(conn)
1609 .await?;
1610 Ok(())
1611}
1612
1613pub async fn mark_superseded(
1620 conn: &mut PgConnection,
1621 id: Uuid,
1622 superseded_by_id: Uuid,
1623) -> ModelResult<()> {
1624 sqlx::query!(
1625 r#"
1626UPDATE credit_registrations
1627SET superseded_by_id = $2,
1628 superseded_at = now()
1629WHERE id = $1
1630 AND deleted_at IS NULL
1631 "#,
1632 id,
1633 superseded_by_id,
1634 )
1635 .execute(conn)
1636 .await?;
1637 Ok(())
1638}
1639
1640pub async fn exists_for_user_and_course(
1645 conn: &mut PgConnection,
1646 user_id: Uuid,
1647 course_id: Uuid,
1648) -> ModelResult<bool> {
1649 let exists = sqlx::query_scalar!(
1650 r#"
1651SELECT EXISTS (
1652 SELECT 1
1653 FROM credit_registrations
1654 WHERE user_id = $1
1655 AND course_id = $2
1656 AND deleted_at IS NULL
1657 ) AS "exists!"
1658 "#,
1659 user_id,
1660 course_id,
1661 )
1662 .fetch_one(conn)
1663 .await?;
1664 Ok(exists)
1665}
1666
1667pub async fn mark_improvement_checked(
1673 conn: &mut PgConnection,
1674 id: Uuid,
1675 completion_updated_at: DateTime<Utc>,
1676) -> ModelResult<()> {
1677 sqlx::query!(
1678 r#"
1679UPDATE credit_registrations
1680SET improvement_checked_completion_updated_at = $2
1681WHERE id = $1
1682 AND deleted_at IS NULL
1683 "#,
1684 id,
1685 completion_updated_at,
1686 )
1687 .execute(conn)
1688 .await?;
1689 Ok(())
1690}
1691
1692pub async fn get_retryable_ids_by_course_id(
1700 conn: &mut PgConnection,
1701 course_id: Uuid,
1702 limit: i64,
1703) -> ModelResult<Vec<Uuid>> {
1704 let res = sqlx::query_scalar!(
1705 r#"
1706SELECT id
1707FROM credit_registrations cr
1708WHERE cr.course_id = $1
1709 AND cr.state = 'failed_permanent'
1710 AND cr.superseded_by_id IS NULL
1711 AND cr.deleted_at IS NULL
1712ORDER BY cr.state_entered_at
1713LIMIT $2
1714 "#,
1715 course_id,
1716 limit,
1717 )
1718 .fetch_all(conn)
1719 .await?;
1720 Ok(res)
1721}
1722
1723pub async fn count_submission_uncertain_by_course_id(
1730 conn: &mut PgConnection,
1731 course_id: Uuid,
1732) -> ModelResult<i64> {
1733 let count = sqlx::query_scalar!(
1734 r#"
1735SELECT COUNT(*) AS "count!"
1736FROM credit_registrations cr
1737WHERE cr.course_id = $1
1738 AND cr.state = 'submission_uncertain'
1739 AND cr.superseded_by_id IS NULL
1740 AND cr.deleted_at IS NULL
1741 "#,
1742 course_id,
1743 )
1744 .fetch_one(conn)
1745 .await?;
1746 Ok(count)
1747}
1748
1749pub async fn count_by_state(
1752 conn: &mut PgConnection,
1753) -> ModelResult<Vec<(CreditRegistrationState, i64)>> {
1754 let rows = sqlx::query!(
1755 r#"
1756SELECT state,
1757 COUNT(*) AS "count!"
1758FROM credit_registrations
1759WHERE superseded_by_id IS NULL
1760 AND deleted_at IS NULL
1761GROUP BY state
1762 "#,
1763 )
1764 .fetch_all(conn)
1765 .await?;
1766 Ok(rows.into_iter().map(|r| (r.state, r.count)).collect())
1767}
1768
1769pub async fn count_pending_by_reason(conn: &mut PgConnection) -> ModelResult<PendingReasonCounts> {
1773 let row = sqlx::query!(
1774 r#"
1775SELECT COUNT(*) FILTER (
1776 WHERE NOT p.completion_eligible
1777 ) AS "completion_count!",
1778 COUNT(*) FILTER (
1779 WHERE p.completion_eligible
1780 AND NOT p.has_verified_student_number
1781 ) AS "student_number_count!"
1782FROM credit_registrations cr
1783 JOIN credit_registration_preconditions p ON p.credit_registration_id = cr.id
1784WHERE cr.state = 'pending'
1785 AND cr.superseded_by_id IS NULL
1786 AND cr.deleted_at IS NULL
1787 "#,
1788 )
1789 .fetch_one(conn)
1790 .await?;
1791 Ok(PendingReasonCounts {
1792 completion_count: row.completion_count,
1793 student_number_count: row.student_number_count,
1794 })
1795}
1796
1797pub async fn count_by_module_and_state_for_course(
1801 conn: &mut PgConnection,
1802 course_id: Uuid,
1803) -> ModelResult<Vec<(Uuid, CreditRegistrationState, i64, i64)>> {
1804 let rows = sqlx::query!(
1805 r#"
1806SELECT course_module_id,
1807 state,
1808 COUNT(*) AS "count!",
1809 COUNT(*) FILTER (WHERE needs_admin_attention) AS "needs_admin_attention_count!"
1810FROM credit_registrations
1811WHERE course_id = $1
1812 AND superseded_by_id IS NULL
1813 AND deleted_at IS NULL
1814GROUP BY course_module_id,
1815 state
1816 "#,
1817 course_id
1818 )
1819 .fetch_all(conn)
1820 .await?;
1821 Ok(rows
1822 .into_iter()
1823 .map(|r| {
1824 (
1825 r.course_module_id,
1826 r.state,
1827 r.count,
1828 r.needs_admin_attention_count,
1829 )
1830 })
1831 .collect())
1832}
1833
1834#[derive(Debug, Clone, PartialEq)]
1837pub struct TeacherCreditRegistration {
1838 pub id: Uuid,
1839 pub user_id: Uuid,
1840 pub first_name: Option<String>,
1841 pub last_name: Option<String>,
1842 pub email: Option<String>,
1843 pub course_id: Uuid,
1844 pub course_module_id: Uuid,
1845 pub course_module_name: Option<String>,
1846 pub course_instance_id: Uuid,
1847 pub course_module_completion_id: Uuid,
1848 pub completion_date: DateTime<Utc>,
1849 pub state: CreditRegistrationState,
1850 pub state_entered_at: DateTime<Utc>,
1851 pub error_code: Option<CreditRegistrationErrorCode>,
1852 pub needs_admin_attention: bool,
1853 pub next_attempt_at: DateTime<Utc>,
1854 pub registered_at: Option<DateTime<Utc>>,
1855 pub sisu_attainment_id: Option<String>,
1856 pub grade_id: Option<String>,
1857 pub credits: Option<f32>,
1858 pub attempt_number: i32,
1859 pub superseded_by_id: Option<Uuid>,
1860 pub student_number: Option<String>,
1862 pub student_number_verified_at: Option<DateTime<Utc>>,
1863 pub student_number_verified_via: Option<StudentNumberVerificationMethod>,
1864 pub sisu_person_id: Option<String>,
1866 pub enrolment_realisation_name: Option<String>,
1867 pub completion_eligible: bool,
1868 pub total_count: i64,
1870}
1871
1872impl TeacherCreditRegistration {
1873 pub fn preconditions(&self) -> PendingPreconditions {
1876 PendingPreconditions {
1877 completion_eligible: self.completion_eligible,
1878 has_verified_student_number: self.student_number.is_some(),
1879 }
1880 }
1881}
1882
1883#[derive(Debug, Clone, Default)]
1885pub struct TeacherCreditRegistrationFilters<'a> {
1886 pub id: Option<Uuid>,
1887 pub user_ids: Option<&'a [Uuid]>,
1888 pub state: Option<CreditRegistrationState>,
1889 pub search: Option<&'a str>,
1891 pub course_instance_id: Option<Uuid>,
1892 pub course_module_completion_id: Option<Uuid>,
1894}
1895
1896async fn teacher_facing_page(
1900 conn: &mut PgConnection,
1901 course_id: Option<Uuid>,
1902 filters: &TeacherCreditRegistrationFilters<'_>,
1903 limit: i64,
1904 offset: i64,
1905) -> ModelResult<Vec<TeacherCreditRegistration>> {
1906 let search_pattern = filters.search.map(search_pattern_of);
1907 let res = sqlx::query_as!(
1908 TeacherCreditRegistration,
1909 r#"
1910SELECT cr.id,
1911 cr.user_id,
1912 ud.first_name AS "first_name?",
1913 ud.last_name AS "last_name?",
1914 ud.email AS "email?",
1915 cr.course_id,
1916 cr.course_module_id,
1917 cm.name AS course_module_name,
1918 cr.course_instance_id,
1919 cr.course_module_completion_id,
1920 cmc.completion_date,
1921 cr.state,
1922 cr.state_entered_at,
1923 cr.error_code AS "error_code?",
1924 cr.needs_admin_attention,
1925 cr.next_attempt_at,
1926 cr.registered_at,
1927 cr.sisu_attainment_id,
1928 cr.grade_id,
1929 cr.credits,
1930 cr.attempt_number,
1931 cr.superseded_by_id,
1932 vsn.student_number AS "student_number?",
1933 vsn.verified_at AS "student_number_verified_at?",
1934 vsn.verified_via AS "student_number_verified_via?",
1935 vsn.sisu_person_id AS "sisu_person_id?",
1936 r.label AS "enrolment_realisation_name?",
1937 p.completion_eligible AS "completion_eligible!",
1938 COUNT(*) OVER () AS "total_count!"
1939FROM credit_registrations cr
1940 JOIN course_modules cm ON cm.id = cr.course_module_id
1941 JOIN course_module_completions cmc ON cmc.id = cr.course_module_completion_id
1942 JOIN credit_registration_preconditions p ON p.credit_registration_id = cr.id
1943 LEFT JOIN user_details ud ON ud.user_id = cr.user_id
1944 LEFT JOIN verified_student_numbers vsn ON vsn.user_id = cr.user_id
1945 AND vsn.deleted_at IS NULL
1946 LEFT JOIN course_module_suotar_realisations r ON r.course_module_id = cr.course_module_id
1947 AND r.course_unit_realisation_id = cr.selected_enrolment_realisation_id
1948 AND r.deleted_at IS NULL
1949WHERE cr.deleted_at IS NULL
1950 AND ($1::uuid IS NULL OR cr.course_id = $1)
1951 AND ($2::uuid IS NULL OR cr.id = $2)
1952 AND ($3::uuid [] IS NULL OR cr.user_id = ANY($3))
1953 AND (
1954 $4::credit_registration_state IS NULL
1955 OR cr.state = $4
1956 )
1957 AND (
1958 $5::text IS NULL
1959 OR ud.name_search_helper LIKE '%' || $5 || '%' ESCAPE '\'
1960 OR ud.email_search_helper LIKE '%' || $5 || '%' ESCAPE '\'
1961 OR LOWER(vsn.student_number) LIKE '%' || $5 || '%' ESCAPE '\'
1962 )
1963 AND ($6::uuid IS NULL OR cr.course_instance_id = $6)
1964 AND ($7::uuid IS NULL OR cr.course_module_completion_id = $7)
1965ORDER BY cmc.completion_date DESC,
1966 cr.attempt_number DESC,
1967 cr.id
1968LIMIT $8 OFFSET $9
1969 "#,
1970 course_id,
1971 filters.id,
1972 filters.user_ids,
1973 filters.state as Option<CreditRegistrationState>,
1974 search_pattern.as_deref(),
1975 filters.course_instance_id,
1976 filters.course_module_completion_id,
1977 limit,
1978 offset,
1979 )
1980 .fetch_all(conn)
1981 .await?;
1982 Ok(res)
1983}
1984
1985pub async fn get_teacher_facing_by_course_id(
1987 conn: &mut PgConnection,
1988 course_id: Uuid,
1989 filters: &TeacherCreditRegistrationFilters<'_>,
1990 limit: i64,
1991 offset: i64,
1992) -> ModelResult<Vec<TeacherCreditRegistration>> {
1993 teacher_facing_page(conn, Some(course_id), filters, limit, offset).await
1994}
1995
1996pub async fn count_teacher_facing_by_course_id(
1998 conn: &mut PgConnection,
1999 course_id: Uuid,
2000 filters: &TeacherCreditRegistrationFilters<'_>,
2001) -> ModelResult<i64> {
2002 let rows = teacher_facing_page(conn, Some(course_id), filters, 1, 0).await?;
2003 Ok(rows.first().map_or(0, |row| row.total_count))
2004}
2005
2006fn search_pattern_of(search: &str) -> String {
2008 escape_like_pattern(&search.to_lowercase())
2009}
2010
2011pub async fn get_teacher_facing_by_id(
2013 conn: &mut PgConnection,
2014 id: Uuid,
2015) -> ModelResult<Option<TeacherCreditRegistration>> {
2016 let rows = teacher_facing_page(
2017 conn,
2018 None,
2019 &TeacherCreditRegistrationFilters {
2020 id: Some(id),
2021 ..TeacherCreditRegistrationFilters::default()
2022 },
2023 1,
2024 0,
2025 )
2026 .await?;
2027 Ok(rows.into_iter().next())
2028}
2029
2030pub async fn get_teacher_facing_attempts_for_completion(
2032 conn: &mut PgConnection,
2033 row: &TeacherCreditRegistration,
2034) -> ModelResult<Vec<TeacherCreditRegistration>> {
2035 get_teacher_facing_by_course_id(
2036 conn,
2037 row.course_id,
2038 &TeacherCreditRegistrationFilters {
2039 user_ids: Some(&[row.user_id]),
2040 course_module_completion_id: Some(row.course_module_completion_id),
2041 ..TeacherCreditRegistrationFilters::default()
2042 },
2043 i64::MAX,
2044 0,
2045 )
2046 .await
2047}
2048
2049#[derive(Debug, Clone, PartialEq)]
2055pub struct AdminCreditRegistration {
2056 pub id: Uuid,
2057 pub created_at: DateTime<Utc>,
2058 pub user_id: Uuid,
2059 pub first_name: Option<String>,
2060 pub last_name: Option<String>,
2061 pub email: Option<String>,
2063 pub course_id: Uuid,
2064 pub course_name: String,
2065 pub course_module_id: Uuid,
2066 pub course_module_name: Option<String>,
2067 pub course_instance_id: Uuid,
2068 pub course_module_completion_id: Uuid,
2069 pub completion_date: DateTime<Utc>,
2070 pub state: CreditRegistrationState,
2071 pub state_entered_at: DateTime<Utc>,
2072 pub error_code: Option<CreditRegistrationErrorCode>,
2073 pub needs_admin_attention: bool,
2074 pub next_attempt_at: DateTime<Utc>,
2075 pub last_attempt_at: Option<DateTime<Utc>>,
2076 pub submitted_at: Option<DateTime<Utc>>,
2077 pub registered_at: Option<DateTime<Utc>>,
2078 pub terminal_at: Option<DateTime<Utc>>,
2079 pub student_number: Option<String>,
2081 pub sisu_person_id: Option<String>,
2082 pub uh_course_code: Option<String>,
2083 pub selected_enrolment_id: Option<String>,
2084 pub grade_scale_id: Option<String>,
2085 pub grade_id: Option<String>,
2086 pub credits: Option<f32>,
2087 pub request_item_id: String,
2088 pub submitted_attainment_id: Option<String>,
2089 pub sisu_attainment_id: Option<String>,
2090 pub submit_retry_count: i32,
2091 pub verify_attempt_count: i32,
2092 pub attempt_number: i32,
2093 pub superseded_by_id: Option<Uuid>,
2094 pub verified_student_number: Option<String>,
2096 pub verified_student_number_at: Option<DateTime<Utc>>,
2097 pub verified_student_number_via: Option<StudentNumberVerificationMethod>,
2098 pub completion_eligible: bool,
2099 pub has_verified_student_number: bool,
2100 pub total_count: i64,
2102}
2103
2104impl AdminCreditRegistration {
2105 pub fn pending_reason(&self) -> Option<CreditRegistrationPendingReason> {
2108 (self.state == CreditRegistrationState::Pending)
2109 .then(|| {
2110 PendingPreconditions {
2111 completion_eligible: self.completion_eligible,
2112 has_verified_student_number: self.has_verified_student_number,
2113 }
2114 .reason()
2115 })
2116 .flatten()
2117 }
2118}
2119
2120#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2122pub enum AdminCreditRegistrationSort {
2123 #[default]
2124 LastActivity,
2125 Created,
2126 TimeInState,
2127 Attempts,
2128}
2129
2130impl AdminCreditRegistrationSort {
2131 fn as_str(self) -> &'static str {
2133 match self {
2134 Self::LastActivity => "last_activity",
2135 Self::Created => "created",
2136 Self::TimeInState => "time_in_state",
2137 Self::Attempts => "attempts",
2138 }
2139 }
2140}
2141
2142#[derive(Debug, Clone, Default)]
2144pub struct AdminCreditRegistrationFilters<'a> {
2145 pub states: Option<&'a [CreditRegistrationState]>,
2146 pub error_codes: Option<&'a [CreditRegistrationErrorCode]>,
2147 pub course_id: Option<Uuid>,
2148 pub course_module_id: Option<Uuid>,
2149 pub user_id: Option<Uuid>,
2150 pub student_number: Option<&'a str>,
2151 pub needs_admin_attention: bool,
2152 pub submitted_after: Option<DateTime<Utc>>,
2153 pub submitted_before: Option<DateTime<Utc>>,
2154 pub search: Option<&'a str>,
2157 pub search_id: Option<Uuid>,
2161 pub id: Option<Uuid>,
2163 pub course_module_completion_id: Option<Uuid>,
2165 pub credit_registration_ids: Option<&'a [Uuid]>,
2167 pub include_superseded: bool,
2169}
2170
2171async fn admin_facing_page(
2175 conn: &mut PgConnection,
2176 filters: &AdminCreditRegistrationFilters<'_>,
2177 sort: AdminCreditRegistrationSort,
2178 limit: i64,
2179 offset: i64,
2180) -> ModelResult<Vec<AdminCreditRegistration>> {
2181 let search_pattern = filters.search.map(search_pattern_of);
2182 let res = sqlx::query_as!(
2183 AdminCreditRegistration,
2184 r#"
2185SELECT cr.id,
2186 cr.created_at,
2187 cr.user_id,
2188 ud.first_name AS "first_name?",
2189 ud.last_name AS "last_name?",
2190 ud.email AS "email?",
2191 cr.course_id,
2192 c.name AS course_name,
2193 cr.course_module_id,
2194 cm.name AS course_module_name,
2195 cr.course_instance_id,
2196 cr.course_module_completion_id,
2197 cmc.completion_date,
2198 cr.state,
2199 cr.state_entered_at,
2200 cr.error_code AS "error_code?",
2201 cr.needs_admin_attention,
2202 cr.next_attempt_at,
2203 cr.last_attempt_at,
2204 cr.submitted_at,
2205 cr.registered_at,
2206 cr.terminal_at,
2207 cr.student_number,
2208 cr.sisu_person_id,
2209 cr.uh_course_code,
2210 cr.selected_enrolment_id,
2211 cr.grade_scale_id,
2212 cr.grade_id,
2213 cr.credits,
2214 cr.request_item_id,
2215 cr.submitted_attainment_id,
2216 cr.sisu_attainment_id,
2217 cr.submit_retry_count,
2218 cr.verify_attempt_count,
2219 cr.attempt_number,
2220 cr.superseded_by_id,
2221 vsn.student_number AS "verified_student_number?",
2222 vsn.verified_at AS "verified_student_number_at?",
2223 vsn.verified_via AS "verified_student_number_via?",
2224 p.completion_eligible AS "completion_eligible!",
2225 p.has_verified_student_number AS "has_verified_student_number!",
2226 COUNT(*) OVER () AS "total_count!"
2227FROM credit_registrations cr
2228 JOIN courses c ON c.id = cr.course_id
2229 JOIN course_modules cm ON cm.id = cr.course_module_id
2230 JOIN course_module_completions cmc ON cmc.id = cr.course_module_completion_id
2231 JOIN credit_registration_preconditions p ON p.credit_registration_id = cr.id
2232 LEFT JOIN user_details ud ON ud.user_id = cr.user_id
2233 LEFT JOIN verified_student_numbers vsn ON vsn.user_id = cr.user_id
2234 AND vsn.deleted_at IS NULL
2235WHERE cr.deleted_at IS NULL
2236 AND ($1::bool OR cr.superseded_by_id IS NULL)
2237 AND (
2238 $2::credit_registration_state [] IS NULL
2239 OR cr.state = ANY($2)
2240 )
2241 AND (
2242 $3::credit_registration_error_code [] IS NULL
2243 OR cr.error_code = ANY($3)
2244 )
2245 AND ($4::uuid IS NULL OR cr.course_id = $4)
2246 AND ($5::uuid IS NULL OR cr.course_module_id = $5)
2247 AND ($6::uuid IS NULL OR cr.user_id = $6)
2248 AND (
2249 $7::text IS NULL
2250 OR cr.student_number = $7
2251 OR vsn.student_number = $7
2252 )
2253 AND (NOT $8::bool OR cr.needs_admin_attention)
2254 AND ($9::timestamptz IS NULL OR cr.submitted_at >= $9)
2255 AND ($10::timestamptz IS NULL OR cr.submitted_at <= $10)
2256 AND (
2257 $11::text IS NULL
2258 OR ud.name_search_helper LIKE '%' || $11 || '%' ESCAPE '\'
2259 OR ud.email_search_helper LIKE '%' || $11 || '%' ESCAPE '\'
2260 OR LOWER(cr.student_number) LIKE '%' || $11 || '%' ESCAPE '\'
2261 OR LOWER(vsn.student_number) LIKE '%' || $11 || '%' ESCAPE '\'
2262 OR LOWER(cr.submitted_attainment_id) LIKE '%' || $11 || '%' ESCAPE '\'
2263 OR LOWER(cr.sisu_attainment_id) LIKE '%' || $11 || '%' ESCAPE '\'
2264 OR LOWER(cr.error_message) LIKE '%' || $11 || '%' ESCAPE '\'
2265 )
2266 AND (
2267 $12::uuid IS NULL
2268 OR cr.id = $12
2269 OR cr.user_id = $12
2270 OR cr.course_module_completion_id = $12
2271 )
2272 AND (
2273 $13::uuid [] IS NULL
2274 OR cr.id = ANY($13)
2275 )
2276 AND ($17::uuid IS NULL OR cr.id = $17)
2277 AND (
2278 $18::uuid IS NULL
2279 OR cr.course_module_completion_id = $18
2280 )
2281ORDER BY CASE
2282 WHEN $14::text = 'attempts' THEN cr.submit_retry_count + cr.verify_attempt_count
2283 END DESC NULLS LAST,
2284 CASE $14::text
2285 WHEN 'created' THEN cr.created_at
2286 WHEN 'time_in_state' THEN cr.state_entered_at
2287 ELSE COALESCE(cr.last_attempt_at, cr.state_entered_at)
2288 END DESC,
2289 cr.id
2290LIMIT $15 OFFSET $16
2291 "#,
2292 filters.include_superseded,
2293 filters.states as Option<&[CreditRegistrationState]>,
2294 filters.error_codes as Option<&[CreditRegistrationErrorCode]>,
2295 filters.course_id,
2296 filters.course_module_id,
2297 filters.user_id,
2298 filters.student_number,
2299 filters.needs_admin_attention,
2300 filters.submitted_after,
2301 filters.submitted_before,
2302 search_pattern.as_deref(),
2303 filters.search_id,
2304 filters.credit_registration_ids as Option<&[Uuid]>,
2305 sort.as_str(),
2306 limit,
2307 offset,
2308 filters.id,
2309 filters.course_module_completion_id,
2310 )
2311 .fetch_all(conn)
2312 .await?;
2313 Ok(res)
2314}
2315
2316pub async fn get_admin_facing(
2318 conn: &mut PgConnection,
2319 filters: &AdminCreditRegistrationFilters<'_>,
2320 sort: AdminCreditRegistrationSort,
2321 limit: i64,
2322 offset: i64,
2323) -> ModelResult<Vec<AdminCreditRegistration>> {
2324 admin_facing_page(conn, filters, sort, limit, offset).await
2325}
2326
2327pub async fn count_admin_facing(
2329 conn: &mut PgConnection,
2330 filters: &AdminCreditRegistrationFilters<'_>,
2331) -> ModelResult<i64> {
2332 let rows =
2333 admin_facing_page(conn, filters, AdminCreditRegistrationSort::default(), 1, 0).await?;
2334 Ok(rows.first().map_or(0, |row| row.total_count))
2335}
2336
2337#[derive(Debug, Clone, PartialEq)]
2339pub struct CreditRegistrationErrorCodeCount {
2340 pub error_code: CreditRegistrationErrorCode,
2341 pub in_flight_count: i64,
2342 pub terminal_failure_count: i64,
2343}
2344
2345pub async fn count_by_error_code(
2347 conn: &mut PgConnection,
2348) -> ModelResult<Vec<CreditRegistrationErrorCodeCount>> {
2349 let rows = sqlx::query!(
2350 r#"
2351SELECT error_code AS "error_code!",
2352 COUNT(*) FILTER (WHERE terminal_at IS NULL) AS "in_flight_count!",
2353 COUNT(*) FILTER (
2354 WHERE state = ANY($1::credit_registration_state [])
2355 ) AS "terminal_failure_count!"
2356FROM credit_registrations
2357WHERE error_code IS NOT NULL
2358 AND superseded_by_id IS NULL
2359 AND deleted_at IS NULL
2360GROUP BY error_code
2361ORDER BY COUNT(*) DESC
2362 "#,
2363 &CreditRegistrationState::HARD_FAILURE_STATES as &[CreditRegistrationState],
2364 )
2365 .fetch_all(conn)
2366 .await?;
2367 Ok(rows
2368 .into_iter()
2369 .map(|row| CreditRegistrationErrorCodeCount {
2370 error_code: row.error_code,
2371 in_flight_count: row.in_flight_count,
2372 terminal_failure_count: row.terminal_failure_count,
2373 })
2374 .collect())
2375}
2376
2377pub async fn count_needing_admin_attention(conn: &mut PgConnection) -> ModelResult<i64> {
2378 let count = sqlx::query_scalar!(
2379 r#"
2380SELECT COUNT(*) AS "count!"
2381FROM credit_registrations
2382WHERE needs_admin_attention
2383 AND superseded_by_id IS NULL
2384 AND deleted_at IS NULL
2385 "#,
2386 )
2387 .fetch_one(conn)
2388 .await?;
2389 Ok(count)
2390}
2391
2392#[derive(Debug, Clone, PartialEq)]
2394pub struct OldestNonTerminalRegistration {
2395 pub id: Uuid,
2396 pub state: CreditRegistrationState,
2397 pub state_entered_at: DateTime<Utc>,
2398}
2399
2400pub async fn get_oldest_non_terminal(
2401 conn: &mut PgConnection,
2402) -> ModelResult<Option<OldestNonTerminalRegistration>> {
2403 let row = sqlx::query_as!(
2404 OldestNonTerminalRegistration,
2405 r#"
2406SELECT id,
2407 state,
2408 state_entered_at
2409FROM credit_registrations
2410WHERE terminal_at IS NULL
2411 AND superseded_by_id IS NULL
2412 AND deleted_at IS NULL
2413ORDER BY state_entered_at
2414LIMIT 1
2415 "#,
2416 )
2417 .fetch_optional(conn)
2418 .await?;
2419 Ok(row)
2420}
2421
2422#[derive(Debug, Clone, PartialEq)]
2424pub struct CreditRegistrationThroughputDay {
2425 pub day: DateTime<Utc>,
2426 pub registered_count: i64,
2427 pub other_success_count: i64,
2428 pub failed_count: i64,
2429}
2430
2431pub async fn get_throughput_by_day(
2434 conn: &mut PgConnection,
2435 since: DateTime<Utc>,
2436) -> ModelResult<Vec<CreditRegistrationThroughputDay>> {
2437 let rows = sqlx::query_as!(
2438 CreditRegistrationThroughputDay,
2439 r#"
2440SELECT DATE_TRUNC('day', terminal_at) AS "day!",
2441 COUNT(*) FILTER (WHERE state = 'registered') AS "registered_count!",
2442 COUNT(*) FILTER (
2443 WHERE state = ANY($2::credit_registration_state [])
2444 ) AS "other_success_count!",
2445 COUNT(*) FILTER (WHERE state = 'failed_permanent') AS "failed_count!"
2446FROM credit_registrations
2447WHERE terminal_at >= $1
2448 AND superseded_by_id IS NULL
2449 AND deleted_at IS NULL
2450GROUP BY 1
2451ORDER BY 1
2452 "#,
2453 since,
2454 &CreditRegistrationState::OTHER_SUCCESS_STATES as &[CreditRegistrationState],
2455 )
2456 .fetch_all(conn)
2457 .await?;
2458 Ok(rows)
2459}
2460
2461#[derive(Debug, Clone, PartialEq, Default)]
2463pub struct TerminalOutcomeTotals {
2464 pub success_count: i64,
2466 pub registered_count: i64,
2468 pub failed_permanent_count: i64,
2469 pub cancelled_count: i64,
2470 pub total_count: i64,
2472}
2473
2474pub async fn count_terminal_outcomes_since(
2475 conn: &mut PgConnection,
2476 since: DateTime<Utc>,
2477) -> ModelResult<TerminalOutcomeTotals> {
2478 let res = sqlx::query_as!(
2479 TerminalOutcomeTotals,
2480 r#"
2481SELECT COUNT(*) FILTER (
2482 WHERE state = ANY($2::credit_registration_state [])
2483 ) AS "success_count!",
2484 COUNT(*) FILTER (WHERE state = 'registered') AS "registered_count!",
2485 COUNT(*) FILTER (WHERE state = 'failed_permanent') AS "failed_permanent_count!",
2486 COUNT(*) FILTER (WHERE state = 'cancelled') AS "cancelled_count!",
2487 COUNT(*) AS "total_count!"
2488FROM credit_registrations
2489WHERE terminal_at >= $1
2490 AND superseded_by_id IS NULL
2491 AND deleted_at IS NULL
2492 "#,
2493 since,
2494 &CreditRegistrationState::SUCCESS_STATES as &[CreditRegistrationState],
2495 )
2496 .fetch_one(conn)
2497 .await?;
2498 Ok(res)
2499}
2500
2501pub async fn count_entered_state_since(
2504 conn: &mut PgConnection,
2505 state: CreditRegistrationState,
2506 since: DateTime<Utc>,
2507) -> ModelResult<i64> {
2508 let count = sqlx::query_scalar!(
2509 r#"
2510SELECT COUNT(*) AS "count!"
2511FROM credit_registrations
2512WHERE state = $1
2513 AND state_entered_at >= $2
2514 AND superseded_by_id IS NULL
2515 AND deleted_at IS NULL
2516 "#,
2517 state as CreditRegistrationState,
2518 since,
2519 )
2520 .fetch_one(conn)
2521 .await?;
2522 Ok(count)
2523}
2524
2525#[derive(Debug, Clone, PartialEq)]
2527pub struct RegistrationLatency {
2528 pub registered_count: i64,
2529 pub p50_end_to_end_secs: Option<i64>,
2531 pub p95_end_to_end_secs: Option<i64>,
2532 pub p50_confirmation_secs: Option<i64>,
2535 pub p95_confirmation_secs: Option<i64>,
2536}
2537
2538pub async fn get_registration_latency_between(
2539 conn: &mut PgConnection,
2540 from: DateTime<Utc>,
2541 to: DateTime<Utc>,
2542) -> ModelResult<RegistrationLatency> {
2543 let res = sqlx::query_as!(
2544 RegistrationLatency,
2545 r#"
2546SELECT COUNT(*) AS "registered_count!",
2547 CEIL(
2548 EXTRACT(
2549 EPOCH
2550 FROM PERCENTILE_DISC(0.5) WITHIN GROUP (
2551 ORDER BY terminal_at - created_at
2552 )
2553 )
2554 )::bigint AS "p50_end_to_end_secs",
2555 CEIL(
2556 EXTRACT(
2557 EPOCH
2558 FROM PERCENTILE_DISC(0.95) WITHIN GROUP (
2559 ORDER BY terminal_at - created_at
2560 )
2561 )
2562 )::bigint AS "p95_end_to_end_secs",
2563 CEIL(
2564 EXTRACT(
2565 EPOCH
2566 FROM PERCENTILE_DISC(0.5) WITHIN GROUP (
2567 ORDER BY registered_at - submitted_at
2568 )
2569 )
2570 )::bigint AS "p50_confirmation_secs",
2571 CEIL(
2572 EXTRACT(
2573 EPOCH
2574 FROM PERCENTILE_DISC(0.95) WITHIN GROUP (
2575 ORDER BY registered_at - submitted_at
2576 )
2577 )
2578 )::bigint AS "p95_confirmation_secs"
2579FROM credit_registrations
2580WHERE state = 'registered'
2581 AND terminal_at >= $1
2582 AND terminal_at < $2
2583 AND superseded_by_id IS NULL
2584 AND deleted_at IS NULL
2585 "#,
2586 from,
2587 to,
2588 )
2589 .fetch_one(conn)
2590 .await?;
2591 Ok(res)
2592}
2593
2594#[derive(Debug, Clone, PartialEq)]
2596pub struct ModuleRegistrationTotals {
2597 pub course_module_id: Uuid,
2598 pub total_count: i64,
2599 pub success_count: i64,
2600 pub in_flight_count: i64,
2601 pub failed_count: i64,
2602 pub needs_admin_attention_count: i64,
2603 pub last_registered_at: Option<DateTime<Utc>>,
2604 pub top_error_code: Option<CreditRegistrationErrorCode>,
2606}
2607
2608pub async fn count_by_module(
2611 conn: &mut PgConnection,
2612) -> ModelResult<Vec<ModuleRegistrationTotals>> {
2613 let res = sqlx::query_as!(
2614 ModuleRegistrationTotals,
2615 r#"
2616SELECT cr.course_module_id,
2617 COUNT(*) AS "total_count!",
2618 COUNT(*) FILTER (
2619 WHERE cr.state = ANY($1::credit_registration_state [])
2620 ) AS "success_count!",
2621 COUNT(*) FILTER (WHERE cr.terminal_at IS NULL) AS "in_flight_count!",
2622 COUNT(*) FILTER (
2623 WHERE cr.state = ANY($2::credit_registration_state [])
2624 ) AS "failed_count!",
2625 COUNT(*) FILTER (WHERE cr.needs_admin_attention) AS "needs_admin_attention_count!",
2626 MAX(cr.registered_at) AS "last_registered_at",
2627 (
2628 SELECT inner_cr.error_code
2629 FROM credit_registrations inner_cr
2630 WHERE inner_cr.course_module_id = cr.course_module_id
2631 AND inner_cr.error_code IS NOT NULL
2632 AND inner_cr.superseded_by_id IS NULL
2633 AND inner_cr.deleted_at IS NULL
2634 GROUP BY inner_cr.error_code
2635 ORDER BY COUNT(*) DESC,
2636 inner_cr.error_code
2637 LIMIT 1
2638 ) AS "top_error_code?: CreditRegistrationErrorCode"
2639FROM credit_registrations cr
2640WHERE superseded_by_id IS NULL
2641 AND deleted_at IS NULL
2642GROUP BY cr.course_module_id
2643 "#,
2644 &CreditRegistrationState::SUCCESS_STATES as &[CreditRegistrationState],
2645 &CreditRegistrationState::HARD_FAILURE_STATES as &[CreditRegistrationState],
2646 )
2647 .fetch_all(conn)
2648 .await?;
2649 Ok(res)
2650}
2651
2652#[derive(Debug, Clone, PartialEq)]
2654pub struct AttentionRegistration {
2655 pub id: Uuid,
2656 pub user_id: Uuid,
2657 pub first_name: Option<String>,
2658 pub last_name: Option<String>,
2659 pub email: Option<String>,
2660 pub course_id: Uuid,
2661 pub course_name: String,
2662 pub course_module_id: Uuid,
2663 pub course_module_name: Option<String>,
2664 pub state: CreditRegistrationState,
2665 pub state_entered_at: DateTime<Utc>,
2666 pub error_code: Option<CreditRegistrationErrorCode>,
2667 pub attempt_count: i32,
2668 pub needs_admin_attention: bool,
2669 pub next_attempt_at: DateTime<Utc>,
2670 pub student_number: Option<String>,
2671 pub stuck_in_state: bool,
2672 pub permanent_error: bool,
2673 pub retry_window_expired: bool,
2674 pub misregistered: bool,
2675 pub too_many_attempts: bool,
2676 pub outcome_uncertain: bool,
2677 pub flagged_by_pipeline: bool,
2678}
2679
2680pub async fn get_attention_items(
2687 conn: &mut PgConnection,
2688 thresholds: &StuckThresholds,
2689 too_many_attempts: i32,
2690 limit: i64,
2691) -> ModelResult<Vec<AttentionRegistration>> {
2692 let (state_thresholds, threshold_secs) = thresholds.state_seconds_arrays();
2693 let res = sqlx::query_as!(
2694 AttentionRegistration,
2695 r#"
2696SELECT cr.id,
2697 cr.user_id,
2698 ud.first_name AS "first_name?",
2699 ud.last_name AS "last_name?",
2700 ud.email AS "email?",
2701 cr.course_id,
2702 c.name AS course_name,
2703 cr.course_module_id,
2704 cm.name AS course_module_name,
2705 cr.state,
2706 cr.state_entered_at,
2707 cr.error_code AS "error_code?",
2708 cr.submit_retry_count + cr.verify_attempt_count AS "attempt_count!",
2709 cr.needs_admin_attention,
2710 cr.next_attempt_at,
2711 cr.student_number,
2712 d.stuck_in_state AS "stuck_in_state!",
2713 d.permanent_error AS "permanent_error!",
2714 d.retry_window_expired AS "retry_window_expired!",
2715 d.misregistered AS "misregistered!",
2716 d.too_many_attempts AS "too_many_attempts!",
2717 d.outcome_uncertain AS "outcome_uncertain!",
2718 d.flagged_by_pipeline AS "flagged_by_pipeline!"
2719FROM credit_registrations cr
2720 JOIN courses c ON c.id = cr.course_id
2721 JOIN course_modules cm ON cm.id = cr.course_module_id
2722 LEFT JOIN user_details ud ON ud.user_id = cr.user_id
2723 LEFT JOIN LATERAL (
2724 SELECT u.threshold_secs
2725 FROM UNNEST($1::credit_registration_state [], $2::double precision []) AS u(state, threshold_secs)
2726 WHERE u.state = cr.state
2727 ) t ON TRUE
2728 CROSS JOIN LATERAL (
2729 SELECT cr.terminal_at IS NULL
2730 AND t.threshold_secs IS NOT NULL
2731 AND now() - cr.state_entered_at > MAKE_INTERVAL(secs => t.threshold_secs) AS stuck_in_state,
2732 cr.state = 'failed_permanent'
2733 AND cr.needs_admin_attention AS permanent_error,
2734 -- Coalesced because error_code is nullable and this is selected into a plain `bool`: a row
2735 -- another detector picked while holding no error code would otherwise fail to decode and take
2736 -- the whole table down with it.
2737 COALESCE(cr.error_code = 'retry_window_expired', FALSE) AS retry_window_expired,
2738 cr.state = 'misregistered' AS misregistered,
2739 cr.submit_retry_count + cr.verify_attempt_count >= $3 AS too_many_attempts,
2740 cr.state = 'submission_uncertain' AS outcome_uncertain,
2741 cr.needs_admin_attention AS flagged_by_pipeline
2742 ) d
2743WHERE cr.superseded_by_id IS NULL
2744 AND cr.deleted_at IS NULL
2745 AND (
2746 d.stuck_in_state
2747 OR d.permanent_error
2748 OR d.retry_window_expired
2749 OR d.misregistered
2750 OR d.too_many_attempts
2751 OR d.outcome_uncertain
2752 OR d.flagged_by_pipeline
2753 )
2754ORDER BY cr.state_entered_at
2755LIMIT $4
2756 "#,
2757 &state_thresholds as &[CreditRegistrationState],
2758 &threshold_secs as &[f64],
2759 too_many_attempts,
2760 limit,
2761 )
2762 .fetch_all(conn)
2763 .await?;
2764 Ok(res)
2765}
2766
2767pub async fn get_live_by_states(
2771 conn: &mut PgConnection,
2772 states: &[CreditRegistrationState],
2773 limit_per_state: i64,
2774) -> ModelResult<Vec<CreditRegistration>> {
2775 let res = sqlx::query_as!(
2776 CreditRegistration,
2777 r#"
2778SELECT cr.*
2779FROM credit_registrations cr
2780 JOIN (
2781 SELECT id,
2782 ROW_NUMBER() OVER (
2783 PARTITION BY state
2784 ORDER BY state_entered_at DESC
2785 ) AS rn
2786 FROM credit_registrations
2787 WHERE state = ANY($1)
2788 AND superseded_by_id IS NULL
2789 AND deleted_at IS NULL
2790 ) ranked ON ranked.id = cr.id
2791WHERE ranked.rn <= $2
2792ORDER BY cr.state_entered_at DESC
2793 "#,
2794 states as &[CreditRegistrationState],
2795 limit_per_state,
2796 )
2797 .fetch_all(conn)
2798 .await?;
2799 Ok(res)
2800}
2801
2802pub async fn requeue_retryable_now(
2808 conn: &mut PgConnection,
2809 course_id: Option<Uuid>,
2810 course_module_id: Option<Uuid>,
2811 limit: i64,
2812) -> ModelResult<i64> {
2813 let count = sqlx::query_scalar!(
2814 r#"
2815WITH due AS (
2816 SELECT id
2817 FROM credit_registrations
2818 WHERE state = 'failed_retryable'
2819 AND next_attempt_at > now()
2820 AND superseded_by_id IS NULL
2821 AND deleted_at IS NULL
2822 AND ($2::uuid IS NULL OR course_id = $2)
2823 AND ($3::uuid IS NULL OR course_module_id = $3)
2824 ORDER BY next_attempt_at
2825 LIMIT $1
2826),
2827updated AS (
2828 UPDATE credit_registrations cr
2829 SET next_attempt_at = now()
2830 FROM due
2831 WHERE cr.id = due.id
2832 RETURNING cr.id
2833)
2834SELECT COUNT(*) AS "count!"
2835FROM updated
2836 "#,
2837 limit,
2838 course_id,
2839 course_module_id,
2840 )
2841 .fetch_one(conn)
2842 .await?;
2843 Ok(count)
2844}
2845
2846#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, ToSchema)]
2851pub struct StuckThresholds {
2852 pub stuck_ready_to_submit_secs: i64,
2853 pub stuck_submitting_secs: i64,
2854 pub stuck_awaiting_verification_secs: i64,
2855 pub stuck_failed_retryable_secs: i64,
2856}
2857
2858impl StuckThresholds {
2859 fn state_seconds_arrays(&self) -> ([CreditRegistrationState; 4], [f64; 4]) {
2863 (
2864 [
2865 CreditRegistrationState::ReadyToSubmit,
2866 CreditRegistrationState::Submitting,
2867 CreditRegistrationState::AwaitingVerification,
2868 CreditRegistrationState::FailedRetryable,
2869 ],
2870 [
2871 self.stuck_ready_to_submit_secs as f64,
2872 self.stuck_submitting_secs as f64,
2873 self.stuck_awaiting_verification_secs as f64,
2874 self.stuck_failed_retryable_secs as f64,
2875 ],
2876 )
2877 }
2878}
2879
2880#[derive(Debug, Clone, PartialEq)]
2881pub struct StuckRegistrationCount {
2882 pub state: CreditRegistrationState,
2883 pub count: i64,
2884 pub severely_stuck_count: i64,
2886 pub oldest_state_entered_at: Option<DateTime<Utc>>,
2887}
2888
2889pub async fn count_stuck(
2892 conn: &mut PgConnection,
2893 thresholds: &StuckThresholds,
2894) -> ModelResult<Vec<StuckRegistrationCount>> {
2895 let (state_thresholds, threshold_secs) = thresholds.state_seconds_arrays();
2896 let rows = sqlx::query_as!(
2897 StuckRegistrationCount,
2898 r#"
2899SELECT cr.state AS "state!",
2900 COUNT(*) AS "count!",
2901 COUNT(*) FILTER (
2902 WHERE now() - cr.state_entered_at > MAKE_INTERVAL(secs => t.threshold_secs * 3)
2903 ) AS "severely_stuck_count!",
2904 MIN(cr.state_entered_at) AS "oldest_state_entered_at"
2905FROM credit_registrations cr
2906 JOIN UNNEST($1::credit_registration_state [], $2::double precision []) AS t(state, threshold_secs) ON t.state = cr.state
2907WHERE cr.terminal_at IS NULL
2908 AND cr.superseded_by_id IS NULL
2909 AND cr.deleted_at IS NULL
2910 AND now() - cr.state_entered_at > MAKE_INTERVAL(secs => t.threshold_secs)
2911GROUP BY cr.state
2912 "#,
2913 &state_thresholds as &[CreditRegistrationState],
2914 &threshold_secs as &[f64],
2915 )
2916 .fetch_all(conn)
2917 .await?;
2918 Ok(rows)
2919}
2920
2921#[cfg(test)]
2922mod tests {
2923 use super::*;
2924 use crate::course_module_completions::{
2925 CourseModuleCompletionGranter, NewCourseModuleCompletion,
2926 };
2927 use crate::credit_registration_events::CreditRegistrationEventKind;
2928 use crate::test_helper::*;
2929
2930 #[test]
2934 fn the_edge_table_keeps_the_machines_invariants() {
2935 use CreditRegistrationState as State;
2936 let import_claims = [State::CheckingEnrolment, State::Submitting];
2937 for from in State::ALL {
2938 if from.is_terminal() || from == State::Misregistered {
2939 assert!(
2940 from.allowed_targets().is_empty(),
2941 "{from:?} is not the pipeline's to move"
2942 );
2943 }
2944 for &to in from.allowed_targets() {
2945 assert_ne!(from, to, "{from:?}: staying put is not an edge");
2946 if matches!(
2947 from,
2948 State::Submitting | State::SubmissionUncertain | State::AwaitingVerification
2949 ) {
2950 assert!(
2951 !import_claims.contains(&to),
2952 "{from:?} -> {to:?} would let a second request out for a submission the \
2953 study registry may already hold"
2954 );
2955 }
2956 if to == State::Submitting {
2957 assert_eq!(from, State::CheckingEnrolment, "only import may submit");
2958 }
2959 if to == State::Registered {
2960 assert!(
2961 matches!(
2962 from,
2963 State::Submitting
2964 | State::AwaitingVerification
2965 | State::SubmissionUncertain
2966 ),
2967 "{from:?} -> registered: only an answer about a sent submission registers a \
2968 row"
2969 );
2970 }
2971 }
2972 }
2973 }
2974
2975 #[test]
2976 fn success_states_const_matches_is_success() {
2977 let from_const: Vec<CreditRegistrationState> =
2978 CreditRegistrationState::SUCCESS_STATES.to_vec();
2979 let from_predicate: Vec<CreditRegistrationState> = CreditRegistrationState::ALL
2980 .into_iter()
2981 .filter(|state| state.is_success())
2982 .collect();
2983 assert_eq!(from_const, from_predicate);
2984 }
2985
2986 async fn insert_registration(
2987 conn: &mut PgConnection,
2988 user: Uuid,
2989 course: Uuid,
2990 course_instance: Uuid,
2991 course_module: Uuid,
2992 ) -> Uuid {
2993 let completion = crate::course_module_completions::insert(
2994 conn,
2995 PKeyPolicy::Generate,
2996 &NewCourseModuleCompletion {
2997 course_id: course,
2998 course_module_id: course_module,
2999 user_id: user,
3000 completion_date: Utc::now(),
3001 completion_registration_attempt_date: None,
3002 completion_language: "en".to_string(),
3003 eligible_for_ects: true,
3004 email: "student@example.com".to_string(),
3005 grade: Some(4),
3006 passed: true,
3007 },
3008 CourseModuleCompletionGranter::Automatic,
3009 )
3010 .await
3011 .unwrap();
3012
3013 insert(
3014 conn,
3015 PKeyPolicy::Generate,
3016 &NewCreditRegistration {
3017 course_module_completion_id: completion.id,
3018 user_id: user,
3019 course_id: course,
3020 course_module_id: course_module,
3021 course_instance_id: course_instance,
3022 attempt_number: 1,
3023 },
3024 None,
3025 )
3026 .await
3027 .unwrap()
3028 }
3029
3030 #[tokio::test]
3031 async fn transition_stamps_state_entered_at_and_writes_an_event() {
3032 insert_data!(:tx, :user, :org, :course, :instance, :course_module);
3033 let id =
3034 insert_registration(tx.as_mut(), user, course, instance.id, course_module.id).await;
3035 let before = get_by_id(tx.as_mut(), id).await.unwrap();
3036
3037 let after = transition(
3038 tx.as_mut(),
3039 id,
3040 &Transition::planted(CreditRegistrationState::ReadyToSubmit),
3041 )
3042 .await
3043 .unwrap();
3044
3045 assert_eq!(after.state, CreditRegistrationState::ReadyToSubmit);
3046 assert!(after.state_entered_at > before.state_entered_at);
3047
3048 let events = crate::credit_registration_events::get_by_registration_id(tx.as_mut(), id)
3049 .await
3050 .unwrap();
3051 assert_eq!(events.len(), 2);
3053 assert_eq!(events[1].kind, CreditRegistrationEventKind::Created);
3054 assert_eq!(events[0].kind, CreditRegistrationEventKind::StateChanged);
3055 assert_eq!(events[0].from_state, Some(CreditRegistrationState::Pending));
3056 assert_eq!(
3057 events[0].to_state,
3058 Some(CreditRegistrationState::ReadyToSubmit)
3059 );
3060 }
3061
3062 #[tokio::test]
3063 async fn consecutive_state_changes_stay_ordered_inside_one_transaction() {
3064 insert_data!(:tx, :user, :org, :course, :instance, :course_module);
3065 let id =
3066 insert_registration(tx.as_mut(), user, course, instance.id, course_module.id).await;
3067
3068 let first = transition(
3069 tx.as_mut(),
3070 id,
3071 &Transition::planted(CreditRegistrationState::ReadyToSubmit),
3072 )
3073 .await
3074 .unwrap();
3075 let second = transition(
3076 tx.as_mut(),
3077 id,
3078 &Transition::planted(CreditRegistrationState::CheckingEnrolment),
3079 )
3080 .await
3081 .unwrap();
3082 assert!(second.state_entered_at > first.state_entered_at);
3083 }
3084
3085 #[tokio::test]
3086 async fn transition_stamps_lifecycle_timestamps() {
3087 insert_data!(:tx, :user, :org, :course, :instance, :course_module);
3088 let id =
3089 insert_registration(tx.as_mut(), user, course, instance.id, course_module.id).await;
3090
3091 let checking = transition(
3092 tx.as_mut(),
3093 id,
3094 &Transition::planted(CreditRegistrationState::CheckingEnrolment),
3095 )
3096 .await
3097 .unwrap();
3098 assert!(checking.enrolment_checked_at.is_none());
3099 assert!(checking.submitted_at.is_none());
3100 assert!(checking.terminal_at.is_none());
3101
3102 let submitting = transition(
3104 tx.as_mut(),
3105 id,
3106 &Transition::planted(CreditRegistrationState::Submitting),
3107 )
3108 .await
3109 .unwrap();
3110 assert!(submitting.enrolment_checked_at.is_some());
3111 assert!(submitting.submitted_at.is_some());
3112 assert!(submitting.registered_at.is_none());
3113 assert!(submitting.terminal_at.is_none());
3114
3115 let registered = transition(
3116 tx.as_mut(),
3117 id,
3118 &Transition::planted(CreditRegistrationState::Registered),
3119 )
3120 .await
3121 .unwrap();
3122 assert!(registered.registered_at.is_some());
3123 assert!(registered.terminal_at.is_some());
3124 assert_eq!(registered.submitted_at, submitting.submitted_at);
3125 assert_eq!(
3126 registered.enrolment_checked_at,
3127 submitting.enrolment_checked_at
3128 );
3129 }
3130
3131 #[tokio::test]
3132 async fn terminal_at_holds_between_terminal_states_and_clears_on_a_retry() {
3133 insert_data!(:tx, :user, :org, :course, :instance, :course_module);
3134 let id =
3135 insert_registration(tx.as_mut(), user, course, instance.id, course_module.id).await;
3136
3137 let first = transition(
3138 tx.as_mut(),
3139 id,
3140 &Transition::planted(CreditRegistrationState::Cancelled),
3141 )
3142 .await
3143 .unwrap();
3144 let terminal_at = first.terminal_at.unwrap();
3145
3146 let second = transition(
3147 tx.as_mut(),
3148 id,
3149 &Transition::planted(CreditRegistrationState::FailedPermanent),
3150 )
3151 .await
3152 .unwrap();
3153 assert_eq!(second.terminal_at, Some(terminal_at));
3154
3155 let retried = transition(
3156 tx.as_mut(),
3157 id,
3158 &Transition::planted(CreditRegistrationState::ReadyToSubmit),
3159 )
3160 .await
3161 .unwrap();
3162 assert_eq!(retried.terminal_at, None);
3163 }
3164
3165 #[tokio::test]
3166 async fn entering_no_usable_enrolment_clears_a_dismissed_banner() {
3167 insert_data!(:tx, :user, :org, :course, :instance, :course_module);
3168 let id =
3169 insert_registration(tx.as_mut(), user, course, instance.id, course_module.id).await;
3170
3171 transition(
3172 tx.as_mut(),
3173 id,
3174 &Transition::planted(CreditRegistrationState::NoUsableEnrolment),
3175 )
3176 .await
3177 .unwrap();
3178 dismiss_enrolment_banner(tx.as_mut(), id, user)
3179 .await
3180 .unwrap();
3181 assert!(
3182 get_by_id(tx.as_mut(), id)
3183 .await
3184 .unwrap()
3185 .enrolment_banner_dismissed_at
3186 .is_some()
3187 );
3188
3189 transition(
3190 tx.as_mut(),
3191 id,
3192 &Transition::planted(CreditRegistrationState::ReadyToSubmit),
3193 )
3194 .await
3195 .unwrap();
3196 assert!(
3198 get_by_id(tx.as_mut(), id)
3199 .await
3200 .unwrap()
3201 .enrolment_banner_dismissed_at
3202 .is_some()
3203 );
3204
3205 let back = transition(
3206 tx.as_mut(),
3207 id,
3208 &Transition::planted(CreditRegistrationState::NoUsableEnrolment),
3209 )
3210 .await
3211 .unwrap();
3212 assert_eq!(back.enrolment_banner_dismissed_at, None);
3213 }
3214
3215 #[tokio::test]
3216 async fn transition_carries_the_error_and_leaves_the_admin_flag_alone_unless_asked() {
3217 insert_data!(:tx, :user, :org, :course, :instance, :course_module);
3218 let id =
3219 insert_registration(tx.as_mut(), user, course, instance.id, course_module.id).await;
3220 set_needs_admin_attention(tx.as_mut(), id, true)
3221 .await
3222 .unwrap();
3223
3224 let failed = transition(
3225 tx.as_mut(),
3226 id,
3227 &Transition {
3228 error_code: Some(CreditRegistrationErrorCode::EnrolmentNotFound),
3229 error_message: Some("no accepted enrolment".to_string()),
3230 ..Transition::planted(CreditRegistrationState::FailedPermanent)
3231 },
3232 )
3233 .await
3234 .unwrap();
3235 assert_eq!(
3236 failed.error_code,
3237 Some(CreditRegistrationErrorCode::EnrolmentNotFound)
3238 );
3239 assert!(failed.needs_admin_attention);
3240
3241 let resolved = transition(
3242 tx.as_mut(),
3243 id,
3244 &Transition {
3245 needs_admin_attention: Some(false),
3246 ..Transition::planted(CreditRegistrationState::ReadyToSubmit)
3247 },
3248 )
3249 .await
3250 .unwrap();
3251 assert!(!resolved.needs_admin_attention);
3252 assert_eq!(resolved.error_code, None);
3253 }
3254
3255 #[tokio::test]
3256 async fn a_transition_expecting_a_stale_prior_state_is_refused() {
3257 insert_data!(:tx, :user, :org, :course, :instance, :course_module);
3258 let id =
3259 insert_registration(tx.as_mut(), user, course, instance.id, course_module.id).await;
3260
3261 transition(
3262 tx.as_mut(),
3263 id,
3264 &Transition::planted(CreditRegistrationState::Blocked),
3265 )
3266 .await
3267 .unwrap();
3268
3269 let refused = transition(
3272 tx.as_mut(),
3273 id,
3274 &Transition {
3275 expected_from_state: Some(CreditRegistrationState::ResolvingEnrolment),
3276 ..Transition::planted(CreditRegistrationState::CheckingEnrolment)
3277 },
3278 )
3279 .await;
3280 assert!(refused.is_err());
3281 assert_eq!(
3282 get_by_id(tx.as_mut(), id).await.unwrap().state,
3283 CreditRegistrationState::Blocked
3284 );
3285 }
3286}