Repository: GeotrekCE/Geotrek-admin

Issue:
API V2 - Filtrer sur plusieurs structures
Actuellement on ne renseigne qu'une seule structure à chaque objet (rando, contenu touristiques...).
Du coup dans les filtres de l'API V2, on ne peut filtrer la liste des randos en passant qu'un seul ID de structure.
Mais le filtre devrait être indépendant de la typologique Simple ou Multiple d'un champs.

Donc il faudrait plutôt basculer le filtre des routes /trek, /touristiccontent (et autres ?) en permettant de passer une liste de structures.
Idem sur d'autres filtres ?


Discussion:
> Idem sur d'autres filtres ?

Je vois aussi type (POI) et route (Trek). Autre « souci » mineur : il y a un mélange de singulier et de pluriel dans les noms des filtres.
OK pour les autres filtres et l'harmonisation de leur nommage.

Resolving patch:
diff --git a/geotrek/api/v2/filters.py b/geotrek/api/v2/filters.py
--- a/geotrek/api/v2/filters.py
+++ b/geotrek/api/v2/filters.py
@@ -1,11 +1,9 @@
-import operator
 from datetime import date
-from functools import reduce
 import coreschema
 
 from coreapi.document import Field
 from django.conf import settings
-from django.contrib.gis.db.models import Union
+from django.contrib.gis.db.models import Collect
 from django.db.models.query_utils import Q
 from django.utils.translation import gettext as _
 from rest_framework.filters import BaseFilterBackend
@@ -26,17 +24,17 @@ def get_schema_fields(self, view):
             Field(
                 name='language', required=False, location='query', schema=coreschema.String(
                     title=_("Language"),
-                    description=_("Set language for translation. Default: all. Example: fr")
+                    description=_("Set language for translation. Can be all or a two-letters language code.")
                 )
             ), Field(
                 name='fields', required=False, location='query', schema=coreschema.String(
                     title=_("Fields"),
-                    description=_("Limit required fields to increase performances. Example: id,url,geometry")
+                    description=_("Limit required fields to increase performances. Example: id,url,geometry.")
                 )
             ), Field(
                 name='omit', required=False, location='query', schema=coreschema.String(
                     title=_("Omit"),
-                    description=_("Omit specified fields to increase performance. Example: url,category")
+                    description=_("Omit specified fields to increase performance. Example: url,category.")
                 )
             ),
         )
@@ -51,7 +49,7 @@ def get_schema_fields(self, view):
             Field(
                 name='format', required=False, location='query', schema=coreschema.String(
                     title=_("Format"),
-                    description=_("Set output format (json / geojson). Default: json. Example: geojson")
+                    description=_("Set output format (json / geojson). Default: json. Example: geojson.")
                 )
             ),
         )
@@ -67,8 +65,8 @@ def get_schema_fields(self, view):
             Field(
                 name=self.bbox_param, required=False, location='query', schema=coreschema.String(
                     title=_("In bbox"),
-                    description=_('Filter elements contained in bbox formatted like SW-lng,SW-lat,NE-lng,NE-lat.'
-                                  'Example: 1.15,46.1,1.56,47.6')
+                    description=_('Filter by a bounding box formatted like W-lng,S-lat,E-lng,N-lat (WGS84).'
+                                  'Example: 1.15,46.1,1.56,47.6.')
                 )
             ),
         )
@@ -84,12 +82,12 @@ def get_schema_fields(self, view):
             Field(
                 name=self.dist_param, required=False, location='query', schema=coreschema.Integer(
                     title=_("Distance"),
-                    description=_('Max distance in meters between point and elements')
+                    description=_('Filter by maximum distance in meters between a point and elements.')
                 )
             ), Field(
                 name=self.point_param, required=False, location='query', schema=coreschema.String(
                     title=_("Point"),
-                    description=_('Reference point to compute distance LNG,LAT. Example: 1.2563,46.5214'),
+                    description=_('Reference point to compute distance (WGS84). Example: lng,lat.'),
                 )
             ),
         )
@@ -112,17 +110,16 @@ def filter_queryset(self, request, queryset, view):
             # the published field of the queryset model is translated
             if language == 'all':
                 # no language specified. Check for all.
-                filters = list()
+                q = Q()
                 for lang in settings.MODELTRANSLATION_LANGUAGES:
                     field_name = 'published_{}'.format(lang)
                     if field_name in associated_published_fields:
-                        filters.append(Q(**{field_name: True}))
-                if filters:
-                    qs = qs.filter(reduce(operator.or_, filters))
+                        q |= Q(**{field_name: True})
+                qs = qs.filter(q)
             else:
                 # one language is specified
                 field_name = 'published_{}'.format(language)
-                qs = qs.filter(Q(**{field_name: True}))
+                qs = qs.filter(**{field_name: True})
 
         return qs
 
@@ -130,22 +127,27 @@ def filter_queryset(self, request, queryset, view):
 class GeotrekSensitiveAreaFilter(BaseFilterBackend):
     def filter_queryset(self, request, queryset, view):
         qs = queryset
-        practices = request.GET.get('practices', '')
+        practices = request.GET.get('practices')
         if practices:
             qs = qs.filter(species__practices__id__in=practices.split(','))
-        structure = request.GET.get('structure', '')
-        if structure:
-            qs = qs.filter(structure_id=structure)
-        period = request.GET.get('period', '')
+        structures = request.GET.get('structures')
+        if structures:
+            qs = qs.filter(structure__in=structures.split(','))
+        period = request.GET.get('period')
         if not period:
             qs = qs.filter(**{'species__period{:02}'.format(date.today().month): True})
         elif period == 'any':
-            qs = qs.filter(reduce(operator.or_, (Q(**{'species__period{:02}'.format(m): True}) for m in range(1, 13))))
+            q = Q()
+            for m in range(1, 13):
+                q |= Q(**{'species__period{:02}'.format(m): True})
+            qs = qs.filter(q)
         elif period == 'ignore':
             pass
         else:
-            months = [int(m) for m in period.split(',')]
-            qs = qs.filter(reduce(operator.or_, (Q(**{'species__period{:02}'.format(m): True}) for m in months)))
+            q = Q()
+            for m in [int(m) for m in period.split(',')]:
+                q |= Q(**{'species__period{:02}'.format(m): True})
+            qs = qs.filter(q)
         return qs.distinct()
 
     def get_schema_fields(self, view):
@@ -153,19 +155,19 @@ def get_schema_fields(self, view):
             Field(
                 name='period', required=False, location='query', schema=coreschema.String(
                     title=_("Period"),
-                    description=_('Period of occupancy. Month numbers (1-12) separated by comas.'
+                    description=_('Filter by period of occupancy. Month numbers (1-12), comma-separated.'
                                   ' any = occupied at any time in the year. ignore = occupied or not.'
-                                  ' Example: 7,8 for july and august')
+                                  ' Example: 7,8 for july and august.')
                 )
             ), Field(
                 name='practices', required=False, location='query', schema=coreschema.String(
                     title=_("Practices"),
-                    description=_('Practices ids separated by comas. Example: 1,3')
+                    description=_('Filter by one or more practice id, comma-separated.')
                 )
             ), Field(
-                name='structure', required=False, location='query', schema=coreschema.Integer(
-                    title=_("Structure"),
-                    description=_('Structure id. Example: 5')
+                name='structures', required=False, location='query', schema=coreschema.Integer(
+                    title=_("Structures"),
+                    description=_('Filter by one or more structure id, comma-separated.')
                 )
             ),
         )
@@ -174,9 +176,9 @@ def get_schema_fields(self, view):
 class GeotrekPOIFilter(BaseFilterBackend):
     def filter_queryset(self, request, queryset, view):
         qs = queryset
-        type = request.GET.get('type', None)
-        if type is not None:
-            qs = qs.filter(type=type)
+        types = request.GET.get('types', None)
+        if types is not None:
+            qs = qs.filter(type__in=types.split(','))
         trek = request.GET.get('trek', None)
         if trek is not None:
             qs = Topology.overlapping(Trek.objects.get(pk=trek), qs)
@@ -185,14 +187,14 @@ def filter_queryset(self, request, queryset, view):
     def get_schema_fields(self, view):
         return (
             Field(
-                name='type', required=False, location='query', schema=coreschema.Integer(
-                    title=_("Type"),
-                    description=_("Limit to POIs that contains a specific POI Type")
+                name='types', required=False, location='query', schema=coreschema.Integer(
+                    title=_("Types"),
+                    description=_("Filter by one or more type id, comma-separated.")
                 )
             ), Field(
                 name='trek', required=False, location='query', schema=coreschema.Integer(
                     title=_("Trek"),
-                    description=_("Id of a trek. It will show only the POIs related to this trek")
+                    description=_("Filter by a trek id. It will show only the POIs related to this trek.")
                 )
             ),
         )
@@ -201,51 +203,35 @@ def get_schema_fields(self, view):
 class GeotrekTouristicContentFilter(BaseFilterBackend):
     def filter_queryset(self, request, queryset, view):
         qs = queryset
-        trek = request.GET.get('near_trek', None)
-        if trek is not None:
-            contents_intersecting = intersecting(qs, Trek.objects.get(pk=trek))
+        near_trek = request.GET.get('near_trek')
+        if near_trek:
+            contents_intersecting = intersecting(qs, Trek.objects.get(pk=near_trek))
             qs = contents_intersecting.order_by('id')
-        categories = request.GET.get('categories', None)
-        if categories is not None:
-            list_categories = [int(p) for p in categories.split(',')]
-            qs = qs.filter(category__in=list_categories)
-        types = request.GET.get('types', None)
-        if types is not None:
-            list_types = [int(p) for p in types.split(',')]
-            qs = qs.filter(Q(type1__in=list_types) | Q(type2__in=list_types))
-        city = request.GET.get('city', None)
-        if city is not None:
-            cities_list = city.split(',')
-            union_geom = City.objects.filter(
-                reduce(operator.or_, (Q(**{'code': c}) for c in cities_list))
-            ).aggregate(Union('geom'))['geom__union']
-            if union_geom:
-                qs = qs.filter(geom__intersects=union_geom)
-            else:
-                qs = qs.none()
-        district = request.GET.get('district', None)
-        if district is not None:
-            districts_list = [int(d) for d in district.split(',')]
-            union_geom = District.objects.filter(
-                reduce(operator.or_, (Q(**{'pk': d}) for d in districts_list))
-            ).aggregate(Union('geom'))['geom__union']
-            if union_geom:
-                qs = qs.filter(geom__intersects=union_geom)
-            else:
-                qs = qs.none()
-        structure = request.GET.get('structure', None)
-        if structure is not None:
-            qs = qs.filter(structure__pk=structure)
-        themes = request.GET.get('theme', None)
-        if themes is not None:
-            list_themes = [int(t) for t in themes.split(',')]
-            qs = qs.filter(themes__in=list_themes)
-        portals = request.GET.get('portal', None)
-        if portals is not None:
-            list_portals = [int(p) for p in portals.split(',')]
-            qs = qs.filter(portal__in=list_portals)
-        q = request.GET.get('q', None)
-        if q is not None:
+        categories = request.GET.get('categories')
+        if categories:
+            qs = qs.filter(category__in=categories.split(','))
+        types = request.GET.get('types')
+        if types:
+            qs = qs.filter(Q(type1__in=types.split(',')) | Q(type2__in=types.split(',')))
+        cities = request.GET.get('cities')
+        if cities:
+            cities_geom = City.objects.filter(code__in=cities.split(',')).aggregate(Collect('geom'))['geom__collect']
+            qs = qs.filter(geom__intersects=cities_geom) if cities_geom else qs.none()
+        districts = request.GET.get('districts')
+        if districts:
+            districts_geom = District.objects.filter(id__in=districts.split(',')).aggregate(Collect('geom'))['geom__collect']
+            qs = qs.filter(geom__intersects=districts_geom) if districts_geom else qs.none()
+        structures = request.GET.get('structures')
+        if structures:
+            qs = qs.filter(structure__in=structures.split(','))
+        themes = request.GET.get('themes')
+        if themes:
+            qs = qs.filter(themes__in=themes.split(','))
+        portals = request.GET.get('portals')
+        if portals:
+            qs = qs.filter(portal__in=portals.split(','))
+        q = request.GET.get('q')
+        if q:
             qs = qs.filter(
                 Q(name__icontains=q) | Q(description__icontains=q) | Q(description_teaser__icontains=q)
             )
@@ -256,47 +242,47 @@ def get_schema_fields(self, view):
             Field(
                 name='near_trek', required=False, location='query', schema=coreschema.Integer(
                     title=_("Near trek"),
-                    description=_("Id of a trek. It will show only the touristics contents related to this trek")
+                    description=_("Filter by a trek id. It will show only the touristics contents related to this trek.")
                 )
             ), Field(
                 name='categories', required=False, location='query', schema=coreschema.Integer(
                     title=_("Categories"),
-                    description=_("Id of the touristic content categories to filter by, separated by comma")
+                    description=_("Filter by one or more category id, comma-separated.")
                 )
             ), Field(
                 name='types', required=False, location='query', schema=coreschema.Integer(
                     title=_("Types"),
-                    description=_("Id of the touristic content types to filter by, separated by comma")
+                    description=_("Filter by one or more types id, comma-separated.")
                 )
             ), Field(
-                name='city', required=False, location='query', schema=coreschema.String(
-                    title=_("City"),
-                    description=_('Id of a city to filter by. Can be multiple cities split by a comma. Example: 31006,31555,31017')
+                name='cities', required=False, location='query', schema=coreschema.String(
+                    title=_("Cities"),
+                    description=_('Filter by one or more city id, comma-separated.')
                 )
             ), Field(
-                name='district', required=False, location='query', schema=coreschema.String(
-                    title=_("District"),
-                    description=_('Id of a district to filter by. Can be multiple districts split by a comma. Example: 2273,2270')
+                name='districts', required=False, location='query', schema=coreschema.String(
+                    title=_("Districts"),
+                    description=_('Filter by one or more district id, comma-separated.')
                 )
             ), Field(
-                name='structure', required=False, location='query', schema=coreschema.Integer(
-                    title=_("Structure"),
-                    description=_('Id of a structure to filter by')
+                name='structures', required=False, location='query', schema=coreschema.Integer(
+                    title=_("Structures"),
+                    description=_('Filter by one or more structure id, comma-separated.')
                 )
             ), Field(
-                name='theme', required=False, location='query', schema=coreschema.String(
-                    title=_("Theme"),
-                    description=_('Id of the themes to filter by, separated by commas. Example: 9,14')
+                name='themes', required=False, location='query', schema=coreschema.String(
+                    title=_("Themes"),
+                    description=_('Filter by one or more themes id, comma-separated.')
                 )
             ), Field(
-                name='portal', required=False, location='query', schema=coreschema.String(
-                    title=_("Portal"),
-                    description=_('Id of the portals to filter by, separated by commas. Example: 3,7')
+                name='portals', required=False, location='query', schema=coreschema.String(
+                    title=_("Portals"),
+                    description=_('Filter by one or more portal id, comma-separated.')
                 )
             ), Field(
                 name='q', required=False, location='query', schema=coreschema.String(
                     title=_("Query string"),
-                    description=_('Search field that returns touristic contents containing data matching the string')
+                    description=_('Filter by some case-insensitive text contained in name, description teaser or description.')
                 )
             )
         )
@@ -305,82 +291,65 @@ def get_schema_fields(self, view):
 class GeotrekTrekQueryParamsFilter(BaseFilterBackend):
     def filter_queryset(self, request, queryset, view):
         qs = queryset
-        duration_min = request.GET.get('duration_min', None)
-        if duration_min is not None:
+        duration_min = request.GET.get('duration_min')
+        if duration_min:
             qs = qs.filter(duration__gte=duration_min)
-        duration_max = request.GET.get('duration_max', None)
-        if duration_max is not None:
+        duration_max = request.GET.get('duration_max')
+        if duration_max:
             qs = qs.filter(duration__lte=duration_max)
-        length_min = request.GET.get('length_min', None)
-        if length_min is not None:
+        length_min = request.GET.get('length_min')
+        if length_min:
             qs = qs.filter(length__gte=length_min)
-        length_max = request.GET.get('length_max', None)
-        if length_max is not None:
+        length_max = request.GET.get('length_max')
+        if length_max:
             qs = qs.filter(length__lte=length_max)
-        difficulty_min = request.GET.get('difficulty_min', None)
-        if difficulty_min is not None:
+        difficulty_min = request.GET.get('difficulty_min')
+        if difficulty_min:
             qs = qs.filter(difficulty__cirkwi_level__gte=difficulty_min)
-        difficulty_max = request.GET.get('difficulty_max', None)
-        if difficulty_max is not None:
+        difficulty_max = request.GET.get('difficulty_max')
+        if difficulty_max:
             qs = qs.filter(difficulty__cirkwi_level__lte=difficulty_max)
-        ascent_min = request.GET.get('ascent_min', None)
-        if ascent_min is not None:
+        ascent_min = request.GET.get('ascent_min')
+        if ascent_min:
             qs = qs.filter(ascent__gte=ascent_min)
-        ascent_max = request.GET.get('ascent_max', None)
-        if ascent_max is not None:
+        ascent_max = request.GET.get('ascent_max')
+        if ascent_max:
             qs = qs.filter(ascent__lte=ascent_max)
-        city = request.GET.get('city', None)
-        if city is not None:
-            cities_list = city.split(',')
-            union_geom = City.objects.filter(
-                reduce(operator.or_, (Q(**{'code': c}) for c in cities_list))
-            ).aggregate(Union('geom'))['geom__union']
-            if union_geom:
-                qs = qs.filter(geom__intersects=union_geom)
-            else:
-                qs = qs.none()
-        district = request.GET.get('district', None)
-        if district is not None:
-            districts_list = [int(d) for d in district.split(',')]
-            union_geom = District.objects.filter(
-                reduce(operator.or_, (Q(**{'pk': d}) for d in districts_list))
-            ).aggregate(Union('geom'))['geom__union']
-            if union_geom:
-                qs = qs.filter(geom__intersects=union_geom)
-            else:
-                qs = qs.none()
-        structure = request.GET.get('structure', None)
-        if structure is not None:
-            qs = qs.filter(structure__pk=structure)
-        accessibilities = request.GET.get('accessibility', None)
-        if accessibilities is not None:
-            list_accessibilities = [int(a) for a in accessibilities.split(',')]
-            qs = qs.filter(accessibilities__in=list_accessibilities)
-        themes = request.GET.get('theme', None)
-        if themes is not None:
-            list_themes = [int(t) for t in themes.split(',')]
-            qs = qs.filter(themes__in=list_themes)
-        portals = request.GET.get('portal', None)
-        if portals is not None:
-            list_portals = [int(p) for p in portals.split(',')]
-            qs = qs.filter(portal__in=list_portals)
-        route = request.GET.get('route', None)
-        if route is not None:
-            qs = qs.filter(route__pk=route)
-        labels = request.GET.get('label', None)
-        if labels is not None:
-            list_labels = [int(label) for label in labels.split(',')]
-            qs = qs.filter(portal__in=list_labels)
-        q = request.GET.get('q', None)
-        if q is not None:
+        cities = request.GET.get('cities')
+        if cities:
+            cities_geom = City.objects.filter(code__in=cities.split(',')).aggregate(Collect('geom'))['geom__collect']
+            qs = qs.filter(geom__intersects=cities_geom) if cities_geom else qs.none()
+        districts = request.GET.get('districts')
+        if districts:
+            districts_geom = District.objects.filter(id__in=districts.split(',')).aggregate(Collect('geom'))['geom__collect']
+            qs = qs.filter(geom__intersects=districts_geom) if districts_geom else qs.none()
+        structures = request.GET.get('structures')
+        if structures:
+            qs = qs.filter(structure__in=structures.split(','))
+        accessibilities = request.GET.get('accessibilities')
+        if accessibilities:
+            qs = qs.filter(accessibilities__in=accessibilities.split(','))
+        themes = request.GET.get('themes')
+        if themes:
+            qs = qs.filter(themes__in=themes.split(','))
+        portals = request.GET.get('portals')
+        if portals:
+            qs = qs.filter(portal__in=portals.split(','))
+        route = request.GET.get('routes')
+        if route:
+            qs = qs.filter(route__in=route.split(','))
+        labels = request.GET.get('labels')
+        if labels:
+            qs = qs.filter(portal__in=labels.split(','))
+        practices = request.GET.get('practices')
+        if practices:
+            qs = qs.filter(practice__in=practices.split(','))
+        q = request.GET.get('q')
+        if q:
             qs = qs.filter(
                 Q(name__icontains=q) | Q(description__icontains=q)
                 | Q(description_teaser__icontains=q) | Q(ambiance__icontains=q)
             )
-        practices = request.GET.get('practice', None)
-        if practices is not None:
-            list_practices = [int(p) for p in practices.split(',')]
-            qs = qs.filter(practice__in=list_practices)
         return qs
 
     def get_schema_fields(self, view):
@@ -388,92 +357,92 @@ def get_schema_fields(self, view):
             Field(
                 name='duration_min', required=False, location='query', schema=coreschema.Number(
                     title=_("Duration min"),
-                    description=_('Set minimum duration for a trek')
+                    description=_('Filter by minimum duration (hours).')
                 )
             ), Field(
                 name='duration_max', required=False, location='query', schema=coreschema.Number(
                     title=_("Duration max"),
-                    description=_('Set maximum duration for a trek')
+                    description=_('Filter by maximum duration (hours).')
                 )
             ), Field(
                 name='length_min', required=False, location='query', schema=coreschema.Integer(
                     title=_("Length min"),
-                    description=_('Set minimum length for a trek')
+                    description=_('Filter by minimum length (meters).')
                 )
             ), Field(
                 name='length_max', required=False, location='query', schema=coreschema.Integer(
                     title=_("Length max"),
-                    description=_('Set maximum length for a trek')
+                    description=_('Filter by maximum length (meters).')
                 )
             ), Field(
                 name='difficulty_min', required=False, location='query', schema=coreschema.Integer(
                     title=_("Difficulty min"),
-                    description=_('Set minimum difficulty for a trek. Difficulty usually goes from 1 (very easy) to 4 (difficult)')
+                    description=_('Filter by minimum difficulty level (id).')
                 )
             ), Field(
                 name='difficulty_max', required=False, location='query', schema=coreschema.Integer(
                     title=_("Difficulty max"),
-                    description=_('Set maximum difficulty for a trek. Difficulty usually goes from 1 (very easy) to 4 (difficult)')
+                    description=_('Filter by maximum difficulty level (id).')
                 )
             ), Field(
                 name='ascent_min', required=False, location='query', schema=coreschema.Integer(
                     title=_("Ascent min"),
-                    description=_('Set minimum ascent for a trek')
+                    description=_('Filter by minimum ascent (meters).')
                 )
             ), Field(
                 name='ascent_max', required=False, location='query', schema=coreschema.Integer(
                     title=_("Ascent max"),
-                    description=_('Set maximum ascent for a trek')
+                    description=_('Filter by maximum ascent (meters).')
                 )
             ), Field(
-                name='city', required=False, location='query', schema=coreschema.String(
-                    title=_("City"),
-                    description=_('Id of a city to filter by. Can be multiple cities split by a comma. Example: 31006,31555,31017')
+                name='cities', required=False, location='query', schema=coreschema.String(
+                    title=_("Cities"),
+                    description=_('Filter by one or more city id, comma-separated.')
                 )
             ), Field(
-                name='district', required=False, location='query', schema=coreschema.String(
-                    title=_("District"),
-                    description=_('Id of a district to filter by. Can be multiple districts split by a comma. Example: 2273,2270')
+                name='districts', required=False, location='query', schema=coreschema.String(
+                    title=_("Districts"),
+                    description=_('Filter by one or more district id, comma-separated.')
                 )
             ), Field(
-                name='structure', required=False, location='query', schema=coreschema.Integer(
-                    title=_("Structure"),
-                    description=_('Id of a structure to filter by')
+                name='structures', required=False, location='query', schema=coreschema.Integer(
+                    title=_("Structures"),
+                    description=_('Filter by one or more structure id, comma-separated.')
                 )
             ), Field(
-                name='accessibility', required=False, location='query', schema=coreschema.String(
-                    title=_("Accessibility"),
-                    description=_('Id of the accessibilities to filter by, separated by commas. Example: 1,2')
+                name='accessibilities', required=False, location='query', schema=coreschema.String(
+                    title=_("Accessibilities"),
+                    description=_('Filter by one or more accessibility id, comma-separated.')
                 )
             ), Field(
-                name='theme', required=False, location='query', schema=coreschema.String(
-                    title=_("Theme"),
-                    description=_('Id of the themes to filter by, separated by commas. Example: 9,14')
+                name='themes', required=False, location='query', schema=coreschema.String(
+                    title=_("Themes"),
+                    description=_('Filter by one or more theme id, comma-separated.')
                 )
             ), Field(
-                name='portal', required=False, location='query', schema=coreschema.String(
-                    title=_("Portal"),
-                    description=_('Id of the portals to filter by, separated by commas. Example: 3,7')
+                name='portals', required=False, location='query', schema=coreschema.String(
+                    title=_("Portals"),
+                    description=_('Filter by one or more portal id, comma-separateds.')
                 )
             ), Field(
-                name='route', required=False, location='query', schema=coreschema.Integer(
-                    title=_("Route"),
-                    description=_('Id of the type of route to filter by')
+                name='routes', required=False, location='query', schema=coreschema.Integer(
+                    title=_("Routes"),
+                    description=_('Filter by one or more route id, comma-separated.')
                 )
             ), Field(
-                name='label', required=False, location='query', schema=coreschema.String(
-                    title=_("Label"),
-                    description=_('Id of the trek label to filter by, separated by commas')
+                name='labels', required=False, location='query', schema=coreschema.String(
+                    title=_("Labels"),
+                    description=_('Filter by one or more label id, comma-separated.')
                 )
             ), Field(
-                name='q', required=False, location='query', schema=coreschema.String(
-                    title=_("Query string"),
-                    description=_('Search field that returns treks containing data matching the string')
+                name='practices', required=False, location='query', schema=coreschema.String(
+                    title=_("Practices"),
+                    description=_('Filter by one or more practice id, comma-separated.')
                 )
             ), Field(
-                name='practice', required=False, location='query', schema=coreschema.String(
-                    title=_("Practice"),
-                    description=_('Id of the trek practice to filter by, separated by commas')
+                name='q', required=False, location='query', schema=coreschema.String(
+                    title=_("Query string"),
+                    description=_('Filter by some case-insensitive text contained in name, description, description teaser or ambiance.')
                 )
             ),
         )
@@ -481,8 +450,8 @@ def get_schema_fields(self, view):
 
 class GeotrekSiteFilter(BaseFilterBackend):
     def filter_queryset(self, request, queryset, view):
-        q = request.GET.get('q', None)
-        if q is not None:
+        q = request.GET.get('q')
+        if q:
             queryset = queryset.filter(name__icontains=q)
         return queryset
 
@@ -491,7 +460,7 @@ def get_schema_fields(self, view):
             Field(
                 name='q', required=False, location='query', schema=coreschema.String(
                     title=_("Query string"),
-                    description=_('Search field that returns sites containing data matching the string')
+                    description=_('Filter by some case-insensitive text contained in name.')
                 )
             ),
         )
@@ -499,25 +468,25 @@ def get_schema_fields(self, view):
 
 class GeotrekRatingScaleFilter(BaseFilterBackend):
     def filter_queryset(self, request, queryset, view):
-        q = request.GET.get('q', None)
-        if q is not None:
+        practices = request.GET.get('practices')
+        if practices:
+            queryset = queryset.filter(practice__in=practices.split(','))
+        q = request.GET.get('q')
+        if q:
             queryset = queryset.filter(name__icontains=q)
-        practice = request.GET.get('practice', None)
-        if practice is not None:
-            queryset = queryset.filter(practice__pk=practice)
         return queryset
 
     def get_schema_fields(self, view):
         return (
             Field(
-                name='q', required=False, location='query', schema=coreschema.String(
-                    title=_("Query string"),
-                    description=_('Search field that returns rating scales containing data matching the string')
+                name='practices', required=False, location='query', schema=coreschema.Integer(
+                    title=_("Practices"),
+                    description=_('Filter by one or more practice id, comma-separated.')
                 )
             ), Field(
-                name='practice', required=False, location='query', schema=coreschema.Integer(
-                    title=_("Practice"),
-                    description=_('Id of a practice to filter by')
+                name='q', required=False, location='query', schema=coreschema.String(
+                    title=_("Query string"),
+                    description=_('Filter by some case-insensitive text contained in name.')
                 )
             ),
         )
@@ -525,27 +494,27 @@ def get_schema_fields(self, view):
 
 class GeotrekRatingFilter(BaseFilterBackend):
     def filter_queryset(self, request, queryset, view):
+        scale = request.GET.get('scale', None)
+        if scale is not None:
+            queryset = queryset.filter(scale__pk=scale)
         q = request.GET.get('q', None)
         if q is not None:
             queryset = queryset.filter(
                 Q(name__icontains=q) | Q(description__icontains=q) | Q(scale__name__icontains=q)
             )
-        scale = request.GET.get('scale', None)
-        if scale is not None:
-            queryset = queryset.filter(scale__pk=scale)
         return queryset
 
     def get_schema_fields(self, view):
         return (
             Field(
-                name='q', required=False, location='query', schema=coreschema.String(
-                    title=_("Query string"),
-                    description=_('Search field that returns rating scales containing data matching the string')
-                )
-            ), Field(
                 name='scale', required=False, location='query', schema=coreschema.Integer(
                     title=_("Rating scale"),
-                    description=_('Id of a rating scale to filter by')
+                    description=_('Filter by a rating scale id.')
+                )
+            ), Field(
+                name='q', required=False, location='query', schema=coreschema.String(
+                    title=_("Query string"),
+                    description=_('Filter by some case-insensitive text contained in name, scale name or description.')
                 )
             ),
         )


Test patch:
diff --git a/geotrek/api/tests/test_v2.py b/geotrek/api/tests/test_v2.py
--- a/geotrek/api/tests/test_v2.py
+++ b/geotrek/api/tests/test_v2.py
@@ -370,16 +370,16 @@ def test_trek_list_filters(self):
             'difficulty_max': '3',
             'ascent_min': '150',
             'ascent_max': '1000',
-            'city': '31000',
-            'district': '420',
-            'structure': '8',
-            'accessibility': '4',
-            'theme': '15',
-            'portal': '16',
-            'label': '23',
-            'route': '68',
+            'cities': ['31000'],
+            'districts': ['420'],
+            'structures': ['8'],
+            'accessibilities': ['4'],
+            'themes': ['15'],
+            'portals': ['16'],
+            'labels': ['23'],
+            'routes': ['68'],
+            'practices': ['1'],
             'q': 'test string',
-            'practice': '1'
         })
         #  test response code
         self.assertEqual(response.status_code, 200)
@@ -390,8 +390,8 @@ def test_trek_list_filters(self):
 
     def test_trek_list_filters_inexistant_zones(self):
         response = self.get_trek_list({
-            'city': '99999',
-            'district': '999',
+            'cities': ['99999'],
+            'districts': ['999'],
         })
         #  test response code
         self.assertEqual(response.status_code, 200)
@@ -401,7 +401,7 @@ def test_trek_list_filters_inexistant_zones(self):
         self.assertEqual(len(json_response.get('results')), 0)
 
     def test_trek_city(self):
-        response = self.get_trek_list({'city': self.city.pk})
+        response = self.get_trek_list({'cities': [self.city.pk]})
         self.assertEqual(len(response.json()['results']), 15)
 
     def test_tour_list(self):
@@ -579,7 +579,7 @@ def test_poi_list(self):
         self.assertEqual(sorted(json_response.get('features')[0].get('properties').keys()),
                          POI_PROPERTIES_GEOJSON_STRUCTURE)
 
-        response = self.get_poi_list({'type': self.poi_type.pk, 'trek': self.treks[0].pk})
+        response = self.get_poi_list({'types': [self.poi_type.pk], 'trek': self.treks[0].pk})
         self.assertEqual(response.status_code, 200)
 
     def test_poi_type(self):
@@ -644,39 +644,39 @@ def test_touristiccontent_near_trek(self):
         self.assertEqual(len(response.json()['results']), 1)
 
     def test_touristiccontent_categories(self):
-        response = self.get_touristiccontent_list({'categories': self.content.category.pk})
+        response = self.get_touristiccontent_list({'categories': [self.content.category.pk]})
         self.assertEqual(len(response.json()['results']), 1)
 
     def test_touristiccontent_types(self):
-        response = self.get_touristiccontent_list({'types': self.content.type1.all()[0].pk})
+        response = self.get_touristiccontent_list({'types': [self.content.type1.all()[0].pk]})
         self.assertEqual(len(response.json()['results']), 1)
 
     def test_touristiccontent_city(self):
-        response = self.get_touristiccontent_list({'city': self.city.pk})
+        response = self.get_touristiccontent_list({'cities': [self.city.pk]})
         self.assertEqual(len(response.json()['results']), 1)
 
     def test_touristiccontent_inexistant_city(self):
-        response = self.get_touristiccontent_list({'city': '99999'})
+        response = self.get_touristiccontent_list({'cities': ['99999']})
         self.assertEqual(len(response.json()['results']), 0)
 
     def test_touristiccontent_district(self):
-        response = self.get_touristiccontent_list({'district': self.district.pk})
+        response = self.get_touristiccontent_list({'districts': [self.district.pk]})
         self.assertEqual(len(response.json()['results']), 1)
 
     def test_touristiccontent_inexistant_district(self):
-        response = self.get_touristiccontent_list({'district': 99999})
+        response = self.get_touristiccontent_list({'districts': [99999]})
         self.assertEqual(len(response.json()['results']), 0)
 
     def test_touristiccontent_structure(self):
-        response = self.get_touristiccontent_list({'structure': self.content.structure.pk})
+        response = self.get_touristiccontent_list({'structures': [self.content.structure.pk]})
         self.assertEqual(len(response.json()['results']), 1)
 
     def test_touristiccontent_theme(self):
-        response = self.get_touristiccontent_list({'theme': self.content.themes.all()[0].pk})
+        response = self.get_touristiccontent_list({'themes': [self.content.themes.all()[0].pk]})
         self.assertEqual(len(response.json()['results']), 1)
 
     def test_touristiccontent_portal(self):
-        response = self.get_touristiccontent_list({'portal': self.content.portal.all()[0].pk})
+        response = self.get_touristiccontent_list({'portals': [self.content.portal.all()[0].pk]})
         self.assertEqual(len(response.json()['results']), 1)
 
     def test_touristiccontent_q(self):
@@ -838,11 +838,11 @@ def setUpTestData(cls):
 
     def test_schema_fields(self):
         response = self.client.get('/api/v2/?format=openapi')
-        self.assertContains(response, 'Filter elements contained in bbox formatted like SW-lng,SW-lat,NE-lng,NE-lat')
-        self.assertContains(response, 'Set language for translation. Default: all. Example: fr')
-        self.assertContains(response, 'Set minimum difficulty for a trek. Difficulty usually goes ')
-        self.assertContains(response, 'Reference point to compute distance LNG,LAT')
-        self.assertContains(response, 'Practices ids separated by comas.')
+        self.assertContains(response, 'Filter by a bounding box formatted like W-lng,S-lat,E-lng,N-lat (WGS84).')
+        self.assertContains(response, 'Set language for translation. Can be all or a two-letters language code.')
+        self.assertContains(response, 'Filter by minimum difficulty level (id).')
+        self.assertContains(response, 'Reference point to compute distance (WGS84). Example: lng,lat.')
+        self.assertContains(response, 'Filter by one or more practice id, comma-separated.')
 
     def test_swagger_ui(self):
         response = self.client.get('/api/v2/')
@@ -897,7 +897,7 @@ def test_filter_q(self):
             self.assertEqual(scale['name']['en'], 'AAA')
 
     def test_filter_practice(self):
-        response = self.client.get('/api/v2/ratingscale/?practice={}'.format(self.practice2.pk))
+        response = self.client.get('/api/v2/ratingscale/?practices={}'.format(self.practice2.pk))
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.json()['count'], 2)
         for scale in response.json()['results']:
diff --git a/geotrek/sensitivity/tests/test_views.py b/geotrek/sensitivity/tests/test_views.py
--- a/geotrek/sensitivity/tests/test_views.py
+++ b/geotrek/sensitivity/tests/test_views.py
@@ -220,7 +220,7 @@ def test_list_sportpractice(self):
     def test_filters_structure(self):
         other_structure = StructureFactory.create(name='other')
         self.sensitivearea_other_structure = SensitiveAreaFactory.create(structure=other_structure)
-        url = '/api/v2/sensitivearea/?format=json&language=en&period=ignore&structure={}'.format(other_structure.pk)
+        url = '/api/v2/sensitivearea/?format=json&language=en&period=ignore&structures={}'.format(other_structure.pk)
         response = self.client.get(url)
         self.assertEqual(response.json()['count'], 1)
         self.assertEqual(response.json()['results'][0]['name'], self.sensitivearea_other_structure.species.name)