Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add context feature #12

Merged
merged 3 commits into from
Oct 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .flake8
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[flake8]
exclude = */docs/*,*/.tox/*,*/.venv/*,.pycharm_helpers/*,*/migrations/*,docs/*,*/__init__.py,scripts/*,deployment/*,django_project/initialize.py
exclude = */tests/*,*/docs/*,*/.tox/*,*/.venv/*,.pycharm_helpers/*,*/migrations/*,docs/*,*/__init__.py,scripts/*,deployment/*,django_project/initialize.py
max-line-length = 79

# E12x continuation line indentation
Expand Down
97 changes: 97 additions & 0 deletions django_project/cloud_native_gis/api/context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# coding=utf-8
"""Cloud Native GIS."""

from rest_framework import status
from rest_framework.permissions import IsAuthenticated
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.

Only accessible to authenticated users.
Validates the query, processes data, and returns results.
"""

permission_classes = [IsAuthenticated]

def get(self, request):
"""Handle GET requests."""
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 *
84 changes: 84 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,84 @@
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)
self.client.login(
username=self.user.username, 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
158 changes: 158 additions & 0 deletions django_project/cloud_native_gis/utils/geometry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
# coding=utf-8
"""Geometry utils."""

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 for multiple (x, y) coordinates within a radius.

Args:
table_name (str): The name of the database table
containing the features.
field_names (list): A list of field names to retrieve from the table.
coordinates (list): A list of tuples containing (x, y)
coordinate pairs.
tolerance (float): The radius tolerance for the spatial query.
srid (int, optional): Spatial Reference System Identifier.
Defaults to 4326.

Returns:
list: A list of dictionaries representing the feature data.
"""
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