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_utils::prelude::{UtilError, UtilErrorType};
7use models::library::course_stats::{AverageMetric, CohortActivity, CountResult};
8use std::collections::HashMap;
9use std::time::Duration;
10use uuid::Uuid;
11
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
797pub fn _add_routes(cfg: &mut web::ServiceConfig) {
798    cfg.route(
799        "/total-users-started-course",
800        web::get().to(get_total_users_started_course),
801    )
802    .route(
803        "/total-users-completed",
804        web::get().to(get_total_users_completed_course),
805    )
806    .route(
807        "/total-users-returned-exercises",
808        web::get().to(get_total_users_returned_at_least_one_exercise),
809    )
810    .route(
811        "/by-instance/total-users-started-course",
812        web::get().to(get_total_users_started_course_by_instance),
813    )
814    .route(
815        "/by-instance/total-users-completed",
816        web::get().to(get_total_users_completed_course_by_instance),
817    )
818    .route(
819        "/by-instance/total-users-returned-exercises",
820        web::get().to(get_total_users_returned_at_least_one_exercise_by_instance),
821    )
822    .route(
823        "/first-submissions-history/{granularity}/{time_window}",
824        web::get().to(get_first_exercise_submissions_history),
825    )
826    .route(
827        "/by-instance/first-submissions-history/{granularity}/{time_window}",
828        web::get().to(get_first_exercise_submissions_history_by_instance),
829    )
830    .route(
831        "/users-returning-exercises-history/{granularity}/{time_window}",
832        web::get().to(get_users_returning_exercises_history),
833    )
834    .route(
835        "/by-instance/users-returning-exercises-history/{granularity}/{time_window}",
836        web::get().to(get_users_returning_exercises_history_by_instance),
837    )
838    .route(
839        "/completions-history/{granularity}/{time_window}",
840        web::get().to(get_course_completions_history),
841    )
842    .route(
843        "/by-instance/completions-history/{granularity}/{time_window}",
844        web::get().to(get_course_completions_history_by_instance),
845    )
846    .route(
847        "/users-starting-history/{granularity}/{time_window}",
848        web::get().to(get_unique_users_starting_history),
849    )
850    .route(
851        "/by-instance/users-starting-history/{granularity}/{time_window}",
852        web::get().to(get_unique_users_starting_history_by_instance),
853    )
854    .route(
855        "/avg-time-to-first-submission/{granularity}/{time_window}",
856        web::get().to(get_avg_time_to_first_submission_history),
857    )
858    .route(
859        "/cohort-activity/{granularity}/{history_window}/{tracking_window}",
860        web::get().to(get_cohort_activity_history),
861    )
862    .route(
863        "/all-language-versions/total-users-started",
864        web::get().to(get_total_users_started_all_language_versions),
865    )
866    .route(
867        "/all-language-versions/users-starting-history/{granularity}/{time_window}",
868        web::get().to(get_unique_users_starting_history_all_language_versions),
869    );
870}