1use crate::{domain::authorization::authorize, prelude::*};
4use headless_lms_models::ModelError;
5use headless_lms_models::library::TimeGranularity;
6use headless_lms_models::library::course_stats::StudentsByCountryTotalsResult;
7use headless_lms_utils::prelude::{UtilError, UtilErrorType};
8use models::library::course_stats::{AverageMetric, CohortActivity, CountResult};
9use std::collections::HashMap;
10use std::time::Duration;
11use utoipa::OpenApi;
12use uuid::Uuid;
13
14#[derive(OpenApi)]
15#[openapi(
16 paths(
17 get_total_users_started_course,
18 get_total_users_completed_course,
19 get_total_users_returned_at_least_one_exercise,
20 get_avg_time_to_first_submission_history,
21 get_cohort_activity_history,
22 get_total_users_started_all_language_versions,
23 get_unique_users_starting_history_all_language_versions,
24 get_course_completions_history_all_language_versions,
25 get_course_completions_history,
26 get_users_returning_exercises_history,
27 get_first_exercise_submissions_history,
28 get_unique_users_starting_history,
29 get_total_users_started_course_by_instance,
30 get_total_users_completed_course_by_instance,
31 get_total_users_returned_at_least_one_exercise_by_instance,
32 get_course_completions_history_by_instance,
33 get_unique_users_starting_history_by_instance,
34 get_first_exercise_submissions_history_by_instance,
35 get_users_returning_exercises_history_by_instance,
36 get_student_enrollments_by_country,
37 get_student_completions_by_country,
38 get_students_by_country_totals,
39 get_first_exercise_submissions_by_module,
40 get_course_completions_history_by_custom_time_period,
41 get_unique_users_starting_history_custom_time_period,
42 get_total_users_started_course_custom_time_period,
43 get_total_users_completed_course_custom_time_period,
44 get_total_users_returned_exercises_custom_time_period
45 ),
46 components(schemas(
47 TimeGranularity,
48 CountResult,
49 AverageMetric,
50 CohortActivity,
51 StudentsByCountryTotalsResult
52 ))
53)]
54pub(crate) struct MainFrontendCourseStatsApiDoc;
55
56const CACHE_DURATION: Duration = Duration::from_secs(3600);
57
58async fn cached_stats_query<F, Fut, T>(
60 cache: &Cache,
61 endpoint: &str,
62 course_id: Uuid,
63 extra_params: Option<&str>,
64 duration: Duration,
65 f: F,
66) -> Result<T, ControllerError>
67where
68 F: FnOnce() -> Fut,
69 Fut: std::future::Future<Output = Result<T, ModelError>>,
70 T: serde::Serialize + serde::de::DeserializeOwned,
71{
72 let cache_key = match extra_params {
73 Some(params) => format!("stats:{}:{}:{}", endpoint, course_id, params),
74 None => format!("stats:{}:{}", endpoint, course_id),
75 };
76
77 let wrapped_f = || async {
78 f().await.map_err(|err| {
79 UtilError::new(UtilErrorType::Other, "Failed to get data", Some(err.into()))
80 })
81 };
82
83 cache
84 .get_or_set(cache_key, duration, wrapped_f)
85 .await
86 .map_err(|_| {
87 ControllerError::new(
88 ControllerErrorType::InternalServerError,
89 "Failed to get data",
90 None,
91 )
92 })
93}
94
95#[utoipa::path(
97 get,
98 path = "/total-users-started-course",
99 operation_id = "getTotalUsersStartedCourse",
100 tag = "course-stats",
101 params(("course_id" = Uuid, Path, description = "Course id")),
102 responses((status = 200, description = "Total users started course", body = CountResult))
103)]
104#[instrument(skip(pool))]
105async fn get_total_users_started_course(
106 pool: web::Data<PgPool>,
107 user: AuthUser,
108 course_id: web::Path<Uuid>,
109 cache: web::Data<Cache>,
110) -> ControllerResult<web::Json<CountResult>> {
111 let mut conn = pool.acquire().await?;
112 let token = authorize(
113 &mut conn,
114 Act::ViewStats,
115 Some(user.id),
116 Res::Course(*course_id),
117 )
118 .await?;
119
120 let res = cached_stats_query(
121 &cache,
122 "total-users-started-course",
123 *course_id,
124 None,
125 CACHE_DURATION,
126 || async {
127 models::library::course_stats::get_total_users_started_course(&mut conn, *course_id)
128 .await
129 },
130 )
131 .await?;
132
133 token.authorized_ok(web::Json(res))
134}
135
136#[utoipa::path(
138 get,
139 path = "/total-users-completed",
140 operation_id = "getTotalUsersCompletedCourse",
141 tag = "course-stats",
142 params(("course_id" = Uuid, Path, description = "Course id")),
143 responses((status = 200, description = "Total users completed course", body = CountResult))
144)]
145#[instrument(skip(pool))]
146async fn get_total_users_completed_course(
147 pool: web::Data<PgPool>,
148 user: AuthUser,
149 course_id: web::Path<Uuid>,
150 cache: web::Data<Cache>,
151) -> ControllerResult<web::Json<CountResult>> {
152 let mut conn = pool.acquire().await?;
153 let token = authorize(
154 &mut conn,
155 Act::ViewStats,
156 Some(user.id),
157 Res::Course(*course_id),
158 )
159 .await?;
160
161 let res = cached_stats_query(
162 &cache,
163 "total-users-completed",
164 *course_id,
165 None,
166 CACHE_DURATION,
167 || async {
168 models::library::course_stats::get_total_users_completed_course(&mut conn, *course_id)
169 .await
170 },
171 )
172 .await?;
173
174 token.authorized_ok(web::Json(res))
175}
176
177#[utoipa::path(
179 get,
180 path = "/total-users-returned-exercises",
181 operation_id = "getTotalUsersReturnedExercises",
182 tag = "course-stats",
183 params(("course_id" = Uuid, Path, description = "Course id")),
184 responses((status = 200, description = "Total users returned exercises", body = CountResult))
185)]
186#[instrument(skip(pool))]
187async fn get_total_users_returned_at_least_one_exercise(
188 pool: web::Data<PgPool>,
189 user: AuthUser,
190 course_id: web::Path<Uuid>,
191 cache: web::Data<Cache>,
192) -> ControllerResult<web::Json<CountResult>> {
193 let mut conn = pool.acquire().await?;
194 let token = authorize(
195 &mut conn,
196 Act::ViewStats,
197 Some(user.id),
198 Res::Course(*course_id),
199 )
200 .await?;
201
202 let res = cached_stats_query(
203 &cache,
204 "total-users-returned-exercises",
205 *course_id,
206 None,
207 CACHE_DURATION,
208 || async {
209 models::library::course_stats::get_total_users_returned_at_least_one_exercise(
210 &mut conn, *course_id,
211 )
212 .await
213 },
214 )
215 .await?;
216
217 token.authorized_ok(web::Json(res))
218}
219
220#[utoipa::path(
226 get,
227 path = "/avg-time-to-first-submission/{granularity}/{time_window}",
228 operation_id = "getAvgTimeToFirstSubmissionHistory",
229 tag = "course-stats",
230 params(
231 ("course_id" = Uuid, Path, description = "Course id"),
232 ("granularity" = TimeGranularity, Path, description = "Time granularity"),
233 ("time_window" = u16, Path, description = "Time window")
234 ),
235 responses((status = 200, description = "Average time to first submission history", body = [AverageMetric]))
236)]
237#[instrument(skip(pool))]
238async fn get_avg_time_to_first_submission_history(
239 pool: web::Data<PgPool>,
240 user: AuthUser,
241 path: web::Path<(Uuid, TimeGranularity, u16)>,
242 cache: web::Data<Cache>,
243) -> ControllerResult<web::Json<Vec<AverageMetric>>> {
244 let (course_id, granularity, time_window) = path.into_inner();
245 let mut conn = pool.acquire().await?;
246 let token = authorize(
247 &mut conn,
248 Act::ViewStats,
249 Some(user.id),
250 Res::Course(course_id),
251 )
252 .await?;
253
254 let cache_key = format!(
255 "avg-time-to-first-submission-{}-{}",
256 granularity, time_window
257 );
258 let res = cached_stats_query(
259 &cache,
260 &cache_key,
261 course_id,
262 None,
263 CACHE_DURATION,
264 || async {
265 models::library::course_stats::avg_time_to_first_submission_history(
266 &mut conn,
267 course_id,
268 granularity,
269 time_window,
270 )
271 .await
272 },
273 )
274 .await?;
275
276 token.authorized_ok(web::Json(res))
277}
278
279#[utoipa::path(
281 get,
282 path = "/cohort-activity/{granularity}/{history_window}/{tracking_window}",
283 operation_id = "getCohortActivityHistory",
284 tag = "course-stats",
285 params(
286 ("course_id" = Uuid, Path, description = "Course id"),
287 ("granularity" = TimeGranularity, Path, description = "Time granularity"),
288 ("history_window" = u16, Path, description = "History window"),
289 ("tracking_window" = u16, Path, description = "Tracking window")
290 ),
291 responses((status = 200, description = "Cohort activity history", body = [CohortActivity]))
292)]
293#[instrument(skip(pool))]
294async fn get_cohort_activity_history(
295 pool: web::Data<PgPool>,
296 user: AuthUser,
297 path: web::Path<(Uuid, TimeGranularity, u16, u16)>,
298 cache: web::Data<Cache>,
299) -> ControllerResult<web::Json<Vec<CohortActivity>>> {
300 let (course_id, granularity, history_window, tracking_window) = path.into_inner();
301 let mut conn = pool.acquire().await?;
302 let token = authorize(
303 &mut conn,
304 Act::ViewStats,
305 Some(user.id),
306 Res::Course(course_id),
307 )
308 .await?;
309
310 let res = cached_stats_query(
311 &cache,
312 &format!(
313 "cohort-activity-{}-{}-{}",
314 granularity, history_window, tracking_window
315 ),
316 course_id,
317 None,
318 CACHE_DURATION,
319 || async {
320 models::library::course_stats::get_cohort_activity_history(
321 &mut conn,
322 course_id,
323 granularity,
324 history_window,
325 tracking_window,
326 )
327 .await
328 },
329 )
330 .await?;
331
332 token.authorized_ok(web::Json(res))
333}
334
335#[utoipa::path(
337 get,
338 path = "/all-language-versions/total-users-started",
339 operation_id = "getTotalUsersStartedAllLanguageVersions",
340 tag = "course-stats",
341 params(("course_id" = Uuid, Path, description = "Course id")),
342 responses((status = 200, description = "Total users started across all language versions", body = CountResult))
343)]
344#[instrument(skip(pool))]
345async fn get_total_users_started_all_language_versions(
346 pool: web::Data<PgPool>,
347 user: AuthUser,
348 course_id: web::Path<Uuid>,
349 cache: web::Data<Cache>,
350) -> ControllerResult<web::Json<CountResult>> {
351 let mut conn = pool.acquire().await?;
352 let token = authorize(
353 &mut conn,
354 Act::ViewStats,
355 Some(user.id),
356 Res::Course(*course_id),
357 )
358 .await?;
359
360 let course = models::courses::get_course(&mut conn, *course_id).await?;
362 let language_group_id = course.course_language_group_id;
363
364 let res = cached_stats_query(
365 &cache,
366 "all-language-versions-total-users-started",
367 language_group_id,
368 None,
369 CACHE_DURATION,
370 || async {
371 models::library::course_stats::get_total_users_started_all_language_versions_of_a_course(
372 &mut conn,
373 language_group_id,
374 )
375 .await
376 },
377 )
378 .await?;
379
380 token.authorized_ok(web::Json(res))
381}
382
383#[utoipa::path(
389 get,
390 path = "/all-language-versions/users-starting-history/{granularity}/{time_window}",
391 operation_id = "getUniqueUsersStartingHistoryAllLanguageVersions",
392 tag = "course-stats",
393 params(
394 ("course_id" = Uuid, Path, description = "Course id"),
395 ("granularity" = TimeGranularity, Path, description = "Time granularity"),
396 ("time_window" = u16, Path, description = "Time window")
397 ),
398 responses((status = 200, description = "Unique users starting history across all language versions", body = [CountResult]))
399)]
400#[instrument(skip(pool))]
401async fn get_unique_users_starting_history_all_language_versions(
402 pool: web::Data<PgPool>,
403 user: AuthUser,
404 path: web::Path<(Uuid, TimeGranularity, u16)>,
405 cache: web::Data<Cache>,
406) -> ControllerResult<web::Json<Vec<CountResult>>> {
407 let (course_id, granularity, time_window) = path.into_inner();
408 let mut conn = pool.acquire().await?;
409 let token = authorize(
410 &mut conn,
411 Act::ViewStats,
412 Some(user.id),
413 Res::Course(course_id),
414 )
415 .await?;
416
417 let course = models::courses::get_course(&mut conn, course_id).await?;
419 let language_group_id = course.course_language_group_id;
420
421 let cache_key = format!(
422 "all-language-versions-users-starting-{}-{}",
423 granularity, time_window
424 );
425 let res = cached_stats_query(
426 &cache,
427 &cache_key,
428 language_group_id,
429 None,
430 CACHE_DURATION,
431 || async {
432 models::library::course_stats::unique_users_starting_history_all_language_versions(
433 &mut conn,
434 language_group_id,
435 granularity,
436 time_window,
437 )
438 .await
439 },
440 )
441 .await?;
442
443 token.authorized_ok(web::Json(res))
444}
445
446#[utoipa::path(
452 get,
453 path = "/all-language-versions/completions-history/{granularity}/{time_window}",
454 operation_id = "getCourseCompletionsHistoryAllLanguageVersions",
455 tag = "course-stats",
456 params(
457 ("course_id" = Uuid, Path, description = "Course id"),
458 ("granularity" = TimeGranularity, Path, description = "Time granularity"),
459 ("time_window" = u16, Path, description = "Time window")
460 ),
461 responses((status = 200, description = "Course completions history across all language versions", body = [CountResult]))
462)]
463#[instrument(skip(pool))]
464async fn get_course_completions_history_all_language_versions(
465 pool: web::Data<PgPool>,
466 user: AuthUser,
467 path: web::Path<(Uuid, TimeGranularity, u16)>,
468 cache: web::Data<Cache>,
469) -> ControllerResult<web::Json<Vec<CountResult>>> {
470 let (course_id, granularity, time_window) = path.into_inner();
471
472 let mut conn = pool.acquire().await?;
473 let token = authorize(
474 &mut conn,
475 Act::ViewStats,
476 Some(user.id),
477 Res::Course(course_id),
478 )
479 .await?;
480
481 let course = models::courses::get_course(&mut conn, course_id).await?;
482 let language_group_id = course.course_language_group_id;
483
484 let cache_key = format!(
485 "all-language-versions-completions-{}-{}",
486 granularity, time_window
487 );
488 let res = cached_stats_query(
489 &cache,
490 &cache_key,
491 language_group_id,
492 None,
493 CACHE_DURATION,
494 || async {
495 models::library::course_stats::course_completions_history_all_language_versions(
496 &mut conn,
497 language_group_id,
498 granularity,
499 time_window,
500 )
501 .await
502 },
503 )
504 .await?;
505
506 token.authorized_ok(web::Json(res))
507}
508
509#[utoipa::path(
515 get,
516 path = "/completions-history/{granularity}/{time_window}",
517 operation_id = "getCourseCompletionsHistory",
518 tag = "course-stats",
519 params(
520 ("course_id" = Uuid, Path, description = "Course id"),
521 ("granularity" = TimeGranularity, Path, description = "Time granularity"),
522 ("time_window" = u16, Path, description = "Time window")
523 ),
524 responses((status = 200, description = "Course completions history", body = [CountResult]))
525)]
526#[instrument(skip(pool))]
527async fn get_course_completions_history(
528 pool: web::Data<PgPool>,
529 user: AuthUser,
530 path: web::Path<(Uuid, TimeGranularity, u16)>,
531 cache: web::Data<Cache>,
532) -> ControllerResult<web::Json<Vec<CountResult>>> {
533 let (course_id, granularity, time_window) = path.into_inner();
534
535 let mut conn = pool.acquire().await?;
536 let token = authorize(
537 &mut conn,
538 Act::ViewStats,
539 Some(user.id),
540 Res::Course(course_id),
541 )
542 .await?;
543
544 let cache_key = format!("completions-{}-{}", granularity, time_window);
545 let res = cached_stats_query(
546 &cache,
547 &cache_key,
548 course_id,
549 None,
550 CACHE_DURATION,
551 || async {
552 models::library::course_stats::course_completions_history(
553 &mut conn,
554 course_id,
555 granularity,
556 time_window,
557 )
558 .await
559 },
560 )
561 .await?;
562
563 token.authorized_ok(web::Json(res))
564}
565
566#[utoipa::path(
572 get,
573 path = "/users-returning-exercises-history/{granularity}/{time_window}",
574 operation_id = "getUsersReturningExercisesHistory",
575 tag = "course-stats",
576 params(
577 ("course_id" = Uuid, Path, description = "Course id"),
578 ("granularity" = TimeGranularity, Path, description = "Time granularity"),
579 ("time_window" = u16, Path, description = "Time window")
580 ),
581 responses((status = 200, description = "Users returning exercises history", body = [CountResult]))
582)]
583#[instrument(skip(pool))]
584async fn get_users_returning_exercises_history(
585 pool: web::Data<PgPool>,
586 user: AuthUser,
587 path: web::Path<(Uuid, TimeGranularity, u16)>,
588 cache: web::Data<Cache>,
589) -> ControllerResult<web::Json<Vec<CountResult>>> {
590 let (course_id, granularity, time_window) = path.into_inner();
591 let mut conn = pool.acquire().await?;
592 let token = authorize(
593 &mut conn,
594 Act::ViewStats,
595 Some(user.id),
596 Res::Course(course_id),
597 )
598 .await?;
599
600 let cache_key = format!("users-returning-{}-{}", granularity, time_window);
601 let res = cached_stats_query(
602 &cache,
603 &cache_key,
604 course_id,
605 None,
606 CACHE_DURATION,
607 || async {
608 models::library::course_stats::users_returning_exercises_history(
609 &mut conn,
610 course_id,
611 granularity,
612 time_window,
613 )
614 .await
615 },
616 )
617 .await?;
618
619 token.authorized_ok(web::Json(res))
620}
621
622#[utoipa::path(
628 get,
629 path = "/first-submissions-history/{granularity}/{time_window}",
630 operation_id = "getFirstExerciseSubmissionsHistory",
631 tag = "course-stats",
632 params(
633 ("course_id" = Uuid, Path, description = "Course id"),
634 ("granularity" = TimeGranularity, Path, description = "Time granularity"),
635 ("time_window" = u16, Path, description = "Time window")
636 ),
637 responses((status = 200, description = "First exercise submissions history", body = [CountResult]))
638)]
639#[instrument(skip(pool))]
640async fn get_first_exercise_submissions_history(
641 pool: web::Data<PgPool>,
642 user: AuthUser,
643 path: web::Path<(Uuid, TimeGranularity, u16)>,
644 cache: web::Data<Cache>,
645) -> ControllerResult<web::Json<Vec<CountResult>>> {
646 let (course_id, granularity, time_window) = path.into_inner();
647 let mut conn = pool.acquire().await?;
648 let token = authorize(
649 &mut conn,
650 Act::ViewStats,
651 Some(user.id),
652 Res::Course(course_id),
653 )
654 .await?;
655
656 let cache_key = format!("first-submissions-{}-{}", granularity, time_window);
657 let res = cached_stats_query(
658 &cache,
659 &cache_key,
660 course_id,
661 None,
662 CACHE_DURATION,
663 || async {
664 models::library::course_stats::first_exercise_submissions_history(
665 &mut conn,
666 course_id,
667 granularity,
668 time_window,
669 )
670 .await
671 },
672 )
673 .await?;
674
675 token.authorized_ok(web::Json(res))
676}
677
678#[utoipa::path(
684 get,
685 path = "/users-starting-history/{granularity}/{time_window}",
686 operation_id = "getUniqueUsersStartingHistory",
687 tag = "course-stats",
688 params(
689 ("course_id" = Uuid, Path, description = "Course id"),
690 ("granularity" = TimeGranularity, Path, description = "Time granularity"),
691 ("time_window" = u16, Path, description = "Time window")
692 ),
693 responses((status = 200, description = "Unique users starting history", body = [CountResult]))
694)]
695#[instrument(skip(pool))]
696async fn get_unique_users_starting_history(
697 pool: web::Data<PgPool>,
698 user: AuthUser,
699 path: web::Path<(Uuid, TimeGranularity, u16)>,
700 cache: web::Data<Cache>,
701) -> ControllerResult<web::Json<Vec<CountResult>>> {
702 let (course_id, granularity, time_window) = path.into_inner();
703 let mut conn = pool.acquire().await?;
704 let token = authorize(
705 &mut conn,
706 Act::ViewStats,
707 Some(user.id),
708 Res::Course(course_id),
709 )
710 .await?;
711
712 let cache_key = format!("users-starting-{}-{}", granularity, time_window);
713 let res = cached_stats_query(
714 &cache,
715 &cache_key,
716 course_id,
717 None,
718 CACHE_DURATION,
719 || async {
720 models::library::course_stats::unique_users_starting_history(
721 &mut conn,
722 course_id,
723 granularity,
724 time_window,
725 )
726 .await
727 },
728 )
729 .await?;
730
731 token.authorized_ok(web::Json(res))
732}
733
734#[utoipa::path(
736 get,
737 path = "/by-instance/total-users-started-course",
738 operation_id = "getTotalUsersStartedCourseByInstance",
739 tag = "course-stats",
740 params(("course_id" = Uuid, Path, description = "Course id")),
741 responses((status = 200, description = "Total users started course by instance", body = HashMap<Uuid, CountResult>))
742)]
743#[instrument(skip(pool))]
744async fn get_total_users_started_course_by_instance(
745 pool: web::Data<PgPool>,
746 user: AuthUser,
747 course_id: web::Path<Uuid>,
748 cache: web::Data<Cache>,
749) -> ControllerResult<web::Json<HashMap<Uuid, CountResult>>> {
750 let mut conn = pool.acquire().await?;
751 let token = authorize(
752 &mut conn,
753 Act::ViewStats,
754 Some(user.id),
755 Res::Course(*course_id),
756 )
757 .await?;
758
759 let res = cached_stats_query(
760 &cache,
761 "total-users-started-course-by-instance",
762 *course_id,
763 None,
764 CACHE_DURATION,
765 || async {
766 models::library::course_stats::get_total_users_started_course_by_instance(
767 &mut conn, *course_id,
768 )
769 .await
770 },
771 )
772 .await?;
773
774 token.authorized_ok(web::Json(res))
775}
776
777#[utoipa::path(
779 get,
780 path = "/by-instance/total-users-completed",
781 operation_id = "getTotalUsersCompletedCourseByInstance",
782 tag = "course-stats",
783 params(("course_id" = Uuid, Path, description = "Course id")),
784 responses((status = 200, description = "Total users completed course by instance", body = HashMap<Uuid, CountResult>))
785)]
786#[instrument(skip(pool))]
787async fn get_total_users_completed_course_by_instance(
788 pool: web::Data<PgPool>,
789 user: AuthUser,
790 course_id: web::Path<Uuid>,
791 cache: web::Data<Cache>,
792) -> ControllerResult<web::Json<HashMap<Uuid, CountResult>>> {
793 let mut conn = pool.acquire().await?;
794 let token = authorize(
795 &mut conn,
796 Act::ViewStats,
797 Some(user.id),
798 Res::Course(*course_id),
799 )
800 .await?;
801
802 let res = cached_stats_query(
803 &cache,
804 "total-users-completed-by-instance",
805 *course_id,
806 None,
807 CACHE_DURATION,
808 || async {
809 models::library::course_stats::get_total_users_completed_course_by_instance(
810 &mut conn, *course_id,
811 )
812 .await
813 },
814 )
815 .await?;
816
817 token.authorized_ok(web::Json(res))
818}
819
820#[utoipa::path(
822 get,
823 path = "/by-instance/total-users-returned-exercises",
824 operation_id = "getTotalUsersReturnedExercisesByInstance",
825 tag = "course-stats",
826 params(("course_id" = Uuid, Path, description = "Course id")),
827 responses((status = 200, description = "Total users returned exercises by instance", body = HashMap<Uuid, CountResult>))
828)]
829#[instrument(skip(pool))]
830async fn get_total_users_returned_at_least_one_exercise_by_instance(
831 pool: web::Data<PgPool>,
832 user: AuthUser,
833 course_id: web::Path<Uuid>,
834 cache: web::Data<Cache>,
835) -> ControllerResult<web::Json<HashMap<Uuid, CountResult>>> {
836 let mut conn = pool.acquire().await?;
837 let token = authorize(
838 &mut conn,
839 Act::ViewStats,
840 Some(user.id),
841 Res::Course(*course_id),
842 )
843 .await?;
844
845 let res = cached_stats_query(
846 &cache,
847 "total-users-returned-exercises-by-instance",
848 *course_id,
849 None,
850 CACHE_DURATION,
851 || async {
852 models::library::course_stats::get_total_users_returned_at_least_one_exercise_by_instance(
853 &mut conn, *course_id,
854 )
855 .await
856 },
857 )
858 .await?;
859
860 token.authorized_ok(web::Json(res))
861}
862
863#[utoipa::path(
869 get,
870 path = "/by-instance/completions-history/{granularity}/{time_window}",
871 operation_id = "getCourseCompletionsHistoryByInstance",
872 tag = "course-stats",
873 params(
874 ("course_id" = Uuid, Path, description = "Course id"),
875 ("granularity" = TimeGranularity, Path, description = "Time granularity"),
876 ("time_window" = u16, Path, description = "Time window")
877 ),
878 responses((status = 200, description = "Course completions history by instance", body = HashMap<Uuid, Vec<CountResult>>))
879)]
880#[instrument(skip(pool))]
881async fn get_course_completions_history_by_instance(
882 pool: web::Data<PgPool>,
883 user: AuthUser,
884 path: web::Path<(Uuid, TimeGranularity, u16)>,
885 cache: web::Data<Cache>,
886) -> ControllerResult<web::Json<HashMap<Uuid, Vec<CountResult>>>> {
887 let (course_id, granularity, time_window) = path.into_inner();
888 let mut conn = pool.acquire().await?;
889 let token = authorize(
890 &mut conn,
891 Act::ViewStats,
892 Some(user.id),
893 Res::Course(course_id),
894 )
895 .await?;
896
897 let cache_key = format!("completions-by-instance-{}-{}", granularity, time_window);
898 let res = cached_stats_query(
899 &cache,
900 &cache_key,
901 course_id,
902 None,
903 CACHE_DURATION,
904 || async {
905 models::library::course_stats::course_completions_history_by_instance(
906 &mut conn,
907 course_id,
908 granularity,
909 time_window,
910 )
911 .await
912 },
913 )
914 .await?;
915
916 token.authorized_ok(web::Json(res))
917}
918
919#[utoipa::path(
925 get,
926 path = "/by-instance/users-starting-history/{granularity}/{time_window}",
927 operation_id = "getUniqueUsersStartingHistoryByInstance",
928 tag = "course-stats",
929 params(
930 ("course_id" = Uuid, Path, description = "Course id"),
931 ("granularity" = TimeGranularity, Path, description = "Time granularity"),
932 ("time_window" = u16, Path, description = "Time window")
933 ),
934 responses((status = 200, description = "Unique users starting history by instance", body = HashMap<Uuid, Vec<CountResult>>))
935)]
936#[instrument(skip(pool))]
937async fn get_unique_users_starting_history_by_instance(
938 pool: web::Data<PgPool>,
939 user: AuthUser,
940 path: web::Path<(Uuid, TimeGranularity, u16)>,
941 cache: web::Data<Cache>,
942) -> ControllerResult<web::Json<HashMap<Uuid, Vec<CountResult>>>> {
943 let (course_id, granularity, time_window) = path.into_inner();
944 let mut conn = pool.acquire().await?;
945 let token = authorize(
946 &mut conn,
947 Act::ViewStats,
948 Some(user.id),
949 Res::Course(course_id),
950 )
951 .await?;
952
953 let cache_key = format!("users-starting-by-instance-{}-{}", granularity, time_window);
954 let res = cached_stats_query(
955 &cache,
956 &cache_key,
957 course_id,
958 None,
959 CACHE_DURATION,
960 || async {
961 models::library::course_stats::unique_users_starting_history_by_instance(
962 &mut conn,
963 course_id,
964 granularity,
965 time_window,
966 )
967 .await
968 },
969 )
970 .await?;
971
972 token.authorized_ok(web::Json(res))
973}
974
975#[utoipa::path(
981 get,
982 path = "/by-instance/first-submissions-history/{granularity}/{time_window}",
983 operation_id = "getFirstExerciseSubmissionsHistoryByInstance",
984 tag = "course-stats",
985 params(
986 ("course_id" = Uuid, Path, description = "Course id"),
987 ("granularity" = TimeGranularity, Path, description = "Time granularity"),
988 ("time_window" = u16, Path, description = "Time window")
989 ),
990 responses((status = 200, description = "First exercise submissions history by instance", body = HashMap<Uuid, Vec<CountResult>>))
991)]
992#[instrument(skip(pool))]
993async fn get_first_exercise_submissions_history_by_instance(
994 pool: web::Data<PgPool>,
995 user: AuthUser,
996 path: web::Path<(Uuid, TimeGranularity, u16)>,
997 cache: web::Data<Cache>,
998) -> ControllerResult<web::Json<HashMap<Uuid, Vec<CountResult>>>> {
999 let (course_id, granularity, time_window) = path.into_inner();
1000 let mut conn = pool.acquire().await?;
1001 let token = authorize(
1002 &mut conn,
1003 Act::ViewStats,
1004 Some(user.id),
1005 Res::Course(course_id),
1006 )
1007 .await?;
1008
1009 let cache_key = format!(
1010 "first-submissions-by-instance-{}-{}",
1011 granularity, time_window
1012 );
1013 let res = cached_stats_query(
1014 &cache,
1015 &cache_key,
1016 course_id,
1017 None,
1018 CACHE_DURATION,
1019 || async {
1020 models::library::course_stats::first_exercise_submissions_history_by_instance(
1021 &mut conn,
1022 course_id,
1023 granularity,
1024 time_window,
1025 )
1026 .await
1027 },
1028 )
1029 .await?;
1030
1031 token.authorized_ok(web::Json(res))
1032}
1033
1034#[utoipa::path(
1040 get,
1041 path = "/by-instance/users-returning-exercises-history/{granularity}/{time_window}",
1042 operation_id = "getUsersReturningExercisesHistoryByInstance",
1043 tag = "course-stats",
1044 params(
1045 ("course_id" = Uuid, Path, description = "Course id"),
1046 ("granularity" = TimeGranularity, Path, description = "Time granularity"),
1047 ("time_window" = u16, Path, description = "Time window")
1048 ),
1049 responses((status = 200, description = "Users returning exercises history by instance", body = HashMap<Uuid, Vec<CountResult>>))
1050)]
1051#[instrument(skip(pool))]
1052async fn get_users_returning_exercises_history_by_instance(
1053 pool: web::Data<PgPool>,
1054 user: AuthUser,
1055 path: web::Path<(Uuid, TimeGranularity, u16)>,
1056 cache: web::Data<Cache>,
1057) -> ControllerResult<web::Json<HashMap<Uuid, Vec<CountResult>>>> {
1058 let (course_id, granularity, time_window) = path.into_inner();
1059 let mut conn = pool.acquire().await?;
1060 let token = authorize(
1061 &mut conn,
1062 Act::ViewStats,
1063 Some(user.id),
1064 Res::Course(course_id),
1065 )
1066 .await?;
1067
1068 let cache_key = format!(
1069 "users-returning-by-instance-{}-{}",
1070 granularity, time_window
1071 );
1072 let res = cached_stats_query(
1073 &cache,
1074 &cache_key,
1075 course_id,
1076 None,
1077 CACHE_DURATION,
1078 || async {
1079 models::library::course_stats::users_returning_exercises_history_by_instance(
1080 &mut conn,
1081 course_id,
1082 granularity,
1083 time_window,
1084 )
1085 .await
1086 },
1087 )
1088 .await?;
1089
1090 token.authorized_ok(web::Json(res))
1091}
1092
1093#[utoipa::path(
1099 get,
1100 path = "/student-enrollments-by-country/{granularity}/{time_window}/{country}",
1101 operation_id = "getStudentEnrollmentsByCountry",
1102 tag = "course-stats",
1103 params(
1104 ("course_id" = Uuid, Path, description = "Course id"),
1105 ("granularity" = TimeGranularity, Path, description = "Time granularity"),
1106 ("time_window" = u16, Path, description = "Time window"),
1107 ("country" = String, Path, description = "Country")
1108 ),
1109 responses((status = 200, description = "Student enrollments by country", body = [CountResult]))
1110)]
1111#[instrument(skip(pool))]
1112async fn get_student_enrollments_by_country(
1113 pool: web::Data<PgPool>,
1114 user: AuthUser,
1115 path: web::Path<(Uuid, TimeGranularity, u16, String)>,
1116 cache: web::Data<Cache>,
1117) -> ControllerResult<web::Json<Vec<CountResult>>> {
1118 let (course_id, granularity, time_window, country) = path.into_inner();
1119
1120 let mut conn = pool.acquire().await?;
1121 let token = authorize(
1122 &mut conn,
1123 Act::ViewStats,
1124 Some(user.id),
1125 Res::Course(course_id),
1126 )
1127 .await?;
1128
1129 let cache_key = format!(
1130 "student-enrollments-by-country-{}-{}-{}",
1131 granularity, time_window, country
1132 );
1133
1134 let res = cached_stats_query(
1135 &cache,
1136 &cache_key,
1137 course_id,
1138 None,
1139 CACHE_DURATION,
1140 || async {
1141 models::library::course_stats::student_enrollments_by_country(
1142 &mut conn,
1143 course_id,
1144 granularity,
1145 time_window,
1146 country,
1147 )
1148 .await
1149 },
1150 )
1151 .await?;
1152
1153 token.authorized_ok(web::Json(res))
1154}
1155
1156#[utoipa::path(
1162 get,
1163 path = "/student-completions-by-country/{granularity}/{time_window}/{country}",
1164 operation_id = "getStudentCompletionsByCountry",
1165 tag = "course-stats",
1166 params(
1167 ("course_id" = Uuid, Path, description = "Course id"),
1168 ("granularity" = TimeGranularity, Path, description = "Time granularity"),
1169 ("time_window" = u16, Path, description = "Time window"),
1170 ("country" = String, Path, description = "Country")
1171 ),
1172 responses((status = 200, description = "Student completions by country", body = [CountResult]))
1173)]
1174#[instrument(skip(pool))]
1175async fn get_student_completions_by_country(
1176 pool: web::Data<PgPool>,
1177 user: AuthUser,
1178 path: web::Path<(Uuid, TimeGranularity, u16, String)>,
1179 cache: web::Data<Cache>,
1180) -> ControllerResult<web::Json<Vec<CountResult>>> {
1181 let (course_id, granularity, time_window, country) = path.into_inner();
1182
1183 let mut conn = pool.acquire().await?;
1184 let token = authorize(
1185 &mut conn,
1186 Act::ViewStats,
1187 Some(user.id),
1188 Res::Course(course_id),
1189 )
1190 .await?;
1191
1192 let cache_key = format!(
1193 "student-completions-by-country-{}-{}-{}",
1194 granularity, time_window, country
1195 );
1196
1197 let res = cached_stats_query(
1198 &cache,
1199 &cache_key,
1200 course_id,
1201 None,
1202 CACHE_DURATION,
1203 || async {
1204 models::library::course_stats::student_completions_by_country(
1205 &mut conn,
1206 course_id,
1207 granularity,
1208 time_window,
1209 country,
1210 )
1211 .await
1212 },
1213 )
1214 .await?;
1215
1216 token.authorized_ok(web::Json(res))
1217}
1218
1219#[utoipa::path(
1223 get,
1224 path = "/students-by-country-totals",
1225 operation_id = "getStudentsByCountryTotals",
1226 tag = "course-stats",
1227 params(("course_id" = Uuid, Path, description = "Course id")),
1228 responses((status = 200, description = "Students by country totals", body = [StudentsByCountryTotalsResult]))
1229)]
1230#[instrument(skip(pool))]
1231async fn get_students_by_country_totals(
1232 pool: web::Data<PgPool>,
1233 user: AuthUser,
1234 path: web::Path<Uuid>,
1235 cache: web::Data<Cache>,
1236) -> ControllerResult<web::Json<Vec<StudentsByCountryTotalsResult>>> {
1237 let mut conn = pool.acquire().await?;
1238 let course_id = path.into_inner();
1239
1240 let token = authorize(
1241 &mut conn,
1242 Act::ViewStats,
1243 Some(user.id),
1244 Res::Course(course_id),
1245 )
1246 .await?;
1247
1248 let cache_key = format!("students-by-country-totals-{}", course_id);
1249
1250 let res = cached_stats_query(
1251 &cache,
1252 &cache_key,
1253 course_id,
1254 None,
1255 CACHE_DURATION,
1256 || async {
1257 models::library::course_stats::students_by_country_totals(&mut conn, course_id).await
1258 },
1259 )
1260 .await?;
1261
1262 token.authorized_ok(web::Json(res))
1263}
1264
1265#[utoipa::path(
1272 get,
1273 path = "/by-module/first-submissions/{granularity}/{time_window}",
1274 operation_id = "getFirstExerciseSubmissionsByModule",
1275 tag = "course-stats",
1276 params(
1277 ("course_id" = Uuid, Path, description = "Course id"),
1278 ("granularity" = TimeGranularity, Path, description = "Time granularity"),
1279 ("time_window" = u16, Path, description = "Time window")
1280 ),
1281 responses((status = 200, description = "First exercise submissions by module", body = HashMap<Uuid, Vec<CountResult>>))
1282)]
1283#[instrument(skip(pool))]
1284async fn get_first_exercise_submissions_by_module(
1285 pool: web::Data<PgPool>,
1286 user: AuthUser,
1287 path: web::Path<(Uuid, TimeGranularity, u16)>,
1288 cache: web::Data<Cache>,
1289) -> ControllerResult<web::Json<HashMap<Uuid, Vec<CountResult>>>> {
1290 let (course_id, granularity, time_window) = path.into_inner();
1291
1292 let mut conn = pool.acquire().await?;
1293 let token = authorize(
1294 &mut conn,
1295 Act::ViewStats,
1296 Some(user.id),
1297 Res::Course(course_id),
1298 )
1299 .await?;
1300
1301 let cache_key = format!(
1302 "first-submissions-by-module-{}-{}",
1303 granularity, time_window
1304 );
1305
1306 let res = cached_stats_query(
1307 &cache,
1308 &cache_key,
1309 course_id,
1310 None,
1311 CACHE_DURATION,
1312 || async {
1313 models::library::course_stats::first_exercise_submissions_by_module(
1314 &mut conn,
1315 course_id,
1316 granularity,
1317 time_window,
1318 )
1319 .await
1320 },
1321 )
1322 .await?;
1323
1324 token.authorized_ok(web::Json(res))
1325}
1326
1327#[utoipa::path(
1334 get,
1335 path = "/completions-history/custom-time-period/{start_date}/{end_date}",
1336 operation_id = "getCourseCompletionsHistoryCustomTimePeriod",
1337 tag = "course-stats",
1338 params(
1339 ("course_id" = Uuid, Path, description = "Course id"),
1340 ("start_date" = String, Path, description = "Start date"),
1341 ("end_date" = String, Path, description = "End date")
1342 ),
1343 responses((status = 200, description = "Course completions history for custom time period", body = [CountResult]))
1344)]
1345#[instrument(skip(pool))]
1346async fn get_course_completions_history_by_custom_time_period(
1347 pool: web::Data<PgPool>,
1348 user: AuthUser,
1349 path: web::Path<(Uuid, String, String)>,
1350 cache: web::Data<Cache>,
1351) -> ControllerResult<web::Json<Vec<CountResult>>> {
1352 let (course_id, start_date, end_date) = path.into_inner();
1353
1354 let mut conn = pool.acquire().await?;
1355 let token = authorize(
1356 &mut conn,
1357 Act::ViewStats,
1358 Some(user.id),
1359 Res::Course(course_id),
1360 )
1361 .await?;
1362
1363 let cache_key = format!("completions-custom-{}-{}", start_date, end_date);
1364
1365 let res = cached_stats_query(
1366 &cache,
1367 &cache_key,
1368 course_id,
1369 None,
1370 CACHE_DURATION,
1371 || async {
1372 models::library::course_stats::course_completions_history_by_custom_time_period(
1373 &mut conn,
1374 course_id,
1375 &start_date,
1376 &end_date,
1377 )
1378 .await
1379 },
1380 )
1381 .await?;
1382
1383 token.authorized_ok(web::Json(res))
1384}
1385
1386#[utoipa::path(
1390 get,
1391 path = "/users-starting-history/custom-time-period/{start_date}/{end_date}",
1392 operation_id = "getUniqueUsersStartingHistoryCustomTimePeriod",
1393 tag = "course-stats",
1394 params(
1395 ("course_id" = Uuid, Path, description = "Course id"),
1396 ("start_date" = String, Path, description = "Start date"),
1397 ("end_date" = String, Path, description = "End date")
1398 ),
1399 responses((status = 200, description = "Unique users starting history for custom time period", body = [CountResult]))
1400)]
1401#[instrument(skip(pool))]
1402async fn get_unique_users_starting_history_custom_time_period(
1403 pool: web::Data<PgPool>,
1404 user: AuthUser,
1405 path: web::Path<(Uuid, String, String)>,
1406 cache: web::Data<Cache>,
1407) -> ControllerResult<web::Json<Vec<CountResult>>> {
1408 let (course_id, start_date, end_date) = path.into_inner();
1409 let mut conn = pool.acquire().await?;
1410 let token = authorize(
1411 &mut conn,
1412 Act::ViewStats,
1413 Some(user.id),
1414 Res::Course(course_id),
1415 )
1416 .await?;
1417
1418 let cache_key = format!("users-starting-custom-{}-{}", start_date, end_date);
1419 let res = cached_stats_query(
1420 &cache,
1421 &cache_key,
1422 course_id,
1423 None,
1424 CACHE_DURATION,
1425 || async {
1426 models::library::course_stats::unique_users_starting_history_by_custom_time_period(
1427 &mut conn,
1428 course_id,
1429 &start_date,
1430 &end_date,
1431 )
1432 .await
1433 },
1434 )
1435 .await?;
1436
1437 token.authorized_ok(web::Json(res))
1438}
1439
1440#[utoipa::path(
1442 get,
1443 path = "/total-users-started-course/custom-time-period/{start_date}/{end_date}",
1444 operation_id = "getTotalUsersStartedCourseCustomTimePeriod",
1445 tag = "course-stats",
1446 params(
1447 ("course_id" = Uuid, Path, description = "Course id"),
1448 ("start_date" = String, Path, description = "Start date"),
1449 ("end_date" = String, Path, description = "End date")
1450 ),
1451 responses((status = 200, description = "Total users started course for custom time period", body = CountResult))
1452)]
1453#[instrument(skip(pool))]
1454async fn get_total_users_started_course_custom_time_period(
1455 pool: web::Data<PgPool>,
1456 user: AuthUser,
1457 path: web::Path<(Uuid, String, String)>,
1458 cache: web::Data<Cache>,
1459) -> ControllerResult<web::Json<CountResult>> {
1460 let (course_id, start_date, end_date) = path.into_inner();
1461 let mut conn = pool.acquire().await?;
1462 let token = authorize(
1463 &mut conn,
1464 Act::ViewStats,
1465 Some(user.id),
1466 Res::Course(course_id),
1467 )
1468 .await?;
1469
1470 let cache_key = format!("total-users-started-custom-{}-{}", start_date, end_date);
1471 let res = cached_stats_query(
1472 &cache,
1473 &cache_key,
1474 course_id,
1475 None,
1476 CACHE_DURATION,
1477 || async {
1478 models::library::course_stats::get_total_users_started_course_custom_time_period(
1479 &mut conn,
1480 course_id,
1481 &start_date,
1482 &end_date,
1483 )
1484 .await
1485 },
1486 )
1487 .await?;
1488
1489 token.authorized_ok(web::Json(res))
1490}
1491
1492#[utoipa::path(
1494 get,
1495 path = "/total-users-completed/custom-time-period/{start_date}/{end_date}",
1496 operation_id = "getTotalUsersCompletedCourseCustomTimePeriod",
1497 tag = "course-stats",
1498 params(
1499 ("course_id" = Uuid, Path, description = "Course id"),
1500 ("start_date" = String, Path, description = "Start date"),
1501 ("end_date" = String, Path, description = "End date")
1502 ),
1503 responses((status = 200, description = "Total users completed course for custom time period", body = CountResult))
1504)]
1505#[instrument(skip(pool))]
1506async fn get_total_users_completed_course_custom_time_period(
1507 pool: web::Data<PgPool>,
1508 user: AuthUser,
1509 path: web::Path<(Uuid, String, String)>,
1510 cache: web::Data<Cache>,
1511) -> ControllerResult<web::Json<CountResult>> {
1512 let (course_id, start_date, end_date) = path.into_inner();
1513 let mut conn = pool.acquire().await?;
1514 let token = authorize(
1515 &mut conn,
1516 Act::ViewStats,
1517 Some(user.id),
1518 Res::Course(course_id),
1519 )
1520 .await?;
1521
1522 let cache_key = format!("total-users-completed-custom-{}-{}", start_date, end_date);
1523 let res = cached_stats_query(
1524 &cache,
1525 &cache_key,
1526 course_id,
1527 None,
1528 CACHE_DURATION,
1529 || async {
1530 models::library::course_stats::get_total_users_completed_course_custom_time_period(
1531 &mut conn,
1532 course_id,
1533 &start_date,
1534 &end_date,
1535 )
1536 .await
1537 },
1538 )
1539 .await?;
1540
1541 token.authorized_ok(web::Json(res))
1542}
1543
1544#[utoipa::path(
1546 get,
1547 path = "/total-users-returned-exercises/custom-time-period/{start_date}/{end_date}",
1548 operation_id = "getTotalUsersReturnedExercisesCustomTimePeriod",
1549 tag = "course-stats",
1550 params(
1551 ("course_id" = Uuid, Path, description = "Course id"),
1552 ("start_date" = String, Path, description = "Start date"),
1553 ("end_date" = String, Path, description = "End date")
1554 ),
1555 responses((status = 200, description = "Total users returned exercises for custom time period", body = CountResult))
1556)]
1557#[instrument(skip(pool))]
1558async fn get_total_users_returned_exercises_custom_time_period(
1559 pool: web::Data<PgPool>,
1560 user: AuthUser,
1561 path: web::Path<(Uuid, String, String)>,
1562 cache: web::Data<Cache>,
1563) -> ControllerResult<web::Json<CountResult>> {
1564 let (course_id, start_date, end_date) = path.into_inner();
1565 let mut conn = pool.acquire().await?;
1566 let token = authorize(
1567 &mut conn,
1568 Act::ViewStats,
1569 Some(user.id),
1570 Res::Course(course_id),
1571 )
1572 .await?;
1573
1574 let cache_key = format!("total-users-returned-custom-{}-{}", start_date, end_date);
1575 let res = cached_stats_query(
1576 &cache,
1577 &cache_key,
1578 course_id,
1579 None,
1580 CACHE_DURATION,
1581 || async {
1582 models::library::course_stats::get_total_users_returned_exercises_custom_time_period(
1583 &mut conn,
1584 course_id,
1585 &start_date,
1586 &end_date,
1587 )
1588 .await
1589 },
1590 )
1591 .await?;
1592
1593 token.authorized_ok(web::Json(res))
1594}
1595
1596pub fn _add_routes(cfg: &mut web::ServiceConfig) {
1597 cfg.route(
1598 "/total-users-started-course",
1599 web::get().to(get_total_users_started_course),
1600 )
1601 .route(
1602 "/total-users-completed",
1603 web::get().to(get_total_users_completed_course),
1604 )
1605 .route(
1606 "/total-users-returned-exercises",
1607 web::get().to(get_total_users_returned_at_least_one_exercise),
1608 )
1609 .route(
1610 "/by-instance/total-users-started-course",
1611 web::get().to(get_total_users_started_course_by_instance),
1612 )
1613 .route(
1614 "/by-instance/total-users-completed",
1615 web::get().to(get_total_users_completed_course_by_instance),
1616 )
1617 .route(
1618 "/by-instance/total-users-returned-exercises",
1619 web::get().to(get_total_users_returned_at_least_one_exercise_by_instance),
1620 )
1621 .route(
1622 "/first-submissions-history/{granularity}/{time_window}",
1623 web::get().to(get_first_exercise_submissions_history),
1624 )
1625 .route(
1626 "/by-instance/first-submissions-history/{granularity}/{time_window}",
1627 web::get().to(get_first_exercise_submissions_history_by_instance),
1628 )
1629 .route(
1630 "/users-returning-exercises-history/{granularity}/{time_window}",
1631 web::get().to(get_users_returning_exercises_history),
1632 )
1633 .route(
1634 "/by-instance/users-returning-exercises-history/{granularity}/{time_window}",
1635 web::get().to(get_users_returning_exercises_history_by_instance),
1636 )
1637 .route(
1638 "/completions-history/{granularity}/{time_window}",
1639 web::get().to(get_course_completions_history),
1640 )
1641 .route(
1642 "/by-instance/completions-history/{granularity}/{time_window}",
1643 web::get().to(get_course_completions_history_by_instance),
1644 )
1645 .route(
1646 "/users-starting-history/{granularity}/{time_window}",
1647 web::get().to(get_unique_users_starting_history),
1648 )
1649 .route(
1650 "/by-instance/users-starting-history/{granularity}/{time_window}",
1651 web::get().to(get_unique_users_starting_history_by_instance),
1652 )
1653 .route(
1654 "/avg-time-to-first-submission/{granularity}/{time_window}",
1655 web::get().to(get_avg_time_to_first_submission_history),
1656 )
1657 .route(
1658 "/cohort-activity/{granularity}/{history_window}/{tracking_window}",
1659 web::get().to(get_cohort_activity_history),
1660 )
1661 .route(
1662 "/all-language-versions/total-users-started",
1663 web::get().to(get_total_users_started_all_language_versions),
1664 )
1665 .route(
1666 "/all-language-versions/users-starting-history/{granularity}/{time_window}",
1667 web::get().to(get_unique_users_starting_history_all_language_versions),
1668 )
1669 .route(
1670 "/all-language-versions/completions-history/{granularity}/{time_window}",
1671 web::get().to(get_course_completions_history_all_language_versions),
1672 )
1673 .route(
1674 "/completions-history/custom-time-period/{start_date}/{end_date}",
1675 web::get().to(get_course_completions_history_by_custom_time_period),
1676 )
1677 .route(
1678 "/student-enrollments-by-country/{granularity}/{time_window}/{country}",
1679 web::get().to(get_student_enrollments_by_country),
1680 )
1681 .route(
1682 "/student-completions-by-country/{granularity}/{time_window}/{country}",
1683 web::get().to(get_student_completions_by_country),
1684 )
1685 .route(
1686 "/students-by-country-totals",
1687 web::get().to(get_students_by_country_totals),
1688 )
1689 .route(
1690 "/by-module/first-submissions/{granularity}/{time_window}",
1691 web::get().to(get_first_exercise_submissions_by_module),
1692 )
1693 .route(
1694 "/users-starting-history/custom-time-period/{start_date}/{end_date}",
1695 web::get().to(get_unique_users_starting_history_custom_time_period),
1696 )
1697 .route(
1698 "/total-users-started-course/custom-time-period/{start_date}/{end_date}",
1699 web::get().to(get_total_users_started_course_custom_time_period),
1700 )
1701 .route(
1702 "/total-users-completed/custom-time-period/{start_date}/{end_date}",
1703 web::get().to(get_total_users_completed_course_custom_time_period),
1704 )
1705 .route(
1706 "/total-users-returned-exercises/custom-time-period/{start_date}/{end_date}",
1707 web::get().to(get_total_users_returned_exercises_custom_time_period),
1708 );
1709}