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 TRIM(cm.uh_course_code) <> ''
412AND cm.completions_can_be_registered_to_sisu
413AND cm.ects_credits IS NOT NULL
414AND cm.ects_credits > 0
415GROUP BY u.email_domain
416ORDER BY "total_completions!" DESC,
417email_domain
418      "#,
419        year,
420        &CreditRegistrationState::SUCCESS_STATES as &[CreditRegistrationState],
421    )
422    .fetch_all(conn)
423    .await?;
424    Ok(res)
425}
426
427/// Gets course completion statistics for a specific email domain.
428///
429/// Similar to get_completion_stats_by_email_domain, but returns per-course statistics
430/// for a specific email domain instead of per-domain statistics.
431///
432/// # Arguments
433///
434/// * `email_domain` - The email domain to filter by (e.g. "gmail.com")
435/// * `year` - Optional year to filter completions by
436pub async fn get_course_completion_stats_for_email_domain(
437    conn: &mut PgConnection,
438    email_domain: String,
439    year: Option<i32>,
440) -> ModelResult<Vec<CourseCompletionStats>> {
441    let res = sqlx::query_as!(
442        CourseCompletionStats,
443        r#"
444WITH unique_registrations AS (
445  SELECT course_module_completion_id
446  FROM course_module_completion_registered_to_study_registries
447  WHERE deleted_at IS NULL
448  UNION
449  SELECT course_module_completion_id
450  FROM credit_registrations
451  WHERE deleted_at IS NULL
452    AND state = ANY($3::credit_registration_state [])
453),
454deduped_completions AS (
455  SELECT *
456  FROM (
457      SELECT cmc.*,
458        CASE
459          WHEN ur.course_module_completion_id IS NOT NULL THEN 1
460          ELSE 0
461        END AS is_registered,
462        ROW_NUMBER() OVER (
463          PARTITION BY cmc.user_id,
464          cmc.course_module_id
465          ORDER BY CASE
466              WHEN ur.course_module_completion_id IS NOT NULL THEN 1
467              ELSE 0
468            END DESC,
469            cmc.created_at DESC
470        ) AS rn
471      FROM course_module_completions cmc
472        LEFT JOIN unique_registrations ur ON cmc.id = ur.course_module_completion_id
473      WHERE cmc.deleted_at IS NULL
474        AND (
475          $2::int IS NULL
476          OR EXTRACT(
477            YEAR
478            FROM cmc.completion_date
479          ) = $2
480        )
481    ) sub
482  WHERE rn = 1
483)
484SELECT c.id AS "course_id!",
485  c.name AS "course_name!",
486  COUNT(DISTINCT d.id) AS "total_completions!",
487  COUNT(DISTINCT d.user_id) AS "unique_users!",
488  ROUND(
489    (
490      SUM(
491        CASE
492          WHEN ur.course_module_completion_id IS NOT NULL THEN 1
493          ELSE 0
494        END
495      ) * 100.0
496    ) / NULLIF(COUNT(DISTINCT d.id), 0),
497    2
498  )::float8 AS "registered_completion_percentage",
499  SUM(
500    CASE
501      WHEN ur.course_module_completion_id IS NOT NULL THEN 1
502      ELSE 0
503    END
504  ) AS "registered_completions!",
505  SUM(
506    CASE
507      WHEN ur.course_module_completion_id IS NULL THEN 1
508      ELSE 0
509    END
510  ) AS "not_registered_completions!",
511  COUNT(
512    DISTINCT CASE
513      WHEN ur.course_module_completion_id IS NOT NULL THEN d.user_id
514    END
515  ) AS "users_with_some_registered_completions!",
516  COUNT(
517    DISTINCT CASE
518      WHEN ur.course_module_completion_id IS NULL THEN d.user_id
519    END
520  ) AS "users_with_some_unregistered_completions!",
521  COALESCE(
522    SUM(
523      CASE
524        WHEN ur.course_module_completion_id IS NOT NULL THEN cm.ects_credits
525        ELSE 0
526      END
527    ),
528    0
529  ) AS "registered_ects_credits!",
530  COALESCE(
531    SUM(
532      CASE
533        WHEN ur.course_module_completion_id IS NULL THEN cm.ects_credits
534        ELSE 0
535      END
536    ),
537    0
538  ) AS "not_registered_ects_credits!"
539FROM deduped_completions d
540  JOIN users u ON d.user_id = u.id
541  AND u.deleted_at IS NULL
542  LEFT JOIN unique_registrations ur ON d.id = ur.course_module_completion_id
543  JOIN courses c ON d.course_id = c.id
544  AND c.deleted_at IS NULL
545  JOIN course_modules cm ON d.course_module_id = cm.id
546  AND cm.deleted_at IS NULL
547WHERE d.prerequisite_modules_completed = TRUE
548  AND c.is_draft = FALSE
549  AND c.is_test_mode = FALSE
550  AND TRIM(cm.uh_course_code) <> ''
551  AND cm.completions_can_be_registered_to_sisu
552  AND cm.ects_credits IS NOT NULL
553  AND cm.ects_credits > 0
554  AND u.email_domain = $1
555GROUP BY c.id,
556  c.name
557ORDER BY "total_completions!" DESC,
558  c.id
559        "#,
560        email_domain,
561        year,
562        &CreditRegistrationState::SUCCESS_STATES as &[CreditRegistrationState],
563    )
564    .fetch_all(conn)
565    .await?;
566    Ok(res)
567}