1use std::collections::HashMap;
5
6use headless_lms_base::config::ApplicationConfiguration;
7use headless_lms_models::course_module_suotar_configurations::{
8 self, SuotarModuleOverview, get_config_facts_for_modules_with_course_code,
9};
10use headless_lms_models::credit_registration_admin_actions::{
11 CreditRegistrationAdminAction, CreditRegistrationAdminActionTarget, GLOBAL_ADMIN_ROLE,
12 NewCreditRegistrationAdminAction,
13};
14use headless_lms_models::credit_registrations::{
15 self, AttentionStanding, CreditRegistrationErrorCode,
16};
17use headless_lms_models::library::credit_registration::config_validation::{
18 CourseCodeVerdict, check_module_config,
19};
20use utoipa::ToSchema;
21
22use crate::prelude::*;
23
24use super::dashboard::{CreditRegistrationStepCount, sum_step_counts};
25use super::{attention_rules, authorize_credit_registration_admin, required_reason};
26
27const COURSES_LIMIT: i64 = 2_000;
30
31#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
38pub struct CreditRegistrationCourseConfigCheck {
39 pub course_code_allowed: Option<bool>,
40 pub message: Option<String>,
42}
43
44#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
45pub struct CreditRegistrationCourseStats {
46 pub course_id: Uuid,
47 pub course_name: String,
48 pub course_module_id: Uuid,
49 pub course_module_name: Option<String>,
50 pub uh_course_code: Option<String>,
51 pub ects_credits: Option<f32>,
52 pub enrolment_link: Option<String>,
54 pub paused_at: Option<DateTime<Utc>>,
55 pub pause_reason: Option<String>,
56 pub last_listed_at: Option<DateTime<Utc>>,
57 pub check: CreditRegistrationCourseConfigCheck,
60 pub config_checked_at: Option<DateTime<Utc>>,
63 pub stored_config_check_message: Option<String>,
65 pub eligible_completion_count: i64,
70 pub registration_count: i64,
71 pub success_count: i64,
74 pub registered_count: i64,
76 pub already_in_sisu_count: i64,
78 pub in_flight_count: i64,
79 pub failed_count: i64,
80 pub needs_attention_count: i64,
83 pub where_registrations_stand: Vec<CreditRegistrationStepCount>,
86 pub last_registered_at: Option<DateTime<Utc>>,
87 pub top_error_code: Option<CreditRegistrationErrorCode>,
88}
89
90#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
91pub struct CreditRegistrationStatsByCourse {
92 pub modules: Vec<CreditRegistrationCourseStats>,
93 pub misconfigured_count: i64,
95}
96
97#[derive(Debug, Deserialize, ToSchema)]
98pub struct AdminPauseCourseModulePayload {
99 pub reason: String,
100}
101
102#[derive(Debug, Deserialize, ToSchema)]
103pub struct AdminResumeCourseModulePayload {
104 pub reason: Option<String>,
105}
106
107#[instrument(skip(pool, app_conf))]
112#[utoipa::path(
113 get,
114 path = "/courses",
115 operation_id = "getCreditRegistrationStatsByCourse",
116 tag = "credit-registration-admin",
117 responses(
118 (status = 200, description = "One row per course module with a course code", body = CreditRegistrationStatsByCourse)
119 )
120)]
121pub async fn get_credit_registration_stats_by_course(
122 user: AuthUser,
123 pool: web::Data<PgPool>,
124 app_conf: web::Data<ApplicationConfiguration>,
125) -> ControllerResult<web::Json<CreditRegistrationStatsByCourse>> {
126 let mut conn = pool.acquire().await?;
127 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
128
129 let overviews =
130 course_module_suotar_configurations::get_module_overviews(&mut conn, COURSES_LIMIT).await?;
131 let mut checks: HashMap<Uuid, CreditRegistrationCourseConfigCheck> =
132 get_config_facts_for_modules_with_course_code(&mut conn, None)
133 .await?
134 .into_iter()
135 .map(|facts| {
136 let check = check_module_config(&facts, CourseCodeVerdict::stored(&facts).as_ref());
137 (
138 facts.course_module_id,
139 CreditRegistrationCourseConfigCheck {
140 course_code_allowed: check.course_code_allowed,
141 message: check.message,
142 },
143 )
144 })
145 .collect();
146 let mut totals: HashMap<Uuid, _> = credit_registrations::count_by_module(&mut conn)
147 .await?
148 .into_iter()
149 .map(|row| (row.course_module_id, row))
150 .collect();
151
152 let rules = attention_rules(&mut conn, &app_conf).await?;
153 let mut needs_attention: HashMap<Uuid, i64> = HashMap::new();
154 for row in credit_registrations::get_attention_items(&mut conn, &rules, None).await? {
155 if row.standing(&rules.blocking) == AttentionStanding::NeedsAttention {
156 *needs_attention.entry(row.course_module_id).or_insert(0) += 1;
157 }
158 }
159 let step_counts = credit_registrations::count_by_step_and_engagement(
160 &mut conn,
161 app_conf.suotar_configuration.account_linking_since,
162 )
163 .await?;
164
165 let modules: Vec<CreditRegistrationCourseStats> = overviews
166 .into_iter()
167 .map(|overview| {
168 let module_id = overview.course_module_id;
169 let mut stats = to_course_stats(
170 overview,
171 checks
172 .remove(&module_id)
173 .unwrap_or(CreditRegistrationCourseConfigCheck {
174 course_code_allowed: None,
175 message: None,
176 }),
177 totals.remove(&module_id),
178 );
179 stats.needs_attention_count = needs_attention.remove(&module_id).unwrap_or(0);
180 stats.where_registrations_stand = sum_step_counts(
181 step_counts
182 .iter()
183 .filter(|row| row.course_module_id == module_id),
184 );
185 stats
186 })
187 .collect();
188
189 token.authorized_ok(web::Json(CreditRegistrationStatsByCourse {
190 misconfigured_count: modules
191 .iter()
192 .filter(|row| row.check.message.is_some())
193 .count() as i64,
194 modules,
195 }))
196}
197
198#[instrument(skip(pool, payload))]
206#[utoipa::path(
207 post,
208 path = "/courses/{course_module_id}/pause",
209 operation_id = "adminPauseCourseModuleCreditRegistration",
210 tag = "credit-registration-admin",
211 params(("course_module_id" = Uuid, Path, description = "Course module id")),
212 request_body = AdminPauseCourseModulePayload,
213 responses(
214 (status = 200, description = "Paused"),
215 (status = 422, description = "No reason given, or the module has no Suotar configuration")
216 )
217)]
218pub async fn admin_pause_course_module_credit_registration(
219 user: AuthUser,
220 pool: web::Data<PgPool>,
221 course_module_id: web::Path<Uuid>,
222 payload: web::Json<AdminPauseCourseModulePayload>,
223) -> ControllerResult<web::Json<bool>> {
224 let mut conn = pool.acquire().await?;
225 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
226
227 let reason = required_reason(&payload.reason)?;
228 let module_id = *course_module_id;
229 require_suotar_configuration(&mut conn, module_id).await?;
230
231 let mut tx = conn.begin().await?;
232 course_module_suotar_configurations::set_paused(
233 &mut tx,
234 module_id,
235 Some(course_module_suotar_configurations::SuotarPause {
236 paused_at: Utc::now(),
237 paused_by_user_id: user.id,
238 reason: Some(reason),
239 }),
240 )
241 .await?;
242 record_module_action(
243 &mut tx,
244 CreditRegistrationAdminAction::PauseCourseModule,
245 module_id,
246 user.id,
247 Some(reason),
248 )
249 .await?;
250 tx.commit().await?;
251
252 token.authorized_ok(web::Json(true))
253}
254
255#[instrument(skip(pool, payload))]
260#[utoipa::path(
261 post,
262 path = "/courses/{course_module_id}/resume",
263 operation_id = "adminResumeCourseModuleCreditRegistration",
264 tag = "credit-registration-admin",
265 params(("course_module_id" = Uuid, Path, description = "Course module id")),
266 request_body = AdminResumeCourseModulePayload,
267 responses(
268 (status = 200, description = "Resumed"),
269 (status = 422, description = "The module has no Suotar configuration")
270 )
271)]
272pub async fn admin_resume_course_module_credit_registration(
273 user: AuthUser,
274 pool: web::Data<PgPool>,
275 course_module_id: web::Path<Uuid>,
276 payload: web::Json<AdminResumeCourseModulePayload>,
277) -> ControllerResult<web::Json<bool>> {
278 let mut conn = pool.acquire().await?;
279 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
280
281 let module_id = *course_module_id;
282 require_suotar_configuration(&mut conn, module_id).await?;
283
284 let mut tx = conn.begin().await?;
285 course_module_suotar_configurations::set_paused(&mut tx, module_id, None).await?;
286 record_module_action(
287 &mut tx,
288 CreditRegistrationAdminAction::ResumeCourseModule,
289 module_id,
290 user.id,
291 payload.reason.as_deref(),
292 )
293 .await?;
294 tx.commit().await?;
295
296 token.authorized_ok(web::Json(true))
297}
298
299async fn require_suotar_configuration(
302 conn: &mut PgConnection,
303 course_module_id: Uuid,
304) -> Result<(), ControllerError> {
305 if course_module_suotar_configurations::exists(conn, course_module_id).await? {
306 return Ok(());
307 }
308 Err(controller_err!(
309 BadRequest,
310 "This course module has no credit registration configuration.".to_string()
311 ))
312}
313
314async fn record_module_action(
315 tx: &mut PgConnection,
316 action: CreditRegistrationAdminAction,
317 course_module_id: Uuid,
318 actor_user_id: Uuid,
319 reason: Option<&str>,
320) -> Result<(), ControllerError> {
321 models::credit_registration_admin_actions::record(
322 tx,
323 &NewCreditRegistrationAdminAction {
324 target_id: Some(course_module_id),
325 reason: reason.map(str::to_string),
326 ..NewCreditRegistrationAdminAction::new(
327 action,
328 CreditRegistrationAdminActionTarget::CourseModule,
329 actor_user_id,
330 GLOBAL_ADMIN_ROLE,
331 )
332 },
333 )
334 .await?;
335 Ok(())
336}
337
338fn to_course_stats(
339 overview: SuotarModuleOverview,
340 check: CreditRegistrationCourseConfigCheck,
341 totals: Option<credit_registrations::ModuleRegistrationTotals>,
342) -> CreditRegistrationCourseStats {
343 CreditRegistrationCourseStats {
344 course_id: overview.course_id,
345 course_name: overview.course_name,
346 course_module_id: overview.course_module_id,
347 course_module_name: overview.course_module_name,
348 uh_course_code: overview.uh_course_code,
349 ects_credits: overview.ects_credits,
350 enrolment_link: overview.enrolment_link,
351 paused_at: overview.paused_at,
352 pause_reason: overview.pause_reason,
353 last_listed_at: overview.last_listed_at,
354 check,
355 config_checked_at: overview.config_checked_at,
356 stored_config_check_message: overview.config_check_message,
357 eligible_completion_count: overview.eligible_completion_count,
358 registration_count: totals.as_ref().map_or(0, |row| row.total_count),
359 success_count: totals.as_ref().map_or(0, |row| row.success_count),
360 registered_count: totals.as_ref().map_or(0, |row| row.registered_count),
361 already_in_sisu_count: totals.as_ref().map_or(0, |row| row.already_in_sisu_count),
362 in_flight_count: totals.as_ref().map_or(0, |row| row.in_flight_count),
363 failed_count: totals.as_ref().map_or(0, |row| row.failed_count),
364 needs_attention_count: 0,
365 where_registrations_stand: Vec::new(),
366 last_registered_at: totals.as_ref().and_then(|row| row.last_registered_at),
367 top_error_code: totals.and_then(|row| row.top_error_code),
368 }
369}
370
371pub fn _add_routes(cfg: &mut ServiceConfig) {
372 cfg.route(
373 "/courses",
374 web::get().to(get_credit_registration_stats_by_course),
375 )
376 .route(
377 "/courses/{course_module_id}/pause",
378 web::post().to(admin_pause_course_module_credit_registration),
379 )
380 .route(
381 "/courses/{course_module_id}/resume",
382 web::post().to(admin_resume_course_module_credit_registration),
383 );
384}