Repository: dov-vlaanderen/pydov
Issue: Box and Point locations do not serialize into stable XML

<!-- You can ask questions about the DOV webservices or about the `pydov` package. If you have a question about the `pydov` Python package, please use following template. -->

* PyDOV version: master
* Python version: 3.10.6
* Operating System: ubuntu

### Description

The pydov.util.location.Box and pydov.util.location.Point classes use a random gml:id since pydov 3.0.0 and GML 3.2. This has as a consequence that they do not serialize into stable XML anymore, causing issues in the RepeatableLog(Recorder|Replayer).

### What I Did

```python
from pydov.util.location import Point
from owslib.etree import etree

p1 = Point(10, 10)
p1_xml = etree.tostring(p1.get_element())
print(p1_xml)

p2 = Point(10, 10)
p2_xml = etree.tostring(p2.get_element())
print(p2_xml)

print('XML is equal:', p1_xml == p2_xml)
```
```
b'<gml32:Point xmlns:gml32="http://www.opengis.net/gml/3.2" srsDimension="2" srsName="http://www.opengis.net/gml/srs/epsg.xml#31370" gml32:id="pydov.1D2pYSHo"><gml32:pos>10.000000 10.000000</gml32:pos></gml32:Point>'
b'<gml32:Point xmlns:gml32="http://www.opengis.net/gml/3.2" srsDimension="2" srsName="http://www.opengis.net/gml/srs/epsg.xml#31370" gml32:id="pydov.tlRGFRLY"><gml32:pos>10.000000 10.000000</gml32:pos></gml32:Point>'
XML is equal: False
```

Changed files:
tests/test_util_location.py, pydov/util/owsutil.py, tests/test_util_owsutil.py, pydov/util/location.py

diff --git a/pydov/util/location.py b/pydov/util/location.py
index d28fa33..f2b01aa 100644
--- a/pydov/util/location.py
+++ b/pydov/util/location.py
@@ -29,7 +29,16 @@ class AbstractLocation(object):
 
     """
 
+    def _get_id_seed(self):
+        """Get the seed for generating a random but stable GML ID for this
+        location.
+
+        Should return the same value for locations considered equal.
+        """
+        raise NotImplementedError('This should be implemented in a subclass.')
+
     def _get_id(self):
+        random.seed(self._get_id_seed())
         random_id = ''.join(random.choice(
             string.ascii_letters + string.digits) for x in range(8))
         return f'pydov.{random_id}'
@@ -184,6 +193,7 @@ class Box(AbstractLocation):
         self.miny = miny
         self.maxx = maxx
         self.maxy = maxy
+        self.epsg = epsg
 
         self.element = etree.Element(
             '{http://www.opengis.net/gml/3.2}Envelope')
@@ -203,6 +213,11 @@ class Box(AbstractLocation):
         upper_corner.text = '{:.06f} {:.06f}'.format(self.maxx, self.maxy)
         self.element.append(upper_corner)
 
+    def _get_id_seed(self):
+        return ','.join(str(i) for i in [
+            self.minx, self.miny, self.maxx, self.miny, self.epsg
+        ])
+
     def get_element(self):
         return self.element
 
@@ -230,6 +245,7 @@ class Point(AbstractLocation):
         """
         self.x = x
         self.y = y
+        self.epsg = epsg
 
         self.element = etree.Element('{http://www.opengis.net/gml/3.2}Point')
         self.element.set('srsDimension', '2')
@@ -242,6 +258,11 @@ class Point(AbstractLocation):
         coordinates.text = '{:.06f} {:.06f}'.format(self.x, self.y)
         self.element.append(coordinates)
 
+    def _get_id_seed(self):
+        return ','.join(str(i) for i in [
+            self.x, self.y, self.epsg
+        ])
+
     def get_element(self):
         return self.element
 
diff --git a/pydov/util/owsutil.py b/pydov/util/owsutil.py
index 113a85e..fdd5b37 100644
--- a/pydov/util/owsutil.py
+++ b/pydov/util/owsutil.py
@@ -333,6 +333,28 @@ def set_geometry_column(location, geometry_column):
     return location.toXML()
 
 
+def unique_gml_ids(location):
+    """Make sure the location query has unique GML id's for all features.
+    Parameters
+    ----------
+    location : etree.ElementTree
+        XML tree of the location filter.
+    Returns
+    -------
+    etree.ElementTree
+        XML tree of the location filter with unique GML ids.
+    """
+    gml_items = location.findall('.//*[@{http://www.opengis.net/gml/3.2}id]')
+    gml_ids = [i.get('{http://www.opengis.net/gml/3.2}id') for i in gml_items]
+
+    if len(gml_ids) == len(set(gml_ids)):
+        return location
+    else:
+        for ix, item in enumerate(gml_items):
+            item.set('{http://www.opengis.net/gml/3.2}id', f'pydov.{ix}')
+        return location
+
+
 def wfs_build_getfeature_request(typename, geometry_column=None, location=None,
                                  filter=None, sort_by=None, propertyname=None,
                                  max_features=None, start_index=0,
@@ -439,6 +461,7 @@ def wfs_build_getfeature_request(typename, geometry_column=None, location=None,
 
     if location is not None:
         location = set_geometry_column(location, geometry_column)
+        location = unique_gml_ids(location)
         filter_parent.append(location)
 
     if filter is not None or location is not None:
diff --git a/tests/test_util_location.py b/tests/test_util_location.py
index bed8407..292dcc2 100644
--- a/tests/test_util_location.py
+++ b/tests/test_util_location.py
@@ -25,7 +25,8 @@ from tests.abstract import clean_xml
 class TestLocation(object):
     """Class grouping tests for the AbstractLocation subtypes."""
 
-    def test_gml_id(self):
+    def test_gml_id_unique(self):
+        """Test whether GML id's for two different locations are unique."""
         box1 = Box(94720, 186910, 112220, 202870)
         id1 = box1.get_element().get('{http://www.opengis.net/gml/3.2}id')
 
@@ -36,6 +37,18 @@ class TestLocation(object):
         assert id2.startswith('pydov')
         assert id1 != id2
 
+    def test_gml_id_stable(self):
+        """Test whether GML id's for two equal locations are the same."""
+        box1 = Box(94720, 186910, 112220, 202870)
+        id1 = box1.get_element().get('{http://www.opengis.net/gml/3.2}id')
+
+        box2 = Box(94720, 186910, 112220, 202870)
+        id2 = box2.get_element().get('{http://www.opengis.net/gml/3.2}id')
+
+        assert id1.startswith('pydov')
+        assert id2.startswith('pydov')
+        assert id1 == id2
+
     def test_box(self, mp_gml_id):
         """Test the default Box type.
 
diff --git a/tests/test_util_owsutil.py b/tests/test_util_owsutil.py
index 8d15c3d..801f347 100644
--- a/tests/test_util_owsutil.py
+++ b/tests/test_util_owsutil.py
@@ -3,13 +3,13 @@ import copy
 
 import pytest
 from owslib.etree import etree
-from owslib.fes2 import FilterRequest, PropertyIsEqualTo, SortBy, SortProperty
+from owslib.fes2 import FilterRequest, PropertyIsEqualTo, SortBy, SortProperty, Or
 from owslib.iso import MD_Metadata
 from owslib.util import nspath_eval
 
 from pydov.util import owsutil
 from pydov.util.dovutil import build_dov_url
-from pydov.util.location import Box, Within
+from pydov.util.location import Box, Within, WithinDistance
 from tests.abstract import clean_xml
 
 location_md_metadata = 'tests/data/types/boring/md_metadata.xml'
@@ -406,6 +406,41 @@ class TestWfsGetFeatureRequest(object):
             '214775.000000</gml:upperCorner></gml:Envelope></fes:Within></fes'
             ':Filter></wfs:Query></wfs:GetFeature>')
 
+    def test_wfs_build_getfeature_request_gml_id_stable(self):
+        """Test the owsutil.wfs_build_getfeature_request method with a
+        typename, box and geometry_column.
+        Test whether the XML of the WFS GetFeature call is stable.
+        """
+        xml1 = owsutil.wfs_build_getfeature_request(
+            'dov-pub:Boringen',
+            location=Within(Box(151650, 214675, 151750, 214775)),
+            geometry_column='geom')
+
+        xml2 = owsutil.wfs_build_getfeature_request(
+            'dov-pub:Boringen',
+            location=Within(Box(151650, 214675, 151750, 214775)),
+            geometry_column='geom')
+
+        assert etree.tostring(xml1) == etree.tostring(xml2)
+
+    def test_wfs_build_getfeature_request_gml_id_unique(self):
+        """Test the owsutil.wfs_build_getfeature_request method with a
+        typename, two boxes and geometry_column.
+        Test whether the GML ids in the XML of the WFS GetFeature are unique.
+        """
+        xml = owsutil.wfs_build_getfeature_request(
+            'dov-pub:Boringen',
+            location=Or([
+                Within(Box(100000, 120000, 200000, 220000)),
+                WithinDistance(Box(100000, 120000, 200000, 220000), 10)
+            ]),
+            geometry_column='geom')
+
+        gml_items = xml.findall('.//*[@{http://www.opengis.net/gml/3.2}id]')
+        gml_ids = [i.get('{http://www.opengis.net/gml/3.2}id') for i in gml_items]
+
+        assert len(gml_ids) == len(set(gml_ids))
+
     def test_wfs_build_getfeature_request_propertyname(self):
         """Test the owsutil.wfs_build_getfeature_request method with a list
         of propertynames.