1use std::collections::HashMap;
4
5use headless_lms_models::course_module_suotar_configurations::{
6 self, SuotarModuleOverview, get_config_facts_for_enabled_modules,
7};
8use headless_lms_models::credit_registration_admin_actions::{
9 CreditRegistrationAdminAction, CreditRegistrationAdminActionTarget, GLOBAL_ADMIN_ROLE,
10 NewCreditRegistrationAdminAction,
11};
12use headless_lms_models::credit_registrations::{self, CreditRegistrationErrorCode};
13use headless_lms_models::library::credit_registration::config_validation::check_module_config;
14use utoipa::ToSchema;
15
16use crate::prelude::*;
17
18use super::{authorize_credit_registration_admin, required_reason};
19
20const COURSES_LIMIT: i64 = 2_000;
23
24#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
30pub struct CreditRegistrationCourseConfigCheck {
31 pub course_code_resolves: Option<bool>,
32 pub product_token_found: Option<bool>,
33 pub message: Option<String>,
35}
36
37#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
38pub struct CreditRegistrationCourseStats {
39 pub course_id: Uuid,
40 pub course_name: String,
41 pub course_module_id: Uuid,
42 pub course_module_name: Option<String>,
43 pub uh_course_code: Option<String>,
44 pub ects_credits: Option<f32>,
45 pub open_university_product_id: Option<String>,
46 pub grade_scale_id: Option<String>,
48 pub old_flow_also_enabled: bool,
50 pub paused_at: Option<DateTime<Utc>>,
51 pub pause_reason: Option<String>,
52 pub active_realisation_count: i64,
53 pub last_listed_at: Option<DateTime<Utc>>,
54 pub check: CreditRegistrationCourseConfigCheck,
57 pub config_checked_at: Option<DateTime<Utc>>,
60 pub stored_config_check_message: Option<String>,
62 pub eligible_completion_count: i64,
65 pub registration_count: i64,
66 pub success_count: i64,
67 pub in_flight_count: i64,
68 pub failed_count: i64,
69 pub needs_admin_attention_count: i64,
70 pub last_registered_at: Option<DateTime<Utc>>,
71 pub top_error_code: Option<CreditRegistrationErrorCode>,
72}
73
74#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
75pub struct CreditRegistrationStatsByCourse {
76 pub modules: Vec<CreditRegistrationCourseStats>,
77 pub misconfigured_count: i64,
79}
80
81#[derive(Debug, Deserialize, ToSchema)]
82pub struct AdminPauseCourseModulePayload {
83 pub reason: String,
84}
85
86#[derive(Debug, Deserialize, ToSchema)]
87pub struct AdminResumeCourseModulePayload {
88 pub reason: Option<String>,
89}
90
91#[instrument(skip(pool))]
96#[utoipa::path(
97 get,
98 path = "/courses",
99 operation_id = "getCreditRegistrationStatsByCourse",
100 tag = "credit-registration-admin",
101 responses(
102 (status = 200, description = "One row per enabled course module", body = CreditRegistrationStatsByCourse)
103 )
104)]
105pub async fn get_credit_registration_stats_by_course(
106 user: AuthUser,
107 pool: web::Data<PgPool>,
108) -> ControllerResult<web::Json<CreditRegistrationStatsByCourse>> {
109 let mut conn = pool.acquire().await?;
110 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
111
112 let overviews =
113 course_module_suotar_configurations::get_module_overviews(&mut conn, COURSES_LIMIT).await?;
114 let mut checks: HashMap<Uuid, CreditRegistrationCourseConfigCheck> =
115 get_config_facts_for_enabled_modules(&mut conn, None)
116 .await?
117 .into_iter()
118 .map(|facts| {
119 let check = check_module_config(&facts);
120 (
121 facts.course_module_id,
122 CreditRegistrationCourseConfigCheck {
123 course_code_resolves: check.course_code_resolves,
124 product_token_found: check.product_token_found,
125 message: check.message,
126 },
127 )
128 })
129 .collect();
130 let mut totals: HashMap<Uuid, _> = credit_registrations::count_by_module(&mut conn)
131 .await?
132 .into_iter()
133 .map(|row| (row.course_module_id, row))
134 .collect();
135
136 let modules: Vec<CreditRegistrationCourseStats> = overviews
137 .into_iter()
138 .map(|overview| {
139 let module_id = overview.course_module_id;
140 to_course_stats(
141 overview,
142 checks
143 .remove(&module_id)
144 .unwrap_or(CreditRegistrationCourseConfigCheck {
145 course_code_resolves: None,
146 product_token_found: None,
147 message: None,
148 }),
149 totals.remove(&module_id),
150 )
151 })
152 .collect();
153
154 token.authorized_ok(web::Json(CreditRegistrationStatsByCourse {
155 misconfigured_count: modules
156 .iter()
157 .filter(|row| row.check.message.is_some())
158 .count() as i64,
159 modules,
160 }))
161}
162
163#[instrument(skip(pool, payload))]
171#[utoipa::path(
172 post,
173 path = "/courses/{course_module_id}/pause",
174 operation_id = "adminPauseCourseModuleCreditRegistration",
175 tag = "credit-registration-admin",
176 params(("course_module_id" = Uuid, Path, description = "Course module id")),
177 request_body = AdminPauseCourseModulePayload,
178 responses(
179 (status = 200, description = "Paused"),
180 (status = 422, description = "No reason given, or the module has no Suotar configuration")
181 )
182)]
183pub async fn admin_pause_course_module_credit_registration(
184 user: AuthUser,
185 pool: web::Data<PgPool>,
186 course_module_id: web::Path<Uuid>,
187 payload: web::Json<AdminPauseCourseModulePayload>,
188) -> ControllerResult<web::Json<bool>> {
189 let mut conn = pool.acquire().await?;
190 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
191
192 let reason = required_reason(&payload.reason)?;
193 let module_id = *course_module_id;
194 require_suotar_configuration(&mut conn, module_id).await?;
195
196 let mut tx = conn.begin().await?;
197 course_module_suotar_configurations::set_paused(
198 &mut tx,
199 module_id,
200 Some(course_module_suotar_configurations::SuotarPause {
201 paused_at: Utc::now(),
202 paused_by_user_id: user.id,
203 reason: Some(reason),
204 }),
205 )
206 .await?;
207 record_module_action(
208 &mut tx,
209 CreditRegistrationAdminAction::PauseCourseModule,
210 module_id,
211 user.id,
212 Some(reason),
213 )
214 .await?;
215 tx.commit().await?;
216
217 token.authorized_ok(web::Json(true))
218}
219
220#[instrument(skip(pool, payload))]
225#[utoipa::path(
226 post,
227 path = "/courses/{course_module_id}/resume",
228 operation_id = "adminResumeCourseModuleCreditRegistration",
229 tag = "credit-registration-admin",
230 params(("course_module_id" = Uuid, Path, description = "Course module id")),
231 request_body = AdminResumeCourseModulePayload,
232 responses(
233 (status = 200, description = "Resumed"),
234 (status = 422, description = "The module has no Suotar configuration")
235 )
236)]
237pub async fn admin_resume_course_module_credit_registration(
238 user: AuthUser,
239 pool: web::Data<PgPool>,
240 course_module_id: web::Path<Uuid>,
241 payload: web::Json<AdminResumeCourseModulePayload>,
242) -> ControllerResult<web::Json<bool>> {
243 let mut conn = pool.acquire().await?;
244 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
245
246 let module_id = *course_module_id;
247 require_suotar_configuration(&mut conn, module_id).await?;
248
249 let mut tx = conn.begin().await?;
250 course_module_suotar_configurations::set_paused(&mut tx, module_id, None).await?;
251 record_module_action(
252 &mut tx,
253 CreditRegistrationAdminAction::ResumeCourseModule,
254 module_id,
255 user.id,
256 payload.reason.as_deref(),
257 )
258 .await?;
259 tx.commit().await?;
260
261 token.authorized_ok(web::Json(true))
262}
263
264async fn require_suotar_configuration(
267 conn: &mut PgConnection,
268 course_module_id: Uuid,
269) -> Result<(), ControllerError> {
270 if course_module_suotar_configurations::exists(conn, course_module_id).await? {
271 return Ok(());
272 }
273 Err(controller_err!(
274 BadRequest,
275 "This course module has no credit registration configuration.".to_string()
276 ))
277}
278
279async fn record_module_action(
280 tx: &mut PgConnection,
281 action: CreditRegistrationAdminAction,
282 course_module_id: Uuid,
283 actor_user_id: Uuid,
284 reason: Option<&str>,
285) -> Result<(), ControllerError> {
286 models::credit_registration_admin_actions::record(
287 tx,
288 &NewCreditRegistrationAdminAction {
289 target_id: Some(course_module_id),
290 reason: reason.map(str::to_string),
291 ..NewCreditRegistrationAdminAction::new(
292 action,
293 CreditRegistrationAdminActionTarget::CourseModule,
294 actor_user_id,
295 GLOBAL_ADMIN_ROLE,
296 )
297 },
298 )
299 .await?;
300 Ok(())
301}
302
303fn to_course_stats(
304 overview: SuotarModuleOverview,
305 check: CreditRegistrationCourseConfigCheck,
306 totals: Option<credit_registrations::ModuleRegistrationTotals>,
307) -> CreditRegistrationCourseStats {
308 CreditRegistrationCourseStats {
309 course_id: overview.course_id,
310 course_name: overview.course_name,
311 course_module_id: overview.course_module_id,
312 course_module_name: overview.course_module_name,
313 uh_course_code: overview.uh_course_code,
314 ects_credits: overview.ects_credits,
315 open_university_product_id: overview.open_university_product_id,
316 grade_scale_id: overview.grade_scale_id,
317 old_flow_also_enabled: overview.old_flow_also_enabled,
318 paused_at: overview.paused_at,
319 pause_reason: overview.pause_reason,
320 active_realisation_count: overview.active_realisation_count,
321 last_listed_at: overview.last_listed_at,
322 check,
323 config_checked_at: overview.config_checked_at,
324 stored_config_check_message: overview.config_check_message,
325 eligible_completion_count: overview.eligible_completion_count,
326 registration_count: totals.as_ref().map_or(0, |row| row.total_count),
327 success_count: totals.as_ref().map_or(0, |row| row.success_count),
328 in_flight_count: totals.as_ref().map_or(0, |row| row.in_flight_count),
329 failed_count: totals.as_ref().map_or(0, |row| row.failed_count),
330 needs_admin_attention_count: totals
331 .as_ref()
332 .map_or(0, |row| row.needs_admin_attention_count),
333 last_registered_at: totals.as_ref().and_then(|row| row.last_registered_at),
334 top_error_code: totals.and_then(|row| row.top_error_code),
335 }
336}
337
338pub fn _add_routes(cfg: &mut ServiceConfig) {
339 cfg.route(
340 "/courses",
341 web::get().to(get_credit_registration_stats_by_course),
342 )
343 .route(
344 "/courses/{course_module_id}/pause",
345 web::post().to(admin_pause_course_module_credit_registration),
346 )
347 .route(
348 "/courses/{course_module_id}/resume",
349 web::post().to(admin_resume_course_module_credit_registration),
350 );
351}