1use chrono::Duration;
9
10use crate::prelude::*;
11use headless_lms_credit_registration::{
12 CreditRegistrationPhase, PhaseContext, PhaseSkipReason, PhaseTick, Runner, registry_health,
13 run_phase_once,
14};
15use headless_lms_models::credit_registrations::RegistrationScope;
16use headless_lms_models::library::credit_registration::enrolment_check_schedule::{
17 EnrolmentCheckGroup, EnrolmentCheckSource,
18};
19use headless_lms_utils::services::suotar::SuotarClient;
20use sqlx::PgPool;
21
22use super::commands;
23
24#[derive(Debug, Deserialize)]
25#[serde(rename_all = "camelCase")]
26pub struct RunTickQuery {
27 pub phase: Option<String>,
29 pub course_id: Option<Uuid>,
30 pub course_slug: Option<String>,
32 pub user_id: Option<Uuid>,
33 pub user_email: Option<String>,
34 pub credit_registration_ids: Option<String>,
36 pub account_linking_enabled: Option<bool>,
40}
41
42#[derive(Debug, Serialize, Deserialize, PartialEq)]
43#[serde(rename_all = "camelCase")]
44pub struct UnresolvedScope {
45 pub status: String,
46 pub half: String,
47 pub value: String,
48}
49
50#[derive(Debug, Serialize, Deserialize, PartialEq)]
51#[serde(rename_all = "camelCase", tag = "status")]
52pub enum PhaseTickResult {
53 #[serde(rename_all = "camelCase")]
54 Ran {
55 phase: String,
56 items_processed: i32,
57 items_failed: i32,
58 error: Option<String>,
60 },
61 Skipped { phase: String, reason: String },
63 ScopeNotSupported { phase: String },
65 #[serde(rename_all = "camelCase")]
66 UnknownPhase {
67 phase: Option<String>,
68 known_phases: Vec<String>,
69 },
70}
71
72impl PhaseTickResult {
73 fn of(phase: CreditRegistrationPhase, tick: PhaseTick) -> Self {
74 match tick {
75 PhaseTick::Ran(outcome) => Self::Ran {
76 phase: phase.as_str().to_string(),
77 items_processed: outcome.items_processed,
78 items_failed: outcome.items_failed,
79 error: outcome.error,
80 },
81 PhaseTick::Skipped(reason) => Self::Skipped {
82 phase: phase.as_str().to_string(),
83 reason: match reason {
84 PhaseSkipReason::Paused => "paused".to_string(),
85 PhaseSkipReason::CircuitBreakerOpen => "circuitBreakerOpen".to_string(),
86 PhaseSkipReason::AccountLinkingDisabled => "accountLinkingDisabled".to_string(),
87 PhaseSkipReason::SisuDayGap => "sisuDayGap".to_string(),
88 },
89 },
90 PhaseTick::ScopeNotSupported => Self::ScopeNotSupported {
91 phase: phase.as_str().to_string(),
92 },
93 }
94 }
95}
96
97#[derive(Debug, Serialize, Deserialize, PartialEq)]
99#[serde(rename_all = "camelCase")]
100pub struct RegistrarTickResult {
101 pub phases: Vec<PhaseTickResult>,
102}
103
104async fn run_tick(
108 app_conf: web::Data<ApplicationConfiguration>,
109 pool: web::Data<PgPool>,
110 suotar_client: web::Data<SuotarClient>,
111 query: web::Query<RunTickQuery>,
112) -> ControllerResult<HttpResponse> {
113 super::assert_enabled(&app_conf);
114 let token = skip_authorize();
115
116 let Some(phase) = query
117 .phase
118 .as_deref()
119 .and_then(CreditRegistrationPhase::from_phase_name)
120 else {
121 return token.authorized_ok(HttpResponse::BadRequest().json(
122 PhaseTickResult::UnknownPhase {
123 phase: query.phase.clone(),
124 known_phases: known_phase_names(),
125 },
126 ));
127 };
128
129 let scope = match resolve_scope(&pool, &query).await? {
130 Ok(scope) => scope,
131 Err(unresolved) => {
133 return token.authorized_ok(HttpResponse::BadRequest().json(unresolved));
134 }
135 };
136
137 let ctx = PhaseContext {
138 account_linking_since: match query.account_linking_enabled {
139 Some(true) => Some(
140 app_conf
141 .suotar_configuration
142 .account_linking_since
143 .unwrap_or(DateTime::UNIX_EPOCH),
144 ),
145 Some(false) => None,
146 None => app_conf.suotar_configuration.account_linking_since,
147 },
148 ..tick_context(&app_conf, &pool, &suotar_client)
149 };
150 debug!(phase = phase.as_str(), ?scope, "run-tick requested");
151 let result = PhaseTickResult::of(phase, run_phase_once(&ctx, phase, &scope).await?);
152 debug!(phase = phase.as_str(), ?result, "run-tick finished");
153 token.authorized_ok(match &result {
154 PhaseTickResult::Ran { .. } | PhaseTickResult::Skipped { .. } => {
155 HttpResponse::Ok().json(&result)
156 }
157 PhaseTickResult::ScopeNotSupported { .. } | PhaseTickResult::UnknownPhase { .. } => {
158 HttpResponse::BadRequest().json(&result)
159 }
160 })
161}
162
163const REGISTRAR_TICK_SEQUENCE: [CreditRegistrationPhase; 5] = [
166 CreditRegistrationPhase::Materialize,
167 CreditRegistrationPhase::Preconditions,
168 CreditRegistrationPhase::ResolveEnrolments,
169 CreditRegistrationPhase::Import,
170 CreditRegistrationPhase::Verify,
171];
172
173async fn run_registrar_tick(
177 app_conf: web::Data<ApplicationConfiguration>,
178 pool: web::Data<PgPool>,
179 suotar_client: web::Data<SuotarClient>,
180) -> ControllerResult<HttpResponse> {
181 super::assert_enabled(&app_conf);
182 let token = skip_authorize();
183
184 let scope = RegistrationScope::default();
185 let ctx = tick_context(&app_conf, &pool, &suotar_client);
186 debug!("run-registrar-tick requested");
187 let mut phases = Vec::new();
188 for phase in REGISTRAR_TICK_SEQUENCE {
189 phases.push(PhaseTickResult::of(
190 phase,
191 run_phase_once(&ctx, phase, &scope).await?,
192 ));
193 }
194 token.authorized_ok(HttpResponse::Ok().json(RegistrarTickResult { phases }))
195}
196
197#[derive(Debug, Deserialize)]
198#[serde(rename_all = "camelCase")]
199pub struct RegradeCompletionPayload {
200 pub credit_registration_id: Uuid,
201 pub grade: Option<i32>,
203 pub passed: Option<bool>,
205}
206
207#[derive(Debug, Serialize, Deserialize, PartialEq)]
208#[serde(rename_all = "camelCase")]
209pub struct RegradeCompletionResult {
210 pub course_module_completion_id: Uuid,
211 pub grade: Option<i32>,
212}
213
214async fn regrade_completion(
220 app_conf: web::Data<ApplicationConfiguration>,
221 pool: web::Data<PgPool>,
222 payload: web::Json<RegradeCompletionPayload>,
223) -> ControllerResult<HttpResponse> {
224 super::assert_enabled(&app_conf);
225 let token = skip_authorize();
226
227 let mut conn = pool.acquire().await?;
228 let registration =
229 models::credit_registrations::get_by_id(&mut conn, payload.credit_registration_id).await?;
230 models::course_module_completions::set_grade_for_testing(
231 &mut conn,
232 registration.course_module_completion_id,
233 payload.grade,
234 payload.passed,
235 )
236 .await?;
237 token.authorized_ok(HttpResponse::Ok().json(RegradeCompletionResult {
238 course_module_completion_id: registration.course_module_completion_id,
239 grade: payload.grade,
240 }))
241}
242
243#[derive(Debug, Deserialize)]
244#[serde(rename_all = "camelCase")]
245pub struct SetTestExclusiveHoldPayload {
246 pub user_email: String,
247 pub course_id: Option<Uuid>,
249 pub hold_secs: i64,
250}
251
252#[derive(Debug, Serialize, Deserialize, PartialEq)]
253#[serde(rename_all = "camelCase")]
254pub struct SetTestExclusiveHoldResult {
255 pub held_until: DateTime<Utc>,
256}
257
258const MAX_TEST_EXCLUSIVE_HOLD_SECS: i64 = 120;
262
263async fn set_test_exclusive_hold(
267 app_conf: web::Data<ApplicationConfiguration>,
268 pool: web::Data<PgPool>,
269 payload: web::Json<SetTestExclusiveHoldPayload>,
270) -> ControllerResult<HttpResponse> {
271 super::assert_enabled(&app_conf);
272 let token = skip_authorize();
273
274 if !(0..=MAX_TEST_EXCLUSIVE_HOLD_SECS).contains(&payload.hold_secs) {
275 return token.authorized_ok(HttpResponse::BadRequest().json(format!(
276 "holdSecs must be between 0 and {MAX_TEST_EXCLUSIVE_HOLD_SECS}"
277 )));
278 }
279
280 let mut conn = pool.acquire().await?;
281 let Some(user_id) = models::user_details::get_active_user_id_by_email_case_insensitive(
282 &mut conn,
283 &payload.user_email,
284 )
285 .await?
286 else {
287 return token.authorized_ok(HttpResponse::BadRequest().json(UnresolvedScope {
288 status: "unresolvedScope".to_string(),
289 half: "userEmail".to_string(),
290 value: payload.user_email.clone(),
291 }));
292 };
293
294 let held_until = Utc::now() + Duration::seconds(payload.hold_secs);
295 models::credit_registrations::testing::set_test_exclusive_hold_for_testing(
296 &mut conn,
297 user_id,
298 payload.course_id,
299 held_until,
300 )
301 .await?;
302
303 token.authorized_ok(HttpResponse::Ok().json(SetTestExclusiveHoldResult { held_until }))
304}
305
306#[derive(Debug, Deserialize)]
307#[serde(rename_all = "camelCase")]
308pub struct ExpireEnrolmentRecheckAllowancePayload {
309 pub credit_registration_id: Uuid,
310 #[serde(default)]
312 pub clear_restarts: bool,
313}
314
315async fn expire_enrolment_recheck_allowance(
318 app_conf: web::Data<ApplicationConfiguration>,
319 pool: web::Data<PgPool>,
320 payload: web::Json<ExpireEnrolmentRecheckAllowancePayload>,
321) -> ControllerResult<HttpResponse> {
322 super::assert_enabled(&app_conf);
323 let token = skip_authorize();
324
325 let mut conn = pool.acquire().await?;
326 models::credit_registrations::testing::expire_enrolment_recheck_allowance_for_testing(
327 &mut conn,
328 payload.credit_registration_id,
329 payload.clear_restarts,
330 )
331 .await?;
332 token.authorized_ok(HttpResponse::Ok().json(()))
333}
334
335#[derive(Debug, Serialize)]
336#[serde(rename_all = "camelCase")]
337pub struct MakeEnrolmentChecksDueResult {
338 pub made_due_count: u64,
339}
340
341async fn make_enrolment_checks_due(
345 app_conf: web::Data<ApplicationConfiguration>,
346 pool: web::Data<PgPool>,
347 query: web::Query<RunTickQuery>,
348) -> ControllerResult<HttpResponse> {
349 super::assert_enabled(&app_conf);
350 let token = skip_authorize();
351
352 let scope = match resolve_scope(&pool, &query).await? {
353 Ok(scope) if !scope.is_unscoped() => scope,
354 Ok(_) => {
355 return token.authorized_ok(
356 HttpResponse::BadRequest().json("A scope is required, or every test's rows move."),
357 );
358 }
359 Err(unresolved) => {
360 return token.authorized_ok(HttpResponse::BadRequest().json(unresolved));
361 }
362 };
363 let mut conn = pool.acquire().await?;
364 let made_due_count =
365 models::credit_registrations::testing::make_enrolment_checks_due_for_testing(
366 &mut conn, &scope,
367 )
368 .await?;
369 token.authorized_ok(HttpResponse::Ok().json(MakeEnrolmentChecksDueResult { made_due_count }))
370}
371
372#[derive(Debug, Serialize)]
373#[serde(rename_all = "camelCase")]
374pub struct MakeRosterListingsDueResult {
375 pub made_due_count: u64,
376}
377
378async fn make_roster_listings_due(
382 app_conf: web::Data<ApplicationConfiguration>,
383 pool: web::Data<PgPool>,
384 query: web::Query<RunTickQuery>,
385) -> ControllerResult<HttpResponse> {
386 super::assert_enabled(&app_conf);
387 let token = skip_authorize();
388
389 let scope = match resolve_scope(&pool, &query).await? {
390 Ok(scope) => scope,
391 Err(unresolved) => {
392 return token.authorized_ok(HttpResponse::BadRequest().json(unresolved));
393 }
394 };
395 let Some(course_id) = scope.course_id else {
396 return token.authorized_ok(
397 HttpResponse::BadRequest().json("A course is required, or every test's codes move."),
398 );
399 };
400 let mut conn = pool.acquire().await?;
401 let made_due_count =
402 models::credit_registration_roster_schedules::testing::make_listings_due_for_testing(
403 &mut conn, course_id,
404 )
405 .await?;
406 registry_health::reset_rate_limits(&scope);
407 token.authorized_ok(HttpResponse::Ok().json(MakeRosterListingsDueResult { made_due_count }))
408}
409
410#[derive(Debug, Deserialize)]
411#[serde(rename_all = "camelCase")]
412pub struct EnrolmentCheckScheduleQuery {
413 pub credit_registration_id: Uuid,
414}
415
416#[derive(Debug, Serialize)]
418#[serde(rename_all = "camelCase")]
419pub struct EnrolmentCheckSchedule {
420 pub state: models::credit_registrations::CreditRegistrationState,
421 pub group: EnrolmentCheckGroup,
422 pub anchor_at: Option<DateTime<Utc>>,
423 pub step: Option<i32>,
424 pub due_at: Option<DateTime<Utc>>,
425 pub next_attempt_at: DateTime<Utc>,
426 pub is_batched: bool,
427 pub source: EnrolmentCheckSource,
428 pub stopped_at: Option<DateTime<Utc>>,
429 pub requested_at: Option<DateTime<Utc>>,
430 pub restart_count: i32,
431 pub checked_at: Option<DateTime<Utc>>,
432 pub first_failed_at: Option<DateTime<Utc>>,
433 pub submit_retry_count: i32,
434 pub error_code: Option<models::credit_registrations::CreditRegistrationErrorCode>,
435 pub seen_enrolment_ids: Option<Vec<String>>,
436 pub claimed_until: Option<DateTime<Utc>>,
437}
438
439async fn enrolment_check_schedule(
440 app_conf: web::Data<ApplicationConfiguration>,
441 pool: web::Data<PgPool>,
442 query: web::Query<EnrolmentCheckScheduleQuery>,
443) -> ControllerResult<HttpResponse> {
444 super::assert_enabled(&app_conf);
445 let token = skip_authorize();
446
447 let mut conn = pool.acquire().await?;
448 let row =
449 models::credit_registrations::get_by_id(&mut conn, query.credit_registration_id).await?;
450 token.authorized_ok(HttpResponse::Ok().json(EnrolmentCheckSchedule {
451 state: row.state,
452 group: row.enrolment_check_group,
453 anchor_at: row.enrolment_check_anchor_at,
454 step: row.enrolment_check_step,
455 due_at: row.enrolment_check_due_at,
456 next_attempt_at: row.next_attempt_at,
457 is_batched: row.is_enrolment_check_batched,
458 source: row.enrolment_check_source,
459 stopped_at: row.enrolment_checks_stopped_at,
460 requested_at: row.enrolment_check_requested_at,
461 restart_count: row.enrolment_check_restart_count,
462 checked_at: row.enrolment_checked_at,
463 first_failed_at: row.first_failed_at,
464 submit_retry_count: row.submit_retry_count,
465 error_code: row.error_code,
466 seen_enrolment_ids: row.seen_enrolment_ids,
467 claimed_until: row.enrolment_check_claimed_until,
468 }))
469}
470
471#[derive(Debug, Deserialize)]
472#[serde(rename_all = "camelCase")]
473pub struct QueuedEmailsQuery {
474 pub user_email: String,
475}
476
477#[derive(Debug, Serialize, Deserialize, PartialEq)]
478#[serde(rename_all = "camelCase")]
479pub struct QueuedEmail {
480 pub template_type: String,
481 pub placeholders: serde_json::Value,
482}
483
484const QUEUED_EMAIL_SCAN: i64 = 200;
488
489async fn queued_emails(
492 app_conf: web::Data<ApplicationConfiguration>,
493 pool: web::Data<PgPool>,
494 query: web::Query<QueuedEmailsQuery>,
495) -> ControllerResult<HttpResponse> {
496 super::assert_enabled(&app_conf);
497 let token = skip_authorize();
498
499 let mut conn = pool.acquire().await?;
500 let Some(user_id) = models::user_details::get_active_user_id_by_email_case_insensitive(
501 &mut conn,
502 &query.user_email,
503 )
504 .await?
505 else {
506 return token.authorized_ok(HttpResponse::BadRequest().json(UnresolvedScope {
507 status: "unresolvedScope".to_string(),
508 half: "userEmail".to_string(),
509 value: query.user_email.clone(),
510 }));
511 };
512 let queued = models::email_deliveries::get_recent_template_types_for_user_for_testing(
513 &mut conn,
514 user_id,
515 QUEUED_EMAIL_SCAN,
516 )
517 .await?
518 .into_iter()
519 .map(|(template_type, placeholders)| QueuedEmail {
520 template_type: serde_json::to_value(template_type)
521 .ok()
522 .and_then(|value| value.as_str().map(str::to_string))
523 .unwrap_or_default(),
524 placeholders,
525 })
526 .collect::<Vec<_>>();
527
528 token.authorized_ok(HttpResponse::Ok().json(queued))
529}
530
531fn tick_context<'a>(
533 app_conf: &'a ApplicationConfiguration,
534 pool: &'a PgPool,
535 suotar_client: &'a SuotarClient,
536) -> PhaseContext<'a> {
537 PhaseContext::from_app(pool, suotar_client, app_conf, Runner::Other("run-tick"))
538}
539
540async fn resolve_scope(
542 pool: &PgPool,
543 query: &RunTickQuery,
544) -> anyhow::Result<Result<RegistrationScope, UnresolvedScope>> {
545 let mut scope = RegistrationScope {
546 course_id: query.course_id,
547 user_id: query.user_id,
548 credit_registration_ids: Vec::new(),
549 };
550 if let Some(slug) = &query.course_slug {
551 let mut conn = pool.acquire().await?;
552 let found = models::courses::get_active_course_id_by_slug(&mut conn, slug).await?;
553 match found {
554 Some(id) => scope.course_id = Some(id),
555 None => {
556 return Ok(Err(UnresolvedScope {
557 status: "unresolvedScope".to_string(),
558 half: "courseSlug".to_string(),
559 value: slug.clone(),
560 }));
561 }
562 }
563 }
564 if let Some(email) = &query.user_email {
565 let mut conn = pool.acquire().await?;
566 let found =
567 models::user_details::get_active_user_id_by_email_case_insensitive(&mut conn, email)
568 .await?;
569 match found {
570 Some(id) => scope.user_id = Some(id),
571 None => {
572 return Ok(Err(UnresolvedScope {
573 status: "unresolvedScope".to_string(),
574 half: "userEmail".to_string(),
575 value: email.clone(),
576 }));
577 }
578 }
579 }
580 if let Some(raw) = &query.credit_registration_ids {
581 for part in raw.split(',').filter(|part| !part.trim().is_empty()) {
582 match Uuid::parse_str(part.trim()) {
583 Ok(id) => scope.credit_registration_ids.push(id),
584 Err(_) => {
585 return Ok(Err(UnresolvedScope {
586 status: "unresolvedScope".to_string(),
587 half: "creditRegistrationIds".to_string(),
588 value: part.trim().to_string(),
589 }));
590 }
591 }
592 }
593 }
594 Ok(Ok(scope))
595}
596
597fn known_phase_names() -> Vec<String> {
598 CreditRegistrationPhase::ALL
599 .iter()
600 .map(|phase| phase.as_str().to_string())
601 .collect()
602}
603
604pub fn _add_routes(cfg: &mut ServiceConfig) {
605 cfg.route("/run-tick", web::post().to(run_tick))
606 .route("/run-registrar-tick", web::post().to(run_registrar_tick))
607 .route("/regrade-completion", web::post().to(regrade_completion))
608 .route(
609 "/test-exclusive-hold",
610 web::post().to(set_test_exclusive_hold),
611 )
612 .route(
613 "/expire-enrolment-recheck-allowance",
614 web::post().to(expire_enrolment_recheck_allowance),
615 )
616 .route(
617 "/enrolment-check-schedule",
618 web::get().to(enrolment_check_schedule),
619 )
620 .route(
621 "/make-enrolment-checks-due",
622 web::post().to(make_enrolment_checks_due),
623 )
624 .route(
625 "/make-roster-listings-due",
626 web::post().to(make_roster_listings_due),
627 )
628 .route("/queued-emails", web::get().to(queued_emails))
629 .configure(commands::_add_routes);
630}
631
632#[cfg(test)]
633mod tests {
634 use actix_web::{App, http::StatusCode, test, web::Data};
635
636 use super::*;
637 use crate::controllers::configure_controllers;
638
639 fn app_conf(test_suotar: bool) -> ApplicationConfiguration {
641 ApplicationConfiguration {
642 test_suotar,
643 ..ApplicationConfiguration::mock_conf()
644 .expect("the mock configuration is built from constants")
645 }
646 }
647
648 async fn call_run_tick(
651 test_suotar: bool,
652 query: &str,
653 ) -> actix_web::dev::ServiceResponse<actix_web::body::BoxBody> {
654 let app_conf = Data::new(app_conf(test_suotar));
655 let pool = Data::new(
656 PgPool::connect_lazy("postgres://headless-lms@localhost:54328/headless_lms_dev")
657 .expect("a lazy pool only parses the url"),
658 );
659 let service = test::init_service(
660 App::new()
661 .app_data(pool)
662 .app_data(Data::new(SuotarClient::mock_for_test()))
663 .app_data(app_conf.clone())
664 .service(
665 web::scope("/api/v0")
666 .configure(|cfg| configure_controllers(cfg, app_conf.clone())),
667 ),
668 )
669 .await;
670 let req = test::TestRequest::post()
671 .uri(&format!("/api/v0/mock-suotar/control/run-tick{query}"))
672 .to_request();
673 test::call_service(&service, req).await
674 }
675
676 #[actix_web::test]
678 async fn run_tick_is_absent_when_the_mock_is_disabled() {
679 let res = call_run_tick(false, "?phase=verify").await;
680 assert_eq!(res.status(), StatusCode::NOT_FOUND);
681 }
682
683 #[actix_web::test]
686 async fn a_scope_a_phase_cannot_apply_is_refused() {
687 let res = call_run_tick(
688 true,
689 &format!("?phase=retention-sweep&courseId={}", Uuid::new_v4()),
690 )
691 .await;
692 assert_eq!(res.status(), StatusCode::BAD_REQUEST);
693 let body: PhaseTickResult = test::read_body_json(res).await;
694 assert_eq!(
695 body,
696 PhaseTickResult::ScopeNotSupported {
697 phase: "retention-sweep".to_string()
698 }
699 );
700 }
701
702 #[actix_web::test]
703 async fn an_invented_phase_name_is_rejected() {
704 let res = call_run_tick(true, "?phase=materialise").await;
705 assert_eq!(res.status(), StatusCode::BAD_REQUEST);
706 let body: PhaseTickResult = test::read_body_json(res).await;
707 assert_eq!(
708 body,
709 PhaseTickResult::UnknownPhase {
710 phase: Some("materialise".to_string()),
711 known_phases: known_phase_names(),
712 }
713 );
714 }
715}