diff --git a/docs/changelog.rst b/docs/changelog.rst index 11be6aef18..5d1c84bd19 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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 diff --git a/geotrek/api/locale/fr/LC_MESSAGES/django.po b/geotrek/api/locale/fr/LC_MESSAGES/django.po index 336d226bc1..c3545928ac 100644 --- a/geotrek/api/locale/fr/LC_MESSAGES/django.po +++ b/geotrek/api/locale/fr/LC_MESSAGES/django.po @@ -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" \ No newline at end of file +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." \ No newline at end of file diff --git a/geotrek/api/tests/test_v2.py b/geotrek/api/tests/test_v2.py index 504668d481..d019bcc65e 100644 --- a/geotrek/api/tests/test_v2.py +++ b/geotrek/api/tests/test_v2.py @@ -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) diff --git a/geotrek/api/v2/filters.py b/geotrek/api/v2/filters.py index df5b639c21..a9edd8174a 100644 --- a/geotrek/api/v2/filters.py +++ b/geotrek/api/v2/filters.py @@ -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) @@ -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.') + ) + ), ) diff --git a/geotrek/api/v2/views/outdoor.py b/geotrek/api/v2/views/outdoor.py index 06dbf6708e..4586b6d924 100644 --- a/geotrek/api/v2/views/outdoor.py +++ b/geotrek/api/v2/views/outdoor.py @@ -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 @@ -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 diff --git a/geotrek/outdoor/models.py b/geotrek/outdoor/models.py index 0e02af1545..b04e29fa29 100644 --- a/geotrek/outdoor/models.py +++ b/geotrek/outdoor/models.py @@ -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): @@ -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" diff --git a/geotrek/outdoor/templates/outdoor/site_detail_attributes.html b/geotrek/outdoor/templates/outdoor/site_detail_attributes.html index 8b63c5d7a1..4e082cc2c9 100644 --- a/geotrek/outdoor/templates/outdoor/site_detail_attributes.html +++ b/geotrek/outdoor/templates/outdoor/site_detail_attributes.html @@ -109,15 +109,15 @@

{% trans "Attributes" %}

{{ object|verbose:"advice" }} {% if object.advice %}{{ object.advice|safe }}{% else %}{% trans "None" %}{% endif %} - {% regroup object.ratings.all by scale as scales_list %} + {% regroup object.super_ratings by scale as scales_list %} {% for scale in scales_list%} {{ scale.grouper.name }} {% for rating in scale.list %} {% if not forloop.last %} - {{ rating.name }}, + {{ rating.name }}, {% else %} - {{ rating.name }} + {{ rating.name }} {% endif %} {% endfor %}