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::{
14    CourseCodeVerdict, check_module_config,
15};
16use utoipa::ToSchema;
17
18use crate::prelude::*;
19
20use super::{authorize_credit_registration_admin, required_reason};
21
22/// Suotar-enabled modules, comfortably above any real deployment's count. The only admin tab
23/// without a page or a limit of its own before this.
24const COURSES_LIMIT: i64 = 2_000;
25
26/// What the configuration check concluded about one module, freshly derived from the same facts and
27/// the same rule the `config-validation` phase uses, with the course code verdict Suotar gave that
28/// phase.
29///
30/// `course_code_allowed` is `None` while Suotar has given no verdict on the current course code:
31/// never checked is not the same as checked and failed, and the two must not render alike.
32#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
33pub struct CreditRegistrationCourseConfigCheck {
34    pub course_code_allowed: Option<bool>,
35    /// Every problem found, in one line. `None` means the module is fine.
36    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    /// Where a student with no usable enrolment is sent to enrol.
48    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    /// What the current facts say. Recomputed on read, so a configuration fixed a minute ago no
53    /// longer shows as broken.
54    pub check: CreditRegistrationCourseConfigCheck,
55    /// When the `config-validation` phase last stamped its verdict on the row. `None` means never,
56    /// which the stored verdict beside it cannot express on its own.
57    pub config_checked_at: Option<DateTime<Utc>>,
58    /// The verdict as the phase stored it, which may be older than `check`.
59    pub stored_config_check_message: Option<String>,
60    /// Every passed, ECTS-eligible completion on the module, whichever path owns it. Wider than
61    /// what `materialize` takes, which is only the ones carrying `register_credits_via_suotar`, so
62    /// a module opted in mid-course keeps a permanent gap against `registration_count` for the
63    /// completions that predate the opt-in.
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, 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/**
162POST `/api/v0/main-frontend/credit-registration-admin/courses/{course_module_id}/pause` - Stops every
163phase from claiming this module's rows.
164
165Freezes the rows where they stand rather than cancelling them, so resuming picks up what was already
166in flight.
167*/
168#[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/**
219POST `/api/v0/main-frontend/credit-registration-admin/courses/{course_module_id}/resume` - Lets the
220phases claim this module's rows again.
221*/
222#[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
262/// A module with no configuration row has nothing to pause: `set_paused` would update no rows and
263/// the call would report a pause that never happened.
264async 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}