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, SuotarAnswer,
11};
12use headless_lms_models::credit_registrations::{
13 self, AdminCreditRegistration, AdminCreditRegistrationFilters, AdminCreditRegistrationSort,
14 CreditRegistrationErrorCode, CreditRegistrationState, HandActionAvailability,
15 ResubmissionFacts, ResubmissionRefusal, 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::backoff::{
20 NOT_REGISTERED_REIMPORT_ADMIN_THRESHOLD, PARTIAL_REGISTRATION_ADMIN_AFTER,
21 UNCERTAIN_ADMIN_AFTER, VERIFY_MAX_AGE,
22};
23use headless_lms_models::library::credit_registration::enrolment_check_schedule::EnrolmentCheckSource;
24use headless_lms_models::library::credit_registration::student_notifications::{
25 self, CreditRegistrationNotificationKind, RegistrationNotificationEmail,
26};
27use headless_lms_models::suotar_api_calls;
28use headless_lms_models::verified_student_numbers::{self, StudentNumberVerificationMethod};
29use std::collections::{HashMap, HashSet};
30use utoipa::ToSchema;
31
32use crate::prelude::*;
33use headless_lms_utils::secret_string::expose_option;
34use secrecy::{ExposeSecret, SecretString};
35
36use super::{
37 AdminLinkingEmail, authorize_credit_registration_admin, build_linking_emails, required_reason,
38};
39
40#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
41pub struct AdminCreditRegistrationRow {
42 pub id: Uuid,
43 pub created_at: DateTime<Utc>,
44 pub user_id: Uuid,
45 pub first_name: Option<String>,
46 pub last_name: Option<String>,
47 pub email: Option<String>,
49 pub course_id: Uuid,
50 pub course_name: String,
51 pub course_module_id: Uuid,
52 pub course_module_name: Option<String>,
53 pub course_instance_id: Uuid,
54 pub course_module_completion_id: Uuid,
55 pub completion_date: DateTime<Utc>,
56 pub state: CreditRegistrationState,
57 pub pending_reason: Option<CreditRegistrationPendingReason>,
60 pub state_entered_at: DateTime<Utc>,
61 pub error_code: Option<CreditRegistrationErrorCode>,
62 pub needs_admin_attention: bool,
63 pub next_attempt_at: DateTime<Utc>,
64 pub last_attempt_at: Option<DateTime<Utc>>,
65 pub submitted_at: Option<DateTime<Utc>>,
66 pub registered_at: Option<DateTime<Utc>>,
67 pub terminal_at: Option<DateTime<Utc>>,
68 pub partially_registered_at: Option<DateTime<Utc>>,
70 pub resubmit_not_before: Option<DateTime<Utc>>,
72 pub not_registered_reimport_count: i32,
74 pub is_waiting_for_enrolment: bool,
75 pub no_usable_enrolment_since: Option<DateTime<Utc>>,
76 pub enrolment_checked_at: Option<DateTime<Utc>>,
77 pub enrolment_check_due_at: Option<DateTime<Utc>>,
79 pub enrolment_checks_stopped_at: Option<DateTime<Utc>>,
80 pub student_number: Option<String>,
82 pub sisu_person_id: Option<String>,
83 pub uh_course_code: Option<String>,
84 pub selected_enrolment_id: Option<String>,
85 pub grade_scale_id: Option<String>,
86 pub grade_id: Option<String>,
87 pub credits: Option<f32>,
88 pub submitted_attainment_id: Option<String>,
89 pub sisu_attainment_id: Option<String>,
90 pub submit_retry_count: i32,
91 pub verify_attempt_count: i32,
92 pub attempt_number: i32,
93 pub superseded: bool,
94 pub superseded_by_id: Option<Uuid>,
95 pub hand_actions: HandActionAvailability,
97 pub verified_student_number: Option<String>,
99 pub verified_student_number_at: Option<DateTime<Utc>>,
100 pub verified_student_number_via: Option<StudentNumberVerificationMethod>,
102}
103
104#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
105pub struct AdminCreditRegistrationEvent {
106 pub id: Uuid,
107 pub created_at: DateTime<Utc>,
108 pub kind: CreditRegistrationEventKind,
109 pub from_state: Option<CreditRegistrationState>,
110 pub to_state: Option<CreditRegistrationState>,
111 pub error_code: Option<CreditRegistrationErrorCode>,
112 pub message: Option<String>,
114 pub actor_user_id: Option<Uuid>,
115 pub suotar_api_call_id: Option<Uuid>,
116 pub details: Option<serde_json::Value>,
119 pub request_item_id: Option<String>,
121 pub suotar_endpoint: Option<suotar_api_calls::SuotarEndpoint>,
122 pub suotar_requested_at: Option<DateTime<Utc>>,
123 pub suotar_answered_at: Option<DateTime<Utc>>,
124 pub suotar_answer: Option<SuotarAnswer>,
125 pub suotar_code: Option<String>,
128}
129
130#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
131pub struct AdminSuotarApiCall {
132 pub id: Uuid,
133 pub endpoint: suotar_api_calls::SuotarEndpoint,
134 pub started_at: DateTime<Utc>,
135 pub duration_ms: Option<i32>,
136 pub http_status: Option<i32>,
137 pub succeeded: bool,
138 pub request_level_error_code: Option<String>,
139 pub worker_name: String,
140}
141
142#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
145pub struct AdminNotificationEmail {
146 pub kind: CreditRegistrationNotificationKind,
147 pub email_delivery_id: Uuid,
150 pub send_status: EmailSendStatusReport,
151}
152
153#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy, ToSchema)]
155pub struct AdminAttentionThresholds {
156 pub partial_registration_secs: i64,
157 pub uncertain_secs: i64,
158 pub verify_window_secs: i64,
159 pub not_registered_reimports: i32,
160}
161
162impl AdminAttentionThresholds {
163 pub const CURRENT: Self = Self {
164 partial_registration_secs: PARTIAL_REGISTRATION_ADMIN_AFTER.num_seconds(),
165 uncertain_secs: UNCERTAIN_ADMIN_AFTER.num_seconds(),
166 verify_window_secs: VERIFY_MAX_AGE.num_seconds(),
167 not_registered_reimports: NOT_REGISTERED_REIMPORT_ADMIN_THRESHOLD,
168 };
169}
170
171#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
172pub struct AdminCreditRegistrationDetails {
173 pub attention_thresholds: AdminAttentionThresholds,
174 pub registration: AdminCreditRegistrationRow,
175 pub attempts: Vec<AdminCreditRegistrationRow>,
177 pub events: Vec<AdminCreditRegistrationEvent>,
178 pub suotar_api_calls: Vec<AdminSuotarApiCall>,
180 pub actions: Vec<CreditRegistrationAdminActionRecord>,
182 pub linking_emails: Vec<AdminLinkingEmail>,
184 pub notification_emails: Vec<AdminNotificationEmail>,
187 pub not_improved_attainment: Option<NotImprovedAttainment>,
190}
191
192#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
194#[serde(rename_all = "snake_case")]
195pub enum AdminCreditRegistrationStateMove {
196 ReadyToSubmit,
199 Cancelled,
200}
201
202impl AdminCreditRegistrationStateMove {
203 fn to_state(self) -> CreditRegistrationState {
204 match self {
205 Self::ReadyToSubmit => CreditRegistrationState::ReadyToSubmit,
206 Self::Cancelled => CreditRegistrationState::Cancelled,
207 }
208 }
209}
210
211#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
214#[serde(rename_all = "snake_case", tag = "kind")]
215pub enum AdminCreditRegistrationAction {
216 StateMove {
217 to_state: AdminCreditRegistrationStateMove,
218 },
219 ClearNeedsAdminAttention,
221 CheckNow,
224}
225
226impl AdminCreditRegistrationAction {
227 fn refusal(
229 self,
230 facts: &ResubmissionFacts,
231 strictness: ResubmissionStrictness,
232 ) -> Option<ResubmissionRefusal> {
233 match self {
234 Self::StateMove { to_state } => {
235 facts.admin_transition_refusal(to_state.to_state(), strictness)
236 }
237 Self::CheckNow => facts.check_now_refusal(),
238 Self::ClearNeedsAdminAttention if facts.is_superseded => {
240 Some(ResubmissionRefusal::Superseded)
241 }
242 Self::ClearNeedsAdminAttention => None,
243 }
244 }
245}
246
247#[derive(Debug, Deserialize, ToSchema)]
248pub struct AdminTransitionCreditRegistrationPayload {
249 pub action: AdminCreditRegistrationAction,
250 pub reason: String,
251}
252
253#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
254#[serde(rename_all = "snake_case")]
255pub enum AdminTransitionOutcome {
256 Applied,
257 Refused,
259 NoChange,
260}
261
262#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
263pub struct AdminTransitionCreditRegistrationResult {
264 pub outcome: AdminTransitionOutcome,
265 pub refusal: Option<ResubmissionRefusal>,
267 pub state: CreditRegistrationState,
268 pub needs_admin_attention: bool,
269}
270
271const MAX_ROWS_PER_BULK_TRANSITION: i64 = 500;
273const MAX_ROWS_PER_REQUEUE: i64 = 5_000;
274const MAX_RELATED_ROWS: i64 = u8::MAX as i64;
277
278#[derive(Debug, Deserialize, ToSchema)]
279pub struct AdminBulkTransitionPayload {
280 pub action: AdminCreditRegistrationAction,
281 pub credit_registration_ids: Vec<Uuid>,
282 pub reason: String,
283}
284
285#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
286pub struct AdminBulkTransitionSkipCount {
287 pub refusal: ResubmissionRefusal,
288 pub count: i64,
289}
290
291#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
292pub struct AdminBulkTransitionResult {
293 pub applied_count: i64,
294 pub skipped: Vec<AdminBulkTransitionSkipCount>,
295 pub not_found_count: i64,
297 pub max_rows_per_call: i64,
298}
299
300#[derive(Debug, Deserialize, ToSchema)]
301pub struct AdminRequeueRetryablePayload {
302 pub course_id: Option<Uuid>,
303 pub course_module_id: Option<Uuid>,
304 pub reason: String,
305}
306
307#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
308pub struct AdminRequeueRetryableResult {
309 pub requeued_count: i64,
310 pub max_rows_per_call: i64,
311}
312
313#[derive(Debug, Deserialize)]
314pub struct ListCreditRegistrationsQuery {
315 page: Option<u32>,
316 limit: Option<u32>,
317 state: Option<Vec<CreditRegistrationState>>,
318 error_code: Option<Vec<CreditRegistrationErrorCode>>,
319 course_id: Option<Uuid>,
320 course_module_id: Option<Uuid>,
321 user_id: Option<Uuid>,
322 student_number: Option<SecretString>,
323 needs_admin_attention: Option<bool>,
324 submitted_after: Option<DateTime<Utc>>,
325 submitted_before: Option<DateTime<Utc>>,
326 search: Option<SecretString>,
327 include_superseded: Option<bool>,
328 sort: Option<String>,
329}
330
331#[instrument(skip(pool))]
336#[utoipa::path(
337 get,
338 path = "/registrations",
339 operation_id = "listCreditRegistrationsForAdmin",
340 tag = "credit-registration-admin",
341 params(
342 ("page" = Option<u32>, Query, description = "Page number, from 1"),
343 ("limit" = Option<u32>, Query, description = "Rows per page"),
344 ("state" = Option<Vec<CreditRegistrationState>>, Query, description = "Ledger states; repeat the parameter for several"),
345 ("error_code" = Option<Vec<CreditRegistrationErrorCode>>, Query, description = "Error codes; repeat the parameter for several"),
346 ("course_id" = Option<Uuid>, Query, description = "Course filter"),
347 ("course_module_id" = Option<Uuid>, Query, description = "Course module filter"),
348 ("user_id" = Option<Uuid>, Query, description = "Student filter"),
349 ("student_number" = Option<String>, Query, description = "Exact student number, frozen on the row or linked to the account"),
350 ("needs_admin_attention" = Option<bool>, Query, description = "Only rows asking for a human"),
351 ("submitted_after" = Option<DateTime<Utc>>, Query, description = "Submitted at or after"),
352 ("submitted_before" = Option<DateTime<Utc>>, Query, description = "Submitted at or before"),
353 ("search" = Option<String>, Query, description = "Name, email, student number, attainment id, stored error text, or a uuid"),
354 ("include_superseded" = Option<bool>, Query, description = "Include replaced attempts"),
355 ("sort" = Option<String>, Query, description = "last_activity, created, time_in_state or attempts")
356 ),
357 responses(
358 (status = 200, description = "A page of the ledger", body = Page<AdminCreditRegistrationRow>)
359 )
360)]
361pub async fn list_credit_registrations_for_admin(
362 user: AuthUser,
363 pool: web::Data<PgPool>,
364 query: MultiQuery<ListCreditRegistrationsQuery>,
365) -> ControllerResult<web::Json<Page<AdminCreditRegistrationRow>>> {
366 let mut conn = pool.acquire().await?;
367 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
368
369 let pagination = parse_pagination(query.page, query.limit, 50)?;
370 let search = non_empty(expose_option(&query.search));
371 let student_number = non_empty(expose_option(&query.student_number));
372 let filters = AdminCreditRegistrationFilters {
373 states: query.state.as_deref(),
374 error_codes: query.error_code.as_deref(),
375 course_id: query.course_id,
376 course_module_id: query.course_module_id,
377 user_id: query.user_id,
378 student_number,
379 needs_admin_attention: query.needs_admin_attention.unwrap_or(false),
380 submitted_after: query.submitted_after,
381 submitted_before: query.submitted_before,
382 search,
383 search_id: search.and_then(|search| Uuid::parse_str(search).ok()),
384 include_superseded: query.include_superseded.unwrap_or(false),
385 ..AdminCreditRegistrationFilters::default()
386 };
387 let sort = match query.sort.as_deref() {
388 Some("created") => AdminCreditRegistrationSort::Created,
389 Some("time_in_state") => AdminCreditRegistrationSort::TimeInState,
390 Some("attempts") => AdminCreditRegistrationSort::Attempts,
391 _ => AdminCreditRegistrationSort::LastActivity,
392 };
393
394 let rows = credit_registrations::get_admin_facing(
395 &mut conn,
396 &filters,
397 sort,
398 pagination.limit(),
399 pagination.offset(),
400 )
401 .await?;
402 let total_count = rows.first().map_or(0, |row| row.total_count);
403 let data = rows.into_iter().map(to_admin_row).collect();
404
405 token.authorized_ok(web::Json(Page::new(pagination, data, total_count)))
406}
407
408#[instrument(skip(pool))]
414#[utoipa::path(
415 get,
416 path = "/registrations/{credit_registration_id}",
417 operation_id = "getCreditRegistrationForAdmin",
418 tag = "credit-registration-admin",
419 params(("credit_registration_id" = Uuid, Path, description = "Credit registration id")),
420 responses(
421 (status = 200, description = "The row and everything that happened to it", body = AdminCreditRegistrationDetails),
422 (status = 404, description = "No such registration")
423 )
424)]
425pub async fn get_credit_registration_for_admin(
426 user: AuthUser,
427 pool: web::Data<PgPool>,
428 credit_registration_id: web::Path<Uuid>,
429) -> ControllerResult<web::Json<AdminCreditRegistrationDetails>> {
430 let mut conn = pool.acquire().await?;
431 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
432
433 let id = *credit_registration_id;
434 let registration = one_admin_row(&mut conn, id)
435 .await?
436 .ok_or_else(|| controller_err!(NotFound, "Not found.".to_string()))?;
437 let attempts = credit_registrations::get_admin_facing(
438 &mut conn,
439 &AdminCreditRegistrationFilters {
440 user_id: Some(registration.user_id),
441 course_id: Some(registration.course_id),
442 course_module_completion_id: Some(registration.course_module_completion_id),
443 include_superseded: true,
444 ..AdminCreditRegistrationFilters::default()
445 },
446 AdminCreditRegistrationSort::Created,
447 MAX_RELATED_ROWS,
448 0,
449 )
450 .await?
451 .into_iter()
452 .map(to_admin_row)
453 .collect();
454
455 let events: Vec<AdminCreditRegistrationEvent> =
456 models::credit_registration_events::get_by_registration_id(&mut conn, id)
457 .await?
458 .into_iter()
459 .map(|event| AdminCreditRegistrationEvent {
460 id: event.id,
461 created_at: event.created_at,
462 kind: event.kind,
463 from_state: event.from_state,
464 to_state: event.to_state,
465 error_code: event.error_code,
466 message: event.message,
467 actor_user_id: event.actor_user_id,
468 suotar_api_call_id: event.suotar_api_call_id,
469 suotar_code: event
470 .details
471 .as_ref()
472 .and_then(|details| details.pointer("/response/code"))
473 .and_then(|code| code.as_str())
474 .map(str::to_string),
475 details: event.details,
476 request_item_id: event.request_item_id,
477 suotar_endpoint: event.suotar_endpoint,
478 suotar_requested_at: event.suotar_requested_at,
479 suotar_answered_at: event.suotar_answered_at,
480 suotar_answer: event.suotar_answer,
481 })
482 .collect();
483 let suotar_api_calls =
484 suotar_api_calls::get_by_credit_registration_id(&mut conn, id, MAX_RELATED_ROWS)
485 .await?
486 .into_iter()
487 .map(to_admin_api_call)
488 .collect();
489 let actions = models::credit_registration_admin_actions::get_page(
490 &mut conn,
491 &CreditRegistrationAdminActionFilters {
492 target_kind: Some(CreditRegistrationAdminActionTarget::CreditRegistration),
493 target_id: Some(id),
494 ..Default::default()
495 },
496 MAX_RELATED_ROWS,
497 0,
498 )
499 .await?
500 .into_iter()
501 .map(|row| row.action)
502 .collect();
503
504 let sisu_person_id = match ®istration.sisu_person_id {
505 Some(person_id) => Some(person_id.clone()),
506 None => verified_student_numbers::get_latest_including_deleted_by_user_id(
507 &mut conn,
508 registration.user_id,
509 )
510 .await?
511 .and_then(|link| link.sisu_person_id),
512 };
513 let linking_emails = match sisu_person_id {
514 Some(person_id) => {
515 let mails = credit_registration_account_linking_emails::get_by_sisu_person_id(
516 &mut conn,
517 person_id.expose_secret(),
518 )
519 .await?;
520 build_linking_emails(&mut conn, mails).await?
521 }
522 None => Vec::new(),
523 };
524
525 let notification_emails = student_notifications::get_for_registrations(&mut conn, &[id])
526 .await?
527 .into_iter()
528 .map(
529 |mail: RegistrationNotificationEmail| AdminNotificationEmail {
530 kind: mail.kind,
531 email_delivery_id: mail.email_delivery_id,
532 send_status: mail.send_status,
533 },
534 )
535 .collect();
536
537 let not_improved_attainment =
538 models::credit_registration_events::get_not_improved_attainment(&mut conn, id).await?;
539
540 token.authorized_ok(web::Json(AdminCreditRegistrationDetails {
541 attention_thresholds: AdminAttentionThresholds::CURRENT,
542 registration: to_admin_row(registration),
543 attempts,
544 events,
545 suotar_api_calls,
546 actions,
547 linking_emails,
548 notification_emails,
549 not_improved_attainment,
550 }))
551}
552
553#[instrument(skip(pool, payload))]
563#[utoipa::path(
564 post,
565 path = "/registrations/{credit_registration_id}/transition",
566 operation_id = "adminTransitionCreditRegistration",
567 tag = "credit-registration-admin",
568 params(("credit_registration_id" = Uuid, Path, description = "Credit registration id")),
569 request_body = AdminTransitionCreditRegistrationPayload,
570 responses(
571 (status = 200, description = "What the transition did", body = AdminTransitionCreditRegistrationResult),
572 (status = 422, description = "No reason given"),
573 (status = 404, description = "No such registration")
574 )
575)]
576pub async fn admin_transition_credit_registration(
577 user: AuthUser,
578 pool: web::Data<PgPool>,
579 credit_registration_id: web::Path<Uuid>,
580 payload: web::Json<AdminTransitionCreditRegistrationPayload>,
581) -> ControllerResult<web::Json<AdminTransitionCreditRegistrationResult>> {
582 let mut conn = pool.acquire().await?;
583 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
584
585 let reason = required_reason(&payload.reason)?;
586 let id = *credit_registration_id;
587 let row = credit_registrations::get_by_id(&mut conn, id).await?;
588 if row.superseded_by_id.is_some() {
589 return Err(controller_err!(
590 BadRequest,
591 "This attempt has been replaced by a later one. Act on the later one.".to_string()
592 ));
593 }
594
595 if let Some(refusal) = payload
598 .action
599 .refusal(&row.resubmission_facts(), ResubmissionStrictness::Any)
600 {
601 return token.authorized_ok(web::Json(AdminTransitionCreditRegistrationResult {
602 outcome: AdminTransitionOutcome::Refused,
603 refusal: Some(refusal),
604 state: row.state,
605 needs_admin_attention: row.needs_admin_attention,
606 }));
607 }
608
609 let mut tx = conn.begin().await?;
610 let applied = apply_transition(&mut tx, &row, payload.action, user.id, reason).await?;
611 if applied.needs_due_now {
612 credit_registrations::make_due_now_batch(
613 &mut tx,
614 &[id],
615 EnrolmentCheckSource::AdminRequest,
616 )
617 .await?;
618 }
619 models::credit_registration_admin_actions::record(
620 &mut tx,
621 &NewCreditRegistrationAdminAction {
622 target_id: Some(id),
623 reason: Some(reason.to_string()),
624 before_state: Some(row.state),
625 after_state: Some(applied.state),
626 details: Some(serde_json::json!({ "outcome": applied.outcome })),
627 affected_row_count: Some(1),
628 ..NewCreditRegistrationAdminAction::new(
629 CreditRegistrationAdminAction::TransitionItem,
630 CreditRegistrationAdminActionTarget::CreditRegistration,
631 user.id,
632 GLOBAL_ADMIN_ROLE,
633 )
634 },
635 )
636 .await?;
637 tx.commit().await?;
638
639 token.authorized_ok(web::Json(AdminTransitionCreditRegistrationResult {
640 outcome: applied.outcome,
641 refusal: None,
642 state: applied.state,
643 needs_admin_attention: applied.needs_admin_attention,
644 }))
645}
646
647#[instrument(skip(pool, payload))]
659#[utoipa::path(
660 post,
661 path = "/registrations/bulk-transition",
662 operation_id = "adminBulkTransitionCreditRegistrations",
663 tag = "credit-registration-admin",
664 request_body = AdminBulkTransitionPayload,
665 responses(
666 (status = 200, description = "What each selected row did", body = AdminBulkTransitionResult),
667 (status = 422, description = "No reason given, or more ids than one call may take")
668 )
669)]
670pub async fn admin_bulk_transition_credit_registrations(
671 user: AuthUser,
672 pool: web::Data<PgPool>,
673 payload: web::Json<AdminBulkTransitionPayload>,
674) -> ControllerResult<web::Json<AdminBulkTransitionResult>> {
675 let mut conn = pool.acquire().await?;
676 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
677
678 let reason = required_reason(&payload.reason)?;
679 if payload.credit_registration_ids.len() as i64 > MAX_ROWS_PER_BULK_TRANSITION {
680 return Err(controller_err!(
681 BadRequest,
682 format!("At most {MAX_ROWS_PER_BULK_TRANSITION} registrations per call.")
683 ));
684 }
685
686 let ids: Vec<Uuid> = payload
689 .credit_registration_ids
690 .iter()
691 .copied()
692 .collect::<HashSet<_>>()
693 .into_iter()
694 .collect();
695
696 let mut tx = conn.begin().await?;
697 let rows = credit_registrations::get_by_ids_for_update(&mut tx, &ids).await?;
701
702 let mut applied_count = 0;
703 let mut due_now_ids = Vec::new();
704 let mut skipped: HashMap<ResubmissionRefusal, i64> = HashMap::new();
705 for row in &rows {
706 match payload.action.refusal(
707 &row.resubmission_facts(),
708 ResubmissionStrictness::AnyExceptSubmissionUncertain,
709 ) {
710 Some(refusal) => *skipped.entry(refusal).or_insert(0) += 1,
711 None => {
712 let applied =
713 apply_transition(&mut tx, row, payload.action, user.id, reason).await?;
714 if applied.needs_due_now {
715 due_now_ids.push(row.id);
716 }
717 applied_count += 1;
718 }
719 }
720 }
721 credit_registrations::make_due_now_batch(
724 &mut tx,
725 &due_now_ids,
726 EnrolmentCheckSource::AdminRequest,
727 )
728 .await?;
729 let mut skipped: Vec<AdminBulkTransitionSkipCount> = skipped
730 .into_iter()
731 .map(|(refusal, count)| AdminBulkTransitionSkipCount { refusal, count })
732 .collect();
733 skipped.sort_by_key(|skip| std::cmp::Reverse(skip.count));
734
735 models::credit_registration_admin_actions::record(
736 &mut tx,
737 &NewCreditRegistrationAdminAction {
738 reason: Some(reason.to_string()),
739 details: Some(serde_json::json!({
740 "action": payload.action,
741 "credit_registration_ids": payload.credit_registration_ids,
742 "skipped": skipped,
743 })),
744 affected_row_count: Some(applied_count),
745 ..NewCreditRegistrationAdminAction::new(
746 CreditRegistrationAdminAction::TransitionItem,
747 CreditRegistrationAdminActionTarget::CreditRegistration,
748 user.id,
749 GLOBAL_ADMIN_ROLE,
750 )
751 },
752 )
753 .await?;
754 tx.commit().await?;
755
756 token.authorized_ok(web::Json(AdminBulkTransitionResult {
757 applied_count: i64::from(applied_count),
758 skipped,
759 not_found_count: ids.len() as i64 - rows.len() as i64,
760 max_rows_per_call: MAX_ROWS_PER_BULK_TRANSITION,
761 }))
762}
763
764#[instrument(skip(pool, payload))]
772#[utoipa::path(
773 post,
774 path = "/registrations/requeue-retryable",
775 operation_id = "adminRequeueRetryableCreditRegistrations",
776 tag = "credit-registration-admin",
777 request_body = AdminRequeueRetryablePayload,
778 responses(
779 (status = 200, description = "How many were made due", body = AdminRequeueRetryableResult),
780 (status = 422, description = "No reason given")
781 )
782)]
783pub async fn admin_requeue_retryable_credit_registrations(
784 user: AuthUser,
785 pool: web::Data<PgPool>,
786 payload: web::Json<AdminRequeueRetryablePayload>,
787) -> ControllerResult<web::Json<AdminRequeueRetryableResult>> {
788 let mut conn = pool.acquire().await?;
789 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
790
791 let reason = required_reason(&payload.reason)?;
792
793 let mut tx = conn.begin().await?;
794 let requeued_count = credit_registrations::requeue_retryable_now(
795 &mut tx,
796 payload.course_id,
797 payload.course_module_id,
798 MAX_ROWS_PER_REQUEUE,
799 )
800 .await?;
801 models::credit_registration_admin_actions::record(
802 &mut tx,
803 &NewCreditRegistrationAdminAction {
804 target_id: payload.course_id,
805 reason: Some(reason.to_string()),
806 details: Some(serde_json::json!({
807 "course_id": payload.course_id,
808 "course_module_id": payload.course_module_id,
809 })),
810 affected_row_count: Some(i32::try_from(requeued_count).unwrap_or(i32::MAX)),
811 ..NewCreditRegistrationAdminAction::new(
812 CreditRegistrationAdminAction::RequeueBatch,
813 match payload.course_id {
814 Some(_) => CreditRegistrationAdminActionTarget::Course,
815 None => CreditRegistrationAdminActionTarget::CreditRegistration,
816 },
817 user.id,
818 GLOBAL_ADMIN_ROLE,
819 )
820 },
821 )
822 .await?;
823 tx.commit().await?;
824
825 token.authorized_ok(web::Json(AdminRequeueRetryableResult {
826 requeued_count,
827 max_rows_per_call: MAX_ROWS_PER_REQUEUE,
828 }))
829}
830
831struct AppliedHandAction {
833 outcome: AdminTransitionOutcome,
834 state: CreditRegistrationState,
836 needs_admin_attention: bool,
837 needs_due_now: bool,
839}
840
841async fn apply_transition(
848 tx: &mut PgConnection,
849 row: &credit_registrations::CreditRegistration,
850 action: AdminCreditRegistrationAction,
851 actor_user_id: Uuid,
852 reason: &str,
853) -> Result<AppliedHandAction, ControllerError> {
854 let id = row.id;
855 Ok(match action {
856 AdminCreditRegistrationAction::ClearNeedsAdminAttention => {
857 if !row.needs_admin_attention {
858 AppliedHandAction {
859 outcome: AdminTransitionOutcome::NoChange,
860 state: row.state,
861 needs_admin_attention: false,
862 needs_due_now: false,
863 }
864 } else {
865 credit_registrations::set_needs_admin_attention(
866 tx,
867 id,
868 credit_registrations::AdminAttention::Clear,
869 )
870 .await?;
871 insert_admin_action_event(tx, id, actor_user_id, reason).await?;
872 AppliedHandAction {
873 outcome: AdminTransitionOutcome::Applied,
874 state: row.state,
875 needs_admin_attention: false,
876 needs_due_now: false,
877 }
878 }
879 }
880 AdminCreditRegistrationAction::CheckNow => {
881 insert_admin_action_event(tx, id, actor_user_id, reason).await?;
882 AppliedHandAction {
883 outcome: AdminTransitionOutcome::Applied,
884 state: row.state,
885 needs_admin_attention: row.needs_admin_attention,
886 needs_due_now: true,
887 }
888 }
889 AdminCreditRegistrationAction::StateMove { to_state } => {
890 let to_state = to_state.to_state();
891 let after = credit_registrations::transition(
892 tx,
893 id,
894 &Transition {
895 needs_admin_attention: Some(credit_registrations::AdminAttention::Clear),
896 event_kind: CreditRegistrationEventKind::AdminAction,
897 event_message: Some(reason.to_string()),
898 actor_user_id: Some(actor_user_id),
899 expected_from_state: Some(row.state),
903 ..Transition::by_hand(to_state)
904 },
905 )
906 .await?;
907 AppliedHandAction {
910 outcome: AdminTransitionOutcome::Applied,
911 state: after.state,
912 needs_admin_attention: after.needs_admin_attention,
913 needs_due_now: !after.state.is_terminal(),
914 }
915 }
916 })
917}
918
919async fn insert_admin_action_event(
922 tx: &mut PgConnection,
923 id: Uuid,
924 actor_user_id: Uuid,
925 reason: &str,
926) -> Result<(), ControllerError> {
927 models::credit_registration_events::insert(
928 tx,
929 &models::credit_registration_events::NewCreditRegistrationEvent {
930 actor_user_id: Some(actor_user_id),
931 message: Some(reason.to_string()),
932 ..models::credit_registration_events::NewCreditRegistrationEvent::new(
933 id,
934 CreditRegistrationEventKind::AdminAction,
935 )
936 },
937 )
938 .await?;
939 Ok(())
940}
941
942async fn one_admin_row(
943 conn: &mut PgConnection,
944 id: Uuid,
945) -> Result<Option<AdminCreditRegistration>, ControllerError> {
946 let rows = credit_registrations::get_admin_facing(
947 conn,
948 &AdminCreditRegistrationFilters {
949 id: Some(id),
950 include_superseded: true,
951 ..AdminCreditRegistrationFilters::default()
952 },
953 AdminCreditRegistrationSort::default(),
954 MAX_RELATED_ROWS,
955 0,
956 )
957 .await?;
958 Ok(rows.into_iter().next())
959}
960
961fn to_admin_row(row: AdminCreditRegistration) -> AdminCreditRegistrationRow {
962 AdminCreditRegistrationRow {
963 superseded: row.superseded_by_id.is_some(),
964 is_waiting_for_enrolment: row.is_waiting_for_enrolment(),
965 pending_reason: row.pending_reason(),
966 hand_actions: row
967 .resubmission_facts()
968 .hand_actions(ResubmissionStrictness::Any),
969 id: row.id,
970 created_at: row.created_at,
971 user_id: row.user_id,
972 first_name: row.first_name,
973 last_name: row.last_name,
974 email: row.email,
975 course_id: row.course_id,
976 course_name: row.course_name,
977 course_module_id: row.course_module_id,
978 course_module_name: row.course_module_name,
979 course_instance_id: row.course_instance_id,
980 course_module_completion_id: row.course_module_completion_id,
981 completion_date: row.completion_date,
982 state: row.state,
983 state_entered_at: row.state_entered_at,
984 error_code: row.error_code,
985 needs_admin_attention: row.needs_admin_attention,
986 next_attempt_at: row.next_attempt_at,
987 last_attempt_at: row.last_attempt_at,
988 submitted_at: row.submitted_at,
989 registered_at: row.registered_at,
990 terminal_at: row.terminal_at,
991 partially_registered_at: row.partially_registered_at,
992 resubmit_not_before: row.resubmit_not_before,
993 not_registered_reimport_count: row.not_registered_reimport_count,
994 no_usable_enrolment_since: row.no_usable_enrolment_since,
995 enrolment_checked_at: row.enrolment_checked_at,
996 enrolment_check_due_at: row.enrolment_check_due_at,
997 enrolment_checks_stopped_at: row.enrolment_checks_stopped_at,
998 student_number: expose_option(&row.student_number).map(str::to_owned),
999 sisu_person_id: expose_option(&row.sisu_person_id).map(str::to_owned),
1000 uh_course_code: row.uh_course_code,
1001 selected_enrolment_id: row.selected_enrolment_id,
1002 grade_scale_id: row.grade_scale_id,
1003 grade_id: row.grade_id,
1004 credits: row.credits,
1005 submitted_attainment_id: row.submitted_attainment_id,
1006 sisu_attainment_id: row.sisu_attainment_id,
1007 submit_retry_count: row.submit_retry_count,
1008 verify_attempt_count: row.verify_attempt_count,
1009 attempt_number: row.attempt_number,
1010 superseded_by_id: row.superseded_by_id,
1011 verified_student_number: expose_option(&row.verified_student_number).map(str::to_owned),
1012 verified_student_number_at: row.verified_student_number_at,
1013 verified_student_number_via: row.verified_student_number_via,
1014 }
1015}
1016
1017fn to_admin_api_call(call: models::suotar_api_calls::SuotarApiCall) -> AdminSuotarApiCall {
1018 AdminSuotarApiCall {
1019 id: call.id,
1020 endpoint: call.endpoint,
1021 started_at: call.started_at,
1022 duration_ms: call.duration_ms,
1023 http_status: call.http_status,
1024 succeeded: call.succeeded,
1025 request_level_error_code: call.request_level_error_code,
1026 worker_name: call.worker_name,
1027 }
1028}
1029
1030pub fn _add_routes(cfg: &mut ServiceConfig) {
1031 cfg.route(
1032 "/registrations",
1033 web::get().to(list_credit_registrations_for_admin),
1034 )
1035 .route(
1036 "/registrations/{credit_registration_id}",
1037 web::get().to(get_credit_registration_for_admin),
1038 )
1039 .route(
1040 "/registrations/bulk-transition",
1041 web::post().to(admin_bulk_transition_credit_registrations),
1042 )
1043 .route(
1044 "/registrations/requeue-retryable",
1045 web::post().to(admin_requeue_retryable_credit_registrations),
1046 )
1047 .route(
1048 "/registrations/{credit_registration_id}/transition",
1049 web::post().to(admin_transition_credit_registration),
1050 );
1051}