diff --git a/doc/dev/configuration.rst b/doc/dev/configuration.rst index 797c99810..27380d6c2 100644 --- a/doc/dev/configuration.rst +++ b/doc/dev/configuration.rst @@ -95,3 +95,8 @@ Social Login * ``GOOGLE_CLIENT_ID`` - Google Client ID * ``GOOGLE_CLIENT_SECRET`` - Google Client Secret + + +API +--- +* ``MAX_EPISODE_ACTIONS`` - maximum number of episode actions that the API will return in one `GET` request. diff --git a/mygpo/api/advanced/__init__.py b/mygpo/api/advanced/__init__.py index 27f1dc428..26bb1fae9 100644 --- a/mygpo/api/advanced/__init__.py +++ b/mygpo/api/advanced/__init__.py @@ -144,6 +144,9 @@ def get_episode_changes(user, podcast, device, since, until, aggregated, version history = EpisodeHistoryEntry.objects.filter(user=user, timestamp__lt=until) + # return the earlier entries first + history = history.order_by('timestamp') + if since: history = history.filter(timestamp__gte=since) @@ -156,12 +159,24 @@ def get_episode_changes(user, podcast, device, since, until, aggregated, version if version == 1: history = map(convert_position, history) + # Limit number of returned episode actions + max_actions = dsettings.MAX_EPISODE_ACTIONS + history = history[:max_actions] + + # evaluate query and turn into list, for negative indexing + history = list(history) + actions = [episode_action_json(a, user) for a in history] if aggregated: actions = list(dict( (a['episode'], a) for a in actions ).values()) - return {'actions': actions, 'timestamp': get_timestamp(until)} + if history: + ts = get_timestamp(history[-1].timestamp) + else: + ts = get_timestamp(until) + + return {'actions': actions, 'timestamp': ts} def episode_action_json(history, user): diff --git a/mygpo/api/tests.py b/mygpo/api/tests.py index 653126825..7f093f471 100644 --- a/mygpo/api/tests.py +++ b/mygpo/api/tests.py @@ -1,16 +1,21 @@ import json +import uuid import copy import unittest +from datetime import datetime, timedelta from urllib.parse import urlencode from django.test.client import Client from django.test import TestCase from django.urls import reverse from django.contrib.auth import get_user_model +from django.test.utils import override_settings from mygpo.podcasts.models import Podcast, Episode from mygpo.api.advanced import episodes +from mygpo.history.models import EpisodeHistoryEntry from mygpo.test import create_auth_string, anon_request +from mygpo.utils import get_timestamp class AdvancedAPITests(unittest.TestCase): @@ -186,3 +191,104 @@ def test_episode_info(self): resp = self.client.get(url) self.assertEqual(resp.status_code, 200) + + +class EpisodeActionTests(TestCase): + + def setUp(self): + self.podcast = Podcast.objects.get_or_create_for_url( + 'http://example.com/directory-podcast.xml', + defaults = { + 'title': 'My Podcast', + }, + ).object + self.episode = Episode.objects.get_or_create_for_url( + self.podcast, + 'http://example.com/directory-podcast/1.mp3', + defaults = { + 'title': 'My Episode', + }, + ).object + User = get_user_model() + self.password = 'asdf' + self.username = 'adv-api-user' + self.user = User(username=self.username, email='user@example.com') + self.user.set_password(self.password) + self.user.save() + self.user.is_active = True + self.client = Client() + self.extra = { + 'HTTP_AUTHORIZATION': create_auth_string(self.username, + self.password) + } + + def tearDown(self): + self.episode.delete() + self.podcast.delete() + self.user.delete() + + @override_settings(MAX_EPISODE_ACTIONS=10) + def test_limit_actions(self): + """ Test that max MAX_EPISODE_ACTIONS episodes are returned """ + + timestamps = [] + t = datetime.utcnow() + for n in range(15): + timestamp = t - timedelta(seconds=n) + EpisodeHistoryEntry.objects.create( + timestamp = timestamp, + episode = self.episode, + user = self.user, + action = EpisodeHistoryEntry.DOWNLOAD, + ) + timestamps.append(timestamp) + + url = reverse(episodes, kwargs={ + 'version': '2', + 'username': self.user.username, + }) + response = self.client.get(url, {'since': '0'}, **self.extra) + self.assertEqual(response.status_code, 200, response.content) + response_obj = json.loads(response.content.decode('utf-8')) + actions = response_obj['actions'] + + # 10 actions should be returned + self.assertEqual(len(actions), 10) + + timestamps = sorted(timestamps) + + # the first 10 actions, according to their timestamp should be returned + for action, timestamp in zip(actions, timestamps): + self.assertEqual(timestamp.isoformat(), action['timestamp']) + + # the `timestamp` field in the response should be the timestamp of the + # last returned action + self.assertEqual( + get_timestamp(timestamps[9]), + response_obj['timestamp'] + ) + + + def test_no_actions(self): + """ Test when there are no actions to return """ + + t1 = get_timestamp(datetime.utcnow()) + + url = reverse(episodes, kwargs={ + 'version': '2', + 'username': self.user.username, + }) + response = self.client.get(url, {'since': '0'}, **self.extra) + self.assertEqual(response.status_code, 200, response.content) + response_obj = json.loads(response.content.decode('utf-8')) + actions = response_obj['actions'] + + # 10 actions should be returned + self.assertEqual(len(actions), 0) + + returned = response_obj['timestamp'] + t2 = get_timestamp(datetime.utcnow()) + # the `timestamp` field in the response should be the timestamp of the + # last returned action + self.assertGreaterEqual(returned, t1) + self.assertGreaterEqual(t2, returned) diff --git a/mygpo/settings.py b/mygpo/settings.py index 7731c5f2f..955007644 100644 --- a/mygpo/settings.py +++ b/mygpo/settings.py @@ -374,4 +374,6 @@ def get_intOrNone(name, default): PODCAST_AD_ID = os.getenv('PODCAST_AD_ID') +MAX_EPISODE_ACTIONS = int(os.getenv('MAX_EPISODE_ACTIONS', 1000)) + SEARCH_CUTOFF = float(os.getenv('SEARCH_CUTOFF', 0.3))