diff --git a/backend/tournesol/tests/test_api_statistics.py b/backend/tournesol/tests/test_api_statistics.py index 26fcd5035a..19dd46b844 100644 --- a/backend/tournesol/tests/test_api_statistics.py +++ b/backend/tournesol/tests/test_api_statistics.py @@ -4,11 +4,13 @@ from rest_framework.test import APIClient from core.models import User +from core.tests.factories.user import UserFactory from core.utils.time import time_ago from tournesol.tests.factories.comparison import ComparisonFactory from tournesol.tests.factories.entity import EntityFactory, VideoFactory from ..models import Comparison, Entity +from .factories.poll import PollWithCriteriasFactory class StatisticsAPI(TestCase): @@ -42,7 +44,7 @@ def setUp(self): n_comparisons=4, ) - non_video = EntityFactory(type="another_type", n_comparisons=5) + EntityFactory(type="another_type", n_comparisons=5) Entity.objects.filter(pk=video_1.pk).update(add_time=time_ago(days=5)) Entity.objects.filter(pk=video_2.pk).update(add_time=time_ago(days=29)) @@ -131,3 +133,39 @@ def test_user_stats(self): self.assertEqual(user_count, len(self._list_of_users)) self.assertEqual(active_users["joined_last_30_days"], 1) + + def test_url_param_poll(self): + """ + The `poll` URL parameter allows to filter the returned results by + poll. + """ + client = APIClient() + + new_poll = "new_poll" + new_user = "new_user" + + response = client.get("/stats/") + data = response.data + + self.assertEqual(len(data["polls"]), 2) + for poll in data["polls"]: + self.assertNotEqual(poll["name"], new_poll) + + poll = PollWithCriteriasFactory.create(name=new_poll) + user = UserFactory(username=new_user) + ComparisonFactory.create_batch(8, poll=poll, user=user) + + response = client.get("/stats/") + data = response.data + self.assertEqual(len(data["polls"]), 3) + + response = client.get("/stats/", {"poll": poll.name}) + data = response.data + + poll = [poll for poll in data["polls"] if poll["name"] == new_poll][0] + self.assertEqual(len(data["polls"]), 1) + self.assertEqual(poll["comparisons"]["total"], 8) + + # Using an unknown poll should return all polls. + response = client.get("/stats/", {"poll": "unknown"}) + self.assertEqual(len(response.data["polls"]), 0) diff --git a/backend/tournesol/views/stats.py b/backend/tournesol/views/stats.py index 59b75a5799..35ae383489 100644 --- a/backend/tournesol/views/stats.py +++ b/backend/tournesol/views/stats.py @@ -5,7 +5,7 @@ from datetime import datetime, time, timezone from typing import List -from drf_spectacular.utils import extend_schema, extend_schema_view +from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view from rest_framework import generics from rest_framework.permissions import AllowAny from rest_framework.response import Response @@ -94,15 +94,40 @@ def append_poll( @extend_schema_view( - get=extend_schema(description="Fetch all Tournesol's public statistics.") + get=extend_schema( + description="Fetch all Tournesol's public statistics.", + parameters=[ + OpenApiParameter( + name="poll", + required=False, + description="Only get stats related to this poll.", + ), + ], + ) ) class StatisticsView(generics.GenericAPIView): - """Return popularity statistics about Tournesol""" + """ + This view returns the contribution stats for all polls. + """ permission_classes = [AllowAny] serializer_class = StatisticsSerializer _days_delta = 30 + def _get_poll_stats(self, poll: Poll): + comparisons = self._get_comparisons_statistics(poll) + compared_entities = self._get_compared_entities_statistics(poll) + return {"comparisons": comparisons, "compared_entities": compared_entities} + + def _build_stats_for_all_poll(self, polls: list[Poll], stats: Statistics): + for poll in polls: + poll_stats = self._get_poll_stats(poll) + stats.append_poll( + poll.name, + poll_stats["compared_entities"], + poll_stats["comparisons"], + ) + def get(self, request): statistics = Statistics() @@ -112,15 +137,19 @@ def get(self, request): ).count() statistics.set_active_users(active_users, last_month_active_users) - for poll in Poll.objects.iterator(): - compared_entities_statistics = self._get_compared_entities_statistics(poll) - comparisons_statistics = self._get_comparisons_statistics(poll) - statistics.append_poll( - poll.name, - compared_entities_statistics, - comparisons_statistics, - ) + selected_poll = request.query_params.get("poll") + + if selected_poll: + try: + poll = Poll.objects.get(name=selected_poll) + except Poll.DoesNotExist: + polls = [] + else: + polls = [poll] + else: + polls = Poll.objects.iterator() + self._build_stats_for_all_poll(polls, statistics) return Response(StatisticsSerializer(statistics).data) def _get_compared_entities_statistics(self, poll): diff --git a/frontend/scripts/openapi.yaml b/frontend/scripts/openapi.yaml index b587f479da..2b823ef115 100644 --- a/frontend/scripts/openapi.yaml +++ b/frontend/scripts/openapi.yaml @@ -1007,6 +1007,12 @@ paths: get: operationId: stats_retrieve description: Fetch all Tournesol's public statistics. + parameters: + - in: query + name: poll + schema: + type: string + description: Only get stats related to this poll. tags: - stats security: diff --git a/frontend/src/features/comparisons/ComparisonSliders.tsx b/frontend/src/features/comparisons/ComparisonSliders.tsx index 92320a1029..12a3ae126d 100644 --- a/frontend/src/features/comparisons/ComparisonSliders.tsx +++ b/frontend/src/features/comparisons/ComparisonSliders.tsx @@ -145,7 +145,7 @@ const ComparisonSliders = ({ await submit(comparison); } finally { setDisableSubmit(false); - refreshStats(); + refreshStats(pollName); } // avoid a "memory leak" warning if the component is unmounted on submit. diff --git a/frontend/src/features/goals/CollectiveGoalWeeklyProgress.tsx b/frontend/src/features/goals/CollectiveGoalWeeklyProgress.tsx index 58fe812445..ca0e040518 100644 --- a/frontend/src/features/goals/CollectiveGoalWeeklyProgress.tsx +++ b/frontend/src/features/goals/CollectiveGoalWeeklyProgress.tsx @@ -15,7 +15,7 @@ const CollectiveGoalWeeklyProgress = () => { const { t } = useTranslation(); const { name: pollName } = useCurrentPoll(); - const stats = useStats(); + const stats = useStats({ poll: pollName }); const pollStats = getPollStats(stats, pollName); const collectiveComparisonsNbr = diff --git a/frontend/src/features/statistics/StatsContext.tsx b/frontend/src/features/statistics/StatsContext.tsx index 2d06cf1411..f822aea195 100644 --- a/frontend/src/features/statistics/StatsContext.tsx +++ b/frontend/src/features/statistics/StatsContext.tsx @@ -5,15 +5,20 @@ import React, { useRef, useState, } from 'react'; -import { Statistics, StatsService } from 'src/services/openapi'; +import { + CancelError, + CancelablePromise, + Statistics, + StatsService, +} from 'src/services/openapi'; // Stats are considered outdated after this amount of miliseconds. const EXPIRATION_TIME = 4000; interface StatsContextValue { stats: Statistics; - getStats: () => Statistics; - refreshStats: () => void; + getStats: (poll: string) => Statistics; + refreshStats: (poll: string) => void; } const initialState: Statistics = { @@ -36,39 +41,63 @@ export const StatsLazyProvider = ({ }: { children: React.ReactNode; }) => { - const loading = useRef(false); + const loading = useRef | null>(null); const lastRefreshAt = useRef(0); + const lastPoll = useRef(undefined); const [stats, setStats] = useState(initialState); - const refreshStats = useCallback(async () => { - const newStats = await StatsService.statsRetrieve(); - loading.current = false; - lastRefreshAt.current = Date.now(); - setStats(newStats); + const refreshStats = useCallback(async (poll: string) => { + if (loading.current) { + loading.current.cancel(); + } + loading.current = StatsService.statsRetrieve({ poll }); + try { + const newStats = await loading.current; + lastRefreshAt.current = Date.now(); + loading.current = null; + setStats(newStats); + } catch (err) { + if (err instanceof CancelError) { + return; + } + console.error(err); + } }, []); /** * Initialize the stats if they are empty or refresh them if they are * outdated. + * + * Note that the getStats implementation assumes that only the stats of a + * single poll are displayed per page. */ - const getStats = useCallback(() => { - const currentTime = Date.now(); - - if (loading.current) { - return stats; - } + const getStats = useCallback( + (poll: string) => { + const currentTime = Date.now(); - if ( - stats.polls.length === 0 || - currentTime - lastRefreshAt.current >= EXPIRATION_TIME - ) { - loading.current = true; - refreshStats(); - } + if (loading.current) { + if (poll === lastPoll.current) { + return stats; + } else { + lastPoll.current = poll; + refreshStats(poll); + } + } else { + if ( + stats.polls.length === 0 || + poll !== lastPoll.current || + currentTime - lastRefreshAt.current >= EXPIRATION_TIME + ) { + lastPoll.current = poll; + refreshStats(poll); + } + } - return stats; - }, [stats, refreshStats]); + return stats; + }, + [stats, refreshStats] + ); const contextValue = useMemo( () => ({ diff --git a/frontend/src/features/statistics/UsageStatsSection.tsx b/frontend/src/features/statistics/UsageStatsSection.tsx index 59bd5975f9..e8da39e17c 100644 --- a/frontend/src/features/statistics/UsageStatsSection.tsx +++ b/frontend/src/features/statistics/UsageStatsSection.tsx @@ -57,7 +57,7 @@ const StatsSection = () => { const { t } = useTranslation(); const { name: pollName } = useCurrentPoll(); - const stats = useStats(); + const stats = useStats({ poll: pollName }); const pollStats = getPollStats(stats, pollName); const comparedEntitiesTitle = useMemo(() => { diff --git a/frontend/src/hooks/useStats.ts b/frontend/src/hooks/useStats.ts index 85d840d903..44535e5649 100644 --- a/frontend/src/hooks/useStats.ts +++ b/frontend/src/hooks/useStats.ts @@ -2,13 +2,17 @@ import { useState, useEffect, useContext } from 'react'; import StatsContext from 'src/features/statistics/StatsContext'; import { Statistics } from 'src/services/openapi'; -export const useStats = () => { +interface Props { + poll: string; +} + +export const useStats = ({ poll }: Props) => { const { getStats } = useContext(StatsContext); - const [stats, setStats] = useState(getStats()); + const [stats, setStats] = useState(getStats(poll)); useEffect(() => { - setStats(getStats()); - }, [getStats]); + setStats(getStats(poll)); + }, [poll, getStats]); return stats; }; diff --git a/frontend/src/pages/about/PublicDownloadSection.tsx b/frontend/src/pages/about/PublicDownloadSection.tsx index 79ca01f535..f74e996a6f 100644 --- a/frontend/src/pages/about/PublicDownloadSection.tsx +++ b/frontend/src/pages/about/PublicDownloadSection.tsx @@ -14,7 +14,7 @@ const PublicDownloadSection = () => { const api_url = import.meta.env.REACT_APP_API_URL; - const stats = useStats(); + const stats = useStats({ poll: pollName }); const pollStats = getPollStats(stats, pollName); const userCount = stats.active_users.total ?? 0; diff --git a/frontend/src/pages/home/videos/sections/ComparisonSection.tsx b/frontend/src/pages/home/videos/sections/ComparisonSection.tsx index 2576a8d492..2ea7b4dcc8 100644 --- a/frontend/src/pages/home/videos/sections/ComparisonSection.tsx +++ b/frontend/src/pages/home/videos/sections/ComparisonSection.tsx @@ -14,7 +14,7 @@ const ComparisonSection = () => { const { t } = useTranslation(); const { name: pollName } = useCurrentPoll(); - const stats = useStats(); + const stats = useStats({ poll: pollName }); const pollStats = getPollStats(stats, pollName); const color = '#fff'; diff --git a/frontend/src/pages/home/videos/sections/recommendations/RecommendationsSection.tsx b/frontend/src/pages/home/videos/sections/recommendations/RecommendationsSection.tsx index ba47d61f99..30c78780dc 100644 --- a/frontend/src/pages/home/videos/sections/recommendations/RecommendationsSection.tsx +++ b/frontend/src/pages/home/videos/sections/recommendations/RecommendationsSection.tsx @@ -18,7 +18,7 @@ const RecommendationsSection = () => { const { t } = useTranslation(); const { name: pollName } = useCurrentPoll(); - const stats = useStats(); + const stats = useStats({ poll: pollName }); const pollStats = getPollStats(stats, pollName); const titleColor = '#fff';