headless_lms_server/controllers/main_frontend/course_credit_registrations/
retry.rs1use 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
17const 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 pub refusal: Option<ResubmissionRefusal>,
30 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 pub skipped: Vec<RetryCreditRegistrationSkip>,
46 pub considered_count: i64,
48 pub max_rows_per_call: i64,
49 pub more_rows_remaining: bool,
51}
52
53#[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#[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
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 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
245async 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
278async 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 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}