Skip to main content

headless_lms_server/controllers/main_frontend/credit_registration_admin/
history.rs

1//! History: queue depth per state per day and the flow through each, and per timeline step with
2//! the Needs attention count.
3//!
4//! Reads the daily snapshots the `ledger-snapshot` phase writes. The ledger holds current state
5//! only, so a row that passed through a state in an hour leaves no depth trace there — history
6//! cannot be reconstructed from it, which is why the snapshots exist.
7
8use chrono::{Duration, NaiveDate};
9use headless_lms_models::credit_registration_daily_snapshots;
10use headless_lms_models::credit_registrations::CreditRegistrationState;
11use headless_lms_models::library::credit_registration::timeline::{
12    Engagement, TimelinePhase, TimelineStep,
13};
14use utoipa::ToSchema;
15
16use crate::prelude::*;
17
18use super::authorize_credit_registration_admin;
19
20const DEFAULT_DAYS: i64 = 30;
21const MAX_DAYS: i64 = 365;
22
23/// One state's depth and flow on one day.
24#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
25pub struct CreditRegistrationHistoryPoint {
26    pub state: CreditRegistrationState,
27    /// Rows in the state when the snapshot was taken, once that day.
28    pub count: i32,
29    /// Transitions into and out of the state during that UTC day. A self-transition is neither.
30    pub entered_count: i32,
31    pub left_count: i32,
32}
33
34/// One timeline step's count on one day.
35#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
36pub struct CreditRegistrationHistoryStepPoint {
37    pub phase: TimelinePhase,
38    pub step: TimelineStep,
39    /// `None` on a step that does not wait on the student.
40    pub engagement: Option<Engagement>,
41    pub count: i32,
42}
43
44#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
45pub struct CreditRegistrationHistoryDay {
46    /// The UTC day the snapshot describes.
47    pub snapshot_date: NaiveDate,
48    /// Every state, whether or not anything is in it: a missing state would read as a gap in the
49    /// chart rather than as an empty queue.
50    pub states: Vec<CreditRegistrationHistoryPoint>,
51    /// Every step that had live rows, Not started included. Empty on days before step snapshots
52    /// began.
53    pub steps: Vec<CreditRegistrationHistoryStepPoint>,
54    /// `None` on days before the count was snapshotted.
55    pub needs_attention_count: Option<i32>,
56}
57
58#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
59pub struct CreditRegistrationHistory {
60    /// Oldest first. Days with no snapshot are absent rather than zeroed: before the phase first
61    /// ran, and on a day it did not, there is no depth to report.
62    pub days: Vec<CreditRegistrationHistoryDay>,
63    pub from: NaiveDate,
64    pub to: NaiveDate,
65}
66
67#[derive(Debug, Deserialize)]
68pub struct HistoryQuery {
69    days: Option<i64>,
70}
71
72/**
73GET `/api/v0/main-frontend/credit-registration-admin/pipeline-history` - Daily queue depth per
74ledger state, with what entered and left each state that day, and the count per timeline step and
75the Needs attention count from the day those snapshots began.
76*/
77#[instrument(skip(pool))]
78#[utoipa::path(
79    get,
80    path = "/pipeline-history",
81    operation_id = "getCreditRegistrationPipelineHistory",
82    tag = "credit-registration-admin",
83    params(("days" = Option<i64>, Query, description = "How many days back to read, today included")),
84    responses(
85        (status = 200, description = "One entry per day that has a snapshot", body = CreditRegistrationHistory)
86    )
87)]
88pub async fn get_credit_registration_pipeline_history(
89    user: AuthUser,
90    pool: web::Data<PgPool>,
91    query: web::Query<HistoryQuery>,
92) -> ControllerResult<web::Json<CreditRegistrationHistory>> {
93    let mut conn = pool.acquire().await?;
94    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
95
96    let days = query.days.unwrap_or(DEFAULT_DAYS).clamp(1, MAX_DAYS);
97    let to = Utc::now().date_naive();
98    let from = to - Duration::days(days - 1);
99
100    let mut history: Vec<CreditRegistrationHistoryDay> = Vec::new();
101    for row in credit_registration_daily_snapshots::get_between(&mut conn, from, to).await? {
102        // The model orders by date then state, so a day's rows arrive together.
103        if history
104            .last()
105            .is_none_or(|day| day.snapshot_date != row.snapshot_date)
106        {
107            history.push(CreditRegistrationHistoryDay {
108                snapshot_date: row.snapshot_date,
109                states: Vec::new(),
110                steps: Vec::new(),
111                needs_attention_count: None,
112            });
113        }
114        if let Some(day) = history.last_mut() {
115            day.states.push(CreditRegistrationHistoryPoint {
116                state: row.state,
117                count: row.count,
118                entered_count: row.entered_count,
119                left_count: row.left_count,
120            });
121        }
122    }
123
124    for row in
125        credit_registration_daily_snapshots::get_step_counts_between(&mut conn, from, to).await?
126    {
127        if let Some(day) = history
128            .iter_mut()
129            .find(|day| day.snapshot_date == row.snapshot_date)
130        {
131            day.steps.push(CreditRegistrationHistoryStepPoint {
132                phase: row.step.phase(),
133                step: row.step,
134                engagement: row.engagement,
135                count: row.count,
136            });
137        }
138    }
139    for (snapshot_date, count) in
140        credit_registration_daily_snapshots::get_attention_counts_between(&mut conn, from, to)
141            .await?
142    {
143        if let Some(day) = history
144            .iter_mut()
145            .find(|day| day.snapshot_date == snapshot_date)
146        {
147            day.needs_attention_count = Some(count);
148        }
149    }
150
151    token.authorized_ok(web::Json(CreditRegistrationHistory {
152        days: history,
153        from,
154        to,
155    }))
156}
157
158pub fn _add_routes(cfg: &mut ServiceConfig) {
159    cfg.route(
160        "/pipeline-history",
161        web::get().to(get_credit_registration_pipeline_history),
162    );
163}