1use 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
35pub 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
97pub 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#[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
163pub 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 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
219pub 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
240pub 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
266pub 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}