From e23364c5522e457587fe6909a044a08866a48e3d Mon Sep 17 00:00:00 2001 From: Dimas Date: Mon, 7 Oct 2024 15:17:10 +0100 Subject: [PATCH] Add api to retrive context data --- .../cloud_native_gis/api/context.py | 86 ++++++++++ .../cloud_native_gis/tests/api/__init__.py | 1 + .../cloud_native_gis/tests/api/context.py | 82 +++++++++ django_project/cloud_native_gis/urls.py | 4 + .../cloud_native_gis/utils/geometry.py | 159 ++++++++++++++++++ .../cloud_native_gis/utils/vector_tile.py | 4 +- 6 files changed, 334 insertions(+), 2 deletions(-) create mode 100644 django_project/cloud_native_gis/api/context.py create mode 100644 django_project/cloud_native_gis/tests/api/context.py create mode 100644 django_project/cloud_native_gis/utils/geometry.py diff --git a/django_project/cloud_native_gis/api/context.py b/django_project/cloud_native_gis/api/context.py new file mode 100644 index 0000000..17f2872 --- /dev/null +++ b/django_project/cloud_native_gis/api/context.py @@ -0,0 +1,86 @@ +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from cloud_native_gis.utils.geometry import ( + query_features +) +from cloud_native_gis.models.layer import Layer + + +class ContextAPIView(APIView): + """Context API endpoint for collection queries. + Basic query validation, log query, get data and return results. + """ + def get(self, request): + try: + key = request.GET.get('key', None) + attributes = request.GET.get('attr', '') + x = request.GET.get('x', None) + y = request.GET.get('y', None) + if None in [key, x, y]: + raise KeyError('Required request argument (' + 'registry, key, x, y) missing.') + + srid = request.GET.get('srid', 4326) + + x_list = x.split(',') + y_list = y.split(',') + + if len(x_list) != len(y_list): + raise ValueError( + 'The number of x and y coordinates must be the same') + + try: + coordinates = [ + (float(x), float(y)) for x, y in zip(x_list, y_list)] + except ValueError: + raise ValueError( + 'All x and y values must be valid floats.') + + try: + tolerance = float(request.GET.get('tolerance', 10.0)) + except ValueError: + raise ValueError('Tolerance should be a float') + + registry = request.GET.get('registry', '') + if registry.lower() not in [ + 'collection', 'service', 'group', 'native']: + raise ValueError('Registry should be "collection", ' + '"service" or "group".') + + outformat = request.GET.get('outformat', 'geojson').lower() + if outformat not in ['geojson', 'json']: + raise ValueError('Output format should be either ' + 'json or geojson') + + data = [] + + if registry == 'native': + try: + layer = Layer.objects.get(unique_id=key) + if attributes: + attributes = attributes.split(',') + else: + attributes = layer.attribute_names + data = query_features( + layer.query_table_name, + field_names=attributes, + coordinates=coordinates, + tolerance=tolerance, + srid=srid + ) + except Layer.DoesNotExist as e: + return Response(str(e), status=status.HTTP_404_NOT_FOUND) + + # Todo : for non native layer + # point = parse_coord(x, y, srid) + # data = Worker( + # registry, key, point, tolerance, outformat).retrieve_all() + + return Response(data, status=status.HTTP_200_OK) + except KeyError as e: + return Response(str(e), status=status.HTTP_400_BAD_REQUEST) + except Exception as e: + return Response( + str(e), status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/django_project/cloud_native_gis/tests/api/__init__.py b/django_project/cloud_native_gis/tests/api/__init__.py index b3a214c..38cec50 100644 --- a/django_project/cloud_native_gis/tests/api/__init__.py +++ b/django_project/cloud_native_gis/tests/api/__init__.py @@ -1 +1,2 @@ from .layer import * +from .context import * diff --git a/django_project/cloud_native_gis/tests/api/context.py b/django_project/cloud_native_gis/tests/api/context.py new file mode 100644 index 0000000..51634af --- /dev/null +++ b/django_project/cloud_native_gis/tests/api/context.py @@ -0,0 +1,82 @@ +from django.test import TestCase +from rest_framework.test import APIClient +from rest_framework import status +from unittest.mock import patch + +from cloud_native_gis.models import Layer +from cloud_native_gis.tests.base import BaseTest +from cloud_native_gis.tests.model_factories import create_user + + +class ContextAPIViewTest(BaseTest, TestCase): + + def setUp(self): + self.client = APIClient() + + self.user = create_user(password=self.password) + + # Create a mock Layer object for testing + self.layer = Layer.objects.create( + name='Test Layer 1', + created_by=self.user, + description='Test Layer 1', + ) + + def test_missing_required_parameters(self): + # Test the case when required parameters are missing + response = self.client.get('/api/context/') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('Required request argument', response.data) + + def test_invalid_coordinate_length(self): + # Test when x and y have different lengths + response = self.client.get('/api/context/', {'key': 'test-layer', 'x': '1,2', 'y': '1'}) + self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) + self.assertIn('The number of x and y coordinates must be the same', response.data) + + def test_invalid_coordinate_format(self): + # Test when coordinates are not valid floats + response = self.client.get('/api/context/', {'key': 'test-layer', 'x': 'a,b', 'y': 'c,d'}) + self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) + self.assertIn('All x and y values must be valid floats', response.data) + + def test_invalid_registry_value(self): + # Test an invalid registry value + response = self.client.get('/api/context/', { + 'key': 'test-layer', + 'x': '1,2', + 'y': '1,2', + 'registry': 'invalid' + }) + self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) + self.assertIn('Registry should be "collection", "service" or "group".', response.data) + + def test_successful_native_query(self): + # Patch the `query_features` function to return mock data + with patch('cloud_native_gis.api.context.query_features') as mock_query_features: + mock_query_features.return_value = [ + {'coordinates': (1.0, 1.0), 'feature': {'name': 'Test', 'type': 'Example'}}] + + response = self.client.get('/api/context/', { + 'key': self.layer.unique_id, + 'x': '1,2', + 'y': '1,2', + 'registry': 'native', + 'outformat': 'geojson', + 'tolerance': '10.0' + }) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + self.assertIn('feature', response.data[0]) + self.assertEqual(response.data[0]['feature']['name'], 'Test') + + def test_layer_does_not_exist(self): + # Test when the specified layer does not exist + response = self.client.get('/api/context/', { + 'key': 'non-existent-layer', + 'x': '1,2', + 'y': '1,2', + 'registry': 'native' + }) + self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/django_project/cloud_native_gis/urls.py b/django_project/cloud_native_gis/urls.py index 38c7a14..1ec6d8f 100644 --- a/django_project/cloud_native_gis/urls.py +++ b/django_project/cloud_native_gis/urls.py @@ -10,6 +10,7 @@ from drf_yasg.views import get_schema_view from drf_yasg import openapi +from cloud_native_gis.api.context import ContextAPIView from cloud_native_gis.api.layer import ( LayerViewSet, LayerStyleViewSet ) @@ -49,6 +50,9 @@ ), path('api/', include(router.urls)), path('api/', include(layer_router.urls)), + path('api/context/', + ContextAPIView.as_view(), + name='cloud-native-gis-context'), path( 'maputnik/', TemplateView.as_view(template_name='cloud_native_gis/maputnik.html'), diff --git a/django_project/cloud_native_gis/utils/geometry.py b/django_project/cloud_native_gis/utils/geometry.py new file mode 100644 index 0000000..4068c2e --- /dev/null +++ b/django_project/cloud_native_gis/utils/geometry.py @@ -0,0 +1,159 @@ +import re + +from django.contrib.gis.geos import Point +from django.db import connection + + +def parse_coord(x: str, y: str, srid: str = '4326') -> Point: + """Parse string DD/DM/DMS coordinate input. Split by °,',". + Signed degrees or suffix E/W/N/S. + + :param x: (longitude) + :type x: str + :param y: Y (latitude) + :type y: str + :param srid: SRID (default=4326). + :type srid: int + :raises ValueError: If string could not be parsed + :return: point wih srid + :rtype: Point + """ + try: + srid = int(srid) + except ValueError: + raise ValueError(f"SRID: '{srid}' not valid") + # Parse Coordinate try DD / otherwise DMS + coords = {'x': x, 'y': y} + degrees = 0.0 + minutes = 0.0 + seconds = 0.0 + + for coord, val in coords.items(): + try: + # Determine hemisphere from cardinal direction + # (override signed degree) + sign = None + for direction in ['N', 'n', 'E', 'e']: + if direction in val.upper(): + sign = 1 + val = val.replace(direction, '') + + for direction in ['S', 's', 'W', 'w']: + if direction in val.upper(): + sign = -1 + val = val.replace(direction, '') + + # Split and get rid of empty space + coord_parts = [v for v in re.split(r'[°\'"]+', val) if v] + if len(coord_parts) >= 4: + raise ValueError + # Degree, minute, decimal seconds + elif len(coord_parts) == 3: + degrees = int(coord_parts[0]) + minutes = int(coord_parts[1]) + seconds = float(coord_parts[2].replace(',', '.')) + # Degree, decimal minutes + elif len(coord_parts) == 2: + degrees = int(coord_parts[0]) + minutes = float(coord_parts[1].replace(',', '.')) + seconds = 0.0 + # Decimal degree + elif len(coord_parts) == 1: + degrees = float(coord_parts[0].replace(',', '.')) + minutes = 0.0 + seconds = 0.0 + + # Determine hemisphere from sign if direction wasn't specified + if sign is None: + sign = -1 if degrees <= 0 else 1 + coords[coord] = ( + sign * (abs(degrees) + (minutes / 60.0) + (seconds / 3600.0)) + ) + + except ValueError: + raise ValueError( + f"Coord '{coords[coord]}' parse failed. " + f"Not valid DD, DM, DMS (°,',\")") + return Point(coords['x'], coords['y'], srid=srid) + + +def query_features( + table_name: str, + field_names: list, + coordinates: list, + tolerance: float, + srid: int = 4326 +): + """ + Return raw feature data from the specified table for multiple (x, y) + coordinates within a tolerance radius. + + :param table_name: Name of the table to query. + :param field_names: List of field names to retrieve. + :param coordinates: List of (x, y) tuples. + :param tolerance: The tolerance radius to use for the query. + :param srid: SRID of the coordinate + + return: A dictionary containing a status message and a list of results + for each coordinate. + The 'result' key holds a list of dictionaries, each containing: + - 'coordinates': The (x, y) coordinate tuple. + - 'feature': A dictionary of field values if a feature is found; + empty fields if not. + The 'status_message' key holds an error or status message if any + issues are encountered. + """ + + data = [] + + status_message = '' + + for x, y in coordinates: + point_geometry = f"ST_SetSRID(ST_MakePoint({x}, {y}), {srid})" + + sql = f""" + SELECT {', '.join([f'"{field}"' for field in field_names])}, + ST_AsGeoJSON(ST_Transform(geometry, {srid})) AS geometry + FROM {table_name} + WHERE ST_DWithin( + ST_Transform(geometry, {srid}), + {point_geometry}, + {tolerance} + ) + ORDER BY ST_Distance( + ST_Transform(geometry, {srid}), + {point_geometry} + ) + LIMIT 1; + """ + + try: + with connection.cursor() as cursor: + cursor.execute(sql) + row = cursor.fetchone() + if row: + feature = { + field: row[i] for i, field in enumerate(field_names) + } + data.append({'coordinates': (x, y), + 'feature': feature}) + else: + data.append({'coordinates': (x, y), + 'feature': { + field: '' for field in field_names} + }) + except Exception as e: + error_message = str(e) + if "does not exist" in error_message: + missing_column = error_message.split('"')[1] + status_message = ( + f"Column '{missing_column}' does not exist." + ) + else: + status_message = f"An error occurred: {error_message}" + break + + return { + 'status_message': status_message, + 'result': data + } diff --git a/django_project/cloud_native_gis/utils/vector_tile.py b/django_project/cloud_native_gis/utils/vector_tile.py index d411df9..e5c37d1 100644 --- a/django_project/cloud_native_gis/utils/vector_tile.py +++ b/django_project/cloud_native_gis/utils/vector_tile.py @@ -12,9 +12,9 @@ def querying_vector_tile( # Define the zoom level at which to start simplifying geometries simplify_zoom_threshold = 5 - # Apply exponential tolerance for simplification if zoom level is less than the threshold simplify_tolerance = ( - 0 if z > simplify_zoom_threshold else 1000 * math.exp(simplify_zoom_threshold - z) + 0 if z > simplify_zoom_threshold else + 1000 * math.exp(simplify_zoom_threshold - z) ) # Conditional geometry transformation