diff --git a/src/dso_api/dynamic_api/filters/openapi.py b/src/dso_api/dynamic_api/filters/openapi.py
index 46874660b..f17cebdc9 100644
--- a/src/dso_api/dynamic_api/filters/openapi.py
+++ b/src/dso_api/dynamic_api/filters/openapi.py
@@ -45,6 +45,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 = {
@@ -60,9 +61,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)"},
}
diff --git a/src/dso_api/dynamic_api/filters/parser.py b/src/dso_api/dynamic_api/filters/parser.py
index 8a801512e..a2e77c2e2 100644
--- a/src/dso_api/dynamic_api/filters/parser.py
+++ b/src/dso_api/dynamic_api/filters/parser.py
@@ -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"}
@@ -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:
diff --git a/src/dso_api/dynamic_api/filters/values.py b/src/dso_api/dynamic_api/filters/values.py
index 8b6323059..2a1c48a22 100644
--- a/src/dso_api/dynamic_api/filters/values.py
+++ b/src/dso_api/dynamic_api/filters/values.py
@@ -2,17 +2,22 @@
from __future__ import annotations
+import logging
import math
import re
from datetime import date, datetime, time
from decimal import Decimal
+from django.contrib.gis.gdal.error import GDALException
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]+)?$")
@@ -72,17 +77,122 @@ 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
+ """
+ srid = crs.srid if crs else 4326
+ stripped_value = value.lstrip()
+ # Try parsing as GeoJSON first
+ if stripped_value.startswith(("{", "[")):
+ return _parse_geojson(stripped_value, srid)
+
+ # Try x,y format if it looks like two numbers separated by a comma
+ if stripped_value.startswith(("POINT", "POLYGON", "MULTIPOLYGON")):
+ return _parse_wkt_geometry(stripped_value, srid)
+ else:
+ return _parse_point_geometry(stripped_value, srid)
+
+
+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, GDALException) as e:
+ raise ValidationError(f"Invalid GeoJSON: {e}") from e
+
+
+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
+
+ 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") and not _validate_bounds(geom, srid):
+ logger.warning("Geometry bounds outside Netherlands")
+
+ # 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
+
+ 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
+
+
+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]:
@@ -106,6 +216,39 @@ def _parse_point(value: str) -> tuple[float, float]:
return x, y
+def _validate_bounds(geom: GEOSGeometry, srid: int | None) -> bool: # noqa: F811
+ """Validate if geometry bounds are within Netherlands extent.
+ Returns True if geometry is within bounds, False otherwise.
+
+ Args:
+ geom: GEOSGeometry object to validate
+ srid: Spatial reference system identifier
+
+ Returns:
+ bool: True if geometry is within Netherlands bounds, False otherwise
+ """
+ bounds = geom.extent # (xmin, ymin, xmax, ymax)
+ corners = [(bounds[0], bounds[1]), (bounds[2], bounds[3])] # SW, NE corners
+
+ if srid == 4326:
+ return _validate_wgs84_bounds(corners)
+ elif srid == 28992:
+ return _validate_rd_bounds(corners)
+ return True # If srid is not 4326 or 28992, assume valid
+
+
+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."""
diff --git a/src/dso_api/dynamic_api/views/doc.py b/src/dso_api/dynamic_api/views/doc.py
index ef1f29037..e405f5f56 100644
--- a/src/dso_api/dynamic_api/views/doc.py
+++ b/src/dso_api/dynamic_api/views/doc.py
@@ -261,6 +261,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 POLYGON(x y ...)
",
+ "Test of er een intersectie is met de waarde.",
+ ),
lookup_context(
"isnull",
"true
| false
",
@@ -425,7 +430,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 {type.upper()}(x y ...)"
- 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":
diff --git a/src/templates/dso_api/dynamic_api/docs/rest/filtering.md b/src/templates/dso_api/dynamic_api/docs/rest/filtering.md
index 230cf5a17..33b4baf12 100644
--- a/src/templates/dso_api/dynamic_api/docs/rest/filtering.md
+++ b/src/templates/dso_api/dynamic_api/docs/rest/filtering.md
@@ -136,10 +136,14 @@ als `exact`. Er is geen *escaping* van de jokertekens mogelijk.
### Bij waarden met een geometrie
-| Operator | Werking | SQL Equivalent |
-| --------------------------------- | -------------------------------------------------- | ------------------------------------------ |
-| `?{geoveld}[contains]={x},{y}` | Geometrie moet voorkomen op een punt (intersectie) | `ST_Intersects({geoveld}, POINT({x} {y}))` |
-| `?{geoveld}[contains]=POINT(x y)` | Idem, nu in de WKT (well-known text) notatie. | `ST_Intersects({geoveld}, POINT({x} {y}))` |
+| Operator | Werking | SQL Equivalent |
+| --------------------------------------------------- | ------------------------------------------------------ | -----------------------------------------------------|
+| `?{geoveld}[contains]={x},{y}` | Geometrie moet voorkomen op een punt (intersectie) | `ST_Intersects({geoveld}, POINT({x} {y}))` |
+| `?{geoveld}[contains]=POINT(x y)` | Idem, nu in de WKT (well-known text) notatie. | `ST_Intersects({geoveld}, POINT({x} {y}))` |
+| `?{geoveld}[intersects]={x},{y}` | Geometrie moet voorkomen op een punt (intersectie) | `ST_Intersects({geoveld}, POINT({x} {y}))` |
+| `?{geoveld}[intersects]=POINT(x y)` | Idem, nu in de WKT (well-known text) notatie. | `ST_Intersects({geoveld}, POINT({x} {y}))` |
+| `?{geoveld}[intersects]=POLYGON ((4.89...))` | Geometry moet overlappen met een polygon (intersectie).| `ST_Intersects({geoveld}, POLYGON ((4.89...)))` |
+| `?{geoveld}[intersects]=MULTIPOLYGON (((4.89...)))` | Geometry moet overlappen met een MULTIPOLYGON | `ST_Intersects({geoveld}, MULTIPOLYGON ((4.89...)))` |
Bij het doorzoeken van geometrievelden wordt gebruik gemaakt van de
projectie opgegeven in de header `Accept-CRS`. Afhankelijk van de
diff --git a/src/tests/test_dynamic_api/test_filters.py b/src/tests/test_dynamic_api/test_filters.py
index 5a2ef57f6..afaf52ff8 100644
--- a/src/tests/test_dynamic_api/test_filters.py
+++ b/src/tests/test_dynamic_api/test_filters.py
@@ -407,12 +407,14 @@ def test_parse_point_invalid(value):
@pytest.mark.parametrize(
"value",
[
+ # Basic invalid formats
"",
"a",
"foo",
"inf,nan",
"0, 0",
"0," + 314 * "1",
+ # Invalid WKT formats
"POINT",
"POINT ",
"POINT(x y)",
@@ -420,19 +422,48 @@ def test_parse_point_invalid(value):
"POINT(1.0,2.0)",
"POINT 1.0 2.0",
"POINT(1. .1)",
- # Outside range of Netherlands:
+ # Out of bounds points
"POINT(0 0)",
"POINT(0.0 0.0)",
"POINT(-1.1 -3.3)",
"POINT(-1 2)",
"POINT(100.0 42.0)",
+ # Invalid GeoJSON
+ '{"type": "Point"}',
+ '{"coordinates": [1,2]}',
+ '{"type": "Invalid", "coordinates": [1,2]}',
],
)
def test_str2geo_invalid(value):
- with pytest.raises(ValidationError) as exc_info:
+ """Test str2geo with invalid input formats."""
+ with pytest.raises(ValidationError):
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]]]}', # noqa: E501
+ "Polygon",
+ ),
+ (
+ '{"type": "MultiPolygon", "coordinates": [[[[4.9, 52.4], [4.9, 52.5], [5.0, 52.5], [5.0, 52.4], [4.9, 52.4]]]]}', # noqa: E501
+ "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(
diff --git a/src/tests/test_dynamic_api/test_openapi.py b/src/tests/test_dynamic_api/test_openapi.py
index 5cdafe421..15b534ff0 100644
--- a/src/tests/test_dynamic_api/test_openapi.py
+++ b/src/tests/test_dynamic_api/test_openapi.py
@@ -153,8 +153,9 @@ def test_openapi_json(api_client, afval_dataset, fietspaaltjes_dataset, filled_r
"eigenaarNaam[like]",
"eigenaarNaam[not]",
"geometry",
- "geometry[isnull]",
"geometry[not]",
+ "geometry[isnull]",
+ "geometry[intersects]",
"id",
"id[gt]",
"id[gte]",
diff --git a/src/tests/test_dynamic_api/views/test_api_filters.py b/src/tests/test_dynamic_api/views/test_api_filters.py
index 01ead8f84..10997a5d5 100644
--- a/src/tests/test_dynamic_api/views/test_api_filters.py
+++ b/src/tests/test_dynamic_api/views/test_api_filters.py
@@ -212,7 +212,7 @@ def test_geofilter_contains(api_client, parkeervakken_parkeervak_model, filled_r
response = api_client.get(
"/v1/parkeervakken/parkeervakken/",
data={"geometry[contains]": "121137.7,489046.9"},
- HTTP_ACCEPT_CRS=28992,
+ HTTP_ACCEPT_CRS="EPSG:28992",
)
data = read_response_json(response)
assert len(data["_embedded"]["parkeervakken"]) == 1, "inside with R/D"
@@ -221,7 +221,7 @@ def test_geofilter_contains(api_client, parkeervakken_parkeervak_model, filled_r
response = api_client.get(
"/v1/parkeervakken/parkeervakken/",
data={"geometry[contains]": "52.388231,4.8897865"},
- HTTP_ACCEPT_CRS=4326,
+ HTTP_ACCEPT_CRS="EPSG:4326",
)
data = read_response_json(response)
assert response.status_code == 200, data
@@ -231,7 +231,7 @@ def test_geofilter_contains(api_client, parkeervakken_parkeervak_model, filled_r
response = api_client.get(
"/v1/parkeervakken/parkeervakken/",
data={"geometry[contains]": "52.3883019,4.8900356"},
- HTTP_ACCEPT_CRS=4326,
+ HTTP_ACCEPT_CRS="EPSG:4326",
)
data = read_response_json(response)
assert len(data["_embedded"]["parkeervakken"]) == 0, "Outside using WGS84"
@@ -240,7 +240,112 @@ def test_geofilter_contains(api_client, parkeervakken_parkeervak_model, filled_r
response = api_client.get(
"/v1/parkeervakken/parkeervakken/",
data={"geometry[contains]": "52.388231,48897865"},
- HTTP_ACCEPT_CRS=4326,
+ HTTP_ACCEPT_CRS="EPSG:4326",
+ )
+ assert response.status_code == 400, "Outside WGS84 range"
+
+ @staticmethod
+ def test_geofilter_intersects(api_client, parkeervakken_parkeervak_model, filled_router):
+ """
+ Prove that geofilter intersects filters work as expected.
+ """
+ parkeervakken_parkeervak_model.objects.create(
+ id="121138489006",
+ type="File",
+ soort="MULDER",
+ aantal=1.0,
+ e_type="E6b",
+ buurtcode="A05d",
+ straatnaam="Zoutkeetsgracht",
+ geometry=GEOSGeometry(
+ "POLYGON((121140.66 489048.21, 121140.72 489047.1, 121140.8 489046.9, 121140.94 "
+ "489046.74,121141.11 489046.62, 121141.31 489046.55, 121141.52 489046.53, "
+ "121134.67 489045.85, 121134.47 489047.87, 121140.66 489048.21))",
+ 28992,
+ ),
+ )
+
+ # Inside using RD
+ response = api_client.get(
+ "/v1/parkeervakken/parkeervakken/",
+ data={"geometry[intersects]": "121137.7,489046.9"},
+ HTTP_ACCEPT_CRS="EPSG:28992",
+ )
+ data = read_response_json(response)
+ assert len(data["_embedded"]["parkeervakken"]) == 1, "inside with R/D"
+
+ # Inside using WGS84 x,y
+ response = api_client.get(
+ "/v1/parkeervakken/parkeervakken/",
+ data={"geometry[intersects]": "52.388231,4.8897865"},
+ HTTP_ACCEPT_CRS="EPSG:4326",
+ )
+ data = read_response_json(response)
+ assert response.status_code == 200, data
+ assert len(data["_embedded"]["parkeervakken"]) == 1, "inside with WGS84"
+
+ # Inside using WGS84 POLYGON
+ response = api_client.get(
+ "/v1/parkeervakken/parkeervakken/",
+ data={
+ "geometry[intersects]": "POLYGON ((4.8982259 52.3748037, 4.8989231 52.3744369, 4.9008431 52.3739718, 4.9011541 52.3751508, 4.899985 52.3761922, 4.8982367 52.3748037, 4.8982259 52.3748037))" # noqa: E501
+ },
+ HTTP_ACCEPT_CRS="EPSG:4326",
+ )
+ data = read_response_json(response)
+ assert response.status_code == 200, data
+
+ # Inside using WGS84 MULTIPOLYGON
+ response = api_client.get(
+ "/v1/parkeervakken/parkeervakken/",
+ data={
+ "geometry[intersects]": "MULTIPOLYGON (((5.639762878417969 51.70404205550062, 5.47222137451172 51.54050328936586, 5.984265804290771 51.445328182347936, 6.050591468811035 51.57152179108081, 5.639762878417969 51.70404205550062)), ((5.268545150756836 51.8780408523543, 5.039935111999512 51.700837165629764, 5.2266597747802725 51.65603805162954, 5.515286922454835 51.827171560252935, 5.268545150756836 51.8780408523543)))" # noqa: E501
+ },
+ HTTP_ACCEPT_CRS="EPSG:4326",
+ )
+ data = read_response_json(response)
+ print("Response data:", data) # Debug print
+ assert response.status_code == 200, data
+ assert len(data["_embedded"]["parkeervakken"]) == 0, "Outside using WGS84"
+
+ # Outside using WGS84
+ response = api_client.get(
+ "/v1/parkeervakken/parkeervakken/",
+ data={"geometry[intersects]": "52.3883019,4.8900356"},
+ HTTP_ACCEPT_CRS="EPSG:4326",
+ )
+ data = read_response_json(response)
+ assert len(data["_embedded"]["parkeervakken"]) == 0, "Outside using WGS84"
+
+ # Outside using WGS84 Polygons
+ response = api_client.get(
+ "/v1/parkeervakken/parkeervakken/",
+ data={
+ "geometry[intersects]": "POLYGON ((4.894892 52.5949621, 4.8935187 52.5957963, 4.9772894 52.6262334, 4.9395239 52.6982808, 4.8070014 52.6762211, 4.894892 52.5949621))" # noqa: E501
+ },
+ HTTP_ACCEPT_CRS="EPSG:4326",
+ )
+ data = read_response_json(response)
+ assert len(data["_embedded"]["parkeervakken"]) == 0, "Outside using WGS84"
+
+ # Outside using WGS84 Multipolygons
+ response = api_client.get(
+ "/v1/parkeervakken/parkeervakken/",
+ data={
+ "geometry[intersects]": "MULTIPOLYGON (((5.639762878417969 51.70404205550062, 5.47222137451172 51.54050328936586, 5.984265804290771 51.445328182347936, 6.050591468811035 51.57152179108081, 5.639762878417969 51.70404205550062)), ((5.268545150756836 51.8780408523543, 5.039935111999512 51.700837165629764, 5.2266597747802725 51.65603805162954, 5.515286922454835 51.827171560252935, 5.268545150756836 51.8780408523543)))" # noqa: E501
+ },
+ HTTP_ACCEPT_CRS="EPSG:4326",
+ )
+ data = read_response_json(response)
+ print("Response data:", data) # Debug print
+ assert response.status_code == 200, data
+ assert len(data["_embedded"]["parkeervakken"]) == 0, "Outside using WGS84"
+
+ # Invalid WGS84 coords
+ response = api_client.get(
+ "/v1/parkeervakken/parkeervakken/",
+ data={"geometry[intersects]": "52.388231,48897865"},
+ HTTP_ACCEPT_CRS="EPSG:4326",
)
assert response.status_code == 400, "Outside WGS84 range"