1use crate::library::TimeGranularity;
2use crate::{prelude::*, roles::UserRole};
3use std::collections::HashMap;
4use utoipa::ToSchema;
5
6#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
9
10pub struct CountResult {
11 pub period: Option<DateTime<Utc>>,
14 pub count: i64,
16}
17
18#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
21
22pub struct AverageMetric {
23 pub period: Option<DateTime<Utc>>,
25 pub average: Option<f64>,
27}
28
29#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
33
34pub struct CohortActivity {
35 pub cohort_start: Option<DateTime<Utc>>,
37 pub activity_period: Option<DateTime<Utc>>,
39 pub offset: Option<i32>,
41 pub active_users: i64,
43}
44
45async 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
62async 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
81pub 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
105pub 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
129pub 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
153pub 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
185pub 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
217pub 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
257pub 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
302pub 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
342pub 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
402pub 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
500pub 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
543pub 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
593pub 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
640pub 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
678pub 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
718pub 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
758pub 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 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 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
829pub 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 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
889pub 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 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
957pub 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 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
1019pub 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
1093pub 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
1135pub 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 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
1216pub 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
1276pub 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
1331pub 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
1383pub 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
1436pub 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}