Skip to main content

headless_lms_models/library/
global_stats.rs

1//! Cross-course counts for `/stats` and `/domain-stats`.
2//!
3//! Every "registered" number here comes from a `registered_completions` CTE spelling out the same
4//! set: a completion is registered if a third-party registrar recorded it in
5//! `course_module_completion_registered_to_study_registries`, or if a `credit_registrations` attempt
6//! of ours reached `registered`, `duplicate` or `not_improved`. Every other credit registration
7//! state leaves the credit's fate unknown to us and must not be counted either way.
8//!
9//! `UNION`, not `UNION ALL`, is what keeps the counts honest: one completion routinely has a row in
10//! both ledgers, because our pipeline mirrors its successes into the legacy one, and several rows in
11//! either — one per registrar there, one per attempt here after a regrade. Deduplicating on the
12//! completion is also why a superseded attempt cannot inflate a count, and why a completion whose
13//! successor attempt is still in flight keeps the credit its first attempt already earned.
14
15use super::TimeGranularity;
16use crate::credit_registrations::CreditRegistrationState;
17use crate::prelude::*;
18use utoipa::ToSchema;
19
20#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
21
22pub struct GlobalStatEntry {
23    pub course_name: String,
24    pub course_id: Uuid,
25    pub organization_id: Uuid,
26    pub organization_name: String,
27    pub year: i32,
28    pub month: Option<i32>, // Will be None when granularity is Year
29    pub value: i64,
30}
31
32#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
33
34pub struct GlobalCourseModuleStatEntry {
35    pub course_name: String,
36    pub course_id: Uuid,
37    pub course_module_id: Uuid,
38    pub course_module_name: Option<String>,
39    pub organization_id: Uuid,
40    pub organization_name: String,
41    pub year: String,
42    pub value: i64,
43    pub course_module_ects_credits: Option<f32>,
44}
45
46#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
47
48pub struct DomainCompletionStats {
49    pub email_domain: String,
50    pub total_completions: i64,
51    pub unique_users: i64,
52    pub registered_completion_percentage: Option<f64>,
53    pub registered_completions: i64,
54    pub not_registered_completions: i64,
55    pub users_with_some_registered_completions: i64,
56    pub users_with_some_unregistered_completions: i64,
57    pub registered_ects_credits: f32,
58    pub not_registered_ects_credits: f32,
59}
60
61#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
62
63pub struct CourseCompletionStats {
64    pub course_id: Uuid,
65    pub course_name: String,
66    pub total_completions: i64,
67    pub unique_users: i64,
68    pub registered_completion_percentage: Option<f64>,
69    pub registered_completions: i64,
70    pub not_registered_completions: i64,
71    pub users_with_some_registered_completions: i64,
72    pub users_with_some_unregistered_completions: i64,
73    pub registered_ects_credits: f32,
74    pub not_registered_ects_credits: f32,
75}
76
77pub async fn get_number_of_people_completed_a_course(
78    conn: &mut PgConnection,
79    granularity: TimeGranularity,
80) -> ModelResult<Vec<GlobalStatEntry>> {
81    let res = sqlx::query_as!(
82        GlobalStatEntry,
83        r#"
84SELECT c.name AS course_name,
85  EXTRACT('year' FROM completion_date)::int as "year!",
86  CASE WHEN $1 = 'Month' THEN EXTRACT('month' FROM completion_date)::int ELSE NULL END as "month",
87  COUNT(DISTINCT user_id) as "value!",
88  c.id as "course_id!",
89  o.id as "organization_id",
90  o.name as "organization_name"
91FROM course_module_completions cmc
92JOIN courses c ON cmc.course_id = c.id
93JOIN organizations o ON c.organization_id = o.id
94WHERE cmc.deleted_at IS NULL
95  AND c.is_draft = FALSE
96  AND c.deleted_at IS NULL
97  AND c.is_test_mode = FALSE
98GROUP BY c.name, c.id, o.id, o.name, "year!", "month"
99ORDER BY c.id, "year!", "month"
100"#,
101        granularity.to_string()
102    )
103    .fetch_all(conn)
104    .await?;
105    Ok(res)
106}
107
108pub async fn get_number_of_people_registered_completion_to_study_registry(
109    conn: &mut PgConnection,
110    granularity: TimeGranularity,
111) -> ModelResult<Vec<GlobalStatEntry>> {
112    let res = sqlx::query_as!(
113        GlobalStatEntry,
114        r#"
115WITH registered_completions AS (
116  SELECT course_module_completion_id,
117    user_id,
118    course_id
119  FROM course_module_completion_registered_to_study_registries
120  WHERE deleted_at IS NULL
121  UNION
122  SELECT course_module_completion_id,
123    user_id,
124    course_id
125  FROM credit_registrations
126  WHERE deleted_at IS NULL
127    AND state = ANY($2::credit_registration_state [])
128)
129SELECT c.name AS course_name,
130  EXTRACT('year' FROM cms.completion_date)::int as "year!",
131  CASE WHEN $1 = 'Month' THEN EXTRACT('month' FROM cms.completion_date)::int ELSE NULL END as "month",
132  COUNT(DISTINCT rc.user_id) as "value!",
133  c.id as "course_id!",
134  o.id as "organization_id",
135  o.name as "organization_name"
136FROM registered_completions rc
137JOIN course_module_completions cms ON rc.course_module_completion_id = cms.id
138JOIN courses c ON rc.course_id = c.id
139JOIN organizations o ON c.organization_id = o.id
140WHERE c.is_draft = FALSE
141  AND c.deleted_at IS NULL
142  AND c.is_test_mode = FALSE
143GROUP BY c.name, c.id, o.id, o.name, "year!", "month"
144ORDER BY c.id, "year!", "month"
145"#,
146        granularity.to_string(),
147        &CreditRegistrationState::SUCCESS_STATES as &[CreditRegistrationState],
148    )
149    .fetch_all(conn)
150    .await?;
151    Ok(res)
152}
153
154pub async fn get_number_of_people_done_at_least_one_exercise(
155    conn: &mut PgConnection,
156    granularity: TimeGranularity,
157) -> ModelResult<Vec<GlobalStatEntry>> {
158    dbg!(&granularity);
159    let res = sqlx::query_as!(
160        GlobalStatEntry,
161        r#"
162SELECT c.name AS course_name,
163  EXTRACT('year' FROM ess.created_at)::int as "year!",
164  CASE WHEN $1 = 'Month' THEN EXTRACT('month' FROM ess.created_at)::int ELSE NULL END as "month",
165  COUNT(DISTINCT ess.user_id) as "value!",
166  c.id as "course_id!",
167  o.id as "organization_id",
168  o.name as "organization_name"
169FROM exercise_slide_submissions ess
170JOIN courses c ON ess.course_id = c.id
171JOIN organizations o ON c.organization_id = o.id
172WHERE ess.deleted_at IS NULL
173  AND c.is_draft = FALSE
174  AND c.deleted_at IS NULL
175  AND c.is_test_mode = FALSE
176GROUP BY c.name, c.id, o.id, o.name, "year!", "month"
177ORDER BY c.id, "year!", "month"
178"#,
179        granularity.to_string()
180    )
181    .fetch_all(conn)
182    .await?;
183    dbg!(&res);
184    Ok(res)
185}
186
187pub async fn get_number_of_people_started_course(
188    conn: &mut PgConnection,
189    granularity: TimeGranularity,
190) -> ModelResult<Vec<GlobalStatEntry>> {
191    let res = sqlx::query_as!(
192        GlobalStatEntry,
193        r#"
194SELECT c.name AS course_name,
195  EXTRACT('year' FROM cie.created_at)::int as "year!",
196  CASE WHEN $1 = 'Month' THEN EXTRACT('month' FROM cie.created_at)::int ELSE NULL END as "month",
197  COUNT(DISTINCT cie.user_id) as "value!",
198  c.id as "course_id!",
199  o.id as "organization_id",
200  o.name as "organization_name"
201FROM course_instance_enrollments cie
202JOIN courses c ON cie.course_id = c.id
203JOIN organizations o ON c.organization_id = o.id
204WHERE cie.deleted_at IS NULL
205  AND c.is_draft = FALSE
206  AND c.deleted_at IS NULL
207  AND c.is_test_mode = FALSE
208GROUP BY c.name, c.id, o.id, o.name, "year!", "month"
209ORDER BY c.id, "year!", "month"
210"#,
211        granularity.to_string()
212    )
213    .fetch_all(conn)
214    .await?;
215    Ok(res)
216}
217
218pub async fn get_course_module_stats_by_completions_registered_to_study_registry(
219    conn: &mut PgConnection,
220    granularity: TimeGranularity,
221) -> ModelResult<Vec<GlobalCourseModuleStatEntry>> {
222    let res = sqlx::query_as!(
223        GlobalCourseModuleStatEntry,
224        r#"
225SELECT c.name as course_name,
226  q.year as "year!",
227  q.value as "value!",
228  q.course_module_id as "course_module_id!",
229  c.id as "course_id",
230  cm.name as "course_module_name",
231  cm.ects_credits as "course_module_ects_credits",
232  o.id as "organization_id",
233  o.name as "organization_name"
234FROM (
235    WITH registered_completions AS (
236      SELECT course_module_completion_id,
237        user_id,
238        course_module_id
239      FROM course_module_completion_registered_to_study_registries
240      WHERE deleted_at IS NULL
241      UNION
242      SELECT course_module_completion_id,
243        user_id,
244        course_module_id
245      FROM credit_registrations
246      WHERE deleted_at IS NULL
247        AND state = ANY($2::credit_registration_state [])
248    )
249    SELECT rc.course_module_id,
250      CASE WHEN $1 = 'Month' THEN
251        EXTRACT('year' FROM cms.completion_date)::VARCHAR || '-' || LPAD(EXTRACT('month' FROM cms.completion_date)::VARCHAR, 2, '0')
252      ELSE
253        EXTRACT('year' FROM cms.completion_date)::VARCHAR
254      END as year,
255      COUNT(DISTINCT rc.user_id) as value
256    FROM registered_completions rc
257      JOIN course_module_completions cms ON rc.course_module_completion_id = cms.id
258    GROUP BY rc.course_module_id,
259      year
260    ORDER BY rc.course_module_id,
261      year
262  ) q
263  JOIN course_modules cm ON q.course_module_id = cm.id
264  JOIN courses c ON cm.course_id = c.id
265  JOIN organizations o ON c.organization_id = o.id
266WHERE c.is_draft = FALSE
267  AND c.deleted_at IS NULL
268  AND c.is_test_mode = FALSE
269"#,
270        granularity.to_string(),
271        &CreditRegistrationState::SUCCESS_STATES as &[CreditRegistrationState],
272    )
273    .fetch_all(conn)
274    .await?;
275    Ok(res)
276}
277
278/// Produces a summary of course completions grouped by user's email domain.
279///
280/// The query deduplicates multiple completions of the same (user, course module) by:
281/// 1. Preferring any completion that is registered in the study registry (see the module docs)
282/// 2. If no registered completion is found, it picks the completion with the newest created_at timestamp
283///
284/// The query aggregates the following counts and sums by email domain:
285///
286/// * `total_completions` - Number of unique completions (after deduplication) for the domain
287/// * `unique_users` - Number of distinct users (by user_id) in those completions
288/// * `registered_completion_percentage` - Fraction of completions that are registered (multiplied by 100)
289/// * `registered_completions` - Number of completions with a matching registration
290/// * `not_registered_completions` - Number of completions without a matching registration
291/// * `users_with_some_registered_completions` - Count of distinct users with at least one registered completion
292/// * `users_with_some_unregistered_completions` - Count of distinct users with at least one unregistered completion
293/// * `registered_ects_credits` - Total ECTS credits for registered completions
294/// * `not_registered_ects_credits` - Total ECTS credits for unregistered completions
295///
296/// # Arguments
297///
298/// * `year` - Optional year to filter completions by
299pub async fn get_completion_stats_by_email_domain(
300    conn: &mut PgConnection,
301    year: Option<i32>,
302) -> ModelResult<Vec<DomainCompletionStats>> {
303    let res = sqlx::query_as!(
304        DomainCompletionStats,
305        r#"
306WITH unique_registrations AS (
307SELECT course_module_completion_id
308FROM course_module_completion_registered_to_study_registries
309WHERE deleted_at IS NULL
310UNION
311SELECT course_module_completion_id
312FROM credit_registrations
313WHERE deleted_at IS NULL
314  AND state = ANY($2::credit_registration_state [])
315),
316deduped_completions AS (
317SELECT *
318FROM (
319    SELECT cmc.*,
320      CASE
321        WHEN ur.course_module_completion_id IS NOT NULL THEN 1
322        ELSE 0
323      END AS is_registered,
324      ROW_NUMBER() OVER (
325        PARTITION BY cmc.user_id,
326        cmc.course_module_id
327        ORDER BY CASE
328            WHEN ur.course_module_completion_id IS NOT NULL THEN 1
329            ELSE 0
330          END DESC,
331          cmc.created_at DESC
332      ) AS rn
333    FROM course_module_completions cmc
334      LEFT JOIN unique_registrations ur ON cmc.id = ur.course_module_completion_id
335    WHERE cmc.deleted_at IS NULL
336      AND (
337        $1::int IS NULL
338        OR EXTRACT(
339          YEAR
340          FROM cmc.completion_date
341        ) = $1
342      )
343  ) sub
344WHERE rn = 1
345)
346SELECT u.email_domain AS "email_domain!",
347COUNT(DISTINCT d.id) AS "total_completions!",
348COUNT(DISTINCT d.user_id) AS "unique_users!",
349ROUND(
350  (
351    SUM(
352      CASE
353        WHEN ur.course_module_completion_id IS NOT NULL THEN 1
354        ELSE 0
355      END
356    ) * 100.0
357  ) / NULLIF(COUNT(DISTINCT d.id), 0),
358  2
359)::float8 AS "registered_completion_percentage",
360SUM(
361  CASE
362    WHEN ur.course_module_completion_id IS NOT NULL THEN 1
363    ELSE 0
364  END
365) AS "registered_completions!",
366SUM(
367  CASE
368    WHEN ur.course_module_completion_id IS NULL THEN 1
369    ELSE 0
370  END
371) AS "not_registered_completions!",
372COUNT(
373  DISTINCT CASE
374    WHEN ur.course_module_completion_id IS NOT NULL THEN d.user_id
375  END
376) AS "users_with_some_registered_completions!",
377COUNT(
378  DISTINCT CASE
379    WHEN ur.course_module_completion_id IS NULL THEN d.user_id
380  END
381) AS "users_with_some_unregistered_completions!",
382COALESCE(
383  SUM(
384    CASE
385      WHEN ur.course_module_completion_id IS NOT NULL THEN cm.ects_credits
386      ELSE 0
387    END
388  ),
389  0
390) AS "registered_ects_credits!",
391COALESCE(
392  SUM(
393    CASE
394      WHEN ur.course_module_completion_id IS NULL THEN cm.ects_credits
395      ELSE 0
396    END
397  ),
398  0
399) AS "not_registered_ects_credits!"
400FROM deduped_completions d
401JOIN users u ON d.user_id = u.id
402AND u.deleted_at IS NULL
403LEFT JOIN unique_registrations ur ON d.id = ur.course_module_completion_id
404JOIN courses c ON d.course_id = c.id
405AND c.deleted_at IS NULL
406JOIN course_modules cm ON d.course_module_id = cm.id
407AND cm.deleted_at IS NULL
408WHERE d.prerequisite_modules_completed = TRUE
409AND c.is_draft = FALSE
410AND c.is_test_mode = FALSE
411AND cm.enable_registering_completion_to_uh_open_university = TRUE
412AND cm.ects_credits IS NOT NULL
413AND cm.ects_credits > 0
414GROUP BY u.email_domain
415ORDER BY "total_completions!" DESC,
416email_domain
417      "#,
418        year,
419        &CreditRegistrationState::SUCCESS_STATES as &[CreditRegistrationState],
420    )
421    .fetch_all(conn)
422    .await?;
423    Ok(res)
424}
425
426/// Gets course completion statistics for a specific email domain.
427///
428/// Similar to get_completion_stats_by_email_domain, but returns per-course statistics
429/// for a specific email domain instead of per-domain statistics.
430///
431/// # Arguments
432///
433/// * `email_domain` - The email domain to filter by (e.g. "gmail.com")
434/// * `year` - Optional year to filter completions by
435pub async fn get_course_completion_stats_for_email_domain(
436    conn: &mut PgConnection,
437    email_domain: String,
438    year: Option<i32>,
439) -> ModelResult<Vec<CourseCompletionStats>> {
440    let res = sqlx::query_as!(
441        CourseCompletionStats,
442        r#"
443WITH unique_registrations AS (
444  SELECT course_module_completion_id
445  FROM course_module_completion_registered_to_study_registries
446  WHERE deleted_at IS NULL
447  UNION
448  SELECT course_module_completion_id
449  FROM credit_registrations
450  WHERE deleted_at IS NULL
451    AND state = ANY($3::credit_registration_state [])
452),
453deduped_completions AS (
454  SELECT *
455  FROM (
456      SELECT cmc.*,
457        CASE
458          WHEN ur.course_module_completion_id IS NOT NULL THEN 1
459          ELSE 0
460        END AS is_registered,
461        ROW_NUMBER() OVER (
462          PARTITION BY cmc.user_id,
463          cmc.course_module_id
464          ORDER BY CASE
465              WHEN ur.course_module_completion_id IS NOT NULL THEN 1
466              ELSE 0
467            END DESC,
468            cmc.created_at DESC
469        ) AS rn
470      FROM course_module_completions cmc
471        LEFT JOIN unique_registrations ur ON cmc.id = ur.course_module_completion_id
472      WHERE cmc.deleted_at IS NULL
473        AND (
474          $2::int IS NULL
475          OR EXTRACT(
476            YEAR
477            FROM cmc.completion_date
478          ) = $2
479        )
480    ) sub
481  WHERE rn = 1
482)
483SELECT c.id AS "course_id!",
484  c.name AS "course_name!",
485  COUNT(DISTINCT d.id) AS "total_completions!",
486  COUNT(DISTINCT d.user_id) AS "unique_users!",
487  ROUND(
488    (
489      SUM(
490        CASE
491          WHEN ur.course_module_completion_id IS NOT NULL THEN 1
492          ELSE 0
493        END
494      ) * 100.0
495    ) / NULLIF(COUNT(DISTINCT d.id), 0),
496    2
497  )::float8 AS "registered_completion_percentage",
498  SUM(
499    CASE
500      WHEN ur.course_module_completion_id IS NOT NULL THEN 1
501      ELSE 0
502    END
503  ) AS "registered_completions!",
504  SUM(
505    CASE
506      WHEN ur.course_module_completion_id IS NULL THEN 1
507      ELSE 0
508    END
509  ) AS "not_registered_completions!",
510  COUNT(
511    DISTINCT CASE
512      WHEN ur.course_module_completion_id IS NOT NULL THEN d.user_id
513    END
514  ) AS "users_with_some_registered_completions!",
515  COUNT(
516    DISTINCT CASE
517      WHEN ur.course_module_completion_id IS NULL THEN d.user_id
518    END
519  ) AS "users_with_some_unregistered_completions!",
520  COALESCE(
521    SUM(
522      CASE
523        WHEN ur.course_module_completion_id IS NOT NULL THEN cm.ects_credits
524        ELSE 0
525      END
526    ),
527    0
528  ) AS "registered_ects_credits!",
529  COALESCE(
530    SUM(
531      CASE
532        WHEN ur.course_module_completion_id IS NULL THEN cm.ects_credits
533        ELSE 0
534      END
535    ),
536    0
537  ) AS "not_registered_ects_credits!"
538FROM deduped_completions d
539  JOIN users u ON d.user_id = u.id
540  AND u.deleted_at IS NULL
541  LEFT JOIN unique_registrations ur ON d.id = ur.course_module_completion_id
542  JOIN courses c ON d.course_id = c.id
543  AND c.deleted_at IS NULL
544  JOIN course_modules cm ON d.course_module_id = cm.id
545  AND cm.deleted_at IS NULL
546WHERE d.prerequisite_modules_completed = TRUE
547  AND c.is_draft = FALSE
548  AND c.is_test_mode = FALSE
549  AND cm.enable_registering_completion_to_uh_open_university = TRUE
550  AND cm.ects_credits IS NOT NULL
551  AND cm.ects_credits > 0
552  AND u.email_domain = $1
553GROUP BY c.id,
554  c.name
555ORDER BY "total_completions!" DESC,
556  c.id
557        "#,
558        email_domain,
559        year,
560        &CreditRegistrationState::SUCCESS_STATES as &[CreditRegistrationState],
561    )
562    .fetch_all(conn)
563    .await?;
564    Ok(res)
565}