Skip to content

Commit

Permalink
added intersects filter
Browse files Browse the repository at this point in the history
  • Loading branch information
barrydaniels-nl committed Dec 16, 2024
1 parent e33ef38 commit b342bc1
Show file tree
Hide file tree
Showing 6 changed files with 237 additions and 38 deletions.
12 changes: 10 additions & 2 deletions src/dso_api/dynamic_api/filters/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"gte": "Greater than or equal to; ",
"not": "Exclude matches; ",
"contains": "Should contain; ",
"intersects": "Use WKT (POLYGON((x1 y1, x2 y2, ...))) or GeoJSON",
}

OPENAPI_LOOKUP_EXAMPLES = {
Expand All @@ -61,9 +62,16 @@
},
"https://geojson.org/schema/Point.json": {
"": "Use x,y or POINT(x y)", # only for no lookup
"intersects": "Use WKT (POLYGON((x1 y1, x2 y2, ...))) or GeoJSON",
},
"https://geojson.org/schema/Polygon.json": {
"contains": "Use x,y or POINT(x y)",
"intersects": "Use WKT (POLYGON((x1 y1, x2 y2, ...))) or GeoJSON",
},
"https://geojson.org/schema/MultiPolygon.json": {
"contains": "Use x,y or POINT(x y)",
"intersects": "Use WKT (POLYGON((x1 y1, x2 y2, ...))) or GeoJSON",
},
"https://geojson.org/schema/Polygon.json": {"contains": "Use x,y or POINT(x y)"},
"https://geojson.org/schema/MultiPolygon.json": {"contains": "Use x,y or POINT(x y)"},
}


Expand Down
4 changes: 2 additions & 2 deletions src/dso_api/dynamic_api/filters/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
# The empty value is there to indicate the field also supports no lookup operator.
# This is mostly used by the OpenAPI generator.
_comparison_lookups = {"", "gte", "gt", "lt", "lte", "in", "not", "isnull"}
_polygon_lookups = {"", "contains", "isnull", "not"}
_polygon_lookups = {"", "contains", "isnull", "not", "intersects"}
_string_lookups = {"", "in", "isnull", "not", "isempty", "like"}

ALLOWED_IDENTIFIER_LOOKUPS = {"", "in", "not", "isnull"}
Expand All @@ -69,7 +69,7 @@
"array": {"", "contains"},
"object": set(),
"https://geojson.org/schema/Geometry.json": _polygon_lookups, # Assume it works.
"https://geojson.org/schema/Point.json": {"", "isnull", "not"},
"https://geojson.org/schema/Point.json": {"", "isnull", "not", "intersects"},
"https://geojson.org/schema/Polygon.json": _polygon_lookups,
"https://geojson.org/schema/MultiPolygon.json": _polygon_lookups,
# Format variants for type string:
Expand Down
164 changes: 158 additions & 6 deletions src/dso_api/dynamic_api/filters/values.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,21 @@

from __future__ import annotations

import logging
import math
import re
from datetime import date, datetime, time
from decimal import Decimal

from django.contrib.gis.geos import GEOSException, GEOSGeometry, Point
from django.contrib.gis.geos.prototypes import geom # noqa: F401
from django.utils.dateparse import parse_datetime
from django.utils.translation import gettext_lazy as _
from gisserver.geometries import CRS
from rest_framework.exceptions import ValidationError

logger = logging.getLogger(__name__)

# Don't want Decimal("NaN"), Decimal("-inf") or '0.321000e+2' to be accepted.
RE_DECIMAL = re.compile(r"^[0-9]+(\.[0-9]+)?$")

Expand Down Expand Up @@ -72,17 +76,136 @@ def str2time(value: str) -> time:

def str2geo(value: str, crs: CRS | None = None) -> GEOSGeometry:
"""Convert a string to a geometry object.
Currently only parses point objects.
Supports Point, Polygon and MultiPolygon objects in WKT or GeoJSON format.
Args:
value: String representation of geometry (WKT, GeoJSON, or x,y format)
crs: Optional coordinate reference system
Returns:
GEOSGeometry object
Raises:
ValidationError: If geometry format is invalid or type not supported
"""
srid = crs.srid if crs else 4326
# Try parsing as GeoJSON first
if value.lstrip().startswith(("{", "[")):
try:
return _parse_geojson(value, srid)
except ValidationError as e:
# If it looks like JSON but isn't valid GeoJSON,
# raise the error instead of trying other formats
raise ValidationError(e) from e

Check warning

Code scanning / CodeQL

Information exposure through an exception Medium

Stack trace information
flows to this location and may be exposed to an external user.

# Try x,y format if it looks like two numbers separated by a comma
if value.lstrip().startswith(("POINT", "POLYGON", "MULTIPOLYGON")):
try:
return _parse_wkt_geometry(value, srid)
except (GEOSException, ValueError):
raise ValidationError(f"Invalid geometry format: {value!r}") from None
else:
try:
return _parse_point_geometry(value, srid)
except (ValidationError, ValueError) as e:
raise ValidationError(f"Unknown geometry format: {value!r}") from e


def _parse_geojson(value: str, srid: int | None) -> GEOSGeometry:
"""Parse GeoJSON string and validate basic structure.
Args:
value: GeoJSON string
Returns:
GEOSGeometry object
Raises:
ValidationError: If GeoJSON is invalid
"""

try:
return GEOSGeometry(value, srid)
except (GEOSException, ValueError) as e:
raise ValidationError(f"Invalid GeoJSON: {e}") from e

Check warning

Code scanning / CodeQL

Information exposure through an exception Medium

Stack trace information
flows to this location and may be exposed to an external user.


def _parse_wkt_geometry(value: str, srid: int | None) -> GEOSGeometry:
"""Parse and validate a WKT geometry string.
Args:
value: WKT geometry string
srid: Optional spatial reference identifier
Returns:
Validated GEOSGeometry object
Raises:
ValidationError: If geometry is invalid or unsupported type
"""
srid = crs.srid if crs else None
x, y = _parse_point(value)
try:
geom = GEOSGeometry(value, srid)
except (GEOSException, ValueError) as e:
raise ValidationError(f"Invalid WKT format in {value}. Error: {e}") from e

Check warning

Code scanning / CodeQL

Information exposure through an exception Medium

Stack trace information
flows to this location and may be exposed to an external user.

if geom.geom_type not in ("Point", "Polygon", "MultiPolygon"):
raise ValidationError(
f"Unsupported geometry type: {geom.geom_type}. "
"Only Point, Polygon and MultiPolygon are supported."
)

# check if the geometry is within the Netherlands, only warn if not
if geom.geom_type in ("Polygon", "MultiPolygon"):
_validate_bounds(geom, srid)

# Try parsing as point
if geom.geom_type == "Point":
try:
return _validate_point_geometry(geom, srid)
except ValidationError as e:
raise ValidationError(f"Invalid point format in {value}. Error: {e}") from e

Check warning

Code scanning / CodeQL

Information exposure through an exception Medium

Stack trace information
flows to this location and may be exposed to an external user.

return geom


def _parse_point_geometry(value: str, srid: int | None) -> GEOSGeometry:
"""Parse and validate a point in x,y format.
Args:
value: String in "x,y" format
srid: Optional spatial reference identifier
Returns:
Validated Point geometry
Raises:
ValidationError: If point format or coordinates are invalid
"""
try:
x, y = _parse_point(value)
return _validate_correct_x_y(x, y, srid)
except ValueError as e:
raise ValidationError(f"{e} in {value!r}") from None
except GEOSException as e:
raise ValidationError(f"Invalid x,y values {x},{y} with SRID {srid}") from e
raise ValidationError(f"Invalid point format in {value!r}. Error: {e}") from e

Check warning

Code scanning / CodeQL

Information exposure through an exception Medium

Stack trace information
flows to this location and may be exposed to an external user.


def _validate_point_geometry(point: GEOSGeometry, srid: int | None) -> GEOSGeometry:
"""Validate a point geometry's coordinates.
Args:
point: Point geometry to validate
srid: Optional spatial reference identifier
Returns:
Validated point geometry
Raises:
ValidationError: If coordinates are invalid
"""
try:
x, y = point.coords
return _validate_correct_x_y(x, y, srid)
except (ValueError, GEOSException) as e:
raise ValidationError(f"Invalid point coordinates {point.coords} with SRID {srid}") from e


def _parse_point(value: str) -> tuple[float, float]:
Expand All @@ -106,6 +229,35 @@ def _parse_point(value: str) -> tuple[float, float]:
return x, y


def _validate_bounds(geom: GEOSGeometry, srid: int | None) -> None: # noqa: F811
"""Validate if geometry bounds are within Netherlands extent.
Logs warning if bounds are outside the valid range.
Args:
geom: GEOSGeometry object to validate
srid: Spatial reference system identifier
"""
bounds = geom.extent # (xmin, ymin, xmax, ymax)
corners = [(bounds[0], bounds[1]), (bounds[2], bounds[3])] # SW, NE corners

if srid == 4326 and not _validate_wgs84_bounds(corners):
logger.warning("WGS84 geometry bounds ", corners, " outside Netherlands")
elif srid == 28992 and not _validate_rd_bounds(corners):
logger.warning("RD geometry bounds ", corners, " outside Netherlands")


def _validate_wgs84_bounds(corners: list[tuple[float, float]]) -> bool:
"""Check if WGS84 coordinates are within Netherlands bounds.
Note: Expects (x,y) format, will swap to (lat,lon) internally.
"""
return all(_valid_nl_wgs84(y, x) for x, y in corners)


def _validate_rd_bounds(corners: list[tuple[float, float]]) -> bool:
"""Check if RD coordinates are within Netherlands bounds."""
return all(_valid_rd(x, y) for x, y in corners)


def _validate_correct_x_y(x: float, y: float, srid: int | None) -> Point:
"""Auto-correct various input variations."""

Expand Down
9 changes: 8 additions & 1 deletion src/dso_api/dynamic_api/views/doc.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,11 @@ def lookup_context(op, example, descr):
lookup_context(
"contains", "Kommagescheiden lijst", "Test of er een intersectie is met de waarde."
),
lookup_context(
"intersects",
"GeoJSON of <code>POLYGON(x y ...)</code>",
"Test of er een intersectie is met de waarde.",
),
lookup_context(
"isnull",
"<code>true</code> of <code>false</code>",
Expand Down Expand Up @@ -446,7 +451,9 @@ def _field_data(field: DatasetFieldSchema):
# Catch-all for other geometry types
type = type[len("https://geojson.org/schema/") : -5]
value_example = f"GeoJSON of <code>{type.upper()}(x y ...)<code>"
lookups = []
# Keep the lookups from get_allowed_lookups for geometry fields
if not lookups:
lookups = QueryFilterEngine.get_allowed_lookups(field) - {""}
elif field.relation or "://" in type:
lookups = _identifier_lookups
if field.type == "string":
Expand Down
78 changes: 55 additions & 23 deletions src/tests/test_dynamic_api/test_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import pytest
from django.apps import apps
from django.contrib.gis.gdal.error import GDALException
from django.http import QueryDict
from django.utils.timezone import now
from rest_framework.exceptions import ValidationError
Expand Down Expand Up @@ -405,34 +406,65 @@ def test_parse_point_invalid(value):


@pytest.mark.parametrize(
"value",
"value,expected_exception",
[
"",
"a",
"foo",
"inf,nan",
"0, 0",
"0," + 314 * "1",
"POINT",
"POINT ",
"POINT(x y)",
"POINT(1.0 2.0",
"POINT(1.0,2.0)",
"POINT 1.0 2.0",
"POINT(1. .1)",
# Outside range of Netherlands:
"POINT(0 0)",
"POINT(0.0 0.0)",
"POINT(-1.1 -3.3)",
"POINT(-1 2)",
"POINT(100.0 42.0)",
# Basic invalid formats
("", ValidationError),
("a", ValidationError),
("foo", ValidationError),
("inf,nan", ValidationError),
("0, 0", ValidationError),
("0," + 314 * "1", ValidationError),
# Invalid WKT formats
("POINT", ValidationError),
("POINT ", ValidationError),
("POINT(x y)", ValidationError),
("POINT(1.0 2.0", ValidationError),
("POINT(1.0,2.0)", ValidationError),
("POINT 1.0 2.0", ValidationError),
("POINT(1. .1)", ValidationError),
# Out of bounds points
("POINT(0 0)", ValidationError),
("POINT(0.0 0.0)", ValidationError),
("POINT(-1.1 -3.3)", ValidationError),
("POINT(-1 2)", ValidationError),
("POINT(100.0 42.0)", ValidationError),
# Invalid GeoJSON
('{"type": "Point"}', GDALException),
('{"coordinates": [1,2]}', GDALException),
('{"type": "Invalid", "coordinates": [1,2]}', GDALException),
],
)
def test_str2geo_invalid(value):
with pytest.raises(ValidationError) as exc_info:
def test_str2geo_invalid(value, expected_exception):
"""Test str2geo with invalid input formats."""
with pytest.raises(expected_exception):
str2geo(value)

assert repr(value) in str(exc_info.value)

@pytest.mark.parametrize(
"value,expected_type",
[
# WKT formats with valid Netherlands coordinates (Amsterdam area)
("POINT(4.9 52.4)", "Point"), # WGS84
("POLYGON((4.9 52.4, 4.9 52.5, 5.0 52.5, 5.0 52.4, 4.9 52.4))", "Polygon"),
("MULTIPOLYGON(((4.9 52.4, 4.9 52.5, 5.0 52.5, 5.0 52.4, 4.9 52.4)))", "MultiPolygon"),
# GeoJSON formats with valid Netherlands coordinates (Amsterdam area)
('{"type": "Point", "coordinates": [4.9, 52.4]}', "Point"), # WGS84
(
'{"type": "Polygon", "coordinates": [[[4.9, 52.4], [4.9, 52.5], [5.0, 52.5], [5.0, 52.4], [4.9, 52.4]]]}',
"Polygon",
),
(
'{"type": "MultiPolygon", "coordinates": [[[[4.9, 52.4], [4.9, 52.5], [5.0, 52.5], [5.0, 52.4], [4.9, 52.4]]]]}',
"MultiPolygon",
),
],
)
def test_str2geo_valid_formats(value, expected_type):
"""Test str2geo with valid WKT and GeoJSON formats for Point, Polygon and MultiPolygon."""
result = str2geo(value)
assert result.geom_type == expected_type
assert result.srid == 4326 # Default SRID


@pytest.mark.parametrize(
Expand Down
Loading

0 comments on commit b342bc1

Please sign in to comment.