Compare commits

...
Sign in to create a new pull request.

4 commits

8 changed files with 250 additions and 102 deletions

View file

@ -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 = {

View file

@ -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

View file

@ -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"

View file

@ -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 (
<input
type="text"
onChange={this.onChange}
onKeyDown={this.onKeyDown}
value={this.state.userInput}
/>
);
}
}
export default AutoCompleteInput;

View file

@ -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):

View file

@ -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)

View file

@ -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)

View file

@ -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,
}