Skip to main content

headless_lms_server/controllers/main_frontend/course_credit_registrations/
retry.rs

1//! Putting a course's failed registrations back on the pipeline, one row or a whole course at a time.
2
3use headless_lms_models::credit_registration_admin_actions::{
4    COURSE_TEACHER_ROLE, CreditRegistrationAdminAction, CreditRegistrationAdminActionTarget,
5    NewCreditRegistrationAdminAction,
6};
7use headless_lms_models::credit_registration_events::CreditRegistrationEventKind;
8use headless_lms_models::credit_registrations::{
9    self, CreditRegistrationState, ResubmissionRefusal, ResubmissionStrictness, Transition,
10};
11use headless_lms_models::library::credit_registration::enrolment_check_schedule::EnrolmentCheckSource;
12use std::collections::HashMap;
13use utoipa::ToSchema;
14
15use crate::prelude::*;
16
17/// A single call never puts more than this back on the pipeline. A course that has more says so in
18/// `more_rows_remaining` and is retried by clicking again.
19const MAX_ROWS_PER_BULK_RETRY: i64 = 500;
20
21#[derive(Debug, Deserialize, ToSchema)]
22pub struct RetryCreditRegistrationPayload {
23    pub reason: Option<String>,
24}
25
26#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
27pub struct RetryCreditRegistrationResult {
28    /// Why the row was left alone, or `null` when it went back on the pipeline.
29    pub refusal: Option<ResubmissionRefusal>,
30    /// Where the row stands after the attempt, whatever the answer.
31    pub state: CreditRegistrationState,
32}
33
34#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
35pub struct RetryCreditRegistrationSkip {
36    pub refusal: ResubmissionRefusal,
37    pub count: i64,
38}
39
40#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
41pub struct RetryFailedCreditRegistrationsResult {
42    pub retried_count: i64,
43    /// Why the rest were left alone, so a teacher can see the admin-only pile rather than wonder.
44    /// Course-wide, not capped: clicking again will not work through these.
45    pub skipped: Vec<RetryCreditRegistrationSkip>,
46    /// How many retriable rows this call took, which is what `max_rows_per_call` bounds.
47    pub considered_count: i64,
48    pub max_rows_per_call: i64,
49    /// The cap stopped short of the course's retriable failures; running it again takes the next batch.
50    pub more_rows_remaining: bool,
51}
52
53/**
54POST `/api/v0/main-frontend/course-credit-registrations/registrations/{credit_registration_id}/retry`
55- Puts one failed registration back on the pipeline.
56
57Authorized on the row's own course, which is why no course id appears in the path: a teacher of one
58course must not be able to pair it with a foreign registration id.
59*/
60#[instrument(skip(pool, payload))]
61#[utoipa::path(
62    post,
63    path = "/registrations/{credit_registration_id}/retry",
64    operation_id = "retryCreditRegistration",
65    tag = "course-credit-registrations",
66    params(("credit_registration_id" = Uuid, Path, description = "Credit registration id")),
67    request_body = RetryCreditRegistrationPayload,
68    responses(
69        (status = 200, description = "What the retry did", body = RetryCreditRegistrationResult),
70        (status = 404, description = "No such registration")
71    )
72)]
73pub async fn retry_credit_registration(
74    user: AuthUser,
75    pool: web::Data<PgPool>,
76    credit_registration_id: web::Path<Uuid>,
77    payload: web::Json<RetryCreditRegistrationPayload>,
78) -> ControllerResult<web::Json<RetryCreditRegistrationResult>> {
79    let mut conn = pool.acquire().await?;
80    let id = *credit_registration_id;
81    let row = models::credit_registrations::get_teacher_facing_by_id(&mut conn, id)
82        .await?
83        .ok_or_else(|| controller_err!(NotFound, "Not found.".to_string()))?;
84    let token =
85        super::authorize_credit_registration_teacher(&mut conn, user.id, row.course_id).await?;
86
87    let reason = non_empty(payload.reason.as_deref());
88    let refusal = row
89        .resubmission_facts()
90        .resubmission_refusal(ResubmissionStrictness::OnlyFailedPermanent);
91
92    let mut tx = conn.begin().await?;
93    let state = match refusal {
94        None => requeue(&mut tx, id, row.state, user.id, reason).await?,
95        Some(_) => row.state,
96    };
97    models::credit_registration_admin_actions::record(
98        &mut tx,
99        &NewCreditRegistrationAdminAction {
100            target_id: Some(id),
101            actor_course_id: Some(row.course_id),
102            reason: reason.map(str::to_string),
103            before_state: Some(row.state),
104            after_state: Some(state),
105            details: Some(serde_json::json!({ "refusal": refusal })),
106            affected_row_count: Some(i32::from(refusal.is_none())),
107            ..NewCreditRegistrationAdminAction::new(
108                CreditRegistrationAdminAction::RetryItem,
109                CreditRegistrationAdminActionTarget::CreditRegistration,
110                user.id,
111                COURSE_TEACHER_ROLE,
112            )
113        },
114    )
115    .await?;
116    tx.commit().await?;
117
118    token.authorized_ok(web::Json(RetryCreditRegistrationResult { refusal, state }))
119}
120
121/**
122POST `/api/v0/main-frontend/course-credit-registrations/courses/{course_id}/retry-failed` - Puts this
123course's failed registrations back on the pipeline.
124
125Refuses the same rows the single-row retry refuses and reports how many of each it left alone rather
126than failing the whole call over them. The cap applies to the rows a retry can actually move: the
127refused ones are counted across the whole course, because they would otherwise sit in the batch
128forever and a course that accumulated a capful of them could never retry anything again.
129*/
130#[instrument(skip(pool, payload))]
131#[utoipa::path(
132    post,
133    path = "/courses/{course_id}/retry-failed",
134    operation_id = "retryFailedCreditRegistrationsForCourse",
135    tag = "course-credit-registrations",
136    params(("course_id" = Uuid, Path, description = "Course id")),
137    request_body = RetryCreditRegistrationPayload,
138    responses(
139        (status = 200, description = "How many were retried and why the rest were not", body = RetryFailedCreditRegistrationsResult)
140    )
141)]
142pub async fn retry_failed_credit_registrations_for_course(
143    user: AuthUser,
144    pool: web::Data<PgPool>,
145    course_id: web::Path<Uuid>,
146    payload: web::Json<RetryCreditRegistrationPayload>,
147) -> ControllerResult<web::Json<RetryFailedCreditRegistrationsResult>> {
148    let mut conn = pool.acquire().await?;
149    let token =
150        super::authorize_credit_registration_teacher(&mut conn, user.id, *course_id).await?;
151
152    let reason = non_empty(payload.reason.as_deref());
153    // One over the cap, so "there is more" is answered without a second count query.
154    let mut candidate_ids = models::credit_registrations::get_retryable_ids_by_course_id(
155        &mut conn,
156        *course_id,
157        MAX_ROWS_PER_BULK_RETRY + 1,
158    )
159    .await?;
160    let more_rows_remaining = candidate_ids.len() as i64 > MAX_ROWS_PER_BULK_RETRY;
161    candidate_ids.truncate(MAX_ROWS_PER_BULK_RETRY as usize);
162    // The permanent refusals are counted over the whole course rather than walked: they are the rows
163    // the query above leaves out, so clicking again will never reach them either.
164    let submission_uncertain_count =
165        models::credit_registrations::count_submission_uncertain_by_course_id(
166            &mut conn, *course_id,
167        )
168        .await?;
169
170    let mut retried_count = 0;
171    let mut skipped: HashMap<ResubmissionRefusal, i64> = if submission_uncertain_count > 0 {
172        HashMap::from([(
173            ResubmissionRefusal::SubmissionUncertain,
174            submission_uncertain_count,
175        )])
176    } else {
177        HashMap::new()
178    };
179
180    let mut tx = conn.begin().await?;
181    // Locked, and read inside the transaction: each row's refusal is judged here and acted on below,
182    // so a row the pipeline moves on in between would make `requeue` refuse it and roll back every row
183    // already retried, which is what two teachers clicking at once would otherwise do to each other.
184    let candidates =
185        models::credit_registrations::get_by_ids_for_update(&mut tx, &candidate_ids).await?;
186    let mut retried_ids = Vec::new();
187    for row in &candidates {
188        // Re-judged rather than trusted from the query above, which ran before the lock: the row may
189        // have moved on in between. Same precedence as the single-row endpoint, so one row gets one
190        // answer whichever way it is asked.
191        let refusal = row
192            .resubmission_facts()
193            .resubmission_refusal(ResubmissionStrictness::OnlyFailedPermanent);
194        match refusal {
195            Some(refusal) => *skipped.entry(refusal).or_insert(0) += 1,
196            None => {
197                transition_to_ready_to_submit(&mut tx, row.id, row.state, user.id, reason).await?;
198                retried_ids.push(row.id);
199                retried_count += 1;
200            }
201        }
202    }
203    // Batched rather than one `UPDATE` per row inside the loop above: the row transition needs its
204    // own audit event per row, but making it due now does not.
205    credit_registrations::make_due_now_batch(
206        &mut tx,
207        &retried_ids,
208        EnrolmentCheckSource::TeacherRequest,
209    )
210    .await?;
211    let mut skipped: Vec<RetryCreditRegistrationSkip> = skipped
212        .into_iter()
213        .map(|(refusal, count)| RetryCreditRegistrationSkip { refusal, count })
214        .collect();
215    skipped.sort_by_key(|skip| std::cmp::Reverse(skip.count));
216
217    models::credit_registration_admin_actions::record(
218        &mut tx,
219        &NewCreditRegistrationAdminAction {
220            target_id: Some(*course_id),
221            actor_course_id: Some(*course_id),
222            reason: reason.map(str::to_string),
223            details: Some(serde_json::json!({ "skipped": skipped })),
224            affected_row_count: Some(i32::try_from(retried_count).unwrap_or(i32::MAX)),
225            ..NewCreditRegistrationAdminAction::new(
226                CreditRegistrationAdminAction::RetryFailedForCourse,
227                CreditRegistrationAdminActionTarget::Course,
228                user.id,
229                COURSE_TEACHER_ROLE,
230            )
231        },
232    )
233    .await?;
234    tx.commit().await?;
235
236    token.authorized_ok(web::Json(RetryFailedCreditRegistrationsResult {
237        retried_count,
238        skipped,
239        considered_count: candidates.len() as i64,
240        max_rows_per_call: MAX_ROWS_PER_BULK_RETRY,
241        more_rows_remaining,
242    }))
243}
244
245/// Moves one row back to `ready_to_submit`, in the caller's transaction.
246///
247/// `from_state` is the state the refusals above were judged against; the transition refuses to
248/// overwrite the row if the pipeline has since moved it on. Does not make the row due: the
249/// single-row caller does that itself right after, and the bulk caller batches it over every row it
250/// retried instead of one `UPDATE` per row.
251async fn transition_to_ready_to_submit(
252    tx: &mut PgConnection,
253    id: Uuid,
254    from_state: CreditRegistrationState,
255    actor_user_id: Uuid,
256    reason: Option<&str>,
257) -> Result<CreditRegistrationState, ControllerError> {
258    let after = credit_registrations::transition(
259        tx,
260        id,
261        &Transition {
262            needs_admin_attention: Some(credit_registrations::AdminAttention::Clear),
263            event_kind: CreditRegistrationEventKind::AdminAction,
264            event_message: Some(
265                reason
266                    .map(str::to_string)
267                    .unwrap_or_else(|| "Retried by a teacher of the course.".to_string()),
268            ),
269            actor_user_id: Some(actor_user_id),
270            expected_from_state: Some(from_state),
271            ..Transition::by_hand(CreditRegistrationState::ReadyToSubmit)
272        },
273    )
274    .await?;
275    Ok(after.state)
276}
277
278/// [`transition_to_ready_to_submit`] plus making the row due now, for the single-row endpoint.
279async fn requeue(
280    tx: &mut PgConnection,
281    id: Uuid,
282    from_state: CreditRegistrationState,
283    actor_user_id: Uuid,
284    reason: Option<&str>,
285) -> Result<CreditRegistrationState, ControllerError> {
286    let state = transition_to_ready_to_submit(tx, id, from_state, actor_user_id, reason).await?;
287    // Nothing else brings the row forward, so without this the retry sits out whatever backoff the
288    // last failure set.
289    credit_registrations::make_due_now_batch(tx, &[id], EnrolmentCheckSource::TeacherRequest)
290        .await?;
291    Ok(state)
292}
293
294pub fn _add_routes(cfg: &mut ServiceConfig) {
295    cfg.route(
296        "/registrations/{credit_registration_id}/retry",
297        web::post().to(retry_credit_registration),
298    )
299    .route(
300        "/courses/{course_id}/retry-failed",
301        web::post().to(retry_failed_credit_registrations_for_course),
302    );
303}