Skip to main content

headless_lms_models/
credit_registration_enrolment_check_outcomes.rs

1//! The log of answered enrolment checks, which the check schedules are tuned from, and the
2//! dashboard's reads over it.
3
4use utoipa::ToSchema;
5
6use crate::library::credit_registration::enrolment_check_schedule::{
7    EnrolmentCheckGroup, EnrolmentCheckSource,
8};
9use crate::prelude::*;
10
11/// Kept well past the call log's 90 days: a check-requested row lives for 180, and the schedules
12/// are refitted from a year of outcomes.
13pub const RETENTION_DAYS: i64 = 400;
14
15/// A check lateness past this counts as very late on the dashboard.
16pub const VERY_LATE_SECS: i64 = 60 * 60;
17
18#[derive(Debug, Clone, PartialEq)]
19pub struct NewEnrolmentCheckOutcome {
20    pub credit_registration_id: Uuid,
21    pub course_module_id: Uuid,
22    pub enrolment_check_group: EnrolmentCheckGroup,
23    pub enrolment_check_step: Option<i32>,
24    pub source: EnrolmentCheckSource,
25    pub due_at: Option<DateTime<Utc>>,
26    pub checked_at: DateTime<Utc>,
27    pub previous_checked_at: Option<DateTime<Utc>>,
28    pub is_enrolment_found: bool,
29    pub enrolled_at: Option<DateTime<Utc>>,
30}
31
32/// Logs one answered enrolment check.
33pub async fn insert(conn: &mut PgConnection, new: &NewEnrolmentCheckOutcome) -> ModelResult<Uuid> {
34    let id = sqlx::query_scalar!(
35        r#"
36INSERT INTO credit_registration_enrolment_check_outcomes (
37    credit_registration_id,
38    course_module_id,
39    enrolment_check_group,
40    enrolment_check_step,
41    source,
42    due_at,
43    checked_at,
44    previous_checked_at,
45    is_enrolment_found,
46    enrolled_at
47  )
48VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
49RETURNING id
50        "#,
51        new.credit_registration_id,
52        new.course_module_id,
53        new.enrolment_check_group as EnrolmentCheckGroup,
54        new.enrolment_check_step,
55        new.source as EnrolmentCheckSource,
56        new.due_at,
57        new.checked_at,
58        new.previous_checked_at,
59        new.is_enrolment_found,
60        new.enrolled_at,
61    )
62    .fetch_one(conn)
63    .await?;
64    Ok(id)
65}
66
67/// Deletes up to `limit` outcomes checked before `cutoff` and returns how many went.
68pub async fn delete_older_than(
69    conn: &mut PgConnection,
70    cutoff: DateTime<Utc>,
71    limit: i64,
72) -> ModelResult<u64> {
73    let res = sqlx::query!(
74        r#"
75DELETE FROM credit_registration_enrolment_check_outcomes
76WHERE id IN (
77    SELECT id
78    FROM credit_registration_enrolment_check_outcomes
79    WHERE checked_at < $1
80    ORDER BY checked_at
81    LIMIT $2
82  )
83        "#,
84        cutoff,
85        limit,
86    )
87    .execute(conn)
88    .await?;
89    Ok(res.rows_affected())
90}
91
92/// How late schedule checks of one group and step ran against their ladder time.
93#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
94pub struct EnrolmentCheckLateness {
95    pub enrolment_check_group: EnrolmentCheckGroup,
96    pub enrolment_check_step: i32,
97    pub check_count: i64,
98    pub p50_late_secs: f64,
99    pub p95_late_secs: f64,
100    pub max_late_secs: f64,
101    /// Checks more than [`VERY_LATE_SECS`] late.
102    pub very_late_count: i64,
103}
104
105/// Lateness of the schedule checks answered since `since`, per group and step. A check brought
106/// forward by anything else has no lateness and is left out.
107pub async fn get_lateness_since(
108    conn: &mut PgConnection,
109    since: DateTime<Utc>,
110) -> ModelResult<Vec<EnrolmentCheckLateness>> {
111    let res = sqlx::query_as!(
112        EnrolmentCheckLateness,
113        r#"
114SELECT enrolment_check_group AS "enrolment_check_group!",
115  enrolment_check_step AS "enrolment_check_step!",
116  COUNT(*) AS "check_count!",
117  PERCENTILE_CONT(0.5) WITHIN GROUP (
118    ORDER BY late_secs
119  ) AS "p50_late_secs!",
120  PERCENTILE_CONT(0.95) WITHIN GROUP (
121    ORDER BY late_secs
122  ) AS "p95_late_secs!",
123  MAX(late_secs) AS "max_late_secs!",
124  COUNT(*) FILTER (
125    WHERE late_secs > $2
126  ) AS "very_late_count!"
127FROM (
128    SELECT enrolment_check_group,
129      enrolment_check_step,
130      GREATEST(EXTRACT(EPOCH FROM checked_at - due_at), 0)::double precision AS late_secs
131    FROM credit_registration_enrolment_check_outcomes
132    WHERE checked_at >= $1
133      AND source = 'schedule'
134      AND due_at IS NOT NULL
135      AND enrolment_check_step IS NOT NULL
136  ) checks
137GROUP BY enrolment_check_group,
138  enrolment_check_step
139ORDER BY enrolment_check_group,
140  enrolment_check_step
141        "#,
142        since,
143        VERY_LATE_SECS as f64,
144    )
145    .fetch_all(conn)
146    .await?;
147    Ok(res)
148}
149
150/// What the checks of one group, step and source found.
151#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
152pub struct EnrolmentCheckFindings {
153    pub enrolment_check_group: EnrolmentCheckGroup,
154    /// `None` for checks of rows whose schedule had run out.
155    pub enrolment_check_step: Option<i32>,
156    pub source: EnrolmentCheckSource,
157    pub check_count: i64,
158    pub found_count: i64,
159    /// Time from the enrolment, as Sisu records it, to the check that found it; over the finds
160    /// that carry an enrolment time.
161    pub p50_detection_secs: Option<f64>,
162    pub p95_detection_secs: Option<f64>,
163}
164
165/// What the checks answered since `since` found, per group, step and source.
166pub async fn get_findings_since(
167    conn: &mut PgConnection,
168    since: DateTime<Utc>,
169) -> ModelResult<Vec<EnrolmentCheckFindings>> {
170    let res = sqlx::query_as!(
171        EnrolmentCheckFindings,
172        r#"
173SELECT enrolment_check_group AS "enrolment_check_group!",
174  enrolment_check_step,
175  source AS "source!",
176  COUNT(*) AS "check_count!",
177  COUNT(*) FILTER (
178    WHERE is_enrolment_found
179  ) AS "found_count!",
180  PERCENTILE_CONT(0.5) WITHIN GROUP (
181    ORDER BY EXTRACT(EPOCH FROM checked_at - enrolled_at)::double precision
182  ) FILTER (
183    WHERE is_enrolment_found
184      AND enrolled_at IS NOT NULL
185  ) AS p50_detection_secs,
186  PERCENTILE_CONT(0.95) WITHIN GROUP (
187    ORDER BY EXTRACT(EPOCH FROM checked_at - enrolled_at)::double precision
188  ) FILTER (
189    WHERE is_enrolment_found
190      AND enrolled_at IS NOT NULL
191  ) AS p95_detection_secs
192FROM credit_registration_enrolment_check_outcomes
193WHERE checked_at >= $1
194GROUP BY enrolment_check_group,
195  enrolment_check_step,
196  source
197ORDER BY enrolment_check_group,
198  source,
199  enrolment_check_step NULLS LAST
200        "#,
201        since,
202    )
203    .fetch_all(conn)
204    .await?;
205    Ok(res)
206}
207
208/// How many rows wait for an enrolment in one group and step.
209#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
210pub struct EnrolmentCheckPopulation {
211    pub enrolment_check_group: EnrolmentCheckGroup,
212    /// `None` for rows whose schedule has run out.
213    pub enrolment_check_step: Option<i32>,
214    pub row_count: i64,
215    /// Of those, how many have never been checked.
216    pub never_checked_count: i64,
217}
218
219/// The live rows waiting for an enrolment right now, per group and step.
220pub async fn get_population(conn: &mut PgConnection) -> ModelResult<Vec<EnrolmentCheckPopulation>> {
221    let res = sqlx::query_as!(
222        EnrolmentCheckPopulation,
223        r#"
224SELECT enrolment_check_group AS "enrolment_check_group!",
225  enrolment_check_step,
226  COUNT(*) AS "row_count!",
227  COUNT(*) FILTER (
228    WHERE enrolment_checked_at IS NULL
229  ) AS "never_checked_count!"
230FROM credit_registrations
231WHERE state IN (
232    'no_usable_enrolment',
233    'ready_to_submit',
234    'resolving_enrolment',
235    'failed_retryable'
236  )
237  AND enrolment_check_anchor_at IS NOT NULL
238  AND superseded_by_id IS NULL
239  AND deleted_at IS NULL
240GROUP BY enrolment_check_group,
241  enrolment_check_step
242ORDER BY enrolment_check_group,
243  enrolment_check_step NULLS LAST
244        "#,
245    )
246    .fetch_all(conn)
247    .await?;
248    Ok(res)
249}