Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[back][front] perf: stats API can be filtered by poll #1830

Merged
merged 9 commits into from
Jan 18, 2024
40 changes: 39 additions & 1 deletion backend/tournesol/tests/test_api_statistics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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)
51 changes: 40 additions & 11 deletions backend/tournesol/views/stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand All @@ -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):
Expand Down
6 changes: 6 additions & 0 deletions frontend/scripts/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/features/comparisons/ComparisonSliders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
77 changes: 53 additions & 24 deletions frontend/src/features/statistics/StatsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -36,39 +41,63 @@ export const StatsLazyProvider = ({
}: {
children: React.ReactNode;
}) => {
const loading = useRef(false);
const loading = useRef<CancelablePromise<Statistics> | null>(null);
const lastRefreshAt = useRef(0);
const lastPoll = useRef<string | undefined>(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(
() => ({
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/features/statistics/UsageStatsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
12 changes: 8 additions & 4 deletions frontend/src/hooks/useStats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Statistics>(getStats());
const [stats, setStats] = useState<Statistics>(getStats(poll));

useEffect(() => {
setStats(getStats());
}, [getStats]);
setStats(getStats(poll));
}, [poll, getStats]);

return stats;
};
2 changes: 1 addition & 1 deletion frontend/src/pages/about/PublicDownloadSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading