1use crate::library::TimeGranularity;
2use crate::{prelude::*, roles::UserRole};
3use std::collections::HashMap;
4
5#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
8#[cfg_attr(feature = "ts_rs", derive(TS))]
9pub struct CountResult {
10 pub period: Option<DateTime<Utc>>,
13 pub count: i64,
15}
16
17#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
20#[cfg_attr(feature = "ts_rs", derive(TS))]
21pub struct AverageMetric {
22 pub period: Option<DateTime<Utc>>,
24 pub average: Option<f64>,
26}
27
28#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
32#[cfg_attr(feature = "ts_rs", derive(TS))]
33pub struct CohortActivity {
34 pub cohort_start: Option<DateTime<Utc>>,
36 pub activity_period: Option<DateTime<Utc>>,
38 pub offset: Option<i32>,
40 pub active_users: i64,
42}
43
44async 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
61async 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
80pub 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
104pub 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
128pub 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
152pub 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
184pub 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
216pub 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
256pub 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
301pub 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
341pub 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
401pub 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
499pub 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
542pub 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
592pub 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
639pub 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
677pub 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
717pub 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
757pub 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 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 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
828pub 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 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
888pub 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 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
956pub 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 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
1018pub 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
1092pub 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
1134pub 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 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
1215pub 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
1275pub 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
1330pub 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
1382pub 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
1435pub 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}