1use 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
16const 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 pub refusal: Option<ResubmissionRefusal>,
29 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 pub skipped: Vec<RetryCreditRegistrationSkip>,
45 pub considered_count: i64,
47 pub max_rows_per_call: i64,
48 pub more_rows_remaining: bool,
50}
51
52#[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#[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 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 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 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 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 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
241async 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
274async 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 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}