Skip to main content

headless_lms_server/controllers/main_frontend/credit_registration_admin/
ledger.rs

1//! Viewing and hand-transitioning rows of the credit registration ledger.
2
3use headless_lms_models::credit_registration_account_linking_emails;
4use headless_lms_models::credit_registration_admin_actions::{
5    CreditRegistrationAdminAction, CreditRegistrationAdminActionFilters,
6    CreditRegistrationAdminActionRecord, CreditRegistrationAdminActionTarget, GLOBAL_ADMIN_ROLE,
7    NewCreditRegistrationAdminAction,
8};
9use headless_lms_models::credit_registration_events::{
10    CreditRegistrationEventKind, NotImprovedAttainment, SuotarAnswer,
11};
12use headless_lms_models::credit_registrations::{
13    self, AdminCreditRegistration, AdminCreditRegistrationFilters, AdminCreditRegistrationSort,
14    CreditRegistrationErrorCode, CreditRegistrationState, HandActionAvailability,
15    ResubmissionFacts, ResubmissionRefusal, ResubmissionStrictness, Transition,
16};
17use headless_lms_models::email_deliveries::EmailSendStatusReport;
18use headless_lms_models::library::credit_registration::CreditRegistrationPendingReason;
19use headless_lms_models::library::credit_registration::backoff::{
20    NOT_REGISTERED_REIMPORT_ADMIN_THRESHOLD, PARTIAL_REGISTRATION_ADMIN_AFTER,
21    UNCERTAIN_ADMIN_AFTER, VERIFY_MAX_AGE,
22};
23use headless_lms_models::library::credit_registration::enrolment_check_schedule::EnrolmentCheckSource;
24use headless_lms_models::library::credit_registration::student_notifications::{
25    self, CreditRegistrationNotificationKind, RegistrationNotificationEmail,
26};
27use headless_lms_models::suotar_api_calls;
28use headless_lms_models::verified_student_numbers::{self, StudentNumberVerificationMethod};
29use std::collections::{HashMap, HashSet};
30use utoipa::ToSchema;
31
32use crate::prelude::*;
33use headless_lms_utils::secret_string::expose_option;
34use secrecy::{ExposeSecret, SecretString};
35
36use super::{
37    AdminLinkingEmail, authorize_credit_registration_admin, build_linking_emails, required_reason,
38};
39
40#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
41pub struct AdminCreditRegistrationRow {
42    pub id: Uuid,
43    pub created_at: DateTime<Utc>,
44    pub user_id: Uuid,
45    pub first_name: Option<String>,
46    pub last_name: Option<String>,
47    /// In full: masking it would leave support unable to answer the question they were asked.
48    pub email: Option<String>,
49    pub course_id: Uuid,
50    pub course_name: String,
51    pub course_module_id: Uuid,
52    pub course_module_name: Option<String>,
53    pub course_instance_id: Uuid,
54    pub course_module_completion_id: Uuid,
55    pub completion_date: DateTime<Utc>,
56    pub state: CreditRegistrationState,
57    /// What a `pending` row is waiting on, which the ledger does not store; `null` for every other
58    /// state.
59    pub pending_reason: Option<CreditRegistrationPendingReason>,
60    pub state_entered_at: DateTime<Utc>,
61    pub error_code: Option<CreditRegistrationErrorCode>,
62    pub needs_admin_attention: bool,
63    pub next_attempt_at: DateTime<Utc>,
64    pub last_attempt_at: Option<DateTime<Utc>>,
65    pub submitted_at: Option<DateTime<Utc>>,
66    pub registered_at: Option<DateTime<Utc>>,
67    pub terminal_at: Option<DateTime<Utc>>,
68    /// When verify first saw only the assessment item attainment.
69    pub partially_registered_at: Option<DateTime<Utc>>,
70    /// Suotar's `retryAfter` for a pending submission: resending earlier may duplicate it.
71    pub resubmit_not_before: Option<DateTime<Utc>>,
72    /// How many times Suotar has lost the submission and it was sent again.
73    pub not_registered_reimport_count: i32,
74    pub is_waiting_for_enrolment: bool,
75    pub no_usable_enrolment_since: Option<DateTime<Utc>>,
76    pub enrolment_checked_at: Option<DateTime<Utc>>,
77    /// The next scheduled enrolment check.
78    pub enrolment_check_due_at: Option<DateTime<Utc>>,
79    pub enrolment_checks_stopped_at: Option<DateTime<Utc>>,
80    /// Frozen on the row before it was sent, so it is what we actually submitted.
81    pub student_number: Option<String>,
82    pub sisu_person_id: Option<String>,
83    pub uh_course_code: Option<String>,
84    pub selected_enrolment_id: Option<String>,
85    pub grade_scale_id: Option<String>,
86    pub grade_id: Option<String>,
87    pub credits: Option<f32>,
88    pub submitted_attainment_id: Option<String>,
89    pub sisu_attainment_id: Option<String>,
90    pub submit_retry_count: i32,
91    pub verify_attempt_count: i32,
92    pub attempt_number: i32,
93    pub superseded: bool,
94    pub superseded_by_id: Option<Uuid>,
95    /// What the single-row hand transition would allow: what the row's action controls render from.
96    pub hand_actions: HandActionAvailability,
97    /// The account's link now, which is not always the number frozen on the row.
98    pub verified_student_number: Option<String>,
99    pub verified_student_number_at: Option<DateTime<Utc>>,
100    /// `admin_manual` means support established the link rather than the student proving it.
101    pub verified_student_number_via: Option<StudentNumberVerificationMethod>,
102}
103
104#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
105pub struct AdminCreditRegistrationEvent {
106    pub id: Uuid,
107    pub created_at: DateTime<Utc>,
108    pub kind: CreditRegistrationEventKind,
109    pub from_state: Option<CreditRegistrationState>,
110    pub to_state: Option<CreditRegistrationState>,
111    pub error_code: Option<CreditRegistrationErrorCode>,
112    /// Our own wording, written by the pipeline or by whoever acted.
113    pub message: Option<String>,
114    pub actor_user_id: Option<Uuid>,
115    pub suotar_api_call_id: Option<Uuid>,
116    /// The `{request, response}` pair, scrubbed at write time: names, student numbers and email
117    /// addresses read `[redacted]` while their keys survive. The values we sent are on the row.
118    pub details: Option<serde_json::Value>,
119    /// The requestItemId the row went out under in the call behind this event.
120    pub request_item_id: Option<String>,
121    pub suotar_endpoint: Option<suotar_api_calls::SuotarEndpoint>,
122    pub suotar_requested_at: Option<DateTime<Utc>>,
123    pub suotar_answered_at: Option<DateTime<Utc>>,
124    pub suotar_answer: Option<SuotarAnswer>,
125    /// Suotar's own per-item code, e.g. `enrolmentNotFound`, which `error_code` classifies and
126    /// sometimes drops. `None` when no item answer came back.
127    pub suotar_code: Option<String>,
128}
129
130#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
131pub struct AdminSuotarApiCall {
132    pub id: Uuid,
133    pub endpoint: suotar_api_calls::SuotarEndpoint,
134    pub started_at: DateTime<Utc>,
135    pub duration_ms: Option<i32>,
136    pub http_status: Option<i32>,
137    pub succeeded: bool,
138    pub request_level_error_code: Option<String>,
139    pub worker_name: String,
140}
141
142/// One of the two student terminal-state mails, in full: `send_status.failure_code` is what drives
143/// the decision to look at the relay.
144#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
145pub struct AdminNotificationEmail {
146    pub kind: CreditRegistrationNotificationKind,
147    /// The delivery this registration is pinned to, so support can find the message in the queue and
148    /// tell "still the first mail" from "a second one went out".
149    pub email_delivery_id: Uuid,
150    pub send_status: EmailSendStatusReport,
151}
152
153/// The pipeline's own limits for asking an admin to look, from `library::credit_registration::backoff`.
154#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy, ToSchema)]
155pub struct AdminAttentionThresholds {
156    pub partial_registration_secs: i64,
157    pub uncertain_secs: i64,
158    pub verify_window_secs: i64,
159    pub not_registered_reimports: i32,
160}
161
162impl AdminAttentionThresholds {
163    pub const CURRENT: Self = Self {
164        partial_registration_secs: PARTIAL_REGISTRATION_ADMIN_AFTER.num_seconds(),
165        uncertain_secs: UNCERTAIN_ADMIN_AFTER.num_seconds(),
166        verify_window_secs: VERIFY_MAX_AGE.num_seconds(),
167        not_registered_reimports: NOT_REGISTERED_REIMPORT_ADMIN_THRESHOLD,
168    };
169}
170
171#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
172pub struct AdminCreditRegistrationDetails {
173    pub attention_thresholds: AdminAttentionThresholds,
174    pub registration: AdminCreditRegistrationRow,
175    /// Every attempt for the same completion, newest first, this one included.
176    pub attempts: Vec<AdminCreditRegistrationRow>,
177    pub events: Vec<AdminCreditRegistrationEvent>,
178    /// The calls the timeline refers to, newest first.
179    pub suotar_api_calls: Vec<AdminSuotarApiCall>,
180    /// Admin and teacher actions targeting this row.
181    pub actions: Vec<CreditRegistrationAdminActionRecord>,
182    /// Every mail addressed to this person, on any course.
183    pub linking_emails: Vec<AdminLinkingEmail>,
184    /// The terminal-state mails queued for this row, with the same send status the student and the
185    /// teacher are shown.
186    pub notification_emails: Vec<AdminNotificationEmail>,
187    /// The grade the registry already held, for a row it declined as no improvement. The row's own
188    /// grade is what we sent.
189    pub not_improved_attainment: Option<NotImprovedAttainment>,
190}
191
192/// The states an admin may move a row to; everything else is the pipeline's to decide.
193#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
194#[serde(rename_all = "snake_case")]
195pub enum AdminCreditRegistrationStateMove {
196    /// Resubmit: the escape hatch out of `submission_uncertain`, and how a `misregistered` row is
197    /// tried again.
198    ReadyToSubmit,
199    Cancelled,
200}
201
202impl AdminCreditRegistrationStateMove {
203    fn to_state(self) -> CreditRegistrationState {
204        match self {
205            Self::ReadyToSubmit => CreditRegistrationState::ReadyToSubmit,
206            Self::Cancelled => CreditRegistrationState::Cancelled,
207        }
208    }
209}
210
211/// What one hand action does to a row: either a state move, or something that leaves the state
212/// alone. Kept apart because only the first is a transition, and only the first is refusable.
213#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
214#[serde(rename_all = "snake_case", tag = "kind")]
215pub enum AdminCreditRegistrationAction {
216    StateMove {
217        to_state: AdminCreditRegistrationStateMove,
218    },
219    /// Stops the row asking for a human.
220    ClearNeedsAdminAttention,
221    /// Makes the row due, so the phase owning its state claims it on the next pass instead of
222    /// waiting out a backoff of up to a day.
223    CheckNow,
224}
225
226impl AdminCreditRegistrationAction {
227    /// Why this action is refused on the row, or `None` if it may go ahead.
228    fn refusal(
229        self,
230        facts: &ResubmissionFacts,
231        strictness: ResubmissionStrictness,
232    ) -> Option<ResubmissionRefusal> {
233        match self {
234            Self::StateMove { to_state } => {
235                facts.admin_transition_refusal(to_state.to_state(), strictness)
236            }
237            Self::CheckNow => facts.check_now_refusal(),
238            // Even clearing a flag on a replaced attempt is an admin acting on the wrong row.
239            Self::ClearNeedsAdminAttention if facts.is_superseded => {
240                Some(ResubmissionRefusal::Superseded)
241            }
242            Self::ClearNeedsAdminAttention => None,
243        }
244    }
245}
246
247#[derive(Debug, Deserialize, ToSchema)]
248pub struct AdminTransitionCreditRegistrationPayload {
249    pub action: AdminCreditRegistrationAction,
250    pub reason: String,
251}
252
253#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
254#[serde(rename_all = "snake_case")]
255pub enum AdminTransitionOutcome {
256    Applied,
257    /// The row was left where it was; `refusal` says why.
258    Refused,
259    NoChange,
260}
261
262#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
263pub struct AdminTransitionCreditRegistrationResult {
264    pub outcome: AdminTransitionOutcome,
265    /// Set exactly when the outcome is `refused`.
266    pub refusal: Option<ResubmissionRefusal>,
267    pub state: CreditRegistrationState,
268    pub needs_admin_attention: bool,
269}
270
271/// A fat-finger bound on one selection; a bigger one is taken in several passes.
272const MAX_ROWS_PER_BULK_TRANSITION: i64 = 500;
273const MAX_ROWS_PER_REQUEUE: i64 = 5_000;
274/// A detail view's related-rows lookups (other attempts, calls, actions) never paginate; this just
275/// bounds them against a pathological completion.
276const MAX_RELATED_ROWS: i64 = u8::MAX as i64;
277
278#[derive(Debug, Deserialize, ToSchema)]
279pub struct AdminBulkTransitionPayload {
280    pub action: AdminCreditRegistrationAction,
281    pub credit_registration_ids: Vec<Uuid>,
282    pub reason: String,
283}
284
285#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
286pub struct AdminBulkTransitionSkipCount {
287    pub refusal: ResubmissionRefusal,
288    pub count: i64,
289}
290
291#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
292pub struct AdminBulkTransitionResult {
293    pub applied_count: i64,
294    pub skipped: Vec<AdminBulkTransitionSkipCount>,
295    /// Distinct selected ids naming no live row.
296    pub not_found_count: i64,
297    pub max_rows_per_call: i64,
298}
299
300#[derive(Debug, Deserialize, ToSchema)]
301pub struct AdminRequeueRetryablePayload {
302    pub course_id: Option<Uuid>,
303    pub course_module_id: Option<Uuid>,
304    pub reason: String,
305}
306
307#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
308pub struct AdminRequeueRetryableResult {
309    pub requeued_count: i64,
310    pub max_rows_per_call: i64,
311}
312
313#[derive(Debug, Deserialize)]
314pub struct ListCreditRegistrationsQuery {
315    page: Option<u32>,
316    limit: Option<u32>,
317    state: Option<Vec<CreditRegistrationState>>,
318    error_code: Option<Vec<CreditRegistrationErrorCode>>,
319    course_id: Option<Uuid>,
320    course_module_id: Option<Uuid>,
321    user_id: Option<Uuid>,
322    student_number: Option<SecretString>,
323    needs_admin_attention: Option<bool>,
324    submitted_after: Option<DateTime<Utc>>,
325    submitted_before: Option<DateTime<Utc>>,
326    search: Option<SecretString>,
327    include_superseded: Option<bool>,
328    sort: Option<String>,
329}
330
331/**
332GET `/api/v0/main-frontend/credit-registration-admin/registrations` - A page of the ledger, filtered
333and sorted.
334*/
335#[instrument(skip(pool))]
336#[utoipa::path(
337    get,
338    path = "/registrations",
339    operation_id = "listCreditRegistrationsForAdmin",
340    tag = "credit-registration-admin",
341    params(
342        ("page" = Option<u32>, Query, description = "Page number, from 1"),
343        ("limit" = Option<u32>, Query, description = "Rows per page"),
344        ("state" = Option<Vec<CreditRegistrationState>>, Query, description = "Ledger states; repeat the parameter for several"),
345        ("error_code" = Option<Vec<CreditRegistrationErrorCode>>, Query, description = "Error codes; repeat the parameter for several"),
346        ("course_id" = Option<Uuid>, Query, description = "Course filter"),
347        ("course_module_id" = Option<Uuid>, Query, description = "Course module filter"),
348        ("user_id" = Option<Uuid>, Query, description = "Student filter"),
349        ("student_number" = Option<String>, Query, description = "Exact student number, frozen on the row or linked to the account"),
350        ("needs_admin_attention" = Option<bool>, Query, description = "Only rows asking for a human"),
351        ("submitted_after" = Option<DateTime<Utc>>, Query, description = "Submitted at or after"),
352        ("submitted_before" = Option<DateTime<Utc>>, Query, description = "Submitted at or before"),
353        ("search" = Option<String>, Query, description = "Name, email, student number, attainment id, stored error text, or a uuid"),
354        ("include_superseded" = Option<bool>, Query, description = "Include replaced attempts"),
355        ("sort" = Option<String>, Query, description = "last_activity, created, time_in_state or attempts")
356    ),
357    responses(
358        (status = 200, description = "A page of the ledger", body = Page<AdminCreditRegistrationRow>)
359    )
360)]
361pub async fn list_credit_registrations_for_admin(
362    user: AuthUser,
363    pool: web::Data<PgPool>,
364    query: MultiQuery<ListCreditRegistrationsQuery>,
365) -> ControllerResult<web::Json<Page<AdminCreditRegistrationRow>>> {
366    let mut conn = pool.acquire().await?;
367    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
368
369    let pagination = parse_pagination(query.page, query.limit, 50)?;
370    let search = non_empty(expose_option(&query.search));
371    let student_number = non_empty(expose_option(&query.student_number));
372    let filters = AdminCreditRegistrationFilters {
373        states: query.state.as_deref(),
374        error_codes: query.error_code.as_deref(),
375        course_id: query.course_id,
376        course_module_id: query.course_module_id,
377        user_id: query.user_id,
378        student_number,
379        needs_admin_attention: query.needs_admin_attention.unwrap_or(false),
380        submitted_after: query.submitted_after,
381        submitted_before: query.submitted_before,
382        search,
383        search_id: search.and_then(|search| Uuid::parse_str(search).ok()),
384        include_superseded: query.include_superseded.unwrap_or(false),
385        ..AdminCreditRegistrationFilters::default()
386    };
387    let sort = match query.sort.as_deref() {
388        Some("created") => AdminCreditRegistrationSort::Created,
389        Some("time_in_state") => AdminCreditRegistrationSort::TimeInState,
390        Some("attempts") => AdminCreditRegistrationSort::Attempts,
391        _ => AdminCreditRegistrationSort::LastActivity,
392    };
393
394    let rows = credit_registrations::get_admin_facing(
395        &mut conn,
396        &filters,
397        sort,
398        pagination.limit(),
399        pagination.offset(),
400    )
401    .await?;
402    let total_count = rows.first().map_or(0, |row| row.total_count);
403    let data = rows.into_iter().map(to_admin_row).collect();
404
405    token.authorized_ok(web::Json(Page::new(pagination, data, total_count)))
406}
407
408/**
409GET `/api/v0/main-frontend/credit-registration-admin/registrations/{credit_registration_id}` - One
410row with its timeline, the calls that timeline refers to, the other attempts for the same completion,
411the actions taken on it and its linking mails.
412*/
413#[instrument(skip(pool))]
414#[utoipa::path(
415    get,
416    path = "/registrations/{credit_registration_id}",
417    operation_id = "getCreditRegistrationForAdmin",
418    tag = "credit-registration-admin",
419    params(("credit_registration_id" = Uuid, Path, description = "Credit registration id")),
420    responses(
421        (status = 200, description = "The row and everything that happened to it", body = AdminCreditRegistrationDetails),
422        (status = 404, description = "No such registration")
423    )
424)]
425pub async fn get_credit_registration_for_admin(
426    user: AuthUser,
427    pool: web::Data<PgPool>,
428    credit_registration_id: web::Path<Uuid>,
429) -> ControllerResult<web::Json<AdminCreditRegistrationDetails>> {
430    let mut conn = pool.acquire().await?;
431    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
432
433    let id = *credit_registration_id;
434    let registration = one_admin_row(&mut conn, id)
435        .await?
436        .ok_or_else(|| controller_err!(NotFound, "Not found.".to_string()))?;
437    let attempts = credit_registrations::get_admin_facing(
438        &mut conn,
439        &AdminCreditRegistrationFilters {
440            user_id: Some(registration.user_id),
441            course_id: Some(registration.course_id),
442            course_module_completion_id: Some(registration.course_module_completion_id),
443            include_superseded: true,
444            ..AdminCreditRegistrationFilters::default()
445        },
446        AdminCreditRegistrationSort::Created,
447        MAX_RELATED_ROWS,
448        0,
449    )
450    .await?
451    .into_iter()
452    .map(to_admin_row)
453    .collect();
454
455    let events: Vec<AdminCreditRegistrationEvent> =
456        models::credit_registration_events::get_by_registration_id(&mut conn, id)
457            .await?
458            .into_iter()
459            .map(|event| AdminCreditRegistrationEvent {
460                id: event.id,
461                created_at: event.created_at,
462                kind: event.kind,
463                from_state: event.from_state,
464                to_state: event.to_state,
465                error_code: event.error_code,
466                message: event.message,
467                actor_user_id: event.actor_user_id,
468                suotar_api_call_id: event.suotar_api_call_id,
469                suotar_code: event
470                    .details
471                    .as_ref()
472                    .and_then(|details| details.pointer("/response/code"))
473                    .and_then(|code| code.as_str())
474                    .map(str::to_string),
475                details: event.details,
476                request_item_id: event.request_item_id,
477                suotar_endpoint: event.suotar_endpoint,
478                suotar_requested_at: event.suotar_requested_at,
479                suotar_answered_at: event.suotar_answered_at,
480                suotar_answer: event.suotar_answer,
481            })
482            .collect();
483    let suotar_api_calls =
484        suotar_api_calls::get_by_credit_registration_id(&mut conn, id, MAX_RELATED_ROWS)
485            .await?
486            .into_iter()
487            .map(to_admin_api_call)
488            .collect();
489    let actions = models::credit_registration_admin_actions::get_page(
490        &mut conn,
491        &CreditRegistrationAdminActionFilters {
492            target_kind: Some(CreditRegistrationAdminActionTarget::CreditRegistration),
493            target_id: Some(id),
494            ..Default::default()
495        },
496        MAX_RELATED_ROWS,
497        0,
498    )
499    .await?
500    .into_iter()
501    .map(|row| row.action)
502    .collect();
503
504    let sisu_person_id = match &registration.sisu_person_id {
505        Some(person_id) => Some(person_id.clone()),
506        None => verified_student_numbers::get_latest_including_deleted_by_user_id(
507            &mut conn,
508            registration.user_id,
509        )
510        .await?
511        .and_then(|link| link.sisu_person_id),
512    };
513    let linking_emails = match sisu_person_id {
514        Some(person_id) => {
515            let mails = credit_registration_account_linking_emails::get_by_sisu_person_id(
516                &mut conn,
517                person_id.expose_secret(),
518            )
519            .await?;
520            build_linking_emails(&mut conn, mails).await?
521        }
522        None => Vec::new(),
523    };
524
525    let notification_emails = student_notifications::get_for_registrations(&mut conn, &[id])
526        .await?
527        .into_iter()
528        .map(
529            |mail: RegistrationNotificationEmail| AdminNotificationEmail {
530                kind: mail.kind,
531                email_delivery_id: mail.email_delivery_id,
532                send_status: mail.send_status,
533            },
534        )
535        .collect();
536
537    let not_improved_attainment =
538        models::credit_registration_events::get_not_improved_attainment(&mut conn, id).await?;
539
540    token.authorized_ok(web::Json(AdminCreditRegistrationDetails {
541        attention_thresholds: AdminAttentionThresholds::CURRENT,
542        registration: to_admin_row(registration),
543        attempts,
544        events,
545        suotar_api_calls,
546        actions,
547        linking_emails,
548        notification_emails,
549        not_improved_attainment,
550    }))
551}
552
553/**
554POST `/api/v0/main-frontend/credit-registration-admin/registrations/{credit_registration_id}/transition`
555- Moves one row by hand.
556
557The escape hatch out of `submission_uncertain`, which the pipeline never leaves on its own because
558re-importing could put a second attainment on a real transcript. Even here, a row is not resubmitted
559while Suotar may still hold its earlier submission as pending (`submission_uncertain_too_recent`,
560`submission_pending`). The row's `hand_actions` says in advance what this refuses.
561*/
562#[instrument(skip(pool, payload))]
563#[utoipa::path(
564    post,
565    path = "/registrations/{credit_registration_id}/transition",
566    operation_id = "adminTransitionCreditRegistration",
567    tag = "credit-registration-admin",
568    params(("credit_registration_id" = Uuid, Path, description = "Credit registration id")),
569    request_body = AdminTransitionCreditRegistrationPayload,
570    responses(
571        (status = 200, description = "What the transition did", body = AdminTransitionCreditRegistrationResult),
572        (status = 422, description = "No reason given"),
573        (status = 404, description = "No such registration")
574    )
575)]
576pub async fn admin_transition_credit_registration(
577    user: AuthUser,
578    pool: web::Data<PgPool>,
579    credit_registration_id: web::Path<Uuid>,
580    payload: web::Json<AdminTransitionCreditRegistrationPayload>,
581) -> ControllerResult<web::Json<AdminTransitionCreditRegistrationResult>> {
582    let mut conn = pool.acquire().await?;
583    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
584
585    let reason = required_reason(&payload.reason)?;
586    let id = *credit_registration_id;
587    let row = credit_registrations::get_by_id(&mut conn, id).await?;
588    if row.superseded_by_id.is_some() {
589        return Err(controller_err!(
590            BadRequest,
591            "This attempt has been replaced by a later one. Act on the later one.".to_string()
592        ));
593    }
594
595    // `Any`: a human is already looking at this one row, so unlike the bulk transition below it is
596    // not refused for being `submission_uncertain`.
597    if let Some(refusal) = payload
598        .action
599        .refusal(&row.resubmission_facts(), ResubmissionStrictness::Any)
600    {
601        return token.authorized_ok(web::Json(AdminTransitionCreditRegistrationResult {
602            outcome: AdminTransitionOutcome::Refused,
603            refusal: Some(refusal),
604            state: row.state,
605            needs_admin_attention: row.needs_admin_attention,
606        }));
607    }
608
609    let mut tx = conn.begin().await?;
610    let applied = apply_transition(&mut tx, &row, payload.action, user.id, reason).await?;
611    if applied.needs_due_now {
612        credit_registrations::make_due_now_batch(
613            &mut tx,
614            &[id],
615            EnrolmentCheckSource::AdminRequest,
616        )
617        .await?;
618    }
619    models::credit_registration_admin_actions::record(
620        &mut tx,
621        &NewCreditRegistrationAdminAction {
622            target_id: Some(id),
623            reason: Some(reason.to_string()),
624            before_state: Some(row.state),
625            after_state: Some(applied.state),
626            details: Some(serde_json::json!({ "outcome": applied.outcome })),
627            affected_row_count: Some(1),
628            ..NewCreditRegistrationAdminAction::new(
629                CreditRegistrationAdminAction::TransitionItem,
630                CreditRegistrationAdminActionTarget::CreditRegistration,
631                user.id,
632                GLOBAL_ADMIN_ROLE,
633            )
634        },
635    )
636    .await?;
637    tx.commit().await?;
638
639    token.authorized_ok(web::Json(AdminTransitionCreditRegistrationResult {
640        outcome: applied.outcome,
641        refusal: None,
642        state: applied.state,
643        needs_admin_attention: applied.needs_admin_attention,
644    }))
645}
646
647/**
648POST `/api/v0/main-frontend/credit-registration-admin/registrations/bulk-transition` - Moves a
649selection of rows by hand, one transaction for the lot.
650
651Resubmitting or cancelling refuses every row in `submission_uncertain`, whatever the selection
652said. Taking one of those back to `ready_to_submit` is a decision about one student's transcript,
653made after somebody has looked the attainment up; a checkbox in a list is not that, and a mis-click
654here would put a second attainment on every one of them. Those rows are reported back untouched, to
655be dealt with one at a time, as is a row whose earlier submission Suotar still holds open
656(`submission_pending`). Each attention item's `hand_actions` says in advance what this skips.
657*/
658#[instrument(skip(pool, payload))]
659#[utoipa::path(
660    post,
661    path = "/registrations/bulk-transition",
662    operation_id = "adminBulkTransitionCreditRegistrations",
663    tag = "credit-registration-admin",
664    request_body = AdminBulkTransitionPayload,
665    responses(
666        (status = 200, description = "What each selected row did", body = AdminBulkTransitionResult),
667        (status = 422, description = "No reason given, or more ids than one call may take")
668    )
669)]
670pub async fn admin_bulk_transition_credit_registrations(
671    user: AuthUser,
672    pool: web::Data<PgPool>,
673    payload: web::Json<AdminBulkTransitionPayload>,
674) -> ControllerResult<web::Json<AdminBulkTransitionResult>> {
675    let mut conn = pool.acquire().await?;
676    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
677
678    let reason = required_reason(&payload.reason)?;
679    if payload.credit_registration_ids.len() as i64 > MAX_ROWS_PER_BULK_TRANSITION {
680        return Err(controller_err!(
681            BadRequest,
682            format!("At most {MAX_ROWS_PER_BULK_TRANSITION} registrations per call.")
683        ));
684    }
685
686    // A selection built by clicking can name the same row twice, and reporting the duplicate as a
687    // registration that does not exist would send an admin looking for a deleted row.
688    let ids: Vec<Uuid> = payload
689        .credit_registration_ids
690        .iter()
691        .copied()
692        .collect::<HashSet<_>>()
693        .into_iter()
694        .collect();
695
696    let mut tx = conn.begin().await?;
697    // Locked, and read inside the transaction: each row's refusal is judged here and acted on below,
698    // so a row the pipeline moves in between would make `apply_transition` refuse it and take every
699    // row already applied down with it.
700    let rows = credit_registrations::get_by_ids_for_update(&mut tx, &ids).await?;
701
702    let mut applied_count = 0;
703    let mut due_now_ids = Vec::new();
704    let mut skipped: HashMap<ResubmissionRefusal, i64> = HashMap::new();
705    for row in &rows {
706        match payload.action.refusal(
707            &row.resubmission_facts(),
708            ResubmissionStrictness::AnyExceptSubmissionUncertain,
709        ) {
710            Some(refusal) => *skipped.entry(refusal).or_insert(0) += 1,
711            None => {
712                let applied =
713                    apply_transition(&mut tx, row, payload.action, user.id, reason).await?;
714                if applied.needs_due_now {
715                    due_now_ids.push(row.id);
716                }
717                applied_count += 1;
718            }
719        }
720    }
721    // Batched rather than one `UPDATE` per row inside the loop above: the row transition needs its
722    // own audit event per row, but making it due now does not.
723    credit_registrations::make_due_now_batch(
724        &mut tx,
725        &due_now_ids,
726        EnrolmentCheckSource::AdminRequest,
727    )
728    .await?;
729    let mut skipped: Vec<AdminBulkTransitionSkipCount> = skipped
730        .into_iter()
731        .map(|(refusal, count)| AdminBulkTransitionSkipCount { refusal, count })
732        .collect();
733    skipped.sort_by_key(|skip| std::cmp::Reverse(skip.count));
734
735    models::credit_registration_admin_actions::record(
736        &mut tx,
737        &NewCreditRegistrationAdminAction {
738            reason: Some(reason.to_string()),
739            details: Some(serde_json::json!({
740                "action": payload.action,
741                "credit_registration_ids": payload.credit_registration_ids,
742                "skipped": skipped,
743            })),
744            affected_row_count: Some(applied_count),
745            ..NewCreditRegistrationAdminAction::new(
746                CreditRegistrationAdminAction::TransitionItem,
747                CreditRegistrationAdminActionTarget::CreditRegistration,
748                user.id,
749                GLOBAL_ADMIN_ROLE,
750            )
751        },
752    )
753    .await?;
754    tx.commit().await?;
755
756    token.authorized_ok(web::Json(AdminBulkTransitionResult {
757        applied_count: i64::from(applied_count),
758        skipped,
759        not_found_count: ids.len() as i64 - rows.len() as i64,
760        max_rows_per_call: MAX_ROWS_PER_BULK_TRANSITION,
761    }))
762}
763
764/**
765POST `/api/v0/main-frontend/credit-registration-admin/registrations/requeue-retryable` - Makes every
766`failed_retryable` row waiting out a backoff due now.
767
768The button pressed once the study registry says an outage is over. Touches nothing but
769`next_attempt_at`: the rows are already where the pipeline wants them, they are merely waiting.
770*/
771#[instrument(skip(pool, payload))]
772#[utoipa::path(
773    post,
774    path = "/registrations/requeue-retryable",
775    operation_id = "adminRequeueRetryableCreditRegistrations",
776    tag = "credit-registration-admin",
777    request_body = AdminRequeueRetryablePayload,
778    responses(
779        (status = 200, description = "How many were made due", body = AdminRequeueRetryableResult),
780        (status = 422, description = "No reason given")
781    )
782)]
783pub async fn admin_requeue_retryable_credit_registrations(
784    user: AuthUser,
785    pool: web::Data<PgPool>,
786    payload: web::Json<AdminRequeueRetryablePayload>,
787) -> ControllerResult<web::Json<AdminRequeueRetryableResult>> {
788    let mut conn = pool.acquire().await?;
789    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
790
791    let reason = required_reason(&payload.reason)?;
792
793    let mut tx = conn.begin().await?;
794    let requeued_count = credit_registrations::requeue_retryable_now(
795        &mut tx,
796        payload.course_id,
797        payload.course_module_id,
798        MAX_ROWS_PER_REQUEUE,
799    )
800    .await?;
801    models::credit_registration_admin_actions::record(
802        &mut tx,
803        &NewCreditRegistrationAdminAction {
804            target_id: payload.course_id,
805            reason: Some(reason.to_string()),
806            details: Some(serde_json::json!({
807                "course_id": payload.course_id,
808                "course_module_id": payload.course_module_id,
809            })),
810            affected_row_count: Some(i32::try_from(requeued_count).unwrap_or(i32::MAX)),
811            ..NewCreditRegistrationAdminAction::new(
812                CreditRegistrationAdminAction::RequeueBatch,
813                match payload.course_id {
814                    Some(_) => CreditRegistrationAdminActionTarget::Course,
815                    None => CreditRegistrationAdminActionTarget::CreditRegistration,
816                },
817                user.id,
818                GLOBAL_ADMIN_ROLE,
819            )
820        },
821    )
822    .await?;
823    tx.commit().await?;
824
825    token.authorized_ok(web::Json(AdminRequeueRetryableResult {
826        requeued_count,
827        max_rows_per_call: MAX_ROWS_PER_REQUEUE,
828    }))
829}
830
831/// What one hand action did to its row.
832struct AppliedHandAction {
833    outcome: AdminTransitionOutcome,
834    /// Where the row ended up.
835    state: CreditRegistrationState,
836    needs_admin_attention: bool,
837    /// The caller must still make the row due now.
838    needs_due_now: bool,
839}
840
841/// Applies one hand action in the caller's transaction.
842///
843/// The caller has already asked `admin_transition_refusal` whether this row may take the move,
844/// because what a refusal is reported as differs per caller. Making the row due is left to the
845/// caller too, rather than done here, so the bulk caller can batch it over every row it applies
846/// instead of one `UPDATE` per row.
847async fn apply_transition(
848    tx: &mut PgConnection,
849    row: &credit_registrations::CreditRegistration,
850    action: AdminCreditRegistrationAction,
851    actor_user_id: Uuid,
852    reason: &str,
853) -> Result<AppliedHandAction, ControllerError> {
854    let id = row.id;
855    Ok(match action {
856        AdminCreditRegistrationAction::ClearNeedsAdminAttention => {
857            if !row.needs_admin_attention {
858                AppliedHandAction {
859                    outcome: AdminTransitionOutcome::NoChange,
860                    state: row.state,
861                    needs_admin_attention: false,
862                    needs_due_now: false,
863                }
864            } else {
865                credit_registrations::set_needs_admin_attention(
866                    tx,
867                    id,
868                    credit_registrations::AdminAttention::Clear,
869                )
870                .await?;
871                insert_admin_action_event(tx, id, actor_user_id, reason).await?;
872                AppliedHandAction {
873                    outcome: AdminTransitionOutcome::Applied,
874                    state: row.state,
875                    needs_admin_attention: false,
876                    needs_due_now: false,
877                }
878            }
879        }
880        AdminCreditRegistrationAction::CheckNow => {
881            insert_admin_action_event(tx, id, actor_user_id, reason).await?;
882            AppliedHandAction {
883                outcome: AdminTransitionOutcome::Applied,
884                state: row.state,
885                needs_admin_attention: row.needs_admin_attention,
886                needs_due_now: true,
887            }
888        }
889        AdminCreditRegistrationAction::StateMove { to_state } => {
890            let to_state = to_state.to_state();
891            let after = credit_registrations::transition(
892                tx,
893                id,
894                &Transition {
895                    needs_admin_attention: Some(credit_registrations::AdminAttention::Clear),
896                    event_kind: CreditRegistrationEventKind::AdminAction,
897                    event_message: Some(reason.to_string()),
898                    actor_user_id: Some(actor_user_id),
899                    // Refuses to overwrite a row the pipeline (or another admin) has moved on since
900                    // `row` was read. The bulk caller reads its rows locked, so only the single-row
901                    // path can actually trip this.
902                    expected_from_state: Some(row.state),
903                    ..Transition::by_hand(to_state)
904                },
905            )
906            .await?;
907            // Nothing else brings the row forward, so without a due-now the resubmit would sit out
908            // the backoff whatever failed last set.
909            AppliedHandAction {
910                outcome: AdminTransitionOutcome::Applied,
911                state: after.state,
912                needs_admin_attention: after.needs_admin_attention,
913                needs_due_now: !after.state.is_terminal(),
914            }
915        }
916    })
917}
918
919/// Records an admin action against the row's timeline without moving its state, for the two
920/// transitions that only clear a flag or reschedule the row.
921async fn insert_admin_action_event(
922    tx: &mut PgConnection,
923    id: Uuid,
924    actor_user_id: Uuid,
925    reason: &str,
926) -> Result<(), ControllerError> {
927    models::credit_registration_events::insert(
928        tx,
929        &models::credit_registration_events::NewCreditRegistrationEvent {
930            actor_user_id: Some(actor_user_id),
931            message: Some(reason.to_string()),
932            ..models::credit_registration_events::NewCreditRegistrationEvent::new(
933                id,
934                CreditRegistrationEventKind::AdminAction,
935            )
936        },
937    )
938    .await?;
939    Ok(())
940}
941
942async fn one_admin_row(
943    conn: &mut PgConnection,
944    id: Uuid,
945) -> Result<Option<AdminCreditRegistration>, ControllerError> {
946    let rows = credit_registrations::get_admin_facing(
947        conn,
948        &AdminCreditRegistrationFilters {
949            id: Some(id),
950            include_superseded: true,
951            ..AdminCreditRegistrationFilters::default()
952        },
953        AdminCreditRegistrationSort::default(),
954        MAX_RELATED_ROWS,
955        0,
956    )
957    .await?;
958    Ok(rows.into_iter().next())
959}
960
961fn to_admin_row(row: AdminCreditRegistration) -> AdminCreditRegistrationRow {
962    AdminCreditRegistrationRow {
963        superseded: row.superseded_by_id.is_some(),
964        is_waiting_for_enrolment: row.is_waiting_for_enrolment(),
965        pending_reason: row.pending_reason(),
966        hand_actions: row
967            .resubmission_facts()
968            .hand_actions(ResubmissionStrictness::Any),
969        id: row.id,
970        created_at: row.created_at,
971        user_id: row.user_id,
972        first_name: row.first_name,
973        last_name: row.last_name,
974        email: row.email,
975        course_id: row.course_id,
976        course_name: row.course_name,
977        course_module_id: row.course_module_id,
978        course_module_name: row.course_module_name,
979        course_instance_id: row.course_instance_id,
980        course_module_completion_id: row.course_module_completion_id,
981        completion_date: row.completion_date,
982        state: row.state,
983        state_entered_at: row.state_entered_at,
984        error_code: row.error_code,
985        needs_admin_attention: row.needs_admin_attention,
986        next_attempt_at: row.next_attempt_at,
987        last_attempt_at: row.last_attempt_at,
988        submitted_at: row.submitted_at,
989        registered_at: row.registered_at,
990        terminal_at: row.terminal_at,
991        partially_registered_at: row.partially_registered_at,
992        resubmit_not_before: row.resubmit_not_before,
993        not_registered_reimport_count: row.not_registered_reimport_count,
994        no_usable_enrolment_since: row.no_usable_enrolment_since,
995        enrolment_checked_at: row.enrolment_checked_at,
996        enrolment_check_due_at: row.enrolment_check_due_at,
997        enrolment_checks_stopped_at: row.enrolment_checks_stopped_at,
998        student_number: expose_option(&row.student_number).map(str::to_owned),
999        sisu_person_id: expose_option(&row.sisu_person_id).map(str::to_owned),
1000        uh_course_code: row.uh_course_code,
1001        selected_enrolment_id: row.selected_enrolment_id,
1002        grade_scale_id: row.grade_scale_id,
1003        grade_id: row.grade_id,
1004        credits: row.credits,
1005        submitted_attainment_id: row.submitted_attainment_id,
1006        sisu_attainment_id: row.sisu_attainment_id,
1007        submit_retry_count: row.submit_retry_count,
1008        verify_attempt_count: row.verify_attempt_count,
1009        attempt_number: row.attempt_number,
1010        superseded_by_id: row.superseded_by_id,
1011        verified_student_number: expose_option(&row.verified_student_number).map(str::to_owned),
1012        verified_student_number_at: row.verified_student_number_at,
1013        verified_student_number_via: row.verified_student_number_via,
1014    }
1015}
1016
1017fn to_admin_api_call(call: models::suotar_api_calls::SuotarApiCall) -> AdminSuotarApiCall {
1018    AdminSuotarApiCall {
1019        id: call.id,
1020        endpoint: call.endpoint,
1021        started_at: call.started_at,
1022        duration_ms: call.duration_ms,
1023        http_status: call.http_status,
1024        succeeded: call.succeeded,
1025        request_level_error_code: call.request_level_error_code,
1026        worker_name: call.worker_name,
1027    }
1028}
1029
1030pub fn _add_routes(cfg: &mut ServiceConfig) {
1031    cfg.route(
1032        "/registrations",
1033        web::get().to(list_credit_registrations_for_admin),
1034    )
1035    .route(
1036        "/registrations/{credit_registration_id}",
1037        web::get().to(get_credit_registration_for_admin),
1038    )
1039    .route(
1040        "/registrations/bulk-transition",
1041        web::post().to(admin_bulk_transition_credit_registrations),
1042    )
1043    .route(
1044        "/registrations/requeue-retryable",
1045        web::post().to(admin_requeue_retryable_credit_registrations),
1046    )
1047    .route(
1048        "/registrations/{credit_registration_id}/transition",
1049        web::post().to(admin_transition_credit_registration),
1050    );
1051}