Skip to content

Commit 527d3fe

Browse files
Merge pull request #656 from geoadmin/feat-PB-2354-implement-cf-extension
PB-2354: Add CF extension fields
2 parents 1bfd6be + 9b0768f commit 527d3fe

21 files changed

Lines changed: 632 additions & 222 deletions

app/stac_api/admin.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,12 @@ class Media:
326326
)
327327
}
328328
),
329+
('CF Extension', {
330+
'fields': (
331+
'cf_standard_name',
332+
'unit',
333+
)
334+
}),
329335
)
330336

331337
list_display = ['name', 'collection', 'collection_published']

app/stac_api/managers.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -289,16 +289,28 @@ def filter_by_query(self, query):
289289
for operator in query[attribute]:
290290
value = query[attribute][operator] # get the values given by the operator
291291

292-
if attribute in ["updated", "created"]:
292+
if attribute in ["updated", "created", "cf:standard_name", "unit"]:
293293
prefix = ""
294294
else:
295295
prefix = "properties_"
296296

297+
model_field = attribute.replace(":", "_")
298+
297299
# __eq does not exist, but = does it as well
298300
if operator == 'eq':
299-
query_filter = f"{prefix}{attribute}"
301+
query_filter = f"{prefix}{model_field}"
300302
else:
301-
query_filter = f"{prefix}{attribute}__{operator.lower()}"
303+
query_filter = f"{prefix}{model_field}__{operator.lower()}"
304+
# PB-2354: This is a bug: As it is now, we cannot query by multiple fields
305+
# because we return already for the first operator.
306+
#
307+
# See this test to demonstrate the bug:
308+
#
309+
# tests.tests_10.test_search_endpoint.SearchEndpointTestCF.test_multiple_cf
310+
#
311+
# We keep this is as it is as noone seemed to use that feature so far.
312+
# Instead, we intend to fix it properly by replacing the Query Extension by the
313+
# Filter Extension, which is the recommended solution anyway.
302314
return self.filter(**{query_filter: value})
303315

304316

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Generated by Django 5.2.13 on 2026-08-10 16:43
2+
3+
import pgtrigger.compiler
4+
import pgtrigger.migrations
5+
6+
from django.db import migrations
7+
from django.db import models
8+
9+
10+
class Migration(migrations.Migration):
11+
12+
dependencies = [
13+
('stac_api', '0079_add_stac_extensions_to_meteoschweiz_collections'),
14+
]
15+
16+
operations = [
17+
migrations.AddField(
18+
model_name='item',
19+
name='cf_standard_name',
20+
field=models.CharField(
21+
blank=True,
22+
help_text='Corresponds to the CF Standard Name from the CF Standard Name Table.',
23+
max_length=255,
24+
null=True
25+
),
26+
),
27+
migrations.AddField(
28+
model_name='item',
29+
name='unit',
30+
field=models.CharField(
31+
blank=True, help_text='Corresponds to the CF units.', max_length=255, null=True
32+
),
33+
),
34+
]
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Generated by Django 5.2.13 on 2026-08-11 15:15
2+
3+
import pgtrigger.compiler
4+
import pgtrigger.migrations
5+
6+
from django.db import migrations
7+
from django.db import models
8+
9+
10+
class Migration(migrations.Migration):
11+
12+
dependencies = [
13+
('stac_api', '0080_item_cf_standard_name_item_unit'),
14+
]
15+
16+
operations = [
17+
pgtrigger.migrations.RemoveTrigger(
18+
model_name='item',
19+
name='update_collection_child_trigger_etag_only',
20+
),
21+
migrations.AddIndex(
22+
model_name='item',
23+
index=models.Index(fields=['cf_standard_name'], name='item_cf_standard_name_idx'),
24+
),
25+
migrations.AddIndex(
26+
model_name='item',
27+
index=models.Index(fields=['unit'], name='item_unit_idx'),
28+
),
29+
pgtrigger.migrations.AddTrigger(
30+
model_name='item',
31+
trigger=pgtrigger.compiler.Trigger(
32+
name='update_collection_child_trigger_etag_only',
33+
sql=pgtrigger.compiler.UpsertTriggerSql(
34+
condition=
35+
'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"))',
36+
declare='DECLARE child stac_api_Item%ROWTYPE;',
37+
func=
38+
"\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",
39+
hash='0308e4b3217a2be3890fde8659088ee924d8c59a',
40+
operation='UPDATE',
41+
pgid='pgtrigger_update_collection_child_trigger_etag_only_bee6a',
42+
table='stac_api_item',
43+
when='AFTER'
44+
)
45+
),
46+
),
47+
]
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Generated by Django 5.2.13 on 2026-08-11 17:03
2+
3+
import django.contrib.postgres.fields
4+
from django.db import migrations
5+
from django.db import models
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
('stac_api', '0081_remove_item_update_collection_child_trigger_etag_only_and_more'),
12+
]
13+
14+
operations = [
15+
migrations.AlterField(
16+
model_name='collection',
17+
name='stac_extensions_enabled',
18+
field=django.contrib.postgres.fields.ArrayField(
19+
base_field=models.CharField(
20+
choices=[(
21+
'https://stac-extensions.github.io/timestamps/v1.1.0/schema.json',
22+
'Timestamps'
23+
),
24+
(
25+
'https://stac-extensions.github.io/forecast/v0.2.0/schema.json',
26+
'Forecast'
27+
), ('https://stac-extensions.github.io/cf/v1.0.0/schema.json', 'CF')],
28+
max_length=255
29+
),
30+
blank=True,
31+
default=list,
32+
help_text=
33+
'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.',
34+
size=None
35+
),
36+
),
37+
migrations.AlterField(
38+
model_name='item',
39+
name='stac_extensions',
40+
field=django.contrib.postgres.fields.ArrayField(
41+
base_field=models.CharField(
42+
choices=[(
43+
'https://stac-extensions.github.io/timestamps/v1.1.0/schema.json',
44+
'Timestamps'
45+
),
46+
(
47+
'https://stac-extensions.github.io/forecast/v0.2.0/schema.json',
48+
'Forecast'
49+
), ('https://stac-extensions.github.io/cf/v1.0.0/schema.json', 'CF')],
50+
max_length=255
51+
),
52+
blank=True,
53+
default=list,
54+
help_text=
55+
"STAC extensions used by this Item. Defined as JSON schema URLs. Must be a subset of the Collection's stac_extensions_enabled field.",
56+
size=None
57+
),
58+
),
59+
]

app/stac_api/models/item.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ class Meta:
8686
models.Index(fields=['forecast_duration'], name='item_fc_duration_idx'),
8787
models.Index(fields=['forecast_variable'], name='item_fc_variable_idx'),
8888
models.Index(fields=['forecast_perturbed'], name='item_fc_perturbed_idx'),
89+
# CF extension properties are "queryable" in the search endpoint
90+
models.Index(fields=['cf_standard_name'], name='item_cf_standard_name_idx'),
91+
models.Index(fields=['unit'], name='item_unit_idx'),
8992
# combination of datetime and start_ and end_datetimes are used in
9093
# managers.py:110 and following
9194
models.Index(
@@ -212,6 +215,19 @@ class Meta:
212215
"Collection's stac_extensions_enabled field.",
213216
)
214217

218+
# Part of the Climate and Forecast (CF) extension
219+
cf_standard_name = models.CharField(
220+
null=True,
221+
blank=True,
222+
max_length=255,
223+
help_text="Corresponds to the CF Standard Name from the CF Standard Name Table."
224+
)
225+
226+
# Part of the Climate and Forecast (CF) extension
227+
unit = models.CharField(
228+
null=True, blank=True, max_length=255, help_text="Corresponds to the CF units."
229+
)
230+
215231
# Custom Manager that preselects the collection
216232
objects = ItemManager()
217233

app/stac_api/serializers/item.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ class ItemsPropertiesSerializer(serializers.Serializer):
102102
forecast_variable = serializers.CharField(required=False, allow_null=True, default=None)
103103
forecast_perturbed = serializers.BooleanField(required=False, allow_null=True, default=None)
104104

105+
cf_standard_name = serializers.CharField(required=False, allow_null=True, default=None)
106+
unit = serializers.CharField(required=False, allow_null=True, default=None)
107+
105108
def to_internal_value(self, data) -> timedelta:
106109
'''Map forecast extension fields with a colon in the name to the corresponding model field.
107110
@@ -115,6 +118,8 @@ def to_internal_value(self, data) -> timedelta:
115118
'forecast:duration': 'forecast_duration',
116119
'forecast:variable': 'forecast_variable',
117120
'forecast:perturbed': 'forecast_perturbed',
121+
'cf:standard_name': 'cf_standard_name',
122+
'unit': 'unit',
118123
}
119124
data_mapped = copy.deepcopy(data)
120125
for with_colon, with_underscore in fields.items():
@@ -139,6 +144,8 @@ def to_representation(self, instance):
139144
'forecast_duration': 'forecast:duration',
140145
'forecast_variable': 'forecast:variable',
141146
'forecast_perturbed': 'forecast:perturbed',
147+
'cf_standard_name': 'cf:standard_name',
148+
'unit': 'unit',
142149
}
143150
for with_colon, with_underscore in fields.items():
144151
if with_colon in ret:

app/stac_api/utils.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -314,19 +314,22 @@ def harmonize_post_get_for_search(request):
314314
if 'intersects' in query_param:
315315
query_param['intersects'] = json.loads(query_param['intersects'])
316316

317-
# Forecast properties can only be filtered with method POST.
317+
# Forecast and CF extension properties can only be filtered with method POST.
318318
# Decision was made as `:` need to be url encoded and (at least for now) we do not need to
319319
# support forecast filtering in the GET request.
320-
if 'forecast:reference_datetime' in query_param:
321-
del query_param['forecast:reference_datetime']
322-
if 'forecast:horizon' in query_param:
323-
del query_param['forecast:horizon']
324-
if 'forecast:duration' in query_param:
325-
del query_param['forecast:duration']
326-
if 'forecast:variable' in query_param:
327-
del query_param['forecast:variable']
328-
if 'forecast:perturbed' in query_param:
329-
del query_param['forecast:perturbed']
320+
forecast_properties = [
321+
'forecast:reference_datetime',
322+
'forecast:horizon',
323+
'forecast:duration',
324+
'forecast:variable',
325+
'forecast:perturbed'
326+
]
327+
cf_properties = ['cf:standard_name', 'unit']
328+
properties_to_remove = forecast_properties + cf_properties
329+
for p in properties_to_remove:
330+
if p in query_param:
331+
del query_param[p]
332+
330333
return query_param
331334

332335

app/stac_api/validators.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ class StacExtension(TextChoices):
3232
'''STAC extensions that can be enabled for a Collection and used by its Items.'''
3333
TIMESTAMPS = 'https://stac-extensions.github.io/timestamps/v1.1.0/schema.json', 'Timestamps'
3434
FORECAST = 'https://stac-extensions.github.io/forecast/v0.2.0/schema.json', 'Forecast'
35+
CF = 'https://stac-extensions.github.io/cf/v1.0.0/schema.json', 'CF'
3536

3637

3738
def validate_stac_extensions_enabled(stac_extensions, collection):
@@ -76,6 +77,8 @@ def validate_item_properties_extensions(properties, stac_extensions):
7677
'forecast:duration': StacExtension.FORECAST,
7778
'forecast:variable': StacExtension.FORECAST,
7879
'forecast:perturbed': StacExtension.FORECAST,
80+
'cf:standard_name': StacExtension.CF,
81+
'unit': StacExtension.CF,
7982
}
8083

8184
errors = {}

app/stac_api/validators_serializer.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ def __init__(self):
7070
# Note: if these values are adapted, don't forget to
7171
# update the spec accordingly.
7272
self.queriable_date_fields = ['created', 'updated']
73-
self.queriable_str_fields = ['title']
73+
self.queriable_str_fields = ['title', 'cf:standard_name', 'unit']
7474

7575
def validate(self, request):
7676
'''Validates the request of the search endpoint

0 commit comments

Comments
 (0)