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::{
14 CourseCodeVerdict, check_module_config,
15};
16use utoipa::ToSchema;
17
18use crate::prelude::*;
19
20use super::{authorize_credit_registration_admin, required_reason};
21
22const COURSES_LIMIT: i64 = 2_000;
25
26#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
33pub struct CreditRegistrationCourseConfigCheck {
34 pub course_code_allowed: Option<bool>,
35 pub message: Option<String>,
37}
38
39#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
40pub struct CreditRegistrationCourseStats {
41 pub course_id: Uuid,
42 pub course_name: String,
43 pub course_module_id: Uuid,
44 pub course_module_name: Option<String>,
45 pub uh_course_code: Option<String>,
46 pub ects_credits: Option<f32>,
47 pub enrolment_link: Option<String>,
49 pub paused_at: Option<DateTime<Utc>>,
50 pub pause_reason: Option<String>,
51 pub last_listed_at: Option<DateTime<Utc>>,
52 pub check: CreditRegistrationCourseConfigCheck,
55 pub config_checked_at: Option<DateTime<Utc>>,
58 pub stored_config_check_message: Option<String>,
60 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, CourseCodeVerdict::stored(&facts).as_ref());
120 (
121 facts.course_module_id,
122 CreditRegistrationCourseConfigCheck {
123 course_code_allowed: check.course_code_allowed,
124 message: check.message,
125 },
126 )
127 })
128 .collect();
129 let mut totals: HashMap<Uuid, _> = credit_registrations::count_by_module(&mut conn)
130 .await?
131 .into_iter()
132 .map(|row| (row.course_module_id, row))
133 .collect();
134
135 let modules: Vec<CreditRegistrationCourseStats> = overviews
136 .into_iter()
137 .map(|overview| {
138 let module_id = overview.course_module_id;
139 to_course_stats(
140 overview,
141 checks
142 .remove(&module_id)
143 .unwrap_or(CreditRegistrationCourseConfigCheck {
144 course_code_allowed: None,
145 message: None,
146 }),
147 totals.remove(&module_id),
148 )
149 })
150 .collect();
151
152 token.authorized_ok(web::Json(CreditRegistrationStatsByCourse {
153 misconfigured_count: modules
154 .iter()
155 .filter(|row| row.check.message.is_some())
156 .count() as i64,
157 modules,
158 }))
159}
160
161#[instrument(skip(pool, payload))]
169#[utoipa::path(
170 post,
171 path = "/courses/{course_module_id}/pause",
172 operation_id = "adminPauseCourseModuleCreditRegistration",
173 tag = "credit-registration-admin",
174 params(("course_module_id" = Uuid, Path, description = "Course module id")),
175 request_body = AdminPauseCourseModulePayload,
176 responses(
177 (status = 200, description = "Paused"),
178 (status = 422, description = "No reason given, or the module has no Suotar configuration")
179 )
180)]
181pub async fn admin_pause_course_module_credit_registration(
182 user: AuthUser,
183 pool: web::Data<PgPool>,
184 course_module_id: web::Path<Uuid>,
185 payload: web::Json<AdminPauseCourseModulePayload>,
186) -> ControllerResult<web::Json<bool>> {
187 let mut conn = pool.acquire().await?;
188 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
189
190 let reason = required_reason(&payload.reason)?;
191 let module_id = *course_module_id;
192 require_suotar_configuration(&mut conn, module_id).await?;
193
194 let mut tx = conn.begin().await?;
195 course_module_suotar_configurations::set_paused(
196 &mut tx,
197 module_id,
198 Some(course_module_suotar_configurations::SuotarPause {
199 paused_at: Utc::now(),
200 paused_by_user_id: user.id,
201 reason: Some(reason),
202 }),
203 )
204 .await?;
205 record_module_action(
206 &mut tx,
207 CreditRegistrationAdminAction::PauseCourseModule,
208 module_id,
209 user.id,
210 Some(reason),
211 )
212 .await?;
213 tx.commit().await?;
214
215 token.authorized_ok(web::Json(true))
216}
217
218#[instrument(skip(pool, payload))]
223#[utoipa::path(
224 post,
225 path = "/courses/{course_module_id}/resume",
226 operation_id = "adminResumeCourseModuleCreditRegistration",
227 tag = "credit-registration-admin",
228 params(("course_module_id" = Uuid, Path, description = "Course module id")),
229 request_body = AdminResumeCourseModulePayload,
230 responses(
231 (status = 200, description = "Resumed"),
232 (status = 422, description = "The module has no Suotar configuration")
233 )
234)]
235pub async fn admin_resume_course_module_credit_registration(
236 user: AuthUser,
237 pool: web::Data<PgPool>,
238 course_module_id: web::Path<Uuid>,
239 payload: web::Json<AdminResumeCourseModulePayload>,
240) -> ControllerResult<web::Json<bool>> {
241 let mut conn = pool.acquire().await?;
242 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
243
244 let module_id = *course_module_id;
245 require_suotar_configuration(&mut conn, module_id).await?;
246
247 let mut tx = conn.begin().await?;
248 course_module_suotar_configurations::set_paused(&mut tx, module_id, None).await?;
249 record_module_action(
250 &mut tx,
251 CreditRegistrationAdminAction::ResumeCourseModule,
252 module_id,
253 user.id,
254 payload.reason.as_deref(),
255 )
256 .await?;
257 tx.commit().await?;
258
259 token.authorized_ok(web::Json(true))
260}
261
262async fn require_suotar_configuration(
265 conn: &mut PgConnection,
266 course_module_id: Uuid,
267) -> Result<(), ControllerError> {
268 if course_module_suotar_configurations::exists(conn, course_module_id).await? {
269 return Ok(());
270 }
271 Err(controller_err!(
272 BadRequest,
273 "This course module has no credit registration configuration.".to_string()
274 ))
275}
276
277async fn record_module_action(
278 tx: &mut PgConnection,
279 action: CreditRegistrationAdminAction,
280 course_module_id: Uuid,
281 actor_user_id: Uuid,
282 reason: Option<&str>,
283) -> Result<(), ControllerError> {
284 models::credit_registration_admin_actions::record(
285 tx,
286 &NewCreditRegistrationAdminAction {
287 target_id: Some(course_module_id),
288 reason: reason.map(str::to_string),
289 ..NewCreditRegistrationAdminAction::new(
290 action,
291 CreditRegistrationAdminActionTarget::CourseModule,
292 actor_user_id,
293 GLOBAL_ADMIN_ROLE,
294 )
295 },
296 )
297 .await?;
298 Ok(())
299}
300
301fn to_course_stats(
302 overview: SuotarModuleOverview,
303 check: CreditRegistrationCourseConfigCheck,
304 totals: Option<credit_registrations::ModuleRegistrationTotals>,
305) -> CreditRegistrationCourseStats {
306 CreditRegistrationCourseStats {
307 course_id: overview.course_id,
308 course_name: overview.course_name,
309 course_module_id: overview.course_module_id,
310 course_module_name: overview.course_module_name,
311 uh_course_code: overview.uh_course_code,
312 ects_credits: overview.ects_credits,
313 enrolment_link: overview.enrolment_link,
314 paused_at: overview.paused_at,
315 pause_reason: overview.pause_reason,
316 last_listed_at: overview.last_listed_at,
317 check,
318 config_checked_at: overview.config_checked_at,
319 stored_config_check_message: overview.config_check_message,
320 eligible_completion_count: overview.eligible_completion_count,
321 registration_count: totals.as_ref().map_or(0, |row| row.total_count),
322 success_count: totals.as_ref().map_or(0, |row| row.success_count),
323 in_flight_count: totals.as_ref().map_or(0, |row| row.in_flight_count),
324 failed_count: totals.as_ref().map_or(0, |row| row.failed_count),
325 needs_admin_attention_count: totals
326 .as_ref()
327 .map_or(0, |row| row.needs_admin_attention_count),
328 last_registered_at: totals.as_ref().and_then(|row| row.last_registered_at),
329 top_error_code: totals.and_then(|row| row.top_error_code),
330 }
331}
332
333pub fn _add_routes(cfg: &mut ServiceConfig) {
334 cfg.route(
335 "/courses",
336 web::get().to(get_credit_registration_stats_by_course),
337 )
338 .route(
339 "/courses/{course_module_id}/pause",
340 web::post().to(admin_pause_course_module_credit_registration),
341 )
342 .route(
343 "/courses/{course_module_id}/resume",
344 web::post().to(admin_resume_course_module_credit_registration),
345 );
346}