Skip to content

Commit 14b7bec

Browse files
Merge pull request #663 from geoadmin/feat-pb-1677-service-stac-implement-the-sort-extension
PB-1677: sort extension - Add sortby query param
2 parents f92a2f5 + 1e6142a commit 14b7bec

14 files changed

Lines changed: 943 additions & 213 deletions

app/stac_api/pagination.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,25 @@ def get_page_size(self, request):
171171
)
172172

173173

174-
class GetPostCursorPagination(CursorPagination):
174+
class SortedCursorPagination(CursorPagination):
175+
'''Pagination class that supports sorting via the sortby parameter'''
176+
ordering = 'name'
177+
178+
def get_ordering(self, request, queryset, view):
179+
'''Get the ordering from the sortby query parameter if present.'''
180+
sort_fields = getattr(view, 'sort_fields', None)
181+
if not sort_fields:
182+
return super().get_ordering(request, queryset, view)
183+
184+
ordering = []
185+
for model_field, is_ascending in sort_fields:
186+
if not is_ascending:
187+
model_field = "-" + model_field
188+
ordering.append(model_field)
189+
return ordering
190+
191+
192+
class GetPostCursorPagination(SortedCursorPagination):
175193
'''Pagination to be used for the GET/POST /search endpoint where the
176194
pagination is either in query or in payload depending on the method.
177195
'''

app/stac_api/utils.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from django.conf import settings
2121
from django.contrib.gis.geos import Point
2222
from django.contrib.gis.geos import Polygon
23+
from django.core.exceptions import ValidationError
2324
from django.core.management import call_command
2425
from django.core.management.base import BaseCommand
2526
from django.core.management.base import CommandParser
@@ -629,3 +630,125 @@ def parse_cache_control_header(cache_control_header):
629630
parts = [i.strip() for i in cache_control_header.split(',')]
630631
args = {i.split('=')[0].strip(): i.split('=')[-1].strip() for i in parts if i}
631632
return {k: True if v == k else v for k, v in args.items()}
633+
634+
635+
# Maps sortby parameter values to Django model field
636+
SORTABLE_FIELDS = {
637+
'id': 'name',
638+
'collection': 'collection__name',
639+
'datetime': 'properties_datetime',
640+
'title': 'properties_title',
641+
'created': 'created',
642+
'updated': 'updated',
643+
}
644+
645+
646+
def parse_sortby_get(sortby_param, sortable_fields):
647+
'''Parse and validate the GET (string) format of the sortby parameter.
648+
649+
The sortby parameter is a comma-separated string of fields prefixed with '+'
650+
(ascending, default) or '-' (descending).
651+
652+
Example: "-created,title".
653+
654+
Args:
655+
sortby_param: string
656+
Comma-separated list of fields prefixed with '+' or '-'
657+
sortable_fields: dict
658+
Mapping of allowed sortby field names to Django model fields
659+
660+
Returns:
661+
list: List of tuples (field, direction) where `direction` is True for ascending,
662+
False for descending and `field` is the Django model field corresponding to
663+
the given input field. Returns empty list if sortby_param is None or empty.
664+
665+
Raises:
666+
ValidationError: If an invalid field is specified
667+
'''
668+
if not sortby_param:
669+
return []
670+
671+
sort_fields = []
672+
for sort_field in sortby_param.split(','):
673+
sort_field = sort_field.strip()
674+
if not sort_field:
675+
continue
676+
677+
if sort_field.startswith('-'):
678+
is_ascending = False
679+
field_name = sort_field[1:]
680+
elif sort_field.startswith('+'):
681+
is_ascending = True
682+
field_name = sort_field[1:]
683+
else:
684+
is_ascending = True
685+
field_name = sort_field
686+
687+
internal_field = _resolve_sort_field(field_name, sortable_fields)
688+
sort_fields.append((internal_field, is_ascending))
689+
return sort_fields
690+
691+
692+
def parse_sortby_post(sortby_param, sortable_fields):
693+
'''Parse the POST (list of objects) format of the sortby parameter.
694+
695+
The sortby parameter in the request body is a list of objects with a 'field'
696+
and a 'direction' ('asc' or 'desc') property.
697+
698+
Example: [{"field": "created", "direction": "desc"}].
699+
700+
Args:
701+
sortby_param: list
702+
List of {"field": ..., "direction": ...} objects
703+
sortable_fields: dict
704+
Mapping of allowed sortby field names to Django model fields
705+
706+
Returns:
707+
list: List of tuples (field, direction) where `direction` is True for ascending,
708+
False for descending and `field` is the Django model field corresponding to
709+
the given input field. Returns empty list if sortby_param is None or empty.
710+
711+
Raises:
712+
ValidationError: If an invalid field or direction is specified
713+
'''
714+
sort_fields = []
715+
for sort_item in sortby_param:
716+
if not isinstance(sort_item, dict) or 'field' not in sort_item:
717+
raise ValidationError("Each sortby entry must be an object with a 'field' property")
718+
field_name = sort_item['field']
719+
direction = str(sort_item.get('direction', 'asc')).lower()
720+
if direction == 'asc':
721+
is_ascending = True
722+
elif direction == 'desc':
723+
is_ascending = False
724+
else:
725+
raise ValidationError(
726+
f"Invalid sort direction '{direction}'. "
727+
f"Allowed values are: 'asc', 'desc'"
728+
)
729+
internal_field = _resolve_sort_field(field_name, sortable_fields)
730+
sort_fields.append((internal_field, is_ascending))
731+
return sort_fields
732+
733+
734+
def _resolve_sort_field(field_name, sortable_fields):
735+
'''Resolve a sortby field name to its Django model field, validating it.
736+
737+
Args:
738+
field_name: string
739+
The field name provided in the sortby parameter
740+
sortable_fields: dict
741+
Mapping of allowed sortby field names to Django model fields
742+
743+
Returns:
744+
string: The Django model field corresponding to the given field name
745+
746+
Raises:
747+
ValidationError: If the field name is not allowed for sorting
748+
'''
749+
if field_name not in sortable_fields:
750+
raise ValidationError(
751+
f"Invalid sort field '{field_name}'. "
752+
f"Allowed fields are: {', '.join(sortable_fields.keys())}"
753+
)
754+
return sortable_fields[field_name]

app/stac_api/validators_serializer.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,19 @@
33

44
from django.contrib.gis.gdal.error import GDALException
55
from django.contrib.gis.geos import GEOSGeometry
6+
from django.core.exceptions import ValidationError as DjangoValidationError
67
from django.db import IntegrityError
78
from django.db import transaction
89
from django.utils.translation import gettext_lazy as _
910

1011
from rest_framework import serializers
1112

13+
from stac_api.utils import SORTABLE_FIELDS
1214
from stac_api.utils import fromisoformat
1315
from stac_api.utils import geometry_from_bbox
1416
from stac_api.utils import harmonize_post_get_for_search
17+
from stac_api.utils import parse_sortby_get
18+
from stac_api.utils import parse_sortby_post
1519
from stac_api.validators import validate_geometry
1620

1721
logger = logging.getLogger(__name__)
@@ -72,7 +76,7 @@ def __init__(self):
7276
self.queriable_date_fields = ['created', 'updated']
7377
self.queriable_str_fields = ['title', 'cf:standard_name', 'unit']
7478

75-
def validate(self, request):
79+
def validate(self, request): # pylint: disable=too-many-branches
7680
'''Validates the request of the search endpoint
7781
7882
This function validates the request of the search endpoint. As a simplification the
@@ -93,6 +97,15 @@ def validate(self, request):
9397
if request.method == "POST":
9498
self.validate_query_parameters_post_search(query_param)
9599

100+
try:
101+
if 'sortby' in query_param:
102+
if request.method == "POST":
103+
request.sort_fields = parse_sortby_post(query_param['sortby'], SORTABLE_FIELDS)
104+
else:
105+
request.sort_fields = parse_sortby_get(query_param['sortby'], SORTABLE_FIELDS)
106+
except DjangoValidationError as error:
107+
self.errors['sortby'] = _(error.message)
108+
96109
if 'bbox' in query_param:
97110
self.validate_bbox(query_param['bbox'])
98111
if 'datetime' in query_param:
@@ -366,6 +379,7 @@ def validate_query_parameters_post_search(self, query_param):
366379
"limit",
367380
"cursor",
368381
"query",
382+
"sortby",
369383
"forecast:reference_datetime",
370384
"forecast:horizon",
371385
"forecast:duration",

app/stac_api/views/general.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ class SearchList(generics.GenericAPIView, mixins.ListModelMixin):
6969
# search overall collections and that the item name is only unique within a collection
7070
# we must use the pk as ordering attribute, otherwise the cursor pagination will not work
7171
ordering = ['pk']
72+
# Resolved sortby fields. None means no sortby was provided and the default ordering is used.
73+
sort_fields = None
7274

7375
# pylint: disable=too-many-branches
7476
def get_queryset(self):
@@ -121,6 +123,7 @@ def list(self, request, *args, **kwargs):
121123

122124
validate_search_request = ValidateSearchRequest()
123125
validate_search_request.validate(request) # validate the search request
126+
self.sort_fields = getattr(request, 'sort_fields', None)
124127
queryset = self.filter_queryset(self.get_queryset())
125128

126129
page = self.paginate_queryset(queryset)

app/stac_api/views/item.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,15 @@
1818
from stac_api.models.collection import Collection
1919
from stac_api.models.item import Asset
2020
from stac_api.models.item import Item
21+
from stac_api.pagination import SortedCursorPagination
2122
from stac_api.serializers.item import AssetSerializer
2223
from stac_api.serializers.item import ItemDetailSerializer
2324
from stac_api.serializers.item import ItemListSerializer
2425
from stac_api.serializers.item import ItemSerializer
2526
from stac_api.serializers.utils import get_relation_links
27+
from stac_api.utils import SORTABLE_FIELDS
2628
from stac_api.utils import get_asset_path
29+
from stac_api.utils import parse_sortby_get
2730
from stac_api.validators_view import validate_collection
2831
from stac_api.validators_view import validate_item
2932
from stac_api.validators_view import validate_renaming
@@ -89,8 +92,10 @@ def get_asset_etag(request, *args, **kwargs):
8992

9093
class ItemsList(generics.GenericAPIView):
9194
serializer_class = ItemSerializer
92-
ordering = ['name']
95+
pagination_class = SortedCursorPagination
9396
name = 'items-list' # this name must match the name in urls.py
97+
# Resolved sortby fields. None means no sortby was provided and the default ordering is used.
98+
sort_fields = None
9499

95100
def get_queryset(self):
96101
# filter based on the url
@@ -122,6 +127,11 @@ def get_queryset(self):
122127

123128
def list(self, request, *args, **kwargs):
124129
validate_collection(self.kwargs)
130+
131+
sort_fields = request.query_params.get('sortby')
132+
if sort_fields:
133+
self.sort_fields = parse_sortby_get(sort_fields, SORTABLE_FIELDS)
134+
125135
queryset = self.filter_queryset(self.get_queryset())
126136
page = self.paginate_queryset(queryset)
127137
if page is not None:

app/tests/tests_10/test_items_endpoint.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,102 @@ def test_items_endpoint_non_existing_collection(self):
188188
response = self.client.get(f"/{STAC_BASE_V}/collections/non-existing-collection/items")
189189
self.assertStatusCode(404, response)
190190

191+
def test_sortby_id_ascending(self):
192+
response = self.client.get(
193+
f"/{STAC_BASE_V}/collections/{self.collection.name}/items?sortby=id"
194+
)
195+
196+
self.assertStatusCode(200, response)
197+
item_ids = [item['id'] for item in response.json()['features']]
198+
self.assertEqual(item_ids, ["item-1", "item-2"])
199+
200+
def test_sortby_id_descending(self):
201+
response = self.client.get(
202+
f"/{STAC_BASE_V}/collections/{self.collection.name}/items?sortby=-id"
203+
)
204+
self.assertStatusCode(200, response)
205+
item_ids = [item['id'] for item in response.json()['features']]
206+
self.assertEqual(item_ids, ["item-2", "item-1"])
207+
208+
def test_sortby_properties_datetime(self):
209+
210+
self.factory.create_item_sample(
211+
self.collection,
212+
name='item-dt-1',
213+
properties_datetime=timezone.now() + timedelta(days=2),
214+
db_create=True
215+
)
216+
self.factory.create_item_sample(
217+
self.collection,
218+
name='item-dt-2',
219+
properties_datetime=timezone.now() + timedelta(days=1),
220+
db_create=True
221+
)
222+
self.factory.create_item_sample(
223+
self.collection,
224+
name='item-dt-3',
225+
properties_datetime=timezone.now() + timedelta(days=3),
226+
db_create=True
227+
)
228+
229+
# Test ascending sort
230+
response = self.client.get(
231+
f"/{STAC_BASE_V}/collections/{self.collection.name}/items?sortby=datetime"
232+
)
233+
234+
self.assertStatusCode(200, response)
235+
item_ids = [item['id'] for item in response.json()['features']]
236+
self.assertEqual(item_ids, ['item-1', 'item-2', 'item-dt-2', 'item-dt-1', 'item-dt-3'])
237+
238+
# Test descending sort
239+
response = self.client.get(
240+
f"/{STAC_BASE_V}/collections/{self.collection.name}/items?sortby=-datetime"
241+
)
242+
self.assertStatusCode(200, response)
243+
item_ids = [item['id'] for item in response.json()['features']]
244+
self.assertEqual(item_ids, ['item-dt-3', 'item-dt-1', 'item-dt-2', 'item-1', 'item-2'])
245+
246+
def test_sortby_multiple_fields(self):
247+
tomorrow = timezone.now() + timedelta(days=1)
248+
self.factory.create_item_sample(
249+
self.collection,
250+
name='item-multi-1',
251+
properties_datetime=tomorrow,
252+
properties_title='AAA',
253+
db_create=True
254+
)
255+
self.factory.create_item_sample(
256+
self.collection,
257+
name='item-multi-2',
258+
properties_datetime=tomorrow,
259+
properties_title='BBB',
260+
db_create=True
261+
)
262+
self.factory.create_item_sample(
263+
self.collection,
264+
name='item-multi-3',
265+
properties_datetime=tomorrow + timedelta(days=1),
266+
properties_title='CCC',
267+
db_create=True
268+
)
269+
270+
# Sort by datetime ascending, then by title descending
271+
response = self.client.get(
272+
(f"/{STAC_BASE_V}/collections/{self.collection.name}/items?"
273+
f"sortby=datetime,-title")
274+
)
275+
self.assertStatusCode(200, response)
276+
item_ids = [item['id'] for item in response.json()['features']]
277+
self.assertEqual(
278+
item_ids, ['item-1', 'item-2', 'item-multi-2', 'item-multi-1', 'item-multi-3']
279+
)
280+
281+
def test_sortby_invalid_field(self):
282+
response = self.client.get(
283+
f"/{STAC_BASE_V}/collections/{self.collection.name}/items?sortby=expires"
284+
)
285+
self.assertStatusCode(400, response)
286+
191287

192288
class ItemsDatetimeQueryEndpointTestCase(StacBaseTestCase):
193289

0 commit comments

Comments
 (0)