headless_lms_models/library/
course_stats.rs

1use crate::library::TimeGranularity;
2use crate::{prelude::*, roles::UserRole};
3use std::collections::HashMap;
4
5/// A generic result representing a count metric over a time period.
6/// When the time period is not applicable (for overall totals), `period` will be `None`.
7#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
8#[cfg_attr(feature = "ts_rs", derive(TS))]
9pub struct CountResult {
10    /// The start of the time period (e.g., day, week, month) associated with this count.
11    /// For overall totals, this will be `None`.
12    pub period: Option<DateTime<Utc>>,
13    /// The count (for example, the number of users).
14    pub count: i64,
15}
16
17/// A generic result representing an average metric over a time period.
18/// The average value (e.g. average time in seconds) may be absent if no data is available.
19#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
20#[cfg_attr(feature = "ts_rs", derive(TS))]
21pub struct AverageMetric {
22    /// The start of the time period (e.g., day, week, month) associated with this metric.
23    pub period: Option<DateTime<Utc>>,
24    /// The average value. For example, the average time (in seconds) from course start to first submission.
25    pub average: Option<f64>,
26}
27
28/// Represents cohort activity metrics for both weekly and daily cohorts.
29/// For daily cohorts, `offset` will be populated (and `activity_period` may be computed from it);
30/// for weekly cohorts, `offset` will be `None` and `activity_period` indicates the week start.
31#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
32#[cfg_attr(feature = "ts_rs", derive(TS))]
33pub struct CohortActivity {
34    /// The start date of the cohort (either day or week).
35    pub cohort_start: Option<DateTime<Utc>>,
36    /// The activity period (for example, the start of the week or the computed activity day).
37    pub activity_period: Option<DateTime<Utc>>,
38    /// The day offset from the cohort start (only applicable for daily cohorts).
39    pub offset: Option<i32>,
40    /// The number of active users in this cohort for the given period.
41    pub active_users: i64,
42}
43
44/// Gets user IDs to exclude from course statistics for a single course.
45/// Excludes users with any role other than MaterialViewer in the course, its organization, or globally.
46async fn get_user_ids_to_exclude_from_course_stats(
47    conn: &mut PgConnection,
48    course_id: Uuid,
49) -> ModelResult<Vec<Uuid>> {
50    let roles = crate::roles::get_course_related_roles(conn, course_id).await?;
51    let user_ids: Vec<_> = roles
52        .iter()
53        .filter(|role| role.role != UserRole::MaterialViewer)
54        .map(|role| role.user_id)
55        .collect::<std::collections::HashSet<_>>()
56        .into_iter()
57        .collect();
58    Ok(user_ids)
59}
60
61/// Gets user IDs to exclude from course language group statistics.
62/// Uses a single query to get all roles and filters out MaterialViewer roles.
63async fn get_user_ids_to_exclude_from_course_language_group_stats(
64    conn: &mut PgConnection,
65    course_language_group_id: Uuid,
66) -> ModelResult<Vec<Uuid>> {
67    let roles =
68        crate::roles::get_course_language_group_related_roles(conn, course_language_group_id)
69            .await?;
70    let user_ids: Vec<_> = roles
71        .iter()
72        .filter(|role| role.role != UserRole::MaterialViewer)
73        .map(|role| role.user_id)
74        .collect::<std::collections::HashSet<_>>()
75        .into_iter()
76        .collect();
77    Ok(user_ids)
78}
79
80/// Total unique users in the course settings table.
81pub async fn get_total_users_started_course(
82    conn: &mut PgConnection,
83    course_id: Uuid,
84) -> ModelResult<CountResult> {
85    let exclude_user_ids = get_user_ids_to_exclude_from_course_stats(conn, course_id).await?;
86    let res = sqlx::query_as!(
87        CountResult,
88        r#"
89SELECT NULL::timestamptz AS "period",
90       COUNT(DISTINCT user_id) AS "count!"
91FROM user_course_settings
92WHERE current_course_id = $1
93  AND deleted_at IS NULL
94  AND user_id != ALL($2);
95        "#,
96        course_id,
97        &exclude_user_ids
98    )
99    .fetch_one(conn)
100    .await?;
101    Ok(res)
102}
103
104/// Total unique users who have completed the course.
105pub async fn get_total_users_completed_course(
106    conn: &mut PgConnection,
107    course_id: Uuid,
108) -> ModelResult<CountResult> {
109    let exclude_user_ids = get_user_ids_to_exclude_from_course_stats(conn, course_id).await?;
110    let res = sqlx::query_as!(
111        CountResult,
112        r#"
113SELECT NULL::timestamptz AS "period",
114       COUNT(DISTINCT user_id) AS "count!"
115FROM course_module_completions
116WHERE course_id = $1
117  AND deleted_at IS NULL
118  AND user_id != ALL($2);
119        "#,
120        course_id,
121        &exclude_user_ids
122    )
123    .fetch_one(conn)
124    .await?;
125    Ok(res)
126}
127
128/// Total unique users who have returned at least one exercise.
129pub async fn get_total_users_returned_at_least_one_exercise(
130    conn: &mut PgConnection,
131    course_id: Uuid,
132) -> ModelResult<CountResult> {
133    let exclude_user_ids = get_user_ids_to_exclude_from_course_stats(conn, course_id).await?;
134    let res = sqlx::query_as!(
135        CountResult,
136        r#"
137SELECT NULL::timestamptz AS "period",
138  COUNT(DISTINCT user_id) AS "count!"
139FROM exercise_slide_submissions
140WHERE course_id = $1
141  AND deleted_at IS NULL
142  AND user_id != ALL($2);
143      "#,
144        course_id,
145        &exclude_user_ids
146    )
147    .fetch_one(conn)
148    .await?;
149    Ok(res)
150}
151
152/// Total unique users who have completed the course in all language versions
153pub async fn get_total_users_completed_all_language_versions_of_a_course(
154    conn: &mut PgConnection,
155    course_language_group_id: Uuid,
156) -> ModelResult<CountResult> {
157    let exclude_user_ids =
158        get_user_ids_to_exclude_from_course_language_group_stats(conn, course_language_group_id)
159            .await?;
160
161    let res = sqlx::query_as!(
162        CountResult,
163        r#"
164SELECT NULL::timestamptz AS "period",
165  COUNT(DISTINCT user_id) AS "count!"
166FROM course_module_completions
167WHERE course_id IN (
168    SELECT id
169    FROM courses
170    WHERE course_language_group_id = $1
171      AND deleted_at IS NULL
172  )
173  AND deleted_at IS NULL
174  AND user_id != ALL($2);
175    "#,
176        course_language_group_id,
177        &exclude_user_ids
178    )
179    .fetch_one(conn)
180    .await?;
181    Ok(res)
182}
183
184/// Total unique users who have started the course in all language versions
185pub async fn get_total_users_started_all_language_versions_of_a_course(
186    conn: &mut PgConnection,
187    course_language_group_id: Uuid,
188) -> ModelResult<CountResult> {
189    let exclude_user_ids =
190        get_user_ids_to_exclude_from_course_language_group_stats(conn, course_language_group_id)
191            .await?;
192
193    let res = sqlx::query_as!(
194        CountResult,
195        r#"
196SELECT NULL::timestamptz AS "period",
197  COUNT(DISTINCT user_id) AS "count!"
198FROM user_course_settings
199WHERE current_course_id IN (
200    SELECT id
201    FROM courses
202    WHERE course_language_group_id = $1
203      AND deleted_at IS NULL
204  )
205  AND deleted_at IS NULL
206  AND user_id != ALL($2);
207    "#,
208        course_language_group_id,
209        &exclude_user_ids
210    )
211    .fetch_one(conn)
212    .await?;
213    Ok(res)
214}
215
216/// Get unique users starting counts with specified time granularity.
217///
218/// The time_window parameter controls how far back to look:
219/// - For Year granularity: number of years
220/// - For Month granularity: number of months
221/// - For Day granularity: number of days
222pub async fn unique_users_starting_history(
223    conn: &mut PgConnection,
224    course_id: Uuid,
225    granularity: TimeGranularity,
226    time_window: u16,
227) -> ModelResult<Vec<CountResult>> {
228    let exclude_user_ids = get_user_ids_to_exclude_from_course_stats(conn, course_id).await?;
229    let (interval_unit, time_unit) = granularity.get_sql_units();
230
231    let res = sqlx::query_as!(
232        CountResult,
233        r#"
234SELECT DATE_TRUNC($5, created_at) AS "period",
235  COUNT(DISTINCT user_id) AS "count!"
236FROM user_course_settings
237WHERE current_course_id = $1
238  AND deleted_at IS NULL
239  AND NOT user_id = ANY($2)
240  AND created_at >= NOW() - ($3 || ' ' || $4)::INTERVAL
241GROUP BY "period"
242ORDER BY "period"
243        "#,
244        course_id,
245        &exclude_user_ids,
246        &time_window.to_string(),
247        interval_unit,
248        time_unit,
249    )
250    .fetch_all(conn)
251    .await?;
252
253    Ok(res)
254}
255
256/// Get first exercise submission counts with specified time granularity.
257///
258/// The time_window parameter controls how far back to look:
259/// - For Year granularity: number of years
260/// - For Month granularity: number of months
261/// - For Day granularity: number of days
262pub async fn first_exercise_submissions_history(
263    conn: &mut PgConnection,
264    course_id: Uuid,
265    granularity: TimeGranularity,
266    time_window: u16,
267) -> ModelResult<Vec<CountResult>> {
268    let exclude_user_ids = get_user_ids_to_exclude_from_course_stats(conn, course_id).await?;
269    let (interval_unit, time_unit) = granularity.get_sql_units();
270
271    let res = sqlx::query_as!(
272        CountResult,
273        r#"
274SELECT DATE_TRUNC($5, first_submission) AS "period",
275  COUNT(user_id) AS "count!"
276FROM (
277    SELECT user_id,
278      MIN(created_at) AS first_submission
279    FROM exercise_slide_submissions
280    WHERE course_id = $1
281      AND deleted_at IS NULL
282      AND NOT user_id = ANY($2)
283      AND created_at >= NOW() - ($3 || ' ' || $4)::INTERVAL
284    GROUP BY user_id
285  ) AS first_submissions
286GROUP BY "period"
287ORDER BY "period"
288        "#,
289        course_id,
290        &exclude_user_ids,
291        &time_window.to_string(),
292        interval_unit,
293        time_unit,
294    )
295    .fetch_all(conn)
296    .await?;
297
298    Ok(res)
299}
300
301/// Get users returning exercises counts with specified time granularity.
302///
303/// The time_window parameter controls how far back to look:
304/// - For Year granularity: number of years
305/// - For Month granularity: number of months
306/// - For Day granularity: number of days
307pub async fn users_returning_exercises_history(
308    conn: &mut PgConnection,
309    course_id: Uuid,
310    granularity: TimeGranularity,
311    time_window: u16,
312) -> ModelResult<Vec<CountResult>> {
313    let exclude_user_ids = get_user_ids_to_exclude_from_course_stats(conn, course_id).await?;
314    let (interval_unit, time_unit) = granularity.get_sql_units();
315
316    let res = sqlx::query_as!(
317        CountResult,
318        r#"
319SELECT DATE_TRUNC($5, created_at) AS "period",
320  COUNT(DISTINCT user_id) AS "count!"
321FROM exercise_slide_submissions
322WHERE course_id = $1
323  AND deleted_at IS NULL
324  AND NOT user_id = ANY($2)
325  AND created_at >= NOW() - ($3 || ' ' || $4)::INTERVAL
326GROUP BY "period"
327ORDER BY "period"
328        "#,
329        course_id,
330        &exclude_user_ids,
331        &time_window.to_string(),
332        interval_unit,
333        time_unit,
334    )
335    .fetch_all(conn)
336    .await?;
337
338    Ok(res)
339}
340
341/// Get average time from course start to first exercise submission with specified time granularity.
342///
343/// The time_window parameter controls how far back to look:
344/// - For Year granularity: number of years
345/// - For Month granularity: number of months
346/// - For Day granularity: number of days
347///
348/// Returns the average time in seconds.
349pub async fn avg_time_to_first_submission_history(
350    conn: &mut PgConnection,
351    course_id: Uuid,
352    granularity: TimeGranularity,
353    time_window: u16,
354) -> ModelResult<Vec<AverageMetric>> {
355    let exclude_user_ids = get_user_ids_to_exclude_from_course_stats(conn, course_id).await?;
356    let (interval_unit, time_unit) = granularity.get_sql_units();
357
358    let res = sqlx::query_as!(
359        AverageMetric,
360        r#"
361SELECT DATE_TRUNC($5, user_start) AS "period",
362  AVG(
363    EXTRACT(
364      EPOCH
365      FROM (first_submission - user_start)
366    )
367  )::float8 AS "average"
368FROM (
369    SELECT u.user_id,
370      MIN(u.created_at) AS user_start,
371      (
372        SELECT MIN(e.created_at)
373        FROM exercise_slide_submissions e
374        WHERE e.user_id = u.user_id
375          AND e.course_id = $1
376          AND e.deleted_at IS NULL
377      ) AS first_submission
378    FROM user_course_settings u
379    WHERE u.current_course_id = $1
380      AND u.deleted_at IS NULL
381      AND NOT u.user_id = ANY($2)
382      AND u.created_at >= NOW() - ($3 || ' ' || $4)::INTERVAL
383    GROUP BY u.user_id
384  ) AS timings
385WHERE first_submission IS NOT NULL
386GROUP BY "period"
387ORDER BY "period"
388        "#,
389        course_id,
390        &exclude_user_ids,
391        &time_window.to_string(),
392        interval_unit,
393        time_unit,
394    )
395    .fetch_all(conn)
396    .await?;
397
398    Ok(res)
399}
400
401/// Get cohort activity statistics with specified time granularity.
402///
403/// Parameters:
404/// - history_window: How far back to look for cohorts
405/// - tracking_window: How long to track activity after each cohort's start
406///
407/// For each granularity:
408/// - Year: windows in years, tracking monthly activity
409/// - Month: windows in months, tracking weekly activity
410/// - Day: windows in days, tracking daily activity
411///
412/// Cohorts are defined by when users first submitted an exercise.
413pub async fn get_cohort_activity_history(
414    conn: &mut PgConnection,
415    course_id: Uuid,
416    granularity: TimeGranularity,
417    history_window: u16,
418    tracking_window: u16,
419) -> ModelResult<Vec<CohortActivity>> {
420    let exclude_user_ids = get_user_ids_to_exclude_from_course_stats(conn, course_id).await?;
421    let (interval_unit, time_unit) = granularity.get_sql_units();
422
423    Ok(sqlx::query_as!(
424        CohortActivity,
425        r#"
426WITH first_activity AS (
427  SELECT user_id,
428    MIN(DATE_TRUNC($6, created_at)) AS first_active_at
429  FROM exercise_slide_submissions
430  WHERE course_id = $1
431    AND created_at >= NOW() - ($3 || ' ' || $4)::INTERVAL
432    AND deleted_at IS NULL
433    AND NOT user_id = ANY($2)
434  GROUP BY user_id
435),
436cohort AS (
437  SELECT user_id,
438    first_active_at AS cohort_start
439  FROM first_activity
440)
441SELECT c.cohort_start AS "cohort_start",
442  DATE_TRUNC($6, s.created_at) AS "activity_period",
443  CASE
444    WHEN $6 = 'day' THEN EXTRACT(
445      DAY
446      FROM (DATE_TRUNC('day', s.created_at) - c.cohort_start)
447    )::integer
448    WHEN $6 = 'week' THEN EXTRACT(
449      WEEK
450      FROM (
451          DATE_TRUNC('week', s.created_at) - c.cohort_start
452        )
453    )::integer
454    WHEN $6 = 'month' THEN (
455      EXTRACT(
456        YEAR
457        FROM s.created_at
458      ) - EXTRACT(
459        YEAR
460        FROM c.cohort_start
461      )
462    )::integer * 12 + (
463      EXTRACT(
464        MONTH
465        FROM s.created_at
466      ) - EXTRACT(
467        MONTH
468        FROM c.cohort_start
469      )
470    )::integer
471    ELSE NULL::integer
472  END AS "offset",
473  COUNT(DISTINCT s.user_id) AS "active_users!"
474FROM cohort c
475  JOIN exercise_slide_submissions s ON (
476    c.user_id = s.user_id
477    AND s.course_id = $1
478  )
479  AND s.created_at >= c.cohort_start
480  AND s.created_at < c.cohort_start + ($5 || ' ' || $4)::INTERVAL
481  AND s.deleted_at IS NULL
482GROUP BY c.cohort_start,
483  "activity_period",
484  "offset"
485ORDER BY c.cohort_start,
486  "offset"
487        "#,
488        course_id,
489        &exclude_user_ids,
490        &history_window.to_string(),
491        interval_unit,
492        &tracking_window.to_string(),
493        time_unit,
494    )
495    .fetch_all(conn)
496    .await?)
497}
498
499/// Get course completion counts with specified time granularity.
500///
501/// The time_window parameter controls how far back to look:
502/// - For Year granularity: number of years
503/// - For Month granularity: number of months
504/// - For Day granularity: number of days
505pub async fn course_completions_history(
506    conn: &mut PgConnection,
507    course_id: Uuid,
508    granularity: TimeGranularity,
509    time_window: u16,
510) -> ModelResult<Vec<CountResult>> {
511    let exclude_user_ids = get_user_ids_to_exclude_from_course_stats(conn, course_id).await?;
512    let (interval_unit, time_unit) = granularity.get_sql_units();
513
514    let res = sqlx::query_as!(
515        CountResult,
516        r#"
517SELECT DATE_TRUNC($5, created_at) AS "period",
518  COUNT(DISTINCT user_id) AS "count!"
519FROM course_module_completions
520WHERE course_id = $1
521  AND prerequisite_modules_completed = TRUE
522  AND needs_to_be_reviewed = FALSE
523  AND passed = TRUE
524  AND deleted_at IS NULL
525  AND NOT user_id = ANY($2)
526  AND created_at >= NOW() - ($3 || ' ' || $4)::INTERVAL
527GROUP BY "period"
528ORDER BY "period"
529          "#,
530        course_id,
531        &exclude_user_ids,
532        &time_window.to_string(),
533        interval_unit,
534        time_unit,
535    )
536    .fetch_all(conn)
537    .await?;
538
539    Ok(res)
540}
541
542/// Get completion counts for all language versions of a course with specified time granularity.
543///
544/// The time_window parameter controls how far back to look:
545/// - For Year granularity: number of years
546/// - For Month granularity: number of months
547/// - For Day granularity: number of days
548pub async fn course_completions_history_all_language_versions(
549    conn: &mut PgConnection,
550    course_language_group_id: Uuid,
551    granularity: TimeGranularity,
552    time_window: u16,
553) -> ModelResult<Vec<CountResult>> {
554    let exclude_user_ids =
555        get_user_ids_to_exclude_from_course_language_group_stats(conn, course_language_group_id)
556            .await?;
557    let (interval_unit, time_unit) = granularity.get_sql_units();
558
559    let res = sqlx::query_as!(
560        CountResult,
561        r#"
562SELECT DATE_TRUNC($5, created_at) AS "period",
563COUNT(DISTINCT user_id) AS "count!"
564FROM course_module_completions
565WHERE course_id IN (
566    SELECT id
567    FROM courses
568    WHERE course_language_group_id = $1
569      AND deleted_at IS NULL
570  )
571  AND prerequisite_modules_completed = TRUE
572  AND needs_to_be_reviewed = FALSE
573  AND passed = TRUE
574  AND deleted_at IS NULL
575  AND NOT user_id = ANY($2)
576  AND created_at >= NOW() - ($3 || ' ' || $4)::INTERVAL
577GROUP BY "period"
578ORDER BY "period"
579        "#,
580        course_language_group_id,
581        &exclude_user_ids,
582        &time_window.to_string(),
583        interval_unit,
584        time_unit,
585    )
586    .fetch_all(conn)
587    .await?;
588
589    Ok(res)
590}
591
592/// Get unique users starting counts for all language versions with specified time granularity.
593///
594/// The time_window parameter controls how far back to look:
595/// - For Year granularity: number of years
596/// - For Month granularity: number of months
597/// - For Day granularity: number of days
598pub async fn unique_users_starting_history_all_language_versions(
599    conn: &mut PgConnection,
600    course_language_group_id: Uuid,
601    granularity: TimeGranularity,
602    time_window: u16,
603) -> ModelResult<Vec<CountResult>> {
604    let exclude_user_ids =
605        get_user_ids_to_exclude_from_course_language_group_stats(conn, course_language_group_id)
606            .await?;
607    let (interval_unit, time_unit) = granularity.get_sql_units();
608
609    let res = sqlx::query_as!(
610        CountResult,
611        r#"
612SELECT DATE_TRUNC($5, created_at) AS "period",
613  COUNT(DISTINCT user_id) AS "count!"
614FROM user_course_settings
615WHERE current_course_id IN (
616    SELECT id
617    FROM courses
618    WHERE course_language_group_id = $1
619      AND deleted_at IS NULL
620  )
621  AND deleted_at IS NULL
622  AND NOT user_id = ANY($2)
623  AND created_at >= NOW() - ($3 || ' ' || $4)::INTERVAL
624GROUP BY "period"
625ORDER BY "period"
626        "#,
627        course_language_group_id,
628        &exclude_user_ids,
629        &time_window.to_string(),
630        interval_unit,
631        time_unit,
632    )
633    .fetch_all(conn)
634    .await?;
635
636    Ok(res)
637}
638
639/// Total unique users in the course settings table, grouped by course instance.
640///
641/// Returns a HashMap where keys are course instance IDs and values are the total user counts
642/// for that instance.
643pub async fn get_total_users_started_course_by_instance(
644    conn: &mut PgConnection,
645    course_id: Uuid,
646) -> ModelResult<HashMap<Uuid, CountResult>> {
647    let exclude_user_ids = get_user_ids_to_exclude_from_course_stats(conn, course_id).await?;
648    let results = sqlx::query!(
649        r#"
650SELECT current_course_instance_id AS "instance_id!",
651  NULL::timestamptz AS "period",
652  COUNT(DISTINCT user_id) AS "count!"
653FROM user_course_settings
654WHERE current_course_id = $1
655  AND deleted_at IS NULL
656  AND user_id != ALL($2)
657GROUP BY current_course_instance_id
658        "#,
659        course_id,
660        &exclude_user_ids
661    )
662    .fetch_all(conn)
663    .await?;
664
665    let mut grouped_results = HashMap::new();
666    for row in results {
667        let count_result = CountResult {
668            period: row.period,
669            count: row.count,
670        };
671        grouped_results.insert(row.instance_id, count_result);
672    }
673
674    Ok(grouped_results)
675}
676
677/// Total unique users who have completed the course, grouped by course instance.
678///
679/// Returns a HashMap where keys are course instance IDs and values are the completion counts
680/// for that instance.
681pub async fn get_total_users_completed_course_by_instance(
682    conn: &mut PgConnection,
683    course_id: Uuid,
684) -> ModelResult<HashMap<Uuid, CountResult>> {
685    let exclude_user_ids = get_user_ids_to_exclude_from_course_stats(conn, course_id).await?;
686    let results = sqlx::query!(
687        r#"
688SELECT ucs.current_course_instance_id AS "instance_id!",
689  NULL::timestamptz AS "period",
690  COUNT(DISTINCT c.user_id) AS "count!"
691FROM course_module_completions c
692JOIN user_course_settings ucs ON c.user_id = ucs.user_id
693  AND ucs.current_course_id = c.course_id
694WHERE c.course_id = $1
695  AND c.deleted_at IS NULL
696  AND c.user_id != ALL($2)
697GROUP BY ucs.current_course_instance_id
698        "#,
699        course_id,
700        &exclude_user_ids
701    )
702    .fetch_all(conn)
703    .await?;
704
705    let mut grouped_results = HashMap::new();
706    for row in results {
707        let count_result = CountResult {
708            period: row.period,
709            count: row.count,
710        };
711        grouped_results.insert(row.instance_id, count_result);
712    }
713
714    Ok(grouped_results)
715}
716
717/// Total unique users who have returned at least one exercise, grouped by course instance.
718///
719/// Returns a HashMap where keys are course instance IDs and values are the submission counts
720/// for that instance.
721pub async fn get_total_users_returned_at_least_one_exercise_by_instance(
722    conn: &mut PgConnection,
723    course_id: Uuid,
724) -> ModelResult<HashMap<Uuid, CountResult>> {
725    let exclude_user_ids = get_user_ids_to_exclude_from_course_stats(conn, course_id).await?;
726    let results = sqlx::query!(
727        r#"
728SELECT ucs.current_course_instance_id AS "instance_id!",
729  NULL::timestamptz AS "period",
730  COUNT(DISTINCT ess.user_id) AS "count!"
731FROM exercise_slide_submissions ess
732JOIN user_course_settings ucs ON ess.user_id = ucs.user_id
733  AND ucs.current_course_id = ess.course_id
734WHERE ess.course_id = $1
735  AND ess.deleted_at IS NULL
736  AND ess.user_id != ALL($2)
737GROUP BY ucs.current_course_instance_id
738        "#,
739        course_id,
740        &exclude_user_ids
741    )
742    .fetch_all(conn)
743    .await?;
744
745    let mut grouped_results = HashMap::new();
746    for row in results {
747        let count_result = CountResult {
748            period: row.period,
749            count: row.count,
750        };
751        grouped_results.insert(row.instance_id, count_result);
752    }
753
754    Ok(grouped_results)
755}
756
757/// Get course completion counts with specified time granularity, grouped by course instance.
758///
759/// Returns a HashMap where keys are course instance IDs and values are vectors of completion counts
760/// over time for that instance.
761///
762/// The time_window parameter controls how far back to look:
763/// - For Year granularity: number of years
764/// - For Month granularity: number of months
765/// - For Day granularity: number of days
766pub async fn course_completions_history_by_instance(
767    conn: &mut PgConnection,
768    course_id: Uuid,
769    granularity: TimeGranularity,
770    time_window: u16,
771) -> ModelResult<HashMap<Uuid, Vec<CountResult>>> {
772    let exclude_user_ids = get_user_ids_to_exclude_from_course_stats(conn, course_id).await?;
773    let (interval_unit, time_unit) = granularity.get_sql_units();
774
775    // Get completions joined with user_course_settings to get instance information
776    let results = sqlx::query!(
777        r#"
778WITH completions AS (
779SELECT c.user_id,
780  c.created_at,
781  ucs.current_course_instance_id
782FROM course_module_completions c
783  JOIN user_course_settings ucs ON c.user_id = ucs.user_id
784  AND ucs.current_course_id = c.course_id
785WHERE c.course_id = $1
786  AND c.prerequisite_modules_completed = TRUE
787  AND c.needs_to_be_reviewed = FALSE
788  AND c.passed = TRUE
789  AND c.deleted_at IS NULL
790  AND NOT c.user_id = ANY($2)
791  AND c.created_at >= NOW() - ($3 || ' ' || $4)::INTERVAL
792)
793SELECT current_course_instance_id AS "instance_id!",
794DATE_TRUNC($5, created_at) AS "period",
795COUNT(DISTINCT user_id) AS "count!"
796FROM completions
797GROUP BY current_course_instance_id,
798period
799ORDER BY current_course_instance_id,
800period "#,
801        course_id,
802        &exclude_user_ids,
803        &time_window.to_string(),
804        interval_unit,
805        time_unit,
806    )
807    .fetch_all(conn)
808    .await?;
809
810    // Convert the flat results into a HashMap grouped by instance_id
811    let mut grouped_results: HashMap<Uuid, Vec<CountResult>> = HashMap::new();
812
813    for row in results {
814        let count_result = CountResult {
815            period: row.period,
816            count: row.count,
817        };
818
819        grouped_results
820            .entry(row.instance_id)
821            .or_default()
822            .push(count_result);
823    }
824
825    Ok(grouped_results)
826}
827
828/// Get unique users starting counts with specified time granularity, grouped by course instance.
829///
830/// Returns a HashMap where keys are course instance IDs and values are vectors of user counts
831/// over time for that instance.
832///
833/// The time_window parameter controls how far back to look:
834/// - For Year granularity: number of years
835/// - For Month granularity: number of months
836/// - For Day granularity: number of days
837pub async fn unique_users_starting_history_by_instance(
838    conn: &mut PgConnection,
839    course_id: Uuid,
840    granularity: TimeGranularity,
841    time_window: u16,
842) -> ModelResult<HashMap<Uuid, Vec<CountResult>>> {
843    let exclude_user_ids = get_user_ids_to_exclude_from_course_stats(conn, course_id).await?;
844    let (interval_unit, time_unit) = granularity.get_sql_units();
845
846    let results = sqlx::query!(
847        r#"
848SELECT current_course_instance_id AS "instance_id!",
849DATE_TRUNC($5, created_at) AS "period",
850COUNT(DISTINCT user_id) AS "count!"
851FROM user_course_settings
852WHERE current_course_id = $1
853AND deleted_at IS NULL
854AND NOT user_id = ANY($2)
855AND created_at >= NOW() - ($3 || ' ' || $4)::INTERVAL
856GROUP BY current_course_instance_id,
857period
858ORDER BY current_course_instance_id,
859period
860    "#,
861        course_id,
862        &exclude_user_ids,
863        &time_window.to_string(),
864        interval_unit,
865        time_unit,
866    )
867    .fetch_all(conn)
868    .await?;
869
870    // Convert the flat results into a HashMap grouped by instance_id
871    let mut grouped_results: HashMap<Uuid, Vec<CountResult>> = HashMap::new();
872
873    for row in results {
874        let count_result = CountResult {
875            period: row.period,
876            count: row.count,
877        };
878
879        grouped_results
880            .entry(row.instance_id)
881            .or_default()
882            .push(count_result);
883    }
884
885    Ok(grouped_results)
886}
887
888/// Get first exercise submission counts with specified time granularity, grouped by course instance.
889///
890/// Returns a HashMap where keys are course instance IDs and values are vectors of submission counts
891/// over time for that instance.
892///
893/// The time_window parameter controls how far back to look:
894/// - For Year granularity: number of years
895/// - For Month granularity: number of months
896/// - For Day granularity: number of days
897pub async fn first_exercise_submissions_history_by_instance(
898    conn: &mut PgConnection,
899    course_id: Uuid,
900    granularity: TimeGranularity,
901    time_window: u16,
902) -> ModelResult<HashMap<Uuid, Vec<CountResult>>> {
903    let exclude_user_ids = get_user_ids_to_exclude_from_course_stats(conn, course_id).await?;
904    let (interval_unit, time_unit) = granularity.get_sql_units();
905
906    let results = sqlx::query!(
907        r#"
908WITH first_submissions AS (
909SELECT user_id,
910  MIN(created_at) AS first_submission
911FROM exercise_slide_submissions
912WHERE course_id = $1
913  AND deleted_at IS NULL
914  AND NOT user_id = ANY($2)
915  AND created_at >= NOW() - ($3 || ' ' || $4)::INTERVAL
916GROUP BY user_id
917)
918SELECT ucs.current_course_instance_id AS "instance_id!",
919DATE_TRUNC($5, fs.first_submission) AS "period",
920COUNT(fs.user_id) AS "count!"
921FROM first_submissions fs
922JOIN user_course_settings ucs ON fs.user_id = ucs.user_id
923AND ucs.current_course_id = $1
924GROUP BY ucs.current_course_instance_id,
925period
926ORDER BY ucs.current_course_instance_id,
927period
928    "#,
929        course_id,
930        &exclude_user_ids,
931        &time_window.to_string(),
932        interval_unit,
933        time_unit,
934    )
935    .fetch_all(conn)
936    .await?;
937
938    // Convert the flat results into a HashMap grouped by instance_id
939    let mut grouped_results: HashMap<Uuid, Vec<CountResult>> = HashMap::new();
940
941    for row in results {
942        let count_result = CountResult {
943            period: row.period,
944            count: row.count,
945        };
946
947        grouped_results
948            .entry(row.instance_id)
949            .or_default()
950            .push(count_result);
951    }
952
953    Ok(grouped_results)
954}
955
956/// Get users returning exercises counts with specified time granularity, grouped by course instance.
957///
958/// Returns a HashMap where keys are course instance IDs and values are vectors of user counts
959/// over time for that instance.
960///
961/// The time_window parameter controls how far back to look:
962/// - For Year granularity: number of years
963/// - For Month granularity: number of months
964/// - For Day granularity: number of days
965pub async fn users_returning_exercises_history_by_instance(
966    conn: &mut PgConnection,
967    course_id: Uuid,
968    granularity: TimeGranularity,
969    time_window: u16,
970) -> ModelResult<HashMap<Uuid, Vec<CountResult>>> {
971    let exclude_user_ids = get_user_ids_to_exclude_from_course_stats(conn, course_id).await?;
972    let (interval_unit, time_unit) = granularity.get_sql_units();
973
974    let results = sqlx::query!(
975        r#"
976SELECT ucs.current_course_instance_id AS "instance_id!",
977DATE_TRUNC($5, ess.created_at) AS "period",
978COUNT(DISTINCT ess.user_id) AS "count!"
979FROM exercise_slide_submissions ess
980JOIN user_course_settings ucs ON ess.user_id = ucs.user_id
981AND ucs.current_course_id = ess.course_id
982WHERE ess.course_id = $1
983AND ess.deleted_at IS NULL
984AND NOT ess.user_id = ANY($2)
985AND ess.created_at >= NOW() - ($3 || ' ' || $4)::INTERVAL
986GROUP BY ucs.current_course_instance_id,
987period
988ORDER BY ucs.current_course_instance_id,
989period
990    "#,
991        course_id,
992        &exclude_user_ids,
993        &time_window.to_string(),
994        interval_unit,
995        time_unit,
996    )
997    .fetch_all(conn)
998    .await?;
999
1000    // Convert the flat results into a HashMap grouped by instance_id
1001    let mut grouped_results: HashMap<Uuid, Vec<CountResult>> = HashMap::new();
1002
1003    for row in results {
1004        let count_result = CountResult {
1005            period: row.period,
1006            count: row.count,
1007        };
1008
1009        grouped_results
1010            .entry(row.instance_id)
1011            .or_default()
1012            .push(count_result);
1013    }
1014
1015    Ok(grouped_results)
1016}
1017
1018/// Get student signup counts by country with specified time granularity.
1019pub async fn student_enrollments_by_country(
1020    conn: &mut PgConnection,
1021    course_id: Uuid,
1022    granularity: TimeGranularity,
1023    time_window: u16,
1024    country: String,
1025) -> ModelResult<Vec<CountResult>> {
1026    let exclude_user_ids = get_user_ids_to_exclude_from_course_stats(conn, course_id).await?;
1027    let (interval_unit, time_unit) = granularity.get_sql_units();
1028
1029    let res = sqlx::query_as!(
1030        CountResult,
1031        r#"
1032SELECT DATE_TRUNC($6, cie.created_at) AS "period",
1033       COUNT(DISTINCT cie.user_id) AS "count!"
1034FROM course_instance_enrollments cie
1035JOIN user_details ud ON ud.user_id = cie.user_id
1036WHERE cie.course_id = $1
1037  AND cie.deleted_at IS NULL
1038  AND NOT cie.user_id = ANY($2)
1039  AND cie.created_at >= NOW() - ($3 || ' ' || $4)::INTERVAL
1040  AND ud.country = $5
1041GROUP BY "period"
1042ORDER BY "period"
1043        "#,
1044        course_id,
1045        &exclude_user_ids,
1046        &time_window.to_string(),
1047        interval_unit,
1048        country,
1049        time_unit,
1050    )
1051    .fetch_all(conn)
1052    .await?;
1053
1054    Ok(res)
1055}
1056
1057#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1058#[cfg_attr(feature = "ts_rs", derive(TS))]
1059pub struct StudentsByCountryTotalsResult {
1060    pub country: Option<String>,
1061    pub count: i64,
1062}
1063
1064pub async fn students_by_country_totals(
1065    conn: &mut PgConnection,
1066    course_id: Uuid,
1067) -> ModelResult<Vec<StudentsByCountryTotalsResult>> {
1068    let exclude_user_ids = get_user_ids_to_exclude_from_course_stats(conn, course_id).await?;
1069
1070    let rows = sqlx::query_as!(
1071        StudentsByCountryTotalsResult,
1072        r#"
1073SELECT ud.country AS "country?",
1074  COUNT(DISTINCT cie.user_id) AS "count!"
1075FROM course_instance_enrollments cie
1076  JOIN user_details ud ON ud.user_id = cie.user_id
1077WHERE cie.course_id = $1
1078  AND cie.deleted_at IS NULL
1079  AND NOT cie.user_id = ANY($2)
1080GROUP BY ud.country
1081ORDER BY COUNT(DISTINCT cie.user_id) ASC
1082        "#,
1083        course_id,
1084        &exclude_user_ids,
1085    )
1086    .fetch_all(conn)
1087    .await?;
1088
1089    Ok(rows)
1090}
1091
1092/// Get student completion counts by country with specified time granularity.
1093pub async fn student_completions_by_country(
1094    conn: &mut PgConnection,
1095    course_id: Uuid,
1096    granularity: TimeGranularity,
1097    time_window: u16,
1098    country: String,
1099) -> ModelResult<Vec<CountResult>> {
1100    let exclude_user_ids = get_user_ids_to_exclude_from_course_stats(conn, course_id).await?;
1101    let (interval_unit, time_unit) = granularity.get_sql_units();
1102
1103    let res = sqlx::query_as!(
1104        CountResult,
1105        r#"
1106SELECT DATE_TRUNC($6, cmc.created_at) AS "period",
1107       COUNT(DISTINCT cmc.user_id) AS "count!"
1108FROM course_module_completions cmc
1109JOIN user_details ud ON ud.user_id = cmc.user_id
1110WHERE cmc.course_id = $1
1111  AND cmc.prerequisite_modules_completed = TRUE
1112  AND cmc.needs_to_be_reviewed = FALSE
1113  AND cmc.passed = TRUE
1114  AND cmc.deleted_at IS NULL
1115  AND NOT cmc.user_id = ANY($2)
1116  AND cmc.created_at >= NOW() - ($3 || ' ' || $4)::INTERVAL
1117  AND ud.country = $5
1118GROUP BY "period"
1119ORDER BY "period"
1120        "#,
1121        course_id,
1122        &exclude_user_ids,
1123        &time_window.to_string(),
1124        interval_unit,
1125        country,
1126        time_unit,
1127    )
1128    .fetch_all(conn)
1129    .await?;
1130
1131    Ok(res)
1132}
1133
1134/// Get first exercise submission counts with specified time granularity, grouped by module.
1135///
1136/// Returns a HashMap where keys are module IDs and values are vectors of submission counts
1137/// over time for that module.
1138///
1139/// The time_window parameter controls how far back to look:
1140/// - For Year granularity: number of years
1141/// - For Month granularity: number of months
1142/// - For Day granularity: number of days
1143///
1144/// Get first exercise submission counts grouped by course module,
1145/// with specified time granularity (year/month/day) and time window.
1146///
1147/// Returns:
1148///   HashMap<module_id, Vec<CountResult>>
1149///
1150/// A CountResult contains:
1151///   period: DateTime<Utc>
1152///   count: i64
1153pub async fn first_exercise_submissions_by_module(
1154    conn: &mut PgConnection,
1155    course_id: Uuid,
1156    granularity: TimeGranularity,
1157    time_window: u16,
1158) -> ModelResult<HashMap<Uuid, Vec<CountResult>>> {
1159    let exclude_user_ids = get_user_ids_to_exclude_from_course_stats(conn, course_id).await?;
1160
1161    let (interval_unit, time_unit) = granularity.get_sql_units();
1162
1163    let results = sqlx::query!(
1164        r#"
1165WITH first_submissions AS (
1166    SELECT
1167        ess.user_id,
1168        MIN(ess.created_at) AS first_submission,
1169        ch.course_module_id AS module_id
1170    FROM exercise_slide_submissions ess
1171    JOIN exercises ex ON ex.id = ess.exercise_id
1172    JOIN chapters ch ON ch.id = ex.chapter_id
1173    WHERE ess.course_id = $1
1174      AND ess.deleted_at IS NULL
1175      AND NOT ess.user_id = ANY($2)
1176      AND ess.created_at >= NOW() - ($3 || ' ' || $4)::INTERVAL
1177      AND ch.course_module_id IS NOT NULL
1178    GROUP BY ess.user_id, ch.course_module_id
1179)
1180SELECT
1181    module_id AS "module_id!",
1182    DATE_TRUNC($5, first_submission) AS "period",
1183    COUNT(user_id) AS "count!"
1184FROM first_submissions
1185GROUP BY module_id, period
1186ORDER BY module_id, period
1187        "#,
1188        course_id,
1189        &exclude_user_ids,
1190        &time_window.to_string(),
1191        interval_unit,
1192        time_unit,
1193    )
1194    .fetch_all(conn)
1195    .await?;
1196
1197    // Convert the flat results into a HashMap grouped by module_id
1198    let mut grouped_results: HashMap<Uuid, Vec<CountResult>> = HashMap::new();
1199
1200    for row in results {
1201        let count_result = CountResult {
1202            period: row.period,
1203            count: row.count,
1204        };
1205
1206        grouped_results
1207            .entry(row.module_id)
1208            .or_default()
1209            .push(count_result);
1210    }
1211
1212    Ok(grouped_results)
1213}
1214
1215/// Get course completion counts for a custom date range.
1216///
1217/// - Groups by day (`DATE(created_at)`)
1218pub async fn course_completions_history_by_custom_time_period(
1219    conn: &mut PgConnection,
1220    course_id: Uuid,
1221    start_date: &str,
1222    end_date: &str,
1223) -> ModelResult<Vec<CountResult>> {
1224    let exclude_user_ids = get_user_ids_to_exclude_from_course_stats(conn, course_id).await?;
1225
1226    let start_dt = DateTime::parse_from_rfc3339(&(start_date.to_owned() + "T00:00:00Z"))
1227        .map_err(|e| {
1228            ModelError::new(
1229                ModelErrorType::InvalidRequest,
1230                format!("Invalid start_date: {}", e),
1231                None,
1232            )
1233        })?
1234        .with_timezone(&Utc);
1235
1236    let end_dt = DateTime::parse_from_rfc3339(&(end_date.to_owned() + "T23:59:59Z"))
1237        .map_err(|e| {
1238            ModelError::new(
1239                ModelErrorType::InvalidRequest,
1240                format!("Invalid end_date: {}", e),
1241                None,
1242            )
1243        })?
1244        .with_timezone(&Utc);
1245
1246    let res = sqlx::query_as!(
1247        CountResult,
1248        r#"
1249SELECT
1250    DATE_TRUNC('day',created_at) AS "period",
1251    COUNT(DISTINCT user_id) AS "count!"
1252FROM course_module_completions
1253WHERE course_id = $1
1254  AND prerequisite_modules_completed = TRUE
1255  AND needs_to_be_reviewed = FALSE
1256  AND passed = TRUE
1257  AND deleted_at IS NULL
1258  AND NOT user_id = ANY($2)
1259  AND created_at >= $3
1260  AND created_at <= $4
1261GROUP BY period
1262ORDER BY period
1263        "#,
1264        course_id,
1265        &exclude_user_ids,
1266        start_dt,
1267        end_dt,
1268    )
1269    .fetch_all(conn)
1270    .await?;
1271
1272    Ok(res)
1273}
1274
1275/// Get unique users starting counts with custom time period, grouped daily.
1276pub async fn unique_users_starting_history_by_custom_time_period(
1277    conn: &mut PgConnection,
1278    course_id: Uuid,
1279    start_date: &str,
1280    end_date: &str,
1281) -> ModelResult<Vec<CountResult>> {
1282    let exclude_user_ids = get_user_ids_to_exclude_from_course_stats(conn, course_id).await?;
1283
1284    let start_dt = DateTime::parse_from_rfc3339(&(start_date.to_owned() + "T00:00:00Z"))
1285        .map_err(|e| {
1286            ModelError::new(
1287                ModelErrorType::InvalidRequest,
1288                format!("Invalid start_date: {}", e),
1289                None,
1290            )
1291        })?
1292        .with_timezone(&Utc);
1293
1294    let end_dt = DateTime::parse_from_rfc3339(&(end_date.to_owned() + "T23:59:59Z"))
1295        .map_err(|e| {
1296            ModelError::new(
1297                ModelErrorType::InvalidRequest,
1298                format!("Invalid end_date: {}", e),
1299                None,
1300            )
1301        })?
1302        .with_timezone(&Utc);
1303
1304    let res = sqlx::query_as!(
1305        CountResult,
1306        r#"
1307SELECT
1308    DATE_TRUNC('day',u.created_at) AS "period",
1309    COUNT(DISTINCT u.user_id) AS "count!"
1310FROM user_course_settings u
1311WHERE u.current_course_id = $1
1312  AND u.deleted_at IS NULL
1313  AND NOT (u.user_id = ANY($2))
1314  AND u.created_at >= $3
1315  AND u.created_at <= $4
1316GROUP BY period
1317ORDER BY period
1318        "#,
1319        course_id,
1320        &exclude_user_ids,
1321        start_dt,
1322        end_dt,
1323    )
1324    .fetch_all(conn)
1325    .await?;
1326
1327    Ok(res)
1328}
1329
1330/// Total unique users who started the course in a custom time period.
1331pub async fn get_total_users_started_course_custom_time_period(
1332    conn: &mut PgConnection,
1333    course_id: Uuid,
1334    start_date: &str,
1335    end_date: &str,
1336) -> ModelResult<CountResult> {
1337    let exclude_user_ids = get_user_ids_to_exclude_from_course_stats(conn, course_id).await?;
1338
1339    let start_dt = DateTime::parse_from_rfc3339(&(start_date.to_owned() + "T00:00:00Z"))
1340        .map_err(|e| {
1341            ModelError::new(
1342                ModelErrorType::InvalidRequest,
1343                format!("Invalid start_date: {}", e),
1344                None,
1345            )
1346        })?
1347        .with_timezone(&Utc);
1348
1349    let end_dt = DateTime::parse_from_rfc3339(&(end_date.to_owned() + "T23:59:59Z"))
1350        .map_err(|e| {
1351            ModelError::new(
1352                ModelErrorType::InvalidRequest,
1353                format!("Invalid end_date: {}", e),
1354                None,
1355            )
1356        })?
1357        .with_timezone(&Utc);
1358    let res = sqlx::query_as!(
1359        CountResult,
1360        r#"
1361SELECT
1362    NULL::timestamptz AS "period",
1363    COUNT(DISTINCT user_id) AS "count!"
1364FROM user_course_settings
1365WHERE current_course_id = $1
1366  AND deleted_at IS NULL
1367  AND user_id != ALL($2)
1368  AND created_at >= $3
1369  AND created_at <= $4
1370        "#,
1371        course_id,
1372        &exclude_user_ids,
1373        start_dt,
1374        end_dt,
1375    )
1376    .fetch_one(conn)
1377    .await?;
1378
1379    Ok(res)
1380}
1381
1382/// Total unique users who completed the course in a custom time period.
1383pub async fn get_total_users_completed_course_custom_time_period(
1384    conn: &mut PgConnection,
1385    course_id: Uuid,
1386    start_date: &str,
1387    end_date: &str,
1388) -> ModelResult<CountResult> {
1389    let exclude_user_ids = get_user_ids_to_exclude_from_course_stats(conn, course_id).await?;
1390
1391    let start_dt = DateTime::parse_from_rfc3339(&(start_date.to_owned() + "T00:00:00Z"))
1392        .map_err(|e| {
1393            ModelError::new(
1394                ModelErrorType::InvalidRequest,
1395                format!("Invalid start_date: {}", e),
1396                None,
1397            )
1398        })?
1399        .with_timezone(&Utc);
1400
1401    let end_dt = DateTime::parse_from_rfc3339(&(end_date.to_owned() + "T23:59:59Z"))
1402        .map_err(|e| {
1403            ModelError::new(
1404                ModelErrorType::InvalidRequest,
1405                format!("Invalid end_date: {}", e),
1406                None,
1407            )
1408        })?
1409        .with_timezone(&Utc);
1410
1411    let res = sqlx::query_as!(
1412        CountResult,
1413        r#"
1414SELECT
1415    NULL::timestamptz AS "period",
1416    COUNT(DISTINCT user_id) AS "count!"
1417FROM course_module_completions
1418WHERE course_id = $1
1419  AND deleted_at IS NULL
1420  AND user_id != ALL($2)
1421  AND created_at >= $3
1422  AND created_at <= $4
1423        "#,
1424        course_id,
1425        &exclude_user_ids,
1426        start_dt,
1427        end_dt,
1428    )
1429    .fetch_one(conn)
1430    .await?;
1431
1432    Ok(res)
1433}
1434
1435/// Total unique users who returned at least one exercise in a custom time period.
1436pub async fn get_total_users_returned_exercises_custom_time_period(
1437    conn: &mut PgConnection,
1438    course_id: Uuid,
1439    start_date: &str,
1440    end_date: &str,
1441) -> ModelResult<CountResult> {
1442    let exclude_user_ids = get_user_ids_to_exclude_from_course_stats(conn, course_id).await?;
1443
1444    let start_dt = DateTime::parse_from_rfc3339(&(start_date.to_owned() + "T00:00:00Z"))
1445        .map_err(|e| {
1446            ModelError::new(
1447                ModelErrorType::InvalidRequest,
1448                format!("Invalid start_date: {}", e),
1449                None,
1450            )
1451        })?
1452        .with_timezone(&Utc);
1453
1454    let end_dt = DateTime::parse_from_rfc3339(&(end_date.to_owned() + "T23:59:59Z"))
1455        .map_err(|e| {
1456            ModelError::new(
1457                ModelErrorType::InvalidRequest,
1458                format!("Invalid end_date: {}", e),
1459                None,
1460            )
1461        })?
1462        .with_timezone(&Utc);
1463
1464    let res = sqlx::query_as!(
1465        CountResult,
1466        r#"
1467SELECT
1468    NULL::timestamptz AS "period",
1469    COUNT(DISTINCT user_id) AS "count!"
1470FROM exercise_slide_submissions
1471WHERE course_id = $1
1472  AND deleted_at IS NULL
1473  AND user_id != ALL($2)
1474  AND created_at >= $3
1475  AND created_at <= $4
1476        "#,
1477        course_id,
1478        &exclude_user_ids,
1479        start_dt,
1480        end_dt,
1481    )
1482    .fetch_one(conn)
1483    .await?;
1484
1485    Ok(res)
1486}