Skip to main content

headless_lms_models/library/
global_stats.rs

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