Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
6 changes: 6 additions & 0 deletions app/stac_api/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,12 @@ class Media:
)
}
),
('CF Extension', {
'fields': (
'cf_standard_name',
'unit',
)
}),
)

list_display = ['name', 'collection', 'collection_published']
Expand Down
18 changes: 15 additions & 3 deletions app/stac_api/managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -289,16 +289,28 @@ def filter_by_query(self, query):
for operator in query[attribute]:
value = query[attribute][operator] # get the values given by the operator

if attribute in ["updated", "created"]:
if attribute in ["updated", "created", "cf:standard_name", "unit"]:
prefix = ""
else:
prefix = "properties_"

model_field = attribute.replace(":", "_")

# __eq does not exist, but = does it as well
if operator == 'eq':
query_filter = f"{prefix}{attribute}"
query_filter = f"{prefix}{model_field}"
else:
query_filter = f"{prefix}{attribute}__{operator.lower()}"
query_filter = f"{prefix}{model_field}__{operator.lower()}"
# PB-2354: This is a bug: As it is now, we cannot query by multiple fields
# because we return already for the first operator.
#
# See this test to demonstrate the bug:
#
# tests.tests_10.test_search_endpoint.SearchEndpointTestCF.test_multiple_cf
#
# We keep this is as it is as noone seemed to use that feature so far.
# Instead, we intend to fix it properly by replacing the Query Extension by the
# Filter Extension, which is the recommended solution anyway.
return self.filter(**{query_filter: value})


Expand Down
34 changes: 34 additions & 0 deletions app/stac_api/migrations/0080_item_cf_standard_name_item_unit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Generated by Django 5.2.13 on 2026-08-10 16:43

import pgtrigger.compiler
import pgtrigger.migrations

from django.db import migrations
from django.db import models


class Migration(migrations.Migration):

dependencies = [
('stac_api', '0079_add_stac_extensions_to_meteoschweiz_collections'),
]

operations = [
migrations.AddField(
model_name='item',
name='cf_standard_name',
field=models.CharField(
blank=True,
help_text='Corresponds to the CF Standard Name from the CF Standard Name Table.',
max_length=255,
null=True
),
),
migrations.AddField(
model_name='item',
name='unit',
field=models.CharField(
blank=True, help_text='Corresponds to the CF units.', max_length=255, null=True
),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Generated by Django 5.2.13 on 2026-08-11 15:15

import pgtrigger.compiler
import pgtrigger.migrations

from django.db import migrations
from django.db import models


class Migration(migrations.Migration):

dependencies = [
('stac_api', '0080_item_cf_standard_name_item_unit'),
]

operations = [
pgtrigger.migrations.RemoveTrigger(
model_name='item',
name='update_collection_child_trigger_etag_only',
),
migrations.AddIndex(
model_name='item',
index=models.Index(fields=['cf_standard_name'], name='item_cf_standard_name_idx'),
),
migrations.AddIndex(
model_name='item',
index=models.Index(fields=['unit'], name='item_unit_idx'),
),
pgtrigger.migrations.AddTrigger(
model_name='item',
trigger=pgtrigger.compiler.Trigger(
name='update_collection_child_trigger_etag_only',
sql=pgtrigger.compiler.UpsertTriggerSql(
condition=
'WHEN (OLD."cf_standard_name" IS DISTINCT FROM (NEW."cf_standard_name") OR OLD."collection_id" IS DISTINCT FROM (NEW."collection_id") OR OLD."created" IS DISTINCT FROM (NEW."created") OR OLD."etag" IS DISTINCT FROM (NEW."etag") OR OLD."forecast_duration" IS DISTINCT FROM (NEW."forecast_duration") OR OLD."forecast_horizon" IS DISTINCT FROM (NEW."forecast_horizon") OR OLD."forecast_perturbed" IS DISTINCT FROM (NEW."forecast_perturbed") OR OLD."forecast_reference_datetime" IS DISTINCT FROM (NEW."forecast_reference_datetime") OR OLD."forecast_variable" IS DISTINCT FROM (NEW."forecast_variable") OR OLD."geometry" IS DISTINCT FROM (NEW."geometry") OR OLD."id" IS DISTINCT FROM (NEW."id") OR OLD."name" IS DISTINCT FROM (NEW."name") OR OLD."properties_datetime" IS DISTINCT FROM (NEW."properties_datetime") OR OLD."properties_end_datetime" IS DISTINCT FROM (NEW."properties_end_datetime") OR OLD."properties_expires" IS DISTINCT FROM (NEW."properties_expires") OR OLD."properties_start_datetime" IS DISTINCT FROM (NEW."properties_start_datetime") OR OLD."properties_title" IS DISTINCT FROM (NEW."properties_title") OR OLD."stac_extensions" IS DISTINCT FROM (NEW."stac_extensions") OR OLD."total_data_size" IS DISTINCT FROM (NEW."total_data_size") OR OLD."unit" IS DISTINCT FROM (NEW."unit"))',
declare='DECLARE child stac_api_Item%ROWTYPE;',
func=
"\n-- update related collection\nchild = COALESCE(NEW, OLD);\nUPDATE stac_api_collection SET\n etag = public.gen_random_uuid()\nWHERE id = child.collection_id;\n\nRAISE INFO '%: updated collection.id=% due to Item.id=%',\n TG_NAME, child.collection_id, child.id;\n\nRETURN child;\n",
hash='0308e4b3217a2be3890fde8659088ee924d8c59a',
operation='UPDATE',
pgid='pgtrigger_update_collection_child_trigger_etag_only_bee6a',
table='stac_api_item',
when='AFTER'
)
),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Generated by Django 5.2.13 on 2026-08-11 17:03

import django.contrib.postgres.fields
from django.db import migrations
from django.db import models


class Migration(migrations.Migration):

dependencies = [
('stac_api', '0081_remove_item_update_collection_child_trigger_etag_only_and_more'),
]

operations = [
migrations.AlterField(
model_name='collection',
name='stac_extensions_enabled',
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(
choices=[(
'https://stac-extensions.github.io/timestamps/v1.1.0/schema.json',
'Timestamps'
),
(
'https://stac-extensions.github.io/forecast/v0.2.0/schema.json',
'Forecast'
), ('https://stac-extensions.github.io/cf/v1.0.0/schema.json', 'CF')],
max_length=255
),
blank=True,
default=list,
help_text=
'STAC extensions that are enabled for the Items in this Collection. It defines which STAC extensions are allowed to be used by the Items in this Collection. This field is for internal/admin use only, it is not exposed through the STAC API.',
size=None
),
),
migrations.AlterField(
model_name='item',
name='stac_extensions',
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(
choices=[(
'https://stac-extensions.github.io/timestamps/v1.1.0/schema.json',
'Timestamps'
),
(
'https://stac-extensions.github.io/forecast/v0.2.0/schema.json',
'Forecast'
), ('https://stac-extensions.github.io/cf/v1.0.0/schema.json', 'CF')],
max_length=255
),
blank=True,
default=list,
help_text=
"STAC extensions used by this Item. Defined as JSON schema URLs. Must be a subset of the Collection's stac_extensions_enabled field.",
size=None
),
),
]
14 changes: 14 additions & 0 deletions app/stac_api/models/item.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ class Meta:
models.Index(fields=['forecast_duration'], name='item_fc_duration_idx'),
models.Index(fields=['forecast_variable'], name='item_fc_variable_idx'),
models.Index(fields=['forecast_perturbed'], name='item_fc_perturbed_idx'),
# CF extension properties are "queryable" in the search endpoint
models.Index(fields=['cf_standard_name'], name='item_cf_standard_name_idx'),
models.Index(fields=['unit'], name='item_unit_idx'),
# combination of datetime and start_ and end_datetimes are used in
# managers.py:110 and following
models.Index(
Expand Down Expand Up @@ -212,6 +215,17 @@ class Meta:
"Collection's stac_extensions_enabled field.",
)

cf_standard_name = models.CharField(
Comment thread
asteiner-swisstopo marked this conversation as resolved.
null=True,
blank=True,
max_length=255,
help_text="Corresponds to the CF Standard Name from the CF Standard Name Table."
)

unit = models.CharField(
null=True, blank=True, max_length=255, help_text="Corresponds to the CF units."
)

# Custom Manager that preselects the collection
objects = ItemManager()

Expand Down
7 changes: 7 additions & 0 deletions app/stac_api/serializers/item.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ class ItemsPropertiesSerializer(serializers.Serializer):
forecast_variable = serializers.CharField(required=False, allow_null=True, default=None)
forecast_perturbed = serializers.BooleanField(required=False, allow_null=True, default=None)

cf_standard_name = serializers.CharField(required=False, allow_null=True, default=None)
unit = serializers.CharField(required=False, allow_null=True, default=None)

def to_internal_value(self, data) -> timedelta:
'''Map forecast extension fields with a colon in the name to the corresponding model field.

Expand All @@ -115,6 +118,8 @@ def to_internal_value(self, data) -> timedelta:
'forecast:duration': 'forecast_duration',
'forecast:variable': 'forecast_variable',
'forecast:perturbed': 'forecast_perturbed',
'cf:standard_name': 'cf_standard_name',
'unit': 'unit',
}
data_mapped = copy.deepcopy(data)
for with_colon, with_underscore in fields.items():
Expand All @@ -139,6 +144,8 @@ def to_representation(self, instance):
'forecast_duration': 'forecast:duration',
'forecast_variable': 'forecast:variable',
'forecast_perturbed': 'forecast:perturbed',
'cf_standard_name': 'cf:standard_name',
'unit': 'unit',
}
for with_colon, with_underscore in fields.items():
if with_colon in ret:
Expand Down
25 changes: 14 additions & 11 deletions app/stac_api/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,19 +314,22 @@ def harmonize_post_get_for_search(request):
if 'intersects' in query_param:
query_param['intersects'] = json.loads(query_param['intersects'])

# Forecast properties can only be filtered with method POST.
# Forecast and CF extension properties can only be filtered with method POST.
# Decision was made as `:` need to be url encoded and (at least for now) we do not need to
# support forecast filtering in the GET request.
if 'forecast:reference_datetime' in query_param:
del query_param['forecast:reference_datetime']
if 'forecast:horizon' in query_param:
del query_param['forecast:horizon']
if 'forecast:duration' in query_param:
del query_param['forecast:duration']
if 'forecast:variable' in query_param:
del query_param['forecast:variable']
if 'forecast:perturbed' in query_param:
del query_param['forecast:perturbed']
forecast_properties = [
'forecast:reference_datetime',
'forecast:horizon',
'forecast:duration',
'forecast:variable',
'forecast:perturbed'
]
cf_properties = ['cf:standard_name', 'unit']
properties_to_remove = forecast_properties + cf_properties
for p in properties_to_remove:
if p in query_param:
del query_param[p]

return query_param


Expand Down
3 changes: 3 additions & 0 deletions app/stac_api/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class StacExtension(TextChoices):
'''STAC extensions that can be enabled for a Collection and used by its Items.'''
TIMESTAMPS = 'https://stac-extensions.github.io/timestamps/v1.1.0/schema.json', 'Timestamps'
FORECAST = 'https://stac-extensions.github.io/forecast/v0.2.0/schema.json', 'Forecast'
CF = 'https://stac-extensions.github.io/cf/v1.0.0/schema.json', 'CF'


def validate_stac_extensions_enabled(stac_extensions, collection):
Expand Down Expand Up @@ -76,6 +77,8 @@ def validate_item_properties_extensions(properties, stac_extensions):
'forecast:duration': StacExtension.FORECAST,
'forecast:variable': StacExtension.FORECAST,
'forecast:perturbed': StacExtension.FORECAST,
'cf:standard_name': StacExtension.CF,
'unit': StacExtension.CF,
}

errors = {}
Expand Down
2 changes: 1 addition & 1 deletion app/stac_api/validators_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ def __init__(self):
# Note: if these values are adapted, don't forget to
# update the spec accordingly.
self.queriable_date_fields = ['created', 'updated']
self.queriable_str_fields = ['title']
self.queriable_str_fields = ['title', 'cf:standard_name', 'unit']

def validate(self, request):
'''Validates the request of the search endpoint
Expand Down
Loading
Loading