headless_lms_models/library/
course_stats.rs

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