1use headless_lms_base::config::ApplicationConfiguration;
4use headless_lms_models::credit_registration_account_linking_emails;
5use headless_lms_models::credit_registration_admin_actions::{
6 CreditRegistrationAdminAction, CreditRegistrationAdminActionFilters,
7 CreditRegistrationAdminActionRecord, CreditRegistrationAdminActionTarget, GLOBAL_ADMIN_ROLE,
8 NewCreditRegistrationAdminAction,
9};
10use headless_lms_models::credit_registration_enrolment_routes::CreditRegistrationEnrolmentRoute;
11use headless_lms_models::credit_registration_events::{
12 CreditRegistrationEventKind, NotImprovedAttainment, SuotarAnswer,
13};
14use headless_lms_models::credit_registration_roster_schedules::{self, ScheduleSelection};
15use headless_lms_models::credit_registrations::{
16 self, AdminCreditRegistration, AdminCreditRegistrationFilters, AdminCreditRegistrationSort,
17 AttentionReason, AttentionStanding, BlockingProblem, CreditRegistrationErrorCode,
18 CreditRegistrationState, HandActionAvailability, ResubmissionFacts, ResubmissionRefusal,
19 ResubmissionStrictness, Transition,
20};
21use headless_lms_models::email_deliveries::EmailSendStatusReport;
22use headless_lms_models::library::credit_registration::CreditRegistrationPendingReason;
23use headless_lms_models::library::credit_registration::backoff::{
24 NOT_REGISTERED_REIMPORT_ADMIN_THRESHOLD, PARTIAL_REGISTRATION_ADMIN_AFTER,
25 UNCERTAIN_ADMIN_AFTER, VERIFY_MAX_AGE,
26};
27use headless_lms_models::library::credit_registration::enrolment_check_schedule::EnrolmentCheckSource;
28use headless_lms_models::library::credit_registration::student_notifications::{
29 self, CreditRegistrationNotificationKind, RegistrationNotificationEmail,
30};
31use headless_lms_models::library::credit_registration::timeline::{
32 Engagement, TimelinePhase, TimelineStep, WaitsOn,
33};
34use headless_lms_models::suotar_api_calls;
35use headless_lms_models::verified_student_numbers::{self, StudentNumberVerificationMethod};
36use std::collections::{HashMap, HashSet};
37use utoipa::ToSchema;
38
39use crate::prelude::*;
40use headless_lms_utils::secret_string::expose_option;
41use secrecy::{ExposeSecret, SecretString};
42
43use super::{
44 AdminLinkingEmail, AdminManualLinkingEmailSend, attention_rules,
45 authorize_credit_registration_admin, build_linking_emails, insert_admin_action_event,
46 manual_linking_email_sends_by_registration, required_reason,
47};
48
49#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
50pub struct AdminCreditRegistrationRow {
51 pub id: Uuid,
52 pub created_at: DateTime<Utc>,
53 pub user_id: Uuid,
54 pub first_name: Option<String>,
55 pub last_name: Option<String>,
56 pub email: Option<String>,
58 pub course_id: Uuid,
59 pub course_name: String,
60 pub course_module_id: Uuid,
61 pub course_module_name: Option<String>,
62 pub course_instance_id: Uuid,
63 pub course_module_completion_id: Uuid,
64 pub completion_date: DateTime<Utc>,
65 pub state: CreditRegistrationState,
66 pub pending_reason: Option<CreditRegistrationPendingReason>,
69 pub timeline_step: TimelineStep,
70 pub phase: TimelinePhase,
71 pub waits_on: WaitsOn,
72 pub engagement: Option<Engagement>,
74 pub phase_started_at: DateTime<Utc>,
76 pub state_changed_at: DateTime<Utc>,
78 pub state_entered_at: DateTime<Utc>,
81 pub attention_reasons: Vec<AttentionReason>,
83 pub attention_standing: Option<AttentionStanding>,
85 pub pressed_at: Option<DateTime<Utc>>,
87 pub enrolment_route: Option<CreditRegistrationEnrolmentRoute>,
88 pub last_visited_at: Option<DateTime<Utc>>,
89 pub error_code: Option<CreditRegistrationErrorCode>,
90 pub needs_admin_attention: bool,
91 pub next_attempt_at: DateTime<Utc>,
92 pub last_attempt_at: Option<DateTime<Utc>>,
93 pub submitted_at: Option<DateTime<Utc>>,
94 pub registered_at: Option<DateTime<Utc>>,
95 pub terminal_at: Option<DateTime<Utc>>,
96 pub partially_registered_at: Option<DateTime<Utc>>,
98 pub resubmit_not_before: Option<DateTime<Utc>>,
100 pub not_registered_reimport_count: i32,
102 pub is_waiting_for_enrolment: bool,
103 pub no_usable_enrolment_since: Option<DateTime<Utc>>,
104 pub enrolment_checked_at: Option<DateTime<Utc>>,
105 pub enrolment_check_due_at: Option<DateTime<Utc>>,
107 pub enrolment_checks_stopped_at: Option<DateTime<Utc>>,
108 pub student_number: Option<String>,
110 pub sisu_person_id: Option<String>,
111 pub uh_course_code: Option<String>,
112 pub selected_enrolment_id: Option<String>,
113 pub grade_scale_id: Option<String>,
114 pub grade_id: Option<String>,
115 pub credits: Option<f32>,
116 pub submitted_attainment_id: Option<String>,
117 pub sisu_attainment_id: Option<String>,
118 pub submit_retry_count: i32,
119 pub verify_attempt_count: i32,
120 pub attempt_number: i32,
121 pub superseded: bool,
122 pub superseded_by_id: Option<Uuid>,
123 pub hand_actions: HandActionAvailability,
125 pub verified_student_number: Option<String>,
127 pub verified_student_number_at: Option<DateTime<Utc>>,
128 pub verified_student_number_via: Option<StudentNumberVerificationMethod>,
130}
131
132#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
133pub struct AdminCreditRegistrationEvent {
134 pub id: Uuid,
135 pub credit_registration_id: Uuid,
137 pub created_at: DateTime<Utc>,
138 pub kind: CreditRegistrationEventKind,
139 pub from_state: Option<CreditRegistrationState>,
140 pub to_state: Option<CreditRegistrationState>,
141 pub error_code: Option<CreditRegistrationErrorCode>,
142 pub message: Option<String>,
144 pub actor_user_id: Option<Uuid>,
145 pub suotar_api_call_id: Option<Uuid>,
146 pub details: Option<serde_json::Value>,
149 pub request_item_id: Option<String>,
151 pub suotar_endpoint: Option<suotar_api_calls::SuotarEndpoint>,
152 pub suotar_requested_at: Option<DateTime<Utc>>,
153 pub suotar_answered_at: Option<DateTime<Utc>>,
154 pub suotar_answer: Option<SuotarAnswer>,
155 pub suotar_code: Option<String>,
158}
159
160#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
161pub struct AdminSuotarApiCall {
162 pub id: Uuid,
163 pub endpoint: suotar_api_calls::SuotarEndpoint,
164 pub started_at: DateTime<Utc>,
165 pub duration_ms: Option<i32>,
166 pub http_status: Option<i32>,
167 pub succeeded: bool,
168 pub request_level_error_code: Option<String>,
169 pub worker_name: String,
170}
171
172#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
175pub struct AdminNotificationEmail {
176 pub credit_registration_id: Uuid,
178 pub kind: CreditRegistrationNotificationKind,
179 pub email_delivery_id: Uuid,
182 pub send_status: EmailSendStatusReport,
183}
184
185#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy, ToSchema)]
187pub struct AdminAttentionThresholds {
188 pub partial_registration_secs: i64,
189 pub uncertain_secs: i64,
190 pub verify_window_secs: i64,
191 pub not_registered_reimports: i32,
192}
193
194impl AdminAttentionThresholds {
195 pub const CURRENT: Self = Self {
196 partial_registration_secs: PARTIAL_REGISTRATION_ADMIN_AFTER.num_seconds(),
197 uncertain_secs: UNCERTAIN_ADMIN_AFTER.num_seconds(),
198 verify_window_secs: VERIFY_MAX_AGE.num_seconds(),
199 not_registered_reimports: NOT_REGISTERED_REIMPORT_ADMIN_THRESHOLD,
200 };
201}
202
203#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
205pub struct AdminCreditRegistrationJourney {
206 pub course_started_at: Option<DateTime<Utc>>,
208 pub first_visited_at: Option<DateTime<Utc>>,
211 pub last_visited_at: Option<DateTime<Utc>>,
213 pub last_check_requested_at: Option<DateTime<Utc>>,
214 pub check_request_source: Option<EnrolmentCheckSource>,
215 pub enrolment_route: Option<CreditRegistrationEnrolmentRoute>,
217 pub pressed_at: Option<DateTime<Utc>>,
219 pub sisu_enrolled_at: Option<DateTime<Utc>>,
221}
222
223#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
226pub struct AdminLinkingSchedule {
227 pub course_code: String,
228 pub last_fetched_at: Option<DateTime<Utc>>,
229 pub last_mailing_fetch_started_at: Option<DateTime<Utc>>,
231 pub next_fetch_at: DateTime<Utc>,
232 pub is_fetch_failing: bool,
233 pub is_enrolment_list_empty: bool,
234 pub linking_emails_since_press: i64,
236 pub last_linking_email_at: Option<DateTime<Utc>>,
237}
238
239#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
241pub struct AdminRegistrationAttention {
242 pub reasons: Vec<AttentionReason>,
243 pub standing: AttentionStanding,
244 pub blocking_problem: Option<BlockingProblem>,
245 pub dismissed_reasons: Option<Vec<AttentionReason>>,
247 pub dismissed_at: Option<DateTime<Utc>>,
248 pub dismissed_by_user_id: Option<Uuid>,
249 pub dismissed_by_first_name: Option<String>,
250 pub dismissed_by_last_name: Option<String>,
251 pub dismissal_reason: Option<String>,
252}
253
254#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
255pub struct AdminCreditRegistrationDetails {
256 pub attention_thresholds: AdminAttentionThresholds,
257 pub registration: AdminCreditRegistrationRow,
258 pub attention: Option<AdminRegistrationAttention>,
260 pub journey: AdminCreditRegistrationJourney,
262 pub linking_schedule: Option<AdminLinkingSchedule>,
265 pub attempts: Vec<AdminCreditRegistrationRow>,
267 pub events: Vec<AdminCreditRegistrationEvent>,
269 pub suotar_api_calls: Vec<AdminSuotarApiCall>,
271 pub actions: Vec<CreditRegistrationAdminActionRecord>,
273 pub linking_emails: Vec<AdminLinkingEmail>,
275 pub manual_linking_email_sends: Vec<AdminManualLinkingEmailSend>,
277 pub notification_emails: Vec<AdminNotificationEmail>,
280 pub not_improved_attainment: Option<NotImprovedAttainment>,
283}
284
285#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
287#[serde(rename_all = "snake_case")]
288pub enum AdminCreditRegistrationStateMove {
289 ReadyToSubmit,
292 Cancelled,
293}
294
295impl AdminCreditRegistrationStateMove {
296 fn to_state(self) -> CreditRegistrationState {
297 match self {
298 Self::ReadyToSubmit => CreditRegistrationState::ReadyToSubmit,
299 Self::Cancelled => CreditRegistrationState::Cancelled,
300 }
301 }
302}
303
304#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
307#[serde(rename_all = "snake_case", tag = "kind")]
308pub enum AdminCreditRegistrationAction {
309 StateMove {
310 to_state: AdminCreditRegistrationStateMove,
311 },
312 ClearNeedsAdminAttention,
314 CheckNow,
317}
318
319impl AdminCreditRegistrationAction {
320 fn refusal(
322 self,
323 facts: &ResubmissionFacts,
324 strictness: ResubmissionStrictness,
325 ) -> Option<ResubmissionRefusal> {
326 match self {
327 Self::StateMove { to_state } => {
328 facts.admin_transition_refusal(to_state.to_state(), strictness)
329 }
330 Self::CheckNow => facts.check_now_refusal(),
331 Self::ClearNeedsAdminAttention if facts.is_superseded => {
333 Some(ResubmissionRefusal::Superseded)
334 }
335 Self::ClearNeedsAdminAttention => None,
336 }
337 }
338}
339
340#[derive(Debug, Deserialize, ToSchema)]
341pub struct AdminTransitionCreditRegistrationPayload {
342 pub action: AdminCreditRegistrationAction,
343 pub reason: String,
344}
345
346#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
347#[serde(rename_all = "snake_case")]
348pub enum AdminTransitionOutcome {
349 Applied,
350 Refused,
352 NoChange,
353}
354
355#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
356pub struct AdminTransitionCreditRegistrationResult {
357 pub outcome: AdminTransitionOutcome,
358 pub refusal: Option<ResubmissionRefusal>,
360 pub state: CreditRegistrationState,
361 pub needs_admin_attention: bool,
362}
363
364const MAX_ROWS_PER_BULK_TRANSITION: i64 = 500;
366const MAX_ROWS_PER_REQUEUE: i64 = 5_000;
367const MAX_RELATED_ROWS: i64 = u8::MAX as i64;
370
371#[derive(Debug, Deserialize, ToSchema)]
372pub struct AdminBulkTransitionPayload {
373 pub action: AdminCreditRegistrationAction,
374 pub credit_registration_ids: Vec<Uuid>,
375 pub reason: String,
376}
377
378#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
379pub struct AdminBulkTransitionSkipCount {
380 pub refusal: ResubmissionRefusal,
381 pub count: i64,
382}
383
384#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
385pub struct AdminBulkTransitionResult {
386 pub applied_count: i64,
387 pub skipped: Vec<AdminBulkTransitionSkipCount>,
388 pub not_found_count: i64,
390 pub max_rows_per_call: i64,
391}
392
393#[derive(Debug, Deserialize, ToSchema)]
394pub struct AdminRequeueRetryablePayload {
395 pub course_id: Option<Uuid>,
396 pub course_module_id: Option<Uuid>,
397 pub reason: String,
398}
399
400#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
401pub struct AdminRequeueRetryableResult {
402 pub requeued_count: i64,
403 pub max_rows_per_call: i64,
404}
405
406#[derive(Debug, Deserialize)]
407pub struct ListCreditRegistrationsQuery {
408 page: Option<u32>,
409 limit: Option<u32>,
410 state: Option<Vec<CreditRegistrationState>>,
411 error_code: Option<Vec<CreditRegistrationErrorCode>>,
412 course_id: Option<Uuid>,
413 course_module_id: Option<Uuid>,
414 user_id: Option<Uuid>,
415 student_number: Option<SecretString>,
416 step: Option<Vec<TimelineStep>>,
417 engagement: Option<Vec<Engagement>>,
418 include_not_started: Option<bool>,
419 needs_attention: Option<bool>,
420 attention_reason: Option<Vec<AttentionReason>>,
421 submitted_after: Option<DateTime<Utc>>,
422 submitted_before: Option<DateTime<Utc>>,
423 search: Option<SecretString>,
424 include_superseded: Option<bool>,
425 sort: Option<String>,
426}
427
428#[instrument(skip(pool, app_conf))]
433#[utoipa::path(
434 get,
435 path = "/registrations",
436 operation_id = "listCreditRegistrationsForAdmin",
437 tag = "credit-registration-admin",
438 params(
439 ("page" = Option<u32>, Query, description = "Page number, from 1"),
440 ("limit" = Option<u32>, Query, description = "Rows per page"),
441 ("state" = Option<Vec<CreditRegistrationState>>, Query, description = "Ledger states; repeat the parameter for several"),
442 ("error_code" = Option<Vec<CreditRegistrationErrorCode>>, Query, description = "Error codes; repeat the parameter for several"),
443 ("course_id" = Option<Uuid>, Query, description = "Course filter"),
444 ("course_module_id" = Option<Uuid>, Query, description = "Course module filter"),
445 ("user_id" = Option<Uuid>, Query, description = "Student filter"),
446 ("student_number" = Option<String>, Query, description = "Exact student number, frozen on the row or linked to the account"),
447 ("step" = Option<Vec<TimelineStep>>, Query, description = "Timeline steps; repeat the parameter for several. A row waiting for a student number matches only if it completed since account linking began, as the overview counts it"),
448 ("engagement" = Option<Vec<Engagement>>, Query, description = "Only rows at a step that waits on the student whose student did this; repeat for several"),
449 ("include_not_started" = Option<bool>, Query, description = "Include rows at a step that waits on the student whose student has not started; left out by default"),
450 ("needs_attention" = Option<bool>, Query, description = "Only rows counted in Needs attention"),
451 ("attention_reason" = Option<Vec<AttentionReason>>, Query, description = "Only rows the Needs attention queue picks for one of these reasons, whatever their section; repeat for several"),
452 ("submitted_after" = Option<DateTime<Utc>>, Query, description = "Submitted at or after"),
453 ("submitted_before" = Option<DateTime<Utc>>, Query, description = "Submitted at or before"),
454 ("search" = Option<String>, Query, description = "Name, email, student number, attainment id, stored error text, or a uuid"),
455 ("include_superseded" = Option<bool>, Query, description = "Include replaced attempts"),
456 ("sort" = Option<String>, Query, description = "last_activity (the default; rows counted in Needs attention first), created, time_in_state or attempts")
457 ),
458 responses(
459 (status = 200, description = "A page of the ledger", body = Page<AdminCreditRegistrationRow>)
460 )
461)]
462pub async fn list_credit_registrations_for_admin(
463 user: AuthUser,
464 pool: web::Data<PgPool>,
465 query: MultiQuery<ListCreditRegistrationsQuery>,
466 app_conf: web::Data<ApplicationConfiguration>,
467) -> ControllerResult<web::Json<Page<AdminCreditRegistrationRow>>> {
468 let mut conn = pool.acquire().await?;
469 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
470
471 let pagination = parse_pagination(query.page, query.limit, 50)?;
472 let search = non_empty(expose_option(&query.search));
473 let student_number = non_empty(expose_option(&query.student_number));
474 let sort = match query.sort.as_deref() {
475 Some("created") => AdminCreditRegistrationSort::Created,
476 Some("time_in_state") => AdminCreditRegistrationSort::TimeInState,
477 Some("attempts") => AdminCreditRegistrationSort::Attempts,
478 _ => AdminCreditRegistrationSort::LastActivity,
479 };
480 let only_needs_attention = query.needs_attention.unwrap_or(false);
481 let attention_reasons: &[AttentionReason] =
482 query.attention_reason.as_deref().unwrap_or_default();
483 let is_selected_by_attention = only_needs_attention || !attention_reasons.is_empty();
484 let whole_queue =
485 if is_selected_by_attention || sort == AdminCreditRegistrationSort::LastActivity {
486 Some(AttentionLookup::load(&mut conn, &app_conf, None).await?)
487 } else {
488 None
489 };
490 let needs_attention_ids = whole_queue
491 .as_ref()
492 .filter(|_| sort == AdminCreditRegistrationSort::LastActivity)
493 .map(|queue| queue.ids(|row| row.standing == AttentionStanding::NeedsAttention));
494 let selected_ids = whole_queue
495 .as_ref()
496 .filter(|_| is_selected_by_attention)
497 .map(|queue| {
498 queue.ids(|row| {
499 (!only_needs_attention || row.standing == AttentionStanding::NeedsAttention)
500 && (attention_reasons.is_empty()
501 || row
502 .reasons
503 .iter()
504 .any(|reason| attention_reasons.contains(reason)))
505 })
506 });
507 let filters = AdminCreditRegistrationFilters {
508 states: query.state.as_deref(),
509 error_codes: query.error_code.as_deref(),
510 course_id: query.course_id,
511 course_module_id: query.course_module_id,
512 user_id: query.user_id,
513 student_number,
514 steps: query.step.as_deref().unwrap_or_default(),
515 engagements: query.engagement.as_deref().unwrap_or_default(),
516 hide_not_started: !query.include_not_started.unwrap_or(false),
517 account_linking_since: app_conf.suotar_configuration.account_linking_since,
518 first_ids: needs_attention_ids.as_deref(),
519 credit_registration_ids: selected_ids.as_deref(),
520 submitted_after: query.submitted_after,
521 submitted_before: query.submitted_before,
522 search,
523 search_id: search.and_then(|search| Uuid::parse_str(search).ok()),
524 include_superseded: query.include_superseded.unwrap_or(false),
525 ..AdminCreditRegistrationFilters::default()
526 };
527
528 let rows = credit_registrations::get_admin_facing(
529 &mut conn,
530 &filters,
531 sort,
532 pagination.limit(),
533 pagination.offset(),
534 )
535 .await?;
536 let total_count = rows.first().map_or(0, |row| row.total_count);
537 let attention = match whole_queue {
538 Some(queue) => queue,
539 None => {
540 let page_ids: Vec<Uuid> = rows.iter().map(|row| row.id).collect();
541 AttentionLookup::load(&mut conn, &app_conf, Some(&page_ids)).await?
542 }
543 };
544 let data = rows
545 .into_iter()
546 .map(|row| to_admin_row(row, &attention))
547 .collect();
548
549 token.authorized_ok(web::Json(Page::new(pagination, data, total_count)))
550}
551
552#[instrument(skip(pool, app_conf))]
558#[utoipa::path(
559 get,
560 path = "/registrations/{credit_registration_id}",
561 operation_id = "getCreditRegistrationForAdmin",
562 tag = "credit-registration-admin",
563 params(("credit_registration_id" = Uuid, Path, description = "Credit registration id")),
564 responses(
565 (status = 200, description = "The row and everything that happened to it", body = AdminCreditRegistrationDetails),
566 (status = 404, description = "No such registration")
567 )
568)]
569pub async fn get_credit_registration_for_admin(
570 user: AuthUser,
571 pool: web::Data<PgPool>,
572 credit_registration_id: web::Path<Uuid>,
573 app_conf: web::Data<ApplicationConfiguration>,
574) -> ControllerResult<web::Json<AdminCreditRegistrationDetails>> {
575 let mut conn = pool.acquire().await?;
576 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
577
578 let id = *credit_registration_id;
579 let registration = one_admin_row(&mut conn, id)
580 .await?
581 .ok_or_else(|| controller_err!(NotFound, "Not found.".to_string()))?;
582 let attempts = credit_registrations::get_admin_facing(
583 &mut conn,
584 &AdminCreditRegistrationFilters {
585 user_id: Some(registration.user_id),
586 course_id: Some(registration.course_id),
587 course_module_completion_id: Some(registration.course_module_completion_id),
588 include_superseded: true,
589 ..AdminCreditRegistrationFilters::default()
590 },
591 AdminCreditRegistrationSort::Created,
592 MAX_RELATED_ROWS,
593 0,
594 )
595 .await?;
596 let attempt_ids: Vec<Uuid> = attempts.iter().map(|attempt| attempt.id).collect();
597 let attention_lookup = AttentionLookup::load(&mut conn, &app_conf, Some(&attempt_ids)).await?;
598 let newest_attempt_id = attempts.first().map_or(id, |attempt| attempt.id);
599 let attempts: Vec<AdminCreditRegistrationRow> = attempts
600 .into_iter()
601 .map(|row| to_admin_row(row, &attention_lookup))
602 .collect();
603
604 let attempt_events =
605 models::credit_registration_events::get_by_registration_ids(&mut conn, &attempt_ids)
606 .await?;
607 let events: Vec<AdminCreditRegistrationEvent> = attempt_events
608 .into_iter()
609 .map(|event| AdminCreditRegistrationEvent {
610 id: event.id,
611 credit_registration_id: event.credit_registration_id,
612 created_at: event.created_at,
613 kind: event.kind,
614 from_state: event.from_state,
615 to_state: event.to_state,
616 error_code: event.error_code,
617 message: event.message,
618 actor_user_id: event.actor_user_id,
619 suotar_api_call_id: event.suotar_api_call_id,
620 suotar_code: event
621 .details
622 .as_ref()
623 .and_then(|details| details.pointer("/response/code"))
624 .and_then(|code| code.as_str())
625 .map(str::to_string),
626 details: event.details,
627 request_item_id: event.request_item_id,
628 suotar_endpoint: event.suotar_endpoint,
629 suotar_requested_at: event.suotar_requested_at,
630 suotar_answered_at: event.suotar_answered_at,
631 suotar_answer: event.suotar_answer,
632 })
633 .collect();
634 let suotar_api_calls =
635 suotar_api_calls::get_by_credit_registration_id(&mut conn, id, MAX_RELATED_ROWS)
636 .await?
637 .into_iter()
638 .map(to_admin_api_call)
639 .collect();
640 let actions = models::credit_registration_admin_actions::get_page(
641 &mut conn,
642 &CreditRegistrationAdminActionFilters {
643 target_kind: Some(CreditRegistrationAdminActionTarget::CreditRegistration),
644 target_id: Some(id),
645 ..Default::default()
646 },
647 MAX_RELATED_ROWS,
648 0,
649 )
650 .await?
651 .into_iter()
652 .map(|row| row.action)
653 .collect();
654
655 let sisu_person_id = match ®istration.sisu_person_id {
656 Some(person_id) => Some(person_id.clone()),
657 None => verified_student_numbers::get_latest_including_deleted_by_user_id(
658 &mut conn,
659 registration.user_id,
660 )
661 .await?
662 .and_then(|link| link.sisu_person_id),
663 };
664 let linking_emails = match sisu_person_id {
665 Some(person_id) => {
666 let mails = credit_registration_account_linking_emails::get_by_sisu_person_id(
667 &mut conn,
668 person_id.expose_secret(),
669 )
670 .await?;
671 build_linking_emails(&mut conn, mails).await?
672 }
673 None => Vec::new(),
674 };
675
676 let manual_linking_email_sends =
677 manual_linking_email_sends_by_registration(&mut conn, &[registration.course_id])
678 .await?
679 .remove(&id)
680 .unwrap_or_default();
681
682 let notification_emails = student_notifications::get_for_registrations(&mut conn, &attempt_ids)
683 .await?
684 .into_iter()
685 .map(
686 |mail: RegistrationNotificationEmail| AdminNotificationEmail {
687 credit_registration_id: mail.credit_registration_id,
688 kind: mail.kind,
689 email_delivery_id: mail.email_delivery_id,
690 send_status: mail.send_status,
691 },
692 )
693 .collect();
694
695 let not_improved_attainment =
696 models::credit_registration_events::get_not_improved_attainment(&mut conn, id).await?;
697
698 let journey = credit_registrations::get_registration_journey(&mut conn, newest_attempt_id)
699 .await?
700 .map(|journey| AdminCreditRegistrationJourney {
701 course_started_at: journey.course_started_at,
702 first_visited_at: journey.first_visited_at,
703 last_visited_at: journey.last_visited_at,
704 last_check_requested_at: journey.last_check_requested_at,
705 check_request_source: journey.check_request_source,
706 enrolment_route: journey.enrolment_route,
707 pressed_at: journey.pressed_at,
708 sisu_enrolled_at: journey
709 .selected_enrolment_date_time
710 .as_deref()
711 .and_then(|text| DateTime::parse_from_rfc3339(text).ok())
712 .map(|at| at.with_timezone(&Utc))
713 .or(journey.checked_enrolled_at),
714 })
715 .ok_or_else(|| controller_err!(NotFound, "Not found.".to_string()))?;
716 let linking_schedule = linking_schedule_for(
717 &mut conn,
718 newest_attempt_id,
719 app_conf.suotar_configuration.account_linking_since,
720 )
721 .await?;
722 let attention = attention_lookup
723 .get(id)
724 .map(|row| AdminRegistrationAttention {
725 reasons: row.reasons.clone(),
726 standing: row.standing,
727 blocking_problem: row.blocking_problem.clone(),
728 dismissed_reasons: row.dismissed_reasons.clone(),
729 dismissed_at: row.dismissed_at,
730 dismissed_by_user_id: row.dismissed_by_user_id,
731 dismissed_by_first_name: row.dismissed_by_first_name.clone(),
732 dismissed_by_last_name: row.dismissed_by_last_name.clone(),
733 dismissal_reason: row.dismissal_reason.clone(),
734 });
735
736 token.authorized_ok(web::Json(AdminCreditRegistrationDetails {
737 attention_thresholds: AdminAttentionThresholds::CURRENT,
738 registration: to_admin_row(registration, &attention_lookup),
739 attention,
740 journey,
741 linking_schedule,
742 attempts,
743 events,
744 suotar_api_calls,
745 actions,
746 linking_emails,
747 manual_linking_email_sends,
748 notification_emails,
749 not_improved_attainment,
750 }))
751}
752
753#[instrument(skip(pool, payload))]
763#[utoipa::path(
764 post,
765 path = "/registrations/{credit_registration_id}/transition",
766 operation_id = "adminTransitionCreditRegistration",
767 tag = "credit-registration-admin",
768 params(("credit_registration_id" = Uuid, Path, description = "Credit registration id")),
769 request_body = AdminTransitionCreditRegistrationPayload,
770 responses(
771 (status = 200, description = "What the transition did", body = AdminTransitionCreditRegistrationResult),
772 (status = 422, description = "No reason given"),
773 (status = 404, description = "No such registration")
774 )
775)]
776pub async fn admin_transition_credit_registration(
777 user: AuthUser,
778 pool: web::Data<PgPool>,
779 credit_registration_id: web::Path<Uuid>,
780 payload: web::Json<AdminTransitionCreditRegistrationPayload>,
781) -> ControllerResult<web::Json<AdminTransitionCreditRegistrationResult>> {
782 let mut conn = pool.acquire().await?;
783 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
784
785 let reason = required_reason(&payload.reason)?;
786 let id = *credit_registration_id;
787 let row = credit_registrations::get_by_id(&mut conn, id).await?;
788 if row.superseded_by_id.is_some() {
789 return Err(controller_err!(
790 BadRequest,
791 "This attempt has been replaced by a later one. Act on the later one.".to_string()
792 ));
793 }
794
795 if let Some(refusal) = payload
798 .action
799 .refusal(&row.resubmission_facts(), ResubmissionStrictness::Any)
800 {
801 return token.authorized_ok(web::Json(AdminTransitionCreditRegistrationResult {
802 outcome: AdminTransitionOutcome::Refused,
803 refusal: Some(refusal),
804 state: row.state,
805 needs_admin_attention: row.needs_admin_attention,
806 }));
807 }
808
809 let mut tx = conn.begin().await?;
810 let applied = apply_transition(&mut tx, &row, payload.action, user.id, reason).await?;
811 if applied.needs_due_now {
812 credit_registrations::make_due_now_batch(
813 &mut tx,
814 &[id],
815 EnrolmentCheckSource::AdminRequest,
816 )
817 .await?;
818 }
819 models::credit_registration_admin_actions::record(
820 &mut tx,
821 &NewCreditRegistrationAdminAction {
822 target_id: Some(id),
823 reason: Some(reason.to_string()),
824 before_state: Some(row.state),
825 after_state: Some(applied.state),
826 details: Some(serde_json::json!({ "outcome": applied.outcome })),
827 affected_row_count: Some(1),
828 ..NewCreditRegistrationAdminAction::new(
829 CreditRegistrationAdminAction::TransitionItem,
830 CreditRegistrationAdminActionTarget::CreditRegistration,
831 user.id,
832 GLOBAL_ADMIN_ROLE,
833 )
834 },
835 )
836 .await?;
837 tx.commit().await?;
838
839 token.authorized_ok(web::Json(AdminTransitionCreditRegistrationResult {
840 outcome: applied.outcome,
841 refusal: None,
842 state: applied.state,
843 needs_admin_attention: applied.needs_admin_attention,
844 }))
845}
846
847#[instrument(skip(pool, payload))]
859#[utoipa::path(
860 post,
861 path = "/registrations/bulk-transition",
862 operation_id = "adminBulkTransitionCreditRegistrations",
863 tag = "credit-registration-admin",
864 request_body = AdminBulkTransitionPayload,
865 responses(
866 (status = 200, description = "What each selected row did", body = AdminBulkTransitionResult),
867 (status = 422, description = "No reason given, or more ids than one call may take")
868 )
869)]
870pub async fn admin_bulk_transition_credit_registrations(
871 user: AuthUser,
872 pool: web::Data<PgPool>,
873 payload: web::Json<AdminBulkTransitionPayload>,
874) -> ControllerResult<web::Json<AdminBulkTransitionResult>> {
875 let mut conn = pool.acquire().await?;
876 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
877
878 let reason = required_reason(&payload.reason)?;
879 if payload.credit_registration_ids.len() as i64 > MAX_ROWS_PER_BULK_TRANSITION {
880 return Err(controller_err!(
881 BadRequest,
882 format!("At most {MAX_ROWS_PER_BULK_TRANSITION} registrations per call.")
883 ));
884 }
885
886 let ids: Vec<Uuid> = payload
889 .credit_registration_ids
890 .iter()
891 .copied()
892 .collect::<HashSet<_>>()
893 .into_iter()
894 .collect();
895
896 let mut tx = conn.begin().await?;
897 let rows = credit_registrations::get_by_ids_for_update(&mut tx, &ids).await?;
901
902 let mut applied_count = 0;
903 let mut due_now_ids = Vec::new();
904 let mut skipped: HashMap<ResubmissionRefusal, i64> = HashMap::new();
905 for row in &rows {
906 match payload.action.refusal(
907 &row.resubmission_facts(),
908 ResubmissionStrictness::AnyExceptSubmissionUncertain,
909 ) {
910 Some(refusal) => *skipped.entry(refusal).or_insert(0) += 1,
911 None => {
912 let applied =
913 apply_transition(&mut tx, row, payload.action, user.id, reason).await?;
914 if applied.needs_due_now {
915 due_now_ids.push(row.id);
916 }
917 applied_count += 1;
918 }
919 }
920 }
921 credit_registrations::make_due_now_batch(
924 &mut tx,
925 &due_now_ids,
926 EnrolmentCheckSource::AdminRequest,
927 )
928 .await?;
929 let mut skipped: Vec<AdminBulkTransitionSkipCount> = skipped
930 .into_iter()
931 .map(|(refusal, count)| AdminBulkTransitionSkipCount { refusal, count })
932 .collect();
933 skipped.sort_by_key(|skip| std::cmp::Reverse(skip.count));
934
935 models::credit_registration_admin_actions::record(
936 &mut tx,
937 &NewCreditRegistrationAdminAction {
938 reason: Some(reason.to_string()),
939 details: Some(serde_json::json!({
940 "action": payload.action,
941 "credit_registration_ids": payload.credit_registration_ids,
942 "skipped": skipped,
943 })),
944 affected_row_count: Some(applied_count),
945 ..NewCreditRegistrationAdminAction::new(
946 CreditRegistrationAdminAction::TransitionItem,
947 CreditRegistrationAdminActionTarget::CreditRegistration,
948 user.id,
949 GLOBAL_ADMIN_ROLE,
950 )
951 },
952 )
953 .await?;
954 tx.commit().await?;
955
956 token.authorized_ok(web::Json(AdminBulkTransitionResult {
957 applied_count: i64::from(applied_count),
958 skipped,
959 not_found_count: ids.len() as i64 - rows.len() as i64,
960 max_rows_per_call: MAX_ROWS_PER_BULK_TRANSITION,
961 }))
962}
963
964#[instrument(skip(pool, payload))]
972#[utoipa::path(
973 post,
974 path = "/registrations/requeue-retryable",
975 operation_id = "adminRequeueRetryableCreditRegistrations",
976 tag = "credit-registration-admin",
977 request_body = AdminRequeueRetryablePayload,
978 responses(
979 (status = 200, description = "How many were made due", body = AdminRequeueRetryableResult),
980 (status = 422, description = "No reason given")
981 )
982)]
983pub async fn admin_requeue_retryable_credit_registrations(
984 user: AuthUser,
985 pool: web::Data<PgPool>,
986 payload: web::Json<AdminRequeueRetryablePayload>,
987) -> ControllerResult<web::Json<AdminRequeueRetryableResult>> {
988 let mut conn = pool.acquire().await?;
989 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
990
991 let reason = required_reason(&payload.reason)?;
992
993 let mut tx = conn.begin().await?;
994 let requeued_count = credit_registrations::requeue_retryable_now(
995 &mut tx,
996 payload.course_id,
997 payload.course_module_id,
998 MAX_ROWS_PER_REQUEUE,
999 )
1000 .await?;
1001 models::credit_registration_admin_actions::record(
1002 &mut tx,
1003 &NewCreditRegistrationAdminAction {
1004 target_id: payload.course_id,
1005 reason: Some(reason.to_string()),
1006 details: Some(serde_json::json!({
1007 "course_id": payload.course_id,
1008 "course_module_id": payload.course_module_id,
1009 })),
1010 affected_row_count: Some(i32::try_from(requeued_count).unwrap_or(i32::MAX)),
1011 ..NewCreditRegistrationAdminAction::new(
1012 CreditRegistrationAdminAction::RequeueBatch,
1013 match payload.course_id {
1014 Some(_) => CreditRegistrationAdminActionTarget::Course,
1015 None => CreditRegistrationAdminActionTarget::CreditRegistration,
1016 },
1017 user.id,
1018 GLOBAL_ADMIN_ROLE,
1019 )
1020 },
1021 )
1022 .await?;
1023 tx.commit().await?;
1024
1025 token.authorized_ok(web::Json(AdminRequeueRetryableResult {
1026 requeued_count,
1027 max_rows_per_call: MAX_ROWS_PER_REQUEUE,
1028 }))
1029}
1030
1031struct AppliedHandAction {
1033 outcome: AdminTransitionOutcome,
1034 state: CreditRegistrationState,
1036 needs_admin_attention: bool,
1037 needs_due_now: bool,
1039}
1040
1041async fn apply_transition(
1048 tx: &mut PgConnection,
1049 row: &credit_registrations::CreditRegistration,
1050 action: AdminCreditRegistrationAction,
1051 actor_user_id: Uuid,
1052 reason: &str,
1053) -> Result<AppliedHandAction, ControllerError> {
1054 let id = row.id;
1055 Ok(match action {
1056 AdminCreditRegistrationAction::ClearNeedsAdminAttention => {
1057 if !row.needs_admin_attention {
1058 AppliedHandAction {
1059 outcome: AdminTransitionOutcome::NoChange,
1060 state: row.state,
1061 needs_admin_attention: false,
1062 needs_due_now: false,
1063 }
1064 } else {
1065 credit_registrations::set_needs_admin_attention(
1066 tx,
1067 id,
1068 credit_registrations::AdminAttention::Clear,
1069 )
1070 .await?;
1071 insert_admin_action_event(tx, id, actor_user_id, reason).await?;
1072 AppliedHandAction {
1073 outcome: AdminTransitionOutcome::Applied,
1074 state: row.state,
1075 needs_admin_attention: false,
1076 needs_due_now: false,
1077 }
1078 }
1079 }
1080 AdminCreditRegistrationAction::CheckNow => {
1081 insert_admin_action_event(tx, id, actor_user_id, reason).await?;
1082 AppliedHandAction {
1083 outcome: AdminTransitionOutcome::Applied,
1084 state: row.state,
1085 needs_admin_attention: row.needs_admin_attention,
1086 needs_due_now: true,
1087 }
1088 }
1089 AdminCreditRegistrationAction::StateMove { to_state } => {
1090 let to_state = to_state.to_state();
1091 let after = credit_registrations::transition(
1092 tx,
1093 id,
1094 &Transition {
1095 needs_admin_attention: Some(credit_registrations::AdminAttention::Clear),
1096 event_kind: CreditRegistrationEventKind::AdminAction,
1097 event_message: Some(reason.to_string()),
1098 actor_user_id: Some(actor_user_id),
1099 expected_from_state: Some(row.state),
1103 ..Transition::by_hand(to_state)
1104 },
1105 )
1106 .await?;
1107 AppliedHandAction {
1110 outcome: AdminTransitionOutcome::Applied,
1111 state: after.state,
1112 needs_admin_attention: after.needs_admin_attention,
1113 needs_due_now: !after.state.is_terminal(),
1114 }
1115 }
1116 })
1117}
1118
1119async fn one_admin_row(
1120 conn: &mut PgConnection,
1121 id: Uuid,
1122) -> Result<Option<AdminCreditRegistration>, ControllerError> {
1123 let rows = credit_registrations::get_admin_facing(
1124 conn,
1125 &AdminCreditRegistrationFilters {
1126 id: Some(id),
1127 include_superseded: true,
1128 ..AdminCreditRegistrationFilters::default()
1129 },
1130 AdminCreditRegistrationSort::default(),
1131 MAX_RELATED_ROWS,
1132 0,
1133 )
1134 .await?;
1135 Ok(rows.into_iter().next())
1136}
1137
1138struct AttentionLookup {
1140 rows: Vec<AttentionLookupRow>,
1142 index_by_id: HashMap<Uuid, usize>,
1143}
1144
1145struct AttentionLookupRow {
1146 id: Uuid,
1147 reasons: Vec<AttentionReason>,
1148 standing: AttentionStanding,
1149 blocking_problem: Option<BlockingProblem>,
1150 dismissed_reasons: Option<Vec<AttentionReason>>,
1151 dismissed_at: Option<DateTime<Utc>>,
1152 dismissed_by_user_id: Option<Uuid>,
1153 dismissed_by_first_name: Option<String>,
1154 dismissed_by_last_name: Option<String>,
1155 dismissal_reason: Option<String>,
1156}
1157
1158impl AttentionLookup {
1159 async fn load(
1161 conn: &mut PgConnection,
1162 app_conf: &ApplicationConfiguration,
1163 only_ids: Option<&[Uuid]>,
1164 ) -> Result<Self, ControllerError> {
1165 let rules = attention_rules(conn, app_conf).await?;
1166 let rows = credit_registrations::get_attention_items(conn, &rules, only_ids)
1167 .await?
1168 .into_iter()
1169 .map(|row| {
1170 let standing = row.standing(&rules.blocking);
1171 AttentionLookupRow {
1172 id: row.id,
1173 standing,
1174 blocking_problem: (standing == AttentionStanding::ExplainedByProblem)
1175 .then(|| row.blocking_problem(&rules.blocking))
1176 .flatten(),
1177 reasons: row.reasons,
1178 dismissed_reasons: row.dismissed_reasons,
1179 dismissed_at: row.dismissed_at,
1180 dismissed_by_user_id: row.dismissed_by_user_id,
1181 dismissed_by_first_name: row.dismissed_by_first_name,
1182 dismissed_by_last_name: row.dismissed_by_last_name,
1183 dismissal_reason: row.dismissal_reason,
1184 }
1185 })
1186 .collect::<Vec<_>>();
1187 let index_by_id = rows
1188 .iter()
1189 .enumerate()
1190 .map(|(index, row)| (row.id, index))
1191 .collect();
1192 Ok(Self { rows, index_by_id })
1193 }
1194
1195 fn ids(&self, keep: impl Fn(&AttentionLookupRow) -> bool) -> Vec<Uuid> {
1196 self.rows
1197 .iter()
1198 .filter(|row| keep(row))
1199 .map(|row| row.id)
1200 .collect()
1201 }
1202
1203 fn get(&self, id: Uuid) -> Option<&AttentionLookupRow> {
1204 self.index_by_id.get(&id).map(|&index| &self.rows[index])
1205 }
1206}
1207
1208async fn linking_schedule_for(
1210 conn: &mut PgConnection,
1211 id: Uuid,
1212 account_linking_since: Option<DateTime<Utc>>,
1213) -> Result<Option<AdminLinkingSchedule>, ControllerError> {
1214 let Some(presser) = credit_registrations::get_waiting_pressers(conn, None, Some(id))
1215 .await?
1216 .into_iter()
1217 .next()
1218 else {
1219 return Ok(None);
1220 };
1221 let Some(course_code) = presser.uh_course_code else {
1222 return Ok(None);
1223 };
1224 let Some(schedule) = credit_registration_roster_schedules::get_schedules(
1225 conn,
1226 None,
1227 ScheduleSelection::Code(&course_code),
1228 account_linking_since,
1229 )
1230 .await?
1231 .into_iter()
1232 .next() else {
1233 return Ok(None);
1234 };
1235 Ok(Some(AdminLinkingSchedule {
1236 next_fetch_at: schedule.next_fetch_at(Utc::now()),
1237 last_fetched_at: schedule.last_fetched_at,
1238 last_mailing_fetch_started_at: schedule.last_mailing_fetch_started_at,
1239 is_fetch_failing: schedule.consecutive_failures > 0,
1240 is_enrolment_list_empty: schedule.last_listed_person_count == Some(0),
1241 linking_emails_since_press: presser.linking_emails_on_code_since_press,
1242 last_linking_email_at: presser.last_linking_email_on_code_at,
1243 course_code,
1244 }))
1245}
1246
1247fn to_admin_row(
1248 row: AdminCreditRegistration,
1249 attention: &AttentionLookup,
1250) -> AdminCreditRegistrationRow {
1251 let position = row.position();
1252 let picked = attention.get(row.id);
1253 let standing = picked.map(|picked| picked.standing);
1254 AdminCreditRegistrationRow {
1255 timeline_step: position.step,
1256 phase: position.step.phase(),
1257 waits_on: WaitsOn::of(
1258 position.step,
1259 position.engagement,
1260 standing == Some(AttentionStanding::NeedsAttention),
1261 credit_registrations::needs_course_setup(row.error_code),
1262 ),
1263 engagement: position.engagement,
1264 phase_started_at: row.phase_started_at(),
1265 state_changed_at: row.state_changed_at,
1266 attention_reasons: picked
1267 .map(|picked| picked.reasons.clone())
1268 .unwrap_or_default(),
1269 attention_standing: standing,
1270 pressed_at: row.pressed_at,
1271 enrolment_route: row.enrolment_route,
1272 last_visited_at: row.last_visited_at,
1273 superseded: row.superseded_by_id.is_some(),
1274 is_waiting_for_enrolment: row.is_waiting_for_enrolment(),
1275 pending_reason: row.pending_reason(),
1276 hand_actions: row
1277 .resubmission_facts()
1278 .hand_actions(ResubmissionStrictness::Any),
1279 id: row.id,
1280 created_at: row.created_at,
1281 user_id: row.user_id,
1282 first_name: row.first_name,
1283 last_name: row.last_name,
1284 email: row.email,
1285 course_id: row.course_id,
1286 course_name: row.course_name,
1287 course_module_id: row.course_module_id,
1288 course_module_name: row.course_module_name,
1289 course_instance_id: row.course_instance_id,
1290 course_module_completion_id: row.course_module_completion_id,
1291 completion_date: row.completion_date,
1292 state: row.state,
1293 state_entered_at: row.state_entered_at,
1294 error_code: row.error_code,
1295 needs_admin_attention: row.needs_admin_attention,
1296 next_attempt_at: row.next_attempt_at,
1297 last_attempt_at: row.last_attempt_at,
1298 submitted_at: row.submitted_at,
1299 registered_at: row.registered_at,
1300 terminal_at: row.terminal_at,
1301 partially_registered_at: row.partially_registered_at,
1302 resubmit_not_before: row.resubmit_not_before,
1303 not_registered_reimport_count: row.not_registered_reimport_count,
1304 no_usable_enrolment_since: row.no_usable_enrolment_since,
1305 enrolment_checked_at: row.enrolment_checked_at,
1306 enrolment_check_due_at: row.enrolment_check_due_at,
1307 enrolment_checks_stopped_at: row.enrolment_checks_stopped_at,
1308 student_number: expose_option(&row.student_number).map(str::to_owned),
1309 sisu_person_id: expose_option(&row.sisu_person_id).map(str::to_owned),
1310 uh_course_code: row.uh_course_code,
1311 selected_enrolment_id: row.selected_enrolment_id,
1312 grade_scale_id: row.grade_scale_id,
1313 grade_id: row.grade_id,
1314 credits: row.credits,
1315 submitted_attainment_id: row.submitted_attainment_id,
1316 sisu_attainment_id: row.sisu_attainment_id,
1317 submit_retry_count: row.submit_retry_count,
1318 verify_attempt_count: row.verify_attempt_count,
1319 attempt_number: row.attempt_number,
1320 superseded_by_id: row.superseded_by_id,
1321 verified_student_number: expose_option(&row.verified_student_number).map(str::to_owned),
1322 verified_student_number_at: row.verified_student_number_at,
1323 verified_student_number_via: row.verified_student_number_via,
1324 }
1325}
1326
1327fn to_admin_api_call(call: models::suotar_api_calls::SuotarApiCall) -> AdminSuotarApiCall {
1328 AdminSuotarApiCall {
1329 id: call.id,
1330 endpoint: call.endpoint,
1331 started_at: call.started_at,
1332 duration_ms: call.duration_ms,
1333 http_status: call.http_status,
1334 succeeded: call.succeeded,
1335 request_level_error_code: call.request_level_error_code,
1336 worker_name: call.worker_name,
1337 }
1338}
1339
1340pub fn _add_routes(cfg: &mut ServiceConfig) {
1341 cfg.route(
1342 "/registrations",
1343 web::get().to(list_credit_registrations_for_admin),
1344 )
1345 .route(
1346 "/registrations/{credit_registration_id}",
1347 web::get().to(get_credit_registration_for_admin),
1348 )
1349 .route(
1350 "/registrations/bulk-transition",
1351 web::post().to(admin_bulk_transition_credit_registrations),
1352 )
1353 .route(
1354 "/registrations/requeue-retryable",
1355 web::post().to(admin_requeue_retryable_credit_registrations),
1356 )
1357 .route(
1358 "/registrations/{credit_registration_id}/transition",
1359 web::post().to(admin_transition_credit_registration),
1360 );
1361}