Skip to content

Commit

Permalink
Add api to retrive context data
Browse files Browse the repository at this point in the history
  • Loading branch information
dimasciput committed Oct 7, 2024
1 parent c57bf78 commit e23364c
Show file tree
Hide file tree
Showing 6 changed files with 334 additions and 2 deletions.
86 changes: 86 additions & 0 deletions django_project/cloud_native_gis/api/context.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions django_project/cloud_native_gis/tests/api/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
from .layer import *
from .context import *
82 changes: 82 additions & 0 deletions django_project/cloud_native_gis/tests/api/context.py
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 4 additions & 0 deletions django_project/cloud_native_gis/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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'),
Expand Down
159 changes: 159 additions & 0 deletions django_project/cloud_native_gis/utils/geometry.py
Original file line number Diff line number Diff line change
@@ -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
}
4 changes: 2 additions & 2 deletions django_project/cloud_native_gis/utils/vector_tile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit e23364c

Please sign in to comment.