From 78484326a2685842e511a57306ff0e407ef871b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hermann=20K=C3=A4ser?= Date: Mon, 20 Feb 2017 15:40:45 +0000 Subject: [PATCH] Shows filter totals in browse sidebar (#137) * Rather than matching cuisine and course filters by title we're matching them by slug, which will be url-safer. * No point in having a `?format=json` when we can just use the application type header --- api/v1/fixtures/test/course.json | 35 +++++++ api/v1/fixtures/test/cuisine.json | 35 +++++++ api/v1/fixtures/test/recipes.json | 104 +++++++++++++++++++ api/v1/recipe/views.py | 2 +- api/v1/recipe_groups/models.py | 6 -- api/v1/recipe_groups/serializers.py | 6 +- api/v1/recipe_groups/tests.py | 78 ++++++++++++++ api/v1/recipe_groups/urls.py | 4 +- api/v1/recipe_groups/views.py | 37 +++++-- frontend/modules/browse/components/Browse.js | 70 +++++++------ frontend/modules/browse/components/Filter.js | 24 +++-- frontend/modules/common/config.js | 6 +- 12 files changed, 344 insertions(+), 63 deletions(-) create mode 100644 api/v1/fixtures/test/course.json create mode 100644 api/v1/fixtures/test/cuisine.json create mode 100644 api/v1/fixtures/test/recipes.json create mode 100644 api/v1/recipe_groups/tests.py diff --git a/api/v1/fixtures/test/course.json b/api/v1/fixtures/test/course.json new file mode 100644 index 00000000..e8dac619 --- /dev/null +++ b/api/v1/fixtures/test/course.json @@ -0,0 +1,35 @@ +[ + { + "pk": 1, + "model": "recipe_groups.course", + "fields": { + "title": "Course 1", + "slug": "course-1", + "author": "1" + } + },{ + "pk": 2, + "model": "recipe_groups.course", + "fields": { + "title": "Course 2", + "slug": "course-2", + "author": "1" + } + },{ + "pk": 3, + "model": "recipe_groups.course", + "fields": { + "title": "Course 3", + "slug": "course-3", + "author": "1" + } + },{ + "pk": 4, + "model": "recipe_groups.course", + "fields": { + "title": "Course 4", + "slug": "course-4", + "author": "1" + } + } +] \ No newline at end of file diff --git a/api/v1/fixtures/test/cuisine.json b/api/v1/fixtures/test/cuisine.json new file mode 100644 index 00000000..fb09d938 --- /dev/null +++ b/api/v1/fixtures/test/cuisine.json @@ -0,0 +1,35 @@ +[ + { + "pk": 1, + "model": "recipe_groups.cuisine", + "fields": { + "title": "Cuisine 1", + "slug": "cuisine-1", + "author": "1" + } + },{ + "pk": 2, + "model": "recipe_groups.cuisine", + "fields": { + "title": "Cuisine 2", + "slug": "cuisine-2", + "author": "1" + } + },{ + "pk": 3, + "model": "recipe_groups.cuisine", + "fields": { + "title": "Cuisine 3", + "slug": "cuisine-3", + "author": "1" + } + },{ + "pk": 4, + "model": "recipe_groups.cuisine", + "fields": { + "title": "Cuisine 4", + "slug": "cuisine-4", + "author": "1" + } + } +] \ No newline at end of file diff --git a/api/v1/fixtures/test/recipes.json b/api/v1/fixtures/test/recipes.json new file mode 100644 index 00000000..d42291a3 --- /dev/null +++ b/api/v1/fixtures/test/recipes.json @@ -0,0 +1,104 @@ +[ + { + "pk": 1, + "model": "recipe.recipe", + "fields": { + "title": "Recipe 1", + "slug": "recipe-1", + "author": "1", + "cuisine": "1", + "course": "1", + "info": "Recipe info", + "prep_time": "1", + "cook_time": "1", + "servings": "1", + "pub_date": "2017-01-01 00:00:00", + "update_date": "2017-01-01 00:00:00" + } + }, + { + "pk": 2, + "model": "recipe.recipe", + "fields": { + "title": "Recipe 2", + "slug": "recipe-2", + "author": "1", + "cuisine": "1", + "course": "2", + "info": "Recipe info", + "prep_time": "1", + "cook_time": "1", + "servings": "1", + "pub_date": "2017-01-01 00:00:00", + "update_date": "2017-01-01 00:00:00" + } + }, + { + "pk": 3, + "model": "recipe.recipe", + "fields": { + "title": "Recipe 3", + "slug": "recipe-3", + "author": "1", + "cuisine": "2", + "course": "1", + "info": "Recipe info", + "prep_time": "1", + "cook_time": "1", + "servings": "1", + "pub_date": "2017-01-01 00:00:00", + "update_date": "2017-01-01 00:00:00" + } + }, + { + "pk": 4, + "model": "recipe.recipe", + "fields": { + "title": "Recipe 4", + "slug": "recipe-4", + "author": "1", + "cuisine": "1", + "course": "3", + "info": "Recipe info", + "prep_time": "1", + "cook_time": "1", + "servings": "1", + "pub_date": "2017-01-01 00:00:00", + "update_date": "2017-01-01 00:00:00" + } + }, + { + "pk": 5, + "model": "recipe.recipe", + "fields": { + "title": "Recipe 5", + "slug": "recipe-5", + "author": "1", + "cuisine": "2", + "course": "2", + "info": "Recipe info", + "prep_time": "1", + "cook_time": "1", + "servings": "1", + "pub_date": "2017-01-01 00:00:00", + "update_date": "2017-01-01 00:00:00" + } + }, + { + "pk": 6, + "model": "recipe.recipe", + "fields": { + "title": "Recipe 6", + "slug": "recipe-6", + "author": "1", + "cuisine": "3", + "course": "1", + "info": "Recipe info", + "prep_time": "1", + "cook_time": "1", + "servings": "1", + "pub_date": "2017-01-01 00:00:00", + "update_date": "2017-01-01 00:00:00" + } + } +] \ No newline at end of file diff --git a/api/v1/recipe/views.py b/api/v1/recipe/views.py index dc9d9623..3ee5fcba 100644 --- a/api/v1/recipe/views.py +++ b/api/v1/recipe/views.py @@ -19,7 +19,7 @@ class RecipeViewSet(viewsets.ModelViewSet): serializer_class = serializers.RecipeSerializer permission_classes = (permissions.IsAuthenticatedOrReadOnly,) filter_backends = (filters.DjangoFilterBackend, filters.SearchFilter) - filter_fields = ('course__title', 'cuisine__title', 'course', 'cuisine', 'title') + filter_fields = ('course__slug', 'cuisine__slug', 'course', 'cuisine', 'title') search_fields = ('title', 'tags__title') diff --git a/api/v1/recipe_groups/models.py b/api/v1/recipe_groups/models.py index afd7b270..1a100822 100644 --- a/api/v1/recipe_groups/models.py +++ b/api/v1/recipe_groups/models.py @@ -26,9 +26,6 @@ class Meta: def __unicode__(self): return self.title - def recipe_count(self): - return self.recipe_set.filter(shared=0).count() - class Course(models.Model): """ @@ -48,9 +45,6 @@ class Meta: def __unicode__(self): return self.title - def recipe_count(self): - return self.recipe_set.filter(shared=0).count() - class Tag(models.Model): """ diff --git a/api/v1/recipe_groups/serializers.py b/api/v1/recipe_groups/serializers.py index 834d0fa3..5df2b848 100644 --- a/api/v1/recipe_groups/serializers.py +++ b/api/v1/recipe_groups/serializers.py @@ -8,16 +8,18 @@ class CuisineSerializer(serializers.ModelSerializer): """ Standard `rest_framework` ModelSerializer """ + total = serializers.IntegerField(read_only=True) + class Meta: model = Cuisine - exclude = ('slug',) class CourseSerializer(serializers.ModelSerializer): """ Standard `rest_framework` ModelSerializer """ + total = serializers.IntegerField(read_only=True) + class Meta: model = Course - exclude = ('slug',) class TagSerializer(serializers.ModelSerializer): diff --git a/api/v1/recipe_groups/tests.py b/api/v1/recipe_groups/tests.py new file mode 100644 index 00000000..efe478ee --- /dev/null +++ b/api/v1/recipe_groups/tests.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python +# encoding: utf-8 +from __future__ import unicode_literals +from django.test import TestCase +from rest_framework.test import APIRequestFactory +from v1.recipe_groups import views + +class RecipeGroupsTests(TestCase): + fixtures = ['test/users.json', 'test/cuisine.json', 'test/course.json', 'test/recipes.json'] + + def setUp(self): + self.factory = APIRequestFactory() + + def test_cuisine_all(self): + view = views.CuisineViewSet.as_view({'get': 'list'}) + request = self.factory.get('/api/v1/recipe_groups/cuisine/') + response = view(request) + + self.assertEqual(response.data.get('count'), 4) + + results = response.data.get('results') + totals = {"cuisine-1": 3, "cuisine-2": 2, "cuisine-3": 1, "cuisine-4": 0} + + for item in results: + self.assertEquals(totals[item.get('slug')], item.get('total')) + + def test_course_all(self): + view = views.CourseViewSet.as_view({'get': 'list'}) + request = self.factory.get('/api/v1/recipe_groups/course/') + response = view(request) + + self.assertEqual(response.data.get('count'), 4) + + results = response.data.get('results') + totals = {"course-1": 3, "course-2": 2, "course-3": 1, "course-4": 0} + + for item in results: + self.assertEquals(totals[item.get('slug')], item.get('total')) + + def test_cuisine_with_course_filter(self): + view = views.CuisineViewSet.as_view({'get': 'list'}) + request = self.factory.get('/api/v1/recipe_groups/cuisine/?course=course-1') + response = view(request) + + self.assertEqual(response.data.get('count'), 3) + + results = response.data.get('results') + totals = {"cuisine-1": 1, "cuisine-2": 1, "cuisine-3": 1} + + for item in results: + self.assertEquals(totals[item.get('slug')], item.get('total')) + + def test_cuisine_with_course_filter_no_results(self): + view = views.CuisineViewSet.as_view({'get': 'list'}) + request = self.factory.get('/api/v1/recipe_groups/cuisine/?course=course-4') + response = view(request) + + self.assertEqual(response.data.get('count'), 0) + + def test_course_with_cuisine_filter(self): + view = views.CourseViewSet.as_view({'get': 'list'}) + request = self.factory.get('/api/v1/recipe_groups/course/?cuisine=cuisine-1') + response = view(request) + + self.assertEqual(response.data.get('count'), 3) + + results = response.data.get('results') + totals = {"course-1": 1, "course-2": 1, "course-3": 1} + + for item in results: + self.assertEquals(totals[item.get('slug')], item.get('total')) + + def test_cuisine_with_course_filter_no_results(self): + view = views.CourseViewSet.as_view({'get': 'list'}) + request = self.factory.get('/api/v1/recipe_groups/course/?cuisine=cuisine-4') + response = view(request) + + self.assertEqual(response.data.get('count'), 0) \ No newline at end of file diff --git a/api/v1/recipe_groups/urls.py b/api/v1/recipe_groups/urls.py index 966dd7de..fe7a7b04 100644 --- a/api/v1/recipe_groups/urls.py +++ b/api/v1/recipe_groups/urls.py @@ -9,8 +9,8 @@ # Create a router and register our viewsets with it. router = DefaultRouter(schema_title='recipe_groups') -router.register(r'cuisine', views.CuisineViewSet) -router.register(r'course', views.CourseViewSet) +router.register(r'cuisine', views.CuisineViewSet, base_name='cuisine') +router.register(r'course', views.CourseViewSet, base_name='course') router.register(r'tag', views.TagViewSet) urlpatterns = [ diff --git a/api/v1/recipe_groups/views.py b/api/v1/recipe_groups/views.py index e9b3025a..c81d59de 100644 --- a/api/v1/recipe_groups/views.py +++ b/api/v1/recipe_groups/views.py @@ -2,10 +2,9 @@ # encoding: utf-8 from __future__ import unicode_literals -from .models import Cuisine, Course, Tag -from .serializers import CuisineSerializer, \ - CourseSerializer, \ - TagSerializer +from django.db.models import Count +from v1.recipe_groups.models import Cuisine, Course, Tag +from v1.recipe_groups import serializers from rest_framework import permissions from rest_framework import viewsets from v1.common.permissions import IsOwnerOrReadOnly @@ -18,11 +17,19 @@ class CuisineViewSet(viewsets.ModelViewSet): Uses `title` as the PK for any lookups. """ - queryset = Cuisine.objects.all() - serializer_class = CuisineSerializer + serializer_class = serializers.CuisineSerializer permission_classes = (permissions.IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly) - lookup_field = 'title' + lookup_field = 'slug' + + def get_queryset(self): + query = Cuisine.objects + + if 'course' in self.request.query_params: + course = Course.objects.get(slug=self.request.query_params.get('course')) + query = query.filter(recipe__course=course) + + return query.annotate(total=Count('recipe', distinct=True)) class CourseViewSet(viewsets.ModelViewSet): @@ -32,11 +39,19 @@ class CourseViewSet(viewsets.ModelViewSet): Uses `title` as the PK for any lookups. """ - queryset = Course.objects.all() - serializer_class = CourseSerializer + serializer_class = serializers.CourseSerializer permission_classes = (permissions.IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly) - lookup_field = 'title' + lookup_field = 'slug' + + def get_queryset(self): + query = Course.objects + + if 'cuisine' in self.request.query_params: + cuisine = Cuisine.objects.get(slug=self.request.query_params.get('cuisine')) + query = query.filter(recipe__cuisine=cuisine) + + return query.annotate(total=Count('recipe', distinct=True)) class TagViewSet(viewsets.ModelViewSet): @@ -47,7 +62,7 @@ class TagViewSet(viewsets.ModelViewSet): Uses `title` as the PK for any lookups. """ queryset = Tag.objects.all() - serializer_class = TagSerializer + serializer_class = serializers.TagSerializer permission_classes = (permissions.IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly) lookup_field = 'title' diff --git a/frontend/modules/browse/components/Browse.js b/frontend/modules/browse/components/Browse.js index a67b3774..45bdd998 100644 --- a/frontend/modules/browse/components/Browse.js +++ b/frontend/modules/browse/components/Browse.js @@ -28,8 +28,8 @@ const FILTER_MAP = { // frontend filter : backend filters 'offset' : 'offset', 'limit' : 'limit', - 'cuisine': 'cuisine__title', - 'course' : 'course__title', + 'cuisine': 'cuisine__slug', + 'course' : 'course__slug', 'search' : 'search' }; @@ -38,31 +38,51 @@ export default injectIntl(React.createClass({ router: React.PropTypes.object }, - init: function() { - var course = []; - var cuisine = []; - request.get(serverURLs.course).type('json') + getInitialState: function() { + return { + recipes: [], + total_recipes: 0, + course: [], + cuisine: [], + }; + }, + + getCourses: function() { + const query = this.props.location.query; + let url = serverURLs.course; + + if ('cuisine' in query) { + url = url + '?cuisine=' + query.cuisine; + } + + request.get(url).type('json') .end((err, res) => { if (!err && res) { - course = res.body.results; - request.get(serverURLs.cuisine).type('json') - .end((err, res) => { - if (!err && res) { - cuisine = res.body.results; - this.setState({ - course: course, - cuisine: cuisine, - }); - } else { - console.error(serverURLs.cuisine, err.toString()); - } - }); + this.setState({course: res.body.results}); } else { console.error(serverURLs.course, err.toString()); } }); }, + getCuisines: function() { + const query = this.props.location.query; + let url = serverURLs.cuisine; + + if ('course' in query) { + url = url + '?course=' + query.course; + } + + request.get(url).type('json') + .end((err, res) => { + if (!err && res) { + this.setState({cuisine: res.body.results}); + } else { + console.error(serverURLs.cuisine, err.toString()); + } + }); + }, + getRecipes: function(url) { url = serverURLs.browse + url; request @@ -80,17 +100,7 @@ export default injectIntl(React.createClass({ }); }, - getInitialState: function() { - return { - recipes: [], - total_recipes: 0, - course: [], - cuisine: [], - }; - }, - componentDidMount: function() { - this.init(); this.buildBackendURL(this.props.location.query); }, @@ -138,6 +148,8 @@ export default injectIntl(React.createClass({ base_url += "&" + FILTER_MAP[filter] + "=" + DEFAULTS[filter]; } } + this.getCourses(); + this.getCuisines(); this.getRecipes(base_url); }, diff --git a/frontend/modules/browse/components/Filter.js b/frontend/modules/browse/components/Filter.js index 2aa3bc13..008ba3d7 100644 --- a/frontend/modules/browse/components/Filter.js +++ b/frontend/modules/browse/components/Filter.js @@ -32,23 +32,29 @@ export default injectIntl(React.createClass({ } }); - var _onClick = this._onClick; - var active = this.props.active; - var items = this.props.data.map(function(item) { - var classNames = "list-group-item"; - if (item.title == active) { - classNames += " active"; + const active = this.props.active; + const items = this.props.data.map((item) => { + let classNames = ["list-group-item"]; + if (item.slug == active) { + classNames += [" active"]; } + + if (item.total == 0) { + return null; + } + return ( + name={ item.slug } + onClick={ this._onClick }> { item.title } + { item.total } ); }); + return (

@@ -60,7 +66,7 @@ export default injectIntl(React.createClass({ href="#" name={ '' } key={ 9999999999999999 } - onClick={ _onClick }> + onClick={ this._onClick }> { formatMessage(messages.clear_filter) } : '' diff --git a/frontend/modules/common/config.js b/frontend/modules/common/config.js index 70c9dd2b..432cc4a1 100644 --- a/frontend/modules/common/config.js +++ b/frontend/modules/common/config.js @@ -7,11 +7,11 @@ var apiUrl = apiHost + '/api/v1'; export var serverURLs = { auth_token: apiUrl + '/accounts/obtain-auth-token/', - browse: apiUrl + '/recipe/recipes/?format=json&fields=id,title,pub_date,rating,photo_thumbnail,info', + browse: apiUrl + '/recipe/recipes/?fields=id,title,pub_date,rating,photo_thumbnail,info', mini_browse: apiUrl + '/recipe/mini-browse/?format=json', //create: apiUrl + '/recipe/recipes/', - cuisine: apiUrl + '/recipe_groups/cuisine/?format=json', - course: apiUrl + '/recipe_groups/course/?format=json', + cuisine: apiUrl + '/recipe_groups/cuisine/', + course: apiUrl + '/recipe_groups/course/', tag: apiUrl + '/recipe_groups/tag/?format=json', ingredient: apiUrl + '/ingredient/ingredient/?format=json', direction: apiUrl + '/recipe/direction/?format=json',