Skip to content

Commit

Permalink
Merge pull request #2768 from GeotrekCE/api_filter_cotations
Browse files Browse the repository at this point in the history
APIv2 : Add filter by ratings on outdoor courses and sites
  • Loading branch information
Chatewgne committed Sep 24, 2021
2 parents 81b0135 + a95f7b3 commit 9908bf3
Show file tree
Hide file tree
Showing 7 changed files with 267 additions and 9 deletions.
4 changes: 4 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ CHANGELOG

**New features**

- APIv2 : Add filter by ratings on outdoor courses and sites
- APIv2 : Add filter by pratices in hierarchy on outdoor courses and sites
- APIv2 : Add filter by ratings in hierarchy on outdoor courses and sites
- Display children sites' ratings in site page
- APIv2 : Add 'sector' and 'attachment' fields to Outdoor Site serialization
- Add DISPLAY_COORDS_AS_DECIMALS setting to format coordinates as decimal degrees instead of degrees minutes seconds

Expand Down
11 changes: 10 additions & 1 deletion geotrek/api/locale/fr/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -415,4 +415,13 @@ msgid "Only return sites that are at the top of the hierarchy and have no parent
msgstr "Ne retourner que les sites qui sont au sommet de la hiérarchie des sites et n'ont pas de parent. Activable par n'importe quelle chaîne de charactères."

msgid "Root sites only"
msgstr "Sites racine seulement"
msgstr "Sites racine seulement"

msgid "Filter by one or more ratings id, comma-separated."
msgstr "Filtrer par un ou plusieurs id de cotation, séparés par des virgules."

msgid "Filter by one or more practices id, comma-separated. Return sites that have theses practices OR have at least one child site that does."
msgstr "Filtrer par un ou plusieurs id de pratique, séparés par des virgules. Retourne les sites qui ont ces pratiques OU des sites enfants avec ces pratiques."

msgid "Filter by one or more ratings id, comma-separated. Return sites that have theses ratings OR have at least one child site that does."
msgstr "Filtrer par un ou plusieurs id de cotation, séparés par des virgules. Retourne les sites qui ont ces cotations OU des sites enfants avec ces cotations."
177 changes: 177 additions & 0 deletions geotrek/api/tests/test_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -3173,3 +3173,180 @@ def test_course_type_list_returns_published_in_language(self):
self.assertNotIn(self.type_with_no_published_course.pk, all_ids)
self.assertNotIn(self.type_with_published_and_not_deleted_course.pk, all_ids)
self.assertIn(self.type_with_published_and_not_deleted_course_with_lang.pk, all_ids)


class OutdoorFilterByRatingsTestCase(BaseApiTest):
""" Test filtering on ratings for outdoor course
"""

@classmethod
def setUpTestData(cls):
cls.site1 = outdoor_factory.SiteFactory()
cls.site2 = outdoor_factory.SiteFactory()
cls.rating_scale = outdoor_factory.RatingScaleFactory(practice=cls.site1.practice)
cls.rating1 = outdoor_factory.RatingFactory(scale=cls.rating_scale)
cls.rating2 = outdoor_factory.RatingFactory(scale=cls.rating_scale)
cls.site1.ratings.set([cls.rating1])
cls.site2.ratings.set([cls.rating2])
cls.course1 = outdoor_factory.CourseFactory()
cls.course2 = outdoor_factory.CourseFactory()
cls.course1.ratings.set([cls.rating1])
cls.course2.ratings.set([cls.rating2])

def test_site_list_ratings_filter(self):
response = self.get_site_list({'ratings': self.rating1.pk})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['count'], 1)
returned_sites = response.json()['results']
all_ids = []
for site in returned_sites:
all_ids.append(site['id'])
self.assertIn(self.site1.pk, all_ids)
self.assertNotIn(self.site2.pk, all_ids)

def test_site_list_ratings_filter2(self):
response = self.get_site_list({'ratings': self.rating2.pk})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['count'], 1)
returned_sites = response.json()['results']
all_ids = []
for site in returned_sites:
all_ids.append(site['id'])
self.assertIn(self.site2.pk, all_ids)
self.assertNotIn(self.site1.pk, all_ids)

def test_site_list_ratings_filter3(self):
response = self.get_site_list({'ratings': f"{self.rating1.pk},{self.rating2.pk}"})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['count'], 2)
returned_sites = response.json()['results']
all_ids = []
for site in returned_sites:
all_ids.append(site['id'])
self.assertIn(self.site1.pk, all_ids)
self.assertIn(self.site2.pk, all_ids)

def test_course_list_ratings_filter(self):
response = self.get_course_list({'ratings': f"{self.rating1.pk},{self.rating2.pk}"})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['count'], 2)
returned_sites = response.json()['results']
all_ids = []
for site in returned_sites:
all_ids.append(site['id'])
self.assertIn(self.course1.pk, all_ids)
self.assertIn(self.course2.pk, all_ids)

def test_course_list_ratings_filter2(self):
response = self.get_course_list({'ratings': self.rating2.pk})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['count'], 1)
returned_sites = response.json()['results']
all_ids = []
for site in returned_sites:
all_ids.append(site['id'])
self.assertNotIn(self.course1.pk, all_ids)
self.assertIn(self.course2.pk, all_ids)


class OutdoorFilterBySuperPracticesTestCase(BaseApiTest):
""" Test APIV2 filtering on ratings on sites
"""

@classmethod
def setUpTestData(cls):
cls.practice1 = outdoor_factory.PracticeFactory()
cls.practice2 = outdoor_factory.PracticeFactory()
cls.practice3 = outdoor_factory.PracticeFactory()
cls.practice3 = outdoor_factory.PracticeFactory()
cls.practice4 = outdoor_factory.PracticeFactory()
cls.site1 = outdoor_factory.SiteFactory(practice=cls.practice1)
cls.site2 = outdoor_factory.SiteFactory(practice=cls.practice2, parent=cls.site1)
cls.site3 = outdoor_factory.SiteFactory(practice=cls.practice3, parent=cls.site2)
cls.site4 = outdoor_factory.SiteFactory(practice=cls.practice4, parent=cls.site2)

def test_filter_practice_in_tree_hierarchy(self):
response = self.get_site_list({'practices_in_hierarchy': self.practice1.pk})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['count'], 1)
returned_types = response.json()['results']
all_ids = []
for type in returned_types:
all_ids.append(type['id'])
self.assertIn(self.site1.pk, all_ids)
self.assertNotIn(self.site2.pk, all_ids)
self.assertNotIn(self.site3.pk, all_ids)
self.assertNotIn(self.site4.pk, all_ids)

def test_filter_practice_in_tree_hierarchy2(self):
response = self.get_site_list({'practices_in_hierarchy': self.practice3.pk})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['count'], 3)
returned_types = response.json()['results']
all_ids = []
for type in returned_types:
all_ids.append(type['id'])
self.assertIn(self.site1.pk, all_ids)
self.assertIn(self.site2.pk, all_ids)
self.assertIn(self.site3.pk, all_ids)
self.assertNotIn(self.site4.pk, all_ids)


class OutdoorFilterBySuperRatingsTestCase(BaseApiTest):
""" Test APIV2 filtering on ratings on sites in hierarchy
"""

@classmethod
def setUpTestData(cls):
cls.site1 = outdoor_factory.SiteFactory()
cls.rating_scale = outdoor_factory.RatingScaleFactory(practice=cls.site1.practice)
cls.rating1 = outdoor_factory.RatingFactory(scale=cls.rating_scale)
cls.rating2 = outdoor_factory.RatingFactory(scale=cls.rating_scale)
cls.rating3 = outdoor_factory.RatingFactory(scale=cls.rating_scale)
cls.rating4 = outdoor_factory.RatingFactory(scale=cls.rating_scale)
cls.site2 = outdoor_factory.SiteFactory(parent=cls.site1)
cls.site3 = outdoor_factory.SiteFactory(parent=cls.site2)
cls.site4 = outdoor_factory.SiteFactory(parent=cls.site2)
cls.site1.ratings.set([cls.rating1])
cls.site2.ratings.set([cls.rating2])
cls.site3.ratings.set([cls.rating3])
cls.site4.ratings.set([cls.rating4])

def test_filter_ratings_in_tree_hierarchy(self):
response = self.get_site_list({'ratings_in_hierarchy': self.rating1.pk})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['count'], 1)
returned_types = response.json()['results']
all_ids = []
for type in returned_types:
all_ids.append(type['id'])
self.assertIn(self.site1.pk, all_ids)
self.assertNotIn(self.site2.pk, all_ids)
self.assertNotIn(self.site3.pk, all_ids)
self.assertNotIn(self.site4.pk, all_ids)

def test_filter_ratings_in_tree_hierarchy2(self):
response = self.get_site_list({'ratings_in_hierarchy': self.rating3.pk})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['count'], 3)
returned_types = response.json()['results']
all_ids = []
for type in returned_types:
all_ids.append(type['id'])
self.assertIn(self.site1.pk, all_ids)
self.assertIn(self.site2.pk, all_ids)
self.assertIn(self.site3.pk, all_ids)
self.assertNotIn(self.site4.pk, all_ids)

def test_filter_ratings_in_tree_hierarchy3(self):
response = self.get_site_list({'ratings_in_hierarchy': f"{self.rating3.pk}, {self.rating4.pk}"})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['count'], 4)
returned_types = response.json()['results']
all_ids = []
for type in returned_types:
all_ids.append(type['id'])
self.assertIn(self.site1.pk, all_ids)
self.assertIn(self.site2.pk, all_ids)
self.assertIn(self.site3.pk, all_ids)
self.assertIn(self.site4.pk, all_ids)
48 changes: 48 additions & 0 deletions geotrek/api/v2/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -717,12 +717,48 @@ def get_schema_fields(self, view):
)


class OutdoorRatingsFilter(BaseFilterBackend):
def filter_queryset(self, request, queryset, view):
ratings = request.GET.get('ratings')
if ratings:
queryset = queryset.filter(ratings__in=ratings.split(','))
return queryset

def get_schema_fields(self, view):
return (
Field(
name='ratings', required=False, location='query', schema=coreschema.Integer(
title=_("Ratings"),
description=_('Filter by one or more ratings id, comma-separated.')
)
),
)


class GeotrekSiteFilter(BaseFilterBackend):
def filter_queryset(self, request, queryset, view):
root_sites_only = request.GET.get('root_sites_only')
if root_sites_only:
# Being a root node <=> having no parent
queryset = queryset.filter(parent=None)
practices_in_hierarchy = request.GET.get('practices_in_hierarchy')
# TODO Optimize this filter by finding an alternative to queryset iterating
if practices_in_hierarchy:
wanted_practices = set(map(int, practices_in_hierarchy.split(',')))
for site in queryset:
# Exclude if practices in hierarchy don't match any wanted practices
found_practices = site.super_practices_id
if found_practices.isdisjoint(wanted_practices):
queryset = queryset.exclude(id=site.pk)
# TODO Optimize this filter by finding an alternative to queryset iterating
ratings_in_hierarchy = request.GET.get('ratings_in_hierarchy')
if ratings_in_hierarchy:
wanted_ratings = set(map(int, ratings_in_hierarchy.split(',')))
for site in queryset:
# Exclude if ratings in hierarchy don't match any wanted ratings
found_ratings = site.super_ratings_id
if found_ratings.isdisjoint(wanted_ratings):
queryset = queryset.exclude(id=site.pk)
q = request.GET.get('q')
if q:
queryset = queryset.filter(name__icontains=q)
Expand All @@ -742,6 +778,18 @@ def get_schema_fields(self, view):
description=_('Only return sites that are at the top of the hierarchy and have no parent. Use any string to activate.')
)
),
Field(
name='practices_in_hierarchy', required=False, location='query', schema=coreschema.Integer(
title=_("Practices in hierarchy"),
description=_('Filter by one or more practices id, comma-separated. Return sites that have theses practices OR have at least one child site that does.')
)
),
Field(
name='ratings_in_hierarchy', required=False, location='query', schema=coreschema.Integer(
title=_("Ratings in hierarchy"),
description=_('Filter by one or more ratings id, comma-separated. Return sites that have theses ratings OR have at least one child site that does.')
)
),
)


Expand Down
6 changes: 4 additions & 2 deletions geotrek/api/v2/views/outdoor.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ class SiteViewSet(api_viewsets.GeotrekGeometricViewset):
filter_backends = api_viewsets.GeotrekGeometricViewset.filter_backends + (
api_filters.GeotrekSiteFilter,
api_filters.NearbyContentFilter,
api_filters.UpdateOrCreateDateFilter
api_filters.UpdateOrCreateDateFilter,
api_filters.OutdoorRatingsFilter
)
serializer_class = api_serializers.SiteSerializer

Expand Down Expand Up @@ -71,7 +72,8 @@ class CourseViewSet(api_viewsets.GeotrekGeometricViewset):
filter_backends = api_viewsets.GeotrekGeometricViewset.filter_backends + (
api_filters.GeotrekCourseFilter,
api_filters.NearbyContentFilter,
api_filters.UpdateOrCreateDateFilter
api_filters.UpdateOrCreateDateFilter,
api_filters.OutdoorRatingsFilter
)
serializer_class = api_serializers.CourseSerializer

Expand Down
24 changes: 21 additions & 3 deletions geotrek/outdoor/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,12 +218,17 @@ def published_children(self):
return self.children.filter(q)

@property
def super_practices(self):
"Return practices of itself and its descendants"
def super_practices_id(self):
"Return practices of itself and its descendants as ids"
practices_id = self.get_descendants(include_self=True) \
.exclude(practice=None) \
.values_list('practice_id', flat=True)
return Practice.objects.filter(id__in=practices_id) # Sorted and unique
return set(practices_id)

@property
def super_practices(self):
"Return practices of itself and its descendants as objects"
return Practice.objects.filter(id__in=self.super_practices_id) # Sorted and unique

@property
def super_practices_display(self):
Expand All @@ -237,6 +242,19 @@ def super_practices_display(self):
return ", ".join(verbose)
super_practices_verbose_name = _('Practices')

@property
def super_ratings_id(self):
"Return ratings of itself and its descendants as ids"
ratings_id = self.get_descendants(include_self=True) \
.exclude(ratings=None) \
.values_list('ratings', flat=True)
return set(ratings_id)

@property
def super_ratings(self):
"Return ratings of itself and its descendants as objects"
return Rating.objects.filter(id__in=self.super_ratings_id) # Sorted and unique

@property
def super_sectors(self):
"Return sectors of itself and its descendants"
Expand Down
6 changes: 3 additions & 3 deletions geotrek/outdoor/templates/outdoor/site_detail_attributes.html
Original file line number Diff line number Diff line change
Expand Up @@ -109,15 +109,15 @@ <h3>{% trans "Attributes" %}</h3>
<th>{{ object|verbose:"advice" }}</th>
<td>{% if object.advice %}{{ object.advice|safe }}{% else %}<span class="none">{% trans "None" %}</span>{% endif %}</td>
</tr>
{% regroup object.ratings.all by scale as scales_list %}
{% regroup object.super_ratings by scale as scales_list %}
{% for scale in scales_list%}
<tr>
<th>{{ scale.grouper.name }}</th>
<td>{% for rating in scale.list %}
{% if not forloop.last %}
{{ rating.name }},
<span{% if not rating in object.ratings.all %} style="font-style: italic;"{% endif %}>{{ rating.name }},<span>
{% else %}
{{ rating.name }}
<span{% if not rating in object.ratings.all %} style="font-style: italic;"{% endif %}>{{ rating.name }}<span>
{% endif %}
{% endfor %}
</td>
Expand Down

0 comments on commit 9908bf3

Please sign in to comment.