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 std::collections::HashMap;
12use utoipa::ToSchema;
13
14use crate::prelude::*;
15
16/// A single call never puts more than this back on the pipeline. A course that has more says so in
17/// `more_rows_remaining` and is retried by clicking again.
18const MAX_ROWS_PER_BULK_RETRY: i64 = 500;
19
20#[derive(Debug, Deserialize, ToSchema)]
21pub struct RetryCreditRegistrationPayload {
22    pub reason: Option<String>,
23}
24
25#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
26pub struct RetryCreditRegistrationResult {
27    /// Why the row was left alone, or `null` when it went back on the pipeline.
28    pub refusal: Option<ResubmissionRefusal>,
29    /// Where the row stands after the attempt, whatever the answer.
30    pub state: CreditRegistrationState,
31}
32
33#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
34pub struct RetryCreditRegistrationSkip {
35    pub refusal: ResubmissionRefusal,
36    pub count: i64,
37}
38
39#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
40pub struct RetryFailedCreditRegistrationsResult {
41    pub retried_count: i64,
42    /// Why the rest were left alone, so a teacher can see the admin-only pile rather than wonder.
43    /// Course-wide, not capped: clicking again will not work through these.
44    pub skipped: Vec<RetryCreditRegistrationSkip>,
45    /// How many retriable rows this call took, which is what `max_rows_per_call` bounds.
46    pub considered_count: i64,
47    pub max_rows_per_call: i64,
48    /// The cap stopped short of the course's retriable failures; running it again takes the next batch.
49    pub more_rows_remaining: bool,
50}
51
52/**
53POST `/api/v0/main-frontend/course-credit-registrations/registrations/{credit_registration_id}/retry`
54- Puts one failed registration back on the pipeline.
55
56Authorized on the row's own course, which is why no course id appears in the path: a teacher of one
57course must not be able to pair it with a foreign registration id.
58*/
59#[instrument(skip(pool, payload))]
60#[utoipa::path(
61    post,
62    path = "/registrations/{credit_registration_id}/retry",
63    operation_id = "retryCreditRegistration",
64    tag = "course-credit-registrations",
65    params(("credit_registration_id" = Uuid, Path, description = "Credit registration id")),
66    request_body = RetryCreditRegistrationPayload,
67    responses(
68        (status = 200, description = "What the retry did", body = RetryCreditRegistrationResult),
69        (status = 404, description = "No such registration")
70    )
71)]
72pub async fn retry_credit_registration(
73    user: AuthUser,
74    pool: web::Data<PgPool>,
75    credit_registration_id: web::Path<Uuid>,
76    payload: web::Json<RetryCreditRegistrationPayload>,
77) -> ControllerResult<web::Json<RetryCreditRegistrationResult>> {
78    let mut conn = pool.acquire().await?;
79    let id = *credit_registration_id;
80    let row = models::credit_registrations::get_teacher_facing_by_id(&mut conn, id)
81        .await?
82        .ok_or_else(|| controller_err!(NotFound, "Not found.".to_string()))?;
83    let token =
84        super::authorize_credit_registration_teacher(&mut conn, user.id, row.course_id).await?;
85
86    let reason = non_empty(payload.reason.as_deref());
87    let refusal = row.state.resubmission_refusal(
88        row.superseded_by_id.is_some(),
89        ResubmissionStrictness::OnlyFailedPermanent,
90    );
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.state.resubmission_refusal(
192            row.superseded_by_id.is_some(),
193            ResubmissionStrictness::OnlyFailedPermanent,
194        );
195        match refusal {
196            Some(refusal) => *skipped.entry(refusal).or_insert(0) += 1,
197            None => {
198                transition_to_ready_to_submit(&mut tx, row.id, row.state, user.id, reason).await?;
199                retried_ids.push(row.id);
200                retried_count += 1;
201            }
202        }
203    }
204    // Batched rather than one `UPDATE` per row inside the loop above: the row transition needs its
205    // own audit event per row, but making it due now does not.
206    credit_registrations::make_due_now_batch(&mut tx, &retried_ids).await?;
207    let mut skipped: Vec<RetryCreditRegistrationSkip> = skipped
208        .into_iter()
209        .map(|(refusal, count)| RetryCreditRegistrationSkip { refusal, count })
210        .collect();
211    skipped.sort_by_key(|skip| std::cmp::Reverse(skip.count));
212
213    models::credit_registration_admin_actions::record(
214        &mut tx,
215        &NewCreditRegistrationAdminAction {
216            target_id: Some(*course_id),
217            actor_course_id: Some(*course_id),
218            reason: reason.map(str::to_string),
219            details: Some(serde_json::json!({ "skipped": skipped })),
220            affected_row_count: Some(i32::try_from(retried_count).unwrap_or(i32::MAX)),
221            ..NewCreditRegistrationAdminAction::new(
222                CreditRegistrationAdminAction::RetryFailedForCourse,
223                CreditRegistrationAdminActionTarget::Course,
224                user.id,
225                COURSE_TEACHER_ROLE,
226            )
227        },
228    )
229    .await?;
230    tx.commit().await?;
231
232    token.authorized_ok(web::Json(RetryFailedCreditRegistrationsResult {
233        retried_count,
234        skipped,
235        considered_count: candidates.len() as i64,
236        max_rows_per_call: MAX_ROWS_PER_BULK_RETRY,
237        more_rows_remaining,
238    }))
239}
240
241/// Moves one row back to `ready_to_submit`, in the caller's transaction.
242///
243/// `from_state` is the state the refusals above were judged against; the transition refuses to
244/// overwrite the row if the pipeline has since moved it on. Does not make the row due: the
245/// single-row caller does that itself right after, and the bulk caller batches it over every row it
246/// retried instead of one `UPDATE` per row.
247async fn transition_to_ready_to_submit(
248    tx: &mut PgConnection,
249    id: Uuid,
250    from_state: CreditRegistrationState,
251    actor_user_id: Uuid,
252    reason: Option<&str>,
253) -> Result<CreditRegistrationState, ControllerError> {
254    let after = credit_registrations::transition(
255        tx,
256        id,
257        &Transition {
258            needs_admin_attention: Some(false),
259            event_kind: CreditRegistrationEventKind::AdminAction,
260            event_message: Some(
261                reason
262                    .map(str::to_string)
263                    .unwrap_or_else(|| "Retried by a teacher of the course.".to_string()),
264            ),
265            actor_user_id: Some(actor_user_id),
266            expected_from_state: Some(from_state),
267            ..Transition::by_hand(CreditRegistrationState::ReadyToSubmit)
268        },
269    )
270    .await?;
271    Ok(after.state)
272}
273
274/// [`transition_to_ready_to_submit`] plus making the row due now, for the single-row endpoint.
275async fn requeue(
276    tx: &mut PgConnection,
277    id: Uuid,
278    from_state: CreditRegistrationState,
279    actor_user_id: Uuid,
280    reason: Option<&str>,
281) -> Result<CreditRegistrationState, ControllerError> {
282    let state = transition_to_ready_to_submit(tx, id, from_state, actor_user_id, reason).await?;
283    // Nothing else brings the row forward, so without this the retry sits out whatever backoff the
284    // last failure set.
285    credit_registrations::make_due_now_batch(tx, &[id]).await?;
286    Ok(state)
287}
288
289pub fn _add_routes(cfg: &mut ServiceConfig) {
290    cfg.route(
291        "/registrations/{credit_registration_id}/retry",
292        web::post().to(retry_credit_registration),
293    )
294    .route(
295        "/courses/{course_id}/retry-failed",
296        web::post().to(retry_failed_credit_registrations_for_course),
297    );
298}