Skip to main content

headless_lms_server/controllers/main_frontend/credit_registration_admin/
courses.rs

1//! The Courses tab: one row per course module with a course code, with its configuration
2//! validated.
3
4use 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
27/// Modules with a course code, comfortably above any real deployment's count. The only admin tab
28/// without a page or a limit of its own before this.
29const COURSES_LIMIT: i64 = 2_000;
30
31/// What the configuration check concluded about one module, freshly derived from the same facts and
32/// the same rule the `config-validation` phase uses, with the course code verdict Suotar gave that
33/// phase.
34///
35/// `course_code_allowed` is `None` while Suotar has given no verdict on the current course code:
36/// never checked is not the same as checked and failed, and the two must not render alike.
37#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
38pub struct CreditRegistrationCourseConfigCheck {
39    pub course_code_allowed: Option<bool>,
40    /// Every problem found, in one line. `None` means the module is fine.
41    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    /// Where a student with no usable enrolment is sent to enrol.
53    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    /// What the current facts say. Recomputed on read, so a configuration fixed a minute ago no
58    /// longer shows as broken.
59    pub check: CreditRegistrationCourseConfigCheck,
60    /// When the `config-validation` phase last stamped its verdict on the row. `None` means never,
61    /// which the stored verdict beside it cannot express on its own.
62    pub config_checked_at: Option<DateTime<Utc>>,
63    /// The verdict as the phase stored it, which may be older than `check`.
64    pub stored_config_check_message: Option<String>,
65    /// Every passed, ECTS-eligible completion on the module, whichever path owns it. Wider than
66    /// what `materialize` takes, which is only the ones carrying `register_credits_via_suotar`, so
67    /// a module keeps a permanent gap against `registration_count` for the completions that predate
68    /// the push path.
69    pub eligible_completion_count: i64,
70    pub registration_count: i64,
71    /// Registered, already in Sisu and better grade in Sisu: the denominator the failure rate is
72    /// taken against, with `failed_count`.
73    pub success_count: i64,
74    /// Of `success_count`, registered by us.
75    pub registered_count: i64,
76    /// Of `success_count`, already in Sisu or with a better grade there.
77    pub already_in_sisu_count: i64,
78    pub in_flight_count: i64,
79    pub failed_count: i64,
80    /// The module's rows counted in Needs attention: the Registrations list with
81    /// `course_module_id` and `needs_attention=true` holds exactly these.
82    pub needs_attention_count: i64,
83    /// The module's "Where registrations stand", Not started left out; each count equals the
84    /// Registrations list filtered by its step, engagement and `course_module_id`.
85    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    /// Modules whose current facts fail the check, which is the tab badge.
94    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/**
108GET `/api/v0/main-frontend/credit-registration-admin/courses` - Every course module with a course code,
109its validated configuration and its volumes.
110*/
111#[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/**
199POST `/api/v0/main-frontend/credit-registration-admin/courses/{course_module_id}/pause` - Stops every
200phase from claiming this module's rows.
201
202Freezes the rows where they stand rather than cancelling them, so resuming picks up what was already
203in flight.
204*/
205#[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/**
256POST `/api/v0/main-frontend/credit-registration-admin/courses/{course_module_id}/resume` - Lets the
257phases claim this module's rows again.
258*/
259#[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
299/// A module with no configuration row has nothing to pause: `set_paused` would update no rows and
300/// the call would report a pause that never happened.
301async 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}