Skip to content

Commit 00cd3ee

Browse files
Merge pull request #658 from geoadmin/feat-PB-2355-improve-extension-handling-in-service-stac
PB-2355 Improve extension handling
2 parents 51a6ca3 + 199df2b commit 00cd3ee

21 files changed

Lines changed: 1201 additions & 22 deletions

app/stac_api/admin.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from stac_api.models.item import ItemLink
3636
from stac_api.utils import build_asset_href
3737
from stac_api.utils import get_query_params
38+
from stac_api.validators import StacExtension
3839
from stac_api.validators import validate_text_to_geometry
3940

4041
logger = logging.getLogger(__name__)
@@ -106,8 +107,60 @@ class CollectionAssetInline(admin.StackedInline):
106107
extra = 0
107108

108109

110+
# helper form to render stac_extensions_enabled as a suggested multi-select instead of a plain
111+
# comma-separated text field (the default ArrayField widget)
112+
class CollectionAdminForm(forms.ModelForm):
113+
stac_extensions_enabled = forms.MultipleChoiceField(
114+
choices=StacExtension.choices,
115+
required=False,
116+
widget=forms.CheckboxSelectMultiple,
117+
help_text=(
118+
"STAC extensions that are enabled for the Items in this Collection. Only Items "
119+
"using one of the selected extensions can be created or updated. This field is for "
120+
"internal/admin use only, it is not exposed through the STAC API."
121+
)
122+
)
123+
124+
125+
class StacExtensionsEnabledFilter(SimpleListFilter):
126+
title = _('STAC extensions enabled')
127+
parameter_name = 'stac_extensions_enabled'
128+
template = 'admin/stac_extensions_enabled_filter.html'
129+
130+
value_none = 'none'
131+
value_forecast = StacExtension.FORECAST
132+
value_timestamps = StacExtension.TIMESTAMPS
133+
134+
def lookups(self, request, model_admin):
135+
return [
136+
(self.value_none, _('None')),
137+
(self.value_forecast, _('Forecast')),
138+
(self.value_timestamps, _('Timestamps')),
139+
]
140+
141+
def queryset(self, request, queryset):
142+
values = request.GET.getlist(self.parameter_name)
143+
if not values:
144+
return queryset
145+
146+
if self.value_none in values:
147+
return queryset.filter(stac_extensions_enabled=[])
148+
149+
return queryset.filter(stac_extensions_enabled__contains=values)
150+
151+
def choices(self, changelist):
152+
values = set(self.request.GET.getlist(self.parameter_name))
153+
for lookup, title in self.lookup_choices:
154+
yield {
155+
'selected': str(lookup) in values,
156+
'value': lookup,
157+
'display': title,
158+
}
159+
160+
109161
@admin.register(Collection)
110162
class CollectionAdmin(admin.ModelAdmin):
163+
form = CollectionAdminForm
111164

112165
class Media:
113166
js = ('js/admin/collection_help_search.js',)
@@ -133,6 +186,7 @@ class Media:
133186
'allow_external_assets',
134187
'external_asset_whitelist',
135188
'cache_control_header',
189+
'stac_extensions_enabled',
136190
]
137191
readonly_fields = [
138192
'extent_start_datetime',
@@ -150,7 +204,7 @@ class Media:
150204
inlines = [ProviderInline, CollectionLinkInline, CollectionAssetInline]
151205
search_fields = ['name']
152206
list_display = ['name', 'published']
153-
list_filter = ['published']
207+
list_filter = ['published', StacExtensionsEnabledFilter]
154208

155209
#helper function which displays the bytes in human-readable format
156210
def displayed_total_data_size(self, instance):
@@ -241,6 +295,7 @@ class Media:
241295
'updated',
242296
'etag',
243297
'displayed_total_data_size',
298+
'stac_extensions',
244299
)
245300
}
246301
),
@@ -343,6 +398,7 @@ def get_fieldsets(self, request, obj=None):
343398
'updated',
344399
'etag',
345400
'displayed_total_data_size',
401+
'stac_extensions',
346402
)
347403
return fields
348404
# Otherwise if this is an update operation only display the read only field
@@ -354,6 +410,7 @@ def get_fieldsets(self, request, obj=None):
354410
'updated',
355411
'etag',
356412
'displayed_total_data_size',
413+
'stac_extensions',
357414
)
358415
return fields
359416

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# Generated by Django 5.2.13 on 2026-07-15 16:43
2+
3+
import pgtrigger.compiler
4+
import pgtrigger.migrations
5+
6+
import django.contrib.postgres.fields
7+
from django.db import migrations
8+
from django.db import models
9+
10+
11+
class Migration(migrations.Migration):
12+
13+
dependencies = [
14+
('stac_api', '0077_remove_asset_add_asset_auto_variables_trigger_and_more'),
15+
]
16+
17+
operations = [
18+
pgtrigger.migrations.RemoveTrigger(
19+
model_name='item',
20+
name='update_collection_child_trigger_etag_only',
21+
),
22+
migrations.AddField(
23+
model_name='collection',
24+
name='stac_extensions_enabled',
25+
field=django.contrib.postgres.fields.ArrayField(
26+
base_field=models.CharField(
27+
choices=[(
28+
'https://stac-extensions.github.io/timestamps/v1.1.0/schema.json',
29+
'Timestamps'
30+
),
31+
(
32+
'https://stac-extensions.github.io/forecast/v0.2.0/schema.json',
33+
'Forecast'
34+
)],
35+
max_length=255
36+
),
37+
blank=True,
38+
default=list,
39+
help_text=
40+
'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.',
41+
size=None
42+
),
43+
),
44+
migrations.AddField(
45+
model_name='item',
46+
name='stac_extensions',
47+
field=django.contrib.postgres.fields.ArrayField(
48+
base_field=models.CharField(
49+
choices=[(
50+
'https://stac-extensions.github.io/timestamps/v1.1.0/schema.json',
51+
'Timestamps'
52+
),
53+
(
54+
'https://stac-extensions.github.io/forecast/v0.2.0/schema.json',
55+
'Forecast'
56+
)],
57+
max_length=255
58+
),
59+
blank=True,
60+
default=list,
61+
help_text=
62+
"STAC extensions used by this Item. Defined as JSON schema URLs. Must be a subset of the Collection's stac_extensions_enabled field.",
63+
size=None
64+
),
65+
),
66+
pgtrigger.migrations.AddTrigger(
67+
model_name='item',
68+
trigger=pgtrigger.compiler.Trigger(
69+
name='update_collection_child_trigger_etag_only',
70+
sql=pgtrigger.compiler.UpsertTriggerSql(
71+
condition=
72+
'WHEN (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"))',
73+
declare='DECLARE child stac_api_Item%ROWTYPE;',
74+
func=
75+
"\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",
76+
hash='44c9e53a63aa52973de9b729e17eca6a036d0018',
77+
operation='UPDATE',
78+
pgid='pgtrigger_update_collection_child_trigger_etag_only_bee6a',
79+
table='stac_api_item',
80+
when='AFTER'
81+
)
82+
),
83+
),
84+
]
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Generated by Django 5.2.13 on 2026-07-29 15:40
2+
3+
from django.db import migrations
4+
5+
from stac_api.validators import StacExtension
6+
7+
FORECAST_EXT = StacExtension.FORECAST.value
8+
TIMESTAMPS_EXT = StacExtension.TIMESTAMPS.value
9+
10+
11+
def update_stac_extensions(apps, schema_editor):
12+
"""Add STAC extensions based on item properties.
13+
14+
- Item with `forecast_reference_datetime` != 0:
15+
Add forecast extension to `stac_extension` and to the collection's
16+
`stac_extension_enabled`
17+
- Item with `properties_expires` != 0:
18+
Add timestamps extension to `stac_extension` and to the collection's
19+
`stac_extension_enabled`
20+
"""
21+
Item = apps.get_model('stac_api', 'Item')
22+
23+
forecast_collections_updated = set()
24+
timestamps_collections_updated = set()
25+
26+
for item in Item.objects.filter(forecast_reference_datetime__isnull=False):
27+
extensions = list(item.stac_extensions)
28+
if FORECAST_EXT not in extensions:
29+
extensions.append(FORECAST_EXT)
30+
item.stac_extensions = extensions
31+
item.save()
32+
33+
if item.collection_id not in forecast_collections_updated:
34+
collection = item.collection
35+
extensions = list(collection.stac_extensions_enabled)
36+
if FORECAST_EXT not in extensions:
37+
extensions.append(FORECAST_EXT)
38+
collection.stac_extensions_enabled = extensions
39+
collection.save()
40+
forecast_collections_updated.add(item.collection_id)
41+
42+
for item in Item.objects.filter(properties_expires__isnull=False):
43+
extensions = list(item.stac_extensions)
44+
if TIMESTAMPS_EXT not in extensions:
45+
extensions.append(TIMESTAMPS_EXT)
46+
item.stac_extensions = extensions
47+
item.save()
48+
49+
if item.collection_id not in timestamps_collections_updated:
50+
collection = item.collection
51+
extensions = list(collection.stac_extensions_enabled)
52+
if TIMESTAMPS_EXT not in extensions:
53+
extensions.append(TIMESTAMPS_EXT)
54+
collection.stac_extensions_enabled = extensions
55+
collection.save()
56+
timestamps_collections_updated.add(item.collection_id)
57+
58+
59+
def reverse_update_stac_extensions(apps, schema_editor):
60+
"""Remove STAC extensions added by this migration."""
61+
Item = apps.get_model('stac_api', 'Item')
62+
63+
forecast_collections_updated = set()
64+
timestamps_collections_updated = set()
65+
66+
for item in Item.objects.filter(forecast_reference_datetime__isnull=False):
67+
item.stac_extensions = [ext for ext in item.stac_extensions if ext != FORECAST_EXT]
68+
item.save()
69+
70+
if item.collection_id not in forecast_collections_updated:
71+
collection = item.collection
72+
collection.stac_extensions_enabled = [
73+
ext for ext in collection.stac_extensions_enabled if ext != FORECAST_EXT
74+
]
75+
collection.save()
76+
forecast_collections_updated.add(item.collection_id)
77+
78+
for item in Item.objects.filter(properties_expires__isnull=False):
79+
item.stac_extensions = [ext for ext in item.stac_extensions if ext != TIMESTAMPS_EXT]
80+
item.save()
81+
82+
if item.collection_id not in timestamps_collections_updated:
83+
collection = item.collection
84+
collection.stac_extensions_enabled = [
85+
ext for ext in collection.stac_extensions_enabled if ext != TIMESTAMPS_EXT
86+
]
87+
collection.save()
88+
timestamps_collections_updated.add(item.collection_id)
89+
90+
91+
class Migration(migrations.Migration):
92+
93+
dependencies = [
94+
('stac_api', '0078_remove_item_update_collection_child_trigger_etag_only_and_more'),
95+
]
96+
97+
operations = [
98+
migrations.RunPython(
99+
update_stac_extensions,
100+
reverse_update_stac_extensions,
101+
),
102+
]

app/stac_api/models/collection.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from stac_api.pgtriggers import generates_collection_triggers
1919
from stac_api.pgtriggers import generates_summary_count_triggers
2020
from stac_api.utils import get_collection_asset_path
21+
from stac_api.validators import StacExtension
2122
from stac_api.validators import validate_cache_control_header
2223
from stac_api.validators import validate_name
2324

@@ -106,6 +107,20 @@ class Meta:
106107
)
107108
)
108109

110+
# This field is for internal/admin use only, it is not exposed through the STAC API and
111+
# can only be set via the Django admin page. It defines which STAC extensions are allowed to
112+
# be used by the Items in this Collection.
113+
stac_extensions_enabled = ArrayField(
114+
models.CharField(max_length=255, choices=StacExtension.choices),
115+
blank=True,
116+
default=list,
117+
help_text=_(
118+
"STAC extensions that are enabled for the Items in this Collection. It defines which "
119+
"STAC extensions are allowed to be used by the Items in this Collection. "
120+
"This field is for internal/admin use only, it is not exposed through the STAC API."
121+
)
122+
)
123+
109124
def __str__(self):
110125
return self.name
111126

app/stac_api/models/item.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import logging
22

33
from django.contrib.gis.db import models
4+
from django.contrib.postgres.fields import ArrayField
45
from django.db.models import Q
56
from django.utils.translation import gettext_lazy as _
67

@@ -17,6 +18,7 @@
1718
from stac_api.pgtriggers import generates_asset_upload_triggers
1819
from stac_api.pgtriggers import generates_item_triggers
1920
from stac_api.utils import get_asset_path
21+
from stac_api.validators import StacExtension
2022
from stac_api.validators import validate_eo_gsd
2123
from stac_api.validators import validate_expires
2224
from stac_api.validators import validate_geoadmin_variant
@@ -197,6 +199,19 @@ class Meta:
197199
"\"unknown\" in case it's missing."
198200
)
199201

202+
# STAC extensions used by this Item. STAC extensions are defined as schema URLs, for example
203+
# https://stac-extensions.github.io/timestamps/v1.1.0/schema.json.
204+
# They are validated against the extensions enabled on the Collection (field
205+
# stac_extensions_enabled).
206+
stac_extensions = ArrayField(
207+
models.CharField(max_length=255, choices=StacExtension.choices),
208+
blank=True,
209+
default=list,
210+
help_text=
211+
"STAC extensions used by this Item. Defined as JSON schema URLs. Must be a subset of the "
212+
"Collection's stac_extensions_enabled field.",
213+
)
214+
200215
# Custom Manager that preselects the collection
201216
objects = ItemManager()
202217

0 commit comments

Comments
 (0)