headless_lms_server/controllers/main_frontend/courses/
stats.rs

1//! Controllers for requests starting with `/api/v0/main-frontend/{course_id}/stats`.
2
3use 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
58/// Helper function to handle caching for stats endpoints
59async 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/// GET `/api/v0/main-frontend/{course_id}/stats/total-users-started-course`
96#[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/// GET `/api/v0/main-frontend/{course_id}/stats/total-users-completed`
137#[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/// GET `/api/v0/main-frontend/{course_id}/stats/total-users-returned-exercises`
178#[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/// GET `/api/v0/main-frontend/{course_id}/stats/avg-time-to-first-submission/{granularity}/{time_window}`
221///
222/// Returns average time to first submission statistics with specified time granularity and window.
223/// - granularity: "year", "month", or "day"
224/// - time_window: number of time units to look back
225#[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/// GET `/api/v0/main-frontend/{course_id}/stats/cohort-activity/{granularity}/{history_window}/{tracking_window}`
280#[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/// GET `/api/v0/main-frontend/{course_id}/stats/all-language-versions/total-users-started`
336#[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    // Get the course to find its language group ID
361    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/// GET `/api/v0/main-frontend/{course_id}/stats/all-language-versions/users-starting-history/{granularity}/{time_window}`
384///
385/// Returns unique users starting statistics for all language versions with specified time granularity and window.
386/// - granularity: "year", "month", or "day"
387/// - time_window: number of time units to look back
388#[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    // Get the course to find its language group ID
418    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/// GET `/api/v0/main-frontend/{course_id}/stats/all-language-versions/completions-history/{granularity}/{time_window}`
447///
448/// Returns completion statistics for all language versions with specified time granularity and window.
449/// - granularity: "year", "month", or "day"
450/// - time_window: number of time units to look back
451#[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/// GET `/api/v0/main-frontend/{course_id}/stats/completions-history/{granularity}/{time_window}`
510///
511/// Returns completion statistics for the course with specified time granularity and window.
512/// - granularity: "year", "month", or "day"
513/// - time_window: number of time units to look back
514#[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/// GET `/api/v0/main-frontend/{course_id}/stats/users-returning-exercises-history/{granularity}/{time_window}`
567///
568/// Returns users returning exercises statistics with specified time granularity and window.
569/// - granularity: "year", "month", or "day"
570/// - time_window: number of time units to look back
571#[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/// GET `/api/v0/main-frontend/{course_id}/stats/first-submissions-history/{granularity}/{time_window}`
623///
624/// Returns first exercise submission statistics with specified time granularity and window.
625/// - granularity: "year", "month", or "day"
626/// - time_window: number of time units to look back
627#[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/// GET `/api/v0/main-frontend/{course_id}/stats/users-starting-history/{granularity}/{time_window}`
679///
680/// Returns unique users starting statistics with specified time granularity and window.
681/// - granularity: "year", "month", or "day"
682/// - time_window: number of time units to look back
683#[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/// GET `/api/v0/main-frontend/{course_id}/stats/by-instance/total-users-started-course`
735#[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/// GET `/api/v0/main-frontend/{course_id}/stats/by-instance/total-users-completed`
778#[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/// GET `/api/v0/main-frontend/{course_id}/stats/by-instance/total-users-returned-exercises`
821#[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/// GET `/api/v0/main-frontend/{course_id}/stats/by-instance/completions-history/{granularity}/{time_window}`
864///
865/// Returns course completion statistics with specified time granularity and window, grouped by course instance.
866/// - granularity: "year", "month", or "day"
867/// - time_window: number of time units to look back
868#[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/// GET `/api/v0/main-frontend/{course_id}/stats/by-instance/users-starting-history/{granularity}/{time_window}`
920///
921/// Returns unique users starting statistics with specified time granularity and window, grouped by course instance.
922/// - granularity: "year", "month", or "day"
923/// - time_window: number of time units to look back
924#[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/// GET `/api/v0/main-frontend/{course_id}/stats/by-instance/first-submissions-history/{granularity}/{time_window}`
976///
977/// Returns first exercise submission statistics with specified time granularity and window, grouped by course instance.
978/// - granularity: "year", "month", or "day"
979/// - time_window: number of time units to look back
980#[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/// GET `/api/v0/main-frontend/{course_id}/stats/by-instance/users-returning-exercises-history/{granularity}/{time_window}`
1035///
1036/// Returns users returning exercises statistics with specified time granularity and window, grouped by course instance.
1037/// - granularity: "year", "month", or "day"
1038/// - time_window: number of time units to look back
1039#[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/// GET `/api/v0/main-frontend/{course_id}/stats/student-enrollments-by-country/{granularity}/{time_window}/{country}`
1094///
1095/// Returns student signup statistics grouped by country with the specified time granularity.
1096/// - granularity: "year", "month", or "day"
1097/// - time_window: number of time units to look back
1098#[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/// GET `/api/v0/main-frontend/{course_id}/stats/student-completions-by-country/{granularity}/{time_window}/{country}`
1157///
1158/// Returns student completion statistics grouped by country with the specified time granularity.
1159/// - granularity: "year", "month", or "day"
1160/// - time_window: number of time units to look back
1161#[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/// GET `/api/v0/main-frontend/{course_id}/stats/students-by-country-totals`
1220///
1221/// Returns all enrolled students grouped by country.
1222#[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/// GET `/api/v0/main-frontend/{course_id}/stats/by-module/first-submissions/{granularity}/{time_window}`
1266///
1267/// Returns first exercise submission statistics with specified time granularity and window,
1268/// grouped by module.
1269/// - granularity: "year", "month", or "day"
1270/// - time_window: number of time units to look back
1271#[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/// GET `/api/v0/main-frontend/{course_id}/stats/completions-history/custom-time-period/{start_date}/{end_date}`
1328///
1329/// Returns completion statistics by custom time period.
1330/// Query parameters:
1331/// - start_date: YYYY-MM-DD
1332/// - end_date: YYYY-MM-DD
1333#[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/// GET `/api/v0/main-frontend/{course_id}/stats/users-starting-history/custom-time-period/{start_date}/{end_date}`
1387///
1388/// Returns unique users starting statistics with specified time period.
1389#[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/// GET `/api/v0/main-frontend/{course_id}/stats/total-users-started-course/custom-time-period/{start_date}/{end_date}`
1441#[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/// GET `/api/v0/main-frontend/{course_id}/stats/total-users-completed/custom-time-period/{start_date}/{end_date}`
1493#[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/// GET `/api/v0/main-frontend/{course_id}/stats/total-users-returned-exercises/custom-time-period/{start_date}/{end_date}`
1545#[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}