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