├── app ├── config │ ├── __init__.py │ ├── apps.py │ ├── admin.py │ ├── asgi.py │ ├── version.py │ ├── settings_test.py │ ├── settings_dev.py │ ├── logging-cfg-unittest-ci.yml │ ├── logging-cfg-management.yml │ └── urls.py ├── helpers │ ├── __init__.py │ ├── renderers.py │ └── logging.py ├── tests │ ├── __init__.py │ ├── tests_09 │ │ ├── __init__.py │ │ ├── sample_data │ │ │ └── __init__.py │ │ ├── utils.py │ │ ├── test_conformance.py │ │ ├── test_cf_forwarded_proto.py │ │ ├── test_landing_page.py │ │ ├── test_get_token_endpoint.py │ │ ├── test_renderer.py │ │ └── test_pgtriggers.py │ ├── tests_10 │ │ ├── __init__.py │ │ ├── sample_data │ │ │ └── __init__.py │ │ ├── utils.py │ │ ├── test_conformance.py │ │ ├── test_cf_forwarded_proto.py │ │ ├── test_utils.py │ │ ├── test_get_token_endpoint.py │ │ ├── test_landing_page.py │ │ ├── test_renderer.py │ │ └── test_pgtriggers.py │ ├── fixtures │ │ └── README.md │ ├── runner.py │ ├── test_healthcheck.py │ ├── test_checker.py │ ├── test_helpers.py │ └── test_superuser_command.py ├── middleware │ ├── __init__.py │ ├── debug.py │ ├── settings_context_processor.py │ ├── exception.py │ ├── api_gateway_authentication.py │ ├── cors.py │ ├── api_gateway.py │ ├── rest_framework_authentication.py │ ├── api_gateway_middleware.py │ ├── cache_headers.py │ └── logging.py ├── stac_api │ ├── __init__.py │ ├── models │ │ └── __init__.py │ ├── views │ │ ├── __init__.py │ │ ├── filters.py │ │ └── test.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0013_crypto_extension.py │ │ ├── 0034_merge_0032_alter_asset_file_0033_auto_20240704_1157.py │ │ ├── 0042_remove_collectionasset_is_external.py │ │ ├── 0044_alter_collectionlink_unique_together.py │ │ ├── 0010_auto_20210621_1453.py │ │ ├── 0008_auto_20210621_0840.py │ │ ├── 0004_auto_20210408_0659.py │ │ ├── 0012_auto_20210709_0734.py │ │ ├── 0066_collectionasset_is_external.py │ │ ├── 0032_alter_asset_file.py │ │ ├── 0038_collection_allow_external_assets.py │ │ ├── 0040_collection_external_asset_pattern.py │ │ ├── 0041_alter_collection_external_asset_pattern.py │ │ ├── 0026_add_asset_upload_content_encoding.py │ │ ├── 0018_assetupload_md5_parts.py │ │ ├── 0049_item_properties_expires.py │ │ ├── 0056_alter_collection_total_data_size_and_more.py │ │ ├── 0009_collection_published.py │ │ ├── 0035_asset_roles.py │ │ ├── 0037_asset_is_external_collectionasset_is_external.py │ │ ├── 0054_update_conformance_endpoint.py │ │ ├── 0058_collectionlink_hreflang_itemlink_hreflang_and_more.py │ │ ├── 0063_collection_cache_control_header.py │ │ ├── 0043_remove_collection_external_asset_pattern_and_more.py │ │ ├── 0029_alter_collectionlink_href_alter_itemlink_href_and_more.py │ │ ├── 0061_remove_item_forecast_mode_remove_item_forecast_param_and_more.py │ │ ├── 0017_data_collection_summaries_lang.py │ │ ├── 0031_alter_collection_extent_geometry_alter_item_geometry.py │ │ ├── 0055_alter_asset_file_size_alter_assetupload_file_size_and_more.py │ │ ├── 0062_item_item_fc_reference_datetime_idx_and_more.py │ │ ├── 0015_data_collection_summaries.py │ │ ├── 0060_item_forecast_perturbed_item_forecast_variable.py │ │ ├── 0011_auto_20210623_0521.py │ │ ├── 0032_delete_conformancepage_landingpage_conformsto_and_more.py │ │ ├── 0051_asset_file_size_assetupload_file_size_and_more.py │ │ ├── 0067_update_conformance.py │ │ ├── 0006_auto_20210419_1409.py │ │ ├── 0033_auto_20240704_1157.py │ │ ├── 0007_auto_20210421_0701.py │ │ ├── 0002_auto_20210218_0726.py │ │ ├── 0047_prefill_counter_tables.py │ │ ├── 0057_item_forecast_duration_item_forecast_horizon_and_more.py │ │ └── 0005_auto_20210408_0821.py │ ├── sample_data │ │ ├── __init__.py │ │ ├── swissTLMRegio │ │ │ ├── collection.json │ │ │ └── items │ │ │ │ └── swisstlmregio-2020.json │ │ └── swissMapRaster200 │ │ │ └── collection.json │ ├── serializers │ │ └── __init__.py │ ├── templates │ │ ├── style │ │ │ ├── favicon.ico │ │ │ ├── logo-CH.png │ │ │ ├── admin │ │ │ │ └── upload.css │ │ │ ├── hover.css │ │ │ └── style.css │ │ └── js │ │ │ └── admin │ │ │ ├── collection_help_search.js │ │ │ ├── item_help_search.js │ │ │ └── asset_help_search.js │ ├── profiling.py │ ├── exceptions.py │ ├── management │ │ └── commands │ │ │ ├── populate_testdb.py │ │ │ ├── manage_superuser.py │ │ │ ├── profile_item_serializer.py │ │ │ ├── profile_cursor_paginator.py │ │ │ └── reset_counter_tables.py │ ├── signals.py │ └── storages.py ├── templates │ ├── __init__.py │ ├── admin │ │ ├── __init__.py │ │ ├── base.html │ │ └── change_form.html │ ├── rest_framework │ │ ├── __init__.py │ │ └── base.html │ └── uploadtemplate.html └── manage.py ├── minio.env ├── .dockerignore ├── .coveragerc ├── adr ├── cache-control-vs-update_interval-graph.png ├── 2020_10_01_authentication.md ├── 2020_10_21_static_asset.md ├── 2020_08_13_application_architecture.md └── 2025_03_04_cache_settings_update.md ├── screenshots └── stac-browser-meteoschweiz-collection.png ├── .vscode ├── settings.json └── launch.json ├── .github ├── workflows │ ├── semver.yml │ └── pr-auto-semver.yml └── release.yml ├── scripts ├── create_large_dummy_asset_file.py └── create_dummy_asset_for_multipart_testing.py ├── .isort.cfg ├── spec ├── .spectral.yaml ├── components │ └── headers.yaml ├── transaction │ ├── tags.yaml │ └── components │ │ ├── examples.yaml │ │ ├── responses.yaml │ │ └── parameters.yaml ├── README.md ├── static │ └── spec │ │ ├── v1 │ │ ├── apitransactional.html │ │ └── api.html │ │ └── v0.9 │ │ ├── apitransactional.html │ │ └── api.html └── Makefile ├── pytest.ini ├── .gitignore ├── doc └── stac-search-endpoint-example.md ├── .style.yapf ├── Pipfile ├── docker-compose.yml ├── .env.default ├── update_to_latest.sh ├── LICENSE.md └── publiccode.yml /app/config/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/middleware/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/stac_api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/templates/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/stac_api/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/stac_api/views/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/templates/admin/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/tests/tests_09/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/tests/tests_10/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/stac_api/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/stac_api/sample_data/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/stac_api/serializers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/templates/rest_framework/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/tests/tests_09/sample_data/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/tests/tests_10/sample_data/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /minio.env: -------------------------------------------------------------------------------- 1 | MINIO_ROOT_USER=minioadmin 2 | MINIO_ROOT_PASSWORD=minioadmin 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .volumes 2 | */logs 3 | /scripts/xxxxxxl_asset_file.zip 4 | .env.* -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | */tests/* 4 | 5 | [report] 6 | exclude_lines = 7 | if TYPE_CHECKING: 8 | -------------------------------------------------------------------------------- /app/stac_api/templates/style/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geoadmin/service-stac/HEAD/app/stac_api/templates/style/favicon.ico -------------------------------------------------------------------------------- /app/stac_api/templates/style/logo-CH.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geoadmin/service-stac/HEAD/app/stac_api/templates/style/logo-CH.png -------------------------------------------------------------------------------- /adr/cache-control-vs-update_interval-graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geoadmin/service-stac/HEAD/adr/cache-control-vs-update_interval-graph.png -------------------------------------------------------------------------------- /screenshots/stac-browser-meteoschweiz-collection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geoadmin/service-stac/HEAD/screenshots/stac-browser-meteoschweiz-collection.png -------------------------------------------------------------------------------- /app/config/apps.py: -------------------------------------------------------------------------------- 1 | from django.contrib.admin.apps import AdminConfig 2 | 3 | 4 | class StacAdminConfig(AdminConfig): 5 | default_site = 'config.admin.StacAdminSite' 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.pytestArgs": [ 3 | "app" 4 | ], 5 | "python.testing.unittestEnabled": false, 6 | "python.testing.pytestEnabled": true 7 | } 8 | -------------------------------------------------------------------------------- /.github/workflows/semver.yml: -------------------------------------------------------------------------------- 1 | name: on-push 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - develop 8 | 9 | jobs: 10 | release: 11 | uses: geoadmin/.github/.github/workflows/semver-release.yml@master -------------------------------------------------------------------------------- /app/config/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class StacAdminSite(admin.AdminSite): 6 | site_header = _('STAC API admin') 7 | site_title = _('geoadmin STAC API') 8 | -------------------------------------------------------------------------------- /.github/workflows/pr-auto-semver.yml: -------------------------------------------------------------------------------- 1 | name: on-pr 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - reopened 8 | - synchronize 9 | - edited 10 | 11 | jobs: 12 | pr-edit: 13 | uses: geoadmin/.github/.github/workflows/pr-auto-semver.yml@master -------------------------------------------------------------------------------- /app/stac_api/views/filters.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Q 2 | from django.utils import timezone 3 | 4 | 5 | def create_is_active_filter(): 6 | """ 7 | Create a filter to check if the item is not expired. 8 | """ 9 | return Q(properties_expires__gte=timezone.now()) | Q(properties_expires=None) 10 | -------------------------------------------------------------------------------- /scripts/create_large_dummy_asset_file.py: -------------------------------------------------------------------------------- 1 | # this will create a 60 GB dummy asset file for testing the upload via 2 | # the admin GUI. 3 | LARGE_FILE_SIZE = 60 * 1024**3 # this is 60 GB 4 | with open("xxxxxxl_asset_file.zip", "wb") as dummy_file: 5 | dummy_file.seek(int(LARGE_FILE_SIZE) - 1) 6 | dummy_file.write(b"\0") 7 | -------------------------------------------------------------------------------- /app/templates/admin/base.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base.html" %} 2 | {% load static %} 3 | 4 | {% block extrahead %} 5 | 6 | {% endblock %} 7 | 8 | {% block userlinks %} 9 | {{ block.super }} 10 | / Version {{ SETTINGS_APP_VERSION }} 11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0013_crypto_extension.py: -------------------------------------------------------------------------------- 1 | from django.contrib.postgres.operations import CryptoExtension 2 | from django.db import migrations 3 | 4 | 5 | class Migration(migrations.Migration): 6 | 7 | dependencies = [ 8 | ('stac_api', '0012_auto_20210709_0734'), 9 | ] 10 | 11 | operations = [ 12 | CryptoExtension(), 13 | ] 14 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Python Debugger: Attach", 6 | "type": "debugpy", 7 | "request": "attach", 8 | "justMyCode": false, 9 | "connect": { 10 | "host": "localhost", 11 | "port": 5678 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /app/helpers/renderers.py: -------------------------------------------------------------------------------- 1 | from rest_framework.renderers import JSONRenderer 2 | 3 | 4 | class GeoJSONRenderer(JSONRenderer): 5 | """ Renders geojson. 6 | 7 | It is used if a client sends an "Accept: application/geo+json" header or uses the 8 | "format=geojson" query parameter. 9 | 10 | """ 11 | media_type = 'application/geo+json' 12 | format = 'geojson' 13 | -------------------------------------------------------------------------------- /app/tests/fixtures/README.md: -------------------------------------------------------------------------------- 1 | # Test fixtures 2 | 3 | This folder contains data fixture to be used in tests. See https://docs.djangoproject.com/en/5.1/topics/db/fixtures/. 4 | 5 | ## E2E tests fixture 6 | 7 | The fixture `e2e-tests.json` is loaded by an init container in DEV and INT staging and use then needed by the E2E tests from [geoadmin/infra-e2e-tests](https://github.com/geoadmin/infra-e2e-tests) 8 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0034_merge_0032_alter_asset_file_0033_auto_20240704_1157.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-07-09 13:57 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('stac_api', '0032_alter_asset_file'), 10 | ('stac_api', '0033_auto_20240704_1157'), 11 | ] 12 | 13 | operations = [] 14 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | line_length=100 3 | known_third_party=pytest,logging_utilities 4 | known_django=django 5 | known_stac_api=stac_api 6 | known_rest_framework=rest_framework,rest_framework_gis,rest_framework_condition 7 | known_storages=storages 8 | known_tests=tests 9 | force_single_line=True 10 | sections=FUTURE,STDLIB,THIRDPARTY,DJANGO,STORAGES,REST_FRAMEWORK,STAC_API,TESTS,FIRSTPARTY,LOCALFOLDER 11 | -------------------------------------------------------------------------------- /app/middleware/debug.py: -------------------------------------------------------------------------------- 1 | import environ 2 | 3 | env = environ.Env() 4 | 5 | 6 | def check_toolbar_env(request): 7 | """ callback to check whether debug toolbar should be shown or not 8 | 9 | for details see 10 | https://django-debug-toolbar.readthedocs.io/en/latest/configuration.html#debug-toolbar-config # pylint: disable=line-too-long 11 | """ 12 | 13 | return env.bool('DEBUG', False) 14 | -------------------------------------------------------------------------------- /spec/.spectral.yaml: -------------------------------------------------------------------------------- 1 | extends: '@ibm-cloud/openapi-ruleset' 2 | rules: 3 | # We generally follow the naming conventions from the upstream spec 4 | # which does not match the IBM API naming conventions. So we turn off 5 | # these specific validation rules. 6 | ibm-parameter-casing-convention: off 7 | ibm-property-casing-convention: off 8 | ibm-enum-casing-convention: off 9 | ibm-path-segment-casing-convention: off 10 | -------------------------------------------------------------------------------- /app/middleware/settings_context_processor.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | def inject_settings_values(request): 5 | """ 6 | Context processor to inject specific settings values into 7 | the template rendering context. Values should be prefixed 8 | with 'SETTINGS_' and otherwise use the same name as in the 9 | settings file. 10 | """ 11 | return {'SETTINGS_APP_VERSION': settings.APP_VERSION} 12 | -------------------------------------------------------------------------------- /app/config/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for project project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /app/templates/admin/change_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_form.html" %} 2 | {% load i18n admin_urls %} 3 | {% block object-tools-items %} 4 | {% if property_upload_url %} 5 |
  • 6 | Upload file 7 |
  • 8 | {% endif %} 9 |
  • 10 | {% translate "History" %} 11 |
  • 12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0042_remove_collectionasset_is_external.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.7 on 2024-07-22 12:05 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('stac_api', '0041_alter_collection_external_asset_pattern'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='collectionasset', 15 | name='is_external', 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0044_alter_collectionlink_unique_together.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.7 on 2024-07-30 15:26 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("stac_api", "0043_remove_collection_external_asset_pattern_and_more"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterUniqueTogether( 13 | name="collectionlink", 14 | unique_together=set(), 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /app/stac_api/templates/style/admin/upload.css: -------------------------------------------------------------------------------- 1 | .box { 2 | padding: 5px 10px 9px; 3 | border: 2px solid; 4 | border-radius: 20px; 5 | margin: 15px 0px; 6 | } 7 | 8 | .info { 9 | border-color: #264b5d; 10 | } 11 | 12 | .error { 13 | border-color: rgb(137, 0, 0); 14 | } 15 | 16 | .statusbox { 17 | padding: 5px 10px 9px; 18 | margin: 15px 0px; 19 | } 20 | 21 | .fileUploadForm { 22 | margin: 20px 0 40px; 23 | } 24 | 25 | .fileUploadForm div { 26 | margin: 20px 0; 27 | } 28 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0010_auto_20210621_1453.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.10 on 2021-06-21 14:53 2 | 3 | from django.db import migrations 4 | from django.db import models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('stac_api', '0009_collection_published'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddIndex( 15 | model_name='collection', 16 | index=models.Index(fields=['published'], name='collection_published_idx'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0008_auto_20210621_0840.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.10 on 2021-06-21 08:40 2 | 3 | from django.db import migrations 4 | from django.db import models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('stac_api', '0007_auto_20210421_0701'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='provider', 16 | name='description', 17 | field=models.TextField(blank=True, default=None, null=True), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /app/tests/runner.py: -------------------------------------------------------------------------------- 1 | from django.test.runner import DiscoverRunner 2 | 3 | 4 | class TestRunner(DiscoverRunner): 5 | 6 | # We run the tests with debug True 7 | # otherwise we run into issues with things 8 | # defined in settings_dev.py. 9 | # Other option would be a dedicated test settings 10 | # file, but this would require to set the ENV 11 | # variable DJANGO_SETTINGS_MODULE whenever running 12 | # tests (see https://stackoverflow.com/a/41349685/9896222) 13 | def __init__(self, *args, **kwargs): 14 | super().__init__(*args, **kwargs) 15 | self.debug_mode = True 16 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0004_auto_20210408_0659.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-04-08 06:59 2 | 3 | from django.db import migrations 4 | from django.db import models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('stac_api', '0003_auto_20210325_1001'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='asset', 16 | name='checksum_multihash', 17 | field=models.CharField( 18 | blank=True, default=None, editable=False, max_length=255, null=True 19 | ), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | pythonpath = app 3 | 4 | DJANGO_SETTINGS_MODULE= app.config.settings_test 5 | 6 | addopts = --ds=config.settings_test 7 | 8 | django_debug_mode = true 9 | 10 | python_files = test_*.py 11 | 12 | # we have some environment variables that are mandatory, make sure that the 13 | # code can read them 14 | # The settings-setup is a bit unideal. Even though we specify the aws settings 15 | # manually in settings_test, it'll still raise exceptions since settings_prod 16 | # wants to read the environment, and settings_prod is included in settings_test 17 | env_files = 18 | .env 19 | .env.local 20 | .env.default 21 | 22 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0012_auto_20210709_0734.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.10 on 2021-07-09 07:34 2 | 3 | from django.db import migrations 4 | from django.db import models 5 | 6 | import stac_api.validators 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('stac_api', '0011_auto_20210623_0521'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name='asset', 18 | name='eo_gsd', 19 | field=models.FloatField( 20 | blank=True, null=True, validators=[stac_api.validators.validate_eo_gsd] 21 | ), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /spec/components/headers.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.3 2 | components: 3 | headers: 4 | ETag: 5 | schema: 6 | type: string 7 | description: >- 8 | The RFC7232 ETag header field in a response provides the current entity- 9 | tag for the selected resource. An entity-tag is an opaque identifier for 10 | different versions of a resource over time, regardless whether multiple 11 | versions are valid at the same time. An entity-tag consists of an opaque 12 | quoted string, possibly prefixed by a weakness indicator. 13 | example: "d01af8b8ebbf899e30095be8754b377ddb0f0ed0f7fddbc33ac23b0d1969736b" 14 | required: true 15 | -------------------------------------------------------------------------------- /app/tests/tests_09/utils.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | 3 | from django.core.management import call_command 4 | from django.urls import reverse 5 | 6 | from tests.tests_09.base_test import VERSION_SHORT 7 | 8 | 9 | def reverse_version(viewname, urlconf=None, args=None, kwargs=None, current_app=None): 10 | ns = VERSION_SHORT 11 | return reverse(ns + ':' + viewname, urlconf, args, kwargs, current_app=ns) 12 | 13 | 14 | def calculate_extent(*args, **kwargs): 15 | out = StringIO() 16 | call_command( 17 | "calculate_extent", 18 | *args, 19 | stdout=out, 20 | stderr=StringIO(), 21 | **kwargs, 22 | ) 23 | return out.getvalue() 24 | -------------------------------------------------------------------------------- /app/tests/tests_10/utils.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | 3 | from django.core.management import call_command 4 | from django.urls import reverse 5 | 6 | from tests.tests_10.base_test import VERSION_SHORT 7 | 8 | 9 | def reverse_version(viewname, urlconf=None, args=None, kwargs=None, current_app=None): 10 | ns = VERSION_SHORT 11 | return reverse(ns + ':' + viewname, urlconf, args, kwargs, current_app=ns) 12 | 13 | 14 | def calculate_extent(*args, **kwargs): 15 | out = StringIO() 16 | call_command( 17 | "calculate_extent", 18 | *args, 19 | stdout=out, 20 | stderr=StringIO(), 21 | **kwargs, 22 | ) 23 | return out.getvalue() 24 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0066_collectionasset_is_external.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.11 on 2025-03-24 13:38 2 | 3 | from django.db import migrations 4 | from django.db import models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('stac_api', '0065_remove_asset_add_del_asset_item_update_interval_trigger_and_more'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='collectionasset', 16 | name='is_external', 17 | field=models.BooleanField( 18 | default=False, help_text='Whether this asset is hosted externally' 19 | ), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0032_alter_asset_file.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-06-27 09:43 2 | 3 | from django.db import migrations 4 | 5 | import stac_api.models.general 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('stac_api', '0031_alter_collection_extent_geometry_alter_item_geometry'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='asset', 17 | name='file', 18 | field=stac_api.models.general.DynamicStorageFileField( 19 | max_length=255, upload_to=stac_api.models.general.upload_asset_to_path_hook 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /adr/2020_10_01_authentication.md: -------------------------------------------------------------------------------- 1 | # Authentication 2 | 3 | > `Status: accepted` 4 | 5 | > `Date: 2020-10-01` 6 | 7 | ## Context 8 | `service-stac` will be accepting machine-to-machine communication and will have an admin interface for operations/debugging. Authentication methods for this two use cases need to be defined. 9 | 10 | ## Decision 11 | Machine-to-machine communication will be using token authentication, access to the admin interface will be granted with usernames/passwords managed in the Django admin interface. At a later stage, this might be changed to a more advanced authentication scheme. 12 | 13 | ## Consequences 14 | Superuser and regular user permissions will be handled within the application. 15 | -------------------------------------------------------------------------------- /app/middleware/exception.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | logger = logging.getLogger(__name__) 5 | 6 | 7 | class ExceptionLoggingMiddleware: 8 | 9 | def __init__(self, get_response): 10 | self.get_response = get_response 11 | 12 | def __call__(self, request): 13 | return self.get_response(request) 14 | 15 | def process_exception(self, request, exception): 16 | # NOTE: this process_exception is not called for REST Framework endpoints. For those 17 | # the exceptions handling and logging is done within stac_api.apps.custom_exception_handler 18 | extra = {"request": request} 19 | logger.critical(repr(exception), extra=extra, exc_info=sys.exc_info()) 20 | -------------------------------------------------------------------------------- /app/tests/test_healthcheck.py: -------------------------------------------------------------------------------- 1 | # Deactivate healthcheck test as endpoint is also deactivated 2 | 3 | # from config.settings import HEALTHCHECK_ENDPOINT 4 | # from config.settings import STAC_BASE 5 | 6 | # from django.test import Client 7 | # from django.test import TestCase 8 | 9 | # class HealthCheckTestCase(TestCase): 10 | 11 | # def setUp(self): 12 | # self.client = Client() 13 | 14 | # def test_healthcheck(self): 15 | # response = self.client.get(f"/{STAC_BASE}/{HEALTHCHECK_ENDPOINT}") 16 | # self.assertEqual(response.status_code, 200) 17 | # self.assertEqual(response['Content-Type'], 'application/json') 18 | # self.assertIn('no-cache', response['Cache-control']) 19 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0038_collection_allow_external_assets.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.7 on 2024-07-18 09:09 2 | 3 | from django.db import migrations 4 | from django.db import models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('stac_api', '0037_asset_is_external_collectionasset_is_external'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='collection', 16 | name='allow_external_assets', 17 | field=models.BooleanField( 18 | default=False, 19 | help_text='Whether this collection can have assets that are hosted externally' 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0040_collection_external_asset_pattern.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.7 on 2024-07-19 12:44 2 | 3 | from django.db import migrations 4 | from django.db import models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('stac_api', '0038_collection_allow_external_assets'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='collection', 16 | name='external_asset_pattern', 17 | field=models.CharField( 18 | blank=True, 19 | help_text='The allowed regex pattern for external URLs', 20 | max_length=1024 21 | ), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0041_alter_collection_external_asset_pattern.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.7 on 2024-07-19 13:16 2 | 3 | from django.db import migrations 4 | from django.db import models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('stac_api', '0040_collection_external_asset_pattern'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='collection', 16 | name='external_asset_pattern', 17 | field=models.CharField( 18 | blank=True, 19 | help_text='The regex pattern for allowed external URLs', 20 | max_length=1024 21 | ), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /app/stac_api/templates/style/hover.css: -------------------------------------------------------------------------------- 1 | .NameHighlights { 2 | position:relative; 3 | } 4 | .NameHighlights div.SearchUsage { 5 | display: none; 6 | } 7 | .NameHighlightsHover { 8 | position:relative; 9 | } 10 | .NameHighlightsHover div.SearchUsage { 11 | display:block; 12 | position:absolute; 13 | width: 70em; 14 | top:4em; 15 | left:70px; 16 | z-index:1000; 17 | color: black; 18 | background-color: #DDD; 19 | padding: 5px; 20 | border-radius: 4px; 21 | } 22 | 23 | 24 | 25 | .SearchUsage ul li { 26 | list-style-type: "- " !important; 27 | list-style-position: inside !important; 28 | margin-left: 1em !important; 29 | } 30 | 31 | .SearchUsage ul li i { 32 | font-family: monospace; 33 | } 34 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0026_add_asset_upload_content_encoding.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.10 on 2023-03-22 14:15 2 | 3 | from django.db import migrations 4 | from django.db import models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('stac_api', '0025_add_asset_media_type'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='assetupload', 16 | name='content_encoding', 17 | field=models.CharField( 18 | blank=True, 19 | choices=[(None, ''), ('gzip', 'Gzip'), ('br', 'Br')], 20 | default='', 21 | max_length=32 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0018_assetupload_md5_parts.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.13 on 2021-09-15 14:05 2 | 3 | import django.core.serializers.json 4 | from django.db import migrations 5 | from django.db import models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('stac_api', '0017_data_collection_summaries_lang'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='assetupload', 17 | name='md5_parts', 18 | field=models.JSONField( 19 | blank=True, 20 | default=list, 21 | editable=False, 22 | encoder=django.core.serializers.json.DjangoJSONEncoder 23 | ), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /app/tests/test_checker.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from django.test import Client 4 | from django.test import TestCase 5 | from django.test import override_settings 6 | 7 | 8 | class CheckerTestCase(TestCase): 9 | 10 | def setUp(self): 11 | self.client = Client() 12 | 13 | def test_checker(self): 14 | response = self.client.get('/checker') 15 | self.assertEqual(response.status_code, 200) 16 | 17 | @override_settings(CHECKER_DELAY=2) 18 | def test_checker_delayed(self): 19 | start = time.time() 20 | response = self.client.get('/checker') 21 | end = time.time() 22 | self.assertEqual(response.status_code, 200) 23 | self.assertGreater(end, start + 2, f'Expected at least 2s, only took {end - start}') 24 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0049_item_properties_expires.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.8 on 2024-08-14 11:13 2 | 3 | from django.db import migrations 4 | from django.db import models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('stac_api', '0048_remove_item_update_item_collection_extent_trigger_and_more'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='item', 16 | name='properties_expires', 17 | field=models.DateTimeField( 18 | blank=True, 19 | help_text= 20 | 'Enter date in yyyy-mm-dd format, and time in UTC hh:mm:ss format', 21 | null=True 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0056_alter_collection_total_data_size_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.8 on 2024-11-28 16:08 2 | 3 | from django.db import migrations 4 | from django.db import models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("stac_api", "0055_alter_asset_file_size_alter_assetupload_file_size_and_more"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="collection", 15 | name="total_data_size", 16 | field=models.BigIntegerField(blank=True, default=0, null=True), 17 | ), 18 | migrations.AlterField( 19 | model_name="item", 20 | name="total_data_size", 21 | field=models.BigIntegerField(blank=True, default=0, null=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0009_collection_published.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.10 on 2021-06-21 13:57 2 | 3 | from django.db import migrations 4 | from django.db import models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('stac_api', '0008_auto_20210621_0840'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='collection', 16 | name='published', 17 | field=models.BooleanField( 18 | default=True, 19 | help_text= 20 | "When not published the collection doesn't appear on the api/stac/v0.9/collections " 21 | "endpoint and its items are not listed in /search endpoint." 22 | "

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

    " 23 | ), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0035_asset_roles.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-07-09 14:10 2 | 3 | import django.contrib.postgres.fields 4 | from django.db import migrations 5 | from django.db import models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('stac_api', '0034_merge_0032_alter_asset_file_0033_auto_20240704_1157'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='asset', 17 | name='roles', 18 | field=django.contrib.postgres.fields.ArrayField( 19 | base_field=models.CharField(max_length=255), 20 | blank=True, 21 | default=None, 22 | help_text='Comma-separated list of roles to describe the purpose of the asset', 23 | null=True, 24 | size=None 25 | ), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /app/tests/tests_09/test_conformance.py: -------------------------------------------------------------------------------- 1 | from django.test import Client 2 | from django.test import TestCase 3 | 4 | from tests.tests_09.base_test import STAC_BASE_V 5 | 6 | 7 | class ConformanceTestCase(TestCase): 8 | 9 | def setUp(self): 10 | self.client = Client() 11 | 12 | def test_conforms_to_page(self): 13 | response = self.client.get(f"/{STAC_BASE_V}/conformance") 14 | self.assertEqual(response.status_code, 200) 15 | self.assertEqual(response['Content-Type'], 'application/json') 16 | required_keys = ['conformsTo'] 17 | self.assertEqual( 18 | set(required_keys).difference(response.json().keys()), 19 | set(), 20 | msg="missing required attribute in json answer" 21 | ) 22 | 23 | self.assertGreater( 24 | len(response.json()['conformsTo']), 0, msg='there are no links defined in conformsTo' 25 | ) 26 | -------------------------------------------------------------------------------- /app/tests/tests_10/test_conformance.py: -------------------------------------------------------------------------------- 1 | from django.test import Client 2 | from django.test import TestCase 3 | 4 | from tests.tests_10.base_test import STAC_BASE_V 5 | 6 | 7 | class ConformanceTestCase(TestCase): 8 | 9 | def setUp(self): 10 | self.client = Client() 11 | 12 | def test_conforms_to_page(self): 13 | response = self.client.get(f"/{STAC_BASE_V}/conformance") 14 | self.assertEqual(response.status_code, 200) 15 | self.assertEqual(response['Content-Type'], 'application/json') 16 | required_keys = ['conformsTo'] 17 | self.assertEqual( 18 | set(required_keys).difference(response.json().keys()), 19 | set(), 20 | msg="missing required attribute in json answer" 21 | ) 22 | 23 | self.assertGreater( 24 | len(response.json()['conformsTo']), 0, msg='there are no links defined in conformsTo' 25 | ) 26 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0037_asset_is_external_collectionasset_is_external.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.7 on 2024-07-16 15:31 2 | 3 | from django.db import migrations 4 | from django.db import models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('stac_api', '0036_collectionasset_and_more'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='asset', 16 | name='is_external', 17 | field=models.BooleanField( 18 | default=False, help_text='Whether this asset is hosted externally' 19 | ), 20 | ), 21 | migrations.AddField( 22 | model_name='collectionasset', 23 | name='is_external', 24 | field=models.BooleanField( 25 | default=False, help_text='Whether this asset is hosted externally' 26 | ), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /app/tests/tests_09/test_cf_forwarded_proto.py: -------------------------------------------------------------------------------- 1 | from django.test import Client 2 | from django.test import TestCase 3 | 4 | from tests.tests_09.base_test import STAC_BASE_V 5 | 6 | 7 | class CFForwardedProtoTestCase(TestCase): 8 | 9 | def setUp(self): # pylint: disable=invalid-name 10 | self.client = Client() 11 | 12 | def test_http_access(self): 13 | response = self.client.get(f"/{STAC_BASE_V}", HTTP_ACCEPT='application/json', follow=True) 14 | for link in response.json().get('links', []): 15 | self.assertTrue(link['href'].startswith('http://')) 16 | 17 | def test_https_access(self): 18 | response = self.client.get( 19 | f"/{STAC_BASE_V}", 20 | HTTP_CLOUDFRONT_FORWARDED_PROTO='https', 21 | HTTP_ACCEPT='application/json', 22 | follow=True 23 | ) 24 | for link in response.json().get('links', []): 25 | self.assertTrue(link['href'].startswith('https://')) 26 | -------------------------------------------------------------------------------- /app/tests/tests_10/test_cf_forwarded_proto.py: -------------------------------------------------------------------------------- 1 | from django.test import Client 2 | from django.test import TestCase 3 | 4 | from tests.tests_10.base_test import STAC_BASE_V 5 | 6 | 7 | class CFForwardedProtoTestCase(TestCase): 8 | 9 | def setUp(self): # pylint: disable=invalid-name 10 | self.client = Client() 11 | 12 | def test_http_access(self): 13 | response = self.client.get(f"/{STAC_BASE_V}", HTTP_ACCEPT='application/json', follow=True) 14 | for link in response.json().get('links', []): 15 | self.assertTrue(link['href'].startswith('http://')) 16 | 17 | def test_https_access(self): 18 | response = self.client.get( 19 | f"/{STAC_BASE_V}", 20 | HTTP_CLOUDFRONT_FORWARDED_PROTO='https', 21 | HTTP_ACCEPT='application/json', 22 | follow=True 23 | ) 24 | for link in response.json().get('links', []): 25 | self.assertTrue(link['href'].startswith('https://')) 26 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | # .github/release.yml 2 | # WARNING this file is managed by terraform and cannot be edited manually, see 3 | # geoadmin/infra-terraform-bgdi/tf/github/geoadmin/modules/service-repository-semver/release_config.tf 4 | 5 | changelog: 6 | exclude: 7 | labels: 8 | - ignore-for-release 9 | - skip-changelog 10 | - skip-rn 11 | - skip-release-note 12 | - no-release-note 13 | - no-rn 14 | - no-changelog 15 | - new-release 16 | authors: 17 | - terraform-bgdi 18 | categories: 19 | - title: Breaking Changes 🛠 20 | labels: 21 | - breaking-change 22 | - title: New Features 23 | labels: 24 | - feature 25 | - enhancement 26 | - title: Data Updates 27 | labels: 28 | - data 29 | - data-integration 30 | - title: Bug Fixes 31 | labels: 32 | - fix 33 | - bugfix 34 | - bug 35 | - title: Other Changes 36 | labels: 37 | - "*" 38 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0054_update_conformance_endpoint.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.8 on 2024-10-09 11:45 2 | 3 | from django.db import migrations 4 | 5 | 6 | def add_landing_page_version(apps, schema_editor): 7 | LandingPage = apps.get_model("stac_api", "LandingPage") 8 | lp = LandingPage.objects.get(version='v1') 9 | lp.conformsTo.insert(2, 'https://api.stacspec.org/v1.0.0/ogcapi-features') 10 | lp.save() 11 | 12 | 13 | def reverse_landing_page_version(apps, schema_editor): 14 | # Remove the landing page v0.9 15 | LandingPage = apps.get_model("stac_api", "LandingPage") 16 | lp = LandingPage.objects.get(version='v1') 17 | lp.conformsTo.remove('https://api.stacspec.org/v1.0.0/ogcapi-features') 18 | lp.save() 19 | 20 | 21 | class Migration(migrations.Migration): 22 | dependencies = [ 23 | ("stac_api", "0053_alter_asset_media_type_and_more"), 24 | ] 25 | 26 | operations = [migrations.RunPython(add_landing_page_version, reverse_landing_page_version)] 27 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0058_collectionlink_hreflang_itemlink_hreflang_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.8 on 2024-12-05 10:23 2 | 3 | from django.db import migrations 4 | from django.db import models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('stac_api', '0057_item_forecast_duration_item_forecast_horizon_and_more'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='collectionlink', 16 | name='hreflang', 17 | field=models.CharField(blank=True, max_length=32, null=True), 18 | ), 19 | migrations.AddField( 20 | model_name='itemlink', 21 | name='hreflang', 22 | field=models.CharField(blank=True, max_length=32, null=True), 23 | ), 24 | migrations.AddField( 25 | model_name='landingpagelink', 26 | name='hreflang', 27 | field=models.CharField(blank=True, max_length=32, null=True), 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0063_collection_cache_control_header.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.11 on 2025-03-06 09:14 2 | 3 | from django.db import migrations 4 | from django.db import models 5 | 6 | import stac_api.validators 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('stac_api', '0062_item_item_fc_reference_datetime_idx_and_more'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name='collection', 18 | name='cache_control_header', 19 | field=models.CharField( 20 | blank=True, 21 | help_text= 22 | 'Cache-Control header value to use for this collection. When set it override the default cache control header value for all API call related to the collection as well as for the data download call.', 23 | max_length=255, 24 | null=True, 25 | validators=[stac_api.validators.validate_cache_control_header] 26 | ), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /app/stac_api/profiling.py: -------------------------------------------------------------------------------- 1 | import cProfile 2 | import functools 3 | import io 4 | import os 5 | import pstats 6 | 7 | 8 | def profiling(function_to_profile): 9 | ''''Profiling wrapper for debugging 10 | 11 | You can use this wrapper to get some profiling data on the function. The profiling data are 12 | printed in the console. 13 | ''' 14 | 15 | @functools.wraps(function_to_profile) 16 | def wrapper(*args, **kwargs): 17 | profiler = cProfile.Profile() 18 | 19 | profiler.enable() 20 | return_value = function_to_profile(*args, **kwargs) 21 | profiler.disable() 22 | 23 | stream = io.StringIO() 24 | stats = pstats.Stats(profiler, 25 | stream=stream).sort_stats(os.getenv('PROFILING_SORT_KEY', 'cumtime')) 26 | stats_lines = os.getenv('PROFILING_STATS_LINES', None) 27 | if stats_lines: 28 | stats.print_stats(stats_lines) 29 | else: 30 | stats.print_stats() 31 | 32 | return return_value 33 | 34 | return wrapper 35 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0043_remove_collection_external_asset_pattern_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.7 on 2024-07-23 08:19 2 | 3 | import django.contrib.postgres.fields 4 | from django.db import migrations 5 | from django.db import models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('stac_api', '0042_remove_collectionasset_is_external'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RemoveField( 16 | model_name='collection', 17 | name='external_asset_pattern', 18 | ), 19 | migrations.AddField( 20 | model_name='collection', 21 | name='external_asset_whitelist', 22 | field=django.contrib.postgres.fields.ArrayField( 23 | base_field=models.CharField(max_length=255), 24 | blank=True, 25 | default=list, 26 | help_text= 27 | 'Provide a comma separated list of protocol://domain values for the external asset url validation', 28 | size=None 29 | ), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0029_alter_collectionlink_href_alter_itemlink_href_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.10 on 2024-03-19 14:58 2 | 3 | from django.db import migrations 4 | from django.db import models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('stac_api', '0028_alter_asset_media_type'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='collectionlink', 16 | name='href', 17 | field=models.URLField(max_length=2048), 18 | ), 19 | migrations.AlterField( 20 | model_name='itemlink', 21 | name='href', 22 | field=models.URLField(max_length=2048), 23 | ), 24 | migrations.AlterField( 25 | model_name='landingpagelink', 26 | name='href', 27 | field=models.URLField(max_length=2048), 28 | ), 29 | migrations.AlterField( 30 | model_name='provider', 31 | name='url', 32 | field=models.URLField(blank=True, max_length=2048, null=True), 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0061_remove_item_forecast_mode_remove_item_forecast_param_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.8 on 2025-01-15 14:23 2 | 3 | from django.db import migrations 4 | from django.db import models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('stac_api', '0060_item_forecast_perturbed_item_forecast_variable'), 11 | ] 12 | 13 | operations = [ 14 | migrations.RemoveField( 15 | model_name='item', 16 | name='forecast_mode', 17 | ), 18 | migrations.RemoveField( 19 | model_name='item', 20 | name='forecast_param', 21 | ), 22 | migrations.AlterField( 23 | model_name='item', 24 | name='forecast_variable', 25 | field=models.CharField( 26 | blank=True, 27 | help_text= 28 | '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.', 29 | null=True 30 | ), 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /app/tests/tests_10/test_utils.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from stac_api.utils import parse_cache_control_header 4 | 5 | 6 | class TestUtils(TestCase): 7 | 8 | def test_parse_cache_control_header(self): 9 | self.assertEqual(parse_cache_control_header('max-age=360'), {'max-age': '360'}) 10 | self.assertEqual(parse_cache_control_header('max-age=360,'), {'max-age': '360'}) 11 | self.assertEqual(parse_cache_control_header(' max-age=360 , '), {'max-age': '360'}) 12 | self.assertEqual( 13 | parse_cache_control_header('max-age=360, public'), { 14 | 'max-age': '360', 'public': True 15 | } 16 | ) 17 | self.assertEqual( 18 | parse_cache_control_header(' max-age = 360 , test = hello'), { 19 | 'max-age': '360', 'test': 'hello' 20 | } 21 | ) 22 | self.assertEqual(parse_cache_control_header(''), {}) 23 | self.assertEqual(parse_cache_control_header(','), {}) 24 | self.assertEqual(parse_cache_control_header(' '), {}) 25 | self.assertEqual(parse_cache_control_header(' , '), {}) 26 | -------------------------------------------------------------------------------- /app/middleware/api_gateway_authentication.py: -------------------------------------------------------------------------------- 1 | from middleware import api_gateway 2 | 3 | from django.conf import settings 4 | 5 | from rest_framework.authentication import RemoteUserAuthentication 6 | 7 | 8 | class ApiGatewayAuthentication(RemoteUserAuthentication): 9 | header = api_gateway.REMOTE_USER_HEADER 10 | 11 | def authenticate(self, request): 12 | if not settings.FEATURE_AUTH_ENABLE_APIGW: 13 | return None 14 | 15 | api_gateway.validate_username_header(request) 16 | return super().authenticate(request) 17 | 18 | def authenticate_header(self, request): 19 | # For this authentication method, users send a "Bearer" token via the 20 | # Authorization header. API Gateway looks up that token in Cognito and 21 | # sets the Geoadmin-Username and Geoadmin-Authenticated headers. In this 22 | # module we only care about the Geoadmin-* headers. But when 23 | # authentication fails with a 401 error we need to hint at the correct 24 | # authentication method from the point of view of the user, which is the 25 | # Authorization/Bearer scheme. 26 | return 'Bearer' 27 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0017_data_collection_summaries_lang.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.13 on 2021-09-02 17:31 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('stac_api', '0016_auto_20210902_1731'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RunSQL( 14 | ''' 15 | WITH collection_summaries AS ( 16 | SELECT 17 | item.collection_id AS collection_id, 18 | array_remove(array_agg(DISTINCT(asset.geoadmin_lang)), null) AS geoadmin_lang 19 | FROM stac_api_item AS item 20 | LEFT JOIN stac_api_asset AS asset ON (asset.item_id = item.id) 21 | GROUP BY item.collection_id 22 | ) 23 | UPDATE stac_api_collection 24 | SET 25 | summaries_geoadmin_lang = collection_summaries.geoadmin_lang 26 | FROM collection_summaries 27 | WHERE collection_summaries.collection_id = stac_api_collection.id; 28 | ''', 29 | reverse_sql=migrations.RunSQL.noop 30 | ) 31 | ] 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | notification-configuration.json 2 | packaged.yaml 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # django developp sqlite db 13 | *db.sqlite3 14 | 15 | # Distribution / packaging 16 | .Python 17 | env/ 18 | _env 19 | build/ 20 | develop-eggs/ 21 | dist/ 22 | downloads/ 23 | eggs/ 24 | .eggs/ 25 | lib/ 26 | lib64/ 27 | parts/ 28 | sdist/ 29 | var/ 30 | wheels/ 31 | *.egg-info/ 32 | .installed.cfg 33 | *.egg 34 | .local/ 35 | 36 | # virtualenv 37 | .venv 38 | 39 | # mypy 40 | .mypy_cache/ 41 | 42 | # unittest report 43 | test-reports/ 44 | tests/report/ 45 | nose2-junit.xml 46 | 47 | # PyCharm generated files 48 | *.idea 49 | */bin/ 50 | */lib64 51 | */pip-selfcheck.json 52 | */pyvenv.cfg 53 | 54 | #vim 55 | *.swp 56 | 57 | # git generate files 58 | *.orig 59 | 60 | # IDE config files 61 | *.sublime-* 62 | *.code* 63 | 64 | # Makefile 65 | .timestamps 66 | 67 | # ignore local files 68 | **/settings.py 69 | .env 70 | .env.*local 71 | .volumes 72 | **/logs 73 | /scripts/**/*.zip 74 | 75 | # Node modules 76 | node_modules/* 77 | 78 | # Coverage 79 | .coverage* 80 | coverage.xml 81 | htmlcov 82 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0031_alter_collection_extent_geometry_alter_item_geometry.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-06-11 06:42 2 | 3 | import django.contrib.gis.db.models.fields 4 | from django.db import migrations 5 | 6 | import stac_api.validators 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('stac_api', '0030_alter_asset_media_type'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name='collection', 18 | name='extent_geometry', 19 | field=django.contrib.gis.db.models.fields.GeometryField( 20 | blank=True, default=None, editable=False, null=True, srid=4326 21 | ), 22 | ), 23 | migrations.AlterField( 24 | model_name='item', 25 | name='geometry', 26 | field=django.contrib.gis.db.models.fields.GeometryField( 27 | default= 28 | 'SRID=4326;POLYGON ((5.96 45.82, 5.96 47.81, 10.49 47.81, 10.49 45.82, 5.96 45.82))', 29 | srid=4326, 30 | validators=[stac_api.validators.validate_geometry] 31 | ), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /spec/transaction/tags.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.3 2 | tags: 3 | - name: Capabilities 4 | - name: Data 5 | - name: STAC 6 | - name: Data Management 7 | description: | 8 | The endpoints in this section create, update or delete STAC metadata. 9 | 10 | See the [Tech Docs](https://docs.geo.admin.ch/download-data/stac-api/overview.html) for more details 11 | on authentication and supported media types. 12 | 13 | - name: Asset Upload Management 14 | description: | 15 | The endpoints in this section manage the process of uploading assets to the STAC catalog. 16 | 17 | See the [Tech Docs](https://docs.geo.admin.ch/download-data/stac-api/overview.html) for examples and 18 | more details on authentication and compression. 19 | 20 | - name: Collection Asset Upload Management 21 | description: | 22 | The endpoints in this section manage the process of uploading collection assets to the STAC catalog. 23 | The flow of the requests is the same as for assets that belong to features. 24 | 25 | See the [Tech Docs](https://docs.geo.admin.ch/download-data/stac-api/overview.html) for examples and 26 | more details on authentication and compression. 27 | -------------------------------------------------------------------------------- /doc/stac-search-endpoint-example.md: -------------------------------------------------------------------------------- 1 | # STAC POST /search endpoint example 2 | 3 | ## Disclaimer 4 | 5 | This Readme file and the according Jupyter notebook might not stay in the service-stac repository but probably will be moved into another repo in the mid-term. 6 | 7 | ## Jupyter notebook 8 | 9 | This [jupyter notebook](./assets/stac-search-endpoint-example.ipynb) serves as a simple example for using the STAC API's POST /search endpoint. For the sake of readability, some best practices, such as proper error handling, checking checksums of the downloaded files, and the like, are not (fully) implemented in this notebook. 10 | 11 | ## Preparation 12 | 13 | For this notebook to work, you need a recent Python version installed. You further need to install the following dependencies, e.g. by running `pip install folium geopandas jupyter mapclassify matplotlib pandas requests xyzservices` or by creating a virtual environment, where those dependencies are installed. 14 | 15 | ## Jupyter notebooks 16 | 17 | Please refer to the documentation on how to run Jupyter notebooks on your machine: https://docs.jupyter.org/en/latest/running.html 18 | Some IDEs support Jupyter notebooks, when the according extensions are installed. 19 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0055_alter_asset_file_size_alter_assetupload_file_size_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.8 on 2024-11-28 16:06 2 | 3 | from django.db import migrations 4 | from django.db import models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("stac_api", "0054_update_conformance_endpoint"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="asset", 15 | name="file_size", 16 | field=models.BigIntegerField(blank=True, default=0, null=True), 17 | ), 18 | migrations.AlterField( 19 | model_name="assetupload", 20 | name="file_size", 21 | field=models.BigIntegerField(blank=True, default=0, null=True), 22 | ), 23 | migrations.AlterField( 24 | model_name="collectionasset", 25 | name="file_size", 26 | field=models.BigIntegerField(blank=True, default=0, null=True), 27 | ), 28 | migrations.AlterField( 29 | model_name="collectionassetupload", 30 | name="file_size", 31 | field=models.BigIntegerField(blank=True, default=0, null=True), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /app/config/version.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import subprocess 3 | 4 | # Note that this file is overwritten during the build 5 | # process by either the git tag of the commit 6 | # (see Dockerfile for details) 7 | 8 | # By default we expect to find a leightweight tag in 9 | # the history 10 | # This has the form 'v[0-9]+\.[0-9]+\.[0-9]+-beta.[0-9]' if 11 | # the tag is directly related to the commit or has an additional 12 | # suffix 'v[0-9]+\.[0-9]+\.[0-9]+-beta.[0-9]-[0-9]+-gHASH' denoting 13 | # the 'distance' to the latest tag 14 | with subprocess.Popen(["git", "describe", "--tags"], stdout=subprocess.PIPE, 15 | stderr=subprocess.PIPE) as proc: 16 | stdout, stderr = proc.communicate() 17 | GIT_VERSION = stdout.decode('utf-8').strip() 18 | if GIT_VERSION == '': 19 | # If theres no git tag found in the history we simply use the short 20 | # version of the latest git commit hash 21 | with subprocess.Popen(["git", "rev-parse", "--short", "HEAD"], 22 | stdout=subprocess.PIPE, 23 | stderr=subprocess.PIPE) as proc: 24 | stdout, stderr = proc.communicate() 25 | APP_VERSION = f"v_{stdout.decode('utf-8').strip()}" 26 | else: 27 | APP_VERSION = GIT_VERSION 28 | -------------------------------------------------------------------------------- /app/stac_api/exceptions.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | import rest_framework.exceptions 6 | from rest_framework import status 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class StacAPIException(rest_framework.exceptions.APIException): 12 | '''STAC API custom exception 13 | 14 | These exception can add additional data to the HTTP response. 15 | ''' 16 | 17 | def __init__(self, detail=None, code=None, data=None): 18 | super().__init__(detail, code) 19 | if isinstance(data, dict): 20 | self.data = data 21 | elif data: 22 | self.data = {'data': data} 23 | 24 | 25 | class UploadNotInProgressError(StacAPIException): 26 | status_code = status.HTTP_409_CONFLICT 27 | default_detail = _('No upload in progress') 28 | default_code = 'conflict' 29 | 30 | 31 | class UploadInProgressError(StacAPIException): 32 | status_code = status.HTTP_409_CONFLICT 33 | default_detail = _('Upload already in progress') 34 | default_code = 'conflict' 35 | 36 | 37 | class NotImplementedException(StacAPIException): 38 | status_code = status.HTTP_501_NOT_IMPLEMENTED 39 | default_detail = _('Not Implemented') 40 | default_code = 'not_implemented' 41 | -------------------------------------------------------------------------------- /app/stac_api/management/commands/populate_testdb.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.conf import settings 4 | 5 | from stac_api.sample_data import importer 6 | from stac_api.utils import CustomBaseCommand 7 | 8 | # path definition relative to the directory that contains manage.py 9 | DATADIR = settings.BASE_DIR / 'app/stac_api/sample_data/' 10 | 11 | 12 | class Command(CustomBaseCommand): 13 | help = """Populates the local test database with sample data 14 | 15 | The sample data has to be located in stac_api/management/sample_data and 16 | structured as follows 17 | / 18 | |- items/ 19 | |- .json 20 | |- .json 21 | |- collection.json 22 | """ 23 | 24 | def handle(self, *args, **options): 25 | # loop over the collection directories inside sample_data 26 | for collection_dir in os.scandir(DATADIR): 27 | if collection_dir.is_dir() and not collection_dir.name.startswith('_'): 28 | self.print('Import collection %s', collection_dir.name, level=1) 29 | importer.import_collection(collection_dir) 30 | else: 31 | self.print('Ignore file %s', collection_dir.name, level=2) 32 | self.print_success('Done') 33 | -------------------------------------------------------------------------------- /.style.yapf: -------------------------------------------------------------------------------- 1 | [style] 2 | based_on_style=google 3 | # Put closing brackets on a separate line, dedented, if the bracketed 4 | # expression can't fit in a single line. Applies to all kinds of brackets, 5 | # including function definitions and calls. For example: 6 | # 7 | # config = { 8 | # 'key1': 'value1', 9 | # 'key2': 'value2', 10 | # } # <--- this bracket is dedented and on a separate line 11 | # 12 | # time_series = self.remote_client.query_entity_counters( 13 | # entity='dev3246.region1', 14 | # key='dns.query_latency_tcp', 15 | # transform=Transformation.AVERAGE(window=timedelta(seconds=60)), 16 | # start_ts=now()-timedelta(days=3), 17 | # end_ts=now(), 18 | # ) # <--- this bracket is dedented and on a separate line 19 | dedent_closing_brackets=True 20 | coalesce_brackets=True 21 | 22 | # This avoid issues with complex dictionary 23 | # see https://github.com/google/yapf/issues/392#issuecomment-407958737 24 | indent_dictionary_value=True 25 | allow_split_before_dict_value=False 26 | 27 | # Split before arguments, but do not split all sub expressions recursively 28 | # (unless needed). 29 | split_all_top_level_comma_separated_values=True 30 | 31 | # Split lines longer than 100 characters (this only applies to code not to 32 | # comment and docstring) 33 | column_limit=100 34 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | yapf = "*" 8 | isort = "*" 9 | pylint = "*" 10 | pylint-django = "*" 11 | django-extensions = "*" 12 | django_debug_toolbar = "*" 13 | pip = "*" 14 | tblib = "*" # needed for traceback when running tests in parallel 15 | mock = "*" 16 | responses = "*" 17 | requests-mock = "*" 18 | moto = {extras = [ "s3",], version = "*"} 19 | debugpy = "*" 20 | parameterized = "*" 21 | stac-api-validator = "*" 22 | pytest = "*" 23 | pytest-django = "*" 24 | pytest-dotenv = "*" 25 | pytest-cov = "*" 26 | pytest-xdist = "*" 27 | 28 | [packages] 29 | gevent = "~=25.5" 30 | gunicorn = "~=23.0" 31 | psycopg = {extras = ["binary"], version = "~=3.2"} 32 | numpy = "~=2.3" 33 | python-dotenv = "~=1.1" 34 | djangorestframework = "~=3.16" 35 | Django = "~=5.2" 36 | PyYAML = "~=6.0" 37 | whitenoise = "~=6.9" 38 | djangorestframework-gis = "~=1.2" 39 | python-dateutil = "~=2.9" 40 | django-storages = "~=1.14" 41 | boto3 = "~=1.38" 42 | django-rest-framework-condition = "~=0.1" 43 | requests = "~=2.32" 44 | py-multihash = "~=2.0" 45 | django-prometheus = "~=2.3" 46 | django-admin-autocomplete-filter = "~=0.7" 47 | django-pgtrigger = "~=4.15" 48 | django-environ = "~=0.12" 49 | language-tags = "~=1.2" 50 | logging-utilities = "~=5.0" 51 | 52 | [requires] 53 | python_version = "3.12" 54 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0062_item_item_fc_reference_datetime_idx_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.11 on 2025-01-27 08:22 2 | 3 | from django.db import migrations 4 | from django.db import models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('stac_api', '0061_remove_item_forecast_mode_remove_item_forecast_param_and_more'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddIndex( 15 | model_name='item', 16 | index=models.Index( 17 | fields=['forecast_reference_datetime'], name='item_fc_reference_datetime_idx' 18 | ), 19 | ), 20 | migrations.AddIndex( 21 | model_name='item', 22 | index=models.Index(fields=['forecast_horizon'], name='item_fc_horizon_idx'), 23 | ), 24 | migrations.AddIndex( 25 | model_name='item', 26 | index=models.Index(fields=['forecast_duration'], name='item_fc_duration_idx'), 27 | ), 28 | migrations.AddIndex( 29 | model_name='item', 30 | index=models.Index(fields=['forecast_variable'], name='item_fc_variable_idx'), 31 | ), 32 | migrations.AddIndex( 33 | model_name='item', 34 | index=models.Index(fields=['forecast_perturbed'], name='item_fc_perturbed_idx'), 35 | ), 36 | ] 37 | -------------------------------------------------------------------------------- /app/middleware/cors.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.conf import settings 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | STAC_BASE = settings.STAC_BASE 8 | 9 | 10 | class CORSHeadersMiddleware: 11 | '''Middleware that adds appropriate CORS headers. 12 | 13 | CORS has only effect on browser applications (e.g. STAC browser), 14 | not on other systems. Therefore we only allow GET and HEAD requests 15 | on all endpoints except /search. 16 | ''' 17 | 18 | def __init__(self, get_response): 19 | self.get_response = get_response 20 | 21 | def __call__(self, request): 22 | # Code to be executed for each request before 23 | # the view (and later middleware) are called. 24 | 25 | response = self.get_response(request) 26 | 27 | # Code to be executed for each request/response after 28 | # the view is called. 29 | response['Access-Control-Allow-Origin'] = '*' 30 | # Access-Control-Allow-Methods: 31 | allow_methods = ['GET', 'HEAD'] 32 | # For /search we allow POST as well 33 | if request.path in (f'/{STAC_BASE}/v0.9/search', f'/{STAC_BASE}/v1/search'): 34 | allow_methods.append('POST') 35 | response['Access-Control-Allow-Methods'] = ','.join(allow_methods) 36 | response['Access-Control-Allow-Headers'] = 'Content-Type,Accept,Accept-Language' 37 | 38 | return response 39 | -------------------------------------------------------------------------------- /scripts/create_dummy_asset_for_multipart_testing.py: -------------------------------------------------------------------------------- 1 | # This will create a dummy asset file for testing the multipart upload via the API. 2 | 3 | import argparse 4 | import gzip 5 | import os 6 | 7 | 8 | def get_args(): 9 | parser = argparse.ArgumentParser( 10 | description="This script creates a dummy asset file for testing the multipart upload via " \ 11 | "the API. File size of the original file is specified via argument. The file will be " \ 12 | "zipped afterwards, hence the final file size is smaller than the specified size " \ 13 | "of the unzipped dummy asset file." 14 | ) 15 | parser.add_argument( 16 | "--size", 17 | type=int, 18 | default=20, 19 | help="Size of the unzipped dummy asset file in MB [Integer, default: 20 MB]" 20 | ) 21 | 22 | args = parser.parse_args() 23 | 24 | return args 25 | 26 | 27 | def main(): 28 | # parsing the args and defining variables 29 | args = get_args() 30 | 31 | FILE_SIZE = args.size * 1024**2 32 | with open("dummy_asset_for_multipart_testing.zip", "wb") as dummy_file: 33 | # compresslevel=1 will result in a low compression level, hence large .zip file size, which is 34 | # desired for testing here. 35 | dummy_file.write(gzip.compress(os.urandom(FILE_SIZE), compresslevel=1)) 36 | 37 | 38 | if __name__ == '__main__': 39 | main() -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | image: kartoza/postgis:16 4 | environment: 5 | - POSTGRES_DB=${DB_NAME:-service_stac_local} 6 | - POSTGRES_USER=postgres 7 | - POSTGRES_PASSWORD=postgres 8 | - POSTGRES_MULTIPLE_EXTENSIONS=postgis,postgis_topology 9 | - EXTRA_CONF=log_min_messages = ${DB_LOG_LEVEL:-FATAL} 10 | user: ${UID} 11 | ports: 12 | - ${DB_PORT:-15432}:5432 13 | volumes: 14 | - type: bind 15 | source: ${PWD}/.volumes/postgresql 16 | target: /var/lib/postgresql 17 | s3: 18 | image: minio/minio 19 | env_file: ./minio.env 20 | user: ${UID} 21 | command: server /data --console-address ":9001" 22 | volumes: 23 | - type: bind 24 | source: ${PWD}/.volumes/minio 25 | target: /data 26 | ports: 27 | - 9090:${S3_PORT:-9000} 28 | - 9001:9001 29 | s3-client: 30 | image: minio/mc 31 | links: 32 | - s3 33 | env_file: ./minio.env 34 | restart: on-failure 35 | entrypoint: > 36 | /bin/sh -c " 37 | set +o history; 38 | while ! echo > /dev/tcp/s3/${S3_PORT:-9000}; 39 | do 40 | echo waiting for minio; 41 | sleep 1; 42 | done; 43 | echo minio server is up; 44 | /usr/bin/mc alias set minio http://s3:${S3_PORT:-9000} minioadmin minioadmin; 45 | /usr/bin/mc policy set download minio/service-stac-local; 46 | exit 0; 47 | " 48 | -------------------------------------------------------------------------------- /app/config/settings_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | The special test settings file ensures that the moto 3 | mock is imported before anything else and can mock the boto3 4 | stuff right from the beginning. 5 | Note: Don't change the order of the first three lines, nothing 6 | else must be imported before the s3mock is started! 7 | 8 | isort:skip_file 9 | """ 10 | # pylint: disable=wildcard-import,unused-wildcard-import 11 | from config.settings import * 12 | 13 | AWS_SETTINGS = { 14 | 'legacy': { 15 | "access_type": "key", 16 | "ACCESS_KEY_ID": 'my-key', 17 | "SECRET_ACCESS_KEY": 'my-key', 18 | "DEFAULT_ACL": 'public-read', 19 | "S3_REGION_NAME": 'wonderland', 20 | "S3_ENDPOINT_URL": None, 21 | "S3_CUSTOM_DOMAIN": 'testserver', 22 | "S3_BUCKET_NAME": 'legacy', 23 | "S3_SIGNATURE_VERSION": "s3v4" 24 | }, 25 | "managed": { 26 | "access_type": "service_account", 27 | "DEFAULT_ACL": 'public-read', 28 | "ROLE_ARN": 'Arnold', 29 | "S3_REGION_NAME": 'wonderland', 30 | "S3_ENDPOINT_URL": None, 31 | "S3_CUSTOM_DOMAIN": 'testserver', 32 | "S3_BUCKET_NAME": 'managed', 33 | "S3_SIGNATURE_VERSION": "s3v4" 34 | } 35 | } 36 | 37 | try: 38 | EXTERNAL_TEST_ASSET_URL = env('EXTERNAL_TEST_ASSET_URL') 39 | EXTERNAL_TEST_ASSET_URL_2 = env('EXTERNAL_TEST_ASSET_URL_2') 40 | except KeyError as err: 41 | raise KeyError('External asset URL must be set for unit testing') from err 42 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0015_data_collection_summaries.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.13 on 2021-08-18 06:51 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('stac_api', '0014_auto_20210715_1358'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RunSQL( 14 | ''' 15 | WITH collection_summaries AS ( 16 | SELECT 17 | item.collection_id AS collection_id, 18 | array_remove(array_agg(DISTINCT(asset.proj_epsg)), null) AS proj_epsg, 19 | array_remove(array_agg(DISTINCT(asset.geoadmin_variant)), null) AS geoadmin_variant, 20 | array_remove(array_agg(DISTINCT(asset.eo_gsd)), null) AS eo_gsd 21 | FROM stac_api_item AS item 22 | LEFT JOIN stac_api_asset AS asset ON (asset.item_id = item.id) 23 | GROUP BY item.collection_id 24 | ) 25 | UPDATE stac_api_collection 26 | SET 27 | summaries_proj_epsg = collection_summaries.proj_epsg, 28 | summaries_geoadmin_variant = collection_summaries.geoadmin_variant, 29 | summaries_eo_gsd = collection_summaries.eo_gsd 30 | FROM collection_summaries 31 | WHERE collection_summaries.collection_id = stac_api_collection.id; 32 | ''', 33 | reverse_sql=migrations.RunSQL.noop 34 | ) 35 | ] 36 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0060_item_forecast_perturbed_item_forecast_variable.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.8 on 2025-01-15 13:08 2 | 3 | from django.db import migrations 4 | from django.db import models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('stac_api', '0059_alter_asset_media_type_and_more'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='item', 16 | name='forecast_perturbed', 17 | field=models.BooleanField( 18 | blank=True, 19 | default=None, 20 | help_text= 21 | '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.', 22 | null=True 23 | ), 24 | ), 25 | migrations.AddField( 26 | model_name='item', 27 | name='forecast_variable', 28 | field=models.CharField( 29 | blank=True, 30 | help_text= 31 | '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.', 32 | null=True 33 | ), 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /app/middleware/api_gateway.py: -------------------------------------------------------------------------------- 1 | REMOTE_USER_HEADER = "HTTP_GEOADMIN_USERNAME" 2 | 3 | 4 | def validate_username_header(request): 5 | """Drop the Geoadmin-Username header if it's invalid. 6 | 7 | This should be called before making any decision based on the value of the 8 | Geoadmin-Username header. 9 | 10 | API Gateway always sends the Geoadmin-Username header regardless of 11 | whether it was able to authenticate the user. If it could not 12 | authenticate the user, the value of the header as seen on the wire is a 13 | single whitespace. An hexdump looks like this: 14 | 15 | 47 65 6f 61 64 6d 69 6e 5f 75 73 65 72 6e 61 6d 65 3a 20 0d 0a 16 | Geoadmin-Username:... 17 | 18 | This doesn't seem possible to reproduce with curl. It is possible to 19 | reproduce with wget. It is unclear whether that technically counts as an 20 | empty value or a whitespace. It is also possible that AWS change their 21 | implementation later to send something slightly different. Regardless, 22 | we already have a separate signal to tell us whether that value is 23 | valid: Geoadmin-Authenticated. So we only consider Geoadmin-Username if 24 | Geoadmin-Authenticated is set to "true". 25 | 26 | Based on discussion in https://code.djangoproject.com/ticket/35971 27 | """ 28 | apigw_auth = request.META.get("HTTP_GEOADMIN_AUTHENTICATED", "false").lower() == "true" 29 | if not apigw_auth and REMOTE_USER_HEADER in request.META: 30 | del request.META[REMOTE_USER_HEADER] 31 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0011_auto_20210623_0521.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.10 on 2021-06-23 05:21 2 | 3 | from django.db import migrations 4 | from django.db import models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('stac_api', '0010_auto_20210621_1453'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='item', 16 | name='properties_datetime', 17 | field=models.DateTimeField( 18 | blank=True, 19 | help_text= 20 | 'Enter date in yyyy-mm-dd format, and time in UTC hh:mm:ss format', 21 | null=True 22 | ), 23 | ), 24 | migrations.AlterField( 25 | model_name='item', 26 | name='properties_end_datetime', 27 | field=models.DateTimeField( 28 | blank=True, 29 | help_text= 30 | 'Enter date in yyyy-mm-dd format, and time in UTC hh:mm:ss format', 31 | null=True 32 | ), 33 | ), 34 | migrations.AlterField( 35 | model_name='item', 36 | name='properties_start_datetime', 37 | field=models.DateTimeField( 38 | blank=True, 39 | help_text= 40 | 'Enter date in yyyy-mm-dd format, and time in UTC hh:mm:ss format', 41 | null=True 42 | ), 43 | ), 44 | ] 45 | -------------------------------------------------------------------------------- /app/middleware/rest_framework_authentication.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | from rest_framework.authentication import BasicAuthentication 4 | from rest_framework.authentication import SessionAuthentication 5 | from rest_framework.authentication import TokenAuthentication 6 | 7 | 8 | class RestrictedBasicAuthentication(BasicAuthentication): 9 | """ A Django rest framework basic authentication class that skips v1 of STAC API. """ 10 | 11 | def authenticate(self, request): 12 | if settings.FEATURE_AUTH_RESTRICT_V1 and request.path.startswith( 13 | f'/{settings.STAC_BASE}/v1/' 14 | ): 15 | return None 16 | 17 | return super().authenticate(request) 18 | 19 | 20 | class RestrictedSessionAuthentication(SessionAuthentication): 21 | """ A Django rest framework session authentication class that skips v1 of STAC API. """ 22 | 23 | def authenticate(self, request): 24 | if settings.FEATURE_AUTH_RESTRICT_V1 and request.path.startswith( 25 | f'/{settings.STAC_BASE}/v1/' 26 | ): 27 | return None 28 | 29 | return super().authenticate(request) 30 | 31 | 32 | class RestrictedTokenAuthentication(TokenAuthentication): 33 | """ A Django rest framework token authentication class that skips v1 of STAC API. """ 34 | 35 | def authenticate(self, request): 36 | if settings.FEATURE_AUTH_RESTRICT_V1 and request.path.startswith( 37 | f'/{settings.STAC_BASE}/v1/' 38 | ): 39 | return None 40 | 41 | return super().authenticate(request) 42 | -------------------------------------------------------------------------------- /adr/2020_10_21_static_asset.md: -------------------------------------------------------------------------------- 1 | # Static Assets 2 | 3 | > `Status: accepted` 4 | 5 | > `Date: 2020-10-21` 6 | 7 | > `Participants: Brice Schaffner, Christoph Böcklin` 8 | 9 | ## Context 10 | 11 | `service-stac` needs to serve some static assets for the admin pages (css, images, icons, ...). Django is not appropriate to serve static files on production environment. Currently Django is served directly by `gunicorn`. As a good practice to avoid issue with slow client and to avoid Denial of Service attacks, `gunicorn` should be served behind a Reversed proxy (e.g. Apache or Nginx). 12 | 13 | ## Decision 14 | 15 | Because it is to us not clear yet if a Reverse Proxy is really necessary for our Architecture (CloudFront with Kubernetes Ingress), we decided to use WhiteNoise for static assets. This middleware seems to performs well with CDN (like CloudFront) therefore we will use it to serve static files as it is very simple to uses and take care of compressing and settings corrects Headers for caching. 16 | 17 | ## Consequences 18 | 19 | We might have to reconsider in future using a Reverse Proxy for `gunicorn` and then uses this proxy (e.g. nginx) to serve static files instead of Whitenoise. 20 | 21 | ## References 22 | 23 | - [Static Files Made Easy — Django Compressor + Whitenoise + AWS CloudFront + Heroku](https://medium.com/technolingo/fastest-static-files-served-django-compressor-whitenoise-aws-cloudfront-ef777849090c) 24 | - [Isn’t serving static files from Python horribly inefficient?](https://whitenoise.readthedocs.io/en/stable/index.html#isn-t-serving-static-files-from-python-horribly-inefficient) 25 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0032_delete_conformancepage_landingpage_conformsto_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-07-04 11:46 2 | 3 | import django.contrib.postgres.fields 4 | from django.db import migrations 5 | from django.db import models 6 | 7 | import stac_api.models.general 8 | import stac_api.validators 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | dependencies = [ 14 | ('stac_api', '0031_alter_collection_extent_geometry_alter_item_geometry'), 15 | ] 16 | 17 | operations = [ 18 | migrations.DeleteModel(name='ConformancePage',), 19 | migrations.AddField( 20 | model_name='landingpage', 21 | name='conformsTo', 22 | field=django.contrib.postgres.fields.ArrayField( 23 | base_field=models.URLField(), 24 | default=stac_api.models.general.get_conformance_default_links, 25 | help_text='Comma-separated list of URLs for the value conformsTo', 26 | size=None 27 | ), 28 | ), 29 | migrations.AddField( 30 | model_name='landingpage', 31 | name='version', 32 | field=models.CharField(default='v1', max_length=255), 33 | ), 34 | migrations.AlterField( 35 | model_name='landingpage', 36 | name='name', 37 | field=models.CharField( 38 | default='ch', 39 | max_length=255, 40 | validators=[stac_api.validators.validate_name], 41 | verbose_name='id' 42 | ), 43 | ), 44 | ] 45 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0051_asset_file_size_assetupload_file_size_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.8 on 2024-10-08 13:07 2 | 3 | from django.db import migrations 4 | from django.db import models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("stac_api", "0050_collectionassetupload_and_more"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="asset", 15 | name="file_size", 16 | field=models.IntegerField(blank=True, default=0, null=True), 17 | ), 18 | migrations.AddField( 19 | model_name="assetupload", 20 | name="file_size", 21 | field=models.IntegerField(blank=True, default=0, null=True), 22 | ), 23 | migrations.AddField( 24 | model_name="collection", 25 | name="total_data_size", 26 | field=models.IntegerField(blank=True, default=0, null=True), 27 | ), 28 | migrations.AddField( 29 | model_name="collectionasset", 30 | name="file_size", 31 | field=models.IntegerField(blank=True, default=0, null=True), 32 | ), 33 | migrations.AddField( 34 | model_name="collectionassetupload", 35 | name="file_size", 36 | field=models.IntegerField(blank=True, default=0, null=True), 37 | ), 38 | migrations.AddField( 39 | model_name="item", 40 | name="total_data_size", 41 | field=models.IntegerField(blank=True, default=0, null=True), 42 | ), 43 | ] 44 | -------------------------------------------------------------------------------- /app/stac_api/views/test.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from rest_framework import generics 4 | 5 | from stac_api.models.general import LandingPage 6 | from stac_api.views.collection import CollectionAssetDetail 7 | from stac_api.views.collection import CollectionDetail 8 | from stac_api.views.item import AssetDetail 9 | from stac_api.views.item import ItemDetail 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class TestHttp500(generics.GenericAPIView): 15 | queryset = LandingPage.objects.all() 16 | 17 | def get(self, request, *args, **kwargs): 18 | logger.debug('Test request that raises an exception') 19 | 20 | raise AttributeError('test exception') 21 | 22 | 23 | class TestCollectionUpsertHttp500(CollectionDetail): 24 | 25 | def perform_upsert(self, serializer, lookup): 26 | super().perform_upsert(serializer, lookup) 27 | raise AttributeError('test exception') 28 | 29 | 30 | class TestItemUpsertHttp500(ItemDetail): 31 | 32 | def perform_upsert(self, serializer, lookup): 33 | super().perform_upsert(serializer, lookup) 34 | 35 | raise AttributeError('test exception') 36 | 37 | 38 | class TestAssetUpsertHttp500(AssetDetail): 39 | 40 | def perform_upsert(self, serializer, lookup): 41 | super().perform_upsert(serializer, lookup) 42 | raise AttributeError('test exception') 43 | 44 | 45 | class TestCollectionAssetUpsertHttp500(CollectionAssetDetail): 46 | 47 | def perform_upsert(self, serializer, lookup): 48 | super().perform_upsert(serializer, lookup) 49 | raise AttributeError('test exception') 50 | -------------------------------------------------------------------------------- /.env.default: -------------------------------------------------------------------------------- 1 | HTTP_PORT=8000 2 | LOGS_DIR=logs 3 | DB_USER=postgres 4 | DB_PW=postgres 5 | DB_NAME=service_stac_local 6 | DB_NAME_TEST=test_service_stac_local 7 | DB_HOST=localhost 8 | DB_PORT=15432 9 | DEBUG=True 10 | DEBUG_PROPAGATE_API_EXCEPTIONS=False 11 | 12 | LEGACY_AWS_ACCESS_KEY_ID=minioadmin 13 | LEGACY_AWS_SECRET_ACCESS_KEY=minioadmin 14 | 15 | LEGACY_AWS_S3_BUCKET_NAME=service-stac-local 16 | LEGACY_AWS_S3_REGION_NAME=eu-central-1 17 | LEGACY_AWS_S3_ENDPOINT_URL=http://127.0.0.1:9090 18 | LEGACY_AWS_S3_CUSTOM_DOMAIN=127.0.0.1:9090/service-stac-local 19 | SECRET_KEY=dummy 20 | HEALTHCHECK_ENDPOINT=healthcheck 21 | ALLOWED_HOSTS=* 22 | MANAGED_BUCKET_COLLECTION_PATTERNS=ch.meteoschweiz.ogd-,ch.bgdi-test. 23 | 24 | # these are just here for completeness 25 | AWS_ROLE_ARN=some-arn 26 | AWS_WEB_IDENTITY_TOKEN_FILE=token 27 | 28 | AWS_S3_BUCKET_NAME=service-stac-local-managed 29 | AWS_S3_REGION_NAME=eu-central-1 30 | AWS_S3_ENDPOINT_URL=http://127.0.0.1:9090 31 | AWS_S3_CUSTOM_DOMAIN=127.0.0.1:9090/service-stac-local 32 | 33 | # SET THIS value to a URL that points to a jpeg file that is publicly 34 | # reachable in the web 35 | # this will be used by the unit tests for external assets 36 | EXTERNAL_TEST_ASSET_URL=https://prod-swisstopoch-hcms-sdweb.imgix.net/2024/07/04/04d3aafe-99b1-4589-934c-337583eb5564.jpeg 37 | EXTERNAL_TEST_ASSET_URL_2=https://prod-swisstopoch-hcms-sdweb.imgix.net/2023/11/14/606881aa-ab86-47f8-8067-cd360f6b48b5.jpg 38 | 39 | # Those settings are required in order to run E2E tests against the localhost 40 | SESSION_EXPIRE_AT_BROWSER_CLOSE=False 41 | SESSION_COOKIE_AGE=28800 42 | SESSION_COOKIE_SECURE=False 43 | -------------------------------------------------------------------------------- /app/tests/tests_09/test_landing_page.py: -------------------------------------------------------------------------------- 1 | from django.test import Client 2 | 3 | from tests.tests_09.base_test import STAC_BASE_V 4 | from tests.tests_09.base_test import STAC_VERSION 5 | from tests.tests_09.base_test import StacBaseTestCase 6 | 7 | 8 | class IndexTestCase(StacBaseTestCase): 9 | 10 | def setUp(self): 11 | self.client = Client() 12 | 13 | def test_landing_page(self): 14 | response = self.client.get(f"/{STAC_BASE_V}/") 15 | self.assertEqual(response.status_code, 200) 16 | self.assertCors(response) 17 | self.assertEqual(response['Content-Type'], 'application/json') 18 | required_keys = ['description', 'id', 'stac_version', 'links'] 19 | self.assertEqual( 20 | set(required_keys).difference(response.json().keys()), 21 | set(), 22 | msg="missing required attribute in json answer" 23 | ) 24 | self.assertEqual(response.json()['id'], 'ch') 25 | self.assertEqual(response.json()['stac_version'], STAC_VERSION) 26 | for link in response.json()['links']: 27 | required_keys = ['href', 'rel'] 28 | self.assertEqual( 29 | set(required_keys).difference(link.keys()), 30 | set(), 31 | msg="missing required attribute in the answer['links'] array" 32 | ) 33 | 34 | def test_landing_page_redirect(self): 35 | response = self.client.get(f"/{STAC_BASE_V}") 36 | self.assertEqual(response.status_code, 301) 37 | self.assertLocationHeader(f"/{STAC_BASE_V}/", response) 38 | self.assertCors(response) 39 | -------------------------------------------------------------------------------- /app/tests/tests_09/test_get_token_endpoint.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.test import TestCase 3 | from django.urls import reverse 4 | 5 | from rest_framework.authtoken.models import Token 6 | from rest_framework.test import APIClient 7 | 8 | from tests.utils import get_http_error_description 9 | 10 | 11 | class GetTokenEndpointTestCase(TestCase): 12 | 13 | def setUp(self): 14 | self.client = APIClient() 15 | self.username = 'SherlockHolmes' 16 | self.password = '221B_BakerStreet' 17 | self.user = get_user_model().objects.create_user( 18 | self.username, 'top@secret.co.uk', self.password 19 | ) 20 | 21 | def test_get_token_with_valid_credentials(self): 22 | url = reverse('get-token') 23 | response = self.client.post(url, {'username': self.username, 'password': self.password}) 24 | self.assertEqual(200, response.status_code, msg=get_http_error_description(response.json())) 25 | generated_token = response.data["token"] 26 | token_from_db = Token.objects.get(user=self.user) 27 | self.assertEqual( 28 | generated_token, 29 | token_from_db.key, 30 | msg="Generated token and token stored in DB do not match." 31 | ) 32 | 33 | def test_get_token_with_invalid_credentials(self): 34 | url = reverse('get-token') 35 | response = self.client.post(url, {'username': self.username, 'password': 'wrong_password'}) 36 | self.assertEqual( 37 | 400, response.status_code, msg="Token for unauthorized user has been created." 38 | ) 39 | -------------------------------------------------------------------------------- /app/tests/tests_10/test_get_token_endpoint.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.test import TestCase 3 | from django.urls import reverse 4 | 5 | from rest_framework.authtoken.models import Token 6 | from rest_framework.test import APIClient 7 | 8 | from tests.utils import get_http_error_description 9 | 10 | 11 | class GetTokenEndpointTestCase(TestCase): 12 | 13 | def setUp(self): 14 | self.client = APIClient() 15 | self.username = 'SherlockHolmes' 16 | self.password = '221B_BakerStreet' 17 | self.user = get_user_model().objects.create_user( 18 | self.username, 'top@secret.co.uk', self.password 19 | ) 20 | 21 | def test_get_token_with_valid_credentials(self): 22 | url = reverse('get-token') 23 | response = self.client.post(url, {'username': self.username, 'password': self.password}) 24 | self.assertEqual(200, response.status_code, msg=get_http_error_description(response.json())) 25 | generated_token = response.data["token"] 26 | token_from_db = Token.objects.get(user=self.user) 27 | self.assertEqual( 28 | generated_token, 29 | token_from_db.key, 30 | msg="Generated token and token stored in DB do not match." 31 | ) 32 | 33 | def test_get_token_with_invalid_credentials(self): 34 | url = reverse('get-token') 35 | response = self.client.post(url, {'username': self.username, 'password': 'wrong_password'}) 36 | self.assertEqual( 37 | 400, response.status_code, msg="Token for unauthorized user has been created." 38 | ) 39 | -------------------------------------------------------------------------------- /app/tests/tests_10/test_landing_page.py: -------------------------------------------------------------------------------- 1 | from django.test import Client 2 | 3 | from tests.tests_10.base_test import STAC_BASE_V 4 | from tests.tests_10.base_test import STAC_VERSION 5 | from tests.tests_10.base_test import StacBaseTestCase 6 | 7 | 8 | class IndexTestCase(StacBaseTestCase): 9 | 10 | def setUp(self): 11 | self.client = Client() 12 | 13 | def test_landing_page(self): 14 | response = self.client.get(f"/{STAC_BASE_V}/") 15 | self.assertEqual(response.status_code, 200) 16 | self.assertCors(response) 17 | self.assertEqual(response['Content-Type'], 'application/json') 18 | required_keys = ['description', 'id', 'stac_version', 'links', 'type'] 19 | self.assertEqual( 20 | set(required_keys).difference(response.json().keys()), 21 | set(), 22 | msg="missing required attribute in json answer" 23 | ) 24 | self.assertEqual(response.json()['id'], 'ch') 25 | self.assertEqual(response.json()['stac_version'], STAC_VERSION) 26 | for link in response.json()['links']: 27 | required_keys = ['href', 'rel'] 28 | self.assertEqual( 29 | set(required_keys).difference(link.keys()), 30 | set(), 31 | msg="missing required attribute in the answer['links'] array" 32 | ) 33 | 34 | def test_landing_page_redirect(self): 35 | response = self.client.get(f"/{STAC_BASE_V}") 36 | self.assertEqual(response.status_code, 301) 37 | self.assertLocationHeader(f"/{STAC_BASE_V}/", response) 38 | self.assertCors(response) 39 | -------------------------------------------------------------------------------- /spec/transaction/components/examples.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.3 2 | components: 3 | examples: 4 | inprogress: 5 | summary: In progress upload example 6 | value: 7 | upload_id: KrFTuglD.N8ireqry_w3.oQqNwrYI7SfSXpVRiusKah0YigDnuM06hfJNIUZg4R_No0MMW9FLU2UG5anTW0boTUYVxKfBZWCFXqnQTpjnQEo1K7la39MYpjSTvIbZgnG 8 | status: in-progress 9 | number_parts: 1 10 | urls: 11 | - url: https://data.geo.admin.ch/ch.swisstopo.pixelkarte-farbe-pk50.noscale/smr200-200-4-2019/smr50-263-2016-2056-kgrs-2.5.tiff 12 | part: 1 13 | expires: '2019-08-24T14:15:22Z' 14 | created: '2019-08-24T14:15:22Z' 15 | file:checksum: 12200ADEC47F803A8CF1055ED36750B3BA573C79A3AF7DA6D6F5A2AED03EA16AF3BC 16 | completed: 17 | summary: Completed upload example 18 | value: 19 | upload_id: KrFTuglD.N8ireqry_w3.oQqNwrYI7SfSXpVRiusKah0YigDnuM06hfJNIUZg4R_No0MMW9FLU2UG5anTW0boTUYVxKfBZWCFXqnQTpjnQEo1K7la39MYpjSTvIbZgnG 20 | status: completed 21 | number_parts: 1 22 | created: '2019-08-24T14:15:22Z' 23 | completed: '2019-08-24T14:15:22Z' 24 | file:checksum: 12200ADEC47F803A8CF1055ED36750B3BA573C79A3AF7DA6D6F5A2AED03EA16AF3BC 25 | aborted: 26 | summary: Aborted upload example 27 | value: 28 | upload_id: KrFTuglD.N8ireqry_w3.oQqNwrYI7SfSXpVRiusKah0YigDnuM06hfJNIUZg4R_No0MMW9FLU2UG5anTW0boTUYVxKfBZWCFXqnQTpjnQEo1K7la39MYpjSTvIbZgnG 29 | status: completed 30 | number_parts: 1 31 | created: '2019-08-24T14:15:22Z' 32 | aborted: '2019-08-24T14:15:22Z' 33 | file:checksum: 12200ADEC47F803A8CF1055ED36750B3BA573C79A3AF7DA6D6F5A2AED03EA16AF3BC 34 | -------------------------------------------------------------------------------- /app/stac_api/management/commands/manage_superuser.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import environ 4 | 5 | from django.contrib.auth import get_user_model 6 | 7 | from stac_api.utils import CustomBaseCommand 8 | 9 | env = environ.Env() 10 | 11 | 12 | class Command(CustomBaseCommand): 13 | """Create or update superuser from information from the environment 14 | 15 | This command is used to make sure that the superuser is created and 16 | configured. The data for it will be created centrally in terraform. 17 | This will help with the password rotation. 18 | """ 19 | 20 | help = "Superuser management (creating or updating)" 21 | 22 | def handle(self, *args: Any, **options: Any) -> None: 23 | User = get_user_model() # pylint: disable=invalid-name 24 | username = env.str('DJANGO_SUPERUSER_USERNAME', default='').strip() 25 | email = env.str('DJANGO_SUPERUSER_EMAIL', default='').strip() 26 | password = env.str('DJANGO_SUPERUSER_PASSWORD', default='').strip() 27 | 28 | if not username or not email or not password: 29 | self.print_error('Environment variables not set or empty') 30 | return 31 | 32 | try: 33 | admin = User.objects.get(username=username) 34 | operation = 'Updated' 35 | except User.DoesNotExist: 36 | admin = User.objects.create(username=username) 37 | operation = 'Created' 38 | 39 | admin.set_password(password) 40 | admin.email = email 41 | admin.is_staff = True 42 | admin.is_superuser = True 43 | admin.save() 44 | 45 | self.print_success('%s the superuser %s', operation, username) 46 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0067_update_conformance.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | 4 | def update_conformance(apps, schema_editor): 5 | # Add item-search conformance 6 | LandingPage = apps.get_model("stac_api", "LandingPage") 7 | lp = LandingPage.objects.get(version='v1') 8 | lp.conformsTo = [ 9 | 'https://api.stacspec.org/v1.0.0/core', 10 | 'https://api.stacspec.org/v1.0.0/collections', 11 | 'https://api.stacspec.org/v1.0.0/ogcapi-features', 12 | 'https://api.stacspec.org/v1.0.0/item-search', 13 | 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core', 14 | 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/oas30', 15 | 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson', 16 | ] 17 | lp.save() 18 | 19 | 20 | def reverse_update_conformance(apps, schema_editor): 21 | # Remove item-search conformance 22 | LandingPage = apps.get_model("stac_api", "LandingPage") 23 | lp = LandingPage.objects.get(version='v1') 24 | lp.conformsTo = [ 25 | 'https://api.stacspec.org/v1.0.0/core', 26 | 'https://api.stacspec.org/v1.0.0/collections', 27 | 'https://api.stacspec.org/v1.0.0/ogcapi-features', 28 | 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core', 29 | 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/oas30', 30 | 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson', 31 | ] 32 | lp.save() 33 | 34 | 35 | class Migration(migrations.Migration): 36 | dependencies = [ 37 | ("stac_api", "0066_collectionasset_is_external"), 38 | ] 39 | 40 | operations = [migrations.RunPython(update_conformance, reverse_update_conformance)] 41 | -------------------------------------------------------------------------------- /app/middleware/api_gateway_middleware.py: -------------------------------------------------------------------------------- 1 | from middleware import api_gateway 2 | 3 | from django.conf import settings 4 | from django.contrib.auth.backends import RemoteUserBackend 5 | from django.contrib.auth.middleware import PersistentRemoteUserMiddleware 6 | 7 | 8 | class ApiGatewayMiddleware(PersistentRemoteUserMiddleware): 9 | """Persist user authentication based on the API Gateway headers.""" 10 | header = api_gateway.REMOTE_USER_HEADER 11 | 12 | def process_request(self, request): 13 | if not settings.FEATURE_AUTH_ENABLE_APIGW: 14 | return None 15 | 16 | if request.path.startswith(f'/{settings.STAC_BASE}/v'): 17 | # API authentication is only done using ApiGatewayAuthentication to avoid creating a new 18 | # session with each request 19 | return None 20 | 21 | api_gateway.validate_username_header(request) 22 | return super().process_request(request) 23 | 24 | 25 | class ApiGatewayUserBackend(RemoteUserBackend): 26 | """ This backend is to be used in conjunction with the ``ApiGatewayMiddleware` and the 27 | ``ApiGatewayAuthentication``. 28 | 29 | Until proper authorization is implemented, all remote users authenticated via API Gateway 30 | headers are treated as superusers. 31 | 32 | """ 33 | 34 | def authenticate(self, request, remote_user): 35 | if not settings.FEATURE_AUTH_ENABLE_APIGW: 36 | return None 37 | 38 | user = super().authenticate(request, remote_user) 39 | if user and not user.is_superuser: 40 | # promote authenticated user to superuser for now until proper authorization is 41 | # implemented 42 | user.is_superuser = True 43 | user.save() 44 | return user 45 | -------------------------------------------------------------------------------- /app/templates/uploadtemplate.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% block content %} 3 | 4 | 5 | 11 | 12 | {% load static %} 13 | 14 | 15 | 16 | 17 | 18 | 19 |

    Upload file for {{ collection_name }}/{{ item_name }}/{{ asset_name }}

    20 | 21 |
    22 | ⓘ This file upload will use the multipart form upload API in the background. 23 | The file will be uploaded to S3 using a presigned url. Maximum file size is 536,870,903 bytes (approximately 512 MB). 24 |
    25 | 30 | 31 |

    File upload

    32 |
    33 |
    34 | 35 | 36 |
    37 |
    38 | 39 |
    40 |
    41 | 42 |

    Current status

    43 |
    44 | ready to upload 45 |
    46 | 47 | {% endblock %} 48 | -------------------------------------------------------------------------------- /spec/README.md: -------------------------------------------------------------------------------- 1 | # Specs 2 | 3 | The API of this service is based on the [STAC API Specification](https://github.com/radiantearth/stac-api-spec) in version `0.9.0`, which itself is based on the [STAC Specification](https://github.com/radiantearth/stac-spec/tree/v0.9.0) and the [_OGC API - Features_](https://github.com/opengeospatial/ogcapi-features) specification. The default STAC spec is amended by geoadmin-specific parts that are explicitly mentioned in the spec, as well as adapted examples that resemble geoadmin-specific use cases. 4 | 5 | The spec is OpenAPI 3.0 compliant. The files are located in `spec/` and slightly split for better understanding. Two different versions of the spec can be compiled from these source files into `spec/static/` folder: an `openapi.yaml` file that contains the 'public' part with the REST endpoint and HTTP methods (mostly GET) defined in the standard spec, and an `openapitransactional.yaml` file that is intended for internal usage and contains info about the additional `/asset` endpoint and additional writing possibilities. 6 | 7 | The spec files can be compiled with 8 | 9 | ```bash 10 | make build-specs 11 | ``` 12 | 13 | and previewed locally with the little html ReDoc wrappers `spec/static/api.html` and `spec/static/apitransactional.html`. The can be served locally by invoking 14 | 15 | ```bash 16 | make serve-specs 17 | ``` 18 | 19 | which starts a simple http server that can be reached under `http://0.0.0.0:8090` (default port is `8090`, if you need another one, check the Makefile on how to do this). 20 | 21 | The generated files along with html wrappers are also included as static targets and are available under `https:///api/stac/v0.9/static/api.html`. 22 | 23 | Once the specs has been built they need to be validated with 24 | 25 | ```bash 26 | make lint-specs 27 | ``` 28 | -------------------------------------------------------------------------------- /spec/transaction/components/responses.yaml: -------------------------------------------------------------------------------- 1 | # openapi: 3.0.3 2 | components: 3 | responses: 4 | Assets: 5 | description: >- 6 | The response is a document consisting of all assets of the feature. 7 | content: 8 | application/json: 9 | schema: 10 | $ref: "./schemas.yaml#/components/schemas/assets" 11 | Asset: 12 | description: >- 13 | The response is a document consisting of one asset of the feature. 14 | headers: 15 | ETag: 16 | $ref: "../../components/headers.yaml#/components/headers/ETag" 17 | content: 18 | application/json: 19 | schema: 20 | $ref: "./schemas.yaml#/components/schemas/asset" 21 | DeletedResource: 22 | description: Status of the delete resource 23 | content: 24 | application/json: 25 | schema: 26 | description: >- 27 | Information about the deleted resource and a link to the parent resource 28 | type: object 29 | properties: 30 | code: 31 | type: integer 32 | example: 200 33 | description: 34 | type: string 35 | example: Resource successfully deleted 36 | links: 37 | type: array 38 | items: 39 | $ref: "../../components/schemas.yaml#/components/schemas/link" 40 | description: >- 41 | The array contain at least a link to the parent resource (`rel: parent`). 42 | example: 43 | - href: https://data.geo.admin.ch/api/stac/v1/collections/ch.swisstopo.pixelkarte-farbe-pk50.noscale 44 | rel: parent 45 | required: 46 | - code 47 | - links 48 | -------------------------------------------------------------------------------- /update_to_latest.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This script updates the Pipfile automatically. It will update all version strings of type 3 | # "~=x.x" to their respective latest version. Version strings of dependencies that use other 4 | # version specifiers (like "*") will be left untouched. All dependencies will be updated 5 | # (with "pipenv update") in the process. 6 | # A regex can be optionally specified as first argument. In this case, only the version strings 7 | # of packages matching the regex will be updated. (e.g. update_to_latest.sh 'django.*') 8 | # This script is meant as a helper only. Use git to revert unwanted changes made by this script. 9 | 10 | cd "$(dirname "$0")" || exit 11 | #If an argument was passed to the script, it will be used as a regular expression 12 | #Else we will simply match any package name 13 | regexp=${1:-'\S+'} 14 | line_regexp="^($regexp) = \"~=[0-9\\.]+\"(.*)$" 15 | 16 | #Generate an array of all packages that need to be updated and switch their version to "*" 17 | packages_to_modify=( $(cat Pipfile | sed -En "s/$line_regexp/\1/ip") ) 18 | echo "The script will try to update the following packages: ${packages_to_modify[*]}" 19 | read -p "Do you want to continue? [Y|N] " -n 1 -r 20 | echo 21 | [[ ! $REPLY =~ ^[Yy]$ ]] && exit 22 | sed -Ei "s/$line_regexp/\1 = \"*\"\2/i" Pipfile 23 | 24 | # Update the packages to the latest version 25 | pipenv update --dev 26 | 27 | # Set the current version in the Pipfile (only for the packages that were updated) 28 | updateVersions="" 29 | while read -r name version ; do 30 | if [[ " ${packages_to_modify[*]} " == *" $name "* ]] ; then 31 | updateVersions+="/^$name =/s/\"\\*\"/$version/" 32 | updateVersions+=$'\n' 33 | fi 34 | done < <(pipenv run pip freeze | sed -E 's/==([0-9]+)\.([0-9]+)\.[0-9]+\w*/ "~=\1.\2"/') 35 | sed -Ei -f <(echo "$updateVersions") Pipfile 36 | -------------------------------------------------------------------------------- /app/tests/tests_09/test_renderer.py: -------------------------------------------------------------------------------- 1 | from django.test import Client 2 | from django.test import TestCase 3 | 4 | from tests.tests_09.base_test import STAC_BASE_V 5 | 6 | 7 | class RendererTestCase(TestCase): 8 | 9 | def setUp(self): 10 | self.client = Client() 11 | 12 | def test_content_type_default(self): 13 | response = self.client.get(f"/{STAC_BASE_V}/search") 14 | self.assertEqual(response.status_code, 200) 15 | self.assertEqual(response['Content-Type'], 'application/json') 16 | self.assertEqual(response.json()['type'], 'FeatureCollection') 17 | 18 | def test_content_type_json(self): 19 | response = self.client.get(f"/{STAC_BASE_V}/search?format=json") 20 | self.assertEqual(response.status_code, 200) 21 | self.assertEqual(response['Content-Type'], 'application/json') 22 | self.assertEqual(response.json()['type'], 'FeatureCollection') 23 | 24 | response = self.client.get(f"/{STAC_BASE_V}/search", headers={'Accept': 'application/json'}) 25 | self.assertEqual(response.status_code, 200) 26 | self.assertEqual(response['Content-Type'], 'application/json') 27 | self.assertEqual(response.json()['type'], 'FeatureCollection') 28 | 29 | def test_content_type_geojson(self): 30 | response = self.client.get(f"/{STAC_BASE_V}/search?format=geojson") 31 | self.assertEqual(response.status_code, 200) 32 | self.assertEqual(response['Content-Type'], 'application/geo+json') 33 | self.assertEqual(response.json()['type'], 'FeatureCollection') 34 | 35 | response = self.client.get( 36 | f"/{STAC_BASE_V}/search", headers={'Accept': 'application/geo+json'} 37 | ) 38 | self.assertEqual(response.status_code, 200) 39 | self.assertEqual(response['Content-Type'], 'application/geo+json') 40 | self.assertEqual(response.json()['type'], 'FeatureCollection') 41 | -------------------------------------------------------------------------------- /app/tests/tests_10/test_renderer.py: -------------------------------------------------------------------------------- 1 | from django.test import Client 2 | from django.test import TestCase 3 | 4 | from tests.tests_10.base_test import STAC_BASE_V 5 | 6 | 7 | class RendererTestCase(TestCase): 8 | 9 | def setUp(self): 10 | self.client = Client() 11 | 12 | def test_content_type_default(self): 13 | response = self.client.get(f"/{STAC_BASE_V}/search") 14 | self.assertEqual(response.status_code, 200) 15 | self.assertEqual(response['Content-Type'], 'application/json') 16 | self.assertEqual(response.json()['type'], 'FeatureCollection') 17 | 18 | def test_content_type_json(self): 19 | response = self.client.get(f"/{STAC_BASE_V}/search?format=json") 20 | self.assertEqual(response.status_code, 200) 21 | self.assertEqual(response['Content-Type'], 'application/json') 22 | self.assertEqual(response.json()['type'], 'FeatureCollection') 23 | 24 | response = self.client.get(f"/{STAC_BASE_V}/search", headers={'Accept': 'application/json'}) 25 | self.assertEqual(response.status_code, 200) 26 | self.assertEqual(response['Content-Type'], 'application/json') 27 | self.assertEqual(response.json()['type'], 'FeatureCollection') 28 | 29 | def test_content_type_geojson(self): 30 | response = self.client.get(f"/{STAC_BASE_V}/search?format=geojson") 31 | self.assertEqual(response.status_code, 200) 32 | self.assertEqual(response['Content-Type'], 'application/geo+json') 33 | self.assertEqual(response.json()['type'], 'FeatureCollection') 34 | 35 | response = self.client.get( 36 | f"/{STAC_BASE_V}/search", headers={'Accept': 'application/geo+json'} 37 | ) 38 | self.assertEqual(response.status_code, 200) 39 | self.assertEqual(response['Content-Type'], 'application/geo+json') 40 | self.assertEqual(response.json()['type'], 'FeatureCollection') 41 | -------------------------------------------------------------------------------- /spec/transaction/components/parameters.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.3 2 | components: 3 | parameters: 4 | uploadId: 5 | name: uploadId 6 | in: path 7 | description: Local identifier of an asset's upload. 8 | required: true 9 | schema: 10 | type: string 11 | presignedUrl: 12 | name: presignedUrl 13 | in: path 14 | description: >- 15 | Presigned url returned by [Create a new Asset's multipart upload](#operation/createAssetUpload). 16 | 17 | Note: the url returned by the above endpoint is the full url including 18 | scheme, host and path 19 | required: true 20 | schema: 21 | type: string 22 | IfMatchWrite: 23 | name: If-Match 24 | in: header 25 | schema: 26 | type: string 27 | description: >- 28 | The RFC7232 `If-Match` header field makes the PUT/PATCH/DEL request method conditional. It is 29 | composed of a comma separated list of ETags or value "*". 30 | 31 | 32 | The server compares the client's ETags (sent with `If-Match`) with the ETag for its 33 | current version of the resource, and if both values don't match (that is, the resource has changed), 34 | the server sends back a `412 Precondition Failed` status, without a body, which tells the client that 35 | he would overwrite another changes of the resource. 36 | example: "d01af8b8ebbf899e30095be8754b377ddb0f0ed0f7fddbc33ac23b0d1969736b" 37 | IdempotencyKey: 38 | name: Idempotency-Key 39 | in: header 40 | schema: 41 | type: string 42 | description: >- 43 | A unique ID for the operation. 44 | This allows making the operation idempotent, so more fault tolerant. 45 | See IETF draft [draft-ietf-httpapi-idempotency-key-header](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/). 46 | example: "8e03978e-40d5-43e8-bc93-6894a57f9324" 47 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | service-stac is available for use under the following license, commonly known 2 | as the 3-clause (or "modified") BSD license: 3 | 4 | ============================== 5 | 6 | Copyright (c) 2021, swisstopo 7 | All rights reserved. 8 | 9 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 10 | 11 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 12 | 13 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 18 | 19 | ============================== 20 | 21 | Portions of service-stac are based on work of others. A non-exhaustive enumeration: 22 | 23 | [Django](https://github.com/django/django) 24 | 25 | [Django Rest Framework](https://github.com/encode/django-rest-framework) 26 | -------------------------------------------------------------------------------- /adr/2020_08_13_application_architecture.md: -------------------------------------------------------------------------------- 1 | # Application Architecture 2 | 3 | > `Status: accepted` 4 | 5 | > `Date: 2020-08-13` 6 | 7 | ## Context 8 | `service-stac` is a new service with use cases that are different to the ones in all existing services, specially the fully CRUD (create, read, update, delete) REST interface. Therefore, the choice of the application structure cannot simply be borrowed from an existing service. The use cases for this service will involve heavy read/write access to data via JSON REST API interface of a substantial amount of assets and metadata objects (tens to hundreds of Millions). Additionally, data must be editable manually, at least in a start/migration phase. 9 | 10 | ## Decision 11 | The following decisions are taken regarding the application architecture 12 | - Programming Language **Python**: Python is used in most of the backend services and is the programming language that's best known within devs, so no reason to change here. 13 | - Application Framework **Django**: Django is used as application framework. Django is very mature and has a wide user community. It comes with excellent documentation and a powerfull ORM. Futhermore, there are well-supported and maintained extensions, e.g. for designing REST API's that can considerably reduce the amount of boilerplate code needed for serializing, authentication, .... 14 | - Asset Storage **S3**: Since this service runs on AWS assets will be stored in S3 object store. 15 | - Metadata Storage **PostGIS**: Since Metadata can contain arbitrary geoJSON-supported geometries, Postgres along with the PostGIS extension is used as storage for metadata. 16 | 17 | The application architecture does initially only involve syncronous operations. Async tasks will be reconsider once certain write operations don't meet performance requirements anymore. 18 | 19 | ## Consequences 20 | Developers not familiar with Django will have to walk through the [Django tutorial](https://docs.djangoproject.com/en/dev/intro/) before they get started with development. 21 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0006_auto_20210419_1409.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-04-19 14:09 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations 5 | from django.db import models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('stac_api', '0005_auto_20210408_0821'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='item', 17 | name='collection', 18 | field=models.ForeignKey( 19 | help_text= 20 | '\n
    \n Search Usage:\n
      \n
    • \n arg will make a non exact search checking if arg is part of\n the collection ID\n
    • \n
    • \n Multiple arg can be used, separated by spaces. This will search for all\n collections ID containing all arguments.\n
    • \n
    • \n "collectionID" will make an exact search for the specified collection.\n
    • \n
    \n Examples :\n
      \n
    • \n Searching for pixelkarte will return all collections which have\n pixelkarte as a part of their collection ID\n
    • \n
    • \n Searching for pixelkarte 2016 4 will return all collection\n which have pixelkarte, 2016 AND 4 as part of their collection ID\n
    • \n
    • \n Searching for ch.swisstopo.pixelkarte.example will yield only this\n collection, if this collection exists. Please note that it would not return\n a collection named ch.swisstopo.pixelkarte.example.2.\n
    • \n
    \n
    ', 21 | on_delete=django.db.models.deletion.PROTECT, 22 | to='stac_api.collection' 23 | ), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0033_auto_20240704_1157.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-07-04 11:57 2 | 3 | from django.db import migrations 4 | 5 | 6 | def add_landing_page_version(apps, schema_editor): 7 | # Truncate the landing page table and add the versions v0.9 and v1 8 | LandingPage = apps.get_model("stac_api", "LandingPage") 9 | LandingPage.objects.all().delete() 10 | lp = LandingPage() 11 | lp.name = 'ch' 12 | lp.title = 'data.geo.admin.ch' 13 | lp.description = 'Data Catalog of the Swiss Federal Spatial Data Infrastructure' 14 | lp.version = 'v0.9' 15 | lp.conformsTo = [ 16 | 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core', 17 | 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/oas30', 18 | 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson' 19 | ] 20 | lp.save() 21 | lp = LandingPage() 22 | lp.name = 'ch' 23 | lp.title = 'data.geo.admin.ch' 24 | lp.description = 'Data Catalog of the Swiss Federal Spatial Data Infrastructure' 25 | lp.version = 'v1' 26 | lp.conformsTo = [ 27 | 'https://api.stacspec.org/v1.0.0/core', 28 | 'https://api.stacspec.org/v1.0.0/collections', 29 | 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core', 30 | 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/oas30', 31 | 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson' 32 | ] 33 | lp.save() 34 | 35 | 36 | def reverse_landing_page_version(apps, schema_editor): 37 | # Remove the landing page v0.9 38 | LandingPage = apps.get_model("stac_api", "LandingPage") 39 | lp = LandingPage.objects.get(version='v0.9') 40 | lp.delete() 41 | 42 | 43 | class Migration(migrations.Migration): 44 | 45 | dependencies = [ 46 | ('stac_api', '0032_delete_conformancepage_landingpage_conformsto_and_more'), 47 | ] 48 | 49 | operations = [ 50 | migrations.RunPython(add_landing_page_version, reverse_landing_page_version), 51 | ] 52 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0007_auto_20210421_0701.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-04-21 07:01 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations 5 | from django.db import models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('stac_api', '0006_auto_20210419_1409'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='asset', 17 | name='item', 18 | field=models.ForeignKey( 19 | help_text= 20 | '\n
    \n Search Usage:\n
      \n
    • \n arg will make a non exact search checking if >arg\n is part of the Item path\n
    • \n
    • \n Multiple arg can be used, separated by spaces. This will search\n for all elements containing all arguments in their path\n
    • \n
    • \n "collectionID/itemID" will make an exact search for the specified item.\n
    • \n
    \n Examples :\n
      \n
    • \n Searching for pixelkarte will return all items which have\n pixelkarte as a part of either their collection ID or their item ID\n
    • \n
    • \n Searching for pixelkarte 2016 4 will return all items\n which have pixelkarte, 2016 AND 4 as part of their collection ID or\n item ID\n
    • \n
    • \n Searching for "ch.swisstopo.pixelkarte.example/item2016-4-example"\n will yield only this item, if this item exists.\n
    • \n
    \n
    ', 21 | on_delete=django.db.models.deletion.PROTECT, 22 | related_name='assets', 23 | related_query_name='asset', 24 | to='stac_api.item' 25 | ), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /publiccode.yml: -------------------------------------------------------------------------------- 1 | publiccodeYmlVersion: 0.5.0 2 | name: STAC API 3 | applicationSuite: geo.admin.ch 4 | url: https://github.com/geoadmin/service-stac.git 5 | landingURL: https://github.com/geoadmin/service-stac 6 | platforms: 7 | - web 8 | categories: 9 | - geographic-information-systems 10 | usedBy: 11 | - Federal Office of Topography swisstopo 12 | - Federal Office of Meteorology and Climatology MeteoSwiss 13 | developmentStatus: stable 14 | softwareType: standalone/web 15 | organisation: 16 | uri: https://ld.admin.ch/office/IV.1.5a 17 | name: Federal Office of Topography 18 | description: 19 | en: 20 | localisedName: STAC API 21 | shortDescription: The STAC API to interact with Swiss federal geodata 22 | longDescription: The STAC API of geo.admin.ch consists of standardized RESTful 23 | API endpoints designed for interacting with federal geodata. All datasets 24 | are organized according to the SpatioTemporal Asset Catalog (STAC) 25 | specification, which simplifies data discovery and usage. This STAC API 26 | implementation conforms to the core STAC API specification. The underlying 27 | data model follows the STAC schema and is extended by the forecast 28 | extension to support weather forecast data. 29 | features: 30 | - List geodata organized into collections, items and assets (STAC compatible) 31 | - Search items by field value or by spatial criteria, e.g., a bounding box 32 | - Data management of a STAC catalog, like uploading and deleting assets 33 | - Web UI to browse, preview and manage the STAC catalog 34 | - Specifications of the API in a website as OpenAPI definition 35 | - Support of weather forecast data through an extension of the data model 36 | legal: 37 | license: BSD-3-Clause-Modification 38 | maintenance: 39 | type: internal 40 | contacts: 41 | - name: Helpdesk geo.admin.ch 42 | email: info@geo.admin.ch 43 | phone: +41 58 469 01 11 44 | affiliation: Swisstopo 45 | localisation: 46 | localisationReady: false 47 | availableLanguages: 48 | - en 49 | -------------------------------------------------------------------------------- /app/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | from pathlib import Path 6 | 7 | from dotenv import load_dotenv 8 | from helpers.logging import redirect_std_to_logger 9 | 10 | 11 | def main(): 12 | """Run administrative tasks.""" 13 | 14 | # if we have .env.local file we are very likely on a development machine, therefore load it 15 | # to have the correct environment when using manage.py commands. 16 | env_file = '.env.local' 17 | if Path(env_file).is_file(): 18 | print(f'WARNING: Load {env_file} environment') 19 | load_dotenv(env_file, override=True, verbose=True) 20 | 21 | # use separate settings.py for tests 22 | if 'test' in sys.argv: 23 | os.environ.setdefault('LOGGING_CFG', 'app/config/logging-cfg-unittest.yml') 24 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings_test') 25 | elif 'runserver' not in sys.argv: 26 | # uses another logging configuration for management command (except for runserver) 27 | os.environ.setdefault('LOGGING_CFG', 'app/config/logging-cfg-management.yml') 28 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') 29 | else: 30 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') 31 | 32 | try: 33 | from django.core.management import \ 34 | execute_from_command_line # pylint: disable=import-outside-toplevel 35 | except ImportError as exc: 36 | raise ImportError( 37 | "Couldn't import Django. Are you sure it's installed and " 38 | "available on your PYTHONPATH environment variable? Did you " 39 | "forget to activate a virtual environment?" 40 | ) from exc 41 | execute_from_command_line(sys.argv) 42 | 43 | 44 | if __name__ == '__main__': 45 | if '--redirect-std-to-logger' in sys.argv: 46 | sys.argv.remove('--redirect-std-to-logger') 47 | with redirect_std_to_logger(__name__): 48 | main() 49 | else: 50 | main() 51 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0002_auto_20210218_0726.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.5 on 2021-02-18 07:26 2 | 3 | from django.db import migrations 4 | from django.db import models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('stac_api', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddIndex( 15 | model_name='collection', 16 | index=models.Index(fields=['name'], name='collection_name_idx'), 17 | ), 18 | migrations.AddIndex( 19 | model_name='item', 20 | index=models.Index(fields=['name'], name='item_name_idx'), 21 | ), 22 | migrations.AddIndex( 23 | model_name='item', 24 | index=models.Index(fields=['properties_datetime'], name='item_datetime_idx'), 25 | ), 26 | migrations.AddIndex( 27 | model_name='item', 28 | index=models.Index( 29 | fields=['properties_start_datetime'], name='item_start_datetime_idx' 30 | ), 31 | ), 32 | migrations.AddIndex( 33 | model_name='item', 34 | index=models.Index(fields=['properties_end_datetime'], name='item_end_datetime_idx'), 35 | ), 36 | migrations.AddIndex( 37 | model_name='item', 38 | index=models.Index(fields=['created'], name='item_created_idx'), 39 | ), 40 | migrations.AddIndex( 41 | model_name='item', 42 | index=models.Index(fields=['updated'], name='item_updated_idx'), 43 | ), 44 | migrations.AddIndex( 45 | model_name='item', 46 | index=models.Index(fields=['properties_title'], name='item_title_idx'), 47 | ), 48 | migrations.AddIndex( 49 | model_name='item', 50 | index=models.Index( 51 | fields=[ 52 | 'properties_datetime', 'properties_start_datetime', 'properties_end_datetime' 53 | ], 54 | name='item_dttme_start_end_dttm_idx' 55 | ), 56 | ), 57 | ] 58 | -------------------------------------------------------------------------------- /app/stac_api/management/commands/profile_item_serializer.py: -------------------------------------------------------------------------------- 1 | import cProfile 2 | import os 3 | import pstats 4 | 5 | from django.conf import settings 6 | 7 | from rest_framework.test import APIRequestFactory 8 | 9 | from stac_api.models.item import Item 10 | from stac_api.utils import CustomBaseCommand 11 | 12 | STAC_BASE_V = f'{settings.STAC_BASE}/v1' 13 | 14 | 15 | class Command(CustomBaseCommand): 16 | help = """ItemSerializer profiling command 17 | 18 | Profiling of the serialization of many items. 19 | 20 | See https://docs.python.org/3.7/library/profile.html 21 | """ 22 | 23 | def add_arguments(self, parser): 24 | super().add_arguments(parser) 25 | parser.add_argument( 26 | '--collection', 27 | type=str, 28 | default='perftest-collection-0', 29 | help="Collection ID to use for the ItemSerializer profiling" 30 | ) 31 | parser.add_argument('--limit', type=int, default=100, help="Limit to use for the query") 32 | parser.add_argument('--sort', type=str, default='tottime', help="Profiling output sorting") 33 | 34 | def handle(self, *args, **options): 35 | # pylint: disable=import-outside-toplevel,possibly-unused-variable 36 | from stac_api.serializers.item import ItemSerializer 37 | collection_id = self.options["collection"] 38 | qs = Item.objects.filter(collection__name=collection_id 39 | ).prefetch_related('assets', 'links')[:self.options['limit']] 40 | context = { 41 | 'request': APIRequestFactory().get(f'{STAC_BASE_V}/collections/{collection_id}/items') 42 | } 43 | cProfile.runctx( 44 | 'ItemSerializer(qs, context=context, many=True).data', 45 | None, 46 | locals(), 47 | f'{settings.BASE_DIR}/{os.environ["LOGS_DIR"]}/stats-file', 48 | sort=self.options['sort'] 49 | ) 50 | stats = pstats.Stats(f'{settings.BASE_DIR}/{os.environ["LOGS_DIR"]}/stats-file') 51 | stats.sort_stats(self.options['sort']).print_stats() 52 | 53 | self.print_success('Done') 54 | -------------------------------------------------------------------------------- /spec/static/spec/v1/apitransactional.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | data.geo.admin.ch STAC API 10 | 11 | 12 | 13 |
    14 | 18 |
    19 | 20 |
    21 | 22 | 29 |
    30 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /spec/static/spec/v0.9/apitransactional.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | data.geo.admin.ch STAC API 10 | 11 | 12 | 13 |
    14 | 18 |
    19 | 20 |
    21 | 22 | 29 |
    30 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /app/helpers/logging.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | from contextlib import redirect_stderr 3 | from contextlib import redirect_stdout 4 | from io import StringIO 5 | from logging import ERROR 6 | from logging import INFO 7 | from logging import getLogger 8 | from logging.config import dictConfig 9 | from time import time 10 | from typing import Generator 11 | 12 | from django.conf import settings 13 | 14 | 15 | class TimestampedStringIO(StringIO): 16 | """ A StringIO-like in-memory text buffer that logs each write and stores a timestamp for when 17 | the content was appended. 18 | 19 | """ 20 | 21 | def __init__(self, level: int) -> None: 22 | super().__init__() 23 | self.level = level 24 | self.messages: list[tuple[float, int, str]] = [] 25 | 26 | def write(self, s: str) -> int: 27 | message = s.strip() 28 | if message: 29 | self.messages.append((time(), self.level, message)) 30 | return len(s) 31 | 32 | 33 | @contextmanager 34 | def redirect_std_to_logger(logger_name: str, 35 | stderr_level: int = ERROR, 36 | stdout_level: int = INFO) -> Generator[None, None, None]: 37 | """ A context manager that redirects sys.stdout and sys.stderr to the logger using the given 38 | levels. 39 | 40 | Use it like this: 41 | 42 | import sys 43 | from utils.logging import redirect_std_to_logger 44 | 45 | with redirect_std_to_logger('my_module'): 46 | sys.out('This gets logged with level INFO') 47 | sys.err('This gets logged with level ERROR') 48 | 49 | """ 50 | 51 | stderr = TimestampedStringIO(stderr_level) 52 | stdout = TimestampedStringIO(stdout_level) 53 | exception: Exception | None = None 54 | with redirect_stderr(stderr), redirect_stdout(stdout): 55 | try: 56 | yield 57 | except Exception as e: # pylint: disable=broad-exception-caught 58 | exception = e 59 | 60 | logger = getLogger(logger_name) 61 | dictConfig(settings.LOGGING) 62 | for _, level, message in sorted(stderr.messages + stdout.messages): 63 | logger.log(level, message) 64 | if exception: 65 | logger.exception(exception) 66 | -------------------------------------------------------------------------------- /app/templates/rest_framework/base.html: -------------------------------------------------------------------------------- 1 | {% extends "rest_framework/base.html" %} 2 | {% load static %} 3 | {% block style %} 4 | {{ block.super }} 5 | 6 | 7 | {% endblock %} 8 | {% block title %}data.geo.admin.ch STAC API{% endblock %} 9 | 10 | {% block content %} 11 | 12 |
    13 | 17 |
    18 | 19 | 28 | 29 | {{ block.super }} 30 | 31 | 45 | 46 | {% endblock %} 47 | 48 | {% block branding %} 49 | 50 | {% endblock %} 51 | 52 | {% block breadcrumbs %} 53 | 54 | {% endblock %} 55 | 56 | {% block userlinks %} 57 | 58 | {% endblock %} -------------------------------------------------------------------------------- /spec/static/spec/v1/api.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | data.geo.admin.ch STAC API 10 | 11 | 12 | 13 | 14 | 15 |
    16 | 20 |
    21 | 22 |
    23 | 24 | 31 |
    32 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /spec/static/spec/v0.9/api.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | data.geo.admin.ch STAC API 10 | 11 | 12 | 13 | 14 | 15 |
    16 | 20 |
    21 | 22 |
    23 | 24 | 31 |
    32 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /app/stac_api/templates/style/style.css: -------------------------------------------------------------------------------- 1 | header, footer { 2 | font-family: "Frutiger Neue",Helvetica,Arial,sans-serif ; 3 | font-size: 14px; 4 | line-height: 1.6; 5 | color: #454545; 6 | box-sizing: border-box; 7 | display: block; 8 | } 9 | 10 | ul.breadcrumb { 11 | margin: 5px 0 5px 0px; 12 | } 13 | 14 | .navbar { 15 | min-height: 0px ; 16 | max-height: 0px ; 17 | border-top: 0px ; 18 | } 19 | 20 | header { 21 | background: linear-gradient(to bottom,#f5f5f5 0,#fff 7%,#fff 100%); 22 | position: relative; 23 | margin: 10px 0 0 0; 24 | padding: 10px 15px 30px 15px; 25 | border-bottom: 4px solid #dc0018; 26 | } 27 | 28 | header::after { 29 | content: "\e905"; 30 | display: block; 31 | height: 0; 32 | clear: both; 33 | visibility: hidden; 34 | } 35 | 36 | ::after, ::before { 37 | box-sizing: border-box; 38 | } 39 | 40 | header a.brand { 41 | width: 100%; 42 | display: flex; 43 | } 44 | 45 | header a.justified { 46 | justify-content: space-between; 47 | } 48 | 49 | .brand h1 { 50 | color: #000; 51 | font-weight: 700; 52 | font-size: 1em; 53 | line-height: 1.3; 54 | margin: 0; 55 | padding-left: 2.5em; 56 | float: left; 57 | padding-right: 0; 58 | } 59 | 60 | .brand img { 61 | height: auto; 62 | margin: 0; 63 | padding-right: 2em; 64 | border-right: 1px solid #e5e5e5; 65 | float: left; 66 | } 67 | 68 | .columns { 69 | float: left; 70 | position: relative; 71 | min-height: 1px; 72 | } 73 | 74 | #footer { 75 | background-color: #e5e5e5; 76 | margin-bottom: 1em; 77 | } 78 | 79 | #footer a { 80 | color: #006699; 81 | } 82 | 83 | .no-margin { 84 | margin-right: 0px; 85 | margin-left: 0px; 86 | } 87 | 88 | .footer-columns{ 89 | position: relative; 90 | min-height: 1px; 91 | padding-right: 15px; 92 | padding-left: 15px; 93 | width: 50%; 94 | float: left; 95 | box-sizing: border-box; 96 | } 97 | 98 | .footer-columns-adaptation-rest { 99 | margin-top: 1em; 100 | } 101 | 102 | .footer-columns-adaptation-api { 103 | margin-bottom: 1em; 104 | background-color: #e5e5e5; 105 | } 106 | 107 | .right { 108 | text-align: right; 109 | } 110 | 111 | .rounded { 112 | border-radius: 4px; 113 | } -------------------------------------------------------------------------------- /app/middleware/cache_headers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | from urllib.parse import urlparse 4 | 5 | from django.conf import settings 6 | from django.utils.cache import add_never_cache_headers 7 | from django.utils.cache import get_max_age 8 | from django.utils.cache import patch_cache_control 9 | from django.utils.cache import patch_response_headers 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | STAC_BASE = settings.STAC_BASE 14 | 15 | 16 | class CacheHeadersMiddleware: 17 | '''Middleware that adds appropriate cache headers to GET and HEAD methods. 18 | 19 | NOTE: /checker, /get-token, /metrics and /{healthcheck} endpoints are marked as never cache. 20 | ''' 21 | 22 | def __init__(self, get_response): 23 | self.get_response = get_response 24 | 25 | def __call__(self, request): 26 | # Code to be executed for each request before 27 | # the view (and later middleware) are called. 28 | 29 | response = self.get_response(request) 30 | 31 | if request.method not in ('GET', 'HEAD', 'OPTIONS'): 32 | return response 33 | 34 | # Code to be executed for each request/response after 35 | # the view is called. 36 | 37 | # match /xxx or /api/stac/xxx or status code 502, 503, 504, 507 38 | # f.ex. /metrics, /checker, /api/stac/{healthcheck}, /api/stac/get-token 39 | if re.match(fr'^(/{STAC_BASE})?/\w+$', 40 | request.path) or response.status_code in (502, 503, 504, 507): 41 | add_never_cache_headers(response) 42 | elif response.status_code >= 500: 43 | patch_cache_control(response, public=True) 44 | patch_response_headers(response, cache_timeout=10) 45 | elif ( 46 | request.method in ('GET', 'HEAD') and 47 | not request.path.startswith(urlparse(settings.STATIC_URL).path) and 48 | get_max_age(response) is None # only set if not already set by the application 49 | ): 50 | logger.debug( 51 | "Patching default cache headers for request %s %s", 52 | request.method, 53 | request.path, 54 | extra={"request": request} 55 | ) 56 | patch_response_headers(response, settings.CACHE_MIDDLEWARE_SECONDS) 57 | patch_cache_control(response, public=True) 58 | 59 | return response 60 | -------------------------------------------------------------------------------- /app/stac_api/templates/js/admin/collection_help_search.js: -------------------------------------------------------------------------------- 1 | window.onload = function() { 2 | innerhtml = ` 3 | Search Usage: 4 |
      5 |
    • 6 | arg will make a non exact search checking if arg is part of the collection ID 7 |
    • 8 |
    • 9 | Multiple arg can be used, separated by spaces. This will search for all 10 | collections ID containing all arguments. 11 |
    • 12 |
    • 13 | "arg" will make an exact search on the collection ID field. 14 |
    • 15 | 16 |
    17 | Examples : 18 |
      19 |
    • 20 | Searching for pixelkarte will return all collections which have pixelkarte as 21 | a part of their collection ID 22 |
    • 23 |
    • 24 | Searching for pixelkarte 2016 4 will return all collections which have 25 | pixelkarte, 2016 AND 4 as part of their or collection ID. 26 |
    • 27 |
    • 28 | Searching for "collection-example-03" will return only the collection 29 | collection-example-03 and nothing else, since Collections ID are unique. 30 |
    • 31 |
    `; 32 | var popup = document.createElement("DIV"); 33 | popup.className = 'SearchUsage' 34 | popup.innerHTML += innerhtml; 35 | var searchbar = document.getElementById("toolbar"); 36 | if (searchbar) { 37 | searchbar.className = 'NameHighlights' 38 | searchbar.appendChild(popup); 39 | 40 | var span = document.querySelectorAll('.NameHighlights'); 41 | for (var i = span.length; i--;) { 42 | (function () { 43 | var t; 44 | span[i].onmouseover = function () { 45 | hideAll(); 46 | clearTimeout(t); 47 | this.className = 'NameHighlightsHover'; 48 | }; 49 | span[i].onmouseout = function () { 50 | var self = this; 51 | t = setTimeout(function () { 52 | self.className = 'NameHighlights'; 53 | }, 300); 54 | }; 55 | })(); 56 | } 57 | 58 | function hideAll() { 59 | for (var i = span.length; i--;) { 60 | span[i].className = 'NameHighlights'; 61 | } 62 | }; 63 | } 64 | }; 65 | -------------------------------------------------------------------------------- /app/stac_api/management/commands/profile_cursor_paginator.py: -------------------------------------------------------------------------------- 1 | import cProfile 2 | import os 3 | import pstats 4 | 5 | from django.conf import settings 6 | 7 | from rest_framework.pagination import CursorPagination 8 | from rest_framework.request import Request 9 | from rest_framework.test import APIRequestFactory 10 | 11 | from stac_api.models.item import Item 12 | from stac_api.utils import CustomBaseCommand 13 | 14 | STAC_BASE_V = f'{settings.STAC_BASE}/v1' 15 | 16 | 17 | class Command(CustomBaseCommand): 18 | help = """Paginator paginate_queryset() profiling command 19 | 20 | Profiling of the method paginator.paginate_queryset(qs, request) 21 | 22 | See https://docs.python.org/3.7/library/profile.html 23 | """ 24 | 25 | def add_arguments(self, parser): 26 | super().add_arguments(parser) 27 | parser.add_argument( 28 | '--collection', 29 | type=str, 30 | default='perftest-collection-0', 31 | help="Collection ID to use for the queryset profiling" 32 | ) 33 | parser.add_argument('--limit', type=int, default=100, help="Limit to use for the queryset") 34 | parser.add_argument('--sort', type=str, default='tottime', help="Profiling output sorting") 35 | parser.add_argument( 36 | '--lines', type=str, default=50, help="Profiling output numbers of line to show" 37 | ) 38 | 39 | def handle(self, *args, **options): 40 | # pylint: disable=import-outside-toplevel,possibly-unused-variable 41 | collection_id = self.options["collection"] 42 | qs = Item.objects.filter(collection__name=collection_id).prefetch_related('assets', 'links') 43 | request = Request( 44 | APIRequestFactory(). 45 | get(f'{STAC_BASE_V}/collections/{collection_id}/items?limit={self.options["limit"]}') 46 | ) 47 | paginator = CursorPagination() 48 | 49 | cProfile.runctx( 50 | 'paginator.paginate_queryset(qs, request)', 51 | None, 52 | locals(), 53 | f'{settings.BASE_DIR}/{os.environ["LOGS_DIR"]}/stats-file', 54 | sort=self.options['sort'] 55 | ) 56 | # pylint: disable=duplicate-code 57 | stats = pstats.Stats(f'{settings.BASE_DIR}/{os.environ["LOGS_DIR"]}/stats-file') 58 | stats.sort_stats(self.options['sort']).print_stats() 59 | 60 | self.print_success('Done') 61 | -------------------------------------------------------------------------------- /app/stac_api/sample_data/swissTLMRegio/collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "stac_version": "0.9.0", 3 | "stac_extensions": [], 4 | "id": "ch.swisstopo.swisstlmregio", 5 | "title": "swissTLMRegio", 6 | "description": "swissTLMRegio is a 2D landscape model, which represents the natural and man-made features of the landscape in vector format. With its high generalisation grade it is a reference dataset for applications requiring a regional or national overview. swissTLMRegio describes about 950 000 objects with their location, shape and neighbourhood connections (topology) as well as the object type and further attributes. swissTLMRegiois composed of 6 thematic topics. Each topic contains one or more feature classes: Transportation, Hydrography, Landcover, Buildings, Miscellaneous, Names..", 7 | "summaries": { 8 | "proj:epsg": [ 9 | 2056 10 | ] 11 | }, 12 | "extent": { 13 | "spatial": { 14 | "bbox": [ 15 | [ 16 | 5.327213305905472, 17 | 45.30427347827474, 18 | 11.403439825339375, 19 | 48.19113734655604 20 | ] 21 | ] 22 | }, 23 | "temporal": { 24 | "interval": [ 25 | [ 26 | "2020-10-18T00:00:00Z", 27 | "2020-10-18T00:00:00Z" 28 | ] 29 | ] 30 | } 31 | }, 32 | "providers": [ 33 | { 34 | "name": "Federal Office of Topography - swisstopo", 35 | "roles": [ 36 | "producer", 37 | "licensor" 38 | ], 39 | "url": "https://www.swisstopo.admin.ch" 40 | } 41 | ], 42 | "license": "proprietary", 43 | "created": "2020-10-22T12:30:00Z", 44 | "updated": "2020-10-22T12:30:00Z", 45 | "links": [ 46 | { 47 | "href": "https://data.geo.admin.ch/api/stac/v0.9/collections/ch.swisstopo.swisstlmregio", 48 | "rel": "self" 49 | }, 50 | { 51 | "href": "https://data.geo.admin.ch/api/stac/v0.9/", 52 | "rel": "root" 53 | }, 54 | { 55 | "href": "https://data.geo.admin.ch/api/stac/v0.9/collections/ch.swisstopo.swisstlmregio/items", 56 | "rel": "items" 57 | }, 58 | { 59 | "href": "https://www.swisstopo.admin.ch/en/home/meta/conditions/geodata/free-geodata.html", 60 | "rel": "license", 61 | "title": "Licence for the free geodata of the Federal Office of Topography swisstopo" 62 | }, 63 | { 64 | "href": "https://www.geocat.ch/geonetwork/srv/eng/catalog.search#/metadata/2a190233-498a-46c4-91ca-509a97d797a2", 65 | "rel": "describedby" 66 | } 67 | ] 68 | } -------------------------------------------------------------------------------- /app/middleware/logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | 4 | from django.conf import settings 5 | from django.http import HttpResponse 6 | from django.http import JsonResponse 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class RequestResponseLoggingMiddleware: 12 | # characters that should not be urlencoded in the log statements 13 | url_safe = ',:/' 14 | 15 | def __init__(self, get_response): 16 | self.get_response = get_response 17 | # One-time configuration and initialization. 18 | 19 | def __call__(self, request): 20 | # Code to be executed for each request before 21 | # the view (and later middleware) are called. 22 | extra = { 23 | "request": request, 24 | "request.query": request.GET.urlencode(RequestResponseLoggingMiddleware.url_safe) 25 | } 26 | 27 | if request.method.upper() in [ 28 | "PATCH", "POST", "PUT" 29 | ] and request.content_type == "application/json" and not request.path.startswith( 30 | '/api/stac/admin' 31 | ): 32 | extra["request.payload"] = request.body.decode()[:settings. 33 | LOGGING_MAX_REQUEST_PAYLOAD_SIZE] 34 | 35 | logger.debug( 36 | "Request %s %s?%s", 37 | request.method.upper(), 38 | request.path, 39 | request.GET.urlencode(RequestResponseLoggingMiddleware.url_safe), 40 | extra=extra 41 | ) 42 | start = time.time() 43 | 44 | response = self.get_response(request) 45 | 46 | extra = { 47 | "request": request, 48 | "response": { 49 | "code": response.status_code, 50 | "headers": dict(response.items()), 51 | "duration": time.time() - start 52 | }, 53 | } 54 | 55 | # Not all response types have a 'content' attribute, 56 | # HttpResponse and JSONResponse sure have 57 | # (e.g. WhiteNoiseFileResponse doesn't) 58 | if isinstance(response, (HttpResponse, JsonResponse)): 59 | extra["response"]["payload"] = response.content.decode( 60 | )[:settings.LOGGING_MAX_RESPONSE_PAYLOAD_SIZE] 61 | 62 | logger.info( 63 | "Response %s %s %s?%s", 64 | response.status_code, 65 | request.method.upper(), 66 | request.path, 67 | request.GET.urlencode(RequestResponseLoggingMiddleware.url_safe), 68 | extra=extra 69 | ) 70 | # Code to be executed for each request/response after 71 | # the view is called. 72 | 73 | return response 74 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0047_prefill_counter_tables.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.7 on 2024-07-23 15:14 2 | 3 | from django.db import migrations 4 | 5 | 6 | # The migration SQL in this file was created manually. 7 | class Migration(migrations.Migration): 8 | migrate_sql = ''' 9 | -- Fill gsd count based on asset values. 10 | TRUNCATE stac_api_gsdcount; 11 | INSERT INTO stac_api_gsdcount (collection_id, value, count) 12 | SELECT item.collection_id, asset.eo_gsd, COUNT(*) AS count 13 | FROM stac_api_asset asset 14 | INNER JOIN stac_api_item item ON asset.item_id = item.id 15 | GROUP BY item.collection_id, asset.eo_gsd; 16 | 17 | -- Fill geoadmin_lang count based on asset values. 18 | TRUNCATE stac_api_geoadminlangcount; 19 | INSERT INTO stac_api_geoadminlangcount (collection_id, value, count) 20 | SELECT item.collection_id, asset.geoadmin_lang, COUNT(*) AS count 21 | FROM stac_api_asset asset 22 | INNER JOIN stac_api_item item ON asset.item_id = item.id 23 | GROUP BY item.collection_id, asset.geoadmin_lang; 24 | 25 | -- Fill geoadmin_variant count based on asset values. 26 | TRUNCATE stac_api_geoadminvariantcount; 27 | INSERT INTO stac_api_geoadminvariantcount (collection_id, value, count) 28 | SELECT item.collection_id, asset.geoadmin_variant, COUNT(*) AS count 29 | FROM stac_api_asset asset 30 | INNER JOIN stac_api_item item ON asset.item_id = item.id 31 | GROUP BY item.collection_id, asset.geoadmin_variant; 32 | 33 | -- Fill proj_epsg count based on asset and collection asset values. 34 | TRUNCATE stac_api_projepsgcount; 35 | INSERT INTO stac_api_projepsgcount (collection_id, value, count) 36 | SELECT collection_id, proj_epsg, COUNT(*) AS count 37 | FROM ( 38 | SELECT item.collection_id, asset.proj_epsg 39 | FROM stac_api_asset asset 40 | INNER JOIN stac_api_item item ON asset.item_id = item.id 41 | UNION 42 | SELECT collection_id, proj_epsg 43 | FROM stac_api_collectionasset 44 | ) assets 45 | GROUP BY collection_id, proj_epsg; 46 | ''' 47 | 48 | reverse_sql = ''' 49 | TRUNCATE stac_api_gsdcount; 50 | 51 | TRUNCATE stac_api_geoadminlangcount; 52 | 53 | TRUNCATE stac_api_geoadminvariantcount; 54 | 55 | TRUNCATE stac_api_projepsgcount; 56 | ''' 57 | 58 | dependencies = [ 59 | ('stac_api', '0046_geoadminlangcount_geoadminvariantcount_gsdcount_and_more'), 60 | ] 61 | 62 | operations = [migrations.RunSQL(sql=migrate_sql, reverse_sql=reverse_sql)] 63 | -------------------------------------------------------------------------------- /app/config/settings_dev.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for project project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.1. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.1/ref/settings/ 11 | """ 12 | import environ 13 | 14 | from .settings_prod import * # pylint: disable=wildcard-import, unused-wildcard-import 15 | 16 | env = environ.Env() 17 | 18 | # SECURITY WARNING: don't run with debug turned on in production! 19 | DEBUG = env.bool('DEBUG', False) 20 | 21 | # If set to True, this will enable logger.debug prints of the output of 22 | # EXPLAIN.. ANALYZE of certain queries and the corresponding SQL statement. 23 | DEBUG_ENABLE_DB_EXPLAIN_ANALYZE = env.bool('DEBUG_ENABLE_DB_EXPLAIN_ANALYZE', 'False') 24 | 25 | if DEBUG: 26 | print('WARNING - running in debug mode !') 27 | 28 | # Quick-start development settings - unsuitable for production 29 | # See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ 30 | 31 | ALLOWED_HOSTS = ['*'] 32 | 33 | # django-extensions 34 | # ------------------------------------------------------------------------------ 35 | if DEBUG: 36 | INSTALLED_APPS += ['django_extensions', 'debug_toolbar'] 37 | 38 | if DEBUG: 39 | MIDDLEWARE = [ 40 | 'debug_toolbar.middleware.DebugToolbarMiddleware', 41 | ] + MIDDLEWARE 42 | 43 | # configuration for debug_toolbar 44 | # see https://django-debug-toolbar.readthedocs.io/en/latest/configuration.html#debug-toolbar-config 45 | DEBUG_TOOLBAR_CONFIG = {'SHOW_TOOLBAR_CALLBACK': 'middleware.debug.check_toolbar_env'} 46 | 47 | # use the default staticfiles mechanism 48 | # STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.StaticFilesStorage' 49 | 50 | if DEBUG: 51 | DEBUG_PROPAGATE_API_EXCEPTIONS = env.bool('DEBUG_PROPAGATE_API_EXCEPTIONS', 'False') 52 | 53 | SHELL_PLUS_POST_IMPORTS = ['from tests.data_factory import Factory'] 54 | 55 | # Regex patterns of collections that should go to the managed bucket 56 | MANAGED_BUCKET_COLLECTION_PATTERNS = env.list( 57 | 'MANAGED_BUCKET_COLLECTION_PATTERNS', default=["ch.meteoschweiz.ogd-"] 58 | ) 59 | 60 | # Since it's impossible to recreate the service-account situation with minio 61 | # we inject some configuration in here to access the second bucket 62 | # in the same way as first bucket, via access/secrets 63 | # Like this we can leave the base (prod) configuration clean, while fixing 64 | # the local setup 65 | AWS_SETTINGS['managed']['access_type'] = "key" 66 | AWS_SETTINGS['managed']['ACCESS_KEY_ID'] = env("LEGACY_AWS_ACCESS_KEY_ID") 67 | AWS_SETTINGS['managed']['SECRET_ACCESS_KEY'] = env("LEGACY_AWS_SECRET_ACCESS_KEY") 68 | -------------------------------------------------------------------------------- /adr/2025_03_04_cache_settings_update.md: -------------------------------------------------------------------------------- 1 | # Cache Settings Update 2 | 3 | > `Status: Accepted` 4 | 5 | > `Date: 2025-03-04` 6 | 7 | > `Participants: Brice Schaffner, Christoph Böcklin, Benjamin Sugden` 8 | 9 | ## Context 10 | 11 | Cache settings have been implemented using the `update_interval` field that set at the asset upload 12 | level and propagated up to the collection level (see [Cache Settings](2023_02_27_cache_settings.md)). 13 | The propagation up the hierarchy was done via PG trigger and worked fine in the beginning. 14 | 15 | But with the growing numbers of assets, items and collections, the PG trigger started to be slow 16 | which had a significant impact on the write Endpoints. 17 | 18 | ### Proposal 19 | 20 | To avoid performance issue due to aggregation accross all assets within a collection, we can set 21 | the cache settings at the collection level and not at the upload level. Also in order to keep it 22 | very simple stupid we can directly set the cache-control header value on the collection instead 23 | of an update interval. So the collection would decide the cache settings for all of its child call. 24 | 25 | To do so we create a new field `cache_control_header` in the collection field. To start this field 26 | is only available on the admin interface and not on the API. 27 | 28 | For collection aggregation endpoints, like the search endpoint or the collections list endpoint, 29 | which can potentially contain more than one collection, we disable the cache in order to avoid any 30 | caching issue and keep the logic simple. 31 | 32 | ## Decision 33 | 34 | - Remove the `update_interval` field from item and collection model 35 | - Keep the `update_interval` field in the upload and asset models 36 | - Mark the `update_interval` field as deprecated in the models and in the openapi spec 37 | - `update_interval` is kept as a hint but has no effect anymore 38 | - Set the cache-control header of the search and collections list endpoints configurable via 39 | environment variable with a default to no cache. 40 | - Add a new `cache_control_header` field to the collection model 41 | - Add the new `cache_control_header` field to the admin interface 42 | - For the GET endpoints of collection detail, items, item detail, assets, assets detail and asset 43 | data download, we either use the value of the related collection `cache_control_header` field or 44 | the default configured in the environment variable (as for before) 45 | 46 | ## Consequences 47 | 48 | - Actually we have 3 collections that have the `update_interval` field used, so for those once this 49 | is deployed we need to manually set the value. 50 | 51 | ## References 52 | 53 | - [Cache Settings](2023_02_27_cache_settings.md) 54 | - [Jira PB-1126](https://swissgeoplatform.atlassian.net/issues/PB-1126) 55 | -------------------------------------------------------------------------------- /app/config/logging-cfg-unittest-ci.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | disable_existing_loggers: False # this allow to get logger at module level 3 | 4 | root: 5 | handlers: 6 | - console 7 | level: DEBUG 8 | propagate: True 9 | 10 | loggers: 11 | tests: 12 | level: DEBUG 13 | handlers: 14 | - console-tests 15 | botocore: 16 | level: INFO 17 | boto3: 18 | level: INFO 19 | s3transfer: 20 | level: INFO 21 | stac_api: 22 | level: DEBUG 23 | middleware: 24 | level: DEBUG 25 | django: 26 | level: INFO 27 | django.db: 28 | level: INFO 29 | django.utils.autoreload: 30 | level: INFO 31 | gunicorn.error: 32 | handlers: 33 | - console 34 | gunicorn.access: 35 | handlers: 36 | - console 37 | 38 | filters: 39 | isotime: 40 | (): logging_utilities.filters.TimeAttribute 41 | isotime: False 42 | utc_isotime: True 43 | django: 44 | (): logging_utilities.filters.django_request.JsonDjangoRequest 45 | attr_name: request 46 | include_keys: 47 | - request.path 48 | - request.method 49 | - request.headers 50 | exclude_keys: 51 | - request.headers.Authorization 52 | - request.headers.Proxy-Authorization 53 | - request.headers.Cookie 54 | 55 | formatters: 56 | standard: 57 | (): logging_utilities.formatters.extra_formatter.ExtraFormatter 58 | format: "[%(utc_isotime)s] %(levelname)-8s - %(name)-26s : %(message)s" 59 | extra_fmt: " - collection: %(collection)s - item: %(item)s - asset: %(asset)s - duration: %(duration)s" 60 | # extra_pretty_print: True 61 | standard-file: 62 | (): logging_utilities.formatters.extra_formatter.ExtraFormatter 63 | format: "[%(utc_isotime)s] %(levelname)-8s - %(name)-26s : %(message)s" 64 | extra_fmt: " - extra: %s" 65 | extra_pretty_print: True 66 | json: 67 | (): logging_utilities.formatters.json_formatter.JsonFormatter 68 | add_always_extra: True 69 | filter_attributes: 70 | - utc_isotime 71 | remove_empty: True 72 | fmt: 73 | time: utc_isotime 74 | level: levelname 75 | logger: name 76 | module: module 77 | function: funcName 78 | process: process 79 | thread: thread 80 | exc_info: exc_info 81 | message: message 82 | 83 | handlers: 84 | console-tests: 85 | level: WARNING 86 | class: logging.StreamHandler 87 | formatter: standard 88 | stream: ext://sys.stdout 89 | filters: 90 | - isotime 91 | - django 92 | console: 93 | level: CRITICAL 94 | class: logging.StreamHandler 95 | formatter: standard 96 | stream: ext://sys.stderr 97 | filters: 98 | - isotime 99 | - django 100 | -------------------------------------------------------------------------------- /app/stac_api/signals.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.db.models import ProtectedError 4 | from django.db.models.signals import pre_delete 5 | from django.dispatch import receiver 6 | 7 | from stac_api.models.collection import CollectionAsset 8 | from stac_api.models.collection import CollectionAssetUpload 9 | from stac_api.models.item import Asset 10 | from stac_api.models.item import AssetUpload 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | @receiver(pre_delete, sender=AssetUpload) 16 | def check_on_going_upload(sender, instance, **kwargs): 17 | if instance.status == AssetUpload.Status.IN_PROGRESS: 18 | logger.error( 19 | "Cannot delete asset %s due to upload %s which is still in progress", 20 | instance.asset.name, 21 | instance.upload_id, 22 | extra={ 23 | 'upload_id': instance.upload_id, 24 | 'asset': instance.asset.name, 25 | 'item': instance.asset.item.name, 26 | 'collection': instance.asset.item.collection.name 27 | } 28 | ) 29 | raise ProtectedError( 30 | f"Asset {instance.asset.name} has still an upload in progress", [instance] 31 | ) 32 | 33 | 34 | @receiver(pre_delete, sender=CollectionAssetUpload) 35 | def check_on_going_collection_asset_upload(sender, instance, **kwargs): 36 | if instance.status == CollectionAssetUpload.Status.IN_PROGRESS: 37 | logger.error( 38 | "Cannot delete collection asset %s due to upload %s which is still in progress", 39 | instance.asset.name, 40 | instance.upload_id, 41 | extra={ 42 | 'upload_id': instance.upload_id, 43 | 'asset': instance.asset.name, 44 | 'collection': instance.asset.collection.name 45 | } 46 | ) 47 | raise ProtectedError( 48 | f"Collection Asset {instance.asset.name} has still an upload in progress", [instance] 49 | ) 50 | 51 | 52 | @receiver(pre_delete, sender=Asset) 53 | def delete_s3_asset(sender, instance, **kwargs): 54 | # The file is not automatically deleted by Django 55 | # when the object holding its reference is deleted 56 | # hence it has to be done here. 57 | if not instance.is_external: 58 | logger.info("The asset %s is deleted from s3", instance.file.name) 59 | instance.file.delete(save=False) 60 | 61 | 62 | @receiver(pre_delete, sender=CollectionAsset) 63 | def delete_s3_collection_asset(sender, instance, **kwargs): 64 | # The file is not automatically deleted by Django 65 | # when the object holding its reference is deleted 66 | # hence it has to be done here. 67 | if not instance.is_external: 68 | logger.info("The collection asset %s is deleted from s3", instance.file.name) 69 | instance.file.delete(save=False) 70 | -------------------------------------------------------------------------------- /app/stac_api/templates/js/admin/item_help_search.js: -------------------------------------------------------------------------------- 1 | window.onload = function() { 2 | var innerhtml = ` 3 | Search Usage: 4 |
      5 |
    • 6 | arg will make a non exact search checking if arg is part of the 7 | collection ID and /or the item ID 8 |
    • 9 |
    • 10 | Multiple arg can be used, separated by spaces. This will search for all 11 | collections ID and item ID containing all arguments. 12 |
    • 13 |
    • 14 | "arg" will make an exact search on the Item ID field. 15 |
    • 16 |
    • 17 | "collectionID/itemID/" will make an exact search on a whole item path. 18 |
    • 19 | 20 |
    21 | Examples : 22 |
      23 |
    • 24 | Searching for pixelkarte will return all items which have pixelkarte as 25 | a part of their item ID or collection ID 26 |
    • 27 |
    • 28 | Searching for pixelkarte 2016 4 will return all items which have pixelkarte, 29 | 2016 AND 4 as part of their item ID or collection ID. 30 |
    • 31 |
    • 32 | Searching for "item-example-03" will yield all items named asset-example-03 in 33 | all collections. If there is one in the ch.swisstopo.swisstlm/ collection and 34 | one in the ch.swisstopo.pixelkarte-farbe-pk200.noscale collection, it will 35 | return two results 36 |
    • 37 |
    • 38 | Searching for "ch.swisstopo.swisstlm/item-example-03" will return the desired 39 | item and nothing else. 40 |
    • 41 |
    `; 42 | var popup = document.createElement("DIV"); 43 | popup.className = 'SearchUsage' 44 | popup.innerHTML += innerhtml; 45 | var searchbar = document.getElementById("toolbar"); 46 | if (searchbar) { 47 | searchbar.className = 'NameHighlights' 48 | searchbar.appendChild(popup); 49 | 50 | var span = document.querySelectorAll('.NameHighlights'); 51 | for (var i = span.length; i--;) { 52 | (function () { 53 | var t; 54 | span[i].onmouseover = function () { 55 | hideAll(); 56 | clearTimeout(t); 57 | this.className = 'NameHighlightsHover'; 58 | }; 59 | span[i].onmouseout = function () { 60 | var self = this; 61 | t = setTimeout(function () { 62 | self.className = 'NameHighlights'; 63 | }, 300); 64 | }; 65 | })(); 66 | } 67 | 68 | function hideAll() { 69 | for (var i = span.length; i--;) { 70 | span[i].className = 'NameHighlights'; 71 | } 72 | }; 73 | } 74 | }; 75 | -------------------------------------------------------------------------------- /app/tests/test_helpers.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from logging import DEBUG 3 | from logging import ERROR 4 | from logging import FATAL 5 | from logging import INFO 6 | from unittest.mock import call 7 | from unittest.mock import patch 8 | 9 | from helpers.logging import TimestampedStringIO 10 | from helpers.logging import redirect_std_to_logger 11 | 12 | from django.test import TestCase 13 | 14 | 15 | class LoggingHelperTests(TestCase): 16 | 17 | def test_timestamped_string_io(self): 18 | out = TimestampedStringIO(level=1) 19 | 20 | with patch('helpers.logging.time', return_value=100): 21 | self.assertEqual(out.write('test'), 4) 22 | self.assertEqual(out.messages, [(100, 1, 'test')]) 23 | 24 | def test_redirect_std_to_logger(self): 25 | with patch('helpers.logging.getLogger') as logger: 26 | with redirect_std_to_logger('test'): 27 | sys.stdout.write('stdout 1') 28 | sys.stderr.write('stderr 1') 29 | sys.stderr.write('stderr 2\n') 30 | sys.stdout.write(' stdout 2') 31 | 32 | self.assertEqual( 33 | logger.mock_calls, 34 | [ 35 | call('test'), 36 | call().log(INFO, 'stdout 1'), 37 | call().log(ERROR, 'stderr 1'), 38 | call().log(ERROR, 'stderr 2'), 39 | call().log(INFO, 'stdout 2'), 40 | ] 41 | ) 42 | 43 | def test_redirect_std_to_logger_custom_level(self): 44 | with patch('helpers.logging.getLogger') as logger: 45 | with redirect_std_to_logger('test', stderr_level=FATAL, stdout_level=DEBUG): 46 | sys.stdout.write('stdout 1') 47 | sys.stderr.write('stderr 1') 48 | sys.stderr.write('stderr 2\n') 49 | sys.stdout.write(' stdout 2') 50 | 51 | self.assertEqual( 52 | logger.mock_calls, 53 | [ 54 | call('test'), 55 | call().log(DEBUG, 'stdout 1'), 56 | call().log(FATAL, 'stderr 1'), 57 | call().log(FATAL, 'stderr 2'), 58 | call().log(DEBUG, 'stdout 2'), 59 | ] 60 | ) 61 | 62 | def test_redirect_std_to_logger_exception(self): 63 | exception = RuntimeError('abort') 64 | with patch('helpers.logging.getLogger') as logger: 65 | with redirect_std_to_logger('test'): 66 | sys.stdout.write('stdout 1') 67 | sys.stderr.write(' stderr 1\n') 68 | raise exception 69 | 70 | self.assertEqual( 71 | logger.mock_calls, 72 | [ 73 | call('test'), 74 | call().log(INFO, 'stdout 1'), 75 | call().log(ERROR, 'stderr 1'), 76 | call().exception(exception), 77 | ] 78 | ) 79 | -------------------------------------------------------------------------------- /app/tests/test_superuser_command.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | from unittest.mock import patch 3 | 4 | from django.contrib.auth import get_user_model 5 | from django.core.management import call_command 6 | from django.test import TestCase 7 | 8 | 9 | class SuperUserCommandTest(TestCase): 10 | 11 | @patch.dict( 12 | 'os.environ', 13 | { 14 | 'DJANGO_SUPERUSER_USERNAME': 'admin', 15 | 'DJANGO_SUPERUSER_EMAIL': 'admin@admin.ch', 16 | 'DJANGO_SUPERUSER_PASSWORD': 'very-secure' 17 | } 18 | ) 19 | def test_command_creates(self): 20 | out = StringIO() 21 | call_command('manage_superuser', verbosity=2, stdout=out) 22 | self.assertIn('Created the superuser admin', out.getvalue()) 23 | 24 | user = get_user_model().objects.filter(username='admin').first() 25 | self.assertIsNotNone(user) 26 | self.assertEqual(user.email, 'admin@admin.ch') 27 | self.assertTrue(user.check_password('very-secure')) 28 | self.assertTrue(user.is_staff) 29 | self.assertTrue(user.is_superuser) 30 | 31 | @patch.dict( 32 | 'os.environ', 33 | { 34 | 'DJANGO_SUPERUSER_USERNAME': 'admin', 35 | 'DJANGO_SUPERUSER_EMAIL': 'admin@admin.ch', 36 | 'DJANGO_SUPERUSER_PASSWORD': 'very-secure' 37 | } 38 | ) 39 | def test_command_updates(self): 40 | user = get_user_model().objects.create( 41 | username='admin', email='amdin@amdin.ch', is_staff=False, is_superuser=False 42 | ) 43 | user.set_password('not-secure') 44 | 45 | out = StringIO() 46 | call_command('manage_superuser', verbosity=2, stdout=out) 47 | self.assertIn('Updated the superuser admin', out.getvalue()) 48 | 49 | user = get_user_model().objects.filter(username='admin').first() 50 | self.assertIsNotNone(user) 51 | self.assertEqual(user.email, 'admin@admin.ch') 52 | self.assertTrue(user.check_password('very-secure')) 53 | self.assertTrue(user.is_staff) 54 | self.assertTrue(user.is_superuser) 55 | 56 | def test_fails_if_undefined(self): 57 | out = StringIO() 58 | call_command('manage_superuser', stderr=out) 59 | self.assertIn('Environment variables not set or empty', out.getvalue()) 60 | self.assertEqual(get_user_model().objects.count(), 0) 61 | 62 | @patch.dict( 63 | 'os.environ', 64 | { 65 | 'DJANGO_SUPERUSER_USERNAME': '', 66 | 'DJANGO_SUPERUSER_EMAIL': '', 67 | 'DJANGO_SUPERUSER_PASSWORD': '' 68 | } 69 | ) 70 | def test_fails_if_empty(self): 71 | out = StringIO() 72 | call_command('manage_superuser', stderr=out) 73 | self.assertIn('Environment variables not set or empty', out.getvalue()) 74 | self.assertEqual(get_user_model().objects.count(), 0) 75 | -------------------------------------------------------------------------------- /spec/Makefile: -------------------------------------------------------------------------------- 1 | SHELL = /bin/bash -o pipefail 2 | .DEFAULT_GOAL := help 3 | 4 | # List of files that should be merged. 5 | # Note: the keys 6 | # OVERWRITES = overwrites/*.overwrite.yaml 7 | 8 | SPEC_HTTP_PORT ?= 8090 9 | 10 | COMPONENTS_DIR = ./components 11 | PARTS_COMPONENTS := $(shell find $(COMPONENTS_DIR) -type f -name "*.yaml" -print) 12 | PARTS := openapi.yaml $(PARTS_COMPONENTS) 13 | 14 | 15 | TRANSACTIONAL_DIR = ./transaction 16 | PARTS_TRANSACTIONAL = $(shell find $(TRANSACTIONAL_DIR) -type f -name "*.yaml" -print) 17 | 18 | STATIC_BASE_DIR = static/spec/v1 19 | OPENAPI = $(STATIC_BASE_DIR)/openapi.yaml 20 | OPENAPI_TRANSACTIONAL = $(STATIC_BASE_DIR)/openapitransactional.yaml 21 | 22 | YQ ?= docker run --rm -v ${PWD}:/workdir 974517877189.dkr.ecr.eu-central-1.amazonaws.com/external/openapi/yq:4.44.1 23 | OPENAPI_VALIDATOR ?= docker run --volume "$(PWD)":/data 974517877189.dkr.ecr.eu-central-1.amazonaws.com/external/openapi/openapi-validator:1.19.2 24 | 25 | all: help 26 | 27 | 28 | .PHONY: help 29 | help: 30 | @echo "Usage: make " 31 | @echo 32 | @echo "Possible targets:" 33 | @echo -e " \033[1mLOCAL DOCKER TARGETS\033[0m " 34 | @echo -e " \033[1mTREAT THE SPECS TARGETS\033[0m " 35 | @echo "- build-specs Build the specs" 36 | @echo "- lint-specs lint the specs" 37 | @echo "- serve-specs serve the specs locally on port $(SPEC_HTTP_PORT)" 38 | 39 | 40 | $(OPENAPI): $(PARTS) 41 | $(YQ) eval-all '. as $$item ireduce ({}; . *nd $$item )' $(sort $(^)) | \ 42 | sed -E '/\$ref:/s/"\..*?(#.*?)"/"\1"/' > $(@) 43 | 44 | $(OPENAPI_TRANSACTIONAL): $(OPENAPI) $(PARTS_TRANSACTIONAL) 45 | $(YQ) eval-all '. as $$item ireduce ({}; . *nd $$item )' $(sort $(^)) | \ 46 | sed -E '/\$ref:/s/"\..*?(#.*?)"/"\1"/' > $(@) 47 | 48 | .PHONY: build-specs 49 | build-specs: clean $(OPENAPI) $(OPENAPI_TRANSACTIONAL) 50 | 51 | .PHONY: clean 52 | clean: 53 | $(RM) $(OPENAPI) $(OPENAPI_TRANSACTIONAL) 54 | 55 | .PHONY: lint-specs 56 | lint-specs: build-specs 57 | $(OPENAPI_VALIDATOR) -e $(OPENAPI) 58 | $(OPENAPI_VALIDATOR) -e $(OPENAPI_TRANSACTIONAL) 59 | 60 | # This can be useful to distinguish style and semantic differences when we 61 | # update the tools. For example if you update yq and see lot of whitespace 62 | # changes, you can "make static/spec/v1/openapi.props" and diff that to check 63 | # for change in the properties themselves. 64 | %.props: %.yaml 65 | $(YQ) -P 'sort_keys(..)' -o=props $(^) > $(@) 66 | 67 | # Start a little server that serves api.html and openapi.yaml 68 | # since ReDoc (the js library used in api.html) cannot load spec 69 | # from file:/// locations, but only from http:// locations 70 | .PHONY: serve-specs 71 | serve-specs: $(OPENAPI_TRANSACTIONAL) 72 | @echo "Serving http://0.0.0.0:$(SPEC_HTTP_PORT)/api.html" 73 | @echo "Serving http://0.0.0.0:$(SPEC_HTTP_PORT)/apitransactional.html" 74 | cd $(STATIC_BASE_DIR) && python3 -m http.server $(SPEC_HTTP_PORT) 75 | 76 | -------------------------------------------------------------------------------- /app/stac_api/migrations/0057_item_forecast_duration_item_forecast_horizon_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.8 on 2024-11-28 13:20 2 | 3 | from django.db import migrations 4 | from django.db import models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('stac_api', '0056_alter_collection_total_data_size_and_more'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='item', 16 | name='forecast_duration', 17 | field=models.DurationField( 18 | blank=True, 19 | help_text= 20 | "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).", 21 | null=True 22 | ), 23 | ), 24 | migrations.AddField( 25 | model_name='item', 26 | name='forecast_horizon', 27 | field=models.DurationField( 28 | blank=True, 29 | help_text= 30 | "The time between the reference datetime and the forecast datetime.Formatted as ISO 8601 duration, e.g. 'PT6H' for a 6-hour forecast.", 31 | null=True 32 | ), 33 | ), 34 | migrations.AddField( 35 | model_name='item', 36 | name='forecast_mode', 37 | field=models.CharField( 38 | blank=True, 39 | choices=[('ctrl', 'Control run'), ('perturb', 'Perturbed run')], 40 | default=None, 41 | help_text= 42 | 'Denotes whether the data corresponds to the control run or perturbed runs.', 43 | null=True 44 | ), 45 | ), 46 | migrations.AddField( 47 | model_name='item', 48 | name='forecast_param', 49 | field=models.CharField( 50 | blank=True, 51 | help_text= 52 | 'Name of the model parameter that corresponds to the data, e.g. `T` (temperature), `P` (pressure), `U`/`V`/`W` (windspeed in three directions).', 53 | null=True 54 | ), 55 | ), 56 | migrations.AddField( 57 | model_name='item', 58 | name='forecast_reference_datetime', 59 | field=models.DateTimeField( 60 | blank=True, 61 | help_text= 62 | "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'.", 63 | null=True 64 | ), 65 | ), 66 | ] 67 | -------------------------------------------------------------------------------- /app/config/logging-cfg-management.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | disable_existing_loggers: False # this allow to get logger at module level 3 | 4 | root: 5 | handlers: 6 | - file-standard 7 | - file-json 8 | - console 9 | level: INFO 10 | propagate: True 11 | 12 | loggers: 13 | stac_api.management: 14 | level: DEBUG 15 | handlers: 16 | - console-management 17 | stac_api: 18 | level: INFO 19 | middleware: 20 | level: INFO 21 | django: 22 | level: INFO 23 | django.db: 24 | level: DEBUG 25 | django.utils.autoreload: 26 | level: INFO 27 | gunicorn.error: 28 | handlers: 29 | - console 30 | gunicorn.access: 31 | handlers: 32 | - console 33 | 34 | filters: 35 | isotime: 36 | (): logging_utilities.filters.TimeAttribute 37 | isotime: False 38 | utc_isotime: True 39 | django: 40 | (): logging_utilities.filters.django_request.JsonDjangoRequest 41 | attr_name: request 42 | include_keys: 43 | - request.path 44 | - request.method 45 | - request.headers 46 | exclude_keys: 47 | - request.headers.Authorization 48 | - request.headers.Proxy-Authorization 49 | - request.headers.Cookie 50 | 51 | formatters: 52 | standard: 53 | (): logging_utilities.formatters.extra_formatter.ExtraFormatter 54 | format: "[%(utc_isotime)s] %(levelname)-8s - %(name)-26s : %(message)s" 55 | extra_fmt: " - collection: %(collection)s - item: %(item)s - asset: %(asset)s - duration: %(duration)s" 56 | # extra_pretty_print: True 57 | json: 58 | (): logging_utilities.formatters.json_formatter.JsonFormatter 59 | add_always_extra: True 60 | filter_attributes: 61 | - utc_isotime 62 | remove_empty: True 63 | fmt: 64 | time: utc_isotime 65 | level: levelname 66 | logger: name 67 | module: module 68 | function: funcName 69 | process: process 70 | thread: thread 71 | exc_info: exc_info 72 | message: message 73 | 74 | handlers: 75 | console-management: 76 | level: WARNING 77 | class: logging.StreamHandler 78 | formatter: standard 79 | stream: ext://sys.stdout 80 | filters: 81 | - isotime 82 | - django 83 | console: 84 | level: ERROR 85 | class: logging.StreamHandler 86 | formatter: standard 87 | stream: ext://sys.stdout 88 | filters: 89 | - isotime 90 | - django 91 | file-standard: 92 | class: logging.FileHandler 93 | formatter: standard 94 | filename: ${BASE_DIR}/${LOGS_DIR}/management-standard-logs.txt 95 | mode: w 96 | filters: 97 | - isotime 98 | - django 99 | file-json: 100 | class: logging.FileHandler 101 | formatter: json 102 | filename: ${BASE_DIR}/${LOGS_DIR}/management-json-logs.json 103 | mode: w 104 | filters: 105 | - isotime 106 | - django 107 | -------------------------------------------------------------------------------- /app/stac_api/sample_data/swissTLMRegio/items/swisstlmregio-2020.json: -------------------------------------------------------------------------------- 1 | { 2 | "stac_version": "0.9.0", 3 | "stac_extensions": [], 4 | "id": "swisstlmregio-2020", 5 | "properties": { 6 | "datetime": "2020-10-18T00:00:00Z", 7 | "created": "2020-10-22T12:30:00Z", 8 | "updated": "2020-10-22T12:30:00Z" 9 | }, 10 | "collection": "ch.swisstopo.swisstlmregio", 11 | "bbox": [ 12 | 5.327213305905472, 13 | 45.30427347827474, 14 | 11.403439825339375, 15 | 48.19113734655604 16 | ], 17 | "geometry": { 18 | "coordinates": [ 19 | [ 20 | [ 21 | 11.199955188064508, 22 | 45.30427347827474 23 | ], 24 | [ 25 | 5.435800505341752, 26 | 45.34985402081985 27 | ], 28 | [ 29 | 5.327213305905472, 30 | 48.19113734655604 31 | ], 32 | [ 33 | 11.403439825339375, 34 | 48.14311756174606 35 | ], 36 | [ 37 | 11.199955188064508, 38 | 45.30427347827474 39 | ] 40 | ] 41 | ], 42 | "type": "Polygon" 43 | }, 44 | "links": [ 45 | { 46 | "href": "https://data.geo.admin.ch/api/stac/v0.9/collections/ch.swisstopo.swisstlmregio/items/swisstlmregio-2020", 47 | "rel": "self" 48 | }, 49 | { 50 | "href": "https://data.geo.admin.ch/api/stac/v0.9/", 51 | "rel": "root" 52 | }, 53 | { 54 | "href": "https://data.geo.admin.ch/api/stac/v0.9/collections/ch.swisstopo.swisstlmregio", 55 | "rel": "collection" 56 | } 57 | ], 58 | "type": "Feature", 59 | "assets": { 60 | "swisstlmregio-2020.xtf.zip": { 61 | "checksum:multihash": "1220a7b5b9c902b5c1e5700781bf994e2af53359db699dff50a734566ceb2bd0f14f", 62 | "created": "2020-10-22T12:30:00Z", 63 | "href": "https://data.geo.admin.ch/ch.swisstopo.swisstlmregio/swisstlmregio-2020/swisstlmregio-2020.xtf.zip", 64 | "proj:epsg": 2056, 65 | "type": "application/x.interlis+zip; version=2.3", 66 | "updated": "2020-10-22T12:30:00Z" 67 | }, 68 | "swisstlmregio-2020.shp.zip": { 69 | "checksum:multihash": "1220a7b5b9c902b5c1e5700781bf994e2af53359db699dff50a734566ceb2bd0f14f", 70 | "created": "2020-10-22T12:30:00Z", 71 | "href": "https://data.geo.admin.ch/ch.swisstopo.swisstlmregio/swisstlmregio-2020/swisstlmregio-2020.shp.zip", 72 | "proj:epsg": 2056, 73 | "type": "application/x.shapefile+zip", 74 | "updated": "2020-10-22T12:30:00Z" 75 | }, 76 | "swisstlmregio-2020.fgdb.zip": { 77 | "checksum:multihash": "1220a7b5b9c902b5c1e5700781bf994e2af53359db699dff50a734566ceb2bd0f14f", 78 | "created": "2020-10-22T12:30:00Z", 79 | "href": "https://data.geo.admin.ch/ch.swisstopo.swisstlmregio/swisstlmregio-2020/swisstlmregio-2020.fgdb.zip", 80 | "proj:epsg": 2056, 81 | "type": "application/x.filegdb+zip", 82 | "updated": "2020-10-22T12:30:00Z" 83 | } 84 | } 85 | } -------------------------------------------------------------------------------- /app/stac_api/templates/js/admin/asset_help_search.js: -------------------------------------------------------------------------------- 1 | window.onload = function() { 2 | var innerhtml = ` 3 | Search Usage: 4 |
      5 |
    • 6 | arg will make a non exact search checking if arg is 7 | part of the collection ID, the item ID and/or the asset ID 8 |
    • 9 |
    • 10 | Multiple arg can be used, separated by spaces. This will search for all assets 11 | ID, items ID / or collections ID containing all arguments. 12 |
    • 13 |
    • 14 | "arg" will make an exact search on the asset ID field. 15 |
    • 16 |
    • 17 | "collectionID/itemID/assetID" will make an exact search on a whole path. 18 |
    • 19 |
    20 | Examples : 21 |
      22 |
    • 23 | Searching for pixelkarte will return all collections which have 24 | pixelkarte as a part of their asset ID, item ID or collection ID 25 |
    • 26 |
    • 27 | Searching for pixelkarte 2016 4 will return all collection which have 28 | pixelkarte, 2016 AND 4 as part of their asset ID, item ID or collection ID. 29 |
    • 30 |
    • 31 | Searching for "asset-example-03" will yield all assets named asset-example-03 32 | in all items and collections. If there is one in the 33 | ch.swisstopo.swisstlm/swisstlmregio-2020 item and one in the 34 | ch.swisstopo.pixelkarte-farbe-pk200.noscale/smr200-200-2-2016 item, 35 | it will return two results 36 |
    • 37 |
    • 38 | Searching for "ch.swisstopo.swisstlm/swisstlmregio-2020/asset-example-03" will 39 | return the desired asset and nothing else. 40 |
    • 41 |
    `; 42 | var popup = document.createElement("DIV"); 43 | popup.className = 'SearchUsage' 44 | popup.innerHTML += innerhtml; 45 | var searchbar = document.getElementById("toolbar"); 46 | if (searchbar) { 47 | searchbar.className = 'NameHighlights' 48 | searchbar.appendChild(popup); 49 | 50 | var span = document.querySelectorAll('.NameHighlights'); 51 | for (var i = span.length; i--;) { 52 | (function () { 53 | var t; 54 | span[i].onmouseover = function () { 55 | hideAll(); 56 | clearTimeout(t); 57 | this.className = 'NameHighlightsHover'; 58 | }; 59 | span[i].onmouseout = function () { 60 | var self = this; 61 | t = setTimeout(function () { 62 | self.className = 'NameHighlights'; 63 | }, 300); 64 | }; 65 | })(); 66 | } 67 | 68 | function hideAll() { 69 | for (var i = span.length; i--;) { 70 | span[i].className = 'NameHighlights'; 71 | } 72 | }; 73 | } 74 | }; 75 | -------------------------------------------------------------------------------- /app/stac_api/sample_data/swissMapRaster200/collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "stac_version": "0.9.0", 3 | "stac_extensions": [], 4 | "id": "ch.swisstopo.pixelkarte-farbe-pk200.noscale", 5 | "title": "National Map 1:200'000", 6 | "description": "The National Map 1:200,000 is a topographic map giving an overview of Switzerland. The map perimeter is divided into four individual sheets (sheet 1-4). The map content is updated periodically in a four-year cycle. The National Map 1:200,000 is published in analogue format as a printed map and in digital format as a Swiss Map Raster. The printed map is available folded and unfolded. The Swiss Map Raster is the digital National Map 1:200,000 and is delivered as a georeferenced TIF file (raster format). The map content is separated into individual colour layers with no direct bearing on the individual map elements. The pixel maps are available as a colour combination corresponding to fixed swisstopo standards (254 dpi and 508 dpi) or separated as colour layers (binary file 508 dpi). They can be ordered as an individual sheet or as any individually defined section of the map series.", 7 | "summaries": { 8 | "eo:gsd": [ 9 | 10, 10 | 20 11 | ], 12 | "geoadmin:variant": [ 13 | "kgrs", 14 | "komb", 15 | "krel" 16 | ], 17 | "proj:epsg": [ 18 | 2056 19 | ] 20 | }, 21 | "extent": { 22 | "spatial": { 23 | "bbox": [ 24 | [ 25 | 5.602407647225925, 26 | 45.50393724771029, 27 | 10.747774424579303, 28 | 48.02711914887954 29 | ] 30 | ] 31 | }, 32 | "temporal": { 33 | "interval": [ 34 | [ 35 | "2016-12-31T00:00:00Z", 36 | "2016-12-31T00:00:00Z" 37 | ] 38 | ] 39 | } 40 | }, 41 | "providers": [ 42 | { 43 | "name": "Federal Office of Topography - swisstopo", 44 | "roles": [ 45 | "producer", 46 | "licensor" 47 | ], 48 | "url": "https://www.swisstopo.admin.ch" 49 | } 50 | ], 51 | "license": "proprietary", 52 | "created": "2020-10-22T12:30:00Z", 53 | "updated": "2020-10-22T12:30:00Z", 54 | "links": [ 55 | { 56 | "href": "https://data.geo.admin.ch/api/stac/v0.9/collections/ch.swisstopo.pixelkarte-farbe-pk200.noscale", 57 | "rel": "self" 58 | }, 59 | { 60 | "href": "https://data.geo.admin.ch/api/stac/v0.9/", 61 | "rel": "root" 62 | }, 63 | { 64 | "href": "https://data.geo.admin.ch/api/stac/v0.9/collections/ch.swisstopo.pixelkarte-farbe-pk200.noscale/items", 65 | "rel": "items" 66 | }, 67 | { 68 | "href": "https://www.swisstopo.admin.ch/en/home/meta/conditions/geodata/free-geodata.html", 69 | "rel": "license", 70 | "title": "Licence for the free geodata of the Federal Office of Topography swisstopo" 71 | }, 72 | { 73 | "href": "https://www.geocat.ch/geonetwork/srv/eng/catalog.search#/metadata/5f75341e-2050-4ed9-a8de-ee7637490565", 74 | "rel": "describedby" 75 | } 76 | ] 77 | } -------------------------------------------------------------------------------- /app/stac_api/migrations/0005_auto_20210408_0821.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-04-08 08:21 2 | 3 | import django.core.serializers.json 4 | import django.core.validators 5 | import django.db.models.deletion 6 | from django.db import migrations 7 | from django.db import models 8 | 9 | import stac_api.models.general 10 | 11 | 12 | class Migration(migrations.Migration): 13 | 14 | dependencies = [ 15 | ('stac_api', '0004_auto_20210408_0659'), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name='AssetUpload', 21 | fields=[ 22 | ('id', models.BigAutoField(primary_key=True, serialize=False)), 23 | ('upload_id', models.CharField(max_length=255)), 24 | ( 25 | 'status', 26 | models.CharField( 27 | choices=[(None, ''), ('in-progress', 'In Progress'), 28 | ('completed', 'Completed'), ('aborted', 'Aborted')], 29 | default='in-progress', 30 | max_length=32 31 | ) 32 | ), 33 | ( 34 | 'number_parts', 35 | models.IntegerField( 36 | validators=[ 37 | django.core.validators.MinValueValidator(1), 38 | django.core.validators.MaxValueValidator(100) 39 | ] 40 | ) 41 | ), 42 | ( 43 | 'urls', 44 | models.JSONField( 45 | blank=True, 46 | default=list, 47 | encoder=django.core.serializers.json.DjangoJSONEncoder 48 | ) 49 | ), 50 | ('created', models.DateTimeField(auto_now_add=True)), 51 | ('ended', models.DateTimeField(blank=True, default=None, null=True)), 52 | ('checksum_multihash', models.CharField(max_length=255)), 53 | ( 54 | 'etag', 55 | models.CharField(default=stac_api.models.general.compute_etag, max_length=56) 56 | ), 57 | ( 58 | 'asset', 59 | models.ForeignKey( 60 | on_delete=django.db.models.deletion.CASCADE, 61 | related_name='+', 62 | to='stac_api.asset' 63 | ) 64 | ), 65 | ], 66 | ), 67 | migrations.AddConstraint( 68 | model_name='assetupload', 69 | constraint=models.UniqueConstraint( 70 | fields=('asset', 'upload_id'), name='unique_together' 71 | ), 72 | ), 73 | migrations.AddConstraint( 74 | model_name='assetupload', 75 | constraint=models.UniqueConstraint( 76 | condition=models.Q(status='in-progress'), 77 | fields=('asset', 'status'), 78 | name='unique_in_progress' 79 | ), 80 | ), 81 | ] 82 | -------------------------------------------------------------------------------- /app/stac_api/management/commands/reset_counter_tables.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from django.db import connection 4 | 5 | from stac_api.utils import CustomBaseCommand 6 | 7 | 8 | class Command(CustomBaseCommand): 9 | help = """Reset the summary counter tables. 10 | 11 | Truncates all the summary counter tables and repopulates with current data to make sure they are 12 | in sync with the values in the asset table. Unless the triggers are disabled or values in the 13 | counter tables are changed manually, this should not be required. 14 | """ 15 | 16 | def handle(self, *args, **options): 17 | self.print_success('running query to update counter tables...') 18 | 19 | start = time.monotonic() 20 | with connection.cursor() as cursor: 21 | cursor.execute( 22 | """ 23 | -- Fill gsd count based on asset values. 24 | TRUNCATE stac_api_gsdcount; 25 | INSERT INTO stac_api_gsdcount (collection_id, value, count) 26 | SELECT item.collection_id, asset.eo_gsd, COUNT(*) AS count 27 | FROM stac_api_asset asset 28 | INNER JOIN stac_api_item item ON asset.item_id = item.id 29 | GROUP BY item.collection_id, asset.eo_gsd; 30 | 31 | -- Fill geoadmin_lang count based on asset values. 32 | TRUNCATE stac_api_geoadminlangcount; 33 | INSERT INTO stac_api_geoadminlangcount (collection_id, value, count) 34 | SELECT item.collection_id, asset.geoadmin_lang, COUNT(*) AS count 35 | FROM stac_api_asset asset 36 | INNER JOIN stac_api_item item ON asset.item_id = item.id 37 | GROUP BY item.collection_id, asset.geoadmin_lang; 38 | 39 | -- Fill geoadmin_variant count based on asset values. 40 | TRUNCATE stac_api_geoadminvariantcount; 41 | INSERT INTO stac_api_geoadminvariantcount (collection_id, value, count) 42 | SELECT item.collection_id, asset.geoadmin_variant, COUNT(*) AS count 43 | FROM stac_api_asset asset 44 | INNER JOIN stac_api_item item ON asset.item_id = item.id 45 | GROUP BY item.collection_id, asset.geoadmin_variant; 46 | 47 | -- Fill proj_epsg count based on asset and collection asset values. 48 | TRUNCATE stac_api_projepsgcount; 49 | INSERT INTO stac_api_projepsgcount (collection_id, value, count) 50 | SELECT collection_id, proj_epsg, COUNT(*) AS count 51 | FROM ( 52 | SELECT item.collection_id, asset.proj_epsg 53 | FROM stac_api_asset asset 54 | INNER JOIN stac_api_item item ON asset.item_id = item.id 55 | UNION 56 | SELECT collection_id, proj_epsg 57 | FROM stac_api_collectionasset 58 | ) assets 59 | GROUP BY collection_id, proj_epsg; 60 | """ 61 | ) 62 | self.print_success( 63 | f"successfully updated counter tables in {(time.monotonic()-start):.3f}s" 64 | ) 65 | -------------------------------------------------------------------------------- /app/tests/tests_09/test_pgtriggers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from tests.tests_09.base_test import StacBaseTransactionTestCase 4 | from tests.tests_09.data_factory import Factory 5 | from tests.tests_09.sample_data.asset_samples import FILE_CONTENT_1 6 | from tests.utils import MockS3PerTestMixin 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class PgTriggersFileSizeTestCase(MockS3PerTestMixin, StacBaseTransactionTestCase): 12 | 13 | def setUp(self): 14 | super().setUp() 15 | self.factory = Factory() 16 | self.collection = self.factory.create_collection_sample().model 17 | self.item = self.factory.create_item_sample(collection=self.collection).model 18 | 19 | # Add a second item 20 | self.item2 = self.factory.create_item_sample(collection=self.collection,).model 21 | 22 | def test_pgtrigger_file_size(self): 23 | self.factory = Factory() 24 | file_size = len(FILE_CONTENT_1) 25 | 26 | self.assertEqual(self.collection.total_data_size, 0) 27 | self.assertEqual(self.item.total_data_size, 0) 28 | 29 | # check collection's and item's file size on asset update 30 | asset1 = self.factory.create_asset_sample(self.item, sample='asset-1', db_create=True) 31 | self.collection.refresh_from_db() 32 | self.assertEqual(self.collection.total_data_size, file_size) 33 | self.assertEqual(self.item.total_data_size, file_size) 34 | self.assertEqual(asset1.model.file_size, file_size) 35 | 36 | # check collection's and item's file size on asset update 37 | asset2 = self.factory.create_asset_sample(self.item, sample='asset-2', db_create=True) 38 | self.collection.refresh_from_db() 39 | self.assertEqual(self.collection.total_data_size, 2 * file_size) 40 | self.assertEqual(self.item.total_data_size, 2 * file_size) 41 | self.assertEqual(asset2.model.file_size, file_size) 42 | 43 | # check collection's and item's file size on adding an empty asset 44 | asset3 = self.factory.create_asset_sample(self.item, sample='asset-no-file', db_create=True) 45 | self.collection.refresh_from_db() 46 | 47 | self.assertEqual(self.collection.total_data_size, 2 * file_size) 48 | self.assertEqual(self.item.total_data_size, 2 * file_size) 49 | self.assertEqual(asset3.model.file_size, 0) 50 | 51 | # check collection's and item's file size when updating asset of another item 52 | asset4 = self.factory.create_asset_sample(self.item2, sample='asset-2', db_create=True) 53 | self.collection.refresh_from_db() 54 | 55 | self.assertEqual( 56 | self.collection.total_data_size, 57 | 3 * file_size, 58 | ) 59 | self.assertEqual(self.item.total_data_size, 2 * file_size) 60 | self.assertEqual(self.item2.total_data_size, file_size) 61 | 62 | # check collection's and item's file size when deleting asset 63 | asset1.model.delete() 64 | self.item.refresh_from_db() 65 | self.collection.refresh_from_db() 66 | 67 | self.assertEqual(self.collection.total_data_size, 2 * file_size) 68 | self.assertEqual(self.item.total_data_size, 1 * file_size) 69 | -------------------------------------------------------------------------------- /app/tests/tests_10/test_pgtriggers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from tests.tests_10.base_test import StacBaseTransactionTestCase 4 | from tests.tests_10.data_factory import Factory 5 | from tests.tests_10.sample_data.asset_samples import FILE_CONTENT_1 6 | from tests.utils import MockS3PerTestMixin 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class PgTriggersFileSizeTestCase(MockS3PerTestMixin, StacBaseTransactionTestCase): 12 | 13 | def setUp(self): 14 | super().setUp() 15 | self.factory = Factory() 16 | self.collection = self.factory.create_collection_sample().model 17 | self.item = self.factory.create_item_sample(collection=self.collection).model 18 | 19 | # Add a second item 20 | self.item2 = self.factory.create_item_sample(collection=self.collection,).model 21 | 22 | def test_pgtrigger_file_size(self): 23 | self.factory = Factory() 24 | file_size = len(FILE_CONTENT_1) 25 | 26 | self.assertEqual(self.collection.total_data_size, 0) 27 | self.assertEqual(self.item.total_data_size, 0) 28 | 29 | # check collection's and item's file size on asset update 30 | asset1 = self.factory.create_asset_sample(self.item, sample='asset-1', db_create=True) 31 | self.collection.refresh_from_db() 32 | self.assertEqual(self.collection.total_data_size, file_size) 33 | self.assertEqual(self.item.total_data_size, file_size) 34 | self.assertEqual(asset1.model.file_size, file_size) 35 | 36 | # check collection's and item's file size on asset update 37 | asset2 = self.factory.create_asset_sample(self.item, sample='asset-2', db_create=True) 38 | self.collection.refresh_from_db() 39 | self.assertEqual(self.collection.total_data_size, 2 * file_size) 40 | self.assertEqual(self.item.total_data_size, 2 * file_size) 41 | self.assertEqual(asset2.model.file_size, file_size) 42 | 43 | # check collection's and item's file size on adding an empty asset 44 | asset3 = self.factory.create_asset_sample(self.item, sample='asset-no-file', db_create=True) 45 | self.collection.refresh_from_db() 46 | 47 | self.assertEqual(self.collection.total_data_size, 2 * file_size) 48 | self.assertEqual(self.item.total_data_size, 2 * file_size) 49 | self.assertEqual(asset3.model.file_size, 0) 50 | 51 | # check collection's and item's file size when updating asset of another item 52 | asset4 = self.factory.create_asset_sample(self.item2, sample='asset-2', db_create=True) 53 | self.collection.refresh_from_db() 54 | 55 | self.assertEqual( 56 | self.collection.total_data_size, 57 | 3 * file_size, 58 | ) 59 | self.assertEqual(self.item.total_data_size, 2 * file_size) 60 | self.assertEqual(self.item2.total_data_size, file_size) 61 | 62 | # check collection's and item's file size when deleting asset 63 | asset1.model.delete() 64 | self.item.refresh_from_db() 65 | self.collection.refresh_from_db() 66 | 67 | self.assertEqual(self.collection.total_data_size, 2 * file_size) 68 | self.assertEqual(self.item.total_data_size, 1 * file_size) 69 | -------------------------------------------------------------------------------- /app/stac_api/storages.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | from time import mktime 4 | from wsgiref.handlers import format_date_time 5 | 6 | from django.conf import settings 7 | 8 | from storages.backends.s3boto3 import S3Boto3Storage 9 | 10 | from stac_api.utils import AVAILABLE_S3_BUCKETS 11 | from stac_api.utils import get_s3_cache_control_value 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class BaseS3Storage(S3Boto3Storage): 17 | # pylint: disable=abstract-method 18 | 19 | object_sha256 = None 20 | cache_control_header = None 21 | asset_content_type = None 22 | 23 | access_key = None 24 | secret_key = None 25 | bucket_name = None 26 | endpoint_url = None 27 | 28 | def __init__(self, s3_bucket: AVAILABLE_S3_BUCKETS): 29 | s3_config = settings.AWS_SETTINGS[s3_bucket.name] 30 | 31 | if s3_config['access_type'] == 'key': 32 | self.access_key = s3_config['ACCESS_KEY_ID'] 33 | self.secret_key = s3_config['SECRET_ACCESS_KEY'] 34 | # else: let it infer from the environment 35 | 36 | self.bucket_name = s3_config['S3_BUCKET_NAME'] 37 | self.endpoint_url = s3_config['S3_ENDPOINT_URL'] 38 | 39 | super().__init__() 40 | self.object_sha256 = None 41 | self.cache_control_header = None 42 | self.asset_content_type = None 43 | 44 | def get_object_parameters(self, name): 45 | """ 46 | Returns a dictionary that is passed to file upload. Override this 47 | method to adjust this on a per-object basis to set e.g ContentDisposition. 48 | 49 | Args: 50 | name: string 51 | file name 52 | 53 | Returns: 54 | Parameters from AWS_S3_OBJECT_PARAMETERS plus the file sha256 checksum as MetaData 55 | """ 56 | params = super().get_object_parameters(name) 57 | 58 | # Set the content-type from the assets metadata 59 | if self.asset_content_type is None: 60 | raise ValueError(f'Missing content-type for asset {name}') 61 | params["ContentType"] = self.asset_content_type 62 | 63 | if 'Metadata' not in params: 64 | params['Metadata'] = {} 65 | if self.object_sha256 is None: 66 | raise ValueError(f'Missing asset object sha256 for {name}') 67 | params['Metadata']['sha256'] = self.object_sha256 68 | 69 | if 'CacheControl' in params: 70 | logger.warning( 71 | 'Global cache-control header for S3 storage will be overwritten for %s', name 72 | ) 73 | params["CacheControl"] = get_s3_cache_control_value(self.cache_control_header) 74 | 75 | stamp = mktime(datetime.now().timetuple()) 76 | params['Expires'] = format_date_time(stamp + settings.STORAGE_ASSETS_CACHE_SECONDS) 77 | 78 | return params 79 | 80 | 81 | class LegacyS3Storage(BaseS3Storage): 82 | # pylint: disable=abstract-method 83 | 84 | def __init__(self): 85 | super().__init__(AVAILABLE_S3_BUCKETS.legacy) 86 | 87 | 88 | class ManagedS3Storage(BaseS3Storage): 89 | # pylint: disable=abstract-method 90 | 91 | def __init__(self): 92 | super().__init__(AVAILABLE_S3_BUCKETS.managed) 93 | -------------------------------------------------------------------------------- /app/config/urls.py: -------------------------------------------------------------------------------- 1 | """project URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.1/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | import time 17 | 18 | from django.conf import settings 19 | from django.contrib import admin 20 | from django.http import JsonResponse 21 | from django.urls import include 22 | from django.urls import path 23 | 24 | STAC_BASE = settings.STAC_BASE 25 | 26 | 27 | def checker(request): 28 | if settings.CHECKER_DELAY > 0: 29 | time.sleep(settings.CHECKER_DELAY) 30 | 31 | data = {"success": True, "message": "OK"} 32 | 33 | return JsonResponse(data) 34 | 35 | 36 | urlpatterns = [ 37 | path('', include('django_prometheus.urls')), 38 | path('checker', checker, name='checker'), 39 | path(f'{STAC_BASE}/', include('stac_api.urls')), 40 | path(f'{STAC_BASE}/admin/', admin.site.urls), 41 | ] 42 | 43 | if settings.DEBUG: 44 | import debug_toolbar 45 | 46 | from stac_api.views.test import TestAssetUpsertHttp500 47 | from stac_api.views.test import TestCollectionAssetUpsertHttp500 48 | from stac_api.views.test import TestCollectionUpsertHttp500 49 | from stac_api.views.test import TestHttp500 50 | from stac_api.views.test import TestItemUpsertHttp500 51 | 52 | urlpatterns = [ 53 | path('__debug__/', include(debug_toolbar.urls)), 54 | path('tests/test_http_500', TestHttp500.as_view()), 55 | path( 56 | 'tests/test_collection_upsert_http_500/', 57 | TestCollectionUpsertHttp500.as_view(), 58 | name='test-collection-detail-http-500' 59 | ), 60 | path( 61 | 'tests/test_item_upsert_http_500//', 62 | TestItemUpsertHttp500.as_view(), 63 | name='test-item-detail-http-500' 64 | ), 65 | path( 66 | 'tests/test_asset_upsert_http_500///', 67 | TestAssetUpsertHttp500.as_view(), 68 | name='test-asset-detail-http-500' 69 | ), 70 | path( 71 | 'tests/test_collection_asset_upsert_http_500//', 72 | TestCollectionAssetUpsertHttp500.as_view(), 73 | name='test-collection-asset-detail-http-500' 74 | ), 75 | # Add v0.9 namespace to test routes. 76 | path( 77 | 'tests/v0.9/test_asset_upsert_http_500///', 78 | include(([ 79 | path("", TestAssetUpsertHttp500.as_view(), name='test-asset-detail-http-500') 80 | ], 81 | 'test_v0.9'), 82 | namespace='test_v0.9') 83 | ) 84 | ] + urlpatterns 85 | --------------------------------------------------------------------------------