# Refactors API geometry processing

* refactors API geometry processing to use GeoJsonValidator and GeometryProcessor just like SimpleGeomForm
* moves cast_to_multipolygon, cast_to_rlp_srid, flatten_geom_to_2D, is_valid_4326 and is_area_valid into GeometryProcessor
* adds changeable SRID to GeoJsonValidator constructor
* updates test data
* updates test cases
This commit is contained in:
2026-09-12 17:58:00 +02:00
parent a067aafde1
commit 8ac8952729
13 changed files with 224 additions and 275 deletions
@@ -1,5 +1,5 @@
{ {
"type": "MultiPolygon", "type": "Polygon",
"coordinates": [ "coordinates": [
], ],
"properties": { "properties": {
@@ -1,5 +1,5 @@
{ {
"type": "MultiPolygon", "type": "Polygon",
"coordinates": [ "coordinates": [
], ],
"properties": { "properties": {
@@ -1,5 +1,5 @@
{ {
"type": "MultiPolygon", "type": "Polygon",
"coordinates": [ "coordinates": [
], ],
"properties": { "properties": {
@@ -1,5 +1,5 @@
{ {
"type": "MultiPolygon", "type": "Polygon",
"coordinates": [ "coordinates": [
], ],
"properties": { "properties": {
@@ -1,46 +1,28 @@
{ {
"type": "MultiPolygon", "type": "Polygon",
"coordinates": [ "coordinates": [
[
[ [
[ 7.489245186249832,
[ 50.29519575559556
7.845568656921382, ],
50.79829702304368 [
], 7.5244137991026605,
[ 50.29519575559556
7.837371826171871, ],
50.80155187891526 [
], 7.5244137991026605,
[ 50.275124048323875
7.835698127746578, ],
50.805267562209806 [
], 7.489245186249832,
[ 50.275124048323875
7.841062545776364, ],
50.806623577403386 [
], 7.489245186249832,
[ 50.29519575559556
7.848916053771969,
50.808359219420474
],
[
7.855696678161618,
50.807057493952975
],
[
7.854666709899899,
50.80423696434001
],
[
7.850461006164548,
50.80217570040005
],
[
7.845568656921382,
50.79829702304368
]
]
] ]
]
], ],
"properties": { "properties": {
"title": "TEST_compensation_CHANGED", "title": "TEST_compensation_CHANGED",
@@ -1,46 +1,28 @@
{ {
"type": "MultiPolygon", "type": "Polygon",
"coordinates": [ "coordinates": [
[
[ [
[ 7.489245186249832,
[ 50.29519575559556
7.845568656921382, ],
50.79829702304368 [
], 7.5244137991026605,
[ 50.29519575559556
7.837371826171871, ],
50.80155187891526 [
], 7.5244137991026605,
[ 50.275124048323875
7.835698127746578, ],
50.805267562209806 [
], 7.489245186249832,
[ 50.275124048323875
7.841062545776364, ],
50.806623577403386 [
], 7.489245186249832,
[ 50.29519575559556
7.848916053771969,
50.808359219420474
],
[
7.855696678161618,
50.807057493952975
],
[
7.854666709899899,
50.80423696434001
],
[
7.850461006164548,
50.80217570040005
],
[
7.845568656921382,
50.79829702304368
]
]
] ]
]
], ],
"properties": { "properties": {
"title": "TEST_account_CHANGED", "title": "TEST_account_CHANGED",
+21 -39
View File
@@ -1,46 +1,28 @@
{ {
"type": "MultiPolygon", "type": "Polygon",
"coordinates": [ "coordinates": [
[
[ [
[ 7.489245186249832,
[ 50.29519575559556
7.845568656921382, ],
50.79829702304368 [
], 7.5244137991026605,
[ 50.29519575559556
7.837371826171871, ],
50.80155187891526 [
], 7.5244137991026605,
[ 50.275124048323875
7.835698127746578, ],
50.805267562209806 [
], 7.489245186249832,
[ 50.275124048323875
7.841062545776364, ],
50.806623577403386 [
], 7.489245186249832,
[ 50.29519575559556
7.848916053771969,
50.808359219420474
],
[
7.855696678161618,
50.807057493952975
],
[
7.854666709899899,
50.80423696434001
],
[
7.850461006164548,
50.80217570040005
],
[
7.845568656921382,
50.79829702304368
]
]
] ]
]
], ],
"properties": { "properties": {
"title": "TEST_EMA_CHANGED", "title": "TEST_EMA_CHANGED",
@@ -1,46 +1,28 @@
{ {
"type": "MultiPolygon", "type": "Polygon",
"coordinates": [ "coordinates": [
[
[ [
[ 7.489245186249832,
[ 50.29519575559556
7.845568656921382, ],
50.79829702304368 [
], 7.5244137991026605,
[ 50.29519575559556
7.837371826171871, ],
50.80155187891526 [
], 7.5244137991026605,
[ 50.275124048323875
7.835698127746578, ],
50.805267562209806 [
], 7.489245186249832,
[ 50.275124048323875
7.841062545776364, ],
50.806623577403386 [
], 7.489245186249832,
[ 50.29519575559556
7.848916053771969,
50.808359219420474
],
[
7.855696678161618,
50.807057493952975
],
[
7.854666709899899,
50.80423696434001
],
[
7.850461006164548,
50.80217570040005
],
[
7.845568656921382,
50.79829702304368
]
]
] ]
]
], ],
"properties": { "properties": {
"title": "Test_intervention_CHANGED", "title": "Test_intervention_CHANGED",
+10 -9
View File
@@ -9,10 +9,11 @@ Created on: 28.01.22
import json import json
from django.contrib.gis import geos from django.contrib.gis import geos
from django.contrib.gis.geos import MultiPolygon
from django.urls import reverse from django.urls import reverse
from api.tests.v1.share.test_api_sharing import BaseAPIV1TestCase from api.tests.v1.share.test_api_sharing import BaseAPIV1TestCase
from konova.models import Geometry from konova.utils.geometry.geometry_validator import GeometryProcessor
class APIV1UpdateTestCase(BaseAPIV1TestCase): class APIV1UpdateTestCase(BaseAPIV1TestCase):
@@ -64,8 +65,8 @@ class APIV1UpdateTestCase(BaseAPIV1TestCase):
put_props = put_body["properties"] put_props = put_body["properties"]
put_geom = geos.fromstr(json.dumps(put_body)) put_geom = geos.fromstr(json.dumps(put_body))
put_geom = Geometry.cast_to_rlp_srid(put_geom) put_geom = MultiPolygon(GeometryProcessor.cast_to_rlp_srid(put_geom))
self.assertEqual(put_geom, self.intervention.geometry.geom) self.assertTrue(put_geom.equals_exact(self.intervention.geometry.geom,0.00001))
self.assertEqual(put_props["title"], self.intervention.title) self.assertEqual(put_props["title"], self.intervention.title)
self.assertNotEqual(modified_on, self.intervention.modified) self.assertNotEqual(modified_on, self.intervention.modified)
self.assertEqual(put_props["responsible"]["registration_file_number"], self.intervention.responsible.registration_file_number) self.assertEqual(put_props["responsible"]["registration_file_number"], self.intervention.responsible.registration_file_number)
@@ -94,8 +95,8 @@ class APIV1UpdateTestCase(BaseAPIV1TestCase):
put_props = put_body["properties"] put_props = put_body["properties"]
put_geom = geos.fromstr(json.dumps(put_body)) put_geom = geos.fromstr(json.dumps(put_body))
put_geom = Geometry.cast_to_rlp_srid(put_geom) put_geom = MultiPolygon(GeometryProcessor.cast_to_rlp_srid(put_geom))
self.assertEqual(put_geom, self.compensation.geometry.geom) self.assertTrue(put_geom.equals_exact(self.compensation.geometry.geom, 0.00001))
self.assertEqual(put_props["title"], self.compensation.title) self.assertEqual(put_props["title"], self.compensation.title)
self.assertNotEqual(modified_on, self.compensation.modified) self.assertNotEqual(modified_on, self.compensation.modified)
self.assertEqual(put_props["is_cef"], self.compensation.is_cef) self.assertEqual(put_props["is_cef"], self.compensation.is_cef)
@@ -124,8 +125,8 @@ class APIV1UpdateTestCase(BaseAPIV1TestCase):
put_props = put_body["properties"] put_props = put_body["properties"]
put_geom = geos.fromstr(json.dumps(put_body)) put_geom = geos.fromstr(json.dumps(put_body))
put_geom = Geometry.cast_to_rlp_srid(put_geom) put_geom = MultiPolygon(GeometryProcessor.cast_to_rlp_srid(put_geom))
self.assertEqual(put_geom, self.eco_account.geometry.geom) self.assertTrue(put_geom.equals_exact(self.eco_account.geometry.geom, 0.00001))
self.assertEqual(put_props["title"], self.eco_account.title) self.assertEqual(put_props["title"], self.eco_account.title)
self.assertNotEqual(modified_on, self.eco_account.modified) self.assertNotEqual(modified_on, self.eco_account.modified)
self.assertEqual(put_props["deductable_surface"], str(self.eco_account.deductable_surface)) self.assertEqual(put_props["deductable_surface"], str(self.eco_account.deductable_surface))
@@ -156,8 +157,8 @@ class APIV1UpdateTestCase(BaseAPIV1TestCase):
put_props = put_body["properties"] put_props = put_body["properties"]
put_geom = geos.fromstr(json.dumps(put_body)) put_geom = geos.fromstr(json.dumps(put_body))
put_geom = Geometry.cast_to_rlp_srid(put_geom) put_geom = MultiPolygon(GeometryProcessor.cast_to_rlp_srid(put_geom))
self.assertEqual(put_geom, self.ema.geometry.geom) self.assertTrue(put_geom.equals_exact(self.ema.geometry.geom,0.00001))
self.assertEqual(put_props["title"], self.ema.title) self.assertEqual(put_props["title"], self.ema.title)
self.assertNotEqual(modified_on, self.ema.modified) self.assertNotEqual(modified_on, self.ema.modified)
self.assertEqual(put_props["responsible"]["conservation_office"], self.ema.responsible.conservation_office) self.assertEqual(put_props["responsible"]["conservation_office"], self.ema.responsible.conservation_office)
+16 -10
View File
@@ -17,6 +17,8 @@ from django.db.models import Q
from api.models import ExternalIdentifier from api.models import ExternalIdentifier
from konova.models import Geometry from konova.models import Geometry
from konova.sub_settings.lanis_settings import DEFAULT_SRID
from konova.utils.geometry.geometry_validator import GeoJsonValidator
from konova.utils.message_templates import DATA_UNSHARED from konova.utils.message_templates import DATA_UNSHARED
@@ -153,16 +155,20 @@ class AbstractModelAPISerializer:
Returns: Returns:
geometry (GEOSGeometry) geometry (GEOSGeometry)
""" """
if isinstance(geojson, dict): if isinstance(geojson, str):
geojson = json.dumps(geojson) geojson = json.loads(geojson)
geometry = geos.fromstr(geojson)
if not geometry.valid: geojson_validator = GeoJsonValidator(geojson, DEFAULT_SRID)
raise ValueError(f"Invalid geometry: {geometry.valid_reason}") geojson_validator.validate()
is_4326 = Geometry.is_valid_4326(geometry)
if not is_4326: if geojson_validator.errors:
raise ValueError("Geometry not in EPSG:4326 (WGS84). Unknown spatial reference system.") raise ValueError(geojson_validator.errors)
geometry = Geometry.cast_to_rlp_srid(geometry)
geometry = Geometry.cast_to_multipolygon(geometry) geometry = geojson_validator.validated_geometry
is_input_geometry_too_small = geometry.area == 0.0 and geojson_validator.num_ignored_geometries > 0
if is_input_geometry_too_small:
raise ValueError(f"Your area was too small to be processed! Must be > 1m²!")
return geometry return geometry
def _get_obj_from_db(self, id, user): def _get_obj_from_db(self, id, user):
+1 -1
View File
@@ -79,7 +79,7 @@ class SimpleGeomForm(BaseForm):
geom = self.data.get("output", json.dumps({})) geom = self.data.get("output", json.dumps({}))
geom = json.loads(geom) geom = json.loads(geom)
geojson_validator = GeoJsonValidator(geom) geojson_validator = GeoJsonValidator(geom, DEFAULT_SRID_RLP)
geojson_validator.validate() geojson_validator.validate()
if geojson_validator.errors: if geojson_validator.errors:
-53
View File
@@ -419,39 +419,6 @@ class Geometry(BaseResource):
return complexity_factor return complexity_factor
@staticmethod
def cast_to_multipolygon(input_geom):
""" If input_geom is not a MultiPolygon, cast to MultiPolygon
Args:
input_geom ():
Returns:
output_geom
"""
output_geom = input_geom
if not isinstance(input_geom, MultiPolygon):
try:
output_geom = MultiPolygon(input_geom, srid=DEFAULT_SRID_RLP)
except TypeError as e:
raise AssertionError(f"Only (Multi)Polygon allowed! Could not convert {input_geom.geom_type} to MultiPolygon")
return output_geom
@staticmethod
def cast_to_rlp_srid(input_geom):
""" If input_geom is not of RLP SRID (25832), cast to RLP SRID
Args:
input_geom ():
Returns:
output_geom
"""
output_geom = input_geom
if output_geom.srid != DEFAULT_SRID_RLP:
output_geom.transform(DEFAULT_SRID_RLP)
return output_geom
def get_conflict_geometries(self): def get_conflict_geometries(self):
""" Getter for geometry ids which conflict with this geometry or are conflicted by this one """ Getter for geometry ids which conflict with this geometry or are conflicted by this one
@@ -462,26 +429,6 @@ class Geometry(BaseResource):
conflict_geoms = Geometry.objects.filter(id__in=conflict_geoms_id) conflict_geoms = Geometry.objects.filter(id__in=conflict_geoms_id)
return conflict_geoms return conflict_geoms
@staticmethod
def is_valid_4326(geometry):
""" Checks whether a given geometry's coordinates are in a valid range to be of EPSG:4326
Args:
geometry: The geometry
Returns:
ret_val (bool): Whether the geometry is valid EPSG:4326
"""
if not geometry.centroid.coords:
# No coordinates at all found, therefore technically proper 4326
return True
try:
lat,lon = geometry.centroid.coords
return (-90.0 <= lat <= 90.0) and (-180.0 <= lon <= 180.0)
except IndexError:
return False
class GeometryConflict(UuidModel): class GeometryConflict(UuidModel):
""" """
+109 -42
View File
@@ -49,6 +49,82 @@ class GeometryProcessor:
""" """
return geometry.num_coords <= GEOM_MAX_VERTICES return geometry.num_coords <= GEOM_MAX_VERTICES
@staticmethod
def is_valid_4326(geometry):
""" Checks whether a given geometry's coordinates are in a valid range to be of EPSG:4326
Args:
geometry: The geometry
Returns:
ret_val (bool): Whether the geometry is valid EPSG:4326
"""
if not geometry.centroid.coords:
# No coordinates at all found, therefore technically proper 4326
return True
try:
lat,lon = geometry.centroid.coords
return (-90.0 <= lat <= 90.0) and (-180.0 <= lon <= 180.0)
except IndexError:
return False
@staticmethod
def cast_to_multipolygon(input_geom: MultiPolygon) -> MultiPolygon:
""" If input_geom is not a MultiPolygon, cast to MultiPolygon
Args:
input_geom ():
Returns:
output_geom
"""
output_geom = input_geom
if not isinstance(input_geom, MultiPolygon):
try:
output_geom = MultiPolygon(input_geom, srid=DEFAULT_SRID_RLP)
except TypeError as e:
raise AssertionError(f"Only (Multi)Polygon allowed! Could not convert {input_geom.geom_type} to MultiPolygon")
return output_geom
@staticmethod
def cast_to_rlp_srid(input_geom: gdal.OGRGeometry) -> gdal.OGRGeometry:
""" If input_geom is not of RLP SRID (25832), cast to RLP SRID
Args:
input_geom ():
Returns:
output_geom
"""
output_geom = input_geom
if output_geom.srid != DEFAULT_SRID_RLP:
output_geom.transform(DEFAULT_SRID_RLP)
return output_geom
@staticmethod
def flatten_geom_to_2D(geom: gdal.OGRGeometry) -> gdal.OGRGeometry:
"""
Enforces a given OGRGeometry from higher dimensions into 2D
"""
wkt_w = WKTWriter(dim=2)
g_wkt = wkt_w.write(geom.geos).decode("utf-8")
geom = gdal.OGRGeometry(g_wkt)
return geom
@staticmethod
def is_area_valid(geom: gdal.OGRGeometry) -> bool:
""" Checks whether the area is at least > 1m²
Returns:
"""
assert geom.srid == DEFAULT_SRID_RLP
is_area_valid = geom.area > 1 # > 1m² (SRID:25832)
return is_area_valid
class GeoJsonValidator: class GeoJsonValidator:
""" GeoJson Validator validates geojson (e.g. from API or form input) """ GeoJson Validator validates geojson (e.g. from API or form input)
@@ -65,11 +141,12 @@ class GeoJsonValidator:
"MultiPolygon25D", "MultiPolygon25D",
] ]
def __init__(self, input_geojson: dict) -> None: def __init__(self, input_geojson: dict, srs: int) -> None:
assert input_geojson is not None assert input_geojson is not None
self._input_geojson = input_geojson self._input_geojson = input_geojson
self._num_ignored_geometries = 0 self._num_ignored_geometries = 0
self._errors = list() self._errors = list()
self._srs = SpatialReference(srs)
@property @property
def num_ignored_geometries(self): def num_ignored_geometries(self):
@@ -125,6 +202,22 @@ class GeoJsonValidator:
""" """
features = self._input_geojson.get("features", []) features = self._input_geojson.get("features", [])
is_input_geojson_empty = len(self._input_geojson) == 0
no_features_in_input_found = not features
if not is_input_geojson_empty and no_features_in_input_found:
# check if _input_geojson is a feature itself
if self.__is_geojson_feature():
features = [
{
"geometry": self._input_geojson
}
]
else:
self.__add_error("Input does not seem to be geojson")
return
try: try:
validated_features = self.__validate_single_features(features) validated_features = self.__validate_single_features(features)
except AssertionError as e: except AssertionError as e:
@@ -133,19 +226,18 @@ class GeoJsonValidator:
# Unionize all polygon features into one new MultiPolygon # Unionize all polygon features into one new MultiPolygon
if validated_features: if validated_features:
multipolygon_geometry = MultiPolygon(*validated_features, srid=DEFAULT_SRID_RLP).unary_union multipolygon_geometry = MultiPolygon(*validated_features, srid=self._srs).unary_union
else: else:
# If no features have been processed, this indicates an empty geometry - so we store an empty geometry # If no features have been processed, this indicates an empty geometry - so we store an empty geometry
multipolygon_geometry = MultiPolygon(srid=DEFAULT_SRID_RLP) multipolygon_geometry = MultiPolygon(srid=self._srs)
# Make sure to convert into a MultiPolygon. Relevant if a single Polygon is provided. # Make sure to convert into a MultiPolygon. Relevant if a single Polygon is provided.
multipolygon_geometry = self.cast_to_multipolygon(multipolygon_geometry) multipolygon_geometry = GeometryProcessor.cast_to_multipolygon(multipolygon_geometry)
self._validated_geometry = multipolygon_geometry self._validated_geometry = multipolygon_geometry
def __validate_single_features(self, features: list) -> list: def __validate_single_features(self, features: list) -> list:
validated_features = [] validated_features = []
srs = SpatialReference(DEFAULT_SRID_RLP)
# Check validity for each feature of the geometry # Check validity for each feature of the geometry
for feature in features: for feature in features:
feature_geom = feature.get("geometry", feature) feature_geom = feature.get("geometry", feature)
@@ -155,17 +247,20 @@ class GeoJsonValidator:
# Try to create a geometry object from the single feature # Try to create a geometry object from the single feature
feature_geom = json.dumps(feature_geom) feature_geom = json.dumps(feature_geom)
g = gdal.OGRGeometry(feature_geom, srs=srs) g = gdal.OGRGeometry(feature_geom, srs=self._srs)
if g.empty:
continue
g = GeometryProcessor.cast_to_rlp_srid(g)
geometry_has_unwanted_dimensions = g.coord_dim > 2 geometry_has_unwanted_dimensions = g.coord_dim > 2
if geometry_has_unwanted_dimensions: if geometry_has_unwanted_dimensions:
g = self.__flatten_geom_to_2D(g) g = GeometryProcessor.flatten_geom_to_2D(g)
geometry_type_is_accepted = g.geom_type not in self._accepted_ogr_types geometry_type_is_accepted = g.geom_type not in self._accepted_ogr_types
if geometry_type_is_accepted: if geometry_type_is_accepted:
raise AssertionError(_("Only surfaces allowed. Points or lines must be buffered.")) raise AssertionError(_("Only surfaces allowed. Points or lines must be buffered."))
is_area_valid = self.__is_area_valid(g) is_area_valid = GeometryProcessor.is_area_valid(g)
if not is_area_valid: if not is_area_valid:
# Geometries with an invalid size will not be saved to the db # Geometries with an invalid size will not be saved to the db
# We assume these are malicious snippets which are not supposed to be in the geometry in the first place # We assume these are malicious snippets which are not supposed to be in the geometry in the first place
@@ -188,41 +283,13 @@ class GeoJsonValidator:
return validated_features return validated_features
def __is_geojson_feature(self):
def __flatten_geom_to_2D(self, geom: gdal.OGRGeometry) -> gdal.OGRGeometry: """ Checks whether _input_geojson is a proper geojson feature
"""
Enforces a given OGRGeometry from higher dimensions into 2D
"""
wkt_w = WKTWriter(dim=2)
g_wkt = wkt_w.write(geom.geos).decode("utf-8")
geom = gdal.OGRGeometry(g_wkt)
return geom
def __is_area_valid(self, geom: gdal.OGRGeometry):
""" Checks whether the area is at least > 1m²
Returns: Returns:
""" """
is_area_valid = geom.area > 1 # > 1m² (SRID:25832) has_type = self._input_geojson.get("type", None) is not None
return is_area_valid has_coordinates = self._input_geojson.get("coordinates", None) is not None
has_properties = self._input_geojson.get("properties", None) is not None
@staticmethod return has_type and has_coordinates and has_properties
def cast_to_multipolygon(input_geom):
""" If input_geom is not a MultiPolygon, cast to MultiPolygon
Args:
input_geom ():
Returns:
output_geom
"""
output_geom = input_geom
if not isinstance(input_geom, MultiPolygon):
try:
output_geom = MultiPolygon(input_geom, srid=DEFAULT_SRID_RLP)
except TypeError as e:
raise AssertionError(f"Only (Multi)Polygon allowed! Could not convert {input_geom.geom_type} to MultiPolygon")
return output_geom