1use 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 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 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 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 pub resubmission_refusal: Option<ResubmissionRefusal>,
81 pub verified_student_number: Option<String>,
83 pub verified_student_number_at: Option<DateTime<Utc>>,
84 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 pub message: Option<String>,
98 pub actor_user_id: Option<Uuid>,
99 pub suotar_api_call_id: Option<Uuid>,
100 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 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#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
127pub struct AdminNotificationEmail {
128 pub kind: CreditRegistrationNotificationKind,
129 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 pub attempts: Vec<AdminCreditRegistrationRow>,
140 pub events: Vec<AdminCreditRegistrationEvent>,
141 pub suotar_api_calls: Vec<AdminSuotarApiCall>,
143 pub actions: Vec<CreditRegistrationAdminActionRecord>,
145 pub linking_emails: Vec<AdminLinkingEmail>,
147 pub notification_emails: Vec<AdminNotificationEmail>,
150 pub not_improved_attainment: Option<NotImprovedAttainment>,
153}
154
155#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
157#[serde(rename_all = "snake_case")]
158pub enum AdminCreditRegistrationStateMove {
159 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#[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 ClearNeedsAdminAttention,
184 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 Refused,
210 NoChange,
211}
212
213#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
214pub struct AdminTransitionCreditRegistrationResult {
215 pub outcome: AdminTransitionOutcome,
216 pub refusal: Option<ResubmissionRefusal>,
218 pub state: CreditRegistrationState,
219 pub needs_admin_attention: bool,
220}
221
222const MAX_ROWS_PER_BULK_TRANSITION: i64 = 500;
224const MAX_ROWS_PER_REQUEUE: i64 = 5_000;
225const 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 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#[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#[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 ®istration.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#[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 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#[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 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 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 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 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#[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
770async 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 expected_from_state: Some(row.state),
818 ..Transition::by_hand(to_state)
819 },
820 )
821 .await?;
822 (
825 AdminTransitionOutcome::Applied,
826 after.state,
827 after.needs_admin_attention,
828 !after.state.is_terminal(),
829 )
830 }
831 })
832}
833
834async 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}