Skip to main content

headless_lms_server/controllers/main_frontend/credit_registration_admin/
courses.rs

1//! The Courses tab: one row per Suotar-enabled course module, with its configuration validated.
2
3use 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
20/// Suotar-enabled modules, comfortably above any real deployment's count. The only admin tab
21/// without a page or a limit of its own before this.
22const COURSES_LIMIT: i64 = 2_000;
23
24/// What the configuration check concluded about one module, freshly derived from the same facts and
25/// the same rule the `config-validation` phase uses.
26///
27/// `course_code_resolves` is `None` while no listing has been attempted: never checked is not the
28/// same as checked and failed, and the two must not render alike.
29#[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    /// Every problem found, in one line. `None` means the module is fine.
34    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    /// The module's override; `None` means the scale is derived from the completion.
47    pub grade_scale_id: Option<String>,
48    /// The old pull path is on as well, which would register the same completion twice.
49    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    /// What the current facts say. Recomputed on read, so a configuration fixed a minute ago no
55    /// longer shows as broken.
56    pub check: CreditRegistrationCourseConfigCheck,
57    /// When the `config-validation` phase last stamped its verdict on the row. `None` means never,
58    /// which the stored verdict beside it cannot express on its own.
59    pub config_checked_at: Option<DateTime<Utc>>,
60    /// The verdict as the phase stored it, which may be older than `check`.
61    pub stored_config_check_message: Option<String>,
62    /// Completions `materialize` would take. Against `registration_count` this is the backfill
63    /// progress: a gap that stops closing is the actionable signal.
64    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    /// Modules whose current facts fail the check, which is the tab badge.
78    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/**
92GET `/api/v0/main-frontend/credit-registration-admin/courses` - Every Suotar-enabled course module,
93its validated configuration and its volumes.
94*/
95#[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/**
164POST `/api/v0/main-frontend/credit-registration-admin/courses/{course_module_id}/pause` - Stops every
165phase from claiming this module's rows.
166
167Freezes the rows where they stand rather than cancelling them, so resuming picks up what was already
168in flight.
169*/
170#[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/**
221POST `/api/v0/main-frontend/credit-registration-admin/courses/{course_module_id}/resume` - Lets the
222phases claim this module's rows again.
223*/
224#[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
264/// A module with no configuration row has nothing to pause: `set_paused` would update no rows and
265/// the call would report a pause that never happened.
266async 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}