Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion app/stac_api/pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,25 @@ def get_page_size(self, request):
)


class GetPostCursorPagination(CursorPagination):
class SortedCursorPagination(CursorPagination):
'''Pagination class that supports sorting via the sortby parameter'''
ordering = 'name'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this declaration and initialisation correct? below it's defined as an array (which by default is empty), here it's a string..?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch but it should be fine. According to the DRF docs both works:

  • ordering = This should be a string, or list of strings, indicating the field against which the cursor based pagination will be applied. For example: ordering = 'slug'. Defaults to -created. This value may also be overridden by using OrderingFilter on the view.


def get_ordering(self, request, queryset, view):
'''Get the ordering from the sortby query parameter if present.'''
sort_fields = getattr(view, 'sort_fields', None)
if not sort_fields:
return super().get_ordering(request, queryset, view)

ordering = []
for model_field, is_ascending in sort_fields:
if not is_ascending:
model_field = "-" + model_field
ordering.append(model_field)
return ordering


class GetPostCursorPagination(SortedCursorPagination):
'''Pagination to be used for the GET/POST /search endpoint where the
pagination is either in query or in payload depending on the method.
'''
Expand Down
123 changes: 123 additions & 0 deletions app/stac_api/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from django.conf import settings
from django.contrib.gis.geos import Point
from django.contrib.gis.geos import Polygon
from django.core.exceptions import ValidationError
from django.core.management import call_command
from django.core.management.base import BaseCommand
from django.core.management.base import CommandParser
Expand Down Expand Up @@ -629,3 +630,125 @@ def parse_cache_control_header(cache_control_header):
parts = [i.strip() for i in cache_control_header.split(',')]
args = {i.split('=')[0].strip(): i.split('=')[-1].strip() for i in parts if i}
return {k: True if v == k else v for k, v in args.items()}


# Maps sortby parameter values to Django model field
SORTABLE_FIELDS = {
'id': 'name',
'collection': 'collection__name',
'datetime': 'properties_datetime',
'title': 'properties_title',
'created': 'created',
'updated': 'updated',
}


def parse_sortby_get(sortby_param, sortable_fields):
'''Parse and validate the GET (string) format of the sortby parameter.

The sortby parameter is a comma-separated string of fields prefixed with '+'
(ascending, default) or '-' (descending).

Example: "-created,title".

Args:
sortby_param: string
Comma-separated list of fields prefixed with '+' or '-'
sortable_fields: dict
Mapping of allowed sortby field names to Django model fields

Returns:
list: List of tuples (field, direction) where `direction` is True for ascending,
False for descending and `field` is the Django model field corresponding to
the given input field. Returns empty list if sortby_param is None or empty.

Raises:
ValidationError: If an invalid field is specified
'''
if not sortby_param:
return []

sort_fields = []
for sort_field in sortby_param.split(','):
sort_field = sort_field.strip()
if not sort_field:
continue

if sort_field.startswith('-'):
is_ascending = False
field_name = sort_field[1:]
elif sort_field.startswith('+'):
is_ascending = True
field_name = sort_field[1:]
else:
is_ascending = True
field_name = sort_field

internal_field = _resolve_sort_field(field_name, sortable_fields)
sort_fields.append((internal_field, is_ascending))
return sort_fields


def parse_sortby_post(sortby_param, sortable_fields):
'''Parse the POST (list of objects) format of the sortby parameter.

The sortby parameter in the request body is a list of objects with a 'field'
and a 'direction' ('asc' or 'desc') property.

Example: [{"field": "created", "direction": "desc"}].

Args:
sortby_param: list
List of {"field": ..., "direction": ...} objects
sortable_fields: dict
Mapping of allowed sortby field names to Django model fields

Returns:
list: List of tuples (field, direction) where `direction` is True for ascending,
False for descending and `field` is the Django model field corresponding to
the given input field. Returns empty list if sortby_param is None or empty.

Raises:
ValidationError: If an invalid field or direction is specified
'''
sort_fields = []
for sort_item in sortby_param:
if not isinstance(sort_item, dict) or 'field' not in sort_item:
raise ValidationError("Each sortby entry must be an object with a 'field' property")
field_name = sort_item['field']
direction = str(sort_item.get('direction', 'asc')).lower()
if direction == 'asc':
is_ascending = True
elif direction == 'desc':
is_ascending = False
else:
raise ValidationError(
f"Invalid sort direction '{direction}'. "
f"Allowed values are: 'asc', 'desc'"
)
internal_field = _resolve_sort_field(field_name, sortable_fields)
sort_fields.append((internal_field, is_ascending))
return sort_fields


def _resolve_sort_field(field_name, sortable_fields):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the other helper functions are not _* prefixed... why this one?

@asteiner-swisstopo asteiner-swisstopo Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because it is only used within the same module.

According to PEP8:

  • _single_leading_underscore: weak “internal use” indicator. E.g. from M import * does not import objects whose names start with an underscore.

The other new functions are used elsewhere, so not just used "internally".

I think that is a common way to mark helper functions, so I keep it as it is.

'''Resolve a sortby field name to its Django model field, validating it.

Args:
field_name: string
The field name provided in the sortby parameter
sortable_fields: dict
Mapping of allowed sortby field names to Django model fields

Returns:
string: The Django model field corresponding to the given field name

Raises:
ValidationError: If the field name is not allowed for sorting
'''
if field_name not in sortable_fields:
raise ValidationError(
f"Invalid sort field '{field_name}'. "
f"Allowed fields are: {', '.join(sortable_fields.keys())}"
)
return sortable_fields[field_name]
16 changes: 15 additions & 1 deletion app/stac_api/validators_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,19 @@

from django.contrib.gis.gdal.error import GDALException
from django.contrib.gis.geos import GEOSGeometry
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import IntegrityError
from django.db import transaction
from django.utils.translation import gettext_lazy as _

from rest_framework import serializers

from stac_api.utils import SORTABLE_FIELDS
from stac_api.utils import fromisoformat
from stac_api.utils import geometry_from_bbox
from stac_api.utils import harmonize_post_get_for_search
from stac_api.utils import parse_sortby_get
from stac_api.utils import parse_sortby_post
from stac_api.validators import validate_geometry

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

def validate(self, request):
def validate(self, request): # pylint: disable=too-many-branches
'''Validates the request of the search endpoint

This function validates the request of the search endpoint. As a simplification the
Expand All @@ -93,6 +97,15 @@ def validate(self, request):
if request.method == "POST":
self.validate_query_parameters_post_search(query_param)

try:
if 'sortby' in query_param:
if request.method == "POST":
request.sort_fields = parse_sortby_post(query_param['sortby'], SORTABLE_FIELDS)
else:
request.sort_fields = parse_sortby_get(query_param['sortby'], SORTABLE_FIELDS)
except DjangoValidationError as error:
self.errors['sortby'] = _(error.message)

if 'bbox' in query_param:
self.validate_bbox(query_param['bbox'])
if 'datetime' in query_param:
Expand Down Expand Up @@ -366,6 +379,7 @@ def validate_query_parameters_post_search(self, query_param):
"limit",
"cursor",
"query",
"sortby",
"forecast:reference_datetime",
"forecast:horizon",
"forecast:duration",
Expand Down
3 changes: 3 additions & 0 deletions app/stac_api/views/general.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ class SearchList(generics.GenericAPIView, mixins.ListModelMixin):
# search overall collections and that the item name is only unique within a collection
# we must use the pk as ordering attribute, otherwise the cursor pagination will not work
ordering = ['pk']
# Resolved sortby fields. None means no sortby was provided and the default ordering is used.
sort_fields = None

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

validate_search_request = ValidateSearchRequest()
validate_search_request.validate(request) # validate the search request
self.sort_fields = getattr(request, 'sort_fields', None)
queryset = self.filter_queryset(self.get_queryset())

page = self.paginate_queryset(queryset)
Expand Down
12 changes: 11 additions & 1 deletion app/stac_api/views/item.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,15 @@
from stac_api.models.collection import Collection
from stac_api.models.item import Asset
from stac_api.models.item import Item
from stac_api.pagination import SortedCursorPagination
from stac_api.serializers.item import AssetSerializer
from stac_api.serializers.item import ItemDetailSerializer
from stac_api.serializers.item import ItemListSerializer
from stac_api.serializers.item import ItemSerializer
from stac_api.serializers.utils import get_relation_links
from stac_api.utils import SORTABLE_FIELDS
from stac_api.utils import get_asset_path
from stac_api.utils import parse_sortby_get
from stac_api.validators_view import validate_collection
from stac_api.validators_view import validate_item
from stac_api.validators_view import validate_renaming
Expand Down Expand Up @@ -89,8 +92,10 @@ def get_asset_etag(request, *args, **kwargs):

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

def get_queryset(self):
# filter based on the url
Expand Down Expand Up @@ -122,6 +127,11 @@ def get_queryset(self):

def list(self, request, *args, **kwargs):
validate_collection(self.kwargs)

sort_fields = request.query_params.get('sortby')
if sort_fields:
self.sort_fields = parse_sortby_get(sort_fields, SORTABLE_FIELDS)

queryset = self.filter_queryset(self.get_queryset())
page = self.paginate_queryset(queryset)
if page is not None:
Expand Down
96 changes: 96 additions & 0 deletions app/tests/tests_10/test_items_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,102 @@ def test_items_endpoint_non_existing_collection(self):
response = self.client.get(f"/{STAC_BASE_V}/collections/non-existing-collection/items")
self.assertStatusCode(404, response)

def test_sortby_id_ascending(self):
response = self.client.get(
f"/{STAC_BASE_V}/collections/{self.collection.name}/items?sortby=id"
)

self.assertStatusCode(200, response)
item_ids = [item['id'] for item in response.json()['features']]
self.assertEqual(item_ids, ["item-1", "item-2"])

def test_sortby_id_descending(self):
response = self.client.get(
f"/{STAC_BASE_V}/collections/{self.collection.name}/items?sortby=-id"
)
self.assertStatusCode(200, response)
item_ids = [item['id'] for item in response.json()['features']]
self.assertEqual(item_ids, ["item-2", "item-1"])

def test_sortby_properties_datetime(self):

self.factory.create_item_sample(
self.collection,
name='item-dt-1',
properties_datetime=timezone.now() + timedelta(days=2),
db_create=True
)
self.factory.create_item_sample(
self.collection,
name='item-dt-2',
properties_datetime=timezone.now() + timedelta(days=1),
db_create=True
)
self.factory.create_item_sample(
self.collection,
name='item-dt-3',
properties_datetime=timezone.now() + timedelta(days=3),
db_create=True
)

# Test ascending sort
response = self.client.get(
f"/{STAC_BASE_V}/collections/{self.collection.name}/items?sortby=datetime"
)

self.assertStatusCode(200, response)
item_ids = [item['id'] for item in response.json()['features']]
self.assertEqual(item_ids, ['item-1', 'item-2', 'item-dt-2', 'item-dt-1', 'item-dt-3'])

# Test descending sort
response = self.client.get(
f"/{STAC_BASE_V}/collections/{self.collection.name}/items?sortby=-datetime"
)
self.assertStatusCode(200, response)
item_ids = [item['id'] for item in response.json()['features']]
self.assertEqual(item_ids, ['item-dt-3', 'item-dt-1', 'item-dt-2', 'item-1', 'item-2'])

def test_sortby_multiple_fields(self):
tomorrow = timezone.now() + timedelta(days=1)
self.factory.create_item_sample(
self.collection,
name='item-multi-1',
properties_datetime=tomorrow,
properties_title='AAA',
db_create=True
)
self.factory.create_item_sample(
self.collection,
name='item-multi-2',
properties_datetime=tomorrow,
properties_title='BBB',
db_create=True
)
self.factory.create_item_sample(
self.collection,
name='item-multi-3',
properties_datetime=tomorrow + timedelta(days=1),
properties_title='CCC',
db_create=True
)

# Sort by datetime ascending, then by title descending
response = self.client.get(
(f"/{STAC_BASE_V}/collections/{self.collection.name}/items?"
f"sortby=datetime,-title")
)
self.assertStatusCode(200, response)
item_ids = [item['id'] for item in response.json()['features']]
self.assertEqual(
item_ids, ['item-1', 'item-2', 'item-multi-2', 'item-multi-1', 'item-multi-3']
)

def test_sortby_invalid_field(self):
response = self.client.get(
f"/{STAC_BASE_V}/collections/{self.collection.name}/items?sortby=expires"
)
self.assertStatusCode(400, response)


class ItemsDatetimeQueryEndpointTestCase(StacBaseTestCase):

Expand Down
Loading