1use 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
14async 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#[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#[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#[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#[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#[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#[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 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#[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 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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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}