diff --git a/Pipfile b/Pipfile index 1fd87661..36d91f5b 100644 --- a/Pipfile +++ b/Pipfile @@ -45,6 +45,7 @@ django-admin-autocomplete-filter = "~=0.7.1" django-pgtrigger = "~=4.11.1" logging-utilities = "~=4.5.0" django-environ = "*" +language-tags = "*" [requires] python_version = "3.12" diff --git a/Pipfile.lock b/Pipfile.lock index f4d53be7..b324db66 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "26f4b2b9d09c47972e02c969bd13d98ec09f34125905c9231d496e8143b9495a" + "sha256": "d5ca45375656b222f2af833c97aa4c4e2971d100c8410c2ecdff089a81744c60" }, "pipfile-spec": 6, "requires": { @@ -367,6 +367,14 @@ "markers": "python_version >= '3.7'", "version": "==1.0.1" }, + "language-tags": { + "hashes": [ + "sha256:d815604622242fdfbbfd747b40c31213617fd03734a267f2e39ee4bd73c88722", + "sha256:e934acba3e3dc85f867703eca421847a9ab7b7679b11b5d5cfd096febbf8bde6" + ], + "index": "pypi", + "version": "==1.2.0" + }, "logging-utilities": { "hashes": [ "sha256:0f88c3ddf33a7e81da20839667a9bedadfe22de6f9087c801aa1f582b89b4c93", diff --git a/app/config/settings_dev.py b/app/config/settings_dev.py index 8acfca8a..1434dcb4 100644 --- a/app/config/settings_dev.py +++ b/app/config/settings_dev.py @@ -79,9 +79,14 @@ # By default sessions expire after two weeks. # Sessions are only useful for user tracking in the admin UI. For security # reason we should expire these sessions as soon as possible. Given the use -# case, it seems reasonable to log out users after 8h of inactivity or whenever -# they restart their browser. +# case, it seems reasonable to log users out after 8h of inactivity. SESSION_COOKIE_AGE = 60 * 60 * 8 -SESSION_EXPIRE_AT_BROWSER_CLOSE = True +# Setting sessions to expire when closing the browser means the age expiration +# is ignored. We could do age-based expiration on the server side but that's +# more work than it's worth so we just persist the session across browser +# restarts and expire it only based on age. Furthermore, close-based expiration +# is sometimes ignored depending on the browser and how it's configured. So +# let's just turn that off (as it is by default). +SESSION_EXPIRE_AT_BROWSER_CLOSE = False SESSION_COOKIE_SAMESITE = "Strict" SESSION_COOKIE_SECURE = True diff --git a/app/middleware/apigw.py b/app/middleware/apigw.py index 97be7005..7263e684 100644 --- a/app/middleware/apigw.py +++ b/app/middleware/apigw.py @@ -2,4 +2,31 @@ class ApiGatewayMiddleware(PersistentRemoteUserMiddleware): + """Persist user authentication based on the API Gateway headers.""" header = "HTTP_GEOADMIN_USERNAME" + + def process_request(self, request): + """Before processing the request, drop the Geoadmin-Username header if it's invalid. + + API Gateway always sends the Geoadmin-Username header regardless of + whether it was able to authenticate the user. If it could not + authenticate the user, the value of the header as seen on the wire is a + single whitespace. An hexdump looks like this: + + 47 65 6f 61 64 6d 69 6e 5f 75 73 65 72 6e 61 6d 65 3a 20 0d 0a + Geoadmin-Username:... + + This doesn't seem possible to reproduce with curl. It is possible to + reproduce with wget. It is unclear whether that technically counts as an + empty value or a whitespace. It is also possible that AWS change their + implementation later to send something slightly different. Regardless, + we already have a separate signal to tell us whether that value is + valid: Geoadmin-Authenticated. So we only consider Geoadmin-Username if + Geoadmin-Authenticated is set to "true". + + Based on discussion in https://code.djangoproject.com/ticket/35971 + """ + apigw_auth = request.META.get("HTTP_GEOADMIN_AUTHENTICATED", "false").lower() == "true" + if not apigw_auth and self.header in request.META: + del request.META[self.header] + return super().process_request(request) diff --git a/app/stac_api/admin.py b/app/stac_api/admin.py index b409c13e..00fe8ac2 100644 --- a/app/stac_api/admin.py +++ b/app/stac_api/admin.py @@ -66,7 +66,18 @@ class ProviderInline(admin.TabularInline): } -class CollectionLinkInline(admin.TabularInline): +class LinkInline(admin.TabularInline): + + def formfield_for_dbfield(self, db_field, request, **kwargs): + # make the hreflang field a bit shorter so that the inline + # will not be rendered too wide + if db_field.attname == 'hreflang': + attrs = {'size': 10} + kwargs['widget'] = forms.TextInput(attrs=attrs) + return super().formfield_for_dbfield(db_field, request, **kwargs) + + +class CollectionLinkInline(LinkInline): model = CollectionLink extra = 0 @@ -154,7 +165,7 @@ def get_readonly_fields(self, request, obj=None): return self.readonly_fields -class ItemLinkInline(admin.TabularInline): +class ItemLinkInline(LinkInline): model = ItemLink extra = 0 @@ -233,6 +244,18 @@ class Media: ) } ), + ( + 'Forecast', + { + 'fields': ( + 'forecast_reference_datetime', + 'forecast_horizon', + 'forecast_duration', + 'forecast_param', + 'forecast_mode', + ) + } + ), ) list_display = ['name', 'collection', 'collection_published'] 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 f0e01423..8e2b03d3 100644 --- a/app/stac_api/management/commands/update_asset_file_size.py +++ b/app/stac_api/management/commands/update_asset_file_size.py @@ -4,6 +4,7 @@ from django.conf import settings 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 @@ -14,42 +15,67 @@ logger = logging.getLogger(__name__) +# increase the log level so boto3 doesn't spam the output +logging.getLogger('boto3').setLevel(logging.WARNING) +logging.getLogger('botocore').setLevel(logging.WARNING) + class Handler(CommandHandler): def update(self): - self.print_success('running command to update file size') + self.print_success('Running command to update file size') + + asset_limit = self.options['count'] + + asset_qs = Asset.objects.filter(file_size=0, is_external=False) + total_asset_count = asset_qs.count() + assets = asset_qs.all()[:asset_limit] + + self.print_success(f'Update file size for {len(assets)} assets out of {total_asset_count}') - self.print_success('update file size for assets') - assets = Asset.objects.filter(file_size=0).all() for asset in assets: selected_bucket = select_s3_bucket(asset.item.collection.name) s3 = get_s3_client(selected_bucket) bucket = settings.AWS_SETTINGS[selected_bucket.name]['S3_BUCKET_NAME'] key = SharedAssetUploadBase.get_path(None, asset) + try: file_size = s3.head_object(Bucket=bucket, Key=key)['ContentLength'] asset.file_size = file_size asset.save() + print(".", end="", flush=True) except ClientError: - logger.error('file size could not be read from s3 bucket for asset %s', key) + logger.error( + 'file size could not be read from s3 bucket [%s] for asset %s', bucket, key + ) + print() + + collection_asset_qs = CollectionAsset.objects.filter(file_size=0) + total_asset_count = collection_asset_qs.count() + collection_assets = collection_asset_qs.all()[:asset_limit] + + self.print_success( + f"Update file size for {len(collection_assets)} collection assets out of " + "{total_asset_count}" + ) - self.print_success('update file size for collection assets') - collection_assets = CollectionAsset.objects.filter(file_size=0).all() for collection_asset in collection_assets: selected_bucket = select_s3_bucket(collection_asset.collection.name) s3 = get_s3_client(selected_bucket) bucket = settings.AWS_SETTINGS[selected_bucket.name]['S3_BUCKET_NAME'] key = SharedAssetUploadBase.get_path(None, collection_asset) + try: file_size = s3.head_object(Bucket=bucket, Key=key)['ContentLength'] collection_asset.file_size = file_size collection_asset.save() + print(".", end="", flush=True) except ClientError: logger.error( - 'file size could not be read from s3 bucket for collection asset %s', key + 'file size could not be read from s3 bucket [%s] for collection asset %s' ) + print() self.print_success('Update completed') @@ -57,5 +83,15 @@ class Command(BaseCommand): help = """Requests the file size of every asset / collection asset from the s3 bucket and updates the value in the database""" + def add_arguments(self, parser: CommandParser) -> None: + super().add_arguments(parser) + parser.add_argument( + '-c', + '--count', + help="The amount of assets to process at once", + required=True, + type=int + ) + def handle(self, *args, **options): Handler(self, options).update() diff --git a/app/stac_api/migrations/0057_item_forecast_duration_item_forecast_horizon_and_more.py b/app/stac_api/migrations/0057_item_forecast_duration_item_forecast_horizon_and_more.py new file mode 100644 index 00000000..6c97692a --- /dev/null +++ b/app/stac_api/migrations/0057_item_forecast_duration_item_forecast_horizon_and_more.py @@ -0,0 +1,66 @@ +# Generated by Django 5.0.8 on 2024-11-28 13:20 + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ('stac_api', '0056_alter_collection_total_data_size_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='item', + name='forecast_duration', + field=models.DurationField( + 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).", + null=True + ), + ), + migrations.AddField( + model_name='item', + name='forecast_horizon', + field=models.DurationField( + 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.", + null=True + ), + ), + migrations.AddField( + model_name='item', + name='forecast_mode', + field=models.CharField( + blank=True, + choices=[('ctrl', 'Control run'), ('perturb', 'Perturbed run')], + default=None, + help_text= + 'Denotes whether the data corresponds to the control run or perturbed runs.', + null=True + ), + ), + migrations.AddField( + model_name='item', + name='forecast_param', + field=models.CharField( + blank=True, + help_text= + 'Name of the model parameter that corresponds to the data, e.g. `T` (temperature), `P` (pressure), `U`/`V`/`W` (windspeed in three directions).', + null=True + ), + ), + migrations.AddField( + model_name='item', + name='forecast_reference_datetime', + field=models.DateTimeField( + 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'.", + null=True + ), + ), + ] diff --git a/app/stac_api/migrations/0058_collectionlink_hreflang_itemlink_hreflang_and_more.py b/app/stac_api/migrations/0058_collectionlink_hreflang_itemlink_hreflang_and_more.py new file mode 100644 index 00000000..6bb9bb69 --- /dev/null +++ b/app/stac_api/migrations/0058_collectionlink_hreflang_itemlink_hreflang_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 5.0.8 on 2024-12-05 10:23 + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ('stac_api', '0057_item_forecast_duration_item_forecast_horizon_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='collectionlink', + name='hreflang', + field=models.CharField(blank=True, max_length=32, null=True), + ), + migrations.AddField( + model_name='itemlink', + name='hreflang', + field=models.CharField(blank=True, max_length=32, null=True), + ), + migrations.AddField( + model_name='landingpagelink', + name='hreflang', + field=models.CharField(blank=True, max_length=32, null=True), + ), + ] diff --git a/app/stac_api/models.py b/app/stac_api/models.py index 206beda0..c1af9568 100644 --- a/app/stac_api/models.py +++ b/app/stac_api/models.py @@ -4,6 +4,7 @@ import time from uuid import uuid4 +from language_tags import tags from multihash import encode as multihash_encode from multihash import to_hex_string @@ -153,6 +154,7 @@ class Link(models.Model): # 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 @@ -160,6 +162,15 @@ class Meta: 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 @@ -437,6 +448,54 @@ class Meta: 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_param = models.CharField( + null=True, + blank=True, + help_text="Name of the model parameter that corresponds to the data, e.g. " + "`T` (temperature), `P` (pressure), `U`/`V`/`W` (windspeed in three " + "directions)." + ) + + class ForecastModeChoices(models.TextChoices): + CONTROL_RUN = "ctrl", _("Control run") + PERTURBED_RUN = "perturb", _("Perturbed run") + + forecast_mode = models.CharField( + null=True, + blank=True, + choices=ForecastModeChoices, + default=None, + help_text="Denotes whether the data corresponds to the control run or " + "perturbed runs." + ) + # Custom Manager that preselects the collection objects = ItemManager() diff --git a/app/stac_api/serializers/collection.py b/app/stac_api/serializers/collection.py index 655d6848..d66547f0 100644 --- a/app/stac_api/serializers/collection.py +++ b/app/stac_api/serializers/collection.py @@ -38,7 +38,7 @@ class CollectionLinkSerializer(NonNullModelSerializer): class Meta: model = CollectionLink - fields = ['href', 'rel', 'title', 'type'] + fields = ['href', 'rel', 'title', 'type', 'hreflang'] # NOTE: when explicitely declaring fields, we need to add the validation as for the field # in model ! diff --git a/app/stac_api/serializers/item.py b/app/stac_api/serializers/item.py index b0cb9fe2..7a2b4807 100644 --- a/app/stac_api/serializers/item.py +++ b/app/stac_api/serializers/item.py @@ -1,4 +1,6 @@ +import copy import logging +from datetime import timedelta from django.core.exceptions import ValidationError as CoreValidationError from django.utils.translation import gettext_lazy as _ @@ -11,6 +13,7 @@ from stac_api.models import ItemLink from stac_api.serializers.utils import AssetsDictSerializer from stac_api.serializers.utils import HrefField +from stac_api.serializers.utils import IsoDurationField from stac_api.serializers.utils import NonNullModelSerializer from stac_api.serializers.utils import UpsertModelSerializerMixin from stac_api.serializers.utils import get_relation_links @@ -47,7 +50,7 @@ class ItemLinkSerializer(NonNullModelSerializer): class Meta: model = ItemLink - fields = ['href', 'rel', 'title', 'type'] + fields = ['href', 'rel', 'title', 'type', 'hreflang'] # NOTE: when explicitely declaring fields, we need to add the validation as for the field # in model ! @@ -82,6 +85,57 @@ class ItemsPropertiesSerializer(serializers.Serializer): updated = serializers.DateTimeField(read_only=True) expires = serializers.DateTimeField(source='properties_expires', required=False, default=None) + forecast_reference_datetime = serializers.DateTimeField(required=False, default=None) + forecast_horizon = IsoDurationField(required=False, default=None) + forecast_duration = IsoDurationField(required=False, default=None) + forecast_param = serializers.CharField(required=False, default=None) + forecast_mode = serializers.ChoiceField( + choices=Item.ForecastModeChoices, required=False, default=None + ) + + def to_internal_value(self, data) -> timedelta: + '''Map forecast extension fields with a colon in the name to the corresponding model field. + + Example: "forecast:duration" --> "forecast_duration". + ''' + + # hardcode a map instead of changing all keys starting with "forecast:" to avoid accidents + fields = { + 'forecast:reference_datetime': 'forecast_reference_datetime', + 'forecast:horizon': 'forecast_horizon', + 'forecast:duration': 'forecast_duration', + 'forecast:param': 'forecast_param', + 'forecast:mode': 'forecast_mode', + } + data_mapped = copy.deepcopy(data) + for with_colon, with_underscore in fields.items(): + if with_colon in data_mapped: + data_mapped[with_underscore] = data_mapped.pop(with_colon) + + ret = super().to_internal_value(data_mapped) + return ret + + def to_representation(self, instance): + '''Maps forecast extension fields to their counterpart in the response with a colon + + Example: "forecast_duration" --> "forecast:duration". + ''' + + ret = super().to_representation(instance) + + # hardcode a map instead of changing all keys starting with "forecast_" to avoid accidents + fields = { + 'forecast_reference_datetime': 'forecast:reference_datetime', + 'forecast_horizon': 'forecast:horizon', + 'forecast_duration': 'forecast:duration', + 'forecast_param': 'forecast:param', + 'forecast_mode': 'forecast:mode', + } + for with_colon, with_underscore in fields.items(): + if with_colon in ret: + ret[with_underscore] = ret.pop(with_colon) + return ret + class AssetBaseSerializer(NonNullModelSerializer, UpsertModelSerializerMixin): '''Asset serializer base class diff --git a/app/stac_api/serializers/utils.py b/app/stac_api/serializers/utils.py index 70ae5220..9ac92085 100644 --- a/app/stac_api/serializers/utils.py +++ b/app/stac_api/serializers/utils.py @@ -1,9 +1,18 @@ import logging from collections import OrderedDict +from typing import Dict +from typing import List + +from django.utils.dateparse import parse_duration +from django.utils.duration import duration_iso_string +from django.utils.translation import gettext_lazy as _ 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.utils import build_asset_href from stac_api.utils import get_browser_url from stac_api.utils import get_url @@ -11,7 +20,12 @@ logger = logging.getLogger(__name__) -def update_or_create_links(model, instance, instance_type, links_data): +def update_or_create_links( + model: type[Link], + instance: type[Item] | type[Collection], + instance_type: str, + links_data: List[Dict] +): '''Update or create links for a model Update the given links list within a model instance or create them when they don't exists yet. @@ -23,13 +37,16 @@ def update_or_create_links(model, instance, instance_type, links_data): ''' links_ids = [] for link_data in links_data: + link: Link + created: bool link, created = model.objects.get_or_create( **{instance_type: instance}, rel=link_data["rel"], defaults={ 'href': link_data.get('href', None), 'link_type': link_data.get('link_type', None), - 'title': link_data.get('title', None) + 'title': link_data.get('title', None), + 'hreflang': link_data.get('hreflang', None) } ) logger.debug( @@ -40,7 +57,7 @@ def update_or_create_links(model, instance, instance_type, links_data): instance_type: instance.name, "link": link_data } ) - links_ids.append(link.id) + links_ids.append(link.pk) # the duplicate here is necessary to update the values in # case the object already exists link.link_type = link_data.get('link_type', link.link_type) @@ -339,3 +356,21 @@ def to_representation(self, value): def to_internal_value(self, data): return data + + +class IsoDurationField(serializers.Field): + '''Handles duration in the ISO 8601 format like "P3DT6H"''' + + def to_internal_value(self, data): + '''Convert from ISO 8601 (e.g. "P3DT1H") to Python's timedelta''' + internal = parse_duration(data) + if internal is None: + raise serializers.ValidationError( + code="payload", + detail={_("Duration doesn't match ISO 8601 format")} + ) + return internal + + def to_representation(self, value): + '''Convert from Python's timedelta to ISO 8601 (e.g. "P3DT02H00M00S")''' + return duration_iso_string(value) diff --git a/app/tests/base_test_admin_page.py b/app/tests/base_test_admin_page.py new file mode 100644 index 00000000..3d83ae8e --- /dev/null +++ b/app/tests/base_test_admin_page.py @@ -0,0 +1,301 @@ +import logging +import time +from io import BytesIO + +from django.contrib.auth import get_user_model +from django.test import Client +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 tests.tests_09.data_factory import Factory + +logger = logging.getLogger(__name__) + + +class AdminBaseTestCase(TestCase): + + def setUp(self): + self.factory = Factory() + self.password = 'sesame' + self.username = 'admin_user' + self.admin_user = get_user_model().objects.create_superuser( + self.username, 'myemail@test.com', self.password + ) + self.client = Client() + self.collection = None + self.item = None + + def _setup(self, create_collection=False, create_item=False): + if create_collection or create_item: + self.client.login(username=self.username, password=self.password) + + if create_collection: + self.collection = self._create_collection()[0] + if create_item: + self.item = self._create_item(self.collection)[0] + + def _create_collection(self, with_link=False, with_provider=False, extra=None): + # Post data to create a new collection + # Note: the *-*_FORMS fields are necessary management form fields + # originating from the AdminInline and must be present + data = { + "name": "test_collection", + "license": "free", + "description": "some very important collection", + "published": "on", + "providers-TOTAL_FORMS": "0", + "providers-INITIAL_FORMS": "0", + "links-TOTAL_FORMS": "0", + "links-INITIAL_FORMS": "0", + "assets-TOTAL_FORMS": "0", + "assets-INITIAL_FORMS": "0" + } + if with_link: + data.update({ + "links-TOTAL_FORMS": "1", + "links-INITIAL_FORMS": "0", + "links-0-href": "http://www.example.com", + "links-0-rel": "example", + "links-0-link_type": "example", + "links-0-title": "Example test", + }) + if with_provider: + data.update({ + "providers-TOTAL_FORMS": "1", + "providers-INITIAL_FORMS": "0", + "providers-0-name": "my-provider", + "providers-0-description": "This is a provider", + "providers-0-roles": "licensor", + "providers-0-url": "http://www.example.com", + }) + if extra is not None: + data.update(extra) + response = self.client.post(reverse('admin:stac_api_collection_add'), data) + + # Status code for successful creation is 302, since in the admin UI + # you're redirected to the list view after successful creation + self.assertEqual(response.status_code, 302, msg="Admin page failed to add new collection") + self.assertTrue( + Collection.objects.filter(name=data["name"]).exists(), + msg="Admin page collection added not found in DB" + ) + collection = Collection.objects.get(name=data["name"]) + link = None + provider = None + + if with_link: + self.assertTrue( + CollectionLink.objects.filter(collection=collection, + rel=data["links-0-rel"]).exists(), + msg="Admin page Link added not found in DB" + ) + link = CollectionLink.objects.get(collection=collection, rel=data["links-0-rel"]) + + if with_provider: + self.assertTrue( + Provider.objects.filter(collection=collection, + name=data["providers-0-name"]).exists(), + msg="Admin page Provider added not found in DB" + ) + provider = Provider.objects.get(collection=collection, name=data["providers-0-name"]) + + return collection, data, link, provider + + def _create_item(self, collection, with_link=False, extra=None, data=None): + + # Post data to create a new item + # Note: the *-*_FORMS fields are necessary management form fields + # originating from the AdminInline and must be present + if not data: + data = { + "collection": collection.id, + "name": "test_item", + "geometry": + "SRID=4326;POLYGON((5.96 45.82, 5.96 47.81, 10.49 47.81, 10.49 45.82, "\ + "5.96 45.82))", + "text_geometry": + "SRID=4326;POLYGON((5.96 45.82, 5.96 47.81, 10.49 47.81, 10.49 45.82, "\ + "5.96 45.82))", + "properties_datetime_0": "2020-12-01", + "properties_datetime_1": "13:15:39", + "properties_title": "test", + "links-TOTAL_FORMS": "0", + "links-INITIAL_FORMS": "0", + } + if with_link: + data.update({ + "links-TOTAL_FORMS": "1", + "links-INITIAL_FORMS": "0", + "links-0-href": "http://www.example.com", + "links-0-rel": "example", + "links-0-link_type": "example", + "links-0-title": "Example test", + }) + if extra: + data.update(extra) + response = self.client.post(reverse('admin:stac_api_item_add'), data) + + # Status code for successful creation is 302, since in the admin UI + # you're redirected to the list view after successful creation + self.assertEqual(response.status_code, 302, msg="Admin page failed to add new item") + self.assertTrue( + Item.objects.filter(collection=collection, name=data["name"]).exists(), + msg="Admin page item added not found in DB" + ) + item = Item.objects.get(collection=collection, name=data["name"]) + link = None + + if with_link: + self.assertTrue( + ItemLink.objects.filter(item=item, rel=data["links-0-rel"]).exists(), + msg="Admin page Link added not found in DB" + ) + link = ItemLink.objects.get(item=item, rel=data["links-0-rel"]) + + # Check the item values + for key, value in data.items(): + if key in [ + 'collection', + 'id', + 'properties_datetime_0', + 'properties_datetime_1', + 'text_geometry' + ]: + continue + if key.startswith('links-0-'): + self.assertEqual( + getattr(link, key[8:]), value, msg=f"Item link field {key} value missmatch" + ) + elif key.startswith('links-'): + continue + else: + self.assertEqual(getattr(item, key), value, msg=f"Item field {key} value missmatch") + + return item, data, link + + def _create_asset_minimal(self, item): + start = time.time() + filecontent = b'my binary data' + filelike = BytesIO(filecontent) + filelike.name = 'testname.txt' + + # we need to create the asset in two steps, since the django admin form + # only takes some values in the creation form, then the rest in the + # change form + data = { + "item": item.id, + "name": "test_asset.txt", + "media_type": "text/plain", + } + + response = self.client.post(reverse('admin:stac_api_asset_add'), data) + logger.debug('Asset created in %fs', time.time() - start) + + # Status code for successful creation is 302, since in the admin UI + # you're redirected to the list view after successful creation + self.assertEqual(response.status_code, 302, msg="Admin page failed to add new asset") + self.assertTrue( + Asset.objects.filter(item=item, name=data["name"]).exists(), + msg="Admin page asset added not found in DB" + ) + + data = { + "item": item.id, + "name": "test_asset.txt", + "description": "", + "eo_gsd": "", + "geoadmin_lang": "", + "geoadmin_variant": "", + "proj_epsg": "", + "title": "", + "media_type": "text/plain", + "file": filelike + } + + asset = Asset.objects.get(item=item, name=data["name"]) + response = self.client.post(reverse('admin:stac_api_asset_change', args=[asset.id]), data) + + asset.refresh_from_db() + + # Check the asset values + for key, value in data.items(): + if key in ['item', 'name', 'file', 'checksum_multihash']: + continue + self.assertEqual( + getattr(asset, key), + value if value else None, + msg=f"Asset field {key} value missmatch" + ) + + # Assert that the filename is set to the value in name + self.assertEqual(asset.filename, data['name']) + + # Check file content is correct + with asset.file.open() as fd: + self.assertEqual(filecontent, fd.read()) + + return asset, data + + def _create_asset(self, item, extra=None): + start = time.time() + filecontent = b'mybinarydata' + filelike = BytesIO(filecontent) + filelike.name = 'testname.zip' + + data = { + "item": item.id, + "name": filelike.name, + "media_type": "application/x.filegdb+zip", + } + if extra: + data.update(extra) + response = self.client.post(reverse('admin:stac_api_asset_add'), data) + logger.debug('Asset created in %fs', time.time() - start) + + # Status code for successful creation is 302, since in the admin UI + # you're redirected to the list view after successful creation + self.assertEqual(response.status_code, 302, msg="Admin page failed to add new asset") + self.assertTrue( + Asset.objects.filter(item=item, name=data["name"]).exists(), + msg="Admin page asset added not found in DB" + ) + + data = { + "item": item.id, + "name": filelike.name, + "description": "This is a description", + "eo_gsd": 10, + "geoadmin_lang": "en", + "geoadmin_variant": "kgrs", + "proj_epsg": 2056, + "title": "My first Asset for test", + "media_type": "application/x.filegdb+zip", + "file": filelike + } + + asset = Asset.objects.get(item=item, name=data["name"]) + response = self.client.post(reverse('admin:stac_api_asset_change', args=[asset.id]), data) + + asset.refresh_from_db() + + # Check the asset values + for key, value in data.items(): + if key in ['item', 'id', 'file']: + continue + self.assertEqual(getattr(asset, key), value, msg=f"Asset field {key} value missmatch") + + # Assert that the filename is set to the value in name + self.assertEqual(asset.filename, data['name']) + + # Check file content is correct + with asset.file.open() as fd: + self.assertEqual(filecontent, fd.read()) + + return asset, data diff --git a/app/tests/test_admin_page.py b/app/tests/test_admin_page.py index 819e43fd..dc9f7002 100644 --- a/app/tests/test_admin_page.py +++ b/app/tests/test_admin_page.py @@ -1,11 +1,7 @@ import logging -import time from io import BytesIO from django.conf import settings -from django.contrib.auth import get_user_model -from django.test import Client -from django.test import TestCase from django.urls import reverse from stac_api.models import Asset @@ -16,7 +12,7 @@ from stac_api.models import Provider from stac_api.utils import parse_multihash -from tests.tests_09.data_factory import Factory +from tests.base_test_admin_page import AdminBaseTestCase from tests.utils import S3TestMixin from tests.utils import mock_s3_asset_file @@ -25,291 +21,6 @@ #-------------------------------------------------------------------------------------------------- -class AdminBaseTestCase(TestCase): - - def setUp(self): - self.factory = Factory() - self.password = 'sesame' - self.username = 'admin_user' - self.admin_user = get_user_model().objects.create_superuser( - self.username, 'myemail@test.com', self.password - ) - self.client = Client() - self.collection = None - self.item = None - - def _setup(self, create_collection=False, create_item=False): - if create_collection or create_item: - self.client.login(username=self.username, password=self.password) - - if create_collection: - self.collection = self._create_collection()[0] - if create_item: - self.item = self._create_item(self.collection)[0] - - def _create_collection(self, with_link=False, with_provider=False, extra=None): - # Post data to create a new collection - # Note: the *-*_FORMS fields are necessary management form fields - # originating from the AdminInline and must be present - data = { - "name": "test_collection", - "license": "free", - "description": "some very important collection", - "published": "on", - "providers-TOTAL_FORMS": "0", - "providers-INITIAL_FORMS": "0", - "links-TOTAL_FORMS": "0", - "links-INITIAL_FORMS": "0", - "assets-TOTAL_FORMS": "0", - "assets-INITIAL_FORMS": "0" - } - if with_link: - data.update({ - "links-TOTAL_FORMS": "1", - "links-INITIAL_FORMS": "0", - "links-0-href": "http://www.example.com", - "links-0-rel": "example", - "links-0-link_type": "example", - "links-0-title": "Example test", - }) - if with_provider: - data.update({ - "providers-TOTAL_FORMS": "1", - "providers-INITIAL_FORMS": "0", - "providers-0-name": "my-provider", - "providers-0-description": "This is a provider", - "providers-0-roles": "licensor", - "providers-0-url": "http://www.example.com", - }) - if extra is not None: - data.update(extra) - response = self.client.post(reverse('admin:stac_api_collection_add'), data) - - # Status code for successful creation is 302, since in the admin UI - # you're redirected to the list view after successful creation - self.assertEqual(response.status_code, 302, msg="Admin page failed to add new collection") - self.assertTrue( - Collection.objects.filter(name=data["name"]).exists(), - msg="Admin page collection added not found in DB" - ) - collection = Collection.objects.get(name=data["name"]) - link = None - provider = None - - if with_link: - self.assertTrue( - CollectionLink.objects.filter(collection=collection, - rel=data["links-0-rel"]).exists(), - msg="Admin page Link added not found in DB" - ) - link = CollectionLink.objects.get(collection=collection, rel=data["links-0-rel"]) - - if with_provider: - self.assertTrue( - Provider.objects.filter(collection=collection, - name=data["providers-0-name"]).exists(), - msg="Admin page Provider added not found in DB" - ) - provider = Provider.objects.get(collection=collection, name=data["providers-0-name"]) - - return collection, data, link, provider - - def _create_item(self, collection, with_link=False, extra=None, data=None): - - # Post data to create a new item - # Note: the *-*_FORMS fields are necessary management form fields - # originating from the AdminInline and must be present - if not data: - data = { - "collection": collection.id, - "name": "test_item", - "geometry": - "SRID=4326;POLYGON((5.96 45.82, 5.96 47.81, 10.49 47.81, 10.49 45.82, "\ - "5.96 45.82))", - "text_geometry": - "SRID=4326;POLYGON((5.96 45.82, 5.96 47.81, 10.49 47.81, 10.49 45.82, "\ - "5.96 45.82))", - "properties_datetime_0": "2020-12-01", - "properties_datetime_1": "13:15:39", - "properties_title": "test", - "links-TOTAL_FORMS": "0", - "links-INITIAL_FORMS": "0", - } - if with_link: - data.update({ - "links-TOTAL_FORMS": "1", - "links-INITIAL_FORMS": "0", - "links-0-href": "http://www.example.com", - "links-0-rel": "example", - "links-0-link_type": "example", - "links-0-title": "Example test", - }) - if extra: - data.update(extra) - response = self.client.post(reverse('admin:stac_api_item_add'), data) - - # Status code for successful creation is 302, since in the admin UI - # you're redirected to the list view after successful creation - self.assertEqual(response.status_code, 302, msg="Admin page failed to add new item") - self.assertTrue( - Item.objects.filter(collection=collection, name=data["name"]).exists(), - msg="Admin page item added not found in DB" - ) - item = Item.objects.get(collection=collection, name=data["name"]) - link = None - - if with_link: - self.assertTrue( - ItemLink.objects.filter(item=item, rel=data["links-0-rel"]).exists(), - msg="Admin page Link added not found in DB" - ) - link = ItemLink.objects.get(item=item, rel=data["links-0-rel"]) - - # Check the item values - for key, value in data.items(): - if key in [ - 'collection', - 'id', - 'properties_datetime_0', - 'properties_datetime_1', - 'text_geometry' - ]: - continue - if key.startswith('links-0-'): - self.assertEqual( - getattr(link, key[8:]), value, msg=f"Item link field {key} value missmatch" - ) - elif key.startswith('links-'): - continue - else: - self.assertEqual(getattr(item, key), value, msg=f"Item field {key} value missmatch") - - return item, data, link - - def _create_asset_minimal(self, item): - start = time.time() - filecontent = b'my binary data' - filelike = BytesIO(filecontent) - filelike.name = 'testname.txt' - - # we need to create the asset in two steps, since the django admin form - # only takes some values in the creation form, then the rest in the - # change form - data = { - "item": item.id, - "name": "test_asset.txt", - "media_type": "text/plain", - } - - response = self.client.post(reverse('admin:stac_api_asset_add'), data) - logger.debug('Asset created in %fs', time.time() - start) - - # Status code for successful creation is 302, since in the admin UI - # you're redirected to the list view after successful creation - self.assertEqual(response.status_code, 302, msg="Admin page failed to add new asset") - self.assertTrue( - Asset.objects.filter(item=item, name=data["name"]).exists(), - msg="Admin page asset added not found in DB" - ) - - data = { - "item": item.id, - "name": "test_asset.txt", - "description": "", - "eo_gsd": "", - "geoadmin_lang": "", - "geoadmin_variant": "", - "proj_epsg": "", - "title": "", - "media_type": "text/plain", - "file": filelike - } - - asset = Asset.objects.get(item=item, name=data["name"]) - response = self.client.post(reverse('admin:stac_api_asset_change', args=[asset.id]), data) - - asset.refresh_from_db() - - # Check the asset values - for key, value in data.items(): - if key in ['item', 'name', 'file', 'checksum_multihash']: - continue - self.assertEqual( - getattr(asset, key), - value if value else None, - msg=f"Asset field {key} value missmatch" - ) - - # Assert that the filename is set to the value in name - self.assertEqual(asset.filename, data['name']) - - # Check file content is correct - with asset.file.open() as fd: - self.assertEqual(filecontent, fd.read()) - - return asset, data - - def _create_asset(self, item, extra=None): - start = time.time() - filecontent = b'mybinarydata' - filelike = BytesIO(filecontent) - filelike.name = 'testname.zip' - - data = { - "item": item.id, - "name": filelike.name, - "media_type": "application/x.filegdb+zip", - } - if extra: - data.update(extra) - response = self.client.post(reverse('admin:stac_api_asset_add'), data) - logger.debug('Asset created in %fs', time.time() - start) - - # Status code for successful creation is 302, since in the admin UI - # you're redirected to the list view after successful creation - self.assertEqual(response.status_code, 302, msg="Admin page failed to add new asset") - self.assertTrue( - Asset.objects.filter(item=item, name=data["name"]).exists(), - msg="Admin page asset added not found in DB" - ) - - data = { - "item": item.id, - "name": filelike.name, - "description": "This is a description", - "eo_gsd": 10, - "geoadmin_lang": "en", - "geoadmin_variant": "kgrs", - "proj_epsg": 2056, - "title": "My first Asset for test", - "media_type": "application/x.filegdb+zip", - "file": filelike - } - - asset = Asset.objects.get(item=item, name=data["name"]) - response = self.client.post(reverse('admin:stac_api_asset_change', args=[asset.id]), data) - - asset.refresh_from_db() - - # Check the asset values - for key, value in data.items(): - if key in ['item', 'id', 'file']: - continue - self.assertEqual(getattr(asset, key), value, msg=f"Asset field {key} value missmatch") - - # Assert that the filename is set to the value in name - self.assertEqual(asset.filename, data['name']) - - # Check file content is correct - with asset.file.open() as fd: - self.assertEqual(filecontent, fd.read()) - - return asset, data - - -#-------------------------------------------------------------------------------------------------- - - class AdminTestCase(AdminBaseTestCase): def test_admin_page(self): @@ -337,7 +48,12 @@ def test_login_failure(self): self.assertEqual("/api/stac/admin/login/?next=/api/stac/admin/", response.url) def test_login_header(self): - response = self.client.get("/api/stac/admin/", headers={"Geoadmin-Username": self.username}) + response = self.client.get( + "/api/stac/admin/", + headers={ + "Geoadmin-Username": self.username, "Geoadmin-Authenticated": "true" + } + ) self.assertEqual(response.status_code, 200, msg="Admin page login with header failed") def test_login_header_noheader(self): @@ -346,7 +62,68 @@ def test_login_header_noheader(self): self.assertEqual("/api/stac/admin/login/?next=/api/stac/admin/", response.url) def test_login_header_wronguser(self): - response = self.client.get("/api/stac/admin/", headers={"Geoadmin-Username": "wronguser"}) + response = self.client.get( + "/api/stac/admin/", + headers={ + "Geoadmin-Username": "wronguser", "Geoadmin-Authenticated": "true" + } + ) + self.assertEqual(response.status_code, 302) + self.assertEqual("/api/stac/admin/login/?next=/api/stac/admin/", response.url) + + def test_login_header_not_authenticated(self): + self.assertNotIn("sessionid", self.client.cookies) + response = self.client.get( + "/api/stac/admin/", + headers={ + "Geoadmin-Username": self.username, "Geoadmin-Authenticated": "false" + } + ) + self.assertEqual(response.status_code, 302) + self.assertEqual("/api/stac/admin/login/?next=/api/stac/admin/", response.url) + + def test_login_header_session(self): + self.assertNotIn("sessionid", self.client.cookies) + + # log in with the header + response = self.client.get( + "/api/stac/admin/", + headers={ + "Geoadmin-Username": self.username, "Geoadmin-Authenticated": "true" + } + ) + self.assertEqual(response.status_code, 200, msg="Admin page login with header failed") + self.assertIn("sessionid", self.client.cookies) + + # verify we still have access just with the session cookie (no header) + response = self.client.get("/api/stac/admin/") + self.assertEqual( + response.status_code, 200, msg="Unable to load admin page with session cookie" + ) + + # verify we still have access just with the session cookie and invalid headers + response = self.client.get( + "/api/stac/admin/", + headers={ + "Geoadmin-Username": " ", "Geoadmin-Authenticated": "false" + } + ) + self.assertEqual( + response.status_code, + 200, + msg=( + "Unable to load admin page with session cookie" + + " and empty Geoadmin-Username header" + ) + ) + + # verify we lose access when we set an invalid user with valid headers + response = self.client.get( + "/api/stac/admin/", + headers={ + "Geoadmin-Username": "wronguser", "Geoadmin-Authenticated": "true" + } + ) self.assertEqual(response.status_code, 302) self.assertEqual("/api/stac/admin/login/?next=/api/stac/admin/", response.url) diff --git a/app/tests/tests_10/data_factory.py b/app/tests/tests_10/data_factory.py index 2eebaa92..4a522de1 100644 --- a/app/tests/tests_10/data_factory.py +++ b/app/tests/tests_10/data_factory.py @@ -907,7 +907,7 @@ def get_last_name(cls, last, extension=''): ) return last - def create_sample(self, sample, name=None, db_create=False, **kwargs): + def create_sample(self, sample, name=None, db_create=False, **kwargs) -> SampleData: '''Create a data sample Args: @@ -1356,8 +1356,13 @@ def __init__(self): self.collection_assets = CollectionAssetFactory() def create_collection_sample( - self, name=None, sample='collection-1', db_create=False, required_only=False, **kwargs - ): + self, + name=None, + sample='collection-1', + db_create=False, + required_only=False, + **kwargs + ) -> SampleData: '''Create a collection data sample Args: diff --git a/app/tests/tests_10/sample_data/collection_samples.py b/app/tests/tests_10/sample_data/collection_samples.py index 47be336f..9d41baa0 100644 --- a/app/tests/tests_10/sample_data/collection_samples.py +++ b/app/tests/tests_10/sample_data/collection_samples.py @@ -54,6 +54,21 @@ } } +links_hreflanged = { + 'link-1': { + 'title': 'Link with hreflang', + 'rel': 'describedBy', + 'href': 'http://perdu.com/', + 'hreflang': 'de' + }, + 'link-2': { + 'title': 'Link with hreflang', + 'rel': 'copiedFrom', + 'href': 'http://perdu.com/', + 'hreflang': 'fr-CH' + } +} + collections = { 'collection-1': { 'name': 'collection-1', @@ -104,6 +119,12 @@ 'license': 'proprietary', 'links': [multiple_links['link-1'], multiple_links['link-2']] }, + 'collection-hreflang-links': { + 'name': 'collection-1', + 'description': 'This a collection description', + 'license': 'proprietary', + 'links': links_hreflanged.values() + }, 'collection-invalid-providers': { 'name': 'collection-invalid-provider', 'description': 'This is a collection with invalid provider', diff --git a/app/tests/tests_10/sample_data/item_samples.py b/app/tests/tests_10/sample_data/item_samples.py index 55babe4b..999696a8 100644 --- a/app/tests/tests_10/sample_data/item_samples.py +++ b/app/tests/tests_10/sample_data/item_samples.py @@ -80,6 +80,21 @@ } } +links_hreflanged = { + 'link-1': { + 'title': 'Link with hreflang', + 'rel': 'describedBy', + 'href': 'http://perdu.com/', + 'hreflang': 'de' + }, + 'link-2': { + 'title': 'Link with hreflang', + 'rel': 'copiedFrom', + 'href': 'http://perdu.com/', + 'hreflang': 'fr-CH' + } +} + items = { 'item-1': { 'name': 'item-1', @@ -151,6 +166,24 @@ }, 'links': links_invalid.values() }, + 'item-hreflang-links': { + 'name': 'item-hreflang-link', + 'geometry': + GEOSGeometry( + json.dumps({ + "coordinates": [[ + [5.644711, 46.775054], + [5.644711, 48.014995], + [6.602408, 48.014995], + [7.602408, 49.014995], + [5.644711, 46.775054], + ]], + "type": "Polygon" + }) + ), + 'properties_datetime': fromisoformat('2024-12-05T13:37Z'), + 'links': links_hreflanged.values() + }, 'item-switzerland': { 'name': 'item-switzerland', 'geometry': geometries['switzerland'], diff --git a/app/tests/tests_10/test_collections_endpoint.py b/app/tests/tests_10/test_collections_endpoint.py index 6ed9fc58..42306568 100644 --- a/app/tests/tests_10/test_collections_endpoint.py +++ b/app/tests/tests_10/test_collections_endpoint.py @@ -1,5 +1,6 @@ import logging from datetime import datetime +from typing import cast from django.contrib.auth import get_user_model from django.test import Client @@ -16,6 +17,7 @@ from tests.tests_10.data_factory import CollectionAssetFactory from tests.tests_10.data_factory import CollectionFactory from tests.tests_10.data_factory import Factory +from tests.tests_10.data_factory import SampleData from tests.tests_10.utils import reverse_version from tests.utils import client_login from tests.utils import disableLogger @@ -596,3 +598,90 @@ def test_unauthorized_collection_delete(self): self.assertStatusCode( 401, response, msg="unauthorized and unimplemented collection delete was permitted." ) + + +class CollectionLinksEndpointTestCase(StacBaseTestCase): + + def setUp(self): + self.client = Client() + client_login(self.client) + + @classmethod + def setUpTestData(cls) -> None: + cls.factory = Factory() + cls.collection_data = cls.factory.create_collection_sample(db_create=True) + cls.collection = cast(Collection, cls.collection_data.model) + return super().setUpTestData() + + def test_create_collection_link_with_simple_link(self): + data = self.collection_data.get_json('put') + + path = f'/{STAC_BASE_V}/collections/{self.collection.name}' + response = self.client.put(path, data=data, content_type="application/json") + + self.assertEqual(response.status_code, 200) + + link = CollectionLink.objects.last() + assert link is not None + self.assertEqual(link.rel, data['links'][0]['rel']) + self.assertEqual(link.hreflang, None) + + def test_create_collection_link_with_hreflang(self): + data = self.collection_data.get_json('put') + data['links'] = [{ + 'rel': 'more-info', + 'href': 'http://www.meteoschweiz.ch/', + 'title': 'A link to a german page', + 'type': 'text/html', + 'hreflang': "de" + }] + + path = f'/{STAC_BASE_V}/collections/{self.collection.name}' + response = self.client.put(path, data=data, content_type="application/json") + + self.assertEqual(response.status_code, 200) + + link = CollectionLink.objects.last() + # Check for None with `assert` because `self.assertNotEqual` is not + # understood by the type checker. + assert link is not None + + self.assertEqual(link.hreflang, 'de') + + def test_read_collection_with_hreflang(self): + collection_data: SampleData = self.factory.create_collection_sample( + sample='collection-hreflang-links', db_create=False + ) + collection = cast(Collection, collection_data.model) + + path = f'/{STAC_BASE_V}/collections/{collection.name}' + response = self.client.get(path, content_type="application/json") + + self.assertEqual(response.status_code, 200) + + json_data = response.json() + self.assertIn('links', json_data) + link_data = json_data['links'] + de_link = link_data[-2] + fr_link = link_data[-1] + self.assertEqual(de_link['hreflang'], 'de') + self.assertEqual(fr_link['hreflang'], 'fr-CH') + + def test_create_collection_link_with_invalid_hreflang(self): + data = self.collection_data.get_json('put') + data['links'] = [{ + 'rel': 'more-info', + 'href': 'http://www.meteoschweiz.ch/', + 'title': 'A link to a german page', + 'type': 'text/html', + 'hreflang': "deUtsches_sprache" + }] + + path = f'/{STAC_BASE_V}/collections/{self.collection.name}' + response = self.client.put(path, data=data, content_type="application/json") + + self.assertEqual(response.status_code, 400) + content = response.json() + description = content['description'][0] + self.assertIn('Unknown code', description) + self.assertIn('Missing language', description) diff --git a/app/tests/tests_10/test_item_model.py b/app/tests/tests_10/test_item_model.py index 2c61789f..ca76b6a2 100644 --- a/app/tests/tests_10/test_item_model.py +++ b/app/tests/tests_10/test_item_model.py @@ -1,6 +1,7 @@ import logging from datetime import datetime from datetime import timedelta +from datetime import timezone from django.contrib.gis.geos import GEOSGeometry from django.core.exceptions import ValidationError @@ -15,6 +16,7 @@ logger = logging.getLogger(__name__) +# pylint: disable=too-many-public-methods class ItemsModelTestCase(TestCase): @classmethod @@ -255,3 +257,95 @@ def test_item_create_model_valid_linestring_geometry(self): ) item.full_clean() item.save() + + def test_item_create_model_sets_forecast_reference_datetime_as_expected_for_rfc3399_format( + self + ): + item = Item( + collection=self.collection, + properties_datetime=utc_aware(datetime.utcnow()), + name='item-1', + forecast_reference_datetime="2024-11-06T12:34:56Z", + ) + item.full_clean() + item.save() + self.assertEqual( + item.forecast_reference_datetime, + datetime( + year=2024, month=11, day=6, hour=12, minute=34, second=56, tzinfo=timezone.utc + ) + ) + + def test_item_create_model_raises_exception_if_forecast_reference_datetime_invalid(self): + with self.assertRaises(ValidationError): + item = Item( + collection=self.collection, + properties_datetime=utc_aware(datetime.utcnow()), + name='item-1', + forecast_reference_datetime="06-11-2024T12:34:56Z", + ) + item.full_clean() + + def test_item_create_model_sets_forecast_horizon_as_expected(self): + item = Item( + collection=self.collection, + properties_datetime=utc_aware(datetime.utcnow()), + name='item-1', + forecast_horizon=timedelta(days=1, hours=2), + ) + item.full_clean() + item.save() + self.assertEqual(item.forecast_horizon, timedelta(days=1, hours=2)) + + def test_item_create_model_sets_forecast_duration_as_expected(self): + item = Item( + collection=self.collection, + properties_datetime=utc_aware(datetime.utcnow()), + name='item-1', + forecast_duration=timedelta(days=1, hours=2), + ) + item.full_clean() + item.save() + self.assertEqual(item.forecast_duration, timedelta(days=1, hours=2)) + + def test_item_create_model_sets_forecast_param_as_expected(self): + item = Item( + collection=self.collection, + properties_datetime=utc_aware(datetime.utcnow()), + name='item-1', + forecast_param="T", + ) + item.full_clean() + item.save() + self.assertEqual(item.forecast_param, "T") + + def test_item_create_model_sets_forecast_mode_as_expected_if_mode_known(self): + item = Item( + collection=self.collection, + properties_datetime=utc_aware(datetime.utcnow()), + name='item-1', + forecast_mode="ctrl", + ) + item.full_clean() + item.save() + self.assertEqual(item.forecast_mode, "ctrl") + + def test_item_create_model_raises_exception_if_value_of_forecast_mode_unknown(self): + with self.assertRaises(ValidationError): + item = Item( + collection=self.collection, + properties_datetime=utc_aware(datetime.utcnow()), + name='item-1', + forecast_mode="unknown mode", + ) + item.full_clean() + + def test_item_create_model_sets_forecast_mode_to_none_if_undefined(self): + item = Item( + collection=self.collection, + properties_datetime=utc_aware(datetime.utcnow()), + name='item-1' + ) + item.full_clean() + item.save() + self.assertEqual(item.forecast_mode, None) diff --git a/app/tests/tests_10/test_items_endpoint.py b/app/tests/tests_10/test_items_endpoint.py index c0761c27..38397530 100644 --- a/app/tests/tests_10/test_items_endpoint.py +++ b/app/tests/tests_10/test_items_endpoint.py @@ -1,13 +1,17 @@ +# pylint: disable=too-many-lines import logging from datetime import datetime from datetime import timedelta +from typing import cast from django.contrib.auth import get_user_model from django.test import Client 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.utils import fromisoformat from stac_api.utils import get_link from stac_api.utils import isoformat @@ -24,6 +28,8 @@ from tests.utils import disableLogger from tests.utils import mock_s3_asset_file +from .data_factory import SampleData + logger = logging.getLogger(__name__) @@ -940,3 +946,93 @@ def test_unauthorized_item_put_patch_delete(self): path = f'/{STAC_BASE_V}/collections/{self.collection.name}/items/{self.item.name}' response = self.client.delete(path) self.assertStatusCode(401, response, msg="Unauthorized delete was permitted.") + + +class ItemsLinksEndpointTestCase(StacBaseTestCase): + + def setUp(self): + self.client = Client() + client_login(self.client) + + @classmethod + def setUpTestData(cls) -> None: + cls.factory = Factory() + cls.collection_data = cls.factory.create_collection_sample(db_create=True) + cls.collection = cast(Collection, cls.collection_data.model) + cls.item_data: SampleData = cls.factory.create_item_sample( + db_create=False, collection=cls.collection + ) + cls.item = cast(Item, cls.item_data.model) + return super().setUpTestData() + + def test_create_item_link_with_simple_link(self): + data = self.item_data.get_json('put') + + path = f'/{STAC_BASE_V}/collections/{self.collection.name}/items/{self.item.name}' + response = self.client.put(path, data=data, content_type="application/json") + + self.assertEqual(response.status_code, 200) + + link = ItemLink.objects.last() + assert link is not None + self.assertEqual(link.rel, data['links'][0]['rel']) + self.assertEqual(link.hreflang, None) + + def test_create_item_link_with_hreflang(self): + data = self.item_data.get_json('put') + data['links'] = [{ + 'rel': 'more-info', + 'href': 'http://www.meteoschweiz.ch/', + 'title': 'A link to a german page', + 'type': 'text/html', + 'hreflang': "de" + }] + + path = f'/{STAC_BASE_V}/collections/{self.collection.name}/items/{self.item.name}' + response = self.client.put(path, data=data, content_type="application/json") + + self.assertEqual(response.status_code, 200) + + link = ItemLink.objects.last() + # Check for None with `assert` because `self.assertNotEqual` is not understood + # by the type checker. + assert link is not None + self.assertEqual(link.hreflang, 'de') + + def test_read_item_with_hreflang(self): + item_data: SampleData = self.factory.create_item_sample( + sample='item-hreflang-links', db_create=False, collection=self.collection + ) + item = cast(Item, item_data.model) + + path = f'/{STAC_BASE_V}/collections/{self.collection.name}/items/{item.name}' + response = self.client.get(path, content_type="application/json") + + self.assertEqual(response.status_code, 200) + + json_data = response.json() + self.assertIn('links', json_data) + link_data = json_data['links'] + de_link = link_data[-2] + fr_link = link_data[-1] + self.assertEqual(de_link['hreflang'], 'de') + self.assertEqual(fr_link['hreflang'], 'fr-CH') + + def test_update_item_link_with_invalid_hreflang(self): + data = self.item_data.get_json('put') + data['links'] = [{ + 'rel': 'more-info', + 'href': 'http://www.meteoschweiz.ch/', + 'title': 'A link to a german page', + 'type': 'text/html', + 'hreflang': "fr/ch" + }] + + path = f'/{STAC_BASE_V}/collections/{self.collection.name}/items/{self.item.name}' + response = self.client.put(path, data=data, content_type="application/json") + + self.assertEqual(response.status_code, 400) + content = response.json() + description = content['description'][0] + self.assertIn('Unknown code', description) + self.assertIn('Missing language', description) diff --git a/app/tests/tests_10/test_serializer.py b/app/tests/tests_10/test_serializer.py index 79952a5c..c8a79efd 100644 --- a/app/tests/tests_10/test_serializer.py +++ b/app/tests/tests_10/test_serializer.py @@ -1,6 +1,7 @@ # pylint: disable=too-many-lines import logging +import unittest from collections import OrderedDict from datetime import datetime from datetime import timedelta @@ -16,6 +17,7 @@ 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_link from stac_api.utils import isoformat from stac_api.utils import utc_aware @@ -578,6 +580,87 @@ def test_item_deserialization_invalid_link(self): serializer.is_valid(raise_exception=True) +class ItemsPropertiesSerializerTestCase(unittest.TestCase): + + def test_deserialization_works_as_expected_for_valid_forecast_data(self): + data = { + "forecast:reference_datetime": "2024-11-19T16:15:00Z", + "forecast:horizon": "P3DT2H", + "forecast:duration": "PT4H", + "forecast:param": "T", + "forecast:mode": "ctrl", + } + + serializer = ItemsPropertiesSerializer(data=data) + self.assertTrue(serializer.is_valid()) + + self.assertTrue( + serializer.validated_data["forecast_reference_datetime"], + datetime(year=2024, month=11, day=19, hour=16, minute=15) + ) + self.assertTrue(serializer.validated_data["forecast_horizon"], timedelta(days=3, hours=2)) + self.assertTrue(serializer.validated_data["forecast_duration"], timedelta(hours=4)) + self.assertTrue(serializer.validated_data["forecast_param"], data["forecast:param"]) + self.assertTrue(serializer.validated_data["forecast_mode"], data["forecast:mode"]) + + def test_deserialization_detects_invalid_forecast_reference_datetime(self): + data = { + "forecast:reference_datetime": "🕒️", + } + + serializer = ItemsPropertiesSerializer(data=data) + self.assertFalse(serializer.is_valid()) + + def test_deserialization_detects_invalid_forecast_horizon(self): + wrong_order = "P1HT2D" + data = { + "forecast:horizon": wrong_order, + } + + serializer = ItemsPropertiesSerializer(data=data) + self.assertFalse(serializer.is_valid()) + + def test_deserialization_detects_invalid_forecast_duration(self): + missing_time_designator = "P2D1H" + data = { + "forecast:duration": missing_time_designator, + } + + serializer = ItemsPropertiesSerializer(data=data) + + self.assertFalse(serializer.is_valid()) + + def test_deserialization_detects_invalid_forecast_mode(self): + nonexistant_mode = "bla" + data = { + "forecast:mode": nonexistant_mode, + } + + serializer = ItemsPropertiesSerializer(data=data) + + self.assertFalse(serializer.is_valid()) + + def test_serialization_works_as_expected_for_valid_forecast_data(self): + data = { + "forecast:reference_datetime": "2024-11-19T16:15:00Z", + "forecast:horizon": "P3DT2H", + "forecast:duration": "PT4H", + "forecast:param": "T", + "forecast:mode": "ctrl", + } + + serializer = ItemsPropertiesSerializer(data=data) + self.assertTrue(serializer.is_valid()) + + actual = serializer.to_representation(serializer.validated_data) + + self.assertEqual(actual["forecast:reference_datetime"], data["forecast:reference_datetime"]) + self.assertEqual(actual["forecast:horizon"], "P3DT02H00M00S") + self.assertEqual(actual["forecast:duration"], "P0DT04H00M00S") + self.assertEqual(actual["forecast:param"], data["forecast:param"]) + self.assertEqual(actual["forecast:mode"], data["forecast:mode"]) + + class AssetSerializationTestCase(StacBaseTestCase): @mock_s3_asset_file diff --git a/spec/components/schemas.yaml b/spec/components/schemas.yaml index 45592d86..48c0a01f 100644 --- a/spec/components/schemas.yaml +++ b/spec/components/schemas.yaml @@ -411,6 +411,10 @@ components: example: 2018-02-12T23:20:50Z type: string format: date-time + duration: + description: ISO 8601 compliant duration + example: P3DT6H + type: string datetimeQuery: description: >- Either a date-time or an interval, open or closed. Date and time expressions @@ -1083,11 +1087,31 @@ components: minLength: 1 maxLength: 255 nullable: true + forecast:reference_datetime: + $ref: "#/components/schemas/datetime" + forecast:horizon: + $ref: "#/components/schemas/duration" + forecast:duration: + $ref: "#/components/schemas/duration" + forecast:param: + description: >- + Name of the model parameter that corresponds to the data + example: T + type: string + nullable: true + forecast:mode: + description: >- + Denotes whether the data corresponds to the control run or perturbed + runs. + enum: + - ctrl + - perturb + type: string + nullable: true required: - created - updated type: object - readOnly: true itemType: title: type description: The GeoJSON type @@ -1213,6 +1237,11 @@ components: - GET - POST type: string + hreflang: + # from https://github.com/stac-extensions/language?tab=readme-ov-file#fields-for-links-and-assets + description: The language of the link target + example: de-CH + type: string required: - href - rel diff --git a/spec/static/spec/v1/openapi.yaml b/spec/static/spec/v1/openapi.yaml index 45f8f60f..120767aa 100644 --- a/spec/static/spec/v1/openapi.yaml +++ b/spec/static/spec/v1/openapi.yaml @@ -304,113 +304,6 @@ paths: tags: - STAC components: - parameters: - assetQuery: - description: >- - Query for properties in assets (e.g. mediatype). Use the JSON form of the assetQueryFilter used in POST. - in: query - name: assetQuery - required: false - schema: - type: string - bbox: - explode: false - in: query - name: bbox - required: false - schema: - $ref: "#/components/schemas/bbox" - style: form - collectionId: - description: Local identifier of a collection - in: path - name: collectionId - required: true - schema: - type: string - collectionsArray: - explode: false - in: query - name: collections - required: false - schema: - $ref: "#/components/schemas/collectionsArray" - datetime: - explode: false - in: query - name: datetime - required: false - schema: - $ref: "#/components/schemas/datetimeQuery" - example: 2018-02-12T00%3A00%3A00Z%2F2018-03-18T12%3A31%3A12Z - style: form - featureId: - description: Local identifier of a feature - in: path - name: featureId - required: true - schema: - type: string - assetObjectHref: - name: assetObjectHref - in: path - description: Full URL to asset object including protocol, host and path - required: true - schema: - type: string - ids: - description: >- - Array of Item ids to return. All other filter parameters that further restrict the number of search results are ignored - explode: false - in: query - name: ids - required: false - schema: - $ref: "#/components/schemas/ids" - limit: - explode: false - in: query - name: limit - required: false - schema: - $ref: "#/components/schemas/limit" - style: form - query: - description: Query for properties in items. Use the JSON form of the queryFilter used in POST. - in: query - name: query - required: false - schema: - type: string - IfNoneMatch: - name: If-None-Match - in: header - schema: - type: string - description: >- - The RFC7232 `If-None-Match` header field makes the GET request method conditional. It is composed of a comma separated list of ETags or value "*". - - - The server compares the client's ETags (sent with `If-None-Match`) with the ETag for its current version of the resource, and if both values match (that is, the resource has not changed), the server sends back a `304 Not Modified` status, without a body, which tells the client that the cached version of the response is still good to use (fresh). - example: "d01af8b8ebbf899e30095be8754b377ddb0f0ed0f7fddbc33ac23b0d1969736b" - IfMatch: - name: If-Match - in: header - schema: - type: string - description: >- - The RFC7232 `If-Match` header field makes the GET request method conditional. It is composed of a comma separated list of ETags or value "*". - - - The server compares the client's ETags (sent with `If-Match`) with the ETag for its current version of the resource, and if both values don't match (that is, the resource has changed), the server sends back a `412 Precondition Failed` status, without a body, which tells the client that the cached version of the response is not good to use anymore. - example: "d01af8b8ebbf899e30095be8754b377ddb0f0ed0f7fddbc33ac23b0d1969736b" - provider: - name: provider - in: query - description: Filter collections by the name of the provider. Supports partial and case-insensitive matching. - required: false - schema: - type: string schemas: assetQuery: additionalProperties: @@ -781,6 +674,10 @@ components: example: 2018-02-12T23:20:50Z type: string format: date-time + duration: + description: ISO 8601 compliant duration + example: P3DT6H + type: string datetimeQuery: description: >- Either a date-time or an interval, open or closed. Date and time expressions adhere to RFC 3339. Open intervals are expressed using double-dots. @@ -1409,11 +1306,30 @@ components: minLength: 1 maxLength: 255 nullable: true + forecast:reference_datetime: + $ref: "#/components/schemas/datetime" + forecast:horizon: + $ref: "#/components/schemas/duration" + forecast:duration: + $ref: "#/components/schemas/duration" + forecast:param: + description: >- + Name of the model parameter that corresponds to the data + example: T + type: string + nullable: true + forecast:mode: + description: >- + Denotes whether the data corresponds to the control run or perturbed runs. + enum: + - ctrl + - perturb + type: string + nullable: true required: - created - updated type: object - readOnly: true itemType: title: type description: The GeoJSON type @@ -1527,6 +1443,10 @@ components: - GET - POST type: string + hreflang: + description: The language of the link target + example: de-CH + type: string required: - href - rel @@ -1878,14 +1798,6 @@ components: type: string format: date-time readOnly: true - headers: - ETag: - schema: - type: string - description: >- - The RFC7232 ETag header field in a response provides the current entity- tag for the selected resource. An entity-tag is an opaque identifier for different versions of a resource over time, regardless whether multiple versions are valid at the same time. An entity-tag consists of an opaque quoted string, possibly prefixed by a weakness indicator. - example: "d01af8b8ebbf899e30095be8754b377ddb0f0ed0f7fddbc33ac23b0d1969736b" - required: true responses: Collection: headers: @@ -2091,3 +2003,118 @@ components: example: code: 500 description: "Internal server error" + parameters: + assetQuery: + description: >- + Query for properties in assets (e.g. mediatype). Use the JSON form of the assetQueryFilter used in POST. + in: query + name: assetQuery + required: false + schema: + type: string + bbox: + explode: false + in: query + name: bbox + required: false + schema: + $ref: "#/components/schemas/bbox" + style: form + collectionId: + description: Local identifier of a collection + in: path + name: collectionId + required: true + schema: + type: string + collectionsArray: + explode: false + in: query + name: collections + required: false + schema: + $ref: "#/components/schemas/collectionsArray" + datetime: + explode: false + in: query + name: datetime + required: false + schema: + $ref: "#/components/schemas/datetimeQuery" + example: 2018-02-12T00%3A00%3A00Z%2F2018-03-18T12%3A31%3A12Z + style: form + featureId: + description: Local identifier of a feature + in: path + name: featureId + required: true + schema: + type: string + assetObjectHref: + name: assetObjectHref + in: path + description: Full URL to asset object including protocol, host and path + required: true + schema: + type: string + ids: + description: >- + Array of Item ids to return. All other filter parameters that further restrict the number of search results are ignored + explode: false + in: query + name: ids + required: false + schema: + $ref: "#/components/schemas/ids" + limit: + explode: false + in: query + name: limit + required: false + schema: + $ref: "#/components/schemas/limit" + style: form + query: + description: Query for properties in items. Use the JSON form of the queryFilter used in POST. + in: query + name: query + required: false + schema: + type: string + IfNoneMatch: + name: If-None-Match + in: header + schema: + type: string + description: >- + The RFC7232 `If-None-Match` header field makes the GET request method conditional. It is composed of a comma separated list of ETags or value "*". + + + The server compares the client's ETags (sent with `If-None-Match`) with the ETag for its current version of the resource, and if both values match (that is, the resource has not changed), the server sends back a `304 Not Modified` status, without a body, which tells the client that the cached version of the response is still good to use (fresh). + example: "d01af8b8ebbf899e30095be8754b377ddb0f0ed0f7fddbc33ac23b0d1969736b" + IfMatch: + name: If-Match + in: header + schema: + type: string + description: >- + The RFC7232 `If-Match` header field makes the GET request method conditional. It is composed of a comma separated list of ETags or value "*". + + + The server compares the client's ETags (sent with `If-Match`) with the ETag for its current version of the resource, and if both values don't match (that is, the resource has changed), the server sends back a `412 Precondition Failed` status, without a body, which tells the client that the cached version of the response is not good to use anymore. + example: "d01af8b8ebbf899e30095be8754b377ddb0f0ed0f7fddbc33ac23b0d1969736b" + provider: + name: provider + in: query + description: Filter collections by the name of the provider. Supports partial and case-insensitive matching. + required: false + schema: + type: string + headers: + ETag: + schema: + type: string + description: >- + The RFC7232 ETag header field in a response provides the current entity- tag for the selected resource. An entity-tag is an opaque identifier for different versions of a resource over time, regardless whether multiple versions are valid at the same time. An entity-tag consists of an opaque quoted string, possibly prefixed by a weakness indicator. + example: "d01af8b8ebbf899e30095be8754b377ddb0f0ed0f7fddbc33ac23b0d1969736b" + required: true diff --git a/spec/static/spec/v1/openapitransactional.yaml b/spec/static/spec/v1/openapitransactional.yaml index 841baaa7..0d0a4f90 100644 --- a/spec/static/spec/v1/openapitransactional.yaml +++ b/spec/static/spec/v1/openapitransactional.yaml @@ -621,6 +621,11 @@ paths: properties: datetime: "2016-05-03T13:22:30.040Z" title: A CS3 item + forecast:reference_datetime: "2024-11-19T16:15:00Z" + forecast:horizon: P3DT2H + forecast:duration: PT4H + forecast:param: T + forecast:mode: ctrl links: - href: https://www.swisstopo.admin.ch/en/home/meta/conditions/geodata/free-geodata.html rel: license @@ -1864,148 +1869,6 @@ paths: code: 400 description: "Unable to log in with provided credentials." components: - parameters: - assetQuery: - description: >- - Query for properties in assets (e.g. mediatype). Use the JSON form of the assetQueryFilter used in POST. - in: query - name: assetQuery - required: false - schema: - type: string - bbox: - explode: false - in: query - name: bbox - required: false - schema: - $ref: "#/components/schemas/bbox" - style: form - collectionId: - description: Local identifier of a collection - in: path - name: collectionId - required: true - schema: - type: string - collectionsArray: - explode: false - in: query - name: collections - required: false - schema: - $ref: "#/components/schemas/collectionsArray" - datetime: - explode: false - in: query - name: datetime - required: false - schema: - $ref: "#/components/schemas/datetimeQuery" - example: 2018-02-12T00%3A00%3A00Z%2F2018-03-18T12%3A31%3A12Z - style: form - featureId: - description: Local identifier of a feature - in: path - name: featureId - required: true - schema: - type: string - assetObjectHref: - name: assetObjectHref - in: path - description: Full URL to asset object including protocol, host and path - required: true - schema: - type: string - ids: - description: >- - Array of Item ids to return. All other filter parameters that further restrict the number of search results are ignored - explode: false - in: query - name: ids - required: false - schema: - $ref: "#/components/schemas/ids" - limit: - explode: false - in: query - name: limit - required: false - schema: - $ref: "#/components/schemas/limit" - style: form - query: - description: Query for properties in items. Use the JSON form of the queryFilter used in POST. - in: query - name: query - required: false - schema: - type: string - IfNoneMatch: - name: If-None-Match - in: header - schema: - type: string - description: >- - The RFC7232 `If-None-Match` header field makes the GET request method conditional. It is composed of a comma separated list of ETags or value "*". - - - The server compares the client's ETags (sent with `If-None-Match`) with the ETag for its current version of the resource, and if both values match (that is, the resource has not changed), the server sends back a `304 Not Modified` status, without a body, which tells the client that the cached version of the response is still good to use (fresh). - example: "d01af8b8ebbf899e30095be8754b377ddb0f0ed0f7fddbc33ac23b0d1969736b" - IfMatch: - name: If-Match - in: header - schema: - type: string - description: >- - The RFC7232 `If-Match` header field makes the GET request method conditional. It is composed of a comma separated list of ETags or value "*". - - - The server compares the client's ETags (sent with `If-Match`) with the ETag for its current version of the resource, and if both values don't match (that is, the resource has changed), the server sends back a `412 Precondition Failed` status, without a body, which tells the client that the cached version of the response is not good to use anymore. - example: "d01af8b8ebbf899e30095be8754b377ddb0f0ed0f7fddbc33ac23b0d1969736b" - provider: - name: provider - in: query - description: Filter collections by the name of the provider. Supports partial and case-insensitive matching. - required: false - schema: - type: string - assetId: - name: assetId - in: path - description: Local identifier of an asset. - required: true - schema: - type: string - uploadId: - name: uploadId - in: path - description: Local identifier of an asset's upload. - required: true - schema: - type: string - presignedUrl: - name: presignedUrl - in: path - description: >- - Presigned url returned by [Create a new Asset's multipart upload](#operation/createAssetUpload). - - Note: the url returned by the above endpoint is the full url including scheme, host and path - required: true - schema: - type: string - IfMatchWrite: - name: If-Match - in: header - schema: - type: string - description: >- - The RFC7232 `If-Match` header field makes the PUT/PATCH/DEL request method conditional. It is composed of a comma separated list of ETags or value "*". - - - The server compares the client's ETags (sent with `If-Match`) with the ETag for its current version of the resource, and if both values don't match (that is, the resource has changed), the server sends back a `412 Precondition Failed` status, without a body, which tells the client that he would overwrite another changes of the resource. - example: "d01af8b8ebbf899e30095be8754b377ddb0f0ed0f7fddbc33ac23b0d1969736b" schemas: assetQuery: additionalProperties: @@ -2376,6 +2239,10 @@ components: example: 2018-02-12T23:20:50Z type: string format: date-time + duration: + description: ISO 8601 compliant duration + example: P3DT6H + type: string datetimeQuery: description: >- Either a date-time or an interval, open or closed. Date and time expressions adhere to RFC 3339. Open intervals are expressed using double-dots. @@ -3004,11 +2871,30 @@ components: minLength: 1 maxLength: 255 nullable: true + forecast:reference_datetime: + $ref: "#/components/schemas/datetime" + forecast:horizon: + $ref: "#/components/schemas/duration" + forecast:duration: + $ref: "#/components/schemas/duration" + forecast:param: + description: >- + Name of the model parameter that corresponds to the data + example: T + type: string + nullable: true + forecast:mode: + description: >- + Denotes whether the data corresponds to the control run or perturbed runs. + enum: + - ctrl + - perturb + type: string + nullable: true required: - created - updated type: object - readOnly: true itemType: title: type description: The GeoJSON type @@ -3122,6 +3008,10 @@ components: - GET - POST type: string + hreflang: + description: The language of the link target + example: de-CH + type: string required: - href - rel @@ -4107,14 +3997,6 @@ components: the collections "external asset whitelist". example: | http://bundesamt.admin.ch/ch.bundesamt.data/no_specific_structure.png - headers: - ETag: - schema: - type: string - description: >- - The RFC7232 ETag header field in a response provides the current entity- tag for the selected resource. An entity-tag is an opaque identifier for different versions of a resource over time, regardless whether multiple versions are valid at the same time. An entity-tag consists of an opaque quoted string, possibly prefixed by a weakness indicator. - example: "d01af8b8ebbf899e30095be8754b377ddb0f0ed0f7fddbc33ac23b0d1969736b" - required: true responses: Collection: headers: @@ -4381,6 +4263,156 @@ components: required: - code - links + parameters: + assetQuery: + description: >- + Query for properties in assets (e.g. mediatype). Use the JSON form of the assetQueryFilter used in POST. + in: query + name: assetQuery + required: false + schema: + type: string + bbox: + explode: false + in: query + name: bbox + required: false + schema: + $ref: "#/components/schemas/bbox" + style: form + collectionId: + description: Local identifier of a collection + in: path + name: collectionId + required: true + schema: + type: string + collectionsArray: + explode: false + in: query + name: collections + required: false + schema: + $ref: "#/components/schemas/collectionsArray" + datetime: + explode: false + in: query + name: datetime + required: false + schema: + $ref: "#/components/schemas/datetimeQuery" + example: 2018-02-12T00%3A00%3A00Z%2F2018-03-18T12%3A31%3A12Z + style: form + featureId: + description: Local identifier of a feature + in: path + name: featureId + required: true + schema: + type: string + assetObjectHref: + name: assetObjectHref + in: path + description: Full URL to asset object including protocol, host and path + required: true + schema: + type: string + ids: + description: >- + Array of Item ids to return. All other filter parameters that further restrict the number of search results are ignored + explode: false + in: query + name: ids + required: false + schema: + $ref: "#/components/schemas/ids" + limit: + explode: false + in: query + name: limit + required: false + schema: + $ref: "#/components/schemas/limit" + style: form + query: + description: Query for properties in items. Use the JSON form of the queryFilter used in POST. + in: query + name: query + required: false + schema: + type: string + IfNoneMatch: + name: If-None-Match + in: header + schema: + type: string + description: >- + The RFC7232 `If-None-Match` header field makes the GET request method conditional. It is composed of a comma separated list of ETags or value "*". + + + The server compares the client's ETags (sent with `If-None-Match`) with the ETag for its current version of the resource, and if both values match (that is, the resource has not changed), the server sends back a `304 Not Modified` status, without a body, which tells the client that the cached version of the response is still good to use (fresh). + example: "d01af8b8ebbf899e30095be8754b377ddb0f0ed0f7fddbc33ac23b0d1969736b" + IfMatch: + name: If-Match + in: header + schema: + type: string + description: >- + The RFC7232 `If-Match` header field makes the GET request method conditional. It is composed of a comma separated list of ETags or value "*". + + + The server compares the client's ETags (sent with `If-Match`) with the ETag for its current version of the resource, and if both values don't match (that is, the resource has changed), the server sends back a `412 Precondition Failed` status, without a body, which tells the client that the cached version of the response is not good to use anymore. + example: "d01af8b8ebbf899e30095be8754b377ddb0f0ed0f7fddbc33ac23b0d1969736b" + provider: + name: provider + in: query + description: Filter collections by the name of the provider. Supports partial and case-insensitive matching. + required: false + schema: + type: string + assetId: + name: assetId + in: path + description: Local identifier of an asset. + required: true + schema: + type: string + uploadId: + name: uploadId + in: path + description: Local identifier of an asset's upload. + required: true + schema: + type: string + presignedUrl: + name: presignedUrl + in: path + description: >- + Presigned url returned by [Create a new Asset's multipart upload](#operation/createAssetUpload). + + Note: the url returned by the above endpoint is the full url including scheme, host and path + required: true + schema: + type: string + IfMatchWrite: + name: If-Match + in: header + schema: + type: string + description: >- + The RFC7232 `If-Match` header field makes the PUT/PATCH/DEL request method conditional. It is composed of a comma separated list of ETags or value "*". + + + The server compares the client's ETags (sent with `If-Match`) with the ETag for its current version of the resource, and if both values don't match (that is, the resource has changed), the server sends back a `412 Precondition Failed` status, without a body, which tells the client that he would overwrite another changes of the resource. + example: "d01af8b8ebbf899e30095be8754b377ddb0f0ed0f7fddbc33ac23b0d1969736b" + headers: + ETag: + schema: + type: string + description: >- + The RFC7232 ETag header field in a response provides the current entity- tag for the selected resource. An entity-tag is an opaque identifier for different versions of a resource over time, regardless whether multiple versions are valid at the same time. An entity-tag consists of an opaque quoted string, possibly prefixed by a weakness indicator. + example: "d01af8b8ebbf899e30095be8754b377ddb0f0ed0f7fddbc33ac23b0d1969736b" + required: true examples: inprogress: summary: In progress upload example diff --git a/spec/transaction/paths.yaml b/spec/transaction/paths.yaml index 270584f3..4a63ff21 100644 --- a/spec/transaction/paths.yaml +++ b/spec/transaction/paths.yaml @@ -300,6 +300,11 @@ paths: properties: datetime: "2016-05-03T13:22:30.040Z" title: A CS3 item + forecast:reference_datetime: "2024-11-19T16:15:00Z" + forecast:horizon: P3DT2H + forecast:duration: PT4H + forecast:param: T + forecast:mode: ctrl links: - href: https://www.swisstopo.admin.ch/en/home/meta/conditions/geodata/free-geodata.html rel: license