diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py index d41f352..15af28f 100644 --- a/src/newsreader/conf/base.py +++ b/src/newsreader/conf/base.py @@ -228,6 +228,10 @@ REST_FRAMEWORK = { "newsreader.accounts.permissions.IsOwner", ), "DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",), + "DEFAULT_THROTTLE_RATES": { + "burst_search": "100/min", + "sustained_search": "2000/day", + }, } SWAGGER_SETTINGS = { diff --git a/src/newsreader/core/forms.py b/src/newsreader/core/forms.py index ca3fe22..d597808 100644 --- a/src/newsreader/core/forms.py +++ b/src/newsreader/core/forms.py @@ -7,3 +7,9 @@ class CheckboxInput(forms.CheckboxInput): def get_context(self, name, value, attrs): context = super().get_context(name, value, attrs) return {**context, **attrs} + + +class MultiAutoCompleteWidget(forms.TextInput): + def get_context(self, name, value, attrs): + context = super().get_context(name, value, attrs) + return context diff --git a/src/newsreader/core/throttling.py b/src/newsreader/core/throttling.py new file mode 100644 index 0000000..f333d95 --- /dev/null +++ b/src/newsreader/core/throttling.py @@ -0,0 +1,20 @@ +from rest_framework.throttling import UserRateThrottle + + +class SearchThrottle(UserRateThrottle): + """ + Only applies throttling to requests with the search param + """ + + def allow_request(self, request, view): + if not "search" in request.GET.keys(): + return True + return super().allow_request(request, view) + + +class BurstSearchThrottle(SearchThrottle): + scope = "burst_search" + + +class SustainedSearchThrottle(SearchThrottle): + scope = "sustained_search" diff --git a/src/newsreader/js/components/AutoCompleteInput.js b/src/newsreader/js/components/AutoCompleteInput.js new file mode 100644 index 0000000..1f95c55 --- /dev/null +++ b/src/newsreader/js/components/AutoCompleteInput.js @@ -0,0 +1,29 @@ +import React from 'react'; + +class AutoCompleteInput extends React.Component { + state = { + filteredSuggestions: [], + activeSuggestions: [], + showSuggestions: false, + userInput: '', + }; + + onChange(e) {} + + onClick(e) {} + + onKeyDown(e) {} + + render() { + return ( + + ); + } +} + +export default AutoCompleteInput; diff --git a/src/newsreader/news/collection/endpoints.py b/src/newsreader/news/collection/endpoints.py index 7f2ede0..2ac4963 100644 --- a/src/newsreader/news/collection/endpoints.py +++ b/src/newsreader/news/collection/endpoints.py @@ -1,4 +1,4 @@ -from rest_framework import status +from rest_framework import filters, status from rest_framework.generics import ( GenericAPIView, ListAPIView, @@ -8,6 +8,7 @@ from rest_framework.generics import ( from rest_framework.response import Response from newsreader.core.pagination import LargeResultSetPagination, ResultSetPagination +from newsreader.core.throttling import BurstSearchThrottle, SustainedSearchThrottle from newsreader.news.collection.models import CollectionRule from newsreader.news.collection.serializers import RuleSerializer from newsreader.news.core.filters import ReadFilter @@ -20,9 +21,14 @@ class ListRuleView(ListAPIView): serializer_class = RuleSerializer pagination_class = ResultSetPagination + filter_backends = [filters.SearchFilter] + search_fields = ["name", "screen_name", "url"] + + throttle_classes = [BurstSearchThrottle, SustainedSearchThrottle] + def get_queryset(self): user = self.request.user - return self.queryset.filter(user=user).order_by("-created") + return self.queryset.filter(user=user).order_by("name", "screen_name") class DetailRuleView(RetrieveUpdateDestroyAPIView): diff --git a/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py b/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py index 44e3eaa..d474541 100644 --- a/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py +++ b/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py @@ -1,14 +1,23 @@ import json +import time -from datetime import date, datetime, time +from datetime import datetime +from urllib.parse import urlencode +from django.core.cache import cache from django.test import TestCase from django.urls import reverse import pytz +from freezegun import freeze_time + from newsreader.accounts.tests.factories import UserFactory -from newsreader.news.collection.tests.factories import FeedFactory +from newsreader.news.collection.tests.factories import ( + FeedFactory, + SubredditFactory, + TwitterTimelineFactory, +) from newsreader.news.core.tests.factories import CategoryFactory, FeedPostFactory @@ -23,44 +32,29 @@ class RuleListViewTestCase(TestCase): response = self.client.get(reverse("api:news:collection:rules-list")) data = response.json() - self.assertEquals(response.status_code, 200) + self.assertEqual(response.status_code, 200) self.assertTrue("results" in data) self.assertTrue("count" in data) - self.assertEquals(data["count"], 3) + self.assertEqual(data["count"], 3) def test_ordering(self): - rules = [ - FeedFactory( - created=datetime.combine( - date(2019, 5, 20), time(hour=16, minute=7, second=37), pytz.utc - ), - user=self.user, - ), - FeedFactory( - created=datetime.combine( - date(2019, 7, 20), time(hour=18, minute=7, second=37), pytz.utc - ), - user=self.user, - ), - FeedFactory( - created=datetime.combine( - date(2019, 7, 20), time(hour=16, minute=7, second=37), pytz.utc - ), - user=self.user, - ), - ] + rules = { + "foo": FeedFactory(name="foo", user=self.user), + "bar": FeedFactory(name="bar", user=self.user), + "dar": FeedFactory(name="dar", user=self.user), + } response = self.client.get(reverse("api:news:collection:rules-list")) data = response.json() - self.assertEquals(response.status_code, 200) + self.assertEqual(response.status_code, 200) self.assertTrue("results" in data) self.assertTrue("count" in data) - self.assertEquals(data["count"], 3) + self.assertEqual(data["count"], 3) - self.assertEquals(data["results"][0]["id"], rules[1].pk) - self.assertEquals(data["results"][1]["id"], rules[2].pk) - self.assertEquals(data["results"][2]["id"], rules[0].pk) + self.assertEqual(data["results"][0]["id"], rules["bar"].pk) + self.assertEqual(data["results"][1]["id"], rules["dar"].pk) + self.assertEqual(data["results"][2]["id"], rules["foo"].pk) def test_pagination_count(self): FeedFactory.create_batch(size=80, user=self.user) @@ -70,20 +64,20 @@ class RuleListViewTestCase(TestCase): ) data = response.json() - self.assertEquals(response.status_code, 200) - self.assertEquals(data["count"], 80) - self.assertEquals(len(data["results"]), 30) + self.assertEqual(response.status_code, 200) + self.assertEqual(data["count"], 80) + self.assertEqual(len(data["results"]), 30) def test_empty(self): response = self.client.get(reverse("api:news:collection:rules-list")) data = response.json() - self.assertEquals(response.status_code, 200) + self.assertEqual(response.status_code, 200) self.assertTrue("results" in data) self.assertTrue("count" in data) - self.assertEquals(data["count"], 0) - self.assertEquals(len(data["results"]), 0) + self.assertEqual(data["count"], 0) + self.assertEqual(len(data["results"]), 0) def test_post(self): category = CategoryFactory(user=self.user) @@ -97,29 +91,29 @@ class RuleListViewTestCase(TestCase): ) data = response.json() - self.assertEquals(response.status_code, 405) - self.assertEquals(data["detail"], 'Method "POST" not allowed.') + self.assertEqual(response.status_code, 405) + self.assertEqual(data["detail"], 'Method "POST" not allowed.') def test_patch(self): response = self.client.patch(reverse("api:news:collection:rules-list")) data = response.json() - self.assertEquals(response.status_code, 405) - self.assertEquals(data["detail"], 'Method "PATCH" not allowed.') + self.assertEqual(response.status_code, 405) + self.assertEqual(data["detail"], 'Method "PATCH" not allowed.') def test_put(self): response = self.client.put(reverse("api:news:collection:rules-list")) data = response.json() - self.assertEquals(response.status_code, 405) - self.assertEquals(data["detail"], 'Method "PUT" not allowed.') + self.assertEqual(response.status_code, 405) + self.assertEqual(data["detail"], 'Method "PUT" not allowed.') def test_delete(self): response = self.client.delete(reverse("api:news:collection:rules-list")) data = response.json() - self.assertEquals(response.status_code, 405) - self.assertEquals(data["detail"], 'Method "DELETE" not allowed.') + self.assertEqual(response.status_code, 405) + self.assertEqual(data["detail"], 'Method "DELETE" not allowed.') def test_rules_with_unauthenticated_user(self): self.client.logout() @@ -128,7 +122,7 @@ class RuleListViewTestCase(TestCase): response = self.client.get(reverse("api:news:collection:rules-list")) - self.assertEquals(response.status_code, 403) + self.assertEqual(response.status_code, 403) def test_rules_with_unauthorized_user(self): other_user = UserFactory() @@ -137,10 +131,108 @@ class RuleListViewTestCase(TestCase): response = self.client.get(reverse("api:news:collection:rules-list")) data = response.json() - self.assertEquals(response.status_code, 200) + self.assertEqual(response.status_code, 200) - self.assertEquals(data["count"], 0) - self.assertEquals(len(data["results"]), 0) + self.assertEqual(data["count"], 0) + self.assertEqual(len(data["results"]), 0) + + +class RuleListViewSearchTestCase(TestCase): + def setUp(self): + self.user = UserFactory(password="test") + self.client.force_login(self.user) + + def test_feed_rules(self): + rules = { + "foo": FeedFactory(name="foo", user=self.user), + "BarFoo": FeedFactory(name="BarFoo", user=self.user), + "FooBar": FeedFactory(name="FooBar", user=self.user), + "boo": FeedFactory(name="boo", user=self.user), + "Boobar": FeedFactory(name="Boobar", user=self.user), + } + + params = urlencode({"search": "foo"}) + url = reverse("api:news:collection:rules-list") + + response = self.client.get(f"{url}?{params}") + response_data = response.json() + + self.assertEqual(response_data["count"], 3) + self.assertEqual(response_data["results"][0]["id"], rules["BarFoo"].pk) + self.assertEqual(response_data["results"][1]["id"], rules["foo"].pk) + self.assertEqual(response_data["results"][2]["id"], rules["FooBar"].pk) + + def test_twitter_profiles(self): + rules = { + "foo": TwitterTimelineFactory( + name="foo", screen_name="foo", user=self.user + ), + "BarFoo": TwitterTimelineFactory( + name="BarFoo", screen_name="foodiddy", user=self.user + ), + "BarFoo dup": TwitterTimelineFactory( + name="BarFoo", screen_name="foodadda", user=self.user + ), + "something else": TwitterTimelineFactory( + name="something else", screen_name="foo", user=self.user + ), + "not found": TwitterTimelineFactory( + name="no result", screen_name="boo", user=self.user + ), + } + + params = urlencode({"search": "foo"}) + url = reverse("api:news:collection:rules-list") + + response = self.client.get(f"{url}?{params}") + response_data = response.json() + + self.assertEqual(response_data["count"], 4) + self.assertEqual(response_data["results"][0]["id"], rules["BarFoo dup"].pk) + self.assertEqual(response_data["results"][1]["id"], rules["BarFoo"].pk) + self.assertEqual(response_data["results"][2]["id"], rules["foo"].pk) + self.assertEqual(response_data["results"][3]["id"], rules["something else"].pk) + + def test_subreddits(self): + rules = { + "foo": SubredditFactory(name="foo", user=self.user), + "BarFoo": SubredditFactory(name="BarFoo", user=self.user), + "FooBar": SubredditFactory(name="FooBar", user=self.user), + "boo": SubredditFactory(name="boo", user=self.user), + "Boobar": SubredditFactory(name="Boobar", user=self.user), + } + + params = urlencode({"search": "foo"}) + url = reverse("api:news:collection:rules-list") + + response = self.client.get(f"{url}?{params}") + response_data = response.json() + + self.assertEqual(response_data["count"], 3) + self.assertEqual(response_data["results"][0]["id"], rules["BarFoo"].pk) + self.assertEqual(response_data["results"][1]["id"], rules["foo"].pk) + self.assertEqual(response_data["results"][2]["id"], rules["FooBar"].pk) + + @freeze_time("2020-10-30 14:00") + def test_ratelimitting(self): + # Trigger ratelimit + cache.set( + f"throttle_burst_search_{self.user.pk}", [time.time() for i in range(100)] + ) + + params = urlencode({"search": "foo"}) + url = reverse("api:news:collection:rules-list") + + response = self.client.get(f"{url}?{params}") + response_data = response.json() + + self.assertEqual(response.status_code, 429) + + message = response_data["detail"] + + self.assertIn("Request was throttled", message) + + cache.delete(f"throttle_burst_search_{self.user.pk}") class NestedRuleListViewTestCase(TestCase): @@ -157,11 +249,11 @@ class NestedRuleListViewTestCase(TestCase): ) data = response.json() - self.assertEquals(response.status_code, 200) + self.assertEqual(response.status_code, 200) self.assertTrue("results" in data) self.assertTrue("count" in data) - self.assertEquals(data["count"], 5) + self.assertEqual(data["count"], 5) def test_pagination(self): rule = FeedFactory.create(user=self.user) @@ -178,11 +270,11 @@ class NestedRuleListViewTestCase(TestCase): ) data = response.json() - self.assertEquals(response.status_code, 200) - self.assertEquals(data["count"], 80) - self.assertEquals(len(data["results"]), 30) + self.assertEqual(response.status_code, 200) + self.assertEqual(data["count"], 80) + self.assertEqual(len(data["results"]), 30) - self.assertEquals( + self.assertEqual( [post["id"] for post in data["results"]], [post.id for post in posts[:30]] ) @@ -194,16 +286,16 @@ class NestedRuleListViewTestCase(TestCase): ) data = response.json() - self.assertEquals(response.status_code, 200) - self.assertEquals(data["count"], 0) - self.assertEquals(len(data["results"]), 0) + self.assertEqual(response.status_code, 200) + self.assertEqual(data["count"], 0) + self.assertEqual(len(data["results"]), 0) def test_not_known(self): response = self.client.get( reverse("api:news:collection:rules-nested-posts", kwargs={"pk": 0}) ) - self.assertEquals(response.status_code, 404) + self.assertEqual(response.status_code, 404) def test_post(self): rule = FeedFactory.create(user=self.user) @@ -215,8 +307,8 @@ class NestedRuleListViewTestCase(TestCase): ) data = response.json() - self.assertEquals(response.status_code, 405) - self.assertEquals(data["detail"], 'Method "POST" not allowed.') + self.assertEqual(response.status_code, 405) + self.assertEqual(data["detail"], 'Method "POST" not allowed.') def test_patch(self): rule = FeedFactory.create(user=self.user) @@ -228,8 +320,8 @@ class NestedRuleListViewTestCase(TestCase): ) data = response.json() - self.assertEquals(response.status_code, 405) - self.assertEquals(data["detail"], 'Method "PATCH" not allowed.') + self.assertEqual(response.status_code, 405) + self.assertEqual(data["detail"], 'Method "PATCH" not allowed.') def test_put(self): rule = FeedFactory.create(user=self.user) @@ -241,8 +333,8 @@ class NestedRuleListViewTestCase(TestCase): ) data = response.json() - self.assertEquals(response.status_code, 405) - self.assertEquals(data["detail"], 'Method "PUT" not allowed.') + self.assertEqual(response.status_code, 405) + self.assertEqual(data["detail"], 'Method "PUT" not allowed.') def test_delete(self): rule = FeedFactory.create(user=self.user) @@ -254,8 +346,8 @@ class NestedRuleListViewTestCase(TestCase): ) data = response.json() - self.assertEquals(response.status_code, 405) - self.assertEquals(data["detail"], 'Method "DELETE" not allowed.') + self.assertEqual(response.status_code, 405) + self.assertEqual(data["detail"], 'Method "DELETE" not allowed.') def test_rule_with_unauthenticated_user(self): self.client.logout() @@ -266,7 +358,7 @@ class NestedRuleListViewTestCase(TestCase): reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}) ) - self.assertEquals(response.status_code, 403) + self.assertEqual(response.status_code, 403) def test_rule_with_unauthorized_user(self): other_user = UserFactory() @@ -276,7 +368,7 @@ class NestedRuleListViewTestCase(TestCase): reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}) ) - self.assertEquals(response.status_code, 403) + self.assertEqual(response.status_code, 403) def test_posts_ordering(self): rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user)) @@ -285,23 +377,17 @@ class NestedRuleListViewTestCase(TestCase): FeedPostFactory( title="I'm the first post", rule=rule, - publication_date=datetime.combine( - date(2019, 5, 20), time(hour=16, minute=7, second=37), pytz.utc - ), + publication_date=datetime(2019, 5, 20, 16, 7, 37, tzinfo=pytz.utc), ), FeedPostFactory( title="I'm the second post", rule=rule, - publication_date=datetime.combine( - date(2019, 7, 20), time(hour=18, minute=7, second=37), pytz.utc - ), + publication_date=datetime(2019, 7, 20, 18, 7, 37, tzinfo=pytz.utc), ), FeedPostFactory( title="I'm the third post", rule=rule, - publication_date=datetime.combine( - date(2019, 7, 20), time(hour=16, minute=7, second=37), pytz.utc - ), + publication_date=datetime(2019, 7, 20, 16, 7, 37, tzinfo=pytz.utc), ), ] @@ -310,14 +396,14 @@ class NestedRuleListViewTestCase(TestCase): ) data = response.json() - self.assertEquals(response.status_code, 200) + self.assertEqual(response.status_code, 200) self.assertTrue("results" in data) self.assertTrue("count" in data) - self.assertEquals(data["count"], 3) + self.assertEqual(data["count"], 3) - self.assertEquals(data["results"][0]["id"], posts[1].pk) - self.assertEquals(data["results"][1]["id"], posts[2].pk) - self.assertEquals(data["results"][2]["id"], posts[0].pk) + self.assertEqual(data["results"][0]["id"], posts[1].pk) + self.assertEqual(data["results"][1]["id"], posts[2].pk) + self.assertEqual(data["results"][2]["id"], posts[0].pk) def test_only_posts_from_rule_are_returned(self): rule = FeedFactory.create(user=self.user) @@ -331,14 +417,14 @@ class NestedRuleListViewTestCase(TestCase): ) data = response.json() - self.assertEquals(response.status_code, 200) + self.assertEqual(response.status_code, 200) self.assertTrue("results" in data) self.assertTrue("count" in data) - self.assertEquals(data["count"], 5) + self.assertEqual(data["count"], 5) for post in data["results"]: - self.assertEquals(post["rule"], rule.pk) + self.assertEqual(post["rule"], rule.pk) def test_unread_posts(self): rule = FeedFactory.create(user=self.user) @@ -354,11 +440,11 @@ class NestedRuleListViewTestCase(TestCase): data = response.json() posts = data["results"] - self.assertEquals(response.status_code, 200) - self.assertEquals(data["count"], 10) + self.assertEqual(response.status_code, 200) + self.assertEqual(data["count"], 10) for post in posts: - self.assertEquals(post["read"], False) + self.assertEqual(post["read"], False) def test_read_posts(self): rule = FeedFactory.create(user=self.user) @@ -374,8 +460,8 @@ class NestedRuleListViewTestCase(TestCase): data = response.json() posts = data["results"] - self.assertEquals(response.status_code, 200) - self.assertEquals(data["count"], 10) + self.assertEqual(response.status_code, 200) + self.assertEqual(data["count"], 10) for post in posts: - self.assertEquals(post["read"], True) + self.assertEqual(post["read"], True) diff --git a/src/newsreader/news/core/forms.py b/src/newsreader/news/core/forms.py index a08022a..c419b8e 100644 --- a/src/newsreader/news/core/forms.py +++ b/src/newsreader/news/core/forms.py @@ -2,6 +2,7 @@ from django import forms from django.forms.widgets import CheckboxSelectMultiple from newsreader.accounts.models import User +from newsreader.core.forms import MultiAutoCompleteWidget from newsreader.news.collection.models import CollectionRule from newsreader.news.core.models import Category @@ -21,7 +22,9 @@ class RulesWidget(CheckboxSelectMultiple): class CategoryForm(forms.ModelForm): rules = forms.ModelMultipleChoiceField( - required=False, queryset=CollectionRule.objects.none(), widget=RulesWidget + required=False, + queryset=CollectionRule.objects.none(), + widget=MultiAutoCompleteWidget, ) user = forms.ModelChoiceField( @@ -35,7 +38,6 @@ class CategoryForm(forms.ModelForm): super().__init__(*args, **kwargs) self.fields["rules"].queryset = CollectionRule.objects.filter(user=self.user) - self.fields["rules"].widget.category = self.instance self.fields["user"].queryset = User.objects.filter(pk=self.user.pk) diff --git a/src/newsreader/news/core/views.py b/src/newsreader/news/core/views.py index c2ff4d5..79259d0 100644 --- a/src/newsreader/news/core/views.py +++ b/src/newsreader/news/core/views.py @@ -44,14 +44,6 @@ class CategoryDetailMixin: success_url = reverse_lazy("news:core:categories") form_class = CategoryForm - def get_context_data(self, **kwargs): - context_data = super().get_context_data(**kwargs) - - rules = CollectionRule.objects.filter(user=self.request.user).order_by("name") - context_data["rules"] = rules - - return context_data - def get_form_kwargs(self): return {**super().get_form_kwargs(), "user": self.request.user} @@ -63,12 +55,15 @@ class CategoryListView(CategoryViewMixin, ListView): def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) + rules = CollectionRule.objects.filter(user=self.request.user).order_by("name") + return { **context, "categories_create_url": reverse_lazy("news:core:category-create"), "categories_update_url": ( reverse_lazy("news:core:category-update", args=(0,)) ), + "rules": rules, }