Skip to main content

headless_lms_models/
credit_registration_daily_snapshots.rs

1//! Daily queue depth per ledger state, and per timeline step with the Needs attention count.
2//!
3//! The ledger holds current state only, so a row that passed through a state in an hour leaves no
4//! depth trace. Aggregates only: anything per-person belongs in the ledger.
5use chrono::NaiveDate;
6use utoipa::ToSchema;
7
8use crate::credit_registrations::{
9    CreditRegistrationState, StepCount, sum_step_counts_over_modules,
10};
11use crate::library::credit_registration::timeline::{Engagement, TimelineStep};
12use crate::prelude::*;
13
14#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
15pub struct CreditRegistrationDailySnapshot {
16    pub id: Uuid,
17    pub created_at: DateTime<Utc>,
18    pub updated_at: DateTime<Utc>,
19    pub deleted_at: Option<DateTime<Utc>>,
20    pub snapshot_date: NaiveDate,
21    pub state: CreditRegistrationState,
22    pub count: i32,
23    pub entered_count: i32,
24    pub left_count: i32,
25}
26
27#[derive(Debug, Clone, PartialEq)]
28pub struct DailyStateCounts {
29    pub state: CreditRegistrationState,
30    pub count: i32,
31    pub entered_count: i32,
32    pub left_count: i32,
33}
34
35/// One row per state, whether or not anything is in it: a state missing from a day would read as a
36/// gap in the chart rather than as an empty queue.
37///
38/// `count` is the depth right now, so this has to be called on the day it describes.
39/// `entered_count`/`left_count` come from the transitions inside `[day_start, day_end)`, which the
40/// caller passes explicitly: `snapshot_date` alone would make the day boundary depend on the
41/// database's timezone.
42pub async fn count_states_for_day(
43    conn: &mut PgConnection,
44    day_start: DateTime<Utc>,
45    day_end: DateTime<Utc>,
46) -> ModelResult<Vec<DailyStateCounts>> {
47    let states = CreditRegistrationState::ALL.to_vec();
48    let res = sqlx::query_as!(
49        DailyStateCounts,
50        r#"
51WITH depth AS (
52  SELECT state,
53    COUNT(*)::int AS count
54  FROM credit_registrations
55  WHERE deleted_at IS NULL
56    -- As in `credit_registrations::count_by_state`, which feeds the funnel and the alerts off the
57    -- same states: counting replaced attempts here would make the two disagree on every course
58    -- that regrades.
59    AND superseded_by_id IS NULL
60  GROUP BY state
61),
62-- A transition writes one event carrying both ends, so entering and leaving are the same rows read
63-- from either side. A self-transition is neither.
64flow AS (
65  SELECT from_state,
66    to_state
67  FROM credit_registration_events
68  WHERE deleted_at IS NULL
69    AND created_at >= $2
70    AND created_at < $3
71    AND from_state IS DISTINCT FROM to_state
72)
73SELECT s.state AS "state!: CreditRegistrationState",
74  COALESCE(depth.count, 0) AS "count!",
75  (
76    SELECT COUNT(*)::int
77    FROM flow
78    WHERE flow.to_state = s.state
79  ) AS "entered_count!",
80  (
81    SELECT COUNT(*)::int
82    FROM flow
83    WHERE flow.from_state = s.state
84  ) AS "left_count!"
85FROM UNNEST($1::credit_registration_state []) AS s(state)
86  LEFT JOIN depth ON depth.state = s.state
87        "#,
88        &states as &[CreditRegistrationState],
89        day_start,
90        day_end,
91    )
92    .fetch_all(conn)
93    .await?;
94    Ok(res)
95}
96
97/// Writes one day's counts. Idempotent, so a re-run cannot double-count.
98pub async fn write_snapshot_for_date(
99    conn: &mut PgConnection,
100    snapshot_date: NaiveDate,
101    counts: &[DailyStateCounts],
102) -> ModelResult<()> {
103    for row in counts {
104        sqlx::query!(
105            r#"
106INSERT INTO credit_registration_daily_snapshots (
107    snapshot_date,
108    state,
109    count,
110    entered_count,
111    left_count
112  )
113VALUES ($1, $2, $3, $4, $5) ON CONFLICT (snapshot_date, state, deleted_at) DO
114UPDATE
115SET count = $3,
116  entered_count = $4,
117  left_count = $5
118            "#,
119            snapshot_date,
120            row.state as CreditRegistrationState,
121            row.count,
122            row.entered_count,
123            row.left_count,
124        )
125        .execute(&mut *conn)
126        .await?;
127    }
128    Ok(())
129}
130
131pub async fn get_between(
132    conn: &mut PgConnection,
133    from: NaiveDate,
134    to: NaiveDate,
135) -> ModelResult<Vec<CreditRegistrationDailySnapshot>> {
136    let res = sqlx::query_as!(
137        CreditRegistrationDailySnapshot,
138        r#"
139SELECT *
140FROM credit_registration_daily_snapshots
141WHERE snapshot_date BETWEEN $1 AND $2
142  AND deleted_at IS NULL
143ORDER BY snapshot_date,
144  state
145        "#,
146        from,
147        to,
148    )
149    .fetch_all(conn)
150    .await?;
151    Ok(res)
152}
153
154/// One timeline step's count on one day.
155#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
156pub struct DailyStepCount {
157    pub snapshot_date: NaiveDate,
158    pub step: TimelineStep,
159    pub engagement: Option<Engagement>,
160    pub count: i32,
161}
162
163/// Writes one day's counts per step and engagement, as
164/// [`count_by_step_and_engagement`](crate::credit_registrations::count_by_step_and_engagement)
165/// returns them summed over modules. Idempotent.
166pub async fn write_step_snapshot_for_date(
167    conn: &mut PgConnection,
168    snapshot_date: NaiveDate,
169    counts: &[StepCount],
170) -> ModelResult<()> {
171    let totals = sum_step_counts_over_modules(counts);
172    let steps: Vec<TimelineStep> = totals.keys().map(|(step, _)| *step).collect();
173    let engagements: Vec<Option<Engagement>> =
174        totals.keys().map(|(_, engagement)| *engagement).collect();
175    let step_counts: Vec<i32> = totals
176        .values()
177        .map(|count| i32::try_from(*count).unwrap_or(i32::MAX))
178        .collect();
179    let mut tx = conn.begin().await?;
180    // A step emptied since an earlier run today must not keep that run's count.
181    sqlx::query!(
182        r#"
183DELETE FROM credit_registration_daily_step_snapshots
184WHERE snapshot_date = $1
185        "#,
186        snapshot_date,
187    )
188    .execute(&mut *tx)
189    .await?;
190    sqlx::query!(
191        r#"
192INSERT INTO credit_registration_daily_step_snapshots (
193    snapshot_date,
194    timeline_step,
195    engagement,
196    count
197  )
198SELECT $1,
199  u.step,
200  u.engagement,
201  u.count
202FROM UNNEST(
203    $2::credit_registration_timeline_step [],
204    $3::credit_registration_engagement [],
205    $4::int []
206  ) AS u(step, engagement, count)
207        "#,
208        snapshot_date,
209        &steps as &[TimelineStep],
210        &engagements as &[Option<Engagement>],
211        &step_counts,
212    )
213    .execute(&mut *tx)
214    .await?;
215    tx.commit().await?;
216    Ok(())
217}
218
219/// Writes one day's Needs attention count. Idempotent.
220pub async fn write_attention_snapshot_for_date(
221    conn: &mut PgConnection,
222    snapshot_date: NaiveDate,
223    needs_attention_count: i64,
224) -> ModelResult<()> {
225    sqlx::query!(
226        r#"
227INSERT INTO credit_registration_daily_attention_snapshots (snapshot_date, needs_attention_count)
228VALUES ($1, $2) ON CONFLICT (snapshot_date, deleted_at) DO
229UPDATE
230SET needs_attention_count = $2
231        "#,
232        snapshot_date,
233        i32::try_from(needs_attention_count).unwrap_or(i32::MAX),
234    )
235    .execute(conn)
236    .await?;
237    Ok(())
238}
239
240/// The step snapshots of every day from `from` to `to`, both included, oldest day first.
241pub async fn get_step_counts_between(
242    conn: &mut PgConnection,
243    from: NaiveDate,
244    to: NaiveDate,
245) -> ModelResult<Vec<DailyStepCount>> {
246    let res = sqlx::query_as!(
247        DailyStepCount,
248        r#"
249SELECT snapshot_date,
250  timeline_step AS "step: TimelineStep",
251  engagement AS "engagement: Engagement",
252  count
253FROM credit_registration_daily_step_snapshots
254WHERE snapshot_date BETWEEN $1 AND $2
255  AND deleted_at IS NULL
256ORDER BY snapshot_date
257        "#,
258        from,
259        to,
260    )
261    .fetch_all(conn)
262    .await?;
263    Ok(res)
264}
265
266/// The Needs attention count per day that has one.
267pub async fn get_attention_counts_between(
268    conn: &mut PgConnection,
269    from: NaiveDate,
270    to: NaiveDate,
271) -> ModelResult<Vec<(NaiveDate, i32)>> {
272    let rows = sqlx::query!(
273        r#"
274SELECT snapshot_date,
275  needs_attention_count
276FROM credit_registration_daily_attention_snapshots
277WHERE snapshot_date BETWEEN $1 AND $2
278  AND deleted_at IS NULL
279ORDER BY snapshot_date
280        "#,
281        from,
282        to,
283    )
284    .fetch_all(conn)
285    .await?;
286    Ok(rows
287        .into_iter()
288        .map(|row| (row.snapshot_date, row.needs_attention_count))
289        .collect())
290}