diff --git a/app/stac_api/admin.py b/app/stac_api/admin.py index 7b775043..68ec5a60 100644 --- a/app/stac_api/admin.py +++ b/app/stac_api/admin.py @@ -17,17 +17,17 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from stac_api.models import BBOX_CH -from stac_api.models import Asset -from stac_api.models import AssetUpload -from stac_api.models import Collection -from stac_api.models import CollectionAsset -from stac_api.models import CollectionLink -from stac_api.models import Item -from stac_api.models import ItemLink -from stac_api.models import LandingPage -from stac_api.models import LandingPageLink -from stac_api.models import Provider +from stac_api.models.collection import Collection +from stac_api.models.collection import CollectionAsset +from stac_api.models.collection import CollectionLink +from stac_api.models.general import BBOX_CH +from stac_api.models.general import LandingPage +from stac_api.models.general import LandingPageLink +from stac_api.models.general import Provider +from stac_api.models.item import Asset +from stac_api.models.item import AssetUpload +from stac_api.models.item import Item +from stac_api.models.item import ItemLink from stac_api.utils import build_asset_href from stac_api.utils import get_query_params from stac_api.validators import validate_href_url diff --git a/app/stac_api/management/commands/calculate_extent.py b/app/stac_api/management/commands/calculate_extent.py index f01104e0..b0d257dc 100644 --- a/app/stac_api/management/commands/calculate_extent.py +++ b/app/stac_api/management/commands/calculate_extent.py @@ -3,7 +3,7 @@ from django.core.management.base import CommandParser from django.db import connection -from stac_api.models import Collection +from stac_api.models.collection import Collection from stac_api.utils import CommandHandler from stac_api.utils import CustomBaseCommand diff --git a/app/stac_api/management/commands/dummy_asset_upload.py b/app/stac_api/management/commands/dummy_asset_upload.py index 758f98b8..7232e8c2 100644 --- a/app/stac_api/management/commands/dummy_asset_upload.py +++ b/app/stac_api/management/commands/dummy_asset_upload.py @@ -4,9 +4,9 @@ from django.conf import settings from django.core.management.base import BaseCommand -from stac_api.models import Asset -from stac_api.models import AssetUpload -from stac_api.models import BaseAssetUpload +from stac_api.models.general import BaseAssetUpload +from stac_api.models.item import Asset +from stac_api.models.item import AssetUpload from stac_api.s3_multipart_upload import MultipartUpload from stac_api.utils import AVAILABLE_S3_BUCKETS from stac_api.utils import CommandHandler diff --git a/app/stac_api/management/commands/dummy_data.py b/app/stac_api/management/commands/dummy_data.py index bb9d39dc..a69702db 100644 --- a/app/stac_api/management/commands/dummy_data.py +++ b/app/stac_api/management/commands/dummy_data.py @@ -13,9 +13,9 @@ from django.core.files.uploadedfile import SimpleUploadedFile from django.core.management.base import BaseCommand -from stac_api.models import Asset -from stac_api.models import Collection -from stac_api.models import Item +from stac_api.models.collection import Collection +from stac_api.models.item import Asset +from stac_api.models.item import Item from stac_api.utils import CommandHandler from stac_api.validators import MEDIA_TYPES diff --git a/app/stac_api/management/commands/list_asset_uploads.py b/app/stac_api/management/commands/list_asset_uploads.py index 079ac3ec..24bb58ce 100644 --- a/app/stac_api/management/commands/list_asset_uploads.py +++ b/app/stac_api/management/commands/list_asset_uploads.py @@ -4,7 +4,7 @@ from django.core.management.base import BaseCommand from django.core.serializers.json import DjangoJSONEncoder -from stac_api.models import AssetUpload +from stac_api.models.item import AssetUpload from stac_api.s3_multipart_upload import MultipartUpload from stac_api.serializers.upload import AssetUploadSerializer from stac_api.utils import CommandHandler diff --git a/app/stac_api/management/commands/profile_cursor_paginator.py b/app/stac_api/management/commands/profile_cursor_paginator.py index c4c7cc97..18c6174c 100644 --- a/app/stac_api/management/commands/profile_cursor_paginator.py +++ b/app/stac_api/management/commands/profile_cursor_paginator.py @@ -10,7 +10,7 @@ from rest_framework.request import Request from rest_framework.test import APIRequestFactory -from stac_api.models import Item +from stac_api.models.item import Item from stac_api.utils import CommandHandler logger = logging.getLogger(__name__) diff --git a/app/stac_api/management/commands/profile_item_serializer.py b/app/stac_api/management/commands/profile_item_serializer.py index 47f2ba79..8439598d 100644 --- a/app/stac_api/management/commands/profile_item_serializer.py +++ b/app/stac_api/management/commands/profile_item_serializer.py @@ -8,7 +8,7 @@ from rest_framework.test import APIRequestFactory -from stac_api.models import Item +from stac_api.models.item import Item from stac_api.utils import CommandHandler logger = logging.getLogger(__name__) diff --git a/app/stac_api/management/commands/profile_serializer_vs_no_drf.py b/app/stac_api/management/commands/profile_serializer_vs_no_drf.py index d4b1ddca..28ed2a2d 100644 --- a/app/stac_api/management/commands/profile_serializer_vs_no_drf.py +++ b/app/stac_api/management/commands/profile_serializer_vs_no_drf.py @@ -7,7 +7,7 @@ from rest_framework.test import APIRequestFactory -from stac_api.models import Item +from stac_api.models.item import Item from stac_api.utils import CommandHandler logger = logging.getLogger(__name__) diff --git a/app/stac_api/management/commands/remove_expired_items.py b/app/stac_api/management/commands/remove_expired_items.py index d1eb2668..6b99042f 100644 --- a/app/stac_api/management/commands/remove_expired_items.py +++ b/app/stac_api/management/commands/remove_expired_items.py @@ -4,9 +4,9 @@ from django.core.management.base import CommandParser from django.utils import timezone -from stac_api.models import AssetUpload -from stac_api.models import BaseAssetUpload -from stac_api.models import Item +from stac_api.models.general import BaseAssetUpload +from stac_api.models.item import AssetUpload +from stac_api.models.item import Item from stac_api.utils import CommandHandler from stac_api.utils import CustomBaseCommand diff --git a/app/stac_api/management/commands/update_asset_file_size.py b/app/stac_api/management/commands/update_asset_file_size.py index a4591c99..a243ac0d 100644 --- a/app/stac_api/management/commands/update_asset_file_size.py +++ b/app/stac_api/management/commands/update_asset_file_size.py @@ -6,8 +6,8 @@ from django.core.management.base import BaseCommand from django.core.management.base import CommandParser -from stac_api.models import Asset -from stac_api.models import CollectionAsset +from stac_api.models.collection import CollectionAsset +from stac_api.models.item import Asset from stac_api.utils import CommandHandler from stac_api.utils import get_s3_client from stac_api.utils import select_s3_bucket diff --git a/app/stac_api/managers.py b/app/stac_api/managers.py index 7ee411e5..6075cc0e 100644 --- a/app/stac_api/managers.py +++ b/app/stac_api/managers.py @@ -105,6 +105,43 @@ def _filter_by_datetime_range(self, start_datetime, end_datetime): ) ) + def filter_by_forecast_reference_datetime(self, date_time): + '''Filter a queryset by forecast reference datetime + + Args: + date_time: + A string containing datetime like "2020-10-28T13:05:10Z" + + Returns: + The queryset filtered by date_time + ''' + start, end = self._parse_datetime_query(date_time) + if end is not None: + return self._filter_by_forecast_reference_datetime_range(start, end) + return self.filter(forecast_reference_datetime=start) + + def _filter_by_forecast_reference_datetime_range(self, start_datetime, end_datetime): + '''Filter a queryset by forecast reference datetime range + + Helper function of filter_by_forecast_reference_datetime + + Args: + start_datetime: + A string with the start datetime or ".." to denote open start + end_datetime: + A string with the end datetime or ".." to denote open end + Returns: + The queryset filtered by datetime range + ''' + if start_datetime == '..': + # open start range + return self.filter(forecast_reference_datetime__lte=end_datetime) + if end_datetime == '..': + # open end range + return self.filter(forecast_reference_datetime__gte=start_datetime) + # else fixed range + return self.filter(forecast_reference_datetime__range=(start_datetime, end_datetime)) + def _parse_datetime_query(self, date_time): '''Parse the datetime query as specified in the api-spec.md. @@ -189,6 +226,54 @@ def filter_by_intersects(self, intersects): the_geom = GEOSGeometry(intersects) return self.filter(geometry__intersects=the_geom) + def filter_by_forecast_horizon(self, duration): + '''Filter by forecast horizon + + Args: + duration: string + ISO 8601 duration string + + Returns: + queryset filtered by forecast horizon + ''' + return self.filter(forecast_horizon=duration) + + def filter_by_forecast_duration(self, duration): + '''Filter by forecast duration + + Args: + duration: string + ISO 8601 duration string + + Returns: + queryset filtered by forecast duration + ''' + return self.filter(forecast_duration=duration) + + def filter_by_forecast_variable(self, val): + '''Filter by forecast variable + + Args: + val: string + foracast variable value + + Returns: + queryset filtered by forecast variable + ''' + return self.filter(forecast_variable=val) + + def filter_by_forecast_perturbed(self, pert): + '''Filter by forecast perturbed + + Args: + pert: boolean + foracast perturbed + + Returns: + queryset filtered by forecast perturbed + ''' + return self.filter(forecast_perturbed=pert) + def filter_by_query(self, query): '''Filter by the query parameter @@ -236,6 +321,21 @@ def filter_by_item_name(self, item_name_array): def filter_by_query(self, query): return self.get_queryset().filter_by_query(query) + def filter_by_forecast_reference_datetime(self, date_time): + return self.get_queryset().filter_by_forecast_reference_datetime(date_time) + + def filter_by_forecast_horizon(self, duration): + return self.get_queryset().filter_by_forecast_horizon(duration) + + def filter_by_forecast_duration(self, duration): + return self.get_queryset().filter_by_forecast_duration(duration) + + def filter_by_forecast_variable(self, val): + return self.get_queryset().filter_by_forecast_variable(val) + + def filter_by_forecast_perturbed(self, pert): + return self.get_queryset().filter_by_forecast_perturbed(pert) + class AssetUploadQuerySet(models.QuerySet): diff --git a/app/stac_api/migrations/0001_initial.py b/app/stac_api/migrations/0001_initial.py index 529ed67d..9857f8f0 100644 --- a/app/stac_api/migrations/0001_initial.py +++ b/app/stac_api/migrations/0001_initial.py @@ -7,7 +7,7 @@ from django.db import migrations from django.db import models -import stac_api.models +import stac_api.models.general import stac_api.validators @@ -74,7 +74,7 @@ class Migration(migrations.Migration): ( 'summaries', models.JSONField( - default=stac_api.models.get_default_summaries_value, + default=stac_api.models.general.get_default_summaries_value, editable=False, encoder=django.core.serializers.json.DjangoJSONEncoder ) @@ -102,7 +102,7 @@ class Migration(migrations.Migration): 'conformsTo', django.contrib.postgres.fields.ArrayField( base_field=models.URLField(), - default=stac_api.models.get_conformance_default_links, + default=stac_api.models.general.get_conformance_default_links, help_text='Comma-separated list of URLs for the value conformsTo', size=None ) @@ -335,7 +335,7 @@ class Migration(migrations.Migration): ( 'file', models.FileField( - max_length=255, upload_to=stac_api.models.upload_asset_to_path_hook + max_length=255, upload_to=stac_api.models.general.upload_asset_to_path_hook ) ), ( diff --git a/app/stac_api/migrations/0005_auto_20210408_0821.py b/app/stac_api/migrations/0005_auto_20210408_0821.py index 7ef461a7..a7079354 100644 --- a/app/stac_api/migrations/0005_auto_20210408_0821.py +++ b/app/stac_api/migrations/0005_auto_20210408_0821.py @@ -6,7 +6,7 @@ from django.db import migrations from django.db import models -import stac_api.models +import stac_api.models.general class Migration(migrations.Migration): @@ -50,7 +50,10 @@ class Migration(migrations.Migration): ('created', models.DateTimeField(auto_now_add=True)), ('ended', models.DateTimeField(blank=True, default=None, null=True)), ('checksum_multihash', models.CharField(max_length=255)), - ('etag', models.CharField(default=stac_api.models.compute_etag, max_length=56)), + ( + 'etag', + models.CharField(default=stac_api.models.general.compute_etag, max_length=56) + ), ( 'asset', models.ForeignKey( diff --git a/app/stac_api/migrations/0014_auto_20210715_1358.py b/app/stac_api/migrations/0014_auto_20210715_1358.py index 032244c7..11493eee 100644 --- a/app/stac_api/migrations/0014_auto_20210715_1358.py +++ b/app/stac_api/migrations/0014_auto_20210715_1358.py @@ -6,7 +6,7 @@ from django.db import migrations from django.db import models -import stac_api.models +import stac_api.models.general class Migration(migrations.Migration): @@ -49,7 +49,7 @@ class Migration(migrations.Migration): model_name='asset', name='etag', field=models.CharField( - default=stac_api.models.compute_etag, editable=False, max_length=56 + default=stac_api.models.general.compute_etag, editable=False, max_length=56 ), ), migrations.AlterField( @@ -61,7 +61,7 @@ class Migration(migrations.Migration): model_name='collection', name='etag', field=models.CharField( - default=stac_api.models.compute_etag, editable=False, max_length=56 + default=stac_api.models.general.compute_etag, editable=False, max_length=56 ), ), migrations.AlterField( @@ -89,7 +89,7 @@ class Migration(migrations.Migration): model_name='item', name='etag', field=models.CharField( - default=stac_api.models.compute_etag, editable=False, max_length=56 + default=stac_api.models.general.compute_etag, editable=False, max_length=56 ), ), migrations.AlterField( diff --git a/app/stac_api/migrations/0032_alter_asset_file.py b/app/stac_api/migrations/0032_alter_asset_file.py index ed8a336d..76164f48 100644 --- a/app/stac_api/migrations/0032_alter_asset_file.py +++ b/app/stac_api/migrations/0032_alter_asset_file.py @@ -2,7 +2,7 @@ from django.db import migrations -import stac_api.models +import stac_api.models.general class Migration(migrations.Migration): @@ -15,8 +15,8 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='asset', name='file', - field=stac_api.models.DynamicStorageFileField( - max_length=255, upload_to=stac_api.models.upload_asset_to_path_hook + field=stac_api.models.general.DynamicStorageFileField( + max_length=255, upload_to=stac_api.models.general.upload_asset_to_path_hook ), ), ] diff --git a/app/stac_api/migrations/0032_delete_conformancepage_landingpage_conformsto_and_more.py b/app/stac_api/migrations/0032_delete_conformancepage_landingpage_conformsto_and_more.py index ff457375..9a19fccc 100644 --- a/app/stac_api/migrations/0032_delete_conformancepage_landingpage_conformsto_and_more.py +++ b/app/stac_api/migrations/0032_delete_conformancepage_landingpage_conformsto_and_more.py @@ -4,7 +4,7 @@ from django.db import migrations from django.db import models -import stac_api.models +import stac_api.models.general import stac_api.validators @@ -21,7 +21,7 @@ class Migration(migrations.Migration): name='conformsTo', field=django.contrib.postgres.fields.ArrayField( base_field=models.URLField(), - default=stac_api.models.get_conformance_default_links, + default=stac_api.models.general.get_conformance_default_links, help_text='Comma-separated list of URLs for the value conformsTo', size=None ), diff --git a/app/stac_api/migrations/0036_collectionasset_and_more.py b/app/stac_api/migrations/0036_collectionasset_and_more.py index efddc860..d69e0a16 100644 --- a/app/stac_api/migrations/0036_collectionasset_and_more.py +++ b/app/stac_api/migrations/0036_collectionasset_and_more.py @@ -9,7 +9,7 @@ from django.db import migrations from django.db import models -import stac_api.models +import stac_api.models.general import stac_api.validators @@ -34,8 +34,8 @@ class Migration(migrations.Migration): ), ( 'file', - stac_api.models.DynamicStorageFileField( - max_length=255, upload_to=stac_api.models.upload_asset_to_path_hook + stac_api.models.general.DynamicStorageFileField( + max_length=255, upload_to=stac_api.models.general.upload_asset_to_path_hook ) ), ( @@ -192,7 +192,7 @@ class Migration(migrations.Migration): ( 'etag', models.CharField( - default=stac_api.models.compute_etag, editable=False, max_length=56 + default=stac_api.models.general.compute_etag, editable=False, max_length=56 ) ), ( diff --git a/app/stac_api/migrations/0050_collectionassetupload_and_more.py b/app/stac_api/migrations/0050_collectionassetupload_and_more.py index 1238a62a..0e01547b 100644 --- a/app/stac_api/migrations/0050_collectionassetupload_and_more.py +++ b/app/stac_api/migrations/0050_collectionassetupload_and_more.py @@ -9,7 +9,7 @@ from django.db import migrations from django.db import models -import stac_api.models +import stac_api.models.general class Migration(migrations.Migration): @@ -69,7 +69,10 @@ class Migration(migrations.Migration): ('created', models.DateTimeField(auto_now_add=True)), ('ended', models.DateTimeField(blank=True, default=None, null=True)), ('checksum_multihash', models.CharField(max_length=255)), - ('etag', models.CharField(default=stac_api.models.compute_etag, max_length=56)), + ( + 'etag', + models.CharField(default=stac_api.models.general.compute_etag, max_length=56) + ), ( 'update_interval', models.IntegerField( diff --git a/app/stac_api/migrations/0062_item_item_fc_reference_datetime_idx_and_more.py b/app/stac_api/migrations/0062_item_item_fc_reference_datetime_idx_and_more.py new file mode 100644 index 00000000..5297a700 --- /dev/null +++ b/app/stac_api/migrations/0062_item_item_fc_reference_datetime_idx_and_more.py @@ -0,0 +1,36 @@ +# Generated by Django 5.0.11 on 2025-01-27 08:22 + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ('stac_api', '0061_remove_item_forecast_mode_remove_item_forecast_param_and_more'), + ] + + operations = [ + migrations.AddIndex( + model_name='item', + index=models.Index( + fields=['forecast_reference_datetime'], name='item_fc_reference_datetime_idx' + ), + ), + migrations.AddIndex( + model_name='item', + index=models.Index(fields=['forecast_horizon'], name='item_fc_horizon_idx'), + ), + migrations.AddIndex( + model_name='item', + index=models.Index(fields=['forecast_duration'], name='item_fc_duration_idx'), + ), + migrations.AddIndex( + model_name='item', + index=models.Index(fields=['forecast_variable'], name='item_fc_variable_idx'), + ), + migrations.AddIndex( + model_name='item', + index=models.Index(fields=['forecast_perturbed'], name='item_fc_perturbed_idx'), + ), + ] diff --git a/app/stac_api/models.py b/app/stac_api/models.py deleted file mode 100644 index 934f613b..00000000 --- a/app/stac_api/models.py +++ /dev/null @@ -1,997 +0,0 @@ -import hashlib -import logging -import os -import time -from uuid import uuid4 - -from language_tags import tags -from multihash import encode as multihash_encode -from multihash import to_hex_string - -from django.conf import settings -from django.contrib.gis.db import models -from django.contrib.gis.geos import Polygon -from django.contrib.postgres.fields import ArrayField -from django.core.exceptions import ValidationError -from django.core.serializers.json import DjangoJSONEncoder -from django.core.validators import MaxValueValidator -from django.core.validators import MinValueValidator -from django.db.models import Q -from django.db.models.deletion import ProtectedError -from django.utils.translation import gettext_lazy as _ - -from stac_api.managers import AssetUploadManager -from stac_api.managers import ItemManager -from stac_api.pgtriggers import SummaryFields -from stac_api.pgtriggers import child_triggers -from stac_api.pgtriggers import generates_asset_triggers -from stac_api.pgtriggers import generates_asset_upload_triggers -from stac_api.pgtriggers import generates_collection_asset_triggers -from stac_api.pgtriggers import generates_collection_triggers -from stac_api.pgtriggers import generates_item_triggers -from stac_api.pgtriggers import generates_summary_count_triggers -from stac_api.utils import get_asset_path -from stac_api.utils import get_collection_asset_path -from stac_api.utils import select_s3_bucket -from stac_api.validators import MEDIA_TYPES -from stac_api.validators import validate_asset_name -from stac_api.validators import validate_asset_name_with_media_type -from stac_api.validators import validate_eo_gsd -from stac_api.validators import validate_geoadmin_variant -from stac_api.validators import validate_geometry -from stac_api.validators import validate_item_properties_datetimes -from stac_api.validators import validate_link_rel -from stac_api.validators import validate_media_type -from stac_api.validators import validate_name - -logger = logging.getLogger(__name__) - -# We use the WGS84 bounding box as defined here: -# https://epsg.io/2056 -_BBOX_CH = Polygon.from_bbox((5.96, 45.82, 10.49, 47.81)) -_BBOX_CH.srid = 4326 -# equal to -# 'SRID=4326;POLYGON ((5.96 45.82, 5.96 47.81, 10.49 47.81, 10.49 45.82, 5.96 45.82))' -BBOX_CH = str(_BBOX_CH) - -SEARCH_TEXT_HELP_ITEM = ''' -
- Search Usage: - - Examples : - -
''' - -SEARCH_TEXT_HELP_COLLECTION = ''' -
- Search Usage: - - Examples : - -
''' - - -def get_conformance_default_links(): - '''A helper function of the class Conformance Page - - The function makes it possible to define the default values as a callable - Returns: - a list of urls - ''' - default_links = ( - 'https://api.stacspec.org/v1.0.0/core', - 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core', - 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/oas30', - 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson' - ) - return default_links - - -def get_default_summaries_value(): - return {} - - -def compute_etag(): - '''Compute a unique ETag''' - return str(uuid4()) - - -class Link(models.Model): - href = models.URLField(max_length=2048) - rel = models.CharField(max_length=30, validators=[validate_link_rel]) - # added link_ to the fieldname, as "type" is reserved - link_type = models.CharField(blank=True, null=True, max_length=150) - title = models.CharField(blank=True, null=True, max_length=255) - hreflang = models.CharField(blank=True, null=True, max_length=32) - - class Meta: - abstract = True - - def __str__(self): - return f'{self.rel}: {self.href}' - - def save(self, *args, **kwargs) -> None: - """Validate the hreflang""" - self.full_clean() - - if self.hreflang is not None and self.hreflang != '' and not tags.check(self.hreflang): - raise ValidationError(_(", ".join([v.message for v in tags.tag(self.hreflang).errors]))) - - super().save(*args, **kwargs) - - -class LandingPage(models.Model): - # using "name" instead of "id", as "id" has a default meaning in django - name = models.CharField( - 'id', unique=False, max_length=255, validators=[validate_name], default='ch' - ) - title = models.CharField(max_length=255, default='data.geo.admin.ch') - description = models.TextField( - default='Data Catalog of the Swiss Federal Spatial Data Infrastructure' - ) - version = models.CharField(max_length=255, default='v1') - - conformsTo = ArrayField( # pylint: disable=invalid-name - models.URLField( - blank=False, - null=False - ), - default=get_conformance_default_links, - help_text=_("Comma-separated list of URLs for the value conformsTo")) - - def __str__(self): - return f'STAC Landing Page {self.version}' - - class Meta: - verbose_name = "STAC Landing Page" - - -class LandingPageLink(Link): - landing_page = models.ForeignKey( - LandingPage, related_name='links', related_query_name='link', on_delete=models.CASCADE - ) - - class Meta: - unique_together = (('rel', 'landing_page')) - ordering = ['pk'] - - -class Provider(models.Model): - collection = models.ForeignKey( - 'stac_api.Collection', - on_delete=models.CASCADE, - related_name='providers', - related_query_name='provider' - ) - name = models.CharField(blank=False, max_length=200) - description = models.TextField(blank=True, null=True, default=None) - # possible roles are licensor, producer, processor or host - allowed_roles = ['licensor', 'producer', 'processor', 'host'] - roles = ArrayField( - models.CharField(max_length=9), - help_text=_( - f"Comma-separated list of roles. Possible values are {', '.join(allowed_roles)}" - ), - blank=True, - null=True, - ) - url = models.URLField(blank=True, null=True, max_length=2048) - - class Meta: - unique_together = (('collection', 'name'),) - ordering = ['pk'] - triggers = child_triggers('collection', 'Provider') - - def __str__(self): - return self.name - - def clean(self): - if self.roles is None: - return - for role in self.roles: - if role not in self.allowed_roles: - logger.error( - 'Invalid provider role %s', role, extra={'collection': self.collection.name} - ) - raise ValidationError( - _('Invalid role, must be in %(roles)s'), - params={'roles': self.allowed_roles}, - code='roles' - ) - - -# For Collections and Items: No primary key will be defined, so that the auto-generated ones -# will be used by Django. For assets, a primary key is defined as "BigAutoField" due the -# expected large number of assets -class Collection(models.Model): - - class Meta: - indexes = [ - models.Index(fields=['name'], name='collection_name_idx'), - models.Index(fields=['published'], name='collection_published_idx') - ] - triggers = generates_collection_triggers() - - published = models.BooleanField( - default=True, - help_text="When not published the collection doesn't appear on the " - "api/stac/v0.9/collections endpoint and its items are not listed in /search endpoint." - "

NOTE: unpublished collections/items can still be accessed by their path.

" - ) - # using "name" instead of "id", as "id" has a default meaning in django - name = models.CharField('id', unique=True, max_length=255, validators=[validate_name]) - created = models.DateTimeField(auto_now_add=True) - # NOTE: the updated field is automatically updated by stac_api.pgtriggers, we use auto_now_add - # only for the initial value. - updated = models.DateTimeField(auto_now_add=True) - description = models.TextField() - # Set to true if the extent needs to be recalculated. - extent_out_of_sync = models.BooleanField(default=False) - extent_geometry = models.GeometryField( - default=None, srid=4326, editable=False, blank=True, null=True - ) - extent_start_datetime = models.DateTimeField(editable=False, null=True, blank=True) - extent_end_datetime = models.DateTimeField(editable=False, null=True, blank=True) - - license = models.CharField(max_length=30) # string - - # DEPRECATED: summaries JSON field is not used anymore and not up to date, it will be removed - # in future. It has been replaced by summaries_proj_epsg, summaries_eo_gsd and - # summaries_geoadmin_variant - summaries = models.JSONField(default=dict, encoder=DjangoJSONEncoder, editable=False) - - # NOTE: the following summaries_* fields are automatically update by the stac_api.pgtriggers - summaries_proj_epsg = ArrayField( - models.IntegerField(), default=list, blank=True, editable=False - ) - summaries_eo_gsd = ArrayField(models.FloatField(), default=list, blank=True, editable=False) - summaries_geoadmin_variant = ArrayField( - models.CharField(max_length=25), default=list, blank=True, editable=False - ) - summaries_geoadmin_lang = ArrayField( - models.CharField(max_length=2), default=list, blank=True, editable=False - ) - - title = models.CharField(blank=True, null=True, max_length=255) - - # NOTE: hidden ETag field, this field is automatically updated by stac_api.pgtriggers - etag = models.CharField( - blank=False, null=False, editable=False, max_length=56, default=compute_etag - ) - - update_interval = models.IntegerField( - default=-1, - null=False, - blank=False, - validators=[MinValueValidator(-1)], - help_text="Minimal update interval in seconds " - "in which the underlying assets data are updated." - ) - - total_data_size = models.BigIntegerField(default=0, null=True, blank=True) - - allow_external_assets = models.BooleanField( - default=False, - help_text=_('Whether this collection can have assets that are hosted externally') - ) - - external_asset_whitelist = ArrayField( - models.CharField(max_length=255), blank=True, default=list, - help_text=_('Provide a comma separated list of ' - 'protocol://domain values for the external asset url validation') - ) - - def __str__(self): - return self.name - - -class CollectionLink(Link): - collection = models.ForeignKey( - Collection, related_name='links', related_query_name='link', on_delete=models.CASCADE - ) - - class Meta: - ordering = ['pk'] - triggers = child_triggers('collection', 'CollectionLink') - - -ITEM_KEEP_ORIGINAL_FIELDS = [ - 'geometry', - 'properties_datetime', - 'properties_start_datetime', - 'properties_end_datetime', -] - - -class Item(models.Model): - - class Meta: - unique_together = (('collection', 'name'),) - indexes = [ - models.Index(fields=['name'], name='item_name_idx'), - # the following 3 indices are used e.g. in collection_temporal_extent - models.Index(fields=['properties_datetime'], name='item_datetime_idx'), - models.Index(fields=['properties_start_datetime'], name='item_start_datetime_idx'), - models.Index(fields=['properties_end_datetime'], name='item_end_datetime_idx'), - # created, updated, and title are "queryable" in the search endpoint - # see: views.py:322 and 323 - models.Index(fields=['created'], name='item_created_idx'), - models.Index(fields=['updated'], name='item_updated_idx'), - models.Index(fields=['properties_title'], name='item_title_idx'), - # combination of datetime and start_ and end_datetimes are used in - # managers.py:110 and following - models.Index( - fields=[ - 'properties_datetime', 'properties_start_datetime', 'properties_end_datetime' - ], - name='item_dttme_start_end_dttm_idx' - ), - models.Index(fields=['update_interval'], name='item_update_interval_idx'), - ] - triggers = generates_item_triggers() - - name = models.CharField('id', blank=False, max_length=255, validators=[validate_name]) - collection = models.ForeignKey( - Collection, on_delete=models.PROTECT, help_text=_(SEARCH_TEXT_HELP_COLLECTION) - ) - geometry = models.GeometryField( - null=False, blank=False, default=BBOX_CH, srid=4326, validators=[validate_geometry] - ) - created = models.DateTimeField(auto_now_add=True) - # NOTE: the updated field is automatically updated by stac_api.pgtriggers, we use auto_now_add - # only for the initial value. - updated = models.DateTimeField(auto_now_add=True) - # after discussion with Chris and Tobias: for the moment only support - # proterties: datetime and title (the rest is hence commented out) - properties_datetime = models.DateTimeField( - blank=True, - null=True, - help_text="Enter date in yyyy-mm-dd format, and time in UTC hh:mm:ss format" - ) - properties_start_datetime = models.DateTimeField( - blank=True, - null=True, - help_text="Enter date in yyyy-mm-dd format, and time in UTC hh:mm:ss format" - ) - properties_end_datetime = models.DateTimeField( - blank=True, - null=True, - help_text="Enter date in yyyy-mm-dd format, and time in UTC hh:mm:ss format" - ) - properties_expires = models.DateTimeField( - blank=True, - null=True, - help_text="Enter date in yyyy-mm-dd format, and time in UTC hh:mm:ss format" - ) - # properties_eo_bands = model.TextFields(blank=True) # ? [string]? - # properties_eo_cloud_cover = models.FloatField(blank=True) - # eo_gsd is defined on asset level and will be updated here on ever - # update of an asset inside this item. - # properties_instruments = models.TextField(blank=True) - # properties_license = models.TextField(blank=True) - # properties_platform = models.TextField(blank=True) - # properties_providers = models.ManyToManyField(Provider) - # Although it is discouraged by Django to set blank=True and null=True on a CharField, it is - # here required because this field is optional and having an empty string value is not permitted - # in the serializer and default to None. None value are then not displayed in the serializer. - properties_title = models.CharField(blank=True, null=True, max_length=255) - - # properties_view_off_nadir = models.FloatField(blank=True) - # properties_view_sun_azimuth = models.FloatField(blank=True) - # properties_view_elevation = models.FloatField(blank=True) - - # NOTE: hidden ETag field, this field is automatically updated by stac_api.pgtriggers - etag = models.CharField( - blank=False, null=False, editable=False, max_length=56, default=compute_etag - ) - - update_interval = models.IntegerField( - default=-1, - null=False, - blank=False, - validators=[MinValueValidator(-1)], - help_text="Minimal update interval in seconds " - "in which the underlying assets data are updated." - ) - - total_data_size = models.BigIntegerField(default=0, null=True, blank=True) - - forecast_reference_datetime = models.DateTimeField( - null=True, - blank=True, - help_text="The reference datetime: i.e. predictions for times after " - "this point occur in the future. Predictions prior to this " - "time represent 'hindcasts', predicting states that have " - "already occurred. This must be in UTC. It is formatted like " - "'2022-08-12T00:00:00Z'." - ) - - forecast_horizon = models.DurationField( - null=True, - blank=True, - help_text="The time between the reference datetime and the forecast datetime." - "Formatted as ISO 8601 duration, e.g. 'PT6H' for a 6-hour forecast.", - ) - - forecast_duration = models.DurationField( - null=True, - blank=True, - help_text="If the forecast is not only for a specific instance in time " - "but instead is for a certain period, you can specify the " - "length here. Formatted as ISO 8601 duration, e.g. 'PT3H' for a 3-hour " - "accumulation. If not given, assumes that the forecast is for an " - "instance in time as if this was set to PT0S (0 seconds).", - ) - - forecast_variable = models.CharField( - null=True, - blank=True, - help_text="Name of the model variable that corresponds to the data. The variables " - "should correspond to the CF Standard Names, " - "e.g. `air_temperature` for the air temperature." - ) - - forecast_perturbed = models.BooleanField( - null=True, - blank=True, - default=None, - help_text="Denotes whether the data corresponds to the control run (`false`) or " - "perturbed runs (`true`). The property needs to be specified in both " - "cases as no default value is specified and as such the meaning is " - "\"unknown\" in case it's missing." - ) - - # Custom Manager that preselects the collection - objects = ItemManager() - - def __str__(self): - # This is used in the admin page in the autocomplete_fields of the Asset page - return f"{self.collection.name}/{self.name}" - - def clean(self): - validate_item_properties_datetimes( - self.properties_datetime, - self.properties_start_datetime, - self.properties_end_datetime, - self.properties_expires, - ) - - -class ItemLink(Link): - item = models.ForeignKey( - Item, related_name='links', related_query_name='link', on_delete=models.CASCADE - ) - - class Meta: - unique_together = (('rel', 'item'),) - ordering = ['pk'] - triggers = child_triggers('item', 'ItemLink') - - -def upload_asset_to_path_hook(instance, filename=None): - '''This returns the asset upload path on S3 and compute the asset file multihash - - Args: - instance: Asset - Asset instance - filename: string - file name of the uploaded asset - - Returns: - Asset file path to use on S3 - ''' - item_name = 'no item on collection asset' - if hasattr(instance, 'item'): - item_name = instance.item.name - logger.debug( - 'Start computing asset file %s multihash (file size: %.1f MB)', - filename, - instance.file.size / 1024**2, - extra={ - "collection": instance.get_collection().name, "item": item_name, "asset": instance.name - } - ) - start = time.time() - ctx = hashlib.sha256() - for chunk in instance.file.chunks(settings.UPLOAD_FILE_CHUNK_SIZE): - ctx.update(chunk) - mhash = to_hex_string(multihash_encode(ctx.digest(), 'sha2-256')) - # set the hash to the storage to use it for upload signing, this temporary attribute is - # then used by storages.S3Storage to set the MetaData.sha256 - instance.file.storage.object_sha256 = ctx.hexdigest() - # Same here for the update_interval that is used by the storages.S3Storage to set the asset's - # update_interval - instance.file.storage.update_interval = instance.update_interval - logger.debug( - 'Set uploaded file %s multihash %s to file:checksum; computation done in %.3fs', - filename, - mhash, - time.time() - start, - extra={ - "collection": instance.get_collection().name, "item": item_name, "asset": instance.name - } - ) - instance.checksum_multihash = mhash - instance.file_size = instance.file.size - return instance.get_asset_path() - - -class DynamicStorageFileField(models.FileField): - - def pre_save(self, model_instance: "AssetBase", add): - """Determine the storage to use for this file - - The storage is determined by the collection's name. See - settings.MANAGED_BUCKET_COLLECTION_PATTERNS - """ - collection = model_instance.get_collection() - - bucket = select_s3_bucket(collection.name).name - - # We need to explicitly instantiate the storage backend here - # Since the backends are configured as strings in the settings, we take these strings - # and import them by those string - # Example is stac_api.storages.LegacyS3Storage - parts = settings.STORAGES[bucket]['BACKEND'].split(".") - - # join the first two parts of the module name together -> stac_api.storages - storage_module_name = ".".join(parts[:-1]) - - # the name of the storage class is the last part -> LegacyS3Storage - storage_cls_name = parts[-1:] - - # import the module - storage_module = __import__(storage_module_name, fromlist=[parts[-2:-1]]) - - # get the class from the module - storage_cls = getattr(storage_module, storage_cls_name[0]) - - # .. and instantiate! - self.storage = storage_cls() - - # we need to specify the storage for the actual - # file as well - model_instance.file.storage = self.storage - - self.storage.asset_content_type = model_instance.media_type - - return super().pre_save(model_instance, add) - - -class AssetBase(models.Model): - - class Meta: - abstract = True - - # using BigIntegerField as primary_key to deal with the expected large number of assets. - id = models.BigAutoField(primary_key=True) - - # using "name" instead of "id", as "id" has a default meaning in django - name = models.CharField('id', max_length=255, validators=[validate_asset_name]) - - # Disable weird pylint errors, where it doesn't take the inherited constructor - # into account when linting, somehow - # pylint: disable=unexpected-keyword-arg - # pylint: disable=no-value-for-parameter - file = DynamicStorageFileField(upload_to=upload_asset_to_path_hook, max_length=255) - roles = ArrayField( - models.CharField(max_length=255), editable=True, blank=True, null=True, default=None, - help_text=_("Comma-separated list of roles to describe the purpose of the asset")) - - @property - def filename(self): - return os.path.basename(self.file.name) - - # From v1 on the json representation of this field changed from "checksum:multihash" to - # "file:checksum". The two names may be used interchangeably for a now. - checksum_multihash = models.CharField( - editable=False, max_length=255, blank=True, null=True, default=None - ) - # here we need to set blank=True otherwise the field is as required in the admin interface - description = models.TextField(blank=True, null=True, default=None) - - proj_epsg = models.IntegerField(null=True, blank=True) - # here we need to set blank=True otherwise the field is as required in the admin interface - title = models.CharField(max_length=255, null=True, blank=True) - media_choices = [ - (x.media_type_str, f'{x.description} ({x.media_type_str})') for x in MEDIA_TYPES - ] - media_type = models.CharField( - choices=media_choices, - max_length=200, - blank=False, - null=False, - help_text= - "This media type will be used as Content-Type header for the asset's object upon " - "upload.

" - "WARNING: when updating the Media Type, the asset's object Content-Type header is not " - "automatically updated, it needs to be uploaded again." - ) - - created = models.DateTimeField(auto_now_add=True) - # NOTE: the updated field is automatically updated by stac_api.pgtriggers, we use auto_now_add - # only for the initial value. - updated = models.DateTimeField(auto_now_add=True) - - # NOTE: hidden ETag field, this field is automatically updated by stac_api.pgtriggers - etag = models.CharField( - blank=False, null=False, editable=False, max_length=56, default=compute_etag - ) - - update_interval = models.IntegerField( - default=-1, - null=False, - blank=False, - validators=[MinValueValidator(-1)], - help_text="Interval in seconds in which the asset data is updated." - "-1 means that the data is not on a regular basis updated." - ) - - file_size = models.BigIntegerField(default=0, null=True, blank=True) - - def __str__(self): - return self.name - - def delete(self, *args, **kwargs): # pylint: disable=signature-differs - try: - super().delete(*args, **kwargs) - except ProtectedError as error: - logger.error('Cannot delete asset %s: %s', self.name, error) - raise ValidationError(error.args[0]) from None - - def clean(self): - # Although the media type is already validated, it still needs to be validated a second - # time as the clean method is run even if the field validation failed and there is no way - # to check what errors were already raised. - media_type = validate_media_type(self.media_type) - validate_asset_name_with_media_type(self.name, media_type) - - -class Asset(AssetBase): - - class Meta: - unique_together = (('item', 'name'),) - ordering = ['id'] - triggers = generates_asset_triggers() - - item = models.ForeignKey( - Item, - related_name='assets', - related_query_name='asset', - on_delete=models.PROTECT, - help_text=_(SEARCH_TEXT_HELP_ITEM) - ) - # From v1 on the json representation of this field changed from "eo:gsd" to "gsd". The two names - # may be used interchangeably for a now. - eo_gsd = models.FloatField(null=True, blank=True, validators=[validate_eo_gsd]) - - class Language(models.TextChoices): - # pylint: disable=invalid-name - GERMAN = 'de', _('German') - ITALIAN = 'it', _('Italian') - FRENCH = 'fr', _('French') - ROMANSH = 'rm', _('Romansh') - ENGLISH = 'en', _('English') - __empty__ = _('') - - # here we need to set blank=True otherwise the field is as required in the admin interface - geoadmin_lang = models.CharField( - max_length=2, choices=Language.choices, default=None, null=True, blank=True - ) - # here we need to set blank=True otherwise the field is as required in the admin interface - geoadmin_variant = models.CharField( - max_length=25, null=True, blank=True, validators=[validate_geoadmin_variant] - ) - - # whether this asset is hosted externally - is_external = models.BooleanField( - default=False, - help_text=_("Whether this asset is hosted externally") - ) - - def get_collection(self): - return self.item.collection - - def get_asset_path(self): - return get_asset_path(self.item, self.name) - - -class CollectionAsset(AssetBase): - - class Meta: - unique_together = (('collection', 'name'),) - ordering = ['id'] - triggers = generates_collection_asset_triggers() - - collection = models.ForeignKey( - Collection, - related_name='assets', - related_query_name='asset', - on_delete=models.PROTECT, - help_text=_(SEARCH_TEXT_HELP_ITEM) - ) - - # CollectionAssets are never external - is_external = False - - def get_collection(self): - return self.collection - - def get_asset_path(self): - return get_collection_asset_path(self.collection, self.name) - - -class BaseAssetUpload(models.Model): - - class Meta: - abstract = True - - class Status(models.TextChoices): - # pylint: disable=invalid-name - IN_PROGRESS = 'in-progress' - COMPLETED = 'completed' - ABORTED = 'aborted' - __empty__ = '' - - class ContentEncoding(models.TextChoices): - # pylint: disable=invalid-name - GZIP = 'gzip' - BR = 'br' - # DEFLATE = 'deflate' - # COMPRESS = 'compress' - __empty__ = '' - - # using BigIntegerField as primary_key to deal with the expected large number of assets. - id = models.BigAutoField(primary_key=True) - upload_id = models.CharField(max_length=255, blank=False, null=False) - status = models.CharField( - choices=Status.choices, max_length=32, default=Status.IN_PROGRESS, blank=False, null=False - ) - number_parts = models.IntegerField( - validators=[MinValueValidator(1), MaxValueValidator(100)], null=False, blank=False - ) # S3 doesn't support more that 10'000 parts - md5_parts = models.JSONField(encoder=DjangoJSONEncoder, editable=False) - urls = models.JSONField(default=list, encoder=DjangoJSONEncoder, blank=True) - created = models.DateTimeField(auto_now_add=True) - ended = models.DateTimeField(blank=True, null=True, default=None) - # From v1 on the json representation of this field changed from "checksum:multihash" to - # "file:checksum". The two names may be used interchangeably for a now. - checksum_multihash = models.CharField(max_length=255, blank=False, null=False) - - # NOTE: hidden ETag field, this field is automatically updated by stac_api.pgtriggers - etag = models.CharField(blank=False, null=False, max_length=56, default=compute_etag) - - update_interval = models.IntegerField( - default=-1, - null=False, - blank=False, - validators=[MinValueValidator(-1)], - help_text="Interval in seconds in which the asset data is updated. " - "-1 means that the data is not on a regular basis updated. " - "This field can only be set via the API." - ) - - file_size = models.BigIntegerField(default=0, null=True, blank=True) - - content_encoding = models.CharField( - choices=ContentEncoding.choices, blank=True, null=False, max_length=32, default='' - ) - - # Custom Manager that preselects the collection - objects = AssetUploadManager() - - -class AssetUpload(BaseAssetUpload): - - class Meta: - constraints = [ - models.UniqueConstraint(fields=['asset', 'upload_id'], name='unique_together'), - # Make sure that there is only one asset upload in progress per asset - models.UniqueConstraint( - fields=['asset', 'status'], - condition=Q(status='in-progress'), - name='unique_in_progress' - ) - ] - triggers = generates_asset_upload_triggers() - - asset = models.ForeignKey(Asset, related_name='+', on_delete=models.CASCADE) - - def update_asset_from_upload(self): - '''Updating the asset's file:checksum and update_interval from the upload - - When the upload is completed, the new file:checksum and update interval from the upload - is set to its asset parent. - ''' - logger.debug( - 'Updating asset %s file:checksum from %s to %s and update_interval from %d to %d ' - 'due to upload complete', - self.asset.name, - self.asset.checksum_multihash, - self.checksum_multihash, - self.asset.update_interval, - self.update_interval, - extra={ - 'upload_id': self.upload_id, - 'asset': self.asset.name, - 'item': self.asset.item.name, - 'collection': self.asset.item.collection.name - } - ) - - self.asset.checksum_multihash = self.checksum_multihash - self.asset.update_interval = self.update_interval - self.asset.file_size = self.file_size - self.asset.save() - - -class CollectionAssetUpload(BaseAssetUpload): - - class Meta: - constraints = [ - models.UniqueConstraint( - fields=['asset', 'upload_id'], - name='unique_asset_upload_collection_asset_upload_id' - ), - # Make sure that there is only one upload in progress per collection asset - models.UniqueConstraint( - fields=['asset', 'status'], - condition=Q(status='in-progress'), - name='unique_asset_upload_in_progress' - ) - ] - triggers = generates_asset_upload_triggers() - - asset = models.ForeignKey(CollectionAsset, related_name='+', on_delete=models.CASCADE) - - def update_asset_from_upload(self): - '''Updating the asset's file:checksum and update_interval from the upload - - When the upload is completed, the new file:checksum and update interval from the upload - is set to its asset parent. - ''' - logger.debug( - 'Updating collection asset %s file:checksum from %s to %s and update_interval ' - 'from %d to %d due to upload complete', - self.asset.name, - self.asset.checksum_multihash, - self.checksum_multihash, - self.asset.update_interval, - self.update_interval, - extra={ - 'upload_id': self.upload_id, - 'asset': self.asset.name, - 'collection': self.asset.collection.name - } - ) - - self.asset.checksum_multihash = self.checksum_multihash - self.asset.update_interval = self.update_interval - self.asset.file_size = self.file_size - self.asset.save() - - -class CountBase(models.Model): - '''CountBase tables are used to help calculate the summary on a collection. - This is only performant if the distinct number of values is small, e.g. we currently only have - 5 possible values for geoadmin_language. - For each assets value we keep a count of how often that value exists, per collection. On - insert/update/delete of an asset we only need to decrease and/or increase the counter value. - Since the number of possible values is small, the aggregate array calculation to update the - collection also stays performant. - ''' - - class Meta: - abstract = True - - id = models.BigAutoField(primary_key=True) - collection = models.ForeignKey( - Collection, - related_name='+', - on_delete=models.CASCADE, - ) - count = models.PositiveIntegerField(null=False) - - -class GSDCount(CountBase): - # Update by asset triggers. - - class Meta: - unique_together = (('collection', 'value'),) - ordering = ['id'] - triggers = generates_summary_count_triggers( - SummaryFields.GSD.value[0], SummaryFields.GSD.value[1] - ) - - value = models.FloatField(null=True, blank=True) - - -class GeoadminLangCount(CountBase): - # Update by asset triggers. - - class Meta: - unique_together = (('collection', 'value'),) - ordering = ['id'] - triggers = generates_summary_count_triggers( - SummaryFields.LANGUAGE.value[0], SummaryFields.LANGUAGE.value[1] - ) - - value = models.CharField(max_length=2, default=None, null=True, blank=True) - - -class GeoadminVariantCount(CountBase): - # Update by asset triggers. - - class Meta: - unique_together = (('collection', 'value'),) - ordering = ['id'] - triggers = generates_summary_count_triggers( - SummaryFields.VARIANT.value[0], SummaryFields.VARIANT.value[1] - ) - - value = models.CharField(max_length=25, null=True, blank=True) - - -class ProjEPSGCount(CountBase): - # Update by asset and collection asset triggers. - - class Meta: - unique_together = (('collection', 'value'),) - ordering = ['id'] - triggers = generates_summary_count_triggers( - SummaryFields.PROJ_EPSG.value[0], SummaryFields.PROJ_EPSG.value[1] - ) - - value = models.IntegerField(null=True, blank=True) diff --git a/app/stac_api/models/__init__.py b/app/stac_api/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/stac_api/models/collection.py b/app/stac_api/models/collection.py new file mode 100644 index 00000000..efb0bf3e --- /dev/null +++ b/app/stac_api/models/collection.py @@ -0,0 +1,264 @@ +import logging + +from django.contrib.gis.db import models +from django.contrib.postgres.fields import ArrayField +from django.core.serializers.json import DjangoJSONEncoder +from django.core.validators import MinValueValidator +from django.db.models import Q +from django.utils.translation import gettext_lazy as _ + +from stac_api.models.general import SEARCH_TEXT_HELP_ITEM +from stac_api.models.general import AssetBase +from stac_api.models.general import BaseAssetUpload +from stac_api.models.general import Link +from stac_api.models.general import compute_etag +from stac_api.pgtriggers import SummaryFields +from stac_api.pgtriggers import child_triggers +from stac_api.pgtriggers import generates_asset_upload_triggers +from stac_api.pgtriggers import generates_collection_asset_triggers +from stac_api.pgtriggers import generates_collection_triggers +from stac_api.pgtriggers import generates_summary_count_triggers +from stac_api.utils import get_collection_asset_path +from stac_api.validators import validate_name + +logger = logging.getLogger(__name__) + + +# For Collections and Items: No primary key will be defined, so that the auto-generated ones +# will be used by Django. For assets, a primary key is defined as "BigAutoField" due the +# expected large number of assets +class Collection(models.Model): + + class Meta: + indexes = [ + models.Index(fields=['name'], name='collection_name_idx'), + models.Index(fields=['published'], name='collection_published_idx') + ] + triggers = generates_collection_triggers() + + published = models.BooleanField( + default=True, + help_text="When not published the collection doesn't appear on the " + "api/stac/v0.9/collections endpoint and its items are not listed in /search endpoint." + "

NOTE: unpublished collections/items can still be accessed by their path.

" + ) + # using "name" instead of "id", as "id" has a default meaning in django + name = models.CharField('id', unique=True, max_length=255, validators=[validate_name]) + created = models.DateTimeField(auto_now_add=True) + # NOTE: the updated field is automatically updated by stac_api.pgtriggers, we use auto_now_add + # only for the initial value. + updated = models.DateTimeField(auto_now_add=True) + description = models.TextField() + # Set to true if the extent needs to be recalculated. + extent_out_of_sync = models.BooleanField(default=False) + extent_geometry = models.GeometryField( + default=None, srid=4326, editable=False, blank=True, null=True + ) + extent_start_datetime = models.DateTimeField(editable=False, null=True, blank=True) + extent_end_datetime = models.DateTimeField(editable=False, null=True, blank=True) + + license = models.CharField(max_length=30) # string + + # DEPRECATED: summaries JSON field is not used anymore and not up to date, it will be removed + # in future. It has been replaced by summaries_proj_epsg, summaries_eo_gsd and + # summaries_geoadmin_variant + summaries = models.JSONField(default=dict, encoder=DjangoJSONEncoder, editable=False) + + # NOTE: the following summaries_* fields are automatically update by the stac_api.pgtriggers + summaries_proj_epsg = ArrayField( + models.IntegerField(), default=list, blank=True, editable=False + ) + summaries_eo_gsd = ArrayField(models.FloatField(), default=list, blank=True, editable=False) + summaries_geoadmin_variant = ArrayField( + models.CharField(max_length=25), default=list, blank=True, editable=False + ) + summaries_geoadmin_lang = ArrayField( + models.CharField(max_length=2), default=list, blank=True, editable=False + ) + + title = models.CharField(blank=True, null=True, max_length=255) + + # NOTE: hidden ETag field, this field is automatically updated by stac_api.pgtriggers + etag = models.CharField( + blank=False, null=False, editable=False, max_length=56, default=compute_etag + ) + + update_interval = models.IntegerField( + default=-1, + null=False, + blank=False, + validators=[MinValueValidator(-1)], + help_text="Minimal update interval in seconds " + "in which the underlying assets data are updated." + ) + + total_data_size = models.BigIntegerField(default=0, null=True, blank=True) + + allow_external_assets = models.BooleanField( + default=False, + help_text=_('Whether this collection can have assets that are hosted externally') + ) + + external_asset_whitelist = ArrayField( + models.CharField(max_length=255), blank=True, default=list, + help_text=_('Provide a comma separated list of ' + 'protocol://domain values for the external asset url validation') + ) + + def __str__(self): + return self.name + + +class CollectionLink(Link): + collection = models.ForeignKey( + Collection, related_name='links', related_query_name='link', on_delete=models.CASCADE + ) + + class Meta: + ordering = ['pk'] + triggers = child_triggers('collection', 'CollectionLink') + + +class CollectionAsset(AssetBase): + + class Meta: + unique_together = (('collection', 'name'),) + ordering = ['id'] + triggers = generates_collection_asset_triggers() + + collection = models.ForeignKey( + Collection, + related_name='assets', + related_query_name='asset', + on_delete=models.PROTECT, + help_text=_(SEARCH_TEXT_HELP_ITEM) + ) + + # CollectionAssets are never external + is_external = False + + def get_collection(self): + return self.collection + + def get_asset_path(self): + return get_collection_asset_path(self.collection, self.name) + + +class CollectionAssetUpload(BaseAssetUpload): + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=['asset', 'upload_id'], + name='unique_asset_upload_collection_asset_upload_id' + ), + # Make sure that there is only one upload in progress per collection asset + models.UniqueConstraint( + fields=['asset', 'status'], + condition=Q(status='in-progress'), + name='unique_asset_upload_in_progress' + ) + ] + triggers = generates_asset_upload_triggers() + + asset = models.ForeignKey(CollectionAsset, related_name='+', on_delete=models.CASCADE) + + def update_asset_from_upload(self): + '''Updating the asset's file:checksum and update_interval from the upload + + When the upload is completed, the new file:checksum and update interval from the upload + is set to its asset parent. + ''' + logger.debug( + 'Updating collection asset %s file:checksum from %s to %s and update_interval ' + 'from %d to %d due to upload complete', + self.asset.name, + self.asset.checksum_multihash, + self.checksum_multihash, + self.asset.update_interval, + self.update_interval, + extra={ + 'upload_id': self.upload_id, + 'asset': self.asset.name, + 'collection': self.asset.collection.name + } + ) + + self.asset.checksum_multihash = self.checksum_multihash + self.asset.update_interval = self.update_interval + self.asset.file_size = self.file_size + self.asset.save() + + +class CountBase(models.Model): + '''CountBase tables are used to help calculate the summary on a collection. + This is only performant if the distinct number of values is small, e.g. we currently only have + 5 possible values for geoadmin_language. + For each assets value we keep a count of how often that value exists, per collection. On + insert/update/delete of an asset we only need to decrease and/or increase the counter value. + Since the number of possible values is small, the aggregate array calculation to update the + collection also stays performant. + ''' + + class Meta: + abstract = True + + id = models.BigAutoField(primary_key=True) + collection = models.ForeignKey( + Collection, + related_name='+', + on_delete=models.CASCADE, + ) + count = models.PositiveIntegerField(null=False) + + +class GSDCount(CountBase): + # Update by asset triggers. + + class Meta: + unique_together = (('collection', 'value'),) + ordering = ['id'] + triggers = generates_summary_count_triggers( + SummaryFields.GSD.value[0], SummaryFields.GSD.value[1] + ) + + value = models.FloatField(null=True, blank=True) + + +class GeoadminLangCount(CountBase): + # Update by asset triggers. + + class Meta: + unique_together = (('collection', 'value'),) + ordering = ['id'] + triggers = generates_summary_count_triggers( + SummaryFields.LANGUAGE.value[0], SummaryFields.LANGUAGE.value[1] + ) + + value = models.CharField(max_length=2, default=None, null=True, blank=True) + + +class GeoadminVariantCount(CountBase): + # Update by asset triggers. + + class Meta: + unique_together = (('collection', 'value'),) + ordering = ['id'] + triggers = generates_summary_count_triggers( + SummaryFields.VARIANT.value[0], SummaryFields.VARIANT.value[1] + ) + + value = models.CharField(max_length=25, null=True, blank=True) + + +class ProjEPSGCount(CountBase): + # Update by asset and collection asset triggers. + + class Meta: + unique_together = (('collection', 'value'),) + ordering = ['id'] + triggers = generates_summary_count_triggers( + SummaryFields.PROJ_EPSG.value[0], SummaryFields.PROJ_EPSG.value[1] + ) + + value = models.IntegerField(null=True, blank=True) diff --git a/app/stac_api/models/general.py b/app/stac_api/models/general.py new file mode 100644 index 00000000..6849409a --- /dev/null +++ b/app/stac_api/models/general.py @@ -0,0 +1,450 @@ +import hashlib +import logging +import os +import time +from uuid import uuid4 + +from language_tags import tags +from multihash import encode as multihash_encode +from multihash import to_hex_string + +from django.conf import settings +from django.contrib.gis.db import models +from django.contrib.gis.geos import Polygon +from django.contrib.postgres.fields import ArrayField +from django.core.exceptions import ValidationError +from django.core.serializers.json import DjangoJSONEncoder +from django.core.validators import MaxValueValidator +from django.core.validators import MinValueValidator +from django.db.models.deletion import ProtectedError +from django.utils.translation import gettext_lazy as _ + +from stac_api.managers import AssetUploadManager +from stac_api.pgtriggers import child_triggers +from stac_api.utils import select_s3_bucket +from stac_api.validators import MEDIA_TYPES +from stac_api.validators import validate_asset_name +from stac_api.validators import validate_asset_name_with_media_type +from stac_api.validators import validate_link_rel +from stac_api.validators import validate_media_type +from stac_api.validators import validate_name + +logger = logging.getLogger(__name__) + +# We use the WGS84 bounding box as defined here: +# https://epsg.io/2056 +_BBOX_CH = Polygon.from_bbox((5.96, 45.82, 10.49, 47.81)) +_BBOX_CH.srid = 4326 +# equal to +# 'SRID=4326;POLYGON ((5.96 45.82, 5.96 47.81, 10.49 47.81, 10.49 45.82, 5.96 45.82))' +BBOX_CH = str(_BBOX_CH) + +SEARCH_TEXT_HELP_ITEM = ''' +
+ Search Usage: +
    +
  • + arg will make a non exact search checking if >arg + is part of the Item path +
  • +
  • + Multiple arg can be used, separated by spaces. This will search + for all elements containing all arguments in their path +
  • +
  • + "collectionID/itemID" will make an exact search for the specified item. +
  • +
+ Examples : +
    +
  • + Searching for pixelkarte will return all items which have + pixelkarte as a part of either their collection ID or their item ID +
  • +
  • + Searching for pixelkarte 2016 4 will return all items + which have pixelkarte, 2016 AND 4 as part of their collection ID or + item ID +
  • +
  • + Searching for "ch.swisstopo.pixelkarte.example/item2016-4-example" + will yield only this item, if this item exists. +
  • +
+
''' + + +def get_conformance_default_links(): + '''A helper function of the class Conformance Page + + The function makes it possible to define the default values as a callable + Returns: + a list of urls + ''' + default_links = ( + 'https://api.stacspec.org/v1.0.0/core', + 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core', + 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/oas30', + 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson' + ) + return default_links + + +def get_default_summaries_value(): + return {} + + +def compute_etag(): + '''Compute a unique ETag''' + return str(uuid4()) + + +class Link(models.Model): + href = models.URLField(max_length=2048) + rel = models.CharField(max_length=30, validators=[validate_link_rel]) + # added link_ to the fieldname, as "type" is reserved + link_type = models.CharField(blank=True, null=True, max_length=150) + title = models.CharField(blank=True, null=True, max_length=255) + hreflang = models.CharField(blank=True, null=True, max_length=32) + + class Meta: + abstract = True + + def __str__(self): + return f'{self.rel}: {self.href}' + + def save(self, *args, **kwargs) -> None: + """Validate the hreflang""" + self.full_clean() + + if self.hreflang is not None and self.hreflang != '' and not tags.check(self.hreflang): + raise ValidationError(_(", ".join([v.message for v in tags.tag(self.hreflang).errors]))) + + super().save(*args, **kwargs) + + +class LandingPage(models.Model): + # using "name" instead of "id", as "id" has a default meaning in django + name = models.CharField( + 'id', unique=False, max_length=255, validators=[validate_name], default='ch' + ) + title = models.CharField(max_length=255, default='data.geo.admin.ch') + description = models.TextField( + default='Data Catalog of the Swiss Federal Spatial Data Infrastructure' + ) + version = models.CharField(max_length=255, default='v1') + + conformsTo = ArrayField( # pylint: disable=invalid-name + models.URLField( + blank=False, + null=False + ), + default=get_conformance_default_links, + help_text=_("Comma-separated list of URLs for the value conformsTo")) + + def __str__(self): + return f'STAC Landing Page {self.version}' + + class Meta: + verbose_name = "STAC Landing Page" + + +class LandingPageLink(Link): + landing_page = models.ForeignKey( + LandingPage, related_name='links', related_query_name='link', on_delete=models.CASCADE + ) + + class Meta: + unique_together = (('rel', 'landing_page')) + ordering = ['pk'] + + +class Provider(models.Model): + collection = models.ForeignKey( + 'stac_api.Collection', + on_delete=models.CASCADE, + related_name='providers', + related_query_name='provider' + ) + name = models.CharField(blank=False, max_length=200) + description = models.TextField(blank=True, null=True, default=None) + # possible roles are licensor, producer, processor or host + allowed_roles = ['licensor', 'producer', 'processor', 'host'] + roles = ArrayField( + models.CharField(max_length=9), + help_text=_( + f"Comma-separated list of roles. Possible values are {', '.join(allowed_roles)}" + ), + blank=True, + null=True, + ) + url = models.URLField(blank=True, null=True, max_length=2048) + + class Meta: + unique_together = (('collection', 'name'),) + ordering = ['pk'] + triggers = child_triggers('collection', 'Provider') + + def __str__(self): + return self.name + + def clean(self): + if self.roles is None: + return + for role in self.roles: + if role not in self.allowed_roles: + logger.error( + 'Invalid provider role %s', role, extra={'collection': self.collection.name} + ) + raise ValidationError( + _('Invalid role, must be in %(roles)s'), + params={'roles': self.allowed_roles}, + code='roles' + ) + + +ITEM_KEEP_ORIGINAL_FIELDS = [ + 'geometry', + 'properties_datetime', + 'properties_start_datetime', + 'properties_end_datetime', +] + + +def upload_asset_to_path_hook(instance, filename=None): + '''This returns the asset upload path on S3 and compute the asset file multihash + + Args: + instance: Asset + Asset instance + filename: string + file name of the uploaded asset + + Returns: + Asset file path to use on S3 + ''' + item_name = 'no item on collection asset' + if hasattr(instance, 'item'): + item_name = instance.item.name + logger.debug( + 'Start computing asset file %s multihash (file size: %.1f MB)', + filename, + instance.file.size / 1024**2, + extra={ + "collection": instance.get_collection().name, "item": item_name, "asset": instance.name + } + ) + start = time.time() + ctx = hashlib.sha256() + for chunk in instance.file.chunks(settings.UPLOAD_FILE_CHUNK_SIZE): + ctx.update(chunk) + mhash = to_hex_string(multihash_encode(ctx.digest(), 'sha2-256')) + # set the hash to the storage to use it for upload signing, this temporary attribute is + # then used by storages.S3Storage to set the MetaData.sha256 + instance.file.storage.object_sha256 = ctx.hexdigest() + # Same here for the update_interval that is used by the storages.S3Storage to set the asset's + # update_interval + instance.file.storage.update_interval = instance.update_interval + logger.debug( + 'Set uploaded file %s multihash %s to file:checksum; computation done in %.3fs', + filename, + mhash, + time.time() - start, + extra={ + "collection": instance.get_collection().name, "item": item_name, "asset": instance.name + } + ) + instance.checksum_multihash = mhash + instance.file_size = instance.file.size + return instance.get_asset_path() + + +class DynamicStorageFileField(models.FileField): + + def pre_save(self, model_instance: "AssetBase", add): + """Determine the storage to use for this file + + The storage is determined by the collection's name. See + settings.MANAGED_BUCKET_COLLECTION_PATTERNS + """ + collection = model_instance.get_collection() + + bucket = select_s3_bucket(collection.name).name + + # We need to explicitly instantiate the storage backend here + # Since the backends are configured as strings in the settings, we take these strings + # and import them by those string + # Example is stac_api.storages.LegacyS3Storage + parts = settings.STORAGES[bucket]['BACKEND'].split(".") + + # join the first two parts of the module name together -> stac_api.storages + storage_module_name = ".".join(parts[:-1]) + + # the name of the storage class is the last part -> LegacyS3Storage + storage_cls_name = parts[-1:] + + # import the module + storage_module = __import__(storage_module_name, fromlist=[parts[-2:-1]]) + + # get the class from the module + storage_cls = getattr(storage_module, storage_cls_name[0]) + + # .. and instantiate! + self.storage = storage_cls() + + # we need to specify the storage for the actual + # file as well + model_instance.file.storage = self.storage + + self.storage.asset_content_type = model_instance.media_type + + return super().pre_save(model_instance, add) + + +class AssetBase(models.Model): + + class Meta: + abstract = True + + # using BigIntegerField as primary_key to deal with the expected large number of assets. + id = models.BigAutoField(primary_key=True) + + # using "name" instead of "id", as "id" has a default meaning in django + name = models.CharField('id', max_length=255, validators=[validate_asset_name]) + + # Disable weird pylint errors, where it doesn't take the inherited constructor + # into account when linting, somehow + # pylint: disable=unexpected-keyword-arg + # pylint: disable=no-value-for-parameter + file = DynamicStorageFileField(upload_to=upload_asset_to_path_hook, max_length=255) + roles = ArrayField( + models.CharField(max_length=255), editable=True, blank=True, null=True, default=None, + help_text=_("Comma-separated list of roles to describe the purpose of the asset")) + + @property + def filename(self): + return os.path.basename(self.file.name) + + # From v1 on the json representation of this field changed from "checksum:multihash" to + # "file:checksum". The two names may be used interchangeably for a now. + checksum_multihash = models.CharField( + editable=False, max_length=255, blank=True, null=True, default=None + ) + # here we need to set blank=True otherwise the field is as required in the admin interface + description = models.TextField(blank=True, null=True, default=None) + + proj_epsg = models.IntegerField(null=True, blank=True) + # here we need to set blank=True otherwise the field is as required in the admin interface + title = models.CharField(max_length=255, null=True, blank=True) + media_choices = [ + (x.media_type_str, f'{x.description} ({x.media_type_str})') for x in MEDIA_TYPES + ] + media_type = models.CharField( + choices=media_choices, + max_length=200, + blank=False, + null=False, + help_text= + "This media type will be used as Content-Type header for the asset's object upon " + "upload.

" + "WARNING: when updating the Media Type, the asset's object Content-Type header is not " + "automatically updated, it needs to be uploaded again." + ) + + created = models.DateTimeField(auto_now_add=True) + # NOTE: the updated field is automatically updated by stac_api.pgtriggers, we use auto_now_add + # only for the initial value. + updated = models.DateTimeField(auto_now_add=True) + + # NOTE: hidden ETag field, this field is automatically updated by stac_api.pgtriggers + etag = models.CharField( + blank=False, null=False, editable=False, max_length=56, default=compute_etag + ) + + update_interval = models.IntegerField( + default=-1, + null=False, + blank=False, + validators=[MinValueValidator(-1)], + help_text="Interval in seconds in which the asset data is updated." + "-1 means that the data is not on a regular basis updated." + ) + + file_size = models.BigIntegerField(default=0, null=True, blank=True) + + def __str__(self): + return self.name + + def delete(self, *args, **kwargs): # pylint: disable=signature-differs + try: + super().delete(*args, **kwargs) + except ProtectedError as error: + logger.error('Cannot delete asset %s: %s', self.name, error) + raise ValidationError(error.args[0]) from None + + def clean(self): + # Although the media type is already validated, it still needs to be validated a second + # time as the clean method is run even if the field validation failed and there is no way + # to check what errors were already raised. + media_type = validate_media_type(self.media_type) + validate_asset_name_with_media_type(self.name, media_type) + + +class BaseAssetUpload(models.Model): + + class Meta: + abstract = True + + class Status(models.TextChoices): + # pylint: disable=invalid-name + IN_PROGRESS = 'in-progress' + COMPLETED = 'completed' + ABORTED = 'aborted' + __empty__ = '' + + class ContentEncoding(models.TextChoices): + # pylint: disable=invalid-name + GZIP = 'gzip' + BR = 'br' + # DEFLATE = 'deflate' + # COMPRESS = 'compress' + __empty__ = '' + + # using BigIntegerField as primary_key to deal with the expected large number of assets. + id = models.BigAutoField(primary_key=True) + upload_id = models.CharField(max_length=255, blank=False, null=False) + status = models.CharField( + choices=Status.choices, max_length=32, default=Status.IN_PROGRESS, blank=False, null=False + ) + number_parts = models.IntegerField( + validators=[MinValueValidator(1), MaxValueValidator(100)], null=False, blank=False + ) # S3 doesn't support more that 10'000 parts + md5_parts = models.JSONField(encoder=DjangoJSONEncoder, editable=False) + urls = models.JSONField(default=list, encoder=DjangoJSONEncoder, blank=True) + created = models.DateTimeField(auto_now_add=True) + ended = models.DateTimeField(blank=True, null=True, default=None) + # From v1 on the json representation of this field changed from "checksum:multihash" to + # "file:checksum". The two names may be used interchangeably for a now. + checksum_multihash = models.CharField(max_length=255, blank=False, null=False) + + # NOTE: hidden ETag field, this field is automatically updated by stac_api.pgtriggers + etag = models.CharField(blank=False, null=False, max_length=56, default=compute_etag) + + update_interval = models.IntegerField( + default=-1, + null=False, + blank=False, + validators=[MinValueValidator(-1)], + help_text="Interval in seconds in which the asset data is updated. " + "-1 means that the data is not on a regular basis updated. " + "This field can only be set via the API." + ) + + file_size = models.BigIntegerField(default=0, null=True, blank=True) + + content_encoding = models.CharField( + choices=ContentEncoding.choices, blank=True, null=False, max_length=32, default='' + ) + + # Custom Manager that preselects the collection + objects = AssetUploadManager() diff --git a/app/stac_api/models/item.py b/app/stac_api/models/item.py new file mode 100644 index 00000000..7d3aae9d --- /dev/null +++ b/app/stac_api/models/item.py @@ -0,0 +1,327 @@ +import logging + +from django.contrib.gis.db import models +from django.core.validators import MinValueValidator +from django.db.models import Q +from django.utils.translation import gettext_lazy as _ + +from stac_api.managers import ItemManager +from stac_api.models.collection import Collection +from stac_api.models.general import BBOX_CH +from stac_api.models.general import SEARCH_TEXT_HELP_ITEM +from stac_api.models.general import AssetBase +from stac_api.models.general import BaseAssetUpload +from stac_api.models.general import Link +from stac_api.models.general import compute_etag +from stac_api.pgtriggers import child_triggers +from stac_api.pgtriggers import generates_asset_triggers +from stac_api.pgtriggers import generates_asset_upload_triggers +from stac_api.pgtriggers import generates_item_triggers +from stac_api.utils import get_asset_path +from stac_api.validators import validate_eo_gsd +from stac_api.validators import validate_geoadmin_variant +from stac_api.validators import validate_geometry +from stac_api.validators import validate_item_properties_datetimes +from stac_api.validators import validate_name + +logger = logging.getLogger(__name__) + +SEARCH_TEXT_HELP_COLLECTION = ''' +
+ Search Usage: +
    +
  • + arg will make a non exact search checking if arg is part of + the collection ID +
  • +
  • + Multiple arg can be used, separated by spaces. This will search for all + collections ID containing all arguments. +
  • +
  • + "collectionID" will make an exact search for the specified collection. +
  • +
+ Examples : +
    +
  • + Searching for pixelkarte will return all collections which have + pixelkarte as a part of their collection ID +
  • +
  • + Searching for pixelkarte 2016 4 will return all collection + which have pixelkarte, 2016 AND 4 as part of their collection ID +
  • +
  • + Searching for ch.swisstopo.pixelkarte.example will yield only this + collection, if this collection exists. Please note that it would not return + a collection named ch.swisstopo.pixelkarte.example.2. +
  • +
+
''' + + +class Item(models.Model): + + class Meta: + unique_together = (('collection', 'name'),) + indexes = [ + models.Index(fields=['name'], name='item_name_idx'), + # the following 3 indices are used e.g. in collection_temporal_extent + models.Index(fields=['properties_datetime'], name='item_datetime_idx'), + models.Index(fields=['properties_start_datetime'], name='item_start_datetime_idx'), + models.Index(fields=['properties_end_datetime'], name='item_end_datetime_idx'), + # created, updated, and title are "queryable" in the search endpoint + # see: views.py:322 and 323 + models.Index(fields=['created'], name='item_created_idx'), + models.Index(fields=['updated'], name='item_updated_idx'), + models.Index(fields=['properties_title'], name='item_title_idx'), + # forecast properties are "queryable" in the search endpoint + models.Index( + fields=['forecast_reference_datetime'], name='item_fc_reference_datetime_idx' + ), + models.Index(fields=['forecast_horizon'], name='item_fc_horizon_idx'), + 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'), + # combination of datetime and start_ and end_datetimes are used in + # managers.py:110 and following + models.Index( + fields=[ + 'properties_datetime', 'properties_start_datetime', 'properties_end_datetime' + ], + name='item_dttme_start_end_dttm_idx' + ), + models.Index(fields=['update_interval'], name='item_update_interval_idx'), + ] + triggers = generates_item_triggers() + + name = models.CharField('id', blank=False, max_length=255, validators=[validate_name]) + collection = models.ForeignKey( + Collection, on_delete=models.PROTECT, help_text=_(SEARCH_TEXT_HELP_COLLECTION) + ) + geometry = models.GeometryField( + null=False, blank=False, default=BBOX_CH, srid=4326, validators=[validate_geometry] + ) + created = models.DateTimeField(auto_now_add=True) + # NOTE: the updated field is automatically updated by stac_api.pgtriggers, we use auto_now_add + # only for the initial value. + updated = models.DateTimeField(auto_now_add=True) + # after discussion with Chris and Tobias: for the moment only support + # proterties: datetime and title (the rest is hence commented out) + properties_datetime = models.DateTimeField( + blank=True, + null=True, + help_text="Enter date in yyyy-mm-dd format, and time in UTC hh:mm:ss format" + ) + properties_start_datetime = models.DateTimeField( + blank=True, + null=True, + help_text="Enter date in yyyy-mm-dd format, and time in UTC hh:mm:ss format" + ) + properties_end_datetime = models.DateTimeField( + blank=True, + null=True, + help_text="Enter date in yyyy-mm-dd format, and time in UTC hh:mm:ss format" + ) + properties_expires = models.DateTimeField( + blank=True, + null=True, + help_text="Enter date in yyyy-mm-dd format, and time in UTC hh:mm:ss format" + ) + # properties_eo_bands = model.TextFields(blank=True) # ? [string]? + # properties_eo_cloud_cover = models.FloatField(blank=True) + # eo_gsd is defined on asset level and will be updated here on ever + # update of an asset inside this item. + # properties_instruments = models.TextField(blank=True) + # properties_license = models.TextField(blank=True) + # properties_platform = models.TextField(blank=True) + # properties_providers = models.ManyToManyField(Provider) + # Although it is discouraged by Django to set blank=True and null=True on a CharField, it is + # here required because this field is optional and having an empty string value is not permitted + # in the serializer and default to None. None value are then not displayed in the serializer. + properties_title = models.CharField(blank=True, null=True, max_length=255) + + # properties_view_off_nadir = models.FloatField(blank=True) + # properties_view_sun_azimuth = models.FloatField(blank=True) + # properties_view_elevation = models.FloatField(blank=True) + + # NOTE: hidden ETag field, this field is automatically updated by stac_api.pgtriggers + etag = models.CharField( + blank=False, null=False, editable=False, max_length=56, default=compute_etag + ) + + update_interval = models.IntegerField( + default=-1, + null=False, + blank=False, + validators=[MinValueValidator(-1)], + help_text="Minimal update interval in seconds " + "in which the underlying assets data are updated." + ) + + total_data_size = models.BigIntegerField(default=0, null=True, blank=True) + + forecast_reference_datetime = models.DateTimeField( + null=True, + blank=True, + help_text="The reference datetime: i.e. predictions for times after " + "this point occur in the future. Predictions prior to this " + "time represent 'hindcasts', predicting states that have " + "already occurred. This must be in UTC. It is formatted like " + "'2022-08-12T00:00:00Z'." + ) + + forecast_horizon = models.DurationField( + null=True, + blank=True, + help_text="The time between the reference datetime and the forecast datetime." + "Formatted as ISO 8601 duration, e.g. 'PT6H' for a 6-hour forecast.", + ) + + forecast_duration = models.DurationField( + null=True, + blank=True, + help_text="If the forecast is not only for a specific instance in time " + "but instead is for a certain period, you can specify the " + "length here. Formatted as ISO 8601 duration, e.g. 'PT3H' for a 3-hour " + "accumulation. If not given, assumes that the forecast is for an " + "instance in time as if this was set to PT0S (0 seconds).", + ) + + forecast_variable = models.CharField( + null=True, + blank=True, + help_text="Name of the model variable that corresponds to the data. The variables " + "should correspond to the CF Standard Names, " + "e.g. `air_temperature` for the air temperature." + ) + + forecast_perturbed = models.BooleanField( + null=True, + blank=True, + default=None, + help_text="Denotes whether the data corresponds to the control run (`false`) or " + "perturbed runs (`true`). The property needs to be specified in both " + "cases as no default value is specified and as such the meaning is " + "\"unknown\" in case it's missing." + ) + + # Custom Manager that preselects the collection + objects = ItemManager() + + def __str__(self): + # This is used in the admin page in the autocomplete_fields of the Asset page + return f"{self.collection.name}/{self.name}" + + def clean(self): + validate_item_properties_datetimes( + self.properties_datetime, + self.properties_start_datetime, + self.properties_end_datetime, + self.properties_expires, + ) + + +class ItemLink(Link): + item = models.ForeignKey( + Item, related_name='links', related_query_name='link', on_delete=models.CASCADE + ) + + class Meta: + unique_together = (('rel', 'item'),) + ordering = ['pk'] + triggers = child_triggers('item', 'ItemLink') + + +class Asset(AssetBase): + + class Meta: + unique_together = (('item', 'name'),) + ordering = ['id'] + triggers = generates_asset_triggers() + + item = models.ForeignKey( + Item, + related_name='assets', + related_query_name='asset', + on_delete=models.PROTECT, + help_text=_(SEARCH_TEXT_HELP_ITEM) + ) + # From v1 on the json representation of this field changed from "eo:gsd" to "gsd". The two names + # may be used interchangeably for a now. + eo_gsd = models.FloatField(null=True, blank=True, validators=[validate_eo_gsd]) + + class Language(models.TextChoices): + # pylint: disable=invalid-name + GERMAN = 'de', _('German') + ITALIAN = 'it', _('Italian') + FRENCH = 'fr', _('French') + ROMANSH = 'rm', _('Romansh') + ENGLISH = 'en', _('English') + __empty__ = _('') + + # here we need to set blank=True otherwise the field is as required in the admin interface + geoadmin_lang = models.CharField( + max_length=2, choices=Language.choices, default=None, null=True, blank=True + ) + # here we need to set blank=True otherwise the field is as required in the admin interface + geoadmin_variant = models.CharField( + max_length=25, null=True, blank=True, validators=[validate_geoadmin_variant] + ) + + # whether this asset is hosted externally + is_external = models.BooleanField( + default=False, + help_text=_("Whether this asset is hosted externally") + ) + + def get_collection(self): + return self.item.collection + + def get_asset_path(self): + return get_asset_path(self.item, self.name) + + +class AssetUpload(BaseAssetUpload): + + class Meta: + constraints = [ + models.UniqueConstraint(fields=['asset', 'upload_id'], name='unique_together'), + # Make sure that there is only one asset upload in progress per asset + models.UniqueConstraint( + fields=['asset', 'status'], + condition=Q(status='in-progress'), + name='unique_in_progress' + ) + ] + triggers = generates_asset_upload_triggers() + + asset = models.ForeignKey(Asset, related_name='+', on_delete=models.CASCADE) + + def update_asset_from_upload(self): + '''Updating the asset's file:checksum and update_interval from the upload + + When the upload is completed, the new file:checksum and update interval from the upload + is set to its asset parent. + ''' + logger.debug( + 'Updating asset %s file:checksum from %s to %s and update_interval from %d to %d ' + 'due to upload complete', + self.asset.name, + self.asset.checksum_multihash, + self.checksum_multihash, + self.asset.update_interval, + self.update_interval, + extra={ + 'upload_id': self.upload_id, + 'asset': self.asset.name, + 'item': self.asset.item.name, + 'collection': self.asset.item.collection.name + } + ) + + self.asset.checksum_multihash = self.checksum_multihash + self.asset.update_interval = self.update_interval + self.asset.file_size = self.file_size + self.asset.save() diff --git a/app/stac_api/s3_multipart_upload.py b/app/stac_api/s3_multipart_upload.py index a8983127..41950947 100644 --- a/app/stac_api/s3_multipart_upload.py +++ b/app/stac_api/s3_multipart_upload.py @@ -12,8 +12,8 @@ from rest_framework import serializers from stac_api.exceptions import UploadNotInProgressError -from stac_api.models import Asset -from stac_api.models import CollectionAsset +from stac_api.models.collection import CollectionAsset +from stac_api.models.item import Asset from stac_api.utils import AVAILABLE_S3_BUCKETS from stac_api.utils import get_s3_cache_control_value from stac_api.utils import get_s3_client diff --git a/app/stac_api/sample_data/importer.py b/app/stac_api/sample_data/importer.py index 3194a99b..74af6cec 100644 --- a/app/stac_api/sample_data/importer.py +++ b/app/stac_api/sample_data/importer.py @@ -9,11 +9,11 @@ from django.contrib.gis.geos import GEOSGeometry from django.core.files.uploadedfile import SimpleUploadedFile -from stac_api.models import Asset -from stac_api.models import Collection -from stac_api.models import CollectionLink -from stac_api.models import Item -from stac_api.models import Provider +from stac_api.models.collection import Collection +from stac_api.models.collection import CollectionLink +from stac_api.models.general import Provider +from stac_api.models.item import Asset +from stac_api.models.item import Item # path definition relative to the directory that contains manage.py DATADIR = settings.BASE_DIR / 'app/stac_api/management/sample_data/' diff --git a/app/stac_api/serializers/collection.py b/app/stac_api/serializers/collection.py index d66547f0..406643fe 100644 --- a/app/stac_api/serializers/collection.py +++ b/app/stac_api/serializers/collection.py @@ -4,10 +4,10 @@ from rest_framework import serializers -from stac_api.models import Collection -from stac_api.models import CollectionAsset -from stac_api.models import CollectionLink -from stac_api.models import Provider +from stac_api.models.collection import Collection +from stac_api.models.collection import CollectionAsset +from stac_api.models.collection import CollectionLink +from stac_api.models.general import Provider from stac_api.serializers.utils import AssetsDictSerializer from stac_api.serializers.utils import HrefField from stac_api.serializers.utils import NonNullModelSerializer diff --git a/app/stac_api/serializers/general.py b/app/stac_api/serializers/general.py index 4d1aba7e..876e3342 100644 --- a/app/stac_api/serializers/general.py +++ b/app/stac_api/serializers/general.py @@ -8,8 +8,8 @@ from rest_framework import serializers from rest_framework.validators import UniqueValidator -from stac_api.models import LandingPage -from stac_api.models import LandingPageLink +from stac_api.models.general import LandingPage +from stac_api.models.general import LandingPageLink from stac_api.utils import get_browser_url from stac_api.utils import get_stac_version from stac_api.utils import get_url diff --git a/app/stac_api/serializers/item.py b/app/stac_api/serializers/item.py index f79bad37..90f299af 100644 --- a/app/stac_api/serializers/item.py +++ b/app/stac_api/serializers/item.py @@ -8,9 +8,9 @@ from rest_framework import serializers from rest_framework_gis import serializers as gis_serializers -from stac_api.models import Asset -from stac_api.models import Item -from stac_api.models import ItemLink +from stac_api.models.item import Asset +from stac_api.models.item import Item +from stac_api.models.item import ItemLink from stac_api.serializers.utils import AssetsDictSerializer from stac_api.serializers.utils import HrefField from stac_api.serializers.utils import IsoDurationField diff --git a/app/stac_api/serializers/upload.py b/app/stac_api/serializers/upload.py index 9a42887c..579f2332 100644 --- a/app/stac_api/serializers/upload.py +++ b/app/stac_api/serializers/upload.py @@ -5,8 +5,8 @@ from rest_framework import serializers from rest_framework.utils.serializer_helpers import ReturnDict -from stac_api.models import AssetUpload -from stac_api.models import CollectionAssetUpload +from stac_api.models.collection import CollectionAssetUpload +from stac_api.models.item import AssetUpload from stac_api.serializers.utils import NonNullModelSerializer from stac_api.utils import is_api_version_1 from stac_api.utils import isoformat diff --git a/app/stac_api/serializers/utils.py b/app/stac_api/serializers/utils.py index 9ac92085..650d0bee 100644 --- a/app/stac_api/serializers/utils.py +++ b/app/stac_api/serializers/utils.py @@ -10,9 +10,9 @@ from rest_framework import serializers from rest_framework.utils.serializer_helpers import ReturnDict -from stac_api.models import Collection -from stac_api.models import Item -from stac_api.models import Link +from stac_api.models.collection import Collection +from stac_api.models.general import Link +from stac_api.models.item import Item from stac_api.utils import build_asset_href from stac_api.utils import get_browser_url from stac_api.utils import get_url diff --git a/app/stac_api/signals.py b/app/stac_api/signals.py index 0410b70f..ad011ddf 100644 --- a/app/stac_api/signals.py +++ b/app/stac_api/signals.py @@ -4,10 +4,10 @@ from django.db.models.signals import pre_delete from django.dispatch import receiver -from stac_api.models import Asset -from stac_api.models import AssetUpload -from stac_api.models import CollectionAsset -from stac_api.models import CollectionAssetUpload +from stac_api.models.collection import CollectionAsset +from stac_api.models.collection import CollectionAssetUpload +from stac_api.models.item import Asset +from stac_api.models.item import AssetUpload logger = logging.getLogger(__name__) diff --git a/app/stac_api/utils.py b/app/stac_api/utils.py index af354a17..9cff55af 100644 --- a/app/stac_api/utils.py +++ b/app/stac_api/utils.py @@ -309,6 +309,20 @@ def harmonize_post_get_for_search(request): query_param['ids'] = query_param['ids'].split(',') # to array if 'collections' in query_param: query_param['collections'] = query_param['collections'].split(',') # to array + + # Forecast 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'] return query_param diff --git a/app/stac_api/validators_serializer.py b/app/stac_api/validators_serializer.py index d0114e6b..509d38e7 100644 --- a/app/stac_api/validators_serializer.py +++ b/app/stac_api/validators_serializer.py @@ -351,7 +351,19 @@ def validate_query_parameters_post_search(self, query_param): Copy of the harmonized QueryDict ''' accepted_query_parameters = [ - "bbox", "collections", "datetime", "ids", "intersects", "limit", "cursor", "query" + "bbox", + "collections", + "datetime", + "ids", + "intersects", + "limit", + "cursor", + "query", + "forecast:reference_datetime", + "forecast:horizon", + "forecast:duration", + "forecast:variable", + "forecast:perturbed" ] wrong_query_parameters = set(query_param.keys()).difference(set(accepted_query_parameters)) if wrong_query_parameters: diff --git a/app/stac_api/validators_view.py b/app/stac_api/validators_view.py index c8bd571d..dbfbcbc8 100644 --- a/app/stac_api/validators_view.py +++ b/app/stac_api/validators_view.py @@ -7,10 +7,10 @@ from rest_framework import serializers -from stac_api.models import Asset -from stac_api.models import Collection -from stac_api.models import CollectionAsset -from stac_api.models import Item +from stac_api.models.collection import Collection +from stac_api.models.collection import CollectionAsset +from stac_api.models.item import Asset +from stac_api.models.item import Item logger = logging.getLogger(__name__) diff --git a/app/stac_api/views/collection.py b/app/stac_api/views/collection.py index 708e0a6c..35650356 100644 --- a/app/stac_api/views/collection.py +++ b/app/stac_api/views/collection.py @@ -8,8 +8,8 @@ from rest_framework.response import Response from rest_framework_condition import etag -from stac_api.models import Collection -from stac_api.models import CollectionAsset +from stac_api.models.collection import Collection +from stac_api.models.collection import CollectionAsset from stac_api.serializers.collection import CollectionAssetSerializer from stac_api.serializers.collection import CollectionSerializer from stac_api.serializers.utils import get_relation_links diff --git a/app/stac_api/views/general.py b/app/stac_api/views/general.py index 3b4d3a71..cbb6f86c 100644 --- a/app/stac_api/views/general.py +++ b/app/stac_api/views/general.py @@ -14,8 +14,8 @@ from rest_framework.permissions import AllowAny from rest_framework.response import Response -from stac_api.models import Item -from stac_api.models import LandingPage +from stac_api.models.general import LandingPage +from stac_api.models.item import Item from stac_api.pagination import GetPostCursorPagination from stac_api.serializers.general import ConformancePageSerializer from stac_api.serializers.general import LandingPageSerializer @@ -69,6 +69,7 @@ class SearchList(generics.GenericAPIView, mixins.ListModelMixin): # we must use the pk as ordering attribute, otherwise the cursor pagination will not work ordering = ['pk'] + # pylint: disable=too-many-branches def get_queryset(self): queryset = Item.objects.filter(collection__published=True ).prefetch_related('assets', 'links') @@ -92,6 +93,18 @@ def get_queryset(self): queryset = queryset.filter_by_query(dict_query) if 'intersects' in query_param: queryset = queryset.filter_by_intersects(json.dumps(query_param['intersects'])) + if 'forecast:reference_datetime' in query_param: + queryset = queryset.filter_by_forecast_reference_datetime( + query_param['forecast:reference_datetime'] + ) + if 'forecast:horizon' in query_param: + queryset = queryset.filter_by_forecast_horizon(query_param['forecast:horizon']) + if 'forecast:duration' in query_param: + queryset = queryset.filter_by_forecast_duration(query_param['forecast:duration']) + if 'forecast:variable' in query_param: + queryset = queryset.filter_by_forecast_variable(query_param['forecast:variable']) + if 'forecast:perturbed' in query_param: + queryset = queryset.filter_by_forecast_perturbed(query_param['forecast:perturbed']) if settings.DEBUG_ENABLE_DB_EXPLAIN_ANALYZE: logger.debug( diff --git a/app/stac_api/views/item.py b/app/stac_api/views/item.py index 8f024d6d..5bbdd590 100644 --- a/app/stac_api/views/item.py +++ b/app/stac_api/views/item.py @@ -12,9 +12,9 @@ from rest_framework.response import Response from rest_framework_condition import etag -from stac_api.models import Asset -from stac_api.models import Collection -from stac_api.models import Item +from stac_api.models.collection import Collection +from stac_api.models.item import Asset +from stac_api.models.item import Item from stac_api.serializers.item import AssetSerializer from stac_api.serializers.item import ItemSerializer from stac_api.serializers.utils import get_relation_links diff --git a/app/stac_api/views/test.py b/app/stac_api/views/test.py index 85b7d1ab..76c5ba4f 100644 --- a/app/stac_api/views/test.py +++ b/app/stac_api/views/test.py @@ -2,7 +2,7 @@ from rest_framework import generics -from stac_api.models import LandingPage +from stac_api.models.general import LandingPage from stac_api.views.collection import CollectionAssetDetail from stac_api.views.collection import CollectionDetail from stac_api.views.item import AssetDetail diff --git a/app/stac_api/views/upload.py b/app/stac_api/views/upload.py index ed84f4a7..ff775179 100644 --- a/app/stac_api/views/upload.py +++ b/app/stac_api/views/upload.py @@ -16,11 +16,11 @@ from stac_api.exceptions import UploadInProgressError from stac_api.exceptions import UploadNotInProgressError -from stac_api.models import Asset -from stac_api.models import AssetUpload -from stac_api.models import BaseAssetUpload -from stac_api.models import CollectionAsset -from stac_api.models import CollectionAssetUpload +from stac_api.models.collection import CollectionAsset +from stac_api.models.collection import CollectionAssetUpload +from stac_api.models.general import BaseAssetUpload +from stac_api.models.item import Asset +from stac_api.models.item import AssetUpload from stac_api.pagination import ExtApiPagination from stac_api.s3_multipart_upload import MultipartUpload from stac_api.serializers.upload import AssetUploadPartsSerializer diff --git a/app/tests/base_test_admin_page.py b/app/tests/base_test_admin_page.py index 3d83ae8e..0e137ca6 100644 --- a/app/tests/base_test_admin_page.py +++ b/app/tests/base_test_admin_page.py @@ -7,12 +7,12 @@ from django.test import TestCase from django.urls import reverse -from stac_api.models import Asset -from stac_api.models import Collection -from stac_api.models import CollectionLink -from stac_api.models import Item -from stac_api.models import ItemLink -from stac_api.models import Provider +from stac_api.models.collection import Collection +from stac_api.models.collection import CollectionLink +from stac_api.models.general import Provider +from stac_api.models.item import Asset +from stac_api.models.item import Item +from stac_api.models.item import ItemLink from tests.tests_09.data_factory import Factory diff --git a/app/tests/test_admin_page.py b/app/tests/test_admin_page.py index 4d339ce3..fff0b77c 100644 --- a/app/tests/test_admin_page.py +++ b/app/tests/test_admin_page.py @@ -5,12 +5,12 @@ from django.test import override_settings from django.urls import reverse -from stac_api.models import Asset -from stac_api.models import Collection -from stac_api.models import CollectionLink -from stac_api.models import Item -from stac_api.models import ItemLink -from stac_api.models import Provider +from stac_api.models.collection import Collection +from stac_api.models.collection import CollectionLink +from stac_api.models.general import Provider +from stac_api.models.item import Asset +from stac_api.models.item import Item +from stac_api.models.item import ItemLink from stac_api.utils import parse_multihash from tests.base_test_admin_page import AdminBaseTestCase diff --git a/app/tests/tests_09/data_factory.py b/app/tests/tests_09/data_factory.py index c9474ab3..f21af465 100644 --- a/app/tests/tests_09/data_factory.py +++ b/app/tests/tests_09/data_factory.py @@ -97,12 +97,12 @@ from django.core.files.base import File from django.core.files.uploadedfile import SimpleUploadedFile -from stac_api.models import Asset -from stac_api.models import Collection -from stac_api.models import CollectionLink -from stac_api.models import Item -from stac_api.models import ItemLink -from stac_api.models import Provider +from stac_api.models.collection import Collection +from stac_api.models.collection import CollectionLink +from stac_api.models.general import Provider +from stac_api.models.item import Asset +from stac_api.models.item import Item +from stac_api.models.item import ItemLink from stac_api.utils import get_s3_resource from stac_api.utils import isoformat from stac_api.validators import get_media_type diff --git a/app/tests/tests_09/sample_data/item_samples.py b/app/tests/tests_09/sample_data/item_samples.py index 55babe4b..3395925a 100644 --- a/app/tests/tests_09/sample_data/item_samples.py +++ b/app/tests/tests_09/sample_data/item_samples.py @@ -2,7 +2,7 @@ from django.contrib.gis.geos import GEOSGeometry -from stac_api.models import BBOX_CH +from stac_api.models.general import BBOX_CH from stac_api.utils import fromisoformat geometries = { diff --git a/app/tests/tests_09/test_asset_model.py b/app/tests/tests_09/test_asset_model.py index 17aa9f42..587caa98 100644 --- a/app/tests/tests_09/test_asset_model.py +++ b/app/tests/tests_09/test_asset_model.py @@ -2,7 +2,7 @@ from django.core.exceptions import ValidationError -from stac_api.models import Asset +from stac_api.models.item import Asset from tests.tests_09.base_test import StacBaseTransactionTestCase from tests.tests_09.data_factory import Factory diff --git a/app/tests/tests_09/test_asset_upload_endpoint.py b/app/tests/tests_09/test_asset_upload_endpoint.py index 8775a20d..ca7d2b08 100644 --- a/app/tests/tests_09/test_asset_upload_endpoint.py +++ b/app/tests/tests_09/test_asset_upload_endpoint.py @@ -10,8 +10,8 @@ from django.contrib.auth import get_user_model from django.test import Client -from stac_api.models import Asset -from stac_api.models import AssetUpload +from stac_api.models.item import Asset +from stac_api.models.item import AssetUpload from stac_api.utils import fromisoformat from stac_api.utils import get_asset_path from stac_api.utils import get_s3_client diff --git a/app/tests/tests_09/test_asset_upload_model.py b/app/tests/tests_09/test_asset_upload_model.py index f48f42e9..c6b874c3 100644 --- a/app/tests/tests_09/test_asset_upload_model.py +++ b/app/tests/tests_09/test_asset_upload_model.py @@ -6,8 +6,8 @@ from django.test import TestCase from django.test import TransactionTestCase -from stac_api.models import Asset -from stac_api.models import AssetUpload +from stac_api.models.item import Asset +from stac_api.models.item import AssetUpload from stac_api.utils import get_sha256_multihash from stac_api.utils import utc_aware diff --git a/app/tests/tests_09/test_assets_endpoint.py b/app/tests/tests_09/test_assets_endpoint.py index c4dc33a1..a3917a81 100644 --- a/app/tests/tests_09/test_assets_endpoint.py +++ b/app/tests/tests_09/test_assets_endpoint.py @@ -8,7 +8,7 @@ from django.test import Client from django.urls import reverse -from stac_api.models import Asset +from stac_api.models.item import Asset from stac_api.utils import get_asset_path from stac_api.utils import utc_aware diff --git a/app/tests/tests_09/test_collection_model.py b/app/tests/tests_09/test_collection_model.py index cfc83d05..cbeb0bd2 100644 --- a/app/tests/tests_09/test_collection_model.py +++ b/app/tests/tests_09/test_collection_model.py @@ -3,7 +3,7 @@ from django.core.exceptions import ValidationError -from stac_api.models import Collection +from stac_api.models.collection import Collection from tests.tests_09.base_test import StacBaseTransactionTestCase from tests.tests_09.data_factory import Factory diff --git a/app/tests/tests_09/test_collections_endpoint.py b/app/tests/tests_09/test_collections_endpoint.py index 99e21dbf..39f03b77 100644 --- a/app/tests/tests_09/test_collections_endpoint.py +++ b/app/tests/tests_09/test_collections_endpoint.py @@ -5,9 +5,9 @@ from django.test import Client from django.urls import reverse -from stac_api.models import Collection -from stac_api.models import CollectionLink -from stac_api.models import Provider +from stac_api.models.collection import Collection +from stac_api.models.collection import CollectionLink +from stac_api.models.general import Provider from stac_api.utils import utc_aware from tests.tests_09.base_test import STAC_BASE_V diff --git a/app/tests/tests_09/test_collections_extent.py b/app/tests/tests_09/test_collections_extent.py index 943c24b8..09691f8b 100644 --- a/app/tests/tests_09/test_collections_extent.py +++ b/app/tests/tests_09/test_collections_extent.py @@ -4,7 +4,7 @@ from django.contrib.gis.geos import GEOSGeometry from django.contrib.gis.geos import Polygon -from stac_api.models import Item +from stac_api.models.item import Item from stac_api.utils import utc_aware from tests.tests_09.base_test import StacBaseTransactionTestCase diff --git a/app/tests/tests_09/test_generic_api.py b/app/tests/tests_09/test_generic_api.py index 88544420..f9345510 100644 --- a/app/tests/tests_09/test_generic_api.py +++ b/app/tests/tests_09/test_generic_api.py @@ -5,7 +5,7 @@ from django.test import Client from django.test import override_settings -from stac_api.models import AssetUpload +from stac_api.models.item import AssetUpload from stac_api.utils import get_asset_path from stac_api.utils import get_link from stac_api.utils import get_sha256_multihash diff --git a/app/tests/tests_09/test_item_model.py b/app/tests/tests_09/test_item_model.py index b7c8cfd5..ad7d4e2e 100644 --- a/app/tests/tests_09/test_item_model.py +++ b/app/tests/tests_09/test_item_model.py @@ -6,8 +6,8 @@ from django.core.exceptions import ValidationError from django.test import TestCase -from stac_api.models import Collection -from stac_api.models import Item +from stac_api.models.collection import Collection +from stac_api.models.item import Item from stac_api.utils import utc_aware from tests.tests_09.data_factory import CollectionFactory diff --git a/app/tests/tests_09/test_items_endpoint.py b/app/tests/tests_09/test_items_endpoint.py index 3f2aaeeb..1924d54f 100644 --- a/app/tests/tests_09/test_items_endpoint.py +++ b/app/tests/tests_09/test_items_endpoint.py @@ -6,7 +6,7 @@ from django.test import Client from django.urls import reverse -from stac_api.models import Item +from stac_api.models.item import Item from stac_api.utils import fromisoformat from stac_api.utils import get_link from stac_api.utils import isoformat diff --git a/app/tests/tests_09/test_items_endpoint_bbox.py b/app/tests/tests_09/test_items_endpoint_bbox.py index 678d89b3..848d9a67 100644 --- a/app/tests/tests_09/test_items_endpoint_bbox.py +++ b/app/tests/tests_09/test_items_endpoint_bbox.py @@ -3,7 +3,7 @@ from django.contrib.gis.geos.geometry import GEOSGeometry from django.test import Client -from stac_api.models import BBOX_CH +from stac_api.models.general import BBOX_CH from tests.tests_09.base_test import STAC_BASE_V from tests.tests_09.base_test import StacBaseTestCase diff --git a/app/tests/tests_09/test_serializer.py b/app/tests/tests_09/test_serializer.py index 66374304..09d0f426 100644 --- a/app/tests/tests_09/test_serializer.py +++ b/app/tests/tests_09/test_serializer.py @@ -12,10 +12,10 @@ from rest_framework.renderers import JSONRenderer from rest_framework.test import APIRequestFactory -from stac_api.models import get_asset_path from stac_api.serializers.collection import CollectionSerializer from stac_api.serializers.item import AssetSerializer from stac_api.serializers.item import ItemSerializer +from stac_api.utils import get_asset_path from stac_api.utils import get_link from stac_api.utils import isoformat from stac_api.utils import utc_aware diff --git a/app/tests/tests_09/test_serializer_asset_upload.py b/app/tests/tests_09/test_serializer_asset_upload.py index e5a54e43..9f11bc72 100644 --- a/app/tests/tests_09/test_serializer_asset_upload.py +++ b/app/tests/tests_09/test_serializer_asset_upload.py @@ -6,7 +6,7 @@ from rest_framework import serializers from rest_framework.exceptions import ValidationError -from stac_api.models import AssetUpload +from stac_api.models.item import AssetUpload from stac_api.serializers.upload import AssetUploadSerializer from stac_api.utils import get_sha256_multihash diff --git a/app/tests/tests_10/data_factory.py b/app/tests/tests_10/data_factory.py index 4a522de1..de59878f 100644 --- a/app/tests/tests_10/data_factory.py +++ b/app/tests/tests_10/data_factory.py @@ -97,13 +97,13 @@ from django.core.files.base import File from django.core.files.uploadedfile import SimpleUploadedFile -from stac_api.models import Asset -from stac_api.models import Collection -from stac_api.models import CollectionAsset -from stac_api.models import CollectionLink -from stac_api.models import Item -from stac_api.models import ItemLink -from stac_api.models import Provider +from stac_api.models.collection import Collection +from stac_api.models.collection import CollectionAsset +from stac_api.models.collection import CollectionLink +from stac_api.models.general import Provider +from stac_api.models.item import Asset +from stac_api.models.item import Item +from stac_api.models.item import ItemLink from stac_api.utils import get_s3_resource from stac_api.utils import isoformat from stac_api.validators import get_media_type diff --git a/app/tests/tests_10/sample_data/item_samples.py b/app/tests/tests_10/sample_data/item_samples.py index 999696a8..adfd76e0 100644 --- a/app/tests/tests_10/sample_data/item_samples.py +++ b/app/tests/tests_10/sample_data/item_samples.py @@ -2,7 +2,7 @@ from django.contrib.gis.geos import GEOSGeometry -from stac_api.models import BBOX_CH +from stac_api.models.general import BBOX_CH from stac_api.utils import fromisoformat geometries = { @@ -218,5 +218,55 @@ 'name': 'item-covers_switzerland', 'geometry': geometries['covers-switzerland'], 'properties_datetime': fromisoformat('2020-10-28T13:05:10Z') - } + }, + 'item-forecast-1': { + 'name': 'item-forecast-1', + 'geometry': geometries['covers-switzerland'], + 'properties_datetime': fromisoformat('2020-10-28T13:05:10Z'), + 'forecast_reference_datetime': fromisoformat('2025-01-01T13:05:10Z'), + 'forecast_horizon': 'PT6H', + 'forecast_duration': 'PT12H', + 'forecast_variable': 'T', + 'forecast_perturbed': 'False', + }, + 'item-forecast-2': { + 'name': 'item-forecast-2', + 'geometry': geometries['covers-switzerland'], + 'properties_datetime': fromisoformat('2020-10-28T13:05:10Z'), + 'forecast_reference_datetime': fromisoformat('2025-02-01T13:05:10Z'), + 'forecast_horizon': 'PT6H', + 'forecast_duration': 'PT12H', + 'forecast_variable': 'T', + 'forecast_perturbed': 'False', + }, + 'item-forecast-3': { + 'name': 'item-forecast-3', + 'geometry': geometries['covers-switzerland'], + 'properties_datetime': fromisoformat('2020-10-28T13:05:10Z'), + 'forecast_reference_datetime': fromisoformat('2025-02-01T13:05:10Z'), + 'forecast_horizon': 'PT3H', + 'forecast_duration': 'PT6H', + 'forecast_variable': 'T', + 'forecast_perturbed': 'False', + }, + 'item-forecast-4': { + 'name': 'item-forecast-4', + 'geometry': geometries['covers-switzerland'], + 'properties_datetime': fromisoformat('2020-10-28T13:05:10Z'), + 'forecast_reference_datetime': fromisoformat('2025-02-01T13:05:10Z'), + 'forecast_horizon': 'PT6H', + 'forecast_duration': 'PT12H', + 'forecast_variable': 'air_temperature', + 'forecast_perturbed': 'True', + }, + 'item-forecast-5': { + 'name': 'item-forecast-5', + 'geometry': geometries['covers-switzerland'], + 'properties_datetime': fromisoformat('2020-10-28T13:05:10Z'), + 'forecast_reference_datetime': fromisoformat('2025-04-01T13:05:10Z'), + 'forecast_horizon': 'PT6H', + 'forecast_duration': 'PT12H', + 'forecast_variable': 'air_temperature', + 'forecast_perturbed': 'False', + }, } diff --git a/app/tests/tests_10/test_asset_model.py b/app/tests/tests_10/test_asset_model.py index 01f37578..757dc3ba 100644 --- a/app/tests/tests_10/test_asset_model.py +++ b/app/tests/tests_10/test_asset_model.py @@ -2,7 +2,7 @@ from django.core.exceptions import ValidationError -from stac_api.models import Asset +from stac_api.models.item import Asset from tests.tests_10.base_test import StacBaseTransactionTestCase from tests.tests_10.data_factory import Factory diff --git a/app/tests/tests_10/test_asset_upload_endpoint.py b/app/tests/tests_10/test_asset_upload_endpoint.py index ef569c76..8369a7dc 100644 --- a/app/tests/tests_10/test_asset_upload_endpoint.py +++ b/app/tests/tests_10/test_asset_upload_endpoint.py @@ -10,8 +10,8 @@ from django.contrib.auth import get_user_model from django.test import Client -from stac_api.models import Asset -from stac_api.models import AssetUpload +from stac_api.models.item import Asset +from stac_api.models.item import AssetUpload from stac_api.utils import fromisoformat from stac_api.utils import get_asset_path from stac_api.utils import get_s3_client diff --git a/app/tests/tests_10/test_asset_upload_model.py b/app/tests/tests_10/test_asset_upload_model.py index c8809762..361fba0e 100644 --- a/app/tests/tests_10/test_asset_upload_model.py +++ b/app/tests/tests_10/test_asset_upload_model.py @@ -6,8 +6,8 @@ from django.test import TestCase from django.test import TransactionTestCase -from stac_api.models import Asset -from stac_api.models import AssetUpload +from stac_api.models.item import Asset +from stac_api.models.item import AssetUpload from stac_api.utils import get_sha256_multihash from stac_api.utils import utc_aware diff --git a/app/tests/tests_10/test_assets_endpoint.py b/app/tests/tests_10/test_assets_endpoint.py index d2afbd36..2c9ab550 100644 --- a/app/tests/tests_10/test_assets_endpoint.py +++ b/app/tests/tests_10/test_assets_endpoint.py @@ -10,7 +10,7 @@ from django.urls import reverse from django.utils import timezone -from stac_api.models import Asset +from stac_api.models.item import Asset from stac_api.utils import get_asset_path from stac_api.utils import utc_aware diff --git a/app/tests/tests_10/test_collection_asset_model.py b/app/tests/tests_10/test_collection_asset_model.py index 21cf9f9a..b75366ae 100644 --- a/app/tests/tests_10/test_collection_asset_model.py +++ b/app/tests/tests_10/test_collection_asset_model.py @@ -2,7 +2,7 @@ from django.core.exceptions import ValidationError -from stac_api.models import CollectionAsset +from stac_api.models.collection import CollectionAsset from tests.tests_10.base_test import StacBaseTransactionTestCase from tests.tests_10.data_factory import Factory diff --git a/app/tests/tests_10/test_collection_asset_upload_endpoint.py b/app/tests/tests_10/test_collection_asset_upload_endpoint.py index 9734ebff..1d02c26a 100644 --- a/app/tests/tests_10/test_collection_asset_upload_endpoint.py +++ b/app/tests/tests_10/test_collection_asset_upload_endpoint.py @@ -10,8 +10,8 @@ from django.contrib.auth import get_user_model from django.test import Client -from stac_api.models import CollectionAsset -from stac_api.models import CollectionAssetUpload +from stac_api.models.collection import CollectionAsset +from stac_api.models.collection import CollectionAssetUpload from stac_api.utils import fromisoformat from stac_api.utils import get_collection_asset_path from stac_api.utils import get_s3_client diff --git a/app/tests/tests_10/test_collection_asset_upload_model.py b/app/tests/tests_10/test_collection_asset_upload_model.py index 9ea016aa..63a0cde3 100644 --- a/app/tests/tests_10/test_collection_asset_upload_model.py +++ b/app/tests/tests_10/test_collection_asset_upload_model.py @@ -6,8 +6,8 @@ from django.test import TestCase from django.test import TransactionTestCase -from stac_api.models import CollectionAsset -from stac_api.models import CollectionAssetUpload +from stac_api.models.collection import CollectionAsset +from stac_api.models.collection import CollectionAssetUpload from stac_api.utils import get_sha256_multihash from stac_api.utils import utc_aware diff --git a/app/tests/tests_10/test_collection_assets_endpoint.py b/app/tests/tests_10/test_collection_assets_endpoint.py index 1596a8d9..c2ce980d 100644 --- a/app/tests/tests_10/test_collection_assets_endpoint.py +++ b/app/tests/tests_10/test_collection_assets_endpoint.py @@ -8,7 +8,7 @@ from django.test import Client from django.urls import reverse -from stac_api.models import CollectionAsset +from stac_api.models.collection import CollectionAsset from stac_api.utils import get_collection_asset_path from stac_api.utils import utc_aware diff --git a/app/tests/tests_10/test_collection_model.py b/app/tests/tests_10/test_collection_model.py index 6e136b92..75998ade 100644 --- a/app/tests/tests_10/test_collection_model.py +++ b/app/tests/tests_10/test_collection_model.py @@ -3,7 +3,7 @@ from django.core.exceptions import ValidationError -from stac_api.models import Collection +from stac_api.models.collection import Collection from tests.tests_10.base_test import StacBaseTransactionTestCase from tests.tests_10.data_factory import Factory diff --git a/app/tests/tests_10/test_collections_endpoint.py b/app/tests/tests_10/test_collections_endpoint.py index 42306568..e5a674bd 100644 --- a/app/tests/tests_10/test_collections_endpoint.py +++ b/app/tests/tests_10/test_collections_endpoint.py @@ -6,9 +6,9 @@ from django.test import Client from django.urls import reverse -from stac_api.models import Collection -from stac_api.models import CollectionLink -from stac_api.models import Provider +from stac_api.models.collection import Collection +from stac_api.models.collection import CollectionLink +from stac_api.models.general import Provider from stac_api.utils import utc_aware from tests.tests_10.base_test import STAC_BASE_V diff --git a/app/tests/tests_10/test_collections_extent.py b/app/tests/tests_10/test_collections_extent.py index be6957c3..19b778c0 100644 --- a/app/tests/tests_10/test_collections_extent.py +++ b/app/tests/tests_10/test_collections_extent.py @@ -4,7 +4,7 @@ from django.contrib.gis.geos import GEOSGeometry from django.contrib.gis.geos import Polygon -from stac_api.models import Item +from stac_api.models.item import Item from stac_api.utils import utc_aware from tests.tests_10.base_test import StacBaseTransactionTestCase diff --git a/app/tests/tests_10/test_external_assets_endpoint.py b/app/tests/tests_10/test_external_assets_endpoint.py index 4a8829b9..0711d514 100644 --- a/app/tests/tests_10/test_external_assets_endpoint.py +++ b/app/tests/tests_10/test_external_assets_endpoint.py @@ -3,7 +3,7 @@ from django.conf import settings from django.test import Client -from stac_api.models import Asset +from stac_api.models.item import Asset from tests.tests_10.base_test import StacBaseTestCase from tests.tests_10.data_factory import Factory diff --git a/app/tests/tests_10/test_generic_api.py b/app/tests/tests_10/test_generic_api.py index e5adc074..d0d42efd 100644 --- a/app/tests/tests_10/test_generic_api.py +++ b/app/tests/tests_10/test_generic_api.py @@ -5,7 +5,7 @@ from django.test import Client from django.test import override_settings -from stac_api.models import AssetUpload +from stac_api.models.item import AssetUpload from stac_api.utils import get_asset_path from stac_api.utils import get_link from stac_api.utils import get_sha256_multihash diff --git a/app/tests/tests_10/test_item_model.py b/app/tests/tests_10/test_item_model.py index c1704bba..83776750 100644 --- a/app/tests/tests_10/test_item_model.py +++ b/app/tests/tests_10/test_item_model.py @@ -7,8 +7,8 @@ from django.core.exceptions import ValidationError from django.test import TestCase -from stac_api.models import Collection -from stac_api.models import Item +from stac_api.models.collection import Collection +from stac_api.models.item import Item from stac_api.utils import utc_aware from tests.tests_10.data_factory import CollectionFactory diff --git a/app/tests/tests_10/test_items_endpoint.py b/app/tests/tests_10/test_items_endpoint.py index 38397530..72dadf19 100644 --- a/app/tests/tests_10/test_items_endpoint.py +++ b/app/tests/tests_10/test_items_endpoint.py @@ -9,9 +9,9 @@ from django.urls import reverse from django.utils import timezone -from stac_api.models import Collection -from stac_api.models import Item -from stac_api.models import ItemLink +from stac_api.models.collection import Collection +from stac_api.models.item import Item +from stac_api.models.item import ItemLink from stac_api.utils import fromisoformat from stac_api.utils import get_link from stac_api.utils import isoformat diff --git a/app/tests/tests_10/test_items_endpoint_bbox.py b/app/tests/tests_10/test_items_endpoint_bbox.py index b327ad0e..47a6e7d4 100644 --- a/app/tests/tests_10/test_items_endpoint_bbox.py +++ b/app/tests/tests_10/test_items_endpoint_bbox.py @@ -3,7 +3,7 @@ from django.contrib.gis.geos.geometry import GEOSGeometry from django.test import Client -from stac_api.models import BBOX_CH +from stac_api.models.general import BBOX_CH from tests.tests_10.base_test import STAC_BASE_V from tests.tests_10.base_test import StacBaseTestCase diff --git a/app/tests/tests_10/test_remove_expired_items.py b/app/tests/tests_10/test_remove_expired_items.py index 505dcd33..cfe1f39f 100644 --- a/app/tests/tests_10/test_remove_expired_items.py +++ b/app/tests/tests_10/test_remove_expired_items.py @@ -5,8 +5,8 @@ from django.test import TestCase from django.utils import timezone -from stac_api.models import Asset -from stac_api.models import Item +from stac_api.models.item import Asset +from stac_api.models.item import Item from tests.tests_10.data_factory import Factory from tests.utils import mock_s3_asset_file diff --git a/app/tests/tests_10/test_search_endpoint.py b/app/tests/tests_10/test_search_endpoint.py index 63321f08..b48023a2 100644 --- a/app/tests/tests_10/test_search_endpoint.py +++ b/app/tests/tests_10/test_search_endpoint.py @@ -2,6 +2,7 @@ import logging from datetime import datetime from datetime import timedelta +from urllib.parse import quote_plus from django.test import Client from django.test import override_settings @@ -580,3 +581,146 @@ def test_post_search_no_cache_setting(self): response.has_header('Cache-Control'), msg="Unexpected Cache-Control header in POST response" ) + + +class SearchEndpointTestForecast(StacBaseTestCase): + + @classmethod + def setUpTestData(cls): + cls.factory = Factory() + cls.collection = cls.factory.create_collection_sample().model + cls.factory.create_item_sample( + cls.collection, 'item-forecast-1', 'item-forecast-1', db_create=True + ) + cls.factory.create_item_sample( + cls.collection, 'item-forecast-2', 'item-forecast-2', db_create=True + ) + cls.factory.create_item_sample( + cls.collection, 'item-forecast-3', 'item-forecast-3', db_create=True + ) + cls.factory.create_item_sample( + cls.collection, 'item-forecast-4', 'item-forecast-4', db_create=True + ) + cls.factory.create_item_sample( + cls.collection, 'item-forecast-5', 'item-forecast-5', db_create=True + ) + cls.now = utc_aware(datetime.utcnow()) + cls.yesterday = cls.now - timedelta(days=1) + + def setUp(self): # pylint: disable=invalid-name + self.client = Client() + client_login(self.client) + self.path = f'/{STAC_BASE_V}/search' + self.maxDiff = None # pylint: disable=invalid-name + + def test_reference_datetime_exact(self): + payload = {"forecast:reference_datetime": "2025-01-01T13:05:10Z"} + response = self.client.post(self.path, data=payload, content_type="application/json") + self.assertStatusCode(200, response) + json_data = response.json() + self.assertEqual(len(json_data['features']), 1) + for feature in json_data['features']: + self.assertIn(feature['id'], ['item-forecast-1']) + + payload = {"forecast:reference_datetime": "2025-02-01T13:05:10Z"} + response = self.client.post(self.path, data=payload, content_type="application/json") + self.assertStatusCode(200, response) + json_data = response.json() + self.assertEqual(len(json_data['features']), 3) + for feature in json_data['features']: + self.assertIn(feature['id'], ['item-forecast-2', 'item-forecast-3', 'item-forecast-4']) + + def test_reference_datetime_range(self): + payload = {"forecast:reference_datetime": "2025-02-01T00:00:00Z/2025-02-28T00:00:00Z"} + response = self.client.post(self.path, data=payload, content_type="application/json") + self.assertStatusCode(200, response) + json_data = response.json() + self.assertEqual(len(json_data['features']), 3) + for feature in json_data['features']: + self.assertIn(feature['id'], ['item-forecast-2', 'item-forecast-3', 'item-forecast-4']) + + def test_reference_datetime_open_end(self): + payload = {"forecast:reference_datetime": "2025-02-01T13:05:10Z/.."} + response = self.client.post(self.path, data=payload, content_type="application/json") + self.assertStatusCode(200, response) + json_data = response.json() + self.assertEqual(len(json_data['features']), 4) + for feature in json_data['features']: + self.assertIn( + feature['id'], + ['item-forecast-2', 'item-forecast-3', 'item-forecast-4', 'item-forecast-5'] + ) + + def test_reference_datetime_open_start(self): + payload = {"forecast:reference_datetime": "../2025-02-01T13:05:10Z"} + response = self.client.post(self.path, data=payload, content_type="application/json") + self.assertStatusCode(200, response) + json_data = response.json() + self.assertEqual(len(json_data['features']), 4) + for feature in json_data['features']: + self.assertIn( + feature['id'], + ['item-forecast-1', 'item-forecast-2', 'item-forecast-3', 'item-forecast-4'] + ) + + def test_horizon(self): + payload = {"forecast:horizon": "PT3H"} + response = self.client.post(self.path, data=payload, content_type="application/json") + self.assertStatusCode(200, response) + json_data = response.json() + self.assertEqual(len(json_data['features']), 1) + for feature in json_data['features']: + self.assertIn(feature['id'], ['item-forecast-3']) + + def test_duration(self): + payload = {"forecast:duration": "PT12H"} + response = self.client.post(self.path, data=payload, content_type="application/json") + self.assertStatusCode(200, response) + json_data = response.json() + self.assertEqual(len(json_data['features']), 4) + for feature in json_data['features']: + self.assertIn( + feature['id'], + ['item-forecast-1', 'item-forecast-2', 'item-forecast-4', 'item-forecast-5'] + ) + + def test_variable(self): + payload = {"forecast:variable": "air_temperature"} + response = self.client.post(self.path, data=payload, content_type="application/json") + self.assertStatusCode(200, response) + json_data = response.json() + self.assertEqual(len(json_data['features']), 2) + for feature in json_data['features']: + self.assertIn(feature['id'], ['item-forecast-4', 'item-forecast-5']) + + def test_perturbed(self): + payload = {"forecast:perturbed": "True"} + response = self.client.post(self.path, data=payload, content_type="application/json") + self.assertStatusCode(200, response) + json_data = response.json() + self.assertEqual(len(json_data['features']), 1) + for feature in json_data['features']: + self.assertIn(feature['id'], ['item-forecast-4']) + + def test_multiple(self): + payload = { + "forecast:perturbed": "False", "forecast:horizon": "PT6H", "forecast:variable": "T" + } + response = self.client.post(self.path, data=payload, content_type="application/json") + self.assertStatusCode(200, response) + json_data = response.json() + self.assertEqual(len(json_data['features']), 2) + for feature in json_data['features']: + self.assertIn(feature['id'], ['item-forecast-1', 'item-forecast-2']) + + def test_get_request_does_not_filter_forecast(self): + response = self.client.get( + f"{self.path}?" + quote_plus( + "forecast:reference_datetime=2025-01-01T13:05:10Z&" + "forecast:duration=PT12H&" + + "forecast:perturbed=False&" + "forecast:horizon=PT6H&" + "forecast:variable=T" + ) + ) + self.assertStatusCode(200, response) + json_data = response.json() + # As GET request should not filter for forecast expect all 5 features to be returned. + self.assertEqual(len(json_data['features']), 5) diff --git a/app/tests/tests_10/test_serializer.py b/app/tests/tests_10/test_serializer.py index e757bcc9..388a350e 100644 --- a/app/tests/tests_10/test_serializer.py +++ b/app/tests/tests_10/test_serializer.py @@ -14,11 +14,11 @@ from rest_framework.renderers import JSONRenderer from rest_framework.test import APIRequestFactory -from stac_api.models import get_asset_path from stac_api.serializers.collection import CollectionSerializer from stac_api.serializers.item import AssetSerializer from stac_api.serializers.item import ItemSerializer from stac_api.serializers.item import ItemsPropertiesSerializer +from stac_api.utils import get_asset_path from stac_api.utils import get_link from stac_api.utils import isoformat from stac_api.utils import utc_aware diff --git a/app/tests/tests_10/test_serializer_asset_upload.py b/app/tests/tests_10/test_serializer_asset_upload.py index c5d359a2..e557c1f6 100644 --- a/app/tests/tests_10/test_serializer_asset_upload.py +++ b/app/tests/tests_10/test_serializer_asset_upload.py @@ -6,7 +6,7 @@ from rest_framework import serializers from rest_framework.exceptions import ValidationError -from stac_api.models import AssetUpload +from stac_api.models.item import AssetUpload from stac_api.serializers.upload import AssetUploadSerializer from stac_api.utils import get_sha256_multihash diff --git a/scripts/fill_local_db.py b/scripts/fill_local_db.py index 6db19f8e..47690898 100644 --- a/scripts/fill_local_db.py +++ b/scripts/fill_local_db.py @@ -19,7 +19,7 @@ import io from rest_framework.renderers import JSONRenderer from rest_framework.parsers import JSONParser -from stac_api.models import * +from stac_api.models.general import * from stac_api.serializers.general import CollectionSerializer from stac_api.serializers.general import CollectionSerializer from stac_api.serializers.general import LinkSerializer @@ -28,14 +28,12 @@ # create link instances for testing link_root = CollectionLink.objects.create( - item=self.item, - href="https://data.geo.admin.ch/api/stac/v0.9/", - rel='root', - link_type='root', - title='Root link' - ) - - + item=self.item, + href="https://data.geo.admin.ch/api/stac/v0.9/", + rel='root', + link_type='root', + title='Root link' +) # create provider instances for testing provider1 = Provider( @@ -60,33 +58,19 @@ updated=datetime.now(), description='test', extent={ - "spatial": { - "bbox": [ - [ - 5.685114, - 45.534903, - 10.747775, - 47.982586 - ] - ] + "spatial": { + "bbox": [[5.685114, 45.534903, 10.747775, 47.982586]] + }, + "temporal": { + "interval": [["2019", None]] + } }, - "temporal": { - "interval": [ - [ - "2019", - None - ] - ] - } - }, collection_name='my collection', item_type='Feature', license='test', - summaries = { - "eo:gsd": [10,20], - "geoadmin:variant": ["kgrel", "komb", "krel"], - "proj:epsg": [2056] - }, + summaries={ + "eo:gsd": [10, 20], "geoadmin:variant": ["kgrel", "komb", "krel"], "proj:epsg": [2056] + }, title='testtitel2' ) @@ -104,33 +88,19 @@ updated=datetime.now(), description='test', extent={ - "spatial": { - "bbox": [ - [ - 5.685114, - 45.534903, - 10.747775, - 47.982586 - ] - ] + "spatial": { + "bbox": [[5.685114, 45.534903, 10.747775, 47.982586]] + }, + "temporal": { + "interval": [["2019", None]] + } }, - "temporal": { - "interval": [ - [ - "2019", - None - ] - ] - } - }, collection_name='b_123', item_type='Feature', license='test', - summaries = { - "eo:gsd": [10,20], - "geoadmin:variant": ["kgrel", "komb", "krel"], - "proj:epsg": [2056] - }, + summaries={ + "eo:gsd": [10, 20], "geoadmin:variant": ["kgrel", "komb", "krel"], "proj:epsg": [2056] + }, title='testtitel2' ) collection2.save() @@ -145,33 +115,19 @@ updated=datetime.now(), description='test', extent={ - "spatial": { - "bbox": [ - [ - 5.685114, - 45.534903, - 10.747775, - 47.982586 - ] - ] + "spatial": { + "bbox": [[5.685114, 45.534903, 10.747775, 47.982586]] + }, + "temporal": { + "interval": [["2019", None]] + } }, - "temporal": { - "interval": [ - [ - "2019", - None - ] - ] - } - }, collection_name='c_123', item_type='Feature', license='test', - summaries = { - "eo:gsd": [10,20], - "geoadmin:variant": ["kgrel", "komb", "krel"], - "proj:epsg": [2056] - }, + summaries={ + "eo:gsd": [10, 20], "geoadmin:variant": ["kgrel", "komb", "krel"], "proj:epsg": [2056] + }, title='testtitel2' ) collection3.save() @@ -185,7 +141,6 @@ provider3.save() link_root.save() - # test the serialization process: # translate into Python native serializer = CollectionSerializer(collection1) diff --git a/spec/components/schemas.yaml b/spec/components/schemas.yaml index b877cd12..5f3cee07 100644 --- a/spec/components/schemas.yaml +++ b/spec/components/schemas.yaml @@ -1096,22 +1096,9 @@ components: forecast:duration: $ref: "#/components/schemas/duration" forecast:variable: - description: >- - Name of the model variable that corresponds to the data. The variables - should correspond to the - [CF Standard Names](https://cfconventions.org/Data/cf-standard-names/current/build/cf-standard-name-table.html), - e.g. `air_temperature` for the air temperature. - example: air_temperature - type: string - nullable: true + $ref: "#/components/schemas/forecast_variable" forecast:perturbed: - description: >- - Denotes whether the data corresponds to the control run (`false`) or - perturbed runs (`true`). The property needs to be specified in both - cases as no default value is specified and as such the meaning is "unknown" - in case it's missing. - type: boolean - nullable: true + $ref: "#/components/schemas/forecast_perturbed" required: - created - updated @@ -1494,6 +1481,43 @@ components: query: $ref: "#/components/schemas/query" type: object + forecast_reference_datetimeFilter: + properties: + forecast:reference_datetime: + $ref: "#/components/schemas/datetimeQuery" + forecast_horizonFilter: + properties: + forecast:horizon: + $ref: "#/components/schemas/duration" + forecast_durationFilter: + properties: + forecast:duration: + $ref: "#/components/schemas/duration" + forecast_variableFilter: + properties: + forecast:variable: + $ref: "#/components/schemas/forecast_variable" + forecast_variable: + description: >- + Name of the model variable that corresponds to the data. The variables + should correspond to the + [CF Standard Names](https://cfconventions.org/Data/cf-standard-names/current/build/cf-standard-name-table.html), + e.g. `air_temperature` for the air temperature. + example: air_temperature + type: string + nullable: true + forecast_perturbedFilter: + properties: + forecast:perturbed: + $ref: "#/components/schemas/forecast_perturbed" + forecast_perturbed: + description: >- + Denotes whether the data corresponds to the control run (`false`) or + perturbed runs (`true`). The property needs to be specified in both + cases as no default value is specified and as such the meaning is "unknown" + in case it's missing. + type: boolean + nullable: true queryProp: anyOf: - description: >- @@ -1588,6 +1612,11 @@ components: - $ref: "#/components/schemas/collectionsFilter" - $ref: "#/components/schemas/idsFilter" - $ref: "#/components/schemas/limitFilter" + - $ref: "#/components/schemas/forecast_reference_datetimeFilter" + - $ref: "#/components/schemas/forecast_horizonFilter" + - $ref: "#/components/schemas/forecast_durationFilter" + - $ref: "#/components/schemas/forecast_variableFilter" + - $ref: "#/components/schemas/forecast_perturbedFilter" description: The search criteria type: object stac_version: diff --git a/spec/openapi.yaml b/spec/openapi.yaml index 80c8d4ad..a49fcec1 100644 --- a/spec/openapi.yaml +++ b/spec/openapi.yaml @@ -274,7 +274,8 @@ paths: get: description: >- Retrieve Items matching filters. Intended as a shorthand API for simple - queries. + queries. To filter by forecast properties please use the + [POST /search](#tag/STAC/operation/postSearchSTAC) request. operationId: getSearchSTAC parameters: diff --git a/spec/static/spec/v1/openapi.yaml b/spec/static/spec/v1/openapi.yaml index 552bd542..c6e61d31 100644 --- a/spec/static/spec/v1/openapi.yaml +++ b/spec/static/spec/v1/openapi.yaml @@ -1331,16 +1331,9 @@ components: forecast:duration: $ref: "#/components/schemas/duration" forecast:variable: - description: >- - Name of the model variable that corresponds to the data. The variables should correspond to the [CF Standard Names](https://cfconventions.org/Data/cf-standard-names/current/build/cf-standard-name-table.html), e.g. `air_temperature` for the air temperature. - example: air_temperature - type: string - nullable: true + $ref: "#/components/schemas/forecast_variable" forecast:perturbed: - description: >- - Denotes whether the data corresponds to the control run (`false`) or perturbed runs (`true`). The property needs to be specified in both cases as no default value is specified and as such the meaning is "unknown" in case it's missing. - type: boolean - nullable: true + $ref: "#/components/schemas/forecast_perturbed" required: - created - updated @@ -1697,6 +1690,37 @@ components: query: $ref: "#/components/schemas/query" type: object + forecast_reference_datetimeFilter: + properties: + forecast:reference_datetime: + $ref: "#/components/schemas/datetimeQuery" + forecast_horizonFilter: + properties: + forecast:horizon: + $ref: "#/components/schemas/duration" + forecast_durationFilter: + properties: + forecast:duration: + $ref: "#/components/schemas/duration" + forecast_variableFilter: + properties: + forecast:variable: + $ref: "#/components/schemas/forecast_variable" + forecast_variable: + description: >- + Name of the model variable that corresponds to the data. The variables should correspond to the [CF Standard Names](https://cfconventions.org/Data/cf-standard-names/current/build/cf-standard-name-table.html), e.g. `air_temperature` for the air temperature. + example: air_temperature + type: string + nullable: true + forecast_perturbedFilter: + properties: + forecast:perturbed: + $ref: "#/components/schemas/forecast_perturbed" + forecast_perturbed: + description: >- + Denotes whether the data corresponds to the control run (`false`) or perturbed runs (`true`). The property needs to be specified in both cases as no default value is specified and as such the meaning is "unknown" in case it's missing. + type: boolean + nullable: true queryProp: anyOf: - description: >- @@ -1783,6 +1807,11 @@ components: - $ref: "#/components/schemas/collectionsFilter" - $ref: "#/components/schemas/idsFilter" - $ref: "#/components/schemas/limitFilter" + - $ref: "#/components/schemas/forecast_reference_datetimeFilter" + - $ref: "#/components/schemas/forecast_horizonFilter" + - $ref: "#/components/schemas/forecast_durationFilter" + - $ref: "#/components/schemas/forecast_variableFilter" + - $ref: "#/components/schemas/forecast_perturbedFilter" description: The search criteria type: object stac_version: @@ -2083,7 +2112,7 @@ paths: /search: get: description: >- - Retrieve Items matching filters. Intended as a shorthand API for simple queries. + Retrieve Items matching filters. Intended as a shorthand API for simple queries. To filter by forecast properties please use the [POST /search](#tag/STAC/operation/postSearchSTAC) request. operationId: getSearchSTAC parameters: - $ref: "#/components/parameters/bbox" diff --git a/spec/static/spec/v1/openapitransactional.yaml b/spec/static/spec/v1/openapitransactional.yaml index 7ab6759e..4133df4d 100644 --- a/spec/static/spec/v1/openapitransactional.yaml +++ b/spec/static/spec/v1/openapitransactional.yaml @@ -1435,16 +1435,9 @@ components: forecast:duration: $ref: "#/components/schemas/duration" forecast:variable: - description: >- - Name of the model variable that corresponds to the data. The variables should correspond to the [CF Standard Names](https://cfconventions.org/Data/cf-standard-names/current/build/cf-standard-name-table.html), e.g. `air_temperature` for the air temperature. - example: air_temperature - type: string - nullable: true + $ref: "#/components/schemas/forecast_variable" forecast:perturbed: - description: >- - Denotes whether the data corresponds to the control run (`false`) or perturbed runs (`true`). The property needs to be specified in both cases as no default value is specified and as such the meaning is "unknown" in case it's missing. - type: boolean - nullable: true + $ref: "#/components/schemas/forecast_perturbed" required: - created - updated @@ -1805,6 +1798,37 @@ components: query: $ref: "#/components/schemas/query" type: object + forecast_reference_datetimeFilter: + properties: + forecast:reference_datetime: + $ref: "#/components/schemas/datetimeQuery" + forecast_horizonFilter: + properties: + forecast:horizon: + $ref: "#/components/schemas/duration" + forecast_durationFilter: + properties: + forecast:duration: + $ref: "#/components/schemas/duration" + forecast_variableFilter: + properties: + forecast:variable: + $ref: "#/components/schemas/forecast_variable" + forecast_variable: + description: >- + Name of the model variable that corresponds to the data. The variables should correspond to the [CF Standard Names](https://cfconventions.org/Data/cf-standard-names/current/build/cf-standard-name-table.html), e.g. `air_temperature` for the air temperature. + example: air_temperature + type: string + nullable: true + forecast_perturbedFilter: + properties: + forecast:perturbed: + $ref: "#/components/schemas/forecast_perturbed" + forecast_perturbed: + description: >- + Denotes whether the data corresponds to the control run (`false`) or perturbed runs (`true`). The property needs to be specified in both cases as no default value is specified and as such the meaning is "unknown" in case it's missing. + type: boolean + nullable: true queryProp: anyOf: - description: >- @@ -1891,6 +1915,11 @@ components: - $ref: "#/components/schemas/collectionsFilter" - $ref: "#/components/schemas/idsFilter" - $ref: "#/components/schemas/limitFilter" + - $ref: "#/components/schemas/forecast_reference_datetimeFilter" + - $ref: "#/components/schemas/forecast_horizonFilter" + - $ref: "#/components/schemas/forecast_durationFilter" + - $ref: "#/components/schemas/forecast_variableFilter" + - $ref: "#/components/schemas/forecast_perturbedFilter" description: The search criteria type: object stac_version: @@ -3503,7 +3532,7 @@ paths: /search: get: description: >- - Retrieve Items matching filters. Intended as a shorthand API for simple queries. + Retrieve Items matching filters. Intended as a shorthand API for simple queries. To filter by forecast properties please use the [POST /search](#tag/STAC/operation/postSearchSTAC) request. operationId: getSearchSTAC parameters: - $ref: "#/components/parameters/bbox"