diff --git a/geonode_dominode/dominode_pygeoapi/__init__.py b/geonode_dominode/dominode_pygeoapi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/geonode_dominode/dominode_pygeoapi/apps.py b/geonode_dominode/dominode_pygeoapi/apps.py new file mode 100644 index 0000000..da89c2b --- /dev/null +++ b/geonode_dominode/dominode_pygeoapi/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class DominodePygeoapiConfig(AppConfig): + name = 'dominode_pygeoapi' diff --git a/geonode_dominode/dominode_pygeoapi/forms.py b/geonode_dominode/dominode_pygeoapi/forms.py new file mode 100644 index 0000000..99a466a --- /dev/null +++ b/geonode_dominode/dominode_pygeoapi/forms.py @@ -0,0 +1,101 @@ +import datetime as dt +import typing + +from django import forms +from django.utils.translation import gettext_lazy as _ + + +class GeojsonField(forms.CharField): + + def validate(self, value: str) -> typing.Dict: + # TODO: convert input to GeoJSON + return value + + +class BboxField(forms.MultiValueField): + + def __init__(self, **kwargs): + fields = ( + forms.FloatField(help_text='lower left corner, coordinate axis 1'), + forms.FloatField(help_text='lower left corner, coordinate axis 2'), + forms.FloatField( + required=False, + help_text='minimum value, coordinate axis 3' + ), + forms.FloatField(help_text='upper right corner, coordinate axis 1'), + forms.FloatField(help_text='upper right corner, coordinate axis 2'), + forms.FloatField( + required=False, + help_text='maximum value, coordinate axis 3' + ), + ) + super().__init__(fields=fields, require_all_fields=False, **kwargs) + + def compress(self, data_list): + # TODO: return a geojson polygon + return data_list + + +class StringListField(forms.CharField): + + def __init__(self, separator: typing.Optional[str] = ',', **kwargs): + super().__init__(**kwargs) + self.separator = separator + + def validate(self, value: str) -> typing.List[str]: + return [i.strip() for i in value.split(self.separator)] + + +class StacDatetimeField(forms.Field): + + def validate( + self, + value: str + ) -> typing.List[typing.Union[str, dt.datetime]]: + # can either be a single datetime, a closed interval or an open interval + open_side = '..' + parts = value.split('/') + result = [] + if len(parts) == 1: + # a single value + # FIXME: guard this with a try block + result.append(dt.datetime.fromisoformat(parts[0])) + elif len(parts) == 2: + start, end = parts + if start == open_side and end == open_side: + raise forms.ValidationError( + _('Interval cannot be open on both sides'), + code='invalid', + ) + elif start == open_side: + result.append(start) + else: + result.append(dt.datetime.fromisoformat(start)) + if end == open_side: + result.append(end) + else: + result.append(dt.datetime.fromisoformat(end)) + else: + raise forms.ValidationError( + _('Invalid datetime format'), + code='invalid' + ) + return result + + +class StacSearchSimpleForm(forms.Form): + bbox = BboxField(required=False) + bbox_crs = forms.CharField(label='bbox-crs', required=False) + datetime_ = StacDatetimeField(label='datetime', required=False) + limit = forms.IntegerField( + min_value=1, + max_value=10000, + initial=10, + required=False + ) + ids = StringListField(required=False) + collections = StringListField(required=False) + + +class StacSearchCompleteForm(StacSearchSimpleForm): + intersects = GeojsonField(required=False) \ No newline at end of file diff --git a/geonode_dominode/dominode_pygeoapi/urls.py b/geonode_dominode/dominode_pygeoapi/urls.py new file mode 100644 index 0000000..7729500 --- /dev/null +++ b/geonode_dominode/dominode_pygeoapi/urls.py @@ -0,0 +1,74 @@ +from django.urls import ( + include, + path, +) + +from . import views + +urlpatterns = [ + path( + '', + views.pygeoapi_root, + name='pygeoapi-root' + ), + path( + 'openapi/', + views.pygeoapi_openapi_endpoint, + name='pygeoapi-openapi' + ), + path( + 'conformance/', + views.pygeoapi_conformance_endpoint, + name='pygeoapi-conformance' + ), + path( + 'collections/', + views.pygeoapi_collections_endpoint, + name='pygeoapi-collection-list' + ), + path( + 'collections//', + views.pygeoapi_collections_endpoint, + name='pygeoapi-collection-detail' + ), + path( + 'collections//queryables/', + views.pygeoapi_collection_queryables, + name='pygeoapi-collection-queryable-list' + ), + path( + 'collections//items/', + views.get_pygeoapi_dataset_list, + name='pygeoapi-collection-item-list' + ), + path( + 'collections//items//', + views.get_pygeoapi_dataset_detail, + name='pygeoapi-collection-item-detail' + ), + path( + 'stac/', + views.stac_catalog_root, + name='pygeoapi-stac-catalog-root' + ), + path( + 'stac/', + views.stac_catalog_path_endpoint, + name='pygeoapi-stac-catalog-path' + ), + path( + 'stac/search/', + views.stac_catalog_path, + name='pygeoapi-stac-search' + ), + path( + 'processes/', + views.get_pygeoapi_processes, + name='pygeoapi-process-list' + ), + path( + 'processes/', + views.get_pygeoapi_processes, + name='pygeoapi-process-detail' + ), +] \ No newline at end of file diff --git a/geonode_dominode/dominode_pygeoapi/views.py b/geonode_dominode/dominode_pygeoapi/views.py new file mode 100644 index 0000000..0f98370 --- /dev/null +++ b/geonode_dominode/dominode_pygeoapi/views.py @@ -0,0 +1,145 @@ +import datetime as dt +"""Integration of pygeoapi into DomiNode""" +import typing + +from django.conf import settings +from django.http import ( + HttpRequest, + HttpResponse +) +from django.views import View +from pygeoapi.api import API +from pygeoapi.openapi import get_oas + +from .forms import ( + StacSearchCompleteForm, + StacSearchSimpleForm, +) + +# TODO: test these views +# TODO: add authentication +# TODO: add authorization + +def pygeoapi_root(request: HttpRequest) -> HttpResponse: + pygeoapi_response = _get_pygeoapi_response(request, 'root') + return _convert_pygeoapi_response_to_django_response(*pygeoapi_response) + + +def pygeoapi_openapi_endpoint(request: HttpRequest) -> HttpResponse: + openapi_config = get_oas(settings.PYGEOAPI_CONFIG) + pygeoapi_response = _get_pygeoapi_response( + request, 'openapi', openapi_config) + return _convert_pygeoapi_response_to_django_response(*pygeoapi_response) + + +def pygeoapi_conformance_endpoint(request: HttpRequest) -> HttpResponse: + pygeoapi_response = _get_pygeoapi_response(request, 'conformance') + return _convert_pygeoapi_response_to_django_response(*pygeoapi_response) + + +def pygeoapi_collections_endpoint( + request: HttpRequest, + name: typing.Optional[str] = None, +) -> HttpResponse: + pygeoapi_response = _get_pygeoapi_response( + request, 'describe_collections', name) + return _convert_pygeoapi_response_to_django_response(*pygeoapi_response) + + +def pygeoapi_collection_queryables( + request: HttpRequest, + name: typing.Optional[str] = None, +) -> HttpResponse: + pygeoapi_response = _get_pygeoapi_response( + request, 'get_collection_queryables', name) + return _convert_pygeoapi_response_to_django_response(*pygeoapi_response) + + +def get_pygeoapi_dataset_list( + request: HttpRequest, + collection_id: str +) -> HttpResponse: + pygeoapi_response = _get_pygeoapi_response( + request, 'get_collection_items', collection_id) + return _convert_pygeoapi_response_to_django_response(*pygeoapi_response) + + +def get_pygeoapi_dataset_detail( + request: HttpRequest, + collection_id: str, + item_id: str, +) -> HttpResponse: + pygeoapi_response = _get_pygeoapi_response( + request, 'get_collection_item', collection_id, item_id) + return _convert_pygeoapi_response_to_django_response(*pygeoapi_response) + + +def stac_catalog_root(request: HttpRequest) -> HttpResponse: + pygeoapi_response = _get_pygeoapi_response(request, 'get_stac_root') + return _convert_pygeoapi_response_to_django_response(*pygeoapi_response) + + +def stac_catalog_path_endpoint( + request: HttpRequest, + path: str +) -> HttpResponse: + pygeoapi_response = _get_pygeoapi_response(request, 'get_stac_path', path) + return _convert_pygeoapi_response_to_django_response(*pygeoapi_response) + + +def get_pygeoapi_processes( + request: HttpRequest, + name: typing.Optional[str] = None +) -> HttpResponse: + pygeoapi_response = _get_pygeoapi_response( + request, 'describe_processes', name) + return _convert_pygeoapi_response_to_django_response(*pygeoapi_response) + + +class StacSearchView(View): + + def get(self, request: HttpRequest) -> HttpResponse: + form_ = StacSearchSimpleForm(request.GET) + if form_.is_valid(): + self._perform_stac_search() + + def post(self, request: HttpRequest) -> HttpResponse: + form_ = StacSearchCompleteForm(request.POST) + if form_.is_valid(): + self._perform_stac_search() + + def _perform_stac_search( + self, + bbox: typing.Optional[typing.Any] = None, + bbox_crs: typing.Optional[str] = None, + datetime_: typing.Optional[dt.datetime] = None, + limit: typing.Optional[int] = None, + ids: typing.Optional[typing.List[str]] = None, + collections: typing.Optional[typing.List[str]] = None, + intersects: typing.Optional[typing.Any] = None, + ): + pass + + +def _get_pygeoapi_response( + request: HttpRequest, + api_method_name: str, + *args, + **kwargs +) -> typing.Tuple[typing.Dict, int, str]: + """Use pygeoapi to process the input request""" + pygeoapi_api = API(settings.PYGEOAPI_CONFIG) + api_method = getattr(pygeoapi_api, api_method_name) + return api_method(request.headers, request.GET, *args, **kwargs) + + +def _convert_pygeoapi_response_to_django_response( + pygeoapi_headers: typing.Mapping, + pygeoapi_status_code: int, + pygeoapi_content: str, +) -> HttpResponse: + """Convert pygeoapi response to a django response""" + response = HttpResponse(pygeoapi_content, status=pygeoapi_status_code) + for key, value in pygeoapi_headers.items(): + response[key] = value + return response diff --git a/geonode_dominode/geonode_dominode/settings.py b/geonode_dominode/geonode_dominode/settings.py index 26c6f97..123f9d7 100644 --- a/geonode_dominode/geonode_dominode/settings.py +++ b/geonode_dominode/geonode_dominode/settings.py @@ -74,6 +74,7 @@ LANGUAGE_CODE = os.getenv('LANGUAGE_CODE', "en") INSTALLED_APPS += ( + 'dominode_pygeoapi.apps.DominodePygeoapiConfig', 'dominode_validation.apps.DominodeValidationConfig', 'geonode_dominode.apps.AppConfig', 'rest_framework', @@ -166,6 +167,10 @@ "handlers": ["console"], "level": "DEBUG", }, "geonode_logstash.logstash": { "handlers": ["console"], "level": "DEBUG", }, + "pygeoapi": { + "handlers": ["console"], + "level": "DEBUG", + }, }, } @@ -204,3 +209,92 @@ CELERY_TASK_DEFAULT_QUEUE = 'default' CELERY_TASK_DEFAULT_ROUTING_KEY = 'default' + +PYGEOAPI_CONFIG = { + 'server': { + # 'bind': { + # 'host': '0.0.0.0', + # 'port': 5000, + # }, + 'url': 'http://dominode.test/dominode-pygeoapi/', + 'mimetype': '', + 'encoding': '', + 'language': '', + 'cors': False, + 'pretty_print': True, + 'limit': 10, + 'map': { + 'url': '', + 'attribution': '', + }, + 'ogc_schemas_location': '', + }, + 'logging': { + 'level': LOGGING['loggers']['pygeoapi']['level'], + }, + 'metadata': { + 'identification': { + 'title': 'DomiNode pygeoapi', + 'description': 'DomiNode\'s pygeoapi integration endpoints' , + 'keywords': [ + 'stac', + ], + 'keywords_type': 'theme', + 'terms_of_service': '', + 'url': '', + }, + 'license': { + 'name': '', + 'url': '', + }, + 'provider': { + 'name': 'Government of the Commonwealth of Dominica', + 'url': 'https://dominica.gov.dm', + }, + 'contact': { + 'name': 'Charles Louis', + 'position': '', + 'address': '', + 'city': '', + 'stateorprovince': '', + 'postalcode': '', + 'country': '', + 'phone': '', + 'fax': '', + 'email': '', + 'url': '', + 'hours': '', + 'instructions': '', + 'role': 'pointOfContact', + }, + }, + 'resources': { + 'internal-rasters': { + 'type': 'stac-collection', + 'title': 'Internal DomiNode raster datasets', + 'description': '', + 'keywords': [], + 'context': {}, + 'links': {}, + 'extents': { + 'spatial': { + 'bbox': [-180, -90, 180, 90], + 'crs': 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', + }, + 'temporal': { + 'begin': '2019-01-01T00:00:00Z', + 'end': '2029-01-01T00:00:00Z', + }, + }, + 'provider': { + 'type': 'stac', + 'default': True, + 'name': 'FileSystem', + 'data': '/data', + 'file_types': [ + '.tif', + ] + } + } + }, +} \ No newline at end of file diff --git a/geonode_dominode/geonode_dominode/urls.py b/geonode_dominode/geonode_dominode/urls.py index 88d115a..505bc38 100644 --- a/geonode_dominode/geonode_dominode/urls.py +++ b/geonode_dominode/geonode_dominode/urls.py @@ -9,6 +9,7 @@ from geonode.monitoring import register_url_event from dominode_validation import urls as dominode_validation_urls +from dominode_pygeoapi import urls as dominode_pygeoapi_urls from .views import ( GroupDetailView, @@ -31,6 +32,7 @@ name='sync_geoserver' ), path('dominode-validation/', include(dominode_validation_urls)), + path('dominode-pygeoapi/', include(dominode_pygeoapi_urls)), url(r'^layers/upload$', RedirectView.as_view(url='/')), url(r'^layers/(?P[^/]*)/replace$', RedirectView.as_view(url='/')), diff --git a/geonode_dominode/requirements.txt b/geonode_dominode/requirements.txt index 598b4ae..973875a 100644 --- a/geonode_dominode/requirements.txt +++ b/geonode_dominode/requirements.txt @@ -3,4 +3,5 @@ djangorestframework~=3.11 django-filter~=2.3 django-json-widget~=1.0 - +geojson==2.5.0 +pygeoapi==0.8.0