-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
c57bf78
commit e23364c
Showing
6 changed files
with
334 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,2 @@ | ||
from .layer import * | ||
from .context import * |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters