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 uuid::Uuid;
12const CACHE_DURATION: Duration = Duration::from_secs(3600);
13
14/// Helper function to handle caching for stats endpoints
15async fn cached_stats_query<F, Fut, T>(
16    cache: &Cache,
17    endpoint: &str,
18    course_id: Uuid,
19    extra_params: Option<&str>,
20    duration: Duration,
21    f: F,
22) -> Result<T, ControllerError>
23where
24    F: FnOnce() -> Fut,
25    Fut: std::future::Future<Output = Result<T, ModelError>>,
26    T: serde::Serialize + serde::de::DeserializeOwned,
27{
28    let cache_key = match extra_params {
29        Some(params) => format!("stats:{}:{}:{}", endpoint, course_id, params),
30        None => format!("stats:{}:{}", endpoint, course_id),
31    };
32
33    let wrapped_f = || async {
34        f().await.map_err(|err| {
35            UtilError::new(UtilErrorType::Other, "Failed to get data", Some(err.into()))
36        })
37    };
38
39    cache
40        .get_or_set(cache_key, duration, wrapped_f)
41        .await
42        .map_err(|_| {
43            ControllerError::new(
44                ControllerErrorType::InternalServerError,
45                "Failed to get data",
46                None,
47            )
48        })
49}
50
51/// GET `/api/v0/main-frontend/{course_id}/stats/total-users-started-course`
52#[instrument(skip(pool))]
53async fn get_total_users_started_course(
54    pool: web::Data<PgPool>,
55    user: AuthUser,
56    course_id: web::Path<Uuid>,
57    cache: web::Data<Cache>,
58) -> ControllerResult<web::Json<CountResult>> {
59    let mut conn = pool.acquire().await?;
60    let token = authorize(
61        &mut conn,
62        Act::ViewStats,
63        Some(user.id),
64        Res::Course(*course_id),
65    )
66    .await?;
67
68    let res = cached_stats_query(
69        &cache,
70        "total-users-started-course",
71        *course_id,
72        None,
73        CACHE_DURATION,
74        || async {
75            models::library::course_stats::get_total_users_started_course(&mut conn, *course_id)
76                .await
77        },
78    )
79    .await?;
80
81    token.authorized_ok(web::Json(res))
82}
83
84/// GET `/api/v0/main-frontend/{course_id}/stats/total-users-completed`
85#[instrument(skip(pool))]
86async fn get_total_users_completed_course(
87    pool: web::Data<PgPool>,
88    user: AuthUser,
89    course_id: web::Path<Uuid>,
90    cache: web::Data<Cache>,
91) -> ControllerResult<web::Json<CountResult>> {
92    let mut conn = pool.acquire().await?;
93    let token = authorize(
94        &mut conn,
95        Act::ViewStats,
96        Some(user.id),
97        Res::Course(*course_id),
98    )
99    .await?;
100
101    let res = cached_stats_query(
102        &cache,
103        "total-users-completed",
104        *course_id,
105        None,
106        CACHE_DURATION,
107        || async {
108            models::library::course_stats::get_total_users_completed_course(&mut conn, *course_id)
109                .await
110        },
111    )
112    .await?;
113
114    token.authorized_ok(web::Json(res))
115}
116
117/// GET `/api/v0/main-frontend/{course_id}/stats/total-users-returned-exercises`
118#[instrument(skip(pool))]
119async fn get_total_users_returned_at_least_one_exercise(
120    pool: web::Data<PgPool>,
121    user: AuthUser,
122    course_id: web::Path<Uuid>,
123    cache: web::Data<Cache>,
124) -> ControllerResult<web::Json<CountResult>> {
125    let mut conn = pool.acquire().await?;
126    let token = authorize(
127        &mut conn,
128        Act::ViewStats,
129        Some(user.id),
130        Res::Course(*course_id),
131    )
132    .await?;
133
134    let res = cached_stats_query(
135        &cache,
136        "total-users-returned-exercises",
137        *course_id,
138        None,
139        CACHE_DURATION,
140        || async {
141            models::library::course_stats::get_total_users_returned_at_least_one_exercise(
142                &mut conn, *course_id,
143            )
144            .await
145        },
146    )
147    .await?;
148
149    token.authorized_ok(web::Json(res))
150}
151
152/// GET `/api/v0/main-frontend/{course_id}/stats/avg-time-to-first-submission/{granularity}/{time_window}`
153///
154/// Returns average time to first submission statistics with specified time granularity and window.
155/// - granularity: "year", "month", or "day"
156/// - time_window: number of time units to look back
157#[instrument(skip(pool))]
158async fn get_avg_time_to_first_submission_history(
159    pool: web::Data<PgPool>,
160    user: AuthUser,
161    path: web::Path<(Uuid, TimeGranularity, u16)>,
162    cache: web::Data<Cache>,
163) -> ControllerResult<web::Json<Vec<AverageMetric>>> {
164    let (course_id, granularity, time_window) = path.into_inner();
165    let mut conn = pool.acquire().await?;
166    let token = authorize(
167        &mut conn,
168        Act::ViewStats,
169        Some(user.id),
170        Res::Course(course_id),
171    )
172    .await?;
173
174    let cache_key = format!(
175        "avg-time-to-first-submission-{}-{}",
176        granularity, time_window
177    );
178    let res = cached_stats_query(
179        &cache,
180        &cache_key,
181        course_id,
182        None,
183        CACHE_DURATION,
184        || async {
185            models::library::course_stats::avg_time_to_first_submission_history(
186                &mut conn,
187                course_id,
188                granularity,
189                time_window,
190            )
191            .await
192        },
193    )
194    .await?;
195
196    token.authorized_ok(web::Json(res))
197}
198
199/// GET `/api/v0/main-frontend/{course_id}/stats/cohort-activity/{granularity}/{history_window}/{tracking_window}`
200#[instrument(skip(pool))]
201async fn get_cohort_activity_history(
202    pool: web::Data<PgPool>,
203    user: AuthUser,
204    path: web::Path<(Uuid, TimeGranularity, u16, u16)>,
205    cache: web::Data<Cache>,
206) -> ControllerResult<web::Json<Vec<CohortActivity>>> {
207    let (course_id, granularity, history_window, tracking_window) = path.into_inner();
208    let mut conn = pool.acquire().await?;
209    let token = authorize(
210        &mut conn,
211        Act::ViewStats,
212        Some(user.id),
213        Res::Course(course_id),
214    )
215    .await?;
216
217    let res = cached_stats_query(
218        &cache,
219        &format!(
220            "cohort-activity-{}-{}-{}",
221            granularity, history_window, tracking_window
222        ),
223        course_id,
224        None,
225        CACHE_DURATION,
226        || async {
227            models::library::course_stats::get_cohort_activity_history(
228                &mut conn,
229                course_id,
230                granularity,
231                history_window,
232                tracking_window,
233            )
234            .await
235        },
236    )
237    .await?;
238
239    token.authorized_ok(web::Json(res))
240}
241
242/// GET `/api/v0/main-frontend/{course_id}/stats/all-language-versions/total-users-started`
243#[instrument(skip(pool))]
244async fn get_total_users_started_all_language_versions(
245    pool: web::Data<PgPool>,
246    user: AuthUser,
247    course_id: web::Path<Uuid>,
248    cache: web::Data<Cache>,
249) -> ControllerResult<web::Json<CountResult>> {
250    let mut conn = pool.acquire().await?;
251    let token = authorize(
252        &mut conn,
253        Act::ViewStats,
254        Some(user.id),
255        Res::Course(*course_id),
256    )
257    .await?;
258
259    // Get the course to find its language group ID
260    let course = models::courses::get_course(&mut conn, *course_id).await?;
261    let language_group_id = course.course_language_group_id;
262
263    let res = cached_stats_query(
264        &cache,
265        "all-language-versions-total-users-started",
266        language_group_id,
267        None,
268        CACHE_DURATION,
269        || async {
270            models::library::course_stats::get_total_users_started_all_language_versions_of_a_course(
271                &mut conn,
272                language_group_id,
273            )
274            .await
275        },
276    )
277    .await?;
278
279    token.authorized_ok(web::Json(res))
280}
281
282/// GET `/api/v0/main-frontend/{course_id}/stats/all-language-versions/users-starting-history/{granularity}/{time_window}`
283///
284/// Returns unique users starting statistics for all language versions with specified time granularity and window.
285/// - granularity: "year", "month", or "day"
286/// - time_window: number of time units to look back
287#[instrument(skip(pool))]
288async fn get_unique_users_starting_history_all_language_versions(
289    pool: web::Data<PgPool>,
290    user: AuthUser,
291    path: web::Path<(Uuid, TimeGranularity, u16)>,
292    cache: web::Data<Cache>,
293) -> ControllerResult<web::Json<Vec<CountResult>>> {
294    let (course_id, granularity, time_window) = path.into_inner();
295    let mut conn = pool.acquire().await?;
296    let token = authorize(
297        &mut conn,
298        Act::ViewStats,
299        Some(user.id),
300        Res::Course(course_id),
301    )
302    .await?;
303
304    // Get the course to find its language group ID
305    let course = models::courses::get_course(&mut conn, course_id).await?;
306    let language_group_id = course.course_language_group_id;
307
308    let cache_key = format!(
309        "all-language-versions-users-starting-{}-{}",
310        granularity, time_window
311    );
312    let res = cached_stats_query(
313        &cache,
314        &cache_key,
315        language_group_id,
316        None,
317        CACHE_DURATION,
318        || async {
319            models::library::course_stats::unique_users_starting_history_all_language_versions(
320                &mut conn,
321                language_group_id,
322                granularity,
323                time_window,
324            )
325            .await
326        },
327    )
328    .await?;
329
330    token.authorized_ok(web::Json(res))
331}
332
333/// GET `/api/v0/main-frontend/{course_id}/stats/all-language-versions/completions-history/{granularity}/{time_window}`
334///
335/// Returns completion statistics for all language versions with specified time granularity and window.
336/// - granularity: "year", "month", or "day"
337/// - time_window: number of time units to look back
338#[instrument(skip(pool))]
339async fn get_course_completions_history(
340    pool: web::Data<PgPool>,
341    user: AuthUser,
342    path: web::Path<(Uuid, TimeGranularity, u16)>,
343    cache: web::Data<Cache>,
344) -> ControllerResult<web::Json<Vec<CountResult>>> {
345    let (course_id, granularity, time_window) = path.into_inner();
346
347    let mut conn = pool.acquire().await?;
348    let token = authorize(
349        &mut conn,
350        Act::ViewStats,
351        Some(user.id),
352        Res::Course(course_id),
353    )
354    .await?;
355
356    let cache_key = format!("completions-{}-{}", granularity, time_window);
357    let res = cached_stats_query(
358        &cache,
359        &cache_key,
360        course_id,
361        None,
362        CACHE_DURATION,
363        || async {
364            models::library::course_stats::course_completions_history(
365                &mut conn,
366                course_id,
367                granularity,
368                time_window,
369            )
370            .await
371        },
372    )
373    .await?;
374
375    token.authorized_ok(web::Json(res))
376}
377
378/// GET `/api/v0/main-frontend/{course_id}/stats/users-returning-exercises-history/{granularity}/{time_window}`
379///
380/// Returns users returning exercises statistics with specified time granularity and window.
381/// - granularity: "year", "month", or "day"
382/// - time_window: number of time units to look back
383#[instrument(skip(pool))]
384async fn get_users_returning_exercises_history(
385    pool: web::Data<PgPool>,
386    user: AuthUser,
387    path: web::Path<(Uuid, TimeGranularity, u16)>,
388    cache: web::Data<Cache>,
389) -> ControllerResult<web::Json<Vec<CountResult>>> {
390    let (course_id, granularity, time_window) = path.into_inner();
391    let mut conn = pool.acquire().await?;
392    let token = authorize(
393        &mut conn,
394        Act::ViewStats,
395        Some(user.id),
396        Res::Course(course_id),
397    )
398    .await?;
399
400    let cache_key = format!("users-returning-{}-{}", granularity, time_window);
401    let res = cached_stats_query(
402        &cache,
403        &cache_key,
404        course_id,
405        None,
406        CACHE_DURATION,
407        || async {
408            models::library::course_stats::users_returning_exercises_history(
409                &mut conn,
410                course_id,
411                granularity,
412                time_window,
413            )
414            .await
415        },
416    )
417    .await?;
418
419    token.authorized_ok(web::Json(res))
420}
421
422/// GET `/api/v0/main-frontend/{course_id}/stats/first-submissions-history/{granularity}/{time_window}`
423///
424/// Returns first exercise submission statistics with specified time granularity and window.
425/// - granularity: "year", "month", or "day"
426/// - time_window: number of time units to look back
427#[instrument(skip(pool))]
428async fn get_first_exercise_submissions_history(
429    pool: web::Data<PgPool>,
430    user: AuthUser,
431    path: web::Path<(Uuid, TimeGranularity, u16)>,
432    cache: web::Data<Cache>,
433) -> ControllerResult<web::Json<Vec<CountResult>>> {
434    let (course_id, granularity, time_window) = path.into_inner();
435    let mut conn = pool.acquire().await?;
436    let token = authorize(
437        &mut conn,
438        Act::ViewStats,
439        Some(user.id),
440        Res::Course(course_id),
441    )
442    .await?;
443
444    let cache_key = format!("first-submissions-{}-{}", granularity, time_window);
445    let res = cached_stats_query(
446        &cache,
447        &cache_key,
448        course_id,
449        None,
450        CACHE_DURATION,
451        || async {
452            models::library::course_stats::first_exercise_submissions_history(
453                &mut conn,
454                course_id,
455                granularity,
456                time_window,
457            )
458            .await
459        },
460    )
461    .await?;
462
463    token.authorized_ok(web::Json(res))
464}
465
466/// GET `/api/v0/main-frontend/{course_id}/stats/users-starting-history/{granularity}/{time_window}`
467///
468/// Returns unique users starting statistics with specified time granularity and window.
469/// - granularity: "year", "month", or "day"
470/// - time_window: number of time units to look back
471#[instrument(skip(pool))]
472async fn get_unique_users_starting_history(
473    pool: web::Data<PgPool>,
474    user: AuthUser,
475    path: web::Path<(Uuid, TimeGranularity, u16)>,
476    cache: web::Data<Cache>,
477) -> ControllerResult<web::Json<Vec<CountResult>>> {
478    let (course_id, granularity, time_window) = path.into_inner();
479    let mut conn = pool.acquire().await?;
480    let token = authorize(
481        &mut conn,
482        Act::ViewStats,
483        Some(user.id),
484        Res::Course(course_id),
485    )
486    .await?;
487
488    let cache_key = format!("users-starting-{}-{}", granularity, time_window);
489    let res = cached_stats_query(
490        &cache,
491        &cache_key,
492        course_id,
493        None,
494        CACHE_DURATION,
495        || async {
496            models::library::course_stats::unique_users_starting_history(
497                &mut conn,
498                course_id,
499                granularity,
500                time_window,
501            )
502            .await
503        },
504    )
505    .await?;
506
507    token.authorized_ok(web::Json(res))
508}
509
510/// GET `/api/v0/main-frontend/{course_id}/stats/by-instance/total-users-started-course`
511#[instrument(skip(pool))]
512async fn get_total_users_started_course_by_instance(
513    pool: web::Data<PgPool>,
514    user: AuthUser,
515    course_id: web::Path<Uuid>,
516    cache: web::Data<Cache>,
517) -> ControllerResult<web::Json<HashMap<Uuid, CountResult>>> {
518    let mut conn = pool.acquire().await?;
519    let token = authorize(
520        &mut conn,
521        Act::ViewStats,
522        Some(user.id),
523        Res::Course(*course_id),
524    )
525    .await?;
526
527    let res = cached_stats_query(
528        &cache,
529        "total-users-started-course-by-instance",
530        *course_id,
531        None,
532        CACHE_DURATION,
533        || async {
534            models::library::course_stats::get_total_users_started_course_by_instance(
535                &mut conn, *course_id,
536            )
537            .await
538        },
539    )
540    .await?;
541
542    token.authorized_ok(web::Json(res))
543}
544
545/// GET `/api/v0/main-frontend/{course_id}/stats/by-instance/total-users-completed`
546#[instrument(skip(pool))]
547async fn get_total_users_completed_course_by_instance(
548    pool: web::Data<PgPool>,
549    user: AuthUser,
550    course_id: web::Path<Uuid>,
551    cache: web::Data<Cache>,
552) -> ControllerResult<web::Json<HashMap<Uuid, CountResult>>> {
553    let mut conn = pool.acquire().await?;
554    let token = authorize(
555        &mut conn,
556        Act::ViewStats,
557        Some(user.id),
558        Res::Course(*course_id),
559    )
560    .await?;
561
562    let res = cached_stats_query(
563        &cache,
564        "total-users-completed-by-instance",
565        *course_id,
566        None,
567        CACHE_DURATION,
568        || async {
569            models::library::course_stats::get_total_users_completed_course_by_instance(
570                &mut conn, *course_id,
571            )
572            .await
573        },
574    )
575    .await?;
576
577    token.authorized_ok(web::Json(res))
578}
579
580/// GET `/api/v0/main-frontend/{course_id}/stats/by-instance/total-users-returned-exercises`
581#[instrument(skip(pool))]
582async fn get_total_users_returned_at_least_one_exercise_by_instance(
583    pool: web::Data<PgPool>,
584    user: AuthUser,
585    course_id: web::Path<Uuid>,
586    cache: web::Data<Cache>,
587) -> ControllerResult<web::Json<HashMap<Uuid, CountResult>>> {
588    let mut conn = pool.acquire().await?;
589    let token = authorize(
590        &mut conn,
591        Act::ViewStats,
592        Some(user.id),
593        Res::Course(*course_id),
594    )
595    .await?;
596
597    let res = cached_stats_query(
598        &cache,
599        "total-users-returned-exercises-by-instance",
600        *course_id,
601        None,
602        CACHE_DURATION,
603        || async {
604            models::library::course_stats::get_total_users_returned_at_least_one_exercise_by_instance(
605                &mut conn, *course_id,
606            )
607            .await
608        },
609    )
610    .await?;
611
612    token.authorized_ok(web::Json(res))
613}
614
615/// GET `/api/v0/main-frontend/{course_id}/stats/by-instance/completions-history/{granularity}/{time_window}`
616///
617/// Returns course completion statistics with specified time granularity and window, grouped by course instance.
618/// - granularity: "year", "month", or "day"
619/// - time_window: number of time units to look back
620#[instrument(skip(pool))]
621async fn get_course_completions_history_by_instance(
622    pool: web::Data<PgPool>,
623    user: AuthUser,
624    path: web::Path<(Uuid, TimeGranularity, u16)>,
625    cache: web::Data<Cache>,
626) -> ControllerResult<web::Json<HashMap<Uuid, Vec<CountResult>>>> {
627    let (course_id, granularity, time_window) = path.into_inner();
628    let mut conn = pool.acquire().await?;
629    let token = authorize(
630        &mut conn,
631        Act::ViewStats,
632        Some(user.id),
633        Res::Course(course_id),
634    )
635    .await?;
636
637    let cache_key = format!("completions-by-instance-{}-{}", granularity, time_window);
638    let res = cached_stats_query(
639        &cache,
640        &cache_key,
641        course_id,
642        None,
643        CACHE_DURATION,
644        || async {
645            models::library::course_stats::course_completions_history_by_instance(
646                &mut conn,
647                course_id,
648                granularity,
649                time_window,
650            )
651            .await
652        },
653    )
654    .await?;
655
656    token.authorized_ok(web::Json(res))
657}
658
659/// GET `/api/v0/main-frontend/{course_id}/stats/by-instance/users-starting-history/{granularity}/{time_window}`
660///
661/// Returns unique users starting statistics with specified time granularity and window, grouped by course instance.
662/// - granularity: "year", "month", or "day"
663/// - time_window: number of time units to look back
664#[instrument(skip(pool))]
665async fn get_unique_users_starting_history_by_instance(
666    pool: web::Data<PgPool>,
667    user: AuthUser,
668    path: web::Path<(Uuid, TimeGranularity, u16)>,
669    cache: web::Data<Cache>,
670) -> ControllerResult<web::Json<HashMap<Uuid, Vec<CountResult>>>> {
671    let (course_id, granularity, time_window) = path.into_inner();
672    let mut conn = pool.acquire().await?;
673    let token = authorize(
674        &mut conn,
675        Act::ViewStats,
676        Some(user.id),
677        Res::Course(course_id),
678    )
679    .await?;
680
681    let cache_key = format!("users-starting-by-instance-{}-{}", granularity, time_window);
682    let res = cached_stats_query(
683        &cache,
684        &cache_key,
685        course_id,
686        None,
687        CACHE_DURATION,
688        || async {
689            models::library::course_stats::unique_users_starting_history_by_instance(
690                &mut conn,
691                course_id,
692                granularity,
693                time_window,
694            )
695            .await
696        },
697    )
698    .await?;
699
700    token.authorized_ok(web::Json(res))
701}
702
703/// GET `/api/v0/main-frontend/{course_id}/stats/by-instance/first-submissions-history/{granularity}/{time_window}`
704///
705/// Returns first exercise submission statistics with specified time granularity and window, grouped by course instance.
706/// - granularity: "year", "month", or "day"
707/// - time_window: number of time units to look back
708#[instrument(skip(pool))]
709async fn get_first_exercise_submissions_history_by_instance(
710    pool: web::Data<PgPool>,
711    user: AuthUser,
712    path: web::Path<(Uuid, TimeGranularity, u16)>,
713    cache: web::Data<Cache>,
714) -> ControllerResult<web::Json<HashMap<Uuid, Vec<CountResult>>>> {
715    let (course_id, granularity, time_window) = path.into_inner();
716    let mut conn = pool.acquire().await?;
717    let token = authorize(
718        &mut conn,
719        Act::ViewStats,
720        Some(user.id),
721        Res::Course(course_id),
722    )
723    .await?;
724
725    let cache_key = format!(
726        "first-submissions-by-instance-{}-{}",
727        granularity, time_window
728    );
729    let res = cached_stats_query(
730        &cache,
731        &cache_key,
732        course_id,
733        None,
734        CACHE_DURATION,
735        || async {
736            models::library::course_stats::first_exercise_submissions_history_by_instance(
737                &mut conn,
738                course_id,
739                granularity,
740                time_window,
741            )
742            .await
743        },
744    )
745    .await?;
746
747    token.authorized_ok(web::Json(res))
748}
749
750/// GET `/api/v0/main-frontend/{course_id}/stats/by-instance/users-returning-exercises-history/{granularity}/{time_window}`
751///
752/// Returns users returning exercises statistics with specified time granularity and window, grouped by course instance.
753/// - granularity: "year", "month", or "day"
754/// - time_window: number of time units to look back
755#[instrument(skip(pool))]
756async fn get_users_returning_exercises_history_by_instance(
757    pool: web::Data<PgPool>,
758    user: AuthUser,
759    path: web::Path<(Uuid, TimeGranularity, u16)>,
760    cache: web::Data<Cache>,
761) -> ControllerResult<web::Json<HashMap<Uuid, Vec<CountResult>>>> {
762    let (course_id, granularity, time_window) = path.into_inner();
763    let mut conn = pool.acquire().await?;
764    let token = authorize(
765        &mut conn,
766        Act::ViewStats,
767        Some(user.id),
768        Res::Course(course_id),
769    )
770    .await?;
771
772    let cache_key = format!(
773        "users-returning-by-instance-{}-{}",
774        granularity, time_window
775    );
776    let res = cached_stats_query(
777        &cache,
778        &cache_key,
779        course_id,
780        None,
781        CACHE_DURATION,
782        || async {
783            models::library::course_stats::users_returning_exercises_history_by_instance(
784                &mut conn,
785                course_id,
786                granularity,
787                time_window,
788            )
789            .await
790        },
791    )
792    .await?;
793
794    token.authorized_ok(web::Json(res))
795}
796
797/// GET `/api/v0/main-frontend/{course_id}/stats/student-enrollments-by-country/{granularity}/{time_window}/{country}`
798///
799/// Returns student signup statistics grouped by country with the specified time granularity.
800/// - granularity: "year", "month", or "day"
801/// - time_window: number of time units to look back
802#[instrument(skip(pool))]
803async fn get_student_enrollments_by_country(
804    pool: web::Data<PgPool>,
805    user: AuthUser,
806    path: web::Path<(Uuid, TimeGranularity, u16, String)>,
807    cache: web::Data<Cache>,
808) -> ControllerResult<web::Json<Vec<CountResult>>> {
809    let (course_id, granularity, time_window, country) = path.into_inner();
810
811    let mut conn = pool.acquire().await?;
812    let token = authorize(
813        &mut conn,
814        Act::ViewStats,
815        Some(user.id),
816        Res::Course(course_id),
817    )
818    .await?;
819
820    let cache_key = format!(
821        "student-enrollments-by-country-{}-{}-{}",
822        granularity, time_window, country
823    );
824
825    let res = cached_stats_query(
826        &cache,
827        &cache_key,
828        course_id,
829        None,
830        CACHE_DURATION,
831        || async {
832            models::library::course_stats::student_enrollments_by_country(
833                &mut conn,
834                course_id,
835                granularity,
836                time_window,
837                country,
838            )
839            .await
840        },
841    )
842    .await?;
843
844    token.authorized_ok(web::Json(res))
845}
846
847/// GET `/api/v0/main-frontend/{course_id}/stats/student-completions-by-country/{granularity}/{time_window}/{country}`
848///
849/// Returns student completion statistics grouped by country with the specified time granularity.
850/// - granularity: "year", "month", or "day"
851/// - time_window: number of time units to look back
852#[instrument(skip(pool))]
853async fn get_student_completions_by_country(
854    pool: web::Data<PgPool>,
855    user: AuthUser,
856    path: web::Path<(Uuid, TimeGranularity, u16, String)>,
857    cache: web::Data<Cache>,
858) -> ControllerResult<web::Json<Vec<CountResult>>> {
859    let (course_id, granularity, time_window, country) = path.into_inner();
860
861    let mut conn = pool.acquire().await?;
862    let token = authorize(
863        &mut conn,
864        Act::ViewStats,
865        Some(user.id),
866        Res::Course(course_id),
867    )
868    .await?;
869
870    let cache_key = format!(
871        "student-completions-by-country-{}-{}-{}",
872        granularity, time_window, country
873    );
874
875    let res = cached_stats_query(
876        &cache,
877        &cache_key,
878        course_id,
879        None,
880        CACHE_DURATION,
881        || async {
882            models::library::course_stats::student_completions_by_country(
883                &mut conn,
884                course_id,
885                granularity,
886                time_window,
887                country,
888            )
889            .await
890        },
891    )
892    .await?;
893
894    token.authorized_ok(web::Json(res))
895}
896
897/// GET `/api/v0/main-frontend/{course_id}/stats/students-by-country-totals`
898///
899/// Returns all enrolled students grouped by country.
900#[instrument(skip(pool))]
901async fn get_students_by_country_totals(
902    pool: web::Data<PgPool>,
903    user: AuthUser,
904    path: web::Path<Uuid>,
905    cache: web::Data<Cache>,
906) -> ControllerResult<web::Json<Vec<StudentsByCountryTotalsResult>>> {
907    let mut conn = pool.acquire().await?;
908    let course_id = path.into_inner();
909
910    let token = authorize(
911        &mut conn,
912        Act::ViewStats,
913        Some(user.id),
914        Res::Course(course_id),
915    )
916    .await?;
917
918    let cache_key = format!("students-by-country-totals-{}", course_id);
919
920    let res = cached_stats_query(
921        &cache,
922        &cache_key,
923        course_id,
924        None,
925        CACHE_DURATION,
926        || async {
927            models::library::course_stats::students_by_country_totals(&mut conn, course_id).await
928        },
929    )
930    .await?;
931
932    token.authorized_ok(web::Json(res))
933}
934
935/// GET `/api/v0/main-frontend/{course_id}/stats/by-module/first-submissions/{granularity}/{time_window}`
936///
937/// Returns first exercise submission statistics with specified time granularity and window,
938/// grouped by module.
939/// - granularity: "year", "month", or "day"
940/// - time_window: number of time units to look back
941#[instrument(skip(pool))]
942async fn get_first_exercise_submissions_by_module(
943    pool: web::Data<PgPool>,
944    user: AuthUser,
945    path: web::Path<(Uuid, TimeGranularity, u16)>,
946    cache: web::Data<Cache>,
947) -> ControllerResult<web::Json<HashMap<Uuid, Vec<CountResult>>>> {
948    let (course_id, granularity, time_window) = path.into_inner();
949
950    let mut conn = pool.acquire().await?;
951    let token = authorize(
952        &mut conn,
953        Act::ViewStats,
954        Some(user.id),
955        Res::Course(course_id),
956    )
957    .await?;
958
959    let cache_key = format!(
960        "first-submissions-by-module-{}-{}",
961        granularity, time_window
962    );
963
964    let res = cached_stats_query(
965        &cache,
966        &cache_key,
967        course_id,
968        None,
969        CACHE_DURATION,
970        || async {
971            models::library::course_stats::first_exercise_submissions_by_module(
972                &mut conn,
973                course_id,
974                granularity,
975                time_window,
976            )
977            .await
978        },
979    )
980    .await?;
981
982    token.authorized_ok(web::Json(res))
983}
984
985/// GET `/api/v0/main-frontend/{course_id}/stats/completions-history/custom-time-period/{start_date}/{end_date}`
986///
987/// Returns completion statistics by custom time period.
988/// Query parameters:
989/// - start_date: YYYY-MM-DD
990/// - end_date: YYYY-MM-DD
991#[instrument(skip(pool))]
992async fn get_course_completions_history_by_custom_time_period(
993    pool: web::Data<PgPool>,
994    user: AuthUser,
995    path: web::Path<(Uuid, String, String)>,
996    cache: web::Data<Cache>,
997) -> ControllerResult<web::Json<Vec<CountResult>>> {
998    let (course_id, start_date, end_date) = path.into_inner();
999
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!("completions-custom-{}-{}", start_date, end_date);
1010
1011    let res = cached_stats_query(
1012        &cache,
1013        &cache_key,
1014        course_id,
1015        None,
1016        CACHE_DURATION,
1017        || async {
1018            models::library::course_stats::course_completions_history_by_custom_time_period(
1019                &mut conn,
1020                course_id,
1021                &start_date,
1022                &end_date,
1023            )
1024            .await
1025        },
1026    )
1027    .await?;
1028
1029    token.authorized_ok(web::Json(res))
1030}
1031
1032/// GET `/api/v0/main-frontend/{course_id}/stats/users-starting-history/custom-time-period/{start_date}/{end_date}`
1033///
1034/// Returns unique users starting statistics with specified time period.
1035#[instrument(skip(pool))]
1036async fn get_unique_users_starting_history_custom_time_period(
1037    pool: web::Data<PgPool>,
1038    user: AuthUser,
1039    path: web::Path<(Uuid, String, String)>,
1040    cache: web::Data<Cache>,
1041) -> ControllerResult<web::Json<Vec<CountResult>>> {
1042    let (course_id, start_date, end_date) = path.into_inner();
1043    let mut conn = pool.acquire().await?;
1044    let token = authorize(
1045        &mut conn,
1046        Act::ViewStats,
1047        Some(user.id),
1048        Res::Course(course_id),
1049    )
1050    .await?;
1051
1052    let cache_key = format!("users-starting-custom-{}-{}", start_date, end_date);
1053    let res = cached_stats_query(
1054        &cache,
1055        &cache_key,
1056        course_id,
1057        None,
1058        CACHE_DURATION,
1059        || async {
1060            models::library::course_stats::unique_users_starting_history_by_custom_time_period(
1061                &mut conn,
1062                course_id,
1063                &start_date,
1064                &end_date,
1065            )
1066            .await
1067        },
1068    )
1069    .await?;
1070
1071    token.authorized_ok(web::Json(res))
1072}
1073
1074/// GET `/api/v0/main-frontend/{course_id}/stats/total-users-started-course/custom-time-period/{start_date}/{end_date}`
1075#[instrument(skip(pool))]
1076async fn get_total_users_started_course_custom_time_period(
1077    pool: web::Data<PgPool>,
1078    user: AuthUser,
1079    path: web::Path<(Uuid, String, String)>,
1080    cache: web::Data<Cache>,
1081) -> ControllerResult<web::Json<CountResult>> {
1082    let (course_id, start_date, end_date) = path.into_inner();
1083    let mut conn = pool.acquire().await?;
1084    let token = authorize(
1085        &mut conn,
1086        Act::ViewStats,
1087        Some(user.id),
1088        Res::Course(course_id),
1089    )
1090    .await?;
1091
1092    let cache_key = format!("total-users-started-custom-{}-{}", start_date, end_date);
1093    let res = cached_stats_query(
1094        &cache,
1095        &cache_key,
1096        course_id,
1097        None,
1098        CACHE_DURATION,
1099        || async {
1100            models::library::course_stats::get_total_users_started_course_custom_time_period(
1101                &mut conn,
1102                course_id,
1103                &start_date,
1104                &end_date,
1105            )
1106            .await
1107        },
1108    )
1109    .await?;
1110
1111    token.authorized_ok(web::Json(res))
1112}
1113
1114/// GET `/api/v0/main-frontend/{course_id}/stats/total-users-completed/custom-time-period/{start_date}/{end_date}`
1115#[instrument(skip(pool))]
1116async fn get_total_users_completed_course_custom_time_period(
1117    pool: web::Data<PgPool>,
1118    user: AuthUser,
1119    path: web::Path<(Uuid, String, String)>,
1120    cache: web::Data<Cache>,
1121) -> ControllerResult<web::Json<CountResult>> {
1122    let (course_id, start_date, end_date) = path.into_inner();
1123    let mut conn = pool.acquire().await?;
1124    let token = authorize(
1125        &mut conn,
1126        Act::ViewStats,
1127        Some(user.id),
1128        Res::Course(course_id),
1129    )
1130    .await?;
1131
1132    let cache_key = format!("total-users-completed-custom-{}-{}", start_date, end_date);
1133    let res = cached_stats_query(
1134        &cache,
1135        &cache_key,
1136        course_id,
1137        None,
1138        CACHE_DURATION,
1139        || async {
1140            models::library::course_stats::get_total_users_completed_course_custom_time_period(
1141                &mut conn,
1142                course_id,
1143                &start_date,
1144                &end_date,
1145            )
1146            .await
1147        },
1148    )
1149    .await?;
1150
1151    token.authorized_ok(web::Json(res))
1152}
1153
1154/// GET `/api/v0/main-frontend/{course_id}/stats/total-users-returned-exercises/custom-time-period/{start_date}/{end_date}`
1155#[instrument(skip(pool))]
1156async fn get_total_users_returned_exercises_custom_time_period(
1157    pool: web::Data<PgPool>,
1158    user: AuthUser,
1159    path: web::Path<(Uuid, String, String)>,
1160    cache: web::Data<Cache>,
1161) -> ControllerResult<web::Json<CountResult>> {
1162    let (course_id, start_date, end_date) = path.into_inner();
1163    let mut conn = pool.acquire().await?;
1164    let token = authorize(
1165        &mut conn,
1166        Act::ViewStats,
1167        Some(user.id),
1168        Res::Course(course_id),
1169    )
1170    .await?;
1171
1172    let cache_key = format!("total-users-returned-custom-{}-{}", start_date, end_date);
1173    let res = cached_stats_query(
1174        &cache,
1175        &cache_key,
1176        course_id,
1177        None,
1178        CACHE_DURATION,
1179        || async {
1180            models::library::course_stats::get_total_users_returned_exercises_custom_time_period(
1181                &mut conn,
1182                course_id,
1183                &start_date,
1184                &end_date,
1185            )
1186            .await
1187        },
1188    )
1189    .await?;
1190
1191    token.authorized_ok(web::Json(res))
1192}
1193
1194pub fn _add_routes(cfg: &mut web::ServiceConfig) {
1195    cfg.route(
1196        "/total-users-started-course",
1197        web::get().to(get_total_users_started_course),
1198    )
1199    .route(
1200        "/total-users-completed",
1201        web::get().to(get_total_users_completed_course),
1202    )
1203    .route(
1204        "/total-users-returned-exercises",
1205        web::get().to(get_total_users_returned_at_least_one_exercise),
1206    )
1207    .route(
1208        "/by-instance/total-users-started-course",
1209        web::get().to(get_total_users_started_course_by_instance),
1210    )
1211    .route(
1212        "/by-instance/total-users-completed",
1213        web::get().to(get_total_users_completed_course_by_instance),
1214    )
1215    .route(
1216        "/by-instance/total-users-returned-exercises",
1217        web::get().to(get_total_users_returned_at_least_one_exercise_by_instance),
1218    )
1219    .route(
1220        "/first-submissions-history/{granularity}/{time_window}",
1221        web::get().to(get_first_exercise_submissions_history),
1222    )
1223    .route(
1224        "/by-instance/first-submissions-history/{granularity}/{time_window}",
1225        web::get().to(get_first_exercise_submissions_history_by_instance),
1226    )
1227    .route(
1228        "/users-returning-exercises-history/{granularity}/{time_window}",
1229        web::get().to(get_users_returning_exercises_history),
1230    )
1231    .route(
1232        "/by-instance/users-returning-exercises-history/{granularity}/{time_window}",
1233        web::get().to(get_users_returning_exercises_history_by_instance),
1234    )
1235    .route(
1236        "/completions-history/{granularity}/{time_window}",
1237        web::get().to(get_course_completions_history),
1238    )
1239    .route(
1240        "/by-instance/completions-history/{granularity}/{time_window}",
1241        web::get().to(get_course_completions_history_by_instance),
1242    )
1243    .route(
1244        "/users-starting-history/{granularity}/{time_window}",
1245        web::get().to(get_unique_users_starting_history),
1246    )
1247    .route(
1248        "/by-instance/users-starting-history/{granularity}/{time_window}",
1249        web::get().to(get_unique_users_starting_history_by_instance),
1250    )
1251    .route(
1252        "/avg-time-to-first-submission/{granularity}/{time_window}",
1253        web::get().to(get_avg_time_to_first_submission_history),
1254    )
1255    .route(
1256        "/cohort-activity/{granularity}/{history_window}/{tracking_window}",
1257        web::get().to(get_cohort_activity_history),
1258    )
1259    .route(
1260        "/all-language-versions/total-users-started",
1261        web::get().to(get_total_users_started_all_language_versions),
1262    )
1263    .route(
1264        "/all-language-versions/users-starting-history/{granularity}/{time_window}",
1265        web::get().to(get_unique_users_starting_history_all_language_versions),
1266    )
1267    .route(
1268        "/completions-history/custom-time-period/{start_date}/{end_date}",
1269        web::get().to(get_course_completions_history_by_custom_time_period),
1270    )
1271    .route(
1272        "/student-enrollments-by-country/{granularity}/{time_window}/{country}",
1273        web::get().to(get_student_enrollments_by_country),
1274    )
1275    .route(
1276        "/student-completions-by-country/{granularity}/{time_window}/{country}",
1277        web::get().to(get_student_completions_by_country),
1278    )
1279    .route(
1280        "/students-by-country-totals",
1281        web::get().to(get_students_by_country_totals),
1282    )
1283    .route(
1284        "/by-module/first-submissions/{granularity}/{time_window}",
1285        web::get().to(get_first_exercise_submissions_by_module),
1286    )
1287    .route(
1288        "/users-starting-history/custom-time-period/{start_date}/{end_date}",
1289        web::get().to(get_unique_users_starting_history_custom_time_period),
1290    )
1291    .route(
1292        "/total-users-started-course/custom-time-period/{start_date}/{end_date}",
1293        web::get().to(get_total_users_started_course_custom_time_period),
1294    )
1295    .route(
1296        "/total-users-completed/custom-time-period/{start_date}/{end_date}",
1297        web::get().to(get_total_users_completed_course_custom_time_period),
1298    )
1299    .route(
1300        "/total-users-returned-exercises/custom-time-period/{start_date}/{end_date}",
1301        web::get().to(get_total_users_returned_exercises_custom_time_period),
1302    );
1303}